@nredd/pi-subagents 0.22.0-nredd.8 → 0.22.0-nredd.9
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/README.md +136 -55
- package/package.json +14 -11
- package/src/abortable.ts +1 -1
- package/src/agent-file-toggle.ts +0 -1
- package/src/agent-manager.ts +360 -103
- package/src/agent-runner.ts +121 -63
- package/src/child-context.ts +19 -1
- package/src/completion-outbox.ts +22 -3
- package/src/cross-extension-rpc.ts +10 -6
- package/src/custom-agents.ts +2 -1
- package/src/default-agents.ts +19 -17
- package/src/detached-results.ts +18 -2
- package/src/enterprise-local.ts +2 -1
- package/src/error-message.ts +11 -0
- package/src/git-path.ts +66 -0
- package/src/group-join.ts +4 -10
- package/src/index.ts +731 -231
- package/src/invocation-config.ts +1 -5
- package/src/mention-clone.ts +4 -3
- package/src/model-resolver.ts +51 -14
- package/src/model-selection.ts +17 -6
- package/src/model-tiers.ts +5 -0
- package/src/nested-tools.ts +80 -21
- package/src/notification-result.ts +118 -0
- package/src/plan-mode-children.ts +176 -0
- package/src/prompts.ts +7 -2
- package/src/quota-admission.ts +16 -6
- package/src/resource-loader.ts +20 -6
- package/src/schedule.ts +65 -19
- package/src/settings.ts +21 -1
- package/src/skill-loader.ts +244 -94
- package/src/status-note.ts +28 -5
- package/src/types.ts +52 -2
- package/src/ui/agent-mention.ts +5 -2
- package/src/ui/agent-widget.ts +41 -22
- package/src/ui/conversation-viewer.ts +4 -2
- package/src/ui/fallback-flag.ts +28 -4
- package/src/ui/fleet-list.ts +89 -54
- package/src/ui/notification-view.ts +4 -1
- package/src/ui/workflow-card.ts +1 -8
- package/src/ui/workflow-dialog.ts +31 -18
- package/src/workflow/host.ts +4 -5
- package/src/workflow/meta.ts +7 -4
- package/src/workflow/progress.ts +4 -3
- package/src/workflow/runtime.ts +23 -7
- package/src/workflow/saved.ts +2 -4
- package/src/workflow/task.ts +12 -7
- package/src/workflow/tool-description.ts +2 -2
- package/src/workflow/worker-source.ts +6 -2
- package/src/worktree.ts +309 -19
package/src/agent-runner.ts
CHANGED
|
@@ -23,7 +23,8 @@ import {
|
|
|
23
23
|
} from "@earendil-works/pi-coding-agent";
|
|
24
24
|
import { Type } from "typebox";
|
|
25
25
|
import { BUILTIN_TOOL_NAMES, getAgentConfig, getConfig, getMemoryToolNames, getReadOnlyMemoryToolNames, getToolNamesForType } from "./agent-types.js";
|
|
26
|
-
import { runInChildSessionContext } from "./child-context.js";
|
|
26
|
+
import { bindChildResourceLoader, runInChildSessionContext } from "./child-context.js";
|
|
27
|
+
import { type Clock, realClock } from "./clock.js";
|
|
27
28
|
import { buildParentContext, extractText } from "./context.js";
|
|
28
29
|
import { DEFAULT_AGENTS } from "./default-agents.js";
|
|
29
30
|
import { detectEnv } from "./env.js";
|
|
@@ -35,10 +36,12 @@ import type { ModelSelection } from "./model-selection.js";
|
|
|
35
36
|
import {
|
|
36
37
|
createNestedSubagentTools,
|
|
37
38
|
getMaxSubagentDepth,
|
|
39
|
+
hasNestableTypes,
|
|
38
40
|
NESTED_WORKFLOW_ERROR,
|
|
39
41
|
NESTED_WORKFLOW_NO_AGENT_ERROR,
|
|
40
42
|
type NestedAgentManager,
|
|
41
43
|
} from "./nested-tools.js";
|
|
44
|
+
import { type PlanGate, planGateReason } from "./plan-mode-children.js";
|
|
42
45
|
import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
|
|
43
46
|
import { createHomeResourceLoader } from "./resource-loader.js";
|
|
44
47
|
import { preloadSkills } from "./skill-loader.js";
|
|
@@ -59,6 +62,7 @@ export const SUBAGENT_TOOL_NAMES = {
|
|
|
59
62
|
WORKFLOW: "SubagentWorkflow",
|
|
60
63
|
GET_RESULT: "get_subagent_result",
|
|
61
64
|
STEER: "steer_subagent",
|
|
65
|
+
STOP: "stop_subagent",
|
|
62
66
|
} as const;
|
|
63
67
|
|
|
64
68
|
/**
|
|
@@ -302,11 +306,10 @@ export function installExtensionToolScope(
|
|
|
302
306
|
/** Receives the scope check the child's nested-call gate extension applies. */
|
|
303
307
|
nestedCallGate: NestedCallGate;
|
|
304
308
|
/** Why workflow tools are refused. */
|
|
305
|
-
workflowError
|
|
309
|
+
workflowError: string;
|
|
306
310
|
},
|
|
307
311
|
): void {
|
|
308
|
-
const { loader, toolNames, disallowedSet, extNames, narrowing, readmitToolNames, nestedCallGate } = ctx;
|
|
309
|
-
const workflowError = ctx.workflowError ?? NESTED_WORKFLOW_ERROR;
|
|
312
|
+
const { loader, toolNames, disallowedSet, extNames, narrowing, readmitToolNames, nestedCallGate, workflowError } = ctx;
|
|
310
313
|
|
|
311
314
|
// The names allowed right now. Mirrors the `ext:` opt-in flip: when any `ext:`
|
|
312
315
|
// selector is present, extension tools become an explicit allowlist — a loaded
|
|
@@ -394,8 +397,14 @@ export function installExtensionToolScope(
|
|
|
394
397
|
};
|
|
395
398
|
}
|
|
396
399
|
|
|
397
|
-
/**
|
|
398
|
-
|
|
400
|
+
/**
|
|
401
|
+
* Set once the child's tool scope (and, while planning, its Plan check) is
|
|
402
|
+
* installed; until then the gate admits everything.
|
|
403
|
+
*/
|
|
404
|
+
type NestedCallGate = {
|
|
405
|
+
allows?: (name: string) => boolean;
|
|
406
|
+
planReason?: (toolName: string, input: unknown, cwd: string) => string | undefined;
|
|
407
|
+
};
|
|
399
408
|
|
|
400
409
|
/**
|
|
401
410
|
* Inline extension that applies the subagent's tool scope to calls a tool makes
|
|
@@ -411,11 +420,13 @@ function scopeGateExtension(gate: NestedCallGate): InlineExtension {
|
|
|
411
420
|
name: SCOPE_GATE_NAME,
|
|
412
421
|
hidden: true,
|
|
413
422
|
factory: (ext) => {
|
|
414
|
-
ext.on("tool_call", (event) =>
|
|
415
|
-
gate.allows?.(event.toolName) === false
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
423
|
+
ext.on("tool_call", (event, ctx) => {
|
|
424
|
+
if (gate.allows?.(event.toolName) === false) {
|
|
425
|
+
return { block: true, reason: `Tool "${event.toolName}" is not available to this subagent.` };
|
|
426
|
+
}
|
|
427
|
+
const reason = gate.planReason?.(event.toolName, event.input, ctx.cwd);
|
|
428
|
+
return reason === undefined ? undefined : { block: true, reason };
|
|
429
|
+
});
|
|
419
430
|
},
|
|
420
431
|
};
|
|
421
432
|
}
|
|
@@ -495,6 +506,8 @@ export interface AssistantMessageInfo {
|
|
|
495
506
|
export interface RunOptions {
|
|
496
507
|
/** Backoff before each stale-ctx re-prompt (#145); defaults to 1s, 3s, 9s. `[]` disables recovery. */
|
|
497
508
|
staleRetryDelaysMs?: readonly number[];
|
|
509
|
+
/** Clock that times the stale-ctx backoff; the manager passes its own. Defaults to native timers. */
|
|
510
|
+
clock?: Clock;
|
|
498
511
|
/** ExtensionAPI instance — used for pi.exec() instead of execSync. */
|
|
499
512
|
pi: ExtensionAPI;
|
|
500
513
|
/** Manager-assigned id; suffixes session name to disambiguate parallel spawns (e.g. `Explore#a1b2c3d4`). */
|
|
@@ -600,6 +613,11 @@ export interface RunOptions {
|
|
|
600
613
|
* fails at the call that wrote it rather than inside the child.
|
|
601
614
|
*/
|
|
602
615
|
structuredOutput?: CompiledSchema;
|
|
616
|
+
/**
|
|
617
|
+
* The Plan restriction for this child's runs (#437). Installed once on the
|
|
618
|
+
* session and read per call; the manager sets `policy` per run.
|
|
619
|
+
*/
|
|
620
|
+
planGate?: PlanGate;
|
|
603
621
|
/** Runtime bridge for opt-in child-safe nested delegation. */
|
|
604
622
|
nestedRuntime?: {
|
|
605
623
|
manager: NestedAgentManager;
|
|
@@ -646,28 +664,40 @@ export interface RunResult {
|
|
|
646
664
|
*/
|
|
647
665
|
function collectResponseText(session: AgentSession) {
|
|
648
666
|
let text = "";
|
|
667
|
+
let laterTool = false;
|
|
668
|
+
let previousText = "";
|
|
669
|
+
let previousLaterTool = false;
|
|
649
670
|
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
|
|
650
671
|
// message_start also fires for user and toolResult messages — resetting on
|
|
651
672
|
// those would wipe assistant text already collected. Reset only when a new
|
|
652
673
|
// ASSISTANT message begins, so getText() is the last assistant message's text.
|
|
653
674
|
if (event.type === "message_start" && event.message.role === "assistant") {
|
|
675
|
+
if (text.trim()) {
|
|
676
|
+
previousText = text;
|
|
677
|
+
previousLaterTool = laterTool;
|
|
678
|
+
}
|
|
654
679
|
text = "";
|
|
680
|
+
laterTool = false;
|
|
655
681
|
}
|
|
656
682
|
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
|
657
683
|
text += event.assistantMessageEvent.delta;
|
|
658
684
|
}
|
|
685
|
+
if (event.type === "tool_execution_end") {
|
|
686
|
+
if (text.trim()) laterTool = true;
|
|
687
|
+
else if (previousText.trim()) previousLaterTool = true;
|
|
688
|
+
}
|
|
659
689
|
});
|
|
660
|
-
return { getText: () => text, unsubscribe };
|
|
690
|
+
return { getText: () => text, getPreviousText: () => previousText, hasLaterTool: () => text.trim() ? laterTool : previousLaterTool, unsubscribe };
|
|
661
691
|
}
|
|
662
692
|
|
|
663
693
|
/**
|
|
664
694
|
* Get the last non-empty assistant text produced during THIS invocation.
|
|
665
695
|
* `startIndex` is the message count captured before the prompt, so the walk-back
|
|
666
696
|
* never crosses into a previous turn: on a resume whose new turn failed empty,
|
|
667
|
-
* this returns "" instead of the prior turn's answer (#144).
|
|
668
|
-
*
|
|
697
|
+
* this returns "" instead of the prior turn's answer (#144). A fresh spawn passes
|
|
698
|
+
* 0, since the whole history belongs to that run.
|
|
669
699
|
*/
|
|
670
|
-
function getLastAssistantText(session: AgentSession, startIndex
|
|
700
|
+
function getLastAssistantText(session: AgentSession, startIndex: number): string {
|
|
671
701
|
for (let i = session.messages.length - 1; i >= startIndex; i--) {
|
|
672
702
|
const msg = session.messages[i];
|
|
673
703
|
if (msg.role !== "assistant") continue;
|
|
@@ -703,7 +733,7 @@ export function forwardModelCall(event: AgentSessionEvent, emit: (event: ModelCa
|
|
|
703
733
|
* no tool call pending, and a stop that was not an abort, a tool-use handoff or
|
|
704
734
|
* a token-limit cut. Anything else is a teaser or a cut-off report.
|
|
705
735
|
*/
|
|
706
|
-
function endedWithReport(session: AgentSession, startIndex
|
|
736
|
+
function endedWithReport(session: AgentSession, startIndex: number): boolean {
|
|
707
737
|
for (let i = session.messages.length - 1; i >= startIndex; i--) {
|
|
708
738
|
const msg = session.messages[i];
|
|
709
739
|
if (msg.role !== "assistant") continue;
|
|
@@ -768,7 +798,7 @@ async function forceFinalReport(session: AgentSession, startIndex: number, signa
|
|
|
768
798
|
* Bounded by `startIndex` (like the text fallback) so a resume that produced no
|
|
769
799
|
* assistant message of its own never inherits a PRIOR turn's stop reason.
|
|
770
800
|
*/
|
|
771
|
-
function finalTurnError(session: AgentSession, startIndex
|
|
801
|
+
function finalTurnError(session: AgentSession, startIndex: number): string | undefined {
|
|
772
802
|
for (let i = session.messages.length - 1; i >= startIndex; i--) {
|
|
773
803
|
const msg = session.messages[i];
|
|
774
804
|
if (msg.role !== "assistant") continue;
|
|
@@ -789,21 +819,18 @@ const STALE_CTX_RETRY_DELAYS_MS: readonly number[] = [1_000, 3_000, 9_000];
|
|
|
789
819
|
/** Sent when the session's last step died on a stale ctx; the transcript already holds the work so far. */
|
|
790
820
|
const STALE_CTX_RETRY_PROMPT = "Your previous step was interrupted by a session reload before it produced output. Continue the task from where you left off.";
|
|
791
821
|
|
|
792
|
-
/** Longest tool output echoed into a partial result. */
|
|
793
|
-
const PARTIAL_TOOL_OUTPUT_CHARS = 1_500;
|
|
794
|
-
|
|
795
822
|
/**
|
|
796
823
|
* Resolve after `ms`, or as soon as `signal` aborts. Never rejects.
|
|
797
824
|
*/
|
|
798
|
-
function sleep(ms: number, signal
|
|
825
|
+
function sleep(ms: number, signal: AbortSignal | undefined, clock: Clock): Promise<void> {
|
|
799
826
|
return new Promise((resolve) => {
|
|
800
827
|
if (signal?.aborted) return resolve();
|
|
801
828
|
const done = () => {
|
|
802
|
-
|
|
829
|
+
timer.cancel();
|
|
803
830
|
signal?.removeEventListener("abort", done);
|
|
804
831
|
resolve();
|
|
805
832
|
};
|
|
806
|
-
const timer = setTimeout(done, ms);
|
|
833
|
+
const timer = clock.setTimeout(done, ms);
|
|
807
834
|
signal?.addEventListener("abort", done, { once: true });
|
|
808
835
|
});
|
|
809
836
|
}
|
|
@@ -822,16 +849,22 @@ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
|
822
849
|
* it to repeat side effects (a push, a PR). The continue prompt adds one user
|
|
823
850
|
* turn and nothing else, so the tool batch is never replayed; a tool that did
|
|
824
851
|
* run is in context as a result.
|
|
852
|
+
*
|
|
853
|
+
* Kept for stale-ctx failures that routing no longer causes itself: core now hands
|
|
854
|
+
* `route` the requesting session's context (#581), so another session's reload or
|
|
855
|
+
* disposal cannot invalidate this request, but an extension callback that captured
|
|
856
|
+
* a dead ctx can still throw, and only this turns that into a bounded retry and a report.
|
|
825
857
|
*/
|
|
826
858
|
export async function retryStaleCtx(
|
|
827
859
|
session: AgentSession,
|
|
828
860
|
startIndex: number,
|
|
829
861
|
signal: AbortSignal | undefined,
|
|
830
862
|
delays: readonly number[] = STALE_CTX_RETRY_DELAYS_MS,
|
|
863
|
+
clock: Clock = realClock,
|
|
831
864
|
): Promise<void> {
|
|
832
865
|
for (const delay of delays) {
|
|
833
866
|
if (!isStaleCtxError(finalTurnError(session, startIndex))) return;
|
|
834
|
-
await sleep(delay, signal);
|
|
867
|
+
await sleep(delay, signal, clock);
|
|
835
868
|
if (signal?.aborted) return;
|
|
836
869
|
await session.prompt(STALE_CTX_RETRY_PROMPT);
|
|
837
870
|
}
|
|
@@ -847,26 +880,29 @@ function mayFollowUp(session: AgentSession, startIndex: number, signal: AbortSig
|
|
|
847
880
|
return !signal?.aborted && staleCtxOutcome(session, startIndex) === undefined;
|
|
848
881
|
}
|
|
849
882
|
|
|
850
|
-
/**
|
|
851
|
-
* What a run that died on a stale ctx had done: the last tool calls with the
|
|
852
|
-
* last tool output, so the parent can see side effects (a pushed branch, an
|
|
853
|
-
* opened PR) instead of "No output". Empty when the run did nothing.
|
|
854
|
-
*/
|
|
883
|
+
/** Bounded activity only: tool arguments, results and unknown names are never copied. */
|
|
855
884
|
function partialProgress(session: AgentSession, startIndex: number): string {
|
|
856
885
|
const calls: string[] = [];
|
|
857
|
-
let lastOutput = "";
|
|
858
886
|
for (let i = session.messages.length - 1; i >= startIndex && calls.length < 5; i--) {
|
|
859
887
|
const msg = session.messages[i];
|
|
860
|
-
if (msg.role
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
if (part.type === "toolCall" && calls.length < 5) calls.unshift(part.name);
|
|
864
|
-
}
|
|
865
|
-
}
|
|
888
|
+
if (msg.role !== "toolResult") continue;
|
|
889
|
+
const name = BUILTIN_TOOL_NAMES.includes(msg.toolName) ? msg.toolName : "other tool";
|
|
890
|
+
calls.unshift(`${name} (${msg.isError ? "failed" : "completed"})`);
|
|
866
891
|
}
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
892
|
+
return calls.length ? `Latest activity: ${calls.join(", ")}.` : "";
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/** Mark prose as earlier progress when subsequent tool results exist. */
|
|
896
|
+
function failureText(session: AgentSession, startIndex: number, text: string, partial: string, laterTool: boolean, streamed: boolean): string {
|
|
897
|
+
if (!text) return partial;
|
|
898
|
+
if (streamed) return laterTool ? `Earlier progress:\n${text}\n\n${partial}` : text;
|
|
899
|
+
let hasLaterResult = false;
|
|
900
|
+
for (let i = session.messages.length - 1; i >= startIndex; i--) {
|
|
901
|
+
const msg = session.messages[i];
|
|
902
|
+
if (msg.role === "assistant" && extractText(msg.content).trim() === text) break;
|
|
903
|
+
if (msg.role === "toolResult") hasLaterResult = true;
|
|
904
|
+
}
|
|
905
|
+
return hasLaterResult ? `Earlier progress:\n${text}\n\n${partial}` : text;
|
|
870
906
|
}
|
|
871
907
|
|
|
872
908
|
/**
|
|
@@ -877,7 +913,7 @@ export function staleCtxOutcome(session: AgentSession, startIndex: number): { fa
|
|
|
877
913
|
const error = finalTurnError(session, startIndex);
|
|
878
914
|
if (!isStaleCtxError(error)) return undefined;
|
|
879
915
|
return {
|
|
880
|
-
failure:
|
|
916
|
+
failure: `interrupted by a session reload or replacement; the agent's model runtime went stale and retries did not recover it: ${error}`,
|
|
881
917
|
partial: partialProgress(session, startIndex),
|
|
882
918
|
};
|
|
883
919
|
}
|
|
@@ -995,11 +1031,20 @@ export async function runAgent(
|
|
|
995
1031
|
|
|
996
1032
|
// Get parent system prompt for append-mode agents
|
|
997
1033
|
const parentSystemPrompt = ctx.getSystemPrompt();
|
|
1034
|
+
const agentDir = resolveAgentDir(options.homeDir);
|
|
1035
|
+
// The child inherits the parent's project-trust decision. Pi otherwise defaults
|
|
1036
|
+
// SettingsManager to trusted, which could load project MCP configuration; resolve
|
|
1037
|
+
// it before resource loading so untrusted skills cannot shadow trusted resources.
|
|
1038
|
+
const projectTrusted = options.configCwd !== undefined && options.configProjectTrusted !== undefined
|
|
1039
|
+
? options.configProjectTrusted
|
|
1040
|
+
: ctx.isProjectTrusted();
|
|
1041
|
+
const settingsManager = SettingsManager.create(configCwd, agentDir, { projectTrusted });
|
|
998
1042
|
|
|
999
1043
|
// Build prompt extras (memory, skill preloading)
|
|
1000
1044
|
const extras: PromptExtras = {};
|
|
1001
1045
|
if (options.worktreeBase) extras.worktreeBase = options.worktreeBase;
|
|
1002
1046
|
if (options.workflow && !options.structuredOutput) extras.workflowChild = true;
|
|
1047
|
+
if (options.planGate?.policy) extras.planMode = true;
|
|
1003
1048
|
|
|
1004
1049
|
// Resolved here, not by each caller: mention and RPC spawns pass no `isolated`,
|
|
1005
1050
|
// and the agent file's value must hold on every path.
|
|
@@ -1013,7 +1058,7 @@ export async function runAgent(
|
|
|
1013
1058
|
|
|
1014
1059
|
// Skill preloading: when skills is string[], preload their content into prompt
|
|
1015
1060
|
if (Array.isArray(skills)) {
|
|
1016
|
-
const loaded = preloadSkills(skills, configCwd, options.homeDir);
|
|
1061
|
+
const loaded = await preloadSkills(skills, configCwd, { agentDir, settingsManager, homeDir: options.homeDir });
|
|
1017
1062
|
if (loaded.length > 0) {
|
|
1018
1063
|
extras.skillBlocks = loaded;
|
|
1019
1064
|
}
|
|
@@ -1052,10 +1097,13 @@ export async function runAgent(
|
|
|
1052
1097
|
? options.nestedRuntime
|
|
1053
1098
|
: undefined;
|
|
1054
1099
|
// One answer for the prompt and the workflow refusal: does this child end up with `Agent`?
|
|
1055
|
-
|
|
1100
|
+
// An allowlist that resolves to no enabled agent leaves Agent nothing to
|
|
1101
|
+
// accept; omit the tools rather than advertise "none" as a type (#331, #333).
|
|
1102
|
+
const nestingPossible = Boolean(
|
|
1056
1103
|
agentConfig?.allowedSubagents && nestedRuntime && !isolated
|
|
1057
|
-
&&
|
|
1104
|
+
&& hasNestableTypes(agentConfig.allowedSubagents, { homeDir: options.homeDir, configCwd, projectTrusted }),
|
|
1058
1105
|
);
|
|
1106
|
+
const agentAvailable = nestingPossible && !agentConfig?.disallowedTools?.includes("Agent");
|
|
1059
1107
|
if (agentConfig?.allowedSubagents && !agentAvailable) extras.nestingDisabled = true;
|
|
1060
1108
|
|
|
1061
1109
|
// Build system prompt from agent config
|
|
@@ -1074,8 +1122,6 @@ export async function runAgent(
|
|
|
1074
1122
|
// Still pass noSkills: true since we don't need the skill loader to load them again.
|
|
1075
1123
|
const noSkills = skills === false || Array.isArray(skills);
|
|
1076
1124
|
|
|
1077
|
-
const agentDir = resolveAgentDir(options.homeDir);
|
|
1078
|
-
|
|
1079
1125
|
// Extension loading:
|
|
1080
1126
|
// - true → all default-discovered extensions
|
|
1081
1127
|
// - false → none (noExtensions)
|
|
@@ -1133,14 +1179,6 @@ export async function runAgent(
|
|
|
1133
1179
|
};
|
|
1134
1180
|
};
|
|
1135
1181
|
|
|
1136
|
-
// The child inherits the parent's project-trust decision. pi's SettingsManager
|
|
1137
|
-
// defaults to trusted, so without this a subagent in a project the user declined
|
|
1138
|
-
// to trust would load its `.pi` extensions and settings, and pi's built-in MCP
|
|
1139
|
-
// would start the stdio servers in its `.pi/mcp.json`.
|
|
1140
|
-
const projectTrusted = options.configCwd !== undefined && options.configProjectTrusted !== undefined
|
|
1141
|
-
? options.configProjectTrusted
|
|
1142
|
-
: ctx.isProjectTrusted();
|
|
1143
|
-
const settingsManager = SettingsManager.create(configCwd, agentDir, { projectTrusted });
|
|
1144
1182
|
// That trust covers the parent's project (`configCwd`), not a directory a spawn
|
|
1145
1183
|
// was sent into. The session answers `ctx.isProjectTrusted()` for its own cwd,
|
|
1146
1184
|
// which pi's MCP uses to read `<cwd>/.pi/mcp.json` — so a cross-directory session
|
|
@@ -1187,7 +1225,7 @@ export async function runAgent(
|
|
|
1187
1225
|
systemPromptOverride: () => systemPrompt,
|
|
1188
1226
|
appendSystemPromptOverride: () => [],
|
|
1189
1227
|
}, options.homeDir);
|
|
1190
|
-
await
|
|
1228
|
+
await bindChildResourceLoader(loader).reload();
|
|
1191
1229
|
|
|
1192
1230
|
// Plain entries in `tools:` are expected to be built-in names (extension tools
|
|
1193
1231
|
// go through `ext:`), so an unknown name there is unambiguously a typo. Previously
|
|
@@ -1272,7 +1310,7 @@ export async function runAgent(
|
|
|
1272
1310
|
// set `allowed_subagents` and a nestedRuntime was provided — and never when
|
|
1273
1311
|
// isolated. Their names collide with EXCLUDED_TOOL_NAMES by design, so the
|
|
1274
1312
|
// scoping below re-admits them explicitly (registry deny + active-set narrow).
|
|
1275
|
-
const nestedTools = agentConfig?.allowedSubagents && nestedRuntime &&
|
|
1313
|
+
const nestedTools = agentConfig?.allowedSubagents && nestedRuntime && nestingPossible
|
|
1276
1314
|
? createNestedSubagentTools({
|
|
1277
1315
|
manager: nestedRuntime.manager,
|
|
1278
1316
|
pi: options.pi,
|
|
@@ -1481,6 +1519,21 @@ export async function runAgent(
|
|
|
1481
1519
|
});
|
|
1482
1520
|
}
|
|
1483
1521
|
|
|
1522
|
+
// Plan mode (#437): outermost, so a call Plan refuses reaches no other hook.
|
|
1523
|
+
// Read per call, because the manager decides the gate per run (spawn, resume).
|
|
1524
|
+
const planGate = options.planGate;
|
|
1525
|
+
if (planGate) {
|
|
1526
|
+
const planReason = (toolName: string, input: unknown, cwd: string) =>
|
|
1527
|
+
planGateReason(planGate, { toolName, input, cwd }, () => session.getAllTools(), readmitToolNames);
|
|
1528
|
+
nestedCallGate.planReason = planReason;
|
|
1529
|
+
const priorBeforeToolCall = session.agent.beforeToolCall;
|
|
1530
|
+
session.agent.beforeToolCall = async (context, signal) => {
|
|
1531
|
+
const reason = planReason(context.toolCall.name, context.args, effectiveCwd);
|
|
1532
|
+
if (reason !== undefined) return { block: true, reason };
|
|
1533
|
+
return priorBeforeToolCall?.(context, signal);
|
|
1534
|
+
};
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1484
1537
|
options.onSessionCreated?.(session);
|
|
1485
1538
|
|
|
1486
1539
|
const run = observeRun(session, { ...options, maxTurns: resolveEffectiveMaxTurns(type, options.maxTurns) });
|
|
@@ -1506,7 +1559,7 @@ export async function runAgent(
|
|
|
1506
1559
|
// pi's `session.abort()` is a no-op until a run is active — so a child whose
|
|
1507
1560
|
// parent is already gone is not prompted at all.
|
|
1508
1561
|
if (options.signal?.aborted !== true) await session.prompt(effectivePrompt);
|
|
1509
|
-
await retryStaleCtx(session, startLen, options.signal, options.staleRetryDelaysMs);
|
|
1562
|
+
await retryStaleCtx(session, startLen, options.signal, options.staleRetryDelaysMs, options.clock);
|
|
1510
1563
|
|
|
1511
1564
|
// A cap fired. The wrap-up steer is only a request: a model deep in a tool
|
|
1512
1565
|
// loop keeps calling tools through the grace turns and is then aborted
|
|
@@ -1537,7 +1590,7 @@ export async function runAgent(
|
|
|
1537
1590
|
}
|
|
1538
1591
|
|
|
1539
1592
|
const stale = staleCtxOutcome(session, startLen);
|
|
1540
|
-
const
|
|
1593
|
+
const text = collector.getText().trim() || getLastAssistantText(session, startLen);
|
|
1541
1594
|
// A child asked for structured output that never gave any has failed, however
|
|
1542
1595
|
// articulate its prose was. Reported through `failure` so it travels the same
|
|
1543
1596
|
// path as a provider error rather than arriving as a successful empty answer.
|
|
@@ -1546,12 +1599,14 @@ export async function runAgent(
|
|
|
1546
1599
|
? `The agent's StructuredOutput call did not match the required schema: ${structuredCapture.lastError}`
|
|
1547
1600
|
: "The agent did not report its answer through StructuredOutput."
|
|
1548
1601
|
: undefined;
|
|
1602
|
+
const failure = stale?.failure ?? finalTurnError(session, startLen) ?? structuredFailure;
|
|
1603
|
+
const streamed = collector.getText().trim() || collector.getPreviousText().trim();
|
|
1549
1604
|
return {
|
|
1550
|
-
responseText,
|
|
1605
|
+
responseText: failure ? failureText(session, startLen, streamed || text, stale?.partial ?? partialProgress(session, startLen), collector.hasLaterTool(), Boolean(streamed)) : text,
|
|
1551
1606
|
session,
|
|
1552
1607
|
aborted: run.aborted,
|
|
1553
1608
|
steered: run.steered,
|
|
1554
|
-
failure
|
|
1609
|
+
failure,
|
|
1555
1610
|
...(structuredCapture?.json !== undefined ? { structuredJson: structuredCapture.json } : {}),
|
|
1556
1611
|
...(structuredRetried ? { structuredRetried } : {}),
|
|
1557
1612
|
};
|
|
@@ -1566,7 +1621,7 @@ export async function runAgent(
|
|
|
1566
1621
|
export async function resumeAgent(
|
|
1567
1622
|
session: AgentSession,
|
|
1568
1623
|
prompt: string,
|
|
1569
|
-
options: Pick<RunOptions, "staleRetryDelaysMs" | "maxTurns" | "onTurnEnd" | "onTextDelta" | "onModelCall" | "onToolActivity" | "onAssistantUsage" | "onCompaction" | "signal"> & {
|
|
1624
|
+
options: Pick<RunOptions, "staleRetryDelaysMs" | "clock" | "maxTurns" | "onTurnEnd" | "onTextDelta" | "onModelCall" | "onToolActivity" | "onAssistantUsage" | "onCompaction" | "signal"> & {
|
|
1570
1625
|
/** The agent's type, for its own `max_turns`. */
|
|
1571
1626
|
type: SubagentType;
|
|
1572
1627
|
},
|
|
@@ -1582,7 +1637,7 @@ export async function resumeAgent(
|
|
|
1582
1637
|
try {
|
|
1583
1638
|
// Already aborted: not prompted, as in `runAgent`.
|
|
1584
1639
|
if (options.signal?.aborted !== true) await session.prompt(prompt);
|
|
1585
|
-
await retryStaleCtx(session, startLen, options.signal, options.staleRetryDelaysMs);
|
|
1640
|
+
await retryStaleCtx(session, startLen, options.signal, options.staleRetryDelaysMs, options.clock);
|
|
1586
1641
|
if (run.steered && mayFollowUp(session, startLen, options.signal) && !endedWithReport(session, startLen)) {
|
|
1587
1642
|
run.finalizing = true;
|
|
1588
1643
|
if (await forceFinalReport(session, startLen, options.signal)) run.aborted = false;
|
|
@@ -1594,9 +1649,12 @@ export async function resumeAgent(
|
|
|
1594
1649
|
}
|
|
1595
1650
|
|
|
1596
1651
|
const stale = staleCtxOutcome(session, startLen);
|
|
1652
|
+
const failure = stale?.failure ?? finalTurnError(session, startLen);
|
|
1653
|
+
const text = collector.getText().trim() || getLastAssistantText(session, startLen);
|
|
1654
|
+
const streamed = collector.getText().trim() || collector.getPreviousText().trim();
|
|
1597
1655
|
return {
|
|
1598
|
-
text:
|
|
1599
|
-
failure
|
|
1656
|
+
text: failure ? failureText(session, startLen, streamed || text, stale?.partial ?? partialProgress(session, startLen), collector.hasLaterTool(), Boolean(streamed)) : text,
|
|
1657
|
+
failure,
|
|
1600
1658
|
aborted: run.aborted,
|
|
1601
1659
|
steered: run.steered,
|
|
1602
1660
|
};
|
package/src/child-context.ts
CHANGED
|
@@ -4,7 +4,13 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
|
|
4
4
|
* Marks resource loading/session construction performed for a subagent. This is
|
|
5
5
|
* async-context-local so concurrent top-level extension work is unaffected.
|
|
6
6
|
*/
|
|
7
|
-
|
|
7
|
+
// Pi reloads extension modules with jiti's module cache disabled. The parent
|
|
8
|
+
// runner and the discovered child extension can therefore hold different module
|
|
9
|
+
// instances, but must consult the same async-local session marker.
|
|
10
|
+
const contextKey = Symbol.for("pi-subagents:child-session-context");
|
|
11
|
+
const globals = globalThis as typeof globalThis & { [contextKey]?: AsyncLocalStorage<boolean> };
|
|
12
|
+
const childSessionContext = globals[contextKey] ?? new AsyncLocalStorage<boolean>();
|
|
13
|
+
globals[contextKey] = childSessionContext;
|
|
8
14
|
|
|
9
15
|
export function inChildSessionContext(): boolean {
|
|
10
16
|
return childSessionContext.getStore() === true;
|
|
@@ -13,3 +19,15 @@ export function inChildSessionContext(): boolean {
|
|
|
13
19
|
export function runInChildSessionContext<T>(fn: () => Promise<T>): Promise<T> {
|
|
14
20
|
return childSessionContext.run(true, fn);
|
|
15
21
|
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Make every future `reload()` of a child session's loader run in the child
|
|
25
|
+
* context. A session reload (e.g. an extension calling `ctx.reload()`) calls
|
|
26
|
+
* `loader.reload()` again and re-runs each extension factory, long after the
|
|
27
|
+
* construction-time {@link runInChildSessionContext} has exited.
|
|
28
|
+
*/
|
|
29
|
+
export function bindChildResourceLoader<T extends { reload(): Promise<void> }>(loader: T): T {
|
|
30
|
+
const reload = loader.reload.bind(loader);
|
|
31
|
+
loader.reload = () => runInChildSessionContext(reload);
|
|
32
|
+
return loader;
|
|
33
|
+
}
|
package/src/completion-outbox.ts
CHANGED
|
@@ -27,18 +27,28 @@ export const NOTIFICATION_HOLD_MS = 200;
|
|
|
27
27
|
export interface OutboxMessage {
|
|
28
28
|
content: string;
|
|
29
29
|
details: NotificationDetails;
|
|
30
|
+
/** Agents whose whole result the content carries: sending it reads their result. */
|
|
31
|
+
delivered?: string[];
|
|
32
|
+
/** Agents whose whole result the content carries, failures included: the parent has seen it, though it stays unread in FleetView. */
|
|
33
|
+
noticed?: string[];
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
/**
|
|
33
37
|
* A notification for one or more agents, built when they finished: a resume
|
|
34
38
|
* before the send must not rewrite it. Reading an agent's result cancels its
|
|
35
|
-
* id, and an item left with none is dropped; `build` gets the ids still pending
|
|
39
|
+
* id, and an item left with none is dropped; `build` gets the ids still pending
|
|
40
|
+
* and the result length the batch allots each (see `notification-result.ts`).
|
|
36
41
|
*/
|
|
37
42
|
export interface OutboxItem {
|
|
38
43
|
ids: string[];
|
|
39
|
-
|
|
44
|
+
/** Each agent's full result length, for the batch to split its budget by. */
|
|
45
|
+
resultChars?: Readonly<Record<string, number>>;
|
|
46
|
+
build(ids: readonly string[], caps: Readonly<Record<string, number>>): OutboxMessage;
|
|
40
47
|
}
|
|
41
48
|
|
|
49
|
+
/** Result length per agent of one batch, from each agent's full length. */
|
|
50
|
+
export type ResultAllotment = (chars: Readonly<Record<string, number>>) => Record<string, number>;
|
|
51
|
+
|
|
42
52
|
export class CompletionOutbox {
|
|
43
53
|
private items: OutboxItem[] = [];
|
|
44
54
|
private timer: TimerHandle | undefined;
|
|
@@ -50,6 +60,8 @@ export class CompletionOutbox {
|
|
|
50
60
|
private parentRunning: () => boolean,
|
|
51
61
|
/** Shared with the scheduler by hosts that control time in-process. */
|
|
52
62
|
private clock: Clock = realClock,
|
|
63
|
+
/** Splits a batch's result budget; by default nothing is allotted and each item keeps its own cap. */
|
|
64
|
+
private allot: ResultAllotment = () => ({}),
|
|
53
65
|
) {}
|
|
54
66
|
|
|
55
67
|
/** A newer completion of the same agent (a resume) replaces its pending one. */
|
|
@@ -101,7 +113,10 @@ export class CompletionOutbox {
|
|
|
101
113
|
this.timer = undefined;
|
|
102
114
|
const items = this.items;
|
|
103
115
|
this.items = [];
|
|
104
|
-
const
|
|
116
|
+
const chars: Record<string, number> = {};
|
|
117
|
+
for (const item of items) for (const id of item.ids) if (item.resultChars?.[id] !== undefined) chars[id] = item.resultChars[id];
|
|
118
|
+
const caps = this.allot(chars);
|
|
119
|
+
const messages = items.map(item => item.build(item.ids, caps));
|
|
105
120
|
return messages.length > 0 ? combine(messages) : undefined;
|
|
106
121
|
}
|
|
107
122
|
|
|
@@ -118,8 +133,12 @@ export class CompletionOutbox {
|
|
|
118
133
|
function combine(messages: OutboxMessage[]): OutboxMessage {
|
|
119
134
|
if (messages.length === 1) return messages[0];
|
|
120
135
|
const [first, ...rest] = messages.flatMap(({ details: { others, ...own } }) => [own, ...(others ?? [])]);
|
|
136
|
+
const delivered = messages.flatMap(m => m.delivered ?? []);
|
|
137
|
+
const noticed = messages.flatMap(m => m.noticed ?? []);
|
|
121
138
|
return {
|
|
122
139
|
content: `${messages.length} background tasks finished.\n\n${messages.map(m => m.content).join("\n\n")}`,
|
|
123
140
|
details: { ...first, others: rest },
|
|
141
|
+
...(delivered.length > 0 ? { delivered } : {}),
|
|
142
|
+
...(noticed.length > 0 ? { noticed } : {}),
|
|
124
143
|
};
|
|
125
144
|
}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { isTopLevelAgent } from "./agent-manager.js";
|
|
17
17
|
import type { ModelRegistry } from "./model-resolver.js";
|
|
18
18
|
import { selectModel } from "./model-selection.js";
|
|
19
|
+
import { planSteerBlocked, planSteerRefusal } from "./plan-mode-children.js";
|
|
19
20
|
import type { AgentRecord } from "./types.js";
|
|
20
21
|
|
|
21
22
|
/** Minimal event bus interface needed by the RPC handlers. */
|
|
@@ -39,11 +40,11 @@ export interface SpawnCapable {
|
|
|
39
40
|
awaitStartup(id: string): Promise<void>;
|
|
40
41
|
abort(id: string): boolean;
|
|
41
42
|
/**
|
|
42
|
-
* The record behind an id, for the stop
|
|
43
|
-
*
|
|
44
|
-
* deliberately shallow view of the manager.
|
|
43
|
+
* The record behind an id, for the stop and steer handlers' ownership check
|
|
44
|
+
* and the steer handler's Plan check. Narrowed to the fields those read, so
|
|
45
|
+
* the RPC layer keeps its deliberately shallow view of the manager.
|
|
45
46
|
*/
|
|
46
|
-
getRecord(id: string): Pick<AgentRecord, "parentAgentId" | "workflowId"> | undefined;
|
|
47
|
+
getRecord(id: string): Pick<AgentRecord, "parentAgentId" | "workflowId" | "status" | "planGate"> | undefined;
|
|
47
48
|
/**
|
|
48
49
|
* Mark a settled agent's result as read by the caller, suppressing the
|
|
49
50
|
* completion notification — what `get_subagent_result` does when it returns
|
|
@@ -152,10 +153,11 @@ export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
|
|
|
152
153
|
// a live hole: no RPC hands out agent ids, so a caller has no ordinary way to
|
|
153
154
|
// name one it does not own — but the guard is cheap and the id may leak some
|
|
154
155
|
// other way. Same refuse-what-we-should-not-touch stance as `consume` below.
|
|
155
|
-
const assertOwned = (agentId: string)
|
|
156
|
+
const assertOwned = (agentId: string) => {
|
|
156
157
|
const record = manager.getRecord(agentId);
|
|
157
158
|
if (!record) throw new Error("Agent not found");
|
|
158
159
|
if (!isTopLevelAgent(record)) throw new Error("Agent is owned by another agent or workflow");
|
|
160
|
+
return record;
|
|
159
161
|
};
|
|
160
162
|
|
|
161
163
|
const unsubStop = handleRpc<{ requestId: string; agentId: string }>(
|
|
@@ -187,7 +189,9 @@ export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
|
|
|
187
189
|
const unsubSteer = handleRpc<{ requestId: string; agentId: string; message: unknown }>(
|
|
188
190
|
events, "subagents:rpc:steer", ({ agentId, message }) => {
|
|
189
191
|
if (typeof message !== "string" || message.trim().length === 0) throw new Error("Steer message must be a non-empty string");
|
|
190
|
-
assertOwned(agentId);
|
|
192
|
+
const record = assertOwned(agentId);
|
|
193
|
+
// Same refusal as `steer_subagent`, so another extension cannot steer a pre-Plan agent into edits.
|
|
194
|
+
if (planSteerBlocked(record, events)) throw new Error(planSteerRefusal(agentId));
|
|
191
195
|
if (!manager.steer(agentId, message)) throw new Error("Agent is not running");
|
|
192
196
|
},
|
|
193
197
|
);
|
package/src/custom-agents.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
|
6
6
|
import { basename, join } from "node:path";
|
|
7
7
|
import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { BUILTIN_TOOL_NAMES } from "./agent-types.js";
|
|
9
|
+
import { errorMessage } from "./error-message.js";
|
|
9
10
|
import { resolveAgentDir } from "./home-paths.js";
|
|
10
11
|
import {
|
|
11
12
|
isPackageResourceExcluded,
|
|
@@ -290,7 +291,7 @@ function readAgentFile(path: string, strict: boolean): { frontmatter: Record<str
|
|
|
290
291
|
try {
|
|
291
292
|
return parseAgentFrontmatter<Record<string, unknown>>(readFileSync(path, "utf-8"));
|
|
292
293
|
} catch (err) {
|
|
293
|
-
const reason =
|
|
294
|
+
const reason = errorMessage(err);
|
|
294
295
|
if (strict) throw new Error(`${path}: ${reason}`);
|
|
295
296
|
warnIfNew(`Skipping agent file ${path}: ${reason}`);
|
|
296
297
|
return undefined;
|