FN-8697: preserve redacted failed-tool diagnostics

Persist safe error detail so operators can diagnose repeated executor tool failures.

- Redact and retain failed-tool diagnostics even when tool output persistence is disabled.
- Cover redaction, disclosure independence, inert rendering, and retry-backoff normalization.

Files changed:
 .../__tests__/AgentLogViewer.rendering.test.tsx    | 29 +++++++++++++++++
 .../src/__tests__/agent-logger-diagnostics.test.ts | 38 ++++++++++++++++++++++
 packages/engine/src/__tests__/agent-logger.test.ts |  4 +--
 .../__tests__/executor-tool-failure-retry.test.ts  | 22 ++++++++++++-
 packages/engine/src/agent-logger.ts                | 11 +++++--
 5 files changed, 99 insertions(+), 5 deletions(-)

Fusion-Task-Id: FN-8697

Fusion-Task-Lineage: 5ed900b6-a42b-4ad7-9270-47053295e6e1

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-01 12:48:30 -07:00
parent 39368f4bb8
commit 494362cd23
5 changed files with 99 additions and 5 deletions

View File

@@ -334,6 +334,35 @@ describe("AgentLogViewer", () => {
expect(content.classList.contains("agent-log-tool-detail-content--collapsed")).toBe(true);
});
it("keeps identical failed-tool disclosures independent and renders error markup as inert text", () => {
Object.defineProperty(window, "innerWidth", { configurable: true, writable: true, value: 390 });
const detail = "Error: edit failed\n<script>alert('inert')</script>\n at edit (tools.ts:12:4)";
const entries = [
makeEntry({ text: "edit", type: "tool_error", detail, timestamp: "2026-01-01T00:00:00Z" }),
makeEntry({ text: "edit", type: "tool_error", detail, timestamp: "2026-01-01T00:00:00Z" }),
makeEntry({ text: "edit", type: "tool_error", detail, timestamp: "2026-01-01T00:00:00Z" }),
];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const toggles = screen.getAllByTestId("tool-detail-toggle");
expect(toggles).toHaveLength(3);
expect(toggles[0]).toHaveAccessibleName("Show output (3 lines)");
expect(toggles[0]).toBeEnabled();
fireEvent.click(toggles[0]);
expect(toggles[0]).toHaveAttribute("aria-expanded", "true");
expect(toggles[1]).toHaveAttribute("aria-expanded", "false");
fireEvent.click(toggles[1]);
expect(toggles[1]).toHaveAttribute("aria-expanded", "true");
expect(toggles[2]).toHaveAttribute("aria-expanded", "false");
const expandedDetails = screen.getAllByTestId("tool-detail-content").filter(
(content) => !content.classList.contains("agent-log-tool-detail-content--collapsed"),
);
expect(expandedDetails).toHaveLength(2);
expect(expandedDetails[0]).toHaveTextContent("<script>alert('inert')</script>");
expect(expandedDetails[1]).toHaveTextContent("<script>alert('inert')</script>");
expect(container.querySelector("script")).toBeNull();
});
it("shows complete long tool output after expanding the output disclosure", () => {
const longDetail = `first line\n${"output ".repeat(45)}AGENT_LOG_RESULT_SUFFIX`;
render(<AgentLogViewer entries={[makeEntry({ text: "Bash", type: "tool_result", detail: longDetail })]} loading={false} />);

View File

@@ -0,0 +1,38 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AgentLogger } from "../agent-logger.js";
vi.mock("../logger.js", () => ({
createLogger: () => ({ log: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() }),
}));
describe("AgentLogger failed-tool diagnostics (FN-8697)", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("persists redacted error detail even when normal tool output persistence is disabled", async () => {
const appendLog = vi.fn().mockResolvedValue(undefined);
const logger = new AgentLogger({
taskId: "FN-8697",
appendLog,
persistAgentToolOutput: false,
});
const secret = "sk-live-ABCDEFG1234567890abcdef";
// FNXC:AgentLogDiagnostics 2026-08-01-19:24: Tool diagnostics must honor every shared-redactor match, including low-entropy opaque values, before either log sink persists them.
const opaqueToken = "a".repeat(40);
const detail = `Error: edit failed\nAuthorization: Bearer ${secret}\ntoken=${opaqueToken}\n at edit (tools.ts:12:4)`;
logger.onToolEnd("edit", true, detail);
await logger.flush();
expect(appendLog).toHaveBeenCalledWith(expect.objectContaining({
type: "tool_error",
text: "edit",
detail: expect.stringContaining("Error: edit failed"),
}));
const persisted = appendLog.mock.calls[0][0].detail as string;
expect(persisted).toContain("at edit (tools.ts:12:4)");
expect(persisted).toContain("[REDACTED]");
expect(persisted).not.toContain(secret);
expect(persisted).not.toContain(opaqueToken);
});
});

