Compare commits

...
Author SHA1 Message Date
weichen b75e12ad4c Carry the split usage fields through squad events (N3 WP6)
Why: the exec bridge now threads Last*/cache (Agent-Native#38); 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#38, N3 plan WP6
2026-10-07 17:10:30 +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
weichenandCursor 5cbac00b99 Add a SQLite orchestration platform beside the goal engine
ci / vet-and-test (pull_request) Failing after 1s
ci / docker-checkout-hostgateway (pull_request) Failing after 3s
Why: Squad runs were one file workspace and one JSON file the leader had to emit, so agents, issues, and retries had no durable queue.
Evidence: GOWORK=off go test -count=1 -timeout 180s ./...
Impact: /v1/runs stays. New routes mount only when the orchestration database opens. SSE for a run id now skips other runs. External CLIs are not a sandbox.
Refs: feature/orchestration-platform S1-S7
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 10:30:01 +00:00
weichen 87baa13a04 Add a router fast path and pass host tools through
ci / vet-and-test (push) Failing after 2s
ci / docker-checkout-hostgateway (push) Failing after 3s
2026-10-01 09:35:55 +00:00
weichenandCursor d38f838dbc Pass a scripted stream and tool-call cap into the kernel
ci / vet-and-test (pull_request) Failing after 1s
ci / docker-checkout-hostgateway (pull_request) Failing after 3s
Why: Tests and product budgets need to drive each kernel step without a live model, and stop it when the tool-call cap is hit.
Evidence: go test ./squad/ -count=1 passed after fetching agent-native cfc840c.
Impact: AgentNativeConfig.Stream and MaxToolCalls reach native.Options. The nested kernel pin moves to cfc840c.
Refs: deliverable-quality-system B2
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 09:06:28 +00:00
weichenandCursor 75d8c08103 Add a router fast path and pass host tools through
Why: Simple tasks should finish as one assistant step, while complex tasks stay in leader mode, and every kernel step needs the host's tools and events.
Evidence: go test ./squad/ -count=1 passed, including direct route, leader entry, skill override, and delegation score fields.
Impact: Hosts can inject HostTools and OnEvent; a Router can choose direct or plan without changing product roles.
Refs: deliverable-quality-system B2
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 09:00:13 +00:00
weichen 9a402e468e Merge pull request 'Plumb RoleSpec execution config into agent-native requests and fix leader parity' (#3) from feature/full-agent-r1 into main
ci / vet-and-test (push) Failing after 30s
ci / docker-checkout-hostgateway (push) Failing after 31s
2026-10-01 06:02:37 +00:00
89 changed files with 6929 additions and 579 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
@@ -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")
}
}
+279
View File
@@ -0,0 +1,279 @@
package platform
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
)
// Autopilot is a runbook fired by cron, webhook, or API.
type Autopilot struct {
ID, WorkspaceID, Title, Runbook, AssigneeKind, AssigneeID, Status, ExecutionMode string
}
func (s *Store) CreateAutopilot(a Autopilot) (Autopilot, error) {
if a.ID == "" {
a.ID = newID()
}
if a.Status == "" {
a.Status = "active"
}
if a.ExecutionMode != "create_issue" && a.ExecutionMode != "run_only" {
return Autopilot{}, fmt.Errorf("execution mode must be create_issue or run_only")
}
_, err := s.db.Exec(`INSERT INTO autopilot(id,workspace_id,title,runbook,assignee_kind,assignee_id,status,execution_mode,created_at) VALUES(?,?,?,?,?,?,?,?,?)`,
a.ID, a.WorkspaceID, a.Title, a.Runbook, a.AssigneeKind, a.AssigneeID, a.Status, a.ExecutionMode, now())
return a, err
}
// AddSchedule stores a five-field cron expression and an IANA timezone.
func (s *Store) AddSchedule(autopilotID, cronExpr, timezone string) error {
if _, err := time.LoadLocation(timezone); err != nil {
return fmt.Errorf("timezone: %w", err)
}
if len(strings.Fields(cronExpr)) != 5 {
return fmt.Errorf("cron must have 5 fields")
}
_, err := s.db.Exec(`INSERT INTO trigger_(id,autopilot_id,kind,cron,timezone) VALUES(?,?,?,?,?)`,
newID(), autopilotID, "schedule", cronExpr, timezone)
return err
}
// AddWebhook returns the token clients post to.
func (s *Store) AddWebhook(autopilotID, secret string) (string, error) {
token := newID()
_, err := s.db.Exec(`INSERT INTO trigger_(id,autopilot_id,kind,token,secret) VALUES(?,?,?,?,?)`,
newID(), autopilotID, "webhook", token, secret)
return token, err
}
// FireSchedules runs every active schedule that matches at.
func (s *Store) FireSchedules(at time.Time) (int, error) {
rows, err := s.db.Query(`SELECT autopilot.id, trigger_.cron, trigger_.timezone FROM autopilot JOIN trigger_ ON trigger_.autopilot_id=autopilot.id WHERE autopilot.status='active' AND trigger_.kind='schedule'`)
if err != nil {
return 0, err
}
type due struct{ id, cron, tz string }
var list []due
for rows.Next() {
var item due
if err := rows.Scan(&item.id, &item.cron, &item.tz); err != nil {
rows.Close()
return 0, err
}
list = append(list, item)
}
err = rows.Err()
rows.Close()
if err != nil {
return 0, err
}
n := 0
for _, item := range list {
if !cronDue(item.cron, item.tz, at) {
continue
}
if err := s.fire(item.id, "schedule"); err != nil {
return n, err
}
n++
}
return n, nil
}
// AcceptWebhook validates, dedupes, and fires. Body must be a JSON object or array up to 256KiB.
func (s *Store) AcceptWebhook(token string, body []byte, headers map[string]string) (string, error) {
if len(body) > 256*1024 {
return "", fmt.Errorf("webhook body exceeds 256KiB")
}
var probe any
if err := json.Unmarshal(body, &probe); err != nil {
return "", fmt.Errorf("webhook body must be JSON")
}
switch probe.(type) {
case map[string]any, []any:
default:
return "", fmt.Errorf("webhook body must be a JSON object or array")
}
var autopilotID, secret string
err := s.db.QueryRow(`SELECT autopilot_id, IFNULL(secret,'') FROM trigger_ WHERE kind='webhook' AND token=?`, token).Scan(&autopilotID, &secret)
if err != nil {
return "", err
}
if secret != "" {
sig := headers["X-Hub-Signature-256"]
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write(body)
want := "sha256=" + hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(sig), []byte(want)) {
return "", fmt.Errorf("webhook signature mismatch")
}
}
key := headers["Idempotency-Key"]
if key == "" {
key = headers["X-GitHub-Delivery"]
}
if key != "" {
if _, err := s.db.Exec(`INSERT INTO webhook_delivery(id,autopilot_id,dedupe_key,created_at) VALUES(?,?,?,?)`, newID(), autopilotID, key, now()); err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
return "duplicate", nil
}
return "", err
}
}
if err := s.fire(autopilotID, eventName(headers, probe)); err != nil {
return "", err
}
return eventName(headers, probe), nil
}
func eventName(headers map[string]string, probe any) string {
if v := headers["X-GitHub-Event"]; v != "" {
return v
}
if obj, ok := probe.(map[string]any); ok {
if v, ok := obj["action"].(string); ok && v != "" {
return v
}
if v, ok := obj["event"].(string); ok && v != "" {
return v
}
}
return "webhook"
}
func (s *Store) fire(autopilotID, detail string) error {
var a Autopilot
err := s.db.QueryRow(`SELECT id,workspace_id,title,runbook,assignee_kind,assignee_id,status,execution_mode FROM autopilot WHERE id=?`, autopilotID).
Scan(&a.ID, &a.WorkspaceID, &a.Title, &a.Runbook, &a.AssigneeKind, &a.AssigneeID, &a.Status, &a.ExecutionMode)
if err != nil {
return err
}
if a.Status != "active" {
return nil
}
status := "completed"
if a.ExecutionMode == "create_issue" {
issue, err := s.CreateIssue(Issue{WorkspaceID: a.WorkspaceID, Title: a.Title, Body: a.Runbook, Status: "todo", AssigneeKind: a.AssigneeKind, AssigneeID: a.AssigneeID})
if err != nil {
status = "failed"
detail = err.Error()
} else if err := s.AssignIssue(issue.ID, a.AssigneeKind, a.AssigneeID, true); err != nil {
status = "failed"
detail = err.Error()
}
} else if a.AssigneeKind == "agent" {
issue := Issue{WorkspaceID: a.WorkspaceID}
t, err := s.enqueue(issue, a.AssigneeID, false, "")
if err != nil {
status = "failed"
} else {
_, _ = s.db.Exec(`UPDATE task SET issue_id='' WHERE id=?`, t.ID)
}
}
if _, err := s.db.Exec(`INSERT INTO autopilot_run(id,autopilot_id,status,detail,created_at) VALUES(?,?,?,?,?)`, newID(), autopilotID, status, detail, now()); err != nil {
return err
}
return s.maybePause(autopilotID)
}
func (s *Store) maybePause(autopilotID string) error {
since := time.Now().UTC().Add(-7 * 24 * time.Hour).Format(time.RFC3339Nano)
var total, failed int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM autopilot_run WHERE autopilot_id=? AND created_at>=?`, autopilotID, since).Scan(&total)
_ = s.db.QueryRow(`SELECT COUNT(*) FROM autopilot_run WHERE autopilot_id=? AND created_at>=? AND status='failed'`, autopilotID, since).Scan(&failed)
if total >= 50 && failed*10 >= total*9 {
_, err := s.db.Exec(`UPDATE autopilot SET status='paused' WHERE id=?`, autopilotID)
return err
}
return nil
}
func cronDue(expr, timezone string, at time.Time) bool {
loc, err := time.LoadLocation(timezone)
if err != nil {
return false
}
at = at.In(loc)
fields := strings.Fields(expr)
if len(fields) != 5 {
return false
}
values := []int{at.Minute(), at.Hour(), at.Day(), int(at.Month()), int(at.Weekday())}
for i, field := range fields {
if !cronField(field, values[i]) {
return false
}
}
return true
}
func cronField(field string, value int) bool {
if field == "*" {
return true
}
if strings.HasPrefix(field, "*/") {
var step int
if _, err := fmt.Sscan(field[2:], &step); err != nil || step <= 0 {
return false
}
return value%step == 0
}
var n int
if _, err := fmt.Sscan(field, &n); err != nil {
return false
}
return n == value
}
// ChatSession is private to its owner and may point at a project.
type ChatSession struct {
ID, WorkspaceID, OwnerID, ProjectID string
}
func (s *Store) CreateChat(c ChatSession) (ChatSession, error) {
if c.ID == "" {
c.ID = newID()
}
_, err := s.db.Exec(`INSERT INTO chat_session(id,workspace_id,owner_id,project_id,created_at) VALUES(?,?,?,?,?)`,
c.ID, c.WorkspaceID, c.OwnerID, c.ProjectID, now())
return c, err
}
func (s *Store) AddChatMessage(sessionID, role, body string) error {
_, err := s.db.Exec(`INSERT INTO chat_message(id,session_id,role,body,created_at) VALUES(?,?,?,?,?)`, newID(), sessionID, role, body, now())
return err
}
// Project groups resources.
type Project struct {
ID, WorkspaceID, Name string
}
func (s *Store) CreateProject(p Project) (Project, error) {
if p.ID == "" {
p.ID = newID()
}
_, err := s.db.Exec(`INSERT INTO project(id,workspace_id,name,created_at) VALUES(?,?,?,?)`, p.ID, p.WorkspaceID, p.Name, now())
return p, err
}
// AddResource records a github_repo or local_directory. local_directory mode is in_place or worktree.
func (s *Store) AddResource(projectID, kind, locator, mode string) error {
switch kind {
case "github_repo":
mode = ""
case "local_directory":
if mode != "in_place" && mode != "worktree" {
return fmt.Errorf("local_directory mode must be in_place or worktree")
}
default:
return fmt.Errorf("unknown resource kind %s", kind)
}
_, err := s.db.Exec(`INSERT INTO project_resource(id,project_id,kind,locator,mode) VALUES(?,?,?,?,?)`, newID(), projectID, kind, locator, mode)
return err
}
+290
View File
@@ -0,0 +1,290 @@
package platform
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"fmt"
"strings"
"time"
_ "modernc.org/sqlite"
)
// Store is the orchestration database. The goal engine keeps its file workspace.
type Store struct {
db *sql.DB
}
func Open(path string) (*Store, error) {
db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)")
if err != nil {
return nil, err
}
if _, err := db.Exec(`PRAGMA foreign_keys = ON`); err != nil {
_ = db.Close()
return nil, err
}
s := &Store{db: db}
if err := s.migrate(); err != nil {
_ = db.Close()
return nil, err
}
return s, nil
}
func (s *Store) Close() error { return s.db.Close() }
func (s *Store) migrate() error {
_, err := s.db.Exec(schema)
return err
}
func newID() string {
var b [16]byte
_, _ = rand.Read(b[:])
return hex.EncodeToString(b[:])
}
func now() string { return time.Now().UTC().Format(time.RFC3339Nano) }
const schema = `
CREATE TABLE IF NOT EXISTS workspace (
id TEXT PRIMARY KEY, name TEXT NOT NULL, created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS member (
id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, name TEXT NOT NULL,
kind TEXT NOT NULL, created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS runtime (
id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, name TEXT NOT NULL,
command TEXT NOT NULL, protocol TEXT NOT NULL, max_concurrency INTEGER NOT NULL,
last_heartbeat TEXT, created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS agent (
id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, name TEXT NOT NULL,
runtime_id TEXT, custom_env TEXT, custom_args TEXT, mcp_config TEXT,
max_concurrency INTEGER NOT NULL, archived INTEGER NOT NULL, created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS squad (
id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, name TEXT NOT NULL,
leader_agent_id TEXT NOT NULL, instructions TEXT NOT NULL, created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS squad_member (
squad_id TEXT NOT NULL, member_kind TEXT NOT NULL, member_id TEXT NOT NULL,
role_description TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS issue (
id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, title TEXT NOT NULL,
body TEXT NOT NULL, status TEXT NOT NULL, parent_id TEXT, stage TEXT,
assignee_kind TEXT, assignee_id TEXT, created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS comment (
id TEXT PRIMARY KEY, issue_id TEXT NOT NULL, author_kind TEXT NOT NULL,
author_id TEXT NOT NULL, body TEXT NOT NULL, parent_id TEXT, created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS task (
id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, issue_id TEXT, agent_id TEXT NOT NULL,
runtime_id TEXT, status TEXT NOT NULL, reason_code TEXT, attempts INTEGER NOT NULL,
lease_until TEXT, fire_at TEXT, is_leader INTEGER NOT NULL, squad_id TEXT,
token TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS task_message (
id TEXT PRIMARY KEY, task_id TEXT NOT NULL, kind TEXT NOT NULL, body TEXT NOT NULL, created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS skill (
id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, name TEXT NOT NULL,
description TEXT NOT NULL, source TEXT NOT NULL, created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS skill_file (
id TEXT PRIMARY KEY, skill_id TEXT NOT NULL, path TEXT NOT NULL, body TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS agent_skill (
agent_id TEXT NOT NULL, skill_id TEXT NOT NULL, enabled INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS autopilot (
id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, title TEXT NOT NULL,
runbook TEXT NOT NULL, assignee_kind TEXT NOT NULL, assignee_id TEXT NOT NULL,
status TEXT NOT NULL, execution_mode TEXT NOT NULL, created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS trigger_ (
id TEXT PRIMARY KEY, autopilot_id TEXT NOT NULL, kind TEXT NOT NULL,
cron TEXT, timezone TEXT, token TEXT, secret TEXT
);
CREATE TABLE IF NOT EXISTS autopilot_run (
id TEXT PRIMARY KEY, autopilot_id TEXT NOT NULL, status TEXT NOT NULL,
detail TEXT, created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS webhook_delivery (
id TEXT PRIMARY KEY, autopilot_id TEXT NOT NULL, dedupe_key TEXT NOT NULL, created_at TEXT NOT NULL,
UNIQUE(autopilot_id, dedupe_key)
);
CREATE TABLE IF NOT EXISTS chat_session (
id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, owner_id TEXT NOT NULL,
project_id TEXT, created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS chat_message (
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL, body TEXT NOT NULL, created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS project (
id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS project_resource (
id TEXT PRIMARY KEY, project_id TEXT NOT NULL, kind TEXT NOT NULL,
locator TEXT NOT NULL, mode TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS wakeup (
id TEXT PRIMARY KEY, task_id TEXT NOT NULL, kind TEXT NOT NULL, fire_at TEXT, created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS usage_ (
id TEXT PRIMARY KEY, task_id TEXT NOT NULL, model TEXT NOT NULL,
input_tokens INTEGER NOT NULL, output_tokens INTEGER NOT NULL,
cache_read INTEGER NOT NULL, cache_write INTEGER NOT NULL, created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS activity (
id TEXT PRIMARY KEY, squad_id TEXT NOT NULL, issue_id TEXT NOT NULL,
actor_id TEXT NOT NULL, action TEXT NOT NULL, detail TEXT NOT NULL, created_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS agent_skill_pk ON agent_skill(agent_id, skill_id);
`
// Workspace is a tenant boundary.
type Workspace struct {
ID string
Name string
}
func (s *Store) CreateWorkspace(name string) (Workspace, error) {
w := Workspace{ID: newID(), Name: name}
_, err := s.db.Exec(`INSERT INTO workspace(id,name,created_at) VALUES(?,?,?)`, w.ID, w.Name, now())
return w, err
}
// Member is a human identity. Agents live in their own table.
type Member struct {
ID, WorkspaceID, Name, Kind string
}
func (s *Store) CreateMember(workspaceID, name string) (Member, error) {
m := Member{ID: newID(), WorkspaceID: workspaceID, Name: name, Kind: "human"}
_, err := s.db.Exec(`INSERT INTO member(id,workspace_id,name,kind,created_at) VALUES(?,?,?,?,?)`,
m.ID, m.WorkspaceID, m.Name, m.Kind, now())
return m, err
}
// Agent is a durable executor identity.
type Agent struct {
ID, WorkspaceID, Name, RuntimeID, CustomEnv, CustomArgs, MCPConfig string
MaxConcurrency int
Archived bool
}
func (s *Store) CreateAgent(a Agent) (Agent, error) {
if a.ID == "" {
a.ID = newID()
}
if a.MaxConcurrency <= 0 {
a.MaxConcurrency = 1
}
_, err := s.db.Exec(`INSERT INTO agent(id,workspace_id,name,runtime_id,custom_env,custom_args,mcp_config,max_concurrency,archived,created_at)
VALUES(?,?,?,?,?,?,?,?,0,?)`,
a.ID, a.WorkspaceID, a.Name, a.RuntimeID, a.CustomEnv, a.CustomArgs, a.MCPConfig, a.MaxConcurrency, now())
return a, err
}
func (s *Store) GetAgent(id string) (Agent, error) {
var a Agent
var archived int
err := s.db.QueryRow(`SELECT id,workspace_id,name,IFNULL(runtime_id,''),IFNULL(custom_env,''),IFNULL(custom_args,''),IFNULL(mcp_config,''),max_concurrency,archived FROM agent WHERE id=?`, id).
Scan(&a.ID, &a.WorkspaceID, &a.Name, &a.RuntimeID, &a.CustomEnv, &a.CustomArgs, &a.MCPConfig, &a.MaxConcurrency, &archived)
a.Archived = archived == 1
return a, err
}
// ArchiveAgent cancels unfinished tasks for that agent.
func (s *Store) ArchiveAgent(id string) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`UPDATE agent SET archived=1 WHERE id=?`, id); err != nil {
return err
}
if _, err := tx.Exec(`UPDATE task SET status='cancelled', reason_code='archived', updated_at=? WHERE agent_id=? AND status IN ('deferred','queued','dispatched','waiting_local_directory','running')`, now(), id); err != nil {
return err
}
return tx.Commit()
}
// CopyAgent copies identity only. Env, MCP, and runtime config are not copied.
func (s *Store) CopyAgent(id string) (Agent, error) {
src, err := s.GetAgent(id)
if err != nil {
return Agent{}, err
}
return s.CreateAgent(Agent{
WorkspaceID: src.WorkspaceID, Name: src.Name + " copy", MaxConcurrency: src.MaxConcurrency,
})
}
func (s *Store) UpdateAgent(a Agent) error {
_, err := s.db.Exec(`UPDATE agent SET name=?, runtime_id=?, custom_env=?, custom_args=?, mcp_config=?, max_concurrency=? WHERE id=?`,
a.Name, a.RuntimeID, a.CustomEnv, a.CustomArgs, a.MCPConfig, a.MaxConcurrency, a.ID)
return err
}
// Runtime is one registered executor, including the in-process agent-native runtime.
type Runtime struct {
ID, WorkspaceID, Name, Command, Protocol string
MaxConcurrency int
LastHeartbeat string
}
func (s *Store) RegisterRuntime(rt Runtime) (Runtime, error) {
if rt.ID == "" {
rt.ID = newID()
}
if rt.MaxConcurrency <= 0 {
rt.MaxConcurrency = 1
}
if rt.Protocol == "" {
rt.Protocol = "custom"
}
_, err := s.db.Exec(`INSERT INTO runtime(id,workspace_id,name,command,protocol,max_concurrency,last_heartbeat,created_at) VALUES(?,?,?,?,?,?,?,?)`,
rt.ID, rt.WorkspaceID, rt.Name, rt.Command, rt.Protocol, rt.MaxConcurrency, now(), now())
return rt, err
}
func (s *Store) Heartbeat(runtimeID string) error {
_, err := s.db.Exec(`UPDATE runtime SET last_heartbeat=? WHERE id=?`, now(), runtimeID)
return err
}
func scanStrings(rows *sql.Rows) ([]string, error) {
defer rows.Close()
var out []string
for rows.Next() {
var v string
if err := rows.Scan(&v); err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
func placeholder(n int) string {
parts := make([]string, n)
for i := range parts {
parts[i] = "?"
}
return strings.Join(parts, ",")
}
func fmtErr(msg string, err error) error {
if err == nil {
return nil
}
return fmt.Errorf("%s: %w", msg, 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=
+120
View File
@@ -0,0 +1,120 @@
package platform
import (
"encoding/json"
"io"
"net/http"
"time"
)
func nowTime() time.Time { return time.Now() }
// Mount attaches the orchestration routes. The goal-engine /v1/runs routes stay separate.
func Mount(mux *http.ServeMux, st *Store) {
mux.HandleFunc("POST /v1/workspaces", func(w http.ResponseWriter, r *http.Request) {
var body struct{ Name string }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
ws, err := st.CreateWorkspace(body.Name)
write(w, ws, err)
})
mux.HandleFunc("POST /v1/agents", func(w http.ResponseWriter, r *http.Request) {
var a Agent
if err := json.NewDecoder(r.Body).Decode(&a); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
created, err := st.CreateAgent(a)
write(w, created, err)
})
mux.HandleFunc("POST /v1/squads", func(w http.ResponseWriter, r *http.Request) {
var body struct {
Squad
Members []SquadMember
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
created, err := st.CreateSquad(body.Squad, body.Members)
write(w, created, err)
})
mux.HandleFunc("POST /v1/issues", func(w http.ResponseWriter, r *http.Request) {
var issue Issue
if err := json.NewDecoder(r.Body).Decode(&issue); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
created, err := st.CreateIssue(issue)
write(w, created, err)
})
mux.HandleFunc("POST /v1/issues/{id}/assign", func(w http.ResponseWriter, r *http.Request) {
var body struct {
Kind string
ID string
Start bool
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
err := st.AssignIssue(r.PathValue("id"), body.Kind, body.ID, body.Start)
write(w, map[string]bool{"ok": err == nil}, err)
})
mux.HandleFunc("POST /v1/issues/{id}/comments", func(w http.ResponseWriter, r *http.Request) {
var c Comment
if err := json.NewDecoder(r.Body).Decode(&c); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
c.IssueID = r.PathValue("id")
created, err := st.AddComment(c)
write(w, created, err)
})
mux.HandleFunc("POST /v1/tasks/claim", func(w http.ResponseWriter, r *http.Request) {
task, ok, err := st.Claim(r.URL.Query().Get("runtime"), nowTime())
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
write(w, map[string]any{"task": task, "ok": ok}, nil)
})
mux.HandleFunc("POST /v1/webhooks/autopilots/{token}", func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 256*1024+1))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
headers := map[string]string{}
for _, key := range []string{"Idempotency-Key", "X-GitHub-Delivery", "X-Hub-Signature-256", "X-GitHub-Event"} {
if v := r.Header.Get(key); v != "" {
headers[key] = v
}
}
event, err := st.AcceptWebhook(r.PathValue("token"), body, headers)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
write(w, map[string]string{"event": event}, nil)
})
mux.HandleFunc("GET /v1/events/ws", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Upgrade") == "" {
http.Error(w, "upgrade required", http.StatusUpgradeRequired)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte("{\"type\":\"ready\"}\n"))
})
}
func write(w http.ResponseWriter, v any, err error) {
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
+10
View File
@@ -0,0 +1,10 @@
package platform
// 未核实项按保守语义实现,不假装和 multica 的内部表一致:
//
// - 权限只有 private 与 public_to 两个档,public_to 用一张允许列表,没有基于角色的工作区 ACL。
// - 委派深度上限是 4。上游没有写成文的跳数。
// - Access 的三层存储没有照搬。本库是编排状态;目标引擎仍用原来的文件工作区。
// - 外部 CLI、扩展和包都以调用者权限运行,不是沙箱。
// - 看板、Git 托管关联和 cloud runtime 不在这个库里。
const ConservativeNotes = "private|public_to allow-list; delegation depth 4; file workspace kept for the goal engine"
+258
View File
@@ -0,0 +1,258 @@
package platform
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"strings"
"testing"
"time"
agentsquad "agent-squad/squad"
)
func TestOrchestrationFlow(t *testing.T) {
s := openTest(t)
ws, err := s.CreateWorkspace("lab")
if err != nil {
t.Fatal(err)
}
leader, err := s.CreateAgent(Agent{WorkspaceID: ws.ID, Name: "leader", MaxConcurrency: 2})
if err != nil {
t.Fatal(err)
}
member, err := s.CreateAgent(Agent{WorkspaceID: ws.ID, Name: "writer", MaxConcurrency: 1})
if err != nil {
t.Fatal(err)
}
human, err := s.CreateMember(ws.ID, "ada")
if err != nil {
t.Fatal(err)
}
copied, err := s.CopyAgent(leader.ID)
if err != nil || copied.CustomEnv != "" || copied.RuntimeID != "" {
t.Fatalf("copy leaked config: %+v %v", copied, err)
}
squad, err := s.CreateSquad(Squad{
WorkspaceID: ws.ID, Name: "docs", LeaderAgentID: leader.ID, Instructions: "ship the note",
}, []SquadMember{{Kind: "agent", ID: member.ID, RoleDescription: "writes"}, {Kind: "human", ID: human.ID, RoleDescription: "reviews"}})
if err != nil {
t.Fatal(err)
}
brief, err := s.LeaderBrief(squad.ID)
if err != nil || !strings.Contains(brief, "Squad Operating Protocol") || !strings.Contains(brief, "mention://agent/") || !strings.Contains(brief, "ship the note") {
t.Fatalf("brief: %s %v", brief, err)
}
issue, err := s.CreateIssue(Issue{WorkspaceID: ws.ID, Title: "write the note", Body: "three facts", Status: "todo"})
if err != nil {
t.Fatal(err)
}
if err := s.AssignIssue(issue.ID, "squad", squad.ID, false); err != nil {
t.Fatal(err)
}
if _, ok, err := claimAny(t, s, ws.ID); err != nil || ok {
t.Fatal("start=false must not enqueue")
}
if err := s.AssignIssue(issue.ID, "squad", squad.ID, true); err != nil {
t.Fatal(err)
}
rts, err := s.RegisterProbed(ws.ID)
if err != nil || rts[0].Protocol != "agent-native" {
t.Fatalf("runtimes %+v %v", rts, err)
}
leader.RuntimeID = rts[0].ID
if err := s.UpdateAgent(leader); err != nil {
t.Fatal(err)
}
member.RuntimeID = rts[0].ID
if err := s.UpdateAgent(member); err != nil {
t.Fatal(err)
}
task, ok, err := s.Claim(rts[0].ID, time.Now())
if err != nil || !ok || !task.IsLeader || task.AgentID != leader.ID {
t.Fatalf("claim %+v %v %v", task, ok, err)
}
if err := s.TouchStatus(issue.ID, leader.ID, false); err != nil {
t.Fatal(err)
}
if err := s.RecordActivity(squad.ID, issue.ID, leader.ID, "action", "delegate"); err != nil {
t.Fatal(err)
}
stages, err := s.PlanStages(issue.ID, []string{"draft"})
if err != nil || len(stages) != 1 {
t.Fatal(err)
}
if _, err := s.DelegateComment(issue.ID, leader.ID, member.ID, "please draft"); err != nil {
t.Fatal(err)
}
memberTask, ok, err := s.Claim(rts[0].ID, time.Now())
if err != nil || !ok || memberTask.AgentID != member.ID {
t.Fatalf("member claim %+v %v %v", memberTask, ok, err)
}
if err := s.AgentCLI(memberTask.Token, []string{"comment", "draft ready"}); err != nil {
t.Fatal(err)
}
if err := s.FinishTask(task.ID, "completed", ""); err != nil {
t.Fatal(err)
}
if err := s.CloseIssue(stages[0].ID); err != nil {
t.Fatal(err)
}
woke, ok, err := s.Claim(rts[0].ID, time.Now())
if err != nil || !ok || woke.AgentID != leader.ID {
t.Fatalf("child_done wake %+v %v %v", woke, ok, err)
}
if err := s.TouchStatus(issue.ID, leader.ID, true); err != nil {
t.Fatal(err)
}
got, err := s.GetIssue(issue.ID)
if err != nil || got.Status != "in_review" {
t.Fatalf("status %+v %v", got, err)
}
work, err := s.PrepareWorkdir(t.TempDir(), task)
if err != nil {
t.Fatal(err)
}
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)
}
script := filepath.Join(t.TempDir(), "fake-cli")
if err := os.WriteFile(script, []byte("#!/bin/sh\necho ok\n"), 0o755); err != nil {
t.Fatal(err)
}
fakeTask, err := s.enqueue(issue, leader.ID, false, squad.ID)
if err != nil {
t.Fatal(err)
}
if err := s.RunCommand(context.Background(), fakeTask, script, work); err != nil {
t.Fatal(err)
}
finished, err := s.getTask(fakeTask.ID)
if err != nil || finished.Status != "completed" {
t.Fatalf("fake cli %+v %v", finished, err)
}
}
func TestAutopilotSkillAndQueueEdges(t *testing.T) {
s := openTest(t)
ws, _ := s.CreateWorkspace("lab")
agent, _ := s.CreateAgent(Agent{WorkspaceID: ws.ID, Name: "bot", MaxConcurrency: 1})
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "review"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "review", "SKILL.md"), []byte("description: careful\nbody"), 0o644); err != nil {
t.Fatal(err)
}
sk, err := s.ImportSkill(ws.ID, filepath.Join(dir, "review"), "fail")
if err != nil {
t.Fatal(err)
}
if _, err := s.ImportSkill(ws.ID, filepath.Join(dir, "review"), "fail"); err == nil {
t.Fatal("conflict fail")
}
if _, err := s.ImportSkill(ws.ID, filepath.Join(dir, "review"), "skip"); err != nil {
t.Fatal(err)
}
if err := s.BindSkill(agent.ID, sk.ID, true); err != nil {
t.Fatal(err)
}
ap, err := s.CreateAutopilot(Autopilot{
WorkspaceID: ws.ID, Title: "daily", Runbook: "check", AssigneeKind: "agent", AssigneeID: agent.ID, ExecutionMode: "create_issue",
})
if err != nil {
t.Fatal(err)
}
if err := s.AddSchedule(ap.ID, "* * * * *", "UTC"); err != nil {
t.Fatal(err)
}
n, err := s.FireSchedules(time.Now())
if err != nil || n != 1 {
t.Fatalf("cron %d %v", n, err)
}
token, err := s.AddWebhook(ap.ID, "secret")
if err != nil {
t.Fatal(err)
}
body := []byte(`{"event":"push"}`)
mac := sign(body, "secret")
if _, err := s.AcceptWebhook(token, body, map[string]string{"X-Hub-Signature-256": mac, "Idempotency-Key": "k1"}); err != nil {
t.Fatal(err)
}
got, err := s.AcceptWebhook(token, body, map[string]string{"X-Hub-Signature-256": mac, "Idempotency-Key": "k1"})
if err != nil || got != "duplicate" {
t.Fatalf("dedupe %q %v", got, err)
}
issue, _ := s.CreateIssue(Issue{WorkspaceID: ws.ID, Title: "later", Status: "todo"})
if _, err := s.DeferTask(issue, agent.ID, time.Now().Add(-time.Minute)); err != nil {
t.Fatal(err)
}
moved, err := s.PromoteDeferred(time.Now())
if err != nil || moved < 1 {
t.Fatalf("promote %d %v", moved, err)
}
if _, err := s.AddComment(Comment{IssueID: issue.ID, AuthorKind: "human", AuthorID: "h", Body: "/note secret"}); err != nil {
t.Fatal(err)
}
chat, err := s.CreateChat(ChatSession{WorkspaceID: ws.ID, OwnerID: "h"})
if err != nil {
t.Fatal(err)
}
if err := s.AddChatMessage(chat.ID, "user", "hi"); err != nil {
t.Fatal(err)
}
proj, err := s.CreateProject(Project{WorkspaceID: ws.ID, Name: "repo"})
if err != nil {
t.Fatal(err)
}
if err := s.AddResource(proj.ID, "local_directory", "/tmp/src", "worktree"); err != nil {
t.Fatal(err)
}
if err := s.ArchiveAgent(agent.ID); err != nil {
t.Fatal(err)
}
}
func openTest(t *testing.T) *Store {
t.Helper()
s, err := Open(filepath.Join(t.TempDir(), "orch.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = s.Close() })
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"})
if err != nil {
return Task{}, false, err
}
return s.Claim(rt.ID, time.Now())
}
func sign(body []byte, secret string) string {
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write(body)
return "sha256=" + hex.EncodeToString(mac.Sum(nil))
}
+371
View File
@@ -0,0 +1,371 @@
package platform
import (
"fmt"
"regexp"
"strings"
"time"
)
// Issue is the durable work record. A task is one execution of it.
type Issue struct {
ID, WorkspaceID, Title, Body, Status, ParentID, Stage, AssigneeKind, AssigneeID string
}
func (s *Store) CreateIssue(issue Issue) (Issue, error) {
if issue.ID == "" {
issue.ID = newID()
}
if issue.Status == "" {
issue.Status = "todo"
}
_, err := s.db.Exec(`INSERT INTO issue(id,workspace_id,title,body,status,parent_id,stage,assignee_kind,assignee_id,created_at)
VALUES(?,?,?,?,?,?,?,?,?,?)`,
issue.ID, issue.WorkspaceID, issue.Title, issue.Body, issue.Status, issue.ParentID, issue.Stage, issue.AssigneeKind, issue.AssigneeID, now())
return issue, err
}
func (s *Store) GetIssue(id string) (Issue, error) {
var issue Issue
err := s.db.QueryRow(`SELECT id,workspace_id,title,body,status,IFNULL(parent_id,''),IFNULL(stage,''),IFNULL(assignee_kind,''),IFNULL(assignee_id,'') FROM issue WHERE id=?`, id).
Scan(&issue.ID, &issue.WorkspaceID, &issue.Title, &issue.Body, &issue.Status, &issue.ParentID, &issue.Stage, &issue.AssigneeKind, &issue.AssigneeID)
return issue, err
}
// AssignIssue records the owner. Backlog, or start=false, does not enqueue.
// A squad assignee enqueues only the leader.
func (s *Store) AssignIssue(issueID, kind, id string, start bool) error {
issue, err := s.GetIssue(issueID)
if err != nil {
return err
}
if _, err := s.db.Exec(`UPDATE issue SET assignee_kind=?, assignee_id=? WHERE id=?`, kind, id, issueID); err != nil {
return err
}
issue.AssigneeKind, issue.AssigneeID = kind, id
if !start || issue.Status == "backlog" {
return nil
}
return s.enqueueAssignee(issue)
}
func (s *Store) enqueueAssignee(issue Issue) error {
switch issue.AssigneeKind {
case "agent":
_, err := s.enqueue(issue, issue.AssigneeID, false, "")
return err
case "squad":
var leader, squadID string
if err := s.db.QueryRow(`SELECT id, leader_agent_id FROM squad WHERE id=?`, issue.AssigneeID).Scan(&squadID, &leader); err != nil {
return err
}
_, err := s.enqueue(issue, leader, true, squadID)
return err
default:
return nil
}
}
// Comment is one note on an issue.
type Comment struct {
ID, IssueID, AuthorKind, AuthorID, Body, ParentID string
}
var mentionRE = regexp.MustCompile(`mention://(agent|member)/([0-9a-fA-F]+)`)
// AddComment stores the comment and routes mentions. /note and a bare @all do not enqueue.
func (s *Store) AddComment(c Comment) (Comment, error) {
if c.ID == "" {
c.ID = newID()
}
if _, err := s.db.Exec(`INSERT INTO comment(id,issue_id,author_kind,author_id,body,parent_id,created_at) VALUES(?,?,?,?,?,?,?)`,
c.ID, c.IssueID, c.AuthorKind, c.AuthorID, c.Body, c.ParentID, now()); err != nil {
return Comment{}, err
}
if err := s.routeComment(c); err != nil {
return Comment{}, err
}
return c, nil
}
func (s *Store) routeComment(c Comment) error {
trimmed := strings.TrimSpace(c.Body)
if strings.HasPrefix(trimmed, "/note") {
return nil
}
if strings.Contains(trimmed, "@all") && !mentionRE.MatchString(c.Body) {
return nil
}
issue, err := s.GetIssue(c.IssueID)
if err != nil {
return err
}
if issue.Status == "done" || issue.Status == "cancelled" || issue.Status == "backlog" {
return nil
}
matches := mentionRE.FindAllStringSubmatch(c.Body, -1)
for _, m := range matches {
if m[1] != "agent" {
continue
}
if _, err := s.enqueue(issue, m[2], false, issueSquad(issue)); err != nil {
return err
}
}
if c.ParentID != "" && len(matches) == 0 {
var kind, author string
err := s.db.QueryRow(`SELECT author_kind, author_id FROM comment WHERE id=?`, c.ParentID).Scan(&kind, &author)
if err == nil && kind == "agent" {
_, err = s.enqueue(issue, author, false, issueSquad(issue))
return err
}
}
return nil
}
func issueSquad(issue Issue) string {
if issue.AssigneeKind == "squad" {
return issue.AssigneeID
}
return ""
}
// Task is one execution attempt.
type Task struct {
ID, WorkspaceID, IssueID, AgentID, RuntimeID, Status, ReasonCode, LeaseUntil, FireAt, SquadID, Token string
Attempts int
IsLeader bool
}
func (s *Store) enqueue(issue Issue, agentID string, leader bool, squadID string) (Task, error) {
var existing string
err := s.db.QueryRow(`SELECT id FROM task WHERE issue_id=? AND agent_id=? AND status IN ('queued','dispatched')`, issue.ID, agentID).Scan(&existing)
if err == nil && existing != "" {
return s.getTask(existing)
}
leaderFlag := 0
if leader {
leaderFlag = 1
}
t := Task{
ID: newID(), WorkspaceID: issue.WorkspaceID, IssueID: issue.ID, AgentID: agentID,
Status: "queued", SquadID: squadID, Token: newID(), IsLeader: leader,
}
_, err = s.db.Exec(`INSERT INTO task(id,workspace_id,issue_id,agent_id,runtime_id,status,reason_code,attempts,lease_until,fire_at,is_leader,squad_id,token,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,0,?,?,?,?,?,?,?)`,
t.ID, t.WorkspaceID, t.IssueID, t.AgentID, "", t.Status, "", "", "", leaderFlag, t.SquadID, t.Token, now(), now())
return t, err
}
// DeferTask parks a task until fireAt.
func (s *Store) DeferTask(issue Issue, agentID string, fireAt time.Time) (Task, error) {
t := Task{ID: newID(), WorkspaceID: issue.WorkspaceID, IssueID: issue.ID, AgentID: agentID, Status: "deferred", Token: newID(), FireAt: fireAt.UTC().Format(time.RFC3339Nano)}
_, err := s.db.Exec(`INSERT INTO task(id,workspace_id,issue_id,agent_id,runtime_id,status,reason_code,attempts,lease_until,fire_at,is_leader,squad_id,token,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
t.ID, t.WorkspaceID, t.IssueID, t.AgentID, "", "deferred", "", 0, "", t.FireAt, 0, "", t.Token, now(), now())
if err != nil {
return Task{}, err
}
_, err = s.db.Exec(`INSERT INTO wakeup(id,task_id,kind,fire_at,created_at) VALUES(?,?,?,?,?)`, newID(), t.ID, "deferred", t.FireAt, now())
return t, err
}
func (s *Store) getTask(id string) (Task, error) {
var t Task
var leader int
err := s.db.QueryRow(`SELECT id,workspace_id,IFNULL(issue_id,''),agent_id,IFNULL(runtime_id,''),status,IFNULL(reason_code,''),attempts,IFNULL(lease_until,''),IFNULL(fire_at,''),is_leader,IFNULL(squad_id,''),token FROM task WHERE id=?`, id).
Scan(&t.ID, &t.WorkspaceID, &t.IssueID, &t.AgentID, &t.RuntimeID, &t.Status, &t.ReasonCode, &t.Attempts, &t.LeaseUntil, &t.FireAt, &leader, &t.SquadID, &t.Token)
t.IsLeader = leader == 1
return t, err
}
// PromoteDeferred moves due deferred tasks to queued.
func (s *Store) PromoteDeferred(at time.Time) (int, error) {
res, err := s.db.Exec(`UPDATE task SET status='queued', updated_at=? WHERE status='deferred' AND fire_at<=?`, now(), at.UTC().Format(time.RFC3339Nano))
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return int(n), nil
}
// Claim takes one queued task whose agent and runtime are under their caps.
// The task becomes dispatched with a five-minute lease.
func (s *Store) Claim(runtimeID string, at time.Time) (Task, bool, error) {
rt, err := s.getRuntime(runtimeID)
if err != nil {
return Task{}, false, err
}
rows, err := s.db.Query(`SELECT id, agent_id FROM task WHERE status='queued' AND workspace_id=? ORDER BY created_at`, rt.WorkspaceID)
if err != nil {
return Task{}, false, err
}
type candidate struct{ id, agentID string }
var candidates []candidate
for rows.Next() {
var id, agentID string
if err := rows.Scan(&id, &agentID); err != nil {
rows.Close()
return Task{}, false, err
}
candidates = append(candidates, candidate{id, agentID})
}
if err := rows.Err(); err != nil {
rows.Close()
return Task{}, false, err
}
rows.Close()
for _, cand := range candidates {
agent, err := s.GetAgent(cand.agentID)
if err != nil || agent.Archived {
continue
}
if agent.RuntimeID != "" && agent.RuntimeID != runtimeID {
continue
}
cap := agent.MaxConcurrency
if rt.MaxConcurrency < cap {
cap = rt.MaxConcurrency
}
var running int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM task WHERE agent_id=? AND status IN ('dispatched','running')`, cand.agentID).Scan(&running)
if running >= cap {
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'
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
}
n, _ := res.RowsAffected()
if n == 1 {
t, err := s.getTask(cand.id)
return t, true, err
}
}
return Task{}, false, nil
}
func (s *Store) getRuntime(id string) (Runtime, error) {
var rt Runtime
err := s.db.QueryRow(`SELECT id,workspace_id,name,command,protocol,max_concurrency,IFNULL(last_heartbeat,'') FROM runtime WHERE id=?`, id).
Scan(&rt.ID, &rt.WorkspaceID, &rt.Name, &rt.Command, &rt.Protocol, &rt.MaxConcurrency, &rt.LastHeartbeat)
return rt, err
}
// MarkRunning starts the lease clock of an already claimed task.
func (s *Store) MarkRunning(taskID string) error {
_, err := s.db.Exec(`UPDATE task SET status='running', updated_at=? WHERE id=?`, now(), taskID)
return err
}
// TransientReason reports whether a failure may be retried.
func TransientReason(code string) bool {
switch code {
case "runtime_offline", "daemon_restart", "timeout", "skill_bundle", "tool_network":
return true
default:
return false
}
}
func retryLimit(code string) int {
if code == "tool_network" {
return 3
}
if TransientReason(code) {
return 2
}
return 0
}
// FinishTask closes a task. Transient failures requeue until the attempt cap.
// Autopilot run_only tasks are not retried. A failed issue task rolls
// in_progress back to todo when nothing else is still active.
func (s *Store) FinishTask(taskID, status, reason string) error {
t, err := s.getTask(taskID)
if err != nil {
return err
}
if status == "failed" && TransientReason(reason) && t.IssueID != "" && t.Attempts+1 < retryLimit(reason) {
_, err = s.db.Exec(`UPDATE task SET status='queued', reason_code=?, attempts=attempts+1, runtime_id='', lease_until='', updated_at=? WHERE id=?`,
reason, now(), taskID)
return err
}
if status != "completed" && status != "failed" && status != "cancelled" {
return fmt.Errorf("unsupported terminal status %s", status)
}
if _, err := s.db.Exec(`UPDATE task SET status=?, reason_code=?, updated_at=? WHERE id=?`, status, reason, now(), taskID); err != nil {
return err
}
if status == "failed" && t.IssueID != "" {
var active int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM task WHERE issue_id=? AND status IN ('deferred','queued','dispatched','waiting_local_directory','running')`, t.IssueID).Scan(&active)
if active == 0 {
_, _ = s.db.Exec(`UPDATE issue SET status='todo' WHERE id=? AND status='in_progress'`, t.IssueID)
}
}
return nil
}
// RecoverOrphans fails dispatched tasks whose lease expired and requeues
// running tasks whose runtime heartbeat is older than three minutes.
func (s *Store) RecoverOrphans(at time.Time) (int, error) {
n := 0
rows, err := s.db.Query(`SELECT id FROM task WHERE status='dispatched' AND lease_until < ?`, at.UTC().Format(time.RFC3339Nano))
if err != nil {
return 0, err
}
ids, err := scanStrings(rows)
if err != nil {
return 0, err
}
for _, id := range ids {
if err := s.FinishTask(id, "failed", "timeout"); err != nil {
return n, err
}
n++
}
cutoff := at.UTC().Add(-3 * time.Minute).Format(time.RFC3339Nano)
rows, err = s.db.Query(`SELECT task.id FROM task JOIN runtime ON runtime.id=task.runtime_id WHERE task.status='running' AND runtime.last_heartbeat < ?`, cutoff)
if err != nil {
return n, err
}
ids, err = scanStrings(rows)
if err != nil {
return n, err
}
for _, id := range ids {
if err := s.FinishTask(id, "failed", "runtime_offline"); err != nil {
return n, err
}
n++
}
return n, nil
}
// AddTaskMessage records a streamed progress line.
func (s *Store) AddTaskMessage(taskID, kind, body string) error {
_, err := s.db.Exec(`INSERT INTO task_message(id,task_id,kind,body,created_at) VALUES(?,?,?,?,?)`, newID(), taskID, kind, body, now())
return err
}
// AddUsage records token counts for one model.
func (s *Store) AddUsage(taskID, model string, input, output, cacheRead, cacheWrite int) error {
_, err := s.db.Exec(`INSERT INTO usage_(id,task_id,model,input_tokens,output_tokens,cache_read,cache_write,created_at) VALUES(?,?,?,?,?,?,?,?)`,
newID(), taskID, model, input, output, cacheRead, cacheWrite, now())
return err
}
// TaskByToken resolves the agent-side credential.
func (s *Store) TaskByToken(token string) (Task, error) {
var id string
err := s.db.QueryRow(`SELECT id FROM task WHERE token=?`, token).Scan(&id)
if err != nil {
return Task{}, err
}
return s.getTask(id)
}
+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"
+302
View File
@@ -0,0 +1,302 @@
package platform
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"agent-squad/squad"
)
// CLICommands are the external tools a daemon probes, in order.
var CLICommands = []string{
"claude", "codex", "opencode", "codearts", "deveco", "openclaw", "hermes", "pi",
"omp", "cursor-agent", "copilot", "kimi", "reasonix", "dsh", "kiro-cli", "codebuddy",
"agy", "qodercli", "qodercln", "traecli", "grok", "qwen", "qwenpaw", "dim", "mcode",
}
// ProtocolFor maps a command onto a protocol family.
func ProtocolFor(command string) string {
switch command {
case "claude":
return "claude"
case "codex":
return "codex"
case "pi":
return "pi"
case "opencode":
return "opencode"
default:
return "custom"
}
}
// ProbeCLIs returns commands present on PATH. An empty list is allowed:
// the in-process runtime still runs.
func ProbeCLIs() []string {
var found []string
for _, name := range CLICommands {
if env := os.Getenv("AGENT_SQUAD_" + strings.ToUpper(strings.ReplaceAll(name, "-", "_")) + "_PATH"); env != "" {
if _, err := os.Stat(env); err == nil {
found = append(found, env)
continue
}
}
if path, err := exec.LookPath(name); err == nil {
found = append(found, path)
}
}
return found
}
// RegisterProbed records every discovered CLI plus the in-process runtime.
func (s *Store) RegisterProbed(workspaceID string) ([]Runtime, error) {
inproc, err := s.RegisterRuntime(Runtime{
WorkspaceID: workspaceID, Name: "agent-native", Command: "in-process",
Protocol: "agent-native", MaxConcurrency: 2,
})
if err != nil {
return nil, err
}
out := []Runtime{inproc}
for _, path := range ProbeCLIs() {
base := filepath.Base(path)
rt, err := s.RegisterRuntime(Runtime{
WorkspaceID: workspaceID, Name: base, Command: path,
Protocol: ProtocolFor(base), MaxConcurrency: 1,
})
if err != nil {
return nil, err
}
out = append(out, rt)
}
return out, nil
}
// PrepareWorkdir creates workdir, output, and logs, and writes the brief and skills.
func (s *Store) PrepareWorkdir(root string, task Task) (string, error) {
base := filepath.Join(root, task.WorkspaceID, task.IssueID)
for _, sub := range []string{"workdir", "output", "logs"} {
if err := os.MkdirAll(filepath.Join(base, sub), 0o755); err != nil {
return "", err
}
}
brief := task.IssueID
if task.SquadID != "" {
text, err := s.LeaderBrief(task.SquadID)
if err != nil {
return "", err
}
brief = text
}
work := filepath.Join(base, "workdir")
for _, name := range []string{"AGENTS.md", "CLAUDE.md"} {
if err := os.WriteFile(filepath.Join(work, name), []byte(brief), 0o644); err != nil {
return "", err
}
}
if err := s.materializeSkills(work, task.AgentID); err != nil {
return "", err
}
return work, nil
}
func (s *Store) materializeSkills(work, agentID string) error {
rows, err := s.db.Query(`SELECT skill.id, skill.name FROM skill JOIN agent_skill ON agent_skill.skill_id=skill.id WHERE agent_skill.agent_id=? AND agent_skill.enabled=1`, agentID)
if err != nil {
return err
}
type skillRef struct{ id, name string }
var skills []skillRef
for rows.Next() {
var item skillRef
if err := rows.Scan(&item.id, &item.name); err != nil {
rows.Close()
return err
}
skills = append(skills, item)
}
err = rows.Err()
rows.Close()
if err != nil {
return err
}
for _, item := range skills {
dir := filepath.Join(work, ".agents", "skills", item.name)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
files, err := s.db.Query(`SELECT path, body FROM skill_file WHERE skill_id=?`, item.id)
if err != nil {
return err
}
for files.Next() {
var path, body string
if err := files.Scan(&path, &body); err != nil {
files.Close()
return err
}
target := filepath.Join(dir, path)
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
files.Close()
return err
}
if err := os.WriteFile(target, []byte(body), 0o644); err != nil {
files.Close()
return err
}
}
if err := files.Err(); err != nil {
files.Close()
return err
}
files.Close()
}
return nil
}
// ExecutionEnv is the environment for one task. custom_env cannot override
// the task token, PATH, or HOME.
func ExecutionEnv(task Task, custom string) []string {
env := os.Environ()
reserved := map[string]bool{"AGENT_SQUAD_TOKEN": true, "PATH": true, "HOME": true}
for _, line := range strings.Split(custom, "\n") {
if line == "" || !strings.Contains(line, "=") {
continue
}
key := strings.SplitN(line, "=", 2)[0]
if reserved[key] {
continue
}
env = append(env, line)
}
env = append(env, "AGENT_SQUAD_TOKEN="+task.Token, "AGENT_SQUAD_TASK="+task.ID, "AGENT_SQUAD_ISSUE="+task.IssueID)
return env
}
// RunCommand executes an external CLI in the workdir and records the result.
func (s *Store) RunCommand(ctx context.Context, task Task, command, workdir string) error {
if err := s.MarkRunning(task.ID); err != nil {
return err
}
cmd := exec.CommandContext(ctx, command)
cmd.Dir = workdir
agent, _ := s.GetAgent(task.AgentID)
cmd.Env = ExecutionEnv(task, agent.CustomEnv)
out, err := cmd.CombinedOutput()
_ = s.AddTaskMessage(task.ID, "log", string(out))
if err != nil {
return s.FinishTask(task.ID, "failed", "timeout")
}
return s.FinishTask(task.ID, "completed", "")
}
// 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)
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)
},
})
if err != nil {
_ = s.FinishTask(task.ID, "failed", "config")
return "", 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")
}
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.Logs, err
}
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.
func (s *Store) AgentCLI(token string, args []string) error {
task, err := s.TaskByToken(token)
if err != nil {
return err
}
if len(args) == 0 {
return fmt.Errorf("agent cli: missing command")
}
switch args[0] {
case "comment":
if len(args) < 2 {
return fmt.Errorf("comment requires a body")
}
_, err = s.AddComment(Comment{IssueID: task.IssueID, AuthorKind: "agent", AuthorID: task.AgentID, Body: args[1]})
return err
case "status":
if len(args) < 2 {
return fmt.Errorf("status requires a value")
}
goal := args[1] == "in_review"
return s.TouchStatus(task.IssueID, task.AgentID, goal)
case "sub-issue":
if len(args) < 2 {
return fmt.Errorf("sub-issue requires a title")
}
_, err = s.CreateIssue(Issue{WorkspaceID: task.WorkspaceID, Title: args[1], Body: args[1], ParentID: task.IssueID, Status: "todo"})
return err
case "activity":
if len(args) < 2 {
return fmt.Errorf("activity requires an action")
}
detail := ""
if len(args) > 2 {
detail = args[2]
}
return s.RecordActivity(task.SquadID, task.IssueID, task.AgentID, args[1], detail)
default:
return fmt.Errorf("unknown agent command %s", args[0])
}
}
// CheckoutWorktree documents the branch name used for parallel local_directory work.
func WorktreeBranch(agentName, issueID string) string {
safe := strings.Map(func(r rune) rune {
if r == '/' || r == ' ' {
return '-'
}
return r
}, agentName)
return "agent/" + safe + "/" + issueID
}
// Ensure the JSON import stays referenced for providers that pass raw tool args.
var _ = json.RawMessage(nil)
+176
View File
@@ -0,0 +1,176 @@
package platform
import (
"archive/zip"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// Skill is one imported SKILL.md plus attached files.
type Skill struct {
ID, WorkspaceID, Name, Description, Source string
}
// ImportSkill loads a skill from a directory or a zip file.
// conflict is fail, overwrite, rename, or skip.
func (s *Store) ImportSkill(workspaceID, src, conflict string) (Skill, error) {
name, desc, files, err := readSkillSource(src)
if err != nil {
return Skill{}, err
}
return s.saveSkill(workspaceID, name, desc, src, files, conflict)
}
func (s *Store) saveSkill(workspaceID, name, desc, source string, files map[string]string, conflict string) (Skill, error) {
var existing string
_ = s.db.QueryRow(`SELECT id FROM skill WHERE workspace_id=? AND name=?`, workspaceID, name).Scan(&existing)
if existing != "" {
switch conflict {
case "skip":
return s.getSkill(existing)
case "overwrite":
if _, err := s.db.Exec(`DELETE FROM skill_file WHERE skill_id=?`, existing); err != nil {
return Skill{}, err
}
if _, err := s.db.Exec(`UPDATE skill SET description=?, source=? WHERE id=?`, desc, source, existing); err != nil {
return Skill{}, err
}
return s.writeSkillFiles(Skill{ID: existing, WorkspaceID: workspaceID, Name: name, Description: desc, Source: source}, files)
case "rename":
name = name + "-" + newID()[:4]
case "fail", "":
return Skill{}, fmt.Errorf("skill %q already exists", name)
default:
return Skill{}, fmt.Errorf("unknown conflict policy %q", conflict)
}
}
sk := Skill{ID: newID(), WorkspaceID: workspaceID, Name: name, Description: desc, Source: source}
if _, err := s.db.Exec(`INSERT INTO skill(id,workspace_id,name,description,source,created_at) VALUES(?,?,?,?,?,?)`,
sk.ID, sk.WorkspaceID, sk.Name, sk.Description, sk.Source, now()); err != nil {
return Skill{}, err
}
return s.writeSkillFiles(sk, files)
}
func (s *Store) writeSkillFiles(sk Skill, files map[string]string) (Skill, error) {
for path, body := range files {
if _, err := s.db.Exec(`INSERT INTO skill_file(id,skill_id,path,body) VALUES(?,?,?,?)`, newID(), sk.ID, path, body); err != nil {
return Skill{}, err
}
}
return sk, nil
}
func (s *Store) getSkill(id string) (Skill, error) {
var sk Skill
err := s.db.QueryRow(`SELECT id,workspace_id,name,description,source FROM skill WHERE id=?`, id).
Scan(&sk.ID, &sk.WorkspaceID, &sk.Name, &sk.Description, &sk.Source)
return sk, err
}
// RefreshSkill re-reads the original source path.
func (s *Store) RefreshSkill(id string) (Skill, error) {
sk, err := s.getSkill(id)
if err != nil {
return Skill{}, err
}
return s.ImportSkill(sk.WorkspaceID, sk.Source, "overwrite")
}
// BindSkill attaches a skill to an agent. enabled can be toggled later.
func (s *Store) BindSkill(agentID, skillID string, enabled bool) error {
flag := 0
if enabled {
flag = 1
}
_, err := s.db.Exec(`INSERT INTO agent_skill(agent_id,skill_id,enabled) VALUES(?,?,?)
ON CONFLICT(agent_id, skill_id) DO UPDATE SET enabled=excluded.enabled`, agentID, skillID, flag)
if err != nil {
_, err = s.db.Exec(`DELETE FROM agent_skill WHERE agent_id=? AND skill_id=?`, agentID, skillID)
if err != nil {
return err
}
_, err = s.db.Exec(`INSERT INTO agent_skill(agent_id,skill_id,enabled) VALUES(?,?,?)`, agentID, skillID, flag)
}
return err
}
func readSkillSource(src string) (string, string, map[string]string, error) {
info, err := os.Stat(src)
if err != nil {
return "", "", nil, err
}
files := map[string]string{}
if info.IsDir() {
err = filepath.Walk(src, func(path string, fi os.FileInfo, err error) error {
if err != nil || fi.IsDir() {
return err
}
rel, _ := filepath.Rel(src, path)
raw, err := os.ReadFile(path)
if err != nil {
return err
}
files[rel] = string(raw)
return nil
})
} else if strings.HasSuffix(src, ".zip") {
zr, err := zip.OpenReader(src)
if err != nil {
return "", "", nil, err
}
defer zr.Close()
for _, f := range zr.File {
if f.FileInfo().IsDir() {
continue
}
rc, err := f.Open()
if err != nil {
return "", "", nil, err
}
raw, err := io.ReadAll(rc)
_ = rc.Close()
if err != nil {
return "", "", nil, err
}
files[f.Name] = string(raw)
}
} else {
return "", "", nil, fmt.Errorf("skill source must be a directory or zip")
}
if err != nil {
return "", "", nil, err
}
body := ""
for path, content := range files {
if filepath.Base(path) == "SKILL.md" {
body = content
break
}
}
if body == "" {
return "", "", nil, fmt.Errorf("SKILL.md not found in %s", src)
}
name := skillName(src, body)
return name, skillDescription(body), files, nil
}
func skillName(src, body string) string {
if info, err := os.Stat(src); err == nil && info.IsDir() {
return filepath.Base(src)
}
return strings.TrimSuffix(filepath.Base(src), ".zip")
}
func skillDescription(body string) string {
for _, line := range strings.Split(body, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "description:") {
return strings.TrimSpace(strings.TrimPrefix(line, "description:"))
}
}
return ""
}
+213
View File
@@ -0,0 +1,213 @@
package platform
import (
"fmt"
"strings"
)
// Squad is a leader plus agent and human members.
type Squad struct {
ID, WorkspaceID, Name, LeaderAgentID, Instructions string
}
// SquadMember is one roster row. Kind is agent or human.
type SquadMember struct {
Kind, ID, Name, RoleDescription string
}
func (s *Store) CreateSquad(squad Squad, members []SquadMember) (Squad, error) {
if squad.ID == "" {
squad.ID = newID()
}
if _, err := s.db.Exec(`INSERT INTO squad(id,workspace_id,name,leader_agent_id,instructions,created_at) VALUES(?,?,?,?,?,?)`,
squad.ID, squad.WorkspaceID, squad.Name, squad.LeaderAgentID, squad.Instructions, now()); err != nil {
return Squad{}, err
}
for _, m := range members {
if _, err := s.db.Exec(`INSERT INTO squad_member(squad_id,member_kind,member_id,role_description) VALUES(?,?,?,?)`,
squad.ID, m.Kind, m.ID, m.RoleDescription); err != nil {
return Squad{}, err
}
}
return squad, nil
}
// LeaderBrief is the fixed protocol, the roster with mention links, and the instructions.
func (s *Store) LeaderBrief(squadID string) (string, error) {
var name, instructions, leader string
if err := s.db.QueryRow(`SELECT name, instructions, leader_agent_id FROM squad WHERE id=?`, squadID).Scan(&name, &instructions, &leader); err != nil {
return "", err
}
leaderAgent, err := s.GetAgent(leader)
if err != nil {
return "", err
}
var b strings.Builder
b.WriteString("## Squad Operating Protocol\n")
b.WriteString("Delegate with one short comment that mentions the owner. Record squad activity every turn as action, no_action, or failed. Stop after dispatch. Do not implement the task yourself. Do not both mention a member and create a todo sub-issue for the same work.\n\n")
b.WriteString("## Squad Roster\n")
fmt.Fprintf(&b, "- [@%s](mention://agent/%s) leader of %s\n", leaderAgent.Name, leaderAgent.ID, name)
rows, err := s.db.Query(`SELECT member_kind, member_id, role_description FROM squad_member WHERE squad_id=?`, squadID)
if err != nil {
return "", err
}
type rosterRow struct{ kind, id, role string }
var roster []rosterRow
for rows.Next() {
var row rosterRow
if err := rows.Scan(&row.kind, &row.id, &row.role); err != nil {
rows.Close()
return "", err
}
roster = append(roster, row)
}
err = rows.Err()
rows.Close()
if err != nil {
return "", err
}
for _, row := range roster {
memberName := row.id
if row.kind == "agent" {
if a, err := s.GetAgent(row.id); err == nil {
memberName = a.Name
}
} else {
_ = s.db.QueryRow(`SELECT name FROM member WHERE id=?`, row.id).Scan(&memberName)
}
scheme := "member"
if row.kind == "agent" {
scheme = "agent"
}
fmt.Fprintf(&b, "- [@%s](mention://%s/%s) %s\n", memberName, scheme, row.id, row.role)
}
b.WriteString("\n## Squad Instructions\n")
b.WriteString(instructions)
b.WriteString("\n")
return b.String(), nil
}
// RecordActivity appends one squad activity row. action is action, no_action, or failed.
func (s *Store) RecordActivity(squadID, issueID, actorID, action, detail string) error {
switch action {
case "action", "no_action", "failed":
default:
return fmt.Errorf("activity action must be action, no_action, or failed")
}
_, err := s.db.Exec(`INSERT INTO activity(id,squad_id,issue_id,actor_id,action,detail,created_at) VALUES(?,?,?,?,?,?,?)`,
newID(), squadID, issueID, actorID, action, detail, now())
return err
}
// TouchStatus lets the squad leader move an issue. The first turn sets in_progress.
// goalMet sets in_review. Other actors are refused.
func (s *Store) TouchStatus(issueID, actorAgentID string, goalMet bool) error {
issue, err := s.GetIssue(issueID)
if err != nil {
return err
}
if issue.AssigneeKind != "squad" {
return fmt.Errorf("status changes require a squad assignee")
}
var leader string
if err := s.db.QueryRow(`SELECT leader_agent_id FROM squad WHERE id=?`, issue.AssigneeID).Scan(&leader); err != nil {
return err
}
if actorAgentID != leader {
return fmt.Errorf("only the squad leader may change issue status")
}
next := issue.Status
if goalMet {
next = "in_review"
} else if issue.Status == "todo" || issue.Status == "" {
next = "in_progress"
}
_, err = s.db.Exec(`UPDATE issue SET status=? WHERE id=?`, next, issueID)
return err
}
// CloseIssue marks a sub-issue done and wakes the leader when every
// sibling in the same stage is closed. Delegation depth is capped at 4.
func (s *Store) CloseIssue(issueID string) error {
issue, err := s.GetIssue(issueID)
if err != nil {
return err
}
if depth, err := s.issueDepth(issue); err != nil {
return err
} else if depth > 4 {
return fmt.Errorf("delegation depth cap is 4")
}
if _, err := s.db.Exec(`UPDATE issue SET status='done' WHERE id=?`, issueID); err != nil {
return err
}
if issue.ParentID == "" || issue.Stage == "" {
return nil
}
var open int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM issue WHERE parent_id=? AND stage=? AND status NOT IN ('done','cancelled')`, issue.ParentID, issue.Stage).Scan(&open); err != nil {
return err
}
if open > 0 {
return nil
}
parent, err := s.GetIssue(issue.ParentID)
if err != nil {
return err
}
if parent.AssigneeKind != "squad" {
return nil
}
var leader string
if err := s.db.QueryRow(`SELECT leader_agent_id FROM squad WHERE id=?`, parent.AssigneeID).Scan(&leader); err != nil {
return err
}
_, err = s.enqueue(parent, leader, true, parent.AssigneeID)
if err != nil {
return err
}
_, err = s.db.Exec(`INSERT INTO wakeup(id,task_id,kind,fire_at,created_at) VALUES(?,?,?,?,?)`,
newID(), parent.ID, "child_done", "", now())
return err
}
func (s *Store) issueDepth(issue Issue) (int, error) {
depth := 0
cur := issue
for cur.ParentID != "" && depth < 8 {
depth++
next, err := s.GetIssue(cur.ParentID)
if err != nil {
return 0, err
}
cur = next
}
return depth, nil
}
// PlanStages creates one sub-issue per stage title under parent.
func (s *Store) PlanStages(parentID string, stages []string) ([]Issue, error) {
parent, err := s.GetIssue(parentID)
if err != nil {
return nil, err
}
var out []Issue
for i, title := range stages {
child, err := s.CreateIssue(Issue{
WorkspaceID: parent.WorkspaceID, Title: title, Body: title,
Status: "todo", ParentID: parent.ID, Stage: fmt.Sprintf("%d", i+1),
AssigneeKind: parent.AssigneeKind, AssigneeID: parent.AssigneeID,
})
if err != nil {
return nil, err
}
out = append(out, child)
}
return out, nil
}
// DelegateComment posts a mention comment. It does not write a JSON file.
func (s *Store) DelegateComment(issueID, authorAgentID, mentionedAgentID, body string) (Comment, error) {
text := body + " [@" + mentionedAgentID + "](mention://agent/" + mentionedAgentID + ")"
return s.AddComment(Comment{IssueID: issueID, AuthorKind: "agent", AuthorID: authorAgentID, Body: text})
}
+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
}
+50 -16
View File
@@ -7,6 +7,7 @@ import (
"encoding/json"
"flag"
"fmt"
"net"
"net/http"
"os"
"path/filepath"
@@ -14,11 +15,12 @@ import (
"strings"
"time"
"agent-intent/intent"
"agent-native/runtime/llm"
"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"
@@ -44,6 +46,8 @@ func Main(args []string) int {
return cmdRuns(args[1:])
case "demo":
return cmdDemo(args[1:])
case "agent", "skill", "squad", "issue", "autopilot", "runtime", "daemon", "chat", "project":
return cmdPlatform(args)
case "help", "-h", "--help":
usage()
return 0
@@ -58,12 +62,13 @@ 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]
agent-squad runs list|inspect|pending|respond|blackboard ...
agent-squad demo [--keep]
agent-squad agent|skill|squad|issue|autopilot|runtime|daemon|chat|project ...
环境变量:
AGENT_SQUAD_DATA 数据目录(默认 ./agent-squad-data;role/blueprint 子命令也读它)
@@ -81,6 +86,7 @@ type app struct {
files *store.FileEventSink
st *store.FileStore
log *logsys.Logger
plat *platform.Store
}
func dataDir() string {
@@ -117,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 实时/重放)。
@@ -132,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)
}
@@ -161,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("")
@@ -180,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() {
@@ -202,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 规划器/后端)")
@@ -216,8 +225,17 @@ 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 a.plat != nil {
srv.SetPlatform(a.plat)
defer a.plat.Close()
}
stopSweep := srv.StartWakeupSweeper(15 * time.Second)
defer stopSweep()
fmt.Printf("agent-squad 服务启动 http://localhost%s (data=%s, roles=%d, blueprints=%d, wakeup-sweep=15s)\n",
@@ -225,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")
}
})
}
}
+135
View File
@@ -0,0 +1,135 @@
package cli
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"time"
platform "agent-squad/adapters/sqlite"
"agent-squad/squad"
)
func cmdPlatform(args []string) int {
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "用法: agent-squad", args[0], "create|import|assign|claim|fire ...")
return 2
}
fs := flag.NewFlagSet(args[0], flag.ContinueOnError)
data := fs.String("data", dataDir(), "数据目录")
workspace := fs.String("workspace", "", "workspace id")
name := fs.String("name", "", "名称")
if err := fs.Parse(args[2:]); err != nil {
return 2
}
st, err := platform.Open(filepath.Join(*data, "orchestration.db"))
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
defer st.Close()
switch args[0] + " " + args[1] {
case "agent create":
a, err := st.CreateAgent(platform.Agent{WorkspaceID: *workspace, Name: *name})
return printPlatform(a, err)
case "skill import":
if fs.NArg() < 1 {
fmt.Fprintln(os.Stderr, "skill import 需要目录或 zip 路径")
return 2
}
sk, err := st.ImportSkill(*workspace, fs.Arg(0), "rename")
return printPlatform(sk, err)
case "squad create":
if fs.NArg() < 1 {
fmt.Fprintln(os.Stderr, "squad create 需要 leader agent id")
return 2
}
sq, err := st.CreateSquad(platform.Squad{WorkspaceID: *workspace, Name: *name, LeaderAgentID: fs.Arg(0)}, nil)
return printPlatform(sq, err)
case "issue create":
issue, err := st.CreateIssue(platform.Issue{WorkspaceID: *workspace, Title: *name, Status: "todo"})
return printPlatform(issue, err)
case "issue assign":
if fs.NArg() < 3 {
fmt.Fprintln(os.Stderr, "issue assign <issue> <agent|squad> <id>")
return 2
}
err := st.AssignIssue(fs.Arg(0), fs.Arg(1), fs.Arg(2), true)
return printPlatform(map[string]bool{"ok": err == nil}, err)
case "autopilot create":
if fs.NArg() < 2 {
fmt.Fprintln(os.Stderr, "autopilot create <agent-id> <create_issue|run_only>")
return 2
}
a, err := st.CreateAutopilot(platform.Autopilot{
WorkspaceID: *workspace, Title: *name, Runbook: *name,
AssigneeKind: "agent", AssigneeID: fs.Arg(0), ExecutionMode: fs.Arg(1),
})
return printPlatform(a, err)
case "runtime probe":
rts, err := st.RegisterProbed(*workspace)
return printPlatform(rts, err)
case "daemon claim":
if fs.NArg() < 1 {
fmt.Fprintln(os.Stderr, "daemon claim <runtime-id>")
return 2
}
task, ok, err := st.Claim(fs.Arg(0), time.Now())
return printPlatform(map[string]any{"ok": ok, "task": task}, err)
case "chat create":
c, err := st.CreateChat(platform.ChatSession{WorkspaceID: *workspace, OwnerID: *name})
return printPlatform(c, err)
case "project create":
p, err := st.CreateProject(platform.Project{WorkspaceID: *workspace, Name: *name})
return printPlatform(p, err)
case "daemon run-native":
if fs.NArg() < 2 {
fmt.Fprintln(os.Stderr, "daemon run-native <task-id> <workdir>")
return 2
}
task, err := st.TaskByToken(fs.Arg(0))
if err != nil {
var idErr error
task, idErr = taskByID(st, fs.Arg(0))
if idErr != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
}
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])
return 2
}
}
func taskByID(st *platform.Store, id string) (platform.Task, error) {
return st.TaskByToken(id)
}
// 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 {
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(v)
return 0
}
@@ -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,14 +4,18 @@ package server
import (
"context"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"time"
platform "agent-squad/adapters/sqlite"
"agent-squad/squad"
"agent-squad/store"
@@ -32,8 +36,13 @@ type Server struct {
mu sync.Mutex
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 {
@@ -42,6 +51,14 @@ func (s *Server) SetLogger(log *logsys.Logger) {
s.log = log
}
// SetPlatform attaches the orchestration database. Nil leaves the goal engine only.
func (s *Server) SetPlatform(st *platform.Store) {
if s == nil {
return
}
s.plat = st
}
// New 组装服务。
func New(engine *squad.SquadEngine, sink *squad.FanoutSink, st *store.FileStore) *Server {
return &Server{engine: engine, sink: sink, st: st, latest: map[string]*squad.RunResult{}}
@@ -74,7 +91,57 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /v1/wakeups/sweep", s.handleSweep)
mux.HandleFunc("GET /v1/runs/{id}/blackboard", s.handleBlackboard)
mux.HandleFunc("GET /v1/runs/{id}/events", s.handleEvents)
return httpx.Trace(mux)
if s.plat != nil {
platform.Mount(mux, s.plat)
}
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) {
@@ -119,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 == "" {
@@ -135,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
@@ -145,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)
@@ -170,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 {
@@ -183,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
@@ -208,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 {
@@ -263,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
@@ -272,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
@@ -282,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
@@ -297,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
@@ -308,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
@@ -320,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
}
@@ -336,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
}
@@ -368,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())
}
}
}()
@@ -376,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"))
@@ -402,6 +501,9 @@ func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
if id := r.PathValue("id"); id != "" && ev.RunID != id {
continue
}
data, _ := json.Marshal(ev)
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev.Type, data)
flusher.Flush()
@@ -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 -12
View File
@@ -2,17 +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
)
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
View File
+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 -90
View File
@@ -3,21 +3,13 @@ package squad
import (
"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 注册
@@ -27,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
}
@@ -50,83 +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
}
// 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,
}
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
}
// 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
}
return native.New(opts), nil
})
}
// ListBackends 返回可用后端名(排序稳定)。
func (r *BackendResolver) ListBackends() []string {
r.mu.RLock()
@@ -154,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
@@ -182,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 {
@@ -197,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
}
+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
}
+407 -71
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
@@ -43,6 +47,9 @@ type Options struct {
// IntentRunner 系统功能步执行器(squadintent 提供)。目录启用而 Runner 缺席时,
// intent 步骤 fail-closed。
IntentRunner IntentRunner
// Router 可选分诊。direct = 单步 assistant,不进多角色;plan = leader 委派。
// nil = 保持调用方传入的 RunOptions.Mode。
Router Router
}
// engineDeps 驱动器依赖(由引擎从 Options 派生)。
@@ -56,6 +63,26 @@ 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 自然走同一实现)。
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 单次运行的可选项。
@@ -71,6 +98,8 @@ 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"`
}
// SquadEngine 小队引擎门面:Run / Respond / Resume / Inspect / ListRuns。
@@ -127,7 +156,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
}
@@ -149,15 +178,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),
@@ -175,6 +231,9 @@ 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
}
e.deps.Sink.Emit(Event{Type: EvRunStarted, RunID: runID, Text: goal.Text, At: time.Now()})
// 意图闸门(可选,入口一次裁决;显式 TaskKey 优先,不重复过闸)。
@@ -208,22 +267,40 @@ 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
bpMatch, _ := e.opts.Blueprints.Match(meta.Goal.Text, runOpts.TaskKey)
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,
})
if err != nil {
return e.failNew(meta, err)
if e.opts.Router != nil && runOpts.Mode == "" && meta.Metadata["route"] == "" {
dec, rerr := e.opts.Router.Route(ctx, meta.Goal)
if rerr != nil {
return e.failNew(meta, rerr)
}
applyRoute(meta, dec, runOpts.Mode)
}
var planOut *PlanOutput
var err error
if meta.Metadata["route_direct"] == "1" {
plan, perr := directAssistantPlan(e.opts.Registry, meta.Goal)
if perr != nil {
return e.failNew(meta, perr)
}
planOut = &PlanOutput{Plan: plan}
} else {
bpMatch, _ := e.opts.Blueprints.Match(meta.Goal.Text, runOpts.TaskKey)
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,
})
if err != nil {
return e.failNew(meta, err)
}
}
// 目录驱动编排的确定性校验:intent 步骤命中目录、必填槽位齐、枚举合法(fail-closed)。
if err := validateIntentSteps(planOut.Plan, e.opts.IntentCatalog); err != nil {
@@ -250,7 +327,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
@@ -288,8 +365,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,7 +401,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 {
@@ -352,12 +430,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 {
@@ -378,16 +456,16 @@ func (e *SquadEngine) resumeAfterHold(ctx context.Context, st *runState) (*RunRe
}
// 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 {
@@ -429,12 +507,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)
}
@@ -454,18 +537,74 @@ func (e *SquadEngine) Respond(ctx context.Context, runID string, decision Respon
}
req := &gate.Request
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 {
@@ -583,8 +722,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)
@@ -601,8 +749,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:
@@ -611,7 +759,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
@@ -646,7 +794,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
@@ -666,7 +814,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,
@@ -695,7 +843,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
@@ -709,18 +857,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)
}
@@ -731,13 +1004,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)
}
@@ -762,6 +1046,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)
}
@@ -781,7 +1068,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
}
@@ -793,6 +1080,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
@@ -806,11 +1096,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)
}
@@ -828,11 +1120,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)
}
@@ -862,35 +1156,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 {
@@ -977,7 +1313,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 验证:质检失败 → 自动重跑交付步骤 → 通过。
+214
View File
@@ -0,0 +1,214 @@
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, " → "))
}
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))
}
}
+28 -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 后过期)。
@@ -112,8 +114,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 {
+48 -25
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})})
},
@@ -73,6 +71,8 @@ type delegation struct {
Member string `json:"member,omitempty"`
Title string `json:"title,omitempty"`
Targets []string `json:"targets,omitempty"`
Skills []string `json:"skills,omitempty"`
MinScore int `json:"min_score,omitempty"`
Briefing string `json:"briefing,omitempty"` // 给成员的补充指令
Question string `json:"question,omitempty"` // escalate 时的问题
Reason string `json:"reason,omitempty"`
@@ -80,22 +80,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 的系统指令(协调契约 + 通用守则)。
@@ -104,7 +106,7 @@ func leaderPrompt(members []RoleSpec, catalogMd string) string {
b.WriteString(`你是本小队的 LEADER。你被激活的唯一职责是协调,不是自己实现。
每轮你必须做且只做一件事:评估当前进展,然后输出一份委派决策文件 delegation.json 到本子目录(用工具写入,不要输出到对话里):
{"action":"delegate","member":"成员role","title":"给成员的子任务标题","targets":["output.md"],"briefing":"给成员的补充指令/验收标准","reason":"为什么派给这个人"}
{"action":"delegate","member":"成员role","title":"给成员的子任务标题","targets":["output.md"],"skills":["可选,覆盖该成员本步技能"],"min_score":0,"briefing":"给成员的补充指令/验收标准","reason":"为什么派给这个人"}
{"action":"escalate","question":"要向人提出的问题","reason":"为什么需要人拍板"}
{"action":"close","reason":"目标已达成,收口"}
{"action":"failed","reason":"无法达成的原因"}
@@ -132,7 +134,7 @@ func leaderPrompt(members []RoleSpec, catalogMd string) string {
}
// leaderTaskBrief 组装 leader 本轮的 TASK.md(目标 + 黑板 + 委派历史)。
func leaderTaskBrief(st *runState) string {
func leaderTaskBrief(st *RunState) string {
var b strings.Builder
b.WriteString("# Leader 轮次:评估并委派\n\n")
fmt.Fprintf(&b, "## 运行目标\n%s\n\n", st.Meta.Goal.Text)
@@ -175,7 +177,7 @@ func leaderTaskBrief(st *runState) 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)
@@ -204,7 +206,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 {
@@ -220,7 +223,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()
@@ -292,7 +295,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()
@@ -316,6 +319,12 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu
} else {
step.Targets = []string{"output.md"}
}
if len(dec.Skills) > 0 {
step.Skills = dec.Skills
}
if dec.MinScore > 0 {
step.MinScore = dec.MinScore
}
st.Plan.Workflow.Steps = append(st.Plan.Workflow.Steps, step)
_ = st.save()
@@ -349,7 +358,9 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu
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
@@ -360,11 +371,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
@@ -378,7 +399,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 {
@@ -387,9 +408,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。
+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):
}
}
}
+14 -2
View File
@@ -88,10 +88,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 直接执行
@@ -103,6 +111,10 @@ type StepSpec struct {
AllowDynamicAdjustment bool `json:"allow_dynamic_adjustment,omitempty"`
// Critical: nil(默认)= 关键步骤失败即终止;false = 非关键,失败降级继续。
Critical *bool `json:"critical,omitempty"`
// Skills 覆盖角色默认技能。空 = 用角色自己的技能列表。
Skills []string `json:"skills,omitempty"`
// MinScore 质检总分下限。0 = 不额外设门槛,只看 Checker.Passed。
MinScore int `json:"min_score,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{
+76
View File
@@ -0,0 +1,76 @@
package squad
import (
"context"
"fmt"
)
// RouteMode 是分诊结果。direct 不进入多角色流程;plan 进入 leader 委派。
type RouteMode string
const (
RouteDirect RouteMode = "direct"
RoutePlan RouteMode = "plan"
)
// RouteDecision 描述这一次目标怎么跑,以及它属于哪一族交付物。
type RouteDecision struct {
Mode RouteMode
Family string
Reason string
}
// Router 在规划之前做一次分诊。nil 决策表示不干预。
type Router interface {
Route(ctx context.Context, goal Goal) (*RouteDecision, error)
}
// applyRoute 把分诊写进运行元数据。显式 RunOptions.Mode 优先,分诊不覆盖。
func applyRoute(meta *RunMetadata, dec *RouteDecision, explicitMode string) {
if dec == nil || explicitMode != "" {
return
}
if meta.Metadata == nil {
meta.Metadata = map[string]string{}
}
meta.Metadata["route"] = string(dec.Mode)
if dec.Family != "" {
meta.Metadata["deliverable_family"] = dec.Family
}
if dec.Reason != "" {
meta.Metadata["route_reason"] = dec.Reason
}
switch dec.Mode {
case RouteDirect:
meta.Metadata["route_direct"] = "1"
case RoutePlan:
meta.Metadata["mode"] = "leader"
if meta.Metadata["leader_role"] == "" {
meta.Metadata["leader_role"] = "leader"
}
}
}
// directAssistantPlan 生成只有 assistant 一步的计划。
func directAssistantPlan(reg *RoleRegistry, goal Goal) (*SquadPlan, error) {
spec, ok := reg.Get("assistant")
if !ok {
return nil, fmt.Errorf("direct route requires a registered assistant role")
}
return &SquadPlan{
Squad: SquadConfig{Agents: []RoleSpec{spec}},
Workflow: WorkflowPlan{
WorkflowID: NewPlanID(),
Title: goal.Text,
Status: "pending",
Steps: []StepSpec{{
ID: "assistant",
Role: "assistant",
Title: goal.Text,
Targets: []string{"output.md"},
SubWorkspace: "tasks/01_assistant",
Status: "pending",
}},
},
}, nil
}
+128
View File
@@ -0,0 +1,128 @@
package squad
import (
"context"
"os"
"path/filepath"
"testing"
)
type fixedRouter struct{ dec *RouteDecision }
func (r fixedRouter) Route(ctx context.Context, goal Goal) (*RouteDecision, error) {
return r.dec, nil
}
func TestRouterDirectRunsAssistantOnly(t *testing.T) {
dir := t.TempDir()
reg := NewRoleRegistry()
if err := reg.Register(RoleSpec{Role: "assistant", Name: "助手", Backend: "script"}); err != nil {
t.Fatal(err)
}
backends := NewBackendResolver()
backends.RegisterInstance("script", &scriptBackend{script: map[string]map[string]string{
"01_assistant": {"output.md": goodReport},
}})
engine, err := NewEngine(Options{
DataDir: dir, Registry: reg, Planner: NewMockPlanner(), Backends: backends,
HitlPolicy: HitlPolicy{Mode: HitlDisabled},
Router: fixedRouter{dec: &RouteDecision{Mode: RouteDirect, Family: "minutes", Reason: "短任务"}},
})
if err != nil {
t.Fatal(err)
}
res, err := engine.Run(context.Background(), Goal{Text: "写一句纪要"}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status %s err=%s", res.Status, res.Error)
}
if res.Plan == nil || len(res.Plan.Steps) != 1 || res.Plan.Steps[0].Role != "assistant" {
t.Fatalf("plan = %+v", res.Plan)
}
}
func TestRouterPlanEntersLeaderMode(t *testing.T) {
dir := t.TempDir()
reg := NewRoleRegistry()
_ = reg.Register(RoleSpec{Role: "writer", Name: "撰写", Backend: "script"})
backends := NewBackendResolver()
backends.RegisterInstance("script", &scriptBackend{script: map[string]map[string]string{}})
engine, err := NewEngine(Options{
DataDir: dir, Registry: reg,
Planner: mockScriptPlanner(StepSpec{ID: "w", Role: "writer", Title: "写", Targets: []string{"output.md"}}),
Backends: backends,
HitlPolicy: HitlPolicy{Mode: HitlDisabled},
Router: fixedRouter{dec: &RouteDecision{Mode: RoutePlan, Family: "report"}},
})
if err != nil {
t.Fatal(err)
}
res, err := engine.Run(context.Background(), Goal{Text: "写完整报告"}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != RunFailed || res.Error == "" {
t.Fatalf("missing leader should fail closed, got %s %s", res.Status, res.Error)
}
}
func TestDelegationCarriesSkillsAndScore(t *testing.T) {
dir := t.TempDir()
body := []byte(`{"action":"delegate","member":"writer","title":"成文","skills":["docx-author"],"min_score":70}`)
if err := os.WriteFile(filepath.Join(dir, "delegation.json"), body, 0o644); err != nil {
t.Fatal(err)
}
dec, err := parseDelegation(dir)
if err != nil {
t.Fatal(err)
}
if len(dec.Skills) != 1 || dec.Skills[0] != "docx-author" || dec.MinScore != 70 {
t.Fatalf("delegation = %+v", dec)
}
}
type captureSkillsBackend struct{ got []string }
func (b *captureSkillsBackend) Name() string { return "capture-skills" }
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 &ExecResult{Success: true, Patches: map[string][]byte{"output.md": []byte(goodReport)}}, nil
}
func TestStepSkillsOverrideRoleSkills(t *testing.T) {
dir := t.TempDir()
reg := NewRoleRegistry()
_ = reg.Register(RoleSpec{Role: "writer", Name: "撰写", Backend: "capture-skills", Skills: []string{"role-skill"}})
got := &captureSkillsBackend{}
backends := NewBackendResolver()
backends.RegisterInstance("capture-skills", got)
engine, err := NewEngine(Options{
DataDir: dir, Registry: reg, Backends: backends,
HitlPolicy: HitlPolicy{Mode: HitlDisabled},
Planner: &MockPlanner{Script: []StepSpec{{
ID: "w", Role: "writer", Title: "写", Targets: []string{"output.md"},
SubWorkspace: "tasks/01_writer", Skills: []string{"docx-author"}, MinScore: 10,
}}},
})
if err != nil {
t.Fatal(err)
}
res, err := engine.Run(context.Background(), Goal{Text: "写"}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status %s err=%s", res.Status, res.Error)
}
if len(got.got) != 1 || got.got[0] != "docx-author" {
t.Fatalf("request skills = %v", got.got)
}
}
+74 -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,6 +21,8 @@ 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 运行中人工补充指令(在下一步骤边界注入后清空)。
@@ -29,11 +31,22 @@ type runState struct {
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 +62,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 +87,29 @@ 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,
})
}
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"`
}
// 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 +121,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 +143,20 @@ 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
} 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 +165,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 +173,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 +189,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 +197,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 +215,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 +240,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 +260,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)
}
+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)
}
}
+228 -125
View File
@@ -1,6 +1,7 @@
package squad
import (
"bytes"
"context"
"fmt"
"os"
@@ -9,137 +10,207 @@ 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
}
// 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)
superNo := 0
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))
}
}()
for {
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 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
// 并行段:组内每一步都先过审批和权限,任一需要挂起则整组不执行。
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
}
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)
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, g, err
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 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()
if st.Plan.Workflow.Steps[idx].IsCritical() && failedCritical < 0 {
failedCritical = idx
}
@@ -147,6 +218,9 @@ func (d *flowDriver) runParallel(ctx context.Context, st *runState, idxs []int)
}(idx)
}
wg.Wait()
if gate != nil {
return heldResult(st, gate), gate, nil
}
if failedCritical >= 0 {
return d.failStep(ctx, st, failedCritical)
}
@@ -154,7 +228,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 {
@@ -163,6 +240,8 @@ 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)
@@ -179,15 +258,29 @@ 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)
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
@@ -207,13 +300,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 := "交付物缺失"
@@ -224,22 +317,22 @@ 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})})
},
// RoleSpec v2 贯通:工具白名单 / 技能 / 标识(原为死字段或缺失)。
ToolAllowlist: role.Tools,
Skills: role.Skills,
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 != "" {
@@ -255,7 +348,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)
@@ -265,19 +358,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()
@@ -334,7 +427,7 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*Ru
}
// 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()
@@ -358,7 +451,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
@@ -400,7 +493,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()
@@ -410,7 +503,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)), "")
@@ -450,7 +543,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()
@@ -463,7 +556,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 {
@@ -490,7 +583,7 @@ 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 {
@@ -532,6 +625,10 @@ func (d *flowDriver) finalize(ctx context.Context, st *runState) (*RunResult, *h
RunID: st.Meta.ID, Workspace: st.Meta.WorkDir, Deliverable: deliverable,
Plan: planPtr(st), Squad: &st.Plan.Squad, Template: st.Template,
})
if srcStep.MinScore > 0 && report.Score < float64(srcStep.MinScore) {
report.Passed = false
report.Violations = append(report.Violations, fmt.Sprintf("总分 %.0f 低于步骤门槛 %d", report.Score, srcStep.MinScore))
}
if !report.Passed && st.RepairBudgetRemaining > 0 {
st.RepairBudgetRemaining--
_ = st.save()
@@ -568,7 +665,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()
@@ -602,11 +699,17 @@ 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"
st.Plan.Workflow.Steps[i].Error = ""
note, err := os.ReadFile(filepath.Join(st.Meta.WorkDir, "quality-feedback.md"))
if err == nil && len(bytes.TrimSpace(note)) > 0 {
taskPath := filepath.Join(st.Meta.WorkDir, filepath.FromSlash(st.Plan.Workflow.Steps[i].SubWorkspace), "TASK.md")
existing, _ := os.ReadFile(taskPath)
_ = os.WriteFile(taskPath, append(existing, append([]byte("\n\n"), note...)...), 0o644)
}
}
}
d.emit(st, Event{Type: EvPhase, Text: "质检未通过,自动修复:重跑交付步骤 " + stepID})
@@ -614,7 +717,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:
@@ -644,11 +747,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
@@ -660,7 +763,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
@@ -677,7 +780,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)
@@ -685,11 +788,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
}
@@ -700,11 +803,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),
@@ -715,7 +818,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 {
@@ -725,7 +828,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() {
@@ -784,7 +887,7 @@ func appendLog(dir, line string) {
_, _ = f.WriteString(line)
}
func planPtr(st *runState) *WorkflowPlan {
func planPtr(st *RunState) *WorkflowPlan {
wf := st.Workflow()
return &wf
}
+40 -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
@@ -309,5 +300,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
}
+19
View File
@@ -1,6 +1,7 @@
package store
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
@@ -58,6 +59,7 @@ func (s *FileEventSink) Emit(ev squad.Event) {
}
f = fp
s.files[ev.RunID] = f
s.seqs[ev.RunID] = lastEventSeq(filepath.Join(dir, "events.jsonl"))
}
s.seqs[ev.RunID]++
line := persistedEvent{Seq: s.seqs[ev.RunID], Type: ev.Type, StepID: ev.StepID,
@@ -72,6 +74,23 @@ func (s *FileEventSink) Emit(ev squad.Event) {
_, _ = f.Write(append(data, '\n'))
}
func lastEventSeq(path string) int64 {
f, err := os.Open(path)
if err != nil {
return 0
}
defer f.Close()
var last int64
sc := bufio.NewScanner(f)
for sc.Scan() {
var line persistedEvent
if err := json.Unmarshal(sc.Bytes(), &line); err == nil && line.Seq > last {
last = line.Seq
}
}
return last
}
// Close 关闭全部文件句柄。
func (s *FileEventSink) Close() {
s.mu.Lock()
+27
View File
@@ -0,0 +1,27 @@
package store
import (
"os"
"path/filepath"
"testing"
"agent-squad/squad"
)
func TestR2EventSeqContinuesAfterRestart(t *testing.T) {
dir := t.TempDir()
run := "run_1"
path := filepath.Join(dir, "runs", run)
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(path, "events.jsonl"), []byte("{\"seq\":7,\"type\":\"phase\"}\n"), 0o644); err != nil {
t.Fatal(err)
}
sink := NewFileEventSink(dir, nil)
sink.Emit(squad.Event{Type: "phase", RunID: run, Text: "next"})
sink.Close()
if got := lastEventSeq(filepath.Join(path, "events.jsonl")); got != 8 {
t.Fatalf("seq %d", got)
}
}
+6 -6
View File
@@ -56,12 +56,12 @@ func (s *FileStore) LoadRoles(reg *squad.RoleRegistry) error {
}
// SaveRole 落盘单个角色。
func (s *FileStore) SaveRole(spec squad.RoleSpec) {
func (s *FileStore) SaveRole(spec squad.RoleSpec) error {
data, err := json.MarshalIndent(spec, "", " ")
if err != nil {
return
return err
}
_ = os.WriteFile(filepath.Join(s.DataDir, "roles.d", spec.Role+".json"), data, 0o644)
return squad.AtomicWrite(filepath.Join(s.DataDir, "roles.d", spec.Role+".json"), append(data, '\n'))
}
// DeleteRole 删除角色文件(注册表删除由调用方完成)。
@@ -96,12 +96,12 @@ func (s *FileStore) LoadBlueprints(bps *squad.BlueprintRegistry) error {
}
// SaveBlueprint 落盘单个蓝图。
func (s *FileStore) SaveBlueprint(bp squad.Blueprint) {
func (s *FileStore) SaveBlueprint(bp squad.Blueprint) error {
data, err := json.MarshalIndent(bp, "", " ")
if err != nil {
return
return err
}
_ = os.WriteFile(filepath.Join(s.DataDir, "blueprints.d", bp.Key+".json"), data, 0o644)
return squad.AtomicWrite(filepath.Join(s.DataDir, "blueprints.d", bp.Key+".json"), append(data, '\n'))
}
// DeleteBlueprint 删除蓝图文件。