Files
Agent-Squad/cmd/agent-squad/server/server.go
T
weichen 626b2a3f34
ci / vet-and-test (pull_request) Successful in 24s
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
Add the Store port with tiered checkpoints and centralize termination
Why: Q1 WP4/WP6/WP7 - run persistence goes through a Store port
(FileStore carries the current layout), superstep boundaries write
tiered snapshots with a graph fingerprint for recovery continuity,
and the four hardcoded stop conditions move behind one Termination
registry with And/Or composition.

Evidence: go vet clean; full suite ok including new checkpoint,
continuity-rejection, termination-primitive, grant-cap and the
Q23-updated leader closeout tests; cmd module green with
SweepExpired wired into the sweeper loop.

Impact: runState saves via Store (FileStore unchanged by default);
advance writes sync/async/on_exit checkpoints and Resume rejects
graph-shape changes from the crash window; leader turn cap now
degrades through finalize instead of a bare failure (Q23); manual
repair grants are bounded (Q26); wakeup cap and step timeout read
from the same registry; the dead SweepExpired is now called (Q6).

Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md Q1 WP4/WP6/WP7
2026-10-07 10:00:33 +00:00

513 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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())
// Q6:与事件唤醒并列,过期挂起按 ExpireAction 合成决策(此前是死代码)。
s.engine.SweepExpired(context.Background(), time.Now())
}
}
}()
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()
}
}
}