You've already forked agentic-coding-workflow
523 lines
18 KiB
Go
523 lines
18 KiB
Go
// Package acp 实现 ACP(Agent Client Protocol)Client 模块。
|
|
// 模块结构(spec §13 决策 #12):单 package + 多文件,与现有 chat/jobs/workspace 一致。
|
|
//
|
|
// 三个聚合根:
|
|
// - AgentKind:admin 注册的 agent 类型(claude_code/gemini/...),含加密 env
|
|
// - Session:用户启动的一个 ACP 会话,绑定 workspace + agent kind + (issue/req?)
|
|
// - Event:JSON-RPC 双向消息流,每条入库
|
|
package acp
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// SessionStatus 是 acp_sessions.status 枚举。
|
|
type SessionStatus string
|
|
|
|
const (
|
|
SessionStarting SessionStatus = "starting"
|
|
SessionRunning SessionStatus = "running"
|
|
SessionCrashed SessionStatus = "crashed"
|
|
SessionExited SessionStatus = "exited"
|
|
)
|
|
|
|
// IsActive 判定 session 是否仍占资源(worktree、subprocess、限流计数)。
|
|
func (s SessionStatus) IsActive() bool {
|
|
return s == SessionStarting || s == SessionRunning
|
|
}
|
|
|
|
// EventDirection 标识事件流向。in = client→agent,out = agent→client。
|
|
type EventDirection string
|
|
|
|
const (
|
|
DirectionIn EventDirection = "in"
|
|
DirectionOut EventDirection = "out"
|
|
)
|
|
|
|
// RPCKind 标识 JSON-RPC 消息类型。
|
|
type RPCKind string
|
|
|
|
const (
|
|
RPCRequest RPCKind = "request"
|
|
RPCResponse RPCKind = "response"
|
|
RPCNotification RPCKind = "notification"
|
|
RPCError RPCKind = "error"
|
|
)
|
|
|
|
// ClientType 标识 agent CLI 的客户端类型,决定 spawn 时注入哪个配置目录
|
|
// 重定向变量(CLAUDE_CONFIG_DIR / CODEX_HOME / GEMINI_CLI_HOME)以及前端
|
|
// 渲染哪套配置表单。generic 仅注入 HOME。
|
|
type ClientType string
|
|
|
|
const (
|
|
ClientClaudeCode ClientType = "claude_code"
|
|
ClientCodex ClientType = "codex"
|
|
ClientGemini ClientType = "gemini"
|
|
ClientGeneric ClientType = "generic"
|
|
)
|
|
|
|
// Valid 判定 client_type 是否为已知枚举值。
|
|
func (t ClientType) Valid() bool {
|
|
switch t {
|
|
case ClientClaudeCode, ClientCodex, ClientGemini, ClientGeneric:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ACW 自家 MCP 注入约定:session 创建时签发的 system token 与 MCP 公网地址
|
|
// 经 extraEnv 注入子进程,同时在 session/new 握手时作为 http MCP server 下发。
|
|
const (
|
|
EnvMCPToken = "ACW_MCP_TOKEN"
|
|
EnvMCPURL = "ACW_MCP_URL"
|
|
// AcwMcpServerName 是自家 MCP 在 mcpServers 里的保留名,第三方条目不可占用。
|
|
AcwMcpServerName = "acw"
|
|
)
|
|
|
|
// McpServerType 是第三方 MCP server 的传输类型。stdio 是 ACP 基线(所有 agent
|
|
// 必须支持);http / sse 需 agent 在 initialize 中声明 mcpCapabilities 才下发。
|
|
type McpServerType string
|
|
|
|
const (
|
|
McpStdio McpServerType = "stdio"
|
|
McpHTTP McpServerType = "http"
|
|
McpSSE McpServerType = "sse"
|
|
)
|
|
|
|
// McpServerSpec 是 admin 在 AgentKind 上配置的一条 MCP server。session/new
|
|
// 握手时转换为 ACP wire 格式随 mcpServers 下发。Env / Headers 可能含密钥,
|
|
// 整个列表加密落库(encrypted_mcp_servers);明文仅返回给 admin 编辑。
|
|
type McpServerSpec struct {
|
|
Name string `json:"name"`
|
|
Type McpServerType `json:"type"`
|
|
Command string `json:"command,omitempty"` // stdio
|
|
Args []string `json:"args,omitempty"` // stdio
|
|
Env map[string]string `json:"env,omitempty"` // stdio
|
|
URL string `json:"url,omitempty"` // http / sse
|
|
Headers map[string]string `json:"headers,omitempty"` // http / sse
|
|
}
|
|
|
|
// AgentKind 是 admin 注册的 agent 类型记录。EncryptedEnv 是 AES-GCM 密文,
|
|
// 仅 supervisor spawn 时解密为 map 注入子进程。其他场景不解密、不出 API。
|
|
// EncryptedMCPServers 同为密文,但明文经 API 返回给 admin 供编辑。
|
|
type AgentKind struct {
|
|
ID uuid.UUID
|
|
Name string
|
|
DisplayName string
|
|
Description string
|
|
BinaryPath string
|
|
Args []string
|
|
EncryptedEnv []byte
|
|
Enabled bool
|
|
ToolAllowlist []string // 命中的工具调用自动放行;含 "*" 表示放行全部可识别的工具(无法识别名称的调用仍走人工审批)
|
|
ClientType ClientType
|
|
EncryptedMCPServers []byte
|
|
CreatedBy uuid.UUID
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
// 成本核算:关联的 llm_models 行(价格来源)+ 每 kind 默认预算上限。
|
|
// 全部 NULLABLE:未配置 model 时成本按 0/agent 自报;未配置上限时无限制。
|
|
ModelID *uuid.UUID
|
|
MaxCostUSD *float64
|
|
MaxTokens *int64
|
|
MaxWallClockSeconds *int32
|
|
}
|
|
|
|
// ConfigFile 是 AgentKind 维度的 agent CLI 配置文件(如 .claude/settings.json、
|
|
// .codex/config.toml)。RelPath 相对受管 home 目录;EncryptedContent 是 AES-GCM
|
|
// 密文(内容可能含 auth token),spawn 前解密物化到受管目录。
|
|
type ConfigFile struct {
|
|
ID uuid.UUID
|
|
AgentKindID uuid.UUID
|
|
RelPath string
|
|
EncryptedContent []byte
|
|
UpdatedBy uuid.UUID
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
// Session 是一次 ACP 会话。AgentSessionID 是 agent 侧的 session 标识(来自
|
|
// session/new response),与本表 ID 不同,仅用于 ACP 协议 method 内填充。
|
|
type Session struct {
|
|
ID uuid.UUID
|
|
WorkspaceID uuid.UUID
|
|
ProjectID uuid.UUID
|
|
AgentKindID uuid.UUID
|
|
UserID uuid.UUID
|
|
IssueID *uuid.UUID
|
|
RequirementID *uuid.UUID
|
|
AgentSessionID *string
|
|
Branch string
|
|
CwdPath string
|
|
IsMainWorktree bool
|
|
Status SessionStatus
|
|
PID *int32
|
|
ExitCode *int32
|
|
LastError *string
|
|
StartedAt time.Time
|
|
EndedAt *time.Time
|
|
// LastStopReason 是 agent 最近一次回合完成时上报的 stopReason 原始字符串
|
|
// (nil = 尚无完成的回合)。归一化枚举见 NormalizeStopReason,但落库为原值。
|
|
LastStopReason *string
|
|
// OrchestratorStepID 非 nil 时表示该 session 由编排器某个 step 驱动;onExit /
|
|
// 启动 reaper 据此把崩溃 session 映射回 step 并触发再驱动。manual session 为 nil。
|
|
OrchestratorStepID *uuid.UUID
|
|
// 成本核算运行时累加器(落库快照)。
|
|
PromptTokens int64
|
|
CompletionTokens int64
|
|
ThinkingTokens int64
|
|
TotalCostUSD float64
|
|
LastActivityAt time.Time
|
|
// 创建时快照的有效预算上限(session 覆盖 > agent-kind 默认 > 全局默认)。
|
|
BudgetMaxCostUSD *float64
|
|
BudgetMaxTokens *int64
|
|
BudgetMaxWallClockSeconds *int32
|
|
// TerminatedReason 非 nil 时记录会话被强制终止的原因(budget_*/reaper_*/manual)。
|
|
TerminatedReason *string
|
|
}
|
|
|
|
// BudgetCaps 是一次会话的有效预算上限快照。nil 字段 = 该维度无限制。
|
|
type BudgetCaps struct {
|
|
MaxCostUSD *float64
|
|
MaxTokens *int64
|
|
MaxWallClockSeconds *int32
|
|
}
|
|
|
|
// BreachReason 标识预算被突破的维度,落 acp_sessions.terminated_reason。
|
|
type BreachReason string
|
|
|
|
const (
|
|
BreachCost BreachReason = "budget_cost"
|
|
BreachTokens BreachReason = "budget_tokens"
|
|
BreachWallClock BreachReason = "budget_wall_clock"
|
|
)
|
|
|
|
// SessionUsageRecord 是 acp_session_usage 表一行(per-turn 账目,镜像 llm_usage)。
|
|
type SessionUsageRecord struct {
|
|
ID int64
|
|
SessionID uuid.UUID
|
|
UserID uuid.UUID
|
|
ProjectID uuid.UUID
|
|
AgentKindID uuid.UUID
|
|
ModelID *uuid.UUID
|
|
PromptTokens int64
|
|
CompletionTokens int64
|
|
ThinkingTokens int64
|
|
CostUSD float64
|
|
SourceEventID *int64
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
// AcpUsageUserRow 按用户聚合的 ACP 用量。
|
|
type AcpUsageUserRow struct {
|
|
UserID uuid.UUID
|
|
PromptTokens, CompletionTokens, ThinkingTokens int64
|
|
CostUSD float64
|
|
}
|
|
|
|
// AcpUsageModelRow 按模型聚合的 ACP 用量。
|
|
type AcpUsageModelRow struct {
|
|
ModelID uuid.UUID
|
|
PromptTokens, CompletionTokens, ThinkingTokens int64
|
|
CostUSD float64
|
|
}
|
|
|
|
// AcpUsageDayRow 按天聚合的 ACP 用量。
|
|
type AcpUsageDayRow struct {
|
|
Day time.Time
|
|
PromptTokens, CompletionTokens, ThinkingTokens int64
|
|
CostUSD float64
|
|
}
|
|
|
|
// ===== run-history dashboard 读模型 =====
|
|
|
|
// DashboardFilter 限定 run-history 汇总的范围。UserID 非 nil 时仅统计该用户的
|
|
// 会话(owner scope);nil 表示全量(admin scope)。ProjectID/RequirementID
|
|
// 为可选过滤;From/To 为半开时间窗 [From, To)。
|
|
type DashboardFilter struct {
|
|
UserID *uuid.UUID
|
|
ProjectID *uuid.UUID
|
|
RequirementID *uuid.UUID
|
|
From time.Time
|
|
To time.Time
|
|
}
|
|
|
|
// SessionRollup 是 run-history 汇总:会话计数 + 成功率 + 总成本 + 平均时长。
|
|
type SessionRollup struct {
|
|
Total int64
|
|
Succeeded int64
|
|
Crashed int64
|
|
Active int64
|
|
TotalCostUSD float64
|
|
TotalTokens int64
|
|
AvgDurationSeconds float64
|
|
}
|
|
|
|
// SuccessRate 返回 succeeded/total(total=0 时为 0),便于 handler/MCP 直接使用。
|
|
func (r SessionRollup) SuccessRate() float64 {
|
|
if r.Total == 0 {
|
|
return 0
|
|
}
|
|
return float64(r.Succeeded) / float64(r.Total)
|
|
}
|
|
|
|
// SessionDayRow 是 run-history 按天的时间序列一行。
|
|
type SessionDayRow struct {
|
|
Day time.Time
|
|
Total int64
|
|
Succeeded int64
|
|
Crashed int64
|
|
TotalCostUSD float64
|
|
}
|
|
|
|
// SessionTimelineBucket 是单会话事件按 (direction, rpc_kind, method) 分桶的一行。
|
|
type SessionTimelineBucket struct {
|
|
Direction string
|
|
RPCKind string
|
|
Method string
|
|
EventCount int64
|
|
FirstAt time.Time
|
|
LastAt time.Time
|
|
}
|
|
|
|
// ReaperSession 是 reaper 需要的最小 session 投影。
|
|
type ReaperSession struct {
|
|
ID uuid.UUID
|
|
UserID uuid.UUID
|
|
StartedAt time.Time
|
|
LastActivityAt time.Time
|
|
MaxWallClockSeconds *int32
|
|
}
|
|
|
|
// StopReason 是回合完成的归一化原因枚举。agent 可能上报枚举外的 vendor-specific
|
|
// 值;这些值归一化为 StopOther,但原始字符串仍会原样落库(stop_reason /
|
|
// last_stop_reason),避免信息丢失。
|
|
type StopReason string
|
|
|
|
const (
|
|
StopEndTurn StopReason = "end_turn"
|
|
StopMaxTokens StopReason = "max_tokens"
|
|
StopRefusal StopReason = "refusal"
|
|
StopCancelled StopReason = "cancelled"
|
|
StopOther StopReason = "other"
|
|
)
|
|
|
|
// NormalizeStopReason 把 agent 上报的原始 stopReason 映射到归一化枚举。
|
|
// 未知值(含空串)映射为 StopOther。
|
|
func NormalizeStopReason(raw string) StopReason {
|
|
switch StopReason(raw) {
|
|
case StopEndTurn:
|
|
return StopEndTurn
|
|
case StopMaxTokens:
|
|
return StopMaxTokens
|
|
case StopRefusal:
|
|
return StopRefusal
|
|
case StopCancelled:
|
|
return StopCancelled
|
|
default:
|
|
return StopOther
|
|
}
|
|
}
|
|
|
|
// TurnStatus 是 acp_turns.status 枚举。
|
|
type TurnStatus string
|
|
|
|
const (
|
|
TurnInProgress TurnStatus = "in_progress"
|
|
TurnCompleted TurnStatus = "completed"
|
|
TurnAborted TurnStatus = "aborted"
|
|
)
|
|
|
|
// Turn 是一次 agent 回合的审计记录。一个 session 内 turn_index 单调递增(0-based)。
|
|
// PromptRequestID 是发起该回合的 session/prompt 的 JSON-RPC id(用于响应相关联)。
|
|
// StopReason 在完成前为 nil;存储 agent 上报的原始字符串。
|
|
type Turn struct {
|
|
ID int64
|
|
SessionID uuid.UUID
|
|
TurnIndex int
|
|
PromptRequestID *string
|
|
Status TurnStatus
|
|
StopReason *string
|
|
UpdateCount int
|
|
StartedAt time.Time
|
|
CompletedAt *time.Time
|
|
}
|
|
|
|
// Event 是一条入库的 JSON-RPC 消息。Payload 是 JSON-RPC envelope 完整 JSON
|
|
// 字节(不解析为结构体,便于无损 replay);超过 64KB 的会按 §3.5 规则裁剪。
|
|
type Event struct {
|
|
ID int64
|
|
SessionID uuid.UUID
|
|
Direction EventDirection
|
|
RPCKind RPCKind
|
|
Method *string
|
|
Payload []byte
|
|
PayloadSize int32
|
|
Truncated bool
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
// Caller 表示发起请求的用户上下文,与 PM 模块的 Caller 同语义。
|
|
type Caller struct {
|
|
UserID uuid.UUID
|
|
IsAdmin bool
|
|
}
|
|
|
|
// AgentKindService 暴露 AgentKind 的应用服务方法。env 走"完整 map 替换"
|
|
// 语义:每次 PATCH 传完整 env,nil 表示不修改;非 nil 整体加密重写。
|
|
type AgentKindService interface {
|
|
Create(ctx context.Context, c Caller, in CreateAgentKindInput) (*AgentKind, error)
|
|
List(ctx context.Context, c Caller) ([]*AgentKind, error)
|
|
Get(ctx context.Context, c Caller, id uuid.UUID) (*AgentKind, error)
|
|
Update(ctx context.Context, c Caller, id uuid.UUID, in UpdateAgentKindInput) (*AgentKind, error)
|
|
Delete(ctx context.Context, c Caller, id uuid.UUID) error
|
|
|
|
// 配置文件管理(admin only)。与 env 不同:内容明文返回给 admin 供编辑,
|
|
// 存储层加密。content 是明文。
|
|
ListConfigFiles(ctx context.Context, c Caller, kindID uuid.UUID) ([]*ConfigFileContent, error)
|
|
UpsertConfigFile(ctx context.Context, c Caller, kindID uuid.UUID, relPath, content string) (*ConfigFileContent, error)
|
|
DeleteConfigFile(ctx context.Context, c Caller, kindID uuid.UUID, relPath string) error
|
|
}
|
|
|
|
// ConfigFileContent 是解密后的配置文件视图(service → handler)。
|
|
type ConfigFileContent struct {
|
|
RelPath string
|
|
Content string
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
// SessionService 暴露 Session 聚合根的应用服务方法。
|
|
type SessionService interface {
|
|
Create(ctx context.Context, c Caller, in CreateSessionInput) (*Session, error)
|
|
Get(ctx context.Context, c Caller, id uuid.UUID) (*Session, error)
|
|
List(ctx context.Context, c Caller, f SessionListFilter) ([]*Session, error)
|
|
Terminate(ctx context.Context, c Caller, id uuid.UUID) error
|
|
|
|
// Dashboard 返回 run-history 汇总 + 按天序列。非 admin 调用强制按 caller
|
|
// scope(UserID 覆盖为 caller),admin 才能查看全量。
|
|
Dashboard(ctx context.Context, c Caller, in DashboardInput) (*DashboardResult, error)
|
|
// Timeline 返回单会话的事件时间线(先经 Get 做 owner/admin 访问校验)。
|
|
Timeline(ctx context.Context, c Caller, sessionID uuid.UUID) ([]SessionTimelineBucket, error)
|
|
}
|
|
|
|
// DashboardInput 是 run-history 汇总的入参(过滤维度由 service 在 scope 内归一化)。
|
|
type DashboardInput struct {
|
|
ProjectID *uuid.UUID
|
|
RequirementID *uuid.UUID
|
|
From time.Time
|
|
To time.Time
|
|
}
|
|
|
|
// DashboardResult 是 run-history 汇总响应:总览 + 按天序列。
|
|
type DashboardResult struct {
|
|
Rollup SessionRollup
|
|
ByDay []SessionDayRow
|
|
From time.Time
|
|
To time.Time
|
|
}
|
|
|
|
// TurnRunner 是编排器驱动一个阻塞回合所需的窄接口(不扩大 SessionService 的
|
|
// HTTP/MCP 暴露面)。sessionService 同时实现 SessionService 与 TurnRunner。
|
|
//
|
|
// - SendPromptAndWait 发送一条 session/prompt 并阻塞等待回合完成,返回原始
|
|
// stopReason(来自 relay.Call 的 server-initiated 请求响应)。
|
|
// - ResumeSession 在崩溃 session 的同一 cwd/worktree 上重新拉起 agent。
|
|
type TurnRunner interface {
|
|
SendPromptAndWait(ctx context.Context, sessionID uuid.UUID, prompt string) (stopReason string, err error)
|
|
ResumeSession(ctx context.Context, c Caller, sessionID uuid.UUID) (*Session, error)
|
|
}
|
|
|
|
// SessionListFilter 控制 sessions 列表的过滤维度。
|
|
// All 仅 admin 可用(查看全部用户);RequirementID nil 表示不按需求过滤。
|
|
type SessionListFilter struct {
|
|
All bool
|
|
RequirementID *uuid.UUID
|
|
}
|
|
|
|
// CreateSessionInput 是创建 session 的入参。
|
|
// branch / issue_number / requirement_number 三选一或全空(spec §8.1)。
|
|
type CreateSessionInput struct {
|
|
WorkspaceID uuid.UUID
|
|
AgentKindID uuid.UUID
|
|
Branch *string
|
|
IssueNumber *int
|
|
RequirementNumber *int
|
|
InitialPrompt *string
|
|
// OrchestratorStepID 非 nil 时把 session 与编排器 step 反向关联(仅由 orchestrator
|
|
// 内部调用设置;HTTP/MCP 入口不暴露)。
|
|
OrchestratorStepID *uuid.UUID
|
|
}
|
|
|
|
// CreateAgentKindInput 携带创建必需字段。Args / Env 可为 nil。
|
|
// ClientType 为空时默认 generic。
|
|
type CreateAgentKindInput struct {
|
|
Name string
|
|
DisplayName string
|
|
Description string
|
|
BinaryPath string
|
|
Args []string
|
|
Env map[string]string
|
|
Enabled bool
|
|
ToolAllowlist []string
|
|
ClientType ClientType
|
|
MCPServers []McpServerSpec
|
|
// 成本核算:model 价格来源 + 默认预算上限(均可选)。
|
|
ModelID *uuid.UUID
|
|
MaxCostUSD *float64
|
|
MaxTokens *int64
|
|
MaxWallClockSeconds *int32
|
|
}
|
|
|
|
// UpdateAgentKindInput 是 patch 输入。Name 不可改(创建后稳定,引用约束)。
|
|
// Env 三态:nil = 不修改;空 map = 清空全部 env;非空 = 整体替换。
|
|
type UpdateAgentKindInput struct {
|
|
DisplayName *string
|
|
Description *string
|
|
BinaryPath *string
|
|
Args []string // nil = 不改;非 nil = 替换
|
|
Env map[string]string // nil = 不改;非 nil(含空)= 替换
|
|
Enabled *bool
|
|
ToolAllowlist []string // nil = 不改;非 nil(含空)= 替换
|
|
ClientType *ClientType // nil = 不改
|
|
MCPServers []McpServerSpec // nil = 不改;非 nil(含空)= 替换
|
|
// 成本核算字段:三态指针,nil = 不改。SetModelID 控制 ModelID 是否参与更新
|
|
// (ModelID 本身可被显式置空,故用独立 flag 区分"不改"与"清空")。
|
|
SetModelID bool
|
|
ModelID *uuid.UUID
|
|
SetMaxCostUSD bool
|
|
MaxCostUSD *float64
|
|
SetMaxTokens bool
|
|
MaxTokens *int64
|
|
SetMaxWallClock bool
|
|
MaxWallClockSeconds *int32
|
|
}
|
|
|
|
// PermissionStatus 是 acp_permission_requests.status 枚举。
|
|
type PermissionStatus string
|
|
|
|
const (
|
|
PermissionPending PermissionStatus = "pending"
|
|
PermissionApproved PermissionStatus = "approved"
|
|
PermissionDenied PermissionStatus = "denied"
|
|
PermissionAuto PermissionStatus = "auto"
|
|
PermissionExpired PermissionStatus = "expired"
|
|
)
|
|
|
|
// PermissionRequest 是一条代理发起的工具调用授权请求记录。
|
|
type PermissionRequest struct {
|
|
ID uuid.UUID
|
|
SessionID uuid.UUID
|
|
AgentRequestID string
|
|
ToolName string
|
|
ToolCall []byte // 原始 toolCall JSON
|
|
Options []byte // 原始 options JSON
|
|
Status PermissionStatus
|
|
ChosenOptionID *string
|
|
DecidedBy *uuid.UUID
|
|
DecidedAt *time.Time
|
|
CreatedAt time.Time
|
|
}
|