Move cross-library squad code into adapter modules

Why: the squad core has to compile without agent-native or agent-intent. Ports stay in the core; adapters and the CLI own the concrete clients.
Evidence: GOWORK=off go test -count=1 ./squad/ ./store/ ./textextract/ ./schema/ passed, and the intent, sqlite, native, and cmd modules passed their own go test.
Impact: core go.mod keeps error-code-system only. Nested gitlinks stay. Callers register the native backend through agent-squad/adapters/native.
Refs: D1
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-03 13:40:58 +00:00
co-authored by Cursor
parent 20a9eb8078
commit a182d7662b
56 changed files with 1088 additions and 343 deletions
+510
View File
@@ -0,0 +1,510 @@
// Package server 提供 agent-squad 的 HTTP API:角色/蓝图 CRUD、发起运行、
// SSE 事件流与 HITL pending/respond。无 UI;产品接入方自行适配展示层。
package server
import (
"context"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"time"
platform "agent-squad/adapters/sqlite"
"agent-squad/squad"
"agent-squad/store"
"error-code-system/errcode"
"error-code-system/httpx"
"logging-system/logsys"
// 导入 domains 才会把种子码填进 errcode.Default。
_ "error-code-system/domains"
)
// Server HTTP 服务。
type Server struct {
engine *squad.SquadEngine
sink *squad.FanoutSink
st *store.FileStore
mu sync.Mutex
latest map[string]*squad.RunResult // 最近一次推进结果(供轮询)
log *logsys.Logger
plat *platform.Store
token string
}
// SetToken 要求除健康检查外的请求带 Bearer token。空串表示不校验。
func (s *Server) SetToken(token string) { s.token = token }
// SetLogger 设置组合根日志器。nil 表示不写日志。
func (s *Server) SetLogger(log *logsys.Logger) {
if s == nil {
return
}
s.log = log
}
// SetPlatform attaches the orchestration database. Nil leaves the goal engine only.
func (s *Server) SetPlatform(st *platform.Store) {
if s == nil {
return
}
s.plat = st
}
// New 组装服务。
func New(engine *squad.SquadEngine, sink *squad.FanoutSink, st *store.FileStore) *Server {
return &Server{engine: engine, sink: sink, st: st, latest: map[string]*squad.RunResult{}}
}
// Handler 返回路由。
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/health", s.handleHealth)
mux.HandleFunc("GET /v1/roles", s.handleListRoles)
mux.HandleFunc("POST /v1/roles", s.handlePutRole)
mux.HandleFunc("PUT /v1/roles/{key}", s.handlePutRole)
mux.HandleFunc("DELETE /v1/roles/{key}", s.handleDeleteRole)
mux.HandleFunc("POST /v1/roles/derive", s.handleDeriveRole)
mux.HandleFunc("GET /v1/roles/recommend", s.handleRecommendRole)
mux.HandleFunc("GET /v1/blueprints", s.handleListBlueprints)
mux.HandleFunc("POST /v1/blueprints", s.handlePutBlueprint)
mux.HandleFunc("DELETE /v1/blueprints/{key}", s.handleDeleteBlueprint)
mux.HandleFunc("POST /v1/runs", s.handleCreateRun)
mux.HandleFunc("GET /v1/runs", s.handleListRuns)
mux.HandleFunc("GET /v1/runs/{id}", s.handleInspectRun)
mux.HandleFunc("GET /v1/runs/{id}/pending", s.handlePending)
mux.HandleFunc("POST /v1/runs/{id}/respond", s.handleRespond)
mux.HandleFunc("POST /v1/runs/{id}/resume", s.handleResume)
mux.HandleFunc("POST /v1/runs/{id}/steer", s.handleSteer)
mux.HandleFunc("POST /v1/runs/{id}/blackboard", s.handleSetField)
mux.HandleFunc("POST /v1/wakeups/sweep", s.handleSweep)
mux.HandleFunc("GET /v1/runs/{id}/blackboard", s.handleBlackboard)
mux.HandleFunc("GET /v1/runs/{id}/events", s.handleEvents)
if s.plat != nil {
platform.Mount(mux, s.plat)
}
var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
limit := int64(1 << 20)
if r.Method == http.MethodPost && r.URL.Path == "/v1/runs" {
limit = 32 << 20
}
r.Body = http.MaxBytesReader(w, r.Body, limit)
mux.ServeHTTP(w, r)
})
if s.token != "" {
inner := h
h = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/health" {
inner.ServeHTTP(w, r)
return
}
const prefix = "Bearer "
header := r.Header.Get("Authorization")
got := strings.TrimPrefix(header, prefix)
if len(header) != len(prefix)+len(s.token) || subtle.ConstantTimeCompare([]byte(got), []byte(s.token)) != 1 {
w.WriteHeader(http.StatusUnauthorized)
return
}
inner.ServeHTTP(w, r)
})
}
return httpx.Trace(h)
}
func (s *Server) decode(w http.ResponseWriter, r *http.Request, dst any) bool {
err := json.NewDecoder(r.Body).Decode(dst)
if err == nil {
return true
}
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) || strings.Contains(err.Error(), "request body too large") {
w.WriteHeader(http.StatusRequestEntityTooLarge)
return false
}
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, "请求 JSON 无法解析"))
return false
}
func rejectBadID(w http.ResponseWriter, id string) bool {
if squad.ValidID(id) {
return false
}
w.WriteHeader(http.StatusBadRequest)
return true
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func (s *Server) writeErr(w http.ResponseWriter, r *http.Request, err error) {
if errcode.From(err) == nil {
err = errcode.Wrap(err, errcode.SysInternal, "内部错误")
}
s.logErr(r, err)
httpx.WriteError(errcode.Default, w, r, err)
}
func (s *Server) logErr(r *http.Request, err error) {
if s == nil || s.log == nil || r == nil {
return
}
ctx := logsys.WithTrace(r.Context(), httpx.TraceID(r))
s.log.Module("server").WithContext(ctx).Error("http error", err)
}
func (s *Server) logRun(r *http.Request, msg string, res *squad.RunResult) {
if s == nil || s.log == nil || r == nil || res == nil {
return
}
ctx := logsys.WithTrace(r.Context(), httpx.TraceID(r))
s.log.Module("server").WithContext(ctx).With("run_id", res.RunID).Info(msg, "status", string(res.Status))
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// ---- 角色 ----
func (s *Server) handleListRoles(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.engine.Registry().List())
}
func (s *Server) handlePutRole(w http.ResponseWriter, r *http.Request) {
var spec squad.RoleSpec
if !s.decode(w, r, &spec) {
return
}
if key := r.PathValue("key"); key != "" && spec.Role == "" {
spec.Role = key
}
if err := s.engine.Registry().Register(spec); err != nil {
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, err.Error()))
return
}
writeJSON(w, http.StatusOK, spec)
}
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
}
s.st.DeleteRole(key)
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleDeriveRole(w http.ResponseWriter, r *http.Request) {
var req squad.DeriveRequest
if !s.decode(w, r, &req) {
return
}
spec, err := s.engine.Registry().Derive(req)
if err != nil {
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, err.Error()))
return
}
writeJSON(w, http.StatusCreated, spec)
}
func (s *Server) handleRecommendRole(w http.ResponseWriter, r *http.Request) {
spec, ok := s.engine.Registry().Recommend(r.URL.Query().Get("q"))
writeJSON(w, http.StatusOK, map[string]any{"role": spec, "matched": ok})
}
// ---- 蓝图 ----
func (s *Server) handleListBlueprints(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.engine.Blueprints().List())
}
func (s *Server) handlePutBlueprint(w http.ResponseWriter, r *http.Request) {
var bp squad.Blueprint
if !s.decode(w, r, &bp) {
return
}
if err := s.engine.Blueprints().Register(bp); err != nil {
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, err.Error()))
return
}
writeJSON(w, http.StatusCreated, bp)
}
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
}
s.st.DeleteBlueprint(key)
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
// ---- 运行 ----
type createRunRequest struct {
Goal squad.Goal `json:"goal"`
TaskKey string `json:"task_key,omitempty"`
Backend string `json:"backend,omitempty"`
// Mode 编排模式:standard(默认)| leader(leader-delegation)。
Mode string `json:"mode,omitempty"`
// LeaderRole / MaxLeaderTurns leader 模式参数(CLI 等价项)。
LeaderRole string `json:"leader_role,omitempty"`
MaxLeaderTurns int `json:"max_leader_turns,omitempty"`
// MaterialsB64 便捷通道:filename → base64 内容。
MaterialsB64 map[string]string `json:"materials_b64,omitempty"`
}
func (s *Server) handleCreateRun(w http.ResponseWriter, r *http.Request) {
var req createRunRequest
if !s.decode(w, r, &req) {
return
}
for name, b64 := range req.MaterialsB64 {
data, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, fmt.Sprintf("materials_b64[%s]", name)))
return
}
req.Goal.Materials = append(req.Goal.Materials, squad.MaterialFile{Filename: name, Content: data})
}
if req.Goal.Text == "" {
s.writeErr(w, r, errcode.New(errcode.SquadGoalRequired, "goal.text is required"))
return
}
// 同步发起:Run 在首次挂起或终态前返回;长时间执行由调用方带超时/异步包装。
// 这里为 HTTP 体验做异步:先返回 run_id 无法预知(ID 在 Run 内生成),
// 因此采用同步调用 + 流式等待由调用方控制超时。
res, err := s.engine.Run(r.Context(), req.Goal, squad.RunOptions{
TaskKey: req.TaskKey, Backend: req.Backend,
Mode: req.Mode, LeaderRole: req.LeaderRole, MaxLeaderTurns: req.MaxLeaderTurns,
})
if err != nil {
s.writeErr(w, r, err)
return
}
s.record(res)
s.logRun(r, "run finished", res)
code := http.StatusOK
if res.Status.Terminal() {
code = http.StatusOK
}
writeJSON(w, code, res)
}
func (s *Server) record(res *squad.RunResult) {
if res == nil {
return
}
s.mu.Lock()
s.latest[res.RunID] = res
s.mu.Unlock()
}
func (s *Server) handleListRuns(w http.ResponseWriter, r *http.Request) {
runs, err := s.engine.ListRuns()
if err != nil {
s.writeErr(w, r, err)
return
}
writeJSON(w, http.StatusOK, runs)
}
func (s *Server) handleInspectRun(w http.ResponseWriter, r *http.Request) {
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
}
writeJSON(w, http.StatusOK, res)
}
func (s *Server) handlePending(w http.ResponseWriter, r *http.Request) {
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
}
writeJSON(w, http.StatusOK, map[string]any{"pending": req})
}
func (s *Server) handleRespond(w http.ResponseWriter, r *http.Request) {
var decision squad.RespondDecision
if !s.decode(w, r, &decision) {
return
}
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
}
s.record(res)
s.logRun(r, "run responded", res)
writeJSON(w, http.StatusOK, res)
}
func (s *Server) handleResume(w http.ResponseWriter, r *http.Request) {
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
}
s.record(res)
s.logRun(r, "run resumed", res)
writeJSON(w, http.StatusOK, res)
}
func (s *Server) handleBlackboard(w http.ResponseWriter, r *http.Request) {
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
}
writeJSON(w, http.StatusOK, bb)
}
func (s *Server) handleSteer(w http.ResponseWriter, r *http.Request) {
var req struct {
Text string `json:"text"`
}
if !s.decode(w, r, &req) {
return
}
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
}
writeJSON(w, http.StatusAccepted, map[string]string{"status": "queued_for_next_step"})
}
func (s *Server) handleSetField(w http.ResponseWriter, r *http.Request) {
var req struct {
Field string `json:"field"`
Value string `json:"value"`
}
if !s.decode(w, r, &req) {
return
}
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
}
writeJSON(w, http.StatusOK, map[string]string{"status": "set"})
}
func (s *Server) handleSweep(w http.ResponseWriter, r *http.Request) {
n := s.engine.SweepWakeups(r.Context())
writeJSON(w, http.StatusOK, map[string]int{"woken": n})
}
// StartWakeupSweeper 周期扫描等待条件(等事件自动唤醒);返回停止函数。
// 间隔默认 15s。
func (s *Server) StartWakeupSweeper(interval time.Duration) (stop func()) {
if interval <= 0 {
interval = 15 * time.Second
}
done := make(chan struct{})
go func() {
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-done:
return
case <-t.C:
s.engine.SweepWakeups(context.Background())
}
}
}()
return func() { close(done) }
}
// 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"))
return
}
cursor := 0
if c := r.URL.Query().Get("cursor"); c != "" {
if n, err := strconv.Atoi(c); err == nil {
cursor = n
}
}
ch, next, cancel := s.sink.Subscribe(cursor)
defer cancel()
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "event: cursor\ndata: %d\n\n", next)
flusher.Flush()
for {
select {
case <-r.Context().Done():
return
case ev, ok := <-ch:
if !ok {
return
}
if id := r.PathValue("id"); id != "" && ev.RunID != id {
continue
}
data, _ := json.Marshal(ev)
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev.Type, data)
flusher.Flush()
}
}
}