@nredd/pi-subagents 0.22.0-nredd.7 → 0.22.0-nredd.8
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 +124 -37
- package/package.json +8 -8
- package/src/agent-file-toggle.ts +6 -4
- package/src/agent-manager.ts +131 -28
- package/src/agent-runner.ts +155 -62
- package/src/cross-extension-rpc.ts +11 -36
- package/src/custom-agents.ts +9 -5
- package/src/default-agents.ts +2 -4
- package/src/enabled-models.ts +8 -7
- package/src/enterprise-local.ts +5 -5
- package/src/home-paths.ts +27 -0
- package/src/index.ts +191 -90
- package/src/invocation-config.ts +3 -16
- package/src/memory.ts +9 -9
- package/src/mention-clone.ts +9 -5
- package/src/model-resolver.ts +41 -11
- package/src/model-scope.ts +2 -1
- package/src/model-selection.ts +159 -0
- package/src/model-tiers.ts +171 -0
- package/src/nested-tools.ts +22 -26
- package/src/output-file.ts +23 -1
- package/src/package-resources.ts +25 -10
- package/src/process-budget.ts +6 -3
- package/src/quota-admission.ts +21 -9
- package/src/resource-loader.ts +51 -0
- package/src/schedule.ts +36 -18
- package/src/settings.ts +42 -12
- package/src/skill-loader.ts +8 -7
- package/src/stale-ctx.ts +16 -0
- package/src/types.ts +27 -0
- package/src/ui/agent-row.ts +13 -3
- package/src/ui/agent-widget.ts +15 -4
- package/src/ui/conversation-viewer.ts +6 -3
- package/src/ui/fallback-flag.ts +90 -0
- package/src/ui/fleet-list.ts +363 -53
- package/src/ui/fleet-state.ts +154 -0
- package/src/ui/notification-view.ts +18 -2
- package/src/ui/workflow-card.ts +5 -1
- package/src/ui/workflow-dialog.ts +10 -0
- package/src/workflow/host.ts +43 -45
- package/src/workflow/journal.ts +13 -1
- package/src/workflow/progress.ts +4 -0
- package/src/workflow/runtime.ts +25 -8
- package/src/workflow/saved.ts +19 -17
- package/src/workflow/task.ts +10 -0
- package/src/workflow/worker-source.ts +156 -38
package/src/agent-runner.ts
CHANGED
|
@@ -14,10 +14,9 @@ import {
|
|
|
14
14
|
createCodemodeExtension,
|
|
15
15
|
createMcpExtension,
|
|
16
16
|
createToolSearchExtension,
|
|
17
|
-
DefaultResourceLoader,
|
|
17
|
+
type DefaultResourceLoader,
|
|
18
18
|
defineTool,
|
|
19
19
|
type ExtensionAPI,
|
|
20
|
-
getAgentDir,
|
|
21
20
|
type InlineExtension,
|
|
22
21
|
SessionManager,
|
|
23
22
|
SettingsManager,
|
|
@@ -28,8 +27,11 @@ import { runInChildSessionContext } from "./child-context.js";
|
|
|
28
27
|
import { buildParentContext, extractText } from "./context.js";
|
|
29
28
|
import { DEFAULT_AGENTS } from "./default-agents.js";
|
|
30
29
|
import { detectEnv } from "./env.js";
|
|
30
|
+
import { normalizeHomePath, resolveAgentDir } from "./home-paths.js";
|
|
31
31
|
import { buildMemoryBlock, buildReadOnlyMemoryBlock } from "./memory.js";
|
|
32
32
|
import type { ModelCallEvent } from "./model-call.js";
|
|
33
|
+
import type { ModelFallback } from "./model-resolver.js";
|
|
34
|
+
import type { ModelSelection } from "./model-selection.js";
|
|
33
35
|
import {
|
|
34
36
|
createNestedSubagentTools,
|
|
35
37
|
getMaxSubagentDepth,
|
|
@@ -38,7 +40,9 @@ import {
|
|
|
38
40
|
type NestedAgentManager,
|
|
39
41
|
} from "./nested-tools.js";
|
|
40
42
|
import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
|
|
43
|
+
import { createHomeResourceLoader } from "./resource-loader.js";
|
|
41
44
|
import { preloadSkills } from "./skill-loader.js";
|
|
45
|
+
import { isStaleCtxError } from "./stale-ctx.js";
|
|
42
46
|
import { createStructuredCapture, createStructuredOutputTool, structuredRetryPrompt } from "./structured-output.js";
|
|
43
47
|
import type { SubagentType, ThinkingLevel } from "./types.js";
|
|
44
48
|
import type { LifetimeUsage } from "./usage.js";
|
|
@@ -187,6 +191,7 @@ export function extensionCanonicalNames(extPath: string): string[] {
|
|
|
187
191
|
export function parseExtensionsSpec(
|
|
188
192
|
entries: string[],
|
|
189
193
|
cwd: string,
|
|
194
|
+
homeDir = homedir(),
|
|
190
195
|
): { names: Set<string>; paths: string[]; wildcard: boolean } {
|
|
191
196
|
const names = new Set<string>();
|
|
192
197
|
const paths: string[] = [];
|
|
@@ -202,10 +207,10 @@ export function parseExtensionsSpec(
|
|
|
202
207
|
names.add(entry.toLowerCase());
|
|
203
208
|
continue;
|
|
204
209
|
}
|
|
210
|
+
// This frontmatter caller historically expands both slash forms on every
|
|
211
|
+
// platform, without interpreting file URLs or Windows shell drive forms.
|
|
205
212
|
let p = entry;
|
|
206
|
-
if (p === "~" || p.startsWith("~/") || p.startsWith("~\\"))
|
|
207
|
-
p = homedir() + p.slice(1);
|
|
208
|
-
}
|
|
213
|
+
if (p === "~" || p.startsWith("~/") || p.startsWith("~\\")) p = homeDir + p.slice(1);
|
|
209
214
|
const abs = isAbsolute(p) ? p : resolve(cwd, p);
|
|
210
215
|
paths.push(abs);
|
|
211
216
|
names.add(extensionCanonicalName(abs));
|
|
@@ -474,37 +479,6 @@ const FINAL_REPORT_PROMPT =
|
|
|
474
479
|
+ "Write your complete final report now: everything you found, concrete file paths and line numbers, "
|
|
475
480
|
+ "what you changed or concluded, and anything unfinished or unverified.";
|
|
476
481
|
|
|
477
|
-
/**
|
|
478
|
-
* Try to find the right model for an agent type.
|
|
479
|
-
* Priority: explicit option > config.model > parent model.
|
|
480
|
-
*/
|
|
481
|
-
export function resolveDefaultModel(
|
|
482
|
-
parentModel: Model<any> | undefined,
|
|
483
|
-
registry: { find(provider: string, modelId: string): Model<any> | undefined; getAvailable?(): Model<any>[] },
|
|
484
|
-
configModel?: string,
|
|
485
|
-
): Model<any> | undefined {
|
|
486
|
-
if (configModel) {
|
|
487
|
-
const slashIdx = configModel.indexOf("/");
|
|
488
|
-
if (slashIdx !== -1) {
|
|
489
|
-
const provider = configModel.slice(0, slashIdx);
|
|
490
|
-
const modelId = configModel.slice(slashIdx + 1);
|
|
491
|
-
|
|
492
|
-
// Build a set of available model keys for fast lookup
|
|
493
|
-
const available = registry.getAvailable?.();
|
|
494
|
-
const availableKeys = available
|
|
495
|
-
? new Set(available.map((m: any) => `${m.provider}/${m.id}`))
|
|
496
|
-
: undefined;
|
|
497
|
-
const isAvailable = (p: string, id: string) =>
|
|
498
|
-
!availableKeys || availableKeys.has(`${p}/${id}`);
|
|
499
|
-
|
|
500
|
-
const found = registry.find(provider, modelId);
|
|
501
|
-
if (found && isAvailable(provider, modelId)) return found;
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
return parentModel;
|
|
506
|
-
}
|
|
507
|
-
|
|
508
482
|
/** Info about a tool event in the subagent. */
|
|
509
483
|
export interface ToolActivity {
|
|
510
484
|
type: "start" | "end";
|
|
@@ -519,11 +493,15 @@ export interface AssistantMessageInfo {
|
|
|
519
493
|
}
|
|
520
494
|
|
|
521
495
|
export interface RunOptions {
|
|
496
|
+
/** Backoff before each stale-ctx re-prompt (#145); defaults to 1s, 3s, 9s. `[]` disables recovery. */
|
|
497
|
+
staleRetryDelaysMs?: readonly number[];
|
|
522
498
|
/** ExtensionAPI instance — used for pi.exec() instead of execSync. */
|
|
523
499
|
pi: ExtensionAPI;
|
|
524
500
|
/** Manager-assigned id; suffixes session name to disambiguate parallel spawns (e.g. `Explore#a1b2c3d4`). */
|
|
525
501
|
agentId?: string;
|
|
526
502
|
model?: Model<any>;
|
|
503
|
+
/** Required admitted spawn outcome. The runner never resolves or admits models. */
|
|
504
|
+
modelSelection: ModelSelection;
|
|
527
505
|
maxTurns?: number;
|
|
528
506
|
signal?: AbortSignal;
|
|
529
507
|
/** Applies only when the agent file leaves `isolated:` unset — frontmatter is authoritative. */
|
|
@@ -557,6 +535,8 @@ export interface RunOptions {
|
|
|
557
535
|
workflow?: boolean;
|
|
558
536
|
/** Override working directory (e.g. for worktree isolation). */
|
|
559
537
|
cwd?: string;
|
|
538
|
+
/** Home directory for tilde expansion and user-scoped storage. Defaults at run time. */
|
|
539
|
+
homeDir?: string;
|
|
560
540
|
/**
|
|
561
541
|
* Directory the worktree copy was created from. Set only when `cwd` points
|
|
562
542
|
* into a worktree — the prompt then tells the agent to stay in the copy
|
|
@@ -592,6 +572,8 @@ export interface RunOptions {
|
|
|
592
572
|
/** Called for every streaming event of each model call, so a UI can say what an in-flight call is doing. */
|
|
593
573
|
onModelCall?: (event: ModelCallEvent) => void;
|
|
594
574
|
onSessionCreated?: (session: AgentSession) => void;
|
|
575
|
+
/** Direct runner callers can observe the same configured fallback as the shared spawn. */
|
|
576
|
+
onModelFallback?: (fallback: ModelFallback) => void;
|
|
595
577
|
/** Called at the end of each agentic turn with the cumulative count. */
|
|
596
578
|
onTurnEnd?: (turnCount: number) => void;
|
|
597
579
|
/**
|
|
@@ -801,6 +783,105 @@ function finalTurnError(session: AgentSession, startIndex = 0): string | undefin
|
|
|
801
783
|
return undefined;
|
|
802
784
|
}
|
|
803
785
|
|
|
786
|
+
/** Waits before each re-prompt of a step lost to a stale ctx: a reload re-registers within seconds. */
|
|
787
|
+
const STALE_CTX_RETRY_DELAYS_MS: readonly number[] = [1_000, 3_000, 9_000];
|
|
788
|
+
|
|
789
|
+
/** Sent when the session's last step died on a stale ctx; the transcript already holds the work so far. */
|
|
790
|
+
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
|
+
|
|
792
|
+
/** Longest tool output echoed into a partial result. */
|
|
793
|
+
const PARTIAL_TOOL_OUTPUT_CHARS = 1_500;
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Resolve after `ms`, or as soon as `signal` aborts. Never rejects.
|
|
797
|
+
*/
|
|
798
|
+
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
799
|
+
return new Promise((resolve) => {
|
|
800
|
+
if (signal?.aborted) return resolve();
|
|
801
|
+
const done = () => {
|
|
802
|
+
clearTimeout(timer);
|
|
803
|
+
signal?.removeEventListener("abort", done);
|
|
804
|
+
resolve();
|
|
805
|
+
};
|
|
806
|
+
const timer = setTimeout(done, ms);
|
|
807
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Re-prompt a session whose final step died on a stale extension ctx, backing
|
|
813
|
+
* off between attempts so a reload in progress can re-register its routes.
|
|
814
|
+
* Returns once the final step no longer fails that way, retries are spent, or
|
|
815
|
+
* `signal` aborts. A permanently dead runtime (the parent session was replaced)
|
|
816
|
+
* exhausts the retries and is reported by `staleCtxOutcome`.
|
|
817
|
+
*
|
|
818
|
+
* Why appending a continue prompt is safe, and re-sending the task is not: the
|
|
819
|
+
* failed step died in model routing or an observer, before the model produced
|
|
820
|
+
* anything, so the transcript already holds every tool call and result the run
|
|
821
|
+
* made. Re-sending the task would show the model the same work twice and invite
|
|
822
|
+
* it to repeat side effects (a push, a PR). The continue prompt adds one user
|
|
823
|
+
* turn and nothing else, so the tool batch is never replayed; a tool that did
|
|
824
|
+
* run is in context as a result.
|
|
825
|
+
*/
|
|
826
|
+
export async function retryStaleCtx(
|
|
827
|
+
session: AgentSession,
|
|
828
|
+
startIndex: number,
|
|
829
|
+
signal: AbortSignal | undefined,
|
|
830
|
+
delays: readonly number[] = STALE_CTX_RETRY_DELAYS_MS,
|
|
831
|
+
): Promise<void> {
|
|
832
|
+
for (const delay of delays) {
|
|
833
|
+
if (!isStaleCtxError(finalTurnError(session, startIndex))) return;
|
|
834
|
+
await sleep(delay, signal);
|
|
835
|
+
if (signal?.aborted) return;
|
|
836
|
+
await session.prompt(STALE_CTX_RETRY_PROMPT);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* Whether a follow-up prompt (the structured-output retry, the forced final
|
|
842
|
+
* report) may still be sent. Not once the run was stopped, and not while the
|
|
843
|
+
* final step is still a stale ctx: the runtime is dead, so the prompt would
|
|
844
|
+
* fail the same way and mask the real outcome behind a doomed turn.
|
|
845
|
+
*/
|
|
846
|
+
function mayFollowUp(session: AgentSession, startIndex: number, signal: AbortSignal | undefined): boolean {
|
|
847
|
+
return !signal?.aborted && staleCtxOutcome(session, startIndex) === undefined;
|
|
848
|
+
}
|
|
849
|
+
|
|
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
|
+
*/
|
|
855
|
+
function partialProgress(session: AgentSession, startIndex: number): string {
|
|
856
|
+
const calls: string[] = [];
|
|
857
|
+
let lastOutput = "";
|
|
858
|
+
for (let i = session.messages.length - 1; i >= startIndex && calls.length < 5; i--) {
|
|
859
|
+
const msg = session.messages[i];
|
|
860
|
+
if (msg.role === "toolResult" && !lastOutput) lastOutput = extractText(msg.content).trim();
|
|
861
|
+
if (msg.role === "assistant") {
|
|
862
|
+
for (const part of [...msg.content].reverse()) {
|
|
863
|
+
if (part.type === "toolCall" && calls.length < 5) calls.unshift(part.name);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
if (calls.length === 0) return "";
|
|
868
|
+
const clipped = lastOutput.length > PARTIAL_TOOL_OUTPUT_CHARS ? `${lastOutput.slice(0, PARTIAL_TOOL_OUTPUT_CHARS)}...` : lastOutput;
|
|
869
|
+
return `Last tool calls: ${calls.join(", ")}.${clipped ? `\nLast tool output:\n${clipped}` : ""}`;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* The failure and partial result for a run whose final step is still a stale
|
|
874
|
+
* ctx after retries; undefined for any other outcome.
|
|
875
|
+
*/
|
|
876
|
+
export function staleCtxOutcome(session: AgentSession, startIndex: number): { failure: string; partial: string } | undefined {
|
|
877
|
+
const error = finalTurnError(session, startIndex);
|
|
878
|
+
if (!isStaleCtxError(error)) return undefined;
|
|
879
|
+
return {
|
|
880
|
+
failure: "interrupted by a session reload or replacement; the agent's model runtime went stale and retries did not recover it",
|
|
881
|
+
partial: partialProgress(session, startIndex),
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
|
|
804
885
|
/** The per-run callbacks a spawn and a resume share, plus the resolved turn limit. */
|
|
805
886
|
type RunObserverOptions = Pick<RunOptions, "onTurnEnd" | "onTextDelta" | "onModelCall" | "onToolActivity" | "onAssistantUsage" | "onCompaction"> & {
|
|
806
887
|
/** Already resolved: undefined = unlimited. */
|
|
@@ -823,9 +904,8 @@ function observeRun(session: AgentSession, options: RunObserverOptions) {
|
|
|
823
904
|
steered: false,
|
|
824
905
|
aborted: false,
|
|
825
906
|
finalizing: false,
|
|
826
|
-
unsubscribe: () => {},
|
|
827
907
|
};
|
|
828
|
-
|
|
908
|
+
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
|
|
829
909
|
if (event.type === "turn_end") {
|
|
830
910
|
turnCount++;
|
|
831
911
|
options.onTurnEnd?.(turnCount);
|
|
@@ -874,7 +954,8 @@ function observeRun(session: AgentSession, options: RunObserverOptions) {
|
|
|
874
954
|
options.onCompaction?.({ reason: event.reason, tokensBefore: event.result.tokensBefore });
|
|
875
955
|
}
|
|
876
956
|
});
|
|
877
|
-
|
|
957
|
+
// Keep the live state object captured by the listener, adding only the real cleanup.
|
|
958
|
+
return Object.assign(run, { unsubscribe });
|
|
878
959
|
}
|
|
879
960
|
|
|
880
961
|
/**
|
|
@@ -888,9 +969,9 @@ function forwardAbortSignal(session: AgentSession, signal?: AbortSignal): () =>
|
|
|
888
969
|
return () => signal.removeEventListener("abort", onAbort);
|
|
889
970
|
}
|
|
890
971
|
|
|
891
|
-
function resolveConfiguredSessionDir(sessionDir: string | undefined, cwd: string): string | undefined {
|
|
972
|
+
function resolveConfiguredSessionDir(sessionDir: string | undefined, cwd: string, homeDir = homedir()): string | undefined {
|
|
892
973
|
if (!sessionDir) return undefined;
|
|
893
|
-
if (sessionDir === "~" || sessionDir.startsWith("~/")) return resolve(
|
|
974
|
+
if (sessionDir === "~" || sessionDir.startsWith("~/")) return resolve(homeDir, sessionDir.slice(2));
|
|
894
975
|
if (isAbsolute(sessionDir)) return sessionDir;
|
|
895
976
|
return resolve(cwd, sessionDir);
|
|
896
977
|
}
|
|
@@ -932,7 +1013,7 @@ export async function runAgent(
|
|
|
932
1013
|
|
|
933
1014
|
// Skill preloading: when skills is string[], preload their content into prompt
|
|
934
1015
|
if (Array.isArray(skills)) {
|
|
935
|
-
const loaded = preloadSkills(skills, configCwd);
|
|
1016
|
+
const loaded = preloadSkills(skills, configCwd, options.homeDir);
|
|
936
1017
|
if (loaded.length > 0) {
|
|
937
1018
|
extras.skillBlocks = loaded;
|
|
938
1019
|
}
|
|
@@ -952,12 +1033,12 @@ export async function runAgent(
|
|
|
952
1033
|
// Read-write memory: add any missing memory tool names (read/write/edit)
|
|
953
1034
|
const extraNames = getMemoryToolNames(existingNames);
|
|
954
1035
|
if (extraNames.length > 0) toolNames = [...toolNames, ...extraNames];
|
|
955
|
-
extras.memoryBlock = buildMemoryBlock(agentConfig.name, agentConfig.memory, configCwd);
|
|
1036
|
+
extras.memoryBlock = buildMemoryBlock(agentConfig.name, agentConfig.memory, configCwd, options.homeDir);
|
|
956
1037
|
} else {
|
|
957
1038
|
// Read-only memory: only add read tool name, use read-only prompt
|
|
958
1039
|
const extraNames = getReadOnlyMemoryToolNames(existingNames);
|
|
959
1040
|
if (extraNames.length > 0) toolNames = [...toolNames, ...extraNames];
|
|
960
|
-
extras.memoryBlock = buildReadOnlyMemoryBlock(agentConfig.name, agentConfig.memory, configCwd);
|
|
1041
|
+
extras.memoryBlock = buildReadOnlyMemoryBlock(agentConfig.name, agentConfig.memory, configCwd, options.homeDir);
|
|
961
1042
|
}
|
|
962
1043
|
}
|
|
963
1044
|
|
|
@@ -993,7 +1074,7 @@ export async function runAgent(
|
|
|
993
1074
|
// Still pass noSkills: true since we don't need the skill loader to load them again.
|
|
994
1075
|
const noSkills = skills === false || Array.isArray(skills);
|
|
995
1076
|
|
|
996
|
-
const agentDir =
|
|
1077
|
+
const agentDir = resolveAgentDir(options.homeDir);
|
|
997
1078
|
|
|
998
1079
|
// Extension loading:
|
|
999
1080
|
// - true → all default-discovered extensions
|
|
@@ -1018,7 +1099,7 @@ export async function runAgent(
|
|
|
1018
1099
|
const noExtensions = extensions === false;
|
|
1019
1100
|
|
|
1020
1101
|
const extensionsSpec = Array.isArray(extensions)
|
|
1021
|
-
? parseExtensionsSpec(extensions, configCwd)
|
|
1102
|
+
? parseExtensionsSpec(extensions, configCwd, options.homeDir)
|
|
1022
1103
|
: undefined;
|
|
1023
1104
|
const keepNames = extensionsSpec?.names ?? new Set<string>();
|
|
1024
1105
|
// `exclude_extensions:` is a denylist applied AFTER the include set — exclude wins.
|
|
@@ -1073,7 +1154,7 @@ export async function runAgent(
|
|
|
1073
1154
|
});
|
|
1074
1155
|
const nestedCallGate: NestedCallGate = {};
|
|
1075
1156
|
|
|
1076
|
-
const loader =
|
|
1157
|
+
const loader = createHomeResourceLoader({
|
|
1077
1158
|
settingsManager,
|
|
1078
1159
|
// pi's CLI hands its built-in extensions to the loader it builds; this one starts
|
|
1079
1160
|
// with none. Forwarded as `builtin:` resources they load as in the main session —
|
|
@@ -1105,7 +1186,7 @@ export async function runAgent(
|
|
|
1105
1186
|
noContextFiles: true,
|
|
1106
1187
|
systemPromptOverride: () => systemPrompt,
|
|
1107
1188
|
appendSystemPromptOverride: () => [],
|
|
1108
|
-
});
|
|
1189
|
+
}, options.homeDir);
|
|
1109
1190
|
await runInChildSessionContext(() => loader.reload());
|
|
1110
1191
|
|
|
1111
1192
|
// Plain entries in `tools:` are expected to be built-in names (extension tools
|
|
@@ -1177,10 +1258,8 @@ export async function runAgent(
|
|
|
1177
1258
|
}
|
|
1178
1259
|
}
|
|
1179
1260
|
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
ctx.model, ctx.modelRegistry, agentConfig?.model,
|
|
1183
|
-
);
|
|
1261
|
+
const model = options.modelSelection.model;
|
|
1262
|
+
if (options.modelSelection.fallback) options.onModelFallback?.(options.modelSelection.fallback);
|
|
1184
1263
|
|
|
1185
1264
|
// Resolve thinking level: explicit option > agent config > undefined (inherit)
|
|
1186
1265
|
const thinkingLevel = options.thinkingLevel ?? agentConfig?.thinking;
|
|
@@ -1201,6 +1280,7 @@ export async function runAgent(
|
|
|
1201
1280
|
depth: nestedRuntime.depth,
|
|
1202
1281
|
maxSubagentDepth: effectiveMaxDepth,
|
|
1203
1282
|
allowedSubagents: agentConfig.allowedSubagents,
|
|
1283
|
+
homeDir: options.homeDir,
|
|
1204
1284
|
configCwd,
|
|
1205
1285
|
projectTrusted,
|
|
1206
1286
|
})
|
|
@@ -1301,8 +1381,17 @@ export async function runAgent(
|
|
|
1301
1381
|
sessionExcludeTools = [...denyTools];
|
|
1302
1382
|
}
|
|
1303
1383
|
|
|
1304
|
-
const configuredSessionDir = resolveConfiguredSessionDir(agentConfig?.sessionDir, effectiveCwd);
|
|
1305
|
-
const
|
|
1384
|
+
const configuredSessionDir = resolveConfiguredSessionDir(agentConfig?.sessionDir, effectiveCwd, options.homeDir);
|
|
1385
|
+
const sessionDirSetting = process.env.PI_CODING_AGENT_SESSION_DIR ?? settingsManager.getSessionDir?.();
|
|
1386
|
+
// SessionManager's implicit default consults the host home again. Pin the
|
|
1387
|
+
// same cwd encoding under our injected agentDir instead when isolating a run.
|
|
1388
|
+
// SDK settings go straight through SessionManager's native normalization;
|
|
1389
|
+
// unlike frontmatter, file URLs expand and relative settings stay relative.
|
|
1390
|
+
const defaultSessionDir = options.homeDir === undefined
|
|
1391
|
+
? sessionDirSetting
|
|
1392
|
+
: sessionDirSetting
|
|
1393
|
+
? normalizeHomePath(sessionDirSetting, options.homeDir)
|
|
1394
|
+
: join(resolve(agentDir), "sessions", `--${resolve(effectiveCwd).replace(/^[/\\\\]/, "").replace(/[/\\\\:]/g, "-")}--`);
|
|
1306
1395
|
// Frontmatter wins when it says anything; otherwise the project default,
|
|
1307
1396
|
// which `rememberAgents` supplies for top-level agents only. Same precedence
|
|
1308
1397
|
// as `outputTranscript`.
|
|
@@ -1417,12 +1506,13 @@ export async function runAgent(
|
|
|
1417
1506
|
// pi's `session.abort()` is a no-op until a run is active — so a child whose
|
|
1418
1507
|
// parent is already gone is not prompted at all.
|
|
1419
1508
|
if (options.signal?.aborted !== true) await session.prompt(effectivePrompt);
|
|
1509
|
+
await retryStaleCtx(session, startLen, options.signal, options.staleRetryDelaysMs);
|
|
1420
1510
|
|
|
1421
1511
|
// A cap fired. The wrap-up steer is only a request: a model deep in a tool
|
|
1422
1512
|
// loop keeps calling tools through the grace turns and is then aborted
|
|
1423
1513
|
// mid-turn, leaving its last narration as the "result". Demand the report
|
|
1424
1514
|
// in a turn that cannot call tools.
|
|
1425
|
-
if (run.steered && options.signal
|
|
1515
|
+
if (run.steered && mayFollowUp(session, startLen, options.signal) && !endedWithReport(session, startLen)) {
|
|
1426
1516
|
run.finalizing = true;
|
|
1427
1517
|
const reported = await forceFinalReport(session, startLen, options.signal);
|
|
1428
1518
|
// A delivered report is a wrap-up, not a failure: the agent did hand back
|
|
@@ -1436,7 +1526,7 @@ export async function runAgent(
|
|
|
1436
1526
|
// the abort forwarding are still live: torn down first, a retry would be
|
|
1437
1527
|
// unkillable.
|
|
1438
1528
|
if (structuredCapture !== undefined && structuredCapture.json === undefined
|
|
1439
|
-
&& !run.aborted && options.signal
|
|
1529
|
+
&& !run.aborted && mayFollowUp(session, startLen, options.signal)) {
|
|
1440
1530
|
structuredRetried = true;
|
|
1441
1531
|
await session.prompt(structuredRetryPrompt(structuredCapture));
|
|
1442
1532
|
}
|
|
@@ -1446,7 +1536,8 @@ export async function runAgent(
|
|
|
1446
1536
|
cleanupAbort();
|
|
1447
1537
|
}
|
|
1448
1538
|
|
|
1449
|
-
const
|
|
1539
|
+
const stale = staleCtxOutcome(session, startLen);
|
|
1540
|
+
const responseText = collector.getText().trim() || getLastAssistantText(session, startLen) || stale?.partial || "";
|
|
1450
1541
|
// A child asked for structured output that never gave any has failed, however
|
|
1451
1542
|
// articulate its prose was. Reported through `failure` so it travels the same
|
|
1452
1543
|
// path as a provider error rather than arriving as a successful empty answer.
|
|
@@ -1460,7 +1551,7 @@ export async function runAgent(
|
|
|
1460
1551
|
session,
|
|
1461
1552
|
aborted: run.aborted,
|
|
1462
1553
|
steered: run.steered,
|
|
1463
|
-
failure: finalTurnError(session, startLen) ?? structuredFailure,
|
|
1554
|
+
failure: stale?.failure ?? finalTurnError(session, startLen) ?? structuredFailure,
|
|
1464
1555
|
...(structuredCapture?.json !== undefined ? { structuredJson: structuredCapture.json } : {}),
|
|
1465
1556
|
...(structuredRetried ? { structuredRetried } : {}),
|
|
1466
1557
|
};
|
|
@@ -1475,7 +1566,7 @@ export async function runAgent(
|
|
|
1475
1566
|
export async function resumeAgent(
|
|
1476
1567
|
session: AgentSession,
|
|
1477
1568
|
prompt: string,
|
|
1478
|
-
options: Pick<RunOptions, "maxTurns" | "onTurnEnd" | "onTextDelta" | "onModelCall" | "onToolActivity" | "onAssistantUsage" | "onCompaction" | "signal"> & {
|
|
1569
|
+
options: Pick<RunOptions, "staleRetryDelaysMs" | "maxTurns" | "onTurnEnd" | "onTextDelta" | "onModelCall" | "onToolActivity" | "onAssistantUsage" | "onCompaction" | "signal"> & {
|
|
1479
1570
|
/** The agent's type, for its own `max_turns`. */
|
|
1480
1571
|
type: SubagentType;
|
|
1481
1572
|
},
|
|
@@ -1491,7 +1582,8 @@ export async function resumeAgent(
|
|
|
1491
1582
|
try {
|
|
1492
1583
|
// Already aborted: not prompted, as in `runAgent`.
|
|
1493
1584
|
if (options.signal?.aborted !== true) await session.prompt(prompt);
|
|
1494
|
-
|
|
1585
|
+
await retryStaleCtx(session, startLen, options.signal, options.staleRetryDelaysMs);
|
|
1586
|
+
if (run.steered && mayFollowUp(session, startLen, options.signal) && !endedWithReport(session, startLen)) {
|
|
1495
1587
|
run.finalizing = true;
|
|
1496
1588
|
if (await forceFinalReport(session, startLen, options.signal)) run.aborted = false;
|
|
1497
1589
|
}
|
|
@@ -1501,9 +1593,10 @@ export async function resumeAgent(
|
|
|
1501
1593
|
cleanupAbort();
|
|
1502
1594
|
}
|
|
1503
1595
|
|
|
1596
|
+
const stale = staleCtxOutcome(session, startLen);
|
|
1504
1597
|
return {
|
|
1505
|
-
text: collector.getText().trim() || getLastAssistantText(session, startLen),
|
|
1506
|
-
failure: finalTurnError(session, startLen),
|
|
1598
|
+
text: collector.getText().trim() || getLastAssistantText(session, startLen) || stale?.partial || "",
|
|
1599
|
+
failure: stale?.failure ?? finalTurnError(session, startLen),
|
|
1507
1600
|
aborted: run.aborted,
|
|
1508
1601
|
steered: run.steered,
|
|
1509
1602
|
};
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { isTopLevelAgent } from "./agent-manager.js";
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
17
|
+
import type { ModelRegistry } from "./model-resolver.js";
|
|
18
|
+
import { selectModel } from "./model-selection.js";
|
|
19
19
|
import type { AgentRecord } from "./types.js";
|
|
20
20
|
|
|
21
21
|
/** Minimal event bus interface needed by the RPC handlers. */
|
|
@@ -58,6 +58,7 @@ export interface SpawnCapable {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
export interface RpcDeps {
|
|
61
|
+
homeDir?: string;
|
|
61
62
|
events: EventBus;
|
|
62
63
|
pi: unknown; // passed through to manager.spawn
|
|
63
64
|
getCtx: () => unknown | undefined; // returns current ExtensionContext
|
|
@@ -112,13 +113,9 @@ export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
|
|
|
112
113
|
const ctx = getCtx();
|
|
113
114
|
if (!ctx) throw new Error("No active session");
|
|
114
115
|
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
// — same pattern the scheduler path already uses — so the spawned
|
|
119
|
-
// agent's auth lookup doesn't crash with "No API key found for
|
|
120
|
-
// undefined".
|
|
121
|
-
let normalizedOptions = options ?? {};
|
|
116
|
+
// Validate serializable overrides through the shared selector. Preserve
|
|
117
|
+
// the raw request for spawn, which records its source and outcome.
|
|
118
|
+
const normalizedOptions = options ?? {};
|
|
122
119
|
// `!= null` on purpose: a JSON-forwarding caller can serialize an unset
|
|
123
120
|
// field as null, and the runner reads `options.model ?? default`, so null
|
|
124
121
|
// means "inherit" — not an override to resolve or scope-check.
|
|
@@ -131,34 +128,12 @@ export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
|
|
|
131
128
|
if (!modelRegistry) {
|
|
132
129
|
throw new Error(`Model override "${label}" provided but ctx.modelRegistry is unavailable`);
|
|
133
130
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
// resolveModel returns a human-readable error string when the
|
|
139
|
-
// input doesn't match any available model. Surface it instead of
|
|
140
|
-
// silently falling back so the caller sees the auth/typo issue.
|
|
141
|
-
throw new Error(resolved);
|
|
142
|
-
}
|
|
143
|
-
model = resolved;
|
|
144
|
-
normalizedOptions = { ...normalizedOptions, model: resolved };
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// A model on the RPC payload is an orchestrator-level choice, exactly
|
|
148
|
-
// like Agent({ model }) — so it gets the Agent tool's hard error, never
|
|
149
|
-
// the frontmatter warn (#240). The check reads the RESOLVED model:
|
|
150
|
-
// resolveModel is fuzzy, so a bare "sonnet" can land on a provider the
|
|
151
|
-
// caller never named. Frontmatter-pinned and parent-inherited models are
|
|
152
|
-
// resolved later, in agent-runner, and keep warn-and-proceed.
|
|
153
|
-
const verdict = checkModelScope({
|
|
154
|
-
model,
|
|
155
|
-
cwd: cwd ?? process.cwd(),
|
|
156
|
-
modelRegistry,
|
|
157
|
-
callerSupplied: true,
|
|
158
|
-
agentLabel: type,
|
|
159
|
-
modelInput: label,
|
|
131
|
+
const selection = selectModel({
|
|
132
|
+
homeDir: deps.homeDir,
|
|
133
|
+
agentType: type, paramModel: override, parentModel: undefined,
|
|
134
|
+
registry: modelRegistry, cwd: cwd ?? process.cwd(),
|
|
160
135
|
});
|
|
161
|
-
if (
|
|
136
|
+
if (typeof selection === "string") throw new Error(selection);
|
|
162
137
|
}
|
|
163
138
|
|
|
164
139
|
const id = manager.spawn(pi, ctx, type, prompt, normalizedOptions);
|
package/src/custom-agents.ts
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
6
6
|
import { basename, join } from "node:path";
|
|
7
|
-
import {
|
|
7
|
+
import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { BUILTIN_TOOL_NAMES } from "./agent-types.js";
|
|
9
|
+
import { resolveAgentDir } from "./home-paths.js";
|
|
9
10
|
import {
|
|
10
11
|
isPackageResourceExcluded,
|
|
11
12
|
isProjectTrusted,
|
|
@@ -55,6 +56,8 @@ export interface LoadCustomAgentsOptions {
|
|
|
55
56
|
* answer is recorded.
|
|
56
57
|
*/
|
|
57
58
|
projectTrusted?: boolean;
|
|
59
|
+
/** Home directory for the default global agent directory. Defaults at call time. */
|
|
60
|
+
homeDir?: string;
|
|
58
61
|
}
|
|
59
62
|
|
|
60
63
|
/**
|
|
@@ -94,16 +97,17 @@ export function loadCustomAgents(
|
|
|
94
97
|
strict = false,
|
|
95
98
|
opts: LoadCustomAgentsOptions = {},
|
|
96
99
|
): Map<string, AgentConfig> {
|
|
97
|
-
const globalDir = join(
|
|
100
|
+
const globalDir = join(resolveAgentDir(opts.homeDir), "agents");
|
|
98
101
|
const workspaceProjectDir = join(cwd, ".agents", "agents");
|
|
99
102
|
const projectDir = join(cwd, ".pi", "agents");
|
|
100
103
|
|
|
101
104
|
const projectTrusted = opts.projectTrusted ?? isProjectTrusted();
|
|
102
|
-
const
|
|
103
|
-
const
|
|
105
|
+
const discovery = { projectTrusted, homeDir: opts.homeDir };
|
|
106
|
+
const pkgDirs = opts.packageDirs ?? packageAgentDirs(cwd, discovery);
|
|
107
|
+
const pkgFiles = opts.packageFiles ?? packageAgentFiles(cwd, discovery);
|
|
104
108
|
// `!./agents/wip.md` alongside `./agents` excludes one file out of a directory
|
|
105
109
|
// that is only enumerated here, so the check has to run per file.
|
|
106
|
-
const excluded: Excluder = opts.packageExcluded ?? (path => isPackageResourceExcluded(path, cwd, "agents",
|
|
110
|
+
const excluded: Excluder = opts.packageExcluded ?? (path => isPackageResourceExcluded(path, cwd, "agents", discovery));
|
|
107
111
|
|
|
108
112
|
const agents = new Map<string, AgentConfig>();
|
|
109
113
|
for (const dir of pkgDirs) loadFromDir(dir, agents, "package", strict, excluded); // lowest priority
|
package/src/default-agents.ts
CHANGED
|
@@ -34,10 +34,8 @@ export const DEFAULT_AGENTS: Map<string, AgentConfig> = new Map([
|
|
|
34
34
|
builtinToolNames: READ_ONLY_TOOLS,
|
|
35
35
|
extensions: true,
|
|
36
36
|
skills: true,
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
// matching enabled model under any provider.
|
|
40
|
-
model: "claude-haiku-4-5",
|
|
37
|
+
// Bind the role per machine, not to a provider or model generation.
|
|
38
|
+
model: "@cheap",
|
|
41
39
|
systemPrompt: `# CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS
|
|
42
40
|
You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
|
|
43
41
|
Your role is EXCLUSIVELY to search and analyze existing code. You do NOT have access to file editing tools.
|
package/src/enabled-models.ts
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
|
|
29
29
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
30
30
|
import { join } from "node:path";
|
|
31
|
-
import {
|
|
31
|
+
import { resolveAgentDir } from "./home-paths.js";
|
|
32
32
|
import type { ModelEntry } from "./model-resolver.js";
|
|
33
33
|
|
|
34
34
|
/** Minimal registry shape — only the methods resolveEnabledModels actually calls. */
|
|
@@ -38,10 +38,10 @@ export interface ModelRegistryRef {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
/** Paths to pi's settings.json files: [project, global] (project takes precedence). */
|
|
41
|
-
function settingsPaths(cwd: string): [project: string, global: string] {
|
|
41
|
+
function settingsPaths(cwd: string, homeDir?: string): [project: string, global: string] {
|
|
42
42
|
return [
|
|
43
43
|
join(cwd, ".pi", "settings.json"),
|
|
44
|
-
join(
|
|
44
|
+
join(resolveAgentDir(homeDir), "settings.json"),
|
|
45
45
|
];
|
|
46
46
|
}
|
|
47
47
|
|
|
@@ -63,8 +63,8 @@ function readField(path: string): string[] | undefined {
|
|
|
63
63
|
* (and matches our own loadSettings precedence in src/settings.ts).
|
|
64
64
|
* Returns undefined when neither file has the field.
|
|
65
65
|
*/
|
|
66
|
-
export function readEnabledModels(cwd: string): string[] | undefined {
|
|
67
|
-
const [project, global] = settingsPaths(cwd);
|
|
66
|
+
export function readEnabledModels(cwd: string, homeDir?: string): string[] | undefined {
|
|
67
|
+
const [project, global] = settingsPaths(cwd, homeDir);
|
|
68
68
|
return readField(project) ?? readField(global);
|
|
69
69
|
}
|
|
70
70
|
|
|
@@ -102,11 +102,12 @@ export function resolveEnabledModels(
|
|
|
102
102
|
patterns: string[] | undefined,
|
|
103
103
|
registry: ModelRegistryRef,
|
|
104
104
|
cwd: string = process.cwd(),
|
|
105
|
+
homeDir?: string,
|
|
105
106
|
): Set<string> | undefined {
|
|
106
107
|
// Fast path: check cache (stat both project and global settings.json files)
|
|
107
108
|
const patternsKey = JSON.stringify(patterns);
|
|
108
|
-
const [project, global] = settingsPaths(cwd);
|
|
109
|
-
const fileHash = `${hashOf(project)};${hashOf(global)}`;
|
|
109
|
+
const [project, global] = settingsPaths(cwd, homeDir);
|
|
110
|
+
const fileHash = `${project}:${hashOf(project)};${global}:${hashOf(global)}`;
|
|
110
111
|
|
|
111
112
|
if (fileHash === cachedHash && patternsKey === cachedPatternsKey) {
|
|
112
113
|
return cachedAllowed;
|
package/src/enterprise-local.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
10
10
|
import { join } from "node:path";
|
|
11
|
-
import {
|
|
11
|
+
import { resolveAgentDir } from "./home-paths.js";
|
|
12
12
|
|
|
13
13
|
const MANIFEST_VERSION = 1;
|
|
14
14
|
const MANIFEST_NAME = "enterprise.local.json";
|
|
@@ -21,16 +21,16 @@ export interface EnterpriseLocalManifest {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
/** Return the target-local manifest path without reading it. */
|
|
24
|
-
export function enterpriseLocalPath(): string {
|
|
25
|
-
return join(
|
|
24
|
+
export function enterpriseLocalPath(homeDir?: string): string {
|
|
25
|
+
return join(resolveAgentDir(homeDir), MANIFEST_NAME);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
29
|
* Read and minimally validate the machine-local manifest. It never exposes its
|
|
30
30
|
* contents in diagnostics: providers can include command-backed credentials.
|
|
31
31
|
*/
|
|
32
|
-
export function readEnterpriseLocalManifest(): EnterpriseLocalManifest | undefined {
|
|
33
|
-
const path = enterpriseLocalPath();
|
|
32
|
+
export function readEnterpriseLocalManifest(homeDir?: string): EnterpriseLocalManifest | undefined {
|
|
33
|
+
const path = enterpriseLocalPath(homeDir);
|
|
34
34
|
if (!existsSync(path)) return undefined;
|
|
35
35
|
|
|
36
36
|
try {
|