Compare commits

..
Author SHA1 Message Date
weichen 9ec3d86e86 Point the nested agent-native pin at the reasoning replay head
ci / vet-and-test (pull_request) Successful in 27s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: Dev's combination check requires nested library pins to match the Dev
top-level pointers; Dev moves agent-native to 3b92cb6 (reasoning capture and
replay, Agent-Native #41) for the quality P2-I2 work.

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

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

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

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

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

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

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

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

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

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

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

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

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

Refs: Q2 plan WP4 (2026-10-07-agent-stack-q2q3-plans.md); task card
.task/feature-agent-stack-q2.md
2026-10-08 01:29:03 +00:00
weichen aec2a4aa0f Merge pull request 'Add review driver, handoff primitive, and group-chat driver (Q2 WP3)' (#44) from feature/q2-review-handoff-groupchat-wp3 into main
ci / vet-and-test (push) Successful in 25s
ci / docker-checkout-hostgateway (push) Successful in 0s
Merge pull request 'Add review driver, handoff primitive, and group-chat driver (Q2 WP3)' (#44) from feature/q2-review-handoff-groupchat-wp3 into main
2026-10-08 01:13:54 +00:00
13 changed files with 1604 additions and 3 deletions
+179
View File
@@ -0,0 +1,179 @@
// Package a2a 把小队接入 A2A(Agent2Agent)1.0 互通面(Q3 WP4):
// - 发布侧:BuildCard 从角色注册表与蓝图注册表渲染 Agent Card(描述本
// 团队能力),CardHandler 以 JSON 服务(挂载在
// /.well-known/agent-card.json);
// - 消费侧:FetchCard 发现远端 Agent Card(fail-closed 校验必备字段),
// Executor 实现 squad.Executor——message/send 发起任务,tasks/get 轮询
// A2A 任务状态机(submitted/working/input-required → completed/failed/
// canceled),终态映射 ExecResult:completed 的 artifacts(TextPart/
// FilePart)落成 patches/目标文件;failed/canceled/input-required 一律
// Success=false(跨进程挂起不在本适配面内,fail-closed 交引擎重试/降级
// 语义处理)。
//
// 传输为 JSON-RPC 2.0 over HTTP(A2A 1.0 形态);不引入协议之外的依赖。
package a2a
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"agent-squad/squad"
)
// ProtocolVersion 对齐的 A2A 协议版本。
const ProtocolVersion = "1.0"
// WellKnownPath 是 Agent Card 的发现路径(A2A 1.0)。
const WellKnownPath = "/.well-known/agent-card.json"
// AgentCard 是 A2A 1.0 Agent Card(发布与消费共用形态)。
type AgentCard struct {
ProtocolVersion string `json:"protocolVersion"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
// URL 是 JSON-RPC 服务端点(消费侧据此发 message/send)。
URL string `json:"url"`
Version string `json:"version"`
Capabilities CardCapabilities `json:"capabilities"`
DefaultInputModes []string `json:"defaultInputModes,omitempty"`
DefaultOutputModes []string `json:"defaultOutputModes,omitempty"`
Skills []AgentSkill `json:"skills,omitempty"`
}
// CardCapabilities 能力声明(本适配面:非流式、无推送、无状态历史)。
type CardCapabilities struct {
Streaming bool `json:"streaming"`
PushNotifications bool `json:"pushNotifications"`
StateTransitionHistory bool `json:"stateTransitionHistory"`
}
// AgentSkill 是一项可被远端调用的能力。
type AgentSkill struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Tags []string `json:"tags,omitempty"`
Examples []string `json:"examples,omitempty"`
}
// CardOptions 覆盖 BuildCard 的身份字段(空则用缺省)。
type CardOptions struct {
Name string
Description string
// URL JSON-RPC 端点(必填:远端没有端点就无法调用本团队)。
URL string
// Version 卡片版本(空 = "1.0.0")。
Version string
// Skills 显式能力清单;空 = 从注册表派生。
Skills []AgentSkill
}
// BuildCard 从角色注册表与蓝图注册表渲染 Agent Card(描述本团队能力)。
// 角色 → 技能(关键词作 tags);蓝图 → 技能(match 词作 examples)。
func BuildCard(reg *squad.RoleRegistry, bps *squad.BlueprintRegistry, opts CardOptions) (AgentCard, error) {
if strings.TrimSpace(opts.URL) == "" {
return AgentCard{}, fmt.Errorf("a2a: Agent Card 需要 JSON-RPC 端点 URL")
}
card := AgentCard{
ProtocolVersion: ProtocolVersion,
Name: orDefault(opts.Name, "agent-squad"),
Description: opts.Description,
URL: opts.URL,
Version: orDefault(opts.Version, "1.0.0"),
DefaultInputModes: []string{"text/plain"},
DefaultOutputModes: []string{"text/plain"},
Skills: append([]AgentSkill(nil), opts.Skills...),
}
if len(card.Skills) == 0 {
if reg != nil {
for _, role := range reg.List() {
desc := firstLine(role.Instructions)
if desc == "" {
desc = role.Name
}
card.Skills = append(card.Skills, AgentSkill{
ID: role.Role, Name: orDefault(role.Name, role.Role),
Description: desc, Tags: append([]string(nil), role.Keywords...),
})
}
}
if bps != nil {
for _, bp := range bps.List() {
card.Skills = append(card.Skills, AgentSkill{
ID: bp.Key, Name: orDefault(bp.Title, bp.Key),
Description: bp.Description, Examples: append([]string(nil), bp.Match...),
})
}
}
}
return card, nil
}
// CardHandler 以 JSON 服务 Agent Card(调用方挂载到 WellKnownPath)。
func CardHandler(card AgentCard) http.Handler {
body, _ := json.Marshal(card)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(body)
})
}
// FetchCard 发现并校验远端 Agent Card(fail-closed:必备字段缺失即拒)。
func FetchCard(client *http.Client, wellKnownURL string) (AgentCard, error) {
if client == nil {
client = http.DefaultClient
}
resp, err := client.Get(wellKnownURL)
if err != nil {
return AgentCard{}, fmt.Errorf("a2a: 获取 Agent Card: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return AgentCard{}, fmt.Errorf("a2a: Agent Card 端点返回 %d", resp.StatusCode)
}
var card AgentCard
if err := json.NewDecoder(resp.Body).Decode(&card); err != nil {
return AgentCard{}, fmt.Errorf("a2a: Agent Card 解析失败: %w", err)
}
if err := validateCard(card); err != nil {
return AgentCard{}, err
}
return card, nil
}
// validateCard 消费侧最小校验(A2A 1.0 必备字段)。
func validateCard(card AgentCard) error {
if card.ProtocolVersion == "" {
return fmt.Errorf("a2a: Agent Card 缺 protocolVersion")
}
if strings.TrimSpace(card.Name) == "" {
return fmt.Errorf("a2a: Agent Card 缺 name")
}
if strings.TrimSpace(card.URL) == "" {
return fmt.Errorf("a2a: Agent Card 缺 url")
}
if strings.TrimSpace(card.Version) == "" {
return fmt.Errorf("a2a: Agent Card 缺 version")
}
return nil
}
func orDefault(v, def string) string {
if strings.TrimSpace(v) != "" {
return v
}
return def
}
func firstLine(s string) string {
s = strings.TrimSpace(s)
if i := strings.IndexByte(s, '\n'); i >= 0 {
s = s[:i]
}
if len(s) > 120 {
s = s[:120] + "…"
}
return s
}
+345
View File
@@ -0,0 +1,345 @@
package a2a
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"agent-squad/squad"
)
// conformance_test.go — Q3 WP4 验收:双向 conformance(本地假 A2A 服务端)。
// 发布侧:BuildCard + CardHandler 在 well-known 路径服务合法 Agent Card;
// 消费侧:FetchCard 发现校验 + Executor 走完任务状态机
// (submitted→working→completed / failed / input-required),终态对齐
// squad.ExecResult。
// fakeA2AServer 最小 A2A 1.0 服务端:well-known 卡片 + JSON-RPC
// (message/send 建任务;tasks/get 按脚本推进状态)。
type fakeA2AServer struct {
mu sync.Mutex
card AgentCard
tasks map[string]*Task
plan []string // 每次 tasks/get 推进到的状态序列(走完停在末态)
calls map[string]int
artifacts []Artifact
statusMsg string
}
func newFakeServer(t *testing.T, card AgentCard, plan []string, artifacts []Artifact) *fakeA2AServer {
t.Helper()
s := &fakeA2AServer{card: card, tasks: map[string]*Task{}, plan: plan,
calls: map[string]int{}, artifacts: artifacts}
return s
}
func (s *fakeA2AServer) handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc(WellKnownPath, func(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
body, _ := json.Marshal(s.card)
s.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(body)
})
mux.HandleFunc("/rpc", func(w http.ResponseWriter, r *http.Request) {
var req rpcRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeRPCError(w, 0, -32700, "parse error")
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.calls[req.Method]++
switch req.Method {
case "message/send":
id := fmt.Sprintf("task_%d", len(s.tasks)+1)
state := StateSubmitted
if len(s.plan) > 0 {
state = s.plan[0]
}
task := &Task{Kind: "task", ID: id, ContextID: "ctx_1",
Status: TaskStatus{State: state, Message: s.statusMessage()}}
s.tasks[id] = task
writeRPCResult(w, req.ID, task)
case "tasks/get":
var params struct {
ID string `json:"id"`
}
raw, _ := json.Marshal(req.Params)
_ = json.Unmarshal(raw, &params)
task, ok := s.tasks[params.ID]
if !ok {
writeRPCError(w, req.ID, -32001, "task not found")
return
}
// 按脚本推进状态机(停留在终态)。
if !isTerminal(task.Status.State) && len(s.plan) > 0 {
n := s.calls["tasks/get#"+params.ID]
s.calls["tasks/get#"+params.ID] = n + 1
idx := n + 1
if idx >= len(s.plan) {
idx = len(s.plan) - 1
}
task.Status.State = s.plan[idx]
task.Status.Message = s.statusMessage()
if task.Status.State == StateCompleted {
task.Artifacts = s.artifacts
}
}
writeRPCResult(w, req.ID, task)
default:
writeRPCError(w, req.ID, -32601, "method not found")
}
})
return mux
}
func (s *fakeA2AServer) statusMessage() *Message {
if s.statusMsg == "" {
return nil
}
return &Message{Kind: "message", Role: "agent",
Parts: []Part{{Kind: "text", Text: s.statusMsg}}}
}
func writeRPCResult(w http.ResponseWriter, id int64, result any) {
body, _ := json.Marshal(result)
env, _ := json.Marshal(rpcResponse{JSONRPC: "2.0", ID: id, Result: body})
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(env)
}
func writeRPCError(w http.ResponseWriter, id int64, code int, msg string) {
env, _ := json.Marshal(rpcResponse{JSONRPC: "2.0", ID: id, Error: &rpcError{Code: code, Message: msg}})
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(env)
}
// ---- 发布侧 ----
func TestBuildCardFromRegistries(t *testing.T) {
reg := squad.NewRoleRegistry()
for _, r := range []squad.RoleSpec{
{Role: "researcher", Name: "调研员", Keywords: []string{"调研", "检索"},
Instructions: "围绕目标调研关键问题。\n第二行不进摘要。"},
{Role: "writer", Name: "撰稿员", Keywords: []string{"撰写"}},
} {
if err := reg.Register(r); err != nil {
t.Fatal(err)
}
}
bps := squad.NewBlueprintRegistry()
if err := bps.Register(squad.Blueprint{Key: "brief", Title: "简报",
Description: "生成简报", Match: []string{"简报", "brief"},
Steps: []squad.StepSpec{{ID: "s1", Role: "writer", Title: "成文"}}}); err != nil {
t.Fatal(err)
}
card, err := BuildCard(reg, bps, CardOptions{
Name: "示例小队", Description: "演示团队能力", URL: "https://team.example/rpc",
})
if err != nil {
t.Fatal(err)
}
if card.ProtocolVersion != ProtocolVersion || card.Version != "1.0.0" {
t.Fatalf("card = %+v", card)
}
byID := map[string]AgentSkill{}
for _, s := range card.Skills {
byID[s.ID] = s
}
if byID["researcher"].Name != "调研员" || byID["researcher"].Description != "围绕目标调研关键问题。" {
t.Fatalf("role skill = %+v", byID["researcher"])
}
if len(byID["researcher"].Tags) != 2 {
t.Fatalf("tags = %v", byID["researcher"].Tags)
}
if byID["brief"].Name != "简报" || strings.Join(byID["brief"].Examples, ",") != "简报,brief" {
t.Fatalf("blueprint skill = %+v", byID["brief"])
}
if _, err := BuildCard(reg, bps, CardOptions{}); err == nil {
t.Fatal("缺 URL 必须 fail-closed")
}
}
// CardHandler 以 JSON 服务卡片(发布端点形态)。
func TestCardHandlerServesJSON(t *testing.T) {
card := AgentCard{ProtocolVersion: ProtocolVersion, Name: "小队", URL: "https://x/rpc", Version: "1.0.0"}
ts := httptest.NewServer(CardHandler(card))
defer ts.Close()
resp, err := http.Get(ts.URL + WellKnownPath)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if ct := resp.Header.Get("Content-Type"); ct != "application/json" {
t.Fatalf("content-type = %s", ct)
}
var got AgentCard
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
t.Fatal(err)
}
if got.Name != "小队" || got.ProtocolVersion != ProtocolVersion {
t.Fatalf("card = %+v", got)
}
}
// ---- 消费侧:发现 + 状态机 ----
func TestFetchCardAndExecuteCompleted(t *testing.T) {
fileBytes := base64.StdEncoding.EncodeToString([]byte("notes 内容"))
artifacts := []Artifact{{
ArtifactID: "art1", Name: "调研结果",
Parts: []Part{
{Kind: "text", Text: "# 调研结论\n\n结论正文,长度足够作为交付物内容。"},
{Kind: "file", File: &FilePart{Name: "notes.md", Bytes: fileBytes, MimeType: "text/markdown"}},
},
}}
card := AgentCard{ProtocolVersion: ProtocolVersion, Name: "远端小队",
URL: "PLACEHOLDER", Version: "1.0.0"}
srv := newFakeServer(t, card, []string{StateWorking, StateWorking, StateCompleted}, artifacts)
ts := httptest.NewServer(srv.handler())
defer ts.Close()
srv.mu.Lock()
srv.card.URL = ts.URL + "/rpc"
srv.mu.Unlock()
// 发现:well-known 路径取卡并校验。
got, err := FetchCard(ts.Client(), ts.URL+WellKnownPath)
if err != nil {
t.Fatal(err)
}
if got.Name != "远端小队" || got.URL != ts.URL+"/rpc" {
t.Fatalf("card = %+v", got)
}
// 执行:submitted/working → completed,工件映射 patches。
var states []string
ex := NewExecutor(got)
ex.Client = ts.Client()
ex.PollInterval = 5 * time.Millisecond
ex.OnState = func(s string) { states = append(states, s) }
res, err := ex.Execute(context.Background(), squad.ExecRequest{
Prompt: "调研主题 X", Targets: []string{"output.md"},
})
if err != nil {
t.Fatal(err)
}
if !res.Success {
t.Fatalf("result = %+v", res)
}
if string(res.Patches["notes.md"]) != "notes 内容" {
t.Fatalf("file part 未落成 patch:%q", res.Patches["notes.md"])
}
if !strings.Contains(string(res.Patches["output.md"]), "调研结论") {
t.Fatalf("文本部件未写入目标:%q", res.Patches["output.md"])
}
joined := strings.Join(states, ",")
if !strings.Contains(joined, StateWorking) || !strings.HasSuffix(joined, StateCompleted) {
t.Fatalf("状态机迁移 = %v", states)
}
srv.mu.Lock()
sends := srv.calls["message/send"]
srv.mu.Unlock()
if sends != 1 {
t.Fatalf("message/send = %d", sends)
}
}
func TestExecuteMapsFailedAndInputRequired(t *testing.T) {
card := AgentCard{ProtocolVersion: ProtocolVersion, Name: "远端", URL: "PLACEHOLDER", Version: "1"}
srv := newFakeServer(t, card, []string{StateWorking, StateFailed}, nil)
srv.statusMsg = "远端执行崩溃"
ts := httptest.NewServer(srv.handler())
defer ts.Close()
srv.mu.Lock()
srv.card.URL = ts.URL + "/rpc"
srv.mu.Unlock()
ex := NewExecutor(card)
ex.BaseURL = ts.URL + "/rpc"
ex.Client = ts.Client()
ex.PollInterval = 5 * time.Millisecond
res, err := ex.Execute(context.Background(), squad.ExecRequest{Prompt: "x", Targets: []string{"output.md"}})
if err != nil {
t.Fatal(err)
}
if res.Success || !strings.Contains(res.ErrorReason, "failed") || !strings.Contains(res.ErrorReason, "远端执行崩溃") {
t.Fatalf("failed 映射 = %+v", res)
}
// input-required:fail-closed(跨进程人工输入不在适配面内)。
srv2 := newFakeServer(t, card, []string{StateInputRequired}, nil)
srv2.statusMsg = "请补充授权"
ts2 := httptest.NewServer(srv2.handler())
defer ts2.Close()
ex2 := &Executor{BaseURL: ts2.URL + "/rpc", Client: ts2.Client(), PollInterval: 5 * time.Millisecond}
res2, err := ex2.Execute(context.Background(), squad.ExecRequest{Prompt: "x", Targets: []string{"output.md"}})
if err != nil {
t.Fatal(err)
}
if res2.Success || !strings.Contains(res2.ErrorReason, "input-required") {
t.Fatalf("input-required 映射 = %+v", res2)
}
}
// 直接回 Message(无任务态)→ 文本落目标。
func TestExecuteDirectMessageResult(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req rpcRequest
_ = json.NewDecoder(r.Body).Decode(&req)
writeRPCResult(w, req.ID, Message{Kind: "message", Role: "agent",
Parts: []Part{{Kind: "text", Text: "直接回答正文"}}})
}))
defer ts.Close()
ex := &Executor{BaseURL: ts.URL, Client: ts.Client()}
res, err := ex.Execute(context.Background(), squad.ExecRequest{Prompt: "x", Targets: []string{"output.md"}})
if err != nil {
t.Fatal(err)
}
if !res.Success || !strings.Contains(string(res.Patches["output.md"]), "直接回答正文") {
t.Fatalf("res = %+v", res)
}
}
// 传输/协议错误 → error(引擎按瞬态评估),不吞成成功。
func TestExecuteProtocolErrors(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeRPCError(w, 1, -32601, "method not found")
}))
defer ts.Close()
ex := &Executor{BaseURL: ts.URL, Client: ts.Client()}
if _, err := ex.Execute(context.Background(), squad.ExecRequest{Prompt: "x"}); err == nil ||
!strings.Contains(err.Error(), "协议错误") {
t.Fatalf("err = %v", err)
}
// ctx 取消传播。
ctx, cancel := context.WithCancel(context.Background())
cancel()
slow := newFakeServer(t, AgentCard{ProtocolVersion: "1.0", Name: "x", URL: ts.URL, Version: "1"},
[]string{StateWorking}, nil)
ts2 := httptest.NewServer(slow.handler())
defer ts2.Close()
ex2 := &Executor{BaseURL: ts2.URL + "/rpc", Client: ts2.Client(), PollInterval: time.Second}
if _, err := ex2.Execute(ctx, squad.ExecRequest{Prompt: "x"}); err == nil {
t.Fatal("取消必须传播")
}
}
// FetchCard fail-closed:缺必备字段拒绝。
func TestFetchCardValidates(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"protocolVersion":"1.0","name":"","url":"","version":""}`))
}))
defer ts.Close()
if _, err := FetchCard(ts.Client(), ts.URL); err == nil || !strings.Contains(err.Error(), "name") {
t.Fatalf("err = %v", err)
}
}
+331
View File
@@ -0,0 +1,331 @@
package a2a
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync/atomic"
"time"
"agent-squad/squad"
)
// executor.go — 远端智能体 Executor 客户端(A2A 任务状态机对齐 ExecResult)。
// 任务状态(A2A 1.0 TaskState)。
const (
StateSubmitted = "submitted"
StateWorking = "working"
StateInputRequired = "input-required"
StateCompleted = "completed"
StateCanceled = "canceled"
StateFailed = "failed"
StateRejected = "rejected"
)
// DefaultPollInterval 是 tasks/get 轮询缺省间隔。
const DefaultPollInterval = 500 * time.Millisecond
// Executor 把远端 A2A 智能体接成 squad.Executor:一次步骤执行 = 一个
// A2A 任务(message/send 发起,tasks/get 轮询到终态)。
type Executor struct {
// BaseURL 远端 JSON-RPC 端点(Agent Card 的 url)。
BaseURL string
// Client HTTP 客户端(nil = http.DefaultClient)。
Client *http.Client
// PollInterval 轮询间隔(0 = DefaultPollInterval)。
PollInterval time.Duration
// OnState 可选状态回调(观测任务状态机迁移)。
OnState func(state string)
callID atomic.Int64
}
// NewExecutor 按 Agent Card 构造远端执行器。
func NewExecutor(card AgentCard) *Executor {
return &Executor{BaseURL: card.URL}
}
// Name 实现 squad.Executor。
func (e *Executor) Name() string { return "a2a" }
// ---- A2A 1.0 报文形态(JSON-RPC 2.0) ----
type rpcRequest struct {
JSONRPC string `json:"jsonrpc"`
ID int64 `json:"id"`
Method string `json:"method"`
Params any `json:"params"`
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
type rpcResponse struct {
JSONRPC string `json:"jsonrpc"`
ID int64 `json:"id"`
Result json.RawMessage `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
}
// Part 是消息/工件的部件(text | file | data)。
type Part struct {
Kind string `json:"kind"`
Text string `json:"text,omitempty"`
File *FilePart `json:"file,omitempty"`
Data *json.RawMessage `json:"data,omitempty"`
}
// FilePart 文件部件:bytes(base64)或 uri 二选一。
type FilePart struct {
Name string `json:"name"`
Bytes string `json:"bytes,omitempty"`
URI string `json:"uri,omitempty"`
MimeType string `json:"mimeType,omitempty"`
}
// Message 是一条 A2A 消息。
type Message struct {
Kind string `json:"kind"`
Role string `json:"role"`
Parts []Part `json:"parts"`
MessageID string `json:"messageId"`
}
// TaskStatus 任务状态。
type TaskStatus struct {
State string `json:"state"`
Message *Message `json:"message,omitempty"`
Timestamp string `json:"timestamp,omitempty"`
}
// Artifact 任务工件。
type Artifact struct {
ArtifactID string `json:"artifactId,omitempty"`
Name string `json:"name,omitempty"`
Parts []Part `json:"parts"`
}
// Task 是 A2A 任务(状态机载体)。
type Task struct {
Kind string `json:"kind"`
ID string `json:"id"`
ContextID string `json:"contextId,omitempty"`
Status TaskStatus `json:"status"`
Artifacts []Artifact `json:"artifacts,omitempty"`
}
// Execute 实现 squad.Executor:发起任务并轮询到终态,映射 ExecResult。
// 传输/协议错误返回 error(引擎按瞬态失败评估);任务级失败
// (failed/canceled/rejected/input-required)返回 Success=false 的结果
// (fail-closed:跨进程人工输入不在本适配面内)。
func (e *Executor) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) {
if strings.TrimSpace(e.BaseURL) == "" {
return nil, fmt.Errorf("a2a: executor 缺 BaseURL")
}
prompt := strings.TrimSpace(req.Prompt)
if prompt == "" {
prompt = strings.TrimSpace(req.Instructions)
}
msg := Message{
Kind: "message", Role: "user",
Parts: []Part{{Kind: "text", Text: prompt}},
MessageID: fmt.Sprintf("msg-%d-%d", time.Now().UnixNano(), e.callID.Add(1)),
}
raw, err := e.call(ctx, "message/send", map[string]any{"message": msg})
if err != nil {
return nil, err
}
// 服务端可直接回 Message(无任务态)或 Task。
var probe struct {
Kind string `json:"kind"`
}
_ = json.Unmarshal(raw, &probe)
if probe.Kind == "message" {
var m Message
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("a2a: message 结果解析失败: %w", err)
}
return e.resultFromParts(m.Parts, req.Targets), nil
}
var task Task
if err := json.Unmarshal(raw, &task); err != nil {
return nil, fmt.Errorf("a2a: task 结果解析失败: %w", err)
}
e.observe(task.Status.State)
if shouldStop(task.Status.State) {
return e.resultFromTask(task, req.Targets), nil
}
// 轮询任务状态机直至终态(ctx 取消传播)。
interval := e.PollInterval
if interval <= 0 {
interval = DefaultPollInterval
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-ticker.C:
}
raw, err := e.call(ctx, "tasks/get", map[string]any{"id": task.ID})
if err != nil {
return nil, err
}
var next Task
if err := json.Unmarshal(raw, &next); err != nil {
return nil, fmt.Errorf("a2a: tasks/get 解析失败: %w", err)
}
if next.Status.State != task.Status.State {
task = next
e.observe(task.Status.State)
} else {
task = next
}
if shouldStop(task.Status.State) {
return e.resultFromTask(task, req.Targets), nil
}
}
}
// call 发一次 JSON-RPC 调用并取 result。
func (e *Executor) call(ctx context.Context, method string, params any) (json.RawMessage, error) {
client := e.Client
if client == nil {
client = http.DefaultClient
}
body, _ := json.Marshal(rpcRequest{
JSONRPC: "2.0", ID: e.callID.Add(1), Method: method, Params: params,
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, e.BaseURL, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("a2a: %s 传输失败: %w", method, err)
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err != nil {
return nil, fmt.Errorf("a2a: %s 读取响应失败: %w", method, err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("a2a: %s 返回 HTTP %d", method, resp.StatusCode)
}
var env rpcResponse
if err := json.Unmarshal(data, &env); err != nil {
return nil, fmt.Errorf("a2a: %s 响应不是合法 JSON-RPC: %w", method, err)
}
if env.Error != nil {
return nil, fmt.Errorf("a2a: %s 协议错误 %d: %s", method, env.Error.Code, env.Error.Message)
}
return env.Result, nil
}
func (e *Executor) observe(state string) {
if e.OnState != nil {
e.OnState(state)
}
}
func isTerminal(state string) bool {
switch state {
case StateCompleted, StateFailed, StateCanceled, StateRejected:
return true
}
return false
}
// shouldStop 轮询停止条件:真终态,或 input-required(跨进程人工输入不在
// 本适配面内,fail-closed 交引擎语义处理,不死等)。
func shouldStop(state string) bool {
return isTerminal(state) || state == StateInputRequired
}
// resultFromTask 终态任务 → ExecResult。
func (e *Executor) resultFromTask(task Task, targets []string) *squad.ExecResult {
switch task.Status.State {
case StateCompleted:
var parts []Part
for _, art := range task.Artifacts {
parts = append(parts, art.Parts...)
}
return e.resultFromParts(parts, targets)
case StateInputRequired:
return &squad.ExecResult{Success: false,
ErrorReason: "a2a: 远端要求人工输入(input-required):" + statusText(task.Status)}
case StateCanceled:
return &squad.ExecResult{Success: false,
ErrorReason: "a2a: 远端任务被取消(canceled):" + statusText(task.Status)}
case StateRejected:
return &squad.ExecResult{Success: false,
ErrorReason: "a2a: 远端拒绝任务(rejected):" + statusText(task.Status)}
default: // failed 及未知终态一律 fail-closed
return &squad.ExecResult{Success: false,
ErrorReason: "a2a: 远端任务失败(" + task.Status.State + "):" + statusText(task.Status)}
}
}
// resultFromParts 部件流 → patches:FilePart(bytes) 按名落 patch;
// 文本部件拼接后写入全部声明目标(无文件工件时的交付物兜底,
// 与其他后端「正文写目标文件」契约一致)。
func (e *Executor) resultFromParts(parts []Part, targets []string) *squad.ExecResult {
patches := map[string][]byte{}
var text strings.Builder
for _, p := range parts {
switch p.Kind {
case "text":
if text.Len() > 0 {
text.WriteString("\n")
}
text.WriteString(p.Text)
case "file":
if p.File == nil || p.File.Name == "" {
continue
}
data, err := base64.StdEncoding.DecodeString(p.File.Bytes)
if err != nil {
continue // uri 形态与坏 base64 不落 patch(消费侧最小面)
}
patches[p.File.Name] = data
}
}
body := strings.TrimSpace(text.String())
if len(targets) == 0 {
targets = []string{"output.md"}
}
if body != "" {
for _, t := range targets {
if _, ok := patches[t]; !ok {
patches[t] = []byte(body + "\n")
}
}
}
if len(patches) == 0 {
return &squad.ExecResult{Success: false, ErrorReason: "a2a: 完成任务无任何工件"}
}
return &squad.ExecResult{Success: true, Patches: patches, Logs: body}
}
func statusText(st TaskStatus) string {
if st.Message == nil {
return ""
}
var b strings.Builder
for _, p := range st.Message.Parts {
if p.Kind == "text" {
b.WriteString(p.Text)
}
}
return b.String()
}
+11
View File
@@ -0,0 +1,11 @@
module agent-squad/adapters/a2a
go 1.27
require agent-squad v0.0.0
require error-code-system v0.0.0 // indirect
replace agent-squad => ../..
replace error-code-system => ../../deps/error-code-system
+41
View File
@@ -0,0 +1,41 @@
package squad
import (
"context"
"strings"
"testing"
)
// brief_test.go — Q3 WP1a:StepSpec.Brief 渲染进 TASK.md「任务指令」节。
func TestStepBriefRendersIntoTaskFile(t *testing.T) {
rec := &promptRecordingBackend{prompts: map[string][]string{}}
engine := newRecordingEngine(t, rec, []StepSpec{
{ID: "s1", Role: "researcher", Title: "起草", Targets: []string{"output.md"},
Brief: "写一份关于「太空电梯」的简报,篇幅:短。\n必须包含单独一行「核对标记:P4」。"},
})
res, err := engine.Run(context.Background(), Goal{Text: "带任务指令"}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status = %s err = %v", res.Status, res.Error)
}
var prompt string
for _, p := range rec.prompts["01_researcher"] {
prompt += p
}
if !strings.Contains(prompt, "## 任务指令") {
t.Fatalf("TASK.md 缺任务指令节:%q", prompt)
}
if !strings.Contains(prompt, "核对标记:P4") {
t.Fatalf("任务指令正文缺失:%q", prompt)
}
// 指令节在期望产出之后、上下文表之前。
iOut := strings.Index(prompt, "期望产出")
iBrief := strings.Index(prompt, "## 任务指令")
iCtx := strings.Index(prompt, "## 任务上下文资源")
if !(iOut < iBrief && iBrief < iCtx) {
t.Fatalf("任务指令节位置错误:%d/%d/%d", iOut, iBrief, iCtx)
}
}
+14
View File
@@ -124,6 +124,9 @@ type RunOptions struct {
ReviewVerifyRole string `json:"review_verify_role,omitempty"`
// ReviewTarget 被评审材料(默认取 context/goal.md)。
ReviewTarget string `json:"review_target,omitempty"`
// TaskBoardLeaseSeconds task_board 任务租约时限(秒;0 = 默认 600)。
// 孤儿租约(claimed 且过期)在驱动重入时再武装回 open。
TaskBoardLeaseSeconds int `json:"task_board_lease_seconds,omitempty"`
}
// SquadEngine 小队引擎门面:Run / Respond / Resume / Inspect / ListRuns。
@@ -260,6 +263,11 @@ func (e *SquadEngine) Run(ctx context.Context, goal Goal, runOpts RunOptions) (*
}
if runOpts.Topology != "" {
meta.Metadata["topology"] = runOpts.Topology
// leader 拓扑映射到委派循环须发生在计划确认之前(确认门按
// mode=leader 豁免);后置映射会让 leader 运行误触计划确认。
if runOpts.Topology == TopologyLeader && meta.Metadata["mode"] == "" {
meta.Metadata["mode"] = "leader"
}
}
if !runOpts.Deadline.IsZero() {
// RFC3339Nano:秒级 RFC3339 会截断亚秒精度,短截止会被立刻判超期。
@@ -283,6 +291,9 @@ func (e *SquadEngine) Run(ctx context.Context, goal Goal, runOpts RunOptions) (*
if runOpts.ReviewTarget != "" {
meta.Metadata["review_target"] = runOpts.ReviewTarget
}
if runOpts.TaskBoardLeaseSeconds > 0 {
meta.Metadata["task_board_lease_seconds"] = fmt.Sprintf("%d", runOpts.TaskBoardLeaseSeconds)
}
e.deps.Sink.Emit(Event{Type: EvRunStarted, RunID: runID, Text: goal.Text, At: time.Now()})
// 意图闸门(可选,入口一次裁决;显式 TaskKey 优先,不重复过闸)。
@@ -564,6 +575,9 @@ func (e *SquadEngine) dispatchTopologyDriver(ctx context.Context, st *RunState)
case TopologyGroupChat:
out, err := e.runGroupChatDriver(ctx, st)
return out, true, err
case TopologyTaskBoard:
out, err := e.runTaskBoardDriver(ctx, st)
return out, true, err
default:
return e.driverFail(st, fmt.Sprintf("topology: %q 驱动未实现(fail-closed)", topo)), true, nil
}
+4
View File
@@ -128,6 +128,10 @@ type StepSpec struct {
Branches []Branch `json:"branches,omitempty"`
// Handoff 非空 = 完成即把最终答案所有权移交给该角色(Q2 handoff 原语)。
Handoff string `json:"handoff,omitempty"`
// Brief 编译期任务指令(Q3 编译器):非空时渲染进 TASK.md 的
// 「任务指令」节,位于标题/产出之后、上下文表之前。图步的富文本任务书
// 载体(此前只有 leader 委派有 briefing)。
Brief string `json:"brief,omitempty"`
}
// LoopSpec 是循环步的预算与停机条件(Q2)。
+6
View File
@@ -33,6 +33,9 @@ type RunState struct {
// Handoffs 所有权转移记录(Q2 handoff 原语):随 RunState 持久化;
// 目标角色的下一步骤一次性接收移交上下文,finalize 取链尾产出。
Handoffs []HandoffRecord `json:"handoffs,omitempty"`
// Board task_board 拓扑的队列状态(Q2):claim/lease/complete 三原语,
// 随 RunState 持久化;孤儿租约在驱动重入时再武装。
Board []BoardTask `json:"board,omitempty"`
// Usage 按角色累计的 token 用量。
Usage map[string]*Usage `json:"usage,omitempty"`
// Leader leader-delegation 模式的循环状态(workspace 内 leader.json 持久化)。
@@ -100,6 +103,7 @@ func (st *RunState) persist() error {
Clarifications: st.Clarifications,
Mailbox: st.Mailbox,
Handoffs: st.Handoffs,
Board: st.Board,
})
}
@@ -110,6 +114,7 @@ type diskRunState struct {
Clarifications []string `json:"clarifications,omitempty"`
Mailbox []MailMessage `json:"mailbox,omitempty"`
Handoffs []HandoffRecord `json:"handoffs,omitempty"`
Board []BoardTask `json:"board,omitempty"`
}
// Workflow 返回计划的编排清单(简洁访问器)。
@@ -166,6 +171,7 @@ func loadRunState(wsDir string) (*RunState, error) {
st.Clarifications = disk.Clarifications
st.Mailbox = disk.Mailbox
st.Handoffs = disk.Handoffs
st.Board = disk.Board
} else if !os.IsNotExist(err) {
return nil, err
}
+308
View File
@@ -0,0 +1,308 @@
package squad
import (
"context"
"fmt"
"strings"
"time"
)
// taskboard.go — task_board 拓扑驱动(Q2 WP4)。
// 形态:计划步骤 = 板上任务(Needs = 依赖门);执行语义是队列化的
// claim/lease/complete 三原语——工作者认领任务(带租约时限)、执行、完成;
// 进程死亡留下的孤儿租约(claimed 且租约过期)在驱动重入时再武装回 open
// (Attempts 保留,超限由步骤级重试护栏兜底)。全部任务收口后走标准
// finalize(交付物/质检/降级判定与图拓扑同一收口面)。
// 挂起(HITL)安全:claimed 且步骤未完的任务在重入时续跑,不重复认领。
func init() {
_ = RegisterTopologyDriver(TopologyTaskBoard)
}
// DefaultTaskLeaseSeconds 任务租约缺省时限(秒)。
const DefaultTaskLeaseSeconds = 600
// boardSkipReason 依赖门无法满足(上游 failed/skipped)时的跳过原因。
const boardSkipReason = "task_board: 依赖未满足(上游失败或跳过)"
// BoardTask 是任务板上一条任务的队列状态。
type BoardTask struct {
ID string `json:"id"`
Status string `json:"status"` // open / claimed / done / failed / skipped
ClaimedBy string `json:"claimed_by,omitempty"`
// LeaseExpires 认领租约到期时刻;过期未 complete = 孤儿租约,重入再武装。
LeaseExpires time.Time `json:"lease_expires,omitempty"`
// Attempts 认领次数(孤儿再武装累加;执行重试由步骤级护栏负责)。
Attempts int `json:"attempts,omitempty"`
}
// runTaskBoardDriver task_board 拓扑入口(幂等可恢复)。
func (e *SquadEngine) runTaskBoardDriver(ctx context.Context, st *RunState) (*RunResult, error) {
drv := &flowDriver{opts: e.deps}
e.initBoard(st)
e.rearmOrphanLeases(st)
leaseTTL := DefaultTaskLeaseSeconds
if v := st.Meta.Metadata["task_board_lease_seconds"]; v != "" {
if n := parseIntDefault(v, DefaultTaskLeaseSeconds); n > 0 {
leaseTTL = n
}
}
for {
idx, held := e.boardHeldTask(st)
if !held {
var ok bool
idx, ok = e.boardClaimNext(st, leaseTTL)
if !ok {
// 无可认领:把依赖已死(上游 failed/skipped)的 open 任务级联
// 置 skipped;再无变化则收口。
if !e.boardSkipDeadlocked(st) {
break
}
continue
}
}
out, gate, err := e.boardRunTask(ctx, drv, st, idx)
if err != nil {
return nil, err
}
if gate != nil {
if out == nil {
out = heldResult(st, gate)
}
return out, nil
}
if out != nil { // 关键任务失败:fail-closed 传播(failStep 已置运行失败)
return out, nil
}
e.boardSettleTask(st, idx)
}
return e.boardFinish(ctx, drv, st)
}
// initBoard 首次进入时从计划步骤派生任务板(恢复重入直读持久化状态)。
func (e *SquadEngine) initBoard(st *RunState) {
st.mu.Lock()
defer st.mu.Unlock()
if len(st.Board) > 0 {
return
}
for _, s := range st.Plan.Workflow.Steps {
status := "open"
switch s.Status {
case "completed":
status = "done"
case "failed":
status = "failed"
case "skipped":
status = "skipped"
}
st.Board = append(st.Board, BoardTask{ID: s.ID, Status: status})
}
_ = st.save()
}
// rearmOrphanLeases 孤儿租约再武装:claimed 且租约过期 → open(进程死亡
// 恢复路径;Attempts 保留作观测)。
func (e *SquadEngine) rearmOrphanLeases(st *RunState) {
now := time.Now()
st.mu.Lock()
var rearmed []string
for i := range st.Board {
t := &st.Board[i]
if t.Status == "claimed" && !t.LeaseExpires.IsZero() && t.LeaseExpires.Before(now) {
t.Status = "open"
t.ClaimedBy = ""
rearmed = append(rearmed, t.ID)
}
}
changed := len(rearmed) > 0
if changed {
_ = st.save()
}
st.mu.Unlock()
if changed {
(&flowDriver{opts: e.deps}).emit(st, Event{Type: EvPhase,
Text: fmt.Sprintf("task_board:孤儿租约再武装 %s", strings.Join(rearmed, ","))})
}
}
// boardHeldTask 返回 claimed 且步骤未完的任务(HITL 挂起/中断重入的续跑点)。
func (e *SquadEngine) boardHeldTask(st *RunState) (int, bool) {
for ti := range st.Board {
t := &st.Board[ti]
if t.Status != "claimed" {
continue
}
if idx, s := e.boardStep(st, t.ID); idx >= 0 && (s.Status == "pending" || s.Status == "running") {
return idx, true
}
}
return -1, false
}
// boardClaimNext 认领下一个可执行任务(open 且依赖门全 done),返回步骤下标。
func (e *SquadEngine) boardClaimNext(st *RunState, leaseTTL int) (int, bool) {
st.mu.Lock()
defer st.mu.Unlock()
for ti := range st.Board {
t := &st.Board[ti]
if t.Status != "open" {
continue
}
if !e.boardDepsDoneLocked(st, t.ID) {
continue
}
idx, s := e.boardStepLocked(st, t.ID)
if idx < 0 {
continue
}
t.Status = "claimed"
t.ClaimedBy = s.Role
t.Attempts++
t.LeaseExpires = time.Now().Add(time.Duration(leaseTTL) * time.Second)
_ = st.save()
(&flowDriver{opts: e.deps}).emit(st, Event{Type: EvPhase, StepID: t.ID, Role: s.Role,
Text: fmt.Sprintf("task_board:%s 认领 %s(租约 %ds,第 %d 次)", s.Role, t.ID, leaseTTL, t.Attempts)})
return idx, true
}
return -1, false
}
// boardSkipDeadlocked 把依赖已死(上游 failed/skipped)的 open 任务级联置
// skipped;返回是否有变化(有则继续循环级联)。
func (e *SquadEngine) boardSkipDeadlocked(st *RunState) bool {
st.mu.Lock()
changed := false
for ti := range st.Board {
t := &st.Board[ti]
if t.Status != "open" {
continue
}
if e.boardDepsDeadLocked(st, t.ID) {
t.Status = "skipped"
if idx, s := e.boardStepLocked(st, t.ID); idx >= 0 {
s.Status = "skipped"
s.Error = boardSkipReason
st.Plan.Workflow.Steps[idx] = *s
}
changed = true
}
}
if changed {
_ = st.save()
}
st.mu.Unlock()
return changed
}
// boardRunTask 执行已认领任务:过门 → runOneStep。
func (e *SquadEngine) boardRunTask(ctx context.Context, drv *flowDriver, st *RunState, idx int) (*RunResult, *HitlGate, error) {
out, gate, err := drv.gateBeforeRun(ctx, st, idx)
if err != nil || gate != nil || out != nil {
return out, gate, err
}
return drv.runOneStep(ctx, st, idx)
}
// boardSettleTask 任务结算:步骤 completed → done;failed(非关键降级)→ failed。
func (e *SquadEngine) boardSettleTask(st *RunState, idx int) {
step := st.Plan.Workflow.Steps[idx]
status := "done"
if step.Status == "failed" {
status = "failed"
}
st.mu.Lock()
for i := range st.Board {
if st.Board[i].ID == step.ID {
st.Board[i].Status = status
st.Board[i].LeaseExpires = time.Time{}
}
}
_ = st.save()
st.mu.Unlock()
(&flowDriver{opts: e.deps}).emit(st, Event{Type: EvPhase, StepID: step.ID, Role: step.Role,
Text: fmt.Sprintf("task_board:%s → %s", step.ID, status)})
}
// boardFinish 全部任务收口后走标准 finalize(交付物/质检/降级与图拓扑同面)。
// 仍有 open/claimed 任务 = 依赖不可满足,fail-closed 不静默收口。
func (e *SquadEngine) boardFinish(ctx context.Context, drv *flowDriver, st *RunState) (*RunResult, error) {
done, total := 0, len(st.Board)
for _, t := range st.Board {
switch t.Status {
case "done":
done++
case "open", "claimed":
return e.driverFail(st, fmt.Sprintf("task_board: 任务 %s 无法收口(依赖不可满足)", t.ID)), nil
}
}
drv.emit(st, Event{Type: EvPhase, Text: fmt.Sprintf("task_board:收口(done %d/%d)", done, total)})
out, gate, err := drv.finalize(ctx, st)
if err != nil {
return nil, err
}
if gate != nil {
return heldResult(st, gate), nil
}
return out, nil
}
// boardStep 按任务 ID 找步骤(只读副本)。
func (e *SquadEngine) boardStep(st *RunState, id string) (int, *StepSpec) {
st.mu.Lock()
defer st.mu.Unlock()
return e.boardStepLocked(st, id)
}
// boardStepLocked 按任务 ID 找步骤(调用方持锁)。返回下标与副本指针。
func (e *SquadEngine) boardStepLocked(st *RunState, id string) (int, *StepSpec) {
for i := range st.Plan.Workflow.Steps {
if st.Plan.Workflow.Steps[i].ID == id {
s := st.Plan.Workflow.Steps[i]
return i, &s
}
}
return -1, nil
}
// boardDepsDoneLocked 依赖门:Needs 全部 done(调用方持锁)。
func (e *SquadEngine) boardDepsDoneLocked(st *RunState, id string) bool {
needs := e.boardNeedsLocked(st, id)
for _, dep := range needs {
found := false
for i := range st.Board {
if st.Board[i].ID == dep {
found = true
if st.Board[i].Status != "done" {
return false
}
}
}
if !found {
return false // 依赖不在板上 = fail-closed 不认领
}
}
return true
}
// boardDepsDeadLocked 依赖已死:任一 Need 为 failed/skipped(级联跳过依据)。
func (e *SquadEngine) boardDepsDeadLocked(st *RunState, id string) bool {
for _, dep := range e.boardNeedsLocked(st, id) {
for i := range st.Board {
if st.Board[i].ID == dep && (st.Board[i].Status == "failed" || st.Board[i].Status == "skipped") {
return true
}
}
}
return false
}
func (e *SquadEngine) boardNeedsLocked(st *RunState, id string) []string {
for i := range st.Plan.Workflow.Steps {
if st.Plan.Workflow.Steps[i].ID == id {
return st.Plan.Workflow.Steps[i].Needs
}
}
return nil
}
+2 -2
View File
@@ -384,8 +384,8 @@ func TestTopologyValidationFailsClosed(t *testing.T) {
{"未注册名", "bogus_topology", "未知拓扑"},
{"primitive 单跑", TopologyMailbox, "跨拓扑原语"},
{"primitive 单跑 handoff", TopologyHandoff, "跨拓扑原语"},
// review/group_chat 驱动已于 WP3 点亮;task_board 属 WP4,仍未点亮。
{"driver 未点亮 task_board", TopologyTaskBoard, "驱动未注册"},
// review/group_chat(WP3)与 task_board(WP4)驱动均已点亮;
// 12/12 受理面由 TestTopologyConformance_allTwelve 汇总钉住。
}
for _, tc := range cases {
engine := newTopologyEngine(t, mockScriptPlanner(
+7
View File
@@ -189,6 +189,13 @@ func (e *WorkspaceEngine) writeTaskFile(subDir string, step *StepSpec, role Role
fmt.Fprintf(&b, "- 角色:%s(%s)\n", role.Name, role.Role)
fmt.Fprintf(&b, "- 期望产出:%v\n\n", step.Targets)
// 编译期任务指令(Q3):编译器渲染好的富文本任务书,权威仅次于目标。
if strings.TrimSpace(step.Brief) != "" {
b.WriteString("## 任务指令\n\n")
b.WriteString(strings.TrimSpace(step.Brief))
b.WriteString("\n\n")
}
b.WriteString("## 任务上下文资源与权威等级(已快照到本目录,只引用本地路径)\n\n")
b.WriteString("| 资源路径 | 权威等级 | 用途说明 | 使用与引用指导 |\n|---|---|---|---|\n")
b.WriteString("| `context/goal.md` | **authoritative** | 运行目标 | 不可推翻 |\n")
+355
View File
@@ -0,0 +1,355 @@
package squad
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// wp4_conformance_test.go — Q2 WP4 验收:task_board 拓扑(claim/lease/complete
// 原语 + 依赖门 + 孤儿租约再武装)与全 12 拓扑 conformance 汇总跑。
// ---- task_board ----
// 菱形依赖 a→(b,c)→d:认领序尊重依赖门,全部 done,标准 finalize 收口。
func TestTopologyConformance_taskBoard(t *testing.T) {
be := &iterBackend{calls: map[string]int{}}
sink := &collectSink{}
engine := newTopologyEngine(t, mockScriptPlanner(
StepSpec{ID: "a", Role: "researcher", Title: "任务a", Targets: []string{"a.md"}},
StepSpec{ID: "b", Role: "writer", Title: "任务b", Needs: []string{"a"}, Targets: []string{"b.md"}},
StepSpec{ID: "c", Role: "reviewer", Title: "任务c", Needs: []string{"a"}, Targets: []string{"c.md"}},
StepSpec{ID: "d", Role: "writer", Title: "任务d", Needs: []string{"b", "c"}, Targets: []string{"output.md"}},
), be, sink)
res, err := engine.Run(context.Background(), Goal{Text: "任务板"}, RunOptions{Topology: TopologyTaskBoard})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status = %s err = %v", res.Status, res.Error)
}
// 认领序尊重依赖门:a 先于 b/c,d 最后(板上按步骤序认领,确定性)。
var order []string
for _, ev := range sink.snapshot() {
if strings.Contains(ev.Text, "认领") {
order = append(order, ev.StepID)
}
}
want := []string{"a", "b", "c", "d"}
if strings.Join(order, ",") != strings.Join(want, ",") {
t.Fatalf("认领序 = %v,期望 %v", order, want)
}
st, _ := engine.loadRun(engine.runDir(res.RunID))
for _, task := range st.Board {
if task.Status != "done" {
t.Fatalf("任务 %s = %s,期望 done", task.ID, task.Status)
}
}
if sink.countText("task_board:收口(done 4/4)") != 1 {
t.Fatal("缺收口事件")
}
}
// 孤儿租约再武装:崩溃(ctx 取消)留下 claimed+过期租约 → Resume 重入
// 再武装回 open、重新认领(Attempts=2)并跑完。
func TestTaskBoardOrphanLeaseRearms(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
crashed := false
be := &iterBackend{calls: map[string]int{}, content: func(key string, _ int, _ string) []byte {
if key == "02_writer" && !crashed {
crashed = true
cancel() // 模拟进程死亡:runOneStep 在 ctx 检查处退出,步骤回到 pending
}
return nil
}}
sink := &collectSink{}
engine := newTopologyEngine(t, mockScriptPlanner(
StepSpec{ID: "t1", Role: "researcher", Title: "一", Targets: []string{"a.md"}},
StepSpec{ID: "t2", Role: "writer", Title: "二", Needs: []string{"t1"}, Targets: []string{"output.md"}},
), be, sink)
res0, err := engine.Run(ctx, Goal{Text: "任务板"}, RunOptions{Topology: TopologyTaskBoard})
if err != nil {
t.Fatal(err)
}
if res0.Status != RunCancelled {
t.Fatalf("取消后应为 RunCancelled:%s", res0.Status)
}
// 崩溃现场:t2 claimed、租约未过期;步骤在盘上是 running(setStep 只改
// 内存,取消路径的 pending 未落盘)——进程死亡即此形态。测试把租约回拨
// 成过期(孤儿)并把步骤置回 pending(模拟重启前的一致化),再走 Resume。
st, err := engine.loadRun(engine.runDir(res0.RunID))
if err != nil {
t.Fatal(err)
}
found := false
for i := range st.Board {
if st.Board[i].ID == "t2" {
found = true
if st.Board[i].Status != "claimed" || st.Board[i].Attempts != 1 {
t.Fatalf("崩溃现场 t2 = %+v", st.Board[i])
}
st.Board[i].LeaseExpires = time.Now().Add(-time.Hour) // 租约过期 = 孤儿
}
}
if !found {
t.Fatal("板上无 t2")
}
for i := range st.Plan.Workflow.Steps {
if st.Plan.Workflow.Steps[i].ID == "t2" && st.Plan.Workflow.Steps[i].Status == "running" {
st.Plan.Workflow.Steps[i].Status = "pending"
}
}
if err := st.save(); err != nil {
t.Fatal(err)
}
// 重入:孤儿租约再武装 → 重新认领 → 跑完收口。
res, err := engine.Resume(context.Background(), st.Meta.ID)
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("恢复后应完成:%s %v", res.Status, res.Error)
}
if sink.countText("孤儿租约再武装 t2") != 1 {
t.Fatal("缺孤儿租约再武装事件")
}
st2, _ := engine.loadRun(engine.runDir(res.RunID))
for _, task := range st2.Board {
if task.ID == "t2" && task.Attempts != 2 {
t.Fatalf("再武装后 Attempts = %d,期望 2", task.Attempts)
}
}
}
// 依赖门级联:非关键任务失败 → 下游 open 任务级联 skipped → 降级收口。
func TestTaskBoardDependencyGateCascade(t *testing.T) {
be := &iterBackend{calls: map[string]int{}, content: func(key string, _ int, _ string) []byte {
return nil
}}
failBe := &failOnceBackend{inner: be, failKey: "02_writer"}
crit := false
sink := &collectSink{}
engine := newTopologyEngine(t, mockScriptPlanner(
StepSpec{ID: "t0", Role: "researcher", Title: "零", Targets: []string{"output.md"}},
StepSpec{ID: "t1", Role: "writer", Title: "一", Needs: []string{"t0"}, Targets: []string{"b.md"}, Critical: &crit},
StepSpec{ID: "t2", Role: "reviewer", Title: "二", Needs: []string{"t1"}, Targets: []string{"c.md"}},
), failBe, sink)
res, err := engine.Run(context.Background(), Goal{Text: "任务板"}, RunOptions{Topology: TopologyTaskBoard})
if err != nil {
t.Fatal(err)
}
if res.Status != RunDegraded {
t.Fatalf("非关键失败+级联跳过应降级收口:%s %v", res.Status, res.Error)
}
st, _ := engine.loadRun(engine.runDir(res.RunID))
byID := map[string]BoardTask{}
for _, task := range st.Board {
byID[task.ID] = task
}
if byID["t0"].Status != "done" || byID["t1"].Status != "failed" || byID["t2"].Status != "skipped" {
t.Fatalf("板状态 = %+v", byID)
}
for _, s := range st.Plan.Workflow.Steps {
if s.ID == "t2" && (s.Status != "skipped" || s.Error != boardSkipReason) {
t.Fatalf("t2 = %s %q", s.Status, s.Error)
}
}
}
// failOnceBackend 对指定目录恒定失败(非瞬态),其余委托 inner。
type failOnceBackend struct {
inner Executor
failKey string
}
func (b *failOnceBackend) Name() string { return b.inner.Name() }
func (b *failOnceBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
key := filepath.Base(filepath.Clean(req.WorkDir))
if key == b.failKey {
return &ExecResult{Success: false, ErrorReason: "fatal: 拒绝执行"}, nil
}
return b.inner.Execute(ctx, req)
}
// ---- 12/12 汇总 ----
// TestTopologyConformance_allTwelve 全 12 内置拓扑汇总跑:每个拓扑名一次
// 脚本化最小运行(零模型零网络)。graph/mode/driver 十种断言完成收口;
// handoff/mailbox 两个 primitive 断言 fail-closed 拒绝单跑(其原语能力分别
// 由 TestTopologyConformance_handoff 与 WP2 mailbox 测试钉住)。
func TestTopologyConformance_allTwelve(t *testing.T) {
specs := BuiltinTopologies()
if len(specs) != 12 {
t.Fatalf("内置拓扑 = %d,期望 12", len(specs))
}
for _, spec := range specs {
spec := spec
t.Run(spec.Name, func(t *testing.T) {
switch spec.Name {
case TopologySequential:
runAgg(t, spec.Name, mockScriptPlanner(
StepSpec{ID: "s1", Role: "researcher", Title: "一", Targets: []string{"output.md"}},
), &iterBackend{calls: map[string]int{}}, RunOptions{}, RunCompleted)
case TopologyParallel:
runAgg(t, spec.Name, mockScriptPlanner(
StepSpec{ID: "s1", Role: "researcher", Title: "甲", Parallel: true, Targets: []string{"a.md"}},
StepSpec{ID: "s2", Role: "writer", Title: "乙", Parallel: true, Targets: []string{"output.md"}},
), &iterBackend{calls: map[string]int{}}, RunOptions{}, RunCompleted)
case TopologyLoop:
runAgg(t, spec.Name, mockScriptPlanner(
StepSpec{ID: "s1", Role: "researcher", Title: "循环体", Targets: []string{"output.md", "fields.json"},
Loop: &LoopSpec{MaxIterations: 2, UntilField: "done", UntilEquals: "yes"}},
), &iterBackend{calls: map[string]int{}, content: func(_ string, _ int, target string) []byte {
if target == "fields.json" {
return fieldsJSON(map[string]string{"done": "no"})
}
return nil
}}, RunOptions{}, RunCompleted)
case TopologyRouter:
runAgg(t, spec.Name, mockScriptPlanner(routerSteps()...),
&iterBackend{calls: map[string]int{}, content: func(_ string, _ int, target string) []byte {
if target == "fields.json" {
return fieldsJSON(map[string]string{"route": "fast"})
}
return nil
}}, RunOptions{}, RunCompleted)
case TopologyEvaluatorOptimizer:
runAgg(t, spec.Name, mockScriptPlanner(
StepSpec{ID: "draft", Role: "writer", Title: "起草", Targets: []string{"output.md"}},
StepSpec{ID: "eval", Role: "reviewer", Title: "评审", Targets: []string{"verdict.md", "fields.json"},
Loop: &LoopSpec{MaxIterations: 4, UntilField: "verdict", UntilEquals: "pass", Body: []string{"draft"}}},
), &iterBackend{calls: map[string]int{}, content: func(_ string, iter int, target string) []byte {
if target == "fields.json" {
if iter >= 3 {
return fieldsJSON(map[string]string{"verdict": "pass"})
}
return fieldsJSON(map[string]string{"verdict": "fail"})
}
return nil
}}, RunOptions{}, RunCompleted)
case TopologyLongRunning:
runAgg(t, spec.Name, mockScriptPlanner(
StepSpec{ID: "s1", Role: "researcher", Title: "一", Targets: []string{"output.md"}},
), &iterBackend{calls: map[string]int{}},
RunOptions{Deadline: time.Now().Add(5 * time.Second)}, RunCompleted)
case TopologyLeader:
b := &leaderScriptBackend{
decisions: []string{delegateJSON("writer", "撰写简报"), closeJSON},
script: map[string]map[string]string{"01_writer": {"output.md": goodReport}},
}
engine := newLeaderEngine(t, b)
res, err := engine.Run(context.Background(), Goal{Text: "汇总"}, RunOptions{Topology: TopologyLeader})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status = %s err = %v", res.Status, res.Error)
}
case TopologyReview:
planner := mockScriptPlanner(
StepSpec{ID: "f1", Role: "researcher", Title: "评审", Targets: []string{"findings.json"}},
)
planner.Agents = []RoleSpec{
{Role: "researcher", Name: "评审", Backend: "agg-review"},
{Role: "verifier", Name: "核实", Backend: "agg-review"},
}
engine := newAggEngine(t, planner, &aggReviewBackend{}, []RoleSpec{
{Role: "researcher", Name: "评审", Backend: "agg-review"},
{Role: "verifier", Name: "核实", Backend: "agg-review"},
})
res, err := engine.Run(context.Background(), Goal{Text: "评审"},
RunOptions{Topology: TopologyReview, ReviewTarget: "材料"})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status = %s err = %v", res.Status, res.Error)
}
data, _ := os.ReadFile(filepath.Join(res.WorkDir, "output.md"))
if !strings.Contains(string(data), "已丢弃 0 条未验证") || !strings.Contains(string(data), "样例问题") {
t.Fatalf("报告 = %s", data)
}
case TopologyGroupChat:
engine := newAggEngine(t, mockScriptPlanner(
StepSpec{ID: "p1", Role: "researcher", Title: "甲"},
StepSpec{ID: "p2", Role: "writer", Title: "乙"},
), &gcScriptBackend{}, []RoleSpec{
{Role: "researcher", Name: "甲", Backend: "gc-script"},
{Role: "writer", Name: "乙", Backend: "gc-script"},
})
res, err := engine.Run(context.Background(), Goal{Text: "群聊"},
RunOptions{Topology: TopologyGroupChat, GroupChatMaxTurns: 2})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status = %s err = %v", res.Status, res.Error)
}
case TopologyTaskBoard:
runAgg(t, spec.Name, mockScriptPlanner(
StepSpec{ID: "t1", Role: "researcher", Title: "一", Targets: []string{"a.md"}},
StepSpec{ID: "t2", Role: "writer", Title: "二", Needs: []string{"t1"}, Targets: []string{"output.md"}},
), &iterBackend{calls: map[string]int{}}, RunOptions{}, RunCompleted)
case TopologyHandoff, TopologyMailbox:
// primitive:不可单独作为运行拓扑(fail-closed);原语能力由
// 专项 conformance 钉住(handoff/mailbox 测试)。
runAgg(t, spec.Name, mockScriptPlanner(
StepSpec{ID: "s1", Role: "researcher", Title: "一", Targets: []string{"output.md"}},
), &iterBackend{calls: map[string]int{}}, RunOptions{}, RunFailed)
default:
t.Fatalf("未覆盖的拓扑 %q", spec.Name)
}
})
}
}
// runAgg 汇总跑的最小引擎:graph 类拓扑共用 iter 三角色装配。
func runAgg(t *testing.T, topo string, planner Planner, be Executor, opts RunOptions, want RunStatus) {
t.Helper()
opts.Topology = topo
engine := newTopologyEngine(t, planner, be, nil)
res, err := engine.Run(context.Background(), Goal{Text: "汇总跑 " + topo}, opts)
if err != nil {
t.Fatal(err)
}
if res.Status != want {
t.Fatalf("status = %s err = %q,期望 %s", res.Status, res.Error, want)
}
}
// aggReviewBackend 汇总跑用的最小 review 后端:finder 出一条发现,verify 判 PASS。
type aggReviewBackend struct{}
func (b *aggReviewBackend) Name() string { return "agg-review" }
func (b *aggReviewBackend) Execute(_ context.Context, req ExecRequest) (*ExecResult, error) {
key := filepath.Base(filepath.Clean(req.WorkDir))
if strings.HasPrefix(key, "verify_") {
return writeOne(req.WorkDir, "verdict.md", "核对成立。\nVERDICT: PASS"), nil
}
return writeOne(req.WorkDir, "findings.json",
`[{"location":"第1段","severity":"high","issue":"样例问题","evidence":"样例证据"}]`), nil
}
// newAggEngine 自定义角色表的汇总引擎(backend 实例名 = be.Name())。
func newAggEngine(t *testing.T, planner Planner, be Executor, roles []RoleSpec) *SquadEngine {
t.Helper()
dir := t.TempDir()
reg := NewRoleRegistry()
for _, r := range roles {
if err := reg.Register(r); err != nil {
t.Fatal(err)
}
}
backends := NewBackendResolver()
backends.RegisterInstance(be.Name(), be)
engine, err := NewEngine(Options{DataDir: dir, Registry: reg, Planner: planner, Backends: backends, Sink: NopSink{}})
if err != nil {
t.Fatal(err)
}
return engine
}