Compare commits

...
Author SHA1 Message Date
weichen 490b0b2dfa Merge pull request 'Bump the agent-native pin to c348441' (#51) from feature/bump-agent-native into main
ci / vet-and-test (push) Successful in 27s
ci / docker-checkout-hostgateway (push) Successful in 1s
2026-10-10 10:08:51 +00:00
weichen 234aa28af9 Bump the agent-native pin to c348441 (call-ID events, ctx call, serial partition)
ci / vet-and-test (pull_request) Successful in 26s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: Dev top-level agent-native moved to c348441 for the E1 increment
(parallel tool execution support); the pin check requires nested
agent-native links to carry the same tree, and the squad harness should
pick up the additive kernel events anyway.

Evidence: go build ./... && go test ./... green against the new pin.

Impact: Gitlink-only bump (3b92cb6 -> c348441); no squad code changes.

Refs: Agent-Native #42/#43/#44; Dev task feature/agent-e1-parallel-fixes.
2026-10-10 10:06:58 +00:00
weichen 8bbf850fad Merge pull request 'Point the nested agent-native pin at the reasoning replay head' (#49) from feature/pin-native-thinking into main
ci / vet-and-test (push) Successful in 27s
ci / docker-checkout-hostgateway (push) Successful in 0s
ci / vet-and-test (pull_request) Successful in 25s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
2026-10-09 16:36:47 +00:00
weichen 9ec3d86e86 Point the nested agent-native pin at the reasoning replay head
ci / vet-and-test (pull_request) Successful in 27s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: Dev's combination check requires nested library pins to match the Dev
top-level pointers; Dev moves agent-native to 3b92cb6 (reasoning capture and
replay, Agent-Native #41) for the quality P2-I2 work.

Evidence: SELF - 3b92cb6 is an ancestor of Agent-Native main (merge of #41);
go build/test in the Dev worktree runs after this bump (recorded on the
phase02 task card).

Impact: Pin only; squad does not consume the thinking fields.
2026-10-09 16:31:51 +00:00
weichen 819b0c5f82 Merge pull request 'Point the nested agent-native pin at the delegation template update' (#48) from feature/q3-native-pin into main
ci / vet-and-test (push) Successful in 26s
ci / docker-checkout-hostgateway (push) Successful in 1s
Merge pull request 'Point the nested agent-native pin at the delegation template update' (#48) from feature/q3-native-pin into main
2026-10-08 04:00:37 +00:00
weichen df1f877a4f Point the nested agent-native pin at the delegation template update
ci / vet-and-test (pull_request) Successful in 26s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: Dev-level pin consistency (scripts/check-pins.sh hard-errors on nested
vs Dev-level drift); agent-native main advanced to 37c5e6f (Request
OutputFormat/Tools, agent-native #40).

Evidence: adapters/native and root suites green against 37c5e6f (SELF);
CI authoritative (GATE).

Impact: nested deps/agent-native 1ac21c6 -> 37c5e6f; no squad source
changes.

Refs: Q3 plan WP3/WP6; Dev card .task/feature-agent-stack-q3.md
2026-10-08 03:58:51 +00:00
weichen 663e89ec58 Merge pull request 'Add the a2a adapter module: Agent Card publishing and remote executor (Q3 WP4)' (#47) from feature/q3-a2a-wp4 into main
ci / vet-and-test (push) Successful in 26s
ci / docker-checkout-hostgateway (push) Successful in 1s
Merge pull request 'Add the a2a adapter module: Agent Card publishing and remote executor (Q3 WP4)' (#47) from feature/q3-a2a-wp4 into main
2026-10-08 03:41:54 +00:00
weichen 424bfc0e29 Add the a2a adapter module: Agent Card publishing and remote executor (Q3 WP4)
ci / vet-and-test (pull_request) Successful in 25s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: Q3 WP4 requires A2A 1.0 interop from zero: publish this team's
capabilities as an Agent Card and consume remote agents as squad.Executors
with the A2A task state machine mapped onto ExecResult.

Evidence: adapters/a2a conformance with a local fake A2A server (both
directions): card build from role/blueprint registries, well-known JSON
serving, FetchCard fail-closed validation, message/send -> tasks/get
polling through submitted/working -> completed with TextPart/FilePart
artifacts mapped to patches/targets, failed/canceled/rejected/input-required
mapped to Success=false fail-closed (no cross-process hold), direct Message
result, JSON-RPC protocol and ctx-cancel error propagation. Module green
with -race; squad root suite unaffected (SELF). CI authoritative (GATE)
for the root module; the adapter module follows the adapters/intent and
adapters/native separate-go.mod precedent.

Impact: new module agent-squad/adapters/a2a (card.go, executor.go); no
changes to the squad root module.

Refs: Q3 plan WP4 (2026-10-07-agent-stack-q2q3-plans.md); task card
.task/feature-agent-stack-q3.md
2026-10-08 03:39:11 +00:00
weichen 633e853bfc Merge pull request 'Add StepSpec.Brief rendered as the task-instruction section of TASK.md' (#46) from feature/q3-step-brief into main
ci / vet-and-test (push) Successful in 25s
ci / docker-checkout-hostgateway (push) Successful in 1s
Merge pull request 'Add StepSpec.Brief rendered as the task-instruction section of TASK.md' (#46) from feature/q3-step-brief into main
2026-10-08 02:11:32 +00:00
weichen d6669cd9c8 Add StepSpec.Brief rendered as the task-instruction section of TASK.md
ci / vet-and-test (pull_request) Successful in 25s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: the Q3 compiler maps core.WorkflowDef prompt steps onto squad graph
steps; the rendered prompt text needs a first-class carrier in the step
task brief (until now only leader delegations had a briefing channel).

Evidence: TestStepBriefRendersIntoTaskFile asserts the section presence,
body, and position (after targets, before the context table); full squad
suite green (SELF), CI authoritative (GATE).

Impact: additive StepSpec field brief + writeTaskFile section; no behavior
change when Brief is empty.

Refs: Q3 plan WP1 (2026-10-07-agent-stack-q2q3-plans.md); Dev task card
.task/feature-agent-stack-q3.md
2026-10-08 02:10:03 +00:00
weichen bcc8d080b0 Merge pull request 'Add task-board driver with claim/lease/complete and 12-topology aggregate (Q2 WP4)' (#45) from feature/q2-taskboard-wp4 into main
ci / vet-and-test (push) Successful in 26s
ci / docker-checkout-hostgateway (push) Successful in 1s
Merge pull request 'Add task-board driver with claim/lease/complete and 12-topology aggregate (Q2 WP4)' (#45) from feature/q2-taskboard-wp4 into main
2026-10-08 01:30:58 +00:00
weichen acbe744142 Add task-board driver with claim/lease/complete and 12-topology aggregate (Q2 WP4)
ci / vet-and-test (pull_request) Successful in 26s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: Q2 acceptance 1 requires a scripted-backend conformance for every one of
the 12 builtin topologies; task_board was the last unlit driver.

Evidence: wp4_conformance_test.go: diamond-dependency board respects the
dependency gate in claim order; orphan lease (claimed + expired) re-arms on
driver re-entry and re-claims with Attempts=2 via Resume; non-critical task
failure cascades dependents to skipped and finishes degraded; fail-closed
when tasks remain unclosable. TestTopologyConformance_allTwelve runs all 12
names (10 runnable to completion, handoff/mailbox rejected as primitives).
Full suite green with -race.

Impact: task_board lit in the topology registry (all 12 topologies now
covered); RunState gains Board (persisted in runstate.json); RunOptions gains
TaskBoardLeaseSeconds; leader topology now maps to mode before plan
confirmation so Topology=leader runs no longer hit the confirmation gate.

Refs: Q2 plan WP4 (2026-10-07-agent-stack-q2q3-plans.md); task card
.task/feature-agent-stack-q2.md
2026-10-08 01:29:03 +00:00
weichen aec2a4aa0f Merge pull request 'Add review driver, handoff primitive, and group-chat driver (Q2 WP3)' (#44) from feature/q2-review-handoff-groupchat-wp3 into main
ci / vet-and-test (push) Successful in 25s
ci / docker-checkout-hostgateway (push) Successful in 0s
Merge pull request 'Add review driver, handoff primitive, and group-chat driver (Q2 WP3)' (#44) from feature/q2-review-handoff-groupchat-wp3 into main
2026-10-08 01:13:54 +00:00
weichen c88100967d Add review driver, handoff primitive, and group-chat driver (Q2 WP3)
ci / vet-and-test (pull_request) Successful in 34s
ci / docker-checkout-hostgateway (pull_request) Successful in 2s
Why: Q2 acceptance 2 requires the squad review topology to be equivalent to
today's backend runReview flow (parallel finders, per-finding verification,
drop unverified); handoff and group_chat complete 11 of 12 builtin topologies.

Evidence: wp3_conformance_test.go mirrors backend review_test.go assertions
(true finder concurrency via release gate, verify calls = 5 unique after
dedupe, only PASS kept, dropped-count line, severity order); handoff records
ownership transfer, target receives filtered history, finalize takes
chain-tail output, unknown target role fails closed; group-chat rotate /
close-marker / moderator conformance with budget stop and fail-closed
unknown nominee. Full suite green with -race.

Impact: review/group_chat drivers lit in the topology registry (task_board
remains fail-closed until WP4); RunState gains Handoffs (persisted);
RunOptions gains GroupChat*/Review* knobs; runOneStep validates handoff
targets and injects handoff context; finalize prefers chain-tail deliverable.
Parse/dedupe/verdict/severity/report formats ported verbatim from backend
internal/runtime/review.go for byte-level report equivalence.

Refs: Q2 plan WP3 (2026-10-07-agent-stack-q2q3-plans.md); task card
.task/feature-agent-stack-q2.md
2026-10-08 01:11:51 +00:00
weichen 01d5b96cda Merge pull request 'Add leader progress ledger, replan cap, and mailbox with actor attribution (Q2 WP2)' (#43) from feature/q2-leader-mailbox-wp2 into main
ci / vet-and-test (push) Successful in 25s
ci / docker-checkout-hostgateway (push) Successful in 0s
Merge pull request 'Add leader progress ledger, replan cap, and mailbox with actor attribution (Q2 WP2)' (#43) from feature/q2-leader-mailbox-wp2 into main
2026-10-08 00:36:38 +00:00
weichen 6d76111a16 Add leader progress ledger, replan cap, and mailbox with actor attribution (Q2 WP2)
ci / vet-and-test (pull_request) Successful in 24s
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
Why: Q2 acceptance requires leader runs to detect stuck delegation loops and
cap replans instead of spinning to the turn limit, and a mailbox primitive
whose messages can never resolve a pending permission approval (acceptance 3).

Evidence: squad conformance tests (leader_mailbox_conformance_test.go):
ledger stuck->replan, replan cap finalize, mailbox delivery after hold,
mailbox-cannot-approve-permission (structural + actor second defense),
post guards; full suite green with -race.

Impact: LeaderState gains Progress/StuckTurns/Replans/MaxReplans/ReplanNote
(persisted in leader.json); RunState gains Mailbox (persisted in
runstate.json); RespondDecision gains Actor - non-human actors are rejected
on choice gates; step briefs and leader task briefs include pending mail.

Refs: Q2 plan WP2 (2026-10-07-agent-stack-q2q3-plans.md); task card
.task/feature-agent-stack-q2.md
2026-10-08 00:34:46 +00:00
weichen 8103b8da35 Merge pull request 'Add the topology surface with loop, router, and deadline mechanics (Q2 WP1)' (#42) from feature/q2-topology-wp1 into main
ci / vet-and-test (push) Successful in 25s
ci / docker-checkout-hostgateway (push) Successful in 1s
Add the topology surface with loop, router, and deadline mechanics (Q2 WP1)
2026-10-08 00:05:06 +00:00
weichen f3a67ced9c Add the topology surface with loop, router, and deadline mechanics (Q2 WP1)
ci / vet-and-test (pull_request) Successful in 24s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: the completion doc Q2 requires twelve builtin topologies each with
a scripted-backend conformance; today the engine has only the
standard/leader mode string with no topology abstraction, no loop
re-arm (cycles are fail-closed by design), no in-graph routing, and no
run deadline.
Evidence: GOWORK=off go vet + go test -count=1 green across squad and
all three adapter modules; ten conformance/validation tests:
sequential, parallel, loop-until-field (exact iteration count +
persisted LoopRuns), loop-budget-exhausted (loop_exhausted event, run
still completes), router-selects-branch (loser skipped, run NOT
degraded), router-no-match fail-closed, evaluator-optimizer (draft
re-armed via loop body until verdict=pass), long-running deadline
(pending steps skipped, degraded closeout), unknown/primitive/
unregistered-driver fail-closed, builtin registry completeness (12).
Impact: additive — Topology/Deadline on RunOptions, Topology on
Blueprint, Loop/LoopRuns/Branches/Handoff on StepSpec; empty topology
keeps byte-identical standard behavior; MaxIterations is the total
iteration cap (first run included); router-skipped steps are exempt
from degraded derivation; runParallel now preserves the first specific
RunFailed error instead of re-wrapping via failStep; deadline metadata
uses RFC3339Nano (second-precision RFC3339 truncated sub-second
deadlines into instant expiry).
Refs: Agent-System-Documents#44 (Q2 plan WP1)
2026-10-08 00:03:24 +00:00
weichen c462bc4ae5 Merge pull request 'Carry the split usage fields through squad events (N3 WP6)' (#41) from feature/n3-usage-fields-wp6-r1 into main
ci / vet-and-test (push) Successful in 22s
ci / docker-checkout-hostgateway (push) Successful in 1s
Carry the split usage fields through squad events (N3 WP6)
2026-10-07 17:13:55 +00:00
weichen cff355d7e6 Carry the split usage fields through squad events (N3 WP6)
ci / vet-and-test (pull_request) Successful in 21s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: the exec bridge now threads Last*/cache (Agent-Native#39); squad
ProcessEvent carries them to hosts (the product's squad runner reads
them for usage accounting).
Evidence: GOWORK=off go vet green across squad and adapters/native;
nested pin at the merged native.
Impact: additive event fields.
Refs: Agent-Native#39, N3 plan WP6
2026-10-07 17:12:24 +00:00
weichen 8b1000eb09 Merge pull request 'Point the nested agent-native pin at the cache-on-message_end follow-up' (#40) from feature/n3-wp6-followup-cascade into main
ci / vet-and-test (push) Successful in 23s
ci / docker-checkout-hostgateway (push) Successful in 1s
Point the nested agent-native pin at the cache-on-message_end follow-up
2026-10-07 17:06:09 +00:00
weichen ef17c6630a Point the nested agent-native pin at the cache-on-message_end follow-up
ci / vet-and-test (pull_request) Successful in 23s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: native main carries the cache-on-message_end follow-up; the Dev
combination check requires the nested pin to follow.
Evidence: tree-only pin move.
Impact: none.
Refs: Agent-Native#37
2026-10-07 17:04:39 +00:00
weichen 40ce140cad Merge pull request 'Point the nested agent-native pin at the usage split and default retry' (#39) from feature/n3-wp6-cascade into main
ci / vet-and-test (push) Successful in 25s
ci / docker-checkout-hostgateway (push) Successful in 1s
Point the nested agent-native pin at the usage split and default retry
2026-10-07 16:33:36 +00:00
weichen b72bbe48a1 Point the nested agent-native pin at the usage split and default retry
ci / vet-and-test (pull_request) Successful in 23s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: native main carries the WP6 usage split and default retry; the
Dev combination check requires the nested pin to follow.
Evidence: tree-only pin move.
Impact: none.
Refs: Agent-Native#35
2026-10-07 16:32:54 +00:00
weichen bf7fe8429b Merge pull request 'Point the nested agent-native pin at the memory port work' (#38) from feature/n3-wp5-cascade into main
ci / vet-and-test (push) Successful in 22s
ci / docker-checkout-hostgateway (push) Successful in 1s
Point the nested agent-native pin at the memory port work
2026-10-07 16:23:00 +00:00
weichen 6dc03c02a9 Point the nested agent-native pin at the memory port work
ci / vet-and-test (pull_request) Successful in 22s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: native main carries the memory port and memory_write (N3 WP5);
the Dev combination check requires the nested pin to follow.
Evidence: tree-only pin move.
Impact: none.
Refs: Agent-Native#34
2026-10-07 16:22:15 +00:00
weichen ff36b23191 Merge pull request 'Point the nested agent-native pin at the checkpoint closure work' (#37) from feature/n3-wp3-cascade into main
ci / vet-and-test (push) Successful in 23s
ci / docker-checkout-hostgateway (push) Successful in 1s
Point the nested agent-native pin at the checkpoint closure work
2026-10-07 15:52:54 +00:00
weichen d775b4b205 Point the nested agent-native pin at the checkpoint closure work
ci / vet-and-test (pull_request) Successful in 24s
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
Why: native main carries the checkpoint closure (N3 WP3); the Dev
combination check requires the nested pin to follow.
Evidence: tree-only pin move.
Impact: none.
Refs: Agent-Native#33
2026-10-07 15:51:23 +00:00
weichen f38cf7c441 Merge pull request 'Point the nested agent-native pin at the OTel GenAI alignment' (#36) from feature/n3-wp4-cascade into main
ci / vet-and-test (push) Successful in 24s
ci / docker-checkout-hostgateway (push) Successful in 1s
Point the nested agent-native pin at the OTel GenAI alignment
2026-10-07 15:39:54 +00:00
weichen a9948da781 Point the nested agent-native pin at the OTel GenAI alignment
ci / vet-and-test (pull_request) Successful in 25s
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
Why: native main carries the GenAI span convention work (N3 WP4); the
Dev combination check requires the nested pin to follow.
Evidence: tree-only pin move.
Impact: none.
Refs: Agent-Native#32
2026-10-07 15:38:08 +00:00
weichen 90077b6601 Merge pull request 'Point the nested agent-native pin at the JournalOnly session work' (#35) from feature/n3-wp2-cascade into main
ci / vet-and-test (push) Successful in 26s
ci / docker-checkout-hostgateway (push) Successful in 0s
Point the nested agent-native pin at the JournalOnly session work
2026-10-07 15:25:05 +00:00
weichen b3363e83b9 Point the nested agent-native pin at the JournalOnly session work
ci / vet-and-test (pull_request) Successful in 24s
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
Why: native main carries JournalOnly sessions and the seed-persist fix
(N3 WP2); the Dev combination check requires the nested pin to follow.
Evidence: tree-only pin move.
Impact: none.
Refs: Agent-Native#31
2026-10-07 15:23:26 +00:00
weichen 931e5ae410 Merge pull request 'Point the nested agent-native pin at the RunState repair work' (#34) from feature/n3-wp1-cascade into main
ci / vet-and-test (push) Successful in 24s
ci / docker-checkout-hostgateway (push) Successful in 1s
Point the nested agent-native pin at the RunState repair work
2026-10-07 15:05:33 +00:00
weichen ec2c6a3495 Point the nested agent-native pin at the RunState repair work
ci / vet-and-test (pull_request) Successful in 25s
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
Why: native main carries repairOrphanTail and the N6 fix (N3 WP1); the
Dev combination check requires the nested pin to follow.
Evidence: tree-only pin move.
Impact: none.
Refs: Agent-Native#30
2026-10-07 15:03:54 +00:00
weichen 6306451552 Merge pull request 'Point the nested agent-native pin at the subagent Background field' (#33) from feature/n2-wp5-cascade into main
ci / vet-and-test (push) Successful in 42s
ci / docker-checkout-hostgateway (push) Successful in 0s
Point the nested agent-native pin at the subagent Background field
2026-10-07 14:17:52 +00:00
weichen 088d7f8543 Point the nested agent-native pin at the subagent Background field
ci / vet-and-test (pull_request) Successful in 26s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: native main carries subagent Request.Background (N2 WP5); the Dev
combination check requires the nested pin to follow.
Evidence: tree-only pin move.
Impact: none.
Refs: Agent-Native#29
2026-10-07 14:11:09 +00:00
weichen 9d3faefb97 Merge pull request 'Point the nested agent-native pin at the skills spec validation' (#32) from feature/n2-wp4-cascade into main
ci / vet-and-test (push) Successful in 29s
ci / docker-checkout-hostgateway (push) Successful in 1s
Point the nested agent-native pin at the skills spec validation
2026-10-07 13:54:30 +00:00
weichen c70d7c96d9 Point the nested agent-native pin at the skills spec validation
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
ci / vet-and-test (pull_request) Successful in 27s
Why: native main (7425b32b) validates skills against the open spec
(N2 WP4); the Dev combination check requires the nested pin to follow.
Evidence: tree-only pin move.
Impact: none.
Refs: Agent-Native#28, N2 WP4 cascade
2026-10-07 13:53:03 +00:00
weichen 92f4e925d2 Merge pull request 'Point the nested agent-native pin at the MCP 2026-07-28 work' (#31) from feature/n2-wp3-cascade into main
ci / vet-and-test (push) Successful in 29s
ci / docker-checkout-hostgateway (push) Successful in 1s
Point the nested agent-native pin at the MCP 2026-07-28 work
2026-10-07 13:42:03 +00:00
weichen 1bd42ab980 Point the nested agent-native pin at the MCP 2026-07-28 work
ci / vet-and-test (pull_request) Successful in 26s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: native main (77e7f978) aligns the MCP client with 2026-07-28
(N2 WP3); the Dev combination check requires the nested pin to follow.
Evidence: tree-only pin move.
Impact: none.
Refs: Agent-Native#27, N2 WP3 cascade
2026-10-07 13:40:27 +00:00
weichen 41ea27668c Merge pull request 'Point the nested agent-native pin at the Anthropic client work' (#30) from feature/n2-wp2-cascade into main
ci / vet-and-test (push) Successful in 26s
ci / docker-checkout-hostgateway (push) Successful in 1s
Point the nested agent-native pin at the Anthropic client work
2026-10-07 13:24:26 +00:00
weichen b4f1d26f33 Point the nested agent-native pin at the Anthropic client work
ci / vet-and-test (pull_request) Successful in 26s
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
Why: native main (d6053baf) adds the native Anthropic Messages client
(N2 WP2); the Dev combination check requires the nested pin to follow.
Evidence: tree-only pin move.
Impact: none.
Refs: Agent-Native#26, N2 WP2 cascade
2026-10-07 13:22:51 +00:00
weichen 56941bf8ea Merge pull request 'Point the nested agent-native pin at the N2 WP1 work' (#29) from feature/n2-wp1-cascade into main
ci / vet-and-test (push) Successful in 43s
ci / docker-checkout-hostgateway (push) Successful in 1s
Point the nested agent-native pin at the N2 WP1 work
2026-10-07 13:11:16 +00:00
weichen fe8de0e057 Point the nested agent-native pin at the N2 WP1 work
ci / vet-and-test (pull_request) Successful in 25s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: native main (64b8bd4c) adds the tool-result spill and cache
contract tests (N2 WP1); the Dev combination check requires the nested
pin to follow.
Evidence: tree-only pin move.
Impact: none.
Refs: Agent-Native#25, N2 WP1 cascade
2026-10-07 13:05:53 +00:00
weichen 029edc08e7 Merge pull request 'Point the nested agent-native pin at the CWD-clean fix' (#28) from feature/native-cascade-follow into main
ci / vet-and-test (push) Successful in 35s
ci / docker-checkout-hostgateway (push) Successful in 0s
Point the nested agent-native pin at the CWD-clean fix
2026-10-07 11:50:46 +00:00
weichen 1d86344c85 Point the nested agent-native pin at the CWD-clean fix
ci / vet-and-test (pull_request) Successful in 35s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: native main (ead690be) keeps workspace-less requests out of the
process CWD; the Dev CI build step requires a clean tree after tests.
Evidence: go build ./... green; tree-only pin move.
Impact: none beyond the pin.
Refs: Agent-Native#24, Agent-System-Dev#242
2026-10-07 11:48:58 +00:00
weichen 551e11ec19 Merge pull request 'Point the nested agent-intent and agent-native pins at the I2 cascade' (#27) from feature/agent-stack-i2 into main
ci / vet-and-test (push) Successful in 28s
ci / docker-checkout-hostgateway (push) Successful in 0s
Point the nested agent-intent and agent-native pins at the I2 cascade
2026-10-07 11:32:47 +00:00
weichen 83e72b4eaa Point the nested agent-intent and agent-native pins at the I2 cascade
ci / vet-and-test (pull_request) Successful in 23s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: the Dev combination check requires every nested agent-intent pin
to equal 343b1e6 (I2 increments 1-2 on intent main); the nested native
pin follows to 3a1ad5a (which itself carries intent 343b1e6).
Evidence: tree-only pin moves; squad code does not consume the new
ports.
Impact: none.
Refs: Agent-Native#23, I2 increment 3 cascade
2026-10-07 11:31:25 +00:00
weichen eba77e782f Merge pull request 'Converge run persistence on SQLite through the Store port (Q1 WP5)' (#26) from feature/agent-stack-q1 into main
ci / vet-and-test (push) Successful in 41s
ci / docker-checkout-hostgateway (push) Successful in 1s
2026-10-07 10:31:16 +00:00
weichen 90ac98ea65 Converge run persistence on SQLite through the Store port
ci / vet-and-test (pull_request) Successful in 23s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: Q1 WP5 - platform's SQLite becomes an implementation of the
squad Store port so run state and orchestration state live in one
database, and platform's in-process execution goes through the
Executor port instead of bypassing it (X6).

Evidence: all five modules go vet clean and go test ok, including
engine-on-SQLite (state in DB, no run-level JSON files, second
engine recovers from the same DB), SQLiteStore round trip, and the
full squad compatibility net.

Impact: runState/hitlGate exported so the port is implementable;
Options.Store wires into engineDeps; leader persistence and
checkpoints route through the port; PrepareWorkspace no longer
writes run-level JSON (st.save owns it); platform RunInProcess
takes squad.Executor (callers updated); serve mounts the engine on
platform's RunStore - single orchestration.db for both.

Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md Q1 WP5
2026-10-07 10:29:47 +00:00
weichen 1c3995beaa Merge pull request 'Add the Store port with tiered checkpoints and centralize termination (Q1 WP4/WP6/WP7)' (#25) from feature/agent-stack-q1 into main
ci / vet-and-test (push) Successful in 25s
ci / docker-checkout-hostgateway (push) Successful in 0s
2026-10-07 10:02:01 +00:00
weichen 626b2a3f34 Add the Store port with tiered checkpoints and centralize termination
ci / vet-and-test (pull_request) Successful in 24s
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
Why: Q1 WP4/WP6/WP7 - run persistence goes through a Store port
(FileStore carries the current layout), superstep boundaries write
tiered snapshots with a graph fingerprint for recovery continuity,
and the four hardcoded stop conditions move behind one Termination
registry with And/Or composition.

Evidence: go vet clean; full suite ok including new checkpoint,
continuity-rejection, termination-primitive, grant-cap and the
Q23-updated leader closeout tests; cmd module green with
SweepExpired wired into the sweeper loop.

Impact: runState saves via Store (FileStore unchanged by default);
advance writes sync/async/on_exit checkpoints and Resume rejects
graph-shape changes from the crash window; leader turn cap now
degrades through finalize instead of a bare failure (Q23); manual
repair grants are bounded (Q26); wakeup cap and step timeout read
from the same registry; the dead SweepExpired is now called (Q6).

Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md Q1 WP4/WP6/WP7
2026-10-07 10:00:33 +00:00
weichen b34d6ed187 Merge pull request 'Persist attempt ids and verify products on interrupted recovery (Q1 WP3)' (#24) from feature/agent-stack-q1 into main
ci / vet-and-test (push) Successful in 24s
ci / docker-checkout-hostgateway (push) Successful in 0s
2026-10-07 09:19:15 +00:00
weichen 96d1e0e49c Persist attempt ids and verify products on interrupted recovery
ci / vet-and-test (pull_request) Successful in 24s
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
Why: Q1 WP3 - each step execution carries a stable attempt id
(runID/stepID/N) for audit and idempotency, and auto-mode
interrupted recovery must not re-execute a step whose products all
landed before the crash (side effects exactly once).

Evidence: go vet clean; full squad suite ok, including the new
kill-at-superstep-boundary test (completed steps never re-execute,
running-with-products judged complete by attempt) and
missing-product retry tests.

Impact: StepSpec gains AttemptID set when entering running and used
as the execution id; recoverInterrupted (HitlAuto) checks the
step's targets first - all present means completed with artifact
merge, otherwise the legacy pending retry. Transient retries share
the same AttemptID.

Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md Q1 WP3
2026-10-07 09:18:08 +00:00
weichen 3870489614 Merge pull request 'Drive workflow execution as supersteps over a compiled dependency graph (Q1 WP1/WP2)' (#23) from feature/agent-stack-q1 into main
ci / vet-and-test (push) Successful in 24s
ci / docker-checkout-hostgateway (push) Successful in 1s
2026-10-07 09:08:21 +00:00
weichen f90abafe79 Drive workflow execution as supersteps over a compiled dependency graph
ci / vet-and-test (pull_request) Successful in 24s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: Q1 WP1/WP2 - replace the linear step scan with per-round graph
compilation and layer advancement so Needs drives scheduling and
write-conflicting steps never run concurrently.

Evidence: go vet clean in all five modules; go test ok everywhere,
including the full compatibility net (engine, leader, recovery, r2
regression, intent, evaluator suites) and new graph tests.

Impact: new squad/graph.go (compile/layer/conflict); StepSpec gains
Writes; advance advances by superstep (parallel run of
conflict-free members, serial for write-conflicts, single-member
groups keep dynamic adjustment). Legacy semantics preserved:
adjacent-Parallel mapping, degraded-continue on non-critical
failure (failed counts as satisfied), empty status = pending,
cycle/unknown/dup needs fail-closed.

Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md Q1 WP1/WP2
2026-10-07 09:06:52 +00:00
weichen bb02683d89 Merge pull request 'Point the nested agent-native pin at the intent-archive cascade head' (#22) from feature/agent-stack-i1 into main
ci / vet-and-test (push) Successful in 23s
ci / docker-checkout-hostgateway (push) Successful in 0s
2026-10-07 08:28:25 +00:00
weichen 4e8d684125 Point the nested agent-native pin at the intent-archive cascade
ci / vet-and-test (pull_request) Successful in 22s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: Agent-Native PR #22 landed after Squad #21; squad's nested
native must carry the same head or Dev check-pins fails.

Evidence: 78b6c647 is the #22 merge on agent-native main.

Impact: Nested pin only.
2026-10-07 08:27:39 +00:00
weichen 5403b6e7a9 Merge pull request 'Point the nested intent pin at the eval-v2 head' (#21) from feature/agent-stack-i1 into main
ci / vet-and-test (push) Successful in 26s
ci / docker-checkout-hostgateway (push) Successful in 1s
2026-10-07 08:24:36 +00:00
weichen ebbebdf20b Point the nested intent pins at the eval-v2 head
ci / vet-and-test (pull_request) Successful in 24s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: Agent-Intent PR #8 landed the multi-turn eval format; nested
copies must carry it or Dev check-pins fails.

Evidence: intent main head is the #8 merge.

Impact: Nested pins only.
2026-10-07 08:22:54 +00:00
weichen 592a410288 Merge pull request 'Point the nested agent-native pin at the intent cascade head' (#20) from feature/agent-stack-i1 into main
ci / vet-and-test (push) Successful in 23s
ci / docker-checkout-hostgateway (push) Successful in 1s
2026-10-07 07:49:50 +00:00
weichen 7fe41e86a1 Point the nested agent-native pin at the intent cascade head
ci / vet-and-test (pull_request) Successful in 24s
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
Why: Agent-Native PR #21 landed the nested intent bump; squad's
nested native must carry the same head or Dev check-pins fails.

Evidence: 6ea69293 is the #21 merge on agent-native main; squad
modules run next.

Impact: Nested pin only.
2026-10-07 07:48:40 +00:00
weichen 6b7a991750 Merge pull request 'Enable NFKC folding in the squad intent gate (I1 WP4)' (#19) from feature/agent-stack-i1 into main
ci / vet-and-test (push) Successful in 26s
ci / docker-checkout-hostgateway (push) Successful in 0s
2026-10-07 07:44:14 +00:00
weichen cb398dadc6 Enable NFKC folding in the squad intent gate and bump the intent pin
ci / vet-and-test (pull_request) Successful in 24s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: Both intent hosts should fold compatibility-equivalent input
(backend intentgate already does); the nested pin carries the I1
command model the gate depends on.

Evidence: adapters/intent suite ok; all five squad modules green.

Impact: NFKC on in NewFromOverlay; nested agent-intent at 2554b50.
2026-10-07 07:42:52 +00:00
weichen 7ecc04ee61 Merge pull request 'Point the nested agent-native pin at the WP5 head' (#18) from feature/agent-stack-n1 into main
ci / vet-and-test (push) Successful in 22s
ci / docker-checkout-hostgateway (push) Successful in 0s
2026-10-07 06:38:25 +00:00
weichen 3bf100fd9b Point the nested agent-native pin at the WP5 head
ci / vet-and-test (pull_request) Successful in 22s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: Dev moved its agent-native pin to c9c2f23 (PRs #19/#20);
check-pins requires the same combination in squad's nested copy.

Evidence: c9c2f23 is the #20 merge on agent-native main; squad
suites run next.

Impact: Nested pin only.
2026-10-07 06:36:39 +00:00
weichen fc9620e05f Merge pull request 'Pass the permission ask port through the native register Config (N1 WP4)' (#17) from feature/agent-stack-n1 into main
ci / vet-and-test (push) Successful in 21s
ci / docker-checkout-hostgateway (push) Successful in 0s
2026-10-07 05:11:42 +00:00
weichen ef14bda11f Pass the permission ask port through the native register Config
ci / vet-and-test (pull_request) Successful in 21s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: Squad members must reach the host's HITL verdict when a kernel
hook answers ask - the new native GateOptions.PermissionAsk field
carries it; nil preserves the legacy ask=reject behavior.

Evidence: go vet clean in all five modules; go test ok everywhere.

Impact: Additive Config field plus its buildOptions mapping.
2026-10-07 05:08:42 +00:00
weichen bf5b210bd1 Point the nested agent-native pin at the N1 spec and guardrails increment
Why: Library PR #18 landed AgentSpec, guardrails, subagent events and
the GateOptions.PermissionAsk field; squad's register mapping needs
that field, and check-pins requires the same combination.

Evidence: native main head is the #18 merge; squad suites run next.

Impact: Nested pin only.
2026-10-07 05:07:37 +00:00
weichen a75d359ade Merge pull request 'Point the nested agent-native pin at the merged N1 increment' (#16) from feature/agent-stack-n1 into main
ci / vet-and-test (push) Successful in 21s
ci / docker-checkout-hostgateway (push) Successful in 1s
2026-10-07 04:43:07 +00:00
weichen 1051dc5ae5 Point the nested agent-native pin at the merged N1 increment
ci / vet-and-test (pull_request) Successful in 20s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: Dev moved its agent-native pin to c2befaf (PR #17); squad's
nested pin must carry the same combination or check-pins fails.

Evidence: c2befaf is the merge of PR #17 on agent-native main; squad
test suite runs next.

Impact: Nested pin only.
2026-10-07 04:41:43 +00:00
weichen bf5b389d55 Pin agent-native message shaping
ci / vet-and-test (push) Successful in 24s
ci / docker-checkout-hostgateway (push) Successful in 0s
Merge pull request 'Pin agent-native message shaping' (#15) from feature/sillytavern-review into main
2026-10-04 19:44:56 +00:00
weichen 61985d457f Merge remote-tracking branch 'origin/main' into feature/sillytavern-review
ci / vet-and-test (pull_request) Successful in 23s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
2026-10-04 19:42:54 +00:00
weichen 5a57636cc0 Pin agent-native message shaping
ci / vet-and-test (pull_request) Successful in 23s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: Dev now builds provider requests through PrepareMessages. Squad's nested copy has to sit on that same commit or the pin check fails.
Evidence: The gitlink moves from e4020d2 to 3a110a7. No squad source changed.
Impact: Squad compiles against the message shaper. Empty MessageShape still leaves transcripts unchanged.
Refs: docs/records/2026-10-04-sillytavern-review.md
2026-10-04 18:28:10 +00:00
weichen 59ca80cee2 Merge pull request 'Pass code orchestration and MCP ask through the squad backend' (#14) from feature/code-orchestration into main
ci / vet-and-test (push) Successful in 22s
ci / docker-checkout-hostgateway (push) Successful in 1s
2026-10-03 18:07:11 +00:00
weichenandCursor dcd523ea05 Pass code orchestration and MCP ask through the squad backend
ci / vet-and-test (pull_request) Successful in 23s
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
Why: squad members use the same native kernel, so the script switch and the user-input callback have to reach that backend.
Evidence: GOWORK=off go test . in adapters/native.
Impact: the new fields default to off and nil, so existing member runs stay the same. The agent-native pin moves to the orchestration commit.
Refs: feature/code-orchestration
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 18:03:25 +00:00
weichen d9a573e497 Merge pull request 'Pass file checkpoints and spans through the squad native backend' (#13) from feature/durable-rewind into main
ci / vet-and-test (push) Successful in 22s
ci / docker-checkout-hostgateway (push) Successful in 0s
2026-10-03 17:40:15 +00:00
weichenandCursor a3b49e7b1f Pass file checkpoints and spans through the squad native backend
ci / vet-and-test (pull_request) Successful in 21s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: squad members write files and call tools through the same kernel, so rewind and span hooks have to reach that backend.
Evidence: GOWORK=off go test . in adapters/native.
Impact: nil checkpoints and a nil trace func leave member runs unchanged. The agent-native pin moves to the durability commit.
Refs: feature/durable-rewind
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 17:27:58 +00:00
weichen 55e9b37770 Merge pull request 'Pass tool gates through the squad native backend' (#12) from feature/permission-hooks into main
ci / vet-and-test (push) Successful in 21s
ci / docker-checkout-hostgateway (push) Successful in 1s
2026-10-03 17:06:48 +00:00
weichen beea97e8cf Pin the rebased agent-native hook bus
ci / vet-and-test (pull_request) Successful in 22s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: The hook-bus commit was replayed onto origin/main, so the squad pin has to follow that commit.
Evidence: deps/agent-native is 0a52d9f, which contains origin/main.
Impact: Squad members load the same kernel commit as the top-level pin.
Refs: feature/permission-hooks
2026-10-03 17:02:37 +00:00
weichenandCursor 41dfe7e3ef Pass tool gates through the squad native backend
Why: Squad members used the same kernel as direct chat but dropped stop, reminder, and before-tool hooks.
Evidence: GOWORK=off go test . in adapters/native passed after pinning agent-native to 99d623a.
Impact: Zero GateOptions still builds the previous backend. A set BeforeTool now runs inside the member loop.
Refs: feature/permission-hooks
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 17:02:28 +00:00
weichen 68b18ec2c5 Merge pull request 'Pin agent-native for context engineering' (#11) from feature/context-engineering into main
ci / vet-and-test (push) Successful in 23s
ci / docker-checkout-hostgateway (push) Successful in 1s
2026-10-03 16:25:49 +00:00
weichenandCursor 4d534508a9 Pin agent-native for context engineering
ci / vet-and-test (pull_request) Successful in 21s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: Squad runs on the same native loop, so it should see tool-result clearing, folded reminders, and the compaction retry.
Evidence: deps/agent-native is 6fd1d44.
Impact: No squad behavior change beyond the native loop it already calls.
Refs: feature/context-engineering
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 16:21:18 +00:00
weichen 3abc5944bd Merge pull request 'Pin agent-native to the streaming retry fix' (#10) from feature/llm-robustness into main
ci / vet-and-test (push) Successful in 22s
ci / docker-checkout-hostgateway (push) Successful in 1s
2026-10-03 15:56:15 +00:00
weichenandCursor 9efce79b86 Pin agent-native to the streaming retry fix
ci / vet-and-test (pull_request) Successful in 23s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: Squad members share the same model stream, so the retry and overflow fixes have to be the commit this repo builds.
Evidence: agent-native 9f5751c. No squad behavior change.
Impact: Only the agent-native gitlink moves.
Refs: feature/llm-robustness
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 15:52:36 +00:00
weichen c5c5a24e2c Merge pull request 'Move cross-library squad code into adapter modules' (#9) from feature/agent-stack-d1 into main
ci / vet-and-test (push) Successful in 29s
ci / docker-checkout-hostgateway (push) Successful in 1s
2026-10-03 14:02:33 +00:00
weichen 02c0d6aae4 Pin agent-native to the SSE abort fix
ci / vet-and-test (pull_request) Successful in 25s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: the squad checkout has to record the native head that treats a cancelled stream as aborted.
Evidence: go test -count=50 -race ./runtime/llm/ passed on d84a67c.
Impact: gitlink only.
Refs: D1
2026-10-03 13:51:51 +00:00
weichen 5e414ffecc Pin intent and native to the D1 schema heads
ci / vet-and-test (pull_request) Successful in 24s
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
Why: the squad checkout has to record the same intent and native commits Dev will pin.
Evidence: gitlinks only. Schema and adapter tests already passed on those heads.
Impact: no squad source change.
Refs: D1
2026-10-03 13:43:54 +00:00
weichenandCursor f68e5bde7b Keep the agent-intent import inside the intent adapter
Why: the command has to load guard overlays without naming agent-intent types. Only adapters/intent may import that library.
Evidence: GOWORK=off go test -count=1 in adapters/intent and cmd/agent-squad passed.
Impact: CLI calls NewFromOverlay. Guard domains stay guard.
Refs: D1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 13:43:03 +00:00
weichenandCursor a182d7662b Move cross-library squad code into adapter modules
Why: the squad core has to compile without agent-native or agent-intent. Ports stay in the core; adapters and the CLI own the concrete clients.
Evidence: GOWORK=off go test -count=1 ./squad/ ./store/ ./textextract/ ./schema/ passed, and the intent, sqlite, native, and cmd modules passed their own go test.
Impact: core go.mod keeps error-code-system only. Nested gitlinks stay. Callers register the native backend through agent-squad/adapters/native.
Refs: D1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 13:40:58 +00:00
weichen 20a9eb8078 Close the squad P1 gaps the audit listed for this batch
ci / vet-and-test (push) Successful in 47s
ci / docker-checkout-hostgateway (push) Successful in 0s
Merge via API
2026-10-03 13:22:54 +00:00
weichenandCursor 31ee395895 Pin agent-native to the commit that records the text sum
ci / vet-and-test (pull_request) Successful in 48s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: squad vet failed against native 11d1a76 because that module did not yet sum golang.org/x/text.
Evidence: GOWORK=off go vet ./squad/ ./squadintent/ ./platform/ ./cli/ passed on native 855fe41.
Impact: the native gitlink and this module's go.sum move. Squad behavior is unchanged.
Refs: I4
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 13:13:46 +00:00
weichenandCursor 1bb0c3a2ae Pin agent-native and agent-intent to the R2 heads
ci / vet-and-test (pull_request) Failing after 14s
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
Why: squad must build against the native and intent commits that carry this batch.
Evidence: agent-intent 05e74d8 and agent-native 11d1a76 are the feature/agent-stack-r2 heads whose race tests passed.
Impact: nested gitlinks move; squad behavior otherwise stays on the preceding commit.
Refs: Q6, I9
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 13:10:40 +00:00
weichenandCursor 9537321782 Close the squad P1 gaps the audit listed for this batch
Why: parallel groups dropped a hold, HITL timeouts were unused, the planner failed closed on any model error, and event sequence numbers restarted at 1.
Evidence: GOWORK=off go test -count=1 -race ./squad/ ./squadintent/ ./store/ ./platform/ passed, including TestR2ExpiredDecisionUsesCancel, TestR2IntentStepSkipsRoleRegistry, TestR2PlannerFallsBackAndCapsSteps, TestR2TaskFileUsesLatestBlackboard, TestR2FanoutCancelDoesNotPanic, TestR2EventSeqContinuesAfterRestart, TestR2JournalModeWAL, and TestR2GateReloadSeesNewIntent.
Impact: a failed or held parallel sibling cancels the rest; expired requests synthesize cancel or fail; the intent gate follows Registry.Reload.
Refs: Q6, Q10, Q11, Q12, Q13, Q17, Q18, Q19, I9
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 13:10:39 +00:00
weichen 8dc2c54adf Merge pull request #7 from feature/agent-stack-r0-r1 into main
ci / vet-and-test (push) Successful in 46s
ci / docker-checkout-hostgateway (push) Successful in 0s
CI on the head commit is success.
2026-10-03 12:47:35 +00:00
weichenandCursor 6c8810029d Clone the repo in CI without GitHub-hosted actions
ci / vet-and-test (pull_request) Successful in 48s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: The runner cannot reach github.com, so cloning actions/checkout timed out before any test ran.
Evidence: The Go 1.27.1 tarball is available from mirrors.aliyun.com. Checkout uses the job token and GITHUB_SHA.
Impact: Submodule checkout still requires SUBMODULE_TOKEN. A missing secret fails the job with that message.
Refs: feature/agent-stack-r0-r1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 10:31:42 +00:00
weichenandCursor e928ea3c0f Pin agent-native and agent-intent to the CI heads
ci / vet-and-test (pull_request) Failing after 4s
ci / docker-checkout-hostgateway (pull_request) Successful in 2s
Why: Squad must execute the native and intent commits that this batch is asking to merge.
Evidence: deps/agent-native is 91c155618487e21edeb4f1d3cc27e985aae2b0d2 and deps/agent-intent is 7f7879b06b053f0a62746309b79e0e1b9a00f42d.
Impact: Both gitlinks match the open pull request heads. Nested guideline pins are unchanged.
Refs: feature/agent-stack-r0-r1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 10:20:21 +00:00
weichenandCursor cda1fd7c70 Fetch CI submodules over HTTPS and drop the host checkout assertion
ci / vet-and-test (pull_request) Failing after 30s
ci / docker-checkout-hostgateway (pull_request) Failing after 31s
Why: SSH submodule checkout failed on the runner, and the docker job required a path this repo does not have.
Evidence: vet-and-test uses recursive HTTPS checkout, setup-go 1.27, GOPROXY, and GOWORK=off vet, test, and race. The docker job only checks go.mod.
Impact: SUBMODULE_TOKEN must be set by the owner before checkout can succeed.
Refs: feature/agent-stack-r0-r1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 10:19:54 +00:00
weichenandCursor 73b8d09545 Hold parallel groups and choice replies until dispatch succeeds
ci / vet-and-test (pull_request) Failing after 6m59s
ci / docker-checkout-hostgateway (pull_request) Failing after 4s
Why: A parallel group only checked the first step, free text cancelled confirmation and permission holds, and a failed reply deleted the pending request before the decision ran.
Evidence: GOWORK=off go vet ./...; go test -count=1 ./squad/ ./cli/; go test -race -count=1 ./squad/. New tests cover the second parallel member, free-text holds, restored pending, AtomicWrite, and listenGuard.
Impact: Any member that needs approval or bash permission keeps the whole group suspended. Free text on confirmation, permission, and quality review is stored as steering and the same request stays pending. A dispatch error restores pending.json.
Refs: feature/agent-stack-r0-r1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 10:15:40 +00:00
weichenandCursor 8e987bf535 Persist step approvals and guard squad HTTP entry points
ci / vet-and-test (pull_request) Failing after 1s
ci / docker-checkout-hostgateway (pull_request) Failing after 3s
Why: A step confirmation cancelled the whole run, approvals and repair budget vanished on restart, and the HTTP server accepted anonymous and oversized requests.
Evidence: GOWORK=off go vet ./... && GOWORK=off go test -count=1 ./... && GOWORK=off go test -race ./squad/... passed. scripts/submodule-consistency.sh ok.
Impact: Approve and skip apply to one step; runstate.json and unix flock survive restart; wakeup leaves permission holds pending; serve defaults to loopback and requires a token off-loopback.
Refs: Q1 Q2 Q3 Q4 Q5 Q8 Q9 Q14 Q15 Q16
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 01:48:10 +00:00
weichen 6aa61dff9f Merge pull request 'Pin agent-native to the harness turn loop' (#6) from feature/agent-harness-p1 into main
ci / vet-and-test (push) Failing after 2s
ci / docker-checkout-hostgateway (push) Failing after 2s
2026-10-01 13:41:41 +00:00
weichenandCursor 590fd86f4c Pin agent-native to the harness turn loop
ci / vet-and-test (pull_request) Failing after 30s
ci / docker-checkout-hostgateway (pull_request) Failing after 4s
Why: squad must compile against the same kernel commit as the direct path, while leaving the new loop switches unset.
Evidence: scripts/submodule-consistency.sh printed ok, and go test ./... passed.
Impact: only the agent-native gitlink changes. Squad runtime behavior is unchanged.
Refs: feature/agent-harness-p1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 13:28:47 +00:00
weichen 40f7db9c61 Add the SQLite orchestration platform
ci / vet-and-test (push) Failing after 1s
ci / docker-checkout-hostgateway (push) Failing after 3s
2026-10-01 10:31:04 +00:00
103 changed files with 8986 additions and 699 deletions
+38 -9
View File
@@ -6,19 +6,48 @@ on:
branches: [main]
jobs:
vet-and-test:
runs-on: nightly
runs-on: ubuntu-latest
env:
GOPROXY: https://goproxy.cn,direct
steps:
- uses: actions/checkout@v4
with:
submodules: true
- run: go vet ./... && go test -count=1 ./...
- name: Checkout
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
run: |
set -euo pipefail
git init -q
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git -c http.extraheader="AUTHORIZATION: token ${TOKEN}" fetch --depth=1 origin "${GITHUB_SHA}"
git checkout -q --detach FETCH_HEAD
if [ -z "${SUBMODULE_TOKEN}" ]; then
echo "SUBMODULE_TOKEN is not configured" >&2
exit 1
fi
git config --global url."https://weichen:${SUBMODULE_TOKEN}@git-home.u1680832.nyat.app:30319/".insteadOf "ssh://git@git-ssh.u1680832.nyat.app:31411/"
git -c fetch.recurseSubmodules=false submodule update --init --recursive
- name: Install Go 1.27.1
run: |
set -euo pipefail
curl -fsSL -o "$RUNNER_TEMP/go.tgz" https://mirrors.aliyun.com/golang/go1.27.1.linux-amd64.tar.gz
tar -C /usr/local -xzf "$RUNNER_TEMP/go.tgz"
ln -sfn /usr/local/go/bin/go /usr/local/bin/go
ln -sfn /usr/local/go/bin/gofmt /usr/local/bin/gofmt
- name: Vet, test, and race
run: GOWORK=off go vet ./... && GOWORK=off go test -count=1 ./... && GOWORK=off go test -count=1 -race ./...
docker-checkout-hostgateway:
runs-on: ubuntu-latest
options: --add-host gitea:host-gateway
env:
GITHUB_SERVER_URL: http://gitea:3000
steps:
- uses: actions/checkout@v4
with:
submodules: true
- run: test -f go.mod && test -f Agent-Native/go.mod && echo "docker-mode checkout via host-gateway OK"
- name: Checkout without GitHub actions
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
git init -q
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git -c http.extraheader="AUTHORIZATION: token ${TOKEN}" fetch --depth=1 origin "${GITHUB_SHA}"
git checkout -q --detach FETCH_HEAD
test -f go.mod && echo "docker-mode checkout via host-gateway OK"
+1 -1
View File
@@ -1,4 +1,4 @@
agent-squad
/agent-squad
agent-squad-data/
*.test
+2
View File
@@ -28,6 +28,8 @@
`go.mod` 的 `replace` 都指向 `./deps/`。嵌套在 `deps/agent-native/deps/`、`deps/agent-intent/deps/` 里的同名副本不参与本仓编译,提交必须与本仓 gitlink 一致(`scripts/submodule-consistency.sh`)。
同一 run 的临界区在 unix 上额外持有 `runs/<id>/.lock` 的 flock,拿不到锁时返回 `ErrRunBusy`。非 unix 平台没有这把文件锁,只保留进程内互斥。
克隆:
```bash
+179
View File
@@ -0,0 +1,179 @@
// Package a2a 把小队接入 A2A(Agent2Agent)1.0 互通面(Q3 WP4):
// - 发布侧:BuildCard 从角色注册表与蓝图注册表渲染 Agent Card(描述本
// 团队能力),CardHandler 以 JSON 服务(挂载在
// /.well-known/agent-card.json);
// - 消费侧:FetchCard 发现远端 Agent Card(fail-closed 校验必备字段),
// Executor 实现 squad.Executor——message/send 发起任务,tasks/get 轮询
// A2A 任务状态机(submitted/working/input-required → completed/failed/
// canceled),终态映射 ExecResult:completed 的 artifacts(TextPart/
// FilePart)落成 patches/目标文件;failed/canceled/input-required 一律
// Success=false(跨进程挂起不在本适配面内,fail-closed 交引擎重试/降级
// 语义处理)。
//
// 传输为 JSON-RPC 2.0 over HTTP(A2A 1.0 形态);不引入协议之外的依赖。
package a2a
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"agent-squad/squad"
)
// ProtocolVersion 对齐的 A2A 协议版本。
const ProtocolVersion = "1.0"
// WellKnownPath 是 Agent Card 的发现路径(A2A 1.0)。
const WellKnownPath = "/.well-known/agent-card.json"
// AgentCard 是 A2A 1.0 Agent Card(发布与消费共用形态)。
type AgentCard struct {
ProtocolVersion string `json:"protocolVersion"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
// URL 是 JSON-RPC 服务端点(消费侧据此发 message/send)。
URL string `json:"url"`
Version string `json:"version"`
Capabilities CardCapabilities `json:"capabilities"`
DefaultInputModes []string `json:"defaultInputModes,omitempty"`
DefaultOutputModes []string `json:"defaultOutputModes,omitempty"`
Skills []AgentSkill `json:"skills,omitempty"`
}
// CardCapabilities 能力声明(本适配面:非流式、无推送、无状态历史)。
type CardCapabilities struct {
Streaming bool `json:"streaming"`
PushNotifications bool `json:"pushNotifications"`
StateTransitionHistory bool `json:"stateTransitionHistory"`
}
// AgentSkill 是一项可被远端调用的能力。
type AgentSkill struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Tags []string `json:"tags,omitempty"`
Examples []string `json:"examples,omitempty"`
}
// CardOptions 覆盖 BuildCard 的身份字段(空则用缺省)。
type CardOptions struct {
Name string
Description string
// URL JSON-RPC 端点(必填:远端没有端点就无法调用本团队)。
URL string
// Version 卡片版本(空 = "1.0.0")。
Version string
// Skills 显式能力清单;空 = 从注册表派生。
Skills []AgentSkill
}
// BuildCard 从角色注册表与蓝图注册表渲染 Agent Card(描述本团队能力)。
// 角色 → 技能(关键词作 tags);蓝图 → 技能(match 词作 examples)。
func BuildCard(reg *squad.RoleRegistry, bps *squad.BlueprintRegistry, opts CardOptions) (AgentCard, error) {
if strings.TrimSpace(opts.URL) == "" {
return AgentCard{}, fmt.Errorf("a2a: Agent Card 需要 JSON-RPC 端点 URL")
}
card := AgentCard{
ProtocolVersion: ProtocolVersion,
Name: orDefault(opts.Name, "agent-squad"),
Description: opts.Description,
URL: opts.URL,
Version: orDefault(opts.Version, "1.0.0"),
DefaultInputModes: []string{"text/plain"},
DefaultOutputModes: []string{"text/plain"},
Skills: append([]AgentSkill(nil), opts.Skills...),
}
if len(card.Skills) == 0 {
if reg != nil {
for _, role := range reg.List() {
desc := firstLine(role.Instructions)
if desc == "" {
desc = role.Name
}
card.Skills = append(card.Skills, AgentSkill{
ID: role.Role, Name: orDefault(role.Name, role.Role),
Description: desc, Tags: append([]string(nil), role.Keywords...),
})
}
}
if bps != nil {
for _, bp := range bps.List() {
card.Skills = append(card.Skills, AgentSkill{
ID: bp.Key, Name: orDefault(bp.Title, bp.Key),
Description: bp.Description, Examples: append([]string(nil), bp.Match...),
})
}
}
}
return card, nil
}
// CardHandler 以 JSON 服务 Agent Card(调用方挂载到 WellKnownPath)。
func CardHandler(card AgentCard) http.Handler {
body, _ := json.Marshal(card)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(body)
})
}
// FetchCard 发现并校验远端 Agent Card(fail-closed:必备字段缺失即拒)。
func FetchCard(client *http.Client, wellKnownURL string) (AgentCard, error) {
if client == nil {
client = http.DefaultClient
}
resp, err := client.Get(wellKnownURL)
if err != nil {
return AgentCard{}, fmt.Errorf("a2a: 获取 Agent Card: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return AgentCard{}, fmt.Errorf("a2a: Agent Card 端点返回 %d", resp.StatusCode)
}
var card AgentCard
if err := json.NewDecoder(resp.Body).Decode(&card); err != nil {
return AgentCard{}, fmt.Errorf("a2a: Agent Card 解析失败: %w", err)
}
if err := validateCard(card); err != nil {
return AgentCard{}, err
}
return card, nil
}
// validateCard 消费侧最小校验(A2A 1.0 必备字段)。
func validateCard(card AgentCard) error {
if card.ProtocolVersion == "" {
return fmt.Errorf("a2a: Agent Card 缺 protocolVersion")
}
if strings.TrimSpace(card.Name) == "" {
return fmt.Errorf("a2a: Agent Card 缺 name")
}
if strings.TrimSpace(card.URL) == "" {
return fmt.Errorf("a2a: Agent Card 缺 url")
}
if strings.TrimSpace(card.Version) == "" {
return fmt.Errorf("a2a: Agent Card 缺 version")
}
return nil
}
func orDefault(v, def string) string {
if strings.TrimSpace(v) != "" {
return v
}
return def
}
func firstLine(s string) string {
s = strings.TrimSpace(s)
if i := strings.IndexByte(s, '\n'); i >= 0 {
s = s[:i]
}
if len(s) > 120 {
s = s[:120] + "…"
}
return s
}
+345
View File
@@ -0,0 +1,345 @@
package a2a
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"agent-squad/squad"
)
// conformance_test.go — Q3 WP4 验收:双向 conformance(本地假 A2A 服务端)。
// 发布侧:BuildCard + CardHandler 在 well-known 路径服务合法 Agent Card;
// 消费侧:FetchCard 发现校验 + Executor 走完任务状态机
// (submitted→working→completed / failed / input-required),终态对齐
// squad.ExecResult。
// fakeA2AServer 最小 A2A 1.0 服务端:well-known 卡片 + JSON-RPC
// (message/send 建任务;tasks/get 按脚本推进状态)。
type fakeA2AServer struct {
mu sync.Mutex
card AgentCard
tasks map[string]*Task
plan []string // 每次 tasks/get 推进到的状态序列(走完停在末态)
calls map[string]int
artifacts []Artifact
statusMsg string
}
func newFakeServer(t *testing.T, card AgentCard, plan []string, artifacts []Artifact) *fakeA2AServer {
t.Helper()
s := &fakeA2AServer{card: card, tasks: map[string]*Task{}, plan: plan,
calls: map[string]int{}, artifacts: artifacts}
return s
}
func (s *fakeA2AServer) handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc(WellKnownPath, func(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
body, _ := json.Marshal(s.card)
s.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(body)
})
mux.HandleFunc("/rpc", func(w http.ResponseWriter, r *http.Request) {
var req rpcRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeRPCError(w, 0, -32700, "parse error")
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.calls[req.Method]++
switch req.Method {
case "message/send":
id := fmt.Sprintf("task_%d", len(s.tasks)+1)
state := StateSubmitted
if len(s.plan) > 0 {
state = s.plan[0]
}
task := &Task{Kind: "task", ID: id, ContextID: "ctx_1",
Status: TaskStatus{State: state, Message: s.statusMessage()}}
s.tasks[id] = task
writeRPCResult(w, req.ID, task)
case "tasks/get":
var params struct {
ID string `json:"id"`
}
raw, _ := json.Marshal(req.Params)
_ = json.Unmarshal(raw, &params)
task, ok := s.tasks[params.ID]
if !ok {
writeRPCError(w, req.ID, -32001, "task not found")
return
}
// 按脚本推进状态机(停留在终态)。
if !isTerminal(task.Status.State) && len(s.plan) > 0 {
n := s.calls["tasks/get#"+params.ID]
s.calls["tasks/get#"+params.ID] = n + 1
idx := n + 1
if idx >= len(s.plan) {
idx = len(s.plan) - 1
}
task.Status.State = s.plan[idx]
task.Status.Message = s.statusMessage()
if task.Status.State == StateCompleted {
task.Artifacts = s.artifacts
}
}
writeRPCResult(w, req.ID, task)
default:
writeRPCError(w, req.ID, -32601, "method not found")
}
})
return mux
}
func (s *fakeA2AServer) statusMessage() *Message {
if s.statusMsg == "" {
return nil
}
return &Message{Kind: "message", Role: "agent",
Parts: []Part{{Kind: "text", Text: s.statusMsg}}}
}
func writeRPCResult(w http.ResponseWriter, id int64, result any) {
body, _ := json.Marshal(result)
env, _ := json.Marshal(rpcResponse{JSONRPC: "2.0", ID: id, Result: body})
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(env)
}
func writeRPCError(w http.ResponseWriter, id int64, code int, msg string) {
env, _ := json.Marshal(rpcResponse{JSONRPC: "2.0", ID: id, Error: &rpcError{Code: code, Message: msg}})
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(env)
}
// ---- 发布侧 ----
func TestBuildCardFromRegistries(t *testing.T) {
reg := squad.NewRoleRegistry()
for _, r := range []squad.RoleSpec{
{Role: "researcher", Name: "调研员", Keywords: []string{"调研", "检索"},
Instructions: "围绕目标调研关键问题。\n第二行不进摘要。"},
{Role: "writer", Name: "撰稿员", Keywords: []string{"撰写"}},
} {
if err := reg.Register(r); err != nil {
t.Fatal(err)
}
}
bps := squad.NewBlueprintRegistry()
if err := bps.Register(squad.Blueprint{Key: "brief", Title: "简报",
Description: "生成简报", Match: []string{"简报", "brief"},
Steps: []squad.StepSpec{{ID: "s1", Role: "writer", Title: "成文"}}}); err != nil {
t.Fatal(err)
}
card, err := BuildCard(reg, bps, CardOptions{
Name: "示例小队", Description: "演示团队能力", URL: "https://team.example/rpc",
})
if err != nil {
t.Fatal(err)
}
if card.ProtocolVersion != ProtocolVersion || card.Version != "1.0.0" {
t.Fatalf("card = %+v", card)
}
byID := map[string]AgentSkill{}
for _, s := range card.Skills {
byID[s.ID] = s
}
if byID["researcher"].Name != "调研员" || byID["researcher"].Description != "围绕目标调研关键问题。" {
t.Fatalf("role skill = %+v", byID["researcher"])
}
if len(byID["researcher"].Tags) != 2 {
t.Fatalf("tags = %v", byID["researcher"].Tags)
}
if byID["brief"].Name != "简报" || strings.Join(byID["brief"].Examples, ",") != "简报,brief" {
t.Fatalf("blueprint skill = %+v", byID["brief"])
}
if _, err := BuildCard(reg, bps, CardOptions{}); err == nil {
t.Fatal("缺 URL 必须 fail-closed")
}
}
// CardHandler 以 JSON 服务卡片(发布端点形态)。
func TestCardHandlerServesJSON(t *testing.T) {
card := AgentCard{ProtocolVersion: ProtocolVersion, Name: "小队", URL: "https://x/rpc", Version: "1.0.0"}
ts := httptest.NewServer(CardHandler(card))
defer ts.Close()
resp, err := http.Get(ts.URL + WellKnownPath)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if ct := resp.Header.Get("Content-Type"); ct != "application/json" {
t.Fatalf("content-type = %s", ct)
}
var got AgentCard
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
t.Fatal(err)
}
if got.Name != "小队" || got.ProtocolVersion != ProtocolVersion {
t.Fatalf("card = %+v", got)
}
}
// ---- 消费侧:发现 + 状态机 ----
func TestFetchCardAndExecuteCompleted(t *testing.T) {
fileBytes := base64.StdEncoding.EncodeToString([]byte("notes 内容"))
artifacts := []Artifact{{
ArtifactID: "art1", Name: "调研结果",
Parts: []Part{
{Kind: "text", Text: "# 调研结论\n\n结论正文,长度足够作为交付物内容。"},
{Kind: "file", File: &FilePart{Name: "notes.md", Bytes: fileBytes, MimeType: "text/markdown"}},
},
}}
card := AgentCard{ProtocolVersion: ProtocolVersion, Name: "远端小队",
URL: "PLACEHOLDER", Version: "1.0.0"}
srv := newFakeServer(t, card, []string{StateWorking, StateWorking, StateCompleted}, artifacts)
ts := httptest.NewServer(srv.handler())
defer ts.Close()
srv.mu.Lock()
srv.card.URL = ts.URL + "/rpc"
srv.mu.Unlock()
// 发现:well-known 路径取卡并校验。
got, err := FetchCard(ts.Client(), ts.URL+WellKnownPath)
if err != nil {
t.Fatal(err)
}
if got.Name != "远端小队" || got.URL != ts.URL+"/rpc" {
t.Fatalf("card = %+v", got)
}
// 执行:submitted/working → completed,工件映射 patches。
var states []string
ex := NewExecutor(got)
ex.Client = ts.Client()
ex.PollInterval = 5 * time.Millisecond
ex.OnState = func(s string) { states = append(states, s) }
res, err := ex.Execute(context.Background(), squad.ExecRequest{
Prompt: "调研主题 X", Targets: []string{"output.md"},
})
if err != nil {
t.Fatal(err)
}
if !res.Success {
t.Fatalf("result = %+v", res)
}
if string(res.Patches["notes.md"]) != "notes 内容" {
t.Fatalf("file part 未落成 patch:%q", res.Patches["notes.md"])
}
if !strings.Contains(string(res.Patches["output.md"]), "调研结论") {
t.Fatalf("文本部件未写入目标:%q", res.Patches["output.md"])
}
joined := strings.Join(states, ",")
if !strings.Contains(joined, StateWorking) || !strings.HasSuffix(joined, StateCompleted) {
t.Fatalf("状态机迁移 = %v", states)
}
srv.mu.Lock()
sends := srv.calls["message/send"]
srv.mu.Unlock()
if sends != 1 {
t.Fatalf("message/send = %d", sends)
}
}
func TestExecuteMapsFailedAndInputRequired(t *testing.T) {
card := AgentCard{ProtocolVersion: ProtocolVersion, Name: "远端", URL: "PLACEHOLDER", Version: "1"}
srv := newFakeServer(t, card, []string{StateWorking, StateFailed}, nil)
srv.statusMsg = "远端执行崩溃"
ts := httptest.NewServer(srv.handler())
defer ts.Close()
srv.mu.Lock()
srv.card.URL = ts.URL + "/rpc"
srv.mu.Unlock()
ex := NewExecutor(card)
ex.BaseURL = ts.URL + "/rpc"
ex.Client = ts.Client()
ex.PollInterval = 5 * time.Millisecond
res, err := ex.Execute(context.Background(), squad.ExecRequest{Prompt: "x", Targets: []string{"output.md"}})
if err != nil {
t.Fatal(err)
}
if res.Success || !strings.Contains(res.ErrorReason, "failed") || !strings.Contains(res.ErrorReason, "远端执行崩溃") {
t.Fatalf("failed 映射 = %+v", res)
}
// input-required:fail-closed(跨进程人工输入不在适配面内)。
srv2 := newFakeServer(t, card, []string{StateInputRequired}, nil)
srv2.statusMsg = "请补充授权"
ts2 := httptest.NewServer(srv2.handler())
defer ts2.Close()
ex2 := &Executor{BaseURL: ts2.URL + "/rpc", Client: ts2.Client(), PollInterval: 5 * time.Millisecond}
res2, err := ex2.Execute(context.Background(), squad.ExecRequest{Prompt: "x", Targets: []string{"output.md"}})
if err != nil {
t.Fatal(err)
}
if res2.Success || !strings.Contains(res2.ErrorReason, "input-required") {
t.Fatalf("input-required 映射 = %+v", res2)
}
}
// 直接回 Message(无任务态)→ 文本落目标。
func TestExecuteDirectMessageResult(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req rpcRequest
_ = json.NewDecoder(r.Body).Decode(&req)
writeRPCResult(w, req.ID, Message{Kind: "message", Role: "agent",
Parts: []Part{{Kind: "text", Text: "直接回答正文"}}})
}))
defer ts.Close()
ex := &Executor{BaseURL: ts.URL, Client: ts.Client()}
res, err := ex.Execute(context.Background(), squad.ExecRequest{Prompt: "x", Targets: []string{"output.md"}})
if err != nil {
t.Fatal(err)
}
if !res.Success || !strings.Contains(string(res.Patches["output.md"]), "直接回答正文") {
t.Fatalf("res = %+v", res)
}
}
// 传输/协议错误 → error(引擎按瞬态评估),不吞成成功。
func TestExecuteProtocolErrors(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeRPCError(w, 1, -32601, "method not found")
}))
defer ts.Close()
ex := &Executor{BaseURL: ts.URL, Client: ts.Client()}
if _, err := ex.Execute(context.Background(), squad.ExecRequest{Prompt: "x"}); err == nil ||
!strings.Contains(err.Error(), "协议错误") {
t.Fatalf("err = %v", err)
}
// ctx 取消传播。
ctx, cancel := context.WithCancel(context.Background())
cancel()
slow := newFakeServer(t, AgentCard{ProtocolVersion: "1.0", Name: "x", URL: ts.URL, Version: "1"},
[]string{StateWorking}, nil)
ts2 := httptest.NewServer(slow.handler())
defer ts2.Close()
ex2 := &Executor{BaseURL: ts2.URL + "/rpc", Client: ts2.Client(), PollInterval: time.Second}
if _, err := ex2.Execute(ctx, squad.ExecRequest{Prompt: "x"}); err == nil {
t.Fatal("取消必须传播")
}
}
// FetchCard fail-closed:缺必备字段拒绝。
func TestFetchCardValidates(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"protocolVersion":"1.0","name":"","url":"","version":""}`))
}))
defer ts.Close()
if _, err := FetchCard(ts.Client(), ts.URL); err == nil || !strings.Contains(err.Error(), "name") {
t.Fatalf("err = %v", err)
}
}
+331
View File
@@ -0,0 +1,331 @@
package a2a
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync/atomic"
"time"
"agent-squad/squad"
)
// executor.go — 远端智能体 Executor 客户端(A2A 任务状态机对齐 ExecResult)。
// 任务状态(A2A 1.0 TaskState)。
const (
StateSubmitted = "submitted"
StateWorking = "working"
StateInputRequired = "input-required"
StateCompleted = "completed"
StateCanceled = "canceled"
StateFailed = "failed"
StateRejected = "rejected"
)
// DefaultPollInterval 是 tasks/get 轮询缺省间隔。
const DefaultPollInterval = 500 * time.Millisecond
// Executor 把远端 A2A 智能体接成 squad.Executor:一次步骤执行 = 一个
// A2A 任务(message/send 发起,tasks/get 轮询到终态)。
type Executor struct {
// BaseURL 远端 JSON-RPC 端点(Agent Card 的 url)。
BaseURL string
// Client HTTP 客户端(nil = http.DefaultClient)。
Client *http.Client
// PollInterval 轮询间隔(0 = DefaultPollInterval)。
PollInterval time.Duration
// OnState 可选状态回调(观测任务状态机迁移)。
OnState func(state string)
callID atomic.Int64
}
// NewExecutor 按 Agent Card 构造远端执行器。
func NewExecutor(card AgentCard) *Executor {
return &Executor{BaseURL: card.URL}
}
// Name 实现 squad.Executor。
func (e *Executor) Name() string { return "a2a" }
// ---- A2A 1.0 报文形态(JSON-RPC 2.0) ----
type rpcRequest struct {
JSONRPC string `json:"jsonrpc"`
ID int64 `json:"id"`
Method string `json:"method"`
Params any `json:"params"`
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
type rpcResponse struct {
JSONRPC string `json:"jsonrpc"`
ID int64 `json:"id"`
Result json.RawMessage `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
}
// Part 是消息/工件的部件(text | file | data)。
type Part struct {
Kind string `json:"kind"`
Text string `json:"text,omitempty"`
File *FilePart `json:"file,omitempty"`
Data *json.RawMessage `json:"data,omitempty"`
}
// FilePart 文件部件:bytes(base64)或 uri 二选一。
type FilePart struct {
Name string `json:"name"`
Bytes string `json:"bytes,omitempty"`
URI string `json:"uri,omitempty"`
MimeType string `json:"mimeType,omitempty"`
}
// Message 是一条 A2A 消息。
type Message struct {
Kind string `json:"kind"`
Role string `json:"role"`
Parts []Part `json:"parts"`
MessageID string `json:"messageId"`
}
// TaskStatus 任务状态。
type TaskStatus struct {
State string `json:"state"`
Message *Message `json:"message,omitempty"`
Timestamp string `json:"timestamp,omitempty"`
}
// Artifact 任务工件。
type Artifact struct {
ArtifactID string `json:"artifactId,omitempty"`
Name string `json:"name,omitempty"`
Parts []Part `json:"parts"`
}
// Task 是 A2A 任务(状态机载体)。
type Task struct {
Kind string `json:"kind"`
ID string `json:"id"`
ContextID string `json:"contextId,omitempty"`
Status TaskStatus `json:"status"`
Artifacts []Artifact `json:"artifacts,omitempty"`
}
// Execute 实现 squad.Executor:发起任务并轮询到终态,映射 ExecResult。
// 传输/协议错误返回 error(引擎按瞬态失败评估);任务级失败
// (failed/canceled/rejected/input-required)返回 Success=false 的结果
// (fail-closed:跨进程人工输入不在本适配面内)。
func (e *Executor) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) {
if strings.TrimSpace(e.BaseURL) == "" {
return nil, fmt.Errorf("a2a: executor 缺 BaseURL")
}
prompt := strings.TrimSpace(req.Prompt)
if prompt == "" {
prompt = strings.TrimSpace(req.Instructions)
}
msg := Message{
Kind: "message", Role: "user",
Parts: []Part{{Kind: "text", Text: prompt}},
MessageID: fmt.Sprintf("msg-%d-%d", time.Now().UnixNano(), e.callID.Add(1)),
}
raw, err := e.call(ctx, "message/send", map[string]any{"message": msg})
if err != nil {
return nil, err
}
// 服务端可直接回 Message(无任务态)或 Task。
var probe struct {
Kind string `json:"kind"`
}
_ = json.Unmarshal(raw, &probe)
if probe.Kind == "message" {
var m Message
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("a2a: message 结果解析失败: %w", err)
}
return e.resultFromParts(m.Parts, req.Targets), nil
}
var task Task
if err := json.Unmarshal(raw, &task); err != nil {
return nil, fmt.Errorf("a2a: task 结果解析失败: %w", err)
}
e.observe(task.Status.State)
if shouldStop(task.Status.State) {
return e.resultFromTask(task, req.Targets), nil
}
// 轮询任务状态机直至终态(ctx 取消传播)。
interval := e.PollInterval
if interval <= 0 {
interval = DefaultPollInterval
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-ticker.C:
}
raw, err := e.call(ctx, "tasks/get", map[string]any{"id": task.ID})
if err != nil {
return nil, err
}
var next Task
if err := json.Unmarshal(raw, &next); err != nil {
return nil, fmt.Errorf("a2a: tasks/get 解析失败: %w", err)
}
if next.Status.State != task.Status.State {
task = next
e.observe(task.Status.State)
} else {
task = next
}
if shouldStop(task.Status.State) {
return e.resultFromTask(task, req.Targets), nil
}
}
}
// call 发一次 JSON-RPC 调用并取 result。
func (e *Executor) call(ctx context.Context, method string, params any) (json.RawMessage, error) {
client := e.Client
if client == nil {
client = http.DefaultClient
}
body, _ := json.Marshal(rpcRequest{
JSONRPC: "2.0", ID: e.callID.Add(1), Method: method, Params: params,
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, e.BaseURL, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("a2a: %s 传输失败: %w", method, err)
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err != nil {
return nil, fmt.Errorf("a2a: %s 读取响应失败: %w", method, err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("a2a: %s 返回 HTTP %d", method, resp.StatusCode)
}
var env rpcResponse
if err := json.Unmarshal(data, &env); err != nil {
return nil, fmt.Errorf("a2a: %s 响应不是合法 JSON-RPC: %w", method, err)
}
if env.Error != nil {
return nil, fmt.Errorf("a2a: %s 协议错误 %d: %s", method, env.Error.Code, env.Error.Message)
}
return env.Result, nil
}
func (e *Executor) observe(state string) {
if e.OnState != nil {
e.OnState(state)
}
}
func isTerminal(state string) bool {
switch state {
case StateCompleted, StateFailed, StateCanceled, StateRejected:
return true
}
return false
}
// shouldStop 轮询停止条件:真终态,或 input-required(跨进程人工输入不在
// 本适配面内,fail-closed 交引擎语义处理,不死等)。
func shouldStop(state string) bool {
return isTerminal(state) || state == StateInputRequired
}
// resultFromTask 终态任务 → ExecResult。
func (e *Executor) resultFromTask(task Task, targets []string) *squad.ExecResult {
switch task.Status.State {
case StateCompleted:
var parts []Part
for _, art := range task.Artifacts {
parts = append(parts, art.Parts...)
}
return e.resultFromParts(parts, targets)
case StateInputRequired:
return &squad.ExecResult{Success: false,
ErrorReason: "a2a: 远端要求人工输入(input-required):" + statusText(task.Status)}
case StateCanceled:
return &squad.ExecResult{Success: false,
ErrorReason: "a2a: 远端任务被取消(canceled):" + statusText(task.Status)}
case StateRejected:
return &squad.ExecResult{Success: false,
ErrorReason: "a2a: 远端拒绝任务(rejected):" + statusText(task.Status)}
default: // failed 及未知终态一律 fail-closed
return &squad.ExecResult{Success: false,
ErrorReason: "a2a: 远端任务失败(" + task.Status.State + "):" + statusText(task.Status)}
}
}
// resultFromParts 部件流 → patches:FilePart(bytes) 按名落 patch;
// 文本部件拼接后写入全部声明目标(无文件工件时的交付物兜底,
// 与其他后端「正文写目标文件」契约一致)。
func (e *Executor) resultFromParts(parts []Part, targets []string) *squad.ExecResult {
patches := map[string][]byte{}
var text strings.Builder
for _, p := range parts {
switch p.Kind {
case "text":
if text.Len() > 0 {
text.WriteString("\n")
}
text.WriteString(p.Text)
case "file":
if p.File == nil || p.File.Name == "" {
continue
}
data, err := base64.StdEncoding.DecodeString(p.File.Bytes)
if err != nil {
continue // uri 形态与坏 base64 不落 patch(消费侧最小面)
}
patches[p.File.Name] = data
}
}
body := strings.TrimSpace(text.String())
if len(targets) == 0 {
targets = []string{"output.md"}
}
if body != "" {
for _, t := range targets {
if _, ok := patches[t]; !ok {
patches[t] = []byte(body + "\n")
}
}
}
if len(patches) == 0 {
return &squad.ExecResult{Success: false, ErrorReason: "a2a: 完成任务无任何工件"}
}
return &squad.ExecResult{Success: true, Patches: patches, Logs: body}
}
func statusText(st TaskStatus) string {
if st.Message == nil {
return ""
}
var b strings.Builder
for _, p := range st.Message.Parts {
if p.Kind == "text" {
b.WriteString(p.Text)
}
}
return b.String()
}
+11
View File
@@ -0,0 +1,11 @@
module agent-squad/adapters/a2a
go 1.27
require agent-squad v0.0.0
require error-code-system v0.0.0 // indirect
replace agent-squad => ../..
replace error-code-system => ../../deps/error-code-system
@@ -6,22 +6,22 @@ import (
"sort"
"strings"
"agent-native/exec"
"agent-squad/squad"
)
// Catalog 把识别快照渲染为 squad 的能力目录数据视图(规划者提示词 + 计划校验用)。
// 闸门未启用(nil Gate)时返回 nil。
func (g *Gate) Catalog() *squad.IntentCatalog {
if g == nil || g.snap == nil {
snap := g.Snapshot()
if g == nil || snap == nil {
return nil
}
catalog := &squad.IntentCatalog{
Revision: g.snap.Revision(),
Markdown: g.snap.CatalogMarkdown(),
Specs: make(map[string]squad.IntentSpecView, len(g.snap.SpecIDs())),
Revision: snap.Revision(),
Markdown: snap.CatalogMarkdown(),
Specs: make(map[string]squad.IntentSpecView, len(snap.SpecIDs())),
}
for _, cs := range g.snap.Catalog() {
for _, cs := range snap.Catalog() {
view := squad.IntentSpecView{
ID: cs.ID,
Description: cs.Description,
@@ -66,11 +66,11 @@ func (g *Gate) EchoRunner() *Runner {
}
// Run 实现 squad.IntentRunner。未知意图/未知 handler fail-closed(不静默降级)。
func (r *Runner) Run(ctx context.Context, step squad.IntentStep) (*exec.Result, error) {
if r == nil || r.gate == nil || r.gate.snap == nil {
func (r *Runner) Run(ctx context.Context, step squad.IntentStep) (*squad.ExecResult, error) {
if r == nil || r.gate == nil || r.gate.Snapshot() == nil {
return nil, fmt.Errorf("intent runner: gate not configured")
}
spec, ok := r.gate.snap.SpecByID(step.IntentID)
spec, ok := r.gate.Snapshot().SpecByID(step.IntentID)
if !ok {
return nil, fmt.Errorf("intent runner: snapshot 中不存在意图 %s", step.IntentID)
}
@@ -90,12 +90,12 @@ func (r *Runner) Run(ctx context.Context, step squad.IntentStep) (*exec.Result,
return textResult(step, text), nil
}
func textResult(step squad.IntentStep, text string) *exec.Result {
func textResult(step squad.IntentStep, text string) *squad.ExecResult {
patches := make(map[string][]byte, len(step.Targets))
for _, t := range step.Targets {
patches[t] = []byte(text)
}
return &exec.Result{Success: true, Patches: patches, Logs: "intent:" + step.IntentID}
return &squad.ExecResult{Success: true, Patches: patches, Logs: "intent:" + step.IntentID}
}
func echoText(step squad.IntentStep, handlerKey string, _ bool) string {
@@ -120,3 +120,25 @@ func TestNilGateCatalogAndRunner(t *testing.T) {
t.Fatal("nil gate runner must fail-closed")
}
}
func TestR2GateReloadSeesNewIntent(t *testing.T) {
fsys := fstest.MapFS{"intents.json": &fstest.MapFile{Data: []byte(`{"intents":[{"id":"demo.old","domain":"demo","version":1,"matcher":{"includes_any":["旧"]}}]}`)}}
g, err := New(context.Background(), Options{
IntentOptions: intent.Options{},
FS: fsys,
Roots: []string{"."},
})
if err != nil {
t.Fatal(err)
}
if _, ok := g.Snapshot().SpecByID("demo.new"); ok {
t.Fatal("new spec exists before reload")
}
fsys["intents.json"].Data = []byte(`{"intents":[{"id":"demo.old","domain":"demo","version":1,"matcher":{"includes_any":["旧"]}},{"id":"demo.new","domain":"demo","version":1,"matcher":{"includes_any":["新"]}}]}`)
if _, err := g.Reload(context.Background()); err != nil {
t.Fatal(err)
}
if _, ok := g.Snapshot().SpecByID("demo.new"); !ok {
t.Fatal("reload did not publish demo.new")
}
}
@@ -31,6 +31,21 @@ type Options struct {
Roots []string
}
// NewFromOverlay 从 JSON overlay 构造闸门,并把 guard 域标成受保护域。
// 命令行用它,这样命令本身不必 import agent-intent。
// NFKC 开启:全角/兼容等价输入与词条同形折叠(与 backend intentgate 一致)。
func NewFromOverlay(ctx context.Context, fsys fs.FS, roots ...string) (*Gate, error) {
return New(ctx, Options{
IntentOptions: intent.Options{
GuardDomains: []string{"guard"},
ProtectedDomains: []string{"guard"},
NFKC: true,
},
FS: fsys,
Roots: roots,
})
}
// New 构造闸门。没有任何意图来源时返回 (nil, nil)——引擎拿到 nil IntentGate,
// 行为与原实现一致(T0 行为解耦)。
func New(ctx context.Context, o Options) (*Gate, error) {
@@ -55,32 +70,40 @@ func New(ctx context.Context, o Options) (*Gate, error) {
if err != nil {
return nil, err
}
return &Gate{rec: intent.NewRecognizer(o.IntentOptions, nil, nil), snap: reg.Snapshot()}, nil
return &Gate{rec: intent.NewRecognizer(o.IntentOptions, nil, nil), reg: reg}, nil
}
// Gate 实现 squad.IntentGate。
// Gate 实现 squad.IntentGate。快照从 Registry 现读,Reload 后下一次 Decide 即生效。
type Gate struct {
rec intent.Recognizer
snap *intent.Snapshot
rec intent.Recognizer
reg *intent.Registry
}
// Snapshot 暴露当前识别快照(诊断/审计用;Reload 由宿主经重建闸门实现)。
// Snapshot 暴露当前识别快照(诊断/审计用)。
func (g *Gate) Snapshot() *intent.Snapshot {
if g == nil {
if g == nil || g.reg == nil {
return nil
}
return g.snap
return g.reg.Snapshot()
}
// Reload 重读配置来源。失败时保留旧快照。
func (g *Gate) Reload(ctx context.Context) (*intent.Snapshot, error) {
if g == nil || g.reg == nil {
return nil, nil
}
return g.reg.Reload(ctx)
}
// Decide 实现 squad.IntentGate。同输入 + 同快照结果确定;无副作用。
func (g *Gate) Decide(ctx context.Context, goal squad.Goal, opts squad.RunOptions) (*squad.IntentDecision, error) {
if g == nil || g.snap == nil {
if g == nil || g.Snapshot() == nil {
return nil, nil
}
if opts.TaskKey != "" {
return nil, nil // 显式 TaskKey 优先(引擎侧同样保证,双保险)
}
res, err := g.rec.Recognize(ctx, goal.Text, g.snap)
res, err := g.rec.Recognize(ctx, goal.Text, g.Snapshot())
if err != nil {
return nil, err
}
+22
View File
@@ -0,0 +1,22 @@
module agent-squad/adapters/intent
go 1.27
require (
agent-intent v0.0.0
agent-squad v0.0.0
)
require (
error-code-system v0.0.0 // indirect
golang.org/x/text v0.29.0 // indirect
logging-system v0.0.0 // indirect
)
replace agent-intent => ../../deps/agent-intent
replace agent-squad => ../..
replace error-code-system => ../../deps/error-code-system
replace logging-system => ../../deps/logging-system
+2
View File
@@ -0,0 +1,2 @@
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
@@ -7,7 +7,6 @@ import (
"strings"
"testing"
"agent-native/exec"
"agent-squad/squad"
)
@@ -16,7 +15,7 @@ type integrationScriptBackend struct{ content string }
func (b *integrationScriptBackend) Name() string { return "script" }
func (b *integrationScriptBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
func (b *integrationScriptBackend) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) {
patches := map[string][]byte{}
for _, t := range req.Targets {
out := filepath.Join(req.WorkDir, t)
@@ -26,7 +25,7 @@ func (b *integrationScriptBackend) Execute(ctx context.Context, req exec.Request
}
patches[t] = []byte(b.content)
}
return &exec.Result{Success: true, Patches: patches, Logs: "script"}, nil
return &squad.ExecResult{Success: true, Patches: patches, Logs: "script"}, nil
}
const intentReport = "# 竞品分析报告\n\n## 概述\n\n由意图定向的蓝图模板生成的交付物,长度满足最小质检要求。\n\n## 结论\n\n符合预期。\n"
+21
View File
@@ -0,0 +1,21 @@
module agent-squad/adapters/native
go 1.27
require (
agent-native v0.0.0
agent-squad v0.0.0
)
require (
error-code-system v0.0.0 // indirect
logging-system v0.0.0 // indirect
)
replace agent-native => ../../deps/agent-native
replace agent-squad => ../..
replace error-code-system => ../../deps/error-code-system
replace logging-system => ../../deps/logging-system
+205
View File
@@ -0,0 +1,205 @@
// Package native 把 agent-native 接成小队的 Executor 与 ModelClient。
package native
import (
"context"
"fmt"
"strings"
"time"
nativexec "agent-native/adapters/native"
"agent-native/exec"
"agent-native/runtime"
"agent-native/runtime/checkpoint"
"agent-native/runtime/hooks"
"agent-native/runtime/llm"
"agent-native/runtime/mcp"
"agent-native/runtime/subagent"
"agent-native/runtime/tools"
"agent-squad/squad"
)
// Config 是组合根交给 agent-native 后端的装配。零值只做模型执行。
type Config struct {
Client *llm.Client
DefaultModel string
MaxTurns int
Timeout time.Duration
Search tools.SearchProvider
Exec *tools.ExecConfig
Fetch *tools.FetchConfig
MCP *mcp.Manager
SkillsRoots []string
Subagent subagent.Factory
Compact *nativexec.CompactConfig
HostTools []tools.Tool
OnEvent func(ev squad.ProcessEvent)
Stream llm.StreamFunc
MaxToolCalls int
BeforeTool func(call llm.ToolCall) error
AfterTool func(call llm.ToolCall, res llm.Message)
Stop func(last llm.Message, info runtime.StopInfo) runtime.StopDecision
Reminders func(turn int) []string
RepeatGuard bool
Hooks *hooks.Bus
Checkpoints *checkpoint.Log
CheckpointAnchor func() string
Trace func(name string, attrs map[string]string) func()
CodeOrchestration bool
Ask func(ctx context.Context, question string) (string, error)
// PermissionAsk 把内核钩子的 ask 决策转交宿主裁决(nil = 内核保持
// ask 按拒绝处理的旧行为)。经 nativexec.GateOptions 透传。
PermissionAsk func(ctx context.Context, call llm.ToolCall) runtime.AskVerdict
}
// Register 把 "agent-native" 工厂注册进解析器。
func Register(r *squad.BackendResolver, client *llm.Client, defaultModel string, maxTurns int, timeout time.Duration) {
RegisterFull(r, Config{Client: client, DefaultModel: defaultModel, MaxTurns: maxTurns, Timeout: timeout})
}
// RegisterFull 注册带工具装配的 "agent-native" 工厂。
func RegisterFull(r *squad.BackendResolver, cfg Config) {
r.Register("agent-native", func(role squad.RoleSpec) (squad.Executor, error) {
opts, err := buildOptions(cfg, role)
if err != nil {
return nil, err
}
next := &bridge{next: nativexec.New(opts)}
if cfg.OnEvent == nil {
return next, nil
}
return &eventTap{next: next, on: cfg.OnEvent}, nil
})
}
func buildOptions(cfg Config, role squad.RoleSpec) (nativexec.Options, error) {
model := role.Model
if model == "" {
model = cfg.DefaultModel
}
if model == "" {
return nativexec.Options{}, fmt.Errorf("agent-native backend requires a model (role=%s)", role.Role)
}
opts := nativexec.Options{
ModelOptions: nativexec.ModelOptions{
Model: model, MaxTurns: cfg.MaxTurns, Timeout: cfg.Timeout, Stream: cfg.Stream,
},
ToolOptions: nativexec.ToolOptions{
Search: cfg.Search, Fetch: cfg.Fetch, MCP: cfg.MCP,
SkillsRoots: cfg.SkillsRoots, Subagent: cfg.Subagent, HostTools: cfg.HostTools,
Checkpoints: cfg.Checkpoints, CheckpointAnchor: cfg.CheckpointAnchor,
CodeOrchestration: cfg.CodeOrchestration,
},
ObserveOptions: nativexec.ObserveOptions{Trace: cfg.Trace},
SessionOptions: nativexec.SessionOptions{Compact: cfg.Compact, MaxToolCalls: cfg.MaxToolCalls},
GateOptions: nativexec.GateOptions{
BeforeTool: cfg.BeforeTool, AfterTool: cfg.AfterTool, Stop: cfg.Stop,
Reminders: cfg.Reminders, RepeatGuard: cfg.RepeatGuard, Hooks: cfg.Hooks,
Ask: cfg.Ask, PermissionAsk: cfg.PermissionAsk,
},
}
if cfg.Client != nil {
opts.APIBase, opts.APIKey = cfg.Client.BaseURL, cfg.Client.APIKey
}
if cfg.Exec != nil && (role.BashPermission == squad.BashPermissionAllow || role.BashPermission == squad.BashPermissionAsk) {
opts.Exec = cfg.Exec
}
return opts, nil
}
type bridge struct{ next exec.Backend }
func (b *bridge) Name() string { return b.next.Name() }
func (b *bridge) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) {
res, err := b.next.Execute(ctx, toNative(req))
if res == nil {
return nil, err
}
return fromNative(res), err
}
func toNative(req squad.ExecRequest) exec.Request {
out := exec.Request{
WorkDir: req.WorkDir, Prompt: req.Prompt, Instructions: req.Instructions,
Targets: req.Targets, Timeout: req.Timeout, ToolProfile: req.ToolProfile,
Model: req.Model, ToolAllowlist: req.ToolAllowlist, Skills: req.Skills,
SessionScope: req.SessionScope, ExecutionID: req.ExecutionID, Env: req.Env,
}
if req.Budget != nil {
out.Budget = &exec.Budget{
MaxPromptTokens: req.Budget.MaxPromptTokens, MaxCompletionTokens: req.Budget.MaxCompletionTokens,
MaxToolCalls: req.Budget.MaxToolCalls, Deadline: req.Budget.Deadline,
}
}
if req.OnEvent != nil {
prev := req.OnEvent
out.OnEvent = func(ev exec.ProcessEvent) {
prev(squad.ProcessEvent{
Type: ev.Type, Text: ev.Text, Tool: ev.Tool, Detail: ev.Detail,
PromptTokens: ev.PromptTokens, CompletionTokens: ev.CompletionTokens,
LastPromptTokens: ev.LastPromptTokens, LastCompletionTokens: ev.LastCompletionTokens,
CacheReadTokens: ev.CacheReadTokens, CacheWriteTokens: ev.CacheWriteTokens,
})
}
}
return out
}
func fromNative(res *exec.Result) *squad.ExecResult {
return &squad.ExecResult{
Success: res.Success, Patches: res.Patches, Logs: res.Logs, ErrorReason: res.ErrorReason,
Retryable: res.Retryable, PromptTokens: res.PromptTokens, CompletionTokens: res.CompletionTokens,
}
}
type eventTap struct {
next squad.Executor
on func(squad.ProcessEvent)
}
func (b *eventTap) Name() string { return b.next.Name() }
func (b *eventTap) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) {
prev := req.OnEvent
req.OnEvent = func(ev squad.ProcessEvent) {
b.on(ev)
if prev != nil {
prev(ev)
}
}
return b.next.Execute(ctx, req)
}
// LLM 把 llm.Client 适配成 ModelClient。
type LLM struct{ Inner *llm.Client }
func (c LLM) Complete(ctx context.Context, turn squad.ModelTurn) (string, error) {
if c.Inner == nil {
return "", fmt.Errorf("llm planner: no model client")
}
msgs := []llm.Message{
{Role: llm.RoleSystem, Content: turn.System},
{Role: llm.RoleUser, Content: turn.User},
}
opts := llm.StreamOptions{MaxTokens: turn.MaxTokens, APIBase: turn.APIBase, APIKey: turn.APIKey}
var sb strings.Builder
for ev := range c.Inner.Stream(ctx, turn.Model, msgs, nil, opts) {
if ev.Kind == llm.KindError {
return "", fmt.Errorf("%s", ev.ErrorMessage)
}
if ev.Kind == llm.KindTextDelta {
sb.WriteString(ev.Text)
}
if ev.Kind == llm.KindMessageEnd {
if ev.Message.IsError {
return "", fmt.Errorf("assistant error: %s", ev.Message.Content)
}
if sb.Len() == 0 {
sb.WriteString(ev.Message.Content)
}
break
}
}
return sb.String(), nil
}
+49
View File
@@ -0,0 +1,49 @@
package native
import (
"testing"
"time"
"agent-native/runtime/llm"
"agent-native/runtime/tools"
"agent-squad/squad"
)
func TestBuildOptionsBashPermissionGating(t *testing.T) {
base := Config{
Client: &llm.Client{BaseURL: "http://x", APIKey: "k"},
DefaultModel: "m1", MaxTurns: 5, Timeout: time.Minute,
Exec: &tools.ExecConfig{Bash: "/bin/bash"},
SkillsRoots: []string{"/skills"},
}
deny, err := buildOptions(base, squad.RoleSpec{Role: "r", BashPermission: squad.BashPermissionDeny})
if err != nil {
t.Fatal(err)
}
if deny.Exec != nil {
t.Fatal("deny role must not get exec tools even when config provides them")
}
ask, _ := buildOptions(base, squad.RoleSpec{Role: "r", BashPermission: squad.BashPermissionAsk})
if ask.Exec == nil {
t.Fatal("ask role gets exec tools")
}
allow, _ := buildOptions(base, squad.RoleSpec{Role: "r", BashPermission: squad.BashPermissionAllow})
if allow.Exec == nil {
t.Fatal("allow role gets exec tools")
}
empty, _ := buildOptions(base, squad.RoleSpec{Role: "r"})
if empty.Exec != nil {
t.Fatal("unset bash_permission defaults to deny")
}
none, _ := buildOptions(Config{Client: base.Client, DefaultModel: "m"}, squad.RoleSpec{Role: "r", BashPermission: squad.BashPermissionAllow})
if none.Exec != nil {
t.Fatal("no Exec config = no exec tools")
}
roleModel, _ := buildOptions(base, squad.RoleSpec{Role: "r", Model: "big"})
if roleModel.Model != "big" {
t.Fatalf("role model must win, got %s", roleModel.Model)
}
if _, err := buildOptions(Config{Client: base.Client}, squad.RoleSpec{Role: "r"}); err == nil {
t.Fatal("missing model must fail-closed")
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ type Store struct {
}
func Open(path string) (*Store, error) {
db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)")
db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)")
if err != nil {
return nil, err
}
+32
View File
@@ -0,0 +1,32 @@
module agent-squad/adapters/sqlite
go 1.27
require (
agent-native v0.0.0
agent-squad v0.0.0
modernc.org/sqlite v1.37.0
)
require (
error-code-system v0.0.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
golang.org/x/sys v0.31.0 // indirect
logging-system v0.0.0 // indirect
modernc.org/libc v1.62.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.9.1 // indirect
)
replace agent-native => ../../deps/agent-native
replace agent-squad => ../..
replace error-code-system => ../../deps/error-code-system
replace logging-system => ../../deps/logging-system
+47
View File
@@ -0,0 +1,47 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
modernc.org/cc/v4 v4.25.2 h1:T2oH7sZdGvTaie0BRNFbIYsabzCxUQg8nLqCdQ2i0ic=
modernc.org/cc/v4 v4.25.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.25.1 h1:TFSzPrAGmDsdnhT9X2UrcPMI3N/mJ9/X9ykKXwLhDsU=
modernc.org/ccgo/v4 v4.25.1/go.mod h1:njjuAYiPflywOOrm3B7kCB444ONP5pAVr8PIEoE0uDw=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.62.1 h1:s0+fv5E3FymN8eJVmnk0llBe6rOxCu/DEU+XygRbS8s=
modernc.org/libc v1.62.1/go.mod h1:iXhATfJQLjG3NWy56a6WVU73lWOcdYVxsvwCgoPljuo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g=
modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
@@ -11,7 +11,7 @@ import (
"testing"
"time"
"agent-native/runtime/llm"
agentsquad "agent-squad/squad"
)
func TestOrchestrationFlow(t *testing.T) {
@@ -116,12 +116,10 @@ func TestOrchestrationFlow(t *testing.T) {
if err != nil {
t.Fatal(err)
}
text, err := s.RunInProcess(context.Background(), woke, work, func(ctx context.Context, model string, messages []llm.Message, td []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
ch := make(chan llm.Event, 1)
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd}}
close(ch)
return ch
})
text, err := s.RunInProcess(context.Background(), woke, work, stubExecutor{result: &agentsquad.ExecResult{
Success: true, Logs: "done",
Patches: map[string][]byte{"output.md": []byte("done")},
}})
if err != nil || text != "done" {
t.Fatalf("in-process %q %v", text, err)
}
@@ -233,6 +231,17 @@ func openTest(t *testing.T) *Store {
return s
}
func TestR2JournalModeWAL(t *testing.T) {
s := openTest(t)
var mode string
if err := s.db.QueryRow(`PRAGMA journal_mode`).Scan(&mode); err != nil {
t.Fatal(err)
}
if mode != "wal" {
t.Fatalf("journal_mode %q", mode)
}
}
func claimAny(t *testing.T, s *Store, workspaceID string) (Task, bool, error) {
t.Helper()
rt, err := s.RegisterRuntime(Runtime{WorkspaceID: workspaceID, Name: "tmp", Command: "in-process", Protocol: "agent-native"})
@@ -233,8 +233,10 @@ func (s *Store) Claim(runtimeID string, at time.Time) (Task, bool, error) {
continue
}
lease := at.UTC().Add(5 * time.Minute).Format(time.RFC3339Nano)
res, err := s.db.Exec(`UPDATE task SET status='dispatched', runtime_id=?, lease_until=?, updated_at=? WHERE id=? AND status='queued'`,
runtimeID, lease, now(), cand.id)
res, err := s.db.Exec(`UPDATE task SET status='dispatched', runtime_id=?, lease_until=?, updated_at=?
WHERE id=? AND status='queued'
AND (SELECT COUNT(*) FROM task WHERE agent_id=? AND status IN ('dispatched','running')) < ?`,
runtimeID, lease, now(), cand.id, cand.agentID, cap)
if err != nil {
return Task{}, false, err
}
+230
View File
@@ -0,0 +1,230 @@
package platform
import (
"database/sql"
"encoding/json"
"fmt"
"os"
"time"
"agent-squad/squad"
)
// SQLiteStore 把 squad 的 Store 端口落到本库(Q1 WP5):run/gate/checkpoint/leader
// 与平台其余表同一个 SQLite 文件——运行状态与平台状态不再各记一份。
// 表名带 squad_run_ 前缀,与平台既有 23 张表并存不冲突。
type SQLiteStore struct {
db *sql.DB
}
// OpenRunStore 在本 Store 的底层连接上提供 squad.Store 端口(schema 随 migrate 建)。
func (s *Store) RunStore() *SQLiteStore {
return &SQLiteStore{db: s.db}
}
const runSchema = `
CREATE TABLE IF NOT EXISTS squad_run_state (
ws_dir TEXT PRIMARY KEY,
meta TEXT NOT NULL, workflow TEXT NOT NULL, squad TEXT NOT NULL, bb TEXT NOT NULL,
template TEXT, approved TEXT, repair_budget_remaining INTEGER NOT NULL DEFAULT 0,
repair_budget_granted INTEGER NOT NULL DEFAULT 0,
clarifications TEXT, steering TEXT, usage TEXT, updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS squad_run_gate (
ws_dir TEXT PRIMARY KEY, gate TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS squad_run_checkpoint (
ws_dir TEXT NOT NULL, superstep INTEGER NOT NULL, at TEXT NOT NULL,
graph_hash TEXT NOT NULL, steps TEXT NOT NULL,
PRIMARY KEY (ws_dir, superstep)
);
CREATE TABLE IF NOT EXISTS squad_run_leader (
ws_dir TEXT PRIMARY KEY, data TEXT NOT NULL
);
`
func (s *SQLiteStore) migrate() error {
_, err := s.db.Exec(runSchema)
return err
}
// OpenSQLiteStore 独立打开一个 squad.Store(不经 platform.Store 的场景,如测试)。
func OpenSQLiteStore(path string) (*SQLiteStore, error) {
db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)")
if err != nil {
return nil, err
}
s := &SQLiteStore{db: db}
if err := s.migrate(); err != nil {
_ = db.Close()
return nil, err
}
return s, nil
}
func j(v any) string {
b, err := json.Marshal(v)
if err != nil {
return ""
}
return string(b)
}
func (s *SQLiteStore) SaveRun(st *squad.RunState) error {
if err := s.migrate(); err != nil {
return err
}
_, err := s.db.Exec(`INSERT INTO squad_run_state
(ws_dir, meta, workflow, squad, bb, template, approved, repair_budget_remaining, repair_budget_granted, clarifications, steering, usage, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(ws_dir) DO UPDATE SET
meta=excluded.meta, workflow=excluded.workflow, squad=excluded.squad, bb=excluded.bb,
template=excluded.template, approved=excluded.approved,
repair_budget_remaining=excluded.repair_budget_remaining,
repair_budget_granted=excluded.repair_budget_granted,
clarifications=excluded.clarifications, steering=excluded.steering,
usage=excluded.usage, updated_at=excluded.updated_at`,
st.Meta.WorkDir, j(st.Meta), j(st.Plan.Workflow), j(st.Plan.Squad), j(st.BB),
st.Template, j(st.Approved), st.RepairBudgetRemaining, st.RepairBudgetGranted,
j(st.Clarifications), j(st.Steering), j(st.Usage), now())
return err
}
func (s *SQLiteStore) LoadRun(wsDir string) (*squad.RunState, error) {
if err := s.migrate(); err != nil {
return nil, err
}
st := &squad.RunState{}
var meta, wf, sq, bb, template, approved, clar, steering, usage string
row := s.db.QueryRow(`SELECT meta, workflow, squad, bb, template, approved,
repair_budget_remaining, repair_budget_granted, clarifications, steering, usage
FROM squad_run_state WHERE ws_dir = ?`, wsDir)
var granted int
err := row.Scan(&meta, &wf, &sq, &bb, &template, &approved,
&st.RepairBudgetRemaining, &granted, &clar, &steering, &usage)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("load run %s: %w", wsDir, os.ErrNotExist)
}
return nil, err
}
st.RepairBudgetGranted = granted
for pair, dst := range map[string]any{
meta: &st.Meta, wf: &st.Plan.Workflow, sq: &st.Plan.Squad, bb: &st.BB,
approved: &st.Approved, clar: &st.Clarifications, steering: &st.Steering, usage: &st.Usage,
} {
if err := json.Unmarshal([]byte(pair), dst); err != nil {
return nil, fmt.Errorf("load run %s: parse: %w", wsDir, err)
}
}
st.Template = template
if st.Approved == nil {
st.Approved = map[string]bool{}
}
return st, nil
}
func (s *SQLiteStore) SaveGate(wsDir string, gate *squad.HitlGate) error {
if err := s.migrate(); err != nil {
return err
}
if gate == nil {
_, err := s.db.Exec(`DELETE FROM squad_run_gate WHERE ws_dir = ?`, wsDir)
return err
}
_, err := s.db.Exec(`INSERT INTO squad_run_gate (ws_dir, gate) VALUES (?,?)
ON CONFLICT(ws_dir) DO UPDATE SET gate=excluded.gate`, wsDir, j(gate))
return err
}
func (s *SQLiteStore) LoadGate(wsDir string) (*squad.HitlGate, error) {
if err := s.migrate(); err != nil {
return nil, err
}
var raw string
err := s.db.QueryRow(`SELECT gate FROM squad_run_gate WHERE ws_dir = ?`, wsDir).Scan(&raw)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
gate := &squad.HitlGate{}
if err := json.Unmarshal([]byte(raw), gate); err != nil {
return nil, fmt.Errorf("parse gate: %w", err)
}
return gate, nil
}
func (s *SQLiteStore) SaveCheckpoint(wsDir string, cp squad.Checkpoint) error {
if err := s.migrate(); err != nil {
return err
}
_, err := s.db.Exec(`INSERT INTO squad_run_checkpoint (ws_dir, superstep, at, graph_hash, steps)
VALUES (?,?,?,?,?)
ON CONFLICT(ws_dir, superstep) DO UPDATE SET at=excluded.at, graph_hash=excluded.graph_hash, steps=excluded.steps`,
wsDir, cp.Superstep, cp.At.UTC().Format("2006-01-02T15:04:05Z07:00"), cp.GraphHash, j(cp.StepStates))
return err
}
func (s *SQLiteStore) LoadLatestCheckpoint(wsDir string) (*squad.Checkpoint, error) {
if err := s.migrate(); err != nil {
return nil, err
}
var cp squad.Checkpoint
var at, steps string
err := s.db.QueryRow(`SELECT superstep, at, graph_hash, steps FROM squad_run_checkpoint
WHERE ws_dir = ? ORDER BY superstep DESC LIMIT 1`, wsDir).
Scan(&cp.Superstep, &at, &cp.GraphHash, &steps)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
t, _ := parseTime(at)
cp.At = t
if err := json.Unmarshal([]byte(steps), &cp.StepStates); err != nil {
return nil, fmt.Errorf("parse checkpoint steps: %w", err)
}
return &cp, nil
}
func (s *SQLiteStore) SaveLeader(wsDir string, data []byte) error {
if err := s.migrate(); err != nil {
return err
}
_, err := s.db.Exec(`INSERT INTO squad_run_leader (ws_dir, data) VALUES (?,?)
ON CONFLICT(ws_dir) DO UPDATE SET data=excluded.data`, wsDir, string(data))
return err
}
func (s *SQLiteStore) LoadLeader(wsDir string) ([]byte, error) {
if err := s.migrate(); err != nil {
return nil, err
}
var raw string
err := s.db.QueryRow(`SELECT data FROM squad_run_leader WHERE ws_dir = ?`, wsDir).Scan(&raw)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
return []byte(raw), nil
}
// Close 只在独立打开(OpenSQLiteStore)时调用;经 platform.Store 打开时由平台统一关闭。
func (s *SQLiteStore) Close() error { return s.db.Close() }
var _ squad.Store = (*SQLiteStore)(nil)
// parseTime 解析检查点时间(RFC3339Nano 与 RFC3339 两态)。
func parseTime(s string) (time.Time, error) {
for _, layout := range []string{time.RFC3339Nano, time.RFC3339} {
if t, err := time.Parse(layout, s); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("bad time %q", s)
}
+188
View File
@@ -0,0 +1,188 @@
package platform
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"agent-squad/squad"
)
func newTestRunState(wsDir string) *squad.RunState {
return &squad.RunState{
Meta: squad.RunMetadata{
ID: "run_test", Status: "executing", WorkDir: wsDir,
CreatedAt: time.Now(), UpdatedAt: time.Now(),
Metadata: map[string]string{"mode": ""},
},
Plan: squad.SquadPlan{
Workflow: squad.WorkflowPlan{
WorkflowID: "wf1", Status: "in_progress",
Steps: []squad.StepSpec{
{ID: "s1", Role: "researcher", Status: "completed", Attempts: 1, AttemptID: "run_test/s1/1"},
{ID: "s2", Role: "writer", Status: "pending"},
},
},
Squad: squad.SquadConfig{SquadName: "demo"},
},
BB: squad.BlackboardState{Facts: []squad.FactItem{{ID: "f1", Claim: "x"}}},
Template: "tpl",
Approved: map[string]bool{"step:s1": true},
RepairBudgetRemaining: 1,
RepairBudgetGranted: 0,
Clarifications: []string{"c1"},
Steering: []string{"st1"},
Usage: map[string]*squad.Usage{"writer": {PromptTokens: 10, CompletionTokens: 5, Steps: 1}},
}
}
// TestSQLiteStoreRoundTrip:run/gate/checkpoint/leader 全链路(Q1 WP5 端口实现)。
func TestSQLiteStoreRoundTrip(t *testing.T) {
st, err := OpenSQLiteStore(filepath.Join(t.TempDir(), "runs.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
ws := filepath.Join(t.TempDir(), "runs", "run_test")
orig := newTestRunState(ws)
if err := st.SaveRun(orig); err != nil {
t.Fatal(err)
}
got, err := st.LoadRun(ws)
if err != nil {
t.Fatal(err)
}
if got.Meta.ID != "run_test" || got.Plan.Workflow.WorkflowID != "wf1" || got.Template != "tpl" {
t.Fatalf("loaded = %+v", got.Meta)
}
if !got.Approved["step:s1"] || got.RepairBudgetRemaining != 1 || len(got.Clarifications) != 1 || len(got.Steering) != 1 {
t.Fatalf("scalars = %+v", got)
}
if got.Usage["writer"].PromptTokens != 10 {
t.Fatalf("usage = %+v", got.Usage)
}
if _, err := st.LoadRun(filepath.Join(t.TempDir(), "nope")); err == nil {
t.Fatal("missing run should error")
}
// gate:写→读→删。
gate := &squad.HitlGate{PrevStatus: squad.RunExecuting}
if err := st.SaveGate(ws, gate); err != nil {
t.Fatal(err)
}
if g2, err := st.LoadGate(ws); err != nil || g2 == nil {
t.Fatalf("load gate: %v %+v", err, g2)
}
if err := st.SaveGate(ws, nil); err != nil {
t.Fatal(err)
}
if g2, err := st.LoadGate(ws); err != nil || g2 != nil {
t.Fatalf("gate after delete: %v %+v", err, g2)
}
// checkpoint:两层,latest 取最大 superstep。
if err := st.SaveCheckpoint(ws, squad.Checkpoint{Superstep: 1, At: time.Now(), GraphHash: "h1", StepStates: []squad.StepSnapshot{{ID: "s1", Status: "completed", AttemptID: "run_test/s1/1"}}}); err != nil {
t.Fatal(err)
}
if err := st.SaveCheckpoint(ws, squad.Checkpoint{Superstep: 2, At: time.Now(), GraphHash: "h2"}); err != nil {
t.Fatal(err)
}
latest, err := st.LoadLatestCheckpoint(ws)
if err != nil || latest == nil {
t.Fatalf("latest: %v", err)
}
if latest.Superstep != 2 || latest.GraphHash != "h2" {
t.Fatalf("latest = %+v", latest)
}
if l2, err := st.LoadLatestCheckpoint(filepath.Join(t.TempDir(), "nope")); err != nil || l2 != nil {
t.Fatalf("latest for unknown: %v %+v", err, l2)
}
// leader bytes。
if err := st.SaveLeader(ws, []byte(`{"turns":2}`)); err != nil {
t.Fatal(err)
}
data, err := st.LoadLeader(ws)
if err != nil || string(data) != `{"turns":2}` {
t.Fatalf("leader = %q %v", data, err)
}
}
// TestEngineRunsOnSQLiteStore:引擎注入 SQLiteStore 后运行状态进 SQLite、
// 不再写 run 级 JSON 文件;第二引擎可从中恢复(验收第三条的实证形态)。
func TestEngineRunsOnSQLiteStore(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "runs.db")
runStore, err := OpenSQLiteStore(dbPath)
if err != nil {
t.Fatal(err)
}
defer runStore.Close()
dir := t.TempDir()
reg := squad.NewRoleRegistry()
for _, spec := range []squad.RoleSpec{{Role: "researcher", Backend: "script"}, {Role: "writer", Backend: "script"}, {Role: "reviewer", Backend: "script"}} {
if err := reg.Register(spec); err != nil {
t.Fatal(err)
}
}
backends := squad.NewBackendResolver()
backends.RegisterInstance("script", stubExecutor{result: &squad.ExecResult{Success: false, Logs: "ok", Patches: map[string][]byte{"output.md": []byte(goodReportLite), "notes.md": []byte(goodReportLite), "fields.json": []byte("{}")}}})
blueprints := squad.NewBlueprintRegistry()
if err := blueprints.Register(squad.Blueprint{
Key: "demo", Title: "入库演示", Match: []string{"入库演示"},
Steps: []squad.StepSpec{
{ID: "s1", Role: "researcher", Title: "调研", Targets: []string{"notes.md", "fields.json"}},
{ID: "s2", Role: "writer", Title: "成文", Targets: []string{"output.md"}},
{ID: "s3", Role: "reviewer", Title: "终审", Targets: []string{"output.md"}},
},
}); err != nil {
t.Fatal(err)
}
eng, err := squad.NewEngine(squad.Options{
DataDir: dir, Registry: reg, Blueprints: blueprints,
Planner: squad.NewRulePlanner(),
Backends: backends, Sink: squad.NopSink{},
Store: runStore,
})
if err != nil {
t.Fatal(err)
}
res, err := eng.Run(context.Background(), squad.Goal{Text: "做一个三步入库演示", HitlMode: squad.HitlAuto}, squad.RunOptions{})
if err != nil || res.Status != squad.RunCompleted {
t.Fatalf("run: %v %+v", err, res)
}
// run 级 JSON 不在盘上(状态全进了 SQLite)。
for _, f := range []string{"workspace.json", "workflow.json", "squad.json", "blackboard.json", "runstate.json"} {
if _, err := os.Stat(filepath.Join(res.WorkDir, f)); !os.IsNotExist(err) {
t.Fatalf("%s should not be on disk with SQLite store (err=%v)", f, err)
}
}
// 状态可经端口读回。
st, err := runStore.LoadRun(res.WorkDir)
if err != nil {
t.Fatal(err)
}
if st.Plan.Workflow.Status != "completed" {
t.Fatalf("workflow status = %s", st.Plan.Workflow.Status)
}
latest, err := runStore.LoadLatestCheckpoint(res.WorkDir)
if err != nil || latest == nil {
t.Fatalf("latest checkpoint: %v", err)
}
// 第二引擎(同库)恢复可用。
eng2, err := squad.NewEngine(squad.Options{
DataDir: dir, Registry: reg, Blueprints: blueprints,
Planner: squad.NewRulePlanner(),
Backends: backends, Sink: squad.NopSink{},
Store: runStore,
})
if err != nil {
t.Fatal(err)
}
if _, err := eng2.Inspect(res.RunID); err != nil {
t.Fatalf("inspect from second engine: %v", err)
}
}
const goodReportLite = "# 测试交付物\n\n## 概述\n\n这是由脚本后端生成的合格交付物,长度超过五十个字符以满足最小质检要求。\n"
@@ -9,8 +9,7 @@ import (
"path/filepath"
"strings"
"agent-native/runtime"
"agent-native/runtime/llm"
"agent-squad/squad"
)
// CLICommands are the external tools a daemon probes, in order.
@@ -196,36 +195,54 @@ func (s *Store) RunCommand(ctx context.Context, task Task, command, workdir stri
return s.FinishTask(task.ID, "completed", "")
}
// RunInProcess executes the task with AgentSession. stream is required; the
// product supplies a provider, tests supply a script.
func (s *Store) RunInProcess(ctx context.Context, task Task, workdir string, stream llm.StreamFunc) (string, error) {
// RunInProcess executes the task through the squad.Executor port(Q1 WP5 修 X6:
// 不再绕过执行端口直驱 agent-native runtime)。调用方经 adapters/native 等
// 适配器构造 Executor;nil 即 fail-closed。
func (s *Store) RunInProcess(ctx context.Context, task Task, workdir string, exec squad.Executor) (string, error) {
if exec == nil {
return "", fmt.Errorf("RunInProcess requires an executor (no bypass)")
}
if err := s.MarkRunning(task.ID); err != nil {
return "", err
}
issue, _ := s.GetIssue(task.IssueID)
agent := runtime.NewAgentSession(runtime.Config{
Model: "agent-native",
Stream: stream,
MaxTurns: 4,
OnEvent: func(ev runtime.Event) {
res, err := exec.Execute(ctx, squad.ExecRequest{
WorkDir: workdir,
Prompt: issue.Title + "\n" + issue.Body,
Targets: []string{"output.md"},
OnEvent: func(ev squad.ProcessEvent) {
_ = s.AddTaskMessage(task.ID, ev.Type, ev.Text)
},
})
res, err := agent.Prompt(ctx, llm.Message{Role: llm.RoleUser, Content: issue.Title + "\n" + issue.Body})
if err != nil {
_ = s.FinishTask(task.ID, "failed", "config")
return "", err
}
if res.Err != nil {
_ = s.FinishTask(task.ID, "failed", "model")
return res.Text, res.Err
if res == nil || !res.Success {
reason := "model"
if res != nil && res.ErrorReason != "" {
reason = res.ErrorReason
}
_ = s.FinishTask(task.ID, "failed", reason)
if res != nil {
return res.Logs, fmt.Errorf("executor failed: %s", res.ErrorReason)
}
return "", fmt.Errorf("executor failed")
}
_ = s.AddUsage(task.ID, "agent-native", res.Usage.PromptTokens, res.Usage.CompletionTokens, res.Usage.CacheReadTokens, res.Usage.CacheWriteTokens)
if len(res.Patches) > 0 {
_, _ = squad.MaterializeTargets(workdir, []string{"output.md"}, res.Patches)
}
_ = s.AddUsage(task.ID, exec.Name(), res.PromptTokens, res.CompletionTokens, 0, 0)
if err := s.FinishTask(task.ID, "completed", ""); err != nil {
return res.Text, err
return res.Logs, err
}
_ = os.WriteFile(filepath.Join(workdir, "output.md"), []byte(res.Text), 0o644)
return res.Text, nil
if data, rerr := os.ReadFile(filepath.Join(workdir, "output.md")); rerr == nil {
return string(data), nil
}
if err := os.WriteFile(filepath.Join(workdir, "output.md"), []byte(res.Logs), 0o644); err != nil {
return res.Logs, err
}
return res.Logs, nil
}
// AgentCLI applies a task-token command: comment, status, sub-issue, or activity.
+30
View File
@@ -0,0 +1,30 @@
package platform
import (
"context"
"agent-squad/squad"
)
// stubExecutor 是测试用的 squad.Executor(X6 后调用方经端口注入)。
type stubExecutor struct {
result *squad.ExecResult
err error
}
func (s stubExecutor) Name() string { return "stub" }
func (s stubExecutor) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) {
if s.result == nil {
return nil, s.err
}
// 每次调用返回拷贝:调用方会改写 Success 字段,共享指针会跨步骤串味。
cp := *s.result
if s.result.Patches != nil {
cp.Patches = map[string][]byte{}
for k, v := range s.result.Patches {
cp.Patches[k] = v
}
}
return &cp, s.err
}
+46 -23
View File
@@ -7,6 +7,7 @@ import (
"encoding/json"
"flag"
"fmt"
"net"
"net/http"
"os"
"path/filepath"
@@ -14,12 +15,12 @@ import (
"strings"
"time"
"agent-intent/intent"
"agent-native/runtime/llm"
"agent-squad/platform"
"agent-squad/server"
squadintent "agent-squad/adapters/intent"
nativeadp "agent-squad/adapters/native"
platform "agent-squad/adapters/sqlite"
"agent-squad/cmd/agent-squad/server"
"agent-squad/squad"
"agent-squad/squadintent"
"agent-squad/store"
"logging-system/config"
@@ -61,7 +62,7 @@ func usage() {
fmt.Print(`agent-squad — 通用智能体小队编排底座
用法:
agent-squad serve [--addr :8090] [--data DIR] [--planner rule|llm] [--model M]
agent-squad serve [--addr 127.0.0.1:8090] [--data DIR] [--token TOK] [--planner rule|llm] [--model M]
agent-squad role list|add|rm|recommend|derive ...
agent-squad blueprint list|add|rm ...
agent-squad run "目标文本" [--task-key K] [--backend B] [--materials DIR] [--hitl-mode interactive|auto|disabled]
@@ -85,6 +86,7 @@ type app struct {
files *store.FileEventSink
st *store.FileStore
log *logsys.Logger
plat *platform.Store
}
func dataDir() string {
@@ -121,12 +123,12 @@ func assemble(dir, plannerName, modelOverride, intentRoot string) (*app, error)
fmt.Fprintln(os.Stderr, "提示:llm 规划器需要 AGENT_SQUAD_LLM_BASE_URL/API_KEY/MODEL,回退 rule 规划器")
} else {
client := &llm.Client{BaseURL: baseURL, APIKey: apiKey}
planner = squad.NewLLMPlanner(client, model)
planner = squad.NewLLMPlanner(nativeadp.LLM{Inner: client}, model)
}
}
// 真实智能体执行一律经 agent-native 自研内核(角色 backend=agent-native)。
if baseURL != "" && apiKey != "" && model != "" {
backends.RegisterAgentNative(&llm.Client{BaseURL: baseURL, APIKey: apiKey}, model, 12, 10*time.Minute)
nativeadp.Register(backends, &llm.Client{BaseURL: baseURL, APIKey: apiKey}, model, 12, 10*time.Minute)
}
// 事件双层:FileEventSink 落盘(seq+脱敏,重启可审计)→ FanoutSink(SSE 实时/重放)。
@@ -136,14 +138,7 @@ func assemble(dir, plannerName, modelOverride, intentRoot string) (*app, error)
var catalog *squad.IntentCatalog
var runner squad.IntentRunner
if intentRoot != "" {
g, err := squadintent.New(context.Background(), squadintent.Options{
IntentOptions: intent.Options{
GuardDomains: []string{"guard"},
ProtectedDomains: []string{"guard"},
},
FS: os.DirFS(intentRoot),
Roots: []string{"."},
})
g, err := squadintent.NewFromOverlay(context.Background(), os.DirFS(intentRoot), ".")
if err != nil {
return nil, fmt.Errorf("意图配置加载失败(fail-closed):%w", err)
}
@@ -165,12 +160,20 @@ func assemble(dir, plannerName, modelOverride, intentRoot string) (*app, error)
}
}
}
// 单库收编(Q1 WP5):引擎运行状态与平台编排状态进同一个 SQLite。
// 库打不开即失败(与 serve 现状一致),不允许静默退回文件态。
plat, err := platform.Open(filepath.Join(dir, "orchestration.db"))
if err != nil {
return nil, fmt.Errorf("编排库打开失败: %w", err)
}
engine, err := squad.NewEngine(squad.Options{
DataDir: dir, Registry: reg, Blueprints: bps, Planner: planner,
Backends: backends, Sink: fileSink, IntentGate: gate,
IntentCatalog: catalog, IntentRunner: runner,
Store: plat.RunStore(),
})
if err != nil {
_ = plat.Close()
return nil, err
}
logCfg, err := config.Load("")
@@ -184,7 +187,7 @@ func assemble(dir, plannerName, modelOverride, intentRoot string) (*app, error)
if err != nil {
return nil, err
}
return &app{engine: engine, sink: fanout, files: fileSink, st: st, log: logger}, nil
return &app{engine: engine, sink: fanout, files: fileSink, st: st, log: logger, plat: plat}, nil
}
func (a *app) close() {
@@ -206,7 +209,9 @@ func first(vals ...string) string {
func cmdServe(args []string) int {
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
addr := fs.String("addr", ":8090", "监听地址")
addr := fs.String("addr", "127.0.0.1:8090", "监听地址")
token := fs.String("token", os.Getenv("AGENT_SQUAD_TOKEN"), "Bearer token;也可用 AGENT_SQUAD_TOKEN")
insecure := fs.Bool("insecure", false, "允许在没有 token 时监听非回环地址")
data := fs.String("data", dataDir(), "数据目录")
planner := fs.String("planner", "rule", "规划器:rule|llm")
model := fs.String("model", "", "默认模型(llm 规划器/后端)")
@@ -220,14 +225,16 @@ func cmdServe(args []string) int {
return 1
}
defer a.close()
if err := listenGuard(*addr, *token, *insecure); err != nil {
fmt.Fprintln(os.Stderr, err)
return 2
}
srv := server.New(a.engine, a.sink, a.st)
srv.SetToken(*token)
srv.SetLogger(a.log)
if plat, err := platform.Open(filepath.Join(*data, "orchestration.db")); err != nil {
fmt.Fprintln(os.Stderr, "编排库打开失败:", err)
return 1
} else {
srv.SetPlatform(plat)
defer plat.Close()
if a.plat != nil {
srv.SetPlatform(a.plat)
defer a.plat.Close()
}
stopSweep := srv.StartWakeupSweeper(15 * time.Second)
defer stopSweep()
@@ -236,6 +243,22 @@ func cmdServe(args []string) int {
return httpListen(*addr, srv.Handler())
}
func listenGuard(addr, token string, insecure bool) error {
if token == "" && !insecure && !loopbackAddr(addr) {
return fmt.Errorf("拒绝在没有 token 时监听非回环地址;传入 --token 或 --insecure")
}
return nil
}
func loopbackAddr(addr string) bool {
host, _, err := net.SplitHostPort(addr)
if err != nil {
host = addr
}
host = strings.Trim(host, "[]")
return host == "127.0.0.1" || host == "::1" || strings.EqualFold(host, "localhost")
}
func httpListen(addr string, h http.Handler) int {
if err := http.ListenAndServe(addr, h); err != nil {
fmt.Fprintln(os.Stderr, "服务退出:", err)
+2 -3
View File
@@ -9,7 +9,6 @@ import (
"path/filepath"
"strings"
"agent-native/exec"
"agent-squad/squad"
)
@@ -21,7 +20,7 @@ type demoBackend struct {
func (b *demoBackend) Name() string { return "demo" }
func (b *demoBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
func (b *demoBackend) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) {
key := filepath.Base(filepath.Clean(req.WorkDir))
files, ok := b.script[key]
if !ok {
@@ -46,7 +45,7 @@ func (b *demoBackend) Execute(ctx context.Context, req exec.Request) (*exec.Resu
}
patches[t] = []byte(content)
}
return &exec.Result{Success: true, Patches: patches, Logs: "demo backend replay"}, nil
return &squad.ExecResult{Success: true, Patches: patches, Logs: "demo backend replay"}, nil
}
// cmdDemo 离线全链路演示:注册角色 → 规划 → 执行 → HITL 阻塞缺口挂起 →
+28
View File
@@ -0,0 +1,28 @@
package cli
import "testing"
func TestListenGuard(t *testing.T) {
cases := []struct {
name string
addr string
token string
insecure bool
ok bool
}{
{name: "loopback without token", addr: "127.0.0.1:8090", ok: true},
{name: "public without token", addr: "0.0.0.0:8090", ok: false},
{name: "public with insecure", addr: "0.0.0.0:8090", insecure: true, ok: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := listenGuard(tc.addr, tc.token, tc.insecure)
if tc.ok && err != nil {
t.Fatal(err)
}
if !tc.ok && err == nil {
t.Fatal("expected refusal")
}
})
}
}
@@ -9,8 +9,8 @@ import (
"path/filepath"
"time"
"agent-native/runtime/llm"
"agent-squad/platform"
platform "agent-squad/adapters/sqlite"
"agent-squad/squad"
)
func cmdPlatform(args []string) int {
@@ -99,7 +99,7 @@ func cmdPlatform(args []string) int {
return 1
}
}
text, err := st.RunInProcess(context.Background(), task, fs.Arg(1), scriptedOK)
text, err := st.RunInProcess(context.Background(), task, fs.Arg(1), scriptedExecutor{})
return printPlatform(map[string]string{"text": text}, err)
default:
fmt.Fprintln(os.Stderr, "未知编排命令", args[0], args[1])
@@ -111,11 +111,16 @@ func taskByID(st *platform.Store, id string) (platform.Task, error) {
return st.TaskByToken(id)
}
func scriptedOK(ctx context.Context, model string, messages []llm.Message, td []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
ch := make(chan llm.Event, 1)
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd}}
close(ch)
return ch
// scriptedExecutor 是 daemon run-native 冒烟路径的脚本执行器(X6 后经端口注入)。
type scriptedExecutor struct{}
func (scriptedExecutor) Name() string { return "scripted" }
func (scriptedExecutor) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) {
return &squad.ExecResult{
Success: true, Logs: "ok",
Patches: map[string][]byte{"output.md": []byte("ok")},
}, nil
}
func printPlatform(v any, err error) int {
@@ -11,7 +11,6 @@ import (
"strings"
"testing"
"agent-native/exec"
"agent-squad/squad"
"agent-squad/store"
)
@@ -21,7 +20,7 @@ type replayBackend struct{ script map[string]map[string]string }
func (b *replayBackend) Name() string { return "replay" }
func (b *replayBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
func (b *replayBackend) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) {
key := filepath.Base(filepath.Clean(req.WorkDir))
files := b.script[key]
patches := map[string][]byte{}
@@ -37,7 +36,7 @@ func (b *replayBackend) Execute(ctx context.Context, req exec.Request) (*exec.Re
}
patches[t] = []byte(content)
}
return &exec.Result{Success: true, Patches: patches, Logs: "replay"}, nil
return &squad.ExecResult{Success: true, Patches: patches, Logs: "replay"}, nil
}
const deliverable = "# 契约验收交付物\n\n## 概述\n\n本文件由回放后端生成,用于验证 agent-squad 全链路契约:注册、规划、执行、HITL、质检与审计。\n\n## 结论\n\n全链路行为符合契约。\n"
+45
View File
@@ -0,0 +1,45 @@
module agent-squad/cmd/agent-squad
go 1.27
require (
agent-native v0.0.0
agent-squad v0.0.0
agent-squad/adapters/intent v0.0.0
agent-squad/adapters/native v0.0.0
agent-squad/adapters/sqlite v0.0.0
error-code-system v0.0.0
logging-system v0.0.0
)
require (
agent-intent v0.0.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.29.0 // indirect
modernc.org/libc v1.62.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.9.1 // indirect
modernc.org/sqlite v1.37.0 // indirect
)
replace agent-intent => ../../deps/agent-intent
replace agent-native => ../../deps/agent-native
replace agent-squad => ../..
replace agent-squad/adapters/intent => ../../adapters/intent
replace agent-squad/adapters/native => ../../adapters/native
replace agent-squad/adapters/sqlite => ../../adapters/sqlite
replace error-code-system => ../../deps/error-code-system
replace logging-system => ../../deps/logging-system
+49
View File
@@ -0,0 +1,49 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
modernc.org/cc/v4 v4.25.2 h1:T2oH7sZdGvTaie0BRNFbIYsabzCxUQg8nLqCdQ2i0ic=
modernc.org/cc/v4 v4.25.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.25.1 h1:TFSzPrAGmDsdnhT9X2UrcPMI3N/mJ9/X9ykKXwLhDsU=
modernc.org/ccgo/v4 v4.25.1/go.mod h1:njjuAYiPflywOOrm3B7kCB444ONP5pAVr8PIEoE0uDw=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.62.1 h1:s0+fv5E3FymN8eJVmnk0llBe6rOxCu/DEU+XygRbS8s=
modernc.org/libc v1.62.1/go.mod h1:iXhATfJQLjG3NWy56a6WVU73lWOcdYVxsvwCgoPljuo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g=
modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+1 -1
View File
@@ -4,7 +4,7 @@ package main
import (
"os"
"agent-squad/cli"
"agent-squad/cmd/agent-squad/cli"
)
func main() {
+74
View File
@@ -0,0 +1,74 @@
package server
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"agent-squad/squad"
"agent-squad/store"
)
func TestMissingTokenIsUnauthorized(t *testing.T) {
dir := t.TempDir()
st, err := store.New(dir)
if err != nil {
t.Fatal(err)
}
reg := squad.NewRoleRegistry()
engine, err := squad.NewEngine(squad.Options{
DataDir: dir, Registry: reg, Planner: &squad.MockPlanner{}, Sink: squad.NopSink{},
})
if err != nil {
t.Fatal(err)
}
srv := New(engine, squad.NewFanoutSink(8), st)
srv.SetToken("secret")
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
resp, err := http.Get(ts.URL + "/v1/roles")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("status %d", resp.StatusCode)
}
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/roles", nil)
req.Header.Set("Authorization", "Bearer secret")
resp, err = http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("authed status %d", resp.StatusCode)
}
}
func TestPathTraversalRejected(t *testing.T) {
ts, _ := newTestServer(t)
resp, err := http.Get(ts.URL + "/v1/runs/a..b")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status %d", resp.StatusCode)
}
}
func TestOversizedBodyIs413(t *testing.T) {
ts, _ := newTestServer(t)
body := strings.NewReader(`{"role":"x","name":"` + strings.Repeat("a", 2<<20) + `"}`)
resp, err := http.Post(ts.URL+"/v1/roles", "application/json", body)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusRequestEntityTooLarge {
t.Fatalf("status %d", resp.StatusCode)
}
}
@@ -4,15 +4,18 @@ package server
import (
"context"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"time"
"agent-squad/platform"
platform "agent-squad/adapters/sqlite"
"agent-squad/squad"
"agent-squad/store"
@@ -34,8 +37,12 @@ type Server struct {
latest map[string]*squad.RunResult // 最近一次推进结果(供轮询)
log *logsys.Logger
plat *platform.Store
token string
}
// SetToken 要求除健康检查外的请求带 Bearer token。空串表示不校验。
func (s *Server) SetToken(token string) { s.token = token }
// SetLogger 设置组合根日志器。nil 表示不写日志。
func (s *Server) SetLogger(log *logsys.Logger) {
if s == nil {
@@ -87,7 +94,54 @@ func (s *Server) Handler() http.Handler {
if s.plat != nil {
platform.Mount(mux, s.plat)
}
return httpx.Trace(mux)
var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
limit := int64(1 << 20)
if r.Method == http.MethodPost && r.URL.Path == "/v1/runs" {
limit = 32 << 20
}
r.Body = http.MaxBytesReader(w, r.Body, limit)
mux.ServeHTTP(w, r)
})
if s.token != "" {
inner := h
h = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/health" {
inner.ServeHTTP(w, r)
return
}
const prefix = "Bearer "
header := r.Header.Get("Authorization")
got := strings.TrimPrefix(header, prefix)
if len(header) != len(prefix)+len(s.token) || subtle.ConstantTimeCompare([]byte(got), []byte(s.token)) != 1 {
w.WriteHeader(http.StatusUnauthorized)
return
}
inner.ServeHTTP(w, r)
})
}
return httpx.Trace(h)
}
func (s *Server) decode(w http.ResponseWriter, r *http.Request, dst any) bool {
err := json.NewDecoder(r.Body).Decode(dst)
if err == nil {
return true
}
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) || strings.Contains(err.Error(), "request body too large") {
w.WriteHeader(http.StatusRequestEntityTooLarge)
return false
}
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, "请求 JSON 无法解析"))
return false
}
func rejectBadID(w http.ResponseWriter, id string) bool {
if squad.ValidID(id) {
return false
}
w.WriteHeader(http.StatusBadRequest)
return true
}
func writeJSON(w http.ResponseWriter, code int, v any) {
@@ -132,8 +186,7 @@ func (s *Server) handleListRoles(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePutRole(w http.ResponseWriter, r *http.Request) {
var spec squad.RoleSpec
if err := json.NewDecoder(r.Body).Decode(&spec); err != nil {
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, "请求 JSON 无法解析"))
if !s.decode(w, r, &spec) {
return
}
if key := r.PathValue("key"); key != "" && spec.Role == "" {
@@ -148,6 +201,9 @@ func (s *Server) handlePutRole(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeleteRole(w http.ResponseWriter, r *http.Request) {
key := r.PathValue("key")
if rejectBadID(w, key) {
return
}
if !s.engine.Registry().Remove(key) {
s.writeErr(w, r, errcode.New(errcode.SquadRoleNotFound, fmt.Sprintf("role %s not found", key)))
return
@@ -158,8 +214,7 @@ func (s *Server) handleDeleteRole(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeriveRole(w http.ResponseWriter, r *http.Request) {
var req squad.DeriveRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, "请求 JSON 无法解析"))
if !s.decode(w, r, &req) {
return
}
spec, err := s.engine.Registry().Derive(req)
@@ -183,8 +238,7 @@ func (s *Server) handleListBlueprints(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePutBlueprint(w http.ResponseWriter, r *http.Request) {
var bp squad.Blueprint
if err := json.NewDecoder(r.Body).Decode(&bp); err != nil {
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, "请求 JSON 无法解析"))
if !s.decode(w, r, &bp) {
return
}
if err := s.engine.Blueprints().Register(bp); err != nil {
@@ -196,6 +250,9 @@ func (s *Server) handlePutBlueprint(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeleteBlueprint(w http.ResponseWriter, r *http.Request) {
key := r.PathValue("key")
if rejectBadID(w, key) {
return
}
if !s.engine.Blueprints().Remove(key) {
s.writeErr(w, r, errcode.New(errcode.SquadBlueprintNotFound, fmt.Sprintf("blueprint %s not found", key)))
return
@@ -221,8 +278,7 @@ type createRunRequest struct {
func (s *Server) handleCreateRun(w http.ResponseWriter, r *http.Request) {
var req createRunRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, "请求 JSON 无法解析"))
if !s.decode(w, r, &req) {
return
}
for name, b64 := range req.MaterialsB64 {
@@ -276,7 +332,11 @@ func (s *Server) handleListRuns(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleInspectRun(w http.ResponseWriter, r *http.Request) {
res, err := s.engine.Inspect(r.PathValue("id"))
id := r.PathValue("id")
if rejectBadID(w, id) {
return
}
res, err := s.engine.Inspect(id)
if err != nil {
s.writeErr(w, r, errcode.Wrap(err, errcode.SquadRunNotFound, "运行不存在"))
return
@@ -285,7 +345,11 @@ func (s *Server) handleInspectRun(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handlePending(w http.ResponseWriter, r *http.Request) {
req, err := s.engine.Pending(r.PathValue("id"))
id := r.PathValue("id")
if rejectBadID(w, id) {
return
}
req, err := s.engine.Pending(id)
if err != nil || req == nil {
writeJSON(w, http.StatusOK, map[string]any{"pending": nil})
return
@@ -295,11 +359,14 @@ func (s *Server) handlePending(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleRespond(w http.ResponseWriter, r *http.Request) {
var decision squad.RespondDecision
if err := json.NewDecoder(r.Body).Decode(&decision); err != nil {
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, "请求 JSON 无法解析"))
if !s.decode(w, r, &decision) {
return
}
res, err := s.engine.Respond(r.Context(), r.PathValue("id"), decision)
id := r.PathValue("id")
if rejectBadID(w, id) {
return
}
res, err := s.engine.Respond(r.Context(), id, decision)
if err != nil {
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, err.Error()))
return
@@ -310,7 +377,11 @@ func (s *Server) handleRespond(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleResume(w http.ResponseWriter, r *http.Request) {
res, err := s.engine.Resume(r.Context(), r.PathValue("id"))
id := r.PathValue("id")
if rejectBadID(w, id) {
return
}
res, err := s.engine.Resume(r.Context(), id)
if err != nil {
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, err.Error()))
return
@@ -321,7 +392,11 @@ func (s *Server) handleResume(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleBlackboard(w http.ResponseWriter, r *http.Request) {
bb, err := s.engine.Blackboard(r.PathValue("id"))
id := r.PathValue("id")
if rejectBadID(w, id) {
return
}
bb, err := s.engine.Blackboard(id)
if err != nil {
s.writeErr(w, r, errcode.Wrap(err, errcode.SquadRunNotFound, "运行不存在"))
return
@@ -333,11 +408,14 @@ func (s *Server) handleSteer(w http.ResponseWriter, r *http.Request) {
var req struct {
Text string `json:"text"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, "请求 JSON 无法解析"))
if !s.decode(w, r, &req) {
return
}
if err := s.engine.Steer(r.PathValue("id"), req.Text); err != nil {
id := r.PathValue("id")
if rejectBadID(w, id) {
return
}
if err := s.engine.Steer(id, req.Text); err != nil {
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, err.Error()))
return
}
@@ -349,11 +427,14 @@ func (s *Server) handleSetField(w http.ResponseWriter, r *http.Request) {
Field string `json:"field"`
Value string `json:"value"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, "请求 JSON 无法解析"))
if !s.decode(w, r, &req) {
return
}
if err := s.engine.SetBlackboardField(r.PathValue("id"), req.Field, req.Value); err != nil {
id := r.PathValue("id")
if rejectBadID(w, id) {
return
}
if err := s.engine.SetBlackboardField(id, req.Field, req.Value); err != nil {
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, err.Error()))
return
}
@@ -381,6 +462,8 @@ func (s *Server) StartWakeupSweeper(interval time.Duration) (stop func()) {
return
case <-t.C:
s.engine.SweepWakeups(context.Background())
// Q6:与事件唤醒并列,过期挂起按 ExpireAction 合成决策(此前是死代码)。
s.engine.SweepExpired(context.Background(), time.Now())
}
}
}()
@@ -389,6 +472,9 @@ func (s *Server) StartWakeupSweeper(interval time.Duration) (stop func()) {
// handleEvents SSE:cursor 之后重放缓冲再实时推送。
func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
if rejectBadID(w, r.PathValue("id")) {
return
}
flusher, ok := w.(http.Flusher)
if !ok {
s.writeErr(w, r, errcode.New(errcode.SysInternal, "streaming unsupported"))
@@ -13,7 +13,6 @@ import (
"testing"
"time"
"agent-native/exec"
"agent-squad/squad"
"agent-squad/store"
@@ -25,7 +24,7 @@ type scriptBackend struct{ script map[string]map[string]string }
func (b *scriptBackend) Name() string { return "script" }
func (b *scriptBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
func (b *scriptBackend) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) {
key := filepath.Base(filepath.Clean(req.WorkDir))
files := b.script[key]
patches := map[string][]byte{}
@@ -39,7 +38,7 @@ func (b *scriptBackend) Execute(ctx context.Context, req exec.Request) (*exec.Re
_ = os.WriteFile(out, []byte(content), 0o644)
patches[t2] = []byte(content)
}
return &exec.Result{Success: true, Patches: patches}, nil
return &squad.ExecResult{Success: true, Patches: patches}, nil
}
const goodReport = "# API 测试交付物\n\n## 概述\n\n脚本后端生成的合格交付物正文,长度超过五十个字符以满足最小质检要求。\n"
+1 -26
View File
@@ -2,31 +2,6 @@ module agent-squad
go 1.27
require (
agent-intent v0.0.0
agent-native v0.0.0
error-code-system v0.0.0
logging-system v0.0.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
golang.org/x/sys v0.31.0 // indirect
modernc.org/libc v1.62.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.9.1 // indirect
modernc.org/sqlite v1.37.0 // indirect
)
replace agent-intent => ./deps/agent-intent
replace agent-native => ./deps/agent-native
require error-code-system v0.0.0
replace error-code-system => ./deps/error-code-system
replace logging-system => ./deps/logging-system
-23
View File
@@ -1,23 +0,0 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
modernc.org/libc v1.62.1 h1:s0+fv5E3FymN8eJVmnk0llBe6rOxCu/DEU+XygRbS8s=
modernc.org/libc v1.62.1/go.mod h1:iXhATfJQLjG3NWy56a6WVU73lWOcdYVxsvwCgoPljuo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g=
modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
+1
View File
@@ -0,0 +1 @@
{"key":"demo","title":"示例","steps":[{"id":"s1","role":"writer","title":"起草","parallel":false}]}
+1
View File
@@ -0,0 +1 @@
{"role":"writer","name":"写作者","backend":"stub","tool_profile":"workspace"}
+59
View File
@@ -0,0 +1,59 @@
package schema
import (
"encoding/json"
"os"
"testing"
"agent-squad/squad"
)
func TestSamplesRoundTrip(t *testing.T) {
var role squad.RoleSpec
mustRoundTrip(t, "role.sample.json", &role)
if role.Role != "writer" || role.Name != "写作者" {
t.Fatalf("%+v", role)
}
var bp squad.Blueprint
mustRoundTrip(t, "blueprint.sample.json", &bp)
if bp.Key != "demo" || len(bp.Steps) != 1 {
t.Fatalf("%+v", bp)
}
var plan squad.WorkflowPlan
mustRoundTrip(t, "workflow-plan.sample.json", &plan)
if plan.WorkflowID != "wf-1" || plan.Status != "pending" {
t.Fatalf("%+v", plan)
}
}
func mustRoundTrip(t *testing.T, name string, dest any) {
t.Helper()
raw, err := os.ReadFile(name)
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(raw, dest); err != nil {
t.Fatal(err)
}
back, err := json.Marshal(dest)
if err != nil {
t.Fatal(err)
}
again := reflectNew(dest)
if err := json.Unmarshal(back, again); err != nil {
t.Fatal(err)
}
}
func reflectNew(v any) any {
switch v.(type) {
case *squad.RoleSpec:
return &squad.RoleSpec{}
case *squad.Blueprint:
return &squad.Blueprint{}
case *squad.WorkflowPlan:
return &squad.WorkflowPlan{}
default:
panic("unknown sample")
}
}
+1
View File
@@ -0,0 +1 @@
{"workflow_id":"wf-1","title":"示例","status":"pending","current_step_index":0,"steps":[{"id":"s1","role":"writer","title":"起草","parallel":false}]}
+56
View File
@@ -0,0 +1,56 @@
package squad
import (
"os"
"path/filepath"
"testing"
)
func TestAtomicWriteLeavesNoTemp(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "runstate.json")
if err := AtomicWrite(path, []byte(`{"ok":true}`)); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(path)
if err != nil || string(got) != `{"ok":true}` {
t.Fatalf("content: %v %s", err, got)
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
if e.Name() != "runstate.json" {
t.Fatalf("temp file left behind: %s", e.Name())
}
}
}
func TestAtomicWriteRenameFailureKeepsOriginal(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "occupied")
if err := os.Mkdir(path, 0o755); err != nil {
t.Fatal(err)
}
kept := filepath.Join(path, "keep.txt")
if err := os.WriteFile(kept, []byte("stay"), 0o644); err != nil {
t.Fatal(err)
}
if err := AtomicWrite(path, []byte("new")); err == nil {
t.Fatal("rename onto a non-empty directory should fail")
}
got, err := os.ReadFile(kept)
if err != nil || string(got) != "stay" {
t.Fatalf("original changed: %v %s", err, got)
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
if e.Name() != "occupied" {
t.Fatalf("temp file left behind: %s", e.Name())
}
}
}
+173
View File
@@ -0,0 +1,173 @@
package squad
import (
"context"
"os"
"path/filepath"
"sync"
"testing"
)
// perStepRecordingBackend 按子工作区归因计数(steps 的 role→tasks/NN_role 布局)。
type perStepRecordingBackend struct {
mu sync.Mutex
calls map[string]int
}
func (b *perStepRecordingBackend) Name() string { return "recording" }
func (b *perStepRecordingBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
b.mu.Lock()
if b.calls == nil {
b.calls = map[string]int{}
}
b.calls[filepath.Base(req.WorkDir)]++
b.mu.Unlock()
out := map[string][]byte{}
for _, t := range req.Targets {
out[t] = []byte(goodReport)
}
if _, err := MaterializeTargets(req.WorkDir, req.Targets, out); err != nil {
return &ExecResult{Success: false, ErrorReason: err.Error()}, nil
}
return &ExecResult{Success: true, Logs: "ok"}, nil
}
func (b *perStepRecordingBackend) countOf(sub string) int {
b.mu.Lock()
defer b.mu.Unlock()
return b.calls[sub]
}
func newAttemptTestEngine(t *testing.T, backend *perStepRecordingBackend, dir string) *SquadEngine {
t.Helper()
reg := NewRoleRegistry()
for _, role := range []string{"researcher", "writer", "reviewer"} {
if err := reg.Register(RoleSpec{Role: role, Backend: "recording"}); err != nil {
t.Fatal(err)
}
}
backends := NewBackendResolver()
backends.RegisterInstance("recording", backend)
eng, err := NewEngine(Options{
DataDir: dir, Registry: reg, Planner: mockScriptPlanner(baseSteps()...),
Backends: backends, Sink: NopSink{},
})
if err != nil {
t.Fatal(err)
}
return eng
}
func flipToInterrupted(t *testing.T, workDir string, stepIdx int) {
t.Helper()
st, err := loadRunState(workDir)
if err != nil {
t.Fatal(err)
}
st.Meta.Status = string(RunExecuting)
st.Meta.CompletedAt = nil
wf := st.Workflow()
wf.Status = "running"
wf.Steps[stepIdx].Status = "running"
wf.Steps[stepIdx].Attempts = 1
st.setWorkflow(wf)
if err := st.save(); err != nil {
t.Fatal(err)
}
}
// TestAttemptIDFormat:attempt id 稳定落盘且形如 runID/stepID/N(Q1 WP3)。
func TestAttemptIDFormat(t *testing.T) {
backend := &perStepRecordingBackend{}
dir := t.TempDir()
eng := newAttemptTestEngine(t, backend, dir)
res, err := eng.Run(context.Background(), Goal{Text: "格式"}, RunOptions{})
if err != nil || res.Status != RunCompleted {
t.Fatalf("run: %v %+v", err, res)
}
if res.Plan == nil {
t.Fatal("no plan")
}
for _, s := range res.Plan.Steps {
want := res.RunID + "/" + s.ID + "/1"
if s.AttemptID != want {
t.Fatalf("step %s attempt id = %q, want %q", s.ID, s.AttemptID, want)
}
}
}
// TestKilledAtSuperstepBoundarySideEffectsOnce:杀进程于 superstep 边界后恢复,
// 已完成步骤不重新执行(执行次数=1);running 且产物齐全的步骤按 attempt 判定
// 完成、不 rerun——Q1 验收第一条(WP3)。
func TestKilledAtSuperstepBoundarySideEffectsOnce(t *testing.T) {
backend := &perStepRecordingBackend{}
dir := t.TempDir()
eng := newAttemptTestEngine(t, backend, dir)
res, err := eng.Run(context.Background(), Goal{Text: "先跑完再中断", HitlMode: HitlAuto}, RunOptions{})
if err != nil || res.Status != RunCompleted {
t.Fatalf("setup run: %v %+v", err, res)
}
before := map[string]int{}
st, err := loadRunState(res.WorkDir)
if err != nil {
t.Fatal(err)
}
for _, s := range st.Plan.Workflow.Steps {
before[s.ID] = backend.countOf(filepath.Base(filepath.FromSlash(s.SubWorkspace)))
}
// 第二步停在 running(产物其实在盘上——首次运行真实写出的)。
flipToInterrupted(t, res.WorkDir, 1)
eng2 := newAttemptTestEngine(t, backend, dir)
res, err = eng2.Resume(context.Background(), res.RunID)
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("resume: %+v", res)
}
for _, s := range res.Plan.Steps {
if s.Status != "completed" && s.Status != "skipped" {
t.Fatalf("step %s not finished: %s", s.ID, s.Status)
}
got := backend.countOf(filepath.Base(filepath.FromSlash(s.SubWorkspace)))
if got != before[s.ID] {
t.Fatalf("step %s executed %d times after kill (before=%d)——已完成/running 步骤被重复执行", s.ID, got, before[s.ID])
}
}
}
// TestInterruptedMissingProductsRetries:running 崩溃且产物缺失时按 auto 一次重试(WP3)。
func TestInterruptedMissingProductsRetries(t *testing.T) {
backend := &perStepRecordingBackend{}
dir := t.TempDir()
eng := newAttemptTestEngine(t, backend, dir)
res, err := eng.Run(context.Background(), Goal{Text: "先跑完再中断", HitlMode: HitlAuto}, RunOptions{})
if err != nil || res.Status != RunCompleted {
t.Fatalf("setup run: %v %+v", err, res)
}
// 第二步停 running 并删掉其交付物(副作用未发生的形态)。
flipToInterrupted(t, res.WorkDir, 1)
st, err := loadRunState(res.WorkDir)
if err != nil {
t.Fatal(err)
}
sub := filepath.FromSlash(st.Plan.Workflow.Steps[1].SubWorkspace)
if err := os.Remove(filepath.Join(res.WorkDir, sub, "output.md")); err != nil {
t.Fatal(err)
}
eng2 := newAttemptTestEngine(t, backend, dir)
res, err = eng2.Resume(context.Background(), res.RunID)
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("resume: %+v", res)
}
got := backend.countOf(filepath.Base(sub))
if got != 2 {
t.Fatalf("missing-product step executed %d times, want 2(auto 一次重试)", got)
}
}
+11 -127
View File
@@ -1,24 +1,15 @@
package squad
import (
"context"
"fmt"
"sync"
"time"
"agent-native/adapters/native"
"agent-native/exec"
"agent-native/runtime/llm"
"agent-native/runtime/mcp"
"agent-native/runtime/subagent"
"agent-native/runtime/tools"
"error-code-system/errcode"
)
// BackendFactory 按角色构造后端:角色级 Model/ToolProfile 在工厂闭包层绑定
// (agent-native exec.Request 无 Model 字段,绑定不侵入执行契约)。
type BackendFactory func(role RoleSpec) (exec.Backend, error)
// (agent-native ExecRequest 无 Model 字段,绑定不侵入执行契约)。
type BackendFactory func(role RoleSpec) (Executor, error)
// BackendResolver 角色 → 智能体后端解析器。
// 内置 "stub"(agent-native 离线桩);真实智能体经 RegisterAgentNative 注册
@@ -28,18 +19,18 @@ type BackendFactory func(role RoleSpec) (exec.Backend, error)
type BackendResolver struct {
mu sync.RWMutex
factories map[string]BackendFactory
underlying map[string]exec.Backend // 无工厂直挂的后端实例
wrapped map[string]exec.Backend // 角色(含模型绑定)→ 已包装实例缓存
underlying map[string]Executor // 无工厂直挂的后端实例
wrapped map[string]Executor // 角色(含模型绑定)→ 已包装实例缓存
}
// NewBackendResolver 创建解析器并注册内置后端。
func NewBackendResolver() *BackendResolver {
r := &BackendResolver{
factories: make(map[string]BackendFactory),
underlying: make(map[string]exec.Backend),
wrapped: make(map[string]exec.Backend),
underlying: make(map[string]Executor),
wrapped: make(map[string]Executor),
}
r.RegisterInstance("stub", &exec.StubBackend{})
r.RegisterInstance("stub", &StubExecutor{})
return r
}
@@ -51,119 +42,12 @@ func (r *BackendResolver) Register(name string, f BackendFactory) {
}
// RegisterInstance 直接注册后端实例(忽略角色级绑定)。
func (r *BackendResolver) RegisterInstance(name string, b exec.Backend) {
func (r *BackendResolver) RegisterInstance(name string, b Executor) {
r.mu.Lock()
defer r.mu.Unlock()
r.underlying[name] = b
}
// AgentNativeConfig 声明 agent-native 后端的全量能力装配(组合根提供)。
// 零值 = 仅基础模型执行:搜索/取回/执行/MCP/技能/委派工具一律不注册(fail-closed)。
type AgentNativeConfig struct {
Client *llm.Client
DefaultModel string
MaxTurns int
Timeout time.Duration
// 以下均为 agent-native Options 的直通注入,详见其注释。
Search tools.SearchProvider
Exec *tools.ExecConfig
Fetch *tools.FetchConfig
MCP *mcp.Manager
SkillsRoots []string
Subagent subagent.Factory
Compact *native.CompactConfig
// HostTools 注册进内核工作区。与内置工具同名时该步骤失败,不覆盖。
HostTools []tools.Tool
// OnEvent 收到内核的逐步过程事件(文本、工具、用量)。与 Request.OnEvent 同时生效。
OnEvent func(ev exec.ProcessEvent)
// Stream 测试注入。为空时按 Client 访问模型。
Stream llm.StreamFunc
// MaxToolCalls 单步工具调用上限。0 表示不限制。
MaxToolCalls int
}
// buildAgentNativeOptions 按角色把配置换算为 native.Options。
// BashPermission 三态在此执行:deny = 不装配执行工具(无论组合根是否提供 Exec);
// ask/allow = 装配(ask 的人工门由引擎 permission 挂起点先行把关)。
func buildAgentNativeOptions(cfg AgentNativeConfig, role RoleSpec) (native.Options, error) {
model := role.Model
if model == "" {
model = cfg.DefaultModel
}
if model == "" {
return native.Options{}, fmt.Errorf("agent-native backend requires a model (role=%s)", role.Role)
}
opts := native.Options{
Model: model,
MaxTurns: cfg.MaxTurns,
Timeout: cfg.Timeout,
Search: cfg.Search,
Fetch: cfg.Fetch,
MCP: cfg.MCP,
SkillsRoots: cfg.SkillsRoots,
Subagent: cfg.Subagent,
Compact: cfg.Compact,
HostTools: cfg.HostTools,
Stream: cfg.Stream,
MaxToolCalls: cfg.MaxToolCalls,
}
if cfg.Client != nil {
opts.APIBase, opts.APIKey = cfg.Client.BaseURL, cfg.Client.APIKey
}
// 显式 allow/ask 才装配(注册层默认 deny,但派生/程序构造的 RoleSpec
// 未必经注册——工厂层兜底,空白串不放行)。
if cfg.Exec != nil && (role.BashPermission == BashPermissionAllow || role.BashPermission == BashPermissionAsk) {
opts.Exec = cfg.Exec
}
return opts, nil
}
// processEventBackend 把配置级过程回调接到每次 Execute,同时保留请求自带的 OnEvent。
type processEventBackend struct {
next exec.Backend
on func(ev exec.ProcessEvent)
}
func (b *processEventBackend) Name() string { return b.next.Name() }
func (b *processEventBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
if b.on != nil {
prev := req.OnEvent
req.OnEvent = func(ev exec.ProcessEvent) {
b.on(ev)
if prev != nil {
prev(ev)
}
}
}
return b.next.Execute(ctx, req)
}
// RegisterAgentNative 注册 "agent-native" 后端工厂(基础签名,向后兼容):
// 角色级 Model 覆盖 defaultModel,无附加能力装配。
func (r *BackendResolver) RegisterAgentNative(client *llm.Client, defaultModel string, maxTurns int, timeout time.Duration) {
r.RegisterAgentNativeFull(AgentNativeConfig{
Client: client, DefaultModel: defaultModel, MaxTurns: maxTurns, Timeout: timeout,
})
}
// RegisterAgentNativeFull 注册带全量能力装配的 "agent-native" 工厂:
// 搜索/取回/执行/MCP/技能/委派经配置注入;BashPermission=deny 的角色
// 不装配执行工具。智能体执行全部委托 agent-native,本库不实现 Agent。
func (r *BackendResolver) RegisterAgentNativeFull(cfg AgentNativeConfig) {
r.Register("agent-native", func(role RoleSpec) (exec.Backend, error) {
opts, err := buildAgentNativeOptions(cfg, role)
if err != nil {
return nil, err
}
next := native.New(opts)
if cfg.OnEvent == nil {
return next, nil
}
return &processEventBackend{next: next, on: cfg.OnEvent}, nil
})
}
// ListBackends 返回可用后端名(排序稳定)。
func (r *BackendResolver) ListBackends() []string {
r.mu.RLock()
@@ -191,7 +75,7 @@ func (r *BackendResolver) ListBackends() []string {
// Resolve 解析角色的执行后端:Role.Backend → Options.DefaultBackend → "stub"。
// MaxConcurrentTasks > 1 时以 agent-native 并发限流包装(per-role 缓存,跨步骤共享;
// 创建段持写锁防并行首解析竞态——否则并发步骤会各拿一份限流器)。
func (r *BackendResolver) Resolve(role RoleSpec, defaultBackend string) (exec.Backend, string, error) {
func (r *BackendResolver) Resolve(role RoleSpec, defaultBackend string) (Executor, string, error) {
name := role.Backend
if name == "" {
name = defaultBackend
@@ -219,7 +103,7 @@ func (r *BackendResolver) Resolve(role RoleSpec, defaultBackend string) (exec.Ba
return b, name, nil
}
var b exec.Backend
var b Executor
if hasUnder {
b = under
} else if hasFactory {
@@ -234,7 +118,7 @@ func (r *BackendResolver) Resolve(role RoleSpec, defaultBackend string) (exec.Ba
// MaxConcurrentTasks>=1 即包装限流(1 = 对象级串行;0 = 不限)。
if max := role.MaxConcurrentTasks; max >= 1 {
b = exec.WrapWithConcurrencyLimit(b, max)
b = WrapWithConcurrencyLimit(b, max)
}
r.wrapped[cacheKey] = b
return b, name, nil
+1 -5
View File
@@ -34,11 +34,7 @@ func saveBlackboard(wsDir string, bb *BlackboardState) error {
if err != nil {
return err
}
tmp := filepath.Join(wsDir, ".blackboard.json.tmp")
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
return os.Rename(tmp, filepath.Join(wsDir, "blackboard.json"))
return AtomicWrite(filepath.Join(wsDir, "blackboard.json"), append(data, '\n'))
}
// mergeStepArtifacts 合并步骤产出:fields.json(字符串键值)与
+6 -4
View File
@@ -13,7 +13,7 @@ type BlueprintRegistry struct {
mu sync.RWMutex
bps map[string]Blueprint
// OnChange 可选钩子:注册/删除后回调(store 层用于落盘)。
OnChange func(bp Blueprint)
OnChange func(bp Blueprint) error
}
// NewBlueprintRegistry 创建空注册表。
@@ -23,8 +23,8 @@ func NewBlueprintRegistry() *BlueprintRegistry {
// Register 注册或更新模板。
func (r *BlueprintRegistry) Register(bp Blueprint) error {
if strings.TrimSpace(bp.Key) == "" {
return fmt.Errorf("blueprint key cannot be empty")
if !ValidID(bp.Key) {
return invalidID(bp.Key)
}
if len(bp.Steps) == 0 {
return fmt.Errorf("blueprint %s has no steps", bp.Key)
@@ -41,7 +41,9 @@ func (r *BlueprintRegistry) Register(bp Blueprint) error {
r.bps[bp.Key] = bp
r.mu.Unlock()
if r.OnChange != nil {
r.OnChange(bp)
if err := r.OnChange(bp); err != nil {
return err
}
}
return nil
}
+41
View File
@@ -0,0 +1,41 @@
package squad
import (
"context"
"strings"
"testing"
)
// brief_test.go — Q3 WP1a:StepSpec.Brief 渲染进 TASK.md「任务指令」节。
func TestStepBriefRendersIntoTaskFile(t *testing.T) {
rec := &promptRecordingBackend{prompts: map[string][]string{}}
engine := newRecordingEngine(t, rec, []StepSpec{
{ID: "s1", Role: "researcher", Title: "起草", Targets: []string{"output.md"},
Brief: "写一份关于「太空电梯」的简报,篇幅:短。\n必须包含单独一行「核对标记:P4」。"},
})
res, err := engine.Run(context.Background(), Goal{Text: "带任务指令"}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status = %s err = %v", res.Status, res.Error)
}
var prompt string
for _, p := range rec.prompts["01_researcher"] {
prompt += p
}
if !strings.Contains(prompt, "## 任务指令") {
t.Fatalf("TASK.md 缺任务指令节:%q", prompt)
}
if !strings.Contains(prompt, "核对标记:P4") {
t.Fatalf("任务指令正文缺失:%q", prompt)
}
// 指令节在期望产出之后、上下文表之前。
iOut := strings.Index(prompt, "期望产出")
iBrief := strings.Index(prompt, "## 任务指令")
iCtx := strings.Index(prompt, "## 任务上下文资源")
if !(iOut < iBrief && iBrief < iCtx) {
t.Fatalf("任务指令节位置错误:%d/%d/%d", iOut, iBrief, iCtx)
}
}
+113
View File
@@ -0,0 +1,113 @@
package squad
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
// TestCheckpointFilesAtBoundaries:sync 默认档——每个 superstep 边界有快照,
// latest 指向最后一层且图指纹与重编译一致(Q1 WP4)。
func TestCheckpointFilesAtBoundaries(t *testing.T) {
eng, _ := newTestEngine(t, mockScriptPlanner(baseSteps()...), map[string]map[string]string{
"01_researcher": {"notes.md": goodReport, "fields.json": "{}"},
"02_writer": {"output.md": goodReport},
"03_reviewer": {"output.md": goodReport},
}, NopSink{})
res, err := eng.Run(context.Background(), Goal{Text: "快照"}, RunOptions{})
if err != nil || res.Status != RunCompleted {
t.Fatalf("run: %v %+v", err, res)
}
latest, err := FileStore{}.LoadLatestCheckpoint(res.WorkDir)
if err != nil || latest == nil {
t.Fatalf("latest checkpoint: %v", err)
}
if latest.Superstep < 3 {
t.Fatalf("superstep = %d, want >= 3(三步各占一层)", latest.Superstep)
}
g, err := compileGraph(res.Plan.Steps)
if err != nil {
t.Fatal(err)
}
if latest.GraphHash != g.hash() {
t.Fatalf("hash = %s, want %s", latest.GraphHash, g.hash())
}
if len(latest.StepStates) != 3 {
t.Fatalf("step states = %d", len(latest.StepStates))
}
if _, err := os.Stat(filepath.Join(res.WorkDir, "checkpoints", "001.json")); err != nil {
t.Fatalf("first boundary snapshot missing: %v", err)
}
}
// TestCheckpointAsyncAndOnExit:async 在退出前落完;on_exit 只有最终快照。
func TestCheckpointAsyncAndOnExit(t *testing.T) {
for _, mode := range []string{"async", "on_exit"} {
eng, _ := newTestEngine(t, mockScriptPlanner(baseSteps()...), map[string]map[string]string{
"01_researcher": {"notes.md": goodReport, "fields.json": "{}"},
"02_writer": {"output.md": goodReport},
"03_reviewer": {"output.md": goodReport},
}, NopSink{})
res, err := eng.Run(context.Background(), Goal{Text: "快照"}, RunOptions{CheckpointMode: mode})
if err != nil || res.Status != RunCompleted {
t.Fatalf("%s run: %v %+v", mode, err, res)
}
latest, err := FileStore{}.LoadLatestCheckpoint(res.WorkDir)
if err != nil || latest == nil {
t.Fatalf("%s latest: %v", mode, err)
}
if latest.Superstep < 3 {
t.Fatalf("%s superstep = %d", mode, latest.Superstep)
}
}
}
// TestGraphContinuityRejectsStructuralChange:崩溃期间图形态被改则拒绝恢复(WP1 裁定)。
func TestGraphContinuityRejectsStructuralChange(t *testing.T) {
eng, _ := newTestEngine(t, mockScriptPlanner(baseSteps()...), map[string]map[string]string{
"01_researcher": {"notes.md": goodReport, "fields.json": "{}"},
"02_writer": {"output.md": goodReport},
"03_reviewer": {"output.md": goodReport},
}, NopSink{})
dir := eng.opts.DataDir
res, err := eng.Run(context.Background(), Goal{Text: "先跑完再中断", HitlMode: HitlAuto}, RunOptions{})
if err != nil || res.Status != RunCompleted {
t.Fatalf("setup run: %v %+v", err, res)
}
// 翻回 running(仅状态变化,图结构不变——应先过连续性校验)。
st, err := loadRunState(res.WorkDir)
if err != nil {
t.Fatal(err)
}
st.Meta.Status = string(RunExecuting)
st.Meta.CompletedAt = nil
wf := st.Workflow()
wf.Status = "running"
wf.Steps[1].Status = "running"
wf.Steps[1].Attempts = 1
// 再改结构:把 s3 的 needs 改成另一个合法 id——编译能过但指纹必变。
wf.Steps[2].Needs = []string{"s1"}
st.setWorkflow(wf)
if err := st.save(); err != nil {
t.Fatal(err)
}
// 用一个新的引擎在同一目录上恢复(图编译本身合法:needs 引用存在的 id)。
reg := NewRoleRegistry()
for _, role := range []string{"researcher", "writer", "reviewer"} {
if err := reg.Register(RoleSpec{Role: role, Backend: "script"}); err != nil {
t.Fatal(err)
}
}
backends := NewBackendResolver()
backends.RegisterInstance("script", &scriptBackend{script: map[string]map[string]string{}})
eng2, err := NewEngine(Options{DataDir: dir, Registry: reg, Planner: eng.opts.Planner, Backends: backends, Sink: NopSink{}})
if err != nil {
t.Fatal(err)
}
_, err = eng2.Resume(context.Background(), res.RunID)
if err == nil || !strings.Contains(err.Error(), "图形态不一致") && !strings.Contains(err.Error(), "graph continuity") {
t.Fatalf("expected continuity rejection, got %v", err)
}
}
+141
View File
@@ -0,0 +1,141 @@
package squad
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
)
func slashClean(p string) string {
return filepath.ToSlash(filepath.Clean(p))
}
// MatchDeliverableRel reports whether a runner rel_path refers to target
// inside workDir. Accepts output.md, ./output.md, absolute paths under workDir,
// and a duplicated step prefix (tasks/01_draft/output.md when workDir already
// ends with tasks/01_draft). Paths that escape workDir never match.
func MatchDeliverableRel(workDir, relPath, target string) bool {
target = slashClean(strings.TrimSpace(target))
if target == "" || target == "." || strings.HasPrefix(target, "../") || strings.Contains(target, "..") {
return false
}
raw := strings.TrimSpace(relPath)
if raw == "" {
return false
}
abs := raw
if !filepath.IsAbs(raw) {
abs = filepath.Join(workDir, raw)
}
abs = filepath.Clean(abs)
rel, err := filepath.Rel(filepath.Clean(workDir), abs)
if err != nil {
return false
}
rel = slashClean(rel)
if rel == ".." || strings.HasPrefix(rel, "../") {
return false
}
if rel == target {
return true
}
if filepath.Base(rel) != filepath.Base(target) {
return false
}
prefix := slashClean(filepath.Dir(rel))
wd := slashClean(workDir)
return prefix != "." && (strings.HasSuffix(wd, "/"+prefix) || strings.HasSuffix(wd, prefix))
}
// CollectTargetPatches maps incoming runner keys onto canonical target names
// and also accepts a non-empty file already at workDir/target. Empty bodies
// do not count — callers must not invent a deliverable.
func CollectTargetPatches(workDir string, targets []string, incoming map[string][]byte) map[string][]byte {
out := make(map[string][]byte, len(targets))
for _, t := range targets {
t = strings.TrimSpace(t)
if t == "" {
continue
}
for k, v := range incoming {
if len(bytes.TrimSpace(v)) == 0 {
continue
}
if MatchDeliverableRel(workDir, k, t) {
out[t] = v
break
}
}
if _, ok := out[t]; ok {
continue
}
p := filepath.Join(workDir, t)
if b, err := os.ReadFile(p); err == nil && len(bytes.TrimSpace(b)) > 0 {
out[t] = b
}
}
return out
}
// MaterializeTargets writes collected non-empty target bodies to workDir/<target>.
// Returns a missing-target error without writing a stub if any required file is absent.
func MaterializeTargets(workDir string, targets []string, incoming map[string][]byte) (map[string][]byte, error) {
collected := CollectTargetPatches(workDir, targets, incoming)
for _, t := range targets {
t = strings.TrimSpace(t)
if t == "" {
continue
}
body, ok := collected[t]
if !ok || len(bytes.TrimSpace(body)) == 0 {
return collected, fmt.Errorf("missing target deliverable %s", t)
}
dest := filepath.Join(workDir, filepath.FromSlash(t))
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return collected, err
}
if err := rejectSymlinkEscape(workDir, dest); err != nil {
return collected, err
}
// Container-written files may be root-owned; make writable before overwrite.
_ = os.Chmod(dest, 0o666)
if err := os.WriteFile(dest, body, 0o644); err != nil {
return collected, err
}
}
return collected, nil
}
func rejectSymlinkEscape(workDir, dest string) error {
root, err := filepath.Abs(workDir)
if err != nil {
return err
}
absDest, err := filepath.Abs(dest)
if err != nil {
return err
}
rel, err := filepath.Rel(root, absDest)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return fmt.Errorf("typed result path rejected")
}
resolvedRoot, err := filepath.EvalSymlinks(root)
if err != nil {
resolvedRoot = root
}
parent := filepath.Dir(absDest)
resolvedParent, err := filepath.EvalSymlinks(parent)
if err != nil {
return fmt.Errorf("typed result path rejected")
}
relParent, err := filepath.Rel(resolvedRoot, resolvedParent)
if err != nil || relParent == ".." || strings.HasPrefix(relParent, ".."+string(os.PathSeparator)) {
return fmt.Errorf("typed result path rejected")
}
if info, err := os.Lstat(absDest); err == nil && info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("typed result path rejected")
}
return nil
}
+519 -65
View File
@@ -5,6 +5,7 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
@@ -34,6 +35,9 @@ type Options struct {
HitlPolicy HitlPolicy
// RepairBudget 质检失败自动修复次数(默认 1)。
RepairBudget int
// Store 运行状态持久化端口(Q1 WP4/WP5);nil 回落 FileStore。
// 注入 SQLiteStore 后运行状态进 SQLite(装配期一次,跨 run 一致)。
Store Store
// IntentGate 可选意图闸门:Run 入口一次确定性裁决(nil = 不启用,
// 行为与原实现一致)。见 intent.go;实现方为 squadintent 适配器。
IntentGate IntentGate
@@ -59,6 +63,31 @@ type engineDeps struct {
StepTimeout time.Duration
HitlPolicy HitlPolicy
IntentRunner IntentRunner
// Store 运行状态持久化端口(Q1 WP4);nil 回落 FileStore(装配期注入替换实现)。
Store Store
}
// storeOr 返回注入的 Store,缺省 FileStore。
func (d engineDeps) storeOr() Store {
if d.Store != nil {
return d.Store
}
return FileStore{}
}
// loadRun 经端口加载运行状态,并让它携带同一端口(后续 save 自然走同一实现)。
// runDir 是运行的工作区目录(所有 run 级 JSON 的根)。
func (e *SquadEngine) runDir(runID string) string {
return filepath.Join(e.opts.DataDir, "runs", runID)
}
func (e *SquadEngine) loadRun(wsDir string) (*RunState, error) {
st, err := e.deps.storeOr().LoadRun(wsDir)
if err != nil {
return nil, err
}
st.store = e.deps.storeOr()
return st, nil
}
// RunOptions 单次运行的可选项。
@@ -74,6 +103,30 @@ type RunOptions struct {
LeaderRole string `json:"leader_role,omitempty"`
// MaxLeaderTurns leader 模式每运行的 leader 轮次上限(默认 8,防失控)。
MaxLeaderTurns int `json:"max_leader_turns,omitempty"`
// CheckpointMode superstep 边界快照档位(Q1 WP4):sync(默认)| async | on_exit。
CheckpointMode string `json:"checkpoint_mode,omitempty"`
// Topology 显式拓扑名(Q2;优先于 Blueprint.Topology;空 = standard 图)。
// 未注册名 / primitive 单跑 / driver 未点亮均 fail-closed。
Topology string `json:"topology,omitempty"`
// Deadline 运行级截止时刻(Q2 long_running):superstep 边界检查,
// 超期把未完步骤置 skipped 并降级收口。零值 = 不设限。
Deadline time.Time `json:"deadline,omitempty"`
// MaxReplans leader 模式的重规划上限(Q2;与 MaxLeaderTurns 独立)。
// 0 = 默认 DefaultMaxReplans。
MaxReplans int `json:"max_replans,omitempty"`
// GroupChatPolicy group_chat 拓扑发言人策略(Q2):rotate(默认)| moderator。
GroupChatPolicy string `json:"group_chat_policy,omitempty"`
// GroupChatModerator 主持人角色 key(moderator 策略必填,须在名册内)。
GroupChatModerator string `json:"group_chat_moderator,omitempty"`
// GroupChatMaxTurns 群聊轮次预算(0 = DefaultMaxGroupChatTurns)。
GroupChatMaxTurns int `json:"group_chat_max_turns,omitempty"`
// ReviewVerifyRole review 拓扑核实角色 key(默认 "verifier")。
ReviewVerifyRole string `json:"review_verify_role,omitempty"`
// ReviewTarget 被评审材料(默认取 context/goal.md)。
ReviewTarget string `json:"review_target,omitempty"`
// TaskBoardLeaseSeconds task_board 任务租约时限(秒;0 = 默认 600)。
// 孤儿租约(claimed 且过期)在驱动重入时再武装回 open。
TaskBoardLeaseSeconds int `json:"task_board_lease_seconds,omitempty"`
}
// SquadEngine 小队引擎门面:Run / Respond / Resume / Inspect / ListRuns。
@@ -130,7 +183,7 @@ func NewEngine(opts Options) (*SquadEngine, error) {
Registry: opts.Registry, Backends: opts.Backends, Evaluator: opts.Evaluator,
Checker: opts.Checker, Sink: opts.Sink, Workspaces: ws,
StepTimeout: opts.StepTimeout, HitlPolicy: opts.HitlPolicy,
IntentRunner: opts.IntentRunner,
IntentRunner: opts.IntentRunner, Store: opts.Store,
}
return &SquadEngine{opts: opts, deps: deps, ws: ws, runLocks: map[string]*sync.Mutex{}}, nil
}
@@ -152,15 +205,42 @@ func (e *SquadEngine) runLock(runID string) *sync.Mutex {
return l
}
// ErrRunBusy 表示该 run 的文件锁正被占用,且在 ctx 截止前没有拿到。
// 非 unix 平台没有 flock,只保留进程内互斥;跨进程互斥需要 unix。
var ErrRunBusy = errors.New("run is busy")
// acquire 进入 run 临界区:先校验 id,再拿进程内锁,然后按 ctx 轮询文件锁。
func (e *SquadEngine) acquire(ctx context.Context, runID string) (func(), error) {
if !ValidID(runID) {
return nil, invalidID(runID)
}
if ctx == nil {
ctx = context.Background()
}
mu := e.runLock(runID)
mu.Lock()
release, err := lockRunFile(ctx, filepath.Join(e.opts.DataDir, "runs", runID))
if err != nil {
mu.Unlock()
return nil, err
}
return func() {
release()
mu.Unlock()
}, nil
}
// Run 发起一次运行:规划(含澄清/确认挂起)→ 执行 → 收口。
func (e *SquadEngine) Run(ctx context.Context, goal Goal, runOpts RunOptions) (*RunResult, error) {
if strings.TrimSpace(goal.Text) == "" {
return nil, fmt.Errorf("goal text is required")
}
runID := fmt.Sprintf("run_%s", time.Now().UTC().Format("20060102T150405.000000000"))
lock := e.runLock(runID)
lock.Lock()
defer lock.Unlock()
release, err := e.acquire(ctx, runID)
if err != nil {
return nil, err
}
defer release()
meta := RunMetadata{
ID: runID, Goal: goal, Status: string(RunPlanning),
@@ -178,6 +258,42 @@ func (e *SquadEngine) Run(ctx context.Context, goal Goal, runOpts RunOptions) (*
meta.Metadata["max_leader_turns"] = fmt.Sprintf("%d", runOpts.MaxLeaderTurns)
}
}
if runOpts.CheckpointMode != "" {
meta.Metadata["checkpoint_mode"] = runOpts.CheckpointMode
}
if runOpts.Topology != "" {
meta.Metadata["topology"] = runOpts.Topology
// leader 拓扑映射到委派循环须发生在计划确认之前(确认门按
// mode=leader 豁免);后置映射会让 leader 运行误触计划确认。
if runOpts.Topology == TopologyLeader && meta.Metadata["mode"] == "" {
meta.Metadata["mode"] = "leader"
}
}
if !runOpts.Deadline.IsZero() {
// RFC3339Nano:秒级 RFC3339 会截断亚秒精度,短截止会被立刻判超期。
meta.Metadata["deadline"] = runOpts.Deadline.UTC().Format(time.RFC3339Nano)
}
if runOpts.MaxReplans > 0 {
meta.Metadata["max_replans"] = fmt.Sprintf("%d", runOpts.MaxReplans)
}
if runOpts.GroupChatPolicy != "" {
meta.Metadata["group_chat_policy"] = runOpts.GroupChatPolicy
}
if runOpts.GroupChatModerator != "" {
meta.Metadata["group_chat_moderator"] = runOpts.GroupChatModerator
}
if runOpts.GroupChatMaxTurns > 0 {
meta.Metadata["group_chat_max_turns"] = fmt.Sprintf("%d", runOpts.GroupChatMaxTurns)
}
if runOpts.ReviewVerifyRole != "" {
meta.Metadata["review_verify_role"] = runOpts.ReviewVerifyRole
}
if runOpts.ReviewTarget != "" {
meta.Metadata["review_target"] = runOpts.ReviewTarget
}
if runOpts.TaskBoardLeaseSeconds > 0 {
meta.Metadata["task_board_lease_seconds"] = fmt.Sprintf("%d", runOpts.TaskBoardLeaseSeconds)
}
e.deps.Sink.Emit(Event{Type: EvRunStarted, RunID: runID, Text: goal.Text, At: time.Now()})
// 意图闸门(可选,入口一次裁决;显式 TaskKey 优先,不重复过闸)。
@@ -211,12 +327,12 @@ func (e *SquadEngine) Run(ctx context.Context, goal Goal, runOpts RunOptions) (*
}
}
st := &runState{Meta: meta, Approved: map[string]bool{}, RepairBudgetRemaining: e.opts.RepairBudget}
st := &RunState{Meta: meta, Approved: map[string]bool{}, RepairBudgetRemaining: e.opts.RepairBudget, store: e.deps.storeOr()}
return e.planAndExecute(ctx, st, runOpts)
}
// planAndExecute 规划 → 挂起或执行;st 可为已有运行状态(澄清续跑复用)。
func (e *SquadEngine) planAndExecute(ctx context.Context, st *runState, runOpts RunOptions) (*RunResult, error) {
func (e *SquadEngine) planAndExecute(ctx context.Context, st *RunState, runOpts RunOptions) (*RunResult, error) {
drv := &flowDriver{opts: e.deps}
meta := &st.Meta
@@ -238,6 +354,10 @@ func (e *SquadEngine) planAndExecute(ctx context.Context, st *runState, runOpts
planOut = &PlanOutput{Plan: plan}
} else {
bpMatch, _ := e.opts.Blueprints.Match(meta.Goal.Text, runOpts.TaskKey)
// 拓扑解析(Q2):RunOptions 优先,Blueprint 兜底。
if meta.Metadata["topology"] == "" && bpMatch.Topology != "" {
meta.Metadata["topology"] = bpMatch.Topology
}
planOut, err = e.opts.Planner.Plan(ctx, PlanInput{
Goal: meta.Goal, Blueprint: bpPtr(bpMatch), Registry: e.opts.Registry, RunID: meta.ID,
IntentCatalog: e.opts.IntentCatalog,
@@ -271,7 +391,7 @@ func (e *SquadEngine) planAndExecute(ctx context.Context, st *runState, runOpts
Context: fmt.Sprintf("原因:%s\n问题:\n%s", planOut.Clarification.Reason, strings.Join(qs, "\n")),
CreatedAt: time.Now(), Status: "pending",
}
gate := &hitlGate{Request: *req, PrevStatus: RunPlanning}
gate := &HitlGate{Request: *req, PrevStatus: RunPlanning}
// 澄清挂起也需要工作区承载 pending.json。
if err := e.materializeWorkspace(meta, st, &SquadPlan{Workflow: WorkflowPlan{WorkflowID: NewPlanID(), Status: "pending", Steps: nil}}); err != nil {
return nil, err
@@ -309,8 +429,9 @@ func (e *SquadEngine) planAndExecute(ctx context.Context, st *runState, runOpts
Context: fmt.Sprintf("确认原因:%s\n编制:\n%s\n步骤:\n%s",
planOut.ConfirmReason, strings.Join(roles, "\n"), strings.Join(lines, "\n")),
CreatedAt: time.Now(), Status: "pending",
Reason: "plan",
}
gate := &hitlGate{Request: *req, Plan: planOut.Plan, PrevStatus: RunPlanning}
gate := &HitlGate{Request: *req, Plan: planOut.Plan, PrevStatus: RunPlanning}
if err := st.saveGate(gate); err != nil {
return nil, err
}
@@ -323,6 +444,20 @@ func (e *SquadEngine) planAndExecute(ctx context.Context, st *runState, runOpts
if err := e.materializeWorkspace(meta, st, planOut.Plan); err != nil {
return nil, err
}
// 拓扑校验与分发(Q2):未注册名 / primitive 单跑 / driver 未点亮 fail-closed;
// leader 拓扑映射到既有 Mode 委派循环(不新造执行面)。
if topo := meta.Metadata["topology"]; topo != "" {
kind, terr := checkTopology(topo)
if terr != nil {
return e.failNew(meta, terr)
}
if kind == TopologyMode && topo == TopologyLeader {
if meta.Metadata["mode"] == "" {
meta.Metadata["mode"] = "leader"
}
}
e.deps.Sink.Emit(Event{Type: EvPhase, RunID: meta.ID, Text: "拓扑 " + topo, At: time.Now()})
}
// leader-delegation 模式:忽略规划步骤,进 leader 委派循环。
if meta.Metadata["mode"] == "leader" {
e.initLeaderState(st)
@@ -335,6 +470,15 @@ func (e *SquadEngine) planAndExecute(ctx context.Context, st *runState, runOpts
}
return out, nil
}
// driver 类拓扑(Q2 WP3+):按名分发专用驱动(review/group_chat;
// task_board 属 WP4,未点亮前 checkTopology 已 fail-closed)。
if out, handled, derr := e.dispatchTopologyDriver(ctx, st); handled {
if derr != nil {
return nil, derr
}
e.finalizeEvent(out)
return out, nil
}
out, _, err := drv.advance(ctx, st)
if err != nil {
return nil, err
@@ -344,7 +488,7 @@ func (e *SquadEngine) planAndExecute(ctx context.Context, st *runState, runOpts
}
// initLeaderState 按运行选项初始化 leader 循环状态。
func (e *SquadEngine) initLeaderState(st *runState) {
func (e *SquadEngine) initLeaderState(st *RunState) {
leaderRole := st.Meta.Metadata["leader_role"]
if leaderRole == "" {
if _, ok := findAgent(st.Plan.Squad.Agents, "leader"); ok {
@@ -357,7 +501,13 @@ func (e *SquadEngine) initLeaderState(st *runState) {
maxTurns = n
}
}
st.Leader = &LeaderState{LeaderRole: leaderRole, MaxTurns: maxTurns}
maxReplans := DefaultMaxReplans
if v := st.Meta.Metadata["max_replans"]; v != "" {
if n := parseIntDefault(v, DefaultMaxReplans); n > 0 {
maxReplans = n
}
}
st.Leader = &LeaderState{LeaderRole: leaderRole, MaxTurns: maxTurns, MaxReplans: maxReplans}
st.saveLeader()
}
@@ -373,12 +523,12 @@ func parseIntDefault(s string, def int) int {
}
// isLeaderRun 该运行是否处于 leader-delegation 模式。
func (e *SquadEngine) isLeaderRun(st *runState) bool {
func (e *SquadEngine) isLeaderRun(st *RunState) bool {
return st.Meta.Metadata["mode"] == "leader"
}
// resumeAfterHold 挂起解除后的统一续跑入口(按模式分发)。
func (e *SquadEngine) resumeAfterHold(ctx context.Context, st *runState) (*RunResult, error) {
func (e *SquadEngine) resumeAfterHold(ctx context.Context, st *RunState) (*RunResult, error) {
if e.isLeaderRun(st) {
out, gate, err := e.runLeaderLoop(ctx, st)
if err != nil {
@@ -389,6 +539,15 @@ func (e *SquadEngine) resumeAfterHold(ctx context.Context, st *runState) (*RunRe
}
return out, nil
}
// driver 类拓扑续跑:驱动自身幂等(review 已完成 finder 不重跑;
// group_chat 按已落盘轮次续转)。
if out, handled, err := e.dispatchTopologyDriver(ctx, st); handled {
if err != nil {
return nil, err
}
e.finalizeEvent(out)
return out, nil
}
drv := &flowDriver{opts: e.deps}
out, _, err := drv.advance(ctx, st)
if err != nil {
@@ -398,17 +557,43 @@ func (e *SquadEngine) resumeAfterHold(ctx context.Context, st *runState) (*RunRe
return out, nil
}
// dispatchTopologyDriver 按运行拓扑分发 driver 类拓扑。
// handled=false 表示非 driver 拓扑(走既有图/模式路径)。
func (e *SquadEngine) dispatchTopologyDriver(ctx context.Context, st *RunState) (*RunResult, bool, error) {
topo := st.Meta.Metadata["topology"]
if topo == "" {
return nil, false, nil
}
spec, ok := LookupTopology(topo)
if !ok || spec.Kind != TopologyDriver {
return nil, false, nil
}
switch topo {
case TopologyReview:
out, err := e.runReviewDriver(ctx, st)
return out, true, err
case TopologyGroupChat:
out, err := e.runGroupChatDriver(ctx, st)
return out, true, err
case TopologyTaskBoard:
out, err := e.runTaskBoardDriver(ctx, st)
return out, true, err
default:
return e.driverFail(st, fmt.Sprintf("topology: %q 驱动未实现(fail-closed)", topo)), true, nil
}
}
// materializeWorkspace 落盘工作区与子工作区(已存在则只更新计划并重建待执行子目录)。
func (e *SquadEngine) materializeWorkspace(meta *RunMetadata, st *runState, plan *SquadPlan) error {
func (e *SquadEngine) materializeWorkspace(meta *RunMetadata, st *RunState, plan *SquadPlan) error {
if st.Meta.WorkDir == "" {
st.Meta = *meta
wsDir, err := e.ws.PrepareWorkspace(&st.Meta, plan)
_, err := e.ws.PrepareWorkspace(&st.Meta, plan)
if err != nil {
return err
}
st.Template = plan.Template
bb, _ := loadBlackboard(wsDir)
st.BB = *bb
// 新运行的黑板在内存初始化;持久化由后续 st.save() 经 Store 端口完成。
st.BB = BlackboardState{Fields: map[string]string{}}
} else {
// 复用既有工作区:磁盘黑板为权威(澄清续跑/计划更新场景)。
if bb, err := loadBlackboard(st.Meta.WorkDir); err == nil {
@@ -450,12 +635,17 @@ func (e *SquadEngine) failNew(meta *RunMetadata, err error) (*RunResult, error)
// Respond 应用人工决策并续跑。按请求 ID 幂等:重复回应同一请求返回当前状态。
func (e *SquadEngine) Respond(ctx context.Context, runID string, decision RespondDecision) (*RunResult, error) {
lock := e.runLock(runID)
lock.Lock()
defer lock.Unlock()
release, err := e.acquire(ctx, runID)
if err != nil {
return nil, err
}
defer release()
return e.respondLocked(ctx, runID, decision)
}
func (e *SquadEngine) respondLocked(ctx context.Context, runID string, decision RespondDecision) (*RunResult, error) {
wsDir := filepath.Join(e.opts.DataDir, "runs", runID)
st, err := loadRunState(wsDir)
st, err := e.loadRun(wsDir)
if err != nil {
return nil, fmt.Errorf("load run %s: %w", runID, err)
}
@@ -475,18 +665,85 @@ func (e *SquadEngine) Respond(ctx context.Context, runID string, decision Respon
}
req := &gate.Request
// 非 human actor 不得决议 choice 门(Q2 验收三的第二道防线:邮箱/代理
// 消息即便伪造 Respond 调用也清不掉 permission/confirmation/quality 门)。
if decision.Actor != "" && decision.Actor != ActorHuman && choiceHoldKind(req.Kind) &&
strings.TrimSpace(decision.Choice) != "" {
e.deps.Sink.Emit(Event{Type: EvPhase, RunID: runID,
Text: fmt.Sprintf("拒绝非人工 actor(%s) 决议 %s 门(请求 %s)", decision.Actor, req.Kind, req.ID), At: time.Now()})
return e.noteAndKeepHold(st, gate, RespondDecision{
RequestID: decision.RequestID,
Answer: fmt.Sprintf("[blocked] 非人工 actor(%s) 尝试决议本门,已拒绝;等待人工裁决。", decision.Actor),
})
}
if strings.TrimSpace(decision.Choice) == "" && strings.TrimSpace(decision.Answer) != "" && choiceHoldKind(req.Kind) {
return e.noteAndKeepHold(st, gate, decision)
}
req.Status = "answered"
now := time.Now()
req.AnsweredAt = &now
if err := st.saveGate(gate); err != nil {
return nil, err
}
e.deps.Sink.Emit(Event{Type: EvHumanResponded, RunID: runID, Text: firstNonEmpty(decision.Choice, decision.Answer),
Data: marshalJSON(decision), At: time.Now()})
_ = st.saveGate(nil)
res, err := e.applyDecision(ctx, st, gate, decision)
if err != nil {
req.Status = "pending"
req.AnsweredAt = nil
_ = st.saveGate(gate)
return nil, err
}
if err := clearSameGate(st, req.ID); err != nil {
return nil, err
}
return res, nil
}
func choiceHoldKind(kind HumanRequestKind) bool {
switch kind {
case HitlConfirmation, HitlPermission, HitlQualityReview:
return true
default:
return false
}
}
func (e *SquadEngine) noteAndKeepHold(st *RunState, gate *HitlGate, decision RespondDecision) (*RunResult, error) {
if err := st.addSteering(decision.Answer); err != nil {
return nil, err
}
gate.Request.Status = "pending"
gate.Request.AnsweredAt = nil
if err := st.saveGate(gate); err != nil {
return nil, err
}
return heldResult(st, gate), nil
}
func clearSameGate(st *RunState, requestID string) error {
current, err := loadGate(st.Meta.WorkDir)
if err != nil || current == nil || current.Request.ID != requestID {
return err
}
return st.saveGate(nil)
}
func (e *SquadEngine) applyDecision(ctx context.Context, st *RunState, gate *HitlGate, decision RespondDecision) (*RunResult, error) {
req := &gate.Request
now := time.Now()
drv := &flowDriver{opts: e.deps}
switch req.Kind {
case HitlClarification:
return e.respondClarification(ctx, st, gate, decision)
case HitlConfirmation:
if req.Reason == "interrupted" {
return e.respondInterrupted(ctx, st, req, decision)
}
if req.StepID != "" && gate.Plan == nil {
return e.respondStepApproval(ctx, st, req, decision)
}
switch strings.ToLower(decision.Choice) {
case "approve":
if gate.Plan != nil {
@@ -604,8 +861,17 @@ func (e *SquadEngine) Respond(ctx context.Context, runID string, decision Respon
case "approve":
return e.manualPass(st, decision)
case "retry":
// 追加一次修复预算并重跑交付步骤。
// 追加一次修复预算并重跑交付步骤。累计追加有上限(Q26),
// 上限内才加——无上限的人工 retry 会把运行变成永动机。
term := e.terminationOf(st)
if st.RepairBudgetGranted >= term.MaxRepairGranted {
drv.emit(st, Event{Type: EvPhase, Text: fmt.Sprintf("人工修复追加已达上限 %d,不再追加(Q26)", term.MaxRepairGranted)})
return &RunResult{RunID: st.Meta.ID, Status: RunFailed, Plan: planPtr(st),
Error: fmt.Sprintf("repair budget grants exhausted (%d/%d)", st.RepairBudgetGranted, term.MaxRepairGranted),
WorkDir: st.Meta.WorkDir}, nil
}
st.RepairBudgetRemaining++
st.RepairBudgetGranted++
st.Meta.Status = string(RunExecuting)
_ = st.save()
deliverStepID := lastCompletedStepID(st)
@@ -622,8 +888,8 @@ func (e *SquadEngine) Respond(ctx context.Context, runID string, decision Respon
wf.Status = "failed"
st.setWorkflow(wf)
_ = st.save()
e.deps.Sink.Emit(Event{Type: EvRunFailed, RunID: runID, Text: "人工裁决质检失败", At: time.Now()})
return &RunResult{RunID: runID, Status: RunFailed, Plan: planPtr(st),
e.deps.Sink.Emit(Event{Type: EvRunFailed, RunID: st.Meta.ID, Text: "人工裁决质检失败", At: time.Now()})
return &RunResult{RunID: st.Meta.ID, Status: RunFailed, Plan: planPtr(st),
Error: "quality review rejected by human", WorkDir: st.Meta.WorkDir}, nil
}
default:
@@ -632,7 +898,7 @@ func (e *SquadEngine) Respond(ctx context.Context, runID string, decision Respon
}
// respondClarification 澄清回应:答案并入目标后重新规划。
func (e *SquadEngine) respondClarification(ctx context.Context, st *runState, gate *hitlGate, decision RespondDecision) (*RunResult, error) {
func (e *SquadEngine) respondClarification(ctx context.Context, st *RunState, gate *HitlGate, decision RespondDecision) (*RunResult, error) {
choice := strings.ToLower(decision.Choice)
if choice == "abort" {
return e.cancelRun(st, "人工中止(澄清阶段)"), nil
@@ -667,7 +933,7 @@ func (e *SquadEngine) respondClarification(ctx context.Context, st *runState, ga
}
// bootstrapMinimalSquad 注册通用最小编制(organizer/specialist/writer/leader)。
func (e *SquadEngine) bootstrapMinimalSquad(st *runState) {
func (e *SquadEngine) bootstrapMinimalSquad(st *RunState) {
backend := st.Meta.Backend
if backend == "" {
backend = e.opts.DefaultBackend
@@ -687,7 +953,7 @@ func (e *SquadEngine) bootstrapMinimalSquad(st *runState) {
}
// manualPass 人工放行质检:标记 ManualPass 并收口。
func (e *SquadEngine) manualPass(st *runState, decision RespondDecision) (*RunResult, error) {
func (e *SquadEngine) manualPass(st *RunState, decision RespondDecision) (*RunResult, error) {
report := e.deps.Checker.Check(CheckRequest{
RunID: st.Meta.ID, Workspace: st.Meta.WorkDir, Deliverable: "output.md",
Plan: planPtr(st), Squad: &st.Plan.Squad, Template: st.Template,
@@ -716,7 +982,7 @@ func (e *SquadEngine) manualPass(st *runState, decision RespondDecision) (*RunRe
Summary: summarize(st, *report, status) + "(质检人工放行)"}, nil
}
func (e *SquadEngine) cancelRun(st *runState, reason string) *RunResult {
func (e *SquadEngine) cancelRun(st *RunState, reason string) *RunResult {
now := time.Now()
st.Meta.Status = string(RunCancelled)
st.Meta.CompletedAt = &now
@@ -730,18 +996,143 @@ func (e *SquadEngine) cancelRun(st *runState, reason string) *RunResult {
}
// failStepResume 权限拒绝后的失败步骤处理(关键失败/非关键降级)。
func (d *flowDriver) failStepResume(ctx context.Context, st *runState, idx int) (*RunResult, *hitlGate, error) {
func (d *flowDriver) failStepResume(ctx context.Context, st *RunState, idx int) (*RunResult, *HitlGate, error) {
return d.failStep(ctx, st, idx)
}
func (e *SquadEngine) respondStepApproval(ctx context.Context, st *RunState, req *HumanRequest, decision RespondDecision) (*RunResult, error) {
switch strings.ToLower(decision.Choice) {
case "approve":
st.Approved["step:"+req.StepID] = true
case "skip":
if !markStep(st, req.StepID, "skipped", "") {
return nil, fmt.Errorf("step %s not found", req.StepID)
}
default:
return e.cancelRun(st, "人工中止(步骤审批)"), nil
}
st.Meta.Status = string(RunExecuting)
if err := st.save(); err != nil {
return nil, err
}
return e.resumeAfterHold(ctx, st)
}
func (e *SquadEngine) respondInterrupted(ctx context.Context, st *RunState, req *HumanRequest, decision RespondDecision) (*RunResult, error) {
switch strings.ToLower(decision.Choice) {
case "retry":
if !markStep(st, req.StepID, "pending", "") {
return nil, fmt.Errorf("step %s not found", req.StepID)
}
case "skip":
if !markStep(st, req.StepID, "skipped", "") {
return nil, fmt.Errorf("step %s not found", req.StepID)
}
default:
return e.cancelRun(st, "人工中止(中断恢复)"), nil
}
st.Meta.Status = string(RunExecuting)
if err := st.save(); err != nil {
return nil, err
}
return e.resumeAfterHold(ctx, st)
}
func markStep(st *RunState, id, status, errText string) bool {
for i := range st.Plan.Workflow.Steps {
if st.Plan.Workflow.Steps[i].ID != id {
continue
}
st.Plan.Workflow.Steps[i].Status = status
st.Plan.Workflow.Steps[i].Error = errText
return true
}
return false
}
// recoverInterrupted 处理崩溃时停在 running 的步骤。
// interactive 挂起确认,不自动再执行;auto 允许一次重试;disabled 直接失败。
func (e *SquadEngine) recoverInterrupted(ctx context.Context, st *RunState) (*RunResult, error) {
for i := range st.Plan.Workflow.Steps {
step := st.Plan.Workflow.Steps[i]
if step.Status != "running" {
continue
}
switch st.policy().mode() {
case HitlDisabled:
st.Plan.Workflow.Steps[i].Status = "failed"
st.Plan.Workflow.Steps[i].Error = "interrupted"
if err := st.save(); err != nil {
return nil, err
}
drv := &flowDriver{opts: e.deps}
out, _, err := drv.failStep(ctx, st, i)
return out, err
case HitlAuto:
if step.Attempts >= 2 {
st.Plan.Workflow.Steps[i].Status = "failed"
st.Plan.Workflow.Steps[i].Error = "interrupted twice"
if err := st.save(); err != nil {
return nil, err
}
drv := &flowDriver{opts: e.deps}
out, _, err := drv.failStep(ctx, st, i)
return out, err
}
// Q1 WP3 产物校验:步骤在 running 崩溃,但其交付物已全部落盘——
// 副作用实际发生过,直接判定完成而不是重新执行(副作用次数=1)。
subDir := filepath.Join(st.Meta.WorkDir, filepath.FromSlash(orDefault(step.SubWorkspace, fmt.Sprintf("tasks/%02d_%s", i+1, step.Role))))
targets := step.Targets
if len(targets) == 0 {
targets = []string{"output.md"}
}
if missingTargets(subDir, targets) == nil {
step.Status = "completed"
step.Error = ""
st.Plan.Workflow.Steps[i] = step
st.mu.Lock()
_ = mergeStepArtifacts(&st.BB, subDir)
err := st.save()
st.mu.Unlock()
if err != nil {
return nil, err
}
drv := &flowDriver{opts: e.deps}
drv.emit(st, Event{Type: EvStepCompleted, StepID: step.ID, Role: step.Role,
Text: fmt.Sprintf("中断恢复:产物齐全,按 attempt %s 判定完成", step.AttemptID)})
continue
}
st.Plan.Workflow.Steps[i].Status = "pending"
if err := st.save(); err != nil {
return nil, err
}
return nil, nil
default:
drv := &flowDriver{opts: e.deps}
out, _, err := drv.hold(st, HitlConfirmation, step.ID, step.Role,
fmt.Sprintf("步骤「%s」在执行中中断。如何继续?", step.Title),
[]HumanOption{
{Value: "retry", Label: "重试"},
{Value: "skip", Label: "跳过该步骤"},
{Value: "abort", Label: "中止运行"},
},
"interrupted", "interrupted", nil)
return out, err
}
}
return nil, nil
}
// Resume 恢复运行:有挂起则返回挂起状态;未完成则继续推进(重启恢复入口)。
func (e *SquadEngine) Resume(ctx context.Context, runID string) (*RunResult, error) {
lock := e.runLock(runID)
lock.Lock()
defer lock.Unlock()
release, err := e.acquire(ctx, runID)
if err != nil {
return nil, err
}
defer release()
wsDir := filepath.Join(e.opts.DataDir, "runs", runID)
st, err := loadRunState(wsDir)
st, err := e.loadRun(wsDir)
if err != nil {
return nil, fmt.Errorf("load run %s: %w", runID, err)
}
@@ -752,13 +1143,24 @@ func (e *SquadEngine) Resume(ctx context.Context, runID string) (*RunResult, err
if RunStatus(st.Meta.Status).Terminal() {
return e.Inspect(runID)
}
// 图连续性校验(Q1 WP4/WP1 裁定):崩溃期间的检查点图指纹与当前 Steps
// 重编译不一致 = 图形态被改过(动态调整或外力),拒绝恢复并提示重新规划。
if err := validateGraphContinuity(st); err != nil {
return nil, err
}
if out, err := e.recoverInterrupted(ctx, st); out != nil || err != nil {
return out, err
}
return e.resumeAfterHold(ctx, st)
}
// Inspect 查看运行当前状态(含挂起请求与黑板)。
func (e *SquadEngine) Inspect(runID string) (*RunResult, error) {
if !ValidID(runID) {
return nil, invalidID(runID)
}
wsDir := filepath.Join(e.opts.DataDir, "runs", runID)
st, err := loadRunState(wsDir)
st, err := e.loadRun(wsDir)
if err != nil {
return nil, fmt.Errorf("load run %s: %w", runID, err)
}
@@ -783,6 +1185,9 @@ func (e *SquadEngine) Inspect(runID string) (*RunResult, error) {
// Blackboard 查看运行黑板。
func (e *SquadEngine) Blackboard(runID string) (*BlackboardState, error) {
if !ValidID(runID) {
return nil, invalidID(runID)
}
wsDir := filepath.Join(e.opts.DataDir, "runs", runID)
return loadBlackboard(wsDir)
}
@@ -802,7 +1207,7 @@ func (e *SquadEngine) ListRuns() ([]RunMetadata, error) {
if !ent.IsDir() {
continue
}
st, err := loadRunState(filepath.Join(runsDir, ent.Name()))
st, err := e.loadRun(filepath.Join(runsDir, ent.Name()))
if err != nil {
continue
}
@@ -814,6 +1219,9 @@ func (e *SquadEngine) ListRuns() ([]RunMetadata, error) {
// Pending 返回运行当前挂起请求(无则 nil)。
func (e *SquadEngine) Pending(runID string) (*HumanRequest, error) {
if !ValidID(runID) {
return nil, invalidID(runID)
}
gate, err := loadGate(filepath.Join(e.opts.DataDir, "runs", runID))
if err != nil || gate == nil {
return nil, err
@@ -827,11 +1235,13 @@ func (e *SquadEngine) Steer(runID, text string) error {
if strings.TrimSpace(text) == "" {
return fmt.Errorf("steering text is empty")
}
lock := e.runLock(runID)
lock.Lock()
defer lock.Unlock()
release, err := e.acquire(context.Background(), runID)
if err != nil {
return err
}
defer release()
st, err := loadRunState(filepath.Join(e.opts.DataDir, "runs", runID))
st, err := e.loadRun(filepath.Join(e.opts.DataDir, "runs", runID))
if err != nil {
return fmt.Errorf("load run %s: %w", runID, err)
}
@@ -849,11 +1259,13 @@ func (e *SquadEngine) SetBlackboardField(runID, key, value string) error {
if strings.TrimSpace(key) == "" {
return fmt.Errorf("field key is empty")
}
lock := e.runLock(runID)
lock.Lock()
defer lock.Unlock()
release, err := e.acquire(context.Background(), runID)
if err != nil {
return err
}
defer release()
st, err := loadRunState(filepath.Join(e.opts.DataDir, "runs", runID))
st, err := e.loadRun(filepath.Join(e.opts.DataDir, "runs", runID))
if err != nil {
return fmt.Errorf("load run %s: %w", runID, err)
}
@@ -883,35 +1295,77 @@ func (e *SquadEngine) SweepWakeups(ctx context.Context) int {
if err != nil || gate == nil || gate.Request.Status != "pending" || gate.Request.WaitCondition == nil {
continue
}
cond := gate.Request.WaitCondition
met, fact := e.evaluateWaitCondition(meta.ID, cond)
if !met {
release, err := e.acquire(ctx, meta.ID)
if err != nil {
continue
}
gate.Request.WakeupFires++
if gate.Request.WakeupFires >= MaxWakeupFires {
// 护栏耗尽:自动中止,不无限自动续跑。
_, _ = e.Respond(ctx, meta.ID, RespondDecision{RequestID: gate.Request.ID, Choice: "abort",
Answer: "自动唤醒次数达上限,自动中止"})
if e.fireWakeup(ctx, meta.ID) {
woken++
continue
}
_ = writeJSON(filepath.Join(e.opts.DataDir, "runs", meta.ID, "pending.json"), gate)
decision := RespondDecision{RequestID: gate.Request.ID, Answer: "[自动唤醒] " + fact}
switch gate.Request.Kind {
case HitlConfirmation, HitlPermission, HitlQualityReview:
decision.Choice = "approve" // 事实型条件满足视为放行依据
default:
decision.Choice = "resolved"
}
if _, err := e.Respond(ctx, meta.ID, decision); err == nil {
woken++
e.deps.Sink.Emit(Event{Type: "wakeup_fired", RunID: meta.ID, Text: fact, At: time.Now()})
}
release()
}
return woken
}
// SweepExpired 把超过 HITL 时限的挂起请求合成 fail 或 cancel 决策。
func (e *SquadEngine) SweepExpired(ctx context.Context, now time.Time) int {
runs, err := e.ListRuns()
if err != nil {
return 0
}
n := 0
for _, meta := range runs {
if !RunStatus(meta.Status).IsHeld() {
continue
}
gate, err := loadGate(filepath.Join(e.opts.DataDir, "runs", meta.ID))
if err != nil || gate == nil || gate.Request.Status != "pending" {
continue
}
decision, due := expiredDecision(gate.Request, e.deps.HitlPolicy, now)
if !due {
continue
}
if _, err := e.Respond(ctx, meta.ID, decision); err == nil {
n++
}
}
return n
}
// fireWakeup 在已持有 run 锁时处理一条挂起。只自动放行 gap 与 escalation。
func (e *SquadEngine) fireWakeup(ctx context.Context, runID string) bool {
gate, err := loadGate(filepath.Join(e.opts.DataDir, "runs", runID))
if err != nil || gate == nil || gate.Request.Status != "pending" || gate.Request.WaitCondition == nil {
return false
}
met, fact := e.evaluateWaitCondition(runID, gate.Request.WaitCondition)
if !met {
return false
}
gate.Request.WakeupFires++
if err := writeJSON(filepath.Join(e.opts.DataDir, "runs", runID, "pending.json"), gate); err != nil {
return false
}
if gate.Request.WakeupFires >= e.terminationOf(nil).MaxWakeupFires {
_, err := e.respondLocked(ctx, runID, RespondDecision{RequestID: gate.Request.ID, Choice: "abort",
Answer: "自动唤醒次数达上限,自动中止"})
return err == nil
}
switch gate.Request.Kind {
case HitlGap, HitlEscalation:
decision := RespondDecision{RequestID: gate.Request.ID, Choice: "resolved", Answer: "[自动唤醒] " + fact}
if _, err := e.respondLocked(ctx, runID, decision); err != nil {
return false
}
e.deps.Sink.Emit(Event{Type: "wakeup_fired", RunID: runID, Text: fact, At: time.Now()})
return true
default:
e.deps.Sink.Emit(Event{Type: "wakeup_held", RunID: runID, Text: fact, At: time.Now()})
return false
}
}
// evaluateWaitCondition 评估等待条件;返回是否满足与事实描述。
func (e *SquadEngine) evaluateWaitCondition(runID string, cond *WaitCondition) (bool, string) {
switch cond.Type {
@@ -998,7 +1452,7 @@ func appendAnswer(d RespondDecision) string {
return "(" + d.Answer + ")"
}
func lastCompletedStepID(st *runState) string {
func lastCompletedStepID(st *RunState) string {
id := ""
for _, s := range st.Plan.Workflow.Steps {
if s.Status == "completed" {
+2 -4
View File
@@ -8,8 +8,6 @@ import (
"strings"
"testing"
"time"
"agent-native/exec"
)
// scriptBackend 测试用脚本后端:按子目录名回放产出。
@@ -17,7 +15,7 @@ type scriptBackend struct{ script map[string]map[string]string }
func (b *scriptBackend) Name() string { return "script" }
func (b *scriptBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
func (b *scriptBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
key := filepath.Base(filepath.Clean(req.WorkDir))
files := b.script[key]
patches := map[string][]byte{}
@@ -33,7 +31,7 @@ func (b *scriptBackend) Execute(ctx context.Context, req exec.Request) (*exec.Re
}
patches[t] = []byte(content)
}
return &exec.Result{Success: true, Patches: patches}, nil
return &ExecResult{Success: true, Patches: patches}, nil
}
const goodReport = "# 测试交付物\n\n## 概述\n\n这是由脚本后端生成的合格交付物,长度超过五十个字符以满足最小质检要求。\n\n## 内容\n\n正文内容。\n"
+2 -4
View File
@@ -6,8 +6,6 @@ import (
"path/filepath"
"strings"
"testing"
"agent-native/exec"
)
// scriptedEvaluator 第一步后建议:追加一个补漏步骤 + 跳过冗余步骤。
@@ -146,7 +144,7 @@ type mutableScriptBackend struct {
func (b *mutableScriptBackend) Name() string { return "mut" }
func (b *mutableScriptBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
func (b *mutableScriptBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
b.calls++
if b.mutate != nil && b.calls == b.mutateAt {
b.mutate(b.script)
@@ -164,7 +162,7 @@ func (b *mutableScriptBackend) Execute(ctx context.Context, req exec.Request) (*
_ = os.WriteFile(out, []byte(content), 0o644)
patches[tg] = []byte(content)
}
return &exec.Result{Success: true, Patches: patches}, nil
return &ExecResult{Success: true, Patches: patches}, nil
}
// TestQualityRepairAutoRerun 验证:质检失败 → 自动重跑交付步骤 → 通过。
+253
View File
@@ -0,0 +1,253 @@
package squad
import (
"fmt"
"hash/fnv"
"strings"
)
// graph 是一次执行图:由权威 Steps 现场编译(Steps 仍是唯一真相源,
// 动态调整改 Steps 后下一轮自然生效)。只携带调度所需的派生信息:
// 依赖、写集与可执行分层。
type graph struct {
needs map[string][]string // step id → 依赖 id(含相邻 Parallel 兼容映射产生的)
order []string // 声明序
writes map[string][]string // step id → 写集
}
// compileGraph 编译步骤图为调度图。规则:
// - 声明 Needs 的步骤:依赖即其 Needs(未知引用/环 fail-closed)。
// - 未声明 Needs 的步骤:保持现状语义——相邻 Parallel=true 的步骤同层
// (依赖 = 上一个「非本组」步骤),非 Parallel 步骤依赖前一个步骤。
// - 混合图允许:Needs 提供剪枝,未声明处保持线性。
func compileGraph(steps []StepSpec) (*graph, error) {
g := &graph{
needs: map[string][]string{},
order: make([]string, 0, len(steps)),
writes: map[string][]string{},
}
ids := map[string]bool{}
for _, s := range steps {
if s.ID == "" {
return nil, fmt.Errorf("graph: 存在空 id 的步骤")
}
if ids[s.ID] {
return nil, fmt.Errorf("graph: 步骤 id 重复:%s", s.ID)
}
ids[s.ID] = true
}
prevNonGroup := -1 // 上一个「非本 Parallel 组」的步骤索引
for i, s := range steps {
g.order = append(g.order, s.ID)
g.writes[s.ID] = append([]string(nil), s.Writes...)
needsDecl := len(s.Needs) > 0
switch {
case needsDecl:
for _, n := range s.Needs {
if !ids[n] {
return nil, fmt.Errorf("graph: 步骤 %s 的 needs 引用未知步骤 %s", s.ID, n)
}
if n == s.ID {
return nil, fmt.Errorf("graph: 步骤 %s 的 needs 引用自身", s.ID)
}
}
g.needs[s.ID] = append([]string(nil), s.Needs...)
prevNonGroup = i
case s.Parallel:
// 相邻 Parallel 组:依赖上一个组外步骤(没有则无依赖)。
if prevNonGroup >= 0 {
g.needs[s.ID] = []string{steps[prevNonGroup].ID}
}
// Parallel 步骤不推进 prevNonGroup:相邻同伴同层。
default:
// 非 Parallel:依赖紧邻的前一步(与旧线性扫描的执行序一致——
// 整组 Parallel 完成后才轮到它,而不是只等组外前一步)。
if i > 0 {
g.needs[s.ID] = []string{steps[i-1].ID}
}
prevNonGroup = i
}
}
if cycle := g.findCycle(); cycle != nil {
return nil, fmt.Errorf("graph: needs 依赖成环:%s", strings.Join(cycle, " → "))
}
// 循环步与路由步校验(Q2):预算必须显式,引用必须存在,两者互斥
//(同一步既循环又分支的语义未定义,fail-closed)。
for _, s := range steps {
if s.Loop != nil && len(s.Branches) > 0 {
return nil, fmt.Errorf("graph: 步骤 %s 不得同时声明 loop 与 branches", s.ID)
}
if s.Loop != nil {
if s.Loop.MaxIterations <= 0 {
return nil, fmt.Errorf("graph: 步骤 %s 的 loop.max_iterations 必须 > 0", s.ID)
}
for _, b := range s.Loop.Body {
if !ids[b] {
return nil, fmt.Errorf("graph: 步骤 %s 的 loop.body 引用未知步骤 %s", s.ID, b)
}
if b == s.ID {
return nil, fmt.Errorf("graph: 步骤 %s 的 loop.body 引用自身", s.ID)
}
}
}
for _, br := range s.Branches {
if br.When.Field == "" {
return nil, fmt.Errorf("graph: 步骤 %s 的分支缺少 when.field", s.ID)
}
if br.When.Equals == "" && br.When.NotEquals == "" {
return nil, fmt.Errorf("graph: 步骤 %s 的分支 when 需要 equals 或 not_equals", s.ID)
}
if len(br.Then) == 0 {
return nil, fmt.Errorf("graph: 步骤 %s 的分支 then 为空", s.ID)
}
for _, t := range br.Then {
if !ids[t] {
return nil, fmt.Errorf("graph: 步骤 %s 的分支 then 引用未知步骤 %s", s.ID, t)
}
if t == s.ID {
return nil, fmt.Errorf("graph: 步骤 %s 的分支 then 引用自身", s.ID)
}
}
}
}
return g, nil
}
// findCycle 报告一条依赖环(没有则 nil)。
func (g *graph) findCycle() []string {
const (
white = iota
gray
black
)
color := map[string]int{}
var stack []string
var visit func(id string) []string
visit = func(id string) []string {
if color[id] == gray {
return append(stack, id)
}
if color[id] == black {
return nil
}
color[id] = gray
stack = append(stack, id)
for _, n := range g.needs[id] {
if c := visit(n); c != nil {
return c
}
}
stack = stack[:len(stack)-1]
color[id] = black
return nil
}
for _, id := range g.order {
if c := visit(id); c != nil {
return c
}
}
return nil
}
// layer 是一个 superstep 的可执行集合:seq 为有写集冲突需按声明序串行的
// 步骤索引,par 为可并行的步骤索引。先执行 par 组(runParallel 语义),
// 再逐个执行 seq(含动态调整),与现状「并行组先于后续顺序步骤」一致。
type layer struct {
par []int
seq []int
}
// stepPending 报告步骤是否仍待执行。蓝图登记的步骤不带 status(空串),
// 旧线性扫描将其视为可执行——这里与之一致:空串等同 pending。
func stepPending(status string) bool {
return status == "" || status == "pending" || status == "running"
}
// nextLayer 计算当前可执行层:状态为 pending/running 且全部依赖已「满足」。
// 依赖满足 = completed/skipped/failed——failed 视为满足与现状「非关键失败
// 降级继续」的线性扫描一致(关键失败时运行已终止,不会走到这里)。
// 无可执行步骤时返回 (layer{}, false):可能全部完成,也可能调度卡死
// (由调用方用 pendingCount 区分)。
func (g *graph) nextLayer(steps []StepSpec) (layer, bool) {
idxOf := map[string]int{}
for i, s := range steps {
idxOf[s.ID] = i
}
satisfied := func(id string) bool {
st := steps[idxOf[id]].Status
return st == "completed" || st == "skipped" || st == "failed"
}
var out layer
for i, s := range steps {
if !stepPending(s.Status) {
continue
}
ready := true
for _, n := range g.needs[s.ID] {
if !satisfied(n) {
ready = false
break
}
}
if !ready {
continue
}
if g.conflictsWithEarlier(out, i, steps) {
out.seq = append(out.seq, i)
} else {
out.par = append(out.par, i)
}
}
return out, len(out.par)+len(out.seq) > 0
}
// conflictsWithEarlier 报告步骤 i 的写集与本层中更早的可执行步骤相交。
func (g *graph) conflictsWithEarlier(l layer, i int, steps []StepSpec) bool {
wi := g.writes[steps[i].ID]
if len(wi) == 0 {
return false
}
earlier := append(append([]int(nil), l.par...), l.seq...)
for _, j := range earlier {
for _, w := range g.writes[steps[j].ID] {
for _, v := range wi {
if w == v {
return true
}
}
}
}
return false
}
// pendingCount 统计仍未终结(pending/running/空串)的步骤数。
func pendingCount(steps []StepSpec) int {
n := 0
for _, s := range steps {
if stepPending(s.Status) {
n++
}
}
return n
}
// hash 是图结构指纹:声明序、依赖与写集的稳定散列。
// 恢复连续性校验用它比对崩溃前后的图形态(Q1 WP4;WP1 的图版本裁定落点)。
func (g *graph) hash() string {
h := fnv.New64a()
for _, id := range g.order {
h.Write([]byte(id))
h.Write([]byte{0})
for _, n := range g.needs[id] {
h.Write([]byte(">"))
h.Write([]byte(n))
}
h.Write([]byte{1})
for _, w := range g.writes[id] {
h.Write([]byte("w"))
h.Write([]byte(w))
}
h.Write([]byte{2})
}
return fmt.Sprintf("%016x", h.Sum64())
}
+139
View File
@@ -0,0 +1,139 @@
package squad
import (
"strings"
"testing"
)
func stepsOf(specs ...StepSpec) []StepSpec {
out := make([]StepSpec, len(specs))
copy(out, specs)
return out
}
func TestCompileGraphLinearAndAdjacentParallel(t *testing.T) {
steps := stepsOf(
StepSpec{ID: "a", Status: "pending"},
StepSpec{ID: "b", Status: "pending", Parallel: true},
StepSpec{ID: "c", Status: "pending", Parallel: true},
StepSpec{ID: "d", Status: "pending"},
)
g, err := compileGraph(steps)
if err != nil {
t.Fatal(err)
}
// 线性链:b/c 同层依赖 a,d 依赖 c。
if len(g.needs["a"]) != 0 {
t.Fatalf("a needs = %v", g.needs["a"])
}
if len(g.needs["b"]) != 1 || g.needs["b"][0] != "a" || g.needs["c"][0] != "a" {
t.Fatalf("parallel needs = %v %v", g.needs["b"], g.needs["c"])
}
if len(g.needs["d"]) != 1 || g.needs["d"][0] != "c" {
t.Fatalf("d needs = %v", g.needs["d"])
}
// 首轮可执行:只有 a。
layer, ok := g.nextLayer(steps)
if !ok || len(layer.par) != 1 || layer.par[0] != 0 || len(layer.seq) != 0 {
t.Fatalf("first layer = %+v", layer)
}
// a 完成后:b、c 同层并行。
steps[0].Status = "completed"
layer, ok = g.nextLayer(steps)
if !ok || len(layer.par) != 2 || layer.par[0] != 1 || layer.par[1] != 2 {
t.Fatalf("second layer = %+v", layer)
}
}
func TestCompileGraphNeedsPruneAndMixed(t *testing.T) {
steps := stepsOf(
StepSpec{ID: "a", Status: "pending"},
StepSpec{ID: "b", Status: "pending"},
StepSpec{ID: "c", Status: "pending", Needs: []string{"a"}},
)
g, err := compileGraph(steps)
if err != nil {
t.Fatal(err)
}
// a 完成后:b(线性依赖 a)与 c(needs a)同层。
steps[0].Status = "completed"
layer, ok := g.nextLayer(steps)
if !ok || len(layer.par) != 2 {
t.Fatalf("layer = %+v", layer)
}
// b 失败(非关键降级)后 c 仍按 needs 调度(与线性扫描的降级继续一致)。
steps[1].Status = "failed"
layer, ok = g.nextLayer(steps)
if !ok || len(layer.par)+len(layer.seq) == 0 {
t.Fatalf("after b failed: layer = %+v", layer)
}
}
func TestCompileGraphRejectsUnknownAndCycleAndDup(t *testing.T) {
if _, err := compileGraph(stepsOf(
StepSpec{ID: "a", Status: "pending", Needs: []string{"ghost"}},
)); err == nil || !strings.Contains(err.Error(), "未知步骤") {
t.Fatalf("unknown ref: %v", err)
}
if _, err := compileGraph(stepsOf(
StepSpec{ID: "a", Status: "pending", Needs: []string{"b"}},
StepSpec{ID: "b", Status: "pending", Needs: []string{"a"}},
)); err == nil || !strings.Contains(err.Error(), "成环") {
t.Fatalf("cycle: %v", err)
}
if _, err := compileGraph(stepsOf(
StepSpec{ID: "a", Status: "pending"},
StepSpec{ID: "a", Status: "pending"},
)); err == nil || !strings.Contains(err.Error(), "重复") {
t.Fatalf("dup: %v", err)
}
if _, err := compileGraph(stepsOf(
StepSpec{ID: "a", Status: "pending", Needs: []string{"a"}},
)); err == nil || !strings.Contains(err.Error(), "自身") {
t.Fatalf("self ref: %v", err)
}
}
func TestWriteSetConflictSerializes(t *testing.T) {
steps := stepsOf(
StepSpec{ID: "z", Status: "completed"},
StepSpec{ID: "a", Status: "pending", Parallel: true, Writes: []string{"report.md"}},
StepSpec{ID: "b", Status: "pending", Parallel: true, Writes: []string{"report.md"}},
StepSpec{ID: "c", Status: "pending", Parallel: true, Writes: []string{"notes.md"}},
StepSpec{ID: "d", Status: "pending", Parallel: true},
)
// 全部首轮可执行(无依赖):b 与 a 冲突 → b 串行;c、d 无冲突 → 并行。
g, err := compileGraph(steps)
if err != nil {
t.Fatal(err)
}
layer, ok := g.nextLayer(steps)
if !ok {
t.Fatal("no layer")
}
if len(layer.seq) != 1 || layer.seq[0] != 2 {
t.Fatalf("seq = %v", layer.seq)
}
if len(layer.par) != 3 || layer.par[0] != 1 || layer.par[1] != 3 || layer.par[2] != 4 {
t.Fatalf("par = %v", layer.par)
}
}
func TestFailedDependencyStillSchedules(t *testing.T) {
// 与线性扫描的「非关键失败降级继续」一致:依赖 failed 仍视为满足。
steps := stepsOf(
StepSpec{ID: "b", Status: "failed"},
StepSpec{ID: "a", Status: "pending", Needs: []string{"b"}},
)
g, err := compileGraph(steps)
if err != nil {
t.Fatal(err)
}
layer, ok := g.nextLayer(steps)
if !ok || len(layer.par) != 1 || layer.par[0] != 1 {
t.Fatalf("failed dependency should still schedule: %+v", layer)
}
if pendingCount(steps) != 1 {
t.Fatalf("pending = %d", pendingCount(steps))
}
}
+355
View File
@@ -0,0 +1,355 @@
package squad
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// groupchat.go — group_chat 拓扑驱动(Q2 WP3)。
// 形态(计划 §三 WP3 与边界 §五):共享转录(BB 字段 transcript +
// group_chat/transcript.md 全量落盘);发言人策略只做**轮转**与**主持人**
// 两种(脚本可判定,LLM 选人策略不进库);预算停机(max_turns,缺省 8)。
// 蓝图步骤 = 参与者名册(按序,不直接执行,driver 标记 skipped 并派生轮次
// 步骤);任一发言含 [close] 标记即收口;主持人模式下主持人输出 JSON 决策
// ({"next":"role"} / {"action":"close"}),未知角色/非法 JSON fail-closed。
// 驱动幂等可恢复:轮次由已落盘的 gc 步骤数重建,挂起(权限/HITL)后
// Respond 重入续转。
func init() {
_ = RegisterTopologyDriver(TopologyGroupChat)
}
// DefaultMaxGroupChatTurns 群聊轮次预算缺省值(预算停机)。
const DefaultMaxGroupChatTurns = 8
// gcRosterSkipReason 名册步骤的 skipped 原因(非执行步骤,不算降级)。
const gcRosterSkipReason = "group_chat: 名册步骤(参与者由驱动接管)"
// gcCloseMarker 任一发言包含该标记(大小写不敏感)即收口。
const gcCloseMarker = "[close]"
// gcTranscriptCapBB 是 BB 转录字段的容量上限(TASK.md 快照只带近端)。
const gcTranscriptCapBB = 4000
// runGroupChatDriver group_chat 拓扑入口。
func (e *SquadEngine) runGroupChatDriver(ctx context.Context, st *RunState) (*RunResult, error) {
drv := &flowDriver{opts: e.deps}
// 名册:首次进入从蓝图步骤取参与者序列并持久化到元数据(恢复时直读)。
roster, err := e.gcRoster(st)
if err != nil {
return e.driverFail(st, err.Error()), nil
}
policy := orDefault(st.Meta.Metadata["group_chat_policy"], "rotate")
moderator := st.Meta.Metadata["group_chat_moderator"]
if policy != "rotate" && policy != "moderator" {
return e.driverFail(st, fmt.Sprintf("group_chat: 未知发言人策略 %q(只支持 rotate/moderator)", policy)), nil
}
if policy == "moderator" {
if moderator == "" || !containsString(roster, moderator) {
return e.driverFail(st, fmt.Sprintf("group_chat: 主持人 %q 不在名册内(fail-closed)", moderator)), nil
}
}
maxTurns := DefaultMaxGroupChatTurns
if v := st.Meta.Metadata["group_chat_max_turns"]; v != "" {
if n := parseIntDefault(v, DefaultMaxGroupChatTurns); n > 0 {
maxTurns = n
}
}
// 名册步骤标记 skipped(首次进入时;恢复重入无 pending 名册步骤)。
st.mu.Lock()
for i := range st.Plan.Workflow.Steps {
s := &st.Plan.Workflow.Steps[i]
if !strings.HasPrefix(s.ID, "gc") && (s.Status == "pending" || s.Status == "running") {
s.Status = "skipped"
s.Error = gcRosterSkipReason
}
}
_ = st.save()
st.mu.Unlock()
for {
// 恢复重入:上次挂起留下的未完 gc 轮次步骤优先续跑(不重新选人)。
if idx := e.gcHeldStepIdx(st); idx >= 0 {
out, gate, err := e.gcRunStep(ctx, drv, st, idx)
if err != nil {
return nil, err
}
if gate != nil {
if out == nil {
out = heldResult(st, gate)
}
return out, nil
}
if out != nil {
return out, nil
}
if e.gcMessageClosed(st, idx) {
drv.emit(st, Event{Type: EvPhase, Text: fmt.Sprintf("group_chat:%s 发出收口标记,停机", st.Plan.Workflow.Steps[idx].Role)})
break
}
continue
}
turns := e.gcTurnCount(st)
if turns >= maxTurns {
drv.emit(st, Event{Type: EvPhase, Text: fmt.Sprintf("group_chat:轮次预算 %d 用尽,停机收口", maxTurns)})
break
}
speaker, closed, derr := e.gcNextSpeaker(st, roster, policy, moderator, turns)
if derr != nil {
return e.driverFail(st, derr.Error()), nil
}
if closed {
drv.emit(st, Event{Type: EvPhase, Text: "group_chat:主持人收口"})
break
}
out, gate, err := e.gcRunTurn(ctx, drv, st, turns, speaker)
if err != nil {
return nil, err
}
if gate != nil {
if out == nil {
out = heldResult(st, gate)
}
return out, nil
}
if out != nil { // 轮次步骤失败(关键)→ fail-closed 传播
return out, nil
}
if e.gcMessageClosed(st, len(st.Plan.Workflow.Steps)-1) {
drv.emit(st, Event{Type: EvPhase, Text: fmt.Sprintf("group_chat:%s 发出收口标记,停机", speaker)})
break
}
}
return e.gcFinish(st, drv), nil
}
// gcRoster 参与者名册(首次从蓝图步骤派生并写入元数据;恢复直读)。
func (e *SquadEngine) gcRoster(st *RunState) ([]string, error) {
if v := st.Meta.Metadata["group_chat_roster"]; v != "" {
return strings.Split(v, ","), nil
}
var roster []string
for _, s := range st.Plan.Workflow.Steps {
if s.Role != "" && !containsString(roster, s.Role) {
roster = append(roster, s.Role)
}
}
if len(roster) == 0 {
return nil, fmt.Errorf("group_chat: 名册为空(蓝图步骤即参与者)")
}
st.mu.Lock()
if st.Meta.Metadata == nil {
st.Meta.Metadata = map[string]string{}
}
st.Meta.Metadata["group_chat_roster"] = strings.Join(roster, ",")
_ = st.save()
st.mu.Unlock()
return roster, nil
}
// gcTurnCount 已完成的轮次数(预算与轮转序号的基准;恢复重入自然续算)。
func (e *SquadEngine) gcTurnCount(st *RunState) int {
n := 0
for _, s := range st.Plan.Workflow.Steps {
if strings.HasPrefix(s.ID, "gc") && s.Status == "completed" {
n++
}
}
return n
}
// gcHeldStepIdx 返回上次挂起留下的未完 gc 轮次步骤下标(无则 -1)。
func (e *SquadEngine) gcHeldStepIdx(st *RunState) int {
for i := range st.Plan.Workflow.Steps {
s := &st.Plan.Workflow.Steps[i]
if strings.HasPrefix(s.ID, "gc") && (s.Status == "pending" || s.Status == "running") {
return i
}
}
return -1
}
// gcNextSpeaker 决定下一发言人。rotate = 轮转;moderator = 主持人裁决
// (上一步是参与者发言 → 主持人接手;主持人决策 close → 收口,next → 指名,
// 非法 JSON / 未知角色 fail-closed)。
func (e *SquadEngine) gcNextSpeaker(st *RunState, roster []string, policy, moderator string, turns int) (string, bool, error) {
if policy == "rotate" {
return roster[turns%len(roster)], false, nil
}
last := e.gcLastStep(st)
if last == nil || last.Role == moderator {
// 首轮或上一步就是主持人(不该连续,防御性让主持人接手)。
if last != nil && last.Role == moderator {
// 解析主持人决策。
dec, err := e.gcParseDecision(st, last)
if err != nil {
return "", false, err
}
if dec.Action == "close" {
return "", true, nil
}
if !containsString(roster, dec.Next) {
return "", false, fmt.Errorf("group_chat: 主持人指名 %q 不在名册内(fail-closed)", dec.Next)
}
return dec.Next, false, nil
}
return moderator, false, nil
}
// 上一步是参与者发言 → 主持人接手裁决。
return moderator, false, nil
}
type gcDecision struct {
Action string `json:"action"`
Next string `json:"next"`
}
// gcParseDecision 宽松解析主持人 message.md 里的 JSON 决策(首 `{` 末 `}`)。
func (e *SquadEngine) gcParseDecision(st *RunState, step *StepSpec) (*gcDecision, error) {
msg := e.gcMessageOf(st, step)
start := strings.Index(msg, "{")
end := strings.LastIndex(msg, "}")
if start < 0 || end <= start {
return nil, fmt.Errorf("group_chat: 主持人输出无 JSON 决策(fail-closed):%q", truncateStr(msg, 80))
}
var dec gcDecision
if err := json.Unmarshal([]byte(msg[start:end+1]), &dec); err != nil {
return nil, fmt.Errorf("group_chat: 主持人决策解析失败(fail-closed): %v", err)
}
if dec.Action == "close" {
return &dec, nil
}
if strings.TrimSpace(dec.Next) == "" {
return nil, fmt.Errorf("group_chat: 主持人决策缺 next/action(fail-closed)")
}
return &dec, nil
}
// gcRunTurn 派生一轮发言步骤并执行。
func (e *SquadEngine) gcRunTurn(ctx context.Context, drv *flowDriver, st *RunState, turns int, speaker string) (*RunResult, *HitlGate, error) {
step := StepSpec{
ID: fmt.Sprintf("gc%02d", turns+1),
Role: speaker,
Title: fmt.Sprintf("群聊第 %d 轮:%s 发言(把发言写入 message.md;同意收口就写 [close])", turns+1, speaker),
Targets: []string{"message.md"},
SubWorkspace: fmt.Sprintf("group_chat/turn_%02d_%s", turns+1, speaker),
Status: "pending",
}
st.mu.Lock()
st.Plan.Workflow.Steps = append(st.Plan.Workflow.Steps, step)
idx := len(st.Plan.Workflow.Steps) - 1
_ = st.save()
st.mu.Unlock()
return e.gcRunStep(ctx, drv, st, idx)
}
// gcRunStep 执行一个轮次步骤:过门 → runOneStep → 转录并入共享状态。
// 挂起(gate)时步骤保持未完,恢复重入经 gcHeldStepIdx 续跑同一步骤。
func (e *SquadEngine) gcRunStep(ctx context.Context, drv *flowDriver, st *RunState, idx int) (*RunResult, *HitlGate, error) {
out, gate, err := drv.gateBeforeRun(ctx, st, idx)
if err != nil || gate != nil || out != nil {
return out, gate, err
}
step := st.Plan.Workflow.Steps[idx]
drv.emit(st, Event{Type: EvPhase, StepID: step.ID, Role: step.Role,
Text: fmt.Sprintf("group_chat:%s 发言(第 %d 轮)", step.Role, e.gcTurnCount(st)+1)})
out, gate, err = drv.runOneStep(ctx, st, idx)
if err != nil || gate != nil || out != nil {
return out, gate, err
}
// 转录并入共享状态:全量落盘 transcript.md,近端进 BB 字段(下一位
// 发言人的 TASK.md 黑板快照可见)。
msg := e.gcMessageOf(st, &st.Plan.Workflow.Steps[idx])
e.gcAppendTranscript(st, e.gcTurnCount(st), step.Role, msg)
return nil, nil, nil
}
// gcAppendTranscript 追加转录(全量文件 + BB 近端快照)。
func (e *SquadEngine) gcAppendTranscript(st *RunState, turn int, speaker, msg string) {
dir := filepath.Join(st.Meta.WorkDir, "group_chat")
_ = os.MkdirAll(dir, 0o755)
line := fmt.Sprintf("%d. %s:%s\n", turn, speaker, strings.TrimSpace(msg))
f, _ := os.OpenFile(filepath.Join(dir, "transcript.md"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if f != nil {
_, _ = f.WriteString(line)
_ = f.Close()
}
st.mu.Lock()
if st.BB.Fields == nil {
st.BB.Fields = map[string]string{}
}
cur := st.BB.Fields["transcript"] + line
if len(cur) > gcTranscriptCapBB {
cur = cur[len(cur)-gcTranscriptCapBB:]
}
st.BB.Fields["transcript"] = cur
_ = st.save()
st.mu.Unlock()
}
// gcLastStep 最后一个已完成的派生轮次步骤(主持人决策解析的锚点)。
func (e *SquadEngine) gcLastStep(st *RunState) *StepSpec {
for i := len(st.Plan.Workflow.Steps) - 1; i >= 0; i-- {
s := &st.Plan.Workflow.Steps[i]
if strings.HasPrefix(s.ID, "gc") && s.Status == "completed" {
return s
}
}
return nil
}
// gcMessageOf 读取某轮的 message.md(缺文件 = 空串)。
func (e *SquadEngine) gcMessageOf(st *RunState, step *StepSpec) string {
data, err := os.ReadFile(filepath.Join(st.Meta.WorkDir, filepath.FromSlash(step.SubWorkspace), "message.md"))
if err != nil {
return ""
}
return string(data)
}
// gcMessageClosed 第 idx 步的发言是否带收口标记。
func (e *SquadEngine) gcMessageClosed(st *RunState, idx int) bool {
if idx < 0 || idx >= len(st.Plan.Workflow.Steps) {
return false
}
msg := e.gcMessageOf(st, &st.Plan.Workflow.Steps[idx])
return strings.Contains(strings.ToLower(msg), gcCloseMarker)
}
// gcFinish 收口:交付物 = 全量转录;写 output.md;状态落盘;构造结果。
func (e *SquadEngine) gcFinish(st *RunState, drv *flowDriver) *RunResult {
transcript := ""
if data, err := os.ReadFile(filepath.Join(st.Meta.WorkDir, "group_chat", "transcript.md")); err == nil {
transcript = string(data)
}
report := "# 群聊转录\n\n" + transcript
_ = os.WriteFile(filepath.Join(st.Meta.WorkDir, "output.md"), []byte(report), 0o644)
turns := e.gcTurnCount(st)
st.mu.Lock()
wf := st.Workflow()
wf.Status = string(RunCompleted)
st.setWorkflow(wf)
st.Meta.Status = string(RunCompleted)
now := time.Now()
st.Meta.CompletedAt = &now
_ = st.save()
st.mu.Unlock()
summary := fmt.Sprintf("group_chat:%d 轮发言,转录已收口", turns)
drv.emit(st, Event{Type: EvPhase, Text: summary})
return &RunResult{RunID: st.Meta.ID, Status: RunCompleted, Plan: planPtr(st),
Deliverable: "output.md", Summary: summary, WorkDir: st.Meta.WorkDir}
}
func truncateStr(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
+91
View File
@@ -0,0 +1,91 @@
package squad
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// handoff.go — handoff 跨拓扑原语(Q2 WP3)。
// 语义(计划 §三 WP3):StepSpec.Handoff 非空的步骤完成后,最终答案所有权
// 移交给目标角色——① 记录所有权转移(Handoffs,随 RunState 持久化);
// ② 目标角色的下一个步骤收到「过滤历史」:只带移交步骤的主产出,不带全量
// 转录(一次性并入任务书,与 mailbox 同款机制);③ finalize 汇总交付物时
// 取链尾(最后一次移交的目标角色)的最后一个完成步骤产出,而非全图最后
// 完成步骤。目标角色不在小队内 = fail-closed(步骤启动前拒绝)。
// HandoffRecord 是一次所有权转移记录。
type HandoffRecord struct {
FromRole string `json:"from_role"`
FromStep string `json:"from_step"`
ToRole string `json:"to_role"`
Output string `json:"output,omitempty"` // 移交步骤主产出(截断上限内)
At time.Time `json:"at"`
Delivered bool `json:"delivered,omitempty"`
}
// handoffOutputCap 移交上下文的产出截断上限(过滤历史:只带主产出,且限量)。
const handoffOutputCap = 64 * 1024
// takeHandoffFor 取出移交给某角色的未投递记录,标记已投递(一次性)。
// 调用方负责随后 st.save()(或在临界区内)。
func takeHandoffFor(st *RunState, role string) *HandoffRecord {
for i := range st.Handoffs {
h := &st.Handoffs[i]
if h.Delivered || h.ToRole != role {
continue
}
h.Delivered = true
out := *h
return &out
}
return nil
}
// formatHandoffSection 渲染任务书里的移交上下文段(无则空串)。
func formatHandoffSection(h *HandoffRecord) string {
if h == nil {
return ""
}
body := h.Output
if len(body) > handoffOutputCap {
body = body[:handoffOutputCap] + "\n…(已截断)"
}
var b strings.Builder
fmt.Fprintf(&b, "\n\n## 移交上下文(所有权来自 %s,步骤 %s)\n", h.FromRole, h.FromStep)
b.WriteString("以下是移交方的主产出(过滤历史:只含该产出,不含其过程转录):\n\n")
b.WriteString(body)
b.WriteString("\n")
return b.String()
}
// handoffChainTail 返回移交链尾角色(无移交记录返回空串)。
func handoffChainTail(st *RunState) string {
if n := len(st.Handoffs); n > 0 {
return st.Handoffs[n-1].ToRole
}
return ""
}
// recordHandoff 步骤完成时的所有权转移记录:主产出快照(截断上限内)+
// 持久化 + 事件。目标角色合法性已在步骤启动前 fail-closed 校验。
func (d *flowDriver) recordHandoff(st *RunState, step StepSpec, subDir string, targets []string) {
output := ""
if data, err := os.ReadFile(filepath.Join(subDir, firstTarget(targets))); err == nil {
output = string(data)
}
if len(output) > handoffOutputCap {
output = output[:handoffOutputCap] + "\n…(已截断)"
}
st.mu.Lock()
st.Handoffs = append(st.Handoffs, HandoffRecord{
FromRole: step.Role, FromStep: step.ID, ToRole: step.Handoff,
Output: output, At: time.Now(),
})
_ = st.save()
st.mu.Unlock()
d.emit(st, Event{Type: EvPhase, StepID: step.ID, Role: step.Role,
Text: fmt.Sprintf("handoff:%s → %s(最终答案所有权移交)", step.Role, step.Handoff)})
}
+39 -4
View File
@@ -75,8 +75,10 @@ type HumanRequest struct {
Kind HumanRequestKind `json:"kind"`
Severity string `json:"severity,omitempty"` // action_required(默认)/ attention
Question string `json:"question"`
Options []HumanOption `json:"options,omitempty"`
Context string `json:"context,omitempty"` // 脱敏摘要(计划/缺口/质检报告)
// Reason 区分同一 Kind 的来源:plan、step_approval、interrupted。空值保持旧行为。
Reason string `json:"reason,omitempty"`
Options []HumanOption `json:"options,omitempty"`
Context string `json:"context,omitempty"` // 脱敏摘要(计划/缺口/质检报告)
// WaitCondition 非空时,条件满足可自动唤醒(人工回应仍然优先)。
WaitCondition *WaitCondition `json:"wait_condition,omitempty"`
// WakeupFires 已自动唤醒次数(护栏:达到 MaxWakeupFires 后过期)。
@@ -103,8 +105,19 @@ type RespondDecision struct {
Choice string `json:"choice,omitempty"`
// EditPlan 确认计划时的修改指令(附加在 Answer 上传递给引擎)。
EditPlan string `json:"edit_plan,omitempty"`
// Actor 决议发起方(Q2):"" 或 human = 人工(默认,兼容既有调用方);
// agent/system = 非人工。非人工 actor 的 choice 决议在
// permission/confirmation/quality_review 门上一律被拒绝(验收三)。
Actor string `json:"actor,omitempty"`
}
// Actor 归属常量。
const (
ActorHuman = "human"
ActorAgent = "agent"
ActorSystem = "system"
)
// newRequestID 生成请求 ID。
func newRequestID() string {
b := make([]byte, 8)
@@ -112,8 +125,30 @@ func newRequestID() string {
return "hr_" + hex.EncodeToString(b)
}
// hitlGate 引擎内部:一次挂起的完整上下文(落盘 pending.json)。
type hitlGate struct {
// expiredDecision 在请求超过 TimeoutAfter 或 ExpiresAt 时合成过期决策。
// ExpireAction 为 cancel 时选择 cancel,否则 fail。未到期返回 false。
func expiredDecision(req HumanRequest, policy HitlPolicy, now time.Time) (RespondDecision, bool) {
var deadline time.Time
switch {
case req.ExpiresAt != nil:
deadline = *req.ExpiresAt
case policy.TimeoutAfter > 0 && !req.CreatedAt.IsZero():
deadline = req.CreatedAt.Add(policy.TimeoutAfter)
default:
return RespondDecision{}, false
}
if now.Before(deadline) {
return RespondDecision{}, false
}
choice := "fail"
if policy.ExpireAction == "cancel" {
choice = "cancel"
}
return RespondDecision{RequestID: req.ID, Choice: choice, Answer: "expired"}, true
}
// HitlGate 引擎内部:一次挂起的完整上下文(落盘 pending.json)。
type HitlGate struct {
Request HumanRequest `json:"request"`
Plan *SquadPlan `json:"plan,omitempty"` // confirmation 时携带候选计划
Steps []StepSpec `json:"steps,omitempty"` // adjustment:建议追加的步骤
+19
View File
@@ -0,0 +1,19 @@
package squad
import (
"fmt"
"regexp"
"strings"
)
// idPattern 限制 run、角色、蓝图 key:首字符为字母或数字,其余为字母、数字、点、下划线、连字符,最长 128,且不得含 ".."。
var idPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
// ValidID 报告 id 是否可安全拼进数据目录路径。
func ValidID(id string) bool {
return idPattern.MatchString(id) && !strings.Contains(id, "..")
}
func invalidID(id string) error {
return fmt.Errorf("invalid id %q", id)
}
+1 -3
View File
@@ -3,8 +3,6 @@ package squad
import (
"context"
"fmt"
"agent-native/exec"
)
// IntentRejection 守卫拦截:拒绝该次运行并给出引导("识别即拒绝"语义)。
@@ -96,7 +94,7 @@ type IntentStep struct {
// IntentRunner 系统功能执行器(squadintent 提供实现:查 spec.Handler 并调宿主处理函数)。
type IntentRunner interface {
Run(ctx context.Context, step IntentStep) (*exec.Result, error)
Run(ctx context.Context, step IntentStep) (*ExecResult, error)
}
// DefaultIntentRole 系统功能步的默认角色 key(无后端语义,仅满足编制覆盖)。
+2 -4
View File
@@ -6,8 +6,6 @@ import (
"path/filepath"
"strings"
"testing"
"agent-native/exec"
)
type stubRunner struct {
@@ -16,7 +14,7 @@ type stubRunner struct {
err error
}
func (r *stubRunner) Run(ctx context.Context, step IntentStep) (*exec.Result, error) {
func (r *stubRunner) Run(ctx context.Context, step IntentStep) (*ExecResult, error) {
r.calls = append(r.calls, step)
if r.err != nil {
return nil, r.err
@@ -25,7 +23,7 @@ func (r *stubRunner) Run(ctx context.Context, step IntentStep) (*exec.Result, er
for _, t := range step.Targets {
patches[t] = []byte(r.text)
}
return &exec.Result{Success: true, Patches: patches, Logs: "stub-intent"}, nil
return &ExecResult{Success: true, Patches: patches, Logs: "stub-intent"}, nil
}
func testCatalog() *IntentCatalog {
+134 -26
View File
@@ -8,32 +8,30 @@ import (
"path/filepath"
"strings"
"time"
"agent-native/exec"
)
// leaderExecRequest 构造 leader 轮次的执行请求:与标准步骤同等装配
// (过程事件桥、ToolProfile/Skills/工具白名单、执行标识与预算)——
// 修平此前 leader 轮丢弃 OnEvent 与 ToolProfile 的缺口。
func leaderExecRequest(drv *flowDriver, st *runState, leader RoleSpec, workDir, prompt, instructions string, targets []string, timeout time.Duration) exec.Request {
func leaderExecRequest(drv *flowDriver, st *RunState, leader RoleSpec, workDir, prompt, instructions string, targets []string, timeout time.Duration) ExecRequest {
stepID := fmt.Sprintf("leader_turn_%d", st.Leader.Turns)
var budget *exec.Budget
var budget *ExecBudget
if timeout > 0 {
budget = &exec.Budget{Deadline: time.Now().Add(timeout)}
budget = &ExecBudget{Deadline: time.Now().Add(timeout)}
}
return exec.Request{
return ExecRequest{
WorkDir: workDir,
Prompt: prompt,
Instructions: instructions,
Targets: targets,
Timeout: timeout,
ToolProfile: orDefault(leader.ToolProfile, exec.ToolProfileWorkspace),
ToolProfile: orDefault(leader.ToolProfile, ToolProfileWorkspace),
ToolAllowlist: leader.Tools,
Skills: leader.Skills,
SessionScope: st.Meta.ID + "/" + stepID,
ExecutionID: st.Meta.ID + "-" + stepID,
Budget: budget,
OnEvent: func(ev exec.ProcessEvent) {
OnEvent: func(ev ProcessEvent) {
drv.emit(st, Event{Type: EvDetail, StepID: stepID, Role: leader.Role, Text: ev.Text,
Data: marshalJSON(map[string]string{"process_type": ev.Type, "tool": ev.Tool, "detail": ev.Detail})})
},
@@ -49,12 +47,34 @@ func leaderExecRequest(drv *flowDriver, st *runState, leader RoleSpec, workDir,
// DefaultMaxLeaderTurns leader 模式每运行的 leader 轮次上限。
const DefaultMaxLeaderTurns = 8
// DefaultMaxReplans leader 卡顿触发的重规划上限(Q2;与轮次上限独立)。
const DefaultMaxReplans = 3
// DefaultStuckThreshold 连续无进展轮数达到该值触发一次重规划(Q2)。
const DefaultStuckThreshold = 2
// LeaderState leader 循环状态(workspace leader.json 持久化,重启可恢复)。
type LeaderState struct {
Turns int `json:"turns"`
MaxTurns int `json:"max_turns"`
LeaderRole string `json:"leader_role"`
Delegations []DelegationRecord `json:"delegations"`
// 进度账本(Q2):每轮委派后记一条进展评估;卡顿检测与重规划的凭据。
Progress []ProgressEntry `json:"progress,omitempty"`
// StuckTurns 连续无进展轮数(有进展即清零)。
StuckTurns int `json:"stuck_turns,omitempty"`
// Replans 已触发的重规划次数;MaxReplans 触顶降级收口。
Replans int `json:"replans,omitempty"`
MaxReplans int `json:"max_replans,omitempty"`
// ReplanNote 一次性重规划指令(并入下一轮 leader 任务书后清空)。
ReplanNote string `json:"replan_note,omitempty"`
}
// ProgressEntry 是进度账本的一条评估记录。
type ProgressEntry struct {
Turn int `json:"turn"`
Signal string `json:"signal"` // progress | stuck
Detail string `json:"detail,omitempty"`
}
// DelegationRecord 一次已执行的委派(历史注入下一轮 leader 上下文)。
@@ -82,22 +102,24 @@ type delegation struct {
const leaderSubWorkspace = "tasks/00_leader"
func (st *runState) loadLeader() {
func (st *RunState) loadLeader() {
if st.Leader != nil {
return
}
st.Leader = &LeaderState{MaxTurns: DefaultMaxLeaderTurns}
data, err := os.ReadFile(filepath.Join(st.Meta.WorkDir, "leader.json"))
if err == nil {
data, err := st.storeOr().LoadLeader(st.Meta.WorkDir)
if err == nil && data != nil {
_ = json.Unmarshal(data, st.Leader)
}
}
func (st *runState) saveLeader() {
func (st *RunState) saveLeader() {
if st.Leader == nil {
return
}
_ = writeJSON(filepath.Join(st.Meta.WorkDir, "leader.json"), st.Leader)
if data, err := json.Marshal(st.Leader); err == nil {
_ = st.storeOr().SaveLeader(st.Meta.WorkDir, data)
}
}
// leaderPrompt 组装 leader 的系统指令(协调契约 + 通用守则)。
@@ -134,7 +156,7 @@ func leaderPrompt(members []RoleSpec, catalogMd string) string {
}
// leaderTaskBrief 组装 leader 本轮的 TASK.md(目标 + 黑板 + 委派历史)。
func leaderTaskBrief(st *runState) string {
func leaderTaskBrief(st *RunState, mail []MailMessage) string {
var b strings.Builder
b.WriteString("# Leader 轮次:评估并委派\n\n")
fmt.Fprintf(&b, "## 运行目标\n%s\n\n", st.Meta.Goal.Text)
@@ -171,13 +193,28 @@ func leaderTaskBrief(st *runState) string {
} else {
b.WriteString("(尚无委派,这是第一轮。)\n\n")
}
if len(st.Leader.Progress) > 0 {
b.WriteString("## 进度账本\n")
for _, p := range st.Leader.Progress {
fmt.Fprintf(&b, "- 第%d轮 [%s] %s\n", p.Turn, p.Signal, p.Detail)
}
fmt.Fprintf(&b, "- 已重规划 %d/%d 次\n\n", st.Leader.Replans, st.Leader.MaxReplans)
}
if st.Leader.ReplanNote != "" {
fmt.Fprintf(&b, "%s\n\n", st.Leader.ReplanNote)
}
if section := formatMailSection(mail); section != "" {
b.WriteString(strings.TrimPrefix(section, "\n\n"))
b.WriteString("\n")
}
b.WriteString("## 本轮产出要求\n\n写 delegation.json(结构见你的指令)到本子目录。\n")
return b.String()
}
// runLeaderLoop leader-delegation 主循环:委派→执行→唤醒,直到
// close/failed/上限/挂起(escalation/gap/权限等由 HITL 处理后重入本函数)。
func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResult, *hitlGate, error) {
func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *RunState) (*RunResult, *HitlGate, error) {
st.loadLeader()
drv := &flowDriver{opts: e.deps}
leader, ok := findAgent(st.Plan.Squad.Agents, st.Leader.LeaderRole)
@@ -206,7 +243,8 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu
}
_ = snapshotContextInto(st.Meta.WorkDir, subDir)
for st.Leader.Turns < st.Leader.MaxTurns {
term := e.terminationOf(st)
for st.Leader.Turns < term.MaxLeaderTurns {
// HITL:leader 角色权限升级(与标准模式同语义)。
if leader.BashPermission == "ask" && !st.Approved["role:"+leader.Role] {
if drv.policy(st).mode() != HitlInteractive {
@@ -222,7 +260,7 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu
},
CreatedAt: time.Now(), Status: "pending",
}
gate := &hitlGate{Request: *req, PrevStatus: RunExecuting}
gate := &HitlGate{Request: *req, PrevStatus: RunExecuting}
if err := st.saveGate(gate); err == nil {
st.Meta.Status = string(RunWaitingHuman)
_ = st.save()
@@ -232,9 +270,20 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu
}
// 每轮重写 leader 上下文(黑板/委派历史已变化)。
if err := os.WriteFile(filepath.Join(subDir, "TASK.md"), []byte(leaderTaskBrief(st)), 0o644); err != nil {
// 邮箱消息与重规划指令是一次性的:并入本轮任务书后清空并落盘。
st.mu.Lock()
leaderMail := takeMailFor(st, st.Leader.LeaderRole)
st.mu.Unlock()
if err := os.WriteFile(filepath.Join(subDir, "TASK.md"), []byte(leaderTaskBrief(st, leaderMail)), 0o644); err != nil {
return nil, nil, err
}
if len(leaderMail) > 0 {
_ = st.save()
}
if st.Leader.ReplanNote != "" {
st.Leader.ReplanNote = ""
st.saveLeader()
}
if err := os.WriteFile(filepath.Join(subDir, "AGENTS.md"), []byte(leaderPrompt(members, e.opts.IntentCatalog.intentCatalogMd())), 0o644); err != nil {
return nil, nil, err
}
@@ -248,7 +297,7 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu
if err != nil {
return e.leaderFail(st, err.Error()), nil, nil
}
prompt := readFileOr(filepath.Join(subDir, "TASK.md"), leaderTaskBrief(st))
prompt := readFileOr(filepath.Join(subDir, "TASK.md"), leaderTaskBrief(st, nil))
prompt = st.injectSteering(prompt)
instructions := readFileOr(filepath.Join(subDir, "AGENTS.md"), leaderPrompt(members, e.opts.IntentCatalog.intentCatalogMd()))
result, execErr := backend.Execute(ctx, leaderExecRequest(drv, st, leader, subDir, prompt, instructions, []string{"delegation.json"}, e.deps.StepTimeout))
@@ -294,7 +343,7 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu
},
CreatedAt: time.Now(), Status: "pending",
}
gate := &hitlGate{Request: *req, PrevStatus: RunExecuting}
gate := &HitlGate{Request: *req, PrevStatus: RunExecuting}
if err := st.saveGate(gate); err == nil {
st.Meta.Status = string(RunWaitingHuman)
_ = st.save()
@@ -337,6 +386,7 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu
memberBrief += "\n\n## Leader 补充指令\n" + dec.Briefing
_ = os.WriteFile(filepath.Join(st.Meta.WorkDir, filepath.FromSlash(step.SubWorkspace), "TASK.md"), []byte(memberBrief), 0o644)
}
factsBefore := len(st.BB.Facts)
out, gate, err := drv.runOneStep(ctx, st, idx)
if err != nil {
return nil, nil, err
@@ -354,10 +404,56 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu
return out, gate, nil // 挂起(gap/权限等),Respond 后重入循环
}
st.Leader.Delegations = append(st.Leader.Delegations, rec)
// 进度账本(Q2):委派完成且有新事实、或不是与上轮同题重复 → 有进展。
factsGrew := len(st.BB.Facts) > factsBefore
sameAsPrev := false
if n := len(st.Leader.Delegations); n >= 2 {
prev := st.Leader.Delegations[n-2]
sameAsPrev = prev.Member == rec.Member && prev.Title == rec.Title
}
if factsGrew || !sameAsPrev {
st.Leader.StuckTurns = 0
st.Leader.Progress = append(st.Leader.Progress, ProgressEntry{
Turn: rec.Turn, Signal: "progress",
Detail: fmt.Sprintf("%s:%s(新事实 %v)", rec.Member, rec.Title, factsGrew),
})
} else {
st.Leader.StuckTurns++
st.Leader.Progress = append(st.Leader.Progress, ProgressEntry{
Turn: rec.Turn, Signal: "stuck",
Detail: fmt.Sprintf("与上轮同题重复且无新事实(连续 %d 轮)", st.Leader.StuckTurns),
})
}
// 卡顿 → 重规划;重规划触顶 → 降级收口(与轮次上限同款 Q23 语义)。
if st.Leader.StuckTurns >= DefaultStuckThreshold {
st.Leader.StuckTurns = 0
st.Leader.Replans++
if st.Leader.Replans >= st.Leader.MaxReplans {
st.saveLeader()
drv.emit(st, Event{Type: EvPhase, Text: fmt.Sprintf(
"重规划达上限 %d,按现有产出降级收口(Q2)", st.Leader.MaxReplans)})
out2, gate2, err2 := drv.finalize(ctx, st)
if err2 != nil {
return nil, nil, err2
}
if gate2 != nil {
return heldResult(st, gate2), gate2, nil
}
return out2, nil, nil
}
st.Leader.ReplanNote = fmt.Sprintf(
"【重规划指令】连续 %d 轮无进展(同题重复且无新事实),已触发第 %d/%d 次重规划。"+
"本轮必须改变策略:换成员、把任务拆小、补充缺失输入,或 escalate 问人;不得原样重发上一次委派。",
DefaultStuckThreshold, st.Leader.Replans, st.Leader.MaxReplans)
drv.emit(st, Event{Type: EvPhase, Text: fmt.Sprintf(
"卡顿检测:触发重规划 %d/%d", st.Leader.Replans, st.Leader.MaxReplans)})
}
st.saveLeader()
// 成员交付快照进 leader inbox(leader 工具沙箱看不到成员目录,
// 须物化到 leader 工作区才能自查交付——InputLayoutSnapshot 的对称应用)。
snapshotDeliverablesToLeader(st, step)
if err := snapshotDeliverablesToLeader(st, step); err != nil {
return nil, nil, err
}
// 成员产出后检查阻塞缺口(等待条件挂起在标准 gap 通道上)。
if g2 := drv.checkBlockingGaps(st); g2 != nil {
return heldResult(st, g2), g2, nil
@@ -368,11 +464,21 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu
return e.leaderFail(st, fmt.Sprintf("leader 输出了未知 action %q", dec.Action)), nil, nil
}
}
return e.leaderFail(st, fmt.Sprintf("leader 轮次达上限 %d(防失控护栏),运行停止;可提高 MaxLeaderTurns 或人工介入", st.Leader.MaxTurns)), nil, nil
// Q23:达上限不再裸失败——按现有产出走 finalize 降级收口(质检/汇总路径与正常收口一致)。
drv2 := &flowDriver{opts: e.deps}
drv2.emit(st, Event{Type: EvPhase, Text: fmt.Sprintf("leader 轮次达上限 %d,按现有产出降级收口(Q23)", term.MaxLeaderTurns)})
out, gate2, err := drv2.finalize(ctx, st)
if err != nil {
return nil, nil, err
}
if gate2 != nil {
return heldResult(st, gate2), gate2, nil
}
return out, nil, nil
}
// leaderFail 统一的 leader 模式失败收口。
func (e *SquadEngine) leaderFail(st *runState, reason string) *RunResult {
func (e *SquadEngine) leaderFail(st *RunState, reason string) *RunResult {
now := time.Now()
st.Meta.Status = string(RunFailed)
st.Meta.CompletedAt = &now
@@ -386,7 +492,7 @@ func (e *SquadEngine) leaderFail(st *runState, reason string) *RunResult {
}
// snapshotDeliverablesToLeader 把成员交付文件复制进 leader 工作区 inbox/<步骤id>/。
func snapshotDeliverablesToLeader(st *runState, step StepSpec) {
func snapshotDeliverablesToLeader(st *RunState, step StepSpec) error {
srcDir := filepath.Join(st.Meta.WorkDir, filepath.FromSlash(step.SubWorkspace))
dstDir := filepath.Join(st.Meta.WorkDir, filepath.FromSlash(leaderSubWorkspace), "inbox", step.ID)
for _, t := range step.Targets {
@@ -395,9 +501,11 @@ func snapshotDeliverablesToLeader(st *runState, step StepSpec) {
continue
}
dst := filepath.Join(dstDir, filepath.Base(filepath.FromSlash(t)))
_ = os.MkdirAll(filepath.Dir(dst), 0o755)
_ = os.WriteFile(dst, data, 0o644)
if err := AtomicWrite(dst, data); err != nil {
return err
}
}
return nil
}
// parseDelegation 解析 leader 产出的 delegation.json。
+356
View File
@@ -0,0 +1,356 @@
package squad
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
// leader_mailbox_conformance_test.go — Q2 WP2 验收:leader 进度账本/重规划
// 上限、mailbox 投递、验收三「mailbox 消息不能把挂起的权限批准掉」。
// ---- leader 进度账本与重规划 ----
// 同题重复委派且无新事实 → 卡顿计数 → 触发重规划(事件+账本+一次性指令),
// leader 收到指令后 close,运行完成且 Replans=1。
func TestLeaderLedgerStuckTriggersReplan(t *testing.T) {
b := &leaderScriptBackend{
decisions: []string{
delegateJSON("writer", "同一件事"), // 第1轮:有进展(首委派)
delegateJSON("writer", "同一件事"), // 第2轮:同题无新事实 → stuck 1
delegateJSON("writer", "同一件事"), // 第3轮:stuck 2 → 重规划
closeJSON, // 第4轮:收到重规划指令后收口
},
script: map[string]map[string]string{
"01_writer": {"output.md": goodReport},
"02_writer": {"output.md": goodReport},
"03_writer": {"output.md": goodReport},
},
prompts: map[string][]string{},
}
sink := &collectSink{}
engine := newLeaderEngineWithSink(t, b, sink)
res, err := engine.Run(context.Background(), Goal{Text: "写一份报告"}, RunOptions{Mode: "leader"})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status = %s err = %v", res.Status, res.Error)
}
st, err := engine.loadRun(engine.runDir(res.RunID))
if err != nil {
t.Fatal(err)
}
st.loadLeader()
if st.Leader.Replans != 1 {
t.Fatalf("Replans = %d,期望 1", st.Leader.Replans)
}
var stuck, progress int
for _, p := range st.Leader.Progress {
switch p.Signal {
case "stuck":
stuck++
case "progress":
progress++
}
}
if stuck != 2 || progress != 1 {
t.Fatalf("账本 progress=%d stuck=%d,期望 1/2", progress, stuck)
}
if sink.countText("触发重规划 1/3") != 1 {
t.Fatal("缺重规划事件")
}
// 重规划指令一次性并入下一轮 leader 任务书。
var sawNote bool
for _, p := range b.prompts["00_leader"] {
if strings.Contains(p, "重规划指令") {
sawNote = true
}
}
if !sawNote {
t.Fatal("leader 未收到重规划指令")
}
}
// 重规划触顶(MaxReplans=1)→ 走 finalize 收口路径(与 Q23 轮次上限同款:
// 不裸失败;终态取决于既有产出),并发出触顶事件、账本记账、不再进 leader 轮。
func TestLeaderReplanCapFinalizes(t *testing.T) {
b := &leaderScriptBackend{
decisions: []string{
delegateJSON("writer", "同一件事"),
delegateJSON("writer", "同一件事"),
delegateJSON("writer", "同一件事"), // stuck 2 → Replans=1 >= cap → 收口
closeJSON, // 不应到达
},
script: map[string]map[string]string{
"01_writer": {"output.md": goodReport},
"02_writer": {"output.md": goodReport},
"03_writer": {"output.md": goodReport},
},
}
sink := &collectSink{}
engine := newLeaderEngineWithSink(t, b, sink)
res, err := engine.Run(context.Background(), Goal{Text: "写一份报告"}, RunOptions{Mode: "leader", MaxReplans: 1})
if err != nil {
t.Fatal(err)
}
switch res.Status {
case RunCompleted, RunDegraded, RunQualityReview:
default:
t.Fatalf("重规划触顶应走 finalize 收口而非 %s(err=%v)", res.Status, res.Error)
}
if sink.countText("重规划达上限 1") != 1 {
t.Fatal("缺触顶事件")
}
st, err := engine.loadRun(engine.runDir(res.RunID))
if err != nil {
t.Fatal(err)
}
st.loadLeader()
if st.Leader.Replans != 1 || st.Leader.MaxReplans != 1 {
t.Fatalf("Replans=%d MaxReplans=%d,期望 1/1", st.Leader.Replans, st.Leader.MaxReplans)
}
if b.turn > 3 {
t.Fatalf("触顶后不应再进 leader 轮(turn=%d)", b.turn)
}
}
func newLeaderEngineWithSink(t *testing.T, backend Executor, sink ProgressSink) *SquadEngine {
t.Helper()
dir := t.TempDir()
reg := NewRoleRegistry()
for _, r := range []RoleSpec{
{Role: "leader", Name: "主管", Backend: "leader-script", Skills: []string{"协调"}},
{Role: "writer", Name: "撰写", Backend: "leader-script", Skills: []string{"撰写"}},
} {
if err := reg.Register(r); err != nil {
t.Fatal(err)
}
}
backends := NewBackendResolver()
backends.RegisterInstance("leader-script", backend)
engine, err := NewEngine(Options{DataDir: dir, Registry: reg, Backends: backends, Sink: sink})
if err != nil {
t.Fatal(err)
}
return engine
}
// ---- mailbox:投递 + 验收三 ----
// 挂起(gap)期间投递 → Respond 恢复 → 下一步骤任务书含消息;广播 "*" 同样到达;
// 已执行完的步骤不被追投;定向消息只投递一次。
func TestMailboxDeliveredAfterHoldIntoNextStep(t *testing.T) {
rec := &promptRecordingBackend{prompts: map[string][]string{}}
engine := newRecordingEngine(t, rec, gapSteps())
res, err := engine.Run(context.Background(), Goal{Text: "带缺口"}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != RunWaitingHuman || res.Pending == nil {
t.Fatalf("应挂起等人工:%s", res.Status)
}
// 挂起期间投递(run 锁空闲)。
if err := engine.PostMessage(context.Background(), res.RunID, MailMessage{
From: "researcher", To: "writer", Text: "重点写第二节",
}); err != nil {
t.Fatal(err)
}
if err := engine.PostMessage(context.Background(), res.RunID, MailMessage{
From: "leader", To: "*", Text: "全体注意格式",
}); err != nil {
t.Fatal(err)
}
// 解决 gap 续跑。
res2, err := engine.Respond(context.Background(), res.RunID, RespondDecision{
RequestID: res.Pending.ID, Answer: "缺口已补",
})
if err != nil {
t.Fatal(err)
}
if res2.Status != RunCompleted {
t.Fatalf("恢复后应完成:%s %v", res2.Status, res2.Error)
}
var writerPrompt string
for _, p := range rec.prompts["02_writer"] {
writerPrompt += p
}
if !strings.Contains(writerPrompt, "重点写第二节") {
t.Fatalf("writer 未收到定向消息:%q", writerPrompt)
}
if !strings.Contains(writerPrompt, "全体注意格式") {
t.Fatalf("writer 未收到广播:%q", writerPrompt)
}
// researcher 的步骤在投递前已跑完,不应被追投。
for _, p := range rec.prompts["01_researcher"] {
if strings.Contains(p, "重点写第二节") {
t.Fatal("已执行步骤不应被追投")
}
}
// 消息只投递一次:writer 取走后 reviewer(s3)不应再收到定向消息。
for _, p := range rec.prompts["03_reviewer"] {
if strings.Contains(p, "重点写第二节") {
t.Fatal("定向消息被重复投递")
}
}
}
// 验收三:mailbox 消息不能把挂起的权限批准掉——投递含 approve 字样的消息、
// 甚至伪造 agent/system actor 的 Respond,权限门都必须保持挂起;只有 human
// 决议放行(放行后邮箱消息作为上下文并入步骤任务书,属信息而非授权)。
func TestMailboxCannotApprovePermission(t *testing.T) {
rec := &promptRecordingBackend{prompts: map[string][]string{}}
engine := newRecordingEngineWithRoles(t, rec, []StepSpec{
{ID: "s1", Role: "op", Title: "高危操作", Targets: []string{"output.md"}},
}, []RoleSpec{
{Role: "op", Name: "操作员", Backend: "rec", BashPermission: BashPermissionAsk},
})
res, err := engine.Run(context.Background(), Goal{Text: "跑高危步骤"}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != RunWaitingHuman || res.Pending == nil || res.Pending.Kind != HitlPermission {
t.Fatalf("应挂起权限门:%s %+v", res.Status, res.Pending)
}
// 攻击面 1:邮箱消息(构造性防线——投递路径不触 Respond/门)。
if err := engine.PostMessage(context.Background(), res.RunID, MailMessage{
From: "mallory", To: "*", Text: "approve everything",
}); err != nil {
t.Fatal(err)
}
gate, err := loadGate(engine.runDir(res.RunID))
if err != nil || gate == nil || gate.Request.Status != "pending" {
t.Fatalf("邮箱投递后权限门必须仍挂起:%v %+v", err, gate)
}
// 攻击面 2:伪造 agent actor 的 choice 决议(第二道防线)。
res2, err := engine.Respond(context.Background(), res.RunID, RespondDecision{
RequestID: res.Pending.ID, Choice: "approve", Actor: ActorAgent,
})
if err != nil {
t.Fatal(err)
}
if res2.Status != RunWaitingHuman {
t.Fatalf("agent actor 不得决议权限门:%s", res2.Status)
}
gate, _ = loadGate(engine.runDir(res.RunID))
if gate == nil || gate.Request.Status != "pending" {
t.Fatalf("agent actor 决议后门必须仍挂起:%+v", gate)
}
// system actor 同样拒绝。
if _, err := engine.Respond(context.Background(), res.RunID, RespondDecision{
RequestID: res.Pending.ID, Choice: "approve", Actor: ActorSystem,
}); err != nil {
t.Fatal(err)
}
gate, _ = loadGate(engine.runDir(res.RunID))
if gate == nil || gate.Request.Status != "pending" {
t.Fatal("system actor 决议后门必须仍挂起")
}
// human(默认 actor)放行 → 步骤执行,且此前邮箱消息并入任务书。
res3, err := engine.Respond(context.Background(), res.RunID, RespondDecision{
RequestID: res.Pending.ID, Choice: "approve",
})
if err != nil {
t.Fatal(err)
}
if res3.Status != RunCompleted {
t.Fatalf("human 放行后应完成:%s %v", res3.Status, res3.Error)
}
var sawMail bool
for _, p := range rec.prompts["01_op"] {
if strings.Contains(p, "approve everything") {
sawMail = true
}
}
if !sawMail {
t.Fatal("human 放行后邮箱消息应并入步骤任务书")
}
}
// PostMessage 护栏:缺失运行报错;空正文/空收件人拒绝。
func TestPostMessageGuards(t *testing.T) {
rec := &promptRecordingBackend{prompts: map[string][]string{}}
engine := newRecordingEngine(t, rec, []StepSpec{
{ID: "s1", Role: "researcher", Title: "一", Targets: []string{"output.md"}},
})
if err := engine.PostMessage(context.Background(), "run_missing", MailMessage{To: "x", Text: "y"}); err == nil {
t.Fatal("缺失运行必须报错")
}
res, err := engine.Run(context.Background(), Goal{Text: "x"}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status = %s", res.Status)
}
if err := engine.PostMessage(context.Background(), res.RunID, MailMessage{To: "researcher", Text: ""}); err == nil {
t.Fatal("空正文必须拒绝")
}
if err := engine.PostMessage(context.Background(), res.RunID, MailMessage{To: "", Text: "x"}); err == nil {
t.Fatal("空收件人必须拒绝")
}
}
// ---- 夹具 ----
// promptRecordingBackend 按目录记录 prompt;gaps.json 产出阻塞缺口触发挂起,
// 其余目标写合格交付物。
type promptRecordingBackend struct {
prompts map[string][]string
}
func (b *promptRecordingBackend) Name() string { return "rec" }
func (b *promptRecordingBackend) Execute(_ context.Context, req ExecRequest) (*ExecResult, error) {
key := filepath.Base(filepath.Clean(req.WorkDir))
b.prompts[key] = append(b.prompts[key], req.Prompt)
patches := map[string][]byte{}
for _, t := range req.Targets {
content := goodReport
switch t {
case "gaps.json":
content = `[{"id":"g1","item":"缺一个输入","owner":"human","blocking":true}]`
case "fields.json":
content = `{}`
}
out := filepath.Join(req.WorkDir, t)
if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil {
return nil, err
}
if err := os.WriteFile(out, []byte(content), 0o644); err != nil {
return nil, err
}
patches[t] = []byte(content)
}
return &ExecResult{Success: true, Patches: patches}, nil
}
func newRecordingEngine(t *testing.T, be Executor, steps []StepSpec) *SquadEngine {
t.Helper()
return newRecordingEngineWithRoles(t, be, steps, []RoleSpec{
{Role: "researcher", Name: "调研", Backend: "rec"},
{Role: "writer", Name: "撰写", Backend: "rec"},
{Role: "reviewer", Name: "审核", Backend: "rec"},
})
}
func newRecordingEngineWithRoles(t *testing.T, be Executor, steps []StepSpec, roles []RoleSpec) *SquadEngine {
t.Helper()
dir := t.TempDir()
reg := NewRoleRegistry()
for _, r := range roles {
if err := reg.Register(r); err != nil {
t.Fatal(err)
}
}
backends := NewBackendResolver()
backends.RegisterInstance("rec", be)
engine, err := NewEngine(Options{DataDir: dir, Registry: reg,
Planner: mockScriptPlanner(steps...), Backends: backends, Sink: NopSink{}})
if err != nil {
t.Fatal(err)
}
return engine
}
+17 -14
View File
@@ -10,8 +10,6 @@ import (
"sync"
"testing"
"time"
"agent-native/exec"
)
// leaderScriptBackend leader 模式脚本后端:00_leader 目录按轮次回放
@@ -26,7 +24,7 @@ type leaderScriptBackend struct {
func (b *leaderScriptBackend) Name() string { return "leader-script" }
func (b *leaderScriptBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
func (b *leaderScriptBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
key := filepath.Base(filepath.Clean(req.WorkDir))
if b.prompts != nil {
b.mu.Lock()
@@ -43,7 +41,7 @@ func (b *leaderScriptBackend) Execute(ctx context.Context, req exec.Request) (*e
}
out := filepath.Join(req.WorkDir, "delegation.json")
_ = os.WriteFile(out, []byte(b.decisions[idx]), 0o644)
return &exec.Result{Success: true, Patches: map[string][]byte{"delegation.json": []byte(b.decisions[idx])}}, nil
return &ExecResult{Success: true, Patches: map[string][]byte{"delegation.json": []byte(b.decisions[idx])}}, nil
}
files := b.script[key]
patches := map[string][]byte{}
@@ -57,7 +55,7 @@ func (b *leaderScriptBackend) Execute(ctx context.Context, req exec.Request) (*e
_ = os.WriteFile(out, []byte(content), 0o644)
patches[t] = []byte(content)
}
return &exec.Result{Success: true, Patches: patches,
return &ExecResult{Success: true, Patches: patches,
PromptTokens: 100, CompletionTokens: 50}, nil
}
@@ -67,7 +65,7 @@ func delegateJSON(member, title string) string {
const closeJSON = `{"action":"close","reason":"所有子任务完成,交付物满足目标"}`
func newLeaderEngine(t *testing.T, backend exec.Backend, roles ...RoleSpec) *SquadEngine {
func newLeaderEngine(t *testing.T, backend Executor, roles ...RoleSpec) *SquadEngine {
t.Helper()
dir := t.TempDir()
reg := NewRoleRegistry()
@@ -202,8 +200,13 @@ func TestLeaderModeTurnCapGuard(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if res.Status != RunFailed || !strings.Contains(res.Error, "上限") {
t.Fatalf("want turn-cap failure, got %s err=%s", res.Status, res.Error)
// Q23:达上限走 finalize 降级收口(completed/degraded/质检挂起),
// 不再是「运行停止」的裸失败。
if res.Status == RunFailed && strings.Contains(res.Error, "运行停止") {
t.Fatalf("Q23 closeout should not be a bare failure, got %s err=%s", res.Status, res.Error)
}
if res.Status != RunCompleted && res.Status != RunDegraded && res.Status != RunQualityReview {
t.Fatalf("Q23 closeout status = %s err=%s", res.Status, res.Error)
}
if b.turn < 3 {
t.Fatalf("leader should have run %d turns, ran %d", 3, b.turn)
@@ -385,18 +388,18 @@ func TestWakeupGuardMaxFires(t *testing.T) {
type eventingBackend struct {
inner *leaderScriptBackend
mu sync.Mutex
reqs []exec.Request
reqs []ExecRequest
}
func (b *eventingBackend) Name() string { return "leader-script" }
func (b *eventingBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
func (b *eventingBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
b.mu.Lock()
b.reqs = append(b.reqs, req)
b.mu.Unlock()
if req.OnEvent != nil {
req.OnEvent(exec.ProcessEvent{Type: "tool_execution_start", Tool: "grep", Detail: `{"q":"x"}`})
req.OnEvent(exec.ProcessEvent{Type: "tool_execution_end", Tool: "grep", Text: "found"})
req.OnEvent(ProcessEvent{Type: "tool_execution_start", Tool: "grep", Detail: `{"q":"x"}`})
req.OnEvent(ProcessEvent{Type: "tool_execution_end", Tool: "grep", Text: "found"})
}
return b.inner.Execute(ctx, req)
}
@@ -435,7 +438,7 @@ func TestLeaderTurnsEmitDetailEventsAndCarryProfile(t *testing.T) {
t.Fatalf("want completed, got %s err=%s", res.Status, res.Error)
}
// leader 轮的请求带上了 ToolProfile/白名单/技能与标识(修平 leader 请求缺口)。
var leaderReq *exec.Request
var leaderReq *ExecRequest
for i := range eb.reqs {
if strings.HasSuffix(eb.reqs[i].WorkDir, "00_leader") {
leaderReq = &eb.reqs[i]
@@ -444,7 +447,7 @@ func TestLeaderTurnsEmitDetailEventsAndCarryProfile(t *testing.T) {
if leaderReq == nil {
t.Fatal("leader request missing")
}
if leaderReq.ToolProfile != exec.ToolProfileWorkspace || len(leaderReq.ToolAllowlist) != 2 || len(leaderReq.Skills) != 1 {
if leaderReq.ToolProfile != ToolProfileWorkspace || len(leaderReq.ToolAllowlist) != 2 || len(leaderReq.Skills) != 1 {
t.Fatalf("leader request not fully assembled: %+v", leaderReq)
}
if !strings.Contains(leaderReq.ExecutionID, "leader_turn_") {
+11
View File
@@ -0,0 +1,11 @@
//go:build !unix
package squad
import "context"
// lockRunFile 在非 unix 平台退回进程内锁(由 SquadEngine.runLock 提供)。
// 跨进程互斥不可用;调用方不要假设两个进程能靠这把锁互相拒绝。
func lockRunFile(ctx context.Context, dir string) (func(), error) {
return func() {}, nil
}
+42
View File
@@ -0,0 +1,42 @@
//go:build unix
package squad
import (
"context"
"os"
"path/filepath"
"syscall"
"time"
)
// lockRunFile 对 runs/<id>/.lock 做非阻塞独占锁,并按 ctx 轮询。
// 直到拿到锁或 ctx 结束。ctx 结束时返回 ErrRunBusy。
func lockRunFile(ctx context.Context, dir string) (func(), error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
f, err := os.OpenFile(filepath.Join(dir, ".lock"), os.O_CREATE|os.O_RDWR, 0o644)
if err != nil {
return nil, err
}
for {
err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
if err == nil {
return func() {
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
_ = f.Close()
}, nil
}
if err != syscall.EWOULDBLOCK && err != syscall.EAGAIN {
_ = f.Close()
return nil, err
}
select {
case <-ctx.Done():
_ = f.Close()
return nil, ErrRunBusy
case <-time.After(20 * time.Millisecond):
}
}
}
+92
View File
@@ -0,0 +1,92 @@
package squad
import (
"context"
"fmt"
"strings"
"time"
)
// mailbox.go — 邮箱原语(Q2 WP2):运行内点对点消息。
// 安全边界(验收三的构造性保证):投递路径永不经过 Respond/gate——
// 消息只在步骤启动时并入任务书;挂起中的人工门(permission/confirmation/
// quality_review)不可能被邮箱消息决议。RespondDecision.Actor 提供
// 第二道防线:非 human actor 的 choice 决议一律拒绝。
// MailMessage 是一条运行内消息。To 是角色 key,"*" = 广播给之后的每个步骤。
type MailMessage struct {
ID string `json:"id"`
From string `json:"from"`
To string `json:"to"`
Text string `json:"text"`
At time.Time `json:"at"`
Delivered bool `json:"delivered,omitempty"`
}
// PostMessage 向一个运行投递邮箱消息。运行执行中(锁被占)返回 ErrRunBusy
// ——挂起等待人工时与回合间隙可投递。消息随 RunState 持久化,重启不丢。
// 本方法不触碰任何 HITL 门(验收三:邮箱永不决议挂起的审批)。
func (e *SquadEngine) PostMessage(ctx context.Context, runID string, msg MailMessage) error {
if strings.TrimSpace(msg.Text) == "" {
return fmt.Errorf("mailbox: 消息正文为空")
}
if strings.TrimSpace(msg.To) == "" {
return fmt.Errorf("mailbox: 收件人必填(角色 key 或 *)")
}
release, err := e.acquire(ctx, runID)
if err != nil {
return err
}
defer release()
wsDir := e.runDir(runID)
st, err := e.loadRun(wsDir)
if err != nil {
return fmt.Errorf("load run %s: %w", runID, err)
}
if msg.ID == "" {
msg.ID = newRequestID()
}
if msg.At.IsZero() {
msg.At = time.Now()
}
st.mu.Lock()
st.Mailbox = append(st.Mailbox, msg)
err = st.save()
st.mu.Unlock()
if err != nil {
return err
}
e.deps.Sink.Emit(Event{Type: EvPhase, RunID: runID, Text: fmt.Sprintf("邮箱消息 %s → %s", msg.From, msg.To), At: time.Now()})
return nil
}
// takeMailFor 取出发给某角色的未投递消息(含广播),标记已投递。
// 调用方负责随后 st.save()(或在临界区内)。
func takeMailFor(st *RunState, role string) []MailMessage {
var out []MailMessage
for i := range st.Mailbox {
m := &st.Mailbox[i]
if m.Delivered {
continue
}
if m.To != role && m.To != "*" {
continue
}
m.Delivered = true
out = append(out, *m)
}
return out
}
// formatMailSection 渲染任务书里的邮箱段(空则返回空串)。
func formatMailSection(msgs []MailMessage) string {
if len(msgs) == 0 {
return ""
}
var b strings.Builder
b.WriteString("\n\n## 邮箱消息\n")
for _, m := range msgs {
fmt.Fprintf(&b, "- [%s] %s:%s\n", m.At.Format(time.RFC3339), m.From, m.Text)
}
return b.String()
}
+57 -5
View File
@@ -66,9 +66,12 @@ type Blueprint struct {
Title string `json:"title"`
Match []string `json:"match,omitempty"` // 关键词命中即视为命中模板
Description string `json:"description,omitempty"`
Agents []RoleSpec `json:"agents,omitempty"` // 默认编制(引用已注册角色或内联角色)
Steps []StepSpec `json:"steps"`
Template string `json:"template,omitempty"` // 交付物骨架模板(Markdown)
// Topology 声明模板的拓扑(Q2;空 = standard 图行为)。
// RunOptions.Topology 优先于本字段。
Topology string `json:"topology,omitempty"`
Agents []RoleSpec `json:"agents,omitempty"` // 默认编制(引用已注册角色或内联角色)
Steps []StepSpec `json:"steps"`
Template string `json:"template,omitempty"` // 交付物骨架模板(Markdown)
}
// PlanningMode 规划模式。
@@ -88,10 +91,18 @@ type StepSpec struct {
Mode string `json:"mode,omitempty"` // 预留
Parallel bool `json:"parallel"`
Needs []string `json:"needs,omitempty"`
// Writes 写集:本步骤会写入的工件相对路径(调度依据)。
// 同一 superstep 内写集相交的步骤强制串行,不相交才可并行(Q1 WP1)。
Writes []string `json:"writes,omitempty"`
Targets []string `json:"targets,omitempty"` // 期望交付物相对路径,缺省 output.md
Status string `json:"status"` // pending, running, completed, failed, skipped
DurationMs int64 `json:"duration_ms,omitempty"`
Error string `json:"error,omitempty"`
// Attempts 该步骤进入 running 的次数。中断恢复用它决定 auto 策略是否还能重试。
Attempts int `json:"attempts,omitempty"`
// AttemptID 当前这次尝试的稳定标识:runID/stepID/N(N=Attempts)。
// 执行事件、产物与副作用记录都挂它;瞬态重试共享同一 AttemptID(Q1 WP3)。
AttemptID string `json:"attempt_id,omitempty"`
DurationMs int64 `json:"duration_ms,omitempty"`
Error string `json:"error,omitempty"`
// RequiresApproval 为 true 时,该步骤启动前进入 HITL 计划确认。
RequiresApproval bool `json:"requires_approval,omitempty"`
// IntentID 非空 = 系统功能步:不经 BackendResolver/智能体,由 IntentRunner 直接执行
@@ -107,6 +118,47 @@ type StepSpec struct {
Skills []string `json:"skills,omitempty"`
// MinScore 质检总分下限。0 = 不额外设门槛,只看 Checker.Passed。
MinScore int `json:"min_score,omitempty"`
// Loop 非空 = 循环步(Q2 loop 拓扑):完成后条件不满足且预算未尽时
// 重置为 pending 再武装(图不允许环,循环由引擎再武装实现)。
Loop *LoopSpec `json:"loop,omitempty"`
// LoopRuns 已再武装次数(引擎维护,随计划持久化)。
LoopRuns int `json:"loop_runs,omitempty"`
// Branches 非空 = 路由步(Q2 router 拓扑):完成后按黑板字段判定,
// 未选中分支的目标步骤置 skipped(skipped=依赖已满足,join 步照常推进)。
Branches []Branch `json:"branches,omitempty"`
// Handoff 非空 = 完成即把最终答案所有权移交给该角色(Q2 handoff 原语)。
Handoff string `json:"handoff,omitempty"`
// Brief 编译期任务指令(Q3 编译器):非空时渲染进 TASK.md 的
// 「任务指令」节,位于标题/产出之后、上下文表之前。图步的富文本任务书
// 载体(此前只有 leader 委派有 briefing)。
Brief string `json:"brief,omitempty"`
}
// LoopSpec 是循环步的预算与停机条件(Q2)。
type LoopSpec struct {
// MaxIterations 再武装上限(必须 >0;触顶即停,带 loop_exhausted 事件)。
MaxIterations int `json:"max_iterations"`
// UntilField 停机判据的黑板字段名;空 = 只按 MaxIterations 迭代。
UntilField string `json:"until_field,omitempty"`
// UntilEquals 停机值:Fields[UntilField] == UntilEquals 时停。
UntilEquals string `json:"until_equals,omitempty"`
// Body 与本步一起再武装的其他步骤 ID(循环体;如 evaluator-optimizer
// 的评估步)。空 = 只再武装本步。
Body []string `json:"body,omitempty"`
}
// Branch 是路由步的一条分支(Q2)。When 按声明序取首个命中。
type Branch struct {
When BranchCond `json:"when"`
// Then 是本分支保留的步骤 ID(其余分支目标置 skipped)。
Then []string `json:"then"`
}
// BranchCond 是分支判据:黑板字段等值/不等值。
type BranchCond struct {
Field string `json:"field"`
Equals string `json:"equals,omitempty"`
NotEquals string `json:"not_equals,omitempty"`
}
// IsCritical 报告步骤是否关键(nil 视为关键)。
+58 -31
View File
@@ -5,23 +5,25 @@ import (
"encoding/json"
"fmt"
"strings"
"agent-native/runtime/llm"
)
// LLMPlanner LLM 规划器:用模型理解目标 → 拆解任务 → 选/派角色 → 生成步骤。
// 模型客户端与默认模型在构造时注入(角色级模型在派生角色时由模型自行指定)。
// 输出为严格 JSON;解析失败时重试一次,仍失败则 fail-closed 返回错误。
type LLMPlanner struct {
Client *llm.Client
Client ModelClient
Model string
APIBase string
APIKey string
MaxTokens int
// Fallback 在模型调用或解析失败时接手。空则保持 fail-closed。
Fallback Planner
// MaxSteps 计划步骤硬上限(默认 32)。
MaxSteps int
}
// NewLLMPlanner 创建 LLM 规划器。
func NewLLMPlanner(client *llm.Client, model string) *LLMPlanner {
func NewLLMPlanner(client ModelClient, model string) *LLMPlanner {
return &LLMPlanner{Client: client, Model: model}
}
@@ -82,6 +84,9 @@ const llmPlannerSystem = `你是智能体小队的规划器。给定用户目标
func (p *LLMPlanner) Plan(ctx context.Context, input PlanInput) (*PlanOutput, error) {
if p.Client == nil || p.Model == "" {
if p.Fallback != nil {
return p.Fallback.Plan(ctx, input)
}
return nil, fmt.Errorf("llm planner requires client and model")
}
userMsg := p.buildPrompt(input)
@@ -95,7 +100,8 @@ func (p *LLMPlanner) Plan(ctx context.Context, input PlanInput) (*PlanOutput, er
}
raw, err := p.complete(ctx, sys, userMsg)
if err != nil {
return nil, fmt.Errorf("llm planner call failed: %w", err)
lastErr = err
continue
}
schema, err := parseLLMPlan(raw)
if err != nil {
@@ -106,6 +112,9 @@ func (p *LLMPlanner) Plan(ctx context.Context, input PlanInput) (*PlanOutput, er
break
}
if parsed == nil {
if p.Fallback != nil {
return p.Fallback.Plan(ctx, input)
}
return nil, fmt.Errorf("llm planner output unparsable after retry: %w", lastErr)
}
@@ -164,11 +173,15 @@ func (p *LLMPlanner) materialize(input PlanInput, parsed *llmPlanSchema) (*PlanO
steps := make([]StepSpec, 0, len(parsed.Steps))
for i, ls := range parsed.Steps {
if ls.Role == "" {
if ls.IntentID == "" && ls.Role == "" {
return nil, fmt.Errorf("step %d missing role", i)
}
if _, ok := input.Registry.Get(ls.Role); !ok {
return nil, fmt.Errorf("step %d references unregistered role %q", i, ls.Role)
if ls.IntentID == "" {
if _, ok := input.Registry.Get(ls.Role); !ok {
return nil, fmt.Errorf("step %d references unregistered role %q", i, ls.Role)
}
} else if ls.Role == "" {
ls.Role = "intent"
}
if _, inSquad := findAgent(agents, ls.Role); !inSquad {
if spec, ok := input.Registry.Get(ls.Role); ok {
@@ -189,6 +202,13 @@ func (p *LLMPlanner) materialize(input PlanInput, parsed *llmPlanSchema) (*PlanO
IntentID: ls.IntentID, IntentSlots: ls.IntentSlots,
})
}
maxSteps := p.MaxSteps
if maxSteps <= 0 {
maxSteps = 32
}
if len(steps) > maxSteps {
return nil, fmt.Errorf("plan has %d steps, max %d", len(steps), maxSteps)
}
plan := &SquadPlan{
Squad: SquadConfig{SquadName: truncate(input.Goal.Text, 40), Version: "1", Agents: agents},
@@ -207,7 +227,8 @@ func (p *LLMPlanner) materialize(input PlanInput, parsed *llmPlanSchema) (*PlanO
func (p *LLMPlanner) buildPrompt(input PlanInput) string {
var b strings.Builder
b.WriteString("## 用户目标\n")
b.WriteString(input.Goal.Text + "\n")
b.WriteString(segmentGoal(input.Goal.Text))
b.WriteString("\n")
if len(input.Goal.Constraints) > 0 {
b.WriteString("\n## 约束\n")
for k, v := range input.Goal.Constraints {
@@ -239,35 +260,41 @@ func (p *LLMPlanner) buildPrompt(input PlanInput) string {
return b.String()
}
const goalSegmentRunes = 2000
// segmentGoal 把长目标按段标出,避免整段原文无界注入提示词。
func segmentGoal(text string) string {
runes := []rune(text)
if len(runes) <= goalSegmentRunes {
return text + "\n"
}
var b strings.Builder
for i := 0; i < len(runes); i += goalSegmentRunes {
end := i + goalSegmentRunes
if end > len(runes) {
end = len(runes)
}
fmt.Fprintf(&b, "\n### 目标第 %d 段\n%s\n", i/goalSegmentRunes+1, string(runes[i:end]))
}
return b.String()
}
// complete 调一次模型并拼接完整文本。
func (p *LLMPlanner) complete(ctx context.Context, system, user string) (string, error) {
msgs := []llm.Message{
{Role: llm.RoleSystem, Content: system},
{Role: llm.RoleUser, Content: user},
if p.Client == nil {
return "", fmt.Errorf("llm planner: no model client")
}
opts := llm.StreamOptions{MaxTokens: p.MaxTokens, APIBase: p.APIBase, APIKey: p.APIKey}
var sb strings.Builder
for ev := range p.Client.Stream(ctx, p.Model, msgs, nil, opts) {
if ev.Kind == llm.KindError {
return "", fmt.Errorf("%s", ev.ErrorMessage)
}
if ev.Kind == llm.KindTextDelta {
sb.WriteString(ev.Text)
}
if ev.Kind == llm.KindMessageEnd {
if ev.Message.IsError {
return "", fmt.Errorf("assistant error: %s", ev.Message.Content)
}
if sb.Len() == 0 {
sb.WriteString(ev.Message.Content)
}
break
}
text, err := p.Client.Complete(ctx, ModelTurn{
Model: p.Model, System: system, User: user,
MaxTokens: p.MaxTokens, APIBase: p.APIBase, APIKey: p.APIKey,
})
if err != nil {
return "", err
}
if ctx.Err() != nil {
return "", ctx.Err()
}
return sb.String(), nil
return text, nil
}
// parseLLMPlan 从模型输出稳健解析 JSON(剥离 think 块/代码围栏/前后杂文)。
+156
View File
@@ -0,0 +1,156 @@
package squad
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// Executor 是小队核心对一次智能体执行的端口。实现放在适配器模块。
type Executor interface {
Name() string
Execute(ctx context.Context, req ExecRequest) (*ExecResult, error)
}
// ModelClient 是规划器对一次模型补全的端口。实现放在适配器模块。
type ModelClient interface {
Complete(ctx context.Context, turn ModelTurn) (string, error)
}
// ModelTurn 是一次规划补全的输入。适配器负责把它变成供应商协议。
type ModelTurn struct {
Model string
System string
User string
MaxTokens int
APIBase string
APIKey string
}
const (
ToolProfileWorkspace = "workspace"
ToolProfileReasoning = "reasoning"
)
// ExecRequest 是一次步骤执行的输入。字段与 native exec.Request 的 JSON 对齐,类型不共享。
type ExecRequest struct {
WorkDir string `json:"work_dir"`
Prompt string `json:"prompt"`
Instructions string `json:"instructions"`
Targets []string `json:"targets"`
Timeout time.Duration `json:"timeout"`
ToolProfile string `json:"tool_profile,omitempty"`
OnEvent func(ProcessEvent) `json:"-"`
Model string `json:"model,omitempty"`
ToolAllowlist []string `json:"tool_allowlist,omitempty"`
Skills []string `json:"skills,omitempty"`
Budget *ExecBudget `json:"budget,omitempty"`
SessionScope string `json:"session_scope,omitempty"`
ExecutionID string `json:"execution_id,omitempty"`
Env map[string]string `json:"env,omitempty"`
}
// ExecBudget 是一次步骤执行的共享预算。
type ExecBudget struct {
MaxPromptTokens int `json:"max_prompt_tokens,omitempty"`
MaxCompletionTokens int `json:"max_completion_tokens,omitempty"`
MaxToolCalls int `json:"max_tool_calls,omitempty"`
Deadline time.Time `json:"deadline,omitempty"`
}
// ProcessEvent 是过程帧。
type ProcessEvent struct {
Type string
Text string
Tool string
Detail string
PromptTokens int
CompletionTokens int
// 以下在 Type=usage 时填写(N3 WP6:上一请求与缓存命中拆分)。
LastPromptTokens int
LastCompletionTokens int
CacheReadTokens int
CacheWriteTokens int
}
// ExecResult 是一次步骤执行的终态。
type ExecResult struct {
Success bool `json:"success"`
Patches map[string][]byte `json:"patches"`
Logs string `json:"logs"`
ErrorReason string `json:"error_reason"`
Retryable bool `json:"retryable,omitempty"`
PromptTokens int `json:"prompt_tokens,omitempty"`
CompletionTokens int `json:"completion_tokens,omitempty"`
}
// StubExecutor 是离线桩,测试与未接模型时使用。
type StubExecutor struct{}
func (s *StubExecutor) Name() string { return "stub" }
func (s *StubExecutor) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
patches := make(map[string][]byte)
for _, target := range req.Targets {
outPath := filepath.Join(req.WorkDir, target)
_ = os.MkdirAll(filepath.Dir(outPath), 0o755)
var content []byte
if strings.HasSuffix(target, ".json") {
if existing, err := os.ReadFile(outPath); err == nil {
var dummy any
if json.Unmarshal(existing, &dummy) == nil {
content = existing
}
}
if len(content) == 0 {
content = []byte(`{"status":"mock_ok"}`)
}
} else {
content = []byte(fmt.Sprintf("# 模拟生成结果\n\n- 目标文件: %s\n- 提示词摘要: %s\n", target, req.Prompt))
}
_ = os.WriteFile(outPath, content, 0o644)
patches[target] = content
}
return &ExecResult{Success: true, Patches: patches, Logs: "Stub agent execution completed."}, nil
}
// LimitingExecutor 用有界信号量限制底层执行并发。
type LimitingExecutor struct {
inner Executor
sem chan struct{}
}
// WrapWithConcurrencyLimit 包装执行器;max<=0 时默认 8。
func WrapWithConcurrencyLimit(inner Executor, max int) Executor {
if inner == nil {
return nil
}
if max <= 0 {
max = 8
}
return &LimitingExecutor{inner: inner, sem: make(chan struct{}, max)}
}
func (l *LimitingExecutor) Name() string {
if l.inner == nil {
return "limiting"
}
return l.inner.Name()
}
func (l *LimitingExecutor) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
select {
case l.sem <- struct{}{}:
defer func() { <-l.sem }()
case <-ctx.Done():
return &ExecResult{
Success: false,
ErrorReason: fmt.Sprintf("agent concurrency wait canceled: %v", ctx.Err()),
}, ctx.Err()
}
return l.inner.Execute(ctx, req)
}
+12 -1
View File
@@ -99,12 +99,23 @@ func (s *FanoutSink) Subscribe(cursor int) (<-chan Event, int, func()) {
copy(replay, s.buffer[cursor:])
next := len(s.buffer)
s.mu.Unlock()
done := make(chan struct{})
var once sync.Once
var replayDone sync.WaitGroup
replayDone.Add(1)
go func() {
defer replayDone.Done()
for _, ev := range replay {
ch <- ev
select {
case <-done:
return
case ch <- ev:
}
}
}()
return ch, next, func() {
once.Do(func() { close(done) })
replayDone.Wait()
s.mu.Lock()
delete(s.subs, id)
s.mu.Unlock()
+92
View File
@@ -0,0 +1,92 @@
package squad
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestR2ExpiredDecisionUsesCancel(t *testing.T) {
created := time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC)
decision, due := expiredDecision(HumanRequest{ID: "hr_1", CreatedAt: created, Status: "pending"},
HitlPolicy{TimeoutAfter: time.Minute, ExpireAction: "cancel"}, created.Add(2*time.Minute))
if !due || decision.Choice != "cancel" || decision.RequestID != "hr_1" {
t.Fatalf("decision %+v due %v", decision, due)
}
if _, early := expiredDecision(HumanRequest{ID: "hr_1", CreatedAt: created},
HitlPolicy{TimeoutAfter: time.Minute}, created.Add(30*time.Second)); early {
t.Fatal("not due yet")
}
}
func TestR2IntentStepSkipsRoleRegistry(t *testing.T) {
p := &LLMPlanner{MaxSteps: 4}
out, err := p.materialize(PlanInput{Goal: Goal{Text: "查进度"}, Registry: NewRoleRegistry()}, &llmPlanSchema{
Steps: []llmStep{{IntentID: "demo.run.status_query", Title: "查"}},
})
if err != nil {
t.Fatal(err)
}
if out.Plan.Workflow.Steps[0].IntentID != "demo.run.status_query" || out.Plan.Workflow.Steps[0].Role != "intent" {
t.Fatalf("step %+v", out.Plan.Workflow.Steps[0])
}
}
func TestR2PlannerFallsBackAndCapsSteps(t *testing.T) {
fallback := NewRulePlanner()
p := &LLMPlanner{Fallback: fallback}
out, err := p.Plan(context.Background(), PlanInput{Goal: Goal{Text: "写一份说明"}, Registry: NewRoleRegistry()})
if err != nil || out == nil || (out.Plan == nil && out.Clarification == nil) {
t.Fatalf("fallback: %+v %v", out, err)
}
tooMany := &llmPlanSchema{}
for i := 0; i < 3; i++ {
tooMany.Steps = append(tooMany.Steps, llmStep{IntentID: "demo.x", Title: "t"})
}
if _, err := (&LLMPlanner{MaxSteps: 2}).materialize(PlanInput{Goal: Goal{Text: "x"}, Registry: NewRoleRegistry()}, tooMany); err == nil || !strings.Contains(err.Error(), "max 2") {
t.Fatalf("cap: %v", err)
}
}
func TestR2GoalIsSegmented(t *testing.T) {
text := strings.Repeat("标", goalSegmentRunes+1)
got := segmentGoal(text)
if !strings.Contains(got, "目标第 2 段") {
t.Fatalf("segments missing: %s", got[:80])
}
}
func TestR2TaskFileUsesLatestBlackboard(t *testing.T) {
dir := t.TempDir()
ws := &WorkspaceEngine{}
step := &StepSpec{ID: "s", Role: "writer", Title: "写", Targets: []string{"output.md"}}
role := RoleSpec{Role: "writer", Name: "写手"}
bb := &BlackboardState{Fields: map[string]string{"fact": "old"}}
if err := ws.writeTaskFile(dir, step, role, bb); err != nil {
t.Fatal(err)
}
bb.Fields["fact"] = "new"
if err := ws.writeTaskFile(dir, step, role, bb); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(filepath.Join(dir, "TASK.md"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(b), "fact = new") || strings.Contains(string(b), "fact = old") {
t.Fatalf("task file: %s", b)
}
}
func TestR2FanoutCancelDoesNotPanic(t *testing.T) {
sink := NewFanoutSink(8)
ch, _, cancel := sink.Subscribe(0)
cancel()
sink.Emit(Event{Type: "phase", Text: "later"})
if _, ok := <-ch; ok {
t.Fatal("channel should be closed")
}
}
+407
View File
@@ -0,0 +1,407 @@
package squad
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
func approvalScript() map[string]map[string]string {
return map[string]map[string]string{
"01_researcher": {"notes.md": "n", "fields.json": `{}`},
"02_writer": {"output.md": goodReport},
"03_reviewer": {"output.md": goodReport},
}
}
func TestStepApprovalApproveExecutes(t *testing.T) {
steps := baseSteps()
steps[0].RequiresApproval = true
eng, _ := newTestEngine(t, mockScriptPlanner(steps...), approvalScript(), nil)
res, err := eng.Run(context.Background(), Goal{Text: "需要审批"}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != RunAwaitingConfirmation || res.Pending == nil || res.Pending.Reason != "step_approval" {
t.Fatalf("want step approval hold, got %s %+v", res.Status, res.Pending)
}
res, err = eng.Respond(context.Background(), res.RunID, RespondDecision{Choice: "approve"})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("approve should execute, got %s %s", res.Status, res.Error)
}
}
func TestStepApprovalSkipDoesNotCancel(t *testing.T) {
steps := baseSteps()
steps[0].RequiresApproval = true
eng, _ := newTestEngine(t, mockScriptPlanner(steps...), approvalScript(), nil)
res, err := eng.Run(context.Background(), Goal{Text: "跳过一步"}, RunOptions{})
if err != nil {
t.Fatal(err)
}
res, err = eng.Respond(context.Background(), res.RunID, RespondDecision{Choice: "skip"})
if err != nil {
t.Fatal(err)
}
if res.Status == RunCancelled || res.Status == RunFailed {
t.Fatalf("skip must not cancel the run, got %s %s", res.Status, res.Error)
}
if res.Plan == nil || len(res.Plan.Steps) == 0 || res.Plan.Steps[0].Status != "skipped" {
t.Fatalf("first step should be skipped: %+v", res.Plan)
}
}
func TestApprovalSurvivesRestart(t *testing.T) {
steps := baseSteps()
steps[0].RequiresApproval = true
eng, dir := newTestEngine(t, mockScriptPlanner(steps...), approvalScript(), nil)
res, err := eng.Run(context.Background(), Goal{Text: "授权要留下"}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if _, err := eng.Respond(context.Background(), res.RunID, RespondDecision{Choice: "approve"}); err != nil {
t.Fatal(err)
}
st, err := loadRunState(res.WorkDir)
if err != nil {
t.Fatal(err)
}
if !st.Approved["step:s1"] {
t.Fatalf("approval not stored: %+v", st.Approved)
}
st.Meta.Status = string(RunExecuting)
st.Meta.CompletedAt = nil
wf := st.Workflow()
wf.Status = "running"
wf.Steps[0].Status = "pending"
st.setWorkflow(wf)
if err := st.save(); err != nil {
t.Fatal(err)
}
eng2, err := NewEngine(Options{
DataDir: dir, Registry: eng.Registry(), Planner: mockScriptPlanner(steps...),
Backends: eng.opts.Backends, Sink: NopSink{},
})
if err != nil {
t.Fatal(err)
}
res, err = eng2.Resume(context.Background(), res.RunID)
if err != nil {
t.Fatal(err)
}
if res.Status == RunAwaitingConfirmation {
t.Fatal("restart asked for approval again")
}
if res.Status != RunCompleted {
t.Fatalf("resume after restart: %s %s", res.Status, res.Error)
}
}
type countBackend struct {
mu sync.Mutex
n int
}
func (b *countBackend) Name() string { return "script" }
func (b *countBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
b.mu.Lock()
b.n++
b.mu.Unlock()
return (&scriptBackend{script: approvalScript()}).Execute(ctx, req)
}
func (b *countBackend) calls() int {
b.mu.Lock()
defer b.mu.Unlock()
return b.n
}
func TestInterruptedRunningStepStaysHeld(t *testing.T) {
backend := &countBackend{}
dir := t.TempDir()
reg := NewRoleRegistry()
for _, role := range []string{"researcher", "writer", "reviewer"} {
if err := reg.Register(RoleSpec{Role: role, Backend: "script"}); err != nil {
t.Fatal(err)
}
}
backends := NewBackendResolver()
backends.RegisterInstance("script", backend)
eng, err := NewEngine(Options{
DataDir: dir, Registry: reg, Planner: mockScriptPlanner(baseSteps()...),
Backends: backends, Sink: NopSink{},
})
if err != nil {
t.Fatal(err)
}
res, err := eng.Run(context.Background(), Goal{Text: "先跑完再假装中断"}, RunOptions{})
if err != nil || res.Status != RunCompleted {
t.Fatalf("setup run: %v %+v", err, res)
}
before := backend.calls()
st, err := loadRunState(res.WorkDir)
if err != nil {
t.Fatal(err)
}
st.Meta.Status = string(RunExecuting)
st.Meta.CompletedAt = nil
wf := st.Workflow()
wf.Status = "running"
wf.Steps[0].Status = "running"
wf.Steps[0].Attempts = 1
st.setWorkflow(wf)
if err := st.save(); err != nil {
t.Fatal(err)
}
eng2, err := NewEngine(Options{DataDir: dir, Registry: reg, Planner: eng.opts.Planner, Backends: backends, Sink: NopSink{}})
if err != nil {
t.Fatal(err)
}
res, err = eng2.Resume(context.Background(), res.RunID)
if err != nil {
t.Fatal(err)
}
if res.Status != RunAwaitingConfirmation || res.Pending == nil || res.Pending.Reason != "interrupted" {
t.Fatalf("want interrupted hold, got %s %+v", res.Status, res.Pending)
}
if backend.calls() != before {
t.Fatalf("interrupted step executed again: calls %d -> %d", before, backend.calls())
}
}
type blockBackend struct {
started chan struct{}
release chan struct{}
once sync.Once
}
func (b *blockBackend) Name() string { return "script" }
func (b *blockBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
b.once.Do(func() { close(b.started) })
select {
case <-b.release:
case <-ctx.Done():
return nil, ctx.Err()
}
return (&scriptBackend{script: approvalScript()}).Execute(ctx, req)
}
func TestTwoEnginesOneRunIsBusy(t *testing.T) {
backend := &blockBackend{started: make(chan struct{}), release: make(chan struct{})}
dir := t.TempDir()
reg := NewRoleRegistry()
for _, role := range []string{"researcher", "writer", "reviewer"} {
_ = reg.Register(RoleSpec{Role: role, Backend: "script"})
}
backends := NewBackendResolver()
backends.RegisterInstance("script", backend)
mk := func() *SquadEngine {
t.Helper()
eng, err := NewEngine(Options{
DataDir: dir, Registry: reg, Planner: mockScriptPlanner(baseSteps()...),
Backends: backends, Sink: NopSink{},
})
if err != nil {
t.Fatal(err)
}
return eng
}
engA := mk()
done := make(chan struct{})
go func() {
defer close(done)
_, _ = engA.Run(context.Background(), Goal{Text: "占住锁"}, RunOptions{})
}()
select {
case <-backend.started:
case <-time.After(3 * time.Second):
t.Fatal("run did not start")
}
runs, err := engA.ListRuns()
if err != nil || len(runs) != 1 {
t.Fatalf("runs: %v %+v", err, runs)
}
engB := mk()
ctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond)
defer cancel()
_, err = engB.Resume(ctx, runs[0].ID)
if !errors.Is(err, ErrRunBusy) {
t.Fatalf("want busy, got %v", err)
}
close(backend.release)
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("holder did not finish")
}
}
func TestWakeupDoesNotApprovePermission(t *testing.T) {
var kinds []string
sink := ProgressFunc(func(ev Event) { kinds = append(kinds, ev.Type) })
steps := gapSteps()
script := map[string]map[string]string{
"01_researcher": {"notes.md": "n", "fields.json": `{}`, "gaps.json": `[{"item":"等字段","blocking":true,"wait_field":"ext"}]`},
"02_writer": {"output.md": goodReport},
"03_reviewer": {"output.md": goodReport},
}
eng, dir := newTestEngine(t, mockScriptPlanner(steps...), script, sink)
res, err := eng.Run(context.Background(), Goal{Text: "权限不能被唤醒放行"}, RunOptions{})
if err != nil || res.Status != RunWaitingHuman {
t.Fatalf("setup: %v %+v", err, res)
}
gate, err := loadGate(filepath.Join(dir, "runs", res.RunID))
if err != nil || gate == nil {
t.Fatal(err)
}
gate.Request.Kind = HitlPermission
gate.Request.WaitCondition = &WaitCondition{Type: "field_set", Field: "ext"}
if err := writeJSON(filepath.Join(dir, "runs", res.RunID, "pending.json"), gate); err != nil {
t.Fatal(err)
}
if err := eng.SetBlackboardField(res.RunID, "ext", "ready"); err != nil {
t.Fatal(err)
}
if n := eng.SweepWakeups(context.Background()); n != 0 {
t.Fatalf("permission wakeup must not count as resolved, got %d", n)
}
insp, err := eng.Inspect(res.RunID)
if err != nil {
t.Fatal(err)
}
if insp.Status != RunWaitingHuman || insp.Pending == nil || insp.Pending.Kind != HitlPermission {
t.Fatalf("permission hold changed: %s %+v", insp.Status, insp.Pending)
}
saw := false
for _, k := range kinds {
if k == "wakeup_held" {
saw = true
}
}
if !saw {
t.Fatalf("expected wakeup_held event, got %v", kinds)
}
}
func TestParallelGroupSecondStepApprovalHolds(t *testing.T) {
steps := baseSteps()
steps[0].Parallel = true
steps[1].Parallel = true
steps[1].RequiresApproval = true
eng, _ := newTestEngine(t, mockScriptPlanner(steps...), approvalScript(), nil)
res, err := eng.Run(context.Background(), Goal{Text: "并行组第二步要审批"}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != RunAwaitingConfirmation || res.Pending == nil || res.Pending.StepID != "s2" {
t.Fatalf("group should hold on the second step, got %s %+v", res.Status, res.Pending)
}
if _, err := os.Stat(filepath.Join(res.WorkDir, "tasks", "01_researcher", "notes.md")); !os.IsNotExist(err) {
t.Fatalf("parallel group ran before approval: %v", err)
}
}
func TestParallelGroupSecondStepPermissionHolds(t *testing.T) {
dir := t.TempDir()
reg := NewRoleRegistry()
for _, spec := range []RoleSpec{
{Role: "researcher", Name: "调研", Backend: "script"},
{Role: "writer", Name: "撰写", Backend: "script", BashPermission: BashPermissionAsk},
{Role: "reviewer", Name: "审核", Backend: "script"},
} {
if err := reg.Register(spec); err != nil {
t.Fatal(err)
}
}
backends := NewBackendResolver()
backends.RegisterInstance("script", &scriptBackend{script: approvalScript()})
steps := baseSteps()
steps[0].Parallel = true
steps[1].Parallel = true
eng, err := NewEngine(Options{
DataDir: dir, Registry: reg, Planner: mockScriptPlanner(steps...), Backends: backends, Sink: NopSink{},
})
if err != nil {
t.Fatal(err)
}
var res *RunResult
res, err = eng.Run(context.Background(), Goal{Text: "并行组第二步要授权"}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != RunWaitingHuman || res.Pending == nil || res.Pending.Kind != HitlPermission || res.Pending.StepID != "s2" {
t.Fatalf("group should hold on writer permission, got %s %+v", res.Status, res.Pending)
}
if _, err := os.Stat(filepath.Join(res.WorkDir, "tasks", "01_researcher", "notes.md")); !os.IsNotExist(err) {
t.Fatalf("parallel group ran before permission: %v", err)
}
}
func TestFreeTextKeepsChoiceHold(t *testing.T) {
for _, kind := range []HumanRequestKind{HitlConfirmation, HitlPermission, HitlQualityReview} {
t.Run(string(kind), func(t *testing.T) {
steps := baseSteps()
steps[0].RequiresApproval = true
eng, dir := newTestEngine(t, mockScriptPlanner(steps...), approvalScript(), nil)
res, err := eng.Run(context.Background(), Goal{Text: "自由文本不能取消"}, RunOptions{})
if err != nil || res.Pending == nil {
t.Fatalf("setup: %v %+v", err, res)
}
work := filepath.Join(dir, "runs", res.RunID)
gate, err := loadGate(work)
if err != nil || gate == nil {
t.Fatal(err)
}
id := gate.Request.ID
gate.Request.Kind = kind
if err := writeJSON(filepath.Join(work, "pending.json"), gate); err != nil {
t.Fatal(err)
}
res, err = eng.Respond(context.Background(), res.RunID, RespondDecision{Answer: "补充一句"})
if err != nil {
t.Fatal(err)
}
if res.Pending == nil || res.Pending.ID != id || res.Pending.Status != "pending" {
t.Fatalf("hold was cleared: %+v", res.Pending)
}
data, err := os.ReadFile(filepath.Join(work, "steering.json"))
if err != nil || !strings.Contains(string(data), "补充一句") {
t.Fatalf("steering not recorded: %v %s", err, data)
}
})
}
}
func TestFailedDecisionRestoresPending(t *testing.T) {
steps := gapSteps()
script := map[string]map[string]string{
"01_researcher": {"notes.md": "n", "fields.json": `{}`, "gaps.json": `[{"item":"等字段","blocking":true,"wait_field":"ext"}]`},
"02_writer": {"output.md": goodReport},
"03_reviewer": {"output.md": goodReport},
}
eng, _ := newTestEngine(t, mockScriptPlanner(steps...), script, nil)
res, err := eng.Run(context.Background(), Goal{Text: "错误选择后仍可回应"}, RunOptions{})
if err != nil || res.Pending == nil {
t.Fatalf("setup: %v %+v", err, res)
}
if _, err := eng.Respond(context.Background(), res.RunID, RespondDecision{Choice: "nope"}); err == nil {
t.Fatal("unknown choice should fail")
}
res, err = eng.Respond(context.Background(), res.RunID, RespondDecision{Choice: "resolved"})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("restored hold should still be answerable, got %s %s", res.Status, res.Error)
}
}
+6 -4
View File
@@ -13,7 +13,7 @@ type RoleRegistry struct {
mu sync.RWMutex
roles map[string]RoleSpec
// OnChange 可选钩子:角色注册/派生后回调(store 层用于落盘)。
OnChange func(spec RoleSpec)
OnChange func(spec RoleSpec) error
}
// NewRoleRegistry 创建空注册表。
@@ -23,8 +23,8 @@ func NewRoleRegistry() *RoleRegistry {
// Register 注册或更新角色;Role 为空或 BashPermission 非法时返回错误。
func (r *RoleRegistry) Register(spec RoleSpec) error {
if strings.TrimSpace(spec.Role) == "" {
return fmt.Errorf("role key cannot be empty")
if !ValidID(spec.Role) {
return invalidID(spec.Role)
}
switch spec.BashPermission {
case "", "deny", "ask", "allow":
@@ -44,7 +44,9 @@ func (r *RoleRegistry) Register(spec RoleSpec) error {
r.roles[spec.Role] = spec
r.mu.Unlock()
if r.OnChange != nil {
r.OnChange(spec)
if err := r.OnChange(spec); err != nil {
return err
}
}
return nil
}
+3 -49
View File
@@ -8,22 +8,18 @@ import (
"sync"
"testing"
"time"
"agent-native/exec"
"agent-native/runtime/llm"
"agent-native/runtime/tools"
)
// captureBackend 记录收到的请求(v2 字段贯通断言用)。
type captureBackend struct {
mu sync.Mutex
reqs []exec.Request
reqs []ExecRequest
script map[string]string // 子目录名 → 单一目标内容
}
func (b *captureBackend) Name() string { return "capture" }
func (b *captureBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
func (b *captureBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
b.mu.Lock()
b.reqs = append(b.reqs, req)
b.mu.Unlock()
@@ -39,7 +35,7 @@ func (b *captureBackend) Execute(ctx context.Context, req exec.Request) (*exec.R
_ = os.WriteFile(out, []byte(content), 0o644)
patches[t] = []byte(content)
}
return &exec.Result{Success: true, Patches: patches}, nil
return &ExecResult{Success: true, Patches: patches}, nil
}
func TestStepRequestCarriesRoleSpecV2Fields(t *testing.T) {
@@ -88,48 +84,6 @@ func TestStepRequestCarriesRoleSpecV2Fields(t *testing.T) {
}
}
func TestBuildAgentNativeOptionsBashPermissionGating(t *testing.T) {
base := AgentNativeConfig{
Client: &llm.Client{BaseURL: "http://x", APIKey: "k"},
DefaultModel: "m1", MaxTurns: 5, Timeout: time.Minute,
Exec: &tools.ExecConfig{Bash: "/bin/bash"},
SkillsRoots: []string{"/skills"},
}
deny, err := buildAgentNativeOptions(base, RoleSpec{Role: "r", BashPermission: BashPermissionDeny})
if err != nil {
t.Fatal(err)
}
if deny.Exec != nil {
t.Fatal("deny role must not get exec tools even when config provides them")
}
ask, _ := buildAgentNativeOptions(base, RoleSpec{Role: "r", BashPermission: BashPermissionAsk})
if ask.Exec == nil {
t.Fatal("ask role gets exec tools (the HITL gate runs before the step)")
}
allow, _ := buildAgentNativeOptions(base, RoleSpec{Role: "r", BashPermission: BashPermissionAllow})
if allow.Exec == nil {
t.Fatal("allow role gets exec tools")
}
// 空白名 = 注册时默认 deny,同样不装配。
empty, _ := buildAgentNativeOptions(base, RoleSpec{Role: "r"})
if empty.Exec != nil {
t.Fatal("unset bash_permission defaults to deny at registration; no exec tools")
}
// 无 Exec 配置时任何档位都不装配。
none, _ := buildAgentNativeOptions(AgentNativeConfig{Client: base.Client, DefaultModel: "m"}, RoleSpec{Role: "r", BashPermission: BashPermissionAllow})
if none.Exec != nil {
t.Fatal("no Exec config = no exec tools regardless of permission")
}
// 模型解析:角色级覆盖默认。
roleModel, _ := buildAgentNativeOptions(base, RoleSpec{Role: "r", Model: "big"})
if roleModel.Model != "big" {
t.Fatalf("role model must win, got %s", roleModel.Model)
}
if _, err := buildAgentNativeOptions(AgentNativeConfig{Client: base.Client}, RoleSpec{Role: "r"}); err == nil {
t.Fatal("missing model must fail-closed")
}
}
func TestDeriveInheritsExecutionConfig(t *testing.T) {
reg := NewRoleRegistry()
base := RoleSpec{
+366
View File
@@ -0,0 +1,366 @@
package squad
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// review.go — review 拓扑驱动(Q2 WP3,验收二)。
// 与 backend internal/runtime/review.go 的 runReview 等价:
// 并行 N finder(semaphore 3)→ 宽松 JSON 解析(首 `[` 末 `]`,丢空 issue)
// → 按 (location,issue) 去重、上限 10 → 每条独立 verifier(尾行
// VERDICT: PASS/FAIL/PARTIAL)→ 只留 PASS(fail-closed)→ severity 稳定排序
// → 报告恒带「已丢弃 N 条未验证」。解析/去重/判定/排序/文案与 backend 同一
// 格式串(计划 §一.5:以报告字节形态为等价判据)。
//
// squad 侧接线:finder = 计划步骤(各自的 findings.json 为目标产物,经
// runOneStep 走完整工作区/事件/HITL 机制);verifier = 驱动内按 verify 角色
// 直接 Execute 的核实调用(对应 backend 的 spawn(verifyProfileID,…)),
// 每次核实落盘 reviews/verify_NN/ 留痕。
func init() {
_ = RegisterTopologyDriver(TopologyReview)
}
// reviewFinding 与 backend reviewFinding 同构(JSON 键一致)。
type reviewFinding struct {
Location string `json:"location"`
Severity string `json:"severity"`
Issue string `json:"issue"`
Evidence string `json:"evidence"`
}
// reviewVerbatimTask 是核实调用的任务文案(与 backend runReview 同串)。
const reviewVerbatimTask = "独立核实这条问题是否成立。材料如下:\n%s\n\n问题:%s\n只在结尾给出一行 VERDICT: PASS、FAIL 或 PARTIAL。"
// parseFindings 宽松解析:首 `[` 末 `]`,丢空 issue(backend 同款)。
func parseFindings(text string) []reviewFinding {
start := strings.Index(text, "[")
end := strings.LastIndex(text, "]")
if start < 0 || end <= start {
return nil
}
var out []reviewFinding
if err := json.Unmarshal([]byte(text[start:end+1]), &out); err != nil {
return nil
}
var kept []reviewFinding
for _, f := range out {
if strings.TrimSpace(f.Issue) == "" {
continue
}
kept = append(kept, f)
}
return kept
}
// dedupeFindings 按 (location,issue) 去重,上限 limit(backend 同款)。
func dedupeFindings(groups [][]reviewFinding, limit int) []reviewFinding {
seen := map[string]bool{}
var out []reviewFinding
for _, group := range groups {
for _, f := range group {
key := strings.TrimSpace(f.Location) + "\n" + strings.TrimSpace(f.Issue)
if seen[key] {
continue
}
seen[key] = true
out = append(out, f)
if len(out) >= limit {
return out
}
}
}
return out
}
// reviewVerdict 尾行 VERDICT 判定(backend 同款:PASS/FAIL/PARTIAL,
// 解析不出 = 空串 = 不通过)。
func reviewVerdict(text string) string {
lines := strings.Split(text, "\n")
for i := len(lines) - 1; i >= 0; i-- {
line := strings.TrimSpace(lines[i])
if line == "" || !strings.HasPrefix(strings.ToUpper(line), "VERDICT:") {
continue
}
v := strings.TrimSpace(line[len("VERDICT:"):])
v = strings.ToUpper(v)
switch {
case strings.HasPrefix(v, "PASS"):
return "PASS"
case strings.HasPrefix(v, "FAIL"):
return "FAIL"
case strings.HasPrefix(v, "PARTIAL"):
return "PARTIAL"
default:
return ""
}
}
return ""
}
// severityRank 稳定排序键(backend 同款)。
func severityRank(s string) int {
switch strings.ToLower(strings.TrimSpace(s)) {
case "critical":
return 0
case "high":
return 1
case "medium":
return 2
case "low":
return 3
default:
return 4
}
}
// reviewParallel semaphore-3 并行(backend parallel 同款)。
func reviewParallel(n int, fn func(i int)) {
if n <= 0 {
return
}
sem := make(chan struct{}, 3)
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
fn(i)
}(i)
}
wg.Wait()
}
// runReviewDriver review 拓扑入口。finder 阶段复用计划步骤(可挂起可恢复:
// 重入时已完成步骤不重跑,findings.json 从各自子工作区读取)。
func (e *SquadEngine) runReviewDriver(ctx context.Context, st *RunState) (*RunResult, error) {
drv := &flowDriver{opts: e.deps}
steps := st.Plan.Workflow.Steps
// verify 角色先行解析(fail-fast:缺角色不浪费 finder 轮次)。
verifyRoleName := orDefault(st.Meta.Metadata["review_verify_role"], "verifier")
verifyRole, ok := findAgent(st.Plan.Squad.Agents, verifyRoleName)
if !ok {
if r, exists := e.deps.Registry.Get(verifyRoleName); exists {
verifyRole, ok = r, true
}
}
if !ok {
return e.driverFail(st, fmt.Sprintf("review: verify 角色 %q 不在小队内(fail-closed)", verifyRoleName)), nil
}
verifyBackend, _, rerr := e.deps.Backends.Resolve(verifyRole, st.Meta.Backend)
if rerr != nil {
return e.driverFail(st, "review: verify 后端解析失败: "+rerr.Error()), nil
}
// 1) finder 阶段:先全组过门(任一要挂起则整组不执行,与 advance 并行组
// 同款语义),再 semaphore-3 并行执行。
var finderIdx []int
for i := range steps {
if steps[i].Status == "pending" || steps[i].Status == "running" {
finderIdx = append(finderIdx, i)
}
}
for _, idx := range finderIdx {
out, gate, err := drv.gateBeforeRun(ctx, st, idx)
if err != nil {
return nil, err
}
if gate != nil {
if out == nil {
out = heldResult(st, gate)
}
return out, nil
}
if out != nil {
return out, nil
}
}
drv.emit(st, Event{Type: EvPhase, Text: fmt.Sprintf("review:%d 个 finder 并行找问题", len(finderIdx))})
var holdGate *HitlGate
var holdOut *RunResult
var fatal *RunResult
finderFailed := 0
var fmu sync.Mutex
reviewParallel(len(finderIdx), func(k int) {
idx := finderIdx[k]
out, gate, err := drv.runOneStep(ctx, st, idx)
fmu.Lock()
defer fmu.Unlock()
if err != nil {
if fatal == nil {
fatal = &RunResult{RunID: st.Meta.ID, Status: RunFailed, Plan: planPtr(st),
Error: err.Error(), WorkDir: st.Meta.WorkDir}
}
return
}
if gate != nil && holdGate == nil {
holdGate = gate
holdOut = out
return
}
if out != nil && out.Status == RunFailed && fatal == nil {
fatal = out // 关键 finder 失败:fail-closed 传播
return
}
if st.Plan.Workflow.Steps[idx].Status == "failed" {
finderFailed++ // 非关键 finder 失败:宽容(backend spawn err 同款),收口降级
}
})
if fatal != nil {
return fatal, nil
}
if holdGate != nil {
if holdOut == nil {
holdOut = heldResult(st, holdGate)
}
return holdOut, nil
}
// 2) 收集各 finder 的 findings.json(宽松解析;缺文件/坏 JSON = 该 finder
// 零发现,与 backend 同款)。
steps = st.Plan.Workflow.Steps
groups := make([][]reviewFinding, 0, len(steps))
for i := range steps {
if steps[i].Status != "completed" {
continue
}
subDir := filepath.Join(st.Meta.WorkDir, filepath.FromSlash(steps[i].SubWorkspace))
data, err := os.ReadFile(filepath.Join(subDir, "findings.json"))
if err != nil {
continue
}
groups = append(groups, parseFindings(string(data)))
}
unique := dedupeFindings(groups, 10)
drv.emit(st, Event{Type: EvPhase, Text: fmt.Sprintf("review:去重后 %d 条,逐条独立核实", len(unique))})
// 3) 逐条 verify(semaphore-3 并行;fail-closed:核实调用失败/无判定 = 丢弃)。
target := reviewTargetMaterial(st)
kept := make([]reviewFinding, len(unique))
pass := make([]bool, len(unique))
reviewParallel(len(unique), func(i int) {
f := unique[i]
body, _ := json.Marshal(f)
task := fmt.Sprintf(reviewVerbatimTask, target, body)
vDir := filepath.Join(st.Meta.WorkDir, "reviews", fmt.Sprintf("verify_%02d", i+1))
if err := os.MkdirAll(vDir, 0o755); err != nil {
return
}
instructions := ""
if e.deps.Workspaces != nil {
instructions = e.deps.Workspaces.ResolveInstructions(verifyRole)
}
res, err := verifyBackend.Execute(ctx, ExecRequest{
WorkDir: vDir,
Prompt: task,
Instructions: instructions,
Targets: []string{"verdict.md"},
Timeout: e.deps.StepTimeout,
SessionScope: st.Meta.ID + "/verify_" + fmt.Sprintf("%02d", i+1),
})
if err != nil || res == nil {
return
}
text := string(res.Patches["verdict.md"]) + "\n" + res.Logs
if data, rerr2 := os.ReadFile(filepath.Join(vDir, "verdict.md")); rerr2 == nil {
text = string(data) + "\n" + text
}
if reviewVerdict(text) == "PASS" {
kept[i] = f
pass[i] = true
}
})
var verified []reviewFinding
for i, ok := range pass {
if ok {
verified = append(verified, kept[i])
}
}
// 4) severity 稳定排序 + 等价报告(backend 同格式串)。
sort.SliceStable(verified, func(i, j int) bool {
return severityRank(verified[i].Severity) < severityRank(verified[j].Severity)
})
dropped := len(unique) - len(verified)
var b strings.Builder
if len(verified) == 0 {
b.WriteString("没有验证通过的问题。\n")
}
for _, f := range verified {
fmt.Fprintf(&b, "- [%s] %s:%s\n 证据:%s\n",
orDefault(f.Severity, "medium"), orDefault(f.Location, "未定位"), f.Issue, f.Evidence)
}
fmt.Fprintf(&b, "已丢弃 %d 条未验证\n", dropped)
report := b.String()
// 5) 收口:报告落盘(reviews/report.md + output.md 交付物约定),
// finder 有失败则降级(诚实信号),否则完成。
reviewsDir := filepath.Join(st.Meta.WorkDir, "reviews")
_ = os.MkdirAll(reviewsDir, 0o755)
_ = os.WriteFile(filepath.Join(reviewsDir, "report.md"), []byte(report), 0o644)
_ = os.WriteFile(filepath.Join(st.Meta.WorkDir, "output.md"), []byte(report), 0o644)
st.mu.Lock()
if st.BB.Fields == nil {
st.BB.Fields = map[string]string{}
}
st.BB.Fields["review_verified"] = fmt.Sprintf("%d", len(verified))
st.BB.Fields["review_dropped"] = fmt.Sprintf("%d", dropped)
wf := st.Workflow()
status := RunCompleted
if finderFailed > 0 {
status = RunDegraded
}
wf.Status = string(status)
st.setWorkflow(wf)
st.Meta.Status = string(status)
now := time.Now()
st.Meta.CompletedAt = &now
_ = st.save()
st.mu.Unlock()
summary := fmt.Sprintf("review:保留 %d 条 / 丢弃 %d 条未验证", len(verified), dropped)
if finderFailed > 0 {
summary += fmt.Sprintf("(%d 个 finder 失败,降级)", finderFailed)
}
drv.emit(st, Event{Type: EvPhase, Text: summary})
return &RunResult{RunID: st.Meta.ID, Status: status, Plan: planPtr(st),
Deliverable: "output.md", Summary: summary, WorkDir: st.Meta.WorkDir}, nil
}
// reviewTargetMaterial 取被评审材料:元数据 review_target 优先,
// 其次运行目标(context/goal.md)。
func reviewTargetMaterial(st *RunState) string {
if v := strings.TrimSpace(st.Meta.Metadata["review_target"]); v != "" {
return v
}
if data, err := os.ReadFile(filepath.Join(st.Meta.WorkDir, "context", "goal.md")); err == nil {
return string(data)
}
return ""
}
// driverFail 驱动级 fail-closed 收口(与 leaderFail 同款语义)。
func (e *SquadEngine) driverFail(st *RunState, reason string) *RunResult {
now := time.Now()
st.mu.Lock()
st.Meta.Status = string(RunFailed)
st.Meta.CompletedAt = &now
wf := st.Workflow()
wf.Status = "failed"
st.setWorkflow(wf)
_ = st.save()
st.mu.Unlock()
(&flowDriver{opts: e.deps}).emit(st, Event{Type: EvPhase, Text: reason})
return &RunResult{RunID: st.Meta.ID, Status: RunFailed, Plan: planPtr(st),
Error: reason, WorkDir: st.Meta.WorkDir}
}
+2 -4
View File
@@ -5,8 +5,6 @@ import (
"os"
"path/filepath"
"testing"
"agent-native/exec"
)
type fixedRouter struct{ dec *RouteDecision }
@@ -89,14 +87,14 @@ type captureSkillsBackend struct{ got []string }
func (b *captureSkillsBackend) Name() string { return "capture-skills" }
func (b *captureSkillsBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
func (b *captureSkillsBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
b.got = append([]string{}, req.Skills...)
for _, target := range req.Targets {
path := filepath.Join(req.WorkDir, target)
_ = os.MkdirAll(filepath.Dir(path), 0o755)
_ = os.WriteFile(path, []byte(goodReport), 0o644)
}
return &exec.Result{Success: true, Patches: map[string][]byte{"output.md": []byte(goodReport)}}, nil
return &ExecResult{Success: true, Patches: map[string][]byte{"output.md": []byte(goodReport)}}, nil
}
func TestStepSkillsOverrideRoleSkills(t *testing.T) {
+92 -17
View File
@@ -10,9 +10,9 @@ import (
"time"
)
// runState 引擎内部运行状态(磁盘即权威:workspace.json / workflow.json /
// RunState 引擎内部运行状态(磁盘即权威:workspace.json / workflow.json /
// squad.json / blackboard.json / pending.json 全部落盘,进程重启可恢复)。
type runState struct {
type RunState struct {
Meta RunMetadata `json:"meta"`
Plan SquadPlan `json:"plan"`
BB BlackboardState `json:"bb"`
@@ -21,19 +21,41 @@ type runState struct {
Approved map[string]bool `json:"approved,omitempty"`
// RepairBudgetRemaining 质检失败后的自动重跑剩余次数。
RepairBudgetRemaining int `json:"repair_budget_remaining,omitempty"`
// RepairBudgetGranted 人工 retry 累计追加次数(Q26 上限判定用)。
RepairBudgetGranted int `json:"repair_budget_granted,omitempty"`
// Clarifications 已澄清问答(追加进 Goal 上下文)。
Clarifications []string `json:"clarifications,omitempty"`
// Steering 运行中人工补充指令(在下一步骤边界注入后清空)。
Steering []string `json:"steering,omitempty"`
// Mailbox 运行内点对点消息(Q2):随 RunState 持久化;只在步骤启动时
// 并入任务书,永不触碰 HITL 门。
Mailbox []MailMessage `json:"mailbox,omitempty"`
// Handoffs 所有权转移记录(Q2 handoff 原语):随 RunState 持久化;
// 目标角色的下一步骤一次性接收移交上下文,finalize 取链尾产出。
Handoffs []HandoffRecord `json:"handoffs,omitempty"`
// Board task_board 拓扑的队列状态(Q2):claim/lease/complete 三原语,
// 随 RunState 持久化;孤儿租约在驱动重入时再武装。
Board []BoardTask `json:"board,omitempty"`
// Usage 按角色累计的 token 用量。
Usage map[string]*Usage `json:"usage,omitempty"`
// Leader leader-delegation 模式的循环状态(workspace 内 leader.json 持久化)。
Leader *LeaderState `json:"-"`
// store 运行状态持久化端口(Q1 WP4);nil 回落 FileStore(现状文件态)。
// 替换实现(如 SQLiteStore)在引擎装配期注入。
store Store `json:"-"`
// mu 保护并行步骤对共享状态(黑板/save/steering/usage)的变更。
mu sync.Mutex `json:"-"`
}
func (st *runState) policy() HitlPolicy {
// storeOr 返回注入的 Store,缺省 FileStore。
func (st *RunState) storeOr() Store {
if st.store != nil {
return st.store
}
return FileStore{}
}
func (st *RunState) policy() HitlPolicy {
return mergeHitlPolicy(HitlPolicy{}, st.Meta.HitlPolicyOf())
}
@@ -49,8 +71,14 @@ func (m RunMetadata) HitlPolicyOf() *HitlPolicy {
return p
}
func (st *runState) save() error {
func (st *RunState) save() error {
st.Meta.UpdatedAt = time.Now()
return st.storeOr().SaveRun(st)
}
// persist 是 FileStore 语义的低层落盘(全部写文件)。Store.SaveRun 调它;
// RunState.save 走 Store 端口,替换实现(SQLiteStore)在装配期注入后自然接管。
func (st *RunState) persist() error {
if err := writeJSON(filepath.Join(st.Meta.WorkDir, "workspace.json"), &st.Meta); err != nil {
return err
}
@@ -68,17 +96,35 @@ func (st *runState) save() error {
return err
}
}
return nil
return writeJSON(filepath.Join(st.Meta.WorkDir, "runstate.json"), diskRunState{
Approved: st.Approved,
RepairBudgetRemaining: st.RepairBudgetRemaining,
RepairBudgetGranted: st.RepairBudgetGranted,
Clarifications: st.Clarifications,
Mailbox: st.Mailbox,
Handoffs: st.Handoffs,
Board: st.Board,
})
}
type diskRunState struct {
Approved map[string]bool `json:"approved,omitempty"`
RepairBudgetRemaining int `json:"repair_budget_remaining,omitempty"`
RepairBudgetGranted int `json:"repair_budget_granted,omitempty"`
Clarifications []string `json:"clarifications,omitempty"`
Mailbox []MailMessage `json:"mailbox,omitempty"`
Handoffs []HandoffRecord `json:"handoffs,omitempty"`
Board []BoardTask `json:"board,omitempty"`
}
// Workflow 返回计划的编排清单(简洁访问器)。
func (st *runState) Workflow() WorkflowPlan { return st.Plan.Workflow }
func (st *RunState) Workflow() WorkflowPlan { return st.Plan.Workflow }
// setWorkflow 写回编排清单。
func (st *runState) setWorkflow(wf WorkflowPlan) { st.Plan.Workflow = wf }
func (st *RunState) setWorkflow(wf WorkflowPlan) { st.Plan.Workflow = wf }
// saveGate 落盘 HITL 挂起上下文(pending.json)。
func (st *runState) saveGate(gate *hitlGate) error {
func (st *RunState) saveGate(gate *HitlGate) error {
if gate == nil {
err := os.Remove(filepath.Join(st.Meta.WorkDir, "pending.json"))
if os.IsNotExist(err) {
@@ -90,8 +136,8 @@ func (st *runState) saveGate(gate *hitlGate) error {
}
// loadRunState 从工作区恢复运行状态。
func loadRunState(wsDir string) (*runState, error) {
st := &runState{}
func loadRunState(wsDir string) (*RunState, error) {
st := &RunState{}
if err := readJSON(filepath.Join(wsDir, "workspace.json"), &st.Meta); err != nil {
return nil, fmt.Errorf("load workspace.json: %w", err)
}
@@ -112,6 +158,23 @@ func loadRunState(wsDir string) (*runState, error) {
if st.Approved == nil {
st.Approved = map[string]bool{}
}
if data, err := os.ReadFile(filepath.Join(wsDir, "runstate.json")); err == nil {
var disk diskRunState
if err := json.Unmarshal(data, &disk); err != nil {
return nil, fmt.Errorf("parse runstate.json: %w", err)
}
if disk.Approved != nil {
st.Approved = disk.Approved
}
st.RepairBudgetRemaining = disk.RepairBudgetRemaining
st.RepairBudgetGranted = disk.RepairBudgetGranted
st.Clarifications = disk.Clarifications
st.Mailbox = disk.Mailbox
st.Handoffs = disk.Handoffs
st.Board = disk.Board
} else if !os.IsNotExist(err) {
return nil, err
}
st.loadSteering()
if data, err := os.ReadFile(filepath.Join(wsDir, "usage.json")); err == nil {
_ = json.Unmarshal(data, &st.Usage)
@@ -120,7 +183,7 @@ func loadRunState(wsDir string) (*runState, error) {
}
// loadGate 恢复挂起上下文;无挂起返回 nil。
func loadGate(wsDir string) (*hitlGate, error) {
func loadGate(wsDir string) (*HitlGate, error) {
data, err := os.ReadFile(filepath.Join(wsDir, "pending.json"))
if err != nil {
if os.IsNotExist(err) {
@@ -128,7 +191,7 @@ func loadGate(wsDir string) (*hitlGate, error) {
}
return nil, err
}
gate := &hitlGate{}
gate := &HitlGate{}
if err := json.Unmarshal(data, gate); err != nil {
return nil, fmt.Errorf("parse pending.json: %w", err)
}
@@ -144,7 +207,7 @@ func readJSON(path string, v any) error {
}
// setStep 并行安全地回填步骤状态(与 save 的全量 marshal 互斥)。
func (st *runState) setStep(idx int, s StepSpec) {
func (st *RunState) setStep(idx int, s StepSpec) {
st.mu.Lock()
st.Plan.Workflow.Steps[idx] = s
st.mu.Unlock()
@@ -152,7 +215,7 @@ func (st *runState) setStep(idx int, s StepSpec) {
// injectSteering 把挂起的运行中人工补充指令追加到 prompt(下一步骤边界注入),
// 注入后清空并落盘。注入发生在步骤开始前,不会污染正在执行的步骤。
func (st *runState) injectSteering(prompt string) string {
func (st *RunState) injectSteering(prompt string) string {
st.mu.Lock()
defer st.mu.Unlock()
if len(st.Steering) == 0 {
@@ -170,8 +233,20 @@ func (st *runState) injectSteering(prompt string) string {
return b.String()
}
// addSteering 追加一条尚未注入的人工补充,并立刻落盘。
func (st *RunState) addSteering(text string) error {
text = strings.TrimSpace(text)
if text == "" {
return nil
}
st.mu.Lock()
defer st.mu.Unlock()
st.Steering = append(st.Steering, text)
return writeJSON(filepath.Join(st.Meta.WorkDir, "steering.json"), st.Steering)
}
// loadSteering 恢复未消费的补充指令。
func (st *runState) loadSteering() {
func (st *RunState) loadSteering() {
data, err := os.ReadFile(filepath.Join(st.Meta.WorkDir, "steering.json"))
if err != nil {
return
@@ -183,7 +258,7 @@ func (st *runState) loadSteering() {
}
// addUsage 累计角色用量。
func (st *runState) addUsage(role string, promptTokens, completionTokens int) {
func (st *RunState) addUsage(role string, promptTokens, completionTokens int) {
if promptTokens == 0 && completionTokens == 0 {
return
}
@@ -203,7 +278,7 @@ func (st *runState) addUsage(role string, promptTokens, completionTokens int) {
}
// usageSnapshot 输出用量快照(值拷贝)。
func (st *runState) usageSnapshot() map[string]Usage {
func (st *RunState) usageSnapshot() map[string]Usage {
if len(st.Usage) == 0 {
return nil
}
+212
View File
@@ -0,0 +1,212 @@
package squad
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
)
// Store 是运行状态持久化端口(Q1 WP4):run/gate/checkpoint 的读写以
// 运行工作区为键。现状文件态收编为 FileStore(行为与此前逐文件直写完全一致);
// SQLite 实现进 adapters/sqlite(WP5)。事件流仍归 store/eventsink(子包),
// 不在本端口内重复。
type Store interface {
SaveRun(st *RunState) error
LoadRun(wsDir string) (*RunState, error)
SaveGate(wsDir string, gate *HitlGate) error
LoadGate(wsDir string) (*HitlGate, error)
SaveCheckpoint(wsDir string, cp Checkpoint) error
LoadLatestCheckpoint(wsDir string) (*Checkpoint, error)
// SaveLeader/LoadLeader 持久化 leader 循环状态(raw JSON bytes;
// 文件态即 leader.json)。LoadLeader 不存在时返回 (nil, nil)。
SaveLeader(wsDir string, data []byte) error
LoadLeader(wsDir string) ([]byte, error)
}
// FileStore 把 runs/<id>/ 目录布局呈现为 Store(现状文件态)。
type FileStore struct{}
func (FileStore) SaveRun(st *RunState) error {
return st.persist()
}
func (FileStore) LoadRun(wsDir string) (*RunState, error) {
return loadRunState(wsDir)
}
func (FileStore) SaveGate(wsDir string, gate *HitlGate) error {
st := &RunState{}
st.Meta.WorkDir = wsDir
return st.saveGate(gate)
}
func (FileStore) LoadGate(wsDir string) (*HitlGate, error) {
return loadGate(wsDir)
}
func (FileStore) SaveLeader(wsDir string, data []byte) error {
return os.WriteFile(filepath.Join(wsDir, "leader.json"), data, 0o644)
}
func (FileStore) LoadLeader(wsDir string) ([]byte, error) {
data, err := os.ReadFile(filepath.Join(wsDir, "leader.json"))
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
return data, nil
}
func (FileStore) SaveCheckpoint(wsDir string, cp Checkpoint) error {
dir := filepath.Join(wsDir, "checkpoints")
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
path := filepath.Join(dir, fmt.Sprintf("%03d.json", cp.Superstep))
if err := writeJSON(path, cp); err != nil {
return err
}
return writeJSON(filepath.Join(dir, "latest.json"), cp)
}
func (FileStore) LoadLatestCheckpoint(wsDir string) (*Checkpoint, error) {
data, err := os.ReadFile(filepath.Join(wsDir, "checkpoints", "latest.json"))
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var cp Checkpoint
if err := json.Unmarshal(data, &cp); err != nil {
return nil, fmt.Errorf("parse latest checkpoint: %w", err)
}
return &cp, nil
}
// Checkpoint 是一个 superstep 边界的状态快照(Q1 WP4)。
// GraphHash 是图结构指纹:恢复时与当前 Steps 重编译比对,不一致说明崩溃
// 期间图形态被改过(动态调整或外力),按 WP1 裁定拒绝恢复并提示重新规划。
type Checkpoint struct {
Superstep int `json:"superstep"`
At time.Time `json:"at"`
GraphHash string `json:"graph_hash"`
StepStates []StepSnapshot `json:"steps"`
wsDir string `json:"-"`
}
// write 立即同步落盘(经当前 Store 端口——SQLite 后端时进数据库)。
func (cp Checkpoint) write(store Store) error {
return store.SaveCheckpoint(cp.wsDir, cp)
}
// StepSnapshot 是检查点里的单步快照。
type StepSnapshot struct {
ID string `json:"id"`
Status string `json:"status"`
AttemptID string `json:"attempt_id,omitempty"`
}
// CheckpointMode 快照写盘档位(Q1 WP4 三档)。
type CheckpointMode string
const (
// CheckpointSync 每个 superstep 边界同步写(默认,与现状「逐步全量落盘」一致)。
CheckpointSync CheckpointMode = "sync"
// CheckpointAsync 边界快照进队列由单 goroutine 串行写(边界不阻塞;恢复粒度同 sync)。
CheckpointAsync CheckpointMode = "async"
// CheckpointOnExit 只在推进退出(终态/挂起/错误)时写最终快照。
CheckpointOnExit CheckpointMode = "on_exit"
)
// checkpointWriter 按档位执行边界快照;async 档单 goroutine 串行落盘。
type checkpointWriter struct {
mode CheckpointMode
store Store
ch chan Checkpoint
done chan error
}
func newCheckpointWriter(mode CheckpointMode, store Store) *checkpointWriter {
if mode == "" {
mode = CheckpointSync
}
if store == nil {
store = FileStore{}
}
w := &checkpointWriter{mode: mode, store: store}
if mode == CheckpointAsync {
w.ch = make(chan Checkpoint, 16)
w.done = make(chan error, 1)
go func() {
var first error
for cp := range w.ch {
if first != nil {
continue
}
if err := cp.write(w.store); err != nil {
first = err
}
}
w.done <- first
}()
}
return w
}
// boundary 由调用方(flowDriver)在 superstep 边界触发:
// sync 立即写;async 入队(满即错,不静默丢);on_exit 跳过。
func (w *checkpointWriter) boundary(wsDir string, cp Checkpoint) error {
cp.wsDir = wsDir
switch w.mode {
case CheckpointAsync:
select {
case w.ch <- cp:
default:
return fmt.Errorf("checkpoint queue full at superstep %d", cp.Superstep)
}
return nil
case CheckpointOnExit:
return nil
default:
return cp.write(w.store)
}
}
// flush 在推进退出前调用:async 落完并报告首个错误;on_exit 写最终快照。
func (w *checkpointWriter) flush(wsDir string, cp Checkpoint) error {
cp.wsDir = wsDir
switch w.mode {
case CheckpointAsync:
close(w.ch)
return <-w.done
case CheckpointOnExit:
return cp.write(w.store)
default:
return nil
}
}
// validateGraphContinuity 恢复连续性校验(Q1 WP4;WP1 的图版本裁定)。
// 最新检查点存在且其图指纹与当前 Steps 重编译不一致时拒绝恢复——
// 崩溃期间图形态被改过,继续推进会把已完成的结果接到错误的图上。
// 检查点不存在(旧运行)或指纹一致则放行。
func validateGraphContinuity(st *RunState) error {
latest, err := st.storeOr().LoadLatestCheckpoint(st.Meta.WorkDir)
if err != nil || latest == nil {
return err
}
g, gerr := compileGraph(st.Plan.Workflow.Steps)
if gerr != nil {
return fmt.Errorf("graph continuity: 当前 Steps 无法编译:%w(请人工介入或重新规划)", gerr)
}
if latest.GraphHash != g.hash() {
return fmt.Errorf("graph continuity: 崩溃前后图形态不一致(checkpoint superstep=%d hash=%s,当前=%s)——可能在动态调整或外部修改后崩溃,请重新规划或人工介入",
latest.Superstep, latest.GraphHash, g.hash())
}
return nil
}
+4 -6
View File
@@ -8,8 +8,6 @@ import (
"sync"
"testing"
"time"
"agent-native/exec"
)
// recordingBackend 记录每次收到的 prompt(按目录),产出按 script。
@@ -22,7 +20,7 @@ type recordingBackend struct {
func (b *recordingBackend) Name() string { return "rec" }
func (b *recordingBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
func (b *recordingBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
key := filepath.Base(filepath.Clean(req.WorkDir))
b.mu.Lock()
b.prompts[key] = append(b.prompts[key], req.Prompt)
@@ -43,7 +41,7 @@ func (b *recordingBackend) Execute(ctx context.Context, req exec.Request) (*exec
if b.tokens {
pt, ct = 120, 80
}
return &exec.Result{Success: true, Patches: patches, PromptTokens: pt, CompletionTokens: ct}, nil
return &ExecResult{Success: true, Patches: patches, PromptTokens: pt, CompletionTokens: ct}, nil
}
func TestSteeringInjectedAtNextStep(t *testing.T) {
@@ -137,7 +135,7 @@ type slowCountBackend struct {
func (b *slowCountBackend) Name() string { return "slow" }
func (b *slowCountBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
func (b *slowCountBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
b.mu.Lock()
b.cur++
if b.cur > b.peak {
@@ -153,7 +151,7 @@ func (b *slowCountBackend) Execute(ctx context.Context, req exec.Request) (*exec
_ = os.MkdirAll(filepath.Dir(out), 0o755)
_ = os.WriteFile(out, []byte(goodReport), 0o644)
}
return &exec.Result{Success: true, Patches: map[string][]byte{}}, nil
return &ExecResult{Success: true, Patches: map[string][]byte{}}, nil
}
func TestRoleConcurrencyLimit(t *testing.T) {
+200
View File
@@ -0,0 +1,200 @@
package squad
import (
"os"
"path/filepath"
"strings"
"unicode"
)
// MaxTransientStepInvocations is the product-layer bound: the first
// invocation plus one fresh re-invocation. SDK in-process auto_retry is
// separate and must not be confused with this.
const MaxTransientStepInvocations = 2
type FailureClass int
const (
FailureNone FailureClass = iota
FailureTransient
FailurePermanent
)
func (c FailureClass) String() string {
switch c {
case FailureTransient:
return "transient"
case FailurePermanent:
return "permanent"
default:
return "none"
}
}
type TraceSignals struct {
ProviderTransient bool
Settled bool
RetryExhausted bool
HasUsableDeliverable bool // always false: write() args in traces are not deliverables
}
type StepFailureSignals struct {
Err error
Result *ExecResult
WorkDir string
ExtraText string
TargetMissing bool
// TargetExists means this invocation wrote a non-empty file that differs
// from the pre-invoke baseline. Do not retry or overwrite it. The step
// still fails unless the backend already reported a trusted success.
TargetExists bool
}
type FailureAssessment struct {
Class FailureClass
Reason string
}
func ScanTraceText(text string) TraceSignals {
var out TraceSignals
if text == "" {
return out
}
// Line scan only. Never parse write.arguments.content into a body.
for _, line := range strings.Split(text, "\n") {
if line == "" {
continue
}
lower := strings.ToLower(line)
if strings.Contains(lower, `"type":"agent_settled"`) || strings.Contains(lower, `"type": "agent_settled"`) {
out.Settled = true
}
if strings.Contains(lower, `"type":"auto_retry_end"`) && strings.Contains(lower, `"success":false`) {
out.ProviderTransient = true
out.RetryExhausted = true
}
if strings.Contains(lower, `"type":"auto_retry_start"`) {
out.ProviderTransient = true
}
if strings.Contains(lower, `"stopreason":"error"`) {
out.ProviderTransient = true
}
if strings.Contains(lower, "connection error") {
out.ProviderTransient = true
}
if strings.Contains(lower, `"errormessage":"terminated"`) || strings.Contains(lower, `"errormessage": "terminated"`) {
out.ProviderTransient = true
}
}
return out
}
func ReadWorkDirTraces(workDir string) string {
if strings.TrimSpace(workDir) == "" {
return ""
}
p := filepath.Join(workDir, "execution.log")
b, err := os.ReadFile(p)
if err != nil {
return ""
}
const capBytes = 512 << 10
if len(b) > capBytes {
b = b[len(b)-capBytes:]
}
return string(b)
}
func AssessStepFailure(sig StepFailureSignals) FailureAssessment {
reason := combinedFailureText(sig)
lower := strings.ToLower(reason)
trace := ScanTraceText(sig.ExtraText + "\n" + ReadWorkDirTraces(sig.WorkDir))
missing := sig.TargetMissing || strings.Contains(lower, "missing target deliverable")
if sig.TargetExists {
return FailureAssessment{Class: FailurePermanent, Reason: "target-already-present: do not overwrite"}
}
if isPermanentReason(lower) {
return FailureAssessment{Class: FailurePermanent, Reason: "permanent: " + compactReason(reason)}
}
runnerFailed := (sig.Result != nil && sig.Result.Retryable) || isRunnerExplicitFailure(lower)
provider := trace.ProviderTransient || hasProviderTermOrConn(lower)
if runnerFailed {
return FailureAssessment{Class: FailureTransient, Reason: "runner-explicit-failure: " + compactReason(reason)}
}
if provider && missing {
kind := "provider-transient-missing-target"
if trace.Settled {
kind = "settled-failure-missing-target"
}
return FailureAssessment{Class: FailureTransient, Reason: kind + ": " + compactReason(reason)}
}
// Model settled cleanly but never wrote the target (e.g. text-only reply,
// no write tool). One product-layer re-invocation with a corrective prompt
// is worth trying; still bounded by MaxTransientStepInvocations.
if missing && trace.Settled && !provider {
return FailureAssessment{Class: FailureTransient, Reason: "settled-no-deliverable: " + compactReason(reason)}
}
if missing {
return FailureAssessment{Class: FailurePermanent, Reason: "permanent-missing: " + compactReason(reason)}
}
return FailureAssessment{Class: FailurePermanent, Reason: "fail-closed: " + compactReason(reason)}
}
func combinedFailureText(sig StepFailureSignals) string {
var parts []string
if sig.Err != nil {
parts = append(parts, sig.Err.Error())
}
if sig.Result != nil {
if sig.Result.ErrorReason != "" {
parts = append(parts, sig.Result.ErrorReason)
}
if sig.Result.Logs != "" {
parts = append(parts, sig.Result.Logs)
}
}
return strings.Join(parts, " ")
}
func isPermanentReason(lower string) bool {
return strings.Contains(lower, "prompt_empty") ||
strings.Contains(lower, "unauthorized") ||
strings.Contains(lower, "budget_exceeded") ||
strings.Contains(lower, "治理强制终止") ||
strings.Contains(lower, "治理拦截")
}
func hasProviderTermOrConn(lower string) bool {
return strings.Contains(lower, "connection error") ||
strings.Contains(lower, "terminated") ||
strings.Contains(lower, "econnreset") ||
strings.Contains(lower, "socket hang up")
}
func isRunnerExplicitFailure(lower string) bool {
return strings.Contains(lower, "pi remote runner call failed") ||
strings.Contains(lower, "auto_retry_failed") ||
strings.Contains(lower, "auto_retry_end") ||
(hasProviderTermOrConn(lower) && !strings.Contains(lower, "missing target deliverable"))
}
func compactReason(s string) string {
s = strings.Join(strings.FieldsFunc(s, func(r rune) bool {
return r == '\n' || r == '\r'
}), " ")
s = strings.TrimSpace(s)
if s == "" {
return "unknown"
}
// Drop accidental write-argument bodies if a caller stuffed logs here.
if len(s) > 240 {
s = s[:240]
}
return strings.Map(func(r rune) rune {
if unicode.IsControl(r) && r != '\t' {
return -1
}
return r
}, s)
}
+308
View File
@@ -0,0 +1,308 @@
package squad
import (
"context"
"fmt"
"strings"
"time"
)
// taskboard.go — task_board 拓扑驱动(Q2 WP4)。
// 形态:计划步骤 = 板上任务(Needs = 依赖门);执行语义是队列化的
// claim/lease/complete 三原语——工作者认领任务(带租约时限)、执行、完成;
// 进程死亡留下的孤儿租约(claimed 且租约过期)在驱动重入时再武装回 open
// (Attempts 保留,超限由步骤级重试护栏兜底)。全部任务收口后走标准
// finalize(交付物/质检/降级判定与图拓扑同一收口面)。
// 挂起(HITL)安全:claimed 且步骤未完的任务在重入时续跑,不重复认领。
func init() {
_ = RegisterTopologyDriver(TopologyTaskBoard)
}
// DefaultTaskLeaseSeconds 任务租约缺省时限(秒)。
const DefaultTaskLeaseSeconds = 600
// boardSkipReason 依赖门无法满足(上游 failed/skipped)时的跳过原因。
const boardSkipReason = "task_board: 依赖未满足(上游失败或跳过)"
// BoardTask 是任务板上一条任务的队列状态。
type BoardTask struct {
ID string `json:"id"`
Status string `json:"status"` // open / claimed / done / failed / skipped
ClaimedBy string `json:"claimed_by,omitempty"`
// LeaseExpires 认领租约到期时刻;过期未 complete = 孤儿租约,重入再武装。
LeaseExpires time.Time `json:"lease_expires,omitempty"`
// Attempts 认领次数(孤儿再武装累加;执行重试由步骤级护栏负责)。
Attempts int `json:"attempts,omitempty"`
}
// runTaskBoardDriver task_board 拓扑入口(幂等可恢复)。
func (e *SquadEngine) runTaskBoardDriver(ctx context.Context, st *RunState) (*RunResult, error) {
drv := &flowDriver{opts: e.deps}
e.initBoard(st)
e.rearmOrphanLeases(st)
leaseTTL := DefaultTaskLeaseSeconds
if v := st.Meta.Metadata["task_board_lease_seconds"]; v != "" {
if n := parseIntDefault(v, DefaultTaskLeaseSeconds); n > 0 {
leaseTTL = n
}
}
for {
idx, held := e.boardHeldTask(st)
if !held {
var ok bool
idx, ok = e.boardClaimNext(st, leaseTTL)
if !ok {
// 无可认领:把依赖已死(上游 failed/skipped)的 open 任务级联
// 置 skipped;再无变化则收口。
if !e.boardSkipDeadlocked(st) {
break
}
continue
}
}
out, gate, err := e.boardRunTask(ctx, drv, st, idx)
if err != nil {
return nil, err
}
if gate != nil {
if out == nil {
out = heldResult(st, gate)
}
return out, nil
}
if out != nil { // 关键任务失败:fail-closed 传播(failStep 已置运行失败)
return out, nil
}
e.boardSettleTask(st, idx)
}
return e.boardFinish(ctx, drv, st)
}
// initBoard 首次进入时从计划步骤派生任务板(恢复重入直读持久化状态)。
func (e *SquadEngine) initBoard(st *RunState) {
st.mu.Lock()
defer st.mu.Unlock()
if len(st.Board) > 0 {
return
}
for _, s := range st.Plan.Workflow.Steps {
status := "open"
switch s.Status {
case "completed":
status = "done"
case "failed":
status = "failed"
case "skipped":
status = "skipped"
}
st.Board = append(st.Board, BoardTask{ID: s.ID, Status: status})
}
_ = st.save()
}
// rearmOrphanLeases 孤儿租约再武装:claimed 且租约过期 → open(进程死亡
// 恢复路径;Attempts 保留作观测)。
func (e *SquadEngine) rearmOrphanLeases(st *RunState) {
now := time.Now()
st.mu.Lock()
var rearmed []string
for i := range st.Board {
t := &st.Board[i]
if t.Status == "claimed" && !t.LeaseExpires.IsZero() && t.LeaseExpires.Before(now) {
t.Status = "open"
t.ClaimedBy = ""
rearmed = append(rearmed, t.ID)
}
}
changed := len(rearmed) > 0
if changed {
_ = st.save()
}
st.mu.Unlock()
if changed {
(&flowDriver{opts: e.deps}).emit(st, Event{Type: EvPhase,
Text: fmt.Sprintf("task_board:孤儿租约再武装 %s", strings.Join(rearmed, ","))})
}
}
// boardHeldTask 返回 claimed 且步骤未完的任务(HITL 挂起/中断重入的续跑点)。
func (e *SquadEngine) boardHeldTask(st *RunState) (int, bool) {
for ti := range st.Board {
t := &st.Board[ti]
if t.Status != "claimed" {
continue
}
if idx, s := e.boardStep(st, t.ID); idx >= 0 && (s.Status == "pending" || s.Status == "running") {
return idx, true
}
}
return -1, false
}
// boardClaimNext 认领下一个可执行任务(open 且依赖门全 done),返回步骤下标。
func (e *SquadEngine) boardClaimNext(st *RunState, leaseTTL int) (int, bool) {
st.mu.Lock()
defer st.mu.Unlock()
for ti := range st.Board {
t := &st.Board[ti]
if t.Status != "open" {
continue
}
if !e.boardDepsDoneLocked(st, t.ID) {
continue
}
idx, s := e.boardStepLocked(st, t.ID)
if idx < 0 {
continue
}
t.Status = "claimed"
t.ClaimedBy = s.Role
t.Attempts++
t.LeaseExpires = time.Now().Add(time.Duration(leaseTTL) * time.Second)
_ = st.save()
(&flowDriver{opts: e.deps}).emit(st, Event{Type: EvPhase, StepID: t.ID, Role: s.Role,
Text: fmt.Sprintf("task_board:%s 认领 %s(租约 %ds,第 %d 次)", s.Role, t.ID, leaseTTL, t.Attempts)})
return idx, true
}
return -1, false
}
// boardSkipDeadlocked 把依赖已死(上游 failed/skipped)的 open 任务级联置
// skipped;返回是否有变化(有则继续循环级联)。
func (e *SquadEngine) boardSkipDeadlocked(st *RunState) bool {
st.mu.Lock()
changed := false
for ti := range st.Board {
t := &st.Board[ti]
if t.Status != "open" {
continue
}
if e.boardDepsDeadLocked(st, t.ID) {
t.Status = "skipped"
if idx, s := e.boardStepLocked(st, t.ID); idx >= 0 {
s.Status = "skipped"
s.Error = boardSkipReason
st.Plan.Workflow.Steps[idx] = *s
}
changed = true
}
}
if changed {
_ = st.save()
}
st.mu.Unlock()
return changed
}
// boardRunTask 执行已认领任务:过门 → runOneStep。
func (e *SquadEngine) boardRunTask(ctx context.Context, drv *flowDriver, st *RunState, idx int) (*RunResult, *HitlGate, error) {
out, gate, err := drv.gateBeforeRun(ctx, st, idx)
if err != nil || gate != nil || out != nil {
return out, gate, err
}
return drv.runOneStep(ctx, st, idx)
}
// boardSettleTask 任务结算:步骤 completed → done;failed(非关键降级)→ failed。
func (e *SquadEngine) boardSettleTask(st *RunState, idx int) {
step := st.Plan.Workflow.Steps[idx]
status := "done"
if step.Status == "failed" {
status = "failed"
}
st.mu.Lock()
for i := range st.Board {
if st.Board[i].ID == step.ID {
st.Board[i].Status = status
st.Board[i].LeaseExpires = time.Time{}
}
}
_ = st.save()
st.mu.Unlock()
(&flowDriver{opts: e.deps}).emit(st, Event{Type: EvPhase, StepID: step.ID, Role: step.Role,
Text: fmt.Sprintf("task_board:%s → %s", step.ID, status)})
}
// boardFinish 全部任务收口后走标准 finalize(交付物/质检/降级与图拓扑同面)。
// 仍有 open/claimed 任务 = 依赖不可满足,fail-closed 不静默收口。
func (e *SquadEngine) boardFinish(ctx context.Context, drv *flowDriver, st *RunState) (*RunResult, error) {
done, total := 0, len(st.Board)
for _, t := range st.Board {
switch t.Status {
case "done":
done++
case "open", "claimed":
return e.driverFail(st, fmt.Sprintf("task_board: 任务 %s 无法收口(依赖不可满足)", t.ID)), nil
}
}
drv.emit(st, Event{Type: EvPhase, Text: fmt.Sprintf("task_board:收口(done %d/%d)", done, total)})
out, gate, err := drv.finalize(ctx, st)
if err != nil {
return nil, err
}
if gate != nil {
return heldResult(st, gate), nil
}
return out, nil
}
// boardStep 按任务 ID 找步骤(只读副本)。
func (e *SquadEngine) boardStep(st *RunState, id string) (int, *StepSpec) {
st.mu.Lock()
defer st.mu.Unlock()
return e.boardStepLocked(st, id)
}
// boardStepLocked 按任务 ID 找步骤(调用方持锁)。返回下标与副本指针。
func (e *SquadEngine) boardStepLocked(st *RunState, id string) (int, *StepSpec) {
for i := range st.Plan.Workflow.Steps {
if st.Plan.Workflow.Steps[i].ID == id {
s := st.Plan.Workflow.Steps[i]
return i, &s
}
}
return -1, nil
}
// boardDepsDoneLocked 依赖门:Needs 全部 done(调用方持锁)。
func (e *SquadEngine) boardDepsDoneLocked(st *RunState, id string) bool {
needs := e.boardNeedsLocked(st, id)
for _, dep := range needs {
found := false
for i := range st.Board {
if st.Board[i].ID == dep {
found = true
if st.Board[i].Status != "done" {
return false
}
}
}
if !found {
return false // 依赖不在板上 = fail-closed 不认领
}
}
return true
}
// boardDepsDeadLocked 依赖已死:任一 Need 为 failed/skipped(级联跳过依据)。
func (e *SquadEngine) boardDepsDeadLocked(st *RunState, id string) bool {
for _, dep := range e.boardNeedsLocked(st, id) {
for i := range st.Board {
if st.Board[i].ID == dep && (st.Board[i].Status == "failed" || st.Board[i].Status == "skipped") {
return true
}
}
}
return false
}
func (e *SquadEngine) boardNeedsLocked(st *RunState, id string) []string {
for i := range st.Plan.Workflow.Steps {
if st.Plan.Workflow.Steps[i].ID == id {
return st.Plan.Workflow.Steps[i].Needs
}
}
return nil
}
+120
View File
@@ -0,0 +1,120 @@
package squad
import (
"fmt"
"time"
)
// Termination 运行级停止条件的单一注册点(Q1 WP6):leader 轮次、修复预算、
// 唤醒次数与步骤超时集中声明;复合判定用 And/Or 原语(AutoGen 终止条件语义)。
// 默认值与现网一致;引擎选项在 terminationOf 覆盖对应字段。
type Termination struct {
MaxLeaderTurns int
RepairBudget int
MaxRepairGranted int // 人工 retry 累计加预算上限(Q26):默认 RepairBudget*2
MaxWakeupFires int
StepTimeout time.Duration
}
// DefaultTermination 与现网四处的硬编码取值一致。
func DefaultTermination() Termination {
return Termination{
MaxLeaderTurns: DefaultMaxLeaderTurns,
RepairBudget: 1,
MaxRepairGranted: 2,
MaxWakeupFires: MaxWakeupFires,
StepTimeout: 10 * time.Minute,
}
}
// terminationOf 取本运行的停止条件:引擎选项与运行状态覆盖默认值。
func (e *SquadEngine) terminationOf(st *RunState) Termination {
t := DefaultTermination()
if e.opts.RepairBudget > 0 {
t.RepairBudget = e.opts.RepairBudget
t.MaxRepairGranted = e.opts.RepairBudget * 2
}
if e.opts.StepTimeout > 0 {
t.StepTimeout = e.opts.StepTimeout
}
if st != nil && st.Leader != nil && st.Leader.MaxTurns > 0 {
t.MaxLeaderTurns = st.Leader.MaxTurns
}
return t
}
// Counters 运行期计数,由各判定处喂入(值语义快照)。
type Counters struct {
LeaderTurns int
RepairSpent int
RepairGranted int
WakeupFires int
Deadline time.Time
}
// Cond 是一条停止条件:返回(原因,是否命中)。
type Cond func(c Counters, now time.Time) (string, bool)
// MaxTurnsC 轮次上限。
func MaxTurnsC(n int) Cond {
return func(c Counters, _ time.Time) (string, bool) {
if c.LeaderTurns >= n {
return fmt.Sprintf("leader 轮次达上限 %d", n), true
}
return "", false
}
}
// RepairBudgetC 修复预算耗尽。
func RepairBudgetC(n int) Cond {
return func(c Counters, _ time.Time) (string, bool) {
if c.RepairSpent > n {
return fmt.Sprintf("修复预算耗尽(%d/%d)", c.RepairSpent, n), true
}
return "", false
}
}
// WakeupsC 自动唤醒次数超限。
func WakeupsC(n int) Cond {
return func(c Counters, _ time.Time) (string, bool) {
if c.WakeupFires > n {
return fmt.Sprintf("自动唤醒次数超限(%d/%d)", c.WakeupFires, n), true
}
return "", false
}
}
// DeadlineC 超过截止时刻。
func DeadlineC() Cond {
return func(c Counters, now time.Time) (string, bool) {
if !c.Deadline.IsZero() && now.After(c.Deadline) {
return "超过截止时刻", true
}
return "", false
}
}
// And 全部条件命中才成立。
func And(conds ...Cond) Cond {
return func(c Counters, now time.Time) (string, bool) {
for _, k := range conds {
if _, hit := k(c, now); !hit {
return "", false
}
}
return "all conditions met", true
}
}
// Or 任一条件命中即成立(默认的停止语义)。
func Or(conds ...Cond) Cond {
return func(c Counters, now time.Time) (string, bool) {
for _, k := range conds {
if r, hit := k(c, now); hit {
return r, true
}
}
return "", false
}
}
+82
View File
@@ -0,0 +1,82 @@
package squad
import (
"context"
"strings"
"testing"
"time"
)
// TestTerminationAndOr:And 全中才成立、Or 任一中即成立(WP6 原语)。
func TestTerminationAndOr(t *testing.T) {
c := Counters{LeaderTurns: 8, RepairSpent: 2, WakeupFires: 0}
or := Or(MaxTurnsC(8), WakeupsC(1))
if _, hit := or(c, time.Now()); !hit {
t.Fatal("or should hit on turns")
}
and := And(MaxTurnsC(8), WakeupsC(1))
if _, hit := and(c, time.Now()); hit {
t.Fatal("and should not hit with zero wakeups")
}
and2 := And(MaxTurnsC(8), RepairBudgetC(1))
if _, hit := and2(c, time.Now()); !hit {
t.Fatal("and should hit when all met")
}
dl := DeadlineC()
past := Counters{Deadline: time.Now().Add(-time.Minute)}
if _, hit := dl(past, time.Now()); !hit {
t.Fatal("deadline should hit when past")
}
}
// TestRepairGrantCap:人工 retry 累计追加有上限(Q26)。
func TestRepairGrantCap(t *testing.T) {
engine, _ := newTestEngine(t, mockScriptPlanner(baseSteps()...), map[string]map[string]string{
"01_researcher": {"notes.md": goodReport, "fields.json": "{}"},
"02_writer": {"output.md": goodReport},
"03_reviewer": {"output.md": goodReport},
}, NopSink{})
res, err := engine.Run(context.Background(), Goal{Text: "先跑完再验", HitlMode: HitlAuto}, RunOptions{})
if err != nil || res.Status != RunCompleted {
t.Fatalf("setup run: %v %+v", err, res)
}
// 预置:修复授予已达上限 + 一个真实的质检挂起门(与 finalize 的形态一致)。
st, err := loadRunState(res.WorkDir)
if err != nil {
t.Fatal(err)
}
st.RepairBudgetGranted = engine.terminationOf(st).MaxRepairGranted
st.Meta.CompletedAt = nil
st.Meta.Status = string(RunQualityReview)
req := &HumanRequest{
ID: newRequestID(), RunID: st.Meta.ID, Kind: HitlQualityReview, Severity: SeverityActionRequired,
Question: "交付物质检未通过且自动修复预算已用尽,请人工裁决:",
Options: []HumanOption{
{Value: "approve", Label: "人工放行(记录人工通过)"},
{Value: "retry", Label: "再修复一次(重跑交付步骤)"},
{Value: "abort", Label: "判定失败"},
},
CreatedAt: time.Now(), Status: "pending",
}
if err := st.saveGate(&HitlGate{Request: *req, PrevStatus: RunExecuting}); err != nil {
t.Fatal(err)
}
if err := st.save(); err != nil {
t.Fatal(err)
}
// 人工 retry 应被拒绝而不是再追加。
out, err := engine.Respond(context.Background(), res.RunID, RespondDecision{Choice: "retry"})
if err != nil {
t.Fatal(err)
}
if out == nil || !strings.Contains(out.Error, "grants exhausted") {
t.Fatalf("expected grant-cap failure, got %+v err=%v", out, err)
}
st2, err := loadRunState(res.WorkDir)
if err != nil {
t.Fatal(err)
}
if st2.RepairBudgetGranted != engine.terminationOf(st2).MaxRepairGranted {
t.Fatalf("granted changed after cap: %d", st2.RepairBudgetGranted)
}
}
+130
View File
@@ -0,0 +1,130 @@
package squad
import "fmt"
// topology.go — 拓扑一等化(Q2):内置拓扑名注册表。
// 拓扑不新造执行面:graph 类编译为 Steps 图(Q1 机制),leader 走既有
// Mode 委派循环,driver 类由引擎按名分发(review/group_chat/task_board),
// primitive 类是跨拓扑原语(handoff/mailbox),不可单独作为运行拓扑。
// 未知名与不可运行Kind一律 fail-closed。
// 内置拓扑名(completion doc Q2 清单的 12 种)。
const (
TopologySequential = "sequential"
TopologyParallel = "parallel"
TopologyLoop = "loop"
TopologyRouter = "router"
TopologyLeader = "leader"
TopologyHandoff = "handoff"
TopologyGroupChat = "group_chat"
TopologyReview = "review"
TopologyEvaluatorOptimizer = "evaluator_optimizer"
TopologyLongRunning = "long_running"
TopologyTaskBoard = "task_board"
TopologyMailbox = "mailbox"
)
// TopologyKind 是拓扑的执行形态。
type TopologyKind string
const (
// TopologyGraph 编译为 Steps 图,由 flowDriver.advance 执行。
TopologyGraph TopologyKind = "graph"
// TopologyMode 走既有 Mode 分支(leader 委派循环)。
TopologyMode TopologyKind = "mode"
// TopologyDriver 由引擎按名分发到专用驱动。
TopologyDriver TopologyKind = "driver"
// TopologyPrimitive 是跨拓扑原语,不可单独运行。
TopologyPrimitive TopologyKind = "primitive"
)
// TopologySpec 是一个内置拓扑的注册项。
type TopologySpec struct {
Name string
Kind TopologyKind
}
// builtinTopologies 是 12 种内置拓扑的注册表(唯一权威清单)。
var builtinTopologies = []TopologySpec{
{Name: TopologySequential, Kind: TopologyGraph},
{Name: TopologyParallel, Kind: TopologyGraph},
{Name: TopologyLoop, Kind: TopologyGraph},
{Name: TopologyRouter, Kind: TopologyGraph},
{Name: TopologyLeader, Kind: TopologyMode},
{Name: TopologyHandoff, Kind: TopologyPrimitive},
{Name: TopologyGroupChat, Kind: TopologyDriver},
{Name: TopologyReview, Kind: TopologyDriver},
{Name: TopologyEvaluatorOptimizer, Kind: TopologyGraph},
{Name: TopologyLongRunning, Kind: TopologyGraph},
{Name: TopologyTaskBoard, Kind: TopologyDriver},
{Name: TopologyMailbox, Kind: TopologyPrimitive},
}
// BuiltinTopologies 返回内置拓扑清单(只读副本)。
func BuiltinTopologies() []TopologySpec {
out := make([]TopologySpec, len(builtinTopologies))
copy(out, builtinTopologies)
return out
}
// LookupTopology 按名查注册项。
func LookupTopology(name string) (TopologySpec, bool) {
for _, t := range builtinTopologies {
if t.Name == name {
return t, true
}
}
return TopologySpec{}, false
}
// registeredDrivers 是已接线的 driver 拓扑(WP 增量逐个点亮;未点亮 = fail-closed)。
var registeredDrivers = map[string]bool{}
// RegisterTopologyDriver 由驱动实现方在 init/装配时点亮一个 driver 拓扑。
func RegisterTopologyDriver(name string) error {
spec, ok := LookupTopology(name)
if !ok {
return fmt.Errorf("topology: 未注册的拓扑名 %q", name)
}
if spec.Kind != TopologyDriver {
return fmt.Errorf("topology: %q 不是 driver 类(kind=%s)", name, spec.Kind)
}
registeredDrivers[name] = true
return nil
}
// checkTopology 校验一次运行的有效拓扑名,返回其 Kind。
// 空名 = 未声明(standard 图行为,兼容既有调用方)。
// 未注册名 / primitive 单跑 / driver 未点亮都 fail-closed。
func checkTopology(name string) (TopologyKind, error) {
if name == "" {
return TopologyGraph, nil
}
spec, ok := LookupTopology(name)
if !ok {
return "", fmt.Errorf("topology: 未知拓扑 %q(内置清单:%s)", name, topologyNames())
}
switch spec.Kind {
case TopologyGraph, TopologyMode:
return spec.Kind, nil
case TopologyPrimitive:
return "", fmt.Errorf("topology: %q 是跨拓扑原语,不可单独作为运行拓扑", name)
case TopologyDriver:
if !registeredDrivers[name] {
return "", fmt.Errorf("topology: %q 驱动未注册(fail-closed)", name)
}
return spec.Kind, nil
}
return "", fmt.Errorf("topology: %q kind 非法", name)
}
func topologyNames() string {
s := ""
for i, t := range builtinTopologies {
if i > 0 {
s += ", "
}
s += t.Name
}
return s
}
+435
View File
@@ -0,0 +1,435 @@
package squad
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// topology_conformance_test.go — Q2 验收一的机器证据:每种拓扑一个
// 脚本化后端的 conformance(零模型、零网络)。
// ---- 脚本化夹具 ----
// collectSink 收集事件流供断言。
type collectSink struct {
mu sync.Mutex
events []Event
}
func (c *collectSink) Emit(ev Event) {
c.mu.Lock()
defer c.mu.Unlock()
c.events = append(c.events, ev)
}
func (c *collectSink) snapshot() []Event {
c.mu.Lock()
defer c.mu.Unlock()
return append([]Event(nil), c.events...)
}
func (c *collectSink) countText(sub string) int {
n := 0
for _, ev := range c.snapshot() {
if strings.Contains(ev.Text, sub) {
n++
}
}
return n
}
// iterBackend 按「步骤子目录 × 第几次执行」回放内容(循环/评估拓扑需要
// 逐轮不同的 fields.json)。
type iterBackend struct {
mu sync.Mutex
calls map[string]int
content func(stepKey string, iter int, target string) []byte
delay time.Duration
}
func (b *iterBackend) Name() string { return "iter" }
func (b *iterBackend) Execute(_ context.Context, req ExecRequest) (*ExecResult, error) {
key := filepath.Base(filepath.Clean(req.WorkDir))
if b.delay > 0 {
time.Sleep(b.delay)
}
b.mu.Lock()
b.calls[key]++
iter := b.calls[key]
b.mu.Unlock()
patches := map[string][]byte{}
for _, t := range req.Targets {
var data []byte
if b.content != nil {
data = b.content(key, iter, t)
}
if data == nil {
data = []byte(goodReport)
}
out := filepath.Join(req.WorkDir, t)
_ = os.MkdirAll(filepath.Dir(out), 0o755)
if err := os.WriteFile(out, data, 0o644); err != nil {
return nil, err
}
patches[t] = data
}
return &ExecResult{Success: true, Patches: patches}, nil
}
func (b *iterBackend) totalCalls() int {
b.mu.Lock()
defer b.mu.Unlock()
n := 0
for _, v := range b.calls {
n += v
}
return n
}
func newTopologyEngine(t *testing.T, planner Planner, be Executor, sink ProgressSink) *SquadEngine {
t.Helper()
dir := t.TempDir()
reg := NewRoleRegistry()
for _, spec := range []RoleSpec{
{Role: "researcher", Name: "调研", Backend: "iter"},
{Role: "writer", Name: "撰写", Backend: "iter"},
{Role: "reviewer", Name: "审核", Backend: "iter"},
} {
if err := reg.Register(spec); err != nil {
t.Fatal(err)
}
}
backends := NewBackendResolver()
backends.RegisterInstance("iter", be)
if sink == nil {
sink = NopSink{}
}
engine, err := NewEngine(Options{
DataDir: dir, Registry: reg, Planner: planner, Backends: backends, Sink: sink,
})
if err != nil {
t.Fatal(err)
}
return engine
}
func fieldsJSON(kv map[string]string) []byte {
b, _ := json.Marshal(kv)
return b
}
// ---- sequential / parallel:既有图语义 + 拓扑名受理 ----
func TestTopologyConformance_sequential(t *testing.T) {
be := &iterBackend{calls: map[string]int{}}
sink := &collectSink{}
engine := newTopologyEngine(t, mockScriptPlanner(
StepSpec{ID: "s1", Role: "researcher", Title: "一", Targets: []string{"notes.md"}},
StepSpec{ID: "s2", Role: "writer", Title: "二", Targets: []string{"output.md"}},
), be, sink)
res, err := engine.Run(context.Background(), Goal{Text: "顺序任务"}, RunOptions{Topology: TopologySequential})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status = %s err = %v", res.Status, res.Error)
}
if be.totalCalls() != 2 {
t.Fatalf("执行次数 = %d", be.totalCalls())
}
if sink.countText("拓扑 sequential") != 1 {
t.Fatal("缺拓扑受理事件")
}
}
func TestTopologyConformance_parallel(t *testing.T) {
be := &iterBackend{calls: map[string]int{}}
engine := newTopologyEngine(t, mockScriptPlanner(
StepSpec{ID: "s1", Role: "researcher", Title: "并行甲", Parallel: true, Targets: []string{"a.md"}},
StepSpec{ID: "s2", Role: "writer", Title: "并行乙", Parallel: true, Targets: []string{"b.md"}},
StepSpec{ID: "s3", Role: "reviewer", Title: "汇合", Targets: []string{"output.md"}},
), be, nil)
res, err := engine.Run(context.Background(), Goal{Text: "并行任务"}, RunOptions{Topology: TopologyParallel})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted || be.totalCalls() != 3 {
t.Fatalf("status = %s calls = %d err = %v", res.Status, be.totalCalls(), res.Error)
}
}
// ---- loop:条件停机 + 预算触顶 ----
func TestTopologyConformance_loopUntilField(t *testing.T) {
// 第 1-2 轮 done=no,第 3 轮 done=yes → 恰执行 3 次。
be := &iterBackend{calls: map[string]int{}, content: func(_ string, iter int, target string) []byte {
if target == "fields.json" {
if iter >= 3 {
return fieldsJSON(map[string]string{"done": "yes"})
}
return fieldsJSON(map[string]string{"done": "no"})
}
return nil
}}
sink := &collectSink{}
engine := newTopologyEngine(t, mockScriptPlanner(
StepSpec{ID: "s1", Role: "researcher", Title: "循环体", Targets: []string{"output.md", "fields.json"},
Loop: &LoopSpec{MaxIterations: 5, UntilField: "done", UntilEquals: "yes"}},
), be, sink)
res, err := engine.Run(context.Background(), Goal{Text: "循环任务"}, RunOptions{Topology: TopologyLoop})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status = %s err = %v", res.Status, res.Error)
}
if be.totalCalls() != 3 {
t.Fatalf("应在条件满足时停机(3 次),实际 %d", be.totalCalls())
}
if sink.countText("再武装") != 2 {
t.Fatalf("再武装事件应 2 次:%d", sink.countText("再武装"))
}
// 计划里的 LoopRuns 持久化为 2。
if len(res.Plan.Steps) != 1 || res.Plan.Steps[0].LoopRuns != 2 {
t.Fatalf("LoopRuns = %+v", res.Plan.Steps)
}
}
func TestTopologyConformance_loopBudgetExhausted(t *testing.T) {
// 条件永不满足:恰执行 MaxIterations 次后触顶(非失败)。
be := &iterBackend{calls: map[string]int{}, content: func(_ string, _ int, target string) []byte {
if target == "fields.json" {
return fieldsJSON(map[string]string{"done": "no"})
}
return nil
}}
sink := &collectSink{}
engine := newTopologyEngine(t, mockScriptPlanner(
StepSpec{ID: "s1", Role: "researcher", Title: "循环体", Targets: []string{"output.md", "fields.json"},
Loop: &LoopSpec{MaxIterations: 3, UntilField: "done", UntilEquals: "yes"}},
), be, sink)
res, err := engine.Run(context.Background(), Goal{Text: "循环任务"}, RunOptions{Topology: TopologyLoop})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("触顶应是完成而非失败:%s %v", res.Status, res.Error)
}
if be.totalCalls() != 3 {
t.Fatalf("恰 3 次,实际 %d", be.totalCalls())
}
if sink.countText("loop_exhausted") != 1 {
t.Fatal("缺 loop_exhausted 事件")
}
}
// ---- router:分支选中/落选跳过/无命中 fail-closed ----
func routerSteps() []StepSpec {
return []StepSpec{
{ID: "r1", Role: "researcher", Title: "路由", Targets: []string{"output.md", "fields.json"},
Branches: []Branch{
{When: BranchCond{Field: "route", Equals: "fast"}, Then: []string{"fast"}},
{When: BranchCond{Field: "route", NotEquals: "fast"}, Then: []string{"slow"}},
}},
{ID: "fast", Role: "writer", Title: "快线", Needs: []string{"r1"}, Targets: []string{"output.md"}},
{ID: "slow", Role: "writer", Title: "慢线", Needs: []string{"r1"}, Targets: []string{"output.md"}},
}
}
func TestTopologyConformance_routerSelectsBranch(t *testing.T) {
be := &iterBackend{calls: map[string]int{}, content: func(_ string, _ int, target string) []byte {
if target == "fields.json" {
return fieldsJSON(map[string]string{"route": "fast"})
}
return nil
}}
engine := newTopologyEngine(t, mockScriptPlanner(routerSteps()...), be, nil)
res, err := engine.Run(context.Background(), Goal{Text: "路由任务"}, RunOptions{Topology: TopologyRouter})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("分支落选是预期路径,不得降级/失败:%s %v", res.Status, res.Error)
}
byID := map[string]StepSpec{}
for _, s := range res.Plan.Steps {
byID[s.ID] = s
}
if byID["fast"].Status != "completed" {
t.Fatalf("fast = %s", byID["fast"].Status)
}
if byID["slow"].Status != "skipped" || byID["slow"].Error != routerSkipReason {
t.Fatalf("slow = %s %q", byID["slow"].Status, byID["slow"].Error)
}
}
func TestTopologyConformance_routerNoMatchFailsClosed(t *testing.T) {
be := &iterBackend{calls: map[string]int{}, content: func(_ string, _ int, target string) []byte {
if target == "fields.json" {
return fieldsJSON(map[string]string{"route": ""}) // 两条分支都不命中
}
return nil
}}
steps := routerSteps()
// 去掉 NotEquals 兜底分支,制造无命中。
steps[0].Branches = []Branch{{When: BranchCond{Field: "route", Equals: "fast"}, Then: []string{"fast"}}}
engine := newTopologyEngine(t, mockScriptPlanner(steps...), be, nil)
res, err := engine.Run(context.Background(), Goal{Text: "路由任务"}, RunOptions{Topology: TopologyRouter})
if err != nil {
t.Fatal(err)
}
if res.Status != RunFailed || !strings.Contains(res.Error, "无分支命中") {
t.Fatalf("无命中必须 fail-closed:%s %v", res.Status, res.Error)
}
}
// ---- evaluator-optimizer:draft→eval 循环复合,收敛即停 ----
func TestTopologyConformance_evaluatorOptimizer(t *testing.T) {
// eval 第 1-2 轮 verdict=fail,第 3 轮 pass;draft 随 body 再武装。
be := &iterBackend{calls: map[string]int{}, content: func(key string, iter int, target string) []byte {
if target == "fields.json" {
if iter >= 3 {
return fieldsJSON(map[string]string{"verdict": "pass"})
}
return fieldsJSON(map[string]string{"verdict": "fail"})
}
if target == "verdict.md" {
return []byte(fmt.Sprintf("评审第 %d 轮\n\n%s", iter, goodReport))
}
return nil
}}
engine := newTopologyEngine(t, mockScriptPlanner(
StepSpec{ID: "draft", Role: "writer", Title: "起草", Targets: []string{"output.md"}},
StepSpec{ID: "eval", Role: "reviewer", Title: "评审", Targets: []string{"verdict.md", "fields.json"},
Loop: &LoopSpec{MaxIterations: 4, UntilField: "verdict", UntilEquals: "pass", Body: []string{"draft"}}},
), be, nil)
res, err := engine.Run(context.Background(), Goal{Text: "起草并评审"}, RunOptions{Topology: TopologyEvaluatorOptimizer})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status = %s err = %v", res.Status, res.Error)
}
byID := map[string]StepSpec{}
for _, s := range res.Plan.Steps {
byID[s.ID] = s
}
if byID["eval"].LoopRuns != 2 {
t.Fatalf("eval 应再武装 2 轮:%+v", byID["eval"])
}
// draft 3 次 + eval 3 次。
if be.totalCalls() != 6 {
t.Fatalf("总执行次数 = %d,期望 6", be.totalCalls())
}
}
// ---- long-running:截止时刻 → 未完步骤 skipped、降级收口 ----
func TestTopologyConformance_longRunningDeadline(t *testing.T) {
// 时序鲁棒设计:每步 300ms、截止 +500ms——装配耗时不影响断言核心:
// s1 必完成(首个 superstep 在截止前开跑),s3 必被跳过(第三个
// superstep 边界必超期),收口必为 degraded。s2 完成与否取决于装配
// 耗时,不做断言。
be := &iterBackend{calls: map[string]int{}, delay: 300 * time.Millisecond}
sink := &collectSink{}
engine := newTopologyEngine(t, mockScriptPlanner(
StepSpec{ID: "s1", Role: "researcher", Title: "一", Targets: []string{"notes.md"}},
StepSpec{ID: "s2", Role: "writer", Title: "二", Targets: []string{"output.md"}},
StepSpec{ID: "s3", Role: "reviewer", Title: "三", Targets: []string{"output.md"}},
), be, sink)
res, err := engine.Run(context.Background(), Goal{Text: "长任务"}, RunOptions{
Topology: TopologyLongRunning,
Deadline: time.Now().Add(500 * time.Millisecond),
})
if err != nil {
t.Fatal(err)
}
if res.Status != RunDegraded {
t.Fatalf("截止后应降级收口:%s %v", res.Status, res.Error)
}
byID := map[string]StepSpec{}
for _, s := range res.Plan.Steps {
byID[s.ID] = s
}
if byID["s1"].Status != "completed" {
t.Fatalf("s1 = %s", byID["s1"].Status)
}
if byID["s3"].Status != "skipped" || byID["s3"].Error != "deadline exceeded" {
t.Fatalf("s3 = %s %q", byID["s3"].Status, byID["s3"].Error)
}
if sink.countText("截止时刻") != 1 {
t.Fatal("缺截止事件")
}
}
// ---- 拓扑校验 fail-closed ----
func TestTopologyValidationFailsClosed(t *testing.T) {
be := &iterBackend{calls: map[string]int{}}
cases := []struct {
name string
topo string
wantErr string
}{
{"未注册名", "bogus_topology", "未知拓扑"},
{"primitive 单跑", TopologyMailbox, "跨拓扑原语"},
{"primitive 单跑 handoff", TopologyHandoff, "跨拓扑原语"},
// review/group_chat(WP3)与 task_board(WP4)驱动均已点亮;
// 12/12 受理面由 TestTopologyConformance_allTwelve 汇总钉住。
}
for _, tc := range cases {
engine := newTopologyEngine(t, mockScriptPlanner(
StepSpec{ID: "s1", Role: "researcher", Title: "一", Targets: []string{"output.md"}},
), be, nil)
res, err := engine.Run(context.Background(), Goal{Text: "x"}, RunOptions{Topology: tc.topo})
if err != nil {
t.Fatalf("%s:err = %v", tc.name, err)
}
if res.Status != RunFailed || !strings.Contains(res.Error, tc.wantErr) {
t.Fatalf("%s:%s %q(期望含 %q)", tc.name, res.Status, res.Error, tc.wantErr)
}
}
}
// leader 拓扑名映射到既有 Mode 委派循环(本测只钉「受理且映射」,
// leader 行为 conformance 在 leader_test.go / WP2 增强)。
func TestTopologyLeaderMapsToMode(t *testing.T) {
spec, ok := LookupTopology(TopologyLeader)
if !ok || spec.Kind != TopologyMode {
t.Fatalf("leader 注册形态:%+v %v", spec, ok)
}
if _, err := checkTopology(TopologyLeader); err != nil {
t.Fatalf("leader 拓扑应可受理:%v", err)
}
}
// 12 种内置拓扑全部注册且 Kind 合法(清单完整性)。
func TestBuiltinTopologiesComplete(t *testing.T) {
want := map[string]TopologyKind{
TopologySequential: TopologyGraph, TopologyParallel: TopologyGraph,
TopologyLoop: TopologyGraph, TopologyRouter: TopologyGraph,
TopologyLeader: TopologyMode, TopologyHandoff: TopologyPrimitive,
TopologyGroupChat: TopologyDriver, TopologyReview: TopologyDriver,
TopologyEvaluatorOptimizer: TopologyGraph, TopologyLongRunning: TopologyGraph,
TopologyTaskBoard: TopologyDriver, TopologyMailbox: TopologyPrimitive,
}
got := BuiltinTopologies()
if len(got) != len(want) {
t.Fatalf("注册数 = %d,期望 %d", len(got), len(want))
}
for _, spec := range got {
if k, ok := want[spec.Name]; !ok || k != spec.Kind {
t.Fatalf("拓扑 %s kind = %s(期望 %s)", spec.Name, spec.Kind, k)
}
}
}
+428 -125
View File
@@ -10,137 +10,235 @@ import (
"sync"
"time"
"agent-native/exec"
"error-code-system/errcode"
)
// flowDriver 工作流驱动器:推进 runState 直到终态或 HITL 挂起边界。
// flowDriver 工作流驱动器:推进 RunState 直到终态或 HITL 挂起边界。
type flowDriver struct {
opts engineDeps
}
// advance 执行到边界;返回 (结果, 挂起上下文),二者互斥非 nil(infra 错误时都为 nil 且 err 非 nil)。
func (d *flowDriver) advance(ctx context.Context, st *runState) (res *RunResult, gate *hitlGate, err error) {
// 推进模型(Q1 WP1/WP2):每轮从权威 Steps 现场编译调度图,按 superstep
// 分层推进——层内无写集冲突的步骤并行(runParallel 复用),有冲突的按声明序
// 串行(含动态调整);动态调整改 Steps 后下一轮自然生效。
func (d *flowDriver) advance(ctx context.Context, st *RunState) (res *RunResult, gate *HitlGate, err error) {
wf := st.Workflow()
wf.Status = "in_progress"
st.setWorkflow(wf)
d.emit(st, Event{Type: EvPhase, Text: "开始执行工作流"})
i := 0
for i < len(st.Plan.Workflow.Steps) {
step := st.Plan.Workflow.Steps[i]
switch step.Status {
case "completed", "skipped", "failed":
i++
continue
superNo := 0
// 运行级截止(Q2 long_running):superstep 边界检查,超期未完步骤置
// skipped 并降级收口;零值/不可解析 = 不设限。
var runDeadline time.Time
if v := st.Meta.Metadata["deadline"]; v != "" {
// RFC3339Nano 同时接受秒级与纳秒级两种写法。
if t, terr := time.Parse(time.RFC3339Nano, v); terr == nil {
runDeadline = t
}
// HITL:角色权限升级(bash=ask 且本运行未授权过该角色)。
role, _ := findAgent(st.Plan.Squad.Agents, step.Role)
if role.BashPermission == BashPermissionAsk && !st.Approved["role:"+role.Role] {
switch d.policyRole(st, role).mode() {
case HitlDisabled, HitlAuto:
step.Error = "permission denied: role " + role.Role + " requires bash approval (hitl mode=" + string(d.policyRole(st, role).mode()) + ")"
st.Plan.Workflow.Steps[i] = step
return d.failStep(ctx, st, i)
default:
return d.hold(st, HitlPermission, step.ID, step.Role,
fmt.Sprintf("角色 %s(%s)声明 bash_permission=ask,是否授权其在本次运行中使用更高权限?", role.Name, role.Role),
[]HumanOption{
{Value: "approve", Label: "授权本次运行"},
{Value: "reject", Label: "拒绝,步骤失败"},
{Value: "abort", Label: "中止运行"},
},
fmt.Sprintf("步骤:%s\n角色:%s\n禁忌:%v", step.Title, role.Role, role.ForbiddenActions), nil)
}
cpw := newCheckpointWriter(CheckpointMode(st.Meta.Metadata["checkpoint_mode"]), st.storeOr())
// 推进退出前 flush:async 落完、on_exit 写最终快照(Q1 WP4 三档的出口语义)。
var lastGraph *graph
defer func() {
if lastGraph == nil {
if g, gerr := compileGraph(st.Plan.Workflow.Steps); gerr == nil {
lastGraph = g
}
}
// HITL:步骤显式要求审批(角色级策略参与判定)。
if step.RequiresApproval && !st.Approved["step:"+step.ID] {
switch d.policyRole(st, role).mode() {
case HitlDisabled, HitlAuto:
step.Error = "approval required but hitl mode is " + string(d.policy(st).mode())
st.Plan.Workflow.Steps[i] = step
return d.failStep(ctx, st, i)
default:
return d.hold(st, HitlConfirmation, step.ID, step.Role,
fmt.Sprintf("步骤「%s」标记为需人工审批后才能执行,是否批准?", step.Title),
[]HumanOption{
{Value: "approve", Label: "批准执行"},
{Value: "skip", Label: "跳过该步骤"},
{Value: "abort", Label: "中止运行"},
},
fmt.Sprintf("角色:%s\n产出:%v", step.Role, step.Targets), nil)
}
if lastGraph != nil {
_ = cpw.flush(st.Meta.WorkDir, snapshotOf(superNo, lastGraph, st.Plan.Workflow.Steps))
}
if step.Parallel {
// 收集相邻并行组,组内并发、组间顺序。
var group []int
for i < len(st.Plan.Workflow.Steps) {
s := st.Plan.Workflow.Steps[i]
if s.Parallel && s.Status != "completed" && s.Status != "skipped" && s.Status != "failed" {
group = append(group, i)
i++
continue
}()
for {
if !runDeadline.IsZero() && time.Now().After(runDeadline) {
d.emit(st, Event{Type: EvPhase, Text: "运行截止时刻已到,未完步骤按 skipped 收口"})
st.mu.Lock()
for i := range st.Plan.Workflow.Steps {
if st.Plan.Workflow.Steps[i].Status == "pending" || st.Plan.Workflow.Steps[i].Status == "running" {
st.Plan.Workflow.Steps[i].Status = "skipped"
st.Plan.Workflow.Steps[i].Error = "deadline exceeded"
}
break
}
if out, g, err := d.runParallel(ctx, st, group); out != nil || g != nil || err != nil {
if out == nil && g != nil {
out = heldResult(st, g)
_ = st.save()
st.mu.Unlock()
return d.finalize(ctx, st)
}
g, gerr := compileGraph(st.Plan.Workflow.Steps)
if gerr != nil {
wf2 := st.Workflow()
wf2.Status = "failed"
st.setWorkflow(wf2)
return &RunResult{RunID: st.Meta.ID, Status: RunFailed, Plan: planPtr(st),
Error: "graph: " + gerr.Error(), WorkDir: st.Meta.WorkDir}, nil, nil
}
layer, ok := g.nextLayer(st.Plan.Workflow.Steps)
if !ok {
if n := pendingCount(st.Plan.Workflow.Steps); n > 0 {
wf2 := st.Workflow()
wf2.Status = "failed"
st.setWorkflow(wf2)
return &RunResult{RunID: st.Meta.ID, Status: RunFailed, Plan: planPtr(st),
Error: fmt.Sprintf("graph: 依赖无法满足,仍有 %d 个步骤无法调度", n), WorkDir: st.Meta.WorkDir}, nil, nil
}
break
}
superNo++
d.emit(st, Event{Type: EvPhase, Text: fmt.Sprintf("superstep %d 推进(并行 %d 步,串行 %d 步)", superNo, len(layer.par), len(layer.seq))})
// 并行段:组内每一步都先过审批和权限,任一需要挂起则整组不执行。
if len(layer.par) > 0 {
for _, idx := range layer.par {
if out, g2, err := d.gateBeforeRun(ctx, st, idx); out != nil || g2 != nil || err != nil {
if out == nil && g2 != nil {
out = heldResult(st, g2)
}
return out, g2, err
}
return out, g, err
}
if out, g2, err := d.runParallel(ctx, st, layer.par); out != nil || g2 != nil || err != nil {
if out == nil && g2 != nil {
out = heldResult(st, g2)
}
return out, g2, err
}
if g2 := d.checkBlockingGaps(st); g2 != nil {
return heldResult(st, g2), g2, nil
}
continue
}
out, g, err := d.runOneStep(ctx, st, i)
if out != nil || g != nil || err != nil {
if out == nil && g != nil {
out = heldResult(st, g)
}
return out, g, err
}
i++
// 阻塞缺口挂起。
if g2 := d.checkBlockingGaps(st); g2 != nil {
return heldResult(st, g2), g2, nil
}
// 动态调整(仅顺序步骤后,避免并发竞态)。
if st.Plan.Workflow.Steps[i-1].AllowDynamicAdjustment && d.opts.Evaluator != nil {
out, g, err := d.applyDynamicAdjustment(ctx, st, i-1)
if out != nil || g != nil || err != nil {
if out == nil && g != nil {
out = heldResult(st, g)
// 单成员并行组实际是顺序执行:与旧线性扫描一致,其后可动态调整
// (多成员组不做,避免并发竞态)。
if len(layer.par) == 1 {
idx := layer.par[0]
if st.Plan.Workflow.Steps[idx].AllowDynamicAdjustment && d.opts.Evaluator != nil {
out, g2, err := d.applyDynamicAdjustment(ctx, st, idx)
if out != nil || g2 != nil || err != nil {
if out == nil && g2 != nil {
out = heldResult(st, g2)
}
return out, g2, err
}
}
return out, g, err
}
}
// 串行段(写集冲突步骤,按声明序逐个执行;顺序步骤后可动态调整)。
for _, idx := range layer.seq {
if out, g2, err := d.gateBeforeRun(ctx, st, idx); out != nil || g2 != nil || err != nil {
if out == nil && g2 != nil {
out = heldResult(st, g2)
}
return out, g2, err
}
out, g2, err := d.runOneStep(ctx, st, idx)
if out != nil || g2 != nil || err != nil {
if out == nil && g2 != nil {
out = heldResult(st, g2)
}
return out, g2, err
}
if g2 := d.checkBlockingGaps(st); g2 != nil {
return heldResult(st, g2), g2, nil
}
if st.Plan.Workflow.Steps[idx].AllowDynamicAdjustment && d.opts.Evaluator != nil {
out, g2, err := d.applyDynamicAdjustment(ctx, st, idx)
if out != nil || g2 != nil || err != nil {
if out == nil && g2 != nil {
out = heldResult(st, g2)
}
return out, g2, err
}
}
}
// superstep 边界快照(sync 立即 / async 入队;on_exit 由 defer 的 flush 写)。
lastGraph = g
if err := cpw.boundary(st.Meta.WorkDir, snapshotOf(superNo, g, st.Plan.Workflow.Steps)); err != nil {
return nil, nil, err
}
}
return d.finalize(ctx, st)
}
// snapshotOf 组装一个 superstep 边界快照:序号、图结构指纹与全量步骤状态。
func snapshotOf(superNo int, g *graph, steps []StepSpec) Checkpoint {
cp := Checkpoint{Superstep: superNo, At: time.Now(), GraphHash: g.hash()}
for _, s := range steps {
cp.StepStates = append(cp.StepStates, StepSnapshot{ID: s.ID, Status: s.Status, AttemptID: s.AttemptID})
}
return cp
}
// gateBeforeRun 在步骤真正执行前检查权限升级和显式审批。
// 返回非空结果或挂起时,调用方不得执行该步骤,也不得执行它所在的并行组。
func (d *flowDriver) gateBeforeRun(ctx context.Context, st *RunState, i int) (*RunResult, *HitlGate, error) {
step := st.Plan.Workflow.Steps[i]
role, _ := findAgent(st.Plan.Squad.Agents, step.Role)
if role.BashPermission == BashPermissionAsk && !st.Approved["role:"+role.Role] {
switch d.policyRole(st, role).mode() {
case HitlDisabled, HitlAuto:
step.Error = "permission denied: role " + role.Role + " requires bash approval (hitl mode=" + string(d.policyRole(st, role).mode()) + ")"
st.Plan.Workflow.Steps[i] = step
return d.failStep(ctx, st, i)
default:
return d.hold(st, HitlPermission, step.ID, step.Role,
fmt.Sprintf("角色 %s(%s)声明 bash_permission=ask,是否授权其在本次运行中使用更高权限?", role.Name, role.Role),
[]HumanOption{
{Value: "approve", Label: "授权本次运行"},
{Value: "reject", Label: "拒绝,步骤失败"},
{Value: "abort", Label: "中止运行"},
},
fmt.Sprintf("步骤:%s\n角色:%s\n禁忌:%v", step.Title, role.Role, role.ForbiddenActions), "", nil)
}
}
if step.RequiresApproval && !st.Approved["step:"+step.ID] {
switch d.policyRole(st, role).mode() {
case HitlDisabled, HitlAuto:
step.Error = "approval required but hitl mode is " + string(d.policy(st).mode())
st.Plan.Workflow.Steps[i] = step
return d.failStep(ctx, st, i)
default:
return d.hold(st, HitlConfirmation, step.ID, step.Role,
fmt.Sprintf("步骤「%s」标记为需人工审批后才能执行,是否批准?", step.Title),
[]HumanOption{
{Value: "approve", Label: "批准执行"},
{Value: "skip", Label: "跳过该步骤"},
{Value: "abort", Label: "中止运行"},
},
fmt.Sprintf("角色:%s\n产出:%v", step.Role, step.Targets), "step_approval", nil)
}
}
return nil, nil, nil
}
// runParallel 并发执行一个并行组;关键步骤失败即终止整个运行。
func (d *flowDriver) runParallel(ctx context.Context, st *runState, idxs []int) (*RunResult, *hitlGate, error) {
func (d *flowDriver) runParallel(ctx context.Context, st *RunState, idxs []int) (*RunResult, *HitlGate, error) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
var wg sync.WaitGroup
var mu sync.Mutex
failedCritical := -1
var firstFail *RunResult
var gate *HitlGate
for _, idx := range idxs {
wg.Add(1)
go func(idx int) {
defer wg.Done()
out, _, err := d.runOneStep(ctx, st, idx)
out, g, err := d.runOneStep(ctx, st, idx)
mu.Lock()
defer mu.Unlock()
if g != nil && gate == nil {
gate = g
cancel()
}
if (out != nil && out.Status == RunFailed) || err != nil {
cancel()
// 保留首个带具体错误的失败结果(如 router 无命中);
// failStep 的通用格式只在没有更具体结果时兜底。
if out != nil && out.Status == RunFailed && firstFail == nil {
firstFail = out
}
if st.Plan.Workflow.Steps[idx].IsCritical() && failedCritical < 0 {
failedCritical = idx
}
@@ -148,6 +246,12 @@ func (d *flowDriver) runParallel(ctx context.Context, st *runState, idxs []int)
}(idx)
}
wg.Wait()
if gate != nil {
return heldResult(st, gate), gate, nil
}
if firstFail != nil {
return firstFail, nil, nil
}
if failedCritical >= 0 {
return d.failStep(ctx, st, failedCritical)
}
@@ -155,7 +259,10 @@ func (d *flowDriver) runParallel(ctx context.Context, st *runState, idxs []int)
}
// runOneStep 执行单个步骤(含瞬态重试与交付物校验)。
func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*RunResult, *hitlGate, error) {
func (d *flowDriver) runOneStep(ctx context.Context, st *RunState, idx int) (*RunResult, *HitlGate, error) {
if err := ctx.Err(); err != nil {
return &RunResult{RunID: st.Meta.ID, Status: RunCancelled, Error: err.Error()}, nil, err
}
step := st.Plan.Workflow.Steps[idx]
role, ok := findAgent(st.Plan.Squad.Agents, step.Role)
if !ok {
@@ -164,12 +271,24 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*Ru
st.mu.Lock()
st.Plan.Squad.Agents = append(st.Plan.Squad.Agents, r)
st.mu.Unlock()
} else if step.IntentID != "" {
role = RoleSpec{Role: step.Role, Name: step.Role}
} else {
step.Status, step.Error = "failed", "role not found: "+step.Role
st.setStep(idx, step)
return d.failStep(ctx, st, idx)
}
}
// handoff 原语 fail-closed(Q2):目标角色必须可解析,启动前拒绝。
if step.Handoff != "" {
if _, ok := findAgent(st.Plan.Squad.Agents, step.Handoff); !ok {
if _, exists := d.opts.Registry.Get(step.Handoff); !exists {
step.Status, step.Error = "failed", "handoff: 未知目标角色 "+step.Handoff
st.setStep(idx, step)
return d.failStep(ctx, st, idx)
}
}
}
subDir := filepath.Join(st.Meta.WorkDir, filepath.FromSlash(orDefault(step.SubWorkspace, fmt.Sprintf("tasks/%02d_%s", idx+1, step.Role))))
if err := os.MkdirAll(subDir, 0o755); err != nil {
return nil, nil, err
@@ -180,15 +299,41 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*Ru
d.emit(st, Event{Type: EvStepStarted, StepID: step.ID, Role: step.Role, Text: step.Title})
step.Status = "running"
step.Attempts++
step.AttemptID = fmt.Sprintf("%s/%s/%d", st.Meta.ID, step.ID, step.Attempts)
step.Error = ""
st.setStep(idx, step)
// 与收口落盘同一把锁:并行步骤一边改 Steps、一边全量 marshal 会数据竞争。
st.mu.Lock()
st.Plan.Workflow.Steps[idx] = step
saveErr := st.save()
st.mu.Unlock()
if saveErr != nil {
return nil, nil, saveErr
}
started := time.Now()
if d.opts.Workspaces != nil {
if err := d.opts.Workspaces.writeTaskFile(subDir, &step, role, &st.BB); err != nil {
return nil, nil, err
}
}
prompt := readFileOr(filepath.Join(subDir, "TASK.md"), step.Title)
prompt = st.injectSteering(prompt)
// 邮箱投递(Q2):本步骤启动时把发给该角色(含广播)的未读消息并入
// 任务书;只走 prompt 面,永不触碰 HITL 门(验收三构造性保证)。
st.mu.Lock()
mail := takeMailFor(st, step.Role)
handoff := takeHandoffFor(st, step.Role)
if len(mail) > 0 || handoff != nil {
_ = st.save()
}
st.mu.Unlock()
prompt += formatMailSection(mail)
// handoff 原语(Q2):目标角色的步骤接收过滤历史(只带移交方主产出)。
prompt += formatHandoffSection(handoff)
instructions := readFileOr(filepath.Join(subDir, "AGENTS.md"), d.opts.Workspaces.ResolveInstructions(role))
var backend exec.Backend
var backend Executor
backendName := ""
if step.IntentID != "" {
backendName = "intent:" + step.IntentID
@@ -208,13 +353,13 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*Ru
}
// 步骤级共享预算:重试与首次尝试共用同一截止时刻
//(agent-execution 标准:重试层共享总预算,不随次数翻倍)。
var stepBudget *exec.Budget
var stepBudget *ExecBudget
if d.opts.StepTimeout > 0 {
stepBudget = &exec.Budget{Deadline: time.Now().Add(d.opts.StepTimeout)}
stepBudget = &ExecBudget{Deadline: time.Now().Add(d.opts.StepTimeout)}
}
var result *exec.Result
var result *ExecResult
var err error
for attempt := 1; attempt <= exec.MaxTransientStepInvocations; attempt++ {
for attempt := 1; attempt <= MaxTransientStepInvocations; attempt++ {
reqPrompt := prompt
if attempt > 1 {
prevReason := "交付物缺失"
@@ -225,14 +370,14 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*Ru
}
reqPrompt += "\n\n【重试提示】上一次执行未成功(" + prevReason + ")。请务必把结果写入:" + strings.Join(targets, ", ")
}
req := exec.Request{
req := ExecRequest{
WorkDir: subDir,
Prompt: reqPrompt,
Instructions: instructions,
Targets: targets,
Timeout: d.opts.StepTimeout,
ToolProfile: orDefault(role.ToolProfile, exec.ToolProfileWorkspace),
OnEvent: func(ev exec.ProcessEvent) {
ToolProfile: orDefault(role.ToolProfile, ToolProfileWorkspace),
OnEvent: func(ev ProcessEvent) {
d.emit(st, Event{Type: EvDetail, StepID: step.ID, Role: step.Role, Text: ev.Text,
Data: marshalJSON(map[string]string{"process_type": ev.Type, "tool": ev.Tool, "detail": ev.Detail})})
},
@@ -240,7 +385,7 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*Ru
ToolAllowlist: role.Tools,
Skills: firstNonEmptySlice(step.Skills, role.Skills),
SessionScope: st.Meta.ID + "/" + step.ID,
ExecutionID: st.Meta.ID + "-" + step.ID,
ExecutionID: step.AttemptID,
Budget: stepBudget,
}
if step.IntentID != "" {
@@ -256,7 +401,7 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*Ru
Title: step.Title, WorkDir: subDir, Targets: targets,
})
if err == nil && result != nil && len(result.Patches) > 0 {
_, _ = exec.MaterializeTargets(subDir, targets, result.Patches)
_, _ = MaterializeTargets(subDir, targets, result.Patches)
}
} else {
result, err = backend.Execute(ctx, req)
@@ -266,19 +411,19 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*Ru
}
// 交付物物化:后端返回 patches 但未写盘时补写。
if err == nil && len(result.Patches) > 0 {
if _, merr := exec.MaterializeTargets(subDir, targets, result.Patches); merr == nil {
if _, merr := MaterializeTargets(subDir, targets, result.Patches); merr == nil {
if allTargetsPresent(subDir, targets) {
result.Success = true
break
}
}
}
assessment := exec.AssessStepFailure(exec.StepFailureSignals{
assessment := AssessStepFailure(StepFailureSignals{
Err: err, Result: result, WorkDir: subDir,
ExtraText: fmt.Sprintf("step=%s backend=%s", step.ID, backendName),
TargetMissing: !allTargetsPresent(subDir, targets),
})
if assessment.Class != exec.FailureTransient || attempt == exec.MaxTransientStepInvocations {
if assessment.Class != FailureTransient || attempt == MaxTransientStepInvocations {
step.Status = "failed"
if err != nil {
step.Error = err.Error()
@@ -331,11 +476,156 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*Ru
}
d.emit(st, Event{Type: EvStepCompleted, StepID: step.ID, Role: step.Role, Text: step.Title,
Data: marshalJSON(map[string]any{"duration_ms": step.DurationMs, "targets": targets})})
// handoff 原语(Q2):完成即移交最终答案所有权——记录 + 主产出快照,
// 供目标角色下一步骤(过滤历史)与 finalize(链尾产出)消费。
if step.Handoff != "" {
d.recordHandoff(st, step, subDir, targets)
}
// 循环步再武装(Q2 loop):停机条件不满足且预算未尽 → 本步与 loop.body
// 一起重置为 pending;触顶发 loop_exhausted 事件后按完成继续。
if step.Loop != nil {
if d.loopShouldRearm(st, step) {
if out, g2, err2 := d.rearmLoop(ctx, st, idx, step); out != nil || g2 != nil || err2 != nil {
return out, g2, err2
}
return nil, nil, nil
}
if step.Loop.UntilField != "" && step.LoopRuns+1 >= step.Loop.MaxIterations {
d.emit(st, Event{Type: EvPhase, StepID: step.ID, Text: fmt.Sprintf("loop_exhausted:%s 达 %d 轮上限,停机条件未满足", step.ID, step.Loop.MaxIterations)})
}
}
// 路由步分支裁决(Q2 router):首个命中的分支保留,其余分支目标置
// skipped;无分支命中 fail-closed 成失败运行。
if len(step.Branches) > 0 {
return d.applyBranches(ctx, st, idx, step)
}
return nil, nil, nil
}
// loopShouldRearm 判定循环步是否再武装:停机条件(黑板字段等值)满足或
// 预算用尽则停。字段判据读的是本轮 mergeStepArtifacts 之后的黑板。
func (d *flowDriver) loopShouldRearm(st *RunState, step StepSpec) bool {
// MaxIterations 是总迭代上限(含首跑):已跑 LoopRuns+1 次。
if step.LoopRuns+1 >= step.Loop.MaxIterations {
return false
}
if step.Loop.UntilField != "" {
st.mu.Lock()
v := st.BB.Fields[step.Loop.UntilField]
st.mu.Unlock()
if v == step.Loop.UntilEquals {
return false
}
}
return true
}
// rearmLoop 把循环步与 loop.body 一起重置为 pending(同一临界区内落盘)。
func (d *flowDriver) rearmLoop(_ context.Context, st *RunState, idx int, step StepSpec) (*RunResult, *HitlGate, error) {
st.mu.Lock()
step.LoopRuns++
step.Status = "pending"
step.Error = ""
st.Plan.Workflow.Steps[idx] = step
for _, bodyID := range step.Loop.Body {
for j := range st.Plan.Workflow.Steps {
if st.Plan.Workflow.Steps[j].ID == bodyID && st.Plan.Workflow.Steps[j].Status == "completed" {
st.Plan.Workflow.Steps[j].Status = "pending"
st.Plan.Workflow.Steps[j].Error = ""
}
}
}
err := st.save()
round := step.LoopRuns
st.mu.Unlock()
if err != nil {
return nil, nil, err
}
d.emit(st, Event{Type: EvPhase, StepID: step.ID, Text: fmt.Sprintf("loop 第 %d 轮再武装:%s", round+1, step.ID)})
return nil, nil, nil
}
// applyBranches 裁决路由步分支:When 按声明序取首个命中;未选中分支的
// pending 目标置 skipped(skipped=依赖已满足,join 步照常推进)。
func (d *flowDriver) applyBranches(_ context.Context, st *RunState, _ int, step StepSpec) (*RunResult, *HitlGate, error) {
st.mu.Lock()
fields := map[string]string{}
for k, v := range st.BB.Fields {
fields[k] = v
}
st.mu.Unlock()
var selected *Branch
for i := range step.Branches {
if branchMatches(fields, step.Branches[i].When) {
selected = &step.Branches[i]
break
}
}
if selected == nil {
wf := st.Workflow()
wf.Status = "failed"
st.setWorkflow(wf)
_ = st.save()
return &RunResult{RunID: st.Meta.ID, Status: RunFailed, Plan: planPtr(st),
Error: fmt.Sprintf("router: 步骤 %s 无分支命中(fail-closed)", step.ID), WorkDir: st.Meta.WorkDir}, nil, nil
}
keep := map[string]bool{}
for _, t := range selected.Then {
keep[t] = true
}
skipped := 0
st.mu.Lock()
for j := range st.Plan.Workflow.Steps {
sj := &st.Plan.Workflow.Steps[j]
if sj.ID == step.ID || keep[sj.ID] || sj.Status != "pending" {
continue
}
for _, br := range step.Branches {
inBranch := false
for _, t := range br.Then {
if t == sj.ID {
inBranch = true
break
}
}
if inBranch {
sj.Status = "skipped"
sj.Error = routerSkipReason
skipped++
break
}
}
}
err := st.save()
st.mu.Unlock()
if err != nil {
return nil, nil, err
}
d.emit(st, Event{Type: EvPhase, StepID: step.ID,
Text: fmt.Sprintf("router %s 选中分支 %v(跳过 %d 步)", step.ID, selected.Then, skipped)})
return nil, nil, nil
}
// routerSkipReason 是分支落选的 skipped 标记(finalize 的降级豁免凭据)。
const routerSkipReason = "router: 分支未选中"
// branchMatches 判定分支条件:Equals 非空须相等,NotEquals 非空须不等;
// 字段未设时按零值 "" 参与比较(compileGraph 已保证两者至少其一非空)。
func branchMatches(fields map[string]string, c BranchCond) bool {
v := fields[c.Field]
if c.Equals != "" && v != c.Equals {
return false
}
if c.NotEquals != "" && v == c.NotEquals {
return false
}
return true
}
// failStep 按关键性处理失败步骤:关键 → 运行失败;非关键 → 标记后继续(返回 nil 让循环推进)。
func (d *flowDriver) failStep(ctx context.Context, st *runState, idx int) (*RunResult, *hitlGate, error) {
func (d *flowDriver) failStep(ctx context.Context, st *RunState, idx int) (*RunResult, *HitlGate, error) {
st.mu.Lock()
step := st.Plan.Workflow.Steps[idx]
critical := step.IsCritical()
@@ -359,7 +649,7 @@ func (d *flowDriver) failStep(ctx context.Context, st *runState, idx int) (*RunR
}
// checkBlockingGaps 检查未解决阻塞缺口;interactive 挂起,auto 记录兜底假设并继续。
func (d *flowDriver) checkBlockingGaps(st *runState) *hitlGate {
func (d *flowDriver) checkBlockingGaps(st *RunState) *HitlGate {
gaps := st.BB.UnresolvedBlockingGaps()
if len(gaps) == 0 {
return nil
@@ -401,7 +691,7 @@ func (d *flowDriver) checkBlockingGaps(st *runState) *hitlGate {
Context: strings.Join(lines, "\n"),
CreatedAt: time.Now(), Status: "pending",
}
gate := &hitlGate{Request: *req, PrevStatus: RunExecuting}
gate := &HitlGate{Request: *req, PrevStatus: RunExecuting}
if err := st.saveGate(gate); err == nil {
st.Meta.Status = string(RunWaitingHuman)
_ = st.save()
@@ -411,7 +701,7 @@ func (d *flowDriver) checkBlockingGaps(st *runState) *hitlGate {
}
// applyDynamicAdjustment 评估步骤产出并应用/审批动态调整。
func (d *flowDriver) applyDynamicAdjustment(ctx context.Context, st *runState, idx int) (*RunResult, *hitlGate, error) {
func (d *flowDriver) applyDynamicAdjustment(ctx context.Context, st *RunState, idx int) (*RunResult, *HitlGate, error) {
step := st.Plan.Workflow.Steps[idx]
subDir := filepath.Join(st.Meta.WorkDir, filepath.FromSlash(step.SubWorkspace))
output := readFileOr(filepath.Join(subDir, firstTarget(step.Targets)), "")
@@ -451,7 +741,7 @@ func (d *flowDriver) applyDynamicAdjustment(ctx context.Context, st *runState, i
evalRes.Reason, strings.Join(addLines, "\n"), strings.Join(skipLines, "\n")),
CreatedAt: time.Now(), Status: "pending",
}
gate := &hitlGate{Request: *req, Steps: evalRes.DynamicStepsToAdd, SkipIDs: evalRes.StepsToSkip, PrevStatus: RunExecuting}
gate := &HitlGate{Request: *req, Steps: evalRes.DynamicStepsToAdd, SkipIDs: evalRes.StepsToSkip, PrevStatus: RunExecuting}
if err := st.saveGate(gate); err == nil {
st.Meta.Status = string(RunWaitingHuman)
_ = st.save()
@@ -464,7 +754,7 @@ func (d *flowDriver) applyDynamicAdjustment(ctx context.Context, st *runState, i
}
// applyAdjustment 应用动态调整:新步骤插到最后一步之前,跳过步骤标记 skipped。
func applyAdjustment(st *runState, add []StepSpec, skipIDs []string) {
func applyAdjustment(st *RunState, add []StepSpec, skipIDs []string) {
steps := st.Plan.Workflow.Steps
for _, id := range skipIDs {
for i := range steps {
@@ -491,21 +781,34 @@ func applyAdjustment(st *runState, add []StepSpec, skipIDs []string) {
}
// finalize 收口:汇总交付物 → 质检 → 自动修复 → (必要时)人工裁决 → 清单。
func (d *flowDriver) finalize(ctx context.Context, st *runState) (*RunResult, *hitlGate, error) {
func (d *flowDriver) finalize(ctx context.Context, st *RunState) (*RunResult, *HitlGate, error) {
wf := st.Workflow()
degraded := false
for _, s := range wf.Steps {
if s.Status == "failed" || s.Status == "skipped" {
if s.Status == "failed" {
degraded = true
}
// router 分支落选是预期路径,不算降级(Q2)。
if s.Status == "skipped" && s.Error != routerSkipReason {
degraded = true
}
}
// 汇总交付物:最后一个 completed 步骤的主产出 → output.md。
// handoff 链尾优先(Q2):有所有权移交时,交付物取链尾角色的最后一个
// completed 步骤产出(最终答案所有权在链尾),无则该角色无完成步骤时回落。
var srcStep *StepSpec
for i := range wf.Steps {
if wf.Steps[i].Status == "completed" {
srcStep = &wf.Steps[i]
}
}
if tail := handoffChainTail(st); tail != "" {
for i := range wf.Steps {
if wf.Steps[i].Status == "completed" && wf.Steps[i].Role == tail {
srcStep = &wf.Steps[i]
}
}
}
if srcStep == nil {
wf.Status = "failed"
st.setWorkflow(wf)
@@ -573,7 +876,7 @@ func (d *flowDriver) finalize(ctx context.Context, st *runState) (*RunResult, *h
Context: string(marshalJSON(report)),
CreatedAt: time.Now(), Status: "pending",
}
gate := &hitlGate{Request: *req, PrevStatus: RunExecuting}
gate := &HitlGate{Request: *req, PrevStatus: RunExecuting}
if err := st.saveGate(gate); err == nil {
st.Meta.Status = string(RunQualityReview)
_ = st.save()
@@ -607,7 +910,7 @@ func (d *flowDriver) finalize(ctx context.Context, st *runState) (*RunResult, *h
}
// rerunForRepair 重置交付物源步骤并重新执行到收口。
func (d *flowDriver) rerunForRepair(ctx context.Context, st *runState, stepID string) (*RunResult, *hitlGate, error) {
func (d *flowDriver) rerunForRepair(ctx context.Context, st *RunState, stepID string) (*RunResult, *HitlGate, error) {
for i := range st.Plan.Workflow.Steps {
if st.Plan.Workflow.Steps[i].ID == stepID {
st.Plan.Workflow.Steps[i].Status = "pending"
@@ -625,7 +928,7 @@ func (d *flowDriver) rerunForRepair(ctx context.Context, st *runState, stepID st
}
// heldResult 由挂起上下文构造对外结果(挂起路径统一返回非 nil 结果)。
func heldResult(st *runState, gate *hitlGate) *RunResult {
func heldResult(st *RunState, gate *HitlGate) *RunResult {
status := RunWaitingHuman
switch gate.Request.Kind {
case HitlClarification:
@@ -655,11 +958,11 @@ func waitConditionFrom(gaps []GapItem) *WaitCondition {
}
// hold 生成挂起请求并返回结果。
func (d *flowDriver) hold(st *runState, kind HumanRequestKind, stepID, role, question string, options []HumanOption, context string, extra *hitlGate) (*RunResult, *hitlGate, error) {
func (d *flowDriver) hold(st *RunState, kind HumanRequestKind, stepID, role, question string, options []HumanOption, context, reason string, extra *HitlGate) (*RunResult, *HitlGate, error) {
req := &HumanRequest{
ID: newRequestID(), RunID: st.Meta.ID, StepID: stepID, Kind: kind,
Severity: SeverityActionRequired,
Question: question, Options: options, Context: context,
Question: question, Options: options, Context: context, Reason: reason,
CreatedAt: time.Now(), Status: "pending",
}
status := RunWaitingHuman
@@ -671,7 +974,7 @@ func (d *flowDriver) hold(st *runState, kind HumanRequestKind, stepID, role, que
}
gate := extra
if gate == nil {
gate = &hitlGate{}
gate = &HitlGate{}
}
gate.Request = *req
gate.PrevStatus = RunExecuting
@@ -688,7 +991,7 @@ func (d *flowDriver) hold(st *runState, kind HumanRequestKind, stepID, role, que
// policy 合并引擎全局策略与 Goal 覆盖(窄者胜)。
// policyRole 在引擎全局/Goal 链之上叠加角色级 HitlPolicy
// (窄者胜;Goal 仍最高)。
func (d *flowDriver) policyRole(st *runState, role RoleSpec) HitlPolicy {
func (d *flowDriver) policyRole(st *RunState, role RoleSpec) HitlPolicy {
p := d.policy(st)
if role.HitlPolicy != nil {
p = mergeHitlPolicy(p, role.HitlPolicy)
@@ -696,11 +999,11 @@ func (d *flowDriver) policyRole(st *runState, role RoleSpec) HitlPolicy {
return p
}
func (d *flowDriver) policy(st *runState) HitlPolicy {
func (d *flowDriver) policy(st *RunState) HitlPolicy {
return mergeHitlPolicy(d.opts.HitlPolicy, st.Meta.HitlPolicyOf())
}
func (d *flowDriver) emit(st *runState, ev Event) {
func (d *flowDriver) emit(st *RunState, ev Event) {
if d.opts.Sink == nil {
return
}
@@ -711,11 +1014,11 @@ func (d *flowDriver) emit(st *runState, ev Event) {
d.opts.Sink.Emit(ev)
}
func (d *flowDriver) emitHuman(st *runState, req *HumanRequest) {
func (d *flowDriver) emitHuman(st *RunState, req *HumanRequest) {
d.emit(st, Event{Type: EvHumanInputRequired, Text: req.Question, Data: marshalJSON(req)})
}
func summarize(st *runState, report QualityReport, status RunStatus) string {
func summarize(st *RunState, report QualityReport, status RunStatus) string {
var b strings.Builder
fmt.Fprintf(&b, "运行 %s:%s。步骤 %d 个(完成 %d/跳过 %d/失败 %d)。质检得分 %.0f。",
st.Meta.ID, status, len(st.Plan.Workflow.Steps),
@@ -726,7 +1029,7 @@ func summarize(st *runState, report QualityReport, status RunStatus) string {
return b.String()
}
func countStatus(st *runState, status string) int {
func countStatus(st *RunState, status string) int {
n := 0
for _, s := range st.Plan.Workflow.Steps {
if s.Status == status {
@@ -736,7 +1039,7 @@ func countStatus(st *runState, status string) int {
return n
}
func buildManifest(st *runState, deliverable string, report QualityReport) Manifest {
func buildManifest(st *RunState, deliverable string, report QualityReport) Manifest {
files := map[string]string{}
_ = filepath.Walk(st.Meta.WorkDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
@@ -795,7 +1098,7 @@ func appendLog(dir, line string) {
_, _ = f.WriteString(line)
}
func planPtr(st *runState) *WorkflowPlan {
func planPtr(st *RunState) *WorkflowPlan {
wf := st.Workflow()
return &wf
}
+47 -13
View File
@@ -34,18 +34,9 @@ func (e *WorkspaceEngine) PrepareWorkspace(meta *RunMetadata, plan *SquadPlan) (
}
meta.WorkDir = wsDir
meta.UpdatedAt = time.Now()
if err := writeJSON(filepath.Join(wsDir, "workspace.json"), meta); err != nil {
return "", err
}
if err := writeJSON(filepath.Join(wsDir, "squad.json"), &plan.Squad); err != nil {
return "", err
}
if err := writeJSON(filepath.Join(wsDir, "workflow.json"), &plan.Workflow); err != nil {
return "", err
}
if err := saveBlackboard(wsDir, &BlackboardState{Fields: map[string]string{}}); err != nil {
return "", err
}
// 注意:run 级状态 JSON 不在此处写——由 st.save() 经 Store 端口统一落盘
// (Q1 WP5:SQLite 后端时这些状态进数据库而非文件)。这里只建目录与
// 上下文/模板等运行无关状态的工作区资产。
ctxIdx, err := e.writeContextAssets(wsDir, &meta.Goal, plan)
if err != nil {
return "", err
@@ -198,6 +189,13 @@ func (e *WorkspaceEngine) writeTaskFile(subDir string, step *StepSpec, role Role
fmt.Fprintf(&b, "- 角色:%s(%s)\n", role.Name, role.Role)
fmt.Fprintf(&b, "- 期望产出:%v\n\n", step.Targets)
// 编译期任务指令(Q3):编译器渲染好的富文本任务书,权威仅次于目标。
if strings.TrimSpace(step.Brief) != "" {
b.WriteString("## 任务指令\n\n")
b.WriteString(strings.TrimSpace(step.Brief))
b.WriteString("\n\n")
}
b.WriteString("## 任务上下文资源与权威等级(已快照到本目录,只引用本地路径)\n\n")
b.WriteString("| 资源路径 | 权威等级 | 用途说明 | 使用与引用指导 |\n|---|---|---|---|\n")
b.WriteString("| `context/goal.md` | **authoritative** | 运行目标 | 不可推翻 |\n")
@@ -309,5 +307,41 @@ func writeJSON(path string, v any) error {
if err != nil {
return err
}
return os.WriteFile(path, data, 0o644)
data = append(data, '\n')
return AtomicWrite(path, data)
}
// AtomicWrite 在同目录创建临时文件,写入后 Sync、Close,再 rename 到目标路径。
func AtomicWrite(path string, data []byte) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
f, err := os.CreateTemp(dir, ".tmp-*")
if err != nil {
return err
}
tmp := f.Name()
ok := false
defer func() {
if !ok {
_ = os.Remove(tmp)
}
}()
if _, err := f.Write(data); err != nil {
_ = f.Close()
return err
}
if err := f.Sync(); err != nil {
_ = f.Close()
return err
}
if err := f.Close(); err != nil {
return err
}
if err := os.Rename(tmp, path); err != nil {
return err
}
ok = true
return nil
}
+452
View File
@@ -0,0 +1,452 @@
package squad
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// wp3_conformance_test.go — Q2 WP3 验收:review 拓扑与 backend runReview 等价
// (验收二:并行找问题、逐条验证、丢掉未验证)、handoff 原语(所有权转移 +
// 过滤历史 + finalize 链尾)、group_chat 驱动(轮转/主持人 + 预算停机)。
// ---- review:与 backend review_test.go 同款断言 ----
// reviewScriptBackend finder 目录回放 findings.json(第 3 个进入者放行,钉住
// 真并发);verify_* 目录按 prompt 内容回放判定(与 backend 夹具同文案)。
type reviewScriptBackend struct {
mu sync.Mutex
once sync.Once
entered int
verifyCalls int
release chan struct{}
}
func (b *reviewScriptBackend) Name() string { return "review-script" }
func (b *reviewScriptBackend) Execute(_ context.Context, req ExecRequest) (*ExecResult, error) {
key := filepath.Base(filepath.Clean(req.WorkDir))
if strings.HasPrefix(key, "verify_") {
b.mu.Lock()
b.verifyCalls++
b.mu.Unlock()
verdict := "看不出对错"
switch {
case strings.Contains(req.Prompt, "无来源"):
verdict = "核对原文确实没有来源。\nVERDICT: PASS"
case strings.Contains(req.Prompt, "错字"):
verdict = "核对后成立。\nVERDICT: PASS"
case strings.Contains(req.Prompt, "过期数据"):
verdict = "数据仍有效。\nVERDICT: FAIL"
}
return writeOne(req.WorkDir, "verdict.md", verdict), nil
}
// finder:三个必须同时在跑(backend review_test.go:16-33 同款门)。
b.mu.Lock()
b.entered++
n := b.entered
b.mu.Unlock()
if n == 3 {
b.once.Do(func() { close(b.release) })
}
select {
case <-b.release:
case <-time.After(time.Second):
return nil, fmt.Errorf("finders did not run together")
}
var findings string
switch key {
case "01_researcher":
findings = `[{"location":"第2段","severity":"high","issue":"无来源","evidence":"显然"},{"location":"第1段","severity":"low","issue":"错字","evidence":"的地"},{"location":"表1","severity":"medium","issue":"过期数据","evidence":"2020"}]`
case "02_writer":
findings = `[{"location":"第2段","severity":"high","issue":"无来源","evidence":"显然"},{"location":"附录","severity":"medium","issue":"数字不一致","evidence":"3 与 4"}]`
default:
findings = `[{"location":"结尾","severity":"low","issue":"没有结论","evidence":"最后一句"}]`
}
return writeOne(req.WorkDir, "findings.json", findings), nil
}
func writeOne(dir, name, content string) *ExecResult {
out := filepath.Join(dir, name)
_ = os.MkdirAll(filepath.Dir(out), 0o755)
_ = os.WriteFile(out, []byte(content), 0o644)
return &ExecResult{Success: true, Patches: map[string][]byte{name: []byte(content)}}
}
func TestTopologyConformance_review(t *testing.T) {
be := &reviewScriptBackend{release: make(chan struct{})}
sink := &collectSink{}
planner := mockScriptPlanner(
StepSpec{ID: "f1", Role: "researcher", Title: "评审一", Targets: []string{"findings.json"}},
StepSpec{ID: "f2", Role: "writer", Title: "评审二", Targets: []string{"findings.json"}},
StepSpec{ID: "f3", Role: "reviewer", Title: "评审三", Targets: []string{"findings.json"}},
)
planner.Agents = []RoleSpec{
{Role: "researcher", Name: "评审一", Backend: "review-script"},
{Role: "writer", Name: "评审二", Backend: "review-script"},
{Role: "reviewer", Name: "评审三", Backend: "review-script"},
{Role: "verifier", Name: "核实", Backend: "review-script"},
}
engine := newWP3Engine(t, planner, be, sink, []RoleSpec{
{Role: "researcher", Name: "评审一", Backend: "review-script"},
{Role: "writer", Name: "评审二", Backend: "review-script"},
{Role: "reviewer", Name: "评审三", Backend: "review-script"},
{Role: "verifier", Name: "核实", Backend: "review-script"},
})
res, err := engine.Run(context.Background(), Goal{Text: "评审材料正文"},
RunOptions{Topology: TopologyReview, ReviewTarget: "材料正文"})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status = %s err = %v", res.Status, res.Error)
}
data, err := os.ReadFile(filepath.Join(res.WorkDir, "output.md"))
if err != nil {
t.Fatal(err)
}
out := string(data)
// 只留 PASS(fail-closed):无来源/错字保留;FAIL 与无判定丢弃。
if !strings.Contains(out, "无来源") || !strings.Contains(out, "错字") ||
strings.Contains(out, "数字不一致") || strings.Contains(out, "没有结论") ||
strings.Contains(out, "过期数据") {
t.Fatalf("filter = %s", out)
}
// 报告恒带丢弃计数(backend 同格式串)。
if !strings.Contains(out, "已丢弃 3 条未验证") {
t.Fatalf("dropped = %s", out)
}
// 去重后逐条验证:verify 调用数 = unique 数 = 5。
if be.verifyCalls != 5 {
t.Fatalf("verify calls = %d, want 5 after dedupe", be.verifyCalls)
}
// severity 稳定排序:high(无来源) 在 low(错字) 前。
if strings.Index(out, "无来源") > strings.Index(out, "错字") {
t.Fatalf("severity order: %s", out)
}
if sink.countText("review:3 个 finder 并行找问题") != 1 ||
sink.countText("review:去重后 5 条") != 1 {
t.Fatal("缺 review 阶段事件")
}
}
// verify 角色缺失 → fail-closed。
func TestReviewVerifyRoleMissingFailsClosed(t *testing.T) {
be := &reviewScriptBackend{release: make(chan struct{})}
planner := mockScriptPlanner(
StepSpec{ID: "f1", Role: "researcher", Title: "评审", Targets: []string{"findings.json"}},
)
engine := newWP3Engine(t, planner, be, NopSink{}, []RoleSpec{
{Role: "researcher", Name: "评审", Backend: "review-script"},
})
res, err := engine.Run(context.Background(), Goal{Text: "评审"},
RunOptions{Topology: TopologyReview})
if err != nil {
t.Fatal(err)
}
if res.Status != RunFailed || !strings.Contains(res.Error, "verify 角色") {
t.Fatalf("应 fail-closed:%s %q", res.Status, res.Error)
}
}
// ---- handoff 原语 ----
// handoffScriptBackend 记录 prompt 并按目录回放带标记的产出(≥50 字过最小质检)。
type handoffScriptBackend struct {
mu sync.Mutex
prompts map[string][]string
}
func (b *handoffScriptBackend) Name() string { return "handoff-script" }
func (b *handoffScriptBackend) Execute(_ context.Context, req ExecRequest) (*ExecResult, error) {
key := filepath.Base(filepath.Clean(req.WorkDir))
b.mu.Lock()
b.prompts[key] = append(b.prompts[key], req.Prompt)
b.mu.Unlock()
pad := "\n\n这是为了满足最小质检长度要求而补充的正文内容,超过五十个字符。"
var content string
switch key {
case "01_researcher":
content = "MARK_RESEARCHER 调研笔记。" + pad
case "02_writer":
content = "MARK_WRITER 终稿。" + pad
default:
content = "MARK_REVIEWER 评审意见。" + pad
}
name := "output.md"
if len(req.Targets) > 0 {
name = req.Targets[0]
}
return writeOne(req.WorkDir, name, content), nil
}
func TestTopologyConformance_handoff(t *testing.T) {
be := &handoffScriptBackend{prompts: map[string][]string{}}
sink := &collectSink{}
engine := newWP3Engine(t, mockScriptPlanner(
StepSpec{ID: "s1", Role: "researcher", Title: "调研", Targets: []string{"notes.md"}, Handoff: "writer"},
StepSpec{ID: "s2", Role: "writer", Title: "成文", Targets: []string{"output.md"}, Needs: []string{"s1"}},
StepSpec{ID: "s3", Role: "reviewer", Title: "终审", Targets: []string{"output.md"}, Needs: []string{"s2"}},
), be, sink, []RoleSpec{
{Role: "researcher", Name: "调研", Backend: "handoff-script"},
{Role: "writer", Name: "撰写", Backend: "handoff-script"},
{Role: "reviewer", Name: "审核", Backend: "handoff-script"},
})
res, err := engine.Run(context.Background(), Goal{Text: "带移交的三步"}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status = %s err = %v", res.Status, res.Error)
}
// ① 所有权转移记录持久化。
st, err := engine.loadRun(engine.runDir(res.RunID))
if err != nil {
t.Fatal(err)
}
if len(st.Handoffs) != 1 || st.Handoffs[0].FromRole != "researcher" || st.Handoffs[0].ToRole != "writer" {
t.Fatalf("Handoffs = %+v", st.Handoffs)
}
if !strings.Contains(st.Handoffs[0].Output, "MARK_RESEARCHER") {
t.Fatalf("移交产出快照缺失:%q", st.Handoffs[0].Output)
}
if sink.countText("handoff:researcher → writer") != 1 {
t.Fatal("缺所有权移交事件")
}
// ② 目标收过滤历史:writer 的任务书带移交上下文,reviewer 不带。
var writerPrompt, reviewerPrompt string
for _, p := range be.prompts["02_writer"] {
writerPrompt += p
}
for _, p := range be.prompts["03_reviewer"] {
reviewerPrompt += p
}
if !strings.Contains(writerPrompt, "移交上下文") || !strings.Contains(writerPrompt, "MARK_RESEARCHER") {
t.Fatalf("writer 未收到过滤历史:%q", writerPrompt)
}
if strings.Contains(reviewerPrompt, "移交上下文") {
t.Fatal("非目标角色不应收到移交上下文")
}
// ③ finalize 取链尾输出:交付物 = writer(链尾)产出,而非最后完成的 reviewer。
data, err := os.ReadFile(filepath.Join(res.WorkDir, "output.md"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "MARK_WRITER") || strings.Contains(string(data), "MARK_REVIEWER") {
t.Fatalf("交付物应为链尾 writer 产出:%s", data)
}
}
// handoff 目标角色未知 → 步骤启动前 fail-closed。
func TestHandoffUnknownRoleFailsClosed(t *testing.T) {
be := &handoffScriptBackend{prompts: map[string][]string{}}
engine := newWP3Engine(t, mockScriptPlanner(
StepSpec{ID: "s1", Role: "researcher", Title: "调研", Handoff: "ghost"},
), be, NopSink{}, []RoleSpec{
{Role: "researcher", Name: "调研", Backend: "handoff-script"},
})
res, err := engine.Run(context.Background(), Goal{Text: "x"}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != RunFailed || !strings.Contains(res.Error, "handoff: 未知目标角色 ghost") {
t.Fatalf("应 fail-closed:%s %q", res.Status, res.Error)
}
}
// ---- group_chat 驱动 ----
// gcScriptBackend 按目录回放 message.md。
type gcScriptBackend struct {
messages map[string]string
}
func (b *gcScriptBackend) Name() string { return "gc-script" }
func (b *gcScriptBackend) Execute(_ context.Context, req ExecRequest) (*ExecResult, error) {
key := filepath.Base(filepath.Clean(req.WorkDir))
msg := b.messages[key]
if msg == "" {
msg = "发言:" + key
}
return writeOne(req.WorkDir, "message.md", msg), nil
}
func TestTopologyConformance_groupChatRotateBudget(t *testing.T) {
be := &gcScriptBackend{messages: map[string]string{
"turn_01_researcher": "第一句",
"turn_02_writer": "第二句",
"turn_03_researcher": "第三句",
}}
sink := &collectSink{}
engine := newWP3Engine(t, mockScriptPlanner(
StepSpec{ID: "p1", Role: "researcher", Title: "参与者甲"},
StepSpec{ID: "p2", Role: "writer", Title: "参与者乙"},
), be, sink, []RoleSpec{
{Role: "researcher", Name: "甲", Backend: "gc-script"},
{Role: "writer", Name: "乙", Backend: "gc-script"},
})
res, err := engine.Run(context.Background(), Goal{Text: "群聊"},
RunOptions{Topology: TopologyGroupChat, GroupChatMaxTurns: 3})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status = %s err = %v", res.Status, res.Error)
}
// 预算停机:恰好 3 轮,轮转序 researcher→writer→researcher。
if sink.countText("轮次预算 3 用尽") != 1 {
t.Fatal("缺预算停机事件")
}
data, err := os.ReadFile(filepath.Join(res.WorkDir, "output.md"))
if err != nil {
t.Fatal(err)
}
out := string(data)
for _, want := range []string{"1. researcher:第一句", "2. writer:第二句", "3. researcher:第三句"} {
if !strings.Contains(out, want) {
t.Fatalf("转录缺 %q:%s", want, out)
}
}
if strings.Contains(out, "4. ") {
t.Fatalf("超出预算仍在发言:%s", out)
}
// 名册步骤不执行,标记 skipped(非降级原因)。
st, _ := engine.loadRun(engine.runDir(res.RunID))
for _, s := range st.Plan.Workflow.Steps {
if s.ID == "p1" || s.ID == "p2" {
if s.Status != "skipped" || s.Error != gcRosterSkipReason {
t.Fatalf("名册步骤 %s = %s/%q", s.ID, s.Status, s.Error)
}
}
}
}
func TestTopologyConformance_groupChatCloseMarker(t *testing.T) {
be := &gcScriptBackend{messages: map[string]string{
"turn_01_researcher": "我先说",
"turn_02_writer": "同意收口 [close]",
}}
sink := &collectSink{}
engine := newWP3Engine(t, mockScriptPlanner(
StepSpec{ID: "p1", Role: "researcher", Title: "甲"},
StepSpec{ID: "p2", Role: "writer", Title: "乙"},
), be, sink, []RoleSpec{
{Role: "researcher", Name: "甲", Backend: "gc-script"},
{Role: "writer", Name: "乙", Backend: "gc-script"},
})
res, err := engine.Run(context.Background(), Goal{Text: "群聊"},
RunOptions{Topology: TopologyGroupChat, GroupChatMaxTurns: 8})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status = %s", res.Status)
}
if sink.countText("发出收口标记") != 1 {
t.Fatal("缺收口标记事件")
}
st, _ := engine.loadRun(engine.runDir(res.RunID))
if n := countGcSteps(st); n != 2 {
t.Fatalf("收口标记后应停在 2 轮,实际 %d", n)
}
}
func TestTopologyConformance_groupChatModerator(t *testing.T) {
be := &gcScriptBackend{messages: map[string]string{
"turn_01_researcher": `{"next":"writer","reason":"请乙补充"}`,
"turn_02_writer": "补一段分析",
"turn_03_researcher": `{"action":"close","reason":"讨论充分"}`,
}}
sink := &collectSink{}
engine := newWP3Engine(t, mockScriptPlanner(
StepSpec{ID: "p1", Role: "researcher", Title: "主持人"},
StepSpec{ID: "p2", Role: "writer", Title: "乙"},
), be, sink, []RoleSpec{
{Role: "researcher", Name: "主持", Backend: "gc-script"},
{Role: "writer", Name: "乙", Backend: "gc-script"},
})
res, err := engine.Run(context.Background(), Goal{Text: "主持人群聊"}, RunOptions{
Topology: TopologyGroupChat, GroupChatPolicy: "moderator",
GroupChatModerator: "researcher", GroupChatMaxTurns: 8,
})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status = %s err = %v", res.Status, res.Error)
}
if sink.countText("主持人收口") != 1 {
t.Fatal("缺主持人收口事件")
}
data, _ := os.ReadFile(filepath.Join(res.WorkDir, "output.md"))
out := string(data)
for _, want := range []string{"1. researcher", "2. writer:补一段分析", "3. researcher"} {
if !strings.Contains(out, want) {
t.Fatalf("转录缺 %q:%s", want, out)
}
}
st, _ := engine.loadRun(engine.runDir(res.RunID))
if n := countGcSteps(st); n != 3 {
t.Fatalf("主持人模式应 3 轮收口,实际 %d", n)
}
}
// 主持人指名不在名册 → fail-closed。
func TestGroupChatModeratorUnknownNextFailsClosed(t *testing.T) {
be := &gcScriptBackend{messages: map[string]string{
"turn_01_researcher": `{"next":"ghost"}`,
}}
engine := newWP3Engine(t, mockScriptPlanner(
StepSpec{ID: "p1", Role: "researcher", Title: "主持人"},
StepSpec{ID: "p2", Role: "writer", Title: "乙"},
), be, NopSink{}, []RoleSpec{
{Role: "researcher", Name: "主持", Backend: "gc-script"},
{Role: "writer", Name: "乙", Backend: "gc-script"},
})
res, err := engine.Run(context.Background(), Goal{Text: "x"}, RunOptions{
Topology: TopologyGroupChat, GroupChatPolicy: "moderator", GroupChatModerator: "researcher",
})
if err != nil {
t.Fatal(err)
}
if res.Status != RunFailed || !strings.Contains(res.Error, "不在名册内") {
t.Fatalf("应 fail-closed:%s %q", res.Status, res.Error)
}
}
// ---- 夹具 ----
func countGcSteps(st *RunState) int {
n := 0
for _, s := range st.Plan.Workflow.Steps {
if strings.HasPrefix(s.ID, "gc") && s.Status == "completed" {
n++
}
}
return n
}
func newWP3Engine(t *testing.T, planner Planner, be Executor, sink ProgressSink, roles []RoleSpec) *SquadEngine {
t.Helper()
dir := t.TempDir()
reg := NewRoleRegistry()
for _, r := range roles {
if err := reg.Register(r); err != nil {
t.Fatal(err)
}
}
backends := NewBackendResolver()
backends.RegisterInstance(be.Name(), be)
engine, err := NewEngine(Options{DataDir: dir, Registry: reg, Planner: planner, Backends: backends, Sink: sink})
if err != nil {
t.Fatal(err)
}
return engine
}
+355
View File
@@ -0,0 +1,355 @@
package squad
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// wp4_conformance_test.go — Q2 WP4 验收:task_board 拓扑(claim/lease/complete
// 原语 + 依赖门 + 孤儿租约再武装)与全 12 拓扑 conformance 汇总跑。
// ---- task_board ----
// 菱形依赖 a→(b,c)→d:认领序尊重依赖门,全部 done,标准 finalize 收口。
func TestTopologyConformance_taskBoard(t *testing.T) {
be := &iterBackend{calls: map[string]int{}}
sink := &collectSink{}
engine := newTopologyEngine(t, mockScriptPlanner(
StepSpec{ID: "a", Role: "researcher", Title: "任务a", Targets: []string{"a.md"}},
StepSpec{ID: "b", Role: "writer", Title: "任务b", Needs: []string{"a"}, Targets: []string{"b.md"}},
StepSpec{ID: "c", Role: "reviewer", Title: "任务c", Needs: []string{"a"}, Targets: []string{"c.md"}},
StepSpec{ID: "d", Role: "writer", Title: "任务d", Needs: []string{"b", "c"}, Targets: []string{"output.md"}},
), be, sink)
res, err := engine.Run(context.Background(), Goal{Text: "任务板"}, RunOptions{Topology: TopologyTaskBoard})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status = %s err = %v", res.Status, res.Error)
}
// 认领序尊重依赖门:a 先于 b/c,d 最后(板上按步骤序认领,确定性)。
var order []string
for _, ev := range sink.snapshot() {
if strings.Contains(ev.Text, "认领") {
order = append(order, ev.StepID)
}
}
want := []string{"a", "b", "c", "d"}
if strings.Join(order, ",") != strings.Join(want, ",") {
t.Fatalf("认领序 = %v,期望 %v", order, want)
}
st, _ := engine.loadRun(engine.runDir(res.RunID))
for _, task := range st.Board {
if task.Status != "done" {
t.Fatalf("任务 %s = %s,期望 done", task.ID, task.Status)
}
}
if sink.countText("task_board:收口(done 4/4)") != 1 {
t.Fatal("缺收口事件")
}
}
// 孤儿租约再武装:崩溃(ctx 取消)留下 claimed+过期租约 → Resume 重入
// 再武装回 open、重新认领(Attempts=2)并跑完。
func TestTaskBoardOrphanLeaseRearms(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
crashed := false
be := &iterBackend{calls: map[string]int{}, content: func(key string, _ int, _ string) []byte {
if key == "02_writer" && !crashed {
crashed = true
cancel() // 模拟进程死亡:runOneStep 在 ctx 检查处退出,步骤回到 pending
}
return nil
}}
sink := &collectSink{}
engine := newTopologyEngine(t, mockScriptPlanner(
StepSpec{ID: "t1", Role: "researcher", Title: "一", Targets: []string{"a.md"}},
StepSpec{ID: "t2", Role: "writer", Title: "二", Needs: []string{"t1"}, Targets: []string{"output.md"}},
), be, sink)
res0, err := engine.Run(ctx, Goal{Text: "任务板"}, RunOptions{Topology: TopologyTaskBoard})
if err != nil {
t.Fatal(err)
}
if res0.Status != RunCancelled {
t.Fatalf("取消后应为 RunCancelled:%s", res0.Status)
}
// 崩溃现场:t2 claimed、租约未过期;步骤在盘上是 running(setStep 只改
// 内存,取消路径的 pending 未落盘)——进程死亡即此形态。测试把租约回拨
// 成过期(孤儿)并把步骤置回 pending(模拟重启前的一致化),再走 Resume。
st, err := engine.loadRun(engine.runDir(res0.RunID))
if err != nil {
t.Fatal(err)
}
found := false
for i := range st.Board {
if st.Board[i].ID == "t2" {
found = true
if st.Board[i].Status != "claimed" || st.Board[i].Attempts != 1 {
t.Fatalf("崩溃现场 t2 = %+v", st.Board[i])
}
st.Board[i].LeaseExpires = time.Now().Add(-time.Hour) // 租约过期 = 孤儿
}
}
if !found {
t.Fatal("板上无 t2")
}
for i := range st.Plan.Workflow.Steps {
if st.Plan.Workflow.Steps[i].ID == "t2" && st.Plan.Workflow.Steps[i].Status == "running" {
st.Plan.Workflow.Steps[i].Status = "pending"
}
}
if err := st.save(); err != nil {
t.Fatal(err)
}
// 重入:孤儿租约再武装 → 重新认领 → 跑完收口。
res, err := engine.Resume(context.Background(), st.Meta.ID)
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("恢复后应完成:%s %v", res.Status, res.Error)
}
if sink.countText("孤儿租约再武装 t2") != 1 {
t.Fatal("缺孤儿租约再武装事件")
}
st2, _ := engine.loadRun(engine.runDir(res.RunID))
for _, task := range st2.Board {
if task.ID == "t2" && task.Attempts != 2 {
t.Fatalf("再武装后 Attempts = %d,期望 2", task.Attempts)
}
}
}
// 依赖门级联:非关键任务失败 → 下游 open 任务级联 skipped → 降级收口。
func TestTaskBoardDependencyGateCascade(t *testing.T) {
be := &iterBackend{calls: map[string]int{}, content: func(key string, _ int, _ string) []byte {
return nil
}}
failBe := &failOnceBackend{inner: be, failKey: "02_writer"}
crit := false
sink := &collectSink{}
engine := newTopologyEngine(t, mockScriptPlanner(
StepSpec{ID: "t0", Role: "researcher", Title: "零", Targets: []string{"output.md"}},
StepSpec{ID: "t1", Role: "writer", Title: "一", Needs: []string{"t0"}, Targets: []string{"b.md"}, Critical: &crit},
StepSpec{ID: "t2", Role: "reviewer", Title: "二", Needs: []string{"t1"}, Targets: []string{"c.md"}},
), failBe, sink)
res, err := engine.Run(context.Background(), Goal{Text: "任务板"}, RunOptions{Topology: TopologyTaskBoard})
if err != nil {
t.Fatal(err)
}
if res.Status != RunDegraded {
t.Fatalf("非关键失败+级联跳过应降级收口:%s %v", res.Status, res.Error)
}
st, _ := engine.loadRun(engine.runDir(res.RunID))
byID := map[string]BoardTask{}
for _, task := range st.Board {
byID[task.ID] = task
}
if byID["t0"].Status != "done" || byID["t1"].Status != "failed" || byID["t2"].Status != "skipped" {
t.Fatalf("板状态 = %+v", byID)
}
for _, s := range st.Plan.Workflow.Steps {
if s.ID == "t2" && (s.Status != "skipped" || s.Error != boardSkipReason) {
t.Fatalf("t2 = %s %q", s.Status, s.Error)
}
}
}
// failOnceBackend 对指定目录恒定失败(非瞬态),其余委托 inner。
type failOnceBackend struct {
inner Executor
failKey string
}
func (b *failOnceBackend) Name() string { return b.inner.Name() }
func (b *failOnceBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
key := filepath.Base(filepath.Clean(req.WorkDir))
if key == b.failKey {
return &ExecResult{Success: false, ErrorReason: "fatal: 拒绝执行"}, nil
}
return b.inner.Execute(ctx, req)
}
// ---- 12/12 汇总 ----
// TestTopologyConformance_allTwelve 全 12 内置拓扑汇总跑:每个拓扑名一次
// 脚本化最小运行(零模型零网络)。graph/mode/driver 十种断言完成收口;
// handoff/mailbox 两个 primitive 断言 fail-closed 拒绝单跑(其原语能力分别
// 由 TestTopologyConformance_handoff 与 WP2 mailbox 测试钉住)。
func TestTopologyConformance_allTwelve(t *testing.T) {
specs := BuiltinTopologies()
if len(specs) != 12 {
t.Fatalf("内置拓扑 = %d,期望 12", len(specs))
}
for _, spec := range specs {
spec := spec
t.Run(spec.Name, func(t *testing.T) {
switch spec.Name {
case TopologySequential:
runAgg(t, spec.Name, mockScriptPlanner(
StepSpec{ID: "s1", Role: "researcher", Title: "一", Targets: []string{"output.md"}},
), &iterBackend{calls: map[string]int{}}, RunOptions{}, RunCompleted)
case TopologyParallel:
runAgg(t, spec.Name, mockScriptPlanner(
StepSpec{ID: "s1", Role: "researcher", Title: "甲", Parallel: true, Targets: []string{"a.md"}},
StepSpec{ID: "s2", Role: "writer", Title: "乙", Parallel: true, Targets: []string{"output.md"}},
), &iterBackend{calls: map[string]int{}}, RunOptions{}, RunCompleted)
case TopologyLoop:
runAgg(t, spec.Name, mockScriptPlanner(
StepSpec{ID: "s1", Role: "researcher", Title: "循环体", Targets: []string{"output.md", "fields.json"},
Loop: &LoopSpec{MaxIterations: 2, UntilField: "done", UntilEquals: "yes"}},
), &iterBackend{calls: map[string]int{}, content: func(_ string, _ int, target string) []byte {
if target == "fields.json" {
return fieldsJSON(map[string]string{"done": "no"})
}
return nil
}}, RunOptions{}, RunCompleted)
case TopologyRouter:
runAgg(t, spec.Name, mockScriptPlanner(routerSteps()...),
&iterBackend{calls: map[string]int{}, content: func(_ string, _ int, target string) []byte {
if target == "fields.json" {
return fieldsJSON(map[string]string{"route": "fast"})
}
return nil
}}, RunOptions{}, RunCompleted)
case TopologyEvaluatorOptimizer:
runAgg(t, spec.Name, mockScriptPlanner(
StepSpec{ID: "draft", Role: "writer", Title: "起草", Targets: []string{"output.md"}},
StepSpec{ID: "eval", Role: "reviewer", Title: "评审", Targets: []string{"verdict.md", "fields.json"},
Loop: &LoopSpec{MaxIterations: 4, UntilField: "verdict", UntilEquals: "pass", Body: []string{"draft"}}},
), &iterBackend{calls: map[string]int{}, content: func(_ string, iter int, target string) []byte {
if target == "fields.json" {
if iter >= 3 {
return fieldsJSON(map[string]string{"verdict": "pass"})
}
return fieldsJSON(map[string]string{"verdict": "fail"})
}
return nil
}}, RunOptions{}, RunCompleted)
case TopologyLongRunning:
runAgg(t, spec.Name, mockScriptPlanner(
StepSpec{ID: "s1", Role: "researcher", Title: "一", Targets: []string{"output.md"}},
), &iterBackend{calls: map[string]int{}},
RunOptions{Deadline: time.Now().Add(5 * time.Second)}, RunCompleted)
case TopologyLeader:
b := &leaderScriptBackend{
decisions: []string{delegateJSON("writer", "撰写简报"), closeJSON},
script: map[string]map[string]string{"01_writer": {"output.md": goodReport}},
}
engine := newLeaderEngine(t, b)
res, err := engine.Run(context.Background(), Goal{Text: "汇总"}, RunOptions{Topology: TopologyLeader})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status = %s err = %v", res.Status, res.Error)
}
case TopologyReview:
planner := mockScriptPlanner(
StepSpec{ID: "f1", Role: "researcher", Title: "评审", Targets: []string{"findings.json"}},
)
planner.Agents = []RoleSpec{
{Role: "researcher", Name: "评审", Backend: "agg-review"},
{Role: "verifier", Name: "核实", Backend: "agg-review"},
}
engine := newAggEngine(t, planner, &aggReviewBackend{}, []RoleSpec{
{Role: "researcher", Name: "评审", Backend: "agg-review"},
{Role: "verifier", Name: "核实", Backend: "agg-review"},
})
res, err := engine.Run(context.Background(), Goal{Text: "评审"},
RunOptions{Topology: TopologyReview, ReviewTarget: "材料"})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status = %s err = %v", res.Status, res.Error)
}
data, _ := os.ReadFile(filepath.Join(res.WorkDir, "output.md"))
if !strings.Contains(string(data), "已丢弃 0 条未验证") || !strings.Contains(string(data), "样例问题") {
t.Fatalf("报告 = %s", data)
}
case TopologyGroupChat:
engine := newAggEngine(t, mockScriptPlanner(
StepSpec{ID: "p1", Role: "researcher", Title: "甲"},
StepSpec{ID: "p2", Role: "writer", Title: "乙"},
), &gcScriptBackend{}, []RoleSpec{
{Role: "researcher", Name: "甲", Backend: "gc-script"},
{Role: "writer", Name: "乙", Backend: "gc-script"},
})
res, err := engine.Run(context.Background(), Goal{Text: "群聊"},
RunOptions{Topology: TopologyGroupChat, GroupChatMaxTurns: 2})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status = %s err = %v", res.Status, res.Error)
}
case TopologyTaskBoard:
runAgg(t, spec.Name, mockScriptPlanner(
StepSpec{ID: "t1", Role: "researcher", Title: "一", Targets: []string{"a.md"}},
StepSpec{ID: "t2", Role: "writer", Title: "二", Needs: []string{"t1"}, Targets: []string{"output.md"}},
), &iterBackend{calls: map[string]int{}}, RunOptions{}, RunCompleted)
case TopologyHandoff, TopologyMailbox:
// primitive:不可单独作为运行拓扑(fail-closed);原语能力由
// 专项 conformance 钉住(handoff/mailbox 测试)。
runAgg(t, spec.Name, mockScriptPlanner(
StepSpec{ID: "s1", Role: "researcher", Title: "一", Targets: []string{"output.md"}},
), &iterBackend{calls: map[string]int{}}, RunOptions{}, RunFailed)
default:
t.Fatalf("未覆盖的拓扑 %q", spec.Name)
}
})
}
}
// runAgg 汇总跑的最小引擎:graph 类拓扑共用 iter 三角色装配。
func runAgg(t *testing.T, topo string, planner Planner, be Executor, opts RunOptions, want RunStatus) {
t.Helper()
opts.Topology = topo
engine := newTopologyEngine(t, planner, be, nil)
res, err := engine.Run(context.Background(), Goal{Text: "汇总跑 " + topo}, opts)
if err != nil {
t.Fatal(err)
}
if res.Status != want {
t.Fatalf("status = %s err = %q,期望 %s", res.Status, res.Error, want)
}
}
// aggReviewBackend 汇总跑用的最小 review 后端:finder 出一条发现,verify 判 PASS。
type aggReviewBackend struct{}
func (b *aggReviewBackend) Name() string { return "agg-review" }
func (b *aggReviewBackend) Execute(_ context.Context, req ExecRequest) (*ExecResult, error) {
key := filepath.Base(filepath.Clean(req.WorkDir))
if strings.HasPrefix(key, "verify_") {
return writeOne(req.WorkDir, "verdict.md", "核对成立。\nVERDICT: PASS"), nil
}
return writeOne(req.WorkDir, "findings.json",
`[{"location":"第1段","severity":"high","issue":"样例问题","evidence":"样例证据"}]`), nil
}
// newAggEngine 自定义角色表的汇总引擎(backend 实例名 = be.Name())。
func newAggEngine(t *testing.T, planner Planner, be Executor, roles []RoleSpec) *SquadEngine {
t.Helper()
dir := t.TempDir()
reg := NewRoleRegistry()
for _, r := range roles {
if err := reg.Register(r); err != nil {
t.Fatal(err)
}
}
backends := NewBackendResolver()
backends.RegisterInstance(be.Name(), be)
engine, err := NewEngine(Options{DataDir: dir, Registry: reg, Planner: planner, Backends: backends, Sink: NopSink{}})
if err != nil {
t.Fatal(err)
}
return engine
}

Some files were not shown because too many files have changed in this diff Show More