mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -06:00
refactor(agents): extract attempt prompt observability
This commit is contained in:
@@ -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";
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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<string, unknown>,
|
||||
) => 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<typeof observeEmbeddedAttemptPrompt>[0];
|
||||
|
||||
function createInput(overrides: Partial<PromptObservabilityInput> = {}): 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();
|
||||
});
|
||||
});
|
||||
@@ -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<NonNullable<ReturnType<typeof createCacheTrace>>, "recordStage"> | null;
|
||||
type PromptHookRunner = Pick<HookRunner, "hasHooks" | "runLlmInput"> | null;
|
||||
type TrajectoryRecorder = Pick<
|
||||
NonNullable<ReturnType<typeof createTrajectoryRuntimeRecorder>>,
|
||||
"recordEvent"
|
||||
> | null;
|
||||
type TrajectoryTool = Parameters<typeof toTrajectoryToolDefinitions>[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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -21,10 +21,6 @@ import type { EmbeddedRunAttemptParams } from "./types.js";
|
||||
|
||||
type AttemptSessionManager = ReturnType<typeof guardSessionManager>;
|
||||
|
||||
export function cloneHookMessages(messages: AgentMessage[]): AgentMessage[] {
|
||||
return messages.map((message) => structuredClone(message));
|
||||
}
|
||||
|
||||
export function flushSessionManagerTranscript(sessionManager: AttemptSessionManager): void {
|
||||
(
|
||||
sessionManager as unknown as {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.]";
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user