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,2285 @@
|
|
|
1
|
+
"""What the MCP tools of runtime `claude-code` do: `next_task`, `get_context`, `submit`.
|
|
2
|
+
|
|
3
|
+
The loop (DESIGN.md 5.5, spike S7), driven by the plugin command in the user's Claude
|
|
4
|
+
Code session::
|
|
5
|
+
|
|
6
|
+
next_task(target) run the build; every agent task it reaches is queued; hand out
|
|
7
|
+
the ready ones (several at once: they run in parallel)
|
|
8
|
+
get_context(id) a role subagent asks for its task: inputs as text, outputs,
|
|
9
|
+
role, skills and their text (refused when an input is labelled
|
|
10
|
+
`nda`, or is of a kind the task's role must not see)
|
|
11
|
+
submit(id, result) the engine checks the work at once: every file changed since
|
|
12
|
+
dispatch is one of the task's outputs, the outputs exist and are
|
|
13
|
+
not empty, the rule's checks pass; accept, or reject with the
|
|
14
|
+
failure's label, the failed checks and the redo instruction
|
|
15
|
+
|
|
16
|
+
`submit` runs the M2-02a agent rule loop one attempt at a time (`loop`): a failure is
|
|
17
|
+
labelled, a try is counted (an infra failure is not, within `max_infra_retries`), the
|
|
18
|
+
model tier escalates after the first failed try, and two tries in a row with the same
|
|
19
|
+
outputs and failures stop early. When the budget is gone the task turns
|
|
20
|
+
`budget_exhausted`, its `rule_fail` (with the label and the loop's `agent` summary) goes
|
|
21
|
+
into the journal of the run that dispatched it, and that run's HANDOFF.md is written at
|
|
22
|
+
once. `next_task` never hands a task out more than `tries + max_infra_retries` times,
|
|
23
|
+
never hands out a stopped task, and reports it (and every rule that depends on it)
|
|
24
|
+
under `blocked`; with nothing left to run it answers `stopped: true` and the HANDOFF
|
|
25
|
+
path.
|
|
26
|
+
|
|
27
|
+
A role that must not see some artifact kinds gets a guarded context (`guarded`). The
|
|
28
|
+
testbench Author (no `rtl`): its model inputs are the block's spec side and the module's
|
|
29
|
+
interface (spec ports, else the model's RTL ports, else the RTL declaration only), never
|
|
30
|
+
a general model slice. The RTL Author (no `tb`, M2-04): an `rtl` section with the
|
|
31
|
+
module's approved plan entry, requirements, interface, dependencies, naming rules and
|
|
32
|
+
style exemplars, and nothing from a testbench. For both, what `submit` tells it of
|
|
33
|
+
failed checks goes through `FeedbackFilter` (no issue in a denied file, no denied path,
|
|
34
|
+
no source excerpt; the redo text says when something was withheld, in words for the kind
|
|
35
|
+
withheld). The journal keeps the full results.
|
|
36
|
+
|
|
37
|
+
A role with no file-reading tool (`tb-author`, `rtl-author`) cannot overwrite a file
|
|
38
|
+
in Claude Code, which refuses a `Write` over a file the agent has not read. So at each
|
|
39
|
+
dispatch of such a role `next_task` moves every output that exists into the state
|
|
40
|
+
backend (`previous`): the agent's earlier attempt, or a file that was there before the
|
|
41
|
+
task. `get_context` shows them (`previous_outputs`: the last attempt's text, and a
|
|
42
|
+
pre-existing file's under `existing_output`, capped), the agent writes each file anew,
|
|
43
|
+
and a pre-existing file is put back, byte for byte with its mode, when the task ends
|
|
44
|
+
without being accepted (`budget_exhausted`, `needs_human`, a rewind) or the dispatch
|
|
45
|
+
fails in the engine. Roles with a read tool edit in place, as before.
|
|
46
|
+
|
|
47
|
+
For a rule whose first check is `format`, that check (the engine, not the agent) formats
|
|
48
|
+
the outputs in place before the other checks; the hashes `submit` records are taken
|
|
49
|
+
after the checks, so they are the formatted files'.
|
|
50
|
+
|
|
51
|
+
A role that writes no files but has `engine_writes` (the Critic) reviews a change
|
|
52
|
+
instead (`review`): its context holds the diff, the spec slice and the model slice; its
|
|
53
|
+
reply is a JSON review the main session passes as `result.review`; `submit` validates it
|
|
54
|
+
and the engine itself writes it as the task's one output.
|
|
55
|
+
|
|
56
|
+
A person who rejects a gate with a note (`approve(reject=true, note=...)`, or
|
|
57
|
+
`chipgraph approve <gate> --reject --note ...`) asks for a revision (`_revise_rejected`):
|
|
58
|
+
when every file the gate covers is an accepted agent task's output, the next `next_task`
|
|
59
|
+
reopens that task (a new budget cycle), rewinds it in the build and hands it out again;
|
|
60
|
+
its context starts `previous_rejection` with the note until it is accepted again. The
|
|
61
|
+
answer lists it under `revised`. The gate then waits for the new output.
|
|
62
|
+
|
|
63
|
+
The engine decides everything (P3): which task, which role, which files, which checks.
|
|
64
|
+
Claude Code only runs the model. Each role's tool table (`chipgraph.core.runtime.roles`)
|
|
65
|
+
picks the subagent (`chipgraph:<role>`), its model per attempt (the role's tier, then its
|
|
66
|
+
escalation tier after a rejection, unless the rule sets its own), and what it must not
|
|
67
|
+
read (`denied_reads`, set on dispatch). The guard hook (`plugin/hooks/guard.py`) blocks
|
|
68
|
+
writes outside a task's outputs and reads of its denied paths while the agent works;
|
|
69
|
+
`submit` checks the diff again.
|
|
70
|
+
|
|
71
|
+
Every function takes a freshly loaded `AppContext` and raises `AppError` for anything
|
|
72
|
+
the caller should see as a tool error.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
from __future__ import annotations
|
|
76
|
+
|
|
77
|
+
import contextlib
|
|
78
|
+
import json
|
|
79
|
+
import os
|
|
80
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
81
|
+
from datetime import UTC, datetime
|
|
82
|
+
from pathlib import Path
|
|
83
|
+
from typing import Any, Literal
|
|
84
|
+
|
|
85
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
86
|
+
|
|
87
|
+
from chipgraph.adapters.runtime.claude_code import loop
|
|
88
|
+
from chipgraph.adapters.runtime.claude_code import review as review_mod
|
|
89
|
+
from chipgraph.adapters.runtime.claude_code.agents import DEFAULT_TIER_MODELS, agent_type
|
|
90
|
+
from chipgraph.adapters.runtime.claude_code.guarded import (
|
|
91
|
+
GuardedContext,
|
|
92
|
+
RtlAuthorContext,
|
|
93
|
+
guarded_context,
|
|
94
|
+
)
|
|
95
|
+
from chipgraph.adapters.runtime.claude_code.isolation import (
|
|
96
|
+
CopyBackResult,
|
|
97
|
+
best_of_n,
|
|
98
|
+
candidate_id,
|
|
99
|
+
check_overlapping_writes,
|
|
100
|
+
copy_outputs_back,
|
|
101
|
+
create_workspace,
|
|
102
|
+
diff_workspace,
|
|
103
|
+
input_paths_for,
|
|
104
|
+
is_candidate_id,
|
|
105
|
+
max_parallel_limit,
|
|
106
|
+
parse_candidate_id,
|
|
107
|
+
remove_workspace,
|
|
108
|
+
should_isolate,
|
|
109
|
+
sweep_workspaces,
|
|
110
|
+
)
|
|
111
|
+
from chipgraph.adapters.runtime.claude_code.previous import (
|
|
112
|
+
MoveToken,
|
|
113
|
+
PreviousOutputs,
|
|
114
|
+
moves_outputs,
|
|
115
|
+
)
|
|
116
|
+
from chipgraph.adapters.runtime.claude_code.runtime import output_hashes
|
|
117
|
+
from chipgraph.app.build import graph_artifacts, make_scheduler, pack_search_paths
|
|
118
|
+
from chipgraph.app.checks import ProfileCheckRunner
|
|
119
|
+
from chipgraph.app.context import AppContext
|
|
120
|
+
from chipgraph.app.errors import AppError
|
|
121
|
+
from chipgraph.core.contracts import (
|
|
122
|
+
AgentResult,
|
|
123
|
+
Approval,
|
|
124
|
+
CheckResult,
|
|
125
|
+
Event,
|
|
126
|
+
RuleInstance,
|
|
127
|
+
RuleSpec,
|
|
128
|
+
)
|
|
129
|
+
from chipgraph.core.contracts.event import EventType
|
|
130
|
+
from chipgraph.core.contracts.types import FailureLabel, ModelTier
|
|
131
|
+
from chipgraph.core.engine.agent_rule import OUTPUTS_CHECK, FeedbackFilter, attempt_checks
|
|
132
|
+
from chipgraph.core.engine.gate import gate_id_for
|
|
133
|
+
from chipgraph.core.engine.graph import BuildGraph, kind_for
|
|
134
|
+
from chipgraph.core.engine.scheduler import RunSummary, Scheduler
|
|
135
|
+
from chipgraph.core.engine.select import (
|
|
136
|
+
VARIANTS,
|
|
137
|
+
Candidate,
|
|
138
|
+
Selection,
|
|
139
|
+
Spend,
|
|
140
|
+
error_count,
|
|
141
|
+
plan_budget,
|
|
142
|
+
select_best,
|
|
143
|
+
warning_count,
|
|
144
|
+
)
|
|
145
|
+
from chipgraph.core.model import ModelQuery, ModelStore, QueryError, default_model_db_path
|
|
146
|
+
from chipgraph.core.plugin_api.pack import discover_packs
|
|
147
|
+
from chipgraph.core.plugin_api.registry import PluginError
|
|
148
|
+
from chipgraph.core.runtime import (
|
|
149
|
+
ACTIVE_STATUSES,
|
|
150
|
+
OPEN_STATUSES,
|
|
151
|
+
AgentTaskRecord,
|
|
152
|
+
QueueError,
|
|
153
|
+
TaskQueue,
|
|
154
|
+
instance_inputs_hash,
|
|
155
|
+
state_key,
|
|
156
|
+
task_for,
|
|
157
|
+
)
|
|
158
|
+
from chipgraph.core.runtime.queue import TaskBudgetState
|
|
159
|
+
from chipgraph.core.runtime.roles import (
|
|
160
|
+
ReviewReport,
|
|
161
|
+
RoleSpec,
|
|
162
|
+
SkillError,
|
|
163
|
+
SkillSet,
|
|
164
|
+
SkillSpec,
|
|
165
|
+
denied_kind,
|
|
166
|
+
find_role,
|
|
167
|
+
load_skills,
|
|
168
|
+
parse_review,
|
|
169
|
+
path_denied,
|
|
170
|
+
review_problems,
|
|
171
|
+
task_denied_reads,
|
|
172
|
+
)
|
|
173
|
+
from chipgraph.core.state import journal as journal_mod
|
|
174
|
+
from chipgraph.core.state.artifacts import ArtifactStore, hash_file
|
|
175
|
+
from chipgraph.core.state.handoff import build_handoff, write_handoff
|
|
176
|
+
|
|
177
|
+
_MAX_INPUT_CHARS = 200_000
|
|
178
|
+
"""Longest input text `get_context` returns per file; longer files are cut."""
|
|
179
|
+
|
|
180
|
+
_NOT_AGENT_WORK = (".git/", ".chipgraph/state/", ".chipgraph/decisions/", ".claude/")
|
|
181
|
+
"""Paths never counted as an agent's changes: VCS and chipgraph state, gate decisions a
|
|
182
|
+
person records during the run, and Claude Code's own project settings. The write guard
|
|
183
|
+
still refuses agent writes to all of them."""
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class SubmitReport(BaseModel):
|
|
187
|
+
"""What the role subagent reported, passed along by the main session on `submit`."""
|
|
188
|
+
|
|
189
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
190
|
+
|
|
191
|
+
status: Literal["done", "needs_human"] = Field(
|
|
192
|
+
default="done",
|
|
193
|
+
description="'needs_human' when the task cannot be done without a person's answer.",
|
|
194
|
+
)
|
|
195
|
+
files_written: tuple[str, ...] = Field(
|
|
196
|
+
default=(), description="Files the agent says it wrote (the engine checks the diff)."
|
|
197
|
+
)
|
|
198
|
+
assumptions: tuple[str, ...] = Field(default=(), description="Assumptions the agent made.")
|
|
199
|
+
open_questions: tuple[str, ...] = Field(
|
|
200
|
+
default=(), description="Questions for a person; required when status is needs_human."
|
|
201
|
+
)
|
|
202
|
+
review: dict[str, Any] | str | None = Field(
|
|
203
|
+
default=None,
|
|
204
|
+
description=(
|
|
205
|
+
"Review tasks only (agent chipgraph:critic): the subagent's JSON review, as an "
|
|
206
|
+
"object or its text. The engine validates it and writes it as the task's output."
|
|
207
|
+
),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# --- Best-of-N round tracking -----------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class CandidateState(BaseModel):
|
|
215
|
+
"""State of one candidate in a best-of-N round."""
|
|
216
|
+
|
|
217
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
218
|
+
|
|
219
|
+
candidate: int = Field(ge=1, description="Candidate number (1..N).")
|
|
220
|
+
variant: str = Field(description="The variant hint for this candidate.")
|
|
221
|
+
task_id: str = Field(description="The candidate's task id (base_task#candidate).")
|
|
222
|
+
workspace: str = Field(description="Absolute path to the candidate's workspace.")
|
|
223
|
+
status: Literal["dispatched", "submitted", "done", "failed", "cancelled"] = Field(
|
|
224
|
+
default="dispatched"
|
|
225
|
+
)
|
|
226
|
+
checks: tuple[CheckResult, ...] = Field(default=(), description="Check results.")
|
|
227
|
+
passed: bool = Field(default=False, description="Whether the candidate passed.")
|
|
228
|
+
errors: int = Field(default=0, ge=0)
|
|
229
|
+
warnings: int = Field(default=0, ge=0)
|
|
230
|
+
spend: Spend = Field(default_factory=Spend)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class BestOfNRound(BaseModel):
|
|
234
|
+
"""State of one best-of-N round for a rule instance.
|
|
235
|
+
|
|
236
|
+
Stored under <state>/runtime/best-of/<sha256(task_id)>.json while in progress.
|
|
237
|
+
"""
|
|
238
|
+
|
|
239
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
240
|
+
|
|
241
|
+
schema_version: Literal[1] = Field(default=1)
|
|
242
|
+
base_task_id: str = Field(description="The original task id (without candidate suffix).")
|
|
243
|
+
run_id: str = Field(description="The run that started this round.")
|
|
244
|
+
round_num: int = Field(ge=1, description="Round number (1-based).")
|
|
245
|
+
n: int = Field(ge=2, description="Number of candidates in this round.")
|
|
246
|
+
candidates: tuple[CandidateState, ...] = Field(description="State of each candidate.")
|
|
247
|
+
ledger_tries: int = Field(description="Total tries in the shared ledger.")
|
|
248
|
+
ledger_used: int = Field(default=0, ge=0, description="Tries used so far.")
|
|
249
|
+
winner: int | None = Field(default=None, description="Winning candidate number, if decided.")
|
|
250
|
+
selection: Selection | None = Field(default=None, description="The selection result.")
|
|
251
|
+
status: Literal["running", "selecting", "done", "failed"] = Field(default="running")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _best_of_path(ctx: AppContext, task_id: str) -> Path:
|
|
255
|
+
"""Path to a best-of-N round state file."""
|
|
256
|
+
return ctx.layout.state_dir / "runtime" / "best-of" / f"{state_key(task_id)}.json"
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _load_best_of_round(ctx: AppContext, task_id: str) -> BestOfNRound | None:
|
|
260
|
+
"""Load a best-of-N round state, or None if not in a round."""
|
|
261
|
+
path = _best_of_path(ctx, task_id)
|
|
262
|
+
if not path.is_file():
|
|
263
|
+
return None
|
|
264
|
+
try:
|
|
265
|
+
return BestOfNRound.model_validate_json(path.read_text(encoding="utf-8"))
|
|
266
|
+
except Exception:
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _save_best_of_round(ctx: AppContext, round_state: BestOfNRound) -> None:
|
|
271
|
+
"""Save a best-of-N round state."""
|
|
272
|
+
path = _best_of_path(ctx, round_state.base_task_id)
|
|
273
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
274
|
+
path.write_text(round_state.model_dump_json(indent=2), encoding="utf-8")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _remove_best_of_round(ctx: AppContext, task_id: str) -> None:
|
|
278
|
+
"""Remove a best-of-N round state file."""
|
|
279
|
+
path = _best_of_path(ctx, task_id)
|
|
280
|
+
path.unlink(missing_ok=True)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# --- shared helpers ---------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _require_runtime(ctx: AppContext) -> None:
|
|
287
|
+
runtime = ctx.require_profile().profile.runtime
|
|
288
|
+
if runtime != "claude-code":
|
|
289
|
+
raise AppError(
|
|
290
|
+
f"this project's profile sets runtime {runtime!r}; next_task, get_context and "
|
|
291
|
+
"submit serve runtime 'claude-code' only"
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _queue(ctx: AppContext) -> TaskQueue:
|
|
296
|
+
return TaskQueue(ctx.layout)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _record(queue: TaskQueue, task_id: str) -> AgentTaskRecord:
|
|
300
|
+
try:
|
|
301
|
+
return queue.require(task_id)
|
|
302
|
+
except QueueError as exc:
|
|
303
|
+
raise AppError(f"{exc}; call next_task for the tasks that exist") from exc
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _graph(ctx: AppContext) -> BuildGraph:
|
|
307
|
+
return make_scheduler(ctx, "*").graph
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _instance(graph: BuildGraph, task_id: str) -> tuple[RuleSpec, RuleInstance]:
|
|
311
|
+
instance = graph.instances.get(task_id)
|
|
312
|
+
if instance is None and is_candidate_id(task_id): # best-of-N: `<instance id>#<n>`
|
|
313
|
+
try:
|
|
314
|
+
instance = graph.instances.get(parse_candidate_id(task_id)[0])
|
|
315
|
+
except ValueError:
|
|
316
|
+
instance = None
|
|
317
|
+
if instance is None:
|
|
318
|
+
raise AppError(f"task {task_id!r} is no longer a rule instance of this project's graph")
|
|
319
|
+
return graph.rules[instance.rule_id], instance
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _agent_for(role: str) -> str:
|
|
323
|
+
"""The plugin subagent for a role: `chipgraph:<role>` (namespace dropped)."""
|
|
324
|
+
return agent_type(role)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _model_for(ctx: AppContext, record: AgentTaskRecord) -> str:
|
|
328
|
+
"""The model of the task's next attempt: its current tier, mapped by the profile."""
|
|
329
|
+
return _tier_model(ctx, record.current_tier)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _tier_model(ctx: AppContext, tier: ModelTier) -> str:
|
|
333
|
+
configured = ctx.require_profile().profile.models.tiers.get(tier)
|
|
334
|
+
return configured or DEFAULT_TIER_MODELS[tier]
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _skill_set(ctx: AppContext) -> SkillSet:
|
|
338
|
+
"""Every skill the profile's packs provide."""
|
|
339
|
+
profile = ctx.require_profile().profile
|
|
340
|
+
try:
|
|
341
|
+
available = discover_packs(pack_search_paths(ctx))
|
|
342
|
+
return load_skills(available[name] for name in profile.packs if name in available)
|
|
343
|
+
except (PluginError, SkillError) as exc:
|
|
344
|
+
raise AppError(str(exc)) from exc
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _check_skills(ctx: AppContext, graph: BuildGraph) -> SkillSet:
|
|
348
|
+
"""Resolve every agent rule's skills for its role; an unknown or misused one is an error."""
|
|
349
|
+
skills = _skill_set(ctx)
|
|
350
|
+
for rule in sorted(graph.rules.values(), key=lambda r: r.id):
|
|
351
|
+
if rule.kind == "agent" and rule.role is not None:
|
|
352
|
+
try:
|
|
353
|
+
skills.resolve(rule.skills, rule.role)
|
|
354
|
+
except SkillError as exc:
|
|
355
|
+
raise AppError(f"rule {rule.id!r}: {exc}") from exc
|
|
356
|
+
return skills
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _denied_reads(ctx: AppContext, graph: BuildGraph, record: AgentTaskRecord) -> tuple[str, ...]:
|
|
360
|
+
"""What the task's agent must not read: its role's read policy over this project."""
|
|
361
|
+
return task_denied_reads(
|
|
362
|
+
record.role,
|
|
363
|
+
artifacts=graph_artifacts(graph),
|
|
364
|
+
profile=ctx.require_profile().profile,
|
|
365
|
+
keep=record.allowed_writes,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _emit(
|
|
370
|
+
ctx: AppContext,
|
|
371
|
+
run_id: str | None,
|
|
372
|
+
task_id: str,
|
|
373
|
+
type_: EventType,
|
|
374
|
+
payload: Mapping[str, Any],
|
|
375
|
+
*,
|
|
376
|
+
failure_label: FailureLabel | None = None,
|
|
377
|
+
) -> None:
|
|
378
|
+
"""Append one event about `task_id` to the journal of the run that dispatched it."""
|
|
379
|
+
if run_id is None:
|
|
380
|
+
return
|
|
381
|
+
journal = journal_mod.Journal(ctx.layout.journal(run_id))
|
|
382
|
+
journal.append(
|
|
383
|
+
Event(
|
|
384
|
+
run_id=run_id,
|
|
385
|
+
seq=journal.next_seq(),
|
|
386
|
+
ts=datetime.now(UTC),
|
|
387
|
+
type=type_,
|
|
388
|
+
rule_instance=task_id,
|
|
389
|
+
payload=dict(payload),
|
|
390
|
+
failure_label=failure_label,
|
|
391
|
+
)
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _handoff_path(ctx: AppContext, run_id: str) -> Path:
|
|
396
|
+
"""Where a run's HANDOFF.md is (`write_handoff` writes it there)."""
|
|
397
|
+
return ctx.layout.run_dir(run_id) / "HANDOFF.md"
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _write_run_handoff(ctx: AppContext, run_id: str) -> Path | None:
|
|
401
|
+
"""Write run `run_id`'s HANDOFF.md again from its journal; `None` if that failed.
|
|
402
|
+
|
|
403
|
+
As in the scheduler, a HANDOFF.md that cannot be written (an `OSError`) never fails
|
|
404
|
+
the call itself.
|
|
405
|
+
"""
|
|
406
|
+
try:
|
|
407
|
+
events = journal_mod.read(ctx.layout.journal(run_id)).events
|
|
408
|
+
target = journal_mod.read_manifest(ctx.layout, run_id).target
|
|
409
|
+
return write_handoff(ctx.layout, build_handoff(events, target=target))
|
|
410
|
+
except OSError:
|
|
411
|
+
return None
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
async def snapshot(ctx: AppContext, extra: Iterable[str] = ()) -> dict[str, str]:
|
|
415
|
+
"""Content hash of every project file an agent could have changed, keyed by path.
|
|
416
|
+
|
|
417
|
+
Files come from `git ls-files --cached --others --exclude-standard` (tracked plus
|
|
418
|
+
untracked, without ignored ones such as build products); without git, from a walk
|
|
419
|
+
of the tree. `extra` paths (a task's outputs) are always included.
|
|
420
|
+
"""
|
|
421
|
+
runner = ctx.registry.get("runner", "local")
|
|
422
|
+
result = await runner.run(
|
|
423
|
+
["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd=ctx.root
|
|
424
|
+
)
|
|
425
|
+
if result.returncode == 0:
|
|
426
|
+
paths = {p for p in result.stdout.split("\0") if p}
|
|
427
|
+
else:
|
|
428
|
+
paths = set()
|
|
429
|
+
for dirpath, dirnames, filenames in os.walk(ctx.root):
|
|
430
|
+
rel_dir = Path(dirpath).relative_to(ctx.root).as_posix()
|
|
431
|
+
dirnames[:] = [
|
|
432
|
+
d
|
|
433
|
+
for d in dirnames
|
|
434
|
+
if not f"{'' if rel_dir == '.' else rel_dir + '/'}{d}/".startswith(_NOT_AGENT_WORK)
|
|
435
|
+
]
|
|
436
|
+
for name in filenames:
|
|
437
|
+
paths.add(name if rel_dir == "." else f"{rel_dir}/{name}")
|
|
438
|
+
paths |= set(extra)
|
|
439
|
+
hashes: dict[str, str] = {}
|
|
440
|
+
for rel in sorted(paths):
|
|
441
|
+
if rel.startswith(_NOT_AGENT_WORK):
|
|
442
|
+
continue
|
|
443
|
+
full = ctx.root / rel
|
|
444
|
+
if full.is_file():
|
|
445
|
+
hashes[rel] = hash_file(full)
|
|
446
|
+
return hashes
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _changed(before: Mapping[str, str], after: Mapping[str, str]) -> list[str]:
|
|
450
|
+
return sorted(p for p in set(before) | set(after) if before.get(p) != after.get(p))
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _engine_outputs(graph: BuildGraph) -> set[str]:
|
|
454
|
+
"""Outputs of the graph's non-agent rules: the engine itself writes those."""
|
|
455
|
+
return {
|
|
456
|
+
ref.path
|
|
457
|
+
for instance in graph.instances.values()
|
|
458
|
+
if graph.rules[instance.rule_id].kind != "agent"
|
|
459
|
+
for ref in instance.outputs
|
|
460
|
+
if ref.path is not None
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _task_entry(
|
|
465
|
+
ctx: AppContext,
|
|
466
|
+
queue: TaskQueue,
|
|
467
|
+
record: AgentTaskRecord,
|
|
468
|
+
*,
|
|
469
|
+
candidate: int | None = None,
|
|
470
|
+
variant: str | None = None,
|
|
471
|
+
) -> dict[str, Any]:
|
|
472
|
+
"""Build a task entry for the next_task response.
|
|
473
|
+
|
|
474
|
+
For best-of-N candidates, `candidate` is the candidate number (1..N) and `variant`
|
|
475
|
+
is the variant hint text.
|
|
476
|
+
"""
|
|
477
|
+
state = record.state
|
|
478
|
+
if review_mod.is_review_role(find_role(record.role)):
|
|
479
|
+
prompt = (
|
|
480
|
+
f"You are doing chipgraph review task {record.task_id!r}. First call get_context "
|
|
481
|
+
f"with task_id={record.task_id!r}; write no file; finish with the review as one "
|
|
482
|
+
"JSON object."
|
|
483
|
+
)
|
|
484
|
+
extra: dict[str, Any] = {"reply": "review"}
|
|
485
|
+
else:
|
|
486
|
+
prompt = (
|
|
487
|
+
f"You are doing chipgraph task {record.task_id!r}. First call get_context with "
|
|
488
|
+
f"task_id={record.task_id!r}, then write only the files in its outputs."
|
|
489
|
+
)
|
|
490
|
+
extra = {}
|
|
491
|
+
entry = {
|
|
492
|
+
"task_id": record.task_id,
|
|
493
|
+
"rule": record.rule_id,
|
|
494
|
+
"role": record.role,
|
|
495
|
+
"agent": _agent_for(record.role),
|
|
496
|
+
"model": _model_for(ctx, record),
|
|
497
|
+
"tier": record.current_tier,
|
|
498
|
+
"skills": list(record.skills),
|
|
499
|
+
"outputs": list(record.allowed_writes),
|
|
500
|
+
"attempt": record.dispatches,
|
|
501
|
+
"tries": record.tries,
|
|
502
|
+
"try": min(state.tries_used + 1, record.tries),
|
|
503
|
+
"tries_left": max(record.tries - state.tries_used, 0),
|
|
504
|
+
"dispatches": state.dispatches,
|
|
505
|
+
"max_dispatches": loop.dispatch_limit(record),
|
|
506
|
+
"previous_label": state.label,
|
|
507
|
+
"tool_calls": queue.tool_calls(record.task_id),
|
|
508
|
+
"tool_call_cap": record.tool_call_cap,
|
|
509
|
+
"prompt": prompt,
|
|
510
|
+
**extra,
|
|
511
|
+
}
|
|
512
|
+
# Add candidate info if this is a best-of-N candidate
|
|
513
|
+
if candidate is not None:
|
|
514
|
+
entry["candidate"] = candidate
|
|
515
|
+
entry["variant"] = variant or ""
|
|
516
|
+
return entry
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _blocked(
|
|
520
|
+
ctx: AppContext,
|
|
521
|
+
graph: BuildGraph,
|
|
522
|
+
queue: TaskQueue,
|
|
523
|
+
summary: RunSummary,
|
|
524
|
+
skip: set[str],
|
|
525
|
+
) -> list[dict[str, Any]]:
|
|
526
|
+
"""Why the build stopped, per instance (from the journal of `summary`'s run).
|
|
527
|
+
|
|
528
|
+
Gates; failed rules with their label (an agent task also with its queue status, and
|
|
529
|
+
the HANDOFF path when its loop stopped); and every rule that depends on one of
|
|
530
|
+
those, with the ones it waits for (`blocked_by`). `skip` holds instances reported
|
|
531
|
+
elsewhere (handed out, in progress, or blocked for a reason of their own): they and
|
|
532
|
+
rules that wait only for them are left out.
|
|
533
|
+
"""
|
|
534
|
+
events = journal_mod.read(ctx.layout.journal(summary.run_id)).events
|
|
535
|
+
gates = {
|
|
536
|
+
e.rule_instance: str(e.payload.get("gate", "")) for e in events if e.type == "gate_wait"
|
|
537
|
+
}
|
|
538
|
+
fails = {e.rule_instance: e for e in events if e.type == "rule_fail"}
|
|
539
|
+
handoff = str(_handoff_path(ctx, summary.run_id))
|
|
540
|
+
blocked: list[dict[str, Any]] = []
|
|
541
|
+
reported: list[str] = []
|
|
542
|
+
for iid in summary.waiting_gate:
|
|
543
|
+
blocked.append(
|
|
544
|
+
{"instance": iid, "reason": f"waiting for gate {gates.get(iid, '?')!r} to be approved"}
|
|
545
|
+
)
|
|
546
|
+
reported.append(iid)
|
|
547
|
+
for iid in summary.failed:
|
|
548
|
+
if iid in skip:
|
|
549
|
+
continue
|
|
550
|
+
event = fails.get(iid)
|
|
551
|
+
entry: dict[str, Any] = {
|
|
552
|
+
"instance": iid,
|
|
553
|
+
"reason": (str(event.payload.get("message", "")) if event else "") or "failed",
|
|
554
|
+
"label": event.failure_label if event else None,
|
|
555
|
+
}
|
|
556
|
+
record = queue.get(iid)
|
|
557
|
+
if record is not None:
|
|
558
|
+
entry["status"] = record.status
|
|
559
|
+
if record.status in ("budget_exhausted", "needs_human"):
|
|
560
|
+
entry["handoff"] = handoff
|
|
561
|
+
blocked.append(entry)
|
|
562
|
+
reported.append(iid)
|
|
563
|
+
waits: dict[str, set[str]] = {}
|
|
564
|
+
waiting = set(summary.blocked)
|
|
565
|
+
for iid in reported:
|
|
566
|
+
for dependent in graph.downstream(iid) & waiting:
|
|
567
|
+
waits.setdefault(dependent, set()).add(iid)
|
|
568
|
+
for dependent in sorted(waits):
|
|
569
|
+
on = sorted(waits[dependent])
|
|
570
|
+
blocked.append(
|
|
571
|
+
{
|
|
572
|
+
"instance": dependent,
|
|
573
|
+
"reason": f"depends on {', '.join(on)}, which did not finish",
|
|
574
|
+
"blocked_by": on,
|
|
575
|
+
}
|
|
576
|
+
)
|
|
577
|
+
return blocked
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
# --- revise: a person rejected a gate with a note (M2-11b) ------------------------------
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _revision_path(ctx: AppContext, task_id: str) -> Path:
|
|
584
|
+
"""Where a task's pending revision note is kept (state backend, engine-owned)."""
|
|
585
|
+
return ctx.layout.state_dir / "runtime" / "revisions" / f"{state_key(task_id)}.json"
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _revision(ctx: AppContext, task_id: str) -> dict[str, Any] | None:
|
|
589
|
+
"""The person's note the task's next attempts must answer, if any."""
|
|
590
|
+
path = _revision_path(ctx, task_id)
|
|
591
|
+
try:
|
|
592
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
593
|
+
except (OSError, ValueError):
|
|
594
|
+
return None
|
|
595
|
+
return data if isinstance(data, dict) else None
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _revision_text(revision: Mapping[str, Any]) -> str:
|
|
599
|
+
return (
|
|
600
|
+
f"A person ({revision.get('by', '?')}) rejected your last output at gate "
|
|
601
|
+
f"{revision.get('gate', '?')!r} with this note: {revision.get('note', '')} "
|
|
602
|
+
"Revise your outputs so they answer the note, and keep what the note does not ask "
|
|
603
|
+
"to change."
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _latest_decision(ctx: AppContext, gate_id: str, instance: RuleInstance) -> Approval | None:
|
|
608
|
+
"""The latest current decision file for `gate_id` over `instance`'s file inputs (the
|
|
609
|
+
rule `GateEvaluator` applies; waivers aside)."""
|
|
610
|
+
refs = [ref for ref in instance.inputs if ref.path is not None]
|
|
611
|
+
current = ctx.store.current_hashes(refs)
|
|
612
|
+
decisions = sorted(
|
|
613
|
+
(
|
|
614
|
+
a
|
|
615
|
+
for a in ctx.review.approvals(gate_id)
|
|
616
|
+
if a.decision != "waive"
|
|
617
|
+
and a.is_current(current)
|
|
618
|
+
and set(current) <= set(a.artifact_hashes)
|
|
619
|
+
),
|
|
620
|
+
key=lambda a: a.at,
|
|
621
|
+
)
|
|
622
|
+
return decisions[-1] if decisions else None
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _revise_rejected(ctx: AppContext, scheduler: Scheduler, target: str) -> list[dict[str, Any]]:
|
|
626
|
+
"""Hand an accepted agent task out again when a person rejected its output, with a note.
|
|
627
|
+
|
|
628
|
+
For each gated instance of `target` whose gate is `rejected` by a decision file (a
|
|
629
|
+
person's `approve(reject=true, note=...)` or `chipgraph approve --reject --note`), when
|
|
630
|
+
every file the gate covers is the output of an accepted agent task that was accepted
|
|
631
|
+
before the rejection: each such task is reopened as a new `ready` task (a new budget
|
|
632
|
+
cycle) and rewound in the build, and the note is kept for its context
|
|
633
|
+
(`previous_rejection`) until it is accepted again. A rejection with no note, of a gate
|
|
634
|
+
over any file no agent writes, or one the task already answered (accepted after it)
|
|
635
|
+
changes nothing: the gate stays rejected for a person to decide.
|
|
636
|
+
"""
|
|
637
|
+
graph = scheduler.graph
|
|
638
|
+
gates = scheduler.gates
|
|
639
|
+
if gates is None:
|
|
640
|
+
return []
|
|
641
|
+
queue = _queue(ctx)
|
|
642
|
+
producers: dict[str, str] = {}
|
|
643
|
+
for iid, inst in graph.instances.items():
|
|
644
|
+
if graph.rules[inst.rule_id].kind == "agent":
|
|
645
|
+
for ref in inst.outputs:
|
|
646
|
+
if ref.path is not None:
|
|
647
|
+
producers[ref.path] = iid
|
|
648
|
+
revised: list[dict[str, Any]] = []
|
|
649
|
+
for iid in sorted(graph.select(target)):
|
|
650
|
+
instance = graph.instances[iid]
|
|
651
|
+
rule = graph.rules[instance.rule_id]
|
|
652
|
+
if not rule.gate:
|
|
653
|
+
continue
|
|
654
|
+
gate_id = gate_id_for(rule.gate, instance.params)
|
|
655
|
+
if gates.status(gate_id, instance) != "rejected":
|
|
656
|
+
continue
|
|
657
|
+
decision = _latest_decision(ctx, gate_id, instance)
|
|
658
|
+
if decision is None or decision.decision != "reject" or not decision.note.strip():
|
|
659
|
+
continue
|
|
660
|
+
covered = [ref.path for ref in instance.inputs if ref.path is not None]
|
|
661
|
+
owners = {producers.get(path) for path in covered}
|
|
662
|
+
if not covered or None in owners:
|
|
663
|
+
continue
|
|
664
|
+
for task_id in sorted(o for o in owners if o is not None):
|
|
665
|
+
record = queue.get(task_id)
|
|
666
|
+
if record is None or record.status != "accepted" or record.updated_at >= decision.at:
|
|
667
|
+
continue
|
|
668
|
+
task_rule, task_instance = _instance(graph, task_id)
|
|
669
|
+
# `enqueue` reopens an accepted task whose outputs no longer hash as accepted:
|
|
670
|
+
# the person's rejection says they no longer hold.
|
|
671
|
+
queue.enqueue(
|
|
672
|
+
task_for(task_rule, task_instance),
|
|
673
|
+
inputs_hash=instance_inputs_hash(ctx.store, task_instance),
|
|
674
|
+
output_hashes={"<rejected>": decision.at.isoformat()},
|
|
675
|
+
tool_call_cap=record.tool_call_cap,
|
|
676
|
+
max_dispatches=record.max_dispatches,
|
|
677
|
+
)
|
|
678
|
+
scheduler.rewind(task_id)
|
|
679
|
+
item = {
|
|
680
|
+
"task_id": task_id,
|
|
681
|
+
"gate": gate_id,
|
|
682
|
+
"by": decision.by,
|
|
683
|
+
"at": decision.at.isoformat(),
|
|
684
|
+
"note": decision.note,
|
|
685
|
+
}
|
|
686
|
+
path = _revision_path(ctx, task_id)
|
|
687
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
688
|
+
path.write_text(json.dumps({"schema_version": 1, **item}, indent=2), encoding="utf-8")
|
|
689
|
+
revised.append(item)
|
|
690
|
+
return revised
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
# --- Best-of-N dispatch helpers ---------------------------------------------------------
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _dispatch_candidates(
|
|
697
|
+
ctx: AppContext,
|
|
698
|
+
queue: TaskQueue,
|
|
699
|
+
graph: BuildGraph,
|
|
700
|
+
base_record: AgentTaskRecord,
|
|
701
|
+
rule: RuleSpec,
|
|
702
|
+
instance: RuleInstance,
|
|
703
|
+
run_id: str,
|
|
704
|
+
baseline: dict[str, str],
|
|
705
|
+
n: int,
|
|
706
|
+
) -> tuple[BestOfNRound, list[tuple[AgentTaskRecord, int, str]]]:
|
|
707
|
+
"""Dispatch N candidates for a best-of-N round.
|
|
708
|
+
|
|
709
|
+
Returns the round state and list of (record, candidate_num, variant) tuples.
|
|
710
|
+
"""
|
|
711
|
+
profile = ctx.require_profile().profile
|
|
712
|
+
vcs = ctx.registry.get("vcs", "git")
|
|
713
|
+
inputs = input_paths_for(instance)
|
|
714
|
+
|
|
715
|
+
# Plan the budget
|
|
716
|
+
budget_plan = plan_budget(
|
|
717
|
+
rule.budget,
|
|
718
|
+
requested=n,
|
|
719
|
+
cfg=profile.select,
|
|
720
|
+
max_cost=None, # No cost cap in this runtime
|
|
721
|
+
variants=len(VARIANTS),
|
|
722
|
+
)
|
|
723
|
+
actual_n = budget_plan.n
|
|
724
|
+
|
|
725
|
+
# Compute round number: base record's attempts counts failed rounds, +1 for current
|
|
726
|
+
# A failed round increments attempts by 1, so round_num = attempts + 1
|
|
727
|
+
round_num = base_record.attempts + 1
|
|
728
|
+
|
|
729
|
+
# Get variant hints
|
|
730
|
+
variant_ids = list(VARIANTS.keys())[:actual_n]
|
|
731
|
+
variant_hints = [VARIANTS[vid] for vid in variant_ids]
|
|
732
|
+
|
|
733
|
+
# Create candidates
|
|
734
|
+
candidates: list[CandidateState] = []
|
|
735
|
+
dispatched: list[tuple[AgentTaskRecord, int, str]] = []
|
|
736
|
+
|
|
737
|
+
for i, (_vid, variant) in enumerate(zip(variant_ids, variant_hints, strict=False), 1):
|
|
738
|
+
cand_task_id = candidate_id(base_record.task_id, i, round_num)
|
|
739
|
+
|
|
740
|
+
# Create workspace for this candidate
|
|
741
|
+
try:
|
|
742
|
+
ws, seed_hashes = create_workspace(
|
|
743
|
+
ctx, vcs, run_id, cand_task_id, inputs, tuple(base_record.allowed_writes)
|
|
744
|
+
)
|
|
745
|
+
except Exception as exc:
|
|
746
|
+
# If workspace creation fails, clean up and raise
|
|
747
|
+
for _, cand_num, _ in dispatched:
|
|
748
|
+
cand_id = candidate_id(base_record.task_id, cand_num, round_num)
|
|
749
|
+
cand_rec = queue.get(cand_id)
|
|
750
|
+
if cand_rec and cand_rec.workspace:
|
|
751
|
+
with contextlib.suppress(Exception):
|
|
752
|
+
remove_workspace(ctx, vcs, cand_rec.workspace)
|
|
753
|
+
raise AppError(f"failed to create workspace for candidate {i}: {exc}") from exc
|
|
754
|
+
|
|
755
|
+
# Dispatch the candidate
|
|
756
|
+
try:
|
|
757
|
+
cand_record = queue.dispatch(
|
|
758
|
+
cand_task_id,
|
|
759
|
+
run_id=run_id,
|
|
760
|
+
baseline=baseline,
|
|
761
|
+
denied_reads=_denied_reads(ctx, graph, base_record),
|
|
762
|
+
workspace=str(ws),
|
|
763
|
+
seed_hashes=seed_hashes,
|
|
764
|
+
)
|
|
765
|
+
except QueueError:
|
|
766
|
+
# The candidate is a record of the same instance under its own id
|
|
767
|
+
from chipgraph.core.plugin_api.types import AgentTask
|
|
768
|
+
|
|
769
|
+
cand_task = AgentTask(
|
|
770
|
+
instance=instance,
|
|
771
|
+
role=base_record.role,
|
|
772
|
+
skills=base_record.skills,
|
|
773
|
+
allowed_writes=base_record.allowed_writes,
|
|
774
|
+
budget=budget_plan.slice,
|
|
775
|
+
)
|
|
776
|
+
queue.enqueue(
|
|
777
|
+
cand_task,
|
|
778
|
+
inputs_hash=base_record.inputs_hash,
|
|
779
|
+
output_hashes={},
|
|
780
|
+
tool_call_cap=base_record.tool_call_cap,
|
|
781
|
+
max_dispatches=loop.max_dispatches(budget_plan.slice.tries),
|
|
782
|
+
task_id=cand_task_id,
|
|
783
|
+
)
|
|
784
|
+
cand_record = queue.dispatch(
|
|
785
|
+
cand_task_id,
|
|
786
|
+
run_id=run_id,
|
|
787
|
+
baseline=baseline,
|
|
788
|
+
denied_reads=_denied_reads(ctx, graph, base_record),
|
|
789
|
+
workspace=str(ws),
|
|
790
|
+
seed_hashes=seed_hashes,
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
candidates.append(
|
|
794
|
+
CandidateState(
|
|
795
|
+
candidate=i,
|
|
796
|
+
variant=variant,
|
|
797
|
+
task_id=cand_task_id,
|
|
798
|
+
workspace=str(ws),
|
|
799
|
+
status="dispatched",
|
|
800
|
+
)
|
|
801
|
+
)
|
|
802
|
+
dispatched.append((cand_record, i, variant))
|
|
803
|
+
|
|
804
|
+
round_state = BestOfNRound(
|
|
805
|
+
base_task_id=base_record.task_id,
|
|
806
|
+
run_id=run_id,
|
|
807
|
+
round_num=round_num,
|
|
808
|
+
n=actual_n,
|
|
809
|
+
candidates=tuple(candidates),
|
|
810
|
+
ledger_tries=budget_plan.limits.tries,
|
|
811
|
+
ledger_used=0,
|
|
812
|
+
status="running",
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
return round_state, dispatched
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
# --- next_task --------------------------------------------------------------------------
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
async def next_task(ctx: AppContext, target: str = "*") -> dict[str, Any]:
|
|
822
|
+
"""Run the build for `target` and hand out every agent task it reached.
|
|
823
|
+
|
|
824
|
+
Returns `tasks` (newly dispatched: each goes to its own subagent, in parallel),
|
|
825
|
+
`in_progress` (dispatched earlier and not submitted yet), `blocked` (gates, people,
|
|
826
|
+
failures, stopped tasks with their label and HANDOFF path, and the rules that
|
|
827
|
+
depend on them), and either `done: true` (the build finished) or, with nothing to
|
|
828
|
+
run, `stopped: true`, `waiting` (why) and `handoff` (this run's HANDOFF.md).
|
|
829
|
+
|
|
830
|
+
A task is never handed out more than its `max_dispatches` (tries plus infra
|
|
831
|
+
retries) in one budget cycle, and never once it is `budget_exhausted` or
|
|
832
|
+
`needs_human`.
|
|
833
|
+
|
|
834
|
+
At the start, sweeps stale workspaces left by killed runs (a process that started
|
|
835
|
+
workspaces and died without cleaning up).
|
|
836
|
+
"""
|
|
837
|
+
ctx.require_profile()
|
|
838
|
+
_require_runtime(ctx)
|
|
839
|
+
|
|
840
|
+
# Sweep stale workspaces from killed runs
|
|
841
|
+
try:
|
|
842
|
+
vcs = ctx.registry.get("vcs", "git")
|
|
843
|
+
sweep_workspaces(ctx, vcs)
|
|
844
|
+
except Exception:
|
|
845
|
+
pass # Best effort; don't block the build
|
|
846
|
+
|
|
847
|
+
scheduler = make_scheduler(ctx, target)
|
|
848
|
+
_check_skills(ctx, scheduler.graph)
|
|
849
|
+
revised = _revise_rejected(ctx, scheduler, target)
|
|
850
|
+
summary = await scheduler.run(target)
|
|
851
|
+
graph = scheduler.graph
|
|
852
|
+
queue = _queue(ctx)
|
|
853
|
+
for item in revised:
|
|
854
|
+
_emit(ctx, summary.run_id, item["task_id"], "agent_turn", {"phase": "revise", **item})
|
|
855
|
+
|
|
856
|
+
agent_ids = [
|
|
857
|
+
iid
|
|
858
|
+
for iid in sorted(graph.select(target))
|
|
859
|
+
if graph.rules[graph.instances[iid].rule_id].kind == "agent"
|
|
860
|
+
]
|
|
861
|
+
now: dict[str, str] | None = None
|
|
862
|
+
dispatched: list[AgentTaskRecord] = []
|
|
863
|
+
in_progress: list[AgentTaskRecord] = []
|
|
864
|
+
held: list[dict[str, Any]] = []
|
|
865
|
+
for iid in agent_ids:
|
|
866
|
+
record = queue.get(iid)
|
|
867
|
+
if record is None:
|
|
868
|
+
continue
|
|
869
|
+
if record.status in ACTIVE_STATUSES:
|
|
870
|
+
in_progress.append(record)
|
|
871
|
+
continue
|
|
872
|
+
# Only tasks this build actually reached (their executor ran and said "waiting").
|
|
873
|
+
if record.status not in OPEN_STATUSES or iid not in summary.failed:
|
|
874
|
+
continue
|
|
875
|
+
limit = loop.dispatch_limit(record)
|
|
876
|
+
if record.state.dispatches >= limit: # the hard bound; a live loop stops before
|
|
877
|
+
held.append(
|
|
878
|
+
{
|
|
879
|
+
"instance": iid,
|
|
880
|
+
"reason": (
|
|
881
|
+
f"handed out {record.state.dispatches} times, its limit (tries plus "
|
|
882
|
+
"infra retries); a person must look at it"
|
|
883
|
+
),
|
|
884
|
+
"label": record.state.label,
|
|
885
|
+
"status": record.status,
|
|
886
|
+
}
|
|
887
|
+
)
|
|
888
|
+
continue
|
|
889
|
+
if now is None:
|
|
890
|
+
now = await snapshot(ctx)
|
|
891
|
+
if record.outside:
|
|
892
|
+
before = queue.baseline(iid)
|
|
893
|
+
left = [p for p in record.outside if before.get(p) != now.get(p)]
|
|
894
|
+
if left:
|
|
895
|
+
held.append(
|
|
896
|
+
{
|
|
897
|
+
"instance": iid,
|
|
898
|
+
"reason": (
|
|
899
|
+
"the last attempt changed files outside the task's outputs; "
|
|
900
|
+
f"revert them before it runs again: {', '.join(left)}"
|
|
901
|
+
),
|
|
902
|
+
}
|
|
903
|
+
)
|
|
904
|
+
continue
|
|
905
|
+
# A role without a read tool cannot overwrite a file: move its outputs aside
|
|
906
|
+
# first, so each `Write` creates one (`previous`); put them back if the
|
|
907
|
+
# dispatch fails here.
|
|
908
|
+
mover = PreviousOutputs(queue, iid, ctx.root)
|
|
909
|
+
token: MoveToken | None = None
|
|
910
|
+
if moves_outputs(find_role(record.role)):
|
|
911
|
+
token = mover.move_aside(record.allowed_writes, dispatch=record.dispatches + 1)
|
|
912
|
+
moved = [m.path for m in token.moved] if token is not None else []
|
|
913
|
+
for path in moved:
|
|
914
|
+
now.pop(path, None)
|
|
915
|
+
|
|
916
|
+
# Isolation: create workspace if fanout.isolate is true
|
|
917
|
+
workspace_path: str | None = None
|
|
918
|
+
seed_hashes: dict[str, str] = {}
|
|
919
|
+
profile = ctx.require_profile().profile
|
|
920
|
+
rule, instance = _instance(graph, iid)
|
|
921
|
+
# A reviewing role (the Critic) writes nothing: the engine records its report in the
|
|
922
|
+
# user's tree. There is nothing to isolate, and it reads the project as it is.
|
|
923
|
+
isolated = should_isolate(profile, rule) and not review_mod.is_review_role(
|
|
924
|
+
find_role(record.role)
|
|
925
|
+
)
|
|
926
|
+
|
|
927
|
+
# F2: check overlapping write sets with tasks being dispatched this cycle
|
|
928
|
+
# (only for isolated tasks - non-isolated tasks write to user's tree directly)
|
|
929
|
+
if isolated:
|
|
930
|
+
# Check max_parallel limit (only for isolated tasks with workspaces)
|
|
931
|
+
max_par = max_parallel_limit(profile)
|
|
932
|
+
live_workspaces = len([r for r in (*dispatched, *in_progress) if r.workspace])
|
|
933
|
+
if live_workspaces >= max_par:
|
|
934
|
+
if token is not None:
|
|
935
|
+
mover.undo(token)
|
|
936
|
+
held.append(
|
|
937
|
+
{
|
|
938
|
+
"instance": iid,
|
|
939
|
+
"reason": (
|
|
940
|
+
f"max_parallel limit reached ({max_par} workspaces); "
|
|
941
|
+
"waiting for a workspace to finish"
|
|
942
|
+
),
|
|
943
|
+
}
|
|
944
|
+
)
|
|
945
|
+
continue
|
|
946
|
+
|
|
947
|
+
# Check for overlapping write sets with tasks already being dispatched
|
|
948
|
+
dispatching_writes = {r.task_id: r.allowed_writes for r in dispatched if r.workspace}
|
|
949
|
+
dispatching_writes[iid] = record.allowed_writes
|
|
950
|
+
overlaps = check_overlapping_writes(dispatching_writes)
|
|
951
|
+
if overlaps and any(iid in tasks for tasks in overlaps.values()):
|
|
952
|
+
if token is not None:
|
|
953
|
+
mover.undo(token)
|
|
954
|
+
# Find which paths overlap
|
|
955
|
+
overlap_info = [
|
|
956
|
+
f"{path}: {', '.join(tasks)}"
|
|
957
|
+
for path, tasks in overlaps.items()
|
|
958
|
+
if iid in tasks
|
|
959
|
+
]
|
|
960
|
+
held.append(
|
|
961
|
+
{
|
|
962
|
+
"instance": iid,
|
|
963
|
+
"reason": (
|
|
964
|
+
f"write set overlaps with another task being dispatched: "
|
|
965
|
+
f"{'; '.join(overlap_info)}"
|
|
966
|
+
),
|
|
967
|
+
}
|
|
968
|
+
)
|
|
969
|
+
continue
|
|
970
|
+
|
|
971
|
+
if isolated:
|
|
972
|
+
try:
|
|
973
|
+
vcs = ctx.registry.get("vcs", "git")
|
|
974
|
+
inputs = input_paths_for(instance)
|
|
975
|
+
|
|
976
|
+
# Check for best-of-N opt-in
|
|
977
|
+
requested_n = best_of_n(profile, rule.id, dict(instance.params))
|
|
978
|
+
if requested_n is not None and requested_n >= 2:
|
|
979
|
+
# Best-of-N: dispatch multiple candidates
|
|
980
|
+
try:
|
|
981
|
+
round_state, cand_dispatched = _dispatch_candidates(
|
|
982
|
+
ctx,
|
|
983
|
+
queue,
|
|
984
|
+
graph,
|
|
985
|
+
record,
|
|
986
|
+
rule,
|
|
987
|
+
instance,
|
|
988
|
+
summary.run_id,
|
|
989
|
+
now,
|
|
990
|
+
requested_n,
|
|
991
|
+
)
|
|
992
|
+
_save_best_of_round(ctx, round_state)
|
|
993
|
+
|
|
994
|
+
# Add all candidates to dispatched and emit events
|
|
995
|
+
for cand_record, cand_num, variant in cand_dispatched:
|
|
996
|
+
payload = {
|
|
997
|
+
"phase": "dispatch",
|
|
998
|
+
"attempt": cand_record.dispatches,
|
|
999
|
+
"try": cand_record.state.tries_used + 1,
|
|
1000
|
+
"tier": cand_record.current_tier,
|
|
1001
|
+
"outputs": list(cand_record.allowed_writes),
|
|
1002
|
+
"agent": _agent_for(cand_record.role),
|
|
1003
|
+
"model": _model_for(ctx, cand_record),
|
|
1004
|
+
"denied_reads": list(cand_record.denied_reads),
|
|
1005
|
+
"workspace": cand_record.workspace,
|
|
1006
|
+
"best_of": {
|
|
1007
|
+
"base_task": iid,
|
|
1008
|
+
"candidate": cand_num,
|
|
1009
|
+
"n": round_state.n,
|
|
1010
|
+
"round": round_state.round_num,
|
|
1011
|
+
"variant": variant,
|
|
1012
|
+
},
|
|
1013
|
+
}
|
|
1014
|
+
_emit(
|
|
1015
|
+
ctx, cand_record.run_id, cand_record.task_id, "agent_turn", payload
|
|
1016
|
+
)
|
|
1017
|
+
dispatched.append(cand_record)
|
|
1018
|
+
continue
|
|
1019
|
+
|
|
1020
|
+
except AppError as exc:
|
|
1021
|
+
if token is not None:
|
|
1022
|
+
mover.undo(token)
|
|
1023
|
+
held.append(
|
|
1024
|
+
{"instance": iid, "reason": f"best-of-N dispatch failed: {exc}"}
|
|
1025
|
+
)
|
|
1026
|
+
continue
|
|
1027
|
+
|
|
1028
|
+
# Regular isolated dispatch (best-of-1 or not opted in)
|
|
1029
|
+
ws, seed_hashes = create_workspace(
|
|
1030
|
+
ctx, vcs, summary.run_id, iid, inputs, tuple(record.allowed_writes)
|
|
1031
|
+
)
|
|
1032
|
+
workspace_path = str(ws)
|
|
1033
|
+
except Exception as exc:
|
|
1034
|
+
if token is not None:
|
|
1035
|
+
mover.undo(token)
|
|
1036
|
+
held.append(
|
|
1037
|
+
{
|
|
1038
|
+
"instance": iid,
|
|
1039
|
+
"reason": f"failed to create isolated workspace: {exc}",
|
|
1040
|
+
}
|
|
1041
|
+
)
|
|
1042
|
+
continue
|
|
1043
|
+
|
|
1044
|
+
try:
|
|
1045
|
+
record = queue.dispatch(
|
|
1046
|
+
iid,
|
|
1047
|
+
run_id=summary.run_id,
|
|
1048
|
+
baseline=now,
|
|
1049
|
+
denied_reads=_denied_reads(ctx, graph, record),
|
|
1050
|
+
workspace=workspace_path,
|
|
1051
|
+
seed_hashes=seed_hashes,
|
|
1052
|
+
)
|
|
1053
|
+
except BaseException:
|
|
1054
|
+
if token is not None:
|
|
1055
|
+
mover.undo(token)
|
|
1056
|
+
if workspace_path:
|
|
1057
|
+
try:
|
|
1058
|
+
vcs = ctx.registry.get("vcs", "git")
|
|
1059
|
+
remove_workspace(ctx, vcs, workspace_path)
|
|
1060
|
+
except Exception:
|
|
1061
|
+
pass
|
|
1062
|
+
raise
|
|
1063
|
+
payload = {
|
|
1064
|
+
"phase": "dispatch",
|
|
1065
|
+
"attempt": record.dispatches,
|
|
1066
|
+
"try": record.state.tries_used + 1,
|
|
1067
|
+
"tier": record.current_tier,
|
|
1068
|
+
"outputs": list(record.allowed_writes),
|
|
1069
|
+
"agent": _agent_for(record.role),
|
|
1070
|
+
"model": _model_for(ctx, record),
|
|
1071
|
+
"denied_reads": list(record.denied_reads),
|
|
1072
|
+
}
|
|
1073
|
+
if moved:
|
|
1074
|
+
payload["moved_aside"] = moved
|
|
1075
|
+
if workspace_path:
|
|
1076
|
+
payload["workspace"] = workspace_path
|
|
1077
|
+
_emit(ctx, record.run_id, iid, "agent_turn", payload)
|
|
1078
|
+
dispatched.append(record)
|
|
1079
|
+
|
|
1080
|
+
skip = {r.task_id for r in (*dispatched, *in_progress)} | {h["instance"] for h in held}
|
|
1081
|
+
blocked = held + _blocked(ctx, graph, queue, summary, skip)
|
|
1082
|
+
|
|
1083
|
+
# Build task entries, including candidate info for best-of-N tasks
|
|
1084
|
+
def _build_task_entry(r: AgentTaskRecord) -> dict[str, Any]:
|
|
1085
|
+
if is_candidate_id(r.task_id):
|
|
1086
|
+
base_task_id, cand_num, _round_num = parse_candidate_id(r.task_id)
|
|
1087
|
+
round_state = _load_best_of_round(ctx, base_task_id)
|
|
1088
|
+
if round_state:
|
|
1089
|
+
cand_state = next(
|
|
1090
|
+
(c for c in round_state.candidates if c.candidate == cand_num), None
|
|
1091
|
+
)
|
|
1092
|
+
if cand_state:
|
|
1093
|
+
return _task_entry(
|
|
1094
|
+
ctx, queue, r, candidate=cand_num, variant=cand_state.variant
|
|
1095
|
+
)
|
|
1096
|
+
return _task_entry(ctx, queue, r)
|
|
1097
|
+
|
|
1098
|
+
answer: dict[str, Any] = {
|
|
1099
|
+
"run_id": summary.run_id,
|
|
1100
|
+
"tasks": [_build_task_entry(r) for r in dispatched],
|
|
1101
|
+
"in_progress": [_build_task_entry(r) for r in in_progress],
|
|
1102
|
+
"done": False,
|
|
1103
|
+
"stopped": False,
|
|
1104
|
+
"waiting": None,
|
|
1105
|
+
"blocked": blocked,
|
|
1106
|
+
"handoff": None,
|
|
1107
|
+
}
|
|
1108
|
+
if revised:
|
|
1109
|
+
answer["revised"] = revised
|
|
1110
|
+
if dispatched or in_progress:
|
|
1111
|
+
return answer
|
|
1112
|
+
if summary.ok and not blocked:
|
|
1113
|
+
answer["done"] = True
|
|
1114
|
+
return answer
|
|
1115
|
+
answer["stopped"] = True
|
|
1116
|
+
answer["handoff"] = str(_handoff_path(ctx, summary.run_id))
|
|
1117
|
+
answer["waiting"] = "; ".join(f"{b['instance']}: {b['reason']}" for b in blocked) or (
|
|
1118
|
+
f"{len(summary.blocked)} rule instance(s) blocked"
|
|
1119
|
+
)
|
|
1120
|
+
return answer
|
|
1121
|
+
|
|
1122
|
+
|
|
1123
|
+
# --- get_context ------------------------------------------------------------------------
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
def _is_nda(ctx: AppContext, path: str, label: str) -> bool:
|
|
1127
|
+
return label == "nda" or ctx.store.labels.label_for(path) == "nda"
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
def _model_input(ctx: AppContext, key: str) -> dict[str, Any]:
|
|
1131
|
+
entry: dict[str, Any] = {"model_key": key}
|
|
1132
|
+
kind, _, name = key.partition("/")
|
|
1133
|
+
db = default_model_db_path(ctx.layout.root)
|
|
1134
|
+
if kind != "block" or not name or not db.is_file():
|
|
1135
|
+
entry["note"] = "not available from the Design Model (run `chipgraph ingest` first)"
|
|
1136
|
+
return entry
|
|
1137
|
+
store = ModelStore(db)
|
|
1138
|
+
try:
|
|
1139
|
+
info = ModelQuery(store.read(), store).block(name)
|
|
1140
|
+
except QueryError as exc:
|
|
1141
|
+
entry["note"] = str(exc)
|
|
1142
|
+
return entry
|
|
1143
|
+
entry["content"] = json.dumps(info.model_dump(mode="json"), indent=1, sort_keys=True)
|
|
1144
|
+
return entry
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
def _refuse(
|
|
1148
|
+
ctx: AppContext,
|
|
1149
|
+
queue: TaskQueue,
|
|
1150
|
+
record: AgentTaskRecord,
|
|
1151
|
+
question: str,
|
|
1152
|
+
payload: Mapping[str, Any],
|
|
1153
|
+
) -> AppError:
|
|
1154
|
+
"""Hand the task to a person with `question`; the error for the tool call."""
|
|
1155
|
+
stopped = queue.needs_human(
|
|
1156
|
+
record.task_id, result=AgentResult(status="needs_human", open_questions=(question,))
|
|
1157
|
+
)
|
|
1158
|
+
restored = _settle(ctx, queue, stopped)
|
|
1159
|
+
extra = {"restored": restored} if restored else {}
|
|
1160
|
+
_emit(
|
|
1161
|
+
ctx,
|
|
1162
|
+
record.run_id,
|
|
1163
|
+
record.task_id,
|
|
1164
|
+
"agent_turn",
|
|
1165
|
+
{"phase": "context_refused", **payload, **extra},
|
|
1166
|
+
)
|
|
1167
|
+
return AppError(question)
|
|
1168
|
+
|
|
1169
|
+
|
|
1170
|
+
def _settle(ctx: AppContext, queue: TaskQueue, record: AgentTaskRecord) -> list[str]:
|
|
1171
|
+
"""Close the task's moved-outputs cycle once it ended (`previous`), and clean up workspace.
|
|
1172
|
+
|
|
1173
|
+
Accepted: the new files stay, workspace is removed (outputs were copied back already).
|
|
1174
|
+
`budget_exhausted` or `needs_human`: every file that existed before the task is put
|
|
1175
|
+
back, workspace is removed (nothing copied back). Returns the paths put back.
|
|
1176
|
+
|
|
1177
|
+
Every ending (accept, reject-and-redispatch, budget_exhausted, needs_human) removes
|
|
1178
|
+
the workspace. A rejected task that will be re-dispatched gets a fresh workspace.
|
|
1179
|
+
"""
|
|
1180
|
+
mover = PreviousOutputs(queue, record.task_id, ctx.root)
|
|
1181
|
+
restored: list[str] = []
|
|
1182
|
+
if record.status == "accepted":
|
|
1183
|
+
mover.accept()
|
|
1184
|
+
elif record.status in ("budget_exhausted", "needs_human"):
|
|
1185
|
+
restored = mover.restore(dispatch=record.dispatches)
|
|
1186
|
+
|
|
1187
|
+
# Clean up workspace for any terminal status (accepted, budget_exhausted, needs_human)
|
|
1188
|
+
# For rejected tasks, the workspace is removed too; next_task creates a fresh one
|
|
1189
|
+
terminal_statuses = ("accepted", "budget_exhausted", "needs_human", "rejected")
|
|
1190
|
+
if record.workspace and record.status in terminal_statuses:
|
|
1191
|
+
try:
|
|
1192
|
+
vcs = ctx.registry.get("vcs", "git")
|
|
1193
|
+
remove_workspace(ctx, vcs, record.workspace)
|
|
1194
|
+
except Exception:
|
|
1195
|
+
pass # Best effort cleanup; the sweep will get it later
|
|
1196
|
+
|
|
1197
|
+
return restored
|
|
1198
|
+
|
|
1199
|
+
|
|
1200
|
+
def _unseeable(role: RoleSpec | None, record: AgentTaskRecord, instance: RuleInstance) -> list[str]:
|
|
1201
|
+
"""Inputs the task's role must not see: of a denied kind (by kind or by name), or on a
|
|
1202
|
+
denied path."""
|
|
1203
|
+
if role is None or role.read_policy.mode != "deny":
|
|
1204
|
+
return []
|
|
1205
|
+
denied = record.denied_reads
|
|
1206
|
+
return sorted(
|
|
1207
|
+
ref.path or str(ref.model_key)
|
|
1208
|
+
for ref in instance.inputs
|
|
1209
|
+
if denied_kind(role, ref.kind, ref.path) is not None
|
|
1210
|
+
or (ref.path is not None and path_denied(ref.path, denied))
|
|
1211
|
+
)
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
def _denies(role: RoleSpec | None, record: AgentTaskRecord) -> Callable[[str], bool]:
|
|
1215
|
+
"""Whether the task's role must not read a repo-relative path: one of its
|
|
1216
|
+
`denied_reads`, or a file its read policy denies by kind or by name."""
|
|
1217
|
+
|
|
1218
|
+
def denied(path: str) -> bool:
|
|
1219
|
+
if path_denied(path, record.denied_reads):
|
|
1220
|
+
return True
|
|
1221
|
+
return role is not None and denied_kind(role, kind_for(path), path) is not None
|
|
1222
|
+
|
|
1223
|
+
return denied
|
|
1224
|
+
|
|
1225
|
+
|
|
1226
|
+
def _instructions(role: RoleSpec | None, rule: RuleSpec, skills: tuple[SkillSpec, ...]) -> str:
|
|
1227
|
+
if review_mod.is_review_role(role):
|
|
1228
|
+
follow = " Follow the instructions in `skill_texts`." if skills else ""
|
|
1229
|
+
return (
|
|
1230
|
+
"Review the change in `review.diff` against `review.spec` and `review.model`. "
|
|
1231
|
+
"You write no file and have no shell: reply with one JSON object that follows "
|
|
1232
|
+
"`review.reply_schema`, with `target`, `base` and `head` as in `review`. Every "
|
|
1233
|
+
"comment is on a file:line inside the diff and quotes its evidence as "
|
|
1234
|
+
f"'path:line text'; no comments means no findings.{follow} On submit the engine "
|
|
1235
|
+
"checks the review against this diff and writes it to `outputs[0]`; an invalid "
|
|
1236
|
+
"review is rejected and uses a try."
|
|
1237
|
+
)
|
|
1238
|
+
reading = (
|
|
1239
|
+
"You may read other project files for style."
|
|
1240
|
+
if role is None or role.can_read_files
|
|
1241
|
+
else "You have no file-reading tools, on purpose: use only this context."
|
|
1242
|
+
)
|
|
1243
|
+
follow = " Follow the instructions in `skill_texts`." if skills else ""
|
|
1244
|
+
return (
|
|
1245
|
+
"Write only the files in `outputs` (absolute paths in `outputs_abs`); the "
|
|
1246
|
+
f"chipgraph guard refuses any other write, and you have no shell. {reading}{follow} "
|
|
1247
|
+
"On submit the engine checks that only the outputs changed, that they exist, and "
|
|
1248
|
+
f"runs these checks: {', '.join(rule.checks) or 'none'}. "
|
|
1249
|
+
"Finish with a short report: files written, assumptions, open questions."
|
|
1250
|
+
)
|
|
1251
|
+
|
|
1252
|
+
|
|
1253
|
+
def _guarded(
|
|
1254
|
+
ctx: AppContext,
|
|
1255
|
+
role: RoleSpec | None,
|
|
1256
|
+
graph: BuildGraph,
|
|
1257
|
+
instance: RuleInstance,
|
|
1258
|
+
record: AgentTaskRecord,
|
|
1259
|
+
) -> GuardedContext | RtlAuthorContext | None:
|
|
1260
|
+
"""The guarded context of a task whose role must not see some kinds, else None: the
|
|
1261
|
+
testbench Author's (no RTL), or the RTL Author's (no testbench, M2-04)."""
|
|
1262
|
+
return guarded_context(ctx, role, instance, list(graph.rules.values()), _denies(role, record))
|
|
1263
|
+
|
|
1264
|
+
|
|
1265
|
+
def _feedback_filter(ctx: AppContext, record: AgentTaskRecord) -> FeedbackFilter | None:
|
|
1266
|
+
"""What `submit` may show the task's role of a failed check (None: everything)."""
|
|
1267
|
+
return FeedbackFilter.for_role(
|
|
1268
|
+
record.role, denied=record.denied_reads, root=str(ctx.root.resolve())
|
|
1269
|
+
)
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
def _previous_rejection(ctx: AppContext, record: AgentTaskRecord) -> list[str]:
|
|
1273
|
+
"""Why the last attempt was rejected: a person's note from a rejected gate (until the
|
|
1274
|
+
task is accepted again), then the redo instruction (it names the label and each
|
|
1275
|
+
failure as `file:line`), or the bare reasons on a record from before it."""
|
|
1276
|
+
revision = _revision(ctx, record.task_id)
|
|
1277
|
+
head = [_revision_text(revision)] if revision is not None else []
|
|
1278
|
+
state = record.budget_state
|
|
1279
|
+
if state is not None and state.redo:
|
|
1280
|
+
return [*head, state.redo]
|
|
1281
|
+
return [*head, *record.reasons]
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
|
+
async def get_context(ctx: AppContext, task_id: str) -> dict[str, Any]:
|
|
1285
|
+
"""The context of one dispatched task: inputs as text, outputs, role, skills.
|
|
1286
|
+
|
|
1287
|
+
Refused, with no content, when any input or output is labelled `nda`: every model
|
|
1288
|
+
this runtime uses is a cloud model. Refused too when an input is of a kind the task's
|
|
1289
|
+
role must not see (its `read_policy`), or on one of its `denied_reads`. Either way the
|
|
1290
|
+
task then waits for a person (`needs_human`).
|
|
1291
|
+
"""
|
|
1292
|
+
ctx.require_profile()
|
|
1293
|
+
_require_runtime(ctx)
|
|
1294
|
+
queue = _queue(ctx)
|
|
1295
|
+
record = _record(queue, task_id)
|
|
1296
|
+
if record.status != "dispatched":
|
|
1297
|
+
raise AppError(f"task {task_id!r} is {record.status}, not dispatched; call next_task")
|
|
1298
|
+
graph = _graph(ctx)
|
|
1299
|
+
rule, instance = _instance(graph, task_id)
|
|
1300
|
+
|
|
1301
|
+
nda = sorted(
|
|
1302
|
+
ref.path
|
|
1303
|
+
for ref in (*instance.inputs, *instance.outputs)
|
|
1304
|
+
if ref.path is not None and _is_nda(ctx, ref.path, ref.label)
|
|
1305
|
+
)
|
|
1306
|
+
if nda:
|
|
1307
|
+
question = (
|
|
1308
|
+
f"task {task_id!r} reads or writes data labelled 'nda' ({', '.join(nda)}); "
|
|
1309
|
+
"runtime claude-code sends context to a cloud model, so it is refused. Run the "
|
|
1310
|
+
"task with a local runtime, or have a person do it."
|
|
1311
|
+
)
|
|
1312
|
+
raise _refuse(ctx, queue, record, question, {"nda": nda})
|
|
1313
|
+
|
|
1314
|
+
role = find_role(record.role)
|
|
1315
|
+
unseeable = _unseeable(role, record, instance)
|
|
1316
|
+
if role is not None and unseeable:
|
|
1317
|
+
kinds = ", ".join(role.read_policy.deny_kinds) or "denied"
|
|
1318
|
+
question = (
|
|
1319
|
+
f"task {task_id!r} gives role {role.id!r} inputs it must not see "
|
|
1320
|
+
f"({kinds} artifacts or paths): {', '.join(unseeable)}; it is refused. Remove "
|
|
1321
|
+
"those inputs from the rule, or give the task to another role or a person."
|
|
1322
|
+
)
|
|
1323
|
+
raise _refuse(ctx, queue, record, question, {"denied_inputs": unseeable})
|
|
1324
|
+
try:
|
|
1325
|
+
skills = _skill_set(ctx).resolve(record.skills, record.role)
|
|
1326
|
+
except SkillError as exc:
|
|
1327
|
+
raise AppError(f"task {task_id!r}: {exc}") from exc
|
|
1328
|
+
|
|
1329
|
+
guarded = _guarded(ctx, role, graph, instance, record)
|
|
1330
|
+
extra_specs: list[str] = []
|
|
1331
|
+
if guarded is not None:
|
|
1332
|
+
listed = {ref.path for ref in instance.inputs}
|
|
1333
|
+
extra_specs = [
|
|
1334
|
+
p
|
|
1335
|
+
for p in guarded.spec_documents()
|
|
1336
|
+
if p not in listed and not path_denied(p, record.denied_reads)
|
|
1337
|
+
]
|
|
1338
|
+
nda_specs = [p for p in extra_specs if _is_nda(ctx, p, "internal")]
|
|
1339
|
+
if nda_specs:
|
|
1340
|
+
question = (
|
|
1341
|
+
f"task {task_id!r} would read the spec labelled 'nda' "
|
|
1342
|
+
f"({', '.join(nda_specs)}); runtime claude-code sends context to a cloud "
|
|
1343
|
+
"model, so it is refused. Run the task with a local runtime, or have a "
|
|
1344
|
+
"person do it."
|
|
1345
|
+
)
|
|
1346
|
+
raise _refuse(ctx, queue, record, question, {"nda": nda_specs})
|
|
1347
|
+
inputs: list[dict[str, Any]] = []
|
|
1348
|
+
for ref in instance.inputs:
|
|
1349
|
+
if ref.path is None:
|
|
1350
|
+
key = str(ref.model_key)
|
|
1351
|
+
inputs.append(guarded.model_input(key) if guarded else _model_input(ctx, key))
|
|
1352
|
+
continue
|
|
1353
|
+
entry: dict[str, Any] = {"path": ref.path, "kind": ref.kind}
|
|
1354
|
+
fragment = graph.spec_fragments.get(f"{ref.repo}:{ref.path}")
|
|
1355
|
+
if fragment:
|
|
1356
|
+
entry["fragment"] = fragment
|
|
1357
|
+
section = guarded.covers(ref.path) if guarded is not None else None
|
|
1358
|
+
if section is not None: # shown in a section of the context already
|
|
1359
|
+
entry["section"] = section
|
|
1360
|
+
elif ctx.store.exists(ref):
|
|
1361
|
+
text = (ctx.root / ref.path).read_text(encoding="utf-8", errors="replace")
|
|
1362
|
+
if len(text) > _MAX_INPUT_CHARS:
|
|
1363
|
+
text = text[:_MAX_INPUT_CHARS]
|
|
1364
|
+
entry["truncated"] = True
|
|
1365
|
+
entry["content"] = text
|
|
1366
|
+
else:
|
|
1367
|
+
entry["missing"] = True
|
|
1368
|
+
inputs.append(entry)
|
|
1369
|
+
for rel in extra_specs: # a guarded task's spec documents, from the profile layout
|
|
1370
|
+
text = (ctx.root / rel).read_text(encoding="utf-8", errors="replace")
|
|
1371
|
+
entry = {"path": rel, "kind": "spec", "from": "layout"}
|
|
1372
|
+
if len(text) > _MAX_INPUT_CHARS:
|
|
1373
|
+
text = text[:_MAX_INPUT_CHARS]
|
|
1374
|
+
entry["truncated"] = True
|
|
1375
|
+
entry["content"] = text
|
|
1376
|
+
inputs.append(entry)
|
|
1377
|
+
|
|
1378
|
+
review: dict[str, Any] | None = None
|
|
1379
|
+
if review_mod.is_review_role(role):
|
|
1380
|
+
scope, review = await review_mod.build_review(ctx, graph, rule, instance)
|
|
1381
|
+
nda_diff = sorted(p for p in scope.files if _is_nda(ctx, p, "public"))
|
|
1382
|
+
if nda_diff:
|
|
1383
|
+
question = (
|
|
1384
|
+
f"task {task_id!r} reviews files labelled 'nda' ({', '.join(nda_diff)}); "
|
|
1385
|
+
"runtime claude-code sends context to a cloud model, so it is refused. Run "
|
|
1386
|
+
"the review with a local runtime, or have a person do it."
|
|
1387
|
+
)
|
|
1388
|
+
raise _refuse(ctx, queue, record, question, {"nda": nda_diff})
|
|
1389
|
+
review_mod.save_scope(ctx, task_id, record.dispatches, scope)
|
|
1390
|
+
|
|
1391
|
+
_emit(
|
|
1392
|
+
ctx,
|
|
1393
|
+
record.run_id,
|
|
1394
|
+
task_id,
|
|
1395
|
+
"agent_turn",
|
|
1396
|
+
{"phase": "context", "attempt": record.dispatches},
|
|
1397
|
+
)
|
|
1398
|
+
root = ctx.root.resolve()
|
|
1399
|
+
# Determine the effective root for this task: workspace if isolated, else project root
|
|
1400
|
+
workspace = record.workspace
|
|
1401
|
+
if workspace:
|
|
1402
|
+
effective_root = Path(workspace)
|
|
1403
|
+
workspace_note = (
|
|
1404
|
+
" This task runs in an isolated workspace. The workspace IS the project for "
|
|
1405
|
+
"this task: write your outputs to the absolute paths in `outputs_abs`. The "
|
|
1406
|
+
"workspace is a copy of the project at HEAD with the task's inputs seeded in."
|
|
1407
|
+
)
|
|
1408
|
+
else:
|
|
1409
|
+
effective_root = root
|
|
1410
|
+
workspace_note = ""
|
|
1411
|
+
|
|
1412
|
+
answer: dict[str, Any] = {
|
|
1413
|
+
"task_id": task_id,
|
|
1414
|
+
"rule": rule.id,
|
|
1415
|
+
"description": rule.description,
|
|
1416
|
+
"role": record.role,
|
|
1417
|
+
"skills": list(record.skills),
|
|
1418
|
+
"skill_texts": {skill.id: skill.text for skill in skills},
|
|
1419
|
+
"params": dict(instance.params),
|
|
1420
|
+
"project_root": str(effective_root),
|
|
1421
|
+
"outputs": list(record.allowed_writes),
|
|
1422
|
+
"outputs_abs": [str(effective_root / p) for p in record.allowed_writes],
|
|
1423
|
+
"checks": list(rule.checks),
|
|
1424
|
+
"attempt": record.dispatches,
|
|
1425
|
+
"tries": record.tries,
|
|
1426
|
+
"previous_rejection": _previous_rejection(ctx, record),
|
|
1427
|
+
"previous_label": record.state.label,
|
|
1428
|
+
"inputs": inputs,
|
|
1429
|
+
"instructions": _instructions(role, rule, skills) + workspace_note,
|
|
1430
|
+
}
|
|
1431
|
+
if workspace:
|
|
1432
|
+
answer["workspace"] = workspace
|
|
1433
|
+
answer["isolated"] = True
|
|
1434
|
+
if review is not None:
|
|
1435
|
+
answer["review"] = review
|
|
1436
|
+
if guarded is not None:
|
|
1437
|
+
answer.update(guarded.sections())
|
|
1438
|
+
answer["instructions"] += guarded.instructions
|
|
1439
|
+
if moves_outputs(role):
|
|
1440
|
+
shown, withheld = PreviousOutputs(queue, task_id, ctx.root).context(
|
|
1441
|
+
record.allowed_writes, _denies(role, record)
|
|
1442
|
+
)
|
|
1443
|
+
answer["previous_outputs"] = shown
|
|
1444
|
+
answer["instructions"] += _previous_instructions(shown, withheld)
|
|
1445
|
+
if record.role == "planner":
|
|
1446
|
+
answer["plan"] = _planner_context(ctx, graph, instance)
|
|
1447
|
+
return answer
|
|
1448
|
+
|
|
1449
|
+
|
|
1450
|
+
def _previous_instructions(shown: list[dict[str, Any]], withheld: list[str]) -> str:
|
|
1451
|
+
"""What `get_context` tells an author without a read tool about its outputs."""
|
|
1452
|
+
text = (
|
|
1453
|
+
" Every output that existed was moved aside when this task was handed out, so "
|
|
1454
|
+
"each output is a new file: write it whole with Write (Edit has no file to change)."
|
|
1455
|
+
)
|
|
1456
|
+
if any("content" in entry for entry in shown):
|
|
1457
|
+
text += (
|
|
1458
|
+
" Your previous attempt is in `previous_outputs`; write the whole corrected "
|
|
1459
|
+
"file with Write."
|
|
1460
|
+
)
|
|
1461
|
+
if any("existing_output" in entry for entry in shown):
|
|
1462
|
+
text += (
|
|
1463
|
+
" A file that existed before this task is in `previous_outputs` under "
|
|
1464
|
+
"`existing_output`; it is put back if the task is not accepted."
|
|
1465
|
+
)
|
|
1466
|
+
if withheld:
|
|
1467
|
+
text += (
|
|
1468
|
+
f" {len(withheld)} moved file(s) are of a kind your role must not see; "
|
|
1469
|
+
"`previous_outputs` lists them without their text."
|
|
1470
|
+
)
|
|
1471
|
+
return text
|
|
1472
|
+
|
|
1473
|
+
|
|
1474
|
+
def _planner_context(ctx: AppContext, graph: BuildGraph, instance: RuleInstance) -> dict[str, Any]:
|
|
1475
|
+
"""A planner task's extra context (M2-03): the block's model slice, the paths it may
|
|
1476
|
+
write, the limits, the naming rule and the plan schema (`digital-rtl` pack)."""
|
|
1477
|
+
from chipgraph.packs.digital_rtl.plan.context import planner_context
|
|
1478
|
+
|
|
1479
|
+
block = instance.params.get("block")
|
|
1480
|
+
if block is None or not instance.outputs or instance.outputs[0].path is None:
|
|
1481
|
+
return {}
|
|
1482
|
+
return planner_context(
|
|
1483
|
+
ctx.root,
|
|
1484
|
+
ctx.require_profile(),
|
|
1485
|
+
list(graph.rules.values()),
|
|
1486
|
+
block,
|
|
1487
|
+
plan_path=instance.outputs[0].path,
|
|
1488
|
+
)
|
|
1489
|
+
|
|
1490
|
+
|
|
1491
|
+
# --- submit -----------------------------------------------------------------------------
|
|
1492
|
+
|
|
1493
|
+
|
|
1494
|
+
def _check_reason(result: CheckResult) -> str:
|
|
1495
|
+
msgs = "; ".join(i.msg for i in result.issues[:5]) or result.log_tail[-500:]
|
|
1496
|
+
return f"check {result.check_id} {result.status}: {msgs}".strip()
|
|
1497
|
+
|
|
1498
|
+
|
|
1499
|
+
async def _take_review(
|
|
1500
|
+
ctx: AppContext,
|
|
1501
|
+
graph: BuildGraph,
|
|
1502
|
+
rule: RuleSpec,
|
|
1503
|
+
instance: RuleInstance,
|
|
1504
|
+
record: AgentTaskRecord,
|
|
1505
|
+
report: SubmitReport,
|
|
1506
|
+
) -> tuple[ReviewReport | None, list[str]]:
|
|
1507
|
+
"""Validate a review task's reply and write it as the task's output (the engine does).
|
|
1508
|
+
|
|
1509
|
+
Returns the review, or `None` and the reasons it is refused (nothing is written).
|
|
1510
|
+
"""
|
|
1511
|
+
if report.review is None:
|
|
1512
|
+
return None, [
|
|
1513
|
+
"review: a review task is submitted with `review`, the subagent's JSON review "
|
|
1514
|
+
"(see review.reply_schema in its context)"
|
|
1515
|
+
]
|
|
1516
|
+
parsed, problems = parse_review(report.review)
|
|
1517
|
+
if parsed is not None:
|
|
1518
|
+
scope = review_mod.load_scope(ctx, record.task_id, record.dispatches)
|
|
1519
|
+
if scope is None: # get_context was not called for this dispatch
|
|
1520
|
+
scope = await review_mod.review_scope(ctx, graph, rule, instance)
|
|
1521
|
+
problems = review_problems(parsed, scope)
|
|
1522
|
+
if parsed is None or problems:
|
|
1523
|
+
return None, [f"review: {p}" for p in problems]
|
|
1524
|
+
out = ctx.root / record.allowed_writes[0]
|
|
1525
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
1526
|
+
out.write_text(json.dumps(parsed.model_dump(mode="json"), indent=2) + "\n", encoding="utf-8")
|
|
1527
|
+
return parsed, []
|
|
1528
|
+
|
|
1529
|
+
|
|
1530
|
+
async def _handle_candidate_submit(
|
|
1531
|
+
ctx: AppContext,
|
|
1532
|
+
queue: TaskQueue,
|
|
1533
|
+
record: AgentTaskRecord,
|
|
1534
|
+
round_state: BestOfNRound,
|
|
1535
|
+
cand_num: int,
|
|
1536
|
+
checks: list[CheckResult],
|
|
1537
|
+
verdict: loop.Verdict,
|
|
1538
|
+
) -> Selection | None:
|
|
1539
|
+
"""Handle a best-of-N candidate submission: update state and run selection if all done.
|
|
1540
|
+
|
|
1541
|
+
A candidate's submit records its check results in the round state but does NOT:
|
|
1542
|
+
- Remove its workspace (needed for copy-back if it wins)
|
|
1543
|
+
- Accept/reject the base record (done after selection)
|
|
1544
|
+
- Settle the candidate record (done after selection)
|
|
1545
|
+
|
|
1546
|
+
When all candidates are submitted, this function:
|
|
1547
|
+
1. Runs `select_best` (deterministic key; Critic tiebreak not wired yet)
|
|
1548
|
+
2. If there's a winner: copies winner's outputs to user's tree, accepts BASE record
|
|
1549
|
+
3. If no candidate passed: rejects BASE record as one counted try
|
|
1550
|
+
4. Marks all candidate records terminal
|
|
1551
|
+
5. Removes all candidate workspaces
|
|
1552
|
+
6. Removes the round state file
|
|
1553
|
+
|
|
1554
|
+
Returns the Selection result if all candidates are done and selection was run,
|
|
1555
|
+
otherwise None.
|
|
1556
|
+
"""
|
|
1557
|
+
# Check if this candidate was already submitted (double submit protection)
|
|
1558
|
+
existing_cand = next((c for c in round_state.candidates if c.candidate == cand_num), None)
|
|
1559
|
+
if existing_cand and existing_cand.status != "dispatched":
|
|
1560
|
+
raise AppError(
|
|
1561
|
+
f"candidate {cand_num} of {round_state.base_task_id!r} was already submitted "
|
|
1562
|
+
f"(status: {existing_cand.status})"
|
|
1563
|
+
)
|
|
1564
|
+
|
|
1565
|
+
# Update the candidate state in the round
|
|
1566
|
+
updated_candidates = []
|
|
1567
|
+
for cand in round_state.candidates:
|
|
1568
|
+
if cand.candidate == cand_num:
|
|
1569
|
+
status: Literal["dispatched", "submitted", "done", "failed", "cancelled"]
|
|
1570
|
+
if verdict.accepted:
|
|
1571
|
+
status = "done"
|
|
1572
|
+
elif verdict.status in ("budget_exhausted", "needs_human"):
|
|
1573
|
+
status = "failed"
|
|
1574
|
+
else:
|
|
1575
|
+
status = "failed"
|
|
1576
|
+
|
|
1577
|
+
updated_cand = CandidateState(
|
|
1578
|
+
candidate=cand.candidate,
|
|
1579
|
+
variant=cand.variant,
|
|
1580
|
+
task_id=cand.task_id,
|
|
1581
|
+
workspace=cand.workspace,
|
|
1582
|
+
status=status,
|
|
1583
|
+
checks=tuple(checks),
|
|
1584
|
+
passed=verdict.accepted,
|
|
1585
|
+
errors=error_count(checks),
|
|
1586
|
+
warnings=warning_count(checks),
|
|
1587
|
+
spend=Spend(tries=1), # Each submit counts as one try
|
|
1588
|
+
)
|
|
1589
|
+
updated_candidates.append(updated_cand)
|
|
1590
|
+
else:
|
|
1591
|
+
updated_candidates.append(cand)
|
|
1592
|
+
|
|
1593
|
+
updated_round = BestOfNRound(
|
|
1594
|
+
schema_version=round_state.schema_version,
|
|
1595
|
+
base_task_id=round_state.base_task_id,
|
|
1596
|
+
run_id=round_state.run_id,
|
|
1597
|
+
round_num=round_state.round_num,
|
|
1598
|
+
n=round_state.n,
|
|
1599
|
+
candidates=tuple(updated_candidates),
|
|
1600
|
+
ledger_tries=round_state.ledger_tries,
|
|
1601
|
+
ledger_used=round_state.ledger_used + 1,
|
|
1602
|
+
winner=round_state.winner,
|
|
1603
|
+
selection=round_state.selection,
|
|
1604
|
+
status=round_state.status,
|
|
1605
|
+
)
|
|
1606
|
+
_save_best_of_round(ctx, updated_round)
|
|
1607
|
+
|
|
1608
|
+
# Check if all candidates are done
|
|
1609
|
+
all_done = all(c.status in ("done", "failed", "cancelled") for c in updated_candidates)
|
|
1610
|
+
if not all_done:
|
|
1611
|
+
return None
|
|
1612
|
+
|
|
1613
|
+
# All candidates done - run selection
|
|
1614
|
+
updated_round = updated_round.model_copy(update={"status": "selecting"})
|
|
1615
|
+
_save_best_of_round(ctx, updated_round)
|
|
1616
|
+
|
|
1617
|
+
# Build Candidate objects for select_best
|
|
1618
|
+
# Use the round state's candidate info (workspace paths) since those are the actual
|
|
1619
|
+
# paths before any cleanup - the queue records may have stale info
|
|
1620
|
+
candidates: list[Candidate] = []
|
|
1621
|
+
for cand in updated_candidates:
|
|
1622
|
+
workspace_path = Path(cand.workspace) if cand.workspace else None
|
|
1623
|
+
candidates.append(
|
|
1624
|
+
Candidate(
|
|
1625
|
+
id=str(cand.candidate),
|
|
1626
|
+
passed=cand.passed,
|
|
1627
|
+
checks=cand.checks,
|
|
1628
|
+
finished=cand.status != "cancelled",
|
|
1629
|
+
workspace=workspace_path if workspace_path and workspace_path.exists() else None,
|
|
1630
|
+
)
|
|
1631
|
+
)
|
|
1632
|
+
|
|
1633
|
+
# Run selection (deterministic key only; Critic tiebreak is NOT wired in this
|
|
1634
|
+
# implementation - that requires the review rule infrastructure and will be wired
|
|
1635
|
+
# in a follow-up, say so in the report)
|
|
1636
|
+
selection = await select_best(candidates, metrics=None, tiebreak=None, ledger=None)
|
|
1637
|
+
|
|
1638
|
+
# Emit select event
|
|
1639
|
+
_emit(
|
|
1640
|
+
ctx,
|
|
1641
|
+
updated_round.run_id,
|
|
1642
|
+
updated_round.base_task_id,
|
|
1643
|
+
"tool_call",
|
|
1644
|
+
{
|
|
1645
|
+
"tool": "select",
|
|
1646
|
+
"event": "select",
|
|
1647
|
+
"best_of": updated_round.round_num,
|
|
1648
|
+
"status": (
|
|
1649
|
+
"done" if selection.winner is not None and not selection.none_passed else "failed"
|
|
1650
|
+
),
|
|
1651
|
+
"winner": selection.winner,
|
|
1652
|
+
"decided_by": selection.decided_by,
|
|
1653
|
+
"none_passed": selection.none_passed,
|
|
1654
|
+
"order": list(selection.order),
|
|
1655
|
+
},
|
|
1656
|
+
)
|
|
1657
|
+
|
|
1658
|
+
# Get the base record to accept/reject
|
|
1659
|
+
base_record = queue.get(updated_round.base_task_id)
|
|
1660
|
+
if base_record is None:
|
|
1661
|
+
raise AppError(f"base task {updated_round.base_task_id!r} not found")
|
|
1662
|
+
|
|
1663
|
+
# Get the winning candidate's info from the round state (not from queue - workspace
|
|
1664
|
+
# path is authoritative in the round state)
|
|
1665
|
+
winner_cand_state: CandidateState | None = None
|
|
1666
|
+
winner_cand_record: AgentTaskRecord | None = None
|
|
1667
|
+
copy_result: CopyBackResult | None = None
|
|
1668
|
+
|
|
1669
|
+
if selection.winner is not None and not selection.none_passed:
|
|
1670
|
+
winner_num = int(selection.winner)
|
|
1671
|
+
winner_cand_state = next((c for c in updated_candidates if c.candidate == winner_num), None)
|
|
1672
|
+
if winner_cand_state:
|
|
1673
|
+
winner_cand_record = queue.get(winner_cand_state.task_id)
|
|
1674
|
+
|
|
1675
|
+
# If we have a winner and it passed, copy back its outputs to user's tree
|
|
1676
|
+
if winner_cand_state and winner_cand_record and winner_cand_state.workspace:
|
|
1677
|
+
workspace_path = Path(winner_cand_state.workspace)
|
|
1678
|
+
if workspace_path.exists():
|
|
1679
|
+
copy_result = copy_outputs_back(
|
|
1680
|
+
workspace_path,
|
|
1681
|
+
ctx.root,
|
|
1682
|
+
winner_cand_record.allowed_writes,
|
|
1683
|
+
winner_cand_record.seed_hashes,
|
|
1684
|
+
)
|
|
1685
|
+
if not copy_result.ok:
|
|
1686
|
+
# Copy-back failed - treat as no winner (infra issue, not counted as try)
|
|
1687
|
+
selection = Selection(
|
|
1688
|
+
winner=None,
|
|
1689
|
+
decided_by="none",
|
|
1690
|
+
none_passed=True,
|
|
1691
|
+
order=selection.order,
|
|
1692
|
+
keys=selection.keys,
|
|
1693
|
+
critic_note=f"copy-back failed: {copy_result.reason}",
|
|
1694
|
+
)
|
|
1695
|
+
winner_cand_state = None
|
|
1696
|
+
winner_cand_record = None
|
|
1697
|
+
|
|
1698
|
+
# Accept or reject the BASE record based on selection outcome
|
|
1699
|
+
graph = _graph(ctx)
|
|
1700
|
+
rule, instance = _instance(graph, updated_round.base_task_id)
|
|
1701
|
+
|
|
1702
|
+
if selection.winner is not None and not selection.none_passed and winner_cand_record:
|
|
1703
|
+
# Winner found and copy-back succeeded - accept the BASE record
|
|
1704
|
+
# Get output hashes from the user's tree (where outputs were just copied)
|
|
1705
|
+
base_result = AgentResult(
|
|
1706
|
+
status="done",
|
|
1707
|
+
files_written=winner_cand_record.allowed_writes,
|
|
1708
|
+
)
|
|
1709
|
+
# Re-dispatch the base so we can accept it (it's in ready/rejected state)
|
|
1710
|
+
if base_record.status in OPEN_STATUSES:
|
|
1711
|
+
base_record = queue.dispatch(
|
|
1712
|
+
updated_round.base_task_id,
|
|
1713
|
+
run_id=updated_round.run_id,
|
|
1714
|
+
baseline={}, # Empty baseline - we're not checking changes
|
|
1715
|
+
denied_reads=(),
|
|
1716
|
+
)
|
|
1717
|
+
if base_record.status == "dispatched":
|
|
1718
|
+
base_record = queue.mark_submitted(updated_round.base_task_id)
|
|
1719
|
+
base_record = queue.accept(
|
|
1720
|
+
updated_round.base_task_id,
|
|
1721
|
+
result=base_result,
|
|
1722
|
+
output_hashes=output_hashes(ctx.store, task_for(rule, instance)),
|
|
1723
|
+
)
|
|
1724
|
+
else:
|
|
1725
|
+
# No winner or none passed - reject the BASE record as one counted try
|
|
1726
|
+
# Use the best candidate's redo text if available
|
|
1727
|
+
best_cand = updated_candidates[0] if updated_candidates else None
|
|
1728
|
+
redo_text = (
|
|
1729
|
+
f"Best-of-{updated_round.n} round {updated_round.round_num}: no candidate passed. "
|
|
1730
|
+
)
|
|
1731
|
+
if best_cand:
|
|
1732
|
+
best_cand_record = queue.get(best_cand.task_id)
|
|
1733
|
+
if best_cand_record and best_cand_record.state.redo:
|
|
1734
|
+
redo_text += f"Best candidate's issues: {best_cand_record.state.redo}"
|
|
1735
|
+
else:
|
|
1736
|
+
# Get the first few issues from the best candidate's checks
|
|
1737
|
+
issues = [i.msg for c in best_cand.checks if not c.ok for i in c.issues[:2]][:3]
|
|
1738
|
+
if issues:
|
|
1739
|
+
redo_text += f"Issues: {'; '.join(issues)}"
|
|
1740
|
+
|
|
1741
|
+
base_result = AgentResult(
|
|
1742
|
+
status="failed",
|
|
1743
|
+
files_written=(),
|
|
1744
|
+
)
|
|
1745
|
+
# Re-dispatch the base so we can reject it (it's in ready state)
|
|
1746
|
+
if base_record.status in OPEN_STATUSES:
|
|
1747
|
+
base_record = queue.dispatch(
|
|
1748
|
+
updated_round.base_task_id,
|
|
1749
|
+
run_id=updated_round.run_id,
|
|
1750
|
+
baseline={},
|
|
1751
|
+
denied_reads=(),
|
|
1752
|
+
)
|
|
1753
|
+
if base_record.status == "dispatched":
|
|
1754
|
+
base_record = queue.mark_submitted(updated_round.base_task_id)
|
|
1755
|
+
# Reject counts as one try for the base
|
|
1756
|
+
base_record = queue.reject(
|
|
1757
|
+
updated_round.base_task_id,
|
|
1758
|
+
result=base_result,
|
|
1759
|
+
reasons=(redo_text,),
|
|
1760
|
+
counted=True,
|
|
1761
|
+
exhausted=base_record.attempts + 1 >= base_record.tries,
|
|
1762
|
+
budget_state=TaskBudgetState(
|
|
1763
|
+
dispatches=base_record.state.dispatches,
|
|
1764
|
+
tries_used=base_record.state.tries_used + 1,
|
|
1765
|
+
failed_tries=base_record.state.failed_tries + 1,
|
|
1766
|
+
redo=redo_text,
|
|
1767
|
+
label="verification",
|
|
1768
|
+
),
|
|
1769
|
+
)
|
|
1770
|
+
|
|
1771
|
+
# Mark all candidate records as terminal (accepted for winner, rejected for losers)
|
|
1772
|
+
# so next_task never hands them out again
|
|
1773
|
+
for cand in updated_candidates:
|
|
1774
|
+
cand_record = queue.get(cand.task_id)
|
|
1775
|
+
if cand_record is None:
|
|
1776
|
+
continue
|
|
1777
|
+
is_winner = (
|
|
1778
|
+
selection.winner is not None
|
|
1779
|
+
and not selection.none_passed
|
|
1780
|
+
and cand.candidate == int(selection.winner)
|
|
1781
|
+
)
|
|
1782
|
+
if cand_record.status == "dispatched":
|
|
1783
|
+
cand_record = queue.mark_submitted(cand.task_id)
|
|
1784
|
+
if cand_record.status != "submitted":
|
|
1785
|
+
continue # Already terminal
|
|
1786
|
+
if is_winner:
|
|
1787
|
+
# The winner is accepted (its outputs are what landed)
|
|
1788
|
+
cand_result = AgentResult(status="done", files_written=cand_record.allowed_writes)
|
|
1789
|
+
queue.accept(
|
|
1790
|
+
cand.task_id,
|
|
1791
|
+
result=cand_result,
|
|
1792
|
+
output_hashes={}, # Candidate's outputs are in workspace, not user tree
|
|
1793
|
+
)
|
|
1794
|
+
else:
|
|
1795
|
+
# Losers are rejected (budget_exhausted so they're never re-dispatched)
|
|
1796
|
+
cand_result = AgentResult(status="failed", files_written=())
|
|
1797
|
+
queue.reject(
|
|
1798
|
+
cand.task_id,
|
|
1799
|
+
result=cand_result,
|
|
1800
|
+
reasons=("not selected as winner in best-of-N round",),
|
|
1801
|
+
counted=False, # Don't count against the candidate's own budget
|
|
1802
|
+
exhausted=True, # Mark as terminal
|
|
1803
|
+
)
|
|
1804
|
+
|
|
1805
|
+
# Clean up all candidate workspaces
|
|
1806
|
+
vcs = ctx.registry.get("vcs", "git")
|
|
1807
|
+
for cand in updated_candidates:
|
|
1808
|
+
if cand.workspace:
|
|
1809
|
+
with contextlib.suppress(Exception):
|
|
1810
|
+
remove_workspace(ctx, vcs, cand.workspace)
|
|
1811
|
+
|
|
1812
|
+
# Update round state with selection result and mark as done
|
|
1813
|
+
final_round = BestOfNRound(
|
|
1814
|
+
schema_version=updated_round.schema_version,
|
|
1815
|
+
base_task_id=updated_round.base_task_id,
|
|
1816
|
+
run_id=updated_round.run_id,
|
|
1817
|
+
round_num=updated_round.round_num,
|
|
1818
|
+
n=updated_round.n,
|
|
1819
|
+
candidates=tuple(updated_candidates),
|
|
1820
|
+
ledger_tries=updated_round.ledger_tries,
|
|
1821
|
+
ledger_used=updated_round.ledger_used,
|
|
1822
|
+
winner=int(selection.winner) if selection.winner and not selection.none_passed else None,
|
|
1823
|
+
selection=selection,
|
|
1824
|
+
status="done" if selection.winner and not selection.none_passed else "failed",
|
|
1825
|
+
)
|
|
1826
|
+
_save_best_of_round(ctx, final_round)
|
|
1827
|
+
|
|
1828
|
+
# If the round failed (no candidate passed), remove the round state so next_task
|
|
1829
|
+
# can start a fresh round
|
|
1830
|
+
if selection.none_passed or selection.winner is None:
|
|
1831
|
+
_remove_best_of_round(ctx, updated_round.base_task_id)
|
|
1832
|
+
|
|
1833
|
+
return selection
|
|
1834
|
+
|
|
1835
|
+
|
|
1836
|
+
async def submit(
|
|
1837
|
+
ctx: AppContext, task_id: str, report: SubmitReport | None = None
|
|
1838
|
+
) -> dict[str, Any]:
|
|
1839
|
+
"""Check a dispatched task's work at once and accept or reject it (DESIGN.md 5.2).
|
|
1840
|
+
|
|
1841
|
+
The checks of the attempt, each run once: no file outside the task's outputs
|
|
1842
|
+
changed since dispatch, every output exists and is not empty, every input exists
|
|
1843
|
+
(`attempt_checks`), and, when those hold, the rule's checks. A failure gets its
|
|
1844
|
+
`label` (`loop.judge`, the M2-02a pieces) and counts against the budget:
|
|
1845
|
+
|
|
1846
|
+
- `rejected`: tries are left; the answer has the failed checks, the redo text,
|
|
1847
|
+
`next_tier`/`next_model` (the tier escalates after the first failed try) and
|
|
1848
|
+
`tries_left`; `next_task` hands the task out again with the redo text in its
|
|
1849
|
+
context (`previous_rejection`);
|
|
1850
|
+
- `infra` (a check errored, a tool could not run): no try is used, but after
|
|
1851
|
+
`max_infra_retries` such submits the task stops;
|
|
1852
|
+
- `budget_exhausted`: the tries ran out, two tries in a row left the same outputs
|
|
1853
|
+
and failures (`stagnation`), or the infra retries ran out. The `rule_fail` event
|
|
1854
|
+
and the run's HANDOFF.md are written at once, and the task is never handed out
|
|
1855
|
+
again until an input changes or it is rewound;
|
|
1856
|
+
- `needs_human`: the checks point at the spec or plan (`planning`).
|
|
1857
|
+
|
|
1858
|
+
A report with `status: needs_human` hands the task to a person without using a try.
|
|
1859
|
+
Tokens and cost are not known per task in this runtime: the loop's token and cost
|
|
1860
|
+
caps do not apply.
|
|
1861
|
+
|
|
1862
|
+
A review task (its role writes no files and has `engine_writes`) is submitted with
|
|
1863
|
+
`report.review`: rejected when it is missing, not JSON, not a `ReviewReport`, or
|
|
1864
|
+
invalid for the diff its context showed (`review_problems`); otherwise the engine
|
|
1865
|
+
writes it as the task's one output and then runs the usual checks. A valid review
|
|
1866
|
+
is accepted whatever its verdict: AI findings never block the build (DESIGN.md 4.8).
|
|
1867
|
+
|
|
1868
|
+
For best-of-N candidates: each candidate is submitted individually; after all are
|
|
1869
|
+
submitted, `select_best` is run (with Critic tiebreak for exact ties), and only the
|
|
1870
|
+
winner's outputs are copied back to the user's tree.
|
|
1871
|
+
"""
|
|
1872
|
+
ctx.require_profile()
|
|
1873
|
+
_require_runtime(ctx)
|
|
1874
|
+
report = report or SubmitReport()
|
|
1875
|
+
queue = _queue(ctx)
|
|
1876
|
+
record = _record(queue, task_id)
|
|
1877
|
+
if record.status != "dispatched":
|
|
1878
|
+
raise AppError(f"task {task_id!r} is {record.status}, not dispatched; call next_task")
|
|
1879
|
+
graph = _graph(ctx)
|
|
1880
|
+
|
|
1881
|
+
# Handle candidate task_id lookup - need to find the real instance
|
|
1882
|
+
if is_candidate_id(task_id):
|
|
1883
|
+
base_task_id, _cand_num, _round_num = parse_candidate_id(task_id)
|
|
1884
|
+
rule, instance = _instance(graph, base_task_id)
|
|
1885
|
+
else:
|
|
1886
|
+
rule, instance = _instance(graph, task_id)
|
|
1887
|
+
reviewing = review_mod.is_review_role(find_role(record.role))
|
|
1888
|
+
if report.review is not None and not reviewing:
|
|
1889
|
+
raise AppError(
|
|
1890
|
+
f"task {task_id!r} is not a review task (role {record.role!r}): submit it "
|
|
1891
|
+
"without `review`"
|
|
1892
|
+
)
|
|
1893
|
+
|
|
1894
|
+
if report.status == "needs_human":
|
|
1895
|
+
if not report.open_questions:
|
|
1896
|
+
raise AppError("a submit with status 'needs_human' must list open_questions")
|
|
1897
|
+
result = AgentResult(
|
|
1898
|
+
status="needs_human",
|
|
1899
|
+
assumptions=report.assumptions,
|
|
1900
|
+
open_questions=report.open_questions,
|
|
1901
|
+
)
|
|
1902
|
+
record = queue.needs_human(task_id, result=result)
|
|
1903
|
+
restored = _settle(ctx, queue, record)
|
|
1904
|
+
_emit(
|
|
1905
|
+
ctx,
|
|
1906
|
+
record.run_id,
|
|
1907
|
+
task_id,
|
|
1908
|
+
"agent_turn",
|
|
1909
|
+
{
|
|
1910
|
+
"phase": "submit",
|
|
1911
|
+
"status": "needs_human",
|
|
1912
|
+
"try": record.state.tries_used + 1,
|
|
1913
|
+
"counted": False,
|
|
1914
|
+
"tier": record.current_tier,
|
|
1915
|
+
"label": "planning",
|
|
1916
|
+
"failed_checks": [],
|
|
1917
|
+
"open_questions": list(report.open_questions),
|
|
1918
|
+
"restored": restored,
|
|
1919
|
+
},
|
|
1920
|
+
)
|
|
1921
|
+
return _submit_answer(
|
|
1922
|
+
ctx, record, verdict=None, reasons=[], outside=[], missing=[], checks=[]
|
|
1923
|
+
)
|
|
1924
|
+
|
|
1925
|
+
record = queue.mark_submitted(task_id)
|
|
1926
|
+
try:
|
|
1927
|
+
review: ReviewReport | None = None
|
|
1928
|
+
review_reasons: list[str] = []
|
|
1929
|
+
if reviewing:
|
|
1930
|
+
review, review_reasons = await _take_review(ctx, graph, rule, instance, record, report)
|
|
1931
|
+
allowed = set(record.allowed_writes)
|
|
1932
|
+
|
|
1933
|
+
# For isolated tasks, diff the workspace; otherwise diff the user's tree
|
|
1934
|
+
workspace = record.workspace
|
|
1935
|
+
copy_back_result: CopyBackResult | None = None
|
|
1936
|
+
if workspace:
|
|
1937
|
+
workspace_path = Path(workspace)
|
|
1938
|
+
changed_in_allowed, changed_outside = diff_workspace(
|
|
1939
|
+
workspace_path, record.allowed_writes, record.seed_hashes
|
|
1940
|
+
)
|
|
1941
|
+
changed = [*changed_in_allowed, *changed_outside]
|
|
1942
|
+
outside = list(changed_outside)
|
|
1943
|
+
missing = (
|
|
1944
|
+
[]
|
|
1945
|
+
if review_reasons
|
|
1946
|
+
else [p for p in record.allowed_writes if not (workspace_path / p).is_file()]
|
|
1947
|
+
)
|
|
1948
|
+
else:
|
|
1949
|
+
now = await snapshot(ctx, extra=record.allowed_writes)
|
|
1950
|
+
changed = _changed(queue.baseline(task_id), now)
|
|
1951
|
+
# Outputs of the other agent tasks: their own subagents write them (the guard
|
|
1952
|
+
# allows nobody else), whatever their status now: tasks that ran in parallel
|
|
1953
|
+
# are submitted one by one, and one may be accepted or rejected before this one.
|
|
1954
|
+
others = {
|
|
1955
|
+
w for other in queue.all() if other.task_id != task_id for w in other.allowed_writes
|
|
1956
|
+
}
|
|
1957
|
+
engine = _engine_outputs(graph)
|
|
1958
|
+
outside = [
|
|
1959
|
+
p for p in changed if p not in allowed and p not in others and p not in engine
|
|
1960
|
+
]
|
|
1961
|
+
missing = (
|
|
1962
|
+
[]
|
|
1963
|
+
if review_reasons
|
|
1964
|
+
else [p for p in record.allowed_writes if not (ctx.root / p).is_file()]
|
|
1965
|
+
)
|
|
1966
|
+
attempt = AgentResult(
|
|
1967
|
+
status="done",
|
|
1968
|
+
files_written=tuple(p for p in changed if p in allowed or p in outside),
|
|
1969
|
+
assumptions=report.assumptions,
|
|
1970
|
+
open_questions=report.open_questions,
|
|
1971
|
+
)
|
|
1972
|
+
# an isolated task is judged on its workspace: its outputs are there, not in the user's tree
|
|
1973
|
+
judged_store = (
|
|
1974
|
+
ArtifactStore(Path(workspace), ctx.store.labels, ctx.store.repo)
|
|
1975
|
+
if workspace
|
|
1976
|
+
else ctx.store
|
|
1977
|
+
)
|
|
1978
|
+
results = list(
|
|
1979
|
+
attempt_checks(instance, attempt, judged_store, allowed_writes=record.allowed_writes)
|
|
1980
|
+
)
|
|
1981
|
+
if reviewing:
|
|
1982
|
+
if review_reasons: # nothing was written: the reply is what failed
|
|
1983
|
+
results = [r for r in results if r.check_id != OUTPUTS_CHECK]
|
|
1984
|
+
results.append(loop.review_result(review_reasons))
|
|
1985
|
+
|
|
1986
|
+
checks: list[CheckResult] = []
|
|
1987
|
+
if not outside and not missing and not review_reasons:
|
|
1988
|
+
# For isolated tasks, run checks in the workspace; otherwise in user's tree
|
|
1989
|
+
check_root = Path(workspace) if workspace else None
|
|
1990
|
+
runner = ProfileCheckRunner(ctx, root_override=check_root)
|
|
1991
|
+
for check_id in rule.checks:
|
|
1992
|
+
checks.append(await runner.run(check_id, instance))
|
|
1993
|
+
results.extend(checks)
|
|
1994
|
+
|
|
1995
|
+
shown = _feedback_filter(ctx, record)
|
|
1996
|
+
verdict = await loop.judge(
|
|
1997
|
+
rule,
|
|
1998
|
+
record,
|
|
1999
|
+
results,
|
|
2000
|
+
attempt,
|
|
2001
|
+
judged_store.current_hashes(instance.outputs),
|
|
2002
|
+
shown=shown,
|
|
2003
|
+
)
|
|
2004
|
+
# The journal keeps every check's full result; the answer and the redo text
|
|
2005
|
+
# show only what the role may see.
|
|
2006
|
+
visible = list(shown.apply(checks)[0]) if shown is not None else checks
|
|
2007
|
+
reasons: list[str] = list(review_reasons)
|
|
2008
|
+
if outside:
|
|
2009
|
+
reasons.append(f"changed files outside the task's outputs: {', '.join(outside)}")
|
|
2010
|
+
if missing:
|
|
2011
|
+
reasons.append(f"missing outputs: {', '.join(missing)}")
|
|
2012
|
+
reasons.extend(_check_reason(c) for c in visible if not c.ok)
|
|
2013
|
+
if not reasons and not verdict.accepted:
|
|
2014
|
+
reasons = list(verdict.state.failures)
|
|
2015
|
+
state = verdict.state
|
|
2016
|
+
if verdict.stop is not None and record.run_id is not None:
|
|
2017
|
+
state = state.model_copy(update={"handoff": str(_handoff_path(ctx, record.run_id))})
|
|
2018
|
+
result = AgentResult(
|
|
2019
|
+
status="done" if verdict.accepted else "failed",
|
|
2020
|
+
files_written=tuple(p for p in changed if p in allowed),
|
|
2021
|
+
assumptions=report.assumptions,
|
|
2022
|
+
open_questions=report.open_questions,
|
|
2023
|
+
)
|
|
2024
|
+
|
|
2025
|
+
# Check if this is a best-of-N candidate
|
|
2026
|
+
is_best_of_candidate = is_candidate_id(task_id)
|
|
2027
|
+
best_of_round: BestOfNRound | None = None
|
|
2028
|
+
if is_best_of_candidate:
|
|
2029
|
+
base_task_id, cand_num, _round_num = parse_candidate_id(task_id)
|
|
2030
|
+
best_of_round = _load_best_of_round(ctx, base_task_id)
|
|
2031
|
+
# Validate: the round must exist and be running
|
|
2032
|
+
if best_of_round is None:
|
|
2033
|
+
raise AppError(
|
|
2034
|
+
f"cannot submit candidate {task_id!r}: no active best-of-N round "
|
|
2035
|
+
f"for base task {base_task_id!r}"
|
|
2036
|
+
)
|
|
2037
|
+
if best_of_round.status not in ("running", "selecting"):
|
|
2038
|
+
raise AppError(
|
|
2039
|
+
f"cannot submit candidate {task_id!r}: best-of-N round is already "
|
|
2040
|
+
f"{best_of_round.status}"
|
|
2041
|
+
)
|
|
2042
|
+
|
|
2043
|
+
# For isolated tasks: on acceptance, copy outputs back to user's tree
|
|
2044
|
+
# For best-of-N candidates: DON'T copy back yet - wait for selection
|
|
2045
|
+
# Refuse if user's tree changed since the workspace was seeded
|
|
2046
|
+
if verdict.accepted and workspace and not is_best_of_candidate:
|
|
2047
|
+
workspace_path = Path(workspace)
|
|
2048
|
+
copy_back_result = copy_outputs_back(
|
|
2049
|
+
workspace_path, ctx.root, record.allowed_writes, record.seed_hashes
|
|
2050
|
+
)
|
|
2051
|
+
if not copy_back_result.ok:
|
|
2052
|
+
# User's tree changed - reject and re-dispatch (not counted as a try)
|
|
2053
|
+
reasons.append(f"copy-back refused: {copy_back_result.reason}")
|
|
2054
|
+
# Create a rejection verdict without counting the try (infra issue)
|
|
2055
|
+
verdict = loop.Verdict(
|
|
2056
|
+
status="rejected",
|
|
2057
|
+
label="infra", # Concurrent edit is an infra issue
|
|
2058
|
+
counted=False,
|
|
2059
|
+
stop=None,
|
|
2060
|
+
tier=verdict.tier,
|
|
2061
|
+
state=verdict.state,
|
|
2062
|
+
tries=verdict.tries,
|
|
2063
|
+
failed=verdict.failed,
|
|
2064
|
+
questions=(),
|
|
2065
|
+
)
|
|
2066
|
+
|
|
2067
|
+
# For best-of-N candidates: DON'T accept/reject the candidate record here.
|
|
2068
|
+
# The candidate stays in "submitted" status; _handle_candidate_submit will
|
|
2069
|
+
# mark all candidates terminal after selection and accept/reject the BASE record.
|
|
2070
|
+
# Also DON'T remove the workspace - it's needed for copy-back if this candidate wins.
|
|
2071
|
+
if not is_best_of_candidate:
|
|
2072
|
+
if verdict.accepted:
|
|
2073
|
+
record = queue.accept(
|
|
2074
|
+
task_id,
|
|
2075
|
+
result=result,
|
|
2076
|
+
output_hashes=output_hashes(ctx.store, task_for(rule, instance)),
|
|
2077
|
+
budget_state=state,
|
|
2078
|
+
)
|
|
2079
|
+
elif verdict.status == "needs_human":
|
|
2080
|
+
questions = tuple(dict.fromkeys((*report.open_questions, *verdict.questions)))
|
|
2081
|
+
record = queue.needs_human(
|
|
2082
|
+
task_id,
|
|
2083
|
+
result=result.model_copy(
|
|
2084
|
+
update={"status": "needs_human", "open_questions": questions}
|
|
2085
|
+
),
|
|
2086
|
+
budget_state=state,
|
|
2087
|
+
)
|
|
2088
|
+
else:
|
|
2089
|
+
record = queue.reject(
|
|
2090
|
+
task_id,
|
|
2091
|
+
result=result,
|
|
2092
|
+
reasons=tuple(reasons),
|
|
2093
|
+
outside=tuple(outside),
|
|
2094
|
+
counted=verdict.counted,
|
|
2095
|
+
exhausted=verdict.status == "budget_exhausted",
|
|
2096
|
+
budget_state=state,
|
|
2097
|
+
)
|
|
2098
|
+
except BaseException:
|
|
2099
|
+
with contextlib.suppress(QueueError):
|
|
2100
|
+
queue.unsubmit(task_id)
|
|
2101
|
+
raise
|
|
2102
|
+
|
|
2103
|
+
# Best-of-N selection: update round state and run selection when all candidates done
|
|
2104
|
+
# For best-of-N candidates, _handle_candidate_submit handles everything:
|
|
2105
|
+
# - Recording the candidate's result in round state
|
|
2106
|
+
# - Running selection when all candidates are done
|
|
2107
|
+
# - Copying the winner's outputs to user's tree
|
|
2108
|
+
# - Accepting/rejecting the BASE record
|
|
2109
|
+
# - Marking all candidate records terminal
|
|
2110
|
+
# - Cleaning up all workspaces
|
|
2111
|
+
selection_result: Selection | None = None
|
|
2112
|
+
restored = []
|
|
2113
|
+
if is_best_of_candidate and best_of_round is not None:
|
|
2114
|
+
base_task_id, cand_num, _round_num = parse_candidate_id(task_id)
|
|
2115
|
+
selection_result = await _handle_candidate_submit(
|
|
2116
|
+
ctx, queue, record, best_of_round, cand_num, checks, verdict
|
|
2117
|
+
)
|
|
2118
|
+
# After _handle_candidate_submit, the candidate record is now terminal
|
|
2119
|
+
record = queue.require(task_id)
|
|
2120
|
+
else:
|
|
2121
|
+
# Non-candidate tasks: settle (move-aside restore + workspace cleanup)
|
|
2122
|
+
restored = _settle(ctx, queue, record)
|
|
2123
|
+
if record.status == "accepted": # the person's note is answered; the gate decides again
|
|
2124
|
+
_revision_path(ctx, task_id).unlink(missing_ok=True)
|
|
2125
|
+
|
|
2126
|
+
for check in checks:
|
|
2127
|
+
_emit(ctx, record.run_id, task_id, "check_result", check.model_dump(mode="json"))
|
|
2128
|
+
payload: dict[str, Any] = {
|
|
2129
|
+
"phase": "submit",
|
|
2130
|
+
"status": record.status,
|
|
2131
|
+
"attempt": record.dispatches,
|
|
2132
|
+
"try": verdict.state.tries_used if verdict.counted else verdict.state.tries_used + 1,
|
|
2133
|
+
"counted": verdict.counted,
|
|
2134
|
+
"tier": verdict.tier,
|
|
2135
|
+
"label": verdict.label,
|
|
2136
|
+
"failed_checks": [r.check_id for r in verdict.failed],
|
|
2137
|
+
"reasons": reasons,
|
|
2138
|
+
"files_written": list(result.files_written),
|
|
2139
|
+
"assumptions": list(report.assumptions),
|
|
2140
|
+
"open_questions": list(report.open_questions),
|
|
2141
|
+
"restored": restored,
|
|
2142
|
+
}
|
|
2143
|
+
if review is not None:
|
|
2144
|
+
payload["review"] = {"verdict": review.verdict, "comments": len(review.comments)}
|
|
2145
|
+
# Add best-of-N candidate info to payload
|
|
2146
|
+
if is_best_of_candidate:
|
|
2147
|
+
base_task_id, cand_num, _round_num = parse_candidate_id(task_id)
|
|
2148
|
+
payload["best_of"] = {
|
|
2149
|
+
"base_task": base_task_id,
|
|
2150
|
+
"candidate": cand_num,
|
|
2151
|
+
}
|
|
2152
|
+
if selection_result is not None:
|
|
2153
|
+
payload["best_of"]["selection"] = {
|
|
2154
|
+
"winner": selection_result.winner,
|
|
2155
|
+
"decided_by": selection_result.decided_by,
|
|
2156
|
+
}
|
|
2157
|
+
_emit(ctx, record.run_id, task_id, "agent_turn", payload)
|
|
2158
|
+
if verdict.stop is not None:
|
|
2159
|
+
_stop(ctx, record)
|
|
2160
|
+
|
|
2161
|
+
answer = _submit_answer(
|
|
2162
|
+
ctx,
|
|
2163
|
+
record,
|
|
2164
|
+
verdict=verdict,
|
|
2165
|
+
reasons=reasons,
|
|
2166
|
+
outside=outside,
|
|
2167
|
+
missing=missing,
|
|
2168
|
+
checks=visible,
|
|
2169
|
+
)
|
|
2170
|
+
if is_best_of_candidate:
|
|
2171
|
+
# A candidate is not a task: it is not accepted or stopped on its own. Say what the
|
|
2172
|
+
# submit meant for the round, not what its private record says.
|
|
2173
|
+
answer["stop_reason"] = None
|
|
2174
|
+
answer["handoff"] = None
|
|
2175
|
+
if selection_result is None:
|
|
2176
|
+
answer["status"], answer["accepted"] = "submitted", False
|
|
2177
|
+
answer["next"] = (
|
|
2178
|
+
"this candidate is in; wait for the other candidate(s) of the round to be "
|
|
2179
|
+
"submitted, then call next_task"
|
|
2180
|
+
)
|
|
2181
|
+
elif selection_result.winner is not None:
|
|
2182
|
+
won = str(cand_num) == str(selection_result.winner)
|
|
2183
|
+
answer["status"], answer["accepted"] = ("won" if won else "lost"), won
|
|
2184
|
+
answer["next"] = "the round is decided and its winner landed; call next_task"
|
|
2185
|
+
else:
|
|
2186
|
+
answer["status"], answer["accepted"] = "round_failed", False
|
|
2187
|
+
answer["next"] = (
|
|
2188
|
+
"no candidate of the round passed, nothing landed; call next_task: a new round "
|
|
2189
|
+
"starts, or the task stops when its budget is spent"
|
|
2190
|
+
)
|
|
2191
|
+
# Add selection result to answer if this was the final candidate
|
|
2192
|
+
if selection_result is not None:
|
|
2193
|
+
answer["selection"] = {
|
|
2194
|
+
"winner": selection_result.winner,
|
|
2195
|
+
"decided_by": selection_result.decided_by,
|
|
2196
|
+
"none_passed": selection_result.none_passed,
|
|
2197
|
+
"order": list(selection_result.order),
|
|
2198
|
+
}
|
|
2199
|
+
return answer
|
|
2200
|
+
|
|
2201
|
+
|
|
2202
|
+
def _stop(ctx: AppContext, record: AgentTaskRecord) -> None:
|
|
2203
|
+
"""Journal a stopped task's `rule_fail` in its run, and write that run's HANDOFF.md."""
|
|
2204
|
+
if record.run_id is None:
|
|
2205
|
+
return
|
|
2206
|
+
info = loop.stop_info(record)
|
|
2207
|
+
payload: dict[str, Any] = {"message": loop.stop_message(info), "agent": info}
|
|
2208
|
+
if record.last_result is not None:
|
|
2209
|
+
payload["result"] = record.last_result.model_dump(mode="json")
|
|
2210
|
+
if record.last_result.open_questions:
|
|
2211
|
+
payload["open_questions"] = list(record.last_result.open_questions)
|
|
2212
|
+
_emit(
|
|
2213
|
+
ctx,
|
|
2214
|
+
record.run_id,
|
|
2215
|
+
record.task_id,
|
|
2216
|
+
"rule_fail",
|
|
2217
|
+
payload,
|
|
2218
|
+
failure_label=record.state.label,
|
|
2219
|
+
)
|
|
2220
|
+
_write_run_handoff(ctx, record.run_id)
|
|
2221
|
+
|
|
2222
|
+
|
|
2223
|
+
def _submit_answer(
|
|
2224
|
+
ctx: AppContext,
|
|
2225
|
+
record: AgentTaskRecord,
|
|
2226
|
+
*,
|
|
2227
|
+
verdict: loop.Verdict | None,
|
|
2228
|
+
reasons: list[str],
|
|
2229
|
+
outside: list[str],
|
|
2230
|
+
missing: list[str],
|
|
2231
|
+
checks: list[CheckResult],
|
|
2232
|
+
) -> dict[str, Any]:
|
|
2233
|
+
state = record.state
|
|
2234
|
+
if record.status == "rejected":
|
|
2235
|
+
hint = (
|
|
2236
|
+
"call next_task: it hands the task out again (model next_model); start its "
|
|
2237
|
+
"subagent with the task's prompt only, the redo text is in its context"
|
|
2238
|
+
)
|
|
2239
|
+
elif record.status == "budget_exhausted":
|
|
2240
|
+
hint = (
|
|
2241
|
+
"stop working on this task: it used its budget; a person must look at it (see handoff)"
|
|
2242
|
+
)
|
|
2243
|
+
elif record.status == "needs_human":
|
|
2244
|
+
hint = "a person must answer the open questions; then build again"
|
|
2245
|
+
else:
|
|
2246
|
+
hint = "call next_task for the next tasks"
|
|
2247
|
+
rejected = record.status == "rejected"
|
|
2248
|
+
# An agent's own needs_human report (no verdict): M2-02a labels it `planning`.
|
|
2249
|
+
label: FailureLabel | None = verdict.label if verdict is not None else "planning"
|
|
2250
|
+
return {
|
|
2251
|
+
"task_id": record.task_id,
|
|
2252
|
+
"accepted": record.status == "accepted",
|
|
2253
|
+
"status": record.status,
|
|
2254
|
+
"attempts": record.attempts,
|
|
2255
|
+
"tries": record.tries,
|
|
2256
|
+
"label": label,
|
|
2257
|
+
"counted": verdict.counted if verdict is not None else False,
|
|
2258
|
+
"failed_checks": loop.failed_checks(verdict.failed) if verdict is not None else [],
|
|
2259
|
+
"redo": state.redo if verdict is not None and not verdict.accepted else None,
|
|
2260
|
+
"next_tier": record.current_tier if rejected else None,
|
|
2261
|
+
"next_model": _model_for(ctx, record) if rejected else None,
|
|
2262
|
+
"tries_left": max(record.tries - state.tries_used, 0),
|
|
2263
|
+
"budget": {
|
|
2264
|
+
"used": state.tries_used,
|
|
2265
|
+
"allowed": record.tries,
|
|
2266
|
+
"infra_retries": state.infra_failures,
|
|
2267
|
+
"max_infra_retries": loop.MAX_INFRA_RETRIES,
|
|
2268
|
+
"dispatches": state.dispatches,
|
|
2269
|
+
"max_dispatches": loop.dispatch_limit(record),
|
|
2270
|
+
},
|
|
2271
|
+
"stop_reason": state.stop if verdict is not None else None,
|
|
2272
|
+
"handoff": state.handoff if verdict is not None and verdict.stop is not None else None,
|
|
2273
|
+
"reasons": reasons,
|
|
2274
|
+
"outside_outputs": outside,
|
|
2275
|
+
"missing": missing,
|
|
2276
|
+
"checks": [
|
|
2277
|
+
{"check_id": c.check_id, "status": c.status, "issues": [i.msg for i in c.issues[:10]]}
|
|
2278
|
+
for c in checks
|
|
2279
|
+
],
|
|
2280
|
+
"result": record.last_result.model_dump(mode="json") if record.last_result else None,
|
|
2281
|
+
"next": hint,
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
|
|
2285
|
+
__all__ = ["SubmitReport", "get_context", "next_task", "snapshot", "submit"]
|