FN-199: request detailed reasoning summaries
Request detailed reasoning bodies for Responses-family model traces while preserving provider fallback behavior.\n\n- Shape supported Responses payloads to request detailed reasoning summaries.\n- Retry once with the prior payload when detailed summaries are rejected.\n- Preserve titled trace bodies across dashboard surfaces and document the setting.\n\nFiles changed:\n .changeset/fn-199-reasoning-summary.md | 7 + docs/dashboard-guide.md | 2 +- docs/settings-reference.md | 4 + .../__tests__/ThinkingTrace.surfaces.test.tsx | 19 +++ .../components/__tests__/ThinkingTrace.test.tsx | 4 + .../src/__tests__/pi-reasoning-summary.test.ts | 150 +++++++++++++++++++++ .../__tests__/reasoning-summary-payload.test.ts | 102 ++++++++++++++ .../src/execution/reasoning-summary-payload.ts | 72 ++++++++++ packages/engine/src/pi.ts | 48 +++++++ 9 files changed, 407 insertions(+), 1 deletion(-) Fusion-Task-Id: FN-199 Fusion-Task-Lineage: a3179f05-e709-4bae-9f4a-5507810717d8 Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-199-reasoning-summary.md
Normal file
7
.changeset/fn-199-reasoning-summary.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Show detailed reasoning bodies alongside titles for supported Responses models.
|
||||
category: fix
|
||||
dev: Uses the pi Agent `onPayload` seam only for the OpenAI Responses API family.
|
||||
@@ -110,7 +110,7 @@ Press `Escape` to close the current/topmost dashboard popup. Popped-out task win
|
||||
<!-- FNXC:ChatFindDocs 2026-08-21-16:29: FN-110 gives the active visible Chat host contextual Find ownership without adding a configurable dashboard shortcut. -->
|
||||
### Thinking traces
|
||||
|
||||
Thinking panes split titled reasoning traces with captured bodies into independently expandable sections, with **Collapse all** and **Expand all** controls. Headings without captured reasoning stay inline in the flowing trace instead of becoming empty collapsible rows, and the defensive empty-state label appears at most once per section. **Raw trace** shows the original unsectioned capture and switches back with **Sectioned trace**; it is available for every trace that contained headings, including a titles-only trace rendered as one flowing block. The workflow live-log console remains raw and unsectioned by design. The same behavior applies while Planning Mode, Mission Interview, and Milestone/Slice Interview stream a generation.
|
||||
Thinking panes split titled reasoning traces with captured bodies into independently expandable sections, with **Collapse all** and **Expand all** controls. Responses-family models now request titled sections with their reasoning bodies. A titles-only trace can still arrive from a provider Fusion cannot configure; those headings stay inline in the flowing trace instead of becoming empty collapsible rows, and the defensive empty-state label appears at most once per section. **Raw trace** shows the original unsectioned capture and switches back with **Sectioned trace**; use it to diagnose a provider-side titles-only payload. The workflow live-log console remains raw and unsectioned by design. The same behavior applies while Planning Mode, Mission Interview, and Milestone/Slice Interview stream a generation.
|
||||
|
||||
### Chat Find
|
||||
|
||||
|
||||
@@ -103,6 +103,10 @@ Fusion persists one canonical ordered vocabulary: `off`, `minimal`, `low`, `medi
|
||||
|
||||
Custom-provider models are presumed thinking-capable and expose all seven levels by default; no capability checkbox or other configuration is required. Fusion registers these levels with pi as transmissible, while pi owns API-specific `off` translation and up-then-down clamping. Consequently, a global or inherited thinking level that custom providers previously ignored is now sent to the gateway. A strict gateway can reject an unsupported effort; select **Off** for that gateway, which pi translates to its explicit non-reasoning form or omits when the API has no such form.
|
||||
|
||||
#### Reasoning-summary detail
|
||||
|
||||
When a real thinking effort is resolved, Fusion requests `reasoning.summary: "detailed"` for Responses-family models: `openai-responses`, `openai-codex-responses`, and `azure-openai-responses`. Pi forwards the effort through its simple stream path but not this summary preference; without Fusion's request shaping, those APIs use their shortest `"auto"` summary. Non-Responses APIs are unchanged. If a provider rejects detailed summaries, Fusion retries that run with the previous request shape. Detailed summaries can consume slightly more output tokens.
|
||||
|
||||
| `ntfyEnabled` | `boolean` | `false` | Enable ntfy push notifications. |
|
||||
| `agentClarificationEnabled` | `boolean` | `false` | Legacy default for programmatic Planning Mode session notification eligibility. Dashboard Planning Mode always starts its infinite, user-validated interview with follow-up questions enabled; this setting no longer suppresses questions or creates a final summary. |
|
||||
| `failureNotificationMode` | `"sticky-only" \| "terminal-only" \| "all"` | `"sticky-only"` | Failure notification behavior. `sticky-only` defers failed-task notifications by `failureNotificationDelayMs` and suppresses transient self-recoveries. `terminal-only` suppresses while auto-retry is still active and only dispatches when `paused === true` or `column === "in-review"` with `status === "failed"`. `all` restores legacy immediate failure notifications. |
|
||||
|
||||
@@ -43,6 +43,25 @@ describe("ThinkingTrace production transcript surfaces", () => {
|
||||
expectTraceIsolation(disclosure);
|
||||
});
|
||||
|
||||
it("keeps detailed bodies in the mobile live-thinking pane", () => {
|
||||
const previousWidth = window.innerWidth;
|
||||
Object.defineProperty(window, "innerWidth", { configurable: true, value: 375 });
|
||||
try {
|
||||
const { container } = render(<StandardStreamingMessage streamingText="" streamingThinking={trace} />);
|
||||
const disclosure = container.querySelector("details.chat-message-thinking") as HTMLDetailsElement;
|
||||
fireEvent.click(disclosure.querySelector("summary")!);
|
||||
|
||||
const sections = disclosure.querySelectorAll<HTMLElement>("[data-testid='thinking-trace-section']");
|
||||
expect(sections).toHaveLength(3);
|
||||
expect(sections[0]).toHaveAttribute("open");
|
||||
expect(sections[0]).toHaveTextContent("Docker tests need development dependencies.");
|
||||
expect(sections[1]).toHaveTextContent("Deployment commits remain independently reviewable.");
|
||||
expect(sections[2]).toHaveTextContent("README edits remain visible in their own section.");
|
||||
} finally {
|
||||
Object.defineProperty(window, "innerWidth", { configurable: true, value: previousWidth });
|
||||
}
|
||||
});
|
||||
|
||||
it("renders the same isolated trace in agent logs in markdown and plain modes", () => {
|
||||
const entries = [{ taskId: "FN-155", timestamp: "2026-08-22T00:00:00.000Z", type: "thinking", text: trace }] as AgentLogEntry[];
|
||||
const { container, rerender } = render(<AgentLogViewer entries={entries} loading={false} renderMarkdown />);
|
||||
|
||||
@@ -76,6 +76,10 @@ describe("ThinkingTrace", () => {
|
||||
it("keeps every populated title expanded and isolated until its own section is collapsed", () => {
|
||||
render(<ThinkingTrace text={trace} />);
|
||||
expect(sections()).toHaveLength(4);
|
||||
const renderedBodies = sections().map((section) => section.textContent).join("\n");
|
||||
expect(renderedBodies).toContain("The Docker image needs development dependencies for test execution.");
|
||||
expect(renderedBodies).toContain("Deployment commits should be split by independently reviewable behavior.");
|
||||
expect(renderedBodies).toContain("The README change belongs in its own reviewed update.");
|
||||
const deployment = sections().find((section) => section.textContent?.includes("Planning deployment commit structure"))!;
|
||||
expect(deployment.textContent).toContain("Deployment commits should be split by independently reviewable behavior.");
|
||||
fireEvent.click(within(deployment).getByText("Planning deployment commit structure"));
|
||||
|
||||
150
packages/engine/src/__tests__/pi-reasoning-summary.test.ts
Normal file
150
packages/engine/src/__tests__/pi-reasoning-summary.test.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const createAgentSessionMock = vi.fn();
|
||||
const modelRegistry = {
|
||||
modelRuntime: { getAuth: vi.fn(), refresh: vi.fn() },
|
||||
find: vi.fn((provider: string, id: string) => ({ provider, id })),
|
||||
getAll: vi.fn(() => []),
|
||||
registerProvider: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("@earendil-works/pi-coding-agent", () => ({
|
||||
LegacyCredentialStorage: { create: vi.fn(() => ({})) },
|
||||
createAgentSession: createAgentSessionMock,
|
||||
createBashTool: vi.fn((cwd: string) => ({ name: "bash", cwd })),
|
||||
createCodingTools: vi.fn(() => []),
|
||||
createEditTool: vi.fn(() => ({ name: "edit" })),
|
||||
createExtensionRuntime: vi.fn(),
|
||||
createFindTool: vi.fn(() => ({ name: "find" })),
|
||||
createGrepTool: vi.fn(() => ({ name: "grep" })),
|
||||
createLsTool: vi.fn(() => ({ name: "ls" })),
|
||||
createReadOnlyTools: vi.fn(() => []),
|
||||
createReadTool: vi.fn(() => ({ name: "read" })),
|
||||
createWriteTool: vi.fn(() => ({ name: "write" })),
|
||||
DefaultResourceLoader: class { async reload() {} },
|
||||
DefaultPackageManager: class { async resolve() { return { extensions: [] }; } },
|
||||
discoverAndLoadExtensions: vi.fn(async () => ({ runtime: { pendingProviderRegistrations: [] }, errors: [] })),
|
||||
getAgentDir: () => "/mock-agent-dir",
|
||||
ModelRegistry: class {},
|
||||
ModelRuntime: { create: vi.fn(async () => modelRegistry.modelRuntime) },
|
||||
SessionManager: { inMemory: () => ({ getSessionId: () => undefined }) },
|
||||
SettingsManager: { inMemory: () => ({}) },
|
||||
}));
|
||||
|
||||
vi.mock("../auth/auth-storage.js", () => ({
|
||||
createFusionAuthStorage: vi.fn(() => ({})),
|
||||
createFusionModelRegistry: vi.fn(async () => modelRegistry),
|
||||
}));
|
||||
vi.mock("../auth/model-registry-refresh.js", () => ({
|
||||
refreshFusionModelRegistry: vi.fn(async () => "completed"),
|
||||
}));
|
||||
vi.mock("../auth/custom-providers.js", () => ({ readCustomProviders: vi.fn(() => []) }));
|
||||
|
||||
function makeSession(agent: { onPayload?: (payload: unknown, model: { api?: unknown }) => unknown | Promise<unknown> } = {}) {
|
||||
return {
|
||||
agent,
|
||||
prompt: vi.fn(async () => undefined),
|
||||
subscribe: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
setThinkingLevel: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
async function createSession(session = makeSession(), options: Record<string, unknown> = {}) {
|
||||
createAgentSessionMock.mockResolvedValueOnce({ session });
|
||||
const { createPiAgentSessionRaw } = await import("../pi.js");
|
||||
const result = await createPiAgentSessionRaw({
|
||||
cwd: "/tmp",
|
||||
systemPrompt: "test",
|
||||
tools: "readonly",
|
||||
defaultProvider: "openai",
|
||||
defaultModelId: "gpt-5",
|
||||
...options,
|
||||
});
|
||||
return { result, session };
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:ThinkingTrace 2026-08-27-10:45:
|
||||
Fusion can prove only the request payload it sends; a provider's generated reasoning bodies are outside this process. These tests therefore exercise the live createFnAgent session hook rather than asserting provider response content.
|
||||
*/
|
||||
describe("createFnAgent reasoning-summary payload hook", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
modelRegistry.find.mockImplementation((provider: string, id: string) => ({ provider, id }));
|
||||
modelRegistry.getAll.mockReturnValue([]);
|
||||
});
|
||||
|
||||
it("installs onPayload on every created pi session", async () => {
|
||||
const session = makeSession();
|
||||
await createSession(session);
|
||||
|
||||
expect(session.agent.onPayload).toEqual(expect.any(Function));
|
||||
});
|
||||
|
||||
it("upgrades a Responses request while preserving its effort", async () => {
|
||||
const { session } = await createSession();
|
||||
const result = await session.agent.onPayload?.(
|
||||
{ reasoning: { effort: "medium", summary: "auto" } },
|
||||
{ api: "openai-responses" },
|
||||
);
|
||||
|
||||
expect(result).toEqual({ reasoning: { effort: "medium", summary: "detailed" } });
|
||||
});
|
||||
|
||||
it("leaves Anthropic and disabled-thinking requests unchanged", async () => {
|
||||
const { session } = await createSession();
|
||||
const anthropicPayload = { reasoning: { effort: "medium", summary: "auto" } };
|
||||
const disabledPayload = { reasoning: { effort: "none" } };
|
||||
|
||||
expect(await session.agent.onPayload?.(anthropicPayload, { api: "anthropic-messages" })).toBeUndefined();
|
||||
expect(await session.agent.onPayload?.(disabledPayload, { api: "openai-responses" })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("chains an upstream replacement and preserves it when Fusion makes no change", async () => {
|
||||
const replacement = { reasoning: { effort: "high", summary: "auto" }, source: "upstream" };
|
||||
const upstream = vi.fn(() => replacement);
|
||||
const { session } = await createSession(makeSession({ onPayload: upstream }));
|
||||
|
||||
expect(await session.agent.onPayload?.({ ignored: true }, { api: "openai-responses" })).toEqual({
|
||||
reasoning: { effort: "high", summary: "detailed" },
|
||||
source: "upstream",
|
||||
});
|
||||
expect(await session.agent.onPayload?.({ ignored: true }, { api: "anthropic-messages" })).toBe(replacement);
|
||||
expect(upstream).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not upgrade requests when summary detail is off", async () => {
|
||||
const { session } = await createSession(makeSession(), { reasoningSummaryDetail: "off" });
|
||||
|
||||
expect(await session.agent.onPayload?.(
|
||||
{ reasoning: { effort: "medium", summary: "auto" } },
|
||||
{ api: "openai-responses" },
|
||||
)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("retries once on the same session after an unsupported-summary rejection", async () => {
|
||||
const requests: unknown[] = [];
|
||||
const agent: { onPayload?: (payload: unknown, model: { api?: unknown }) => Promise<unknown> } = {};
|
||||
const session = makeSession(agent);
|
||||
const prompt = session.prompt;
|
||||
let attempts = 0;
|
||||
prompt.mockImplementation(async () => {
|
||||
requests.push(await agent.onPayload?.(
|
||||
{ reasoning: { effort: "medium", summary: "auto" } },
|
||||
{ api: "openai-responses" },
|
||||
));
|
||||
attempts += 1;
|
||||
if (attempts === 1) throw new Error("Unsupported reasoning summary: detailed");
|
||||
});
|
||||
|
||||
const { result } = await createSession(session);
|
||||
await (result.session as any).promptWithFallback("test summary fallback");
|
||||
|
||||
expect(prompt).toHaveBeenCalledTimes(2);
|
||||
expect(requests).toEqual([
|
||||
{ reasoning: { effort: "medium", summary: "detailed" } },
|
||||
undefined,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyReasoningSummaryToPayload,
|
||||
isReasoningSummaryUnsupportedError,
|
||||
} from "../reasoning-summary-payload.js";
|
||||
|
||||
const responsesApis = ["openai-responses", "openai-codex-responses", "azure-openai-responses"] as const;
|
||||
const nonResponsesApis = [
|
||||
"anthropic-messages",
|
||||
"openai-completions",
|
||||
"google-generative-ai",
|
||||
"bedrock-converse-stream",
|
||||
"mistral-conversations",
|
||||
"pi-messages",
|
||||
] as const;
|
||||
|
||||
describe("applyReasoningSummaryToPayload", () => {
|
||||
it.each(responsesApis)("upgrades an enabled %s request while preserving effort", (api) => {
|
||||
const payload = { reasoning: { effort: "medium", summary: "auto" }, input: "keep" };
|
||||
|
||||
expect(applyReasoningSummaryToPayload(payload, { api }, "detailed")).toEqual({
|
||||
reasoning: { effort: "medium", summary: "detailed" },
|
||||
input: "keep",
|
||||
});
|
||||
});
|
||||
|
||||
it.each(nonResponsesApis)("leaves %s payloads byte-identical", (api) => {
|
||||
const payload = { reasoning: { effort: "high", summary: "auto" }, input: "keep" };
|
||||
|
||||
expect(applyReasoningSummaryToPayload(payload, { api }, "detailed")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("adds detailed only to an enabled request whose summary is absent", () => {
|
||||
const payload = { reasoning: { effort: "high" } };
|
||||
|
||||
expect(applyReasoningSummaryToPayload(payload, { api: "openai-responses" }, "detailed")).toEqual({
|
||||
reasoning: { effort: "high", summary: "detailed" },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["missing reasoning", { input: "keep" }],
|
||||
["thinking disabled", { reasoning: { effort: "none" } }],
|
||||
["existing detailed summary", { reasoning: { effort: "high", summary: "detailed" } }],
|
||||
["explicit concise summary", { reasoning: { effort: "high", summary: "concise" } }],
|
||||
["null payload", null],
|
||||
["string payload", "payload"],
|
||||
["array payload", []],
|
||||
["string reasoning", { reasoning: "high" }],
|
||||
["null reasoning", { reasoning: null }],
|
||||
])("does not alter %s", (_name, payload) => {
|
||||
expect(applyReasoningSummaryToPayload(payload, { api: "openai-responses" }, "detailed")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not mutate either the request or its reasoning object", () => {
|
||||
const reasoning = { effort: "medium", summary: "auto" };
|
||||
const payload = { reasoning, input: "keep" };
|
||||
|
||||
const result = applyReasoningSummaryToPayload(payload, { api: "openai-responses" }, "detailed");
|
||||
|
||||
expect(payload).toEqual({ reasoning: { effort: "medium", summary: "auto" }, input: "keep" });
|
||||
expect(result).not.toBe(payload);
|
||||
expect(result?.reasoning).not.toBe(reasoning);
|
||||
});
|
||||
|
||||
it("treats auto and off detail as no-op requests", () => {
|
||||
const payload = { reasoning: { effort: "medium", summary: "auto" } };
|
||||
|
||||
expect(applyReasoningSummaryToPayload(payload, { api: "openai-responses" }, "auto")).toBeUndefined();
|
||||
expect(applyReasoningSummaryToPayload(payload, { api: "openai-responses" }, "off")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("can deliberately request concise without overriding an explicit choice", () => {
|
||||
const payload = { reasoning: { effort: "medium", summary: "auto" } };
|
||||
|
||||
expect(applyReasoningSummaryToPayload(payload, { api: "openai-responses" }, "concise")).toEqual({
|
||||
reasoning: { effort: "medium", summary: "concise" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isReasoningSummaryUnsupportedError", () => {
|
||||
it.each([
|
||||
"Unsupported reasoning summary: detailed",
|
||||
"reasoning_summary is invalid for this model",
|
||||
"Unknown reasoning summary option",
|
||||
"Summary reasoning is not supported by this endpoint",
|
||||
])("recognizes an explicit summary capability rejection: %s", (message) => {
|
||||
expect(isReasoningSummaryUnsupportedError(message)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"400 Bad Request",
|
||||
"maximum context length exceeded",
|
||||
"invalid API key",
|
||||
"cannot specify both thinking and reasoning_effort",
|
||||
"reasoning effort is unsupported",
|
||||
"summary field is invalid",
|
||||
])("does not misclassify unrelated provider errors: %s", (message) => {
|
||||
expect(isReasoningSummaryUnsupportedError(message)).toBe(false);
|
||||
});
|
||||
});
|
||||
72
packages/engine/src/execution/reasoning-summary-payload.ts
Normal file
72
packages/engine/src/execution/reasoning-summary-payload.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
FNXC:ThinkingTrace 2026-08-27-10:45:
|
||||
Pi-coding-agent streams Responses models through `streamSimple`, which forwards reasoning effort but cannot carry `reasoningSummary`. Pi therefore defaults Responses payloads to the short `"auto"` summary that can contain titles without bodies.
|
||||
|
||||
Pi 0.84.1 exposes `Agent.onPayload` as the request-shaping seam. This helper upgrades only already-enabled Responses reasoning; CLI and ACP runtimes are structurally exempt because they never construct a pi Agent.
|
||||
*/
|
||||
|
||||
export const RESPONSES_FAMILY_APIS = new Set([
|
||||
"openai-responses",
|
||||
"openai-codex-responses",
|
||||
"azure-openai-responses",
|
||||
] as const);
|
||||
|
||||
export type ResponsesFamilyApi = typeof RESPONSES_FAMILY_APIS extends Set<infer Api> ? Api : never;
|
||||
export type ReasoningSummaryDetail = "auto" | "concise" | "detailed" | "off";
|
||||
|
||||
type PayloadModel = { api?: unknown };
|
||||
type ReasoningPayload = { effort?: unknown; summary?: unknown };
|
||||
type ProviderPayload = Record<string, unknown> & { reasoning?: unknown };
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isResponsesFamilyApi(api: unknown): api is ResponsesFamilyApi {
|
||||
return typeof api === "string" && RESPONSES_FAMILY_APIS.has(api as ResponsesFamilyApi);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a replacement only when an already-enabled Responses request needs a
|
||||
* more detailed reasoning summary. This preserves pi's undefined-means-keep
|
||||
* contract and never enables reasoning for a request that omitted it.
|
||||
*/
|
||||
export function applyReasoningSummaryToPayload(
|
||||
payload: unknown,
|
||||
model: PayloadModel,
|
||||
detail: ReasoningSummaryDetail,
|
||||
): ProviderPayload | undefined {
|
||||
if (detail === "off" || detail === "auto" || !isResponsesFamilyApi(model.api) || !isRecord(payload)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const reasoning = payload.reasoning;
|
||||
if (!isRecord(reasoning)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const effort = reasoning.effort;
|
||||
if (typeof effort !== "string" || effort.length === 0 || effort === "none") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const summary = reasoning.summary;
|
||||
if (summary !== undefined && summary !== "auto") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...payload,
|
||||
reasoning: {
|
||||
...reasoning,
|
||||
summary: detail,
|
||||
} satisfies ReasoningPayload,
|
||||
};
|
||||
}
|
||||
|
||||
/** Match only explicit provider rejections of the optional summary request. */
|
||||
export function isReasoningSummaryUnsupportedError(message: string): boolean {
|
||||
const mentionsReasoningSummary = /\breasoning[\s_-]*summary\b|\bsummary\b[\s\S]{0,80}\breasoning\b/i.test(message);
|
||||
const rejectsFeature = /\b(?:unsupported|not supported|unknown|unrecognized|invalid|not allowed|not available)\b/i.test(message);
|
||||
return mentionsReasoningSummary && rejectsFeature;
|
||||
}
|
||||
@@ -88,6 +88,11 @@ import { resolvePermanentAgentToolDecision } from "./agents/permanent-agent-gati
|
||||
import type { SystemPromptLayers } from "./execution/prompt-layers.js";
|
||||
import { READONLY_ALLOWLIST, filterCustomToolsForReadonly, isReadonlyAllowed } from "./workflows/workflow-step-tool-policy.js";
|
||||
import { createStreamingDeltaNormalizer } from "./execution/streaming-delta.js";
|
||||
import {
|
||||
applyReasoningSummaryToPayload,
|
||||
isReasoningSummaryUnsupportedError,
|
||||
type ReasoningSummaryDetail,
|
||||
} from "./execution/reasoning-summary-payload.js";
|
||||
import { isModelAuthTierIncompatibilityError, isProviderModelNotFoundError, isUnsupportedMessageRoleError } from "./errors/transient-error-detector.js";
|
||||
import { logMcpForwardingSkipped, runtimeSupportsMcp } from "./mcp/mcp-runtime-support.js";
|
||||
import { connectMcpSessionTools, type McpClientFactory, type McpSessionToolset } from "./mcp/mcp-session-tools.js";
|
||||
@@ -231,12 +236,14 @@ interface ToolHookResult {
|
||||
type AgentToolHookSession = AgentSession & {
|
||||
agent?: {
|
||||
afterToolCall?: (payload: ToolHookPayload) => Promise<ToolHookResult | undefined>;
|
||||
onPayload?: (payload: unknown, model: { api?: unknown }) => unknown | undefined | Promise<unknown | undefined>;
|
||||
state?: {
|
||||
messages?: Array<Record<string, unknown>>;
|
||||
};
|
||||
};
|
||||
__fusionToolResultGuardInstalled?: boolean;
|
||||
__fusionMessageContentGuardInstalled?: boolean;
|
||||
__fusionReasoningSummaryPayloadHookInstalled?: boolean;
|
||||
};
|
||||
const FN_MEMORY_APPEND_TOOL_NAME = "fn_memory_append";
|
||||
const FUSION_SHUTDOWN_WRAP_FLAG = "__fusionSessionShutdownDisposeWrapped";
|
||||
@@ -1099,6 +1106,8 @@ export interface AgentOptions {
|
||||
fallbackThinkingLevel?: string;
|
||||
/** Default thinking effort level (e.g. "medium", "high"). When provided, sets the session's thinking level after creation. */
|
||||
defaultThinkingLevel?: string;
|
||||
/** Detail requested for already-enabled Responses reasoning summaries; defaults to detailed. */
|
||||
reasoningSummaryDetail?: ReasoningSummaryDetail;
|
||||
/** Optional pre-configured SessionManager. When provided, the agent session
|
||||
* uses this instead of creating an in-memory session. Pass a file-based
|
||||
* SessionManager to enable session persistence and pause/resume. */
|
||||
@@ -3135,10 +3144,37 @@ export async function createPiAgentSessionRaw(options: AgentOptions): Promise<Ag
|
||||
piLog.debug("Fallback session created successfully");
|
||||
}
|
||||
|
||||
const reasoningSummaryDetail = options.reasoningSummaryDetail ?? "detailed";
|
||||
let reasoningSummaryCompatibilityDisabled = reasoningSummaryDetail === "off";
|
||||
/*
|
||||
FNXC:ThinkingTrace 2026-08-27-10:45:
|
||||
Pi may already own an onPayload hook for extension request processing. Chain it so a replacement payload remains authoritative, then upgrade only its existing Responses reasoning; fallback-swapped sessions install the same hook so recovery cannot revert to titles-only summaries.
|
||||
*/
|
||||
const installReasoningSummaryPayloadHook = (session: AgentToolHookSession): void => {
|
||||
if (session.__fusionReasoningSummaryPayloadHookInstalled || !session.agent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const agent = session.agent;
|
||||
const previousOnPayload = agent.onPayload;
|
||||
agent.onPayload = async (payload, model) => {
|
||||
const previousResult = previousOnPayload ? await previousOnPayload(payload, model) : undefined;
|
||||
const effectivePayload = previousResult ?? payload;
|
||||
const replacement = applyReasoningSummaryToPayload(
|
||||
effectivePayload,
|
||||
model,
|
||||
reasoningSummaryCompatibilityDisabled ? "off" : reasoningSummaryDetail,
|
||||
);
|
||||
return replacement ?? previousResult;
|
||||
};
|
||||
session.__fusionReasoningSummaryPayloadHookInstalled = true;
|
||||
};
|
||||
|
||||
let activeSession = sessionResult.session;
|
||||
wrapSessionDisposeWithShutdown(activeSession);
|
||||
installToolResultContentGuard(activeSession as AgentToolHookSession);
|
||||
installMessageContentGuard(activeSession as AgentToolHookSession, sessionManager as unknown as SessionManagerLike);
|
||||
installReasoningSummaryPayloadHook(activeSession as AgentToolHookSession);
|
||||
(activeSession as any).__fusionMemoryAppendAvailable = options.customTools?.some((tool) => tool.name === FN_MEMORY_APPEND_TOOL_NAME) === true;
|
||||
const promptableSession = activeSession as PromptableSession;
|
||||
|
||||
@@ -3172,6 +3208,7 @@ export async function createPiAgentSessionRaw(options: AgentOptions): Promise<Ag
|
||||
targetSession as unknown as AgentToolHookSession,
|
||||
sessionManager as unknown as SessionManagerLike,
|
||||
);
|
||||
installReasoningSummaryPayloadHook(targetSession as unknown as AgentToolHookSession);
|
||||
(targetSession as any).__fusionMemoryAppendAvailable = options.customTools?.some((tool) => tool.name === FN_MEMORY_APPEND_TOOL_NAME) === true;
|
||||
const deltaNormalizer = createStreamingDeltaNormalizer();
|
||||
targetSession.subscribe((event) => {
|
||||
@@ -3358,6 +3395,17 @@ export async function createPiAgentSessionRaw(options: AgentOptions): Promise<Ag
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:ThinkingTrace 2026-08-27-10:45:
|
||||
Detailed summaries improve traces but are optional request metadata. A provider that rejects the field retries once on this same session with the hook disabled, so a capability mismatch never fails the run or leaks to another session.
|
||||
*/
|
||||
if (!reasoningSummaryCompatibilityDisabled && isReasoningSummaryUnsupportedError(errorMessage)) {
|
||||
reasoningSummaryCompatibilityDisabled = true;
|
||||
piLog.warn(`Provider rejected detailed reasoning summary for ${describeModel(activeSession)}; retrying without the summary upgrade`);
|
||||
await promptSessionAndCheck(activeSession, prompt, effectivePromptOptions);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fallbackModel || usingFallback || !isRetryableModelSelectionError(errorMessage)) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user