View File

@@ -551,7 +551,7 @@ describe("AgentLogger", () => {
persistAgentToolOutput: true,
});
const longError = "error:" + "y".repeat(1200);
const longError = "error:" + "y-".repeat(600);
logger.onToolEnd("Read", true, longError);
await vi.advanceTimersByTimeAsync(0);
@@ -568,7 +568,7 @@ describe("AgentLogger", () => {
persistAgentToolOutput: true,
});
const longResult = "x".repeat(600);
const longResult = "x-".repeat(300);
logger.onToolEnd("Bash", false, longResult);
await vi.advanceTimersByTimeAsync(0);

View File

@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { TaskDetail } from "@fusion/core";
import { resolveConsecutiveToolFailureRetryBackoffMs, resolveMaxConsecutiveToolFailureRetries, type TaskDetail } from "@fusion/core";
import "./executor-test-helpers.js";
import { TaskExecutor } from "../executor.js";
import { createMockStore, resetExecutorMocks } from "./executor-test-helpers.js";
@@ -105,6 +105,26 @@ describe("executor consecutive tool-failure retry (FN-7996)", () => {
}));
});
it("normalizes the configured backoff and waits before retrying", async () => {
const { executor, task } = makeHarness({
retries: 2.9,
entries: [{ type: "tool_error" }, { type: "tool_error" }, { type: "tool_error" }],
settings: { executorToolFailureRetryBackoffMs: 2500.9 },
});
const execute = vi.spyOn(executor as any, "execute").mockResolvedValue(undefined);
expect(resolveMaxConsecutiveToolFailureRetries({ executorToolFailureRetryCount: 2.9 })).toBe(2);
expect(resolveMaxConsecutiveToolFailureRetries({ executorToolFailureRetryCount: -1 })).toBe(2);
expect(resolveConsecutiveToolFailureRetryBackoffMs({ executorToolFailureRetryBackoffMs: 2500.9 })).toBe(2500);
expect(resolveConsecutiveToolFailureRetryBackoffMs({ executorToolFailureRetryBackoffMs: -1 })).toBe(2000);
await (executor as any).handleGraphFailure(task, graphFailure());
await vi.advanceTimersByTimeAsync(2499);
expect(execute).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(execute).toHaveBeenCalledWith(task);
});
it("parks unchanged after a spent retry budget and emits one exhaustion audit", async () => {
const { executor, store, task } = makeHarness({
retries: 2,

View File

@@ -1,6 +1,6 @@
import { createHash } from "node:crypto";
import type { TaskStore, AgentLogEntry, AgentRole } from "@fusion/core";
import { categorizeToolName } from "@fusion/core";
import { categorizeToolName, redactSecrets } from "@fusion/core";
import { createLogger } from "./logger.js";
/**
@@ -483,12 +483,19 @@ export class AgentLogger {
const isToolEntry = type === "tool" || type === "tool_result" || type === "tool_error";
// FNXC:AgentLogging 2026-07-15-16:00: Failed tool detail is diagnostic signal, unlike verbose arguments/success output, and must survive default-off tool-output persistence for FN-7995 Activity diagnosis.
const includeDetail = !isToolEntry || type === "tool_error" || this.persistAgentToolOutput;
/*
FNXC:AgentLogDiagnostics 2026-08-01-17:51:
FN-8697 requires useful failed-tool detail to cross the established secret-redaction boundary
before an AgentLogger batch reaches JSONL, the API, or the Activity disclosure. Redact at this
common entry point so store and callback sinks cannot persist or display credentials.
*/
const persistedDetail = detail !== undefined && includeDetail ? redactSecrets(detail) : undefined;
const entry: AgentLogEntry = {
timestamp: new Date().toISOString(),
taskId: this.taskId,
text,
type,
...(detail !== undefined && includeDetail && { detail }),
...(persistedDetail !== undefined && { detail: persistedDetail }),
...(this.agent !== undefined && { agent: this.agent }),
...(timing?.durationMs !== undefined && { durationMs: timing.durationMs }),
...(timing?.timeToFirstTokenMs !== undefined && { timeToFirstTokenMs: timing.timeToFirstTokenMs }),