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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user