@underactive/pi-topping-moa-fusion 0.2.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/README.md +25 -4
- package/package.json +5 -5
- package/src/debate/debateFanout.ts +2 -2
- package/src/index.ts +13 -2
- package/src/moa/fanoutWiring.ts +2 -2
- package/src/moa/verification.ts +5 -5
- package/src/planning/contextEdits.ts +62 -0
- package/src/planning/planFile.ts +30 -13
- package/src/planning/planMode.ts +127 -6
- package/src/planning/tools/enterPlanMode.ts +8 -1
- package/src/planning/tools/exitPlanMode.ts +20 -1
- package/src/planning/tools/mfPlanSubagent.ts +30 -4
- package/src/planning/tools/shared.ts +1 -0
- package/src/planning/tools/structuredResults.ts +161 -0
- package/src/planning/tools/writePlan.ts +5 -0
- package/src/runtime/results.ts +18 -0
- package/src/spec/runSpec.ts +343 -0
- package/src/spec/specContract.ts +324 -0
- package/src/ui/moaModelPicker.ts +21 -3
- package/src/ui/moaProgressWidget.ts +55 -4
- package/src/ui/moaSetupOverlay.ts +9 -3
- package/src/ui/planReviewOverlay.ts +16 -8
- package/src/ui/rosterEditor.ts +18 -12
- package/src/ui/workingOverlay.ts +2 -2
package/src/planning/planMode.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
2
|
-
import type { Message
|
|
2
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
3
3
|
import { Key } from "@earendil-works/pi-tui";
|
|
4
4
|
import type {
|
|
5
|
+
AgentBeforeSettleEvent,
|
|
5
6
|
AgentSettledEvent,
|
|
6
7
|
AgentStartEvent,
|
|
7
8
|
ExtensionAPI,
|
|
@@ -9,6 +10,7 @@ import type {
|
|
|
9
10
|
MessageEndEvent,
|
|
10
11
|
MessageStartEvent,
|
|
11
12
|
MessageUpdateEvent,
|
|
13
|
+
SessionBoundaryDraft,
|
|
12
14
|
ThemeColor,
|
|
13
15
|
ToolExecutionStartEvent,
|
|
14
16
|
} from "@earendil-works/pi-coding-agent";
|
|
@@ -39,10 +41,18 @@ import { MoaProgressWidget } from "../ui/moaProgressWidget.ts";
|
|
|
39
41
|
import { showObserveOverlay, type ObserveSession } from "../ui/observeOverlay.ts";
|
|
40
42
|
import { planNamingOverlay, withWorkingOverlay } from "../ui/workingOverlay.ts";
|
|
41
43
|
import { ASK_USER_QUESTION_TOOL_NAME, createAskUserQuestionTracker, isAskUserQuestionInstalled } from "./askUserQuestion.ts";
|
|
44
|
+
import {
|
|
45
|
+
buildVerificationPendingMessage,
|
|
46
|
+
collectOmissionDrafts,
|
|
47
|
+
isPlanInstructionEntry,
|
|
48
|
+
isPlanModeInstructionText,
|
|
49
|
+
isVerificationPendingEntry,
|
|
50
|
+
type ScannedEntry,
|
|
51
|
+
} from "./contextEdits.ts";
|
|
42
52
|
import { buildPlanModeExitInstructions, buildPlanModeInstructions, buildPlanModeReentryInstructions } from "./instructions.ts";
|
|
43
53
|
import { latestPlanModeStateEntry, PlanModeStatePersistence, validateToolLoadoutSnapshot } from "./modeState.ts";
|
|
44
54
|
import { getPlan, getPlanSlug, isValidPlanSlug, resetPlanSlug, saveRepoPlanFile, setPlanSlug, slugifyPlanName } from "./planFile.ts";
|
|
45
|
-
import { PLAN_EXIT_CONTEXT_TYPE, PLAN_MODE_CONTEXT_TYPE, PLAN_MODE_CUSTOM_TOOLS, PLAN_MODE_READ_ONLY_TOOLS, PLAN_ONLY_REGISTERED_TOOLS } from "./tools/shared.ts";
|
|
55
|
+
import { PLAN_EXIT_CONTEXT_TYPE, PLAN_MODE_CONTEXT_TYPE, PLAN_MODE_CUSTOM_TOOLS, PLAN_MODE_READ_ONLY_TOOLS, PLAN_ONLY_REGISTERED_TOOLS, VERIFICATION_PENDING_CONTEXT_TYPE } from "./tools/shared.ts";
|
|
46
56
|
|
|
47
57
|
/** Trailing chars of the implementer report kept for the verifier's evidence. */
|
|
48
58
|
const IMPLEMENTATION_REPORT_MAX_CHARS = 8000;
|
|
@@ -86,6 +96,17 @@ function extractAssistantText(content: unknown): string {
|
|
|
86
96
|
return "";
|
|
87
97
|
}
|
|
88
98
|
|
|
99
|
+
// Existing tests and stale post-reload contexts may not expose getBranch;
|
|
100
|
+
// boundary handlers should become inert rather than throw in that case.
|
|
101
|
+
function readBranchEntries(ctx: ExtensionContext): readonly ScannedEntry[] {
|
|
102
|
+
try {
|
|
103
|
+
const manager = ctx.sessionManager as { getBranch?: () => readonly ScannedEntry[] };
|
|
104
|
+
return typeof manager.getBranch === "function" ? manager.getBranch() : [];
|
|
105
|
+
} catch {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
89
110
|
export function createPlanModeController(pi: ExtensionAPI) {
|
|
90
111
|
let planModeEnabled = false;
|
|
91
112
|
let toolsBeforePlanMode: string[] | undefined;
|
|
@@ -99,6 +120,8 @@ export function createPlanModeController(pi: ExtensionAPI) {
|
|
|
99
120
|
let activeRunMoaInfo: MfPlanInfo | undefined;
|
|
100
121
|
let implementationHandoff: ImplementationHandoff | undefined;
|
|
101
122
|
let implementationPending = false;
|
|
123
|
+
let verificationInFlight = false;
|
|
124
|
+
const omittedEntryIds = new Set<string>();
|
|
102
125
|
let lastImplementationStopReason: string | undefined;
|
|
103
126
|
// The implementer's final assistant text, captured while pending so the
|
|
104
127
|
// verifier can be handed the implementer's own (untrusted) report. Capped to
|
|
@@ -263,6 +286,32 @@ export function createPlanModeController(pi: ExtensionAPI) {
|
|
|
263
286
|
applyReadOnlyProviderEnv();
|
|
264
287
|
pi.setActiveTools(getPlanModeTools(toolsBeforePlanMode));
|
|
265
288
|
};
|
|
289
|
+
/**
|
|
290
|
+
* Plan-only tools are kept out of normal mode by removing them from the
|
|
291
|
+
* active set, not with pi 0.99's `prepareLoadout` / `hiddenDeclarations`:
|
|
292
|
+
* - Hidden tools stay active. `getActiveTools()` would always list
|
|
293
|
+
* write_plan, exit_plan_mode, and mf_plan_subagent, so the contamination
|
|
294
|
+
* checks in `enablePlanModeTools` and `validateToolLoadoutSnapshot` would
|
|
295
|
+
* treat every loadout as poisoned and swap the user's real one for
|
|
296
|
+
* `fullLoadoutFallback()` on every entry and resume, contradicting
|
|
297
|
+
* plan-tool-leak and plan-resume-tool-restore.
|
|
298
|
+
* - Hidden tools stay callable. pi rejects calls to inactive tools; a hidden
|
|
299
|
+
* one would run with only its `isEnabled` guard in the way. It cannot
|
|
300
|
+
* replace the plan-mode read-only narrowing either: hidden bash/edit/write
|
|
301
|
+
* would still run.
|
|
302
|
+
* - The hook runs only while its host tool is active. Outside plan mode that
|
|
303
|
+
* is enter_plan_mode alone; drop it via --tools or the tool picker and all
|
|
304
|
+
* three declarations leak.
|
|
305
|
+
* - After resume or /tree, 0.99.2 strips the hidden declarations only while
|
|
306
|
+
* the host tool is active. The model still sees the plan-only tools when
|
|
307
|
+
* the host is inactive, on pre-0.99 hosts (peerDependencies are "*" and
|
|
308
|
+
* they ignore the hook), and through earlier tool calls in history, which
|
|
309
|
+
* the projection does not filter.
|
|
310
|
+
* - A capability gate would only add surface: older hosts still need this
|
|
311
|
+
* path, so both mechanisms would ship.
|
|
312
|
+
* - The saving is two tool-change deltas per planning round, and this is
|
|
313
|
+
* already a no-op when the set is clean.
|
|
314
|
+
*/
|
|
266
315
|
const deactivatePlanOnlyTools = (): void => {
|
|
267
316
|
const active = pi.getActiveTools();
|
|
268
317
|
const filtered = active.filter((name) => !PLAN_ONLY_REGISTERED_TOOLS.includes(name));
|
|
@@ -548,6 +597,63 @@ export function createPlanModeController(pi: ExtensionAPI) {
|
|
|
548
597
|
activeProgressWidget?.updateRoleTranscript("Implement", implementationMessages);
|
|
549
598
|
}
|
|
550
599
|
},
|
|
600
|
+
onAgentBeforeSettle: (event: AgentBeforeSettleEvent, ctx: ExtensionContext) => {
|
|
601
|
+
try {
|
|
602
|
+
const branch = readBranchEntries(ctx);
|
|
603
|
+
const persistedOmissions = new Set(
|
|
604
|
+
branch
|
|
605
|
+
.filter((entry) => entry.type === "context_edit" && entry.replacement === null && typeof entry.targetId === "string")
|
|
606
|
+
.map((entry) => entry.targetId as string),
|
|
607
|
+
);
|
|
608
|
+
// A different extension can invalidate the combined boundary proposal.
|
|
609
|
+
// Forget locally proposed edits that did not reach the branch so they are
|
|
610
|
+
// retried rather than suppressed for the rest of this process.
|
|
611
|
+
for (const id of omittedEntryIds) {
|
|
612
|
+
if (!persistedOmissions.has(id)) omittedEntryIds.delete(id);
|
|
613
|
+
}
|
|
614
|
+
const drafts: SessionBoundaryDraft[] = [];
|
|
615
|
+
|
|
616
|
+
// Persist plan-instruction omissions after plan mode exits. This is
|
|
617
|
+
// additive to onContext's per-request filter and repairs older sessions.
|
|
618
|
+
if (!planModeEnabled) {
|
|
619
|
+
drafts.push(...collectOmissionDrafts(branch, isPlanInstructionEntry, omittedEntryIds));
|
|
620
|
+
}
|
|
621
|
+
if (!verificationInFlight) {
|
|
622
|
+
drafts.push(...collectOmissionDrafts(branch, isVerificationPendingEntry, omittedEntryIds));
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const stopReason = lastImplementationStopReason;
|
|
626
|
+
// KEEP IN LOCKSTEP with onAgentSettled's verification branch below.
|
|
627
|
+
// exit_plan_mode marks implementation pending before the follow-up has
|
|
628
|
+
// run, so implementationTurns distinguishes that planning boundary.
|
|
629
|
+
const verificationWillDispatch = implementationPending
|
|
630
|
+
&& implementationTurns > 0
|
|
631
|
+
&& event.context.pendingMessages.length === 0
|
|
632
|
+
&& event.outcome !== "aborted"
|
|
633
|
+
&& event.outcome !== "error"
|
|
634
|
+
&& !implementationTurnFailed(stopReason)
|
|
635
|
+
&& stopReason !== "aborted"
|
|
636
|
+
&& implementationHandoff !== undefined
|
|
637
|
+
&& (implementationHandoff.verifier ?? loadMoaConfig().verifier) !== undefined;
|
|
638
|
+
if (verificationWillDispatch) {
|
|
639
|
+
drafts.push({
|
|
640
|
+
type: "custom_message",
|
|
641
|
+
customType: VERIFICATION_PENDING_CONTEXT_TYPE,
|
|
642
|
+
content: buildVerificationPendingMessage(implementationHandoff?.planFilePath),
|
|
643
|
+
display: false,
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (drafts.length === 0) return undefined;
|
|
648
|
+
for (const draft of drafts) {
|
|
649
|
+
if (draft.type === "context_edit") omittedEntryIds.add(draft.targetId);
|
|
650
|
+
}
|
|
651
|
+
return { entries: [...event.entries, ...drafts] };
|
|
652
|
+
} catch {
|
|
653
|
+
// A thrown boundary handler could reject every proposed entry.
|
|
654
|
+
return undefined;
|
|
655
|
+
}
|
|
656
|
+
},
|
|
551
657
|
onAgentSettled: (_event: AgentSettledEvent, ctx: ExtensionContext) => {
|
|
552
658
|
updateStatus(ctx);
|
|
553
659
|
if (!implementationPending) return;
|
|
@@ -595,10 +701,13 @@ export function createPlanModeController(pi: ExtensionAPI) {
|
|
|
595
701
|
// explicit guard is required before the verification branch.
|
|
596
702
|
if (stopReason === "aborted") return;
|
|
597
703
|
if (!implementationHandoff) return;
|
|
704
|
+
// KEEP IN LOCKSTEP with verificationWillDispatch in onAgentBeforeSettle.
|
|
598
705
|
const verifier = implementationHandoff.verifier ?? loadMoaConfig().verifier;
|
|
599
706
|
if (verifier) {
|
|
707
|
+
verificationInFlight = true;
|
|
600
708
|
void runImplementationVerification(ctx, moaRunHost, { ...implementationHandoff, verifier }, report)
|
|
601
|
-
.catch(reportFlowFailure("Verification"))
|
|
709
|
+
.catch(reportFlowFailure("Verification"))
|
|
710
|
+
.finally(() => { verificationInFlight = false; });
|
|
602
711
|
}
|
|
603
712
|
},
|
|
604
713
|
onContext: async (event: { messages: AgentMessage[] }) => {
|
|
@@ -607,14 +716,15 @@ export function createPlanModeController(pi: ExtensionAPI) {
|
|
|
607
716
|
const msg = message as AgentMessage & { customType?: string };
|
|
608
717
|
if (msg.customType === PLAN_MODE_CONTEXT_TYPE || msg.customType === PLAN_EXIT_CONTEXT_TYPE) return false;
|
|
609
718
|
if (msg.role !== "user") return true;
|
|
610
|
-
|
|
611
|
-
if (Array.isArray(msg.content)) return !msg.content.some((part) => part.type === "text" && ((part as TextContent).text?.startsWith("[PLAN MODE ACTIVE]") || (part as TextContent).text?.startsWith("[PLAN MODE RE-ENTRY]")));
|
|
612
|
-
return true;
|
|
719
|
+
return !isPlanModeInstructionText(msg.content);
|
|
613
720
|
}) };
|
|
614
721
|
},
|
|
615
722
|
onBeforeAgentStart: async () => {
|
|
616
723
|
if (needsExitReminder) {
|
|
617
724
|
needsExitReminder = false;
|
|
725
|
+
// The reminder turn is also the first normal-mode turn: repair a
|
|
726
|
+
// leaked plan-only loadout here too, not from the next turn on.
|
|
727
|
+
if (!planModeEnabled) deactivatePlanOnlyTools();
|
|
618
728
|
persistState();
|
|
619
729
|
return { message: { customType: PLAN_EXIT_CONTEXT_TYPE, content: buildPlanModeExitInstructions(), display: false } };
|
|
620
730
|
}
|
|
@@ -641,6 +751,8 @@ export function createPlanModeController(pi: ExtensionAPI) {
|
|
|
641
751
|
activeCancelSession = undefined;
|
|
642
752
|
activeObserveSession = undefined;
|
|
643
753
|
implementationHandoff = undefined;
|
|
754
|
+
verificationInFlight = false;
|
|
755
|
+
omittedEntryIds.clear();
|
|
644
756
|
footerError = undefined;
|
|
645
757
|
lastRenderedFooter = undefined;
|
|
646
758
|
lastFooterCtx = undefined;
|
|
@@ -680,6 +792,13 @@ export function createPlanModeController(pi: ExtensionAPI) {
|
|
|
680
792
|
} else {
|
|
681
793
|
deactivatePlanOnlyTools();
|
|
682
794
|
}
|
|
795
|
+
try {
|
|
796
|
+
if (collectOmissionDrafts(readBranchEntries(ctx), isVerificationPendingEntry, new Set()).length > 0) {
|
|
797
|
+
ctx.ui.notify("The previous session ended before plan verification finished — the last implementation is unverified. Run /mf-plan-implement to verify it.", "warning");
|
|
798
|
+
}
|
|
799
|
+
} catch {
|
|
800
|
+
// A stale session-start context cannot be notified safely.
|
|
801
|
+
}
|
|
683
802
|
updateStatus(ctx);
|
|
684
803
|
startFooterTicker(ctx);
|
|
685
804
|
if (ctx.mode === "tui" && typeof ctx.ui.onTerminalInput === "function") {
|
|
@@ -694,6 +813,8 @@ export function createPlanModeController(pi: ExtensionAPI) {
|
|
|
694
813
|
},
|
|
695
814
|
onSessionShutdown: (_event: { reason: string }) => {
|
|
696
815
|
implementationPending = false;
|
|
816
|
+
verificationInFlight = false;
|
|
817
|
+
omittedEntryIds.clear();
|
|
697
818
|
lastImplementationStopReason = undefined;
|
|
698
819
|
lastImplementationReport = undefined;
|
|
699
820
|
resetImplementationTranscript();
|
|
@@ -12,9 +12,10 @@ import { modelRefLabel, TRIGGER_TURN, type ModelRef, type ThinkingLevel } from "
|
|
|
12
12
|
import { showMoaModelPicker, showImplementingModelPicker } from "../../ui/moaModelPicker.ts";
|
|
13
13
|
import { showMoaSetup } from "../../ui/moaSetupOverlay.ts";
|
|
14
14
|
import { showPromptEditor } from "../../ui/promptEditor.ts";
|
|
15
|
-
import { getPlan } from "../planFile.ts";
|
|
15
|
+
import { getPlan, getPlanFilePath } from "../planFile.ts";
|
|
16
16
|
import { isAskUserQuestionInstalled } from "../askUserQuestion.ts";
|
|
17
17
|
import { buildPlanModeInstructions, buildPlanModeReentryInstructions } from "../instructions.ts";
|
|
18
|
+
import { ENTER_PLAN_MODE_OUTPUT_SCHEMA } from "./structuredResults.ts";
|
|
18
19
|
|
|
19
20
|
export interface InteractivePlanModeHost {
|
|
20
21
|
beginInteractive(ctx: ExtensionContext): void;
|
|
@@ -136,6 +137,11 @@ export function registerEnterPlanModeTool(pi: ExtensionAPI, host: EnterPlanModeH
|
|
|
136
137
|
Type.String({ description: "Short description of what is being planned (used to name the plan file)" }),
|
|
137
138
|
),
|
|
138
139
|
}),
|
|
140
|
+
outputSchema: ENTER_PLAN_MODE_OUTPUT_SCHEMA,
|
|
141
|
+
// Swaps the active tools and provider env, persists state, and with a
|
|
142
|
+
// plan_prompt writes the prompt record under .pi/mf-plan/. Not read-only;
|
|
143
|
+
// destructive is left at the default because that write can overwrite.
|
|
144
|
+
annotations: { readOnlyHint: false },
|
|
139
145
|
// enter_plan_mode is paired with a same-message ask_user_question only in
|
|
140
146
|
// the single-model path; marking it sequential means the questionnaire
|
|
141
147
|
// resolves first, so mf-plan's own overlays never draw over it.
|
|
@@ -159,6 +165,7 @@ export function registerEnterPlanModeTool(pi: ExtensionAPI, host: EnterPlanModeH
|
|
|
159
165
|
type: "text",
|
|
160
166
|
text: `Plan mode enabled (single model — the current session model). Mutating tools are disabled until your plan is approved via exit_plan_mode.\n\n${instructions}`,
|
|
161
167
|
}],
|
|
168
|
+
structuredContent: { status: "entered", mode: "single", reentry, planFilePath: getPlanFilePath() },
|
|
162
169
|
};
|
|
163
170
|
},
|
|
164
171
|
});
|
|
@@ -10,6 +10,7 @@ import { showPlanReview } from "../../ui/planReviewOverlay.ts";
|
|
|
10
10
|
import { planNamingOverlay, withWorkingOverlay } from "../../ui/workingOverlay.ts";
|
|
11
11
|
import { ASK_USER_QUESTION_PENDING_MESSAGE } from "../askUserQuestion.ts";
|
|
12
12
|
import { getPlan, getPlanFilePath, saveRepoPlanFile, slugifyPlanName, writePlan } from "../planFile.ts";
|
|
13
|
+
import { EXIT_PLAN_MODE_OUTPUT_SCHEMA, type ExitPlanModeStructuredResult } from "./structuredResults.ts";
|
|
13
14
|
|
|
14
15
|
import type { ImplementationHandoff } from "../../moa/implementationRetry.ts";
|
|
15
16
|
|
|
@@ -35,6 +36,10 @@ export function registerExitPlanModeTool(pi: ExtensionAPI, host: ExitPlanModeHos
|
|
|
35
36
|
label: "Exit Plan Mode",
|
|
36
37
|
description: "Use when you are in plan mode and have finished writing your plan to the plan file and are ready for user approval. Reads the plan from the plan file — does NOT take plan content as a parameter. Only use for tasks that require writing code; not for pure research.",
|
|
37
38
|
parameters: Type.Object({}),
|
|
39
|
+
outputSchema: EXIT_PLAN_MODE_OUTPUT_SCHEMA,
|
|
40
|
+
// Restores tools and env and queues implementation; the edit branch and the
|
|
41
|
+
// non-TUI approve overwrite plan files, and every call reopens approval.
|
|
42
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
|
|
38
43
|
// exit_plan_mode opens a model/UI surface the model may not have asked
|
|
39
44
|
// the user about yet; marking it sequential means a same-message
|
|
40
45
|
// ask_user_question resolves first, so approval never lands on unread
|
|
@@ -52,6 +57,14 @@ export function registerExitPlanModeTool(pi: ExtensionAPI, host: ExitPlanModeHos
|
|
|
52
57
|
|
|
53
58
|
const plan = getPlan();
|
|
54
59
|
const filePath = getPlanFilePath();
|
|
60
|
+
// Read at return time: the slug may be named during this call.
|
|
61
|
+
const structuredResult = (
|
|
62
|
+
status: ExitPlanModeStructuredResult["status"],
|
|
63
|
+
kickoff: ExitPlanModeStructuredResult["kickoff"],
|
|
64
|
+
): ExitPlanModeStructuredResult => {
|
|
65
|
+
const repoPlanSlug = host.getPlanRepoSlug();
|
|
66
|
+
return { status, planFilePath: filePath, headless: !ctx.hasUI, kickoff, ...(repoPlanSlug ? { repoPlanSlug } : {}) };
|
|
67
|
+
};
|
|
55
68
|
|
|
56
69
|
if (!plan || plan.trim() === "") {
|
|
57
70
|
return {
|
|
@@ -95,6 +108,7 @@ export function registerExitPlanModeTool(pi: ExtensionAPI, host: ExitPlanModeHos
|
|
|
95
108
|
text: buildImplementationKickoffMessage(plan, filePath),
|
|
96
109
|
},
|
|
97
110
|
],
|
|
111
|
+
structuredContent: structuredResult("approved", "inline"),
|
|
98
112
|
};
|
|
99
113
|
}
|
|
100
114
|
|
|
@@ -154,10 +168,15 @@ export function registerExitPlanModeTool(pi: ExtensionAPI, host: ExitPlanModeHos
|
|
|
154
168
|
return {
|
|
155
169
|
details: undefined,
|
|
156
170
|
content: [{ type: "text", text: "Plan approved and saved. Plan mode has exited. Stop here — do not write files, run commands, or call any tools in this turn. A follow-up message will tell you to begin implementation." }],
|
|
171
|
+
structuredContent: structuredResult("approved", "follow_up"),
|
|
157
172
|
};
|
|
158
173
|
}
|
|
159
174
|
|
|
160
|
-
return {
|
|
175
|
+
return {
|
|
176
|
+
content: [{ type: "text", text: "User wants to keep refining the plan. Continue working on the plan file and call exit_plan_mode when ready." }],
|
|
177
|
+
details: undefined,
|
|
178
|
+
structuredContent: structuredResult("keep_planning", "none"),
|
|
179
|
+
};
|
|
161
180
|
}
|
|
162
181
|
},
|
|
163
182
|
});
|
|
@@ -17,6 +17,7 @@ import { parseRef as parseModelRefLabel, type ThinkingLevel } from "../../shared
|
|
|
17
17
|
import { activityLoopCount } from "../../ui/agentStatus.ts";
|
|
18
18
|
import { showCancelOverlay } from "../../ui/cancelOverlay.ts";
|
|
19
19
|
import { PLAN_SUBAGENT_NAMES } from "./shared.ts";
|
|
20
|
+
import { buildSubagentStructuredResult, MF_PLAN_SUBAGENT_OUTPUT_SCHEMA } from "./structuredResults.ts";
|
|
20
21
|
|
|
21
22
|
const CANCEL_HINT_WIDGET_KEY = "mf-plan-cancel";
|
|
22
23
|
|
|
@@ -38,7 +39,18 @@ export interface MfPlanSubagentHost {
|
|
|
38
39
|
setActiveCancelSession(session: CancelSession | undefined): void;
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Test-only runner seam. The defaults are the real subprocess runners, which
|
|
44
|
+
* apply the read-only --tools allowlist and the provider env handshake.
|
|
45
|
+
*/
|
|
46
|
+
export type MfPlanSubagentDeps = {
|
|
47
|
+
runSingleAgent?: typeof runSingleAgent;
|
|
48
|
+
runParallelAgents?: typeof runParallelAgents;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export function registerMfPlanSubagentTool(pi: ExtensionAPI, host: MfPlanSubagentHost, deps: MfPlanSubagentDeps = {}): void {
|
|
52
|
+
const runSingle = deps.runSingleAgent ?? runSingleAgent;
|
|
53
|
+
const runParallel = deps.runParallelAgents ?? runParallelAgents;
|
|
42
54
|
pi.registerTool({
|
|
43
55
|
name: "mf_plan_subagent",
|
|
44
56
|
label: "Moa Plan Subagent",
|
|
@@ -51,6 +63,11 @@ export function registerMfPlanSubagentTool(pi: ExtensionAPI, host: MfPlanSubagen
|
|
|
51
63
|
`Default scope is "user" (from ${path.join(getAgentDir(), "agents")}).`,
|
|
52
64
|
].join(" "),
|
|
53
65
|
|
|
66
|
+
outputSchema: MF_PLAN_SUBAGENT_OUTPUT_SCHEMA,
|
|
67
|
+
// Installs agent files, writes temp prompts, and spawns paid subprocesses
|
|
68
|
+
// whose read-only boundary is cooperative (hence the tripwire), so no
|
|
69
|
+
// read-only claim; every call starts new, nondeterministic runs.
|
|
70
|
+
annotations: { readOnlyHint: false, idempotentHint: false },
|
|
54
71
|
// mf_plan_subagent can share a message with ask_user_question; marking it
|
|
55
72
|
// sequential means the questionnaire resolves first so its overlay never
|
|
56
73
|
// draws over a live questionnaire.
|
|
@@ -184,7 +201,7 @@ export function registerMfPlanSubagentTool(pi: ExtensionAPI, host: MfPlanSubagen
|
|
|
184
201
|
...agentExtensionOptions(t.agent),
|
|
185
202
|
signal: run.add(`${t.agent} #${i + 1}`, signal).signal,
|
|
186
203
|
}));
|
|
187
|
-
const results = await
|
|
204
|
+
const results = await runParallel(
|
|
188
205
|
ctx.cwd,
|
|
189
206
|
agents,
|
|
190
207
|
taskList,
|
|
@@ -208,7 +225,9 @@ export function registerMfPlanSubagentTool(pi: ExtensionAPI, host: MfPlanSubagen
|
|
|
208
225
|
);
|
|
209
226
|
|
|
210
227
|
// resolveOnAbort makes the runner resolve on abort; rethrow so a
|
|
211
|
-
// whole-turn abort (pi's ESC) keeps its original semantics
|
|
228
|
+
// whole-turn abort (pi's ESC) keeps its original semantics, which also
|
|
229
|
+
// means it carries no structured result. A child abort the user did not
|
|
230
|
+
// ask for is reported per agent as "aborted".
|
|
212
231
|
if (signal?.aborted) throw new Error("Subagent was aborted");
|
|
213
232
|
|
|
214
233
|
const successCount = results.filter((r) => !isFailedResult(r)).length;
|
|
@@ -223,6 +242,8 @@ export function registerMfPlanSubagentTool(pi: ExtensionAPI, host: MfPlanSubagen
|
|
|
223
242
|
});
|
|
224
243
|
|
|
225
244
|
const header = `Parallel: ${successCount}/${results.length} succeeded${cancelledCount > 0 ? `, ${cancelledCount} cancelled by user` : ""}`;
|
|
245
|
+
// Subagent usage stays out of the tool result's `usage` so session
|
|
246
|
+
// token accounting is unchanged; it is reported per agent instead.
|
|
226
247
|
return {
|
|
227
248
|
details: undefined,
|
|
228
249
|
content: [
|
|
@@ -231,6 +252,7 @@ export function registerMfPlanSubagentTool(pi: ExtensionAPI, host: MfPlanSubagen
|
|
|
231
252
|
text: `${header}\n\n${summaries.join("\n\n---\n\n")}`,
|
|
232
253
|
},
|
|
233
254
|
],
|
|
255
|
+
structuredContent: buildSubagentStructuredResult("parallel", results),
|
|
234
256
|
};
|
|
235
257
|
} finally {
|
|
236
258
|
unsubscribeF4?.();
|
|
@@ -261,7 +283,7 @@ export function registerMfPlanSubagentTool(pi: ExtensionAPI, host: MfPlanSubagen
|
|
|
261
283
|
: undefined;
|
|
262
284
|
setCancelHint(ctx, "f4: cancel mf-plan agent");
|
|
263
285
|
try {
|
|
264
|
-
const result = await
|
|
286
|
+
const result = await runSingle(
|
|
265
287
|
ctx.cwd,
|
|
266
288
|
agents,
|
|
267
289
|
params.agent!,
|
|
@@ -277,22 +299,26 @@ export function registerMfPlanSubagentTool(pi: ExtensionAPI, host: MfPlanSubagen
|
|
|
277
299
|
|
|
278
300
|
if (signal?.aborted) throw new Error("Subagent was aborted");
|
|
279
301
|
|
|
302
|
+
const structuredContent = buildSubagentStructuredResult("single", [result]);
|
|
280
303
|
if (result.cancelled) {
|
|
281
304
|
return {
|
|
282
305
|
details: undefined,
|
|
283
306
|
content: [{ type: "text", text: "The user cancelled this subagent. Do not retry; continue planning with the information you already have." }],
|
|
307
|
+
structuredContent,
|
|
284
308
|
};
|
|
285
309
|
}
|
|
286
310
|
if (isFailedResult(result)) {
|
|
287
311
|
return {
|
|
288
312
|
details: undefined,
|
|
289
313
|
content: [{ type: "text", text: `Agent failed: ${getResultOutput(result)}` }],
|
|
314
|
+
structuredContent,
|
|
290
315
|
isError: true,
|
|
291
316
|
};
|
|
292
317
|
}
|
|
293
318
|
return {
|
|
294
319
|
details: undefined,
|
|
295
320
|
content: [{ type: "text", text: getFinalOutput(result.messages) || "(no output)" }],
|
|
321
|
+
structuredContent,
|
|
296
322
|
};
|
|
297
323
|
} finally {
|
|
298
324
|
unsubscribeF4?.();
|
|
@@ -17,3 +17,4 @@ export const PLAN_ONLY_REGISTERED_TOOLS = ["write_plan", "exit_plan_mode", "mf_p
|
|
|
17
17
|
export const PLAN_SUBAGENT_NAMES = new Set(["moa-explore", "mf-plan"]);
|
|
18
18
|
export const PLAN_MODE_CONTEXT_TYPE = "mf-plan-context";
|
|
19
19
|
export const PLAN_EXIT_CONTEXT_TYPE = "mf-plan-exit";
|
|
20
|
+
export const VERIFICATION_PENDING_CONTEXT_TYPE = "mf-plan-verification-pending";
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output schemas and structured results for the plan-mode tools (pi 0.99
|
|
3
|
+
* `outputSchema` / `structuredContent`).
|
|
4
|
+
*
|
|
5
|
+
* These values are not model-facing and are not saved in the session: pi sends
|
|
6
|
+
* only `content` to the model and leaves `structuredContent` out of the
|
|
7
|
+
* persisted tool-result message. They reach live programmatic consumers only —
|
|
8
|
+
* `tool_execution_end` events (RPC/JSON harnesses), `tool_result` handlers, and
|
|
9
|
+
* codemode scripts. Each tool's text `content` is unchanged and comes from the
|
|
10
|
+
* same `isFailedResult` / `getResultOutput` / `truncateOutput` calls as the
|
|
11
|
+
* values here, so the two cannot disagree.
|
|
12
|
+
*
|
|
13
|
+
* `classifySubagentResult` is the one status derivation over `SingleResult`;
|
|
14
|
+
* any other consumer of runner results can import it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Type, type Static } from "typebox";
|
|
18
|
+
|
|
19
|
+
import { getResultOutput, isFailedResult, truncateOutput, type SingleResult } from "../../runtime/results.ts";
|
|
20
|
+
|
|
21
|
+
const SubagentUsageSchema = Type.Object({
|
|
22
|
+
input: Type.Number({ description: "Input tokens" }),
|
|
23
|
+
output: Type.Number({ description: "Output tokens" }),
|
|
24
|
+
cacheRead: Type.Number({ description: "Cache-read tokens" }),
|
|
25
|
+
cacheWrite: Type.Number({ description: "Cache-write tokens" }),
|
|
26
|
+
cacheWrite1h: Type.Number({ description: "One-hour cache-write tokens" }),
|
|
27
|
+
cost: Type.Number({ description: "Total cost reported by the provider" }),
|
|
28
|
+
turns: Type.Number({ description: "Assistant turns" }),
|
|
29
|
+
toolCalls: Type.Number({ description: "Tool calls the agent made" }),
|
|
30
|
+
contextTokens: Type.Optional(Type.Number({ description: "Context size of the latest turn, when reported" })),
|
|
31
|
+
}, { additionalProperties: false, description: "Token usage and cost the runner recorded for the agent" });
|
|
32
|
+
|
|
33
|
+
const SubagentAgentResultSchema = Type.Object({
|
|
34
|
+
agent: Type.String({ description: "Agent name" }),
|
|
35
|
+
status: Type.Union([
|
|
36
|
+
Type.Literal("completed"),
|
|
37
|
+
Type.Literal("failed"),
|
|
38
|
+
Type.Literal("cancelled"),
|
|
39
|
+
Type.Literal("aborted"),
|
|
40
|
+
], { description: "cancelled: stopped by the user; aborted: the child stopped without a user cancel; failed: nonzero exit, provider error, or no messages" }),
|
|
41
|
+
exitCode: Type.Number({ description: "Child process exit code" }),
|
|
42
|
+
stopReason: Type.Optional(Type.String({ description: "Stop reason of the agent's last assistant message" })),
|
|
43
|
+
model: Type.Optional(Type.String({ description: "Model the agent ran on" })),
|
|
44
|
+
output: Type.String({ description: "Final answer, or the error for an agent that did not complete, capped at 50 KB" }),
|
|
45
|
+
truncated: Type.Boolean({ description: "Whether `output` was cut at the 50 KB cap" }),
|
|
46
|
+
usage: SubagentUsageSchema,
|
|
47
|
+
}, { additionalProperties: false });
|
|
48
|
+
|
|
49
|
+
export const MF_PLAN_SUBAGENT_OUTPUT_SCHEMA = Type.Object({
|
|
50
|
+
mode: Type.Union([Type.Literal("single"), Type.Literal("parallel")], { description: "single: one agent from agent + task; parallel: the tasks array" }),
|
|
51
|
+
status: Type.Union([
|
|
52
|
+
Type.Literal("completed"),
|
|
53
|
+
Type.Literal("partial"),
|
|
54
|
+
Type.Literal("failed"),
|
|
55
|
+
Type.Literal("cancelled"),
|
|
56
|
+
Type.Literal("aborted"),
|
|
57
|
+
], { description: "completed: every agent completed; partial: some did; otherwise failed, aborted, or cancelled, in that order of precedence" }),
|
|
58
|
+
total: Type.Number({ description: "Agents requested" }),
|
|
59
|
+
succeeded: Type.Number({ description: "Agents that completed" }),
|
|
60
|
+
failed: Type.Number({ description: "Agents that failed" }),
|
|
61
|
+
cancelled: Type.Number({ description: "Agents the user cancelled" }),
|
|
62
|
+
aborted: Type.Number({ description: "Agents that stopped without a user cancel" }),
|
|
63
|
+
results: Type.Array(SubagentAgentResultSchema, { description: "One entry per requested agent, in request order" }),
|
|
64
|
+
}, { additionalProperties: false });
|
|
65
|
+
|
|
66
|
+
export const WRITE_PLAN_OUTPUT_SCHEMA = Type.Object({
|
|
67
|
+
status: Type.Literal("written", { description: "The plan file was written" }),
|
|
68
|
+
filePath: Type.String({ description: "Plan file whose content was replaced" }),
|
|
69
|
+
length: Type.Number({ description: "Length of the written plan in characters" }),
|
|
70
|
+
}, { additionalProperties: false });
|
|
71
|
+
|
|
72
|
+
export const ENTER_PLAN_MODE_OUTPUT_SCHEMA = Type.Object({
|
|
73
|
+
status: Type.Literal("entered", { description: "Plan mode is now on" }),
|
|
74
|
+
mode: Type.Literal("single", { description: "The tool always starts single-model planning; MoA planning starts from /mf-plan" }),
|
|
75
|
+
reentry: Type.Boolean({ description: "Whether a plan file already existed, so planning continues it" }),
|
|
76
|
+
planFilePath: Type.String({ description: "Plan file write_plan writes to" }),
|
|
77
|
+
}, { additionalProperties: false });
|
|
78
|
+
|
|
79
|
+
export const EXIT_PLAN_MODE_OUTPUT_SCHEMA = Type.Object({
|
|
80
|
+
status: Type.Union([Type.Literal("approved"), Type.Literal("keep_planning")], { description: "approved: plan mode has exited; keep_planning: the user sent the plan back and plan mode is still on" }),
|
|
81
|
+
planFilePath: Type.String({ description: "Plan file that was reviewed" }),
|
|
82
|
+
headless: Type.Boolean({ description: "Whether the plan was auto-approved without a UI (MOA_PLAN_AUTO_APPROVE=1)" }),
|
|
83
|
+
kickoff: Type.Union([
|
|
84
|
+
Type.Literal("inline"),
|
|
85
|
+
Type.Literal("follow_up"),
|
|
86
|
+
Type.Literal("none"),
|
|
87
|
+
], { description: "inline: this result is the implementation kickoff; follow_up: the kickoff is queued as the next user message; none: no implementation was started" }),
|
|
88
|
+
repoPlanSlug: Type.Optional(Type.String({ description: "Name of the plan's copies under .pi/mf-plan/" })),
|
|
89
|
+
}, { additionalProperties: false });
|
|
90
|
+
|
|
91
|
+
export type MfPlanSubagentStructuredResult = Static<typeof MF_PLAN_SUBAGENT_OUTPUT_SCHEMA>;
|
|
92
|
+
export type SubagentAgentResult = Static<typeof SubagentAgentResultSchema>;
|
|
93
|
+
export type SubagentAgentStatus = SubagentAgentResult["status"];
|
|
94
|
+
export type WritePlanStructuredResult = Static<typeof WRITE_PLAN_OUTPUT_SCHEMA>;
|
|
95
|
+
export type EnterPlanModeStructuredResult = Static<typeof ENTER_PLAN_MODE_OUTPUT_SCHEMA>;
|
|
96
|
+
export type ExitPlanModeStructuredResult = Static<typeof EXIT_PLAN_MODE_OUTPUT_SCHEMA>;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A user cancel wins over the stop reason it produces; an `aborted` stop
|
|
100
|
+
* without one is a child abort nobody asked for. The runner marks every
|
|
101
|
+
* cancelled result failed, so `completed` is exactly `!isFailedResult`.
|
|
102
|
+
*/
|
|
103
|
+
export function classifySubagentResult(result: SingleResult): SubagentAgentStatus {
|
|
104
|
+
if (result.cancelled) return "cancelled";
|
|
105
|
+
if (result.stopReason === "aborted") return "aborted";
|
|
106
|
+
return isFailedResult(result) ? "failed" : "completed";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function aggregateStatus(statuses: readonly SubagentAgentStatus[]): MfPlanSubagentStructuredResult["status"] {
|
|
110
|
+
const completed = statuses.filter((status) => status === "completed").length;
|
|
111
|
+
if (completed === statuses.length) return "completed";
|
|
112
|
+
if (completed > 0) return "partial";
|
|
113
|
+
if (statuses.includes("failed")) return "failed";
|
|
114
|
+
if (statuses.includes("aborted")) return "aborted";
|
|
115
|
+
return "cancelled";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function toAgentResult(result: SingleResult): SubagentAgentResult {
|
|
119
|
+
const fullOutput = getResultOutput(result);
|
|
120
|
+
const output = truncateOutput(fullOutput);
|
|
121
|
+
const { usage } = result;
|
|
122
|
+
return {
|
|
123
|
+
agent: result.agent,
|
|
124
|
+
status: classifySubagentResult(result),
|
|
125
|
+
exitCode: result.exitCode,
|
|
126
|
+
...(result.stopReason ? { stopReason: result.stopReason } : {}),
|
|
127
|
+
...(result.model ? { model: result.model } : {}),
|
|
128
|
+
output,
|
|
129
|
+
truncated: output !== fullOutput,
|
|
130
|
+
usage: {
|
|
131
|
+
input: usage.input,
|
|
132
|
+
output: usage.output,
|
|
133
|
+
cacheRead: usage.cacheRead,
|
|
134
|
+
cacheWrite: usage.cacheWrite,
|
|
135
|
+
cacheWrite1h: usage.cacheWrite1h,
|
|
136
|
+
cost: usage.cost,
|
|
137
|
+
turns: usage.turns,
|
|
138
|
+
toolCalls: usage.toolCalls,
|
|
139
|
+
...(usage.contextTokens !== undefined ? { contextTokens: usage.contextTokens } : {}),
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Results stay in request order; a single-mode run's status is its agent's status. */
|
|
145
|
+
export function buildSubagentStructuredResult(
|
|
146
|
+
mode: MfPlanSubagentStructuredResult["mode"],
|
|
147
|
+
results: readonly SingleResult[],
|
|
148
|
+
): MfPlanSubagentStructuredResult {
|
|
149
|
+
const entries = results.map(toAgentResult);
|
|
150
|
+
const count = (status: SubagentAgentStatus): number => entries.filter((entry) => entry.status === status).length;
|
|
151
|
+
return {
|
|
152
|
+
mode,
|
|
153
|
+
status: aggregateStatus(entries.map((entry) => entry.status)),
|
|
154
|
+
total: entries.length,
|
|
155
|
+
succeeded: count("completed"),
|
|
156
|
+
failed: count("failed"),
|
|
157
|
+
cancelled: count("cancelled"),
|
|
158
|
+
aborted: count("aborted"),
|
|
159
|
+
results: entries,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
@@ -2,6 +2,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
|
|
4
4
|
import { getPlanFilePath, writePlan } from "../planFile.ts";
|
|
5
|
+
import { WRITE_PLAN_OUTPUT_SCHEMA } from "./structuredResults.ts";
|
|
5
6
|
|
|
6
7
|
export function registerWritePlanTool(pi: ExtensionAPI, isEnabled: () => boolean): void {
|
|
7
8
|
pi.registerTool({
|
|
@@ -11,6 +12,9 @@ export function registerWritePlanTool(pi: ExtensionAPI, isEnabled: () => boolean
|
|
|
11
12
|
parameters: Type.Object({
|
|
12
13
|
content: Type.String({ description: "The full content to write to the plan file (replaces existing content)" }),
|
|
13
14
|
}),
|
|
15
|
+
outputSchema: WRITE_PLAN_OUTPUT_SCHEMA,
|
|
16
|
+
// Replaces the whole plan file; the same content yields a byte-identical file.
|
|
17
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
|
|
14
18
|
|
|
15
19
|
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
16
20
|
if (!isEnabled()) {
|
|
@@ -27,6 +31,7 @@ export function registerWritePlanTool(pi: ExtensionAPI, isEnabled: () => boolean
|
|
|
27
31
|
return {
|
|
28
32
|
content: [{ type: "text", text: `Plan written to ${filePath} (${params.content.length} chars)` }],
|
|
29
33
|
details: { filePath, length: params.content.length },
|
|
34
|
+
structuredContent: { status: "written", filePath, length: params.content.length },
|
|
30
35
|
};
|
|
31
36
|
},
|
|
32
37
|
});
|
package/src/runtime/results.ts
CHANGED
|
@@ -92,6 +92,24 @@ export function getResultOutput(result: { exitCode: number; stopReason?: string;
|
|
|
92
92
|
return getFinalOutput(result.messages) || "(no output)";
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Longest failure message kept for a status row. Generous enough that a
|
|
97
|
+
* provider's JSON error payload survives whole for a progress table to wrap
|
|
98
|
+
* across several lines, bounded so a multi-KB stderr dump cannot crowd out the
|
|
99
|
+
* table it renders in.
|
|
100
|
+
*/
|
|
101
|
+
const MAX_STATUS_DETAIL_CHARS = 500;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Collapse an agent failure into one bounded line for a progress row. Runs of
|
|
105
|
+
* whitespace — newlines included — are flattened because the row is laid out as
|
|
106
|
+
* a single line before its surface wraps it to the available width.
|
|
107
|
+
*/
|
|
108
|
+
export function statusDetail(output: string): string {
|
|
109
|
+
const collapsed = output.replace(/\s+/g, " ").trim();
|
|
110
|
+
return collapsed.length <= MAX_STATUS_DETAIL_CHARS ? collapsed : `${collapsed.slice(0, MAX_STATUS_DETAIL_CHARS - 1)}…`;
|
|
111
|
+
}
|
|
112
|
+
|
|
95
113
|
export function truncateOutput(output: string): string {
|
|
96
114
|
const maxBytes = 50 * 1024;
|
|
97
115
|
const bytes = Buffer.from(output, "utf8");
|