diff --git a/src/agents/pi-embedded-error-observation.test.ts b/src/agents/pi-embedded-error-observation.test.ts index e3c2c629721..94979ebfb8c 100644 --- a/src/agents/pi-embedded-error-observation.test.ts +++ b/src/agents/pi-embedded-error-observation.test.ts @@ -3,6 +3,7 @@ import * as loggingConfigModule from "../logging/config.js"; import { buildApiErrorObservationFields, buildTextObservationFields, + sanitizeForConsole, } from "./pi-embedded-error-observation.js"; afterEach(() => { @@ -110,6 +111,16 @@ describe("buildApiErrorObservationFields", () => { expect(observed.providerErrorMessagePreview?.endsWith("…")).toBe(true); }); + it("caps oversized raw inputs before hashing and fingerprinting", () => { + const oversized = "X".repeat(70_000); + const bounded = "X".repeat(64_000); + + expect(buildApiErrorObservationFields(oversized)).toMatchObject({ + rawErrorHash: buildApiErrorObservationFields(bounded).rawErrorHash, + rawErrorFingerprint: buildApiErrorObservationFields(bounded).rawErrorFingerprint, + }); + }); + it("returns empty observation fields for empty input", () => { expect(buildApiErrorObservationFields(undefined)).toEqual({}); expect(buildApiErrorObservationFields("")).toEqual({}); @@ -163,3 +174,9 @@ describe("buildApiErrorObservationFields", () => { expect(observed.rawErrorPreview).toContain("custom"); }); }); + +describe("sanitizeForConsole", () => { + it("strips control characters from console-facing values", () => { + expect(sanitizeForConsole("run-1\nprovider\tmodel\rtest")).toBe("run-1 provider model test"); + }); +}); diff --git a/src/agents/pi-embedded-error-observation.ts b/src/agents/pi-embedded-error-observation.ts index 86bd728dad9..260bf83f4c5 100644 --- a/src/agents/pi-embedded-error-observation.ts +++ b/src/agents/pi-embedded-error-observation.ts @@ -4,6 +4,8 @@ import { getDefaultRedactPatterns, redactSensitiveText } from "../logging/redact import { getApiErrorPayloadFingerprint, parseApiErrorInfo } from "./pi-embedded-helpers.js"; import { stableStringify } from "./stable-stringify.js"; +const MAX_OBSERVATION_INPUT_CHARS = 64_000; +const MAX_FINGERPRINT_MESSAGE_CHARS = 8_000; const RAW_ERROR_PREVIEW_MAX_CHARS = 400; const PROVIDER_ERROR_PREVIEW_MAX_CHARS = 200; const REQUEST_ID_RE = /\brequest[_ ]?id\b\s*[:=]\s*["'()]*([A-Za-z0-9._:-]+)/i; @@ -29,6 +31,40 @@ function truncateForObservation(text: string | undefined, maxChars: number): str return trimmed.length > maxChars ? `${trimmed.slice(0, maxChars)}…` : trimmed; } +function boundObservationInput(text: string | undefined): string | undefined { + const trimmed = text?.trim(); + if (!trimmed) { + return undefined; + } + return trimmed.length > MAX_OBSERVATION_INPUT_CHARS + ? trimmed.slice(0, MAX_OBSERVATION_INPUT_CHARS) + : trimmed; +} + +export function sanitizeForConsole(text: string | undefined, maxChars = 200): string | undefined { + const trimmed = text?.trim(); + if (!trimmed) { + return undefined; + } + const withoutControlChars = Array.from(trimmed) + .filter((char) => { + const code = char.charCodeAt(0); + return !( + code <= 0x08 || + code === 0x0b || + code === 0x0c || + (code >= 0x0e && code <= 0x1f) || + code === 0x7f + ); + }) + .join(""); + const sanitized = withoutControlChars + .replace(/[\r\n\t]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + return sanitized.length > maxChars ? `${sanitized.slice(0, maxChars)}…` : sanitized; +} + function replaceRequestIdPreview( text: string | undefined, requestId: string | undefined, @@ -71,12 +107,16 @@ function buildObservationFingerprint(params: { type?: string; message?: string; }): string | null { + const boundedMessage = + params.message && params.message.length > MAX_FINGERPRINT_MESSAGE_CHARS + ? params.message.slice(0, MAX_FINGERPRINT_MESSAGE_CHARS) + : params.message; const structured = - params.httpCode || params.type || params.message + params.httpCode || params.type || boundedMessage ? stableStringify({ httpCode: params.httpCode, type: params.type, - message: params.message, + message: boundedMessage, }) : null; if (structured) { @@ -97,7 +137,7 @@ export function buildApiErrorObservationFields(rawError?: string): { providerErrorMessagePreview?: string; requestIdHash?: string; } { - const trimmed = rawError?.trim(); + const trimmed = boundObservationInput(rawError); if (!trimmed) { return {}; } diff --git a/src/agents/pi-embedded-runner/run/failover-observation.ts b/src/agents/pi-embedded-runner/run/failover-observation.ts index c4c50b18e47..9b915535314 100644 --- a/src/agents/pi-embedded-runner/run/failover-observation.ts +++ b/src/agents/pi-embedded-runner/run/failover-observation.ts @@ -1,6 +1,9 @@ import { redactIdentifier } from "../../../logging/redact-identifier.js"; import type { AuthProfileFailureReason } from "../../auth-profiles.js"; -import { buildApiErrorObservationFields } from "../../pi-embedded-error-observation.js"; +import { + buildApiErrorObservationFields, + sanitizeForConsole, +} from "../../pi-embedded-error-observation.js"; import type { FailoverReason } from "../../pi-embedded-helpers.js"; import { log } from "../logger.js"; @@ -42,6 +45,9 @@ export function createFailoverDecisionLogger( const safeProfileId = normalizedBase.profileId ? redactIdentifier(normalizedBase.profileId, { len: 12 }) : undefined; + const safeRunId = sanitizeForConsole(normalizedBase.runId) ?? "-"; + const safeProvider = sanitizeForConsole(normalizedBase.provider) ?? "-"; + const safeModel = sanitizeForConsole(normalizedBase.model) ?? "-"; const profileText = safeProfileId ?? "-"; const reasonText = normalizedBase.failoverReason ?? "none"; return (decision, extra) => { @@ -63,8 +69,8 @@ export function createFailoverDecisionLogger( status: extra?.status, ...observedError, consoleMessage: - `embedded run failover decision: runId=${normalizedBase.runId ?? "-"} stage=${normalizedBase.stage} decision=${decision} ` + - `reason=${reasonText} provider=${normalizedBase.provider}/${normalizedBase.model} profile=${profileText}`, + `embedded run failover decision: runId=${safeRunId} stage=${normalizedBase.stage} decision=${decision} ` + + `reason=${reasonText} provider=${safeProvider}/${safeModel} profile=${profileText}`, }); }; } diff --git a/src/agents/pi-embedded-subscribe.handlers.lifecycle.test.ts b/src/agents/pi-embedded-subscribe.handlers.lifecycle.test.ts index b84b532709e..b93cf43cebe 100644 --- a/src/agents/pi-embedded-subscribe.handlers.lifecycle.test.ts +++ b/src/agents/pi-embedded-subscribe.handlers.lifecycle.test.ts @@ -94,7 +94,7 @@ describe("handleAgentEnd", () => { }); }); - it("redacts logged error text while keeping lifecycle events unchanged", () => { + it("redacts logged error text before emitting lifecycle events", () => { const onAgentEvent = vi.fn(); const ctx = createContext( { @@ -118,7 +118,7 @@ describe("handleAgentEnd", () => { stream: "lifecycle", data: { phase: "error", - error: "x-api-key: sk-abcdefghijklmnopqrstuvwxyz123456", + error: "x-api-key: ***", }, }); }); diff --git a/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts b/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts index 10d1eaf426c..c666784ff8e 100644 --- a/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts +++ b/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts @@ -3,6 +3,7 @@ import { createInlineCodeState } from "../markdown/code-spans.js"; import { buildApiErrorObservationFields, buildTextObservationFields, + sanitizeForConsole, } from "./pi-embedded-error-observation.js"; import { classifyFailoverReason, formatAssistantErrorText } from "./pi-embedded-helpers.js"; import type { EmbeddedPiSubscribeContext } from "./pi-embedded-subscribe.handlers.types.js"; @@ -46,6 +47,7 @@ export function handleAgentEnd(ctx: EmbeddedPiSubscribeContext) { const observedError = buildApiErrorObservationFields(rawError); const safeErrorText = buildTextObservationFields(errorText).textPreview ?? "LLM request failed."; + const safeRunId = sanitizeForConsole(ctx.params.runId) ?? "-"; ctx.log.warn("embedded run agent end", { event: "embedded_run_agent_end", tags: ["error_handling", "lifecycle", "agent_end", "assistant_error"], @@ -56,14 +58,14 @@ export function handleAgentEnd(ctx: EmbeddedPiSubscribeContext) { provider: lastAssistant.provider, model: lastAssistant.model, ...observedError, - consoleMessage: `embedded run agent end: runId=${ctx.params.runId} isError=true error=${safeErrorText}`, + consoleMessage: `embedded run agent end: runId=${safeRunId} isError=true error=${safeErrorText}`, }); emitAgentEvent({ runId: ctx.params.runId, stream: "lifecycle", data: { phase: "error", - error: errorText, + error: safeErrorText, endedAt: Date.now(), }, }); @@ -71,7 +73,7 @@ export function handleAgentEnd(ctx: EmbeddedPiSubscribeContext) { stream: "lifecycle", data: { phase: "error", - error: errorText, + error: safeErrorText, }, }); } else {