diff --git a/internal/cloud/code-agent-chat.mjs b/internal/cloud/code-agent-chat.mjs index e9a09477..5a4aa801 100644 --- a/internal/cloud/code-agent-chat.mjs +++ b/internal/cloud/code-agent-chat.mjs @@ -125,6 +125,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) { const messageId = `msg_${randomUUID()}`; const traceId = cleanProtocolId(params.traceId, "trc") || `trc_${randomUUID()}`; const providerPlan = resolveProviderPlan(options.env ?? process.env, options); + const sessionRegistry = resolveCodeAgentSessionRegistry(options); const base = { conversationId, sessionId, @@ -156,10 +157,28 @@ export async function handleCodeAgentChat(params = {}, options = {}) { skillsDirs: options.skillsDirs, skillsDirsExact: options.skillsDirsExact, skillsDiscoveryDelayMs: options.skillsDiscoveryDelayMs, - sessionRegistry: options.sessionRegistry, + sessionRegistry, codexStdioManager: options.codexStdioManager }); - return completedRunnerPayload({ base, runnerResult, messageId, now: options.now }); + return completedRunnerPayload({ base, runnerResult, messageId, now: options.now, sessionRegistry }); + } + + if (runnerIntent.kind === "session_context") { + const runnerResult = await callReadOnlyRunner({ + intent: runnerIntent, + conversationId, + traceId, + sessionId: requestedSessionId, + env: options.env ?? process.env, + now: options.now, + workspace: options.workspace, + skillsDirs: options.skillsDirs, + skillsDirsExact: options.skillsDirsExact, + skillsDiscoveryDelayMs: options.skillsDiscoveryDelayMs, + sessionRegistry, + codexStdioManager: options.codexStdioManager + }); + return completedRunnerPayload({ base, runnerResult, messageId, now: options.now, sessionRegistry }); } const codexStdioAvailability = await inspectCodexStdioFeasibility(options.env ?? process.env, options); @@ -194,7 +213,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) { model: providerPlan.model, codexStdioManager: options.codexStdioManager }); - return completedRunnerPayload({ base, runnerResult: stdioResult, messageId, now: options.now }); + return completedRunnerPayload({ base, runnerResult: stdioResult, messageId, now: options.now, sessionRegistry }); } if (runnerIntent.kind === "m3_io") { @@ -206,11 +225,11 @@ export async function handleCodeAgentChat(params = {}, options = {}) { env: options.env ?? process.env, now: options.now, workspace: options.workspace, - sessionRegistry: options.sessionRegistry, + sessionRegistry, requestJson: options.m3IoSkillRequestJson, codexStdioManager: options.codexStdioManager }); - return completedRunnerPayload({ base, runnerResult, messageId, now: options.now }); + return completedRunnerPayload({ base, runnerResult, messageId, now: options.now, sessionRegistry }); } if (runnerIntent.kind !== "none") { @@ -225,10 +244,10 @@ export async function handleCodeAgentChat(params = {}, options = {}) { skillsDirs: options.skillsDirs, skillsDirsExact: options.skillsDirsExact, skillsDiscoveryDelayMs: options.skillsDiscoveryDelayMs, - sessionRegistry: options.sessionRegistry, + sessionRegistry, codexStdioManager: options.codexStdioManager }); - return completedRunnerPayload({ base, runnerResult, messageId, now: options.now }); + return completedRunnerPayload({ base, runnerResult, messageId, now: options.now, sessionRegistry }); } const providerResult = await callConfiguredProvider({ @@ -348,6 +367,13 @@ export async function handleCodeAgentChat(params = {}, options = {}) { if (error.runnerLimitations !== undefined) payload.runnerLimitations = error.runnerLimitations; if (error.codexStdioFeasibility !== undefined) payload.codexStdioFeasibility = error.codexStdioFeasibility; if (error.longLivedSessionGate !== undefined) payload.longLivedSessionGate = error.longLivedSessionGate; + if (error.conversationFacts !== undefined) { + payload.conversationFacts = error.conversationFacts; + } else if (typeof sessionRegistry.getConversationFacts === "function") { + payload.conversationFacts = sessionRegistry.getConversationFacts(conversationId); + } + if (payload.conversationFacts && payload.error?.blocker) payload.error.blocker.conversationFacts = payload.conversationFacts; + if (payload.conversationFacts && payload.blocker) payload.blocker.conversationFacts = payload.conversationFacts; if (error.availability !== undefined) { payload.availability = error.availability; } else if (["provider_unavailable", "provider_timeout", "codex_cli_binary_missing"].includes(error.code)) { @@ -359,7 +385,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) { } } -function completedRunnerPayload({ base, runnerResult, messageId, now }) { +function completedRunnerPayload({ base, runnerResult, messageId, now, sessionRegistry }) { const completedAt = nowIso(now); const blocker = structuredCompletionBlocker(runnerResult, { traceId: base.traceId, @@ -368,7 +394,7 @@ function completedRunnerPayload({ base, runnerResult, messageId, now }) { runner: runnerResult.runner, capabilityLevel: runnerResult.capabilityLevel }); - return finalizeCodeAgentChatPayload({ + const payload = finalizeCodeAgentChatPayload({ ...base, sessionId: runnerResult.session?.sessionId ?? base.sessionId, status: "completed", @@ -400,6 +426,7 @@ function completedRunnerPayload({ base, runnerResult, messageId, now }) { usage: null, providerTrace: runnerResult.providerTrace }); + return attachConversationFacts(payload, sessionRegistry, { now }); } export function validateCodeAgentChatSchema(payload) { @@ -492,6 +519,33 @@ function finalizeCodeAgentChatPayload(payload) { return payload; } +function attachConversationFacts(payload, sessionRegistry, { now } = {}) { + if (!sessionRegistry || typeof sessionRegistry.recordFact !== "function") return payload; + const conversationFacts = sessionRegistry.recordFact(payload.conversationId, { + kind: payload.toolCalls?.some((toolCall) => toolCall?.name === "session.context") ? "session_context" : "runner_turn", + conversationId: payload.conversationId, + sessionId: payload.sessionId, + traceId: payload.traceId, + provider: payload.provider, + backend: payload.backend, + workspace: payload.workspace, + sandbox: payload.sandbox, + session: payload.session, + sessionMode: payload.sessionMode, + sessionReuse: payload.sessionReuse, + implementationType: payload.implementationType, + runner: payload.runner, + runnerTrace: payload.runnerTrace, + capabilityLevel: payload.capabilityLevel, + toolCalls: payload.toolCalls, + skills: payload.skills + }, { now }); + return { + ...payload, + conversationFacts + }; +} + export async function describeCodeAgentAvailability(env = process.env, options = {}) { const providerPlan = resolveProviderPlan(env, options); const providerContract = inspectCodeAgentProviderEnv(env); @@ -970,6 +1024,7 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId, }); if (!sessionAcquire.ok) { const blockedSession = sessionAcquire.session; + const conversationFacts = registry.getConversationFacts(conversationId); const blockedCodexStdioFeasibility = await inspectCodexStdioFeasibility(env, { codexStdioManager, workspace }); const blockedTrace = runnerTrace({ traceId, @@ -992,6 +1047,7 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId, implementationType: READONLY_IMPLEMENTATION_TYPE, runnerLimitations: [...READONLY_LIMITATION_FLAGS], codexStdioFeasibility: blockedCodexStdioFeasibility, + conversationFacts, longLivedSessionGate: longLivedSessionGate({ provider: READONLY_RUNNER_PROVIDER, runnerKind: READONLY_RUNNER_KIND, @@ -1261,6 +1317,39 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId, }); } + if (intent.kind === "session_context") { + const conversationFacts = registry.getConversationFacts(conversationId); + session = releaseReadOnlySession(registry, session, { now, traceId, conversationId }); + const contextToolCall = sessionContextToolCall({ conversationFacts, workspace: resolvedWorkspace, traceId }); + return readOnlyRunnerResult({ + content: sessionContextReply({ conversationFacts, session }), + workspace: resolvedWorkspace, + toolCalls: [contextToolCall], + skills: conversationFacts.latestSkills + ? { + status: conversationFacts.latestSkills.status ?? "ready", + items: conversationFacts.latestSkills.names.map((name) => ({ name })), + count: conversationFacts.latestSkills.count ?? conversationFacts.latestSkills.names.length, + totalCount: conversationFacts.latestSkills.totalCount ?? conversationFacts.latestSkills.names.length, + blockers: [] + } + : notRequestedSkills(), + runner, + runnerTrace: runnerTrace({ + traceId, + workspace: resolvedWorkspace, + session, + events: [...events, "tool:session.context:completed"], + startedAt, + outputTruncated: false + }), + session, + codexStdioFeasibility, + outputTruncated: false, + conversationFacts + }); + } + session = registry.fail(session.sessionId, { now, traceId, @@ -1871,6 +1960,9 @@ function detectReadOnlyRunnerIntent(message) { reason: `只读 runner 不直接调用 gateway/box-simu/patch-panel、泛化硬件写接口或宣称 M3/M4/M5 验收通过;M3 DO1/DI1 只能通过 Skill CLI -> HWLAB API ${HWLAB_M3_IO_API_ROUTE}。` }; } + if (isSessionContextRequest(text)) { + return { kind: "session_context", toolName: "session.context" }; + } if (/\bpwd\b/u.test(lower) || /当前.*(?:工作目录|目录)|工作目录|当前路径|workspace path|工作区路径/iu.test(text)) { return { kind: "pwd", toolName: "pwd" }; } @@ -1897,6 +1989,13 @@ function detectReadOnlyRunnerIntent(message) { return { kind: "none" }; } +function isSessionContextRequest(text) { + const value = String(text ?? ""); + const referencesEarlierTurns = /(?:前两轮|前2轮|上一轮|上两轮|刚才|前面|之前|previous|earlier|context|上下文|根据.*(?:结果|内容|事实)|基于.*(?:结果|内容|事实))/iu.test(value); + const asksForFacts = /(?:工作目录|当前目录|workspace|路径|skills?|skill|技能|能使用|可用|session|conversation|会话|trace|runner)/iu.test(value); + return referencesEarlierTurns && asksForFacts; +} + function detectM3IoIntent(text) { const normalized = String(text ?? ""); if (!/\bM3\b|DO1|DI1|数字输出|数字输入|回读|读回|状态|status/u.test(normalized)) { @@ -2479,7 +2578,7 @@ function notRequestedSkills() { }; } -function readOnlyRunnerResult({ content, workspace, toolCalls, skills, runner, runnerTrace, session, codexStdioFeasibility, outputTruncated }) { +function readOnlyRunnerResult({ content, workspace, toolCalls, skills, runner, runnerTrace, session, codexStdioFeasibility, outputTruncated, conversationFacts }) { return { provider: READONLY_RUNNER_PROVIDER, model: READONLY_RUNNER_MODEL, @@ -2506,6 +2605,7 @@ function readOnlyRunnerResult({ content, workspace, toolCalls, skills, runner, r runner, runnerTrace, capabilityLevel: READONLY_SESSION_CAPABILITY_LEVEL, + ...(conversationFacts ? { conversationFacts } : {}), providerTrace: { runnerKind: READONLY_RUNNER_KIND, toolCalls: toolCalls.length, @@ -2518,6 +2618,55 @@ function readOnlyRunnerResult({ content, workspace, toolCalls, skills, runner, r }; } +function sessionContextToolCall({ conversationFacts, workspace, traceId }) { + const bounded = boundToolOutput(JSON.stringify({ + conversationId: conversationFacts.conversationId, + sessionId: conversationFacts.sessionId, + turnCount: conversationFacts.turnCount, + workspace: conversationFacts.workspace, + skills: { + status: conversationFacts.latestSkills?.status ?? "not_requested", + count: conversationFacts.latestSkills?.totalCount ?? conversationFacts.latestSkills?.count ?? 0, + names: conversationFacts.latestSkills?.names?.slice(0, 8) ?? [] + }, + latestTraceId: conversationFacts.latestTraceId, + recentToolCalls: conversationFacts.recentToolCalls?.slice(-6) ?? [], + valuesRedacted: true + })); + return { + id: `tool_${randomUUID()}`, + type: "session-facts", + name: "session.context", + status: "completed", + cwd: workspace, + command: "session.context", + exitCode: 0, + stdout: bounded.text, + stderrSummary: "", + outputTruncated: bounded.truncated, + traceId + }; +} + +function sessionContextReply({ conversationFacts, session }) { + const skills = conversationFacts.latestSkills; + const skillNames = skills?.names?.length ? skills.names.join(", ") : "上一轮没有可用 skill 清单"; + const skillCount = skills ? (skills.totalCount ?? skills.count ?? skills.names?.length ?? 0) : 0; + const toolSummary = conversationFacts.recentToolCalls?.length + ? conversationFacts.recentToolCalls.map((toolCall) => `${toolCall.name ?? "tool"}:${toolCall.status ?? "unknown"}`).join(", ") + : "none"; + const traceSummary = conversationFacts.traceIds?.length ? conversationFacts.traceIds.join(", ") : "none"; + return [ + "根据同一个 conversation/session 中已记录的 runner facts:", + `- conversationId=${conversationFacts.conversationId}; sessionId=${conversationFacts.sessionId}; sessionStatus=${conversationFacts.sessionStatus ?? session.status}; sessionMode=${conversationFacts.sessionMode ?? session.sessionMode}; turnCount=${conversationFacts.turnCount}.`, + `- 当前工作目录/workspace=${conversationFacts.workspace ?? session.workspace ?? "unknown"}.`, + `- 可用 skills=${skillCount}; ${skillNames}.`, + `- 最近 toolCalls=${toolSummary}; traceIds=${traceSummary}.`, + "证明:本轮走 session.context 的 Code Agent session fact 路径,runner=hwlab-readonly-runner,capabilityLevel=read-only-session-tools;不是 openai-responses-fallback,也不是 stateless-one-shot。", + limitationReplyLine() + ].join("\n"); +} + function m3IoSkillArgsForIntent(intent, { env, traceId }) { const args = [ "m3", diff --git a/internal/cloud/code-agent-session-registry.mjs b/internal/cloud/code-agent-session-registry.mjs index f9cd66fa..7aa26584 100644 --- a/internal/cloud/code-agent-session-registry.mjs +++ b/internal/cloud/code-agent-session-registry.mjs @@ -2,6 +2,9 @@ import { randomUUID } from "node:crypto"; export const DEFAULT_CODE_AGENT_SESSION_IDLE_TIMEOUT_MS = 30 * 60 * 1000; export const DEFAULT_CODE_AGENT_MAX_SESSIONS = 200; +export const DEFAULT_CODE_AGENT_MAX_CONVERSATION_FACTS = 12; +export const DEFAULT_CODE_AGENT_MAX_FACT_TOOL_CALLS = 8; +export const DEFAULT_CODE_AGENT_MAX_FACT_SKILLS = 24; export const CODE_AGENT_SESSION_STATUSES = Object.freeze([ "creating", "ready", @@ -15,10 +18,14 @@ export const CODE_AGENT_SESSION_STATUSES = Object.freeze([ export function createCodeAgentSessionRegistry(options = {}) { const idleTimeoutMs = positiveInteger(options.idleTimeoutMs, DEFAULT_CODE_AGENT_SESSION_IDLE_TIMEOUT_MS); const maxSessions = positiveInteger(options.maxSessions, DEFAULT_CODE_AGENT_MAX_SESSIONS); + const maxConversationFacts = positiveInteger(options.maxConversationFacts, DEFAULT_CODE_AGENT_MAX_CONVERSATION_FACTS); + const maxFactToolCalls = positiveInteger(options.maxFactToolCalls, DEFAULT_CODE_AGENT_MAX_FACT_TOOL_CALLS); + const maxFactSkills = positiveInteger(options.maxFactSkills, DEFAULT_CODE_AGENT_MAX_FACT_SKILLS); const idFactory = typeof options.idFactory === "function" ? options.idFactory : () => `ses_${randomUUID()}`; const defaultNow = options.now; const sessions = new Map(); const conversations = new Map(); + const conversationFacts = new Map(); function acquire(params = {}) { const timestamp = timestampFor(params.now ?? defaultNow); @@ -166,6 +173,40 @@ export function createCodeAgentSessionRegistry(options = {}) { return sessionId ? get(sessionId, { ...params, conversationId }) : null; } + function recordFact(conversationIdValue, fact, params = {}) { + const conversationId = requiredId(conversationIdValue, "cnv"); + const timestamp = timestampFor(params.now ?? defaultNow); + const sanitized = sanitizeConversationFact(fact, { + conversationId, + timestamp, + maxFactToolCalls, + maxFactSkills + }); + if (!sanitized) return getConversationFacts(conversationId); + const existing = conversationFacts.get(conversationId) ?? { + conversationId, + facts: [] + }; + existing.facts.push(sanitized); + existing.facts = existing.facts.slice(-maxConversationFacts); + conversationFacts.set(conversationId, existing); + return getConversationFacts(conversationId); + } + + function getConversationFacts(conversationIdValue, params = {}) { + const conversationId = requiredId(conversationIdValue, "cnv"); + const stored = conversationFacts.get(conversationId); + const facts = stored?.facts ?? []; + const session = getByConversation(conversationId, params); + return publicConversationFacts({ + conversationId, + session, + facts, + maxFactToolCalls, + maxConversationFacts + }); + } + function expireIdle(params = {}) { const timestamp = timestampFor(params.now ?? defaultNow); const timestampMs = Date.parse(timestamp); @@ -186,7 +227,9 @@ export function createCodeAgentSessionRegistry(options = {}) { implementationType: "controlled-readonly-session-registry", status: "available", sessionCount: sessions.size, + conversationFactCount: conversationFacts.size, maxSessions, + maxConversationFacts, idleTimeoutMs, statuses: [...CODE_AGENT_SESSION_STATUSES], sessionMode: "controlled-readonly-session-registry", @@ -205,6 +248,7 @@ export function createCodeAgentSessionRegistry(options = {}) { function clear() { sessions.clear(); conversations.clear(); + conversationFacts.clear(); } function prune() { @@ -217,6 +261,7 @@ export function createCodeAgentSessionRegistry(options = {}) { if (conversations.get(conversationId) === sessionId) { conversations.delete(conversationId); } + conversationFacts.delete(conversationId); } } } @@ -228,6 +273,8 @@ export function createCodeAgentSessionRegistry(options = {}) { interrupt, get, getByConversation, + recordFact, + getConversationFacts, expireIdle, describe, clear @@ -290,6 +337,163 @@ function publicSession(session, { conversationId = null, reused = null } = {}) { }; } +function publicConversationFacts({ conversationId, session, facts, maxFactToolCalls, maxConversationFacts }) { + const latest = facts.at(-1) ?? null; + const latestSkills = [...facts].reverse().find((fact) => fact.skills?.status && fact.skills.status !== "not_requested")?.skills ?? null; + const latestWorkspace = [...facts].reverse().find((fact) => fact.workspace)?.workspace ?? session?.workspace ?? null; + const traceIds = [...new Set(facts.map((fact) => fact.traceId).filter(Boolean))].slice(-6); + const recentToolCalls = facts + .flatMap((fact) => fact.toolCalls?.items ?? []) + .slice(-maxFactToolCalls); + return { + conversationId, + sessionId: session?.sessionId ?? latest?.sessionId ?? null, + sessionStatus: session?.status ?? latest?.sessionStatus ?? null, + sessionMode: session?.sessionMode ?? latest?.sessionMode ?? null, + capabilityLevel: session?.capabilityLevel ?? latest?.capabilityLevel ?? null, + runnerKind: session?.runnerKind ?? latest?.runnerKind ?? null, + workspace: latestWorkspace, + sandbox: session?.sandbox ?? latest?.sandbox ?? null, + turnCount: facts.length, + maxFacts: maxConversationFacts, + traceIds, + latestTraceId: traceIds.at(-1) ?? session?.lastTraceId ?? null, + latestSkills, + recentToolCalls, + facts: facts.map((fact) => ({ ...fact })), + valuesRedacted: true, + secretMaterialStored: false, + summary: conversationFactsSummary({ facts, session, latestSkills, latestWorkspace, traceIds, recentToolCalls }) + }; +} + +function sanitizeConversationFact(fact, { conversationId, timestamp, maxFactToolCalls, maxFactSkills }) { + if (!fact || typeof fact !== "object") return null; + const session = fact.session && typeof fact.session === "object" ? fact.session : null; + const runner = fact.runner && typeof fact.runner === "object" ? fact.runner : null; + const runnerTrace = fact.runnerTrace && typeof fact.runnerTrace === "object" ? fact.runnerTrace : null; + const toolCalls = sanitizeToolCalls(fact.toolCalls, maxFactToolCalls); + const skills = sanitizeSkillsFact(fact.skills, maxFactSkills); + return { + kind: safeFactText(fact.kind ?? "runner_turn", 80), + conversationId, + sessionId: safeFactText(session?.sessionId ?? fact.sessionId ?? runner?.sessionId, 120), + sessionStatus: safeFactText(session?.status ?? fact.sessionStatus, 40), + sessionMode: safeFactText(fact.sessionMode ?? session?.sessionMode ?? runner?.sessionMode ?? runnerTrace?.sessionMode, 120), + sessionReused: fact.sessionReuse?.reused === true || session?.reused === true || runner?.sessionReused === true, + turn: numberOrNull(session?.turn ?? fact.sessionReuse?.turn ?? runnerTrace?.turn ?? runner?.turn), + recordedAt: timestamp, + traceId: safeFactText(fact.traceId ?? runnerTrace?.traceId, 120), + provider: safeFactText(fact.provider ?? runner?.provider, 120), + backend: safeFactText(fact.backend ?? runner?.backend, 160), + runnerKind: safeFactText(runner?.kind ?? runnerTrace?.runnerKind ?? fact.runnerKind, 120), + workspace: safeFactText(fact.workspace ?? runner?.workspace ?? runnerTrace?.workspace ?? session?.workspace, 240), + sandbox: safeFactText(fact.sandbox ?? runner?.sandbox ?? runnerTrace?.sandbox ?? session?.sandbox, 120), + capabilityLevel: safeFactText(fact.capabilityLevel ?? runner?.capabilityLevel ?? session?.capabilityLevel, 120), + toolCalls, + skills, + runnerTrace: sanitizeRunnerTrace(runnerTrace), + valuesRedacted: true, + secretMaterialStored: false + }; +} + +function sanitizeToolCalls(toolCalls, limit) { + const items = Array.isArray(toolCalls) + ? toolCalls.slice(0, limit).map((toolCall) => ({ + name: safeFactText(toolCall?.name, 120), + type: safeFactText(toolCall?.type, 80), + status: safeFactText(toolCall?.status, 40), + route: safeFactText(toolCall?.route ?? toolCall?.hwlabApi?.route, 120), + method: safeFactText(toolCall?.method, 16), + traceId: safeFactText(toolCall?.traceId, 120), + outputTruncated: toolCall?.outputTruncated === true + })).filter((toolCall) => toolCall.name || toolCall.status || toolCall.route) + : []; + return { + count: Array.isArray(toolCalls) ? toolCalls.length : 0, + items, + truncated: Array.isArray(toolCalls) && toolCalls.length > items.length + }; +} + +function sanitizeSkillsFact(skills, limit) { + if (!skills || typeof skills !== "object") { + return { + status: "not_requested", + count: 0, + totalCount: 0, + names: [], + blockers: [], + truncated: false + }; + } + const items = Array.isArray(skills.items) ? skills.items : []; + const names = items + .map((item) => safeFactText(item?.name, 120)) + .filter(Boolean) + .slice(0, limit); + return { + status: safeFactText(skills.status ?? "unknown", 40), + count: numberOrNull(skills.count ?? items.length) ?? items.length, + totalCount: numberOrNull(skills.totalCount ?? skills.count ?? items.length) ?? items.length, + names, + blockers: Array.isArray(skills.blockers) + ? skills.blockers.map((blocker) => safeFactText(blocker?.code, 120)).filter(Boolean).slice(0, 6) + : [], + truncated: skills.truncated === true || items.length > names.length + }; +} + +function sanitizeRunnerTrace(runnerTrace) { + if (!runnerTrace || typeof runnerTrace !== "object") return null; + return { + traceId: safeFactText(runnerTrace.traceId, 120), + runnerKind: safeFactText(runnerTrace.runnerKind, 120), + sessionMode: safeFactText(runnerTrace.sessionMode, 120), + sessionId: safeFactText(runnerTrace.sessionId, 120), + turn: numberOrNull(runnerTrace.turn), + events: Array.isArray(runnerTrace.events) + ? runnerTrace.events.map((event) => safeFactText(event, 120)).filter(Boolean).slice(-8) + : [], + route: safeFactText(runnerTrace.route, 120), + status: safeFactText(runnerTrace.status, 80), + outputTruncated: runnerTrace.outputTruncated === true, + valuesPrinted: false + }; +} + +function conversationFactsSummary({ facts, session, latestSkills, latestWorkspace, traceIds, recentToolCalls }) { + const skillNames = latestSkills?.names?.slice(0, 5) ?? []; + const toolNames = recentToolCalls.map((toolCall) => [toolCall.name, toolCall.status].filter(Boolean).join(":")).filter(Boolean).slice(-5); + return [ + `turns=${facts.length}`, + `sessionId=${session?.sessionId ?? facts.at(-1)?.sessionId ?? "unknown"}`, + `sessionStatus=${session?.status ?? facts.at(-1)?.sessionStatus ?? "unknown"}`, + latestWorkspace ? `workspace=${latestWorkspace}` : null, + latestSkills ? `skills=${latestSkills.totalCount ?? latestSkills.count ?? skillNames.length}${skillNames.length ? ` (${skillNames.join(",")})` : ""}` : "skills=not_requested", + traceIds.length ? `traceIds=${traceIds.join(",")}` : null, + toolNames.length ? `tools=${toolNames.join(",")}` : null + ].filter(Boolean).join(" / "); +} + +function safeFactText(value, maxLength) { + if (value === undefined || value === null || value === "") return null; + const normalized = String(value) + .replace(/\s+/gu, " ") + .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gu, "Bearer ***") + .replace(/sk-[A-Za-z0-9._-]+/gu, "sk-***") + .replace(/([A-Za-z0-9_]*(?:TOKEN|KEY|SECRET|PASSWORD)[A-Za-z0-9_]*=)[^\s]+/giu, "$1***") + .replace(/\/\/[^/@\s]+@/gu, "//***@") + .trim(); + if (!normalized) return null; + return normalized.length > maxLength ? `${normalized.slice(0, maxLength - 12)}...[redacted]` : normalized; +} + +function numberOrNull(value) { + return Number.isFinite(value) ? value : null; +} + function sessionExpired(session, timestampMs) { if (!Number.isFinite(timestampMs) || session.status === "expired") return session.status === "expired"; return Date.parse(session.expiresAt) <= timestampMs; diff --git a/internal/cloud/code-agent-session-registry.test.mjs b/internal/cloud/code-agent-session-registry.test.mjs index d4abb9a0..6b5b347a 100644 --- a/internal/cloud/code-agent-session-registry.test.mjs +++ b/internal/cloud/code-agent-session-registry.test.mjs @@ -102,6 +102,104 @@ test("code agent session registry creates, reuses, and expires sessions without assert.equal(expired.blocker.sourceIssue, "pikasTech/HWLAB#317"); }); +test("code agent session registry stores bounded non-sensitive conversation facts", () => { + const registry = createCodeAgentSessionRegistry({ + maxConversationFacts: 2, + maxFactSkills: 2, + idFactory: () => "ses_fact_registry" + }); + const acquired = registry.acquire({ + conversationId: "cnv_fact_registry", + workspace: "/workspace/hwlab", + traceId: "trc_fact_registry_1", + now: "2026-05-23T00:00:00.000Z" + }); + registry.release(acquired.session.sessionId, { + conversationId: "cnv_fact_registry", + traceId: "trc_fact_registry_1", + now: "2026-05-23T00:00:00.010Z" + }); + + registry.recordFact("cnv_fact_registry", { + traceId: "trc_fact_registry_1", + provider: "codex-readonly-runner", + backend: "hwlab-cloud-api/codex-readonly-runner", + workspace: "/workspace/hwlab", + sandbox: "read-only", + session: acquired.session, + sessionMode: "controlled-readonly-session-registry", + capabilityLevel: "read-only-session-tools", + runner: { + kind: "hwlab-readonly-runner", + sessionId: "ses_fact_registry" + }, + runnerTrace: { + traceId: "trc_fact_registry_1", + runnerKind: "hwlab-readonly-runner", + sessionMode: "controlled-readonly-session-registry", + events: ["tool:pwd:completed"], + valuesPrinted: false + }, + toolCalls: [{ + name: "pwd", + status: "completed", + stdout: "must-not-store-output sk-secret-value OPENAI_API_KEY=secret", + traceId: "trc_fact_registry_1" + }], + skills: { + status: "ready", + items: [ + { name: "alpha" }, + { name: "beta" }, + { name: "gamma" } + ], + count: 3, + totalCount: 3 + } + }, { now: "2026-05-23T00:00:00.020Z" }); + registry.recordFact("cnv_fact_registry", { + traceId: "trc_fact_registry_2", + workspace: "/workspace/hwlab", + runner: { kind: "hwlab-readonly-runner" }, + toolCalls: [{ + name: "skills.discover", + status: "completed", + stdout: "must-not-store-output sk-secret-value OPENAI_API_KEY=secret" + }], + skills: { + status: "ready", + items: [ + { name: "alpha" }, + { name: "beta" }, + { name: "gamma" } + ], + count: 3, + totalCount: 3 + } + }, { now: "2026-05-23T00:00:00.030Z" }); + registry.recordFact("cnv_fact_registry", { + traceId: "trc_fact_registry_3", + workspace: "/workspace/hwlab", + runner: { kind: "hwlab-readonly-runner" }, + toolCalls: [{ name: "session.context", status: "completed" }] + }, { now: "2026-05-23T00:00:00.040Z" }); + + const facts = registry.getConversationFacts("cnv_fact_registry"); + assert.equal(facts.conversationId, "cnv_fact_registry"); + assert.equal(facts.sessionId, "ses_fact_registry"); + assert.equal(facts.workspace, "/workspace/hwlab"); + assert.equal(facts.turnCount, 2); + assert.deepEqual(facts.traceIds, ["trc_fact_registry_2", "trc_fact_registry_3"]); + assert.equal(facts.valuesRedacted, true); + assert.equal(facts.secretMaterialStored, false); + assert.deepEqual(facts.latestSkills.names, ["alpha", "beta"]); + assert.equal(facts.latestSkills.truncated, true); + assert.equal(JSON.stringify(facts).includes("must-not-store-output"), false); + assert.equal(JSON.stringify(facts).includes("sk-secret-value"), false); + assert.equal(JSON.stringify(facts).includes("OPENAI_API_KEY=secret"), false); + assert.match(facts.summary, /workspace=\/workspace\/hwlab/u); +}); + test("code agent session registry reports busy sessions as structured blocker", () => { const registry = createCodeAgentSessionRegistry({ idFactory: () => "ses_busy" @@ -260,6 +358,15 @@ test("expired, busy, and failed session states are surfaced as structured blocke traceId: "trc_expired_seed", now: "2026-05-23T00:02:00.001Z" }); + expiredRegistry.recordFact("cnv_expired_chat", { + traceId: "trc_expired_seed", + workspace: process.cwd(), + session: first.session, + runner: { kind: "hwlab-readonly-runner" }, + sessionMode: "controlled-readonly-session-registry", + capabilityLevel: "read-only-session-tools", + toolCalls: [{ name: "pwd", status: "completed" }] + }, { now: "2026-05-23T00:02:00.002Z" }); const expired = await handleCodeAgentChat( { conversationId: "cnv_expired_chat", @@ -285,6 +392,11 @@ test("expired, busy, and failed session states are surfaced as structured blocke assert.equal(expired.session.status, "expired"); assert.equal(expired.capabilityLevel, "blocked"); assert.equal(expired.longLivedSessionGate.status, "blocked"); + assert.equal(expired.conversationFacts.conversationId, "cnv_expired_chat"); + assert.equal(expired.conversationFacts.sessionId, "ses_expired_chat"); + assert.equal(expired.conversationFacts.turnCount, 1); + assert.equal(expired.conversationFacts.recentToolCalls[0].name, "pwd"); + assert.equal(expired.error.blocker.conversationFacts.sessionId, "ses_expired_chat"); const busyRegistry = createCodeAgentSessionRegistry({ idFactory: () => "ses_busy_chat" @@ -298,6 +410,21 @@ test("expired, busy, and failed session states are surfaced as structured blocke traceId: "trc_busy_seed", now: "2026-05-23T00:03:00.000Z" }); + busyRegistry.recordFact("cnv_busy_chat", { + traceId: "trc_busy_seed", + workspace: process.cwd(), + sessionId: "ses_busy_chat", + runner: { kind: "hwlab-readonly-runner" }, + sessionMode: "controlled-readonly-session-registry", + capabilityLevel: "read-only-session-tools", + toolCalls: [{ name: "skills.discover", status: "completed" }], + skills: { + status: "ready", + items: [{ name: "alpha-skill" }], + count: 1, + totalCount: 1 + } + }, { now: "2026-05-23T00:03:00.001Z" }); const busy = await handleCodeAgentChat( { conversationId: "cnv_busy_chat", @@ -322,6 +449,9 @@ test("expired, busy, and failed session states are surfaced as structured blocke assert.equal(busy.session.status, "busy"); assert.equal(busy.capabilityLevel, "blocked"); assert.equal(busy.error.blockers[0].sourceIssue, "pikasTech/HWLAB#317"); + assert.equal(busy.conversationFacts.conversationId, "cnv_busy_chat"); + assert.equal(busy.conversationFacts.sessionId, "ses_busy_chat"); + assert.equal(busy.conversationFacts.latestSkills.names[0], "alpha-skill"); const failedRegistry = createCodeAgentSessionRegistry({ idFactory: () => "ses_failed_chat" @@ -340,6 +470,15 @@ test("expired, busy, and failed session states are surfaced as structured blocke traceId: "trc_failed_seed", now: "2026-05-23T00:04:00.001Z" }); + failedRegistry.recordFact("cnv_failed_chat", { + traceId: "trc_failed_seed", + workspace: process.cwd(), + session: failedSeed.session, + runner: { kind: "hwlab-readonly-runner" }, + sessionMode: "controlled-readonly-session-registry", + capabilityLevel: "read-only-session-tools", + toolCalls: [{ name: "pwd", status: "completed" }] + }, { now: "2026-05-23T00:04:00.002Z" }); const failed = await handleCodeAgentChat( { conversationId: "cnv_failed_chat", @@ -363,6 +502,9 @@ test("expired, busy, and failed session states are surfaced as structured blocke assert.equal(failed.error.blocker.code, "session_failed"); assert.equal(failed.session.status, "failed"); assert.equal(failed.capabilityLevel, "blocked"); + assert.equal(failed.conversationFacts.conversationId, "cnv_failed_chat"); + assert.equal(failed.conversationFacts.sessionId, "ses_failed_chat"); + assert.equal(failed.conversationFacts.recentToolCalls[0].name, "pwd"); }); test("OpenAI fallback and codex one-shot do not pass the long-lived session gate", async () => { diff --git a/internal/cloud/server.mjs b/internal/cloud/server.mjs index 7f375c1b..cf3cfaa9 100644 --- a/internal/cloud/server.mjs +++ b/internal/cloud/server.mjs @@ -26,6 +26,7 @@ import { describeCodeAgentAvailability, handleCodeAgentChat } from "./code-agent-chat.mjs"; +import { createCodeAgentSessionRegistry } from "./code-agent-session-registry.mjs"; import { buildGateDiagnosticsRows } from "./gate-diagnostics.mjs"; import { applyRuntimeDbReadinessLayers, @@ -80,6 +81,7 @@ const LIVE_BUILD_EXTERNAL_COMPONENTS = Object.freeze([ export function createCloudApiServer(options = {}) { const env = options.env ?? process.env; ensureCodeAgentRuntimeBase(env); + const sessionRegistry = options.sessionRegistry || createCodeAgentSessionRegistry(); const runtimeStore = options.runtimeStore || createConfiguredCloudRuntimeStore({ ...options, env }); const gatewayRegistry = options.gatewayRegistry || createGatewayDemoRegistry({ staleMs: parsePositiveInteger(env.HWLAB_GATEWAY_DEMO_STALE_MS, 30000), @@ -87,7 +89,7 @@ export function createCloudApiServer(options = {}) { }); return createServer(async (request, response) => { try { - await routeRequest(request, response, { ...options, env, runtimeStore, gatewayRegistry }); + await routeRequest(request, response, { ...options, env, runtimeStore, gatewayRegistry, sessionRegistry }); } catch (error) { sendJson(response, 500, { error: { diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index 0d0fef4d..e5257d41 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -2640,6 +2640,111 @@ test("cloud api /v1/agent/chat reuses controlled read-only session and exposes b } }); +test("cloud api /v1/agent/chat answers third-turn context from same conversation session facts", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-session-continuity-")); + const workspace = path.join(root, "workspace"); + const skillsDir = path.join(root, "skills"); + await mkdir(workspace, { recursive: true }); + await mkdir(path.join(skillsDir, "alpha"), { recursive: true }); + await mkdir(path.join(skillsDir, "beta"), { recursive: true }); + await writeFile(path.join(skillsDir, "alpha", "SKILL.md"), [ + "---", + "name: alpha-skill", + "description: Alpha skill summary.", + "---", + "", + "# Alpha" + ].join("\n")); + await writeFile(path.join(skillsDir, "beta", "SKILL.md"), [ + "---", + "name: beta-skill", + "description: Beta skill summary.", + "---", + "", + "# Beta" + ].join("\n")); + + const server = createCloudApiServer({ + env: { + PATH: process.env.PATH, + HWLAB_CODE_AGENT_WORKSPACE: workspace, + HWLAB_CODE_AGENT_PROVIDER: "codex-cli", + HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "0", + HWLAB_CODE_AGENT_CODEX_COMMAND: "/tmp/hwlab-missing-codex" + }, + skillsDirs: [skillsDir], + skillsDirsExact: true + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const first = await postAgent(port, { + conversationId: "cnv_server-session-continuity", + traceId: "trc_server-session-continuity-pwd", + message: "用pwd列出你当前的工作目录" + }); + assert.equal(first.status, "completed"); + assert.equal(first.provider, "codex-readonly-runner"); + assert.equal(first.runner.kind, "hwlab-readonly-runner"); + assert.equal(first.capabilityLevel, "read-only-session-tools"); + assert.equal(first.sessionMode, "controlled-readonly-session-registry"); + assert.equal(first.toolCalls[0].name, "pwd"); + assert.equal(first.workspace, workspace); + assert.equal(first.conversationFacts.turnCount, 1); + assert.equal(first.conversationFacts.workspace, workspace); + + const second = await postAgent(port, { + conversationId: "cnv_server-session-continuity", + traceId: "trc_server-session-continuity-skills", + message: "列出你能使用的所有skill" + }); + assert.equal(second.status, "completed"); + assert.equal(second.sessionId, first.sessionId); + assert.equal(second.sessionReuse.reused, true); + assert.equal(second.sessionReuse.turn, 2); + assert.equal(second.toolCalls[0].name, "skills.discover"); + assert.equal(second.skills.totalCount, 2); + assert.deepEqual(second.skills.items.map((skill) => skill.name), ["alpha-skill", "beta-skill"]); + assert.equal(second.conversationFacts.turnCount, 2); + assert.deepEqual(second.conversationFacts.latestSkills.names, ["alpha-skill", "beta-skill"]); + + const third = await postAgent(port, { + conversationId: "cnv_server-session-continuity", + traceId: "trc_server-session-continuity-context", + message: "根据前两轮结果说明你现在在哪个工作目录、能使用哪些skill" + }); + assert.equal(third.status, "completed"); + assert.equal(third.sessionId, first.sessionId); + assert.equal(third.sessionReuse.reused, true); + assert.equal(third.sessionReuse.turn, 3); + assert.equal(third.provider, "codex-readonly-runner"); + assert.equal(third.backend, "hwlab-cloud-api/codex-readonly-runner"); + assert.equal(third.runner.kind, "hwlab-readonly-runner"); + assert.equal(third.capabilityLevel, "read-only-session-tools"); + assert.equal(third.toolCalls[0].name, "session.context"); + assert.equal(third.toolCalls[0].status, "completed"); + assert.equal(third.conversationFacts.turnCount, 3); + assert.equal(third.conversationFacts.workspace, workspace); + assert.deepEqual(third.conversationFacts.latestSkills.names, ["alpha-skill", "beta-skill"]); + assert.match(third.reply.content, new RegExp(escapeRegExp(workspace), "u")); + assert.match(third.reply.content, /alpha-skill/u); + assert.match(third.reply.content, /beta-skill/u); + assert.match(third.reply.content, /不是 openai-responses-fallback/u); + assert.match(third.reply.content, /不是 stateless-one-shot/u); + assert.notEqual(third.provider, "openai-responses"); + assert.notEqual(third.runner.kind, "openai-responses-fallback"); + assert.notEqual(third.runner.kind, "codex-cli-one-shot-ephemeral"); + assert.notEqual(third.sessionMode, "ephemeral-one-shot"); + assert.ok(third.runnerTrace.events.includes("tool:session.context:completed")); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + await rm(root, { recursive: true, force: true }); + } +}); + test("cloud api /v1/agent/chat blocks forbidden file paths without leaking values", async () => { const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-file-block-")); const server = createCloudApiServer({ @@ -3574,6 +3679,10 @@ async function postAgentRaw(port, body, headers = {}) { }; } +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} + function createGreenM3StatusRuntimeStore({ blocker = null } = {}) { return { async readiness() { diff --git a/scripts/src/code-agent-response-contract.mjs b/scripts/src/code-agent-response-contract.mjs index 80c00985..8d1a1eef 100644 --- a/scripts/src/code-agent-response-contract.mjs +++ b/scripts/src/code-agent-response-contract.mjs @@ -281,7 +281,8 @@ export function summarizeCodeAgentPayload(payload, { httpStatus = null, traceId implementationType: null, runnerLimitations: [], codexStdioFeasibility: null, - longLivedSessionGate: null + longLivedSessionGate: null, + conversationFacts: null }; } @@ -329,7 +330,8 @@ export function summarizeCodeAgentPayload(payload, { httpStatus = null, traceId implementationType: stringOrNull(payload.implementationType), runnerLimitations: summarizeStringList(payload.runnerLimitations), codexStdioFeasibility: summarizeCodexStdioFeasibility(payload.codexStdioFeasibility), - longLivedSessionGate: summarizeLongLivedSessionGate(payload.longLivedSessionGate) + longLivedSessionGate: summarizeLongLivedSessionGate(payload.longLivedSessionGate), + conversationFacts: summarizeConversationFacts(payload.conversationFacts) }; } @@ -357,7 +359,8 @@ export function summarizeCodeAgentPayload(payload, { httpStatus = null, traceId implementationType: stringOrNull(payload.implementationType), runnerLimitations: summarizeStringList(payload.runnerLimitations), codexStdioFeasibility: summarizeCodexStdioFeasibility(payload.codexStdioFeasibility), - longLivedSessionGate: summarizeLongLivedSessionGate(payload.longLivedSessionGate) + longLivedSessionGate: summarizeLongLivedSessionGate(payload.longLivedSessionGate), + conversationFacts: summarizeConversationFacts(payload.conversationFacts) }; } @@ -793,6 +796,39 @@ function summarizeSession(value) { }; } +function summarizeConversationFacts(value) { + if (!value || typeof value !== "object") return null; + return { + conversationId: stringOrNull(value.conversationId), + sessionId: stringOrNull(value.sessionId), + sessionStatus: stringOrNull(value.sessionStatus), + sessionMode: stringOrNull(value.sessionMode), + capabilityLevel: stringOrNull(value.capabilityLevel), + runnerKind: stringOrNull(value.runnerKind), + workspace: stringOrNull(value.workspace), + sandbox: stringOrNull(value.sandbox), + turnCount: typeof value.turnCount === "number" ? value.turnCount : null, + latestTraceId: stringOrNull(value.latestTraceId), + traceIds: Array.isArray(value.traceIds) ? value.traceIds.map(stringOrNull).filter(Boolean).slice(-6) : [], + latestSkills: value.latestSkills && typeof value.latestSkills === "object" + ? { + status: stringOrNull(value.latestSkills.status), + count: typeof value.latestSkills.count === "number" ? value.latestSkills.count : null, + totalCount: typeof value.latestSkills.totalCount === "number" ? value.latestSkills.totalCount : null, + names: Array.isArray(value.latestSkills.names) ? value.latestSkills.names.map(stringOrNull).filter(Boolean).slice(0, 8) : [] + } + : null, + recentToolCalls: Array.isArray(value.recentToolCalls) + ? value.recentToolCalls.map((tool) => ({ + name: stringOrNull(tool?.name), + status: stringOrNull(tool?.status) + })).filter((tool) => tool.name || tool.status).slice(-8) + : [], + valuesRedacted: value.valuesRedacted === true, + secretMaterialStored: value.secretMaterialStored === true + }; +} + function summarizeCodexStdioFeasibility(value) { if (!value || typeof value !== "object") return null; return { diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index 8d96da26..b40f8102 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -4214,6 +4214,14 @@ async function inspectJourneyUi(page) { [...document.querySelectorAll(".message-card.message-agent .message-attribution")] .map((element) => element.textContent ?? "") .join("\n") + ), + conversationFactsVisible: /conversationFacts|fact traces|fact skills|fact tools|turns=|sessionId=|workspace=/u.test( + [ + ...document.querySelectorAll(".message-card.message-agent .code-agent-facts"), + document.querySelector("#code-agent-summary") + ] + .map((element) => element?.textContent ?? "") + .join("\n") ) }; }); @@ -4589,6 +4597,7 @@ async function runLocalAgentFixtureSmoke({ ui.attributionShowsToolCalls && ui.attributionMissingFieldsStructured && ui.attributionFallbackNotRunnerControl && + ui.conversationFactsVisible && !ui.completedMessageVisible && !ui.failedMessageVisible ); @@ -5867,9 +5876,27 @@ async function handleLocalAgentFixtureApi({ request, response, url, options = {} await delay(options.agentDelayMs ?? 0); const timestamp = "2026-05-22T00:00:00.000Z"; const conversationId = stringOrFallback(body?.conversationId, "cnv_source_fixture_browser"); + const traceId = stringOrFallback(body?.traceId, "trc_source_fixture_browser"); const normalizedMessage = String(body?.message ?? ""); const isSkillListPrompt = /列出你能使用的所有skill/u.test(normalizedMessage); const messageId = isSkillListPrompt ? "msg_source_fixture_browser_skills" : "msg_source_fixture_browser_simple"; + const fixtureSkills = isSkillListPrompt + ? { + status: "ready", + items: [ + { name: "source-fixture-skill" } + ], + count: 1, + totalCount: 1, + blockers: [] + } + : { + status: "not_requested", + count: 0, + totalCount: 0, + items: [], + blockers: [] + }; jsonResponse(response, 200, { conversationId, sessionId: conversationId, @@ -5879,7 +5906,7 @@ async function handleLocalAgentFixtureApi({ request, response, url, options = {} evidenceLevel: "SOURCE", createdAt: timestamp, updatedAt: timestamp, - traceId: stringOrFallback(body?.traceId, "trc_source_fixture_browser"), + traceId, provider: "source-fixture", model: "gpt-source-fixture", backend: "local-source-fixture", @@ -5894,16 +5921,43 @@ async function handleLocalAgentFixtureApi({ request, response, url, options = {} capabilityLevel: "text-chat-only", sessionMode: "provider-text-request", toolCalls: [], - skills: { - status: "not_requested", - count: 0, - items: [] - }, + skills: fixtureSkills, runnerTrace: { - traceId: stringOrFallback(body?.traceId, "trc_source_fixture_browser"), + traceId, runnerKind: "openai-responses-fallback", events: ["fixture:text-chat-only"] }, + conversationFacts: { + conversationId, + sessionId: conversationId, + sessionStatus: "idle", + sessionMode: "provider-text-request", + capabilityLevel: "text-chat-only", + runnerKind: "openai-responses-fallback", + workspace: "/workspace/hwlab", + sandbox: "none", + turnCount: isSkillListPrompt ? 2 : 1, + latestTraceId: traceId, + traceIds: isSkillListPrompt ? ["trc_source_fixture_browser_simple", traceId] : [traceId], + latestSkills: isSkillListPrompt + ? { + status: "ready", + count: 1, + totalCount: 1, + names: ["source-fixture-skill"], + blockers: [] + } + : null, + recentToolCalls: isSkillListPrompt + ? [{ name: "skills.discover", status: "completed" }] + : [], + facts: [], + valuesRedacted: true, + secretMaterialStored: false, + summary: isSkillListPrompt + ? `turns=2 / sessionId=${conversationId} / workspace=/workspace/hwlab / skills=1 (source-fixture-skill) / traceIds=trc_source_fixture_browser_simple,${traceId}` + : `turns=1 / sessionId=${conversationId} / workspace=/workspace/hwlab / skills=not_requested / traceIds=${traceId}` + }, reply: { messageId, role: "assistant", diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index 1a7d73b1..b657d103 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -675,6 +675,7 @@ function initCommandBar() { skills: result.skills, runner: result.runner, runnerTrace: result.runnerTrace, + conversationFacts: result.conversationFacts, m3Evidence: extractCodeAgentM3Evidence(result), capabilityLevel: result.capabilityLevel, sourceKind: completion.sourceKind, @@ -2355,6 +2356,8 @@ function codeAgentSummaryRows(summary) { codeAgentSummaryRow("runner", summary.runnerKind, summary.kind === "long-lived-session" ? "ok" : "source"), codeAgentSummaryRow("toolCalls", summary.toolCalls, summary.toolCalls === "none" ? "source" : summary.tone), codeAgentSummaryRow("skills", summary.skills, summary.skills === "none" ? "source" : summary.tone), + codeAgentSummaryRow("conversation facts", conversationFactsSummary(summary.latestMessage?.conversationFacts), summary.latestMessage?.conversationFacts ? "source" : "blocked"), + codeAgentSummaryRow("fact traces", conversationFactTracesSummary(summary.latestMessage?.conversationFacts), summary.latestMessage?.conversationFacts ? "source" : "blocked"), codeAgentSummaryRow("runnerTrace", summary.runnerTrace, "source"), codeAgentSummaryRow("readiness blockers", summary.blockersLabel, summary.readinessBlockers.length > 0 ? "blocked" : "source"), codeAgentSummaryRow("last traceId", summary.lastTraceId, "source"), @@ -2690,7 +2693,7 @@ function codeAgentCapabilityFactsPanel(message) { const grid = document.createElement("dl"); grid.className = "code-agent-fact-grid"; - for (const row of facts.rows) { + for (const row of [...facts.rows, ...conversationFactRows(message.conversationFacts)]) { const item = document.createElement("div"); item.className = "code-agent-fact-row"; item.append(textSpan(row.label, "code-agent-fact-key"), textSpan(row.value ?? "none", "code-agent-fact-value mono")); @@ -2801,6 +2804,8 @@ function messageEvidenceFields(message) { recordField("toolCalls", toolCallsSummary(message.toolCalls)), recordField("skills", skillsSummary(message.skills)), recordField("runnerTrace", runnerTraceSummary(message.runnerTrace)), + recordField("conversationFacts", conversationFactsSummary(message.conversationFacts)), + recordField("factTraces", conversationFactTracesSummary(message.conversationFacts)), recordField("operationId", operationIdSummary(message)), recordField("audit", auditSummary(message)), recordField("evidence", evidenceSummary(message)), @@ -2928,6 +2933,66 @@ function runnerTraceSummary(runnerTrace) { return summary === "none" ? null : summary; } +function conversationFactRows(conversationFacts) { + if (!conversationFacts || typeof conversationFacts !== "object") return []; + return [ + { + label: "conversationFacts", + value: conversationFactsSummary(conversationFacts) + }, + { + label: "fact traces", + value: conversationFactTracesSummary(conversationFacts) + }, + { + label: "fact skills", + value: conversationFactSkillsSummary(conversationFacts) + }, + { + label: "fact tools", + value: conversationFactToolsSummary(conversationFacts) + } + ]; +} + +function conversationFactsSummary(conversationFacts) { + if (!conversationFacts || typeof conversationFacts !== "object") return "none"; + return [ + recordField("turns", conversationFacts.turnCount), + recordField("sessionId", conversationFacts.sessionId), + recordField("status", conversationFacts.sessionStatus), + recordField("workspace", conversationFacts.workspace), + recordField("mode", conversationFacts.sessionMode), + conversationFacts.valuesRedacted === true ? "valuesRedacted=true" : null + ].filter(Boolean).join(" / ") || "none"; +} + +function conversationFactTracesSummary(conversationFacts) { + if (!conversationFacts || typeof conversationFacts !== "object") return "none"; + const traces = Array.isArray(conversationFacts.traceIds) ? conversationFacts.traceIds.filter(Boolean) : []; + return traces.length ? traces.slice(-4).join(",") : conversationFacts.latestTraceId ?? "none"; +} + +function conversationFactSkillsSummary(conversationFacts) { + const skills = conversationFacts?.latestSkills; + if (!skills || typeof skills !== "object") return "none"; + const names = Array.isArray(skills.names) ? skills.names.filter(Boolean).slice(0, 6).join(",") : ""; + return [ + skills.status ?? "unknown", + recordField("count", skills.totalCount ?? skills.count), + names ? `items=${names}` : null + ].filter(Boolean).join(":") || "none"; +} + +function conversationFactToolsSummary(conversationFacts) { + const toolCalls = Array.isArray(conversationFacts?.recentToolCalls) ? conversationFacts.recentToolCalls : []; + if (toolCalls.length === 0) return "none"; + return toolCalls + .map((toolCall) => [toolCall.name ?? "tool", toolCall.status ? `(${toolCall.status})` : null].filter(Boolean).join("")) + .slice(-5) + .join(","); +} + function sessionReuseSummary(sessionReuse) { if (!sessionReuse || typeof sessionReuse !== "object") return null; const state = sessionReuse.reused ? "reused" : "new"; diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index de50ed84..1616806e 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -753,6 +753,7 @@ assert.match(app, /implementationType:\s*result\.implementationType/); assert.match(app, /runnerLimitations:\s*result\.runnerLimitations/); assert.match(app, /codexStdioFeasibility:\s*result\.codexStdioFeasibility/); assert.match(app, /longLivedSessionGate:\s*result\.longLivedSessionGate/); +assert.match(app, /conversationFacts:\s*result\.conversationFacts/); assert.match(app, /providerTraceSummary\(message\.providerTrace\)/); assert.match(app, /recordField\("conversation", message\.conversationId\)/); assert.match(app, /recordField\("provider", message\.provider\)/); @@ -766,14 +767,22 @@ assert.match(app, /recordField\("implementation", message\.implementationType\)/ assert.match(app, /recordField\("limitations", limitationsSummary\(message\.runnerLimitations\)\)/); assert.match(app, /recordField\("codexStdio", codexStdioFeasibilitySummary\(message\.codexStdioFeasibility\)\)/); assert.match(app, /recordField\("longLivedGate", longLivedSessionGateSummary\(message\.longLivedSessionGate\)\)/); +assert.match(app, /recordField\("conversationFacts", conversationFactsSummary\(message\.conversationFacts\)\)/); +assert.match(app, /recordField\("factTraces", conversationFactTracesSummary\(message\.conversationFacts\)\)/); assert.match(app, /codeAgentSummaryRow\("sessionId\/status", summary\.sessionIdStatus/); assert.match(app, /codeAgentSummaryRow\("workspace", summary\.workspace/); assert.match(app, /codeAgentSummaryRow\("sandbox", summary\.sandbox/); assert.match(app, /codeAgentSummaryRow\("runner", summary\.runnerKind/); assert.match(app, /codeAgentSummaryRow\("toolCalls", summary\.toolCalls/); assert.match(app, /codeAgentSummaryRow\("skills", summary\.skills/); +assert.match(app, /codeAgentSummaryRow\("conversation facts", conversationFactsSummary\(summary\.latestMessage\?\.conversationFacts\)/); +assert.match(app, /codeAgentSummaryRow\("fact traces", conversationFactTracesSummary\(summary\.latestMessage\?\.conversationFacts\)/); assert.match(app, /codeAgentSummaryRow\("runnerTrace", summary\.runnerTrace/); assert.match(app, /function sessionSummary/); +assert.match(app, /function conversationFactRows/); +assert.match(app, /function conversationFactsSummary/); +assert.match(app, /function conversationFactSkillsSummary/); +assert.match(app, /function conversationFactToolsSummary/); assert.match(app, /function longLivedSessionGateSummary/); assert.match(app, /read-only-session-tools/); assert.match(app, /controlled-readonly-session-registry/);