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.
- chipgraph/__init__.py +7 -0
- chipgraph/adapters/__init__.py +1 -0
- chipgraph/adapters/format/__init__.py +6 -0
- chipgraph/adapters/format/__main__.py +16 -0
- chipgraph/adapters/format/_yaml_lines.py +54 -0
- chipgraph/adapters/format/chip_yaml.py +323 -0
- chipgraph/adapters/format/qsoc_contract.py +246 -0
- chipgraph/adapters/llm/__init__.py +58 -0
- chipgraph/adapters/llm/_budget.py +173 -0
- chipgraph/adapters/llm/_errors.py +50 -0
- chipgraph/adapters/llm/_nda.py +30 -0
- chipgraph/adapters/llm/_retry.py +85 -0
- chipgraph/adapters/llm/anthropic.py +247 -0
- chipgraph/adapters/llm/decide_backend.py +179 -0
- chipgraph/adapters/llm/fake.py +87 -0
- chipgraph/adapters/llm/guard.py +68 -0
- chipgraph/adapters/parser/__init__.py +22 -0
- chipgraph/adapters/parser/cocotb.py +304 -0
- chipgraph/adapters/parser/generic_regex.py +75 -0
- chipgraph/adapters/parser/verible.py +55 -0
- chipgraph/adapters/parser/verilator.py +46 -0
- chipgraph/adapters/review/__init__.py +7 -0
- chipgraph/adapters/review/file.py +123 -0
- chipgraph/adapters/review/github.py +886 -0
- chipgraph/adapters/runner/__init__.py +7 -0
- chipgraph/adapters/runner/local.py +122 -0
- chipgraph/adapters/runtime/__init__.py +5 -0
- chipgraph/adapters/runtime/claude_code/__init__.py +24 -0
- chipgraph/adapters/runtime/claude_code/agents.py +158 -0
- chipgraph/adapters/runtime/claude_code/decisions.py +280 -0
- chipgraph/adapters/runtime/claude_code/guarded.py +310 -0
- chipgraph/adapters/runtime/claude_code/isolation.py +482 -0
- chipgraph/adapters/runtime/claude_code/loop.py +342 -0
- chipgraph/adapters/runtime/claude_code/previous.py +419 -0
- chipgraph/adapters/runtime/claude_code/review.py +492 -0
- chipgraph/adapters/runtime/claude_code/runtime.py +192 -0
- chipgraph/adapters/runtime/claude_code/service.py +2285 -0
- chipgraph/adapters/tool/__init__.py +7 -0
- chipgraph/adapters/tool/cmd.py +229 -0
- chipgraph/adapters/tool/edalize.py +773 -0
- chipgraph/adapters/tool/filelist.py +259 -0
- chipgraph/adapters/tool/pyslang.py +485 -0
- chipgraph/adapters/vcs/__init__.py +1 -0
- chipgraph/adapters/vcs/git.py +521 -0
- chipgraph/app/__init__.py +10 -0
- chipgraph/app/baseline.py +339 -0
- chipgraph/app/build.py +332 -0
- chipgraph/app/checks.py +202 -0
- chipgraph/app/context.py +191 -0
- chipgraph/app/errors.py +11 -0
- chipgraph/app/executors.py +79 -0
- chipgraph/app/findings.py +203 -0
- chipgraph/app/ingest.py +523 -0
- chipgraph/checks/__init__.py +52 -0
- chipgraph/checks/_cdc_analyze.py +272 -0
- chipgraph/checks/_cdc_elaborate.py +455 -0
- chipgraph/checks/_common.py +268 -0
- chipgraph/checks/_cross.py +96 -0
- chipgraph/checks/_model.py +64 -0
- chipgraph/checks/_naming_pyslang.py +235 -0
- chipgraph/checks/_naming_rules.py +127 -0
- chipgraph/checks/_xref_syntax.py +260 -0
- chipgraph/checks/cdc_struct.py +235 -0
- chipgraph/checks/connect.py +353 -0
- chipgraph/checks/cross_chip.py +454 -0
- chipgraph/checks/duplicate.py +153 -0
- chipgraph/checks/filelist.py +323 -0
- chipgraph/checks/format.py +268 -0
- chipgraph/checks/generated.py +151 -0
- chipgraph/checks/hardcode.py +233 -0
- chipgraph/checks/layout.py +155 -0
- chipgraph/checks/naming.py +311 -0
- chipgraph/checks/plan_check.py +901 -0
- chipgraph/checks/ports_diff.py +203 -0
- chipgraph/checks/spec_schema.py +277 -0
- chipgraph/checks/style.py +910 -0
- chipgraph/checks/tb_static.py +599 -0
- chipgraph/checks/trace.py +224 -0
- chipgraph/checks/vendor_guard.py +369 -0
- chipgraph/checks/wrap_check.py +486 -0
- chipgraph/cli/__init__.py +1688 -0
- chipgraph/cli/next_steps.py +137 -0
- chipgraph/core/__init__.py +1 -0
- chipgraph/core/config/__init__.py +93 -0
- chipgraph/core/config/defaults.py +14 -0
- chipgraph/core/config/errors.py +32 -0
- chipgraph/core/config/loader.py +766 -0
- chipgraph/core/config/models.py +845 -0
- chipgraph/core/config/templates.py +340 -0
- chipgraph/core/contracts/__init__.py +75 -0
- chipgraph/core/contracts/_paths.py +26 -0
- chipgraph/core/contracts/agent.py +49 -0
- chipgraph/core/contracts/artifact.py +68 -0
- chipgraph/core/contracts/check.py +76 -0
- chipgraph/core/contracts/event.py +70 -0
- chipgraph/core/contracts/export.py +128 -0
- chipgraph/core/contracts/finding.py +171 -0
- chipgraph/core/contracts/gate.py +50 -0
- chipgraph/core/contracts/rule.py +177 -0
- chipgraph/core/contracts/types.py +45 -0
- chipgraph/core/engine/__init__.py +70 -0
- chipgraph/core/engine/agent_rule.py +1362 -0
- chipgraph/core/engine/baseline.py +283 -0
- chipgraph/core/engine/decide.py +462 -0
- chipgraph/core/engine/fanout.py +1358 -0
- chipgraph/core/engine/gate.py +170 -0
- chipgraph/core/engine/graph.py +647 -0
- chipgraph/core/engine/records.py +60 -0
- chipgraph/core/engine/rules.py +92 -0
- chipgraph/core/engine/scheduler.py +637 -0
- chipgraph/core/engine/select.py +1239 -0
- chipgraph/core/engine/targets.py +342 -0
- chipgraph/core/model/__init__.py +98 -0
- chipgraph/core/model/entities.py +244 -0
- chipgraph/core/model/export.py +16 -0
- chipgraph/core/model/ingest.py +693 -0
- chipgraph/core/model/json_value.py +4 -0
- chipgraph/core/model/keys.py +95 -0
- chipgraph/core/model/model.py +153 -0
- chipgraph/core/model/provenance.py +27 -0
- chipgraph/core/model/query.py +583 -0
- chipgraph/core/model/registry.py +80 -0
- chipgraph/core/model/relations.py +61 -0
- chipgraph/core/model/store.py +279 -0
- chipgraph/core/plugin_api/__init__.py +81 -0
- chipgraph/core/plugin_api/local.py +167 -0
- chipgraph/core/plugin_api/pack.py +159 -0
- chipgraph/core/plugin_api/protocols.py +177 -0
- chipgraph/core/plugin_api/registry.py +111 -0
- chipgraph/core/plugin_api/types.py +94 -0
- chipgraph/core/runtime/__init__.py +41 -0
- chipgraph/core/runtime/executor.py +79 -0
- chipgraph/core/runtime/queue.py +565 -0
- chipgraph/core/runtime/roles/__init__.py +91 -0
- chipgraph/core/runtime/roles/data/author.md +41 -0
- chipgraph/core/runtime/roles/data/critic.md +56 -0
- chipgraph/core/runtime/roles/data/planner.md +38 -0
- chipgraph/core/runtime/roles/data/researcher.md +37 -0
- chipgraph/core/runtime/roles/data/rtl-author.md +64 -0
- chipgraph/core/runtime/roles/data/tb-author.md +48 -0
- chipgraph/core/runtime/roles/data/triage.md +34 -0
- chipgraph/core/runtime/roles/policy.py +299 -0
- chipgraph/core/runtime/roles/registry.py +52 -0
- chipgraph/core/runtime/roles/review.py +275 -0
- chipgraph/core/runtime/roles/schema.py +82 -0
- chipgraph/core/runtime/roles/skills.py +134 -0
- chipgraph/core/runtime/roles/spec.py +232 -0
- chipgraph/core/state/__init__.py +1 -0
- chipgraph/core/state/artifacts.py +156 -0
- chipgraph/core/state/backend.py +117 -0
- chipgraph/core/state/findings.py +151 -0
- chipgraph/core/state/handoff.py +411 -0
- chipgraph/core/state/idempotency.py +59 -0
- chipgraph/core/state/journal.py +238 -0
- chipgraph/core/state/layout.py +82 -0
- chipgraph/core/state/lock.py +195 -0
- chipgraph/core/state/trace.py +225 -0
- chipgraph/learn/__init__.py +96 -0
- chipgraph/learn/draft.py +187 -0
- chipgraph/learn/infer.py +580 -0
- chipgraph/learn/models.py +145 -0
- chipgraph/learn/try_run.py +228 -0
- chipgraph/mcp/__init__.py +12 -0
- chipgraph/mcp/model_tools.py +167 -0
- chipgraph/mcp/server.py +787 -0
- chipgraph/orgs/qnsc/naming-v1.yml +113 -0
- chipgraph/orgs/qnsc/profile.yml +5 -0
- chipgraph/packs/__init__.py +8 -0
- chipgraph/packs/assist/__init__.py +16 -0
- chipgraph/packs/assist/ask/__init__.py +58 -0
- chipgraph/packs/assist/ask/_db.py +57 -0
- chipgraph/packs/assist/ask/_project.py +230 -0
- chipgraph/packs/assist/ask/answer.py +266 -0
- chipgraph/packs/assist/ask/check.py +182 -0
- chipgraph/packs/assist/ask/contract.py +115 -0
- chipgraph/packs/assist/ask/documents.py +157 -0
- chipgraph/packs/assist/ask/fts.py +80 -0
- chipgraph/packs/assist/ask/retrieve.py +331 -0
- chipgraph/packs/assist/audit/__init__.py +383 -0
- chipgraph/packs/assist/pack.yml +13 -0
- chipgraph/packs/assist/triage/__init__.py +64 -0
- chipgraph/packs/assist/triage/contract.py +94 -0
- chipgraph/packs/assist/triage/parse.py +371 -0
- chipgraph/packs/assist/triage/question.py +384 -0
- chipgraph/packs/assist/triage/report.py +160 -0
- chipgraph/packs/assist/triage/rules.py +447 -0
- chipgraph/packs/assist/triage/run.py +224 -0
- chipgraph/packs/digital_rtl/__init__.py +19 -0
- chipgraph/packs/digital_rtl/layout.py +144 -0
- chipgraph/packs/digital_rtl/pack.yml +25 -0
- chipgraph/packs/digital_rtl/plan/__init__.py +34 -0
- chipgraph/packs/digital_rtl/plan/__main__.py +16 -0
- chipgraph/packs/digital_rtl/plan/context.py +82 -0
- chipgraph/packs/digital_rtl/plan/expand.py +94 -0
- chipgraph/packs/digital_rtl/plan/model.py +150 -0
- chipgraph/packs/digital_rtl/plan/resolver.py +400 -0
- chipgraph/packs/digital_rtl/plan/show.py +176 -0
- chipgraph/packs/digital_rtl/pr/__init__.py +11 -0
- chipgraph/packs/digital_rtl/pr/body.py +165 -0
- chipgraph/packs/digital_rtl/pr/gate.py +110 -0
- chipgraph/packs/digital_rtl/pr/open.py +397 -0
- chipgraph/packs/digital_rtl/rtl/__init__.py +9 -0
- chipgraph/packs/digital_rtl/rtl/context.py +272 -0
- chipgraph/packs/digital_rtl/rules/open_pr.yml +30 -0
- chipgraph/packs/digital_rtl/rules/plan.yml +28 -0
- chipgraph/packs/digital_rtl/rules/plan_expand.yml +29 -0
- chipgraph/packs/digital_rtl/rules/pr_gate.yml +27 -0
- chipgraph/packs/digital_rtl/rules/review.yml +31 -0
- chipgraph/packs/digital_rtl/rules/rtl_module.yml +53 -0
- chipgraph/packs/digital_rtl/skills/lang-sv-rtl.md +58 -0
- chipgraph/packs/digital_rtl/skills/org-naming.md +29 -0
- chipgraph/packs/digital_rtl/skills/plan-modules.md +63 -0
- chipgraph/packs/digital_rtl/skills/review-diff.md +41 -0
- chipgraph/packs/digital_rtl/targets/plan.yml +11 -0
- chipgraph/packs/digital_rtl/targets/rtl.yml +20 -0
- chipgraph/packs/digital_rtl/wrapper/__init__.py +24 -0
- chipgraph/packs/digital_rtl/wrapper/common.py +171 -0
- chipgraph/packs/digital_rtl/wrapper/context.py +222 -0
- chipgraph/packs/digital_rtl/wrapper/emacs.py +626 -0
- chipgraph/packs/digital_rtl/wrapper/expansion.py +304 -0
- chipgraph/packs/digital_rtl/wrapper/lisp.py +504 -0
- chipgraph/packs/digital_rtl/wrapper/resolver.py +60 -0
- chipgraph/packs/digital_rtl/wrapper/rules/wrapper.yml +41 -0
- chipgraph/packs/digital_rtl/wrapper/rules/wrapper_gen.yml +35 -0
- chipgraph/packs/digital_rtl/wrapper/skills/wrapper-auto.md +120 -0
- chipgraph/packs/dv/__init__.py +15 -0
- chipgraph/packs/dv/declaration.py +392 -0
- chipgraph/packs/dv/interface.py +596 -0
- chipgraph/packs/dv/pack.yml +14 -0
- chipgraph/packs/dv/rules/tb_module.yml +42 -0
- chipgraph/packs/dv/skills/dv/cocotb.md +72 -0
- chipgraph/packs/spec_core/__init__.py +9 -0
- chipgraph/packs/spec_core/extract/__init__.py +12 -0
- chipgraph/packs/spec_core/extract/_markdown.py +371 -0
- chipgraph/packs/spec_core/extract/_text.py +116 -0
- chipgraph/packs/spec_core/extract/mas.py +975 -0
- chipgraph/packs/spec_core/gen/__init__.py +11 -0
- chipgraph/packs/spec_core/gen/agents_md/__init__.py +148 -0
- chipgraph/packs/spec_core/gen/agents_md/_context.py +239 -0
- chipgraph/packs/spec_core/gen/agents_md/_markers.py +131 -0
- chipgraph/packs/spec_core/gen/agents_md/agents_md.md.j2 +178 -0
- chipgraph/packs/spec_core/gen/diagram/__init__.py +110 -0
- chipgraph/packs/spec_core/gen/diagram/__main__.py +44 -0
- chipgraph/packs/spec_core/gen/diagram/_diagrams.py +403 -0
- chipgraph/packs/spec_core/gen/diagram/_scene.py +163 -0
- chipgraph/packs/spec_core/pack.yml +6 -0
- chipgraph/py.typed +0 -0
- chipgraph-0.1.0.dist-info/METADATA +53 -0
- chipgraph-0.1.0.dist-info/RECORD +252 -0
- chipgraph-0.1.0.dist-info/WHEEL +4 -0
- chipgraph-0.1.0.dist-info/entry_points.txt +61 -0
- 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
|
+
]
|