Merge pull request #698 from pikasTech/fix/v02-context-completed-693

fix: preserve AgentRun completed turn context
This commit is contained in:
Lyon
2026-06-02 12:39:42 +08:00
committed by GitHub
4 changed files with 252 additions and 10 deletions
@@ -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。
+110 -1
View File
@@ -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 {
+54 -3
View File
@@ -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);
+83 -6
View File
@@ -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));