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>
511 lines
14 KiB
Go
511 lines
14 KiB
Go
// 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()
|
||
}
|
||
}
|
||
}
|