From 52582d6ac5a6dd0dd3eab1b214251274a78151fe Mon Sep 17 00:00:00 2001 From: vaskarvelas Date: Fri, 24 Apr 2026 01:55:06 +0300 Subject: [PATCH] refactor: share agent runtime hygiene helpers --- .../mod/_core/admin/views/agent/AGENTS.md | 1 + .../_all/mod/_core/admin/views/agent/store.js | 137 ++++++++++++++---- app/L0/_all/mod/_core/agent-chat/AGENTS.md | 7 +- .../mod/_core/agent-chat/runtime-hygiene.js | 76 ++++++++++ .../docs/agent/onscreen-agent-runtime.md | 2 +- .../docs/app/admin-agent-runtime.md | 2 +- .../_all/mod/_core/onscreen_agent/AGENTS.md | 1 + app/L0/_all/mod/_core/onscreen_agent/store.js | 95 +++++------- tests/runtime_hygiene_test.mjs | 125 ++++++++++++++++ 9 files changed, 358 insertions(+), 88 deletions(-) create mode 100644 app/L0/_all/mod/_core/agent-chat/runtime-hygiene.js create mode 100644 tests/runtime_hygiene_test.mjs diff --git a/app/L0/_all/mod/_core/admin/views/agent/AGENTS.md b/app/L0/_all/mod/_core/admin/views/agent/AGENTS.md index 705608d2..e990e660 100644 --- a/app/L0/_all/mod/_core/admin/views/agent/AGENTS.md +++ b/app/L0/_all/mod/_core/admin/views/agent/AGENTS.md @@ -102,6 +102,7 @@ Current behavior: - browser execution blocks are detected by the `_____javascript` separator - `execution.js` runs browser-side JavaScript in an async wrapper; console logs and ordinary returned arrays or objects should both use the same YAML-first transcript serializer, with `log↓` or `info↓` or `warn↓` or `error↓` or related block labels for console output and `result↓` for returned values, falling back to JSON only when the lightweight YAML helper cannot serialize the shape - `_core/admin/views/agent/store.js/evaluateAdminAssistantMessage` is the extension seam that evaluates a settled assistant message immediately before execution results are serialized into the admin `execution-output` follow-up turn; `_core/agent-chat` currently provides the first-party repeated-message detector there, emitting loop notices on the 2nd exact assistant-message send as `info`, on the 3rd send as `warn`, and on the 4th send onward as `error` +- `_core/agent-chat/runtime-hygiene.js` now owns the shared clone/apply/normalize/resolve/create helper flow that `store.js` uses to route admin `submit`, `assistant-response`, `history-compact`, `protocol-retry`, and `execution-output` messages through `_core/admin/views/agent/store.js/processAdminAgentMessage` without leaking hook mutations back into source history objects - when an execution follow-up turn returns no assistant content, the runtime retries the same request once automatically before sending a short protocol-correction user message - empty-response protocol-correction messages must not re-echo the prior execution output; they should tell the agent to continue from the execution output above or provide the user-facing answer - loaded admin skills are passed through execution as typed runtime values, not pasted blindly into the prompt diff --git a/app/L0/_all/mod/_core/admin/views/agent/store.js b/app/L0/_all/mod/_core/admin/views/agent/store.js index 4aacf7b4..bf691747 100644 --- a/app/L0/_all/mod/_core/admin/views/agent/store.js +++ b/app/L0/_all/mod/_core/admin/views/agent/store.js @@ -13,10 +13,13 @@ import * as agentView from "/mod/_core/admin/views/agent/view.js"; import { mapManagerStateToAdminState } from "/mod/_core/admin/views/agent/huggingface.js"; +import { prependAssistantEvaluationLogs } from "/mod/_core/agent-chat/assistant-message-evaluation.js"; import { - normalizeAssistantEvaluationLogEntry, - prependAssistantEvaluationLogs -} from "/mod/_core/agent-chat/assistant-message-evaluation.js"; + applyConversationMessage, + createProcessedConversationMessage, + normalizeAssistantEvaluation, + resolveProcessedConversationMessage +} from "/mod/_core/agent-chat/runtime-hygiene.js"; import { DEFAULT_MODEL_INPUT, DTYPE_OPTIONS, normalizeHuggingFaceModelInput } from "/mod/_core/huggingface/helpers.js"; import { getHuggingFaceManager } from "/mod/_core/huggingface/manager.js"; import { closeDialog, openDialog } from "/mod/_core/visual/forms/dialog.js"; @@ -30,6 +33,13 @@ import { const huggingfaceManager = getHuggingFaceManager(); +const processAdminAgentMessage = globalThis.space.extend( + import.meta, + async function processAdminAgentMessage(context = {}) { + return context; + } +); + function normalizePromptBudgetSingleMessageRatio( value, fallback = config.DEFAULT_ADMIN_CHAT_SETTINGS.promptBudgetRatios.singleMessage @@ -210,15 +220,7 @@ const MAX_COMPACT_TRIM_ATTEMPTS = 4; const evaluateAdminAssistantMessage = globalThis.space.extend( import.meta, async function evaluateAdminAssistantMessage(context = {}) { - return { - assistantContent: typeof context?.assistantContent === "string" ? context.assistantContent : "", - history: Array.isArray(context?.history) ? context.history : [], - logs: Array.isArray(context?.logs) - ? context.logs.map((entry) => normalizeAssistantEvaluationLogEntry(entry)).filter(Boolean) - : [], - messageId: typeof context?.messageId === "string" ? context.messageId : "", - store: context?.store || null - }; + return normalizeAssistantEvaluation(context); } ); @@ -1415,15 +1417,27 @@ const model = { return true; }, - consumeNextQueuedSubmissionMessage() { + async consumeNextQueuedSubmissionMessage() { if (!this.queuedSubmissions.length) { return null; } const [snapshot, ...rest] = this.queuedSubmissions; this.queuedSubmissions = rest; - return createMessage("user", snapshot.content, { - attachments: Array.isArray(snapshot.attachments) ? snapshot.attachments.slice() : [] + return createProcessedConversationMessage({ + content: snapshot.content, + context: { + draftSubmission: snapshot, + history: this.history, + phase: "submit", + store: this + }, + createMessage, + options: { + attachments: Array.isArray(snapshot.attachments) ? snapshot.attachments.slice() : [] + }, + processMessage: processAdminAgentMessage, + role: "user" }); }, @@ -2013,9 +2027,21 @@ const model = { } if (isAbortError(error) && this.stopRequested) { - const hasContent = Boolean(assistantMessage.content.trim()); + let hasContent = Boolean(assistantMessage.content.trim()); if (hasContent) { + applyConversationMessage( + assistantMessage, + await resolveProcessedConversationMessage({ + history: requestMessages, + message: assistantMessage, + phase: "assistant-response", + responseMeta, + processMessage: processAdminAgentMessage, + store: this + }) + ); + hasContent = Boolean(assistantMessage.content.trim()); await this.refreshPromptInputFromHistory(this.history); await this.persistHistory({ immediate: true @@ -2040,6 +2066,17 @@ const model = { this.activeRequestController = null; } + applyConversationMessage( + assistantMessage, + await resolveProcessedConversationMessage({ + history: requestMessages, + message: assistantMessage, + phase: "assistant-response", + responseMeta, + processMessage: processAdminAgentMessage, + store: this + }) + ); await this.refreshPromptInputFromHistory(this.history); await this.persistHistory({ immediate: true @@ -2138,8 +2175,20 @@ const model = { throw new Error("History compaction returned no content."); } - const compactedMessage = createMessage("user", normalizedCompactedHistory, { - kind: "history-compact" + const compactedMessage = await createProcessedConversationMessage({ + content: normalizedCompactedHistory, + context: { + history: this.history, + mode, + phase: "history-compact", + store: this + }, + createMessage, + options: { + kind: "history-compact" + }, + processMessage: processAdminAgentMessage, + role: "user" }); this.executionOutputOverrides = Object.create(null); this.rerunningMessageId = ""; @@ -2319,8 +2368,20 @@ const model = { continue; } - nextUserMessage = createMessage("user", buildEmptyAssistantRetryMessage(), { - kind: "execution-retry" + nextUserMessage = await createProcessedConversationMessage({ + content: buildEmptyAssistantRetryMessage(), + context: { + history: requestMessages, + phase: "protocol-retry", + responseMeta: streamResult.responseMeta, + store: this + }, + createMessage, + options: { + kind: "execution-retry" + }, + processMessage: processAdminAgentMessage, + role: "user" }); this.status = hasVerifiedEmptyAssistantResponse(streamResult) ? "Retrying: assistant response was empty after execution..." @@ -2360,8 +2421,20 @@ const model = { return "complete"; } - const executionOutputMessage = createMessage("user", execution.formatExecutionResultsMessage(executionResults), { - kind: "execution-output" + const executionOutputMessage = await createProcessedConversationMessage({ + content: execution.formatExecutionResultsMessage(executionResults), + context: { + executionResults, + history: this.history, + phase: "execution-output", + store: this + }, + createMessage, + options: { + kind: "execution-output" + }, + processMessage: processAdminAgentMessage, + role: "user" }); await this.replaceHistory([...this.history, executionOutputMessage]); await this.persistHistory({ @@ -2395,7 +2468,7 @@ const model = { finalOutcome = outcome; if (outcome === "queued") { - nextUserMessage = this.consumeNextQueuedSubmissionMessage(); + nextUserMessage = await this.consumeNextQueuedSubmissionMessage(); if (nextUserMessage) { this.stopRequested = false; @@ -2412,7 +2485,7 @@ const model = { break; } - const queuedMessage = this.consumeNextQueuedSubmissionMessage(); + const queuedMessage = await this.consumeNextQueuedSubmissionMessage(); if (queuedMessage) { nextUserMessage = queuedMessage; @@ -2474,8 +2547,20 @@ const model = { } } - const userMessage = createMessage("user", draftSubmission.content, { - attachments: draftSubmission.attachments + const userMessage = await createProcessedConversationMessage({ + content: draftSubmission.content, + context: { + draftSubmission, + history: this.history, + phase: "submit", + store: this + }, + createMessage, + options: { + attachments: draftSubmission.attachments + }, + processMessage: processAdminAgentMessage, + role: "user" }); this.clearComposerDraft(); await this.runSubmissionSeries(userMessage); diff --git a/app/L0/_all/mod/_core/agent-chat/AGENTS.md b/app/L0/_all/mod/_core/agent-chat/AGENTS.md index 60ba0039..bd7124f4 100644 --- a/app/L0/_all/mod/_core/agent-chat/AGENTS.md +++ b/app/L0/_all/mod/_core/agent-chat/AGENTS.md @@ -13,6 +13,7 @@ Documentation is top priority for this module. After any change under `_core/age This module owns: - `assistant-message-evaluation.js`: shared assistant-message normalization, exact-repeat detection, severity-based loop warning construction, and safe prepending of synthetic transcript logs ahead of real execution console output +- `runtime-hygiene.js`: shared conversation-message cloning, safe in-place message application, assistant-evaluation context normalization, and processed-message helper flow reused by the admin and overlay agent stores - `ext/js/_core/onscreen_agent/store.js/evaluateOnscreenAssistantMessage/end/*.js`: hook implementations for overlay assistant-message evaluation - `ext/js/_core/admin/views/agent/store.js/evaluateAdminAssistantMessage/end/*.js`: hook implementations for admin assistant-message evaluation @@ -25,7 +26,11 @@ Current shared helper contract: - severity must escalate as `info` on the 2nd exact send, `warn` on the 3rd exact send, and `error` on the 4th exact send onward - the warning text should stay short, direct, and framed as loop pressure visible through the normal execution transcript channel - prepending synthetic transcript logs must not rewrite or trim the real execution console entries that already exist on the execution result -- this module owns the first-party repeated-message loop policy for both overlay and admin chat; the consuming stores own only the evaluation seams and transcript insertion point +- `runtime-hygiene.js` should clone conversation messages before passing them into message-processing hooks, so hook mutations cannot leak back into the source history snapshots by aliasing +- `runtime-hygiene.js` should expose `applyConversationMessage(...)` for the stores that need to merge processed assistant-message updates back into existing history objects without sharing attachment-array references +- `normalizeAssistantEvaluation(...)` should sanitize assistant-evaluation inputs into the shared `{ assistantContent, history, logs, messageId, store }` shape for both chat surfaces +- `resolveProcessedConversationMessage(...)` and `createProcessedConversationMessage(...)` should be the shared way to route `submit`, `assistant-response`, `history-compact`, `protocol-retry`, and `execution-output` messages through surface-owned processing seams +- this module owns the first-party repeated-message loop policy for both overlay and admin chat, plus the shared runtime-hygiene helper flow; the consuming stores own only the evaluation seams, transcript insertion point, and surface-local message-processing policy ## Development Guidance diff --git a/app/L0/_all/mod/_core/agent-chat/runtime-hygiene.js b/app/L0/_all/mod/_core/agent-chat/runtime-hygiene.js new file mode 100644 index 00000000..ff7a3c03 --- /dev/null +++ b/app/L0/_all/mod/_core/agent-chat/runtime-hygiene.js @@ -0,0 +1,76 @@ +import { normalizeAssistantEvaluationLogEntry } from "./assistant-message-evaluation.js"; + +function cloneConversationAttachments(attachments) { + return Array.isArray(attachments) ? attachments.map((attachment) => ({ ...attachment })) : []; +} + +export function cloneConversationMessage(message) { + if (!message || typeof message !== "object") { + return null; + } + + return { + ...message, + attachments: cloneConversationAttachments(message.attachments) + }; +} + +export function applyConversationMessage(targetMessage, nextMessage) { + if (!targetMessage || !nextMessage || targetMessage === nextMessage) { + return targetMessage; + } + + Object.assign(targetMessage, nextMessage); + targetMessage.attachments = cloneConversationAttachments(nextMessage.attachments); + return targetMessage; +} + +export function normalizeAssistantEvaluation(context = {}) { + return { + assistantContent: typeof context?.assistantContent === "string" ? context.assistantContent : "", + history: Array.isArray(context?.history) + ? context.history.map((message) => cloneConversationMessage(message)).filter(Boolean) + : [], + logs: Array.isArray(context?.logs) + ? context.logs.map((entry) => normalizeAssistantEvaluationLogEntry(entry)).filter(Boolean) + : [], + messageId: typeof context?.messageId === "string" ? context.messageId : "", + store: context?.store || null + }; +} + +export async function resolveProcessedConversationMessage(options = {}) { + const fallbackMessage = cloneConversationMessage(options.message); + const processMessage = typeof options.processMessage === "function" ? options.processMessage : async (context) => context; + const processedContext = await processMessage({ + ...options, + history: Array.isArray(options.history) + ? options.history.map((message) => cloneConversationMessage(message)).filter(Boolean) + : [], + message: fallbackMessage + }); + const processedMessage = + processedContext && typeof processedContext === "object" ? processedContext.message : fallbackMessage; + + return cloneConversationMessage(processedMessage) || fallbackMessage; +} + +export async function createProcessedConversationMessage(options = {}) { + const createMessage = + typeof options.createMessage === "function" + ? options.createMessage + : (role, content, messageOptions = {}) => ({ + ...messageOptions, + content, + role + }); + + return resolveProcessedConversationMessage({ + ...(options.context && typeof options.context === "object" && !Array.isArray(options.context) + ? options.context + : {}), + history: Array.isArray(options.context?.history) ? options.context.history : [], + message: createMessage(options.role, options.content, options.options), + processMessage: options.processMessage + }); +} diff --git a/app/L0/_all/mod/_core/documentation/docs/agent/onscreen-agent-runtime.md b/app/L0/_all/mod/_core/documentation/docs/agent/onscreen-agent-runtime.md index e0eb2309..4ada8b42 100644 --- a/app/L0/_all/mod/_core/documentation/docs/agent/onscreen-agent-runtime.md +++ b/app/L0/_all/mod/_core/documentation/docs/agent/onscreen-agent-runtime.md @@ -108,7 +108,7 @@ That shared history styling resets rendered markdown bubbles back to normal whit The settings and prompt-history dialogs reuse the shared `_core/visual/forms/dialog.css` shell layout. Their header and footer rows stay fixed while only the settings body or prompt-history frame scrolls, so the footer actions remain reachable even when the content is long. Caught overlay runtime errors are logged through `console.error` and shown through the shared toast stack from `_core/visual/chrome/toast.js`. The composer placeholder still belongs to ready-state and lightweight status guidance, so raw exception text should not be pushed into the textarea placeholder. -Overlay execution transcripts now use the shared YAML-first formatter for both console logs and returned values, emitting block headers such as `log↓`, `warn↓`, `error↓`, and `result↓` so structured telemetry stays complete across the thread and execution cards. Immediately before those execution results are serialized back into the `execution-output` follow-up turn, the overlay also runs the shared assistant-message evaluation seam; the current first-party hook from `_core/agent-chat` prepends synthetic loop warnings when the exact same assistant message reappears, using `info` on the 2nd send, `warn` on the 3rd send, and `error` on the 4th send onward. +Overlay execution transcripts now use the shared YAML-first formatter for both console logs and returned values, emitting block headers such as `log↓`, `warn↓`, `error↓`, and `result↓` so structured telemetry stays complete across the thread and execution cards. Immediately before those execution results are serialized back into the `execution-output` follow-up turn, the overlay also runs the shared assistant-message evaluation seam; the current first-party hook from `_core/agent-chat` prepends synthetic loop warnings when the exact same assistant message reappears, using `info` on the 2nd send, `warn` on the 3rd send, and `error` on the 4th send onward. The same `_core/agent-chat` module now also owns the shared runtime-hygiene helper flow that the overlay store uses to clone processed messages, normalize assistant-evaluation input, and route `submit`, `assistant-response`, `history-compact`, `protocol-retry`, and `execution-output` turns through `processOnscreenAgentMessage` without leaking hook mutations back into source history objects. The settings dialog now has two provider tabs named `API` and `Local`. `API` keeps the OpenAI-compatible endpoint, model, and key fields. `Local` mounts the shared Hugging Face config sidebar in onscreen mode, so the overlay reads the same saved-model list and live WebGPU worker state as the routed Local LLM page and the admin chat. Opening the Local tab should refresh saved-model shortcuts without booting the worker; saving local settings persists the selected repo id and dtype, then starts background model preparation. When no local model is selected and saved models exist, the Local panel preselects the browser-wide last successfully loaded saved model from `_core/huggingface/manager.js`, falling back to the first saved entry if that last-used entry was discarded. When no local model is selected, no local model is loaded, and the shared saved-model list is empty, the Local panel prefills the Hugging Face model field with the same testing-page default: `onnx-community/gemma-4-E4B-it-ONNX`. diff --git a/app/L0/_all/mod/_core/documentation/docs/app/admin-agent-runtime.md b/app/L0/_all/mod/_core/documentation/docs/app/admin-agent-runtime.md index 458c5902..18e9e5a7 100644 --- a/app/L0/_all/mod/_core/documentation/docs/app/admin-agent-runtime.md +++ b/app/L0/_all/mod/_core/documentation/docs/app/admin-agent-runtime.md @@ -37,7 +37,7 @@ The shared thread view keeps settled admin assistant replies markdown-rendered, That shared history styling resets rendered markdown bubbles back to normal white-space so parser formatting newlines between tags do not show up as visible blank lines, and it also collapses direct block margins inside list items so loose markdown bullets do not render blank-line-sized gaps between entries. The admin agent's empty-state astronaut, thread avatar helmet, and admin-shell launcher avatar now all resolve through the shared authenticated-app artwork folder at `/mod/_core/visual/res/chat/admin/`. Repo-owned app image assets should stay under `_core/visual/res/`, not under `_core/admin/`. Caught admin runtime failures are logged through `console.error` in addition to any status-line copy shown in the surface, so debugging no longer depends on the composer or placeholder text alone. -Admin execution transcripts now match the overlay contract for both console logs and returned values: structured payloads use the same YAML-first serializer, console output is emitted through block headers such as `log↓` or `warn↓`, and returned values use `result↓`, with JSON only as the fallback when the lightweight YAML helper cannot serialize the shape. Immediately before those execution results are serialized into the admin `execution-output` follow-up turn, the admin store also runs the shared assistant-message evaluation seam; the current first-party hook from `_core/agent-chat` prepends synthetic loop warnings when the exact same assistant message reappears, using `info` on the 2nd send, `warn` on the 3rd send, and `error` on the 4th send onward. +Admin execution transcripts now match the overlay contract for both console logs and returned values: structured payloads use the same YAML-first serializer, console output is emitted through block headers such as `log↓` or `warn↓`, and returned values use `result↓`, with JSON only as the fallback when the lightweight YAML helper cannot serialize the shape. Immediately before those execution results are serialized into the admin `execution-output` follow-up turn, the admin store also runs the shared assistant-message evaluation seam; the current first-party hook from `_core/agent-chat` prepends synthetic loop warnings when the exact same assistant message reappears, using `info` on the 2nd send, `warn` on the 3rd send, and `error` on the 4th send onward. That same shared `_core/agent-chat` module now also owns the runtime-hygiene helper flow that the admin store uses to clone processed messages, normalize assistant-evaluation input, and route `submit`, `assistant-response`, `history-compact`, `protocol-retry`, and `execution-output` turns through `processAdminAgentMessage` without leaking hook mutations back into source history objects. ## Skill Discovery diff --git a/app/L0/_all/mod/_core/onscreen_agent/AGENTS.md b/app/L0/_all/mod/_core/onscreen_agent/AGENTS.md index 91e3cbe9..2cfb3486 100644 --- a/app/L0/_all/mod/_core/onscreen_agent/AGENTS.md +++ b/app/L0/_all/mod/_core/onscreen_agent/AGENTS.md @@ -203,6 +203,7 @@ Current stable seams: - `execution.js` exposes `_core/onscreen_agent/execution.js/validateOnscreenAgentExecutionBlockPlan`; this seam owns block-level execution-plan validation, should stay generic to the overlay execution protocol, and feature modules should attach their own task-specific validators from `ext/js/` instead of adding those checks directly to `execution.js` - `api.js` exposes `_core/onscreen_agent/api.js/streamOnscreenAgentCompletion` as the transport seam and `_core/onscreen_agent/api.js/prepareOnscreenAgentApiRequest` as the prepared-request mutation seam for API-mode fetches - `store.js` exposes `_core/onscreen_agent/store.js/processOnscreenAgentMessage`; it runs before overlay messages are committed or reused after key lifecycle steps and receives a context object with `phase`, `message`, `history`, and `store` +- `_core/agent-chat/runtime-hygiene.js` now owns the shared clone/apply/normalize/resolve/create helper flow that `store.js` uses to route overlay `submit`, `assistant-response`, `history-compact`, `protocol-retry`, and `execution-output` messages through that seam without leaking hook mutations back into source history objects Current runtime namespace: diff --git a/app/L0/_all/mod/_core/onscreen_agent/store.js b/app/L0/_all/mod/_core/onscreen_agent/store.js index 4d592c99..aa36c194 100644 --- a/app/L0/_all/mod/_core/onscreen_agent/store.js +++ b/app/L0/_all/mod/_core/onscreen_agent/store.js @@ -11,10 +11,13 @@ import * as skills from "/mod/_core/onscreen_agent/skills.js"; import * as storage from "/mod/_core/onscreen_agent/storage.js"; import * as agentView from "/mod/_core/onscreen_agent/view.js"; import { renderMarkdown } from "/mod/_core/framework/js/markdown-frontmatter.js"; +import { prependAssistantEvaluationLogs } from "/mod/_core/agent-chat/assistant-message-evaluation.js"; import { - normalizeAssistantEvaluationLogEntry, - prependAssistantEvaluationLogs -} from "/mod/_core/agent-chat/assistant-message-evaluation.js"; + applyConversationMessage, + createProcessedConversationMessage, + normalizeAssistantEvaluation, + resolveProcessedConversationMessage +} from "/mod/_core/agent-chat/runtime-hygiene.js"; import { DEFAULT_MODEL_INPUT, DTYPE_OPTIONS, normalizeHuggingFaceModelInput } from "/mod/_core/huggingface/helpers.js"; import { getHuggingFaceManager } from "/mod/_core/huggingface/manager.js"; import { positionPopover } from "/mod/_core/visual/chrome/popover.js"; @@ -454,27 +457,6 @@ function logOnscreenAgentError(context, error) { console.error(`[onscreen-agent] ${context}`, error); } -function cloneConversationMessage(message) { - if (!message || typeof message !== "object") { - return null; - } - - return { - ...message, - attachments: Array.isArray(message.attachments) ? [...message.attachments] : [] - }; -} - -function applyConversationMessage(targetMessage, nextMessage) { - if (!targetMessage || !nextMessage || targetMessage === nextMessage) { - return targetMessage; - } - - Object.assign(targetMessage, nextMessage); - targetMessage.attachments = Array.isArray(nextMessage.attachments) ? [...nextMessage.attachments] : []; - return targetMessage; -} - const processOnscreenAgentMessage = globalThis.space.extend( import.meta, async function processOnscreenAgentMessage(context = {}) { @@ -485,37 +467,18 @@ const processOnscreenAgentMessage = globalThis.space.extend( const evaluateOnscreenAssistantMessage = globalThis.space.extend( import.meta, async function evaluateOnscreenAssistantMessage(context = {}) { - return { - assistantContent: typeof context?.assistantContent === "string" ? context.assistantContent : "", - history: Array.isArray(context?.history) ? context.history : [], - logs: Array.isArray(context?.logs) - ? context.logs.map((entry) => normalizeAssistantEvaluationLogEntry(entry)).filter(Boolean) - : [], - messageId: typeof context?.messageId === "string" ? context.messageId : "", - store: context?.store || null - }; + return normalizeAssistantEvaluation(context); } ); -async function resolveProcessedOnscreenAgentMessage(context = {}) { - const fallbackMessage = cloneConversationMessage(context.message); - const processedContext = await processOnscreenAgentMessage({ - ...context, - history: Array.isArray(context.history) - ? context.history.map((message) => cloneConversationMessage(message)).filter(Boolean) - : [], - message: fallbackMessage - }); - const processedMessage = - processedContext && typeof processedContext === "object" ? processedContext.message : fallbackMessage; - - return cloneConversationMessage(processedMessage) || fallbackMessage; -} - -async function createProcessedMessage(role, content, options = {}, context = {}) { - return resolveProcessedOnscreenAgentMessage({ - ...context, - message: createMessage(role, content, options) +function createProcessedMessage(role, content, options = {}, context = {}) { + return createProcessedConversationMessage({ + content, + context, + createMessage, + options, + processMessage: processOnscreenAgentMessage, + role }); } @@ -4120,15 +4083,27 @@ const model = { return true; }, - consumeNextQueuedSubmissionMessage() { + async consumeNextQueuedSubmissionMessage() { if (!this.queuedSubmissions.length) { return null; } const [snapshot, ...rest] = this.queuedSubmissions; this.queuedSubmissions = rest; - return createMessage("user", snapshot.content, { - attachments: Array.isArray(snapshot.attachments) ? snapshot.attachments.slice() : [] + return createProcessedConversationMessage({ + content: snapshot.content, + context: { + draftSubmission: snapshot, + history: this.history, + phase: "submit", + store: this + }, + createMessage, + options: { + attachments: Array.isArray(snapshot.attachments) ? snapshot.attachments.slice() : [] + }, + processMessage: processOnscreenAgentMessage, + role: "user" }); }, @@ -4966,11 +4941,12 @@ const model = { if (hasContent) { applyConversationMessage( assistantMessage, - await resolveProcessedOnscreenAgentMessage({ + await resolveProcessedConversationMessage({ history: requestMessages, message: assistantMessage, phase: "assistant-response", responseMeta, + processMessage: processOnscreenAgentMessage, store: this }) ); @@ -5002,11 +4978,12 @@ const model = { applyConversationMessage( assistantMessage, - await resolveProcessedOnscreenAgentMessage({ + await resolveProcessedConversationMessage({ history: requestMessages, message: assistantMessage, phase: "assistant-response", responseMeta, + processMessage: processOnscreenAgentMessage, store: this }) ); @@ -5402,7 +5379,7 @@ const model = { finalOutcome = outcome; if (outcome === "queued") { - nextUserMessage = this.consumeNextQueuedSubmissionMessage(); + nextUserMessage = await this.consumeNextQueuedSubmissionMessage(); if (nextUserMessage) { this.stopRequested = false; @@ -5423,7 +5400,7 @@ const model = { break; } - const queuedMessage = this.consumeNextQueuedSubmissionMessage(); + const queuedMessage = await this.consumeNextQueuedSubmissionMessage(); if (queuedMessage) { nextUserMessage = queuedMessage; diff --git a/tests/runtime_hygiene_test.mjs b/tests/runtime_hygiene_test.mjs new file mode 100644 index 00000000..d06bda82 --- /dev/null +++ b/tests/runtime_hygiene_test.mjs @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + createProcessedConversationMessage, + normalizeAssistantEvaluation, + resolveProcessedConversationMessage +} from "../app/L0/_all/mod/_core/agent-chat/runtime-hygiene.js"; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const ROOT_DIR = path.resolve(SCRIPT_DIR, ".."); + +function createMessage(role, content, options = {}) { + return { + attachments: Array.isArray(options.attachments) ? options.attachments.slice() : [], + content, + id: options.id || `${role}-1`, + kind: typeof options.kind === "string" ? options.kind : "", + role + }; +} + +test("normalizeAssistantEvaluation sanitizes logs and preserves runtime context", () => { + const store = { id: "store-1" }; + const history = [ + createMessage("assistant", "first", { id: "assistant-1" }) + ]; + const evaluation = normalizeAssistantEvaluation({ + assistantContent: "Checking now...", + history, + logs: [ + { level: "warn", text: "keep this" }, + { level: "nope", text: "fallback level" }, + { level: "info", text: " " } + ], + messageId: "assistant-2", + store + }); + + assert.deepEqual(evaluation.history, history); + assert.notEqual(evaluation.history, history); + assert.notEqual(evaluation.history[0], history[0]); + assert.deepEqual(evaluation.logs, [ + { level: "warn", text: "keep this" }, + { level: "log", text: "fallback level" } + ]); + assert.equal(evaluation.messageId, "assistant-2"); + assert.equal(evaluation.store, store); +}); + +test("resolveProcessedConversationMessage isolates hook mutations from original history and message", async () => { + const originalHistory = [ + createMessage("user", "alpha", { id: "user-1" }) + ]; + const originalMessage = createMessage("user", "beta", { id: "user-2", kind: "submit" }); + + const processedMessage = await resolveProcessedConversationMessage({ + history: originalHistory, + message: originalMessage, + phase: "submit", + processMessage: async (context) => { + context.history[0].content = "mutated history copy"; + context.message.content = "mutated message copy"; + return { + ...context, + message: { + ...context.message, + kind: "processed", + meta: "kept" + } + }; + } + }); + + assert.equal(originalHistory[0].content, "alpha"); + assert.equal(originalMessage.content, "beta"); + assert.deepEqual(processedMessage.attachments, []); + assert.equal(processedMessage.content, "mutated message copy"); + assert.equal(processedMessage.kind, "processed"); + assert.equal(processedMessage.meta, "kept"); +}); + +test("createProcessedConversationMessage creates message then routes through processing hook", async () => { + const processedMessage = await createProcessedConversationMessage({ + content: "execution result", + context: { + executionResults: [{ status: "success" }], + phase: "execution-output" + }, + createMessage, + options: { + kind: "execution-output" + }, + processMessage: async (context) => ({ + ...context, + message: { + ...context.message, + content: `${context.message.content}\n\n[hygiene ok]` + } + }), + role: "user" + }); + + assert.equal(processedMessage.role, "user"); + assert.equal(processedMessage.kind, "execution-output"); + assert.match(processedMessage.content, /\[hygiene ok\]/u); +}); + +test("admin agent store exposes runtime hygiene seam for processed history messages", async () => { + const storePath = path.join(ROOT_DIR, "app/L0/_all/mod/_core/admin/views/agent/store.js"); + const storeSource = await fs.readFile(storePath, "utf8"); + + assert.match(storeSource, /processAdminAgentMessage/u); + assert.match(storeSource, /createProcessedConversationMessage\(/u); + assert.match(storeSource, /normalizeAssistantEvaluation\(context\)/u); + assert.match(storeSource, /resolveProcessedConversationMessage\(/u); + assert.match(storeSource, /phase: "assistant-response"/u); + assert.match(storeSource, /phase: "history-compact"/u); + assert.match(storeSource, /phase: "execution-output"/u); + assert.match(storeSource, /phase: "submit"/u); + assert.match(storeSource, /async consumeNextQueuedSubmissionMessage\(/u); +});