feat(swarm): #46/#28 暴露 input/result 代理端点(I/O 闭环)

agent_swarm PR#41 已合并 main(契约冻结),HM 补齐客户端+swarm 双方点名需要的
两条透传端点(均设备签名,挂 /api/heicode/swarms/:id 下):

- POST /swarms/:id/input  → 代理 Swarm POST …/{deployment_id}/input(追加用户输入,
  注入 source=user_append;终态 run 自动 reopen,stopped 拒绝)。写路,需
  SWARM_RUNTIME_ENABLED+base+token,缺一即拒不伪造受理。**指令文本仅转发,绝不落
  HM 日志/审计/事件**(#40 原文不进事件流 + #46 勿落日志);审计只记发生过一次 append。
- GET  /swarms/:id/result → 代理 Swarm GET …/{deployment_id}/result,返回
  {summary, deliverable, artifacts[], termination_reason, status};产物按 uri 取非内联。
  读路,需 base+token(与派发开关解耦);透出前 stripSensitiveKeys 递归剔除 secret_ref 等兜底。

新增:callSwarmRuntimeJSON(通用 JSON 调用 + envelope 归一,错误信息不含请求体)、
agentRuntimeSwarm{Input,Result}Path(默认 create-path 基路径,SWARM_RUNTIME_{INPUT,RESULT}_PATH 可覆盖)。

测试:swarm_io_proxy_test.go 覆盖路径构造(默认/空回退/env 覆盖)、input 转发(方法/路径/鉴权/body)、
result 解析 + deliverable/artifacts 脱敏、运行时非 2xx 报错。go build ./... + controller/router 全绿。

影响面:Manager ✅(透传端点)/ agent_swarm ✅(消费 PR#41 契约)/ Client ✅(驾驶舱 I/O 闭环)/
Agent ❌ / CodeGW ❌ / 计费 ❌ / 密钥 ❌(不经手 secret)/ 审计 ✅(input_appended,不含原文)/ 发布链路 ❌

Refs #46 #28。Depends-on-contract: agent_swarm PR#41 (已合并 main)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 18:41:39 +08:00
co-authored by Claude Opus 4.8
parent 219bbf1836
commit 2c60af0216
4 changed files with 302 additions and 0 deletions
+161
View File
@@ -418,6 +418,167 @@ func callSwarmRuntimeStop(ctx context.Context, cfg agentRuntimeConfig, dep model
}, nil
}
// callSwarmRuntimeJSON 向 Swarm 运行时发起一次 JSON 请求并归一化 envelope(复用 stop 的解析 helper)。
// 返回 data(envelope.data 或顶层)、HTTP 状态、错误。错误信息仅含 HTTP 状态 + 运行时响应体,
// 绝不含调用方请求体(如 /input 的指令文本)。
func callSwarmRuntimeJSON(ctx context.Context, cfg agentRuntimeConfig, method, endpoint string, body []byte, dep model.AgentDeployment) (map[string]any, int, error) {
var reader io.Reader
if len(body) > 0 {
reader = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, endpoint, reader)
if err != nil {
return nil, 0, err
}
if len(body) > 0 {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(cfg.Token))
if cid := strings.TrimSpace(dep.CorrelationID); cid != "" {
req.Header.Set("X-Correlation-ID", cid)
}
client := &http.Client{Timeout: cfg.Timeout}
resp, err := client.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if readErr != nil {
return nil, resp.StatusCode, readErr
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, resp.StatusCode, fmt.Errorf("runtime returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
}
var envelope map[string]any
if len(respBody) > 0 {
if err := common.Unmarshal(respBody, &envelope); err != nil {
return nil, resp.StatusCode, err
}
}
if message := agentRuntimeEnvelopeError(envelope); message != "" {
return nil, resp.StatusCode, errors.New(message)
}
return extractAgentRuntimeData(envelope), resp.StatusCode, nil
}
// HeicodeAppendSwarmInput: POST /api/heicode/swarms/:id/input — 追加用户输入(写路,代理到 Swarm)。
// agent_swarm PR#41 runtime-contract:POST …/{deployment_id}/input,注入 source=user_append 任务
// (终态 run 自动 reopen 为 running;stopped 拒绝)。**指令文本仅转发,绝不落 HM 日志/审计/事件**
// (#40「原文不进事件流」+ #46「勿落日志」)。与 stop 一样需运行时开关 + base_url + 服务令牌,缺一即拒,
// 绝不伪造受理。
func HeicodeAppendSwarmInput(c *gin.Context) {
dep, ok := findUserSwarmDeployment(c)
if !ok {
return
}
cfg := agentRuntimeClientConfigForMode(agentRuntimeModeSwarm)
if !cfg.Enabled || strings.TrimSpace(cfg.BaseURL) == "" || strings.TrimSpace(cfg.Token) == "" {
agentError(c, "POLICY_REJECTED",
"swarm input not enabled: requires SWARM_RUNTIME_ENABLED=true with SWARM_RUNTIME_BASE_URL and SWARM_RUNTIME_SERVICE_TOKEN")
return
}
var body struct {
Instruction string `json:"instruction"`
}
if err := c.ShouldBindJSON(&body); err != nil {
agentError(c, "POLICY_REJECTED", err.Error())
return
}
if strings.TrimSpace(body.Instruction) == "" {
agentError(c, "POLICY_REJECTED", "instruction is required")
return
}
runtimeID := firstNonEmpty(strings.TrimSpace(dep.RuntimeDeploymentID), strings.TrimSpace(dep.RuntimeSwarmID))
if runtimeID == "" {
agentError(c, "POLICY_REJECTED", "swarm run has no runtime deployment id yet")
return
}
endpoint, err := agentRuntimeURL(cfg.BaseURL, agentRuntimeSwarmInputPath(cfg, runtimeID))
if err != nil {
agentError(c, "RUNTIME_UNAVAILABLE", err.Error())
return
}
payload, err := common.Marshal(gin.H{
"instruction": body.Instruction,
"manager_deployment_id": dep.DeploymentID,
})
if err != nil {
agentError(c, "POLICY_REJECTED", err.Error())
return
}
data, _, err := callSwarmRuntimeJSON(c.Request.Context(), cfg, http.MethodPost, endpoint, payload, dep)
if err != nil {
// err 仅含 HTTP 状态 + 运行时响应体,不含指令文本。
common.SysLog("HeicodeAppendSwarmInput: " + err.Error())
agentError(c, "RUNTIME_UNAVAILABLE", "failed to append input at runtime: "+err.Error())
return
}
// 审计:只记发生过一次 append,**不记指令文本**。
recordAgentAuditEvent(agentEvent{
EventID: "evt_" + common.GetUUID()[:12],
Event: "swarm.input_appended",
SchemaVersion: 1,
UserID: dep.UserID,
ChannelID: dep.ChannelID,
BindingScope: dep.BindingScope,
DeploymentID: dep.DeploymentID,
CorrelationID: dep.CorrelationID,
OccurredAt: agentNow(),
}, "agent_swarm_query", dep.DeploymentID, agentRequestID(c), "ok")
common.ApiSuccess(c, gin.H{
"deployment_id": dep.DeploymentID,
"swarm_id": dep.RuntimeSwarmID,
"accepted": true,
"task_id": stringFromMap(data, "task_id"),
"runtime_status": stringFromMap(data, "status", "runtime_status"),
"note": "input appended; original instruction is not echoed into the event stream",
})
}
// HeicodeGetSwarmResult: GET /api/heicode/swarms/:id/result — 用户面结果(读路,代理到 Swarm)。
// agent_swarm PR#41 runtime-contract:GET …/{deployment_id}/result →
// {summary, deliverable, artifacts[], termination_reason, status};产物内容按各 artifact 的 uri
// (git/runtime)取,非内联。透出前递归剔除敏感键(stripSensitiveKeys)兜底,绝不含 secret_ref 等。
// 读路:需 base_url + 服务令牌(运行时可达),不强制 SWARM_RUNTIME_ENABLED(与派发开关解耦)。
func HeicodeGetSwarmResult(c *gin.Context) {
dep, ok := findUserSwarmDeployment(c)
if !ok {
return
}
cfg := agentRuntimeClientConfigForMode(agentRuntimeModeSwarm)
if strings.TrimSpace(cfg.BaseURL) == "" || strings.TrimSpace(cfg.Token) == "" {
agentError(c, "POLICY_REJECTED",
"swarm result not available: requires SWARM_RUNTIME_BASE_URL and SWARM_RUNTIME_SERVICE_TOKEN")
return
}
runtimeID := firstNonEmpty(strings.TrimSpace(dep.RuntimeDeploymentID), strings.TrimSpace(dep.RuntimeSwarmID))
if runtimeID == "" {
agentError(c, "POLICY_REJECTED", "swarm run has no runtime deployment id yet")
return
}
endpoint, err := agentRuntimeURL(cfg.BaseURL, agentRuntimeSwarmResultPath(cfg, runtimeID))
if err != nil {
agentError(c, "RUNTIME_UNAVAILABLE", err.Error())
return
}
data, _, err := callSwarmRuntimeJSON(c.Request.Context(), cfg, http.MethodGet, endpoint, nil, dep)
if err != nil {
common.SysLog("HeicodeGetSwarmResult: " + err.Error())
agentError(c, "RUNTIME_UNAVAILABLE", "failed to fetch swarm result at runtime: "+err.Error())
return
}
common.ApiSuccess(c, gin.H{
"deployment_id": dep.DeploymentID,
"swarm_id": dep.RuntimeSwarmID,
"status": stringFromMap(data, "status"),
"summary": stringFromMap(data, "summary"),
"termination_reason": stringFromMap(data, "termination_reason"),
"deliverable": stripSensitiveKeys(data["deliverable"]),
"artifacts": stripSensitiveKeys(data["artifacts"]),
})
}
// swarmTerminalEventTypes 是 agent_swarm event-schema v1(#15)的运行终态事件。命中即可结束 SSE 流。
var swarmTerminalEventTypes = map[string]bool{
"swarm.completed": true,