chipgraph 0.1.0__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.
Files changed (252) hide show
  1. chipgraph/__init__.py +7 -0
  2. chipgraph/adapters/__init__.py +1 -0
  3. chipgraph/adapters/format/__init__.py +6 -0
  4. chipgraph/adapters/format/__main__.py +16 -0
  5. chipgraph/adapters/format/_yaml_lines.py +54 -0
  6. chipgraph/adapters/format/chip_yaml.py +323 -0
  7. chipgraph/adapters/format/qsoc_contract.py +246 -0
  8. chipgraph/adapters/llm/__init__.py +58 -0
  9. chipgraph/adapters/llm/_budget.py +173 -0
  10. chipgraph/adapters/llm/_errors.py +50 -0
  11. chipgraph/adapters/llm/_nda.py +30 -0
  12. chipgraph/adapters/llm/_retry.py +85 -0
  13. chipgraph/adapters/llm/anthropic.py +247 -0
  14. chipgraph/adapters/llm/decide_backend.py +179 -0
  15. chipgraph/adapters/llm/fake.py +87 -0
  16. chipgraph/adapters/llm/guard.py +68 -0
  17. chipgraph/adapters/parser/__init__.py +22 -0
  18. chipgraph/adapters/parser/cocotb.py +304 -0
  19. chipgraph/adapters/parser/generic_regex.py +75 -0
  20. chipgraph/adapters/parser/verible.py +55 -0
  21. chipgraph/adapters/parser/verilator.py +46 -0
  22. chipgraph/adapters/review/__init__.py +7 -0
  23. chipgraph/adapters/review/file.py +123 -0
  24. chipgraph/adapters/review/github.py +886 -0
  25. chipgraph/adapters/runner/__init__.py +7 -0
  26. chipgraph/adapters/runner/local.py +122 -0
  27. chipgraph/adapters/runtime/__init__.py +5 -0
  28. chipgraph/adapters/runtime/claude_code/__init__.py +24 -0
  29. chipgraph/adapters/runtime/claude_code/agents.py +158 -0
  30. chipgraph/adapters/runtime/claude_code/decisions.py +280 -0
  31. chipgraph/adapters/runtime/claude_code/guarded.py +310 -0
  32. chipgraph/adapters/runtime/claude_code/isolation.py +482 -0
  33. chipgraph/adapters/runtime/claude_code/loop.py +342 -0
  34. chipgraph/adapters/runtime/claude_code/previous.py +419 -0
  35. chipgraph/adapters/runtime/claude_code/review.py +492 -0
  36. chipgraph/adapters/runtime/claude_code/runtime.py +192 -0
  37. chipgraph/adapters/runtime/claude_code/service.py +2285 -0
  38. chipgraph/adapters/tool/__init__.py +7 -0
  39. chipgraph/adapters/tool/cmd.py +229 -0
  40. chipgraph/adapters/tool/edalize.py +773 -0
  41. chipgraph/adapters/tool/filelist.py +259 -0
  42. chipgraph/adapters/tool/pyslang.py +485 -0
  43. chipgraph/adapters/vcs/__init__.py +1 -0
  44. chipgraph/adapters/vcs/git.py +521 -0
  45. chipgraph/app/__init__.py +10 -0
  46. chipgraph/app/baseline.py +339 -0
  47. chipgraph/app/build.py +332 -0
  48. chipgraph/app/checks.py +202 -0
  49. chipgraph/app/context.py +191 -0
  50. chipgraph/app/errors.py +11 -0
  51. chipgraph/app/executors.py +79 -0
  52. chipgraph/app/findings.py +203 -0
  53. chipgraph/app/ingest.py +523 -0
  54. chipgraph/checks/__init__.py +52 -0
  55. chipgraph/checks/_cdc_analyze.py +272 -0
  56. chipgraph/checks/_cdc_elaborate.py +455 -0
  57. chipgraph/checks/_common.py +268 -0
  58. chipgraph/checks/_cross.py +96 -0
  59. chipgraph/checks/_model.py +64 -0
  60. chipgraph/checks/_naming_pyslang.py +235 -0
  61. chipgraph/checks/_naming_rules.py +127 -0
  62. chipgraph/checks/_xref_syntax.py +260 -0
  63. chipgraph/checks/cdc_struct.py +235 -0
  64. chipgraph/checks/connect.py +353 -0
  65. chipgraph/checks/cross_chip.py +454 -0
  66. chipgraph/checks/duplicate.py +153 -0
  67. chipgraph/checks/filelist.py +323 -0
  68. chipgraph/checks/format.py +268 -0
  69. chipgraph/checks/generated.py +151 -0
  70. chipgraph/checks/hardcode.py +233 -0
  71. chipgraph/checks/layout.py +155 -0
  72. chipgraph/checks/naming.py +311 -0
  73. chipgraph/checks/plan_check.py +901 -0
  74. chipgraph/checks/ports_diff.py +203 -0
  75. chipgraph/checks/spec_schema.py +277 -0
  76. chipgraph/checks/style.py +910 -0
  77. chipgraph/checks/tb_static.py +599 -0
  78. chipgraph/checks/trace.py +224 -0
  79. chipgraph/checks/vendor_guard.py +369 -0
  80. chipgraph/checks/wrap_check.py +486 -0
  81. chipgraph/cli/__init__.py +1688 -0
  82. chipgraph/cli/next_steps.py +137 -0
  83. chipgraph/core/__init__.py +1 -0
  84. chipgraph/core/config/__init__.py +93 -0
  85. chipgraph/core/config/defaults.py +14 -0
  86. chipgraph/core/config/errors.py +32 -0
  87. chipgraph/core/config/loader.py +766 -0
  88. chipgraph/core/config/models.py +845 -0
  89. chipgraph/core/config/templates.py +340 -0
  90. chipgraph/core/contracts/__init__.py +75 -0
  91. chipgraph/core/contracts/_paths.py +26 -0
  92. chipgraph/core/contracts/agent.py +49 -0
  93. chipgraph/core/contracts/artifact.py +68 -0
  94. chipgraph/core/contracts/check.py +76 -0
  95. chipgraph/core/contracts/event.py +70 -0
  96. chipgraph/core/contracts/export.py +128 -0
  97. chipgraph/core/contracts/finding.py +171 -0
  98. chipgraph/core/contracts/gate.py +50 -0
  99. chipgraph/core/contracts/rule.py +177 -0
  100. chipgraph/core/contracts/types.py +45 -0
  101. chipgraph/core/engine/__init__.py +70 -0
  102. chipgraph/core/engine/agent_rule.py +1362 -0
  103. chipgraph/core/engine/baseline.py +283 -0
  104. chipgraph/core/engine/decide.py +462 -0
  105. chipgraph/core/engine/fanout.py +1358 -0
  106. chipgraph/core/engine/gate.py +170 -0
  107. chipgraph/core/engine/graph.py +647 -0
  108. chipgraph/core/engine/records.py +60 -0
  109. chipgraph/core/engine/rules.py +92 -0
  110. chipgraph/core/engine/scheduler.py +637 -0
  111. chipgraph/core/engine/select.py +1239 -0
  112. chipgraph/core/engine/targets.py +342 -0
  113. chipgraph/core/model/__init__.py +98 -0
  114. chipgraph/core/model/entities.py +244 -0
  115. chipgraph/core/model/export.py +16 -0
  116. chipgraph/core/model/ingest.py +693 -0
  117. chipgraph/core/model/json_value.py +4 -0
  118. chipgraph/core/model/keys.py +95 -0
  119. chipgraph/core/model/model.py +153 -0
  120. chipgraph/core/model/provenance.py +27 -0
  121. chipgraph/core/model/query.py +583 -0
  122. chipgraph/core/model/registry.py +80 -0
  123. chipgraph/core/model/relations.py +61 -0
  124. chipgraph/core/model/store.py +279 -0
  125. chipgraph/core/plugin_api/__init__.py +81 -0
  126. chipgraph/core/plugin_api/local.py +167 -0
  127. chipgraph/core/plugin_api/pack.py +159 -0
  128. chipgraph/core/plugin_api/protocols.py +177 -0
  129. chipgraph/core/plugin_api/registry.py +111 -0
  130. chipgraph/core/plugin_api/types.py +94 -0
  131. chipgraph/core/runtime/__init__.py +41 -0
  132. chipgraph/core/runtime/executor.py +79 -0
  133. chipgraph/core/runtime/queue.py +565 -0
  134. chipgraph/core/runtime/roles/__init__.py +91 -0
  135. chipgraph/core/runtime/roles/data/author.md +41 -0
  136. chipgraph/core/runtime/roles/data/critic.md +56 -0
  137. chipgraph/core/runtime/roles/data/planner.md +38 -0
  138. chipgraph/core/runtime/roles/data/researcher.md +37 -0
  139. chipgraph/core/runtime/roles/data/rtl-author.md +64 -0
  140. chipgraph/core/runtime/roles/data/tb-author.md +48 -0
  141. chipgraph/core/runtime/roles/data/triage.md +34 -0
  142. chipgraph/core/runtime/roles/policy.py +299 -0
  143. chipgraph/core/runtime/roles/registry.py +52 -0
  144. chipgraph/core/runtime/roles/review.py +275 -0
  145. chipgraph/core/runtime/roles/schema.py +82 -0
  146. chipgraph/core/runtime/roles/skills.py +134 -0
  147. chipgraph/core/runtime/roles/spec.py +232 -0
  148. chipgraph/core/state/__init__.py +1 -0
  149. chipgraph/core/state/artifacts.py +156 -0
  150. chipgraph/core/state/backend.py +117 -0
  151. chipgraph/core/state/findings.py +151 -0
  152. chipgraph/core/state/handoff.py +411 -0
  153. chipgraph/core/state/idempotency.py +59 -0
  154. chipgraph/core/state/journal.py +238 -0
  155. chipgraph/core/state/layout.py +82 -0
  156. chipgraph/core/state/lock.py +195 -0
  157. chipgraph/core/state/trace.py +225 -0
  158. chipgraph/learn/__init__.py +96 -0
  159. chipgraph/learn/draft.py +187 -0
  160. chipgraph/learn/infer.py +580 -0
  161. chipgraph/learn/models.py +145 -0
  162. chipgraph/learn/try_run.py +228 -0
  163. chipgraph/mcp/__init__.py +12 -0
  164. chipgraph/mcp/model_tools.py +167 -0
  165. chipgraph/mcp/server.py +787 -0
  166. chipgraph/orgs/qnsc/naming-v1.yml +113 -0
  167. chipgraph/orgs/qnsc/profile.yml +5 -0
  168. chipgraph/packs/__init__.py +8 -0
  169. chipgraph/packs/assist/__init__.py +16 -0
  170. chipgraph/packs/assist/ask/__init__.py +58 -0
  171. chipgraph/packs/assist/ask/_db.py +57 -0
  172. chipgraph/packs/assist/ask/_project.py +230 -0
  173. chipgraph/packs/assist/ask/answer.py +266 -0
  174. chipgraph/packs/assist/ask/check.py +182 -0
  175. chipgraph/packs/assist/ask/contract.py +115 -0
  176. chipgraph/packs/assist/ask/documents.py +157 -0
  177. chipgraph/packs/assist/ask/fts.py +80 -0
  178. chipgraph/packs/assist/ask/retrieve.py +331 -0
  179. chipgraph/packs/assist/audit/__init__.py +383 -0
  180. chipgraph/packs/assist/pack.yml +13 -0
  181. chipgraph/packs/assist/triage/__init__.py +64 -0
  182. chipgraph/packs/assist/triage/contract.py +94 -0
  183. chipgraph/packs/assist/triage/parse.py +371 -0
  184. chipgraph/packs/assist/triage/question.py +384 -0
  185. chipgraph/packs/assist/triage/report.py +160 -0
  186. chipgraph/packs/assist/triage/rules.py +447 -0
  187. chipgraph/packs/assist/triage/run.py +224 -0
  188. chipgraph/packs/digital_rtl/__init__.py +19 -0
  189. chipgraph/packs/digital_rtl/layout.py +144 -0
  190. chipgraph/packs/digital_rtl/pack.yml +25 -0
  191. chipgraph/packs/digital_rtl/plan/__init__.py +34 -0
  192. chipgraph/packs/digital_rtl/plan/__main__.py +16 -0
  193. chipgraph/packs/digital_rtl/plan/context.py +82 -0
  194. chipgraph/packs/digital_rtl/plan/expand.py +94 -0
  195. chipgraph/packs/digital_rtl/plan/model.py +150 -0
  196. chipgraph/packs/digital_rtl/plan/resolver.py +400 -0
  197. chipgraph/packs/digital_rtl/plan/show.py +176 -0
  198. chipgraph/packs/digital_rtl/pr/__init__.py +11 -0
  199. chipgraph/packs/digital_rtl/pr/body.py +165 -0
  200. chipgraph/packs/digital_rtl/pr/gate.py +110 -0
  201. chipgraph/packs/digital_rtl/pr/open.py +397 -0
  202. chipgraph/packs/digital_rtl/rtl/__init__.py +9 -0
  203. chipgraph/packs/digital_rtl/rtl/context.py +272 -0
  204. chipgraph/packs/digital_rtl/rules/open_pr.yml +30 -0
  205. chipgraph/packs/digital_rtl/rules/plan.yml +28 -0
  206. chipgraph/packs/digital_rtl/rules/plan_expand.yml +29 -0
  207. chipgraph/packs/digital_rtl/rules/pr_gate.yml +27 -0
  208. chipgraph/packs/digital_rtl/rules/review.yml +31 -0
  209. chipgraph/packs/digital_rtl/rules/rtl_module.yml +53 -0
  210. chipgraph/packs/digital_rtl/skills/lang-sv-rtl.md +58 -0
  211. chipgraph/packs/digital_rtl/skills/org-naming.md +29 -0
  212. chipgraph/packs/digital_rtl/skills/plan-modules.md +63 -0
  213. chipgraph/packs/digital_rtl/skills/review-diff.md +41 -0
  214. chipgraph/packs/digital_rtl/targets/plan.yml +11 -0
  215. chipgraph/packs/digital_rtl/targets/rtl.yml +20 -0
  216. chipgraph/packs/digital_rtl/wrapper/__init__.py +24 -0
  217. chipgraph/packs/digital_rtl/wrapper/common.py +171 -0
  218. chipgraph/packs/digital_rtl/wrapper/context.py +222 -0
  219. chipgraph/packs/digital_rtl/wrapper/emacs.py +626 -0
  220. chipgraph/packs/digital_rtl/wrapper/expansion.py +304 -0
  221. chipgraph/packs/digital_rtl/wrapper/lisp.py +504 -0
  222. chipgraph/packs/digital_rtl/wrapper/resolver.py +60 -0
  223. chipgraph/packs/digital_rtl/wrapper/rules/wrapper.yml +41 -0
  224. chipgraph/packs/digital_rtl/wrapper/rules/wrapper_gen.yml +35 -0
  225. chipgraph/packs/digital_rtl/wrapper/skills/wrapper-auto.md +120 -0
  226. chipgraph/packs/dv/__init__.py +15 -0
  227. chipgraph/packs/dv/declaration.py +392 -0
  228. chipgraph/packs/dv/interface.py +596 -0
  229. chipgraph/packs/dv/pack.yml +14 -0
  230. chipgraph/packs/dv/rules/tb_module.yml +42 -0
  231. chipgraph/packs/dv/skills/dv/cocotb.md +72 -0
  232. chipgraph/packs/spec_core/__init__.py +9 -0
  233. chipgraph/packs/spec_core/extract/__init__.py +12 -0
  234. chipgraph/packs/spec_core/extract/_markdown.py +371 -0
  235. chipgraph/packs/spec_core/extract/_text.py +116 -0
  236. chipgraph/packs/spec_core/extract/mas.py +975 -0
  237. chipgraph/packs/spec_core/gen/__init__.py +11 -0
  238. chipgraph/packs/spec_core/gen/agents_md/__init__.py +148 -0
  239. chipgraph/packs/spec_core/gen/agents_md/_context.py +239 -0
  240. chipgraph/packs/spec_core/gen/agents_md/_markers.py +131 -0
  241. chipgraph/packs/spec_core/gen/agents_md/agents_md.md.j2 +178 -0
  242. chipgraph/packs/spec_core/gen/diagram/__init__.py +110 -0
  243. chipgraph/packs/spec_core/gen/diagram/__main__.py +44 -0
  244. chipgraph/packs/spec_core/gen/diagram/_diagrams.py +403 -0
  245. chipgraph/packs/spec_core/gen/diagram/_scene.py +163 -0
  246. chipgraph/packs/spec_core/pack.yml +6 -0
  247. chipgraph/py.typed +0 -0
  248. chipgraph-0.1.0.dist-info/METADATA +53 -0
  249. chipgraph-0.1.0.dist-info/RECORD +252 -0
  250. chipgraph-0.1.0.dist-info/WHEEL +4 -0
  251. chipgraph-0.1.0.dist-info/entry_points.txt +61 -0
  252. chipgraph-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,1362 @@
