Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61985d457f | ||
|
|
5a57636cc0 | ||
|
|
59ca80cee2 | ||
|
|
dcd523ea05 | ||
|
|
d9a573e497 | ||
|
|
a3b49e7b1f | ||
|
|
55e9b37770 | ||
|
|
beea97e8cf | ||
|
|
41dfe7e3ef | ||
|
|
68b18ec2c5 | ||
|
|
4d534508a9 | ||
|
|
3abc5944bd | ||
|
|
9efce79b86 | ||
|
|
c5c5a24e2c | ||
|
|
02c0d6aae4 | ||
|
|
5e414ffecc | ||
|
|
f68e5bde7b | ||
|
|
a182d7662b | ||
|
|
20a9eb8078 | ||
|
|
31ee395895 | ||
|
|
1bb0c3a2ae | ||
|
|
9537321782 | ||
|
|
8dc2c54adf | ||
|
|
6c8810029d | ||
|
|
e928ea3c0f | ||
|
|
cda1fd7c70 | ||
|
|
73b8d09545 | ||
|
|
8e987bf535 | ||
|
|
6aa61dff9f |
+38
-9
@@ -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
@@ -1,4 +1,4 @@
|
||||
agent-squad
|
||||
/agent-squad
|
||||
agent-squad-data/
|
||||
*.test
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -0,0 +1,200 @@
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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,
|
||||
},
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ type Store struct {
|
||||
}
|
||||
|
||||
func Open(path string) (*Store, error) {
|
||||
db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)")
|
||||
db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -233,6 +233,17 @@ func openTest(t *testing.T) *Store {
|
||||
return s
|
||||
}
|
||||
|
||||
func TestR2JournalModeWAL(t *testing.T) {
|
||||
s := openTest(t)
|
||||
var mode string
|
||||
if err := s.db.QueryRow(`PRAGMA journal_mode`).Scan(&mode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mode != "wal" {
|
||||
t.Fatalf("journal_mode %q", mode)
|
||||
}
|
||||
}
|
||||
|
||||
func claimAny(t *testing.T, s *Store, workspaceID string) (Task, bool, error) {
|
||||
t.Helper()
|
||||
rt, err := s.RegisterRuntime(Runtime{WorkspaceID: workspaceID, Name: "tmp", Command: "in-process", Protocol: "agent-native"})
|
||||
@@ -233,8 +233,10 @@ func (s *Store) Claim(runtimeID string, at time.Time) (Task, bool, error) {
|
||||
continue
|
||||
}
|
||||
lease := at.UTC().Add(5 * time.Minute).Format(time.RFC3339Nano)
|
||||
res, err := s.db.Exec(`UPDATE task SET status='dispatched', runtime_id=?, lease_until=?, updated_at=? WHERE id=? AND status='queued'`,
|
||||
runtimeID, lease, now(), cand.id)
|
||||
res, err := s.db.Exec(`UPDATE task SET status='dispatched', runtime_id=?, lease_until=?, updated_at=?
|
||||
WHERE id=? AND status='queued'
|
||||
AND (SELECT COUNT(*) FROM task WHERE agent_id=? AND status IN ('dispatched','running')) < ?`,
|
||||
runtimeID, lease, now(), cand.id, cand.agentID, cap)
|
||||
if err != nil {
|
||||
return Task{}, false, err
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -14,12 +15,12 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"agent-intent/intent"
|
||||
"agent-native/runtime/llm"
|
||||
"agent-squad/platform"
|
||||
"agent-squad/server"
|
||||
squadintent "agent-squad/adapters/intent"
|
||||
nativeadp "agent-squad/adapters/native"
|
||||
platform "agent-squad/adapters/sqlite"
|
||||
"agent-squad/cmd/agent-squad/server"
|
||||
"agent-squad/squad"
|
||||
"agent-squad/squadintent"
|
||||
"agent-squad/store"
|
||||
|
||||
"logging-system/config"
|
||||
@@ -61,7 +62,7 @@ func usage() {
|
||||
fmt.Print(`agent-squad — 通用智能体小队编排底座
|
||||
|
||||
用法:
|
||||
agent-squad serve [--addr :8090] [--data DIR] [--planner rule|llm] [--model M]
|
||||
agent-squad serve [--addr 127.0.0.1:8090] [--data DIR] [--token TOK] [--planner rule|llm] [--model M]
|
||||
agent-squad role list|add|rm|recommend|derive ...
|
||||
agent-squad blueprint list|add|rm ...
|
||||
agent-squad run "目标文本" [--task-key K] [--backend B] [--materials DIR] [--hitl-mode interactive|auto|disabled]
|
||||
@@ -121,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 实时/重放)。
|
||||
@@ -136,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)
|
||||
}
|
||||
@@ -206,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 规划器/后端)")
|
||||
@@ -220,7 +216,12 @@ 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)
|
||||
@@ -236,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)
|
||||
@@ -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 阻塞缺口挂起 →
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"agent-native/runtime/llm"
|
||||
"agent-squad/platform"
|
||||
platform "agent-squad/adapters/sqlite"
|
||||
)
|
||||
|
||||
func cmdPlatform(args []string) int {
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-native/exec"
|
||||
"agent-squad/squad"
|
||||
"agent-squad/store"
|
||||
)
|
||||
@@ -21,7 +20,7 @@ type replayBackend struct{ script map[string]map[string]string }
|
||||
|
||||
func (b *replayBackend) Name() string { return "replay" }
|
||||
|
||||
func (b *replayBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
|
||||
func (b *replayBackend) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) {
|
||||
key := filepath.Base(filepath.Clean(req.WorkDir))
|
||||
files := b.script[key]
|
||||
patches := map[string][]byte{}
|
||||
@@ -37,7 +36,7 @@ func (b *replayBackend) Execute(ctx context.Context, req exec.Request) (*exec.Re
|
||||
}
|
||||
patches[t] = []byte(content)
|
||||
}
|
||||
return &exec.Result{Success: true, Patches: patches, Logs: "replay"}, nil
|
||||
return &squad.ExecResult{Success: true, Patches: patches, Logs: "replay"}, nil
|
||||
}
|
||||
|
||||
const deliverable = "# 契约验收交付物\n\n## 概述\n\n本文件由回放后端生成,用于验证 agent-squad 全链路契约:注册、规划、执行、HITL、质检与审计。\n\n## 结论\n\n全链路行为符合契约。\n"
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -4,7 +4,7 @@ package main
|
||||
import (
|
||||
"os"
|
||||
|
||||
"agent-squad/cli"
|
||||
"agent-squad/cmd/agent-squad/cli"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -0,0 +1,74 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-squad/squad"
|
||||
"agent-squad/store"
|
||||
)
|
||||
|
||||
func TestMissingTokenIsUnauthorized(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, err := store.New(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg := squad.NewRoleRegistry()
|
||||
engine, err := squad.NewEngine(squad.Options{
|
||||
DataDir: dir, Registry: reg, Planner: &squad.MockPlanner{}, Sink: squad.NopSink{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := New(engine, squad.NewFanoutSink(8), st)
|
||||
srv.SetToken("secret")
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
resp, err := http.Get(ts.URL + "/v1/roles")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("status %d", resp.StatusCode)
|
||||
}
|
||||
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/roles", nil)
|
||||
req.Header.Set("Authorization", "Bearer secret")
|
||||
resp, err = http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("authed status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathTraversalRejected(t *testing.T) {
|
||||
ts, _ := newTestServer(t)
|
||||
resp, err := http.Get(ts.URL + "/v1/runs/a..b")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOversizedBodyIs413(t *testing.T) {
|
||||
ts, _ := newTestServer(t)
|
||||
body := strings.NewReader(`{"role":"x","name":"` + strings.Repeat("a", 2<<20) + `"}`)
|
||||
resp, err := http.Post(ts.URL+"/v1/roles", "application/json", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -4,15 +4,18 @@ package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"agent-squad/platform"
|
||||
platform "agent-squad/adapters/sqlite"
|
||||
"agent-squad/squad"
|
||||
"agent-squad/store"
|
||||
|
||||
@@ -34,8 +37,12 @@ type Server struct {
|
||||
latest map[string]*squad.RunResult // 最近一次推进结果(供轮询)
|
||||
log *logsys.Logger
|
||||
plat *platform.Store
|
||||
token string
|
||||
}
|
||||
|
||||
// SetToken 要求除健康检查外的请求带 Bearer token。空串表示不校验。
|
||||
func (s *Server) SetToken(token string) { s.token = token }
|
||||
|
||||
// SetLogger 设置组合根日志器。nil 表示不写日志。
|
||||
func (s *Server) SetLogger(log *logsys.Logger) {
|
||||
if s == nil {
|
||||
@@ -87,7 +94,54 @@ func (s *Server) Handler() http.Handler {
|
||||
if s.plat != nil {
|
||||
platform.Mount(mux, s.plat)
|
||||
}
|
||||
return httpx.Trace(mux)
|
||||
var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
limit := int64(1 << 20)
|
||||
if r.Method == http.MethodPost && r.URL.Path == "/v1/runs" {
|
||||
limit = 32 << 20
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, limit)
|
||||
mux.ServeHTTP(w, r)
|
||||
})
|
||||
if s.token != "" {
|
||||
inner := h
|
||||
h = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/v1/health" {
|
||||
inner.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
const prefix = "Bearer "
|
||||
header := r.Header.Get("Authorization")
|
||||
got := strings.TrimPrefix(header, prefix)
|
||||
if len(header) != len(prefix)+len(s.token) || subtle.ConstantTimeCompare([]byte(got), []byte(s.token)) != 1 {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
inner.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
return httpx.Trace(h)
|
||||
}
|
||||
|
||||
func (s *Server) decode(w http.ResponseWriter, r *http.Request, dst any) bool {
|
||||
err := json.NewDecoder(r.Body).Decode(dst)
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
var maxErr *http.MaxBytesError
|
||||
if errors.As(err, &maxErr) || strings.Contains(err.Error(), "request body too large") {
|
||||
w.WriteHeader(http.StatusRequestEntityTooLarge)
|
||||
return false
|
||||
}
|
||||
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, "请求 JSON 无法解析"))
|
||||
return false
|
||||
}
|
||||
|
||||
func rejectBadID(w http.ResponseWriter, id string) bool {
|
||||
if squad.ValidID(id) {
|
||||
return false
|
||||
}
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return true
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
@@ -132,8 +186,7 @@ func (s *Server) handleListRoles(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePutRole(w http.ResponseWriter, r *http.Request) {
|
||||
var spec squad.RoleSpec
|
||||
if err := json.NewDecoder(r.Body).Decode(&spec); err != nil {
|
||||
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, "请求 JSON 无法解析"))
|
||||
if !s.decode(w, r, &spec) {
|
||||
return
|
||||
}
|
||||
if key := r.PathValue("key"); key != "" && spec.Role == "" {
|
||||
@@ -148,6 +201,9 @@ func (s *Server) handlePutRole(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleDeleteRole(w http.ResponseWriter, r *http.Request) {
|
||||
key := r.PathValue("key")
|
||||
if rejectBadID(w, key) {
|
||||
return
|
||||
}
|
||||
if !s.engine.Registry().Remove(key) {
|
||||
s.writeErr(w, r, errcode.New(errcode.SquadRoleNotFound, fmt.Sprintf("role %s not found", key)))
|
||||
return
|
||||
@@ -158,8 +214,7 @@ func (s *Server) handleDeleteRole(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleDeriveRole(w http.ResponseWriter, r *http.Request) {
|
||||
var req squad.DeriveRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, "请求 JSON 无法解析"))
|
||||
if !s.decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
spec, err := s.engine.Registry().Derive(req)
|
||||
@@ -183,8 +238,7 @@ func (s *Server) handleListBlueprints(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePutBlueprint(w http.ResponseWriter, r *http.Request) {
|
||||
var bp squad.Blueprint
|
||||
if err := json.NewDecoder(r.Body).Decode(&bp); err != nil {
|
||||
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, "请求 JSON 无法解析"))
|
||||
if !s.decode(w, r, &bp) {
|
||||
return
|
||||
}
|
||||
if err := s.engine.Blueprints().Register(bp); err != nil {
|
||||
@@ -196,6 +250,9 @@ func (s *Server) handlePutBlueprint(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleDeleteBlueprint(w http.ResponseWriter, r *http.Request) {
|
||||
key := r.PathValue("key")
|
||||
if rejectBadID(w, key) {
|
||||
return
|
||||
}
|
||||
if !s.engine.Blueprints().Remove(key) {
|
||||
s.writeErr(w, r, errcode.New(errcode.SquadBlueprintNotFound, fmt.Sprintf("blueprint %s not found", key)))
|
||||
return
|
||||
@@ -221,8 +278,7 @@ type createRunRequest struct {
|
||||
|
||||
func (s *Server) handleCreateRun(w http.ResponseWriter, r *http.Request) {
|
||||
var req createRunRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, "请求 JSON 无法解析"))
|
||||
if !s.decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
for name, b64 := range req.MaterialsB64 {
|
||||
@@ -276,7 +332,11 @@ func (s *Server) handleListRuns(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleInspectRun(w http.ResponseWriter, r *http.Request) {
|
||||
res, err := s.engine.Inspect(r.PathValue("id"))
|
||||
id := r.PathValue("id")
|
||||
if rejectBadID(w, id) {
|
||||
return
|
||||
}
|
||||
res, err := s.engine.Inspect(id)
|
||||
if err != nil {
|
||||
s.writeErr(w, r, errcode.Wrap(err, errcode.SquadRunNotFound, "运行不存在"))
|
||||
return
|
||||
@@ -285,7 +345,11 @@ func (s *Server) handleInspectRun(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handlePending(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := s.engine.Pending(r.PathValue("id"))
|
||||
id := r.PathValue("id")
|
||||
if rejectBadID(w, id) {
|
||||
return
|
||||
}
|
||||
req, err := s.engine.Pending(id)
|
||||
if err != nil || req == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"pending": nil})
|
||||
return
|
||||
@@ -295,11 +359,14 @@ func (s *Server) handlePending(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleRespond(w http.ResponseWriter, r *http.Request) {
|
||||
var decision squad.RespondDecision
|
||||
if err := json.NewDecoder(r.Body).Decode(&decision); err != nil {
|
||||
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, "请求 JSON 无法解析"))
|
||||
if !s.decode(w, r, &decision) {
|
||||
return
|
||||
}
|
||||
res, err := s.engine.Respond(r.Context(), r.PathValue("id"), decision)
|
||||
id := r.PathValue("id")
|
||||
if rejectBadID(w, id) {
|
||||
return
|
||||
}
|
||||
res, err := s.engine.Respond(r.Context(), id, decision)
|
||||
if err != nil {
|
||||
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, err.Error()))
|
||||
return
|
||||
@@ -310,7 +377,11 @@ func (s *Server) handleRespond(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleResume(w http.ResponseWriter, r *http.Request) {
|
||||
res, err := s.engine.Resume(r.Context(), r.PathValue("id"))
|
||||
id := r.PathValue("id")
|
||||
if rejectBadID(w, id) {
|
||||
return
|
||||
}
|
||||
res, err := s.engine.Resume(r.Context(), id)
|
||||
if err != nil {
|
||||
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, err.Error()))
|
||||
return
|
||||
@@ -321,7 +392,11 @@ func (s *Server) handleResume(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleBlackboard(w http.ResponseWriter, r *http.Request) {
|
||||
bb, err := s.engine.Blackboard(r.PathValue("id"))
|
||||
id := r.PathValue("id")
|
||||
if rejectBadID(w, id) {
|
||||
return
|
||||
}
|
||||
bb, err := s.engine.Blackboard(id)
|
||||
if err != nil {
|
||||
s.writeErr(w, r, errcode.Wrap(err, errcode.SquadRunNotFound, "运行不存在"))
|
||||
return
|
||||
@@ -333,11 +408,14 @@ func (s *Server) handleSteer(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, "请求 JSON 无法解析"))
|
||||
if !s.decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if err := s.engine.Steer(r.PathValue("id"), req.Text); err != nil {
|
||||
id := r.PathValue("id")
|
||||
if rejectBadID(w, id) {
|
||||
return
|
||||
}
|
||||
if err := s.engine.Steer(id, req.Text); err != nil {
|
||||
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, err.Error()))
|
||||
return
|
||||
}
|
||||
@@ -349,11 +427,14 @@ func (s *Server) handleSetField(w http.ResponseWriter, r *http.Request) {
|
||||
Field string `json:"field"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, "请求 JSON 无法解析"))
|
||||
if !s.decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if err := s.engine.SetBlackboardField(r.PathValue("id"), req.Field, req.Value); err != nil {
|
||||
id := r.PathValue("id")
|
||||
if rejectBadID(w, id) {
|
||||
return
|
||||
}
|
||||
if err := s.engine.SetBlackboardField(id, req.Field, req.Value); err != nil {
|
||||
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, err.Error()))
|
||||
return
|
||||
}
|
||||
@@ -389,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"))
|
||||
@@ -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"
|
||||
Vendored
+1
-1
Submodule deps/agent-intent updated: 3674dc4bfd...65aaebca1e
Vendored
+1
-1
Submodule deps/agent-native updated: a34689c78f...3a110a728c
Vendored
+1
-1
Submodule deps/error-code-system updated: dbeed2f1b6...a3ee67bba2
Vendored
+1
-1
Submodule deps/logging-system updated: 2c5716fdcf...49b5d8be60
@@ -2,31 +2,6 @@ module agent-squad
|
||||
|
||||
go 1.27
|
||||
|
||||
require (
|
||||
agent-intent v0.0.0
|
||||
agent-native v0.0.0
|
||||
error-code-system v0.0.0
|
||||
logging-system v0.0.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
modernc.org/libc v1.62.1 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.9.1 // indirect
|
||||
modernc.org/sqlite v1.37.0 // indirect
|
||||
)
|
||||
|
||||
replace agent-intent => ./deps/agent-intent
|
||||
|
||||
replace agent-native => ./deps/agent-native
|
||||
require error-code-system v0.0.0
|
||||
|
||||
replace error-code-system => ./deps/error-code-system
|
||||
|
||||
replace logging-system => ./deps/logging-system
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
modernc.org/libc v1.62.1 h1:s0+fv5E3FymN8eJVmnk0llBe6rOxCu/DEU+XygRbS8s=
|
||||
modernc.org/libc v1.62.1/go.mod h1:iXhATfJQLjG3NWy56a6WVU73lWOcdYVxsvwCgoPljuo=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g=
|
||||
modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
|
||||
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"key":"demo","title":"示例","steps":[{"id":"s1","role":"writer","title":"起草","parallel":false}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"role":"writer","name":"写作者","backend":"stub","tool_profile":"workspace"}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"workflow_id":"wf-1","title":"示例","status":"pending","current_step_index":0,"steps":[{"id":"s1","role":"writer","title":"起草","parallel":false}]}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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(无后端语义,仅满足编制覆盖)。
|
||||
|
||||
@@ -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
@@ -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
@@ -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_") {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
@@ -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"))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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 删除蓝图文件。
|
||||
|
||||
Reference in New Issue
Block a user