diff --git a/src/agents/embedded-agent-runner/run/attempt-before-agent-run.ts b/src/agents/embedded-agent-runner/run/attempt-before-agent-run.ts index 7967dd2eb7a4..9790b14a194f 100644 --- a/src/agents/embedded-agent-runner/run/attempt-before-agent-run.ts +++ b/src/agents/embedded-agent-runner/run/attempt-before-agent-run.ts @@ -5,7 +5,8 @@ import { resolveBlockMessage } from "../../../plugins/hook-decision-types.js"; import type { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js"; import type { AgentMessage } from "../../runtime/index.js"; import { log } from "../logger.js"; -import { cloneHookMessages, flushSessionManagerTranscript } from "./attempt-transcript-helpers.js"; +import { cloneHookMessages } from "./attempt-hook-messages.js"; +import { flushSessionManagerTranscript } from "./attempt-transcript-helpers.js"; import { sessionMessagesContainIdempotencyKey } from "./pre-persisted-user-turn.js"; import type { EmbeddedRunAttemptParams } from "./types.js"; diff --git a/src/agents/embedded-agent-runner/run/attempt-hook-messages.ts b/src/agents/embedded-agent-runner/run/attempt-hook-messages.ts new file mode 100644 index 000000000000..494e4418ea03 --- /dev/null +++ b/src/agents/embedded-agent-runner/run/attempt-hook-messages.ts @@ -0,0 +1,6 @@ +import type { AgentMessage } from "../../runtime/index.js"; + +/** Gives hooks an isolated message snapshot they cannot mutate in-session. */ +export function cloneHookMessages(messages: AgentMessage[]): AgentMessage[] { + return messages.map((message) => structuredClone(message)); +} diff --git a/src/agents/embedded-agent-runner/run/attempt-prompt-observability.test.ts b/src/agents/embedded-agent-runner/run/attempt-prompt-observability.test.ts new file mode 100644 index 000000000000..cd70bc7fe08d --- /dev/null +++ b/src/agents/embedded-agent-runner/run/attempt-prompt-observability.test.ts @@ -0,0 +1,208 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { AgentMessage } from "../../runtime/index.js"; + +const hoisted = vi.hoisted(() => ({ + buildAgentHookContextChannelFields: vi.fn(() => ({ channel: "discord" })), + buildAgentHookContextIdentityFields: vi.fn(() => ({ senderId: "sender-1" })), + emitTrustedDiagnosticEvent: vi.fn(), + hasHooks: vi.fn(() => true), + log: { + debug: vi.fn(), + info: vi.fn(), + isEnabled: vi.fn(() => false), + warn: vi.fn(), + }, + onExecutionPhase: vi.fn(), + recordStage: vi.fn(), + recordTrajectoryEvent: vi.fn(), + runLlmInput: vi.fn( + async ( + _event: { + historyMessages?: unknown[]; + imagesCount?: number; + prompt?: string; + tools?: unknown[]; + }, + _context: Record, + ) => undefined, + ), + toTrajectoryToolDefinitions: vi.fn((tools: ReadonlyArray<{ name?: string }>) => + tools.map((tool) => ({ name: tool.name })), + ), +})); + +vi.mock("../../../infra/diagnostic-events.js", () => ({ + emitTrustedDiagnosticEvent: hoisted.emitTrustedDiagnosticEvent, +})); +vi.mock("../../../plugins/hook-agent-context.js", () => ({ + buildAgentHookContextChannelFields: hoisted.buildAgentHookContextChannelFields, + buildAgentHookContextIdentityFields: hoisted.buildAgentHookContextIdentityFields, +})); +vi.mock("../../../trajectory/runtime.js", () => ({ + toTrajectoryToolDefinitions: hoisted.toTrajectoryToolDefinitions, +})); +vi.mock("../logger.js", () => ({ log: hoisted.log })); + +import { observeEmbeddedAttemptPrompt } from "./attempt-prompt-observability.js"; + +type PromptObservabilityInput = Parameters[0]; + +function createInput(overrides: Partial = {}): PromptObservabilityInput { + const sessionMessages: AgentMessage[] = [ + { + role: "user", + content: [{ type: "text", text: "history" }], + timestamp: 1, + }, + ]; + return { + attempt: { + messageChannel: "discord", + modelId: "model-1", + onExecutionPhase: hoisted.onExecutionPhase, + provider: "provider-1", + runId: "run-1", + senderId: "sender-1", + sessionFile: "/tmp/session.jsonl", + sessionId: "session-1", + sessionKey: "agent:main:discord:channel-1", + trigger: "user", + workspaceDir: "/tmp/workspace", + }, + cacheTrace: { recordStage: hoisted.recordStage }, + contextTokenBudget: 32_000, + diagnosticTrace: { + traceId: "1".repeat(32), + spanId: "2".repeat(16), + }, + effectivePrompt: "effective prompt", + effectiveTools: [{ name: "visible-tool" }], + hookAgentId: "main", + hookMessagesForCurrentPrompt: sessionMessages, + hookRunner: { + hasHooks: hoisted.hasHooks, + runLlmInput: hoisted.runLlmInput, + }, + imageCount: 2, + isRawModelRun: false, + llmBoundaryPromptForPrecheck: "[boundary] model prompt", + promptForModel: "model prompt", + promptSubmissionRuntimeOnly: false, + reserveTokens: 4_096, + runTrace: { + traceId: "1".repeat(32), + spanId: "3".repeat(16), + }, + sessionMessages, + skipPromptSubmission: false, + streamStrategy: "provider-stream", + systemPromptForHook: "system prompt", + systemPromptText: "system prompt", + toolSearchCompacted: true, + tools: [{ name: "hook-tool" }], + trajectoryRecorder: { recordEvent: hoisted.recordTrajectoryEvent }, + transcriptLeafId: "leaf-1", + transport: "sse", + uncompactedEffectiveTools: [{ name: "visible-tool" }, { name: "deferred-tool" }], + ...overrides, + } as PromptObservabilityInput; +} + +describe("observeEmbeddedAttemptPrompt", () => { + beforeEach(() => { + vi.clearAllMocks(); + hoisted.hasHooks.mockReturnValue(true); + hoisted.log.isEnabled.mockReturnValue(false); + }); + + it("records the assembled prompt boundary and dispatches a cloned llm_input snapshot", () => { + const input = createInput(); + + expect(observeEmbeddedAttemptPrompt(input)).toEqual({ skipPromptSubmission: false }); + + expect(hoisted.recordStage).toHaveBeenNthCalledWith(1, "prompt:before", { + prompt: "model prompt", + messages: input.sessionMessages, + }); + expect(hoisted.recordStage).toHaveBeenNthCalledWith(2, "prompt:images", { + prompt: "model prompt", + messages: input.sessionMessages, + note: "images: prompt=2", + }); + expect(hoisted.recordTrajectoryEvent).toHaveBeenCalledWith( + "context.compiled", + expect.objectContaining({ + imagesCount: 2, + providerVisibleTools: [{ name: "visible-tool" }], + tools: [{ name: "visible-tool" }, { name: "deferred-tool" }], + transcriptLeafId: "leaf-1", + }), + ); + expect(hoisted.emitTrustedDiagnosticEvent).toHaveBeenCalledWith( + expect.objectContaining({ + type: "context.assembled", + contextTokenBudget: 32_000, + historyTextChars: 7, + messageCount: 1, + promptChars: 16, + reserveTokens: 4_096, + }), + ); + expect(hoisted.onExecutionPhase).toHaveBeenCalledWith({ + phase: "context_assembled", + provider: "provider-1", + model: "model-1", + }); + expect(hoisted.runLlmInput).toHaveBeenCalledOnce(); + const [event, context] = hoisted.runLlmInput.mock.calls[0] ?? []; + expect(event).toMatchObject({ + prompt: "[boundary] model prompt", + imagesCount: 2, + tools: [{ name: "hook-tool" }], + }); + expect(event?.historyMessages).toEqual(input.hookMessagesForCurrentPrompt); + expect(event?.historyMessages).not.toBe(input.hookMessagesForCurrentPrompt); + expect(context).toMatchObject({ + agentId: "main", + channel: "discord", + senderId: "sender-1", + }); + }); + + it("marks a blank current prompt as skipped while preserving its compiled trace", () => { + const input = createInput({ + imageCount: 0, + llmBoundaryPromptForPrecheck: " ", + promptForModel: " ", + }); + + expect(observeEmbeddedAttemptPrompt(input)).toEqual({ skipPromptSubmission: true }); + + expect(hoisted.recordTrajectoryEvent).toHaveBeenNthCalledWith( + 1, + "context.compiled", + expect.any(Object), + ); + expect(hoisted.recordTrajectoryEvent).toHaveBeenNthCalledWith(2, "prompt.skipped", { + reason: "blank_user_prompt", + prompt: " ", + messages: input.sessionMessages, + imagesCount: 0, + }); + expect(hoisted.log.warn).toHaveBeenCalledWith( + expect.stringContaining("embedded run prompt skipped: blank user prompt"), + ); + expect(hoisted.runLlmInput).not.toHaveBeenCalled(); + }); + + it("keeps an already-blocked prompt out of cache, trajectory, and hook dispatch", () => { + const input = createInput({ skipPromptSubmission: true }); + + expect(observeEmbeddedAttemptPrompt(input)).toEqual({ skipPromptSubmission: true }); + + expect(hoisted.recordStage).not.toHaveBeenCalled(); + expect(hoisted.recordTrajectoryEvent).not.toHaveBeenCalled(); + expect(hoisted.runLlmInput).not.toHaveBeenCalled(); + expect(hoisted.emitTrustedDiagnosticEvent).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/agents/embedded-agent-runner/run/attempt-prompt-observability.ts b/src/agents/embedded-agent-runner/run/attempt-prompt-observability.ts new file mode 100644 index 000000000000..552d04ed12b5 --- /dev/null +++ b/src/agents/embedded-agent-runner/run/attempt-prompt-observability.ts @@ -0,0 +1,218 @@ +/** Records the fully assembled prompt boundary before preflight and submission. */ +import { emitTrustedDiagnosticEvent } from "../../../infra/diagnostic-events.js"; +import { + createChildDiagnosticTraceContext, + type DiagnosticTraceContext, + freezeDiagnosticTraceContext, +} from "../../../infra/diagnostic-trace-context.js"; +import { + buildAgentHookContextChannelFields, + buildAgentHookContextIdentityFields, +} from "../../../plugins/hook-agent-context.js"; +import type { PluginHookLlmInputEvent } from "../../../plugins/hook-types.js"; +import type { HookRunner } from "../../../plugins/hooks.js"; +import { + type createTrajectoryRuntimeRecorder, + toTrajectoryToolDefinitions, +} from "../../../trajectory/runtime.js"; +import type { createCacheTrace } from "../../cache-trace.js"; +import type { AgentMessage } from "../../runtime/index.js"; +import type { AgentSession } from "../../sessions/index.js"; +import { log } from "../logger.js"; +import { summarizeSessionContext } from "./attempt-context-summary.js"; +import { cloneHookMessages } from "./attempt-hook-messages.js"; +import { resolvePromptSubmissionSkipReason } from "./attempt-prompt-skip.js"; +import type { EmbeddedRunAttemptParams } from "./types.js"; + +type AttemptPromptObservabilityParams = Pick< + EmbeddedRunAttemptParams, + | "channelContext" + | "chatId" + | "currentChannelId" + | "messageChannel" + | "messageProvider" + | "messageTo" + | "modelId" + | "onExecutionPhase" + | "provider" + | "runId" + | "senderId" + | "sessionFile" + | "sessionId" + | "sessionKey" + | "trigger" + | "workspaceDir" +>; +type CacheTrace = Pick>, "recordStage"> | null; +type PromptHookRunner = Pick | null; +type TrajectoryRecorder = Pick< + NonNullable>, + "recordEvent" +> | null; +type TrajectoryTool = Parameters[0][number]; + +export function observeEmbeddedAttemptPrompt(input: { + attempt: AttemptPromptObservabilityParams; + cacheTrace: CacheTrace; + contextTokenBudget: number; + diagnosticTrace: DiagnosticTraceContext; + effectivePrompt: string; + effectiveTools: readonly TrajectoryTool[]; + hookAgentId: string; + hookMessagesForCurrentPrompt: AgentMessage[]; + hookRunner: PromptHookRunner; + imageCount: number; + isRawModelRun: boolean; + llmBoundaryPromptForPrecheck: string; + promptForModel: string; + promptSubmissionRuntimeOnly?: boolean; + reserveTokens: number; + runTrace: DiagnosticTraceContext; + sessionMessages: AgentMessage[]; + skipPromptSubmission: boolean; + streamStrategy: string; + systemPromptForHook: string; + systemPromptText?: string; + toolSearchCompacted: boolean; + tools: PluginHookLlmInputEvent["tools"]; + trajectoryRecorder: TrajectoryRecorder; + transcriptLeafId: string | null; + transport: AgentSession["agent"]["transport"]; + uncompactedEffectiveTools: readonly TrajectoryTool[]; +}): { skipPromptSubmission: boolean } { + const { attempt } = input; + let skipPromptSubmission = input.skipPromptSubmission; + + if (!skipPromptSubmission) { + input.cacheTrace?.recordStage("prompt:before", { + prompt: input.promptForModel, + messages: input.sessionMessages, + }); + input.cacheTrace?.recordStage("prompt:images", { + prompt: input.promptForModel, + messages: input.sessionMessages, + note: `images: prompt=${input.imageCount}`, + }); + const providerVisibleTools = toTrajectoryToolDefinitions(input.effectiveTools); + input.trajectoryRecorder?.recordEvent("context.compiled", { + systemPrompt: input.systemPromptForHook, + prompt: input.promptForModel, + messages: input.sessionMessages, + tools: toTrajectoryToolDefinitions( + input.toolSearchCompacted ? input.uncompactedEffectiveTools : input.effectiveTools, + ), + ...(input.toolSearchCompacted ? { providerVisibleTools } : {}), + imagesCount: input.imageCount, + streamStrategy: input.streamStrategy, + transport: input.transport, + transcriptLeafId: input.transcriptLeafId, + }); + } + + const promptSkipReason = skipPromptSubmission + ? null + : resolvePromptSubmissionSkipReason({ + prompt: input.promptForModel, + messages: input.sessionMessages, + runtimeOnly: input.promptSubmissionRuntimeOnly, + imageCount: input.imageCount, + }); + if (promptSkipReason) { + skipPromptSubmission = true; + const skipContext = + `runId=${attempt.runId} sessionId=${attempt.sessionId} trigger=${attempt.trigger} ` + + `provider=${attempt.provider}/${attempt.modelId}`; + if (promptSkipReason === "blank_user_prompt") { + log.warn(`embedded run prompt skipped: blank user prompt ${skipContext}`); + } else { + log.info(`embedded run prompt skipped: empty prompt/history/images ${skipContext}`); + } + input.trajectoryRecorder?.recordEvent("prompt.skipped", { + reason: promptSkipReason, + prompt: input.promptForModel, + messages: input.sessionMessages, + imagesCount: input.imageCount, + }); + } + + const sessionSummary = summarizeSessionContext(input.sessionMessages); + emitTrustedDiagnosticEvent({ + type: "context.assembled", + runId: attempt.runId, + ...(attempt.sessionKey && { sessionKey: attempt.sessionKey }), + ...(attempt.sessionId && { sessionId: attempt.sessionId }), + provider: attempt.provider, + model: attempt.modelId, + ...((attempt.messageChannel ?? attempt.messageProvider) + ? { channel: attempt.messageChannel ?? attempt.messageProvider } + : {}), + trigger: attempt.trigger, + messageCount: input.sessionMessages.length, + historyTextChars: sessionSummary.totalTextChars, + historyImageBlocks: sessionSummary.totalImageBlocks, + maxMessageTextChars: sessionSummary.maxMessageTextChars, + systemPromptChars: input.systemPromptText?.length ?? 0, + promptChars: input.effectivePrompt.length, + promptImages: input.imageCount, + contextTokenBudget: input.contextTokenBudget, + reserveTokens: input.reserveTokens, + trace: freezeDiagnosticTraceContext(createChildDiagnosticTraceContext(input.runTrace)), + }); + attempt.onExecutionPhase?.({ + phase: "context_assembled", + provider: attempt.provider, + model: attempt.modelId, + }); + + if (log.isEnabled("debug")) { + log.debug( + `[context-diag] pre-prompt: sessionKey=${attempt.sessionKey ?? attempt.sessionId} ` + + `messages=${input.sessionMessages.length} roleCounts=${sessionSummary.roleCounts} ` + + `historyTextChars=${sessionSummary.totalTextChars} ` + + `maxMessageTextChars=${sessionSummary.maxMessageTextChars} ` + + `historyImageBlocks=${sessionSummary.totalImageBlocks} ` + + `systemPromptChars=${input.systemPromptText?.length ?? 0} ` + + `promptChars=${input.effectivePrompt.length} ` + + `promptImages=${input.imageCount} ` + + `provider=${attempt.provider}/${attempt.modelId} sessionFile=${attempt.sessionFile}`, + ); + } + + if (!skipPromptSubmission && !input.isRawModelRun && input.hookRunner?.hasHooks("llm_input")) { + void input.hookRunner + .runLlmInput( + { + runId: attempt.runId, + sessionId: attempt.sessionId, + provider: attempt.provider, + model: attempt.modelId, + systemPrompt: input.systemPromptForHook, + prompt: input.llmBoundaryPromptForPrecheck, + historyMessages: cloneHookMessages(input.hookMessagesForCurrentPrompt), + imagesCount: input.imageCount, + tools: input.tools, + }, + { + runId: attempt.runId, + trace: freezeDiagnosticTraceContext(input.diagnosticTrace), + agentId: input.hookAgentId, + sessionKey: attempt.sessionKey, + sessionId: attempt.sessionId, + workspaceDir: attempt.workspaceDir, + trigger: attempt.trigger, + ...buildAgentHookContextChannelFields(attempt), + ...buildAgentHookContextIdentityFields({ + trigger: attempt.trigger, + senderId: attempt.senderId, + chatId: attempt.chatId, + channelContext: attempt.channelContext, + }), + }, + ) + .catch((err: unknown) => { + log.warn(`llm_input hook failed: ${String(err)}`); + }); + } + + return { skipPromptSubmission }; +} diff --git a/src/agents/embedded-agent-runner/run/attempt-prompt-skip.ts b/src/agents/embedded-agent-runner/run/attempt-prompt-skip.ts new file mode 100644 index 000000000000..4ee3a7ee9beb --- /dev/null +++ b/src/agents/embedded-agent-runner/run/attempt-prompt-skip.ts @@ -0,0 +1,41 @@ +export type PromptSubmissionSkipReason = "blank_user_prompt" | "empty_prompt_history_images"; + +/** Classifies prompt submissions that have no visible current-turn content. */ +export function resolvePromptSubmissionSkipReason(params: { + prompt: string; + messages: readonly unknown[]; + imageCount: number; + runtimeOnly?: boolean; +}): PromptSubmissionSkipReason | null { + if (params.prompt.trim().length > 0 || params.imageCount > 0) { + return null; + } + return params.messages.some(hasVisiblePromptHistory) + ? "blank_user_prompt" + : "empty_prompt_history_images"; +} + +function hasVisiblePromptHistory(message: unknown): boolean { + if (!message || typeof message !== "object") { + return false; + } + const record = message as { role?: unknown; content?: unknown }; + if (record.role !== "user" && record.role !== "assistant") { + return false; + } + return hasNonEmptyContent(record.content); +} + +function hasNonEmptyContent(content: unknown): boolean { + if (typeof content === "string") { + return content.trim().length > 0; + } + if (Array.isArray(content)) { + return content.some(hasNonEmptyContent); + } + if (!content || typeof content !== "object") { + return false; + } + const record = content as { text?: unknown; content?: unknown }; + return hasNonEmptyContent(record.text) || hasNonEmptyContent(record.content); +} diff --git a/src/agents/embedded-agent-runner/run/attempt-transcript-helpers.ts b/src/agents/embedded-agent-runner/run/attempt-transcript-helpers.ts index 228cb97d26fb..8bbc64d67221 100644 --- a/src/agents/embedded-agent-runner/run/attempt-transcript-helpers.ts +++ b/src/agents/embedded-agent-runner/run/attempt-transcript-helpers.ts @@ -21,10 +21,6 @@ import type { EmbeddedRunAttemptParams } from "./types.js"; type AttemptSessionManager = ReturnType; -export function cloneHookMessages(messages: AgentMessage[]): AgentMessage[] { - return messages.map((message) => structuredClone(message)); -} - export function flushSessionManagerTranscript(sessionManager: AttemptSessionManager): void { ( sessionManager as unknown as { diff --git a/src/agents/embedded-agent-runner/run/attempt.prompt-helpers.test.ts b/src/agents/embedded-agent-runner/run/attempt.prompt-helpers.test.ts index dfa61f5a177c..1c45f24453c8 100644 --- a/src/agents/embedded-agent-runner/run/attempt.prompt-helpers.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.prompt-helpers.test.ts @@ -36,10 +36,10 @@ vi.mock("../../music-generation-task-status.js", () => musicGenerationTaskStatus vi.mock("../../video-generation-task-status.js", () => videoGenerationTaskStatusMocks); vi.mock("../../../plugins/host-hook-state.js", () => hostHookStateMocks); +import { resolvePromptSubmissionSkipReason } from "./attempt-prompt-skip.js"; import { forgetPromptBuildDrainCacheForRun, mergeOrphanedTrailingUserPrompt, - resolvePromptSubmissionSkipReason, resolveAttemptMediaTaskSystemPromptAddition, resolvePromptBuildHookResult, shouldInjectHeartbeatPrompt, diff --git a/src/agents/embedded-agent-runner/run/attempt.prompt-helpers.ts b/src/agents/embedded-agent-runner/run/attempt.prompt-helpers.ts index 0200ff81beeb..7821d1559384 100644 --- a/src/agents/embedded-agent-runner/run/attempt.prompt-helpers.ts +++ b/src/agents/embedded-agent-runner/run/attempt.prompt-helpers.ts @@ -272,52 +272,6 @@ export function shouldWarnOnOrphanedUserRepair( return trigger === "user" || trigger === "manual"; } -type PromptSubmissionSkipReason = "blank_user_prompt" | "empty_prompt_history_images"; - -/** - * Distinguishes a truly empty prompt/history from a blank follow-up in a visible - * conversation. This lets callers skip model submission while reporting the - * reason accurately. - */ -export function resolvePromptSubmissionSkipReason(params: { - prompt: string; - messages: readonly unknown[]; - imageCount: number; - runtimeOnly?: boolean; -}): PromptSubmissionSkipReason | null { - if (params.prompt.trim().length > 0 || params.imageCount > 0) { - return null; - } - return params.messages.some(hasVisiblePromptHistory) - ? "blank_user_prompt" - : "empty_prompt_history_images"; -} - -function hasVisiblePromptHistory(message: unknown): boolean { - if (!message || typeof message !== "object") { - return false; - } - const record = message as { role?: unknown; content?: unknown }; - if (record.role !== "user" && record.role !== "assistant") { - return false; - } - return hasNonEmptyContent(record.content); -} - -function hasNonEmptyContent(content: unknown): boolean { - if (typeof content === "string") { - return content.trim().length > 0; - } - if (Array.isArray(content)) { - return content.some(hasNonEmptyContent); - } - if (!content || typeof content !== "object") { - return false; - } - const record = content as { text?: unknown; content?: unknown }; - return hasNonEmptyContent(record.text) || hasNonEmptyContent(record.content); -} - const QUEUED_USER_MESSAGE_MARKER = "[Queued user message from a previous active turn; preserved as context only. " + "Continue with the active prompt below.]"; diff --git a/src/agents/embedded-agent-runner/run/attempt.ts b/src/agents/embedded-agent-runner/run/attempt.ts index 6f3d37f1b4d3..7bd00475fe2c 100644 --- a/src/agents/embedded-agent-runner/run/attempt.ts +++ b/src/agents/embedded-agent-runner/run/attempt.ts @@ -13,18 +13,8 @@ import { OPENCLAW_EMBEDDED_CONTEXT_ENGINE_HOST, } from "../../../context-engine/host-compat.js"; import { resolveContextEngineOwnerPluginId } from "../../../context-engine/registry.js"; -import { emitTrustedDiagnosticEvent } from "../../../infra/diagnostic-events.js"; -import { - createChildDiagnosticTraceContext, - freezeDiagnosticTraceContext, -} from "../../../infra/diagnostic-trace-context.js"; import { formatErrorMessage } from "../../../infra/errors.js"; import type { AssistantMessage } from "../../../llm/types.js"; -import { - buildAgentHookContextChannelFields, - buildAgentHookContextIdentityFields, -} from "../../../plugins/hook-agent-context.js"; -import { toTrajectoryToolDefinitions } from "../../../trajectory/runtime.js"; import { createBundleLspToolRuntime } from "../../agent-bundle-lsp-runtime.js"; import { materializeBundleMcpToolsForRun } from "../../agent-bundle-mcp-tools.js"; import { resolveAgentDir, resolveSessionAgentIds } from "../../agent-scope.js"; @@ -61,10 +51,10 @@ import { runEmbeddedAttemptBeforeAgentRun } from "./attempt-before-agent-run.js" import { prepareEmbeddedAttemptBootstrap } from "./attempt-bootstrap-prepare.js"; import { prepareEmbeddedAttemptBundleTools } from "./attempt-bundle-tools.js"; import { installEmbeddedAttemptContextGuards } from "./attempt-context-guards.js"; -import { summarizeSessionContext } from "./attempt-context-summary.js"; import { prepareEmbeddedAttemptHistory } from "./attempt-history-prepare.js"; import { prepareEmbeddedAttemptPromptAssembly } from "./attempt-prompt-assembly.js"; import { prepareEmbeddedAttemptPromptContext } from "./attempt-prompt-context.js"; +import { observeEmbeddedAttemptPrompt } from "./attempt-prompt-observability.js"; import { handleEmbeddedAttemptMidTurnPrecheck, prepareEmbeddedAttemptPromptPreflight, @@ -92,11 +82,7 @@ import { prepareEmbeddedAttemptTimeout } from "./attempt-timeout-prepare.js"; import { prepareEmbeddedAttemptToolBase } from "./attempt-tool-base-prepare.js"; import { prepareEmbeddedAttemptToolCatalog } from "./attempt-tool-catalog.js"; import { prepareEmbeddedAttemptTrajectory } from "./attempt-trajectory.js"; -import { - cloneHookMessages, - removeTrailingMidTurnPrecheckAssistantError, -} from "./attempt-transcript-helpers.js"; -import { resolvePromptSubmissionSkipReason } from "./attempt.prompt-helpers.js"; +import { removeTrailingMidTurnPrecheckAssistantError } from "./attempt-transcript-helpers.js"; import { resolveEmbeddedAttemptSessionWriteLockOptions } from "./attempt.run-decisions.js"; import { acquireEmbeddedAttemptSessionFileOwner, @@ -996,142 +982,36 @@ export async function runEmbeddedAttempt( : undefined, }); - if (!skipPromptSubmission) { - cacheTrace?.recordStage("prompt:before", { - prompt: promptForModel, - messages: activeSession.messages, - }); - cacheTrace?.recordStage("prompt:images", { - prompt: promptForModel, - messages: activeSession.messages, - note: `images: prompt=${imageResult.images.length}`, - }); - const trajectoryProviderVisibleTools = toTrajectoryToolDefinitions(effectiveTools); - trajectoryRecorder?.recordEvent("context.compiled", { - systemPrompt: systemPromptForHook, - prompt: promptForModel, - messages: activeSession.messages, - tools: toTrajectoryToolDefinitions( - toolSearch.compacted ? uncompactedEffectiveTools : effectiveTools, - ), - ...(toolSearch.compacted - ? { providerVisibleTools: trajectoryProviderVisibleTools } - : {}), - imagesCount: imageResult.images.length, - streamStrategy, - transport: effectiveAgentTransport, - transcriptLeafId, - }); - } - - const promptSkipReason = skipPromptSubmission - ? null - : resolvePromptSubmissionSkipReason({ - prompt: promptForModel, - messages: activeSession.messages, - runtimeOnly: promptSubmission.runtimeOnly, - imageCount: imageResult.images.length, - }); - if (promptSkipReason) { - skipPromptSubmission = true; - const skipContext = - `runId=${params.runId} sessionId=${params.sessionId} trigger=${params.trigger} ` + - `provider=${params.provider}/${params.modelId}`; - if (promptSkipReason === "blank_user_prompt") { - log.warn(`embedded run prompt skipped: blank user prompt ${skipContext}`); - } else { - log.info(`embedded run prompt skipped: empty prompt/history/images ${skipContext}`); - } - trajectoryRecorder?.recordEvent("prompt.skipped", { - reason: promptSkipReason, - prompt: promptForModel, - messages: activeSession.messages, - imagesCount: imageResult.images.length, - }); - } - - const msgCount = activeSession.messages.length; - const systemLen = systemPromptText?.length ?? 0; - const promptLen = effectivePrompt.length; - const sessionSummary = summarizeSessionContext(activeSession.messages); const reserveTokens = settingsManager.getCompactionReserveTokens(); - emitTrustedDiagnosticEvent({ - type: "context.assembled", - runId: params.runId, - ...(params.sessionKey && { sessionKey: params.sessionKey }), - ...(params.sessionId && { sessionId: params.sessionId }), - provider: params.provider, - model: params.modelId, - ...((params.messageChannel ?? params.messageProvider) - ? { channel: params.messageChannel ?? params.messageProvider } - : {}), - trigger: params.trigger, - messageCount: msgCount, - historyTextChars: sessionSummary.totalTextChars, - historyImageBlocks: sessionSummary.totalImageBlocks, - maxMessageTextChars: sessionSummary.maxMessageTextChars, - systemPromptChars: systemLen, - promptChars: promptLen, - promptImages: imageResult.images.length, + skipPromptSubmission = observeEmbeddedAttemptPrompt({ + attempt: params, + cacheTrace, contextTokenBudget, + diagnosticTrace, + effectivePrompt, + effectiveTools, + hookAgentId, + hookMessagesForCurrentPrompt, + hookRunner, + imageCount: imageResult.images.length, + isRawModelRun, + llmBoundaryPromptForPrecheck, + promptForModel, + promptSubmissionRuntimeOnly: promptSubmission.runtimeOnly, reserveTokens, - trace: freezeDiagnosticTraceContext(createChildDiagnosticTraceContext(runTrace)), - }); - params.onExecutionPhase?.({ - phase: "context_assembled", - provider: params.provider, - model: params.modelId, - }); - - // Diagnostic: log context sizes before prompt to help debug early overflow errors. - if (log.isEnabled("debug")) { - log.debug( - `[context-diag] pre-prompt: sessionKey=${params.sessionKey ?? params.sessionId} ` + - `messages=${msgCount} roleCounts=${sessionSummary.roleCounts} ` + - `historyTextChars=${sessionSummary.totalTextChars} ` + - `maxMessageTextChars=${sessionSummary.maxMessageTextChars} ` + - `historyImageBlocks=${sessionSummary.totalImageBlocks} ` + - `systemPromptChars=${systemLen} promptChars=${promptLen} ` + - `promptImages=${imageResult.images.length} ` + - `provider=${params.provider}/${params.modelId} sessionFile=${params.sessionFile}`, - ); - } - - if (!skipPromptSubmission && !isRawModelRun && hookRunner?.hasHooks("llm_input")) { - hookRunner - .runLlmInput( - { - runId: params.runId, - sessionId: params.sessionId, - provider: params.provider, - model: params.modelId, - systemPrompt: systemPromptForHook, - prompt: llmBoundaryPromptForPrecheck, - historyMessages: cloneHookMessages(hookMessagesForCurrentPrompt), - imagesCount: imageResult.images.length, - tools, - }, - { - runId: params.runId, - trace: freezeDiagnosticTraceContext(diagnosticTrace), - agentId: hookAgentId, - sessionKey: params.sessionKey, - sessionId: params.sessionId, - workspaceDir: params.workspaceDir, - trigger: params.trigger, - ...buildAgentHookContextChannelFields(params), - ...buildAgentHookContextIdentityFields({ - trigger: params.trigger, - senderId: params.senderId, - chatId: params.chatId, - channelContext: params.channelContext, - }), - }, - ) - .catch((err: unknown) => { - log.warn(`llm_input hook failed: ${String(err)}`); - }); - } + runTrace, + sessionMessages: activeSession.messages, + skipPromptSubmission, + streamStrategy, + systemPromptForHook, + systemPromptText, + toolSearchCompacted: toolSearch.compacted, + tools, + trajectoryRecorder, + transcriptLeafId, + transport: effectiveAgentTransport, + uncompactedEffectiveTools, + }).skipPromptSubmission; const promptPreflight = await prepareEmbeddedAttemptPromptPreflight({ attempt: params,