1
+ """The agent rule loop: write, check at once, triage, fix within the budget (DESIGN.md 5.2).
2
+
3
+ ```
4
+ run_task(task) -> run the rule's checks -> pass: done
5
+ -> fail: classify the failure (a FailureLabel)
6
+ infra: retry, no try used (max_infra_retries)
7
+ planning: stop, a person answers (needs_human)
8
+ other: the budget decides: retry with the
9
+ failures as feedback (and the escalated
10
+ tier), or stop and write HANDOFF
11
+ ```
12
+
13
+ The pieces are small and pure so an out-of-process runtime (whose `submit` step checks
14
+ one attempt at a time) can reuse them without this loop:
15
+
16
+ - `classify` / `FailureClassifier`: the `FailureLabel` of a failed attempt, from its
17
+ `CheckResult`s and `AgentResult`, through a data-driven `LabelMap`;
18
+ - `AttemptBudget`: counts tries (infra retries are counted apart), sums tokens and cost,
19
+ escalates the model tier after the first failed try, and says when and why it is
20
+ exhausted (`tries` | `tokens` | `cost` | `stagnation`);
21
+ - `render_feedback`: the bounded redo instruction for the next try;
22
+ - `FeedbackFilter`: what a role whose read policy denies some artifacts (the testbench
23
+ Author, `rtl`; the RTL Author, `tb`) is shown of a failed check: no issue in a denied
24
+ file, no denied path, no source excerpt, and a note when something was withheld (one
25
+ per denied kind, `withheld_note`). The loop below applies it
26
+ to the redo text of such a role (denying by the role's kinds and globs, and by the
27
+ task's denied paths when the executor is given `denied_reads`: the same list the
28
+ out-of-process runtime gives its guard, layout paths included);
29
+ - `attempt_checks`: the checks the engine adds itself (missing inputs, missing or empty
30
+ outputs, files written outside the outputs).
31
+
32
+ `AgentRuleExecutor` runs the loop over an `AgentRuntime`. It is a `CheckingExecutor`:
33
+ the scheduler hands it its check runner and journal, so each try's checks run once and
34
+ each runtime call is one `agent_turn` event. The number of runtime calls for one
35
+ instance is never more than `budget.tries + max_infra_retries`.
36
+
37
+ A stop that only a person can undo (the budget ran out, or a person must answer
38
+ questions) is remembered under the state directory with the instance's inputs hash:
39
+ a later run (or `resume`) reports the same stop without calling the runtime again,
40
+ until an input changes or the instance is rewound (`Scheduler.rewind`).
41
+
42
+ Generic: no model, harness, tool or project is named here.
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import hashlib
48
+ import json
49
+ import os
50
+ import re
51
+ from collections.abc import Callable, Iterable, Mapping, Sequence
52
+ from pathlib import Path
53
+ from typing import Any, Literal
54
+
55
+ from pydantic import BaseModel, ConfigDict, Field
56
+
57
+ from chipgraph.core.config.models import DecideCfg
58
+ from chipgraph.core.contracts import AgentResult, Budget, CheckResult, Issue, ModelTier
59
+ from chipgraph.core.contracts.rule import RuleInstance, RuleSpec
60
+ from chipgraph.core.contracts.types import FailureLabel
61
+ from chipgraph.core.engine.decide import (
62
+ DecisionLog,
63
+ Deferred,
64
+ ModelBackend,
65
+ Question,
66
+ decide,
67
+ )
68
+ from chipgraph.core.engine.graph import kind_for
69
+ from chipgraph.core.engine.scheduler import CheckRunner, ExecContext, ExecEventType, ExecOutcome
70
+ from chipgraph.core.plugin_api.protocols import AgentRuntime
71
+ from chipgraph.core.plugin_api.types import AgentTask
72
+ from chipgraph.core.runtime.executor import instance_inputs_hash, task_for
73
+ from chipgraph.core.runtime.roles import find_role, model_ladder, path_denied
74
+ from chipgraph.core.state.artifacts import ArtifactStore
75
+ from chipgraph.core.state.layout import StateLayout
76
+
77
+ DEFAULT_MAX_INFRA_RETRIES = 2
78
+ """Runtime calls an instance may repeat for infrastructure errors, on top of its tries."""
79
+
80
+ ExhaustReason = Literal["tries", "tokens", "cost", "stagnation"]
81
+ """Why an `AttemptBudget` is exhausted."""
82
+
83
+ StopReason = Literal["tries", "tokens", "cost", "stagnation", "infra", "needs_human", "planning"]
84
+ """Why the loop stopped without success: an `ExhaustReason`, or one of
85
+ `infra` (infra retries used up), `needs_human` (the agent asked), `planning` (the checks
86
+ show a problem in the spec or plan, which a person must fix)."""
87
+
88
+ STICKY_STOPS: frozenset[StopReason] = frozenset(
89
+ {"tries", "tokens", "cost", "stagnation", "needs_human", "planning"}
90
+ )
91
+ """Stops that hold until an input changes or the instance is rewound. An `infra` stop
92
+ does not: once the tool is fixed, `resume` tries again."""
93
+
94
+ # Synthetic check ids for the checks the engine adds to every attempt.
95
+ INPUTS_CHECK = "agent.inputs"
96
+ OUTPUTS_CHECK = "agent.outputs"
97
+ WRITES_CHECK = "agent.writes"
98
+ RUNTIME_CHECK = "agent.runtime"
99
+
100
+ _LABEL_PRECEDENCE: tuple[FailureLabel, ...] = ("planning", "context", "constraint", "verification")
101
+ """When failures carry different labels, the first of these wins: a spec problem is not
102
+ fixed by editing the outputs, a missing input not by following a naming rule, and so on."""
103
+
104
+
105
+ # --- the label map ----------------------------------------------------------------------
106
+
107
+
108
+ class LabelMap(BaseModel):
109
+ """Which `FailureLabel` a failing check stands for (data-driven, packs extend it).
110
+
111
+ `issues` maps an issue `rule` prefix and `checks` a check id prefix to a label. A
112
+ prefix matches an id equal to it or continuing with one of `. _ - / : [`; the
113
+ longest matching prefix wins, and an issue's rule wins over its check's id. An
114
+ `error` status is always `infra` (the check could not run). A failing check that
115
+ matches nothing counts as `verification`.
116
+ """
117
+
118
+ model_config = ConfigDict(extra="forbid", frozen=True)
119
+
120
+ schema_version: Literal[1] = Field(default=1, description="Schema version of this model.")
121
+ checks: dict[str, FailureLabel] = Field(
122
+ default_factory=dict, description="Check id prefix -> label."
123
+ )
124
+ issues: dict[str, FailureLabel] = Field(
125
+ default_factory=dict, description="Issue rule prefix -> label."
126
+ )
127
+
128
+ def merged(self, other: LabelMap) -> LabelMap:
129
+ """This map extended by `other`; `other` wins on the same prefix."""
130
+ return LabelMap(
131
+ checks={**self.checks, **other.checks}, issues={**self.issues, **other.issues}
132
+ )
133
+
134
+ def for_check(self, check_id: str) -> FailureLabel | None:
135
+ """The label for `check_id`, or `None` if no prefix matches."""
136
+ return _longest_match(self.checks, check_id)
137
+
138
+ def for_issue(self, rule: str) -> FailureLabel | None:
139
+ """The label for an issue `rule`, or `None` if no prefix matches."""
140
+ return _longest_match(self.issues, rule) if rule else None
141
+
142
+
143
+ _SEPARATORS = "._-/:["
144
+
145
+
146
+ def _longest_match(table: Mapping[str, FailureLabel], value: str) -> FailureLabel | None:
147
+ best: tuple[int, FailureLabel] | None = None
148
+ for prefix, label in table.items():
149
+ matches = value == prefix or (
150
+ value.startswith(prefix)
151
+ and len(value) > len(prefix)
152
+ and value[len(prefix)] in _SEPARATORS
153
+ )
154
+ if matches and (best is None or len(prefix) > best[0]):
155
+ best = (len(prefix), label)
156
+ return best[1] if best is not None else None
157
+
158
+
159
+ DEFAULT_LABEL_MAP = LabelMap(
160
+ checks={
161
+ # Project rules: where files go, how they are named and listed, what is generated.
162
+ "layout": "constraint",
163
+ "naming": "constraint",
164
+ "filelist": "constraint",
165
+ "generated": "constraint",
166
+ "hardcode": "constraint",
167
+ "duplicate": "constraint",
168
+ # Checks on the content itself.
169
+ "lint": "verification",
170
+ "sim": "verification",
171
+ "test": "verification",
172
+ "formal": "verification",
173
+ "synth": "verification",
174
+ # The engine's own checks (see `attempt_checks`).
175
+ INPUTS_CHECK: "context",
176
+ OUTPUTS_CHECK: "context",
177
+ WRITES_CHECK: "constraint",
178
+ },
179
+ issues={
180
+ "input.missing": "context",
181
+ "output.missing": "context",
182
+ "output.empty": "context",
183
+ "write.outside": "constraint",
184
+ "layout": "constraint",
185
+ "naming": "constraint",
186
+ "filelist": "constraint",
187
+ "generated": "constraint",
188
+ "spec.ambiguous": "planning",
189
+ "spec.conflict": "planning",
190
+ "req.conflict": "planning",
191
+ },
192
+ )
193
+ """The default `LabelMap`:
194
+
195
+ | matches (check id / issue rule prefix) | label |
196
+ |----------------------------------------------------------------|--------------|
197
+ | any check with status `error`, a runtime that raised | infra |
198
+ | issue `spec.ambiguous`, `spec.conflict`, `req.conflict` | planning |
199
+ | issue `input.missing`, `output.missing`, `output.empty`; | context |
200
+ | checks `agent.inputs`, `agent.outputs` | |
201
+ | issue `write.outside`, `layout`, `naming`, `filelist`, | constraint |
202
+ | `generated`; checks `layout`, `naming`, `filelist`, | |
203
+ | `generated`, `hardcode`, `duplicate`, `agent.writes` | |
204
+ | checks `lint`, `sim`, `test`, `formal`, `synth`; anything else | verification |
205
+ """
206
+
207
+ _LACKS_INPUT_RE = re.compile(
208
+ r"\b(missing|lack(?:s|ed|ing)?|not (?:found|provided|available|given)|unavailable|"
209
+ r"cannot (?:find|read|open)|no access)\b",
210
+ re.IGNORECASE,
211
+ )
212
+ """An agent that gave up (`failed`) and says it is missing something: `context`."""
213
+
214
+
215
+ # --- classify ---------------------------------------------------------------------------
216
+
217
+
218
+ def failing(results: Iterable[CheckResult]) -> tuple[CheckResult, ...]:
219
+ """The results that are not ok (`fail` or `error`)."""
220
+ return tuple(r for r in results if not r.ok)
221
+
222
+
223
+ def _result_label(result: CheckResult, label_map: LabelMap) -> FailureLabel | None:
224
+ if result.status == "error":
225
+ return "infra"
226
+ issue_labels = [
227
+ label
228
+ for issue in result.issues
229
+ if issue.severity == "error"
230
+ for label in (label_map.for_issue(issue.rule),)
231
+ if label is not None
232
+ ]
233
+ if issue_labels:
234
+ return _first_by_precedence(issue_labels)
235
+ return label_map.for_check(result.check_id)
236
+
237
+
238
+ def _first_by_precedence(labels: Iterable[FailureLabel]) -> FailureLabel:
239
+ found = set(labels)
240
+ for label in _LABEL_PRECEDENCE:
241
+ if label in found:
242
+ return label
243
+ return "infra" if "infra" in found else "verification"
244
+
245
+
246
+ def deterministic_label(
247
+ results: Sequence[CheckResult],
248
+ agent: AgentResult | None = None,
249
+ *,
250
+ label_map: LabelMap = DEFAULT_LABEL_MAP,
251
+ ) -> FailureLabel | None:
252
+ """The label the deterministic rules give a failed attempt, or `None` if none applies.
253
+
254
+ `agent` is the attempt's `AgentResult` (`None` when the runtime raised: `infra`).
255
+ Rules, in order: no result -> `infra`; `needs_human` -> `planning`; every failing
256
+ check errored -> `infra`; otherwise the failing checks' labels (an errored check
257
+ among failing ones is ignored: its retry would not fix the others), the first by
258
+ precedence (planning, context, constraint, verification); an agent that gave up and
259
+ says it lacks something -> `context`. `None` when nothing matched.
260
+ """
261
+ if agent is None:
262
+ return "infra"
263
+ if agent.status == "needs_human":
264
+ return "planning"
265
+ bad = failing(results)
266
+ labels: list[FailureLabel] = []
267
+ unmatched = False
268
+ if bad:
269
+ per_result = [_result_label(r, label_map) for r in bad]
270
+ if all(label == "infra" for label in per_result):
271
+ return "infra"
272
+ for label in per_result:
273
+ if label is None:
274
+ unmatched = True
275
+ elif label != "infra":
276
+ labels.append(label)
277
+ if agent.status == "failed" and any(
278
+ _LACKS_INPUT_RE.search(text) for text in (*agent.open_questions, *agent.assumptions)
279
+ ):
280
+ labels.append("context")
281
+ if not labels:
282
+ return None
283
+ if unmatched:
284
+ labels.append("verification")
285
+ return _first_by_precedence(labels)
286
+
287
+
288
+ def classify(
289
+ results: Sequence[CheckResult],
290
+ agent: AgentResult | None = None,
291
+ *,
292
+ label_map: LabelMap = DEFAULT_LABEL_MAP,
293
+ ) -> FailureLabel:
294
+ """The `FailureLabel` of a failed attempt, by the deterministic rules only.
295
+
296
+ See `deterministic_label`; when no rule applies the label is `verification`.
297
+ """
298
+ label = deterministic_label(results, agent, label_map=label_map)
299
+ return label if label is not None else "verification"
300
+
301
+
302
+ _MODEL_CHOICES: tuple[FailureLabel, ...] = ("context", "constraint", "verification", "planning")
303
+
304
+
305
+ class FailureClassifier:
306
+ """Classifies a failed attempt: deterministic rules first, then `decide()`.
307
+
308
+ With no `backend`, this is `classify`. With one, a failure no rule explains is put
309
+ to `decide()` as a multiple-choice question (`triage.agent.<hash>`) whose context
310
+ is the redo feedback; an answer that cannot be had (deferred, undecided, refused)
311
+ counts as `verification`. `infra` is never a model's answer: it is decided by the
312
+ check and runtime statuses alone.
313
+ """
314
+
315
+ def __init__(
316
+ self,
317
+ *,
318
+ label_map: LabelMap = DEFAULT_LABEL_MAP,
319
+ backend: ModelBackend | None = None,
320
+ log: DecisionLog | None = None,
321
+ cfg: DecideCfg | None = None,
322
+ ) -> None:
323
+ if backend is not None and log is None:
324
+ raise ValueError("a FailureClassifier with a model backend needs a DecisionLog")
325
+ self.label_map = label_map
326
+ self.backend = backend
327
+ self.log = log
328
+ self.cfg = cfg
329
+
330
+ async def classify(
331
+ self, results: Sequence[CheckResult], agent: AgentResult | None = None
332
+ ) -> FailureLabel:
333
+ """The `FailureLabel` of a failed attempt."""
334
+ label = deterministic_label(results, agent, label_map=self.label_map)
335
+ if label is not None:
336
+ return label
337
+ if self.backend is None or self.log is None:
338
+ return "verification"
339
+ context = render_feedback(results, "verification", max_chars=2000)
340
+ digest = hashlib.sha256(context.encode("utf-8")).hexdigest()[:16]
341
+ question = Question(
342
+ id=f"triage.agent.{digest}",
343
+ prompt=(
344
+ "An agent's attempt failed these checks. Is the cause missing context "
345
+ "(an input or output is missing), a broken project rule (constraint), "
346
+ "wrong content (verification), or a spec or plan a person must fix "
347
+ "(planning)?"
348
+ ),
349
+ choices=_MODEL_CHOICES,
350
+ context=context,
351
+ )
352
+
353
+ def no_rule(_: Question) -> None:
354
+ return None
355
+
356
+ try:
357
+ answer = await decide(
358
+ question, rules=(no_rule,), backend=self.backend, cfg=self.cfg, log=self.log
359
+ )
360
+ except Exception: # undecided, a refusing or failing backend: never stop the loop
361
+ return "verification"
362
+ if isinstance(answer, Deferred):
363
+ return "verification"
364
+ value = question.match(answer.value)
365
+ for choice in _MODEL_CHOICES:
366
+ if value == choice:
367
+ return choice
368
+ return "verification"
369
+
370
+
371
+ # --- the engine's own checks of an attempt ----------------------------------------------
372
+
373
+
374
+ def _synthetic(check_id: str, issues: Sequence[Issue]) -> CheckResult:
375
+ return CheckResult(
376
+ check_id=check_id,
377
+ status="fail" if issues else "pass",
378
+ issues=tuple(issues),
379
+ duration_s=0.0,
380
+ idempotency_key=f"{check_id}:"
381
+ + hashlib.sha256(
382
+ json.dumps([i.model_dump(mode="json") for i in issues], sort_keys=True).encode()
383
+ ).hexdigest(),
384
+ )
385
+
386
+
387
+ def attempt_checks(
388
+ instance: RuleInstance,
389
+ agent: AgentResult,
390
+ store: ArtifactStore,
391
+ *,
392
+ allowed_writes: Sequence[str],
393
+ ) -> tuple[CheckResult, ...]:
394
+ """The checks the engine runs on every attempt, before the rule's own.
395
+
396
+ - `agent.inputs`: an input file of the instance is missing (`input.missing`);
397
+ - `agent.outputs`: an output is missing (`output.missing`) or empty (`output.empty`);
398
+ - `agent.writes`: the agent reports writing a file outside `allowed_writes`
399
+ (`write.outside`).
400
+
401
+ Each returns `pass` when it found nothing, so the feedback can say what was fine.
402
+ """
403
+ input_issues = [
404
+ Issue(file=ref.path, rule="input.missing", msg=f"input {ref.path} does not exist")
405
+ for ref in instance.inputs
406
+ if ref.path is not None and not store.exists(ref)
407
+ ]
408
+ output_issues: list[Issue] = []
409
+ for ref in instance.outputs:
410
+ if ref.path is None:
411
+ continue
412
+ if not store.exists(ref):
413
+ output_issues.append(
414
+ Issue(
415
+ file=ref.path, rule="output.missing", msg=f"output {ref.path} was not written"
416
+ )
417
+ )
418
+ elif (store.root / ref.path).is_file() and (store.root / ref.path).stat().st_size == 0:
419
+ output_issues.append(
420
+ Issue(file=ref.path, rule="output.empty", msg=f"output {ref.path} is empty")
421
+ )
422
+ allowed = set(allowed_writes)
423
+ write_issues = [
424
+ Issue(
425
+ file=path,
426
+ rule="write.outside",
427
+ msg=f"{path} is not one of the outputs; write only: {', '.join(sorted(allowed))}",
428
+ )
429
+ for path in sorted(set(agent.files_written) - allowed)
430
+ ]
431
+ return (
432
+ _synthetic(INPUTS_CHECK, input_issues),
433
+ _synthetic(OUTPUTS_CHECK, output_issues),
434
+ _synthetic(WRITES_CHECK, write_issues),
435
+ )
436
+
437
+
438
+ def runtime_error_result(message: str) -> CheckResult:
439
+ """A `CheckResult` (status `error`) standing for a runtime call that raised."""
440
+ return CheckResult(
441
+ check_id=RUNTIME_CHECK,
442
+ status="error",
443
+ issues=(Issue(rule="runtime.error", msg=message),),
444
+ log_tail=message,
445
+ duration_s=0.0,
446
+ idempotency_key=f"{RUNTIME_CHECK}:error",
447
+ )
448
+
449
+
450
+ # --- feedback ---------------------------------------------------------------------------
451
+
452
+ _FIX_HINT: dict[FailureLabel, str] = {
453
+ "verification": "Fix the content so these checks pass; keep what already passes.",
454
+ "constraint": (
455
+ "Follow the project's rules (layout, naming, filelists, generated files) and "
456
+ "write only the allowed outputs."
457
+ ),
458
+ "context": (
459
+ "Write every output, non-empty. If an input you need is missing, do not guess: "
460
+ "say so in open_questions."
461
+ ),
462
+ "planning": (
463
+ "The checks point at the spec or plan, not at your work. Do not work around it: "
464
+ "list the questions a person must answer in open_questions."
465
+ ),
466
+ "infra": (
467
+ "The last attempt hit a tool or infrastructure error, not a problem in your work. "
468
+ "Do the same task again."
469
+ ),
470
+ }
471
+
472
+
473
+ def _issue_line(issue: Issue) -> str:
474
+ where = issue.file or ""
475
+ if where and issue.line is not None:
476
+ where = f"{where}:{issue.line}"
477
+ rule = f"[{issue.rule}] " if issue.rule else ""
478
+ prefix = f"{where}: " if where else ""
479
+ return f"{prefix}{rule}{issue.msg}".strip()
480
+
481
+
482
+ def _cut(text: str, limit: int) -> str:
483
+ return text if len(text) <= limit else text[: max(limit - 1, 0)] + "…"
484
+
485
+
486
+ def render_feedback(
487
+ results: Sequence[CheckResult],
488
+ label: FailureLabel,
489
+ *,
490
+ attempt: int | None = None,
491
+ max_issues: int = 20,
492
+ max_issues_per_check: int = 8,
493
+ max_log_chars: int = 600,
494
+ max_chars: int = 6000,
495
+ withheld: int = 0,
496
+ withheld_note: str = "",
497
+ ) -> str:
498
+ """The redo instruction for the next try: what failed, where, and what to fix.
499
+
500
+ Lists each failing check with up to `max_issues_per_check` `file:line` issues (at
501
+ most `max_issues` in all), the tail of its log when it has no error issue, the label and
502
+ a hint for it. Bounded: long logs and messages are cut, and the whole text is at
503
+ most `max_chars` characters. `withheld` (from `FeedbackFilter.apply`, over the
504
+ same `results`) adds `withheld_note` (`FeedbackFilter.note`; default
505
+ `WITHHELD_NOTE`), so the agent knows some output was hidden.
506
+ """
507
+ bad = failing(results)
508
+ head = f"Attempt {attempt} failed" if attempt is not None else "The last attempt failed"
509
+ lines = [
510
+ f"{head} ({label}): {len(bad)} check(s) did not pass." if bad else f"{head} ({label}).",
511
+ "",
512
+ ]
513
+ shown = 0
514
+ for result in bad:
515
+ lines.append(f"- check `{result.check_id}`: {result.status}")
516
+ errors = [i for i in result.issues if i.severity == "error"] or list(result.issues)
517
+ take = max(0, min(max_issues_per_check, max_issues - shown))
518
+ for issue in errors[:take]:
519
+ lines.append(f" - {_cut(_issue_line(issue), 300)}")
520
+ shown += min(take, len(errors))
521
+ if len(errors) > take:
522
+ lines.append(f" - … and {len(errors) - take} more issue(s)")
523
+ # No error issue explains the failure (none, or only warnings; or a filter
524
+ # withheld them): the log's tail is the explanation.
525
+ if not any(i.severity == "error" for i in errors) and result.log_tail.strip():
526
+ tail = result.log_tail.strip()[-max_log_chars:]
527
+ lines.append(" log tail:")
528
+ lines.extend(f" {line}" for line in tail.splitlines())
529
+ lines.extend(["", f"What to fix: {_FIX_HINT[label]}"])
530
+ note = f"\n\n{withheld_note or WITHHELD_NOTE}" if withheld else ""
531
+ text = "\n".join(lines)
532
+ if len(text) + len(note) > max_chars:
533
+ text = text[: max(max_chars - len(note) - 2, 0)] + "\n…"
534
+ return text + note
535
+
536
+
537
+ # --- feedback for a role that must not see some artifacts --------------------------------
538
+
539
+ WITHHELD_NOTE = (
540
+ "Some check output was withheld: it points into files your role must not see (the "
541
+ "design). A failure in the design was hidden; fix the test only if the spec says so, "
542
+ "otherwise report needs_human with the question."
543
+ )
544
+ """Added to the redo instruction when `FeedbackFilter.apply` withheld anything, for a role
545
+ that must not see the design (`rtl`)."""
546
+
547
+ TB_WITHHELD_NOTE = (
548
+ "Some check output was withheld: it points into files your role must not see (the "
549
+ "testbench). A failure in the testbench was hidden; the behaviour it expected is kept "
550
+ "above: fix the design so it meets that and the spec, or report needs_human with the "
551
+ "question if the spec does not say what is expected."
552
+ )
553
+ """The same note for a role that must not see the testbench (`tb`): the behavioural text
554
+ (`expected 0x2, got 0x0`) stays, the test's own lines do not."""
555
+
556
+
557
+ def withheld_note(kinds: Sequence[str]) -> str:
558
+ """The note for a role that must not see `kinds` (its first denied kind decides)."""
559
+ if kinds and kinds[0] == "tb":
560
+ return TB_WITHHELD_NOTE
561
+ if not kinds or kinds[0] == "rtl":
562
+ return WITHHELD_NOTE
563
+ return (
564
+ f"Some check output was withheld: it points into files your role must not see "
565
+ f"({kinds[0]} artifacts). Fix your outputs only where the visible failures and the "
566
+ "spec say so, otherwise report needs_human with the question."
567
+ )
568
+
569
+
570
+ _EXCERPT_RE = re.compile(r"^\s*(?:\d+\s*)?\|")
571
+ """A source excerpt line of a compiler message: a line-number gutter, ` 12 | ...`, or
572
+ the gutter of its caret line, ` | ^~~`."""
573
+ _CARET_RE = re.compile(r"^\s*[\^~]+[\s\^~]*$")
574
+ """A caret line with no gutter: only `^` and `~` under a quoted source line."""
575
+ _PATH_TOKEN_RE = re.compile(r"[^\s'\"`()\[\]{}<>,;:|=]+")
576
+ """A run of characters that may be a file path (paths are matched, then checked)."""
577
+
578
+
579
+ def _looks_like_path(token: str) -> bool:
580
+ return "/" in token or "." in token.strip(".")
581
+
582
+
583
+ class FeedbackFilter(BaseModel):
584
+ """What a role must not be shown of a failed check (DESIGN.md 5.1: the testbench
585
+ Author never sees the design, the RTL Author never sees the testbench).
586
+
587
+ A path is denied when it matches one of `denied` (repo-relative paths and globs, as
588
+ `path_denied` reads them; an absolute path under `root` is made relative first), or
589
+ when its file kind (`kind_for`, by suffix) is one of `kinds`. `apply` then
590
+
591
+ - drops every issue whose `file` is denied;
592
+ - replaces each denied path in an issue's message with `<kind>` (the first of
593
+ `kinds`, else `<withheld>`), and cuts message lines that are source excerpts
594
+ (a `NN |` gutter, a caret line);
595
+ - drops every line of the log tail that names a denied path or is a source excerpt;
596
+
597
+ and keeps the rest: issues in other files (the role's own outputs) and plain
598
+ behavioural text (`expected 0x2, got 0x0`).
599
+ """
600
+
601
+ model_config = ConfigDict(extra="forbid", frozen=True)
602
+
603
+ schema_version: Literal[1] = Field(default=1, description="Schema version of this model.")
604
+ denied: tuple[str, ...] = Field(
605
+ default=(), description="Repo-relative paths and globs the role must not see."
606
+ )
607
+ kinds: tuple[str, ...] = Field(
608
+ default=(), description="Artifact kinds the role must not see (matched by suffix)."
609
+ )
610
+ root: str | None = Field(
611
+ default=None, description="Absolute project root, to relativise absolute paths."
612
+ )
613
+
614
+ @classmethod
615
+ def for_role(
616
+ cls, role_id: str | None, *, denied: Iterable[str] = (), root: str | None = None
617
+ ) -> FeedbackFilter | None:
618
+ """The filter for a task of `role_id`, or `None` when its role may see everything.
619
+
620
+ `denied` is the task's own list (its `denied_reads`, when the runtime computed
621
+ one); the role's `deny_globs` are always added.
622
+ """
623
+ role = find_role(role_id) if role_id is not None else None
624
+ if role is None or role.read_policy.mode != "deny":
625
+ return None
626
+ policy = role.read_policy
627
+ return cls(
628
+ denied=tuple(dict.fromkeys((*denied, *policy.deny_globs))),
629
+ kinds=tuple(policy.deny_kinds),
630
+ root=root,
631
+ )
632
+
633
+ @property
634
+ def note(self) -> str:
635
+ """The note the redo text gets when something was withheld (`withheld_note`)."""
636
+ return withheld_note(self.kinds)
637
+
638
+ @property
639
+ def placeholder(self) -> str:
640
+ """What a denied path is replaced with in a message."""
641
+ return f"<{self.kinds[0]}>" if self.kinds else "<withheld>"
642
+
643
+ def denies(self, path: str | None) -> bool:
644
+ """Whether `path` (repo-relative or absolute, maybe with `:line`) is denied."""
645
+ if not path:
646
+ return False
647
+ text = path.strip().replace("\\", "/")
648
+ if self.root is not None:
649
+ root = self.root.rstrip("/") + "/"
650
+ if text.startswith(root):
651
+ text = text[len(root) :]
652
+ while text.startswith("./"):
653
+ text = text[2:]
654
+ if self.kinds and kind_for(text) in self.kinds:
655
+ return True
656
+ return not text.startswith("/") and path_denied(text, self.denied)
657
+
658
+ def _scrub(self, text: str, *, drop_named: bool) -> tuple[str, int]:
659
+ """`text` without excerpt lines and denied paths; and how much was withheld."""
660
+ kept: list[str] = []
661
+ withheld = 0
662
+ for line in text.splitlines():
663
+ if _EXCERPT_RE.match(line) or _CARET_RE.match(line):
664
+ withheld += 1
665
+ continue
666
+ named = [
667
+ m.group(0)
668
+ for m in _PATH_TOKEN_RE.finditer(line)
669
+ if _looks_like_path(m.group(0)) and self.denies(m.group(0))
670
+ ]
671
+ if named and drop_named:
672
+ withheld += 1
673
+ continue
674
+ for token in sorted(set(named), key=len, reverse=True):
675
+ line = line.replace(token, self.placeholder)
676
+ withheld += bool(named)
677
+ kept.append(line)
678
+ return "\n".join(kept), withheld
679
+
680
+ def apply(self, results: Iterable[CheckResult]) -> tuple[tuple[CheckResult, ...], int]:
681
+ """`results` as the role may see them, and the number of items withheld."""
682
+ shown: list[CheckResult] = []
683
+ withheld = 0
684
+ for result in results:
685
+ issues: list[Issue] = []
686
+ for issue in result.issues:
687
+ if self.denies(issue.file):
688
+ withheld += 1
689
+ continue
690
+ msg, cut = self._scrub(issue.msg, drop_named=False)
691
+ withheld += cut
692
+ if cut and not msg.strip(): # the message was all source excerpt
693
+ continue
694
+ issues.append(issue if msg == issue.msg else issue.model_copy(update={"msg": msg}))
695
+ tail, cut = self._scrub(result.log_tail, drop_named=True)
696
+ withheld += cut
697
+ shown.append(result.model_copy(update={"issues": tuple(issues), "log_tail": tail}))
698
+ return tuple(shown), withheld
699
+
700
+
701
+ def failure_signature(results: Iterable[CheckResult]) -> str:
702
+ """A hash of the failing checks and their issues: the same failures, the same hash."""
703
+ entries = sorted(
704
+ json.dumps(
705
+ {
706
+ "check": r.check_id,
707
+ "status": r.status,
708
+ "issues": sorted(
709
+ json.dumps([i.file, i.line, i.rule, i.msg], sort_keys=True) for i in r.issues
710
+ ),
711
+ },
712
+ sort_keys=True,
713
+ )
714
+ for r in failing(results)
715
+ )
716
+ return hashlib.sha256(json.dumps(entries).encode("utf-8")).hexdigest()
717
+
718
+
719
+ # --- the budget -------------------------------------------------------------------------
720
+
721
+
722
+ class BudgetState(BaseModel):
723
+ """What an `AttemptBudget` has counted so far (persistable, e.g. by a task queue)."""
724
+
725
+ model_config = ConfigDict(extra="forbid", frozen=True)
726
+
727
+ schema_version: Literal[1] = Field(default=1, description="Schema version of this model.")
728
+ tries_used: int = Field(default=0, ge=0, description="Tries used (failed or passed).")
729
+ failed_tries: int = Field(default=0, ge=0, description="Tries that failed.")
730
+ infra_failures: int = Field(
731
+ default=0, ge=0, description="Calls that failed on infra (not tries)."
732
+ )
733
+ tokens: int = Field(default=0, ge=0, description="Tokens reported over every call.")
734
+ cost: float = Field(default=0.0, ge=0, description="Cost reported over every call, USD.")
735
+ tokens_reported: bool = Field(default=False, description="Whether any call reported tokens.")
736
+ cost_reported: bool = Field(default=False, description="Whether any call reported a cost.")
737
+ last_signature: str | None = Field(
738
+ default=None, description="Output hashes and failures of the last failed try."
739
+ )
740
+ stagnant: bool = Field(
741
+ default=False, description="The last two failed tries had the same signature."
742
+ )
743
+
744
+
745
+ class AttemptBudget:
746
+ """Counts the tries of one agent rule instance against its `Budget`.
747
+
748
+ - A try is one runtime call whose result was checked (passed or failed); an infra
749
+ retry is counted apart (`record_infra`) and is not a try.
750
+ - `spend` adds a call's reported `tokens`/`cost`; `Budget.tokens` and `max_cost`
751
+ cap them.
752
+ - `tier` is the first tier until a try failed, then `escalate` if it is set.
753
+ - Stagnation: two failed tries in a row with the same output hashes and the same
754
+ failures exhaust the budget early (when `stagnation` is on).
755
+ """
756
+
757
+ def __init__(
758
+ self,
759
+ budget: Budget,
760
+ *,
761
+ tier: ModelTier | None = None,
762
+ escalate: ModelTier | None = None,
763
+ max_cost: float | None = None,
764
+ stagnation: bool = True,
765
+ state: BudgetState | None = None,
766
+ ) -> None:
767
+ self.budget = budget
768
+ self.first_tier: ModelTier = tier if tier is not None else budget.tier
769
+ self.escalate: ModelTier | None = escalate if tier is not None else budget.escalate
770
+ self.max_cost = max_cost
771
+ self.stagnation = stagnation
772
+ self.state = state if state is not None else BudgetState()
773
+
774
+ @classmethod
775
+ def for_rule(
776
+ cls, rule: RuleSpec, *, max_cost: float | None = None, stagnation: bool = True
777
+ ) -> AttemptBudget:
778
+ """The budget of an agent rule, with the model tiers of its role (`model_ladder`)."""
779
+ role = find_role(rule.role) if rule.role is not None else None
780
+ tier, escalate = model_ladder(role, rule.budget)
781
+ return cls(
782
+ rule.budget, tier=tier, escalate=escalate, max_cost=max_cost, stagnation=stagnation
783
+ )
784
+
785
+ @property
786
+ def tries(self) -> int:
787
+ """The tries the budget allows."""
788
+ return self.budget.tries
789
+
790
+ @property
791
+ def remaining(self) -> int:
792
+ """Tries left."""
793
+ return max(self.budget.tries - self.state.tries_used, 0)
794
+
795
+ @property
796
+ def tier(self) -> ModelTier:
797
+ """The model tier for the next try."""
798
+ if self.state.failed_tries >= 1 and self.escalate is not None:
799
+ return self.escalate
800
+ return self.first_tier
801
+
802
+ def task_budget(self) -> Budget:
803
+ """The `Budget` to hand the runtime for the next try, at the current tier."""
804
+ tier = self.tier
805
+ escalate = self.escalate if self.escalate is not None and tier != self.escalate else None
806
+ return Budget(
807
+ tries=self.budget.tries, tier=tier, escalate=escalate, tokens=self.budget.tokens
808
+ )
809
+
810
+ def spend(self, result: AgentResult | None) -> None:
811
+ """Add the tokens and cost one runtime call reported (any call, retry or not)."""
812
+ if result is None:
813
+ return
814
+ update: dict[str, Any] = {}
815
+ if result.tokens is not None:
816
+ update["tokens"] = self.state.tokens + result.tokens
817
+ update["tokens_reported"] = True
818
+ if result.cost is not None:
819
+ update["cost"] = self.state.cost + result.cost
820
+ update["cost_reported"] = True
821
+ if update:
822
+ self.state = self.state.model_copy(update=update)
823
+
824
+ def record_pass(self) -> None:
825
+ """Count a try that passed."""
826
+ self.state = self.state.model_copy(update={"tries_used": self.state.tries_used + 1})
827
+
828
+ def record_failure(
829
+ self, results: Iterable[CheckResult], output_hashes: Mapping[str, str]
830
+ ) -> None:
831
+ """Count a failed try, with the outputs it left and the checks it failed."""
832
+ signature = hashlib.sha256(
833
+ json.dumps(
834
+ {
835
+ "outputs": dict(sorted(output_hashes.items())),
836
+ "failures": failure_signature(results),
837
+ },
838
+ sort_keys=True,
839
+ ).encode("utf-8")
840
+ ).hexdigest()
841
+ stagnant = self.state.last_signature == signature
842
+ self.state = self.state.model_copy(
843
+ update={
844
+ "tries_used": self.state.tries_used + 1,
845
+ "failed_tries": self.state.failed_tries + 1,
846
+ "last_signature": signature,
847
+ "stagnant": stagnant,
848
+ }
849
+ )
850
+
851
+ def record_infra(self) -> None:
852
+ """Count a call that failed on infra (not a try)."""
853
+ self.state = self.state.model_copy(update={"infra_failures": self.state.infra_failures + 1})
854
+
855
+ @property
856
+ def over_spend(self) -> ExhaustReason | None:
857
+ """`tokens` or `cost` when a cap is reached, else `None`."""
858
+ if self.budget.tokens is not None and self.state.tokens >= self.budget.tokens:
859
+ return "tokens"
860
+ if self.max_cost is not None and self.state.cost >= self.max_cost:
861
+ return "cost"
862
+ return None
863
+
864
+ @property
865
+ def exhausted(self) -> ExhaustReason | None:
866
+ """Why no further try may run, or `None` while one may."""
867
+ spent = self.over_spend
868
+ if spent is not None:
869
+ return spent
870
+ if self.stagnation and self.state.stagnant:
871
+ return "stagnation"
872
+ if self.state.tries_used >= self.budget.tries:
873
+ return "tries"
874
+ return None
875
+
876
+
877
+ # --- remembered stops -------------------------------------------------------------------
878
+
879
+
880
+ class AgentStop(BaseModel):
881
+ """An agent rule instance that stopped and must not run again by itself."""
882
+
883
+ model_config = ConfigDict(extra="forbid", frozen=True)
884
+
885
+ schema_version: Literal[1] = Field(default=1, description="Schema version of this model.")
886
+ instance_id: str = Field(description="The rule instance that stopped.")
887
+ inputs_hash: str = Field(description="Hash of the instance's inputs when it stopped.")
888
+ reason: StopReason = Field(description="Why it stopped.")
889
+ label: FailureLabel = Field(description="The label of the last failure.")
890
+ message: str = Field(description="The rule_fail message.")
891
+ summary: dict[str, Any] = Field(description="The `agent` payload of the stop.")
892
+ result: AgentResult = Field(description="The final AgentResult.")
893
+
894
+
895
+ class StopStore:
896
+ """Remembered stops, one JSON file per instance under `<state>/agent_rules/`."""
897
+
898
+ def __init__(self, layout: StateLayout) -> None:
899
+ self._dir = layout.state_dir / "agent_rules"
900
+
901
+ def _path(self, instance_id: str) -> Path:
902
+ return self._dir / f"{hashlib.sha256(instance_id.encode('utf-8')).hexdigest()}.json"
903
+
904
+ def get(self, instance_id: str) -> AgentStop | None:
905
+ """The remembered stop of `instance_id`, if any."""
906
+ path = self._path(instance_id)
907
+ if not path.is_file():
908
+ return None
909
+ return AgentStop.model_validate_json(path.read_text(encoding="utf-8"))
910
+
911
+ def put(self, stop: AgentStop) -> None:
912
+ """Remember `stop` (atomic write)."""
913
+ self._dir.mkdir(parents=True, exist_ok=True)
914
+ path = self._path(stop.instance_id)
915
+ tmp = path.with_suffix(".json.tmp")
916
+ tmp.write_text(stop.model_dump_json(indent=2), encoding="utf-8")
917
+ os.replace(tmp, path)
918
+
919
+ def delete(self, instance_id: str) -> None:
920
+ """Forget the stop of `instance_id` (a no-op if none)."""
921
+ self._path(instance_id).unlink(missing_ok=True)
922
+
923
+
924
+ # --- the executor -----------------------------------------------------------------------
925
+
926
+
927
+ def _noop_emit(_type: ExecEventType, _payload: dict[str, Any]) -> None:
928
+ return None
929
+
930
+
931
+ AgentStatus = Literal["done", "failed", "needs_human", "budget_exhausted"]
932
+
933
+
934
+ class _InstanceRun:
935
+ """The state of the loop for one instance: budget, merged results, last failures."""
936
+
937
+ def __init__(
938
+ self,
939
+ executor: AgentRuleExecutor,
940
+ rule: RuleSpec,
941
+ instance: RuleInstance,
942
+ base: AgentTask,
943
+ ctx: ExecContext,
944
+ inputs_hash: str,
945
+ ) -> None:
946
+ self.executor = executor
947
+ self.rule = rule
948
+ self.instance = instance
949
+ self.iid = instance.instance_id
950
+ self.base = base
951
+ self.ctx = ctx
952
+ self.inputs_hash = inputs_hash
953
+ self.budget = AttemptBudget.for_rule(
954
+ rule, max_cost=executor.max_cost, stagnation=executor.stagnation
955
+ )
956
+ self.calls = 0
957
+ self.assumptions: list[str] = []
958
+ self.questions: list[str] = []
959
+ self.files: tuple[str, ...] = ()
960
+ self.feedback: str | None = None
961
+ self.previous: list[str] = []
962
+ self.infra_note: str | None = None
963
+ self.last_label: FailureLabel = "infra"
964
+ self.last_results: tuple[CheckResult, ...] = ()
965
+ denied = executor.denied_reads(rule, instance) if executor.denied_reads else ()
966
+ self.shown = FeedbackFilter.for_role(
967
+ rule.role, denied=denied, root=str(executor.store.root.resolve())
968
+ )
969
+
970
+ def redo(self, results: Sequence[CheckResult], label: FailureLabel, **kw: Any) -> str:
971
+ """`render_feedback` over what the role may see (`FeedbackFilter`)."""
972
+ if self.shown is None:
973
+ return render_feedback(results, label, **kw)
974
+ visible, withheld = self.shown.apply(results)
975
+ return render_feedback(
976
+ visible, label, withheld=withheld, withheld_note=self.shown.note, **kw
977
+ )
978
+
979
+ # --- one call ----------------------------------------------------------------
980
+
981
+ def task(self) -> AgentTask:
982
+ """The task for the next runtime call: feedback, try number and current tier."""
983
+ context = dict(self.base.context)
984
+ context["try"] = str(self.budget.state.tries_used + 1)
985
+ context["tier"] = self.budget.tier
986
+ if self.feedback is not None:
987
+ context["feedback"] = self.feedback
988
+ context["previous_failures"] = json.dumps(self.previous)
989
+ if self.infra_note is not None:
990
+ context["infra_retry"] = self.infra_note
991
+ return self.base.model_copy(
992
+ update={"context": context, "budget": self.budget.task_budget()}
993
+ )
994
+
995
+ def merge(self, result: AgentResult) -> None:
996
+ for text in result.assumptions:
997
+ if text not in self.assumptions:
998
+ self.assumptions.append(text)
999
+ for text in result.open_questions:
1000
+ if text not in self.questions:
1001
+ self.questions.append(text)
1002
+ self.files = result.files_written
1003
+
1004
+ async def check(self, agent: AgentResult) -> tuple[CheckResult, ...]:
1005
+ """The engine's own checks of the attempt, then the rule's checks (each once)."""
1006
+ results = attempt_checks(
1007
+ self.instance, agent, self.executor.store, allowed_writes=self.base.allowed_writes
1008
+ )
1009
+ if self.ctx.run_check is not None:
1010
+ for check_id in self.rule.checks:
1011
+ results += (await self.ctx.run_check(check_id),)
1012
+ return results
1013
+
1014
+ def turn(
1015
+ self,
1016
+ *,
1017
+ try_no: int,
1018
+ tier: ModelTier,
1019
+ agent: AgentResult | None,
1020
+ label: FailureLabel | None,
1021
+ counted: bool,
1022
+ results: Sequence[CheckResult],
1023
+ ) -> None:
1024
+ """Journal one runtime call as an `agent_turn` event."""
1025
+ payload: dict[str, Any] = {
1026
+ "phase": "try",
1027
+ "call": self.calls,
1028
+ "try": try_no,
1029
+ "counted": counted,
1030
+ "tier": tier,
1031
+ "status": agent.status if agent is not None else "error",
1032
+ "label": label,
1033
+ "failed_checks": [r.check_id for r in failing(results)],
1034
+ "tokens": agent.tokens if agent is not None else None,
1035
+ "cost": agent.cost if agent is not None else None,
1036
+ }
1037
+ if agent is not None and agent.open_questions:
1038
+ payload["open_questions"] = list(agent.open_questions)
1039
+ self.ctx.emit("agent_turn", payload)
1040
+
1041
+ # --- results -------------------------------------------------------------------
1042
+
1043
+ def summary(
1044
+ self, status: AgentStatus, reason: StopReason | None, label: FailureLabel | None
1045
+ ) -> dict[str, Any]:
1046
+ """The `agent` payload of the instance's final `rule_done`/`rule_fail` event."""
1047
+ state = self.budget.state
1048
+ bad = failing(self.last_results) if status != "done" else ()
1049
+ return {
1050
+ "instance": self.iid,
1051
+ "status": status,
1052
+ "tries": state.tries_used,
1053
+ "max_tries": self.budget.tries,
1054
+ "infra_failures": state.infra_failures,
1055
+ "calls": self.calls,
1056
+ "reason": reason,
1057
+ "label": label,
1058
+ "tier": self.budget.tier,
1059
+ "failed_checks": [r.check_id for r in bad],
1060
+ "failures": [_issue_summary(r) for r in bad][:10],
1061
+ "tokens": state.tokens if state.tokens_reported else None,
1062
+ "cost": state.cost if state.cost_reported else None,
1063
+ }
1064
+
1065
+ def final(self, status: AgentStatus, questions: Sequence[str] = ()) -> AgentResult:
1066
+ """The instance's `AgentResult`: merged over every call, tokens and cost summed."""
1067
+ state = self.budget.state
1068
+ open_questions = list(self.questions)
1069
+ open_questions.extend(q for q in questions if q not in open_questions)
1070
+ return AgentResult(
1071
+ status=status,
1072
+ files_written=self.files,
1073
+ assumptions=tuple(self.assumptions),
1074
+ open_questions=tuple(open_questions),
1075
+ tokens=state.tokens if state.tokens_reported else None,
1076
+ cost=state.cost if state.cost_reported else None,
1077
+ )
1078
+
1079
+ def done(self) -> ExecOutcome:
1080
+ result = self.final("done")
1081
+ self.executor.results[self.iid] = result
1082
+ if self.executor.stops is not None:
1083
+ self.executor.stops.delete(self.iid)
1084
+ return ExecOutcome(
1085
+ ok=True,
1086
+ payload={
1087
+ "agent": self.summary("done", None, None),
1088
+ "result": result.model_dump(mode="json"),
1089
+ },
1090
+ )
1091
+
1092
+ def stop(
1093
+ self, reason: StopReason, label: FailureLabel, *, questions: Sequence[str] = ()
1094
+ ) -> ExecOutcome:
1095
+ status: AgentStatus
1096
+ if reason in ("needs_human", "planning"):
1097
+ status = "needs_human"
1098
+ elif reason == "infra":
1099
+ status = "failed"
1100
+ else:
1101
+ status = "budget_exhausted"
1102
+ result = self.final(status, questions)
1103
+ info = self.summary(status, reason, label)
1104
+ message = _stop_message(info)
1105
+ self.executor.results[self.iid] = result
1106
+ if self.executor.stops is not None and reason in STICKY_STOPS:
1107
+ self.executor.stops.put(
1108
+ AgentStop(
1109
+ instance_id=self.iid,
1110
+ inputs_hash=self.inputs_hash,
1111
+ reason=reason,
1112
+ label=label,
1113
+ message=message,
1114
+ summary=info,
1115
+ result=result,
1116
+ )
1117
+ )
1118
+ return _stop_outcome(label, message, info, result, again=False)
1119
+
1120
+ # --- the loop --------------------------------------------------------------------
1121
+
1122
+ async def run(self) -> ExecOutcome:
1123
+ max_calls = self.executor.max_calls(self.rule)
1124
+ while self.calls < max_calls: # the hard bound: tries + max_infra_retries
1125
+ outcome = await self.step()
1126
+ if outcome is not None:
1127
+ return outcome
1128
+ # Unreachable while the counters are consistent (every call records a try or
1129
+ # an infra retry, and either one stops the loop at its limit); kept so the
1130
+ # loop can never run past its bound.
1131
+ return self.stop("tries", self.last_label)
1132
+
1133
+ async def step(self) -> ExecOutcome | None:
1134
+ """One runtime call and its checks; an outcome when the loop ends, else `None`."""
1135
+ self.calls += 1
1136
+ try_no = self.budget.state.tries_used + 1
1137
+ tier = self.budget.tier
1138
+ task = self.task()
1139
+ agent: AgentResult | None = None
1140
+ error = ""
1141
+ try:
1142
+ agent = await self.executor.runtime.run_task(task)
1143
+ except Exception as exc: # a runtime failure not caused by the work: infra
1144
+ error = f"{type(exc).__name__}: {exc}"
1145
+ self.budget.spend(agent)
1146
+ if agent is not None:
1147
+ self.merge(agent)
1148
+
1149
+ if agent is not None and agent.status == "needs_human":
1150
+ self.turn(
1151
+ try_no=try_no, tier=tier, agent=agent, label="planning", counted=False, results=()
1152
+ )
1153
+ self.last_label = "planning"
1154
+ return self.stop("needs_human", "planning")
1155
+
1156
+ results = (runtime_error_result(error),) if agent is None else await self.check(agent)
1157
+ if agent is not None and agent.status == "done" and not failing(results):
1158
+ self.budget.record_pass()
1159
+ self.turn(
1160
+ try_no=try_no, tier=tier, agent=agent, label=None, counted=True, results=results
1161
+ )
1162
+ return self.done()
1163
+
1164
+ label = await self.executor.classifier.classify(results, agent)
1165
+ self.last_label = label
1166
+ self.last_results = results
1167
+
1168
+ if label == "infra":
1169
+ self.budget.record_infra()
1170
+ self.turn(
1171
+ try_no=try_no, tier=tier, agent=agent, label=label, counted=False, results=results
1172
+ )
1173
+ spent = self.budget.over_spend
1174
+ if spent is not None:
1175
+ return self.stop(spent, label)
1176
+ if self.budget.state.infra_failures > self.executor.max_infra_retries:
1177
+ return self.stop("infra", label)
1178
+ self.infra_note = self.redo(results, "infra", max_chars=1500)
1179
+ return None
1180
+
1181
+ self.infra_note = None
1182
+ self.budget.record_failure(
1183
+ results, self.executor.store.current_hashes(self.instance.outputs)
1184
+ )
1185
+ self.turn(try_no=try_no, tier=tier, agent=agent, label=label, counted=True, results=results)
1186
+ if label == "planning":
1187
+ questions = [
1188
+ _issue_line(issue)
1189
+ for r in failing(results)
1190
+ for issue in r.issues
1191
+ if issue.severity == "error"
1192
+ ][:10] or ["the checks point at the spec or plan; a person must decide"]
1193
+ return self.stop("planning", label, questions=questions)
1194
+ reason = self.budget.exhausted
1195
+ if reason is not None:
1196
+ return self.stop(reason, label)
1197
+ self.feedback = self.redo(results, label, attempt=self.budget.state.tries_used)
1198
+ self.previous = [r.check_id for r in failing(results)]
1199
+ return None
1200
+
1201
+
1202
+ class AgentRuleExecutor:
1203
+ """Runs `kind: agent` rules through an `AgentRuntime`, in the bounded loop above.
1204
+
1205
+ `checks` is only used by `execute` (outside a scheduler); under a scheduler the
1206
+ `ExecContext`'s check runner is used, so each check runs once per try. `layout`
1207
+ enables remembered stops (see the module docstring); without it, every run starts
1208
+ with a fresh budget. `max_cost` caps the summed `AgentResult.cost` (USD). `results`
1209
+ holds the last `AgentResult` of each instance. `denied_reads(rule, instance)` gives
1210
+ the paths and globs the instance's role must not read (the project's layout paths
1211
+ among them), which the redo text of a denying role is filtered by.
1212
+ """
1213
+
1214
+ def __init__(
1215
+ self,
1216
+ runtime: AgentRuntime,
1217
+ *,
1218
+ store: ArtifactStore,
1219
+ layout: StateLayout | None = None,
1220
+ classifier: FailureClassifier | None = None,
1221
+ max_infra_retries: int = DEFAULT_MAX_INFRA_RETRIES,
1222
+ max_cost: float | None = None,
1223
+ stagnation: bool = True,
1224
+ checks: CheckRunner | None = None,
1225
+ denied_reads: Callable[[RuleSpec, RuleInstance], Iterable[str]] | None = None,
1226
+ ) -> None:
1227
+ if max_infra_retries < 0:
1228
+ raise ValueError("max_infra_retries must be >= 0")
1229
+ self.runtime = runtime
1230
+ self.store = store
1231
+ self.stops = StopStore(layout) if layout is not None else None
1232
+ self.classifier = classifier if classifier is not None else FailureClassifier()
1233
+ self.max_infra_retries = max_infra_retries
1234
+ self.max_cost = max_cost
1235
+ self.stagnation = stagnation
1236
+ self.checks = checks
1237
+ self.denied_reads = denied_reads
1238
+ self.results: dict[str, AgentResult] = {}
1239
+
1240
+ def max_calls(self, rule: RuleSpec) -> int:
1241
+ """The most runtime calls one instance of `rule` can make: tries + infra retries."""
1242
+ return rule.budget.tries + self.max_infra_retries
1243
+
1244
+ def forget(self, instance_id: str) -> None:
1245
+ """Forget a remembered stop (`Scheduler.rewind` calls this)."""
1246
+ if self.stops is not None:
1247
+ self.stops.delete(instance_id)
1248
+
1249
+ async def execute(self, rule: RuleSpec, instance: RuleInstance) -> ExecOutcome:
1250
+ """Run the loop outside a scheduler, with `self.checks` (if any) and no journal."""
1251
+ runner = self.checks
1252
+
1253
+ async def run_check(check_id: str) -> CheckResult:
1254
+ assert runner is not None
1255
+ return await runner.run(check_id, instance)
1256
+
1257
+ ctx = ExecContext(
1258
+ run_id="", run_check=run_check if runner is not None else None, emit=_noop_emit
1259
+ )
1260
+ return await self.execute_checked(rule, instance, ctx)
1261
+
1262
+ async def execute_checked(
1263
+ self, rule: RuleSpec, instance: RuleInstance, ctx: ExecContext
1264
+ ) -> ExecOutcome:
1265
+ """Run the loop for one instance, checking each try through `ctx`."""
1266
+ iid = instance.instance_id
1267
+ try:
1268
+ base = task_for(rule, instance)
1269
+ except ValueError as exc:
1270
+ return ExecOutcome(ok=False, failure_label="planning", message=str(exc))
1271
+
1272
+ inputs_hash = instance_inputs_hash(self.store, instance)
1273
+ if self.stops is not None:
1274
+ stop = self.stops.get(iid)
1275
+ if stop is not None and stop.inputs_hash == inputs_hash:
1276
+ self.results[iid] = stop.result
1277
+ return _stop_outcome(
1278
+ stop.label, stop.message, stop.summary, stop.result, again=True
1279
+ )
1280
+ if stop is not None: # an input changed: a fresh budget
1281
+ self.stops.delete(iid)
1282
+
1283
+ return await _InstanceRun(self, rule, instance, base, ctx, inputs_hash).run()
1284
+
1285
+
1286
+ def _stop_outcome(
1287
+ label: FailureLabel,
1288
+ message: str,
1289
+ info: Mapping[str, Any],
1290
+ result: AgentResult,
1291
+ *,
1292
+ again: bool,
1293
+ ) -> ExecOutcome:
1294
+ payload: dict[str, Any] = {
1295
+ "agent": {**info, "stopped_earlier": again},
1296
+ "result": result.model_dump(mode="json"),
1297
+ }
1298
+ if result.open_questions:
1299
+ payload["open_questions"] = list(result.open_questions)
1300
+ if again:
1301
+ message = (
1302
+ f"{message} (stopped in an earlier run; change an input or run "
1303
+ f"`chipgraph rewind {info.get('instance', '')}` to try again)"
1304
+ )
1305
+ return ExecOutcome(ok=False, failure_label=label, message=message, payload=payload)
1306
+
1307
+
1308
+ def _issue_summary(result: CheckResult) -> str:
1309
+ issues = [i for i in result.issues if i.severity == "error"] or list(result.issues)
1310
+ first = _cut(_issue_line(issues[0]), 200) if issues else _cut(result.log_tail.strip(), 200)
1311
+ more = f" (+{len(issues) - 1} more)" if len(issues) > 1 else ""
1312
+ return f"{result.check_id}: {result.status}" + (f": {first}{more}" if first else "")
1313
+
1314
+
1315
+ def _stop_message(info: Mapping[str, Any]) -> str:
1316
+ reason = info["reason"]
1317
+ tries = f"{info['tries']} of {info['max_tries']} tries"
1318
+ infra = info["infra_failures"]
1319
+ if infra:
1320
+ tries += f", {infra} infra failure{'' if infra == 1 else 's'}"
1321
+ failed = ", ".join(info["failed_checks"]) or "none"
1322
+ head = {
1323
+ "tries": "budget exhausted (tries)",
1324
+ "tokens": "budget exhausted (token cap)",
1325
+ "cost": "budget exhausted (cost cap)",
1326
+ "stagnation": "stopped early: two tries in a row left the same outputs and failures",
1327
+ "infra": "stopped: infrastructure errors persisted",
1328
+ "needs_human": "stopped: the agent needs a person to answer open questions",
1329
+ "planning": "stopped: the checks point at the spec or plan",
1330
+ }[reason]
1331
+ return f"agent {head} after {tries}; last failures [{info['label']}]: {failed}"
1332
+
1333
+
1334
+ __all__ = [
1335
+ "DEFAULT_LABEL_MAP",
1336
+ "DEFAULT_MAX_INFRA_RETRIES",
1337
+ "INPUTS_CHECK",
1338
+ "OUTPUTS_CHECK",
1339
+ "RUNTIME_CHECK",
1340
+ "STICKY_STOPS",
1341
+ "TB_WITHHELD_NOTE",
1342
+ "WITHHELD_NOTE",
1343
+ "WRITES_CHECK",
1344
+ "AgentRuleExecutor",
1345
+ "AgentStop",
1346
+ "AttemptBudget",
1347
+ "BudgetState",
1348
+ "ExhaustReason",
1349
+ "FailureClassifier",
1350
+ "FeedbackFilter",
1351
+ "LabelMap",
1352
+ "StopReason",
1353
+ "StopStore",
1354
+ "attempt_checks",
1355
+ "classify",
1356
+ "deterministic_label",
1357
+ "failing",
1358
+ "failure_signature",
1359
+ "render_feedback",
1360
+ "runtime_error_result",
1361
+ "withheld_note",
1362
+ ]