Agents: harden embedded error observations

This commit is contained in:
Altay
2026-03-09 22:11:21 +03:00
parent ce88235e40
commit 4900042298
5 changed files with 76 additions and 11 deletions

View File

@@ -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");
});
});

View File

@@ -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 {};
}

View File

@@ -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}`,
});
};
}

View File

@@ -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: ***",
},
});
});

View File

@@ -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 {