agentltl-coding 0.2.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentltl_coding/__init__.py +21 -0
- agentltl_coding/cli.py +482 -0
- agentltl_coding/edit.py +67 -0
- agentltl_coding/guard.py +461 -0
- agentltl_coding/harness.py +110 -0
- agentltl_coding/library/ask-before-creating-credentials.yaml +9 -0
- agentltl_coding/library/ask-before-discarding-work.yaml +12 -0
- agentltl_coding/library/ask-before-infra-changes.yaml +9 -0
- agentltl_coding/library/ask-before-installing.yaml +12 -0
- agentltl_coding/library/ask-before-reading-secret-stores.yaml +35 -0
- agentltl_coding/library/ask-before-recursive-delete.yaml +7 -0
- agentltl_coding/library/devops-secrets.yaml +10 -0
- agentltl_coding/library/no-claude-coauthor.yaml +11 -0
- agentltl_coding/library/no-codex-coauthor.yaml +11 -0
- agentltl_coding/library/no-copilot-coauthor.yaml +11 -0
- agentltl_coding/library/no-env-dumps.yaml +18 -0
- agentltl_coding/library/no-force-push.yaml +7 -0
- agentltl_coding/library/no-leaky-debug.yaml +13 -0
- agentltl_coding/library/no-push-to-main.yaml +7 -0
- agentltl_coding/library/no-secrets-in-commands.yaml +21 -0
- agentltl_coding/library/no-skipping-secret-scans.yaml +9 -0
- agentltl_coding/library/no-vibe-coauthor.yaml +11 -0
- agentltl_coding/library/protect-env-files.yaml +9 -0
- agentltl_coding/library/protect-secret-files.yaml +53 -0
- agentltl_coding/library/read-before-overwrite.yaml +11 -0
- agentltl_coding/library/subagents-on-sonnet.yaml +12 -0
- agentltl_coding/library/tests-before-finishing.yaml +13 -0
- agentltl_coding/library/tests-before-push.yaml +14 -0
- agentltl_coding/memory.py +390 -0
- agentltl_coding/pattern.py +291 -0
- agentltl_coding/rules.py +920 -0
- agentltl_coding/scan.py +54 -0
- agentltl_coding/session.py +149 -0
- agentltl_coding/store.py +123 -0
- agentltl_coding-0.2.4.dist-info/METADATA +129 -0
- agentltl_coding-0.2.4.dist-info/RECORD +40 -0
- agentltl_coding-0.2.4.dist-info/WHEEL +5 -0
- agentltl_coding-0.2.4.dist-info/entry_points.txt +2 -0
- agentltl_coding-0.2.4.dist-info/licenses/LICENSE +21 -0
- agentltl_coding-0.2.4.dist-info/top_level.txt +1 -0
agentltl_coding/rules.py
ADDED
|
@@ -0,0 +1,920 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agentltl_coding/rules.py – AGENTLTL.yaml → AgentLTL constraints.
|
|
3
|
+
|
|
4
|
+
settings:
|
|
5
|
+
mode: block # default escalation for every rule
|
|
6
|
+
retries: 3 # attempts allowed by mode: retry before asking you
|
|
7
|
+
retry_counting: cumulative # or consecutive (reset by an allowed call), hybrid
|
|
8
|
+
report: 3 # violations of the same mode one refusal may list
|
|
9
|
+
unparseable: {interactive: ask, auto: note}
|
|
10
|
+
announce: true # list the rules to the agent at the start of a session
|
|
11
|
+
scope: session # default memory for rules: session | project
|
|
12
|
+
scan_output: true # warn when a call's output contains a credential
|
|
13
|
+
rules:
|
|
14
|
+
- id: tests-before-push
|
|
15
|
+
before: {first: pytest, then: git_push, since: [Edit, Write]}
|
|
16
|
+
why: CI is slow; run the tests locally first.
|
|
17
|
+
fix: Run pytest, then push.
|
|
18
|
+
mode: warn
|
|
19
|
+
from: {file: CLAUDE.md, line: 12, id: 3f2a9c01d4} # optional: the memory it came from
|
|
20
|
+
use: [no-force-push, {tests-before-push: {mode: warn}}] # packaged rules, see library/
|
|
21
|
+
disable: [memory-first] # switch off rules by id (from use:, the user file, or built in)
|
|
22
|
+
tools: # cli-to-tools specs for your own commands
|
|
23
|
+
deploy: {options: [{flags: [--prod], type: bool}]}
|
|
24
|
+
|
|
25
|
+
Rule kinds (exactly one per rule; targets are described in :mod:`agentltl_coding.pattern`),
|
|
26
|
+
each compiled to an AgentLTL formula over the calls up to the one being checked:
|
|
27
|
+
|
|
28
|
+
never: T T is never called G ¬matches(T)
|
|
29
|
+
before: [A, B] B only once A has been called; G(matches(B) → Y O matches(A))
|
|
30
|
+
dict form adds ``since: S`` ... Y(¬matches(S) S matches(A))
|
|
31
|
+
(an A must come after the last S)
|
|
32
|
+
require: T when one of T's tools is called, G(T's tools → matches(T))
|
|
33
|
+
its arguments match T
|
|
34
|
+
at_most: {call: T, times: n} G(matches(T) → < n earlier)
|
|
35
|
+
finally: T before finishing, T has run F matches(T)
|
|
36
|
+
finally: {call: T, since: S} ... after the last S G(matches(S) → F matches(T))
|
|
37
|
+
ltl: '...' raw AgentLTL formula (``agentltl.parse`` syntax)
|
|
38
|
+
formula: {type, args} structured AgentLTL formula
|
|
39
|
+
|
|
40
|
+
``finally`` rules never refuse a call: they are checked when the agent is about to finish
|
|
41
|
+
(:meth:`agentltl_coding.Guard.finish`), which sends it back to do what is missing, at most
|
|
42
|
+
``finish_retries`` times per turn.
|
|
43
|
+
|
|
44
|
+
Every kind but ``finally``/``ltl``/``formula`` is past-only under ``G``, so AgentLTL judges it on the
|
|
45
|
+
call being checked alone: a rule broken earlier (by an override) never blocks unrelated
|
|
46
|
+
calls. A call whose files are only known at run time may match: that counts against it for
|
|
47
|
+
``never``/``before``'s ``then``/``at_most``, and does not satisfy ``require`` or ``before``'s
|
|
48
|
+
``first``.
|
|
49
|
+
|
|
50
|
+
``never``, ``require`` and ``at_most`` also take ``with:`` / ``where:`` at rule level.
|
|
51
|
+
|
|
52
|
+
``scope`` says which memory a rule reads: ``session`` (the calls made in this session;
|
|
53
|
+
starts empty with each new session, kept across compaction and resume) or ``project``
|
|
54
|
+
(every call made in this project, across sessions; never reset automatically).
|
|
55
|
+
|
|
56
|
+
Rules from the user's AGENTLTL.yaml (see :class:`agentltl_coding.Harness`) apply everywhere;
|
|
57
|
+
the project file adds to them and replaces a user rule with the same id. A harness may add
|
|
58
|
+
built-in rules, each switched off by a setting of its own (``memory_first: false``).
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
from __future__ import annotations
|
|
62
|
+
|
|
63
|
+
import os
|
|
64
|
+
from dataclasses import dataclass, field
|
|
65
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
66
|
+
|
|
67
|
+
import yaml
|
|
68
|
+
|
|
69
|
+
from .harness import current as _harness
|
|
70
|
+
from .pattern import Paths, RuleError, parse_target, tool_matches
|
|
71
|
+
|
|
72
|
+
_MAYBE = (" Its file arguments are only known at run time (xargs, find -exec, or a $VARIABLE),"
|
|
73
|
+
" so the guard cannot rule it out; name the files explicitly.")
|
|
74
|
+
|
|
75
|
+
FILE_NAME = "AGENTLTL.yaml"
|
|
76
|
+
|
|
77
|
+
# escalation mode -> AgentLTL ConstraintSeverity name, one to one
|
|
78
|
+
MODES: Dict[str, str] = {
|
|
79
|
+
"block": "PERSISTENT_BLOCK", # denied every time; the agent cannot override
|
|
80
|
+
"warn": "BLOCK_AND_WARN", # denied once; the agent may insist by repeating the exact call
|
|
81
|
+
"retry": "SOFT_BLOCK", # denied up to `retries` times, then you are asked
|
|
82
|
+
"ask": "ASK", # you are asked every time
|
|
83
|
+
"stop": "HARD_STOP", # denied, and the agent stops until you reply
|
|
84
|
+
"log": "TOLERATE", # allowed; the agent is told it broke the rule
|
|
85
|
+
}
|
|
86
|
+
_MODE_HELP: Dict[str, str] = {
|
|
87
|
+
"block": "denied every time; only you can let it through (edit the rule, or run it yourself)",
|
|
88
|
+
"warn": "denied once; {agent} may override by repeating the exact same call",
|
|
89
|
+
"retry": "denied, and after the allowed retries you are asked to approve",
|
|
90
|
+
"ask": "you are asked to approve every time",
|
|
91
|
+
"stop": "denied and {agent} stops working until you reply",
|
|
92
|
+
"log": "allowed, but {agent} is told the rule was broken",
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def mode_help(mode: str) -> str:
|
|
97
|
+
return _MODE_HELP[mode].format(agent=_harness().agent)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# Strongest first. When one call breaks several rules, the strongest mode decides (AgentLTL's
|
|
101
|
+
# SEVERITY_STRENGTH); that also keeps a `warn` override (repeat the exact call) from slipping
|
|
102
|
+
# past a stronger rule the same call breaks.
|
|
103
|
+
STRENGTH = ("stop", "block", "ask", "retry", "warn", "log")
|
|
104
|
+
UNPARSEABLE = ("ask", "note", "allow", "deny")
|
|
105
|
+
SCOPES = ("session", "project")
|
|
106
|
+
KINDS = ("never", "before", "require", "at_most", "finally", "ltl", "formula")
|
|
107
|
+
_RULE_KEYS = {"id", "why", "fix", "mode", "scope", "with", "where", "from", *KINDS}
|
|
108
|
+
_FROM_KEYS = {"file", "line", "id", "text"}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class Settings:
|
|
113
|
+
mode: str = "block"
|
|
114
|
+
retries: int = 3
|
|
115
|
+
unparseable_interactive: str = "ask"
|
|
116
|
+
unparseable_auto: str = "note"
|
|
117
|
+
announce: bool = True
|
|
118
|
+
scope: str = "session"
|
|
119
|
+
scan_output: bool = True
|
|
120
|
+
retry_counting: str = "cumulative" # cumulative | consecutive | hybrid
|
|
121
|
+
report: int = 3 # violations one refusal may list (nudge_max)
|
|
122
|
+
finish_retries: int = 2 # times per turn `finally` rules send the agent back
|
|
123
|
+
builtins: Dict[str, bool] = field(default_factory=dict) # harness built-in rule switches
|
|
124
|
+
|
|
125
|
+
def builtin(self, name: str) -> bool:
|
|
126
|
+
return self.builtins.get(name, True)
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def memory_first(self) -> bool: # the harnesses' memory built-in, by its setting name
|
|
130
|
+
return self.builtin("memory_first")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class Rule:
|
|
135
|
+
id: str
|
|
136
|
+
kind: str
|
|
137
|
+
why: str
|
|
138
|
+
fix: str
|
|
139
|
+
mode: str
|
|
140
|
+
formula: Any
|
|
141
|
+
tools: Tuple[str, ...] = ()
|
|
142
|
+
source: str = ""
|
|
143
|
+
targets: Tuple[Any, ...] = ()
|
|
144
|
+
scope: str = "session"
|
|
145
|
+
origins: Tuple[Dict[str, Any], ...] = () # `from:` the memory statements it enforces
|
|
146
|
+
label: str = ""
|
|
147
|
+
# Why a call breaks the rule, in plain words, given the calls up to and including it
|
|
148
|
+
# (None for ltl/formula rules, whose explanation is AgentLTL's own).
|
|
149
|
+
explain: Optional[Callable[[List[Any]], Optional[str]]] = None
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def summary(self) -> str:
|
|
153
|
+
"""One line saying what the rule checks."""
|
|
154
|
+
return self.label or str(self.formula)
|
|
155
|
+
|
|
156
|
+
def constraint(self) -> Any:
|
|
157
|
+
from agentltl import Constraint
|
|
158
|
+
return Constraint(self.id, self.formula, description=self.why, repair=self.fix,
|
|
159
|
+
applies_to_final_answer=self.kind == "finally")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass
|
|
163
|
+
class RuleSet:
|
|
164
|
+
rules: List[Rule] = field(default_factory=list)
|
|
165
|
+
settings: Settings = field(default_factory=Settings)
|
|
166
|
+
tool_specs: Dict[str, Any] = field(default_factory=dict)
|
|
167
|
+
files: List[str] = field(default_factory=list)
|
|
168
|
+
explicit_settings: bool = False
|
|
169
|
+
disabled: List[str] = field(default_factory=list) # ids switched off by `disable:`
|
|
170
|
+
used: List[str] = field(default_factory=list) # library packs named by `use:`
|
|
171
|
+
|
|
172
|
+
def get(self, rule_id: str) -> Optional[Rule]:
|
|
173
|
+
return next((r for r in self.rules if r.id == rule_id), None)
|
|
174
|
+
|
|
175
|
+
def scoped(self, scope: Optional[str]) -> List[Rule]:
|
|
176
|
+
return [r for r in self.rules if scope is None or r.scope == scope]
|
|
177
|
+
|
|
178
|
+
def constraints(self, scope: Optional[str] = None) -> List[Any]:
|
|
179
|
+
"""Constraints of one scope (all when None), strongest mode first (see STRENGTH)."""
|
|
180
|
+
ordered = sorted(self.scoped(scope), key=lambda r: STRENGTH.index(r.mode))
|
|
181
|
+
return [r.constraint() for r in ordered]
|
|
182
|
+
|
|
183
|
+
def severities(self, scope: Optional[str] = None) -> Dict[str, Any]:
|
|
184
|
+
from agentltl import ConstraintSeverity
|
|
185
|
+
return {r.id: ConstraintSeverity[MODES[r.mode]] for r in self.scoped(scope)}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class RuleFileError(Exception):
|
|
189
|
+
"""One or more problems in a rule file; ``problems`` lists them with locations."""
|
|
190
|
+
|
|
191
|
+
def __init__(self, problems: List[str]) -> None:
|
|
192
|
+
super().__init__("\n".join(problems))
|
|
193
|
+
self.problems = problems
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ── locating and loading ──────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
def find_rule_file(start: str, stop: Optional[str] = None) -> Optional[str]:
|
|
199
|
+
"""The nearest AGENTLTL.yaml from *start* upwards, not above *stop* or the git root."""
|
|
200
|
+
here = os.path.abspath(start or os.getcwd())
|
|
201
|
+
stop = os.path.abspath(stop) if stop else None
|
|
202
|
+
while True:
|
|
203
|
+
candidate = os.path.join(here, FILE_NAME)
|
|
204
|
+
if os.path.isfile(candidate):
|
|
205
|
+
return candidate
|
|
206
|
+
if here == stop or os.path.exists(os.path.join(here, ".git")):
|
|
207
|
+
return None
|
|
208
|
+
parent = os.path.dirname(here)
|
|
209
|
+
if parent == here:
|
|
210
|
+
return None
|
|
211
|
+
here = parent
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def user_rule_file() -> Optional[str]:
|
|
215
|
+
path = _harness().user_file()
|
|
216
|
+
return path if os.path.isfile(path) else None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def rule_files(cwd: str, project_dir: Optional[str] = None) -> List[str]:
|
|
220
|
+
"""User-level then project-level rule files that apply in *cwd*."""
|
|
221
|
+
files = [f for f in (user_rule_file(), find_rule_file(cwd, project_dir)) if f]
|
|
222
|
+
return list(dict.fromkeys(files))
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def load(files: List[str], paths: Optional[Paths] = None) -> RuleSet:
|
|
226
|
+
"""Compile the rule files, later files overriding earlier ones.
|
|
227
|
+
|
|
228
|
+
Raises:
|
|
229
|
+
RuleFileError: With every problem found, each prefixed by ``file:line``.
|
|
230
|
+
"""
|
|
231
|
+
merged = RuleSet(files=list(files))
|
|
232
|
+
problems: List[str] = []
|
|
233
|
+
for path in files:
|
|
234
|
+
try:
|
|
235
|
+
with open(path, encoding="utf-8") as fh:
|
|
236
|
+
text = fh.read()
|
|
237
|
+
part = loads(text, paths, source=path)
|
|
238
|
+
except RuleFileError as exc:
|
|
239
|
+
problems += exc.problems
|
|
240
|
+
continue
|
|
241
|
+
except OSError as exc:
|
|
242
|
+
problems.append(f"{path}: {exc}")
|
|
243
|
+
continue
|
|
244
|
+
by_id = {r.id: i for i, r in enumerate(merged.rules)}
|
|
245
|
+
for rule in part.rules:
|
|
246
|
+
if rule.id in by_id:
|
|
247
|
+
merged.rules[by_id[rule.id]] = rule
|
|
248
|
+
else:
|
|
249
|
+
merged.rules.append(rule)
|
|
250
|
+
merged.tool_specs.update(part.tool_specs)
|
|
251
|
+
merged.rules = [r for r in merged.rules if r.id not in part.disabled]
|
|
252
|
+
merged.disabled += part.disabled
|
|
253
|
+
merged.used += part.used
|
|
254
|
+
if part.explicit_settings:
|
|
255
|
+
merged.settings = part.settings
|
|
256
|
+
if problems:
|
|
257
|
+
raise RuleFileError(problems)
|
|
258
|
+
for setting, raw in _harness().builtins.items():
|
|
259
|
+
if (merged.settings.builtin(setting) and not merged.get(raw["id"])
|
|
260
|
+
and raw["id"] not in merged.disabled):
|
|
261
|
+
builtin = compile_rule(raw, merged.settings, paths or Paths(), "built-in")
|
|
262
|
+
builtin.source = "built-in"
|
|
263
|
+
merged.rules.append(builtin)
|
|
264
|
+
return merged
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def loads(text: str, paths: Optional[Paths] = None, source: str = "<rules>") -> RuleSet:
|
|
268
|
+
"""Compile one rule file's text."""
|
|
269
|
+
try:
|
|
270
|
+
data = yaml.safe_load(text) or {}
|
|
271
|
+
lines = _rule_lines(text)
|
|
272
|
+
except yaml.YAMLError as exc:
|
|
273
|
+
mark = getattr(exc, "problem_mark", None)
|
|
274
|
+
where = f"{source}:{mark.line + 1}" if mark else source
|
|
275
|
+
raise RuleFileError([f"{where}: invalid YAML: {getattr(exc, 'problem', exc)}"]) from None
|
|
276
|
+
if not isinstance(data, dict):
|
|
277
|
+
raise RuleFileError([f"{source}: expected a mapping with 'rules' (and optionally "
|
|
278
|
+
"'settings', 'tools')"])
|
|
279
|
+
problems: List[str] = []
|
|
280
|
+
unknown = set(data) - {"version", "settings", "rules", "tools", "use", "disable"}
|
|
281
|
+
if unknown:
|
|
282
|
+
problems.append(f"{source}: unknown top-level key(s) {sorted(unknown)}")
|
|
283
|
+
settings = Settings()
|
|
284
|
+
try:
|
|
285
|
+
settings = _settings(data.get("settings"))
|
|
286
|
+
except RuleError as exc:
|
|
287
|
+
problems.append(f"{source}: settings: {exc}")
|
|
288
|
+
|
|
289
|
+
tools = data.get("tools") or {}
|
|
290
|
+
if not isinstance(tools, dict):
|
|
291
|
+
problems.append(f"{source}: 'tools' must map command names to cli-to-tools specs")
|
|
292
|
+
tools = {}
|
|
293
|
+
else:
|
|
294
|
+
problems += _check_tools(tools, source)
|
|
295
|
+
|
|
296
|
+
raw_rules = data.get("rules") or []
|
|
297
|
+
if not isinstance(raw_rules, list):
|
|
298
|
+
raise RuleFileError(problems + [f"{source}: 'rules' must be a list"])
|
|
299
|
+
rules: List[Rule] = []
|
|
300
|
+
seen: Dict[str, str] = {}
|
|
301
|
+
paths = paths or Paths()
|
|
302
|
+
try:
|
|
303
|
+
packed, packed_tools = _use(data.get("use"), settings, paths)
|
|
304
|
+
except RuleError as exc:
|
|
305
|
+
problems.append(f"{source}: use: {exc}")
|
|
306
|
+
packed, packed_tools = [], {}
|
|
307
|
+
tools = {**packed_tools, **tools}
|
|
308
|
+
disabled = data.get("disable") or []
|
|
309
|
+
if not isinstance(disabled, list) or not all(isinstance(d, str) for d in disabled):
|
|
310
|
+
problems.append(f"{source}: disable: expected a list of rule ids")
|
|
311
|
+
disabled = []
|
|
312
|
+
for i, raw in enumerate(raw_rules):
|
|
313
|
+
where = f"{source}:{lines[i]}" if i < len(lines) else f"{source}: rules[{i}]"
|
|
314
|
+
try:
|
|
315
|
+
rule = compile_rule(raw, settings, paths, where)
|
|
316
|
+
except RuleError as exc:
|
|
317
|
+
problems.append(f"{where}: {exc}")
|
|
318
|
+
continue
|
|
319
|
+
if rule.id in seen:
|
|
320
|
+
problems.append(f"{where}: duplicate id '{rule.id}' (first at {seen[rule.id]})")
|
|
321
|
+
continue
|
|
322
|
+
seen[rule.id] = where
|
|
323
|
+
rule.source = source
|
|
324
|
+
rules.append(rule)
|
|
325
|
+
if problems:
|
|
326
|
+
raise RuleFileError(problems)
|
|
327
|
+
own = {r.id for r in rules} # a rule of the file replaces a library rule by id
|
|
328
|
+
rules = [r for r in packed if r.id not in own] + rules
|
|
329
|
+
rules = [r for r in rules if r.id not in disabled]
|
|
330
|
+
return RuleSet(rules, settings, tools, [source], "settings" in data, list(disabled),
|
|
331
|
+
[_use_name(u) for u in data.get("use") or []])
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _use_name(item: Any) -> str:
|
|
335
|
+
return item if isinstance(item, str) else next(iter(item))
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# ── the rule library ──────────────────────────────────────────────────────────
|
|
339
|
+
|
|
340
|
+
LIBRARY_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "library")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def library() -> Dict[str, Dict[str, Any]]:
|
|
344
|
+
"""The packaged rules, by name: each has ``summary``, ``tags``, ``rules``, ``tools``, and
|
|
345
|
+
for a bundle, ``include`` (other entries it switches on). ``harnesses`` lists the agents an
|
|
346
|
+
entry is for (absent: every one); elsewhere ``use:`` of it switches nothing on."""
|
|
347
|
+
out: Dict[str, Dict[str, Any]] = {}
|
|
348
|
+
if os.path.isdir(LIBRARY_DIR):
|
|
349
|
+
for name in sorted(os.listdir(LIBRARY_DIR)):
|
|
350
|
+
if name.endswith(".yaml"):
|
|
351
|
+
with open(os.path.join(LIBRARY_DIR, name), encoding="utf-8") as fh:
|
|
352
|
+
pack = yaml.safe_load(fh) or {}
|
|
353
|
+
pack["path"] = os.path.join(LIBRARY_DIR, name)
|
|
354
|
+
out[name[:-5]] = pack
|
|
355
|
+
return out
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _use(raw: Any, settings: Settings, paths: Paths) -> Tuple[List[Rule], Dict[str, Any]]:
|
|
359
|
+
"""Compile ``use: [name, {name: {mode: warn}}]`` into the library's rules."""
|
|
360
|
+
if raw is None:
|
|
361
|
+
return [], {}
|
|
362
|
+
if not isinstance(raw, list):
|
|
363
|
+
raise RuleError("expected a list of library rule names (see `agentltl library`)")
|
|
364
|
+
packs = library()
|
|
365
|
+
rules: List[Rule] = []
|
|
366
|
+
tools: Dict[str, Any] = {}
|
|
367
|
+
for item in raw:
|
|
368
|
+
name, extra = (item, {}) if isinstance(item, str) else (
|
|
369
|
+
next(iter(item.items())) if isinstance(item, dict) and len(item) == 1 else (None, None))
|
|
370
|
+
if name is None or not isinstance(extra, dict) or set(extra) - {"mode", "scope", "from"}:
|
|
371
|
+
raise RuleError(f"{item!r}: expected a name, or "
|
|
372
|
+
"{name: {mode: ..., scope: ..., from: ...}}")
|
|
373
|
+
if name not in packs:
|
|
374
|
+
import difflib
|
|
375
|
+
close = difflib.get_close_matches(name, packs, n=1)
|
|
376
|
+
hint = f" (did you mean '{close[0]}'?)" if close else ""
|
|
377
|
+
raise RuleError(f"no library rule '{name}'{hint}; `agentltl library` lists them")
|
|
378
|
+
for entry in _expand(name, packs):
|
|
379
|
+
pack = packs[entry]
|
|
380
|
+
if not _harness().matches(pack.get("harnesses")):
|
|
381
|
+
continue # another agent's rule (a project file both share)
|
|
382
|
+
for i, rule in enumerate(pack.get("rules") or []):
|
|
383
|
+
if any(r.id == rule.get("id") for r in rules):
|
|
384
|
+
continue # already switched on by another entry or bundle
|
|
385
|
+
compiled = compile_rule({**rule, **extra}, settings, paths,
|
|
386
|
+
f"library:{entry}[{i}]")
|
|
387
|
+
compiled.source = f"library:{entry}"
|
|
388
|
+
rules.append(compiled)
|
|
389
|
+
tools.update(pack.get("tools") or {})
|
|
390
|
+
return rules, tools
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _expand(name: str, packs: Dict[str, Dict[str, Any]], seen: Tuple[str, ...] = ()) -> List[str]:
|
|
394
|
+
"""*name* and, for a bundle, the entries its ``include:`` lists (recursively)."""
|
|
395
|
+
if name in seen:
|
|
396
|
+
raise RuleError(f"library entry '{name}' includes itself")
|
|
397
|
+
if name not in packs:
|
|
398
|
+
raise RuleError(f"library entry '{seen[-1]}' includes unknown '{name}'")
|
|
399
|
+
out = [name]
|
|
400
|
+
for sub in packs[name].get("include") or []:
|
|
401
|
+
out += [n for n in _expand(sub, packs, seen + (name,)) if n not in out]
|
|
402
|
+
return out
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _rule_lines(text: str) -> List[int]:
|
|
406
|
+
node = yaml.compose(text)
|
|
407
|
+
if not isinstance(node, yaml.MappingNode):
|
|
408
|
+
return []
|
|
409
|
+
for key, value in node.value:
|
|
410
|
+
if key.value == "rules" and isinstance(value, yaml.SequenceNode):
|
|
411
|
+
return [item.start_mark.line + 1 for item in value.value]
|
|
412
|
+
return []
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _settings(raw: Any) -> Settings:
|
|
416
|
+
if raw is None:
|
|
417
|
+
return Settings()
|
|
418
|
+
if not isinstance(raw, dict):
|
|
419
|
+
raise RuleError("expected a mapping")
|
|
420
|
+
switches = set(_harness().builtins)
|
|
421
|
+
unknown = set(raw) - {"mode", "retries", "unparseable", "announce", "scope", "scan_output",
|
|
422
|
+
"retry_counting", "report", "finish_retries"} - switches
|
|
423
|
+
if unknown:
|
|
424
|
+
raise RuleError(f"unknown key(s) {sorted(unknown)}")
|
|
425
|
+
s = Settings()
|
|
426
|
+
for key in switches & set(raw):
|
|
427
|
+
if not isinstance(raw[key], bool):
|
|
428
|
+
raise RuleError(f"{key} must be true or false")
|
|
429
|
+
s.builtins[key] = raw[key]
|
|
430
|
+
if "retry_counting" in raw:
|
|
431
|
+
if raw["retry_counting"] not in ("cumulative", "consecutive", "hybrid"):
|
|
432
|
+
raise RuleError("retry_counting must be cumulative, consecutive or hybrid")
|
|
433
|
+
s.retry_counting = raw["retry_counting"]
|
|
434
|
+
if "report" in raw:
|
|
435
|
+
if not isinstance(raw["report"], int) or raw["report"] < 1:
|
|
436
|
+
raise RuleError("report must be a positive integer")
|
|
437
|
+
s.report = raw["report"]
|
|
438
|
+
if "finish_retries" in raw:
|
|
439
|
+
if not isinstance(raw["finish_retries"], int) or raw["finish_retries"] < 0:
|
|
440
|
+
raise RuleError("finish_retries must be a non-negative integer")
|
|
441
|
+
s.finish_retries = raw["finish_retries"]
|
|
442
|
+
if "mode" in raw:
|
|
443
|
+
s.mode = _mode(raw["mode"])
|
|
444
|
+
if "retries" in raw:
|
|
445
|
+
if not isinstance(raw["retries"], int) or raw["retries"] < 1:
|
|
446
|
+
raise RuleError("retries must be a positive integer")
|
|
447
|
+
s.retries = raw["retries"]
|
|
448
|
+
if "scope" in raw:
|
|
449
|
+
s.scope = _scope(raw["scope"])
|
|
450
|
+
if "announce" in raw:
|
|
451
|
+
if not isinstance(raw["announce"], bool):
|
|
452
|
+
raise RuleError("announce must be true or false")
|
|
453
|
+
s.announce = raw["announce"]
|
|
454
|
+
if "scan_output" in raw:
|
|
455
|
+
if not isinstance(raw["scan_output"], bool):
|
|
456
|
+
raise RuleError("scan_output must be true or false")
|
|
457
|
+
s.scan_output = raw["scan_output"]
|
|
458
|
+
unp = raw.get("unparseable")
|
|
459
|
+
if isinstance(unp, str):
|
|
460
|
+
unp = {"interactive": unp, "auto": unp}
|
|
461
|
+
if unp is not None:
|
|
462
|
+
if not isinstance(unp, dict) or set(unp) - {"interactive", "auto"}:
|
|
463
|
+
raise RuleError("unparseable must be one of ask/note/allow/deny, or a mapping with "
|
|
464
|
+
"'interactive' and/or 'auto'")
|
|
465
|
+
for key, value in unp.items():
|
|
466
|
+
if value not in UNPARSEABLE:
|
|
467
|
+
raise RuleError(f"unparseable.{key} must be one of {', '.join(UNPARSEABLE)}")
|
|
468
|
+
s.unparseable_interactive = unp.get("interactive", s.unparseable_interactive)
|
|
469
|
+
s.unparseable_auto = unp.get("auto", s.unparseable_auto)
|
|
470
|
+
return s
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _scope(value: Any) -> str:
|
|
474
|
+
if value not in SCOPES:
|
|
475
|
+
raise RuleError(f"scope must be one of {', '.join(SCOPES)}, got {value!r}")
|
|
476
|
+
return value
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _mode(value: Any) -> str:
|
|
480
|
+
if value not in MODES:
|
|
481
|
+
raise RuleError(f"mode must be one of {', '.join(MODES)}, got {value!r}")
|
|
482
|
+
return value
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _check_tools(tools: Dict[str, Any], source: str) -> List[str]:
|
|
486
|
+
try:
|
|
487
|
+
from cli_to_tools import SpecRegistry
|
|
488
|
+
except ImportError:
|
|
489
|
+
return []
|
|
490
|
+
try:
|
|
491
|
+
SpecRegistry(packs=[]).load_dict(tools)
|
|
492
|
+
except Exception as exc: # the spec loader raises whatever argparse or YAML shapes give
|
|
493
|
+
return [f"{source}: tools: {exc}"]
|
|
494
|
+
return []
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
# ── compiling one rule ────────────────────────────────────────────────────────
|
|
498
|
+
|
|
499
|
+
def compile_rule(raw: Any, settings: Settings, paths: Paths, where: str = "rule") -> Rule:
|
|
500
|
+
if not isinstance(raw, dict):
|
|
501
|
+
raise RuleError("a rule must be a mapping with an id and one rule kind")
|
|
502
|
+
unknown = set(raw) - _RULE_KEYS
|
|
503
|
+
if unknown:
|
|
504
|
+
raise RuleError(f"unknown key(s) {sorted(unknown)}; rule kinds are {', '.join(KINDS)}")
|
|
505
|
+
rule_id = raw.get("id")
|
|
506
|
+
if not isinstance(rule_id, str) or not rule_id.strip():
|
|
507
|
+
raise RuleError("missing 'id'")
|
|
508
|
+
kinds = [k for k in KINDS if k in raw]
|
|
509
|
+
if len(kinds) != 1:
|
|
510
|
+
raise RuleError(f"rule '{rule_id}' needs exactly one of {', '.join(KINDS)}"
|
|
511
|
+
+ (f", has {', '.join(kinds)}" if kinds else ""))
|
|
512
|
+
kind = kinds[0]
|
|
513
|
+
if ("with" in raw or "where" in raw) and kind not in ("never", "require", "at_most"):
|
|
514
|
+
raise RuleError(f"rule '{rule_id}': 'with'/'where' at rule level only apply to "
|
|
515
|
+
"never, require and at_most; put them on a target instead")
|
|
516
|
+
mode = _mode(raw.get("mode", settings.mode))
|
|
517
|
+
scope = _scope(raw.get("scope", settings.scope))
|
|
518
|
+
why = str(raw.get("why") or "").strip()
|
|
519
|
+
fix = str(raw.get("fix") or "").strip()
|
|
520
|
+
origins = _origins(raw.get("from"), rule_id)
|
|
521
|
+
formula, label, explain, tools, *targets = _BUILDERS[kind](raw, paths, f"{rule_id}.{kind}")
|
|
522
|
+
if kind in ("ltl", "formula"):
|
|
523
|
+
_require_runtime_safe(formula, rule_id)
|
|
524
|
+
return Rule(rule_id, kind, why, fix, mode, formula, tools, where, tuple(targets), scope,
|
|
525
|
+
origins, label, explain)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _origins(raw: Any, rule_id: str) -> Tuple[Dict[str, Any], ...]:
|
|
529
|
+
"""``from:``: the memory statement(s) a rule was written from (see ``agentltl memory``).
|
|
530
|
+
A statement id alone, a mapping with file/line/id/text, or a list of these."""
|
|
531
|
+
if raw is None:
|
|
532
|
+
return ()
|
|
533
|
+
items = raw if isinstance(raw, list) else [raw]
|
|
534
|
+
out = []
|
|
535
|
+
for item in items:
|
|
536
|
+
if isinstance(item, str):
|
|
537
|
+
item = {"id": item}
|
|
538
|
+
if not isinstance(item, dict) or not item or set(item) - _FROM_KEYS:
|
|
539
|
+
raise RuleError(f"rule '{rule_id}': 'from' takes a statement id, or a mapping with "
|
|
540
|
+
f"{', '.join(sorted(_FROM_KEYS))}")
|
|
541
|
+
out.append(item)
|
|
542
|
+
return tuple(out)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
class _Pattern:
|
|
546
|
+
"""A rule target as an AgentLTL call pattern: ``match`` answers True, False, or None
|
|
547
|
+
for "maybe" (files only known at run time), with paths resolved from *paths*."""
|
|
548
|
+
|
|
549
|
+
def __init__(self, target: Any, paths: Paths) -> None:
|
|
550
|
+
self.target, self.paths = target, paths
|
|
551
|
+
self.tools = target.tools
|
|
552
|
+
|
|
553
|
+
def match(self, name: str, args: Dict[str, Any], call: Any = None) -> Optional[bool]:
|
|
554
|
+
return self.target.match(name, args, self.paths, call)
|
|
555
|
+
|
|
556
|
+
def bind(self, bindings: Dict[str, Any]) -> "_Pattern":
|
|
557
|
+
return _Pattern(_bound(self.target, bindings), self.paths)
|
|
558
|
+
|
|
559
|
+
def describe(self) -> str:
|
|
560
|
+
return self.target.describe()
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
class _AnyTool:
|
|
564
|
+
"""A call to one of *tools* (``*`` globs), whatever its arguments."""
|
|
565
|
+
|
|
566
|
+
def __init__(self, tools: Tuple[str, ...]) -> None:
|
|
567
|
+
self.tools = tools
|
|
568
|
+
|
|
569
|
+
def match(self, name: str, args: Dict[str, Any]) -> bool:
|
|
570
|
+
return tool_matches(name, self.tools)
|
|
571
|
+
|
|
572
|
+
def describe(self) -> str:
|
|
573
|
+
return " or ".join(self.tools)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _bound(target: Any, bindings: Dict[str, Any]) -> Any:
|
|
577
|
+
"""*target* with its ``$variables`` replaced by values from *bindings*."""
|
|
578
|
+
import dataclasses
|
|
579
|
+
if hasattr(target, "targets"):
|
|
580
|
+
return type(target)(tuple(_bound(t, bindings) for t in target.targets))
|
|
581
|
+
with_ = dict(target.with_)
|
|
582
|
+
variables = {}
|
|
583
|
+
for arg, name in target.variables.items():
|
|
584
|
+
if name in bindings:
|
|
585
|
+
with_[arg] = bindings[name]
|
|
586
|
+
else:
|
|
587
|
+
variables[arg] = name
|
|
588
|
+
return dataclasses.replace(target, with_=with_, variables=variables)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _matches(target: Any, paths: Paths, maybe: bool) -> Any:
|
|
592
|
+
from agentltl import Matches
|
|
593
|
+
return Matches(_Pattern(target, paths), maybe=maybe)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _always(body: Any) -> Any:
|
|
597
|
+
"""``G(body)``. With a past-only body each call is judged on its own: a rule broken
|
|
598
|
+
earlier (by an override) never blocks unrelated calls."""
|
|
599
|
+
from agentltl import Globally
|
|
600
|
+
return Globally(body)
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _never(raw: Dict[str, Any], paths: Paths, where: str) -> Tuple[Any, ...]:
|
|
604
|
+
"""``G ¬matches(T)``; a possible match counts."""
|
|
605
|
+
from agentltl import Not
|
|
606
|
+
target = parse_target(raw["never"], where, with_=raw.get("with"), where_=raw.get("where"))
|
|
607
|
+
|
|
608
|
+
def explain(calls: List[Any]) -> Optional[str]:
|
|
609
|
+
c = calls[-1]
|
|
610
|
+
hit = target.match(c.name, c.args, paths)
|
|
611
|
+
if hit is True:
|
|
612
|
+
return f"{c.name} is not allowed: it matches {target.describe_hit(c.name, c.args, paths)}."
|
|
613
|
+
if hit is None:
|
|
614
|
+
return f"{c.name} may match {target.describe_hit(c.name, c.args, paths)}." + _MAYBE
|
|
615
|
+
return None
|
|
616
|
+
|
|
617
|
+
formula = _always(Not(_matches(target, paths, maybe=True)))
|
|
618
|
+
return formula, f"never {target.describe()}", explain, target.tools, target
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _before(raw: Dict[str, Any], paths: Paths, where: str) -> Tuple[Any, ...]:
|
|
622
|
+
"""``G(matches(B) → Y(¬matches(S) S matches(A)))``: every B has an A after the last S
|
|
623
|
+
(``since``), or at all (``Y O matches(A)``). A possible B or S counts; a possible A does
|
|
624
|
+
not."""
|
|
625
|
+
from agentltl import Implies, Not, Once, Previous, Since
|
|
626
|
+
spec = raw["before"]
|
|
627
|
+
since = None
|
|
628
|
+
if isinstance(spec, list) and len(spec) == 2:
|
|
629
|
+
first, then = spec
|
|
630
|
+
elif isinstance(spec, dict) and {"first", "then"} <= set(spec) and not (
|
|
631
|
+
set(spec) - {"first", "then", "since"}):
|
|
632
|
+
first, then = spec["first"], spec["then"]
|
|
633
|
+
if spec.get("since") is not None:
|
|
634
|
+
since = parse_target(spec["since"], f"{where}.since")
|
|
635
|
+
else:
|
|
636
|
+
raise RuleError(f"{where}: expected [first, then] or {{first, then, since}}")
|
|
637
|
+
a = parse_target(first, f"{where}.first")
|
|
638
|
+
b = parse_target(then, f"{where}.then")
|
|
639
|
+
if _parts_with_variables(a) or _parts_with_variables(b):
|
|
640
|
+
return _before_same_value(a, b, since, paths, where)
|
|
641
|
+
|
|
642
|
+
def explain(calls: List[Any]) -> Optional[str]:
|
|
643
|
+
c = calls[-1]
|
|
644
|
+
if b.match(c.name, c.args, paths) is False:
|
|
645
|
+
return None
|
|
646
|
+
start = -1
|
|
647
|
+
if since is not None: # a call that may have been a `since` one resets, to be safe
|
|
648
|
+
start = max((i for i, p in enumerate(calls[:-1])
|
|
649
|
+
if since.match(p.name, p.args, paths, p) is not False), default=-1)
|
|
650
|
+
if any(a.matches(p.name, p.args, paths, p) for p in calls[start + 1:-1]):
|
|
651
|
+
return None
|
|
652
|
+
tail = ""
|
|
653
|
+
if since is not None and start >= 0:
|
|
654
|
+
tail = f" since the last {since.describe()} (call #{start + 1})"
|
|
655
|
+
return f"{c.name} needs {a.describe()} to have run first{tail}."
|
|
656
|
+
|
|
657
|
+
first_ok = _matches(a, paths, maybe=False)
|
|
658
|
+
if since is None:
|
|
659
|
+
earlier = Previous(Once(first_ok))
|
|
660
|
+
else:
|
|
661
|
+
earlier = Previous(Since(Not(_matches(since, paths, maybe=True)), first_ok))
|
|
662
|
+
formula = _always(Implies(_matches(b, paths, maybe=True), earlier))
|
|
663
|
+
label = f"{a.describe()} before {b.describe()}" + (f" since {since.describe()}" if since else "")
|
|
664
|
+
return (formula, label, explain, a.tools + b.tools, a, b) + ((since,) if since else ())
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _parts(target: Any) -> List[Any]:
|
|
668
|
+
return list(getattr(target, "targets", (target,)))
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _parts_with_variables(target: Any) -> List[Any]:
|
|
672
|
+
return [t for t in _parts(target) if t.variables]
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _before_same_value(a: Any, b: Any, since: Any, paths: Paths, where: str) -> Tuple[Any, ...]:
|
|
676
|
+
"""``before`` whose two sides share ``$variables``:
|
|
677
|
+
``G(∀x ∈ values of the call here. Y O matches(first with x))``.
|
|
678
|
+
|
|
679
|
+
A variable is implicitly universal: for every value x the call being judged has (as
|
|
680
|
+
``then``), an earlier ``first`` call had it too. Several variables nest one ForAll each:
|
|
681
|
+
with single values (a chart and its version) the earlier call must have had that exact
|
|
682
|
+
combination; with lists, every combination.
|
|
683
|
+
"""
|
|
684
|
+
from functools import reduce
|
|
685
|
+
|
|
686
|
+
from agentltl import ForAll, Once, Or, Previous
|
|
687
|
+
|
|
688
|
+
if since is not None:
|
|
689
|
+
raise RuleError(f"{where}: 'since' cannot be combined with $variables yet")
|
|
690
|
+
names = sorted({v for t in _parts(a) + _parts(b) for v in t.variables.values()})
|
|
691
|
+
if not names:
|
|
692
|
+
raise RuleError(f"{where}: use a $variable on both 'first' and 'then'")
|
|
693
|
+
for t in _parts(a) + _parts(b):
|
|
694
|
+
missing = [n for n in names if n not in t.variables.values()]
|
|
695
|
+
if missing:
|
|
696
|
+
raise RuleError(f"{where}: every target must bind "
|
|
697
|
+
f"{', '.join('$' + n for n in missing)} ({t.describe()} does not)")
|
|
698
|
+
for t in _parts(a):
|
|
699
|
+
if t.where or t.exists is not None:
|
|
700
|
+
raise RuleError(f"{where}.first: with a $variable, 'first' takes 'with' values only "
|
|
701
|
+
"(they are compared for equality)")
|
|
702
|
+
|
|
703
|
+
def values_here(var: str, call: Any) -> List[Any]:
|
|
704
|
+
values: List[Any] = []
|
|
705
|
+
for t in _parts(b):
|
|
706
|
+
if t.matches(call.name, call.args, paths, call):
|
|
707
|
+
for arg, name in t.variables.items():
|
|
708
|
+
if name == var:
|
|
709
|
+
value = call.args.get(arg)
|
|
710
|
+
values += value if isinstance(value, list) else [value]
|
|
711
|
+
return list(dict.fromkeys(x for x in values if x is not None))
|
|
712
|
+
|
|
713
|
+
def domain_of(var: str) -> Callable[..., List[Any]]:
|
|
714
|
+
def domain(trace: Any, metrics: Any, position: int) -> List[Any]:
|
|
715
|
+
call = trace.at(position)
|
|
716
|
+
return values_here(var, call) if call is not None else []
|
|
717
|
+
return domain
|
|
718
|
+
|
|
719
|
+
def explain(calls: List[Any]) -> Optional[str]:
|
|
720
|
+
c = calls[-1]
|
|
721
|
+
missing = []
|
|
722
|
+
for combo in _combinations({v: values_here(v, c) for v in names}):
|
|
723
|
+
wanted = [_bound(t, combo) for t in _parts(a)]
|
|
724
|
+
if not any(w.matches(p.name, p.args, paths, p) for p in calls[:-1] for w in wanted):
|
|
725
|
+
missing.append(combo)
|
|
726
|
+
if not missing:
|
|
727
|
+
return None
|
|
728
|
+
combo = missing[0]
|
|
729
|
+
bound = ", ".join(f"{k}={v!r}" for k, v in combo.items())
|
|
730
|
+
wanted = []
|
|
731
|
+
for t in _parts(a):
|
|
732
|
+
values = {**t.with_, **{arg: combo.get(name) for arg, name in t.variables.items()}}
|
|
733
|
+
shown = ", ".join(f"{k}={v!r}" for k, v in values.items())
|
|
734
|
+
for tool in t.tools:
|
|
735
|
+
if not any(tool_matches(p.name, (tool,)) for p in calls):
|
|
736
|
+
wanted.append(f"{tool} was never called")
|
|
737
|
+
else:
|
|
738
|
+
wanted.append(f"no earlier {tool} call had {shown}")
|
|
739
|
+
return f"for {bound}: {' or '.join(wanted)}."
|
|
740
|
+
|
|
741
|
+
body = reduce(Or, [Previous(Once(_matches(t, paths, maybe=False))) for t in _parts(a)])
|
|
742
|
+
for var in reversed(names):
|
|
743
|
+
body = ForAll(var, domain_of(var), body, description=f"values of ${var} in {b.describe()}")
|
|
744
|
+
label = f"{a.describe()} before {b.describe()}"
|
|
745
|
+
return _always(body), label, explain, a.tools + b.tools, a, b
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _combinations(values: Dict[str, List[Any]]) -> List[Dict[str, Any]]:
|
|
749
|
+
import itertools
|
|
750
|
+
names = list(values)
|
|
751
|
+
return [dict(zip(names, combo)) for combo in itertools.product(*(values[n] for n in names))]
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def _require(raw: Dict[str, Any], paths: Paths, where: str) -> Tuple[Any, ...]:
|
|
755
|
+
"""``G(called one of T's tools → matches(T))``; a possible match does not satisfy it."""
|
|
756
|
+
from agentltl import Implies, Matches
|
|
757
|
+
target = parse_target(raw["require"], where, with_=raw.get("with"), where_=raw.get("where"))
|
|
758
|
+
for part in getattr(target, "targets", (target,)):
|
|
759
|
+
if not part.with_ and not part.where:
|
|
760
|
+
raise RuleError(f"{where}: require needs 'with' or 'where' on every target "
|
|
761
|
+
f"(what the arguments of {part.describe()} must be)")
|
|
762
|
+
|
|
763
|
+
def explain(calls: List[Any]) -> Optional[str]:
|
|
764
|
+
c = calls[-1]
|
|
765
|
+
if not tool_matches(c.name, target.tools):
|
|
766
|
+
return None
|
|
767
|
+
hit = target.match(c.name, c.args, paths)
|
|
768
|
+
if hit is None:
|
|
769
|
+
return f"{c.name} must be called as {target.describe()}, which cannot be checked." + _MAYBE
|
|
770
|
+
if hit is False:
|
|
771
|
+
return f"{c.name} must be called as {target.describe()}."
|
|
772
|
+
return None
|
|
773
|
+
|
|
774
|
+
formula = _always(Implies(Matches(_AnyTool(target.tools)), _matches(target, paths, maybe=False)))
|
|
775
|
+
return formula, f"require {target.describe()}", explain, target.tools, target
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _at_most(raw: Dict[str, Any], paths: Paths, where: str) -> Tuple[Any, ...]:
|
|
779
|
+
"""``G(matches(T) → fewer than n earlier calls matched T)``."""
|
|
780
|
+
from agentltl import CountBefore, Implies
|
|
781
|
+
spec = raw["at_most"]
|
|
782
|
+
if not isinstance(spec, dict) or set(spec) != {"call", "times"}:
|
|
783
|
+
raise RuleError(f"{where}: expected {{call: <target>, times: <n>}}")
|
|
784
|
+
times = spec["times"]
|
|
785
|
+
if not isinstance(times, int) or times < 0:
|
|
786
|
+
raise RuleError(f"{where}.times: expected a non-negative integer")
|
|
787
|
+
target = parse_target(spec["call"], f"{where}.call", with_=raw.get("with"),
|
|
788
|
+
where_=raw.get("where"))
|
|
789
|
+
|
|
790
|
+
def explain(calls: List[Any]) -> Optional[str]:
|
|
791
|
+
c = calls[-1]
|
|
792
|
+
if target.match(c.name, c.args, paths) is False:
|
|
793
|
+
return None
|
|
794
|
+
n = sum(1 for p in calls[:-1] if target.matches(p.name, p.args, paths, p))
|
|
795
|
+
if n >= times:
|
|
796
|
+
return f"{target.describe()} may run at most {times} time(s); it already ran {n}."
|
|
797
|
+
return None
|
|
798
|
+
|
|
799
|
+
formula = _always(Implies(_matches(target, paths, maybe=True),
|
|
800
|
+
CountBefore(_matches(target, paths, maybe=False), times, "<")))
|
|
801
|
+
return formula, f"at most {times} x {target.describe()}", explain, target.tools, target
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def _ltl(raw: Dict[str, Any], paths: Paths, where: str) -> Tuple[Any, ...]:
|
|
805
|
+
from agentltl import parse
|
|
806
|
+
text = raw["ltl"]
|
|
807
|
+
if not isinstance(text, str):
|
|
808
|
+
raise RuleError(f"{where}: expected a formula string")
|
|
809
|
+
try:
|
|
810
|
+
formula = parse(text)
|
|
811
|
+
except Exception as exc:
|
|
812
|
+
raise RuleError(f"{where}: cannot parse formula: {exc}") from None
|
|
813
|
+
return formula, str(formula), None, tuple(sorted(_tools_in(formula)))
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def _formula(raw: Dict[str, Any], paths: Paths, where: str) -> Tuple[Any, ...]:
|
|
817
|
+
formula = build_formula(raw["formula"], where)
|
|
818
|
+
return formula, str(formula), None, tuple(sorted(_tools_in(formula)))
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def _finally(raw: Dict[str, Any], paths: Paths, where: str) -> Tuple[Any, ...]:
|
|
822
|
+
"""Before finishing: T has run (``F matches(T)``), or has run after the last S
|
|
823
|
+
(``G(matches(S) → F matches(T))``). Judged on the finished trace only."""
|
|
824
|
+
from agentltl import Eventually, Globally, Implies
|
|
825
|
+
spec = raw["finally"]
|
|
826
|
+
since = None
|
|
827
|
+
if isinstance(spec, dict) and "call" in spec:
|
|
828
|
+
if set(spec) - {"call", "since"}:
|
|
829
|
+
raise RuleError(f"{where}: expected a target, or {{call: <target>, since: <target>}}")
|
|
830
|
+
call = parse_target(spec["call"], f"{where}.call")
|
|
831
|
+
if spec.get("since") is not None:
|
|
832
|
+
since = parse_target(spec["since"], f"{where}.since")
|
|
833
|
+
else:
|
|
834
|
+
call = parse_target(spec, where)
|
|
835
|
+
done = Eventually(_matches(call, paths, maybe=False))
|
|
836
|
+
formula = done if since is None else Globally(Implies(_matches(since, paths, maybe=True), done))
|
|
837
|
+
|
|
838
|
+
def explain(calls: List[Any]) -> Optional[str]:
|
|
839
|
+
start = -1
|
|
840
|
+
if since is not None:
|
|
841
|
+
start = max((i for i, p in enumerate(calls)
|
|
842
|
+
if since.match(p.name, p.args, paths, p) is not False), default=-2)
|
|
843
|
+
if start == -2:
|
|
844
|
+
return None # nothing that needs it happened
|
|
845
|
+
if any(call.matches(p.name, p.args, paths, p) for p in calls[start + 1:]):
|
|
846
|
+
return None
|
|
847
|
+
tail = f" after the last {since.describe()} (call #{start + 1})" if since else ""
|
|
848
|
+
return f"{call.describe()} has not run{tail}."
|
|
849
|
+
|
|
850
|
+
label = f"finally {call.describe()}" + (f" after {since.describe()}" if since else "")
|
|
851
|
+
return (formula, label, explain, call.tools, call) + ((since,) if since else ())
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
_BUILDERS = {"never": _never, "before": _before, "require": _require, "at_most": _at_most,
|
|
855
|
+
"finally": _finally, "ltl": _ltl, "formula": _formula}
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def build_formula(spec: Any, where: str = "formula") -> Any:
|
|
859
|
+
"""``{type: Before, args: {a: x, b: y}}`` → ``Before("x", "y")``, recursively."""
|
|
860
|
+
import dataclasses
|
|
861
|
+
|
|
862
|
+
import agentltl
|
|
863
|
+
|
|
864
|
+
if isinstance(spec, list):
|
|
865
|
+
return [build_formula(s, where) for s in spec]
|
|
866
|
+
if not (isinstance(spec, dict) and "type" in spec):
|
|
867
|
+
return spec
|
|
868
|
+
cls = getattr(agentltl, str(spec["type"]), None)
|
|
869
|
+
if not (isinstance(cls, type) and issubclass(cls, agentltl.Formula)) or cls is agentltl.Predicate:
|
|
870
|
+
raise RuleError(f"{where}: unknown formula type {spec['type']!r}")
|
|
871
|
+
args = spec.get("args") or {}
|
|
872
|
+
if not isinstance(args, dict):
|
|
873
|
+
raise RuleError(f"{where}: 'args' must be a mapping")
|
|
874
|
+
fields = {f.name for f in dataclasses.fields(cls)}
|
|
875
|
+
unknown = set(args) - fields
|
|
876
|
+
if unknown:
|
|
877
|
+
raise RuleError(f"{where}: {cls.__name__} has no argument(s) {sorted(unknown)}; "
|
|
878
|
+
f"expected {sorted(fields)}")
|
|
879
|
+
try:
|
|
880
|
+
return cls(**{k: build_formula(v, f"{where}.{k}") for k, v in args.items()})
|
|
881
|
+
except TypeError as exc:
|
|
882
|
+
raise RuleError(f"{where}: {exc}") from None
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def _tools_in(formula: Any) -> set:
|
|
886
|
+
import dataclasses
|
|
887
|
+
out: set = set()
|
|
888
|
+
if isinstance(formula, (list, tuple)):
|
|
889
|
+
for f in formula:
|
|
890
|
+
out |= _tools_in(f)
|
|
891
|
+
return out
|
|
892
|
+
if not dataclasses.is_dataclass(formula):
|
|
893
|
+
return out
|
|
894
|
+
for f in dataclasses.fields(formula):
|
|
895
|
+
value = getattr(formula, f.name)
|
|
896
|
+
if f.name in ("tool", "a", "b", "tool_a", "tool_b", "target", "tools") and value:
|
|
897
|
+
out |= {value} if isinstance(value, str) else {v for v in value if isinstance(v, str)}
|
|
898
|
+
else:
|
|
899
|
+
out |= _tools_in(value)
|
|
900
|
+
return out
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def _require_runtime_safe(formula: Any, rule_id: str) -> None:
|
|
904
|
+
"""Reject formulas a check made call by call can't judge, as AgentLTL classifies them:
|
|
905
|
+
one that fails until some call happens (UNSAFE) would refuse every call before it, and
|
|
906
|
+
one that can't fail before the session ends (INERT) would never refuse anything."""
|
|
907
|
+
from agentltl.runtime_safety import RuntimeSafety, classify_runtime_safety
|
|
908
|
+
safety = classify_runtime_safety(formula).safety
|
|
909
|
+
if safety == RuntimeSafety.UNSAFE:
|
|
910
|
+
raise RuleError(
|
|
911
|
+
f"rule '{rule_id}': this formula fails until some call happens (called(\"x\") is "
|
|
912
|
+
"false until x runs), so every call before that would be refused. Say when it "
|
|
913
|
+
"applies, e.g. `G(now(\"deploy\") -> called(\"pytest\"))`, or use a rule kind such "
|
|
914
|
+
"as `before: {first: pytest, then: git_push}`.")
|
|
915
|
+
if safety == RuntimeSafety.INERT:
|
|
916
|
+
raise RuleError(
|
|
917
|
+
f"rule '{rule_id}': this is a liveness property: it can't fail before the session "
|
|
918
|
+
"ends (F, in_order: what it asks for may still happen), so it would never refuse a "
|
|
919
|
+
"call. Bound it, e.g. `within_steps(\"deploy\", \"notify\", 3)`, or say what must "
|
|
920
|
+
"not happen first, e.g. `before(\"pytest\", \"git_push\")`.")
|