fix(pi-claude-cli): unblock parameterless MCP tool calls in triage

Triage with claude-sonnet-4-6 via pi-claude-cli kept looping on
fn_review_spec calls that were rejected by pi's validator with
"root: must be object". Parameterless MCP tools (schema
{type:"object", properties:{}}) emit zero input_json_delta events,
so partialJson stayed "" and the catch fell through to
finalArgs = "" — a string, which TypeBox's Type.Object({}) rightly
refuses. Default empty partialJson to {} so the call lands.

Also:
- Add a 2-step reminder loop in triage before swapping to the
  fallback planning model — primary models that wrote PROMPT.md
  but forgot fn_review_spec recover from a nudge, no need to pay
  the cold-start tax of a new triage on a different model.
- Inject @runfusion/fusion's own pi extension into dashboard/
  daemon/serve sessions and propagate the path to createFnAgent
  via setHostExtensionPaths so fn_* tools register globally
  without requiring `pi install npm:@runfusion/fusion`.
- Drop the "historical" qualifier from replayed tool labels —
  Claude was reading "TOOL RESULT (historical Read):" as
  "previous session, ignore" and looping on verification.
- Remove subprocess-lifecycle stderr debug logs that landed for
  hang diagnosis — root cause is fixed, the noise can go.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-26 00:00:49 -07:00
parent 15149905fd
commit c1a2b6edd7
13 changed files with 269 additions and 89 deletions

View File

@@ -840,11 +840,20 @@ describe("Triage re-pick after restart", () => {
const task = makeTask("FN-062", "triage");
store.getTask.mockResolvedValue(makeTaskDetail("FN-062", "triage"));
// Slow agent to keep task in processing
// Slow agent to keep task in processing — only the first prompt() blocks;
// subsequent calls (e.g. the no-APPROVE reminder loop in triage) resolve
// immediately so cleanup can drain.
let resolvePrompt: (() => void) | undefined;
let promptCallCount = 0;
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockImplementation(() => new Promise<void>((r) => { resolvePrompt = r; })),
prompt: vi.fn().mockImplementation(() => {
promptCallCount += 1;
if (promptCallCount === 1) {
return new Promise<void>((r) => { resolvePrompt = r; });
}
return Promise.resolve();
}),
dispose: vi.fn(),
},
} as any);

View File

@@ -17,7 +17,7 @@ export { MissionAutopilot, type MissionAutopilotOptions } from "./mission-autopi
export { MissionExecutionLoop, type MissionExecutionLoopOptions, type ValidationResult, loopLog } from "./mission-execution-loop.js";
export { aiMergeTask, type MergerOptions } from "./merger.js";
export { reviewStep, type ReviewType, type ReviewVerdict, type ReviewResult, type ReviewOptions } from "./reviewer.js";
export { createFnAgent, promptWithFallback, describeModel, type AgentOptions, type AgentResult } from "./pi.js";
export { createFnAgent, promptWithFallback, describeModel, setHostExtensionPaths, getHostExtensionPaths, type AgentOptions, type AgentResult } from "./pi.js";
export {
resolveSessionSkills,
createSkillsOverrideFromSelection,

View File

@@ -50,6 +50,24 @@ export interface AgentResult {
sessionFile?: string;
}
/**
* Process-global list of extension paths to inject into every createFnAgent
* session. Set once at startup by the host (cli's dashboard/daemon/serve)
* before any sessions are created. The paths are passed to pi's
* `DefaultResourceLoader` as `additionalExtensionPaths` so the cli's own
* `@runfusion/fusion` extension (registering `fn_*` tools) is loaded inside
* every agent session — including chat sessions that pass no `customTools`.
*/
let hostExtensionPaths: string[] = [];
export function setHostExtensionPaths(paths: readonly string[]): void {
hostExtensionPaths = [...paths];
}
export function getHostExtensionPaths(): readonly string[] {
return hostExtensionPaths;
}
export interface PromptableSession extends AgentSession {
promptWithFallback: (prompt: string, options?: unknown) => Promise<void>;
}
@@ -1066,6 +1084,10 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
settingsManager,
systemPromptOverride: () => options.systemPrompt,
appendSystemPromptOverride: () => [],
// Inject host-supplied extension paths (e.g. cli's own `@runfusion/fusion`
// extension that registers `fn_*` tools) so they're loaded inside every
// agent session, including chat sessions that don't pass `customTools`.
...(hostExtensionPaths.length > 0 ? { additionalExtensionPaths: [...hostExtensionPaths] } : {}),
...(skillsOverrideFn ? { skillsOverride: skillsOverrideFn } : {}),
});
await resourceLoader.reload();

View File

@@ -1043,6 +1043,44 @@ export class TriageProcessor {
return;
}
// Before swapping to the fallback model, give the primary one more
// shot with a pointed reminder. The model may have written PROMPT.md
// but stopped without calling fn_review_spec — that's recoverable
// with a nudge, no need to discard the session and pay the cold-start
// tax of a new triage on a different model.
const MAX_REVIEW_REMINDERS = 2;
let reviewReminders = 0;
while (
specReviewVerdictRef.current !== "APPROVE" &&
!this.pauseAborted.has(task.id) &&
!this.stuckAborted.has(task.id) &&
createdSubtasksRef.current.length === 0 &&
reviewReminders < MAX_REVIEW_REMINDERS
) {
reviewReminders += 1;
const verdictDesc =
specReviewVerdictRef.current === null
? "fn_review_spec was never called"
: `verdict was ${specReviewVerdictRef.current}`;
triageLog.warn(
`${task.id} primary planning model returned without APPROVE (${verdictDesc}) — reminder ${reviewReminders}/${MAX_REVIEW_REMINDERS}`,
);
await this.store.logEntry(
task.id,
`Primary planning model returned without APPROVE (${verdictDesc}) — reminder ${reviewReminders}/${MAX_REVIEW_REMINDERS}`,
);
const reminder =
specReviewVerdictRef.current === null
? "You wrote the PROMPT.md but did not call `fn_review_spec()`. Call `fn_review_spec()` now to validate the spec. Do not stop until the verdict is APPROVE."
: `Spec review verdict was ${specReviewVerdictRef.current}. Address the feedback, rewrite the PROMPT.md as needed, and call \`fn_review_spec()\` again. Do not stop until the verdict is APPROVE.`;
stuckDetector?.recordActivity(task.id);
await promptWithFallback(session, reminder);
checkSessionError(session);
if (this.pauseAborted.has(task.id) || this.stuckAborted.has(task.id)) {
break;
}
}
const planningFallbackProvider = settings.planningFallbackProvider;
const planningFallbackModelId = settings.planningFallbackModelId;
const canRetryWithPlanningFallback =