Compare commits

..
Author SHA1 Message Date
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
78 changed files with 4844 additions and 472 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,19 @@ type Options struct {
Roots []string
}
// NewFromOverlay 从 JSON overlay 构造闸门,并把 guard 域标成受保护域。
// 命令行用它,这样命令本身不必 import agent-intent。
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"},
},
FS: fsys,
Roots: roots,
})
}
// New 构造闸门。没有任何意图来源时返回 (nil, nil)——引擎拿到 nil IntentGate,
// 行为与原实现一致(T0 行为解耦)。
func New(ctx context.Context, o Options) (*Gate, error) {
@@ -55,32 +68,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
+203
View File
@@ -0,0 +1,203 @@
// 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,
})
}
}
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)
}
+30
View File
@@ -0,0 +1,30 @@
module agent-squad/adapters/sqlite
go 1.27
require (
agent-native v0.0.0
modernc.org/sqlite v1.37.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
golang.org/x/sys v0.31.0 // indirect
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"
+260
View File
@@ -0,0 +1,260 @@
package platform
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"strings"
"testing"
"time"
"agent-native/runtime/llm"
)
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, func(ctx context.Context, model string, messages []llm.Message, td []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
ch := make(chan llm.Event, 1)
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd}}
close(ch)
return ch
})
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)
}
+285
View File
@@ -0,0 +1,285 @@
package platform
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"agent-native/runtime"
"agent-native/runtime/llm"
)
// 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 with AgentSession. stream is required; the
// product supplies a provider, tests supply a script.
func (s *Store) RunInProcess(ctx context.Context, task Task, workdir string, stream llm.StreamFunc) (string, error) {
if err := s.MarkRunning(task.ID); err != nil {
return "", err
}
issue, _ := s.GetIssue(task.IssueID)
agent := runtime.NewAgentSession(runtime.Config{
Model: "agent-native",
Stream: stream,
MaxTurns: 4,
OnEvent: func(ev runtime.Event) {
_ = s.AddTaskMessage(task.ID, ev.Type, ev.Text)
},
})
res, err := agent.Prompt(ctx, llm.Message{Role: llm.RoleUser, Content: issue.Title + "\n" + issue.Body})
if err != nil {
_ = s.FinishTask(task.ID, "failed", "config")
return "", err
}
if res.Err != nil {
_ = s.FinishTask(task.ID, "failed", "model")
return res.Text, res.Err
}
_ = s.AddUsage(task.ID, "agent-native", res.Usage.PromptTokens, res.Usage.CompletionTokens, res.Usage.CacheReadTokens, res.Usage.CacheWriteTokens)
if err := s.FinishTask(task.ID, "completed", ""); err != nil {
return res.Text, err
}
_ = os.WriteFile(filepath.Join(workdir, "output.md"), []byte(res.Text), 0o644)
return res.Text, 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})
}
+43 -15
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 子命令也读它)
@@ -117,12 +122,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 +137,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)
}
@@ -202,7 +200,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 +216,20 @@ func cmdServe(args []string) int {
return 1
}
defer a.close()
if err := listenGuard(*addr, *token, *insecure); err != nil {
fmt.Fprintln(os.Stderr, err)
return 2
}
srv := server.New(a.engine, a.sink, a.st)
srv.SetToken(*token)
srv.SetLogger(a.log)
if plat, err := platform.Open(filepath.Join(*data, "orchestration.db")); err != nil {
fmt.Fprintln(os.Stderr, "编排库打开失败:", err)
return 1
} else {
srv.SetPlatform(plat)
defer plat.Close()
}
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 +237,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")
}
})
}
}
+130
View File
@@ -0,0 +1,130 @@
package cli
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"time"
"agent-native/runtime/llm"
platform "agent-squad/adapters/sqlite"
)
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), scriptedOK)
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)
}
func scriptedOK(ctx context.Context, model string, messages []llm.Message, td []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
ch := make(chan llm.Event, 1)
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd}}
close(ch)
return ch
}
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
}
@@ -376,6 +470,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 +499,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())
}
}
}
+11 -127
View File
@@ -1,24 +1,15 @@
package squad
import (
"context"
"fmt"
"sync"
"time"
"agent-native/adapters/native"
"agent-native/exec"
"agent-native/runtime/llm"
"agent-native/runtime/mcp"
"agent-native/runtime/subagent"
"agent-native/runtime/tools"
"error-code-system/errcode"
)
// BackendFactory 按角色构造后端:角色级 Model/ToolProfile 在工厂闭包层绑定
// (agent-native exec.Request 无 Model 字段,绑定不侵入执行契约)。
type BackendFactory func(role RoleSpec) (exec.Backend, error)
// (agent-native ExecRequest 无 Model 字段,绑定不侵入执行契约)。
type BackendFactory func(role RoleSpec) (Executor, error)
// BackendResolver 角色 → 智能体后端解析器。
// 内置 "stub"(agent-native 离线桩);真实智能体经 RegisterAgentNative 注册
@@ -28,18 +19,18 @@ type BackendFactory func(role RoleSpec) (exec.Backend, error)
type BackendResolver struct {
mu sync.RWMutex
factories map[string]BackendFactory
underlying map[string]exec.Backend // 无工厂直挂的后端实例
wrapped map[string]exec.Backend // 角色(含模型绑定)→ 已包装实例缓存
underlying map[string]Executor // 无工厂直挂的后端实例
wrapped map[string]Executor // 角色(含模型绑定)→ 已包装实例缓存
}
// NewBackendResolver 创建解析器并注册内置后端。
func NewBackendResolver() *BackendResolver {
r := &BackendResolver{
factories: make(map[string]BackendFactory),
underlying: make(map[string]exec.Backend),
wrapped: make(map[string]exec.Backend),
underlying: make(map[string]Executor),
wrapped: make(map[string]Executor),
}
r.RegisterInstance("stub", &exec.StubBackend{})
r.RegisterInstance("stub", &StubExecutor{})
return r
}
@@ -51,119 +42,12 @@ func (r *BackendResolver) Register(name string, f BackendFactory) {
}
// RegisterInstance 直接注册后端实例(忽略角色级绑定)。
func (r *BackendResolver) RegisterInstance(name string, b exec.Backend) {
func (r *BackendResolver) RegisterInstance(name string, b Executor) {
r.mu.Lock()
defer r.mu.Unlock()
r.underlying[name] = b
}
// AgentNativeConfig 声明 agent-native 后端的全量能力装配(组合根提供)。
// 零值 = 仅基础模型执行:搜索/取回/执行/MCP/技能/委派工具一律不注册(fail-closed)。
type AgentNativeConfig struct {
Client *llm.Client
DefaultModel string
MaxTurns int
Timeout time.Duration
// 以下均为 agent-native Options 的直通注入,详见其注释。
Search tools.SearchProvider
Exec *tools.ExecConfig
Fetch *tools.FetchConfig
MCP *mcp.Manager
SkillsRoots []string
Subagent subagent.Factory
Compact *native.CompactConfig
// HostTools 注册进内核工作区。与内置工具同名时该步骤失败,不覆盖。
HostTools []tools.Tool
// OnEvent 收到内核的逐步过程事件(文本、工具、用量)。与 Request.OnEvent 同时生效。
OnEvent func(ev exec.ProcessEvent)
// Stream 测试注入。为空时按 Client 访问模型。
Stream llm.StreamFunc
// MaxToolCalls 单步工具调用上限。0 表示不限制。
MaxToolCalls int
}
// buildAgentNativeOptions 按角色把配置换算为 native.Options。
// BashPermission 三态在此执行:deny = 不装配执行工具(无论组合根是否提供 Exec);
// ask/allow = 装配(ask 的人工门由引擎 permission 挂起点先行把关)。
func buildAgentNativeOptions(cfg AgentNativeConfig, role RoleSpec) (native.Options, error) {
model := role.Model
if model == "" {
model = cfg.DefaultModel
}
if model == "" {
return native.Options{}, fmt.Errorf("agent-native backend requires a model (role=%s)", role.Role)
}
opts := native.Options{
Model: model,
MaxTurns: cfg.MaxTurns,
Timeout: cfg.Timeout,
Search: cfg.Search,
Fetch: cfg.Fetch,
MCP: cfg.MCP,
SkillsRoots: cfg.SkillsRoots,
Subagent: cfg.Subagent,
Compact: cfg.Compact,
HostTools: cfg.HostTools,
Stream: cfg.Stream,
MaxToolCalls: cfg.MaxToolCalls,
}
if cfg.Client != nil {
opts.APIBase, opts.APIKey = cfg.Client.BaseURL, cfg.Client.APIKey
}
// 显式 allow/ask 才装配(注册层默认 deny,但派生/程序构造的 RoleSpec
// 未必经注册——工厂层兜底,空白串不放行)。
if cfg.Exec != nil && (role.BashPermission == BashPermissionAllow || role.BashPermission == BashPermissionAsk) {
opts.Exec = cfg.Exec
}
return opts, nil
}
// processEventBackend 把配置级过程回调接到每次 Execute,同时保留请求自带的 OnEvent。
type processEventBackend struct {
next exec.Backend
on func(ev exec.ProcessEvent)
}
func (b *processEventBackend) Name() string { return b.next.Name() }
func (b *processEventBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
if b.on != nil {
prev := req.OnEvent
req.OnEvent = func(ev exec.ProcessEvent) {
b.on(ev)
if prev != nil {
prev(ev)
}
}
}
return b.next.Execute(ctx, req)
}
// RegisterAgentNative 注册 "agent-native" 后端工厂(基础签名,向后兼容):
// 角色级 Model 覆盖 defaultModel,无附加能力装配。
func (r *BackendResolver) RegisterAgentNative(client *llm.Client, defaultModel string, maxTurns int, timeout time.Duration) {
r.RegisterAgentNativeFull(AgentNativeConfig{
Client: client, DefaultModel: defaultModel, MaxTurns: maxTurns, Timeout: timeout,
})
}
// RegisterAgentNativeFull 注册带全量能力装配的 "agent-native" 工厂:
// 搜索/取回/执行/MCP/技能/委派经配置注入;BashPermission=deny 的角色
// 不装配执行工具。智能体执行全部委托 agent-native,本库不实现 Agent。
func (r *BackendResolver) RegisterAgentNativeFull(cfg AgentNativeConfig) {
r.Register("agent-native", func(role RoleSpec) (exec.Backend, error) {
opts, err := buildAgentNativeOptions(cfg, role)
if err != nil {
return nil, err
}
next := native.New(opts)
if cfg.OnEvent == nil {
return next, nil
}
return &processEventBackend{next: next, on: cfg.OnEvent}, nil
})
}
// ListBackends 返回可用后端名(排序稳定)。
func (r *BackendResolver) ListBackends() []string {
r.mu.RLock()
@@ -191,7 +75,7 @@ func (r *BackendResolver) ListBackends() []string {
// Resolve 解析角色的执行后端:Role.Backend → Options.DefaultBackend → "stub"。
// MaxConcurrentTasks > 1 时以 agent-native 并发限流包装(per-role 缓存,跨步骤共享;
// 创建段持写锁防并行首解析竞态——否则并发步骤会各拿一份限流器)。
func (r *BackendResolver) Resolve(role RoleSpec, defaultBackend string) (exec.Backend, string, error) {
func (r *BackendResolver) Resolve(role RoleSpec, defaultBackend string) (Executor, string, error) {
name := role.Backend
if name == "" {
name = defaultBackend
@@ -219,7 +103,7 @@ func (r *BackendResolver) Resolve(role RoleSpec, defaultBackend string) (exec.Ba
return b, name, nil
}
var b exec.Backend
var b Executor
if hasUnder {
b = under
} else if hasFactory {
@@ -234,7 +118,7 @@ func (r *BackendResolver) Resolve(role RoleSpec, defaultBackend string) (exec.Ba
// MaxConcurrentTasks>=1 即包装限流(1 = 对象级串行;0 = 不限)。
if max := role.MaxConcurrentTasks; max >= 1 {
b = exec.WrapWithConcurrencyLimit(b, max)
b = WrapWithConcurrencyLimit(b, max)
}
r.wrapped[cacheKey] = b
return b, name, nil
+1 -5
View File
@@ -34,11 +34,7 @@ func saveBlackboard(wsDir string, bb *BlackboardState) error {
if err != nil {
return err
}
tmp := filepath.Join(wsDir, ".blackboard.json.tmp")
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
return os.Rename(tmp, filepath.Join(wsDir, "blackboard.json"))
return AtomicWrite(filepath.Join(wsDir, "blackboard.json"), append(data, '\n'))
}
// mergeStepArtifacts 合并步骤产出:fields.json(字符串键值)与
+6 -4
View File
@@ -13,7 +13,7 @@ type BlueprintRegistry struct {
mu sync.RWMutex
bps map[string]Blueprint
// OnChange 可选钩子:注册/删除后回调(store 层用于落盘)。
OnChange func(bp Blueprint)
OnChange func(bp Blueprint) error
}
// NewBlueprintRegistry 创建空注册表。
@@ -23,8 +23,8 @@ func NewBlueprintRegistry() *BlueprintRegistry {
// Register 注册或更新模板。
func (r *BlueprintRegistry) Register(bp Blueprint) error {
if strings.TrimSpace(bp.Key) == "" {
return fmt.Errorf("blueprint key cannot be empty")
if !ValidID(bp.Key) {
return invalidID(bp.Key)
}
if len(bp.Steps) == 0 {
return fmt.Errorf("blueprint %s has no steps", bp.Key)
@@ -41,7 +41,9 @@ func (r *BlueprintRegistry) Register(bp Blueprint) error {
r.bps[bp.Key] = bp
r.mu.Unlock()
if r.OnChange != nil {
r.OnChange(bp)
if err := r.OnChange(bp); err != nil {
return err
}
}
return nil
}
+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
}
+289 -39
View File
@@ -5,6 +5,7 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
@@ -152,15 +153,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),
@@ -309,6 +337,7 @@ 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}
if err := st.saveGate(gate); err != nil {
@@ -450,10 +479,15 @@ 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)
if err != nil {
@@ -475,18 +509,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 {
@@ -622,8 +712,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:
@@ -734,11 +824,113 @@ func (d *flowDriver) failStepResume(ctx context.Context, st *runState, idx int)
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
}
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)
@@ -752,11 +944,17 @@ func (e *SquadEngine) Resume(ctx context.Context, runID string) (*RunResult, err
if RunStatus(st.Meta.Status).Terminal() {
return e.Inspect(runID)
}
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)
if err != nil {
@@ -783,6 +981,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)
}
@@ -814,6 +1015,9 @@ func (e *SquadEngine) ListRuns() ([]RunMetadata, error) {
// Pending 返回运行当前挂起请求(无则 nil)。
func (e *SquadEngine) Pending(runID string) (*HumanRequest, error) {
if !ValidID(runID) {
return nil, invalidID(runID)
}
gate, err := loadGate(filepath.Join(e.opts.DataDir, "runs", runID))
if err != nil || gate == nil {
return nil, err
@@ -827,9 +1031,11 @@ 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))
if err != nil {
@@ -849,9 +1055,11 @@ 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))
if err != nil {
@@ -883,35 +1091,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 >= 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 {
+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 验证:质检失败 → 自动重跑交付步骤 → 通过。
+26 -2
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,6 +114,28 @@ func newRequestID() string {
return "hr_" + hex.EncodeToString(b)
}
// 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"`
+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 {
+14 -12
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})})
},
@@ -357,7 +355,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
@@ -386,7 +386,7 @@ func (e *SquadEngine) leaderFail(st *runState, reason string) *RunResult {
}
// snapshotDeliverablesToLeader 把成员交付文件复制进 leader 工作区 inbox/<步骤id>/。
func snapshotDeliverablesToLeader(st *runState, step StepSpec) {
func snapshotDeliverablesToLeader(st *runState, step StepSpec) error {
srcDir := filepath.Join(st.Meta.WorkDir, filepath.FromSlash(step.SubWorkspace))
dstDir := filepath.Join(st.Meta.WorkDir, filepath.FromSlash(leaderSubWorkspace), "inbox", step.ID)
for _, t := range step.Targets {
@@ -395,9 +395,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。
+10 -12
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()
@@ -385,18 +383,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 +433,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 +442,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):
}
}
}
+4 -2
View File
@@ -90,8 +90,10 @@ type StepSpec struct {
Needs []string `json:"needs,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"`
DurationMs int64 `json:"duration_ms,omitempty"`
Error string `json:"error,omitempty"`
// RequiresApproval 为 true 时,该步骤启动前进入 HITL 计划确认。
RequiresApproval bool `json:"requires_approval,omitempty"`
// IntentID 非空 = 系统功能步:不经 BackendResolver/智能体,由 IntentRunner 直接执行
+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 块/代码围栏/前后杂文)。
+151
View File
@@ -0,0 +1,151 @@
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
}
// 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{
+2 -4
View File
@@ -5,8 +5,6 @@ import (
"os"
"path/filepath"
"testing"
"agent-native/exec"
)
type fixedRouter struct{ dec *RouteDecision }
@@ -89,14 +87,14 @@ type captureSkillsBackend struct{ got []string }
func (b *captureSkillsBackend) Name() string { return "capture-skills" }
func (b *captureSkillsBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
func (b *captureSkillsBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
b.got = append([]string{}, req.Skills...)
for _, target := range req.Targets {
path := filepath.Join(req.WorkDir, target)
_ = os.MkdirAll(filepath.Dir(path), 0o755)
_ = os.WriteFile(path, []byte(goodReport), 0o644)
}
return &exec.Result{Success: true, Patches: map[string][]byte{"output.md": []byte(goodReport)}}, nil
return &ExecResult{Success: true, Patches: map[string][]byte{"output.md": []byte(goodReport)}}, nil
}
func TestStepSkillsOverrideRoleSkills(t *testing.T) {
+36 -1
View File
@@ -68,7 +68,17 @@ 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,
Clarifications: st.Clarifications,
})
}
type diskRunState struct {
Approved map[string]bool `json:"approved,omitempty"`
RepairBudgetRemaining int `json:"repair_budget_remaining,omitempty"`
Clarifications []string `json:"clarifications,omitempty"`
}
// Workflow 返回计划的编排清单(简洁访问器)。
@@ -112,6 +122,19 @@ 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.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)
@@ -170,6 +193,18 @@ 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() {
data, err := os.ReadFile(filepath.Join(st.Meta.WorkDir, "steering.json"))
+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)
}
+98 -55
View File
@@ -10,8 +10,6 @@ import (
"sync"
"time"
"agent-native/exec"
"error-code-system/errcode"
)
@@ -36,51 +34,25 @@ func (d *flowDriver) advance(ctx context.Context, st *runState) (res *RunResult,
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)
}
}
// 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 out, g, err := d.gateBeforeRun(ctx, st, i); out != nil || g != nil || err != nil {
if out == nil && g != nil {
out = heldResult(st, g)
}
return out, g, err
}
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" {
if out, g, err := d.gateBeforeRun(ctx, st, i); out != nil || g != nil || err != nil {
if out == nil && g != nil {
out = heldResult(st, g)
}
return out, g, err
}
group = append(group, i)
i++
continue
@@ -128,19 +100,69 @@ func (d *flowDriver) advance(ctx context.Context, st *runState) (res *RunResult,
return d.finalize(ctx, st)
}
// 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) {
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
}
@@ -148,6 +170,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)
}
@@ -156,6 +181,9 @@ 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) {
if err := ctx.Err(); err != nil {
return &RunResult{RunID: st.Meta.ID, Status: RunCancelled, Error: err.Error()}, nil, err
}
step := st.Plan.Workflow.Steps[idx]
role, ok := findAgent(st.Plan.Squad.Agents, step.Role)
if !ok {
@@ -164,6 +192,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)
@@ -180,15 +210,28 @@ 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.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
@@ -208,13 +251,13 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*Ru
}
// 步骤级共享预算:重试与首次尝试共用同一截止时刻
//(agent-execution 标准:重试层共享总预算,不随次数翻倍)。
var stepBudget *exec.Budget
var stepBudget *ExecBudget
if d.opts.StepTimeout > 0 {
stepBudget = &exec.Budget{Deadline: time.Now().Add(d.opts.StepTimeout)}
stepBudget = &ExecBudget{Deadline: time.Now().Add(d.opts.StepTimeout)}
}
var result *exec.Result
var result *ExecResult
var err error
for attempt := 1; attempt <= exec.MaxTransientStepInvocations; attempt++ {
for attempt := 1; attempt <= MaxTransientStepInvocations; attempt++ {
reqPrompt := prompt
if attempt > 1 {
prevReason := "交付物缺失"
@@ -225,14 +268,14 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*Ru
}
reqPrompt += "\n\n【重试提示】上一次执行未成功(" + prevReason + ")。请务必把结果写入:" + strings.Join(targets, ", ")
}
req := exec.Request{
req := ExecRequest{
WorkDir: subDir,
Prompt: reqPrompt,
Instructions: instructions,
Targets: targets,
Timeout: d.opts.StepTimeout,
ToolProfile: orDefault(role.ToolProfile, exec.ToolProfileWorkspace),
OnEvent: func(ev exec.ProcessEvent) {
ToolProfile: orDefault(role.ToolProfile, ToolProfileWorkspace),
OnEvent: func(ev ProcessEvent) {
d.emit(st, Event{Type: EvDetail, StepID: step.ID, Role: step.Role, Text: ev.Text,
Data: marshalJSON(map[string]string{"process_type": ev.Type, "tool": ev.Tool, "detail": ev.Detail})})
},
@@ -256,7 +299,7 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*Ru
Title: step.Title, WorkDir: subDir, Targets: targets,
})
if err == nil && result != nil && len(result.Patches) > 0 {
_, _ = exec.MaterializeTargets(subDir, targets, result.Patches)
_, _ = MaterializeTargets(subDir, targets, result.Patches)
}
} else {
result, err = backend.Execute(ctx, req)
@@ -266,19 +309,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()
@@ -655,11 +698,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
+37 -1
View File
@@ -309,5 +309,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 删除蓝图文件。