diff --git a/docs/reference/spec-v02-hwlab-cloud-api.md b/docs/reference/spec-v02-hwlab-cloud-api.md index e80625b1..cd8266af 100644 --- a/docs/reference/spec-v02-hwlab-cloud-api.md +++ b/docs/reference/spec-v02-hwlab-cloud-api.md @@ -18,6 +18,7 @@ - `internal/cloud/code-agent-*.ts` 负责 Codex stdio session、trace store、result cache、provider profile 和取消/轮询。 - AgentRun v0.1 接入只使用标准 `threadId` 路径:`POST /v1/agent/chat` 收到的 `conversationId/sessionId/threadId` 必须写入 AgentRun command `payload.threadId` 和 `SessionRef.threadId`;协议字段、trace、result 和 conversation facts 都以该字段为唯一 thread identity。 - AgentRun run 级 events 写回 HWLAB trace 时必须按当前 `commandId` 归属过滤;同一 run 的旧 command 尾部事件不能混入后续 command trace。取消、失败或 blocked 轮次如果已有 assistant/tool 可读上下文,必须以脱敏、限长的 partial context 写入 conversation facts,供后续 `inspect`/`--from-trace` 和同一 thread 的下一轮使用。 +- AgentRun completed 轮次续接不能只依赖 runner 内部 `threadId` 记忆;Cloud API 必须从请求、account workspace 或 account conversation 中取同一 `conversationId/sessionId/threadId` 的历史消息,生成脱敏、限长的 `conversationContext`,并把它并入 AgentRun command `payload.prompt`,同时保留 `payload.message` 为本轮原始用户消息。该上下文不得包含 device-pod session token、provider key 或其他 Secret 值;trace 只记录上下文来源和条数。 - Cloud API 通过 AgentRun v0.1 `runner-jobs.transientEnv` 传递本次 Code Agent turn 的短期上下文,例如 `HWLAB_RUNTIME_*`、`HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME` 和 device-pod session token。`transientEnv` 不设固定 8 项上限,新增短期上下文时必须按 name 去重、只传本次 Job 需要的 value,并继续禁止承载 GitHub token、provider key、长期 SSH key 或其他可复用 credential;文档、日志和 trace 只允许保留脱敏后的 name、来源或摘要,不打印 Secret 值。 - 同 Pod sidecar `hwlab-codex-api-forwarder` 监听 `127.0.0.1:49280/responses`,用于 `codex-api` profile 直连 hyueapi,并保持 hyueapi 在 `NO_PROXY` 中。 - `hwlab-code-agent-workspace` PVC 挂载到 `/workspace/hwlab`,用于长会话 workspace;它是 cloud-api 运行资源,不是独立用户入口。 @@ -58,6 +59,10 @@ 阅读 docs/reference/spec-v02-hwlab-cloud-api.md,然后用 cli 手动测试以下内容:在同一 `conversationId/sessionId/threadId` 中先发起一轮会产生可见 assistant/tool 进展的 Code Agent 请求并取消,再发送“回答一下刚才调查结果”这类后续消息;第二轮必须继续使用同一个标准 `threadId`,且 trace/result 不能混入上一 command 的尾部事件。 +## T2.2 + +阅读 docs/reference/spec-v02-hwlab-cloud-api.md,然后用 cli 手动测试以下内容:在同一 `conversationId/sessionId/threadId` 中先完成一轮“看看 device-pod 可用性?”这类 Code Agent 请求,再发送“总结我们刚才的对话内容”;第二轮 AgentRun command 必须包含脱敏历史上下文,并且 assistant reply 不能回答“这是本会话第一条消息”或等价的新会话结论。 + ## T3 阅读 docs/reference/spec-v02-hwlab-cloud-api.md,然后用 cli 手动测试以下内容:未登录访问 `/v1/device-pods` 必须返回认证错误;登录后访问 device-pod list/status 时必须显示 `contractVersion=device-pod-authority-v1` 和 `fake=false` 来源,不得出现 fake fallback。 diff --git a/internal/cloud/code-agent-agentrun-adapter.ts b/internal/cloud/code-agent-agentrun-adapter.ts index 7167fe21..6312523a 100644 --- a/internal/cloud/code-agent-agentrun-adapter.ts +++ b/internal/cloud/code-agent-agentrun-adapter.ts @@ -33,6 +33,9 @@ const AGENTRUN_BACKENDS = Object.freeze(["codex", "deepseek", "minimax-m3"]); const THREAD_CONTINUITY_POLICY = "hwlab-agentrun-v01-reuse-runner-thread"; const SESSION_POLICY_RUN_LOCAL = "hwlab-agentrun-v01-session-runner-reuse"; const TERMINAL_RUN_STATUSES = new Set(["completed", "failed", "blocked", "cancelled", "canceled"]); +const DEFAULT_CONVERSATION_CONTEXT_MESSAGE_LIMIT = 8; +const DEFAULT_CONVERSATION_CONTEXT_MESSAGE_TEXT_LIMIT = 900; +const DEFAULT_CONVERSATION_CONTEXT_TOTAL_TEXT_LIMIT = 4_800; const HWLAB_RESOURCE_TOOL_ALIASES = Object.freeze([ Object.freeze({ name: "hwpod", path: "tools/device-pod-cli.mjs", kind: "node-script" }) ]); @@ -519,12 +522,26 @@ function buildAgentRunCreateRunInput({ params, env, traceId, backendProfile, ses function buildAgentRunCommandInput({ params, traceId, backendProfile, sessionId }) { const prompt = String(params.message ?? params.prompt ?? "").trim(); + const conversationContext = buildAgentRunConversationContext(params, prompt, traceId); + const commandPrompt = conversationContext.promptPrefix ? `${conversationContext.promptPrefix}\n\n[HWLAB 当前用户消息]\n${prompt}` : prompt; const threadId = safeOpaqueId(params.threadId); return { type: "turn", payload: { - prompt, + prompt: commandPrompt, message: prompt, + ...(conversationContext.messages.length > 0 + ? { + originalPrompt: prompt, + conversationContext: { + source: conversationContext.source, + messageCount: conversationContext.messages.length, + truncated: conversationContext.truncated, + valuesRedacted: true, + messages: conversationContext.messages + } + } + : {}), traceId, conversationId: safeConversationId(params.conversationId) || null, sessionId: sessionId ?? scopedAgentRunSessionIdForParams(params, traceId, backendProfile), @@ -540,6 +557,98 @@ function buildAgentRunCommandInput({ params, traceId, backendProfile, sessionId }; } +function buildAgentRunConversationContext(params = {}, currentPrompt = "", traceId = "") { + const messages = conversationContextMessages(params, currentPrompt, traceId); + if (messages.length === 0) return { source: "none", messages: [], promptPrefix: "", truncated: false }; + const lines = [ + "[HWLAB 同一会话的已脱敏历史上下文]", + "以下内容来自当前 conversation/session/thread 的上一轮消息;请用它延续对话,不要把本轮当作新会话。" + ]; + for (const message of messages) { + const role = message.role === "assistant" ? "Assistant" : "User"; + lines.push(`${role}: ${message.text}`); + } + return { + source: conversationContextSource(params), + messages, + promptPrefix: lines.join("\n"), + truncated: Boolean(params.conversationContext?.truncated || params.conversationContextTruncated) + }; +} + +function conversationContextMessages(params = {}, currentPrompt = "", traceId = "") { + const rawMessages = Array.isArray(params.conversationContext?.messages) + ? params.conversationContext.messages + : Array.isArray(params.messages) + ? params.messages + : []; + if (rawMessages.length === 0) return []; + const current = normalizeContextText(currentPrompt); + const currentTraceId = safeTraceId(traceId ?? params.traceId); + const normalized = []; + let totalTextLength = 0; + for (const raw of rawMessages) { + const message = normalizeConversationContextMessage(raw); + if (!message) continue; + if (currentTraceId && safeTraceId(raw?.traceId ?? raw?.runnerTrace?.traceId) === currentTraceId) continue; + if (message.text === current) continue; + normalized.push(message); + } + const selected = []; + for (const message of normalized.slice(-DEFAULT_CONVERSATION_CONTEXT_MESSAGE_LIMIT).reverse()) { + if (totalTextLength >= DEFAULT_CONVERSATION_CONTEXT_TOTAL_TEXT_LIMIT) break; + const remaining = DEFAULT_CONVERSATION_CONTEXT_TOTAL_TEXT_LIMIT - totalTextLength; + const text = boundContextText(message.text, Math.min(DEFAULT_CONVERSATION_CONTEXT_MESSAGE_TEXT_LIMIT, remaining)); + if (!text) continue; + selected.push({ ...message, text }); + totalTextLength += text.length; + } + return selected.reverse(); +} + +function normalizeConversationContextMessage(raw) { + if (!raw || typeof raw !== "object") return null; + const role = normalizeConversationContextRole(raw.role ?? raw.author ?? raw.source ?? raw.type); + if (!role) return null; + const text = boundContextText(normalizeContextText(firstNonEmpty(raw.text, raw.content, raw.message, raw.reply?.content, raw.result?.reply?.content)), DEFAULT_CONVERSATION_CONTEXT_MESSAGE_TEXT_LIMIT); + if (!text) return null; + return { + role, + text, + traceId: safeTraceId(raw.traceId ?? raw.runnerTrace?.traceId) || null, + status: firstNonEmpty(raw.status, raw.runnerTrace?.sessionStatus, raw.sessionStatus) || null + }; +} + +function normalizeConversationContextRole(value) { + const role = String(value ?? "").trim().toLowerCase(); + if (["user", "human", "operator"].includes(role)) return "user"; + if (["assistant", "agent", "code-agent", "code_agent", "model"].includes(role)) return "assistant"; + return null; +} + +function conversationContextSource(params = {}) { + return firstNonEmpty(params.conversationContext?.source, params.contextSource, Array.isArray(params.conversationContext?.messages) ? "server" : "request-messages") || "request-messages"; +} + +function normalizeContextText(value) { + return String(value ?? "").replace(/\s+/gu, " ").trim(); +} + +function boundContextText(value, limit) { + const text = redactContextSecrets(normalizeContextText(value)); + if (!text) return ""; + if (text.length <= limit) return text; + return `${text.slice(0, Math.max(0, limit - 15)).trimEnd()} ...[truncated]`; +} + +function redactContextSecrets(value) { + return String(value ?? "") + .replace(/\b(sk|pk|ak|pat|ghp|github_pat|glpat|xox[baprs]?|AIza)[A-Za-z0-9_\-]{8,}\b/gu, "[REDACTED_SECRET]") + .replace(/\b[A-Za-z0-9+/]{32,}={0,2}\b/gu, "[REDACTED_TOKEN]") + .replace(/(token|secret|password|passwd|api[_-]?key|credential)\s*[:=]\s*[^\s,;]+/giu, "$1=[REDACTED]"); +} + function buildAgentRunSteerCommandInput({ params, traceId, steerTraceId, mapped }) { const prompt = String(params.message ?? params.prompt ?? params.text ?? "").trim(); return { diff --git a/internal/cloud/server-agent-chat.test.ts b/internal/cloud/server-agent-chat.test.ts index e21ffa87..eee7f595 100644 --- a/internal/cloud/server-agent-chat.test.ts +++ b/internal/cloud/server-agent-chat.test.ts @@ -140,6 +140,7 @@ test("cloud api /v1/agent/chat supports short submit and result polling", async test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapter", async () => { const calls = []; const ownerSessions = new Map(); + const conversationMessages = []; const agentRunServer = createHttpServer(async (request, response) => { const url = new URL(request.url || "/", "http://127.0.0.1"); const chunks = []; @@ -202,7 +203,23 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte assert.equal(body.payload.threadId, "019e8078-db67-7750-a5d9-1a99f3abd445"); assert.equal(body.payload.threadContinuityPolicy, "hwlab-agentrun-v01-reuse-runner-thread"); assert.equal(body.payload.sessionPolicy, "hwlab-agentrun-v01-session-runner-reuse"); - const secondTurn = /第二轮/u.test(body.payload.prompt); + const secondTurn = /第二轮/u.test(body.payload.message ?? body.payload.prompt); + if (secondTurn) { + assert.match(body.payload.prompt, /HWLAB 同一会话的已脱敏历史上下文/u); + assert.match(body.payload.prompt, /看看device-pod可用性/u); + assert.match(body.payload.prompt, /v0\.2 device-pod 可用性快照/u); + assert.match(body.payload.prompt, /总结我们刚才的对话内容/u); + assert.equal(body.payload.message, "AgentRun adapter smoke 第二轮:总结我们刚才的对话内容"); + assert.equal(body.payload.originalPrompt, body.payload.message); + assert.equal(body.payload.conversationContext.source, "account-conversation"); + assert.equal(body.payload.conversationContext.messageCount, 2); + assert.equal(body.payload.conversationContext.valuesRedacted, true); + assert.equal(JSON.stringify(body.payload.conversationContext).includes("test-device-pod-session-token"), false); + assert.equal(body.payload.prompt.includes("test-device-pod-session-token"), false); + assert.equal(body.payload.prompt.includes("sk-test-device-secret"), false); + } else { + assert.equal(Object.hasOwn(body.payload, "conversationContext"), false); + } return send({ id: secondTurn ? "cmd_hwlab_adapter_second" : "cmd_hwlab_adapter", runId: "run_hwlab_adapter", state: "pending", type: "turn", seq: secondTurn ? 2 : 1 }); } if (request.method === "POST" && url.pathname === "/api/v1/runs/run_hwlab_adapter/runner-jobs") { @@ -316,7 +333,7 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte accessController: { required: false, async authenticate() { - return { ok: true, actor: null, session: null }; + return { ok: true, actor: { id: "usr_agent_owner", username: "agent-owner", displayName: "Agent Owner", role: "user", status: "active" }, session: { id: "auth_ses_agent_owner" } }; }, async createCodeAgentDevicePodSession() { return { token: "test-device-pod-session-token" }; @@ -327,6 +344,13 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte }, async getAgentSession(sessionId) { return ownerSessions.get(sessionId) ?? null; + }, + async visibleConversationForActor(actor, conversationId, projectId) { + assert.equal(actor.id, "usr_agent_owner"); + assert.equal(actor.role, "user"); + assert.equal(conversationId, "cnv_server-test-agentrun"); + assert.equal(projectId, "pikasTech/HWLAB"); + return { conversationId, messages: conversationMessages }; } } }); @@ -340,6 +364,7 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte headers: { "content-type": "application/json", "x-trace-id": traceId }, body: JSON.stringify({ conversationId: "cnv_server-test-agentrun", + projectId: "pikasTech/HWLAB", sessionId: "ses_server-test-agentrun", ownerUserId: "usr_agent_owner", ownerRole: "user", @@ -392,6 +417,30 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte assert.equal(commandTraceEvent.createdAt, "2026-06-01T00:00:00.500Z"); assert.match(commandTraceEvent.command, /hwpod profile list/u); assert.match(commandTraceEvent.stdoutSummary, /profile\.list/u); + conversationMessages.push( + { + id: "msg_693_user", + role: "user", + text: "看看device-pod可用性?", + status: "sent", + traceId, + conversationId: "cnv_server-test-agentrun", + sessionId: "ses_server-test-agentrun", + threadId: "019e8078-db67-7750-a5d9-1a99f3abd445", + createdAt: "2026-06-02T00:00:00.000Z" + }, + { + id: "msg_693_agent", + role: "agent", + text: "v0.2 device-pod 可用性快照:可用;token=test-device-pod-session-token sk-test-device-secret-000000", + status: "completed", + traceId, + conversationId: "cnv_server-test-agentrun", + sessionId: "ses_server-test-agentrun", + threadId: "019e8078-db67-7750-a5d9-1a99f3abd445", + createdAt: "2026-06-02T00:00:01.000Z" + } + ); const steer = await fetch(`http://127.0.0.1:${port}/v1/agent/chat/steer`, { method: "POST", @@ -400,6 +449,7 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte traceId, steerTraceId: "trc_steer_server_test", conversationId: "cnv_server-test-agentrun", + projectId: "pikasTech/HWLAB", sessionId: "ses_server-test-agentrun", threadId: "019e8078-db67-7750-a5d9-1a99f3abd445", message: "请按 STEER_MARK 调整最终回复" @@ -422,11 +472,12 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte headers: { "content-type": "application/json", "x-trace-id": secondTraceId }, body: JSON.stringify({ conversationId: "cnv_server-test-agentrun", + projectId: "pikasTech/HWLAB", sessionId: "ses_server-test-agentrun", ownerUserId: "usr_agent_owner", ownerRole: "user", threadId: "019e8078-db67-7750-a5d9-1a99f3abd445", - message: "AgentRun adapter smoke 第二轮" + message: "AgentRun adapter smoke 第二轮:总结我们刚才的对话内容" }) }); assert.equal(second.status, 202); diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index aaf69822..e70d442f 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -88,10 +88,11 @@ export async function handleCodeAgentChatHttp(request, response, options) { }; const workspaceClaim = await claimWorkbenchWorkspaceTurn({ params: chatParams, options, traceId, response }); if (workspaceClaim?.blocked) return; + const hydratedChatParams = await hydrateCodeAgentConversationContext({ params: chatParams, options, workspaceClaim, traceId }); if (codeAgentChatShortConnectionRequested(request, params, options)) { submitCodeAgentChatTurn({ - params: chatParams, + params: hydratedChatParams, options, traceId }); @@ -104,8 +105,8 @@ export async function handleCodeAgentChatHttp(request, response, options) { traceId, workspaceId: workspaceClaim?.workspace?.id ?? null, workspaceRevision: workspaceClaim?.workspace?.revision ?? null, - conversationId: safeConversationId(params.conversationId) || null, - sessionId: safeSessionId(params.sessionId) || null, + conversationId: safeConversationId(hydratedChatParams.conversationId) || null, + sessionId: safeSessionId(hydratedChatParams.sessionId) || null, traceUrl, resultUrl: `/v1/agent/chat/result/${encodeURIComponent(traceId)}`, streamUrl: `${traceUrl}/stream`, @@ -119,9 +120,9 @@ export async function handleCodeAgentChatHttp(request, response, options) { return; } - const payload = await runCodeAgentChat(chatParams, options); - await recordCodeAgentSessionOwner({ payload, params: chatParams, options, status: payload.status === "completed" ? "active" : payload.status }); - const responsePayload = annotateOwner(payload, chatParams); + const payload = await runCodeAgentChat(hydratedChatParams, options); + await recordCodeAgentSessionOwner({ payload, params: hydratedChatParams, options, status: payload.status === "completed" ? "active" : payload.status }); + const responsePayload = annotateOwner(payload, hydratedChatParams); sendJson(response, responsePayload.status === "failed" && responsePayload.error?.code === "invalid_params" ? 400 : 200, responsePayload); } @@ -130,6 +131,82 @@ async function runCodeAgentChat(params, options) { return handleCodeAgentChat(params, await codeAgentChatExecutionOptions(options, params)); } +async function hydrateCodeAgentConversationContext({ params = {}, options = {}, workspaceClaim = null, traceId } = {}) { + if (Array.isArray(params.conversationContext?.messages) && params.conversationContext.messages.length > 0) return params; + const directMessages = Array.isArray(params.messages) ? params.messages : []; + if (directMessages.length > 0) return { ...params, conversationContext: codeAgentConversationContextPayload({ messages: directMessages, source: "request-messages" }) }; + const workspaceMessages = messagesFromWorkbenchWorkspace(workspaceClaim?.workspace); + if (workspaceMessages.length > 0) { + return attachCodeAgentConversationContext(params, workspaceMessages, "workbench-workspace", traceId, options); + } + const conversationId = safeConversationId(params.conversationId ?? workspaceClaim?.workspace?.selectedConversationId); + if (!options.actor || !conversationId || typeof options.accessController?.visibleConversationForActor !== "function") return params; + try { + const conversation = await options.accessController.visibleConversationForActor(options.actor, conversationId, params.projectId ?? workspaceClaim?.workspace?.projectId); + const messages = Array.isArray(conversation?.messages) ? conversation.messages : Array.isArray(conversation?.snapshot?.messages) ? conversation.snapshot.messages : []; + return messages.length > 0 ? attachCodeAgentConversationContext(params, messages, "account-conversation", traceId, options) : params; + } catch (error) { + (options.traceStore ?? defaultCodeAgentTraceStore).append(traceId, { + type: "conversation-context", + status: "degraded", + label: "conversation-context:lookup-failed", + errorCode: error?.code ?? "conversation_context_lookup_failed", + message: "Stored conversation context lookup failed; this turn will continue with threadId/sessionId only.", + valuesPrinted: false + }); + return params; + } +} + +function messagesFromWorkbenchWorkspace(workspace = null) { + const json = workspace?.workspace && typeof workspace.workspace === "object" ? workspace.workspace : {}; + if (Array.isArray(json.messages)) return json.messages; + if (Array.isArray(workspace?.messages)) return workspace.messages; + return []; +} + +function attachCodeAgentConversationContext(params, messages, source, traceId, options = {}) { + const payload = codeAgentConversationContextPayload({ messages, source }); + if (payload.messages.length === 0) return params; + (options.traceStore ?? defaultCodeAgentTraceStore).append(traceId, { + type: "conversation-context", + status: "running", + label: "conversation-context:attached", + message: `Attached ${payload.messages.length} redacted prior conversation messages for AgentRun thread continuation.`, + source, + messageCount: payload.messages.length, + valuesPrinted: false + }); + return { ...params, conversationContext: payload }; +} + +function codeAgentConversationContextPayload({ messages = [], source = "unknown" } = {}) { + const selected = Array.isArray(messages) ? messages.slice(-12).map(publicConversationContextMessage).filter(Boolean) : []; + return { + source, + messages: selected, + truncated: Array.isArray(messages) && messages.length > selected.length, + valuesRedacted: true + }; +} + +function publicConversationContextMessage(message) { + if (!message || typeof message !== "object") return null; + const role = textValue(message.role); + const text = textValue(firstNonEmptyValue(message.text, message.content, message.message, message.reply?.content)); + if (!role || !text) return null; + return { + role, + text, + status: textValue(message.status), + traceId: safeTraceId(message.traceId ?? message.runnerTrace?.traceId) || null, + sessionId: safeSessionId(message.sessionId) || null, + threadId: safeOpaqueId(message.threadId) || null, + createdAt: textValue(message.createdAt), + updatedAt: textValue(message.updatedAt) + }; +} + async function codeAgentChatExecutionOptions(options = {}, params = {}) { const env = codeAgentProviderProfileEnv(options.env ?? process.env, params); Object.assign(env, await codeAgentDevicePodAuthEnv(options, env));