@nredd/pi-subagents 0.22.0-nredd.7 → 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 +245 -77
- package/package.json +16 -13
- package/src/abortable.ts +1 -1
- package/src/agent-file-toggle.ts +6 -5
- package/src/agent-manager.ts +489 -129
- package/src/agent-runner.ts +244 -93
- package/src/child-context.ts +19 -1
- package/src/completion-outbox.ts +22 -3
- package/src/cross-extension-rpc.ts +21 -42
- package/src/custom-agents.ts +11 -6
- package/src/default-agents.ts +21 -21
- package/src/detached-results.ts +18 -2
- package/src/enabled-models.ts +8 -7
- package/src/enterprise-local.ts +7 -6
- package/src/error-message.ts +11 -0
- package/src/git-path.ts +66 -0
- package/src/group-join.ts +4 -10
- package/src/home-paths.ts +27 -0
- package/src/index.ts +898 -297
- package/src/invocation-config.ts +4 -21
- package/src/memory.ts +9 -9
- package/src/mention-clone.ts +13 -8
- package/src/model-resolver.ts +92 -25
- package/src/model-scope.ts +2 -1
- package/src/model-selection.ts +170 -0
- package/src/model-tiers.ts +176 -0
- package/src/nested-tools.ts +99 -44
- package/src/notification-result.ts +118 -0
- package/src/output-file.ts +23 -1
- package/src/package-resources.ts +25 -10
- package/src/plan-mode-children.ts +176 -0
- package/src/process-budget.ts +6 -3
- package/src/prompts.ts +7 -2
- package/src/quota-admission.ts +35 -13
- package/src/resource-loader.ts +65 -0
- package/src/schedule.ts +94 -30
- package/src/settings.ts +63 -13
- package/src/skill-loader.ts +244 -93
- package/src/stale-ctx.ts +16 -0
- package/src/status-note.ts +28 -5
- package/src/types.ts +79 -2
- package/src/ui/agent-mention.ts +5 -2
- package/src/ui/agent-row.ts +13 -3
- package/src/ui/agent-widget.ts +55 -25
- package/src/ui/conversation-viewer.ts +10 -5
- package/src/ui/fallback-flag.ts +114 -0
- package/src/ui/fleet-list.ts +431 -86
- package/src/ui/fleet-state.ts +154 -0
- package/src/ui/notification-view.ts +22 -3
- package/src/ui/workflow-card.ts +6 -9
- package/src/ui/workflow-dialog.ts +36 -13
- package/src/workflow/host.ts +47 -50
- package/src/workflow/journal.ts +13 -1
- package/src/workflow/meta.ts +7 -4
- package/src/workflow/progress.ts +8 -3
- package/src/workflow/runtime.ts +48 -15
- package/src/workflow/saved.ts +21 -21
- package/src/workflow/task.ts +22 -7
- package/src/workflow/tool-description.ts +2 -2
- package/src/workflow/worker-source.ts +162 -40
- package/src/worktree.ts +309 -19
package/src/agent-runner.ts
CHANGED
|
@@ -14,31 +14,38 @@ 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,
|
|
24
23
|
} from "@earendil-works/pi-coding-agent";
|
|
25
24
|
import { Type } from "typebox";
|
|
26
25
|
import { BUILTIN_TOOL_NAMES, getAgentConfig, getConfig, getMemoryToolNames, getReadOnlyMemoryToolNames, getToolNamesForType } from "./agent-types.js";
|
|
27
|
-
import { runInChildSessionContext } from "./child-context.js";
|
|
26
|
+
import { bindChildResourceLoader, runInChildSessionContext } from "./child-context.js";
|
|
27
|
+
import { type Clock, realClock } from "./clock.js";
|
|
28
28
|
import { buildParentContext, extractText } from "./context.js";
|
|
29
29
|
import { DEFAULT_AGENTS } from "./default-agents.js";
|
|
30
30
|
import { detectEnv } from "./env.js";
|
|
31
|
+
import { normalizeHomePath, resolveAgentDir } from "./home-paths.js";
|
|
31
32
|
import { buildMemoryBlock, buildReadOnlyMemoryBlock } from "./memory.js";
|
|
32
33
|
import type { ModelCallEvent } from "./model-call.js";
|
|
34
|
+
import type { ModelFallback } from "./model-resolver.js";
|
|
35
|
+
import type { ModelSelection } from "./model-selection.js";
|
|
33
36
|
import {
|
|
34
37
|
createNestedSubagentTools,
|
|
35
38
|
getMaxSubagentDepth,
|
|
39
|
+
hasNestableTypes,
|
|
36
40
|
NESTED_WORKFLOW_ERROR,
|
|
37
41
|
NESTED_WORKFLOW_NO_AGENT_ERROR,
|
|
38
42
|
type NestedAgentManager,
|
|
39
43
|
} from "./nested-tools.js";
|
|
44
|
+
import { type PlanGate, planGateReason } from "./plan-mode-children.js";
|
|
40
45
|
import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
|
|
46
|
+
import { createHomeResourceLoader } from "./resource-loader.js";
|
|
41
47
|
import { preloadSkills } from "./skill-loader.js";
|
|
48
|
+
import { isStaleCtxError } from "./stale-ctx.js";
|
|
42
49
|
import { createStructuredCapture, createStructuredOutputTool, structuredRetryPrompt } from "./structured-output.js";
|
|
43
50
|
import type { SubagentType, ThinkingLevel } from "./types.js";
|
|
44
51
|
import type { LifetimeUsage } from "./usage.js";
|
|
@@ -55,6 +62,7 @@ export const SUBAGENT_TOOL_NAMES = {
|
|
|
55
62
|
WORKFLOW: "SubagentWorkflow",
|
|
56
63
|
GET_RESULT: "get_subagent_result",
|
|
57
64
|
STEER: "steer_subagent",
|
|
65
|
+
STOP: "stop_subagent",
|
|
58
66
|
} as const;
|
|
59
67
|
|
|
60
68
|
/**
|
|
@@ -187,6 +195,7 @@ export function extensionCanonicalNames(extPath: string): string[] {
|
|
|
187
195
|
export function parseExtensionsSpec(
|
|
188
196
|
entries: string[],
|
|
189
197
|
cwd: string,
|
|
198
|
+
homeDir = homedir(),
|
|
190
199
|
): { names: Set<string>; paths: string[]; wildcard: boolean } {
|
|
191
200
|
const names = new Set<string>();
|
|
192
201
|
const paths: string[] = [];
|
|
@@ -202,10 +211,10 @@ export function parseExtensionsSpec(
|
|
|
202
211
|
names.add(entry.toLowerCase());
|
|
203
212
|
continue;
|
|
204
213
|
}
|
|
214
|
+
// This frontmatter caller historically expands both slash forms on every
|
|
215
|
+
// platform, without interpreting file URLs or Windows shell drive forms.
|
|
205
216
|
let p = entry;
|
|
206
|
-
if (p === "~" || p.startsWith("~/") || p.startsWith("~\\"))
|
|
207
|
-
p = homedir() + p.slice(1);
|
|
208
|
-
}
|
|
217
|
+
if (p === "~" || p.startsWith("~/") || p.startsWith("~\\")) p = homeDir + p.slice(1);
|
|
209
218
|
const abs = isAbsolute(p) ? p : resolve(cwd, p);
|
|
210
219
|
paths.push(abs);
|
|
211
220
|
names.add(extensionCanonicalName(abs));
|
|
@@ -297,11 +306,10 @@ export function installExtensionToolScope(
|
|
|
297
306
|
/** Receives the scope check the child's nested-call gate extension applies. */
|
|
298
307
|
nestedCallGate: NestedCallGate;
|
|
299
308
|
/** Why workflow tools are refused. */
|
|
300
|
-
workflowError
|
|
309
|
+
workflowError: string;
|
|
301
310
|
},
|
|
302
311
|
): void {
|
|
303
|
-
const { loader, toolNames, disallowedSet, extNames, narrowing, readmitToolNames, nestedCallGate } = ctx;
|
|
304
|
-
const workflowError = ctx.workflowError ?? NESTED_WORKFLOW_ERROR;
|
|
312
|
+
const { loader, toolNames, disallowedSet, extNames, narrowing, readmitToolNames, nestedCallGate, workflowError } = ctx;
|
|
305
313
|
|
|
306
314
|
// The names allowed right now. Mirrors the `ext:` opt-in flip: when any `ext:`
|
|
307
315
|
// selector is present, extension tools become an explicit allowlist — a loaded
|
|
@@ -389,8 +397,14 @@ export function installExtensionToolScope(
|
|
|
389
397
|
};
|
|
390
398
|
}
|
|
391
399
|
|
|
392
|
-
/**
|
|
393
|
-
|
|
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
|
+
};
|
|
394
408
|
|
|
395
409
|
/**
|
|
396
410
|
* Inline extension that applies the subagent's tool scope to calls a tool makes
|
|
@@ -406,11 +420,13 @@ function scopeGateExtension(gate: NestedCallGate): InlineExtension {
|
|
|
406
420
|
name: SCOPE_GATE_NAME,
|
|
407
421
|
hidden: true,
|
|
408
422
|
factory: (ext) => {
|
|
409
|
-
ext.on("tool_call", (event) =>
|
|
410
|
-
gate.allows?.(event.toolName) === false
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
+
});
|
|
414
430
|
},
|
|
415
431
|
};
|
|
416
432
|
}
|
|
@@ -474,37 +490,6 @@ const FINAL_REPORT_PROMPT =
|
|
|
474
490
|
+ "Write your complete final report now: everything you found, concrete file paths and line numbers, "
|
|
475
491
|
+ "what you changed or concluded, and anything unfinished or unverified.";
|
|
476
492
|
|
|
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
493
|
/** Info about a tool event in the subagent. */
|
|
509
494
|
export interface ToolActivity {
|
|
510
495
|
type: "start" | "end";
|
|
@@ -519,11 +504,17 @@ export interface AssistantMessageInfo {
|
|
|
519
504
|
}
|
|
520
505
|
|
|
521
506
|
export interface RunOptions {
|
|
507
|
+
/** Backoff before each stale-ctx re-prompt (#145); defaults to 1s, 3s, 9s. `[]` disables recovery. */
|
|
508
|
+
staleRetryDelaysMs?: readonly number[];
|
|
509
|
+
/** Clock that times the stale-ctx backoff; the manager passes its own. Defaults to native timers. */
|
|
510
|
+
clock?: Clock;
|
|
522
511
|
/** ExtensionAPI instance — used for pi.exec() instead of execSync. */
|
|
523
512
|
pi: ExtensionAPI;
|
|
524
513
|
/** Manager-assigned id; suffixes session name to disambiguate parallel spawns (e.g. `Explore#a1b2c3d4`). */
|
|
525
514
|
agentId?: string;
|
|
526
515
|
model?: Model<any>;
|
|
516
|
+
/** Required admitted spawn outcome. The runner never resolves or admits models. */
|
|
517
|
+
modelSelection: ModelSelection;
|
|
527
518
|
maxTurns?: number;
|
|
528
519
|
signal?: AbortSignal;
|
|
529
520
|
/** Applies only when the agent file leaves `isolated:` unset — frontmatter is authoritative. */
|
|
@@ -557,6 +548,8 @@ export interface RunOptions {
|
|
|
557
548
|
workflow?: boolean;
|
|
558
549
|
/** Override working directory (e.g. for worktree isolation). */
|
|
559
550
|
cwd?: string;
|
|
551
|
+
/** Home directory for tilde expansion and user-scoped storage. Defaults at run time. */
|
|
552
|
+
homeDir?: string;
|
|
560
553
|
/**
|
|
561
554
|
* Directory the worktree copy was created from. Set only when `cwd` points
|
|
562
555
|
* into a worktree — the prompt then tells the agent to stay in the copy
|
|
@@ -592,6 +585,8 @@ export interface RunOptions {
|
|
|
592
585
|
/** Called for every streaming event of each model call, so a UI can say what an in-flight call is doing. */
|
|
593
586
|
onModelCall?: (event: ModelCallEvent) => void;
|
|
594
587
|
onSessionCreated?: (session: AgentSession) => void;
|
|
588
|
+
/** Direct runner callers can observe the same configured fallback as the shared spawn. */
|
|
589
|
+
onModelFallback?: (fallback: ModelFallback) => void;
|
|
595
590
|
/** Called at the end of each agentic turn with the cumulative count. */
|
|
596
591
|
onTurnEnd?: (turnCount: number) => void;
|
|
597
592
|
/**
|
|
@@ -618,6 +613,11 @@ export interface RunOptions {
|
|
|
618
613
|
* fails at the call that wrote it rather than inside the child.
|
|
619
614
|
*/
|
|
620
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;
|
|
621
621
|
/** Runtime bridge for opt-in child-safe nested delegation. */
|
|
622
622
|
nestedRuntime?: {
|
|
623
623
|
manager: NestedAgentManager;
|
|
@@ -664,28 +664,40 @@ export interface RunResult {
|
|
|
664
664
|
*/
|
|
665
665
|
function collectResponseText(session: AgentSession) {
|
|
666
666
|
let text = "";
|
|
667
|
+
let laterTool = false;
|
|
668
|
+
let previousText = "";
|
|
669
|
+
let previousLaterTool = false;
|
|
667
670
|
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
|
|
668
671
|
// message_start also fires for user and toolResult messages — resetting on
|
|
669
672
|
// those would wipe assistant text already collected. Reset only when a new
|
|
670
673
|
// ASSISTANT message begins, so getText() is the last assistant message's text.
|
|
671
674
|
if (event.type === "message_start" && event.message.role === "assistant") {
|
|
675
|
+
if (text.trim()) {
|
|
676
|
+
previousText = text;
|
|
677
|
+
previousLaterTool = laterTool;
|
|
678
|
+
}
|
|
672
679
|
text = "";
|
|
680
|
+
laterTool = false;
|
|
673
681
|
}
|
|
674
682
|
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
|
675
683
|
text += event.assistantMessageEvent.delta;
|
|
676
684
|
}
|
|
685
|
+
if (event.type === "tool_execution_end") {
|
|
686
|
+
if (text.trim()) laterTool = true;
|
|
687
|
+
else if (previousText.trim()) previousLaterTool = true;
|
|
688
|
+
}
|
|
677
689
|
});
|
|
678
|
-
return { getText: () => text, unsubscribe };
|
|
690
|
+
return { getText: () => text, getPreviousText: () => previousText, hasLaterTool: () => text.trim() ? laterTool : previousLaterTool, unsubscribe };
|
|
679
691
|
}
|
|
680
692
|
|
|
681
693
|
/**
|
|
682
694
|
* Get the last non-empty assistant text produced during THIS invocation.
|
|
683
695
|
* `startIndex` is the message count captured before the prompt, so the walk-back
|
|
684
696
|
* never crosses into a previous turn: on a resume whose new turn failed empty,
|
|
685
|
-
* this returns "" instead of the prior turn's answer (#144).
|
|
686
|
-
*
|
|
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.
|
|
687
699
|
*/
|
|
688
|
-
function getLastAssistantText(session: AgentSession, startIndex
|
|
700
|
+
function getLastAssistantText(session: AgentSession, startIndex: number): string {
|
|
689
701
|
for (let i = session.messages.length - 1; i >= startIndex; i--) {
|
|
690
702
|
const msg = session.messages[i];
|
|
691
703
|
if (msg.role !== "assistant") continue;
|
|
@@ -721,7 +733,7 @@ export function forwardModelCall(event: AgentSessionEvent, emit: (event: ModelCa
|
|
|
721
733
|
* no tool call pending, and a stop that was not an abort, a tool-use handoff or
|
|
722
734
|
* a token-limit cut. Anything else is a teaser or a cut-off report.
|
|
723
735
|
*/
|
|
724
|
-
function endedWithReport(session: AgentSession, startIndex
|
|
736
|
+
function endedWithReport(session: AgentSession, startIndex: number): boolean {
|
|
725
737
|
for (let i = session.messages.length - 1; i >= startIndex; i--) {
|
|
726
738
|
const msg = session.messages[i];
|
|
727
739
|
if (msg.role !== "assistant") continue;
|
|
@@ -786,7 +798,7 @@ async function forceFinalReport(session: AgentSession, startIndex: number, signa
|
|
|
786
798
|
* Bounded by `startIndex` (like the text fallback) so a resume that produced no
|
|
787
799
|
* assistant message of its own never inherits a PRIOR turn's stop reason.
|
|
788
800
|
*/
|
|
789
|
-
function finalTurnError(session: AgentSession, startIndex
|
|
801
|
+
function finalTurnError(session: AgentSession, startIndex: number): string | undefined {
|
|
790
802
|
for (let i = session.messages.length - 1; i >= startIndex; i--) {
|
|
791
803
|
const msg = session.messages[i];
|
|
792
804
|
if (msg.role !== "assistant") continue;
|
|
@@ -801,6 +813,111 @@ function finalTurnError(session: AgentSession, startIndex = 0): string | undefin
|
|
|
801
813
|
return undefined;
|
|
802
814
|
}
|
|
803
815
|
|
|
816
|
+
/** Waits before each re-prompt of a step lost to a stale ctx: a reload re-registers within seconds. */
|
|
817
|
+
const STALE_CTX_RETRY_DELAYS_MS: readonly number[] = [1_000, 3_000, 9_000];
|
|
818
|
+
|
|
819
|
+
/** Sent when the session's last step died on a stale ctx; the transcript already holds the work so far. */
|
|
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.";
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Resolve after `ms`, or as soon as `signal` aborts. Never rejects.
|
|
824
|
+
*/
|
|
825
|
+
function sleep(ms: number, signal: AbortSignal | undefined, clock: Clock): Promise<void> {
|
|
826
|
+
return new Promise((resolve) => {
|
|
827
|
+
if (signal?.aborted) return resolve();
|
|
828
|
+
const done = () => {
|
|
829
|
+
timer.cancel();
|
|
830
|
+
signal?.removeEventListener("abort", done);
|
|
831
|
+
resolve();
|
|
832
|
+
};
|
|
833
|
+
const timer = clock.setTimeout(done, ms);
|
|
834
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Re-prompt a session whose final step died on a stale extension ctx, backing
|
|
840
|
+
* off between attempts so a reload in progress can re-register its routes.
|
|
841
|
+
* Returns once the final step no longer fails that way, retries are spent, or
|
|
842
|
+
* `signal` aborts. A permanently dead runtime (the parent session was replaced)
|
|
843
|
+
* exhausts the retries and is reported by `staleCtxOutcome`.
|
|
844
|
+
*
|
|
845
|
+
* Why appending a continue prompt is safe, and re-sending the task is not: the
|
|
846
|
+
* failed step died in model routing or an observer, before the model produced
|
|
847
|
+
* anything, so the transcript already holds every tool call and result the run
|
|
848
|
+
* made. Re-sending the task would show the model the same work twice and invite
|
|
849
|
+
* it to repeat side effects (a push, a PR). The continue prompt adds one user
|
|
850
|
+
* turn and nothing else, so the tool batch is never replayed; a tool that did
|
|
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.
|
|
857
|
+
*/
|
|
858
|
+
export async function retryStaleCtx(
|
|
859
|
+
session: AgentSession,
|
|
860
|
+
startIndex: number,
|
|
861
|
+
signal: AbortSignal | undefined,
|
|
862
|
+
delays: readonly number[] = STALE_CTX_RETRY_DELAYS_MS,
|
|
863
|
+
clock: Clock = realClock,
|
|
864
|
+
): Promise<void> {
|
|
865
|
+
for (const delay of delays) {
|
|
866
|
+
if (!isStaleCtxError(finalTurnError(session, startIndex))) return;
|
|
867
|
+
await sleep(delay, signal, clock);
|
|
868
|
+
if (signal?.aborted) return;
|
|
869
|
+
await session.prompt(STALE_CTX_RETRY_PROMPT);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Whether a follow-up prompt (the structured-output retry, the forced final
|
|
875
|
+
* report) may still be sent. Not once the run was stopped, and not while the
|
|
876
|
+
* final step is still a stale ctx: the runtime is dead, so the prompt would
|
|
877
|
+
* fail the same way and mask the real outcome behind a doomed turn.
|
|
878
|
+
*/
|
|
879
|
+
function mayFollowUp(session: AgentSession, startIndex: number, signal: AbortSignal | undefined): boolean {
|
|
880
|
+
return !signal?.aborted && staleCtxOutcome(session, startIndex) === undefined;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/** Bounded activity only: tool arguments, results and unknown names are never copied. */
|
|
884
|
+
function partialProgress(session: AgentSession, startIndex: number): string {
|
|
885
|
+
const calls: string[] = [];
|
|
886
|
+
for (let i = session.messages.length - 1; i >= startIndex && calls.length < 5; i--) {
|
|
887
|
+
const msg = session.messages[i];
|
|
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"})`);
|
|
891
|
+
}
|
|
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;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* The failure and partial result for a run whose final step is still a stale
|
|
910
|
+
* ctx after retries; undefined for any other outcome.
|
|
911
|
+
*/
|
|
912
|
+
export function staleCtxOutcome(session: AgentSession, startIndex: number): { failure: string; partial: string } | undefined {
|
|
913
|
+
const error = finalTurnError(session, startIndex);
|
|
914
|
+
if (!isStaleCtxError(error)) return undefined;
|
|
915
|
+
return {
|
|
916
|
+
failure: `interrupted by a session reload or replacement; the agent's model runtime went stale and retries did not recover it: ${error}`,
|
|
917
|
+
partial: partialProgress(session, startIndex),
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
|
|
804
921
|
/** The per-run callbacks a spawn and a resume share, plus the resolved turn limit. */
|
|
805
922
|
type RunObserverOptions = Pick<RunOptions, "onTurnEnd" | "onTextDelta" | "onModelCall" | "onToolActivity" | "onAssistantUsage" | "onCompaction"> & {
|
|
806
923
|
/** Already resolved: undefined = unlimited. */
|
|
@@ -823,9 +940,8 @@ function observeRun(session: AgentSession, options: RunObserverOptions) {
|
|
|
823
940
|
steered: false,
|
|
824
941
|
aborted: false,
|
|
825
942
|
finalizing: false,
|
|
826
|
-
unsubscribe: () => {},
|
|
827
943
|
};
|
|
828
|
-
|
|
944
|
+
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
|
|
829
945
|
if (event.type === "turn_end") {
|
|
830
946
|
turnCount++;
|
|
831
947
|
options.onTurnEnd?.(turnCount);
|
|
@@ -874,7 +990,8 @@ function observeRun(session: AgentSession, options: RunObserverOptions) {
|
|
|
874
990
|
options.onCompaction?.({ reason: event.reason, tokensBefore: event.result.tokensBefore });
|
|
875
991
|
}
|
|
876
992
|
});
|
|
877
|
-
|
|
993
|
+
// Keep the live state object captured by the listener, adding only the real cleanup.
|
|
994
|
+
return Object.assign(run, { unsubscribe });
|
|
878
995
|
}
|
|
879
996
|
|
|
880
997
|
/**
|
|
@@ -888,9 +1005,9 @@ function forwardAbortSignal(session: AgentSession, signal?: AbortSignal): () =>
|
|
|
888
1005
|
return () => signal.removeEventListener("abort", onAbort);
|
|
889
1006
|
}
|
|
890
1007
|
|
|
891
|
-
function resolveConfiguredSessionDir(sessionDir: string | undefined, cwd: string): string | undefined {
|
|
1008
|
+
function resolveConfiguredSessionDir(sessionDir: string | undefined, cwd: string, homeDir = homedir()): string | undefined {
|
|
892
1009
|
if (!sessionDir) return undefined;
|
|
893
|
-
if (sessionDir === "~" || sessionDir.startsWith("~/")) return resolve(
|
|
1010
|
+
if (sessionDir === "~" || sessionDir.startsWith("~/")) return resolve(homeDir, sessionDir.slice(2));
|
|
894
1011
|
if (isAbsolute(sessionDir)) return sessionDir;
|
|
895
1012
|
return resolve(cwd, sessionDir);
|
|
896
1013
|
}
|
|
@@ -914,11 +1031,20 @@ export async function runAgent(
|
|
|
914
1031
|
|
|
915
1032
|
// Get parent system prompt for append-mode agents
|
|
916
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 });
|
|
917
1042
|
|
|
918
1043
|
// Build prompt extras (memory, skill preloading)
|
|
919
1044
|
const extras: PromptExtras = {};
|
|
920
1045
|
if (options.worktreeBase) extras.worktreeBase = options.worktreeBase;
|
|
921
1046
|
if (options.workflow && !options.structuredOutput) extras.workflowChild = true;
|
|
1047
|
+
if (options.planGate?.policy) extras.planMode = true;
|
|
922
1048
|
|
|
923
1049
|
// Resolved here, not by each caller: mention and RPC spawns pass no `isolated`,
|
|
924
1050
|
// and the agent file's value must hold on every path.
|
|
@@ -932,7 +1058,7 @@ export async function runAgent(
|
|
|
932
1058
|
|
|
933
1059
|
// Skill preloading: when skills is string[], preload their content into prompt
|
|
934
1060
|
if (Array.isArray(skills)) {
|
|
935
|
-
const loaded = preloadSkills(skills, configCwd);
|
|
1061
|
+
const loaded = await preloadSkills(skills, configCwd, { agentDir, settingsManager, homeDir: options.homeDir });
|
|
936
1062
|
if (loaded.length > 0) {
|
|
937
1063
|
extras.skillBlocks = loaded;
|
|
938
1064
|
}
|
|
@@ -952,12 +1078,12 @@ export async function runAgent(
|
|
|
952
1078
|
// Read-write memory: add any missing memory tool names (read/write/edit)
|
|
953
1079
|
const extraNames = getMemoryToolNames(existingNames);
|
|
954
1080
|
if (extraNames.length > 0) toolNames = [...toolNames, ...extraNames];
|
|
955
|
-
extras.memoryBlock = buildMemoryBlock(agentConfig.name, agentConfig.memory, configCwd);
|
|
1081
|
+
extras.memoryBlock = buildMemoryBlock(agentConfig.name, agentConfig.memory, configCwd, options.homeDir);
|
|
956
1082
|
} else {
|
|
957
1083
|
// Read-only memory: only add read tool name, use read-only prompt
|
|
958
1084
|
const extraNames = getReadOnlyMemoryToolNames(existingNames);
|
|
959
1085
|
if (extraNames.length > 0) toolNames = [...toolNames, ...extraNames];
|
|
960
|
-
extras.memoryBlock = buildReadOnlyMemoryBlock(agentConfig.name, agentConfig.memory, configCwd);
|
|
1086
|
+
extras.memoryBlock = buildReadOnlyMemoryBlock(agentConfig.name, agentConfig.memory, configCwd, options.homeDir);
|
|
961
1087
|
}
|
|
962
1088
|
}
|
|
963
1089
|
|
|
@@ -971,10 +1097,13 @@ export async function runAgent(
|
|
|
971
1097
|
? options.nestedRuntime
|
|
972
1098
|
: undefined;
|
|
973
1099
|
// One answer for the prompt and the workflow refusal: does this child end up with `Agent`?
|
|
974
|
-
|
|
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(
|
|
975
1103
|
agentConfig?.allowedSubagents && nestedRuntime && !isolated
|
|
976
|
-
&&
|
|
1104
|
+
&& hasNestableTypes(agentConfig.allowedSubagents, { homeDir: options.homeDir, configCwd, projectTrusted }),
|
|
977
1105
|
);
|
|
1106
|
+
const agentAvailable = nestingPossible && !agentConfig?.disallowedTools?.includes("Agent");
|
|
978
1107
|
if (agentConfig?.allowedSubagents && !agentAvailable) extras.nestingDisabled = true;
|
|
979
1108
|
|
|
980
1109
|
// Build system prompt from agent config
|
|
@@ -993,8 +1122,6 @@ export async function runAgent(
|
|
|
993
1122
|
// Still pass noSkills: true since we don't need the skill loader to load them again.
|
|
994
1123
|
const noSkills = skills === false || Array.isArray(skills);
|
|
995
1124
|
|
|
996
|
-
const agentDir = getAgentDir();
|
|
997
|
-
|
|
998
1125
|
// Extension loading:
|
|
999
1126
|
// - true → all default-discovered extensions
|
|
1000
1127
|
// - false → none (noExtensions)
|
|
@@ -1018,7 +1145,7 @@ export async function runAgent(
|
|
|
1018
1145
|
const noExtensions = extensions === false;
|
|
1019
1146
|
|
|
1020
1147
|
const extensionsSpec = Array.isArray(extensions)
|
|
1021
|
-
? parseExtensionsSpec(extensions, configCwd)
|
|
1148
|
+
? parseExtensionsSpec(extensions, configCwd, options.homeDir)
|
|
1022
1149
|
: undefined;
|
|
1023
1150
|
const keepNames = extensionsSpec?.names ?? new Set<string>();
|
|
1024
1151
|
// `exclude_extensions:` is a denylist applied AFTER the include set — exclude wins.
|
|
@@ -1052,14 +1179,6 @@ export async function runAgent(
|
|
|
1052
1179
|
};
|
|
1053
1180
|
};
|
|
1054
1181
|
|
|
1055
|
-
// The child inherits the parent's project-trust decision. pi's SettingsManager
|
|
1056
|
-
// defaults to trusted, so without this a subagent in a project the user declined
|
|
1057
|
-
// to trust would load its `.pi` extensions and settings, and pi's built-in MCP
|
|
1058
|
-
// would start the stdio servers in its `.pi/mcp.json`.
|
|
1059
|
-
const projectTrusted = options.configCwd !== undefined && options.configProjectTrusted !== undefined
|
|
1060
|
-
? options.configProjectTrusted
|
|
1061
|
-
: ctx.isProjectTrusted();
|
|
1062
|
-
const settingsManager = SettingsManager.create(configCwd, agentDir, { projectTrusted });
|
|
1063
1182
|
// That trust covers the parent's project (`configCwd`), not a directory a spawn
|
|
1064
1183
|
// was sent into. The session answers `ctx.isProjectTrusted()` for its own cwd,
|
|
1065
1184
|
// which pi's MCP uses to read `<cwd>/.pi/mcp.json` — so a cross-directory session
|
|
@@ -1073,7 +1192,7 @@ export async function runAgent(
|
|
|
1073
1192
|
});
|
|
1074
1193
|
const nestedCallGate: NestedCallGate = {};
|
|
1075
1194
|
|
|
1076
|
-
const loader =
|
|
1195
|
+
const loader = createHomeResourceLoader({
|
|
1077
1196
|
settingsManager,
|
|
1078
1197
|
// pi's CLI hands its built-in extensions to the loader it builds; this one starts
|
|
1079
1198
|
// with none. Forwarded as `builtin:` resources they load as in the main session —
|
|
@@ -1105,8 +1224,8 @@ export async function runAgent(
|
|
|
1105
1224
|
noContextFiles: true,
|
|
1106
1225
|
systemPromptOverride: () => systemPrompt,
|
|
1107
1226
|
appendSystemPromptOverride: () => [],
|
|
1108
|
-
});
|
|
1109
|
-
await
|
|
1227
|
+
}, options.homeDir);
|
|
1228
|
+
await bindChildResourceLoader(loader).reload();
|
|
1110
1229
|
|
|
1111
1230
|
// Plain entries in `tools:` are expected to be built-in names (extension tools
|
|
1112
1231
|
// go through `ext:`), so an unknown name there is unambiguously a typo. Previously
|
|
@@ -1177,10 +1296,8 @@ export async function runAgent(
|
|
|
1177
1296
|
}
|
|
1178
1297
|
}
|
|
1179
1298
|
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
ctx.model, ctx.modelRegistry, agentConfig?.model,
|
|
1183
|
-
);
|
|
1299
|
+
const model = options.modelSelection.model;
|
|
1300
|
+
if (options.modelSelection.fallback) options.onModelFallback?.(options.modelSelection.fallback);
|
|
1184
1301
|
|
|
1185
1302
|
// Resolve thinking level: explicit option > agent config > undefined (inherit)
|
|
1186
1303
|
const thinkingLevel = options.thinkingLevel ?? agentConfig?.thinking;
|
|
@@ -1193,7 +1310,7 @@ export async function runAgent(
|
|
|
1193
1310
|
// set `allowed_subagents` and a nestedRuntime was provided — and never when
|
|
1194
1311
|
// isolated. Their names collide with EXCLUDED_TOOL_NAMES by design, so the
|
|
1195
1312
|
// scoping below re-admits them explicitly (registry deny + active-set narrow).
|
|
1196
|
-
const nestedTools = agentConfig?.allowedSubagents && nestedRuntime &&
|
|
1313
|
+
const nestedTools = agentConfig?.allowedSubagents && nestedRuntime && nestingPossible
|
|
1197
1314
|
? createNestedSubagentTools({
|
|
1198
1315
|
manager: nestedRuntime.manager,
|
|
1199
1316
|
pi: options.pi,
|
|
@@ -1201,6 +1318,7 @@ export async function runAgent(
|
|
|
1201
1318
|
depth: nestedRuntime.depth,
|
|
1202
1319
|
maxSubagentDepth: effectiveMaxDepth,
|
|
1203
1320
|
allowedSubagents: agentConfig.allowedSubagents,
|
|
1321
|
+
homeDir: options.homeDir,
|
|
1204
1322
|
configCwd,
|
|
1205
1323
|
projectTrusted,
|
|
1206
1324
|
})
|
|
@@ -1301,8 +1419,17 @@ export async function runAgent(
|
|
|
1301
1419
|
sessionExcludeTools = [...denyTools];
|
|
1302
1420
|
}
|
|
1303
1421
|
|
|
1304
|
-
const configuredSessionDir = resolveConfiguredSessionDir(agentConfig?.sessionDir, effectiveCwd);
|
|
1305
|
-
const
|
|
1422
|
+
const configuredSessionDir = resolveConfiguredSessionDir(agentConfig?.sessionDir, effectiveCwd, options.homeDir);
|
|
1423
|
+
const sessionDirSetting = process.env.PI_CODING_AGENT_SESSION_DIR ?? settingsManager.getSessionDir?.();
|
|
1424
|
+
// SessionManager's implicit default consults the host home again. Pin the
|
|
1425
|
+
// same cwd encoding under our injected agentDir instead when isolating a run.
|
|
1426
|
+
// SDK settings go straight through SessionManager's native normalization;
|
|
1427
|
+
// unlike frontmatter, file URLs expand and relative settings stay relative.
|
|
1428
|
+
const defaultSessionDir = options.homeDir === undefined
|
|
1429
|
+
? sessionDirSetting
|
|
1430
|
+
: sessionDirSetting
|
|
1431
|
+
? normalizeHomePath(sessionDirSetting, options.homeDir)
|
|
1432
|
+
: join(resolve(agentDir), "sessions", `--${resolve(effectiveCwd).replace(/^[/\\\\]/, "").replace(/[/\\\\:]/g, "-")}--`);
|
|
1306
1433
|
// Frontmatter wins when it says anything; otherwise the project default,
|
|
1307
1434
|
// which `rememberAgents` supplies for top-level agents only. Same precedence
|
|
1308
1435
|
// as `outputTranscript`.
|
|
@@ -1392,6 +1519,21 @@ export async function runAgent(
|
|
|
1392
1519
|
});
|
|
1393
1520
|
}
|
|
1394
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
|
+
|
|
1395
1537
|
options.onSessionCreated?.(session);
|
|
1396
1538
|
|
|
1397
1539
|
const run = observeRun(session, { ...options, maxTurns: resolveEffectiveMaxTurns(type, options.maxTurns) });
|
|
@@ -1417,12 +1559,13 @@ export async function runAgent(
|
|
|
1417
1559
|
// pi's `session.abort()` is a no-op until a run is active — so a child whose
|
|
1418
1560
|
// parent is already gone is not prompted at all.
|
|
1419
1561
|
if (options.signal?.aborted !== true) await session.prompt(effectivePrompt);
|
|
1562
|
+
await retryStaleCtx(session, startLen, options.signal, options.staleRetryDelaysMs, options.clock);
|
|
1420
1563
|
|
|
1421
1564
|
// A cap fired. The wrap-up steer is only a request: a model deep in a tool
|
|
1422
1565
|
// loop keeps calling tools through the grace turns and is then aborted
|
|
1423
1566
|
// mid-turn, leaving its last narration as the "result". Demand the report
|
|
1424
1567
|
// in a turn that cannot call tools.
|
|
1425
|
-
if (run.steered && options.signal
|
|
1568
|
+
if (run.steered && mayFollowUp(session, startLen, options.signal) && !endedWithReport(session, startLen)) {
|
|
1426
1569
|
run.finalizing = true;
|
|
1427
1570
|
const reported = await forceFinalReport(session, startLen, options.signal);
|
|
1428
1571
|
// A delivered report is a wrap-up, not a failure: the agent did hand back
|
|
@@ -1436,7 +1579,7 @@ export async function runAgent(
|
|
|
1436
1579
|
// the abort forwarding are still live: torn down first, a retry would be
|
|
1437
1580
|
// unkillable.
|
|
1438
1581
|
if (structuredCapture !== undefined && structuredCapture.json === undefined
|
|
1439
|
-
&& !run.aborted && options.signal
|
|
1582
|
+
&& !run.aborted && mayFollowUp(session, startLen, options.signal)) {
|
|
1440
1583
|
structuredRetried = true;
|
|
1441
1584
|
await session.prompt(structuredRetryPrompt(structuredCapture));
|
|
1442
1585
|
}
|
|
@@ -1446,7 +1589,8 @@ export async function runAgent(
|
|
|
1446
1589
|
cleanupAbort();
|
|
1447
1590
|
}
|
|
1448
1591
|
|
|
1449
|
-
const
|
|
1592
|
+
const stale = staleCtxOutcome(session, startLen);
|
|
1593
|
+
const text = collector.getText().trim() || getLastAssistantText(session, startLen);
|
|
1450
1594
|
// A child asked for structured output that never gave any has failed, however
|
|
1451
1595
|
// articulate its prose was. Reported through `failure` so it travels the same
|
|
1452
1596
|
// path as a provider error rather than arriving as a successful empty answer.
|
|
@@ -1455,12 +1599,14 @@ export async function runAgent(
|
|
|
1455
1599
|
? `The agent's StructuredOutput call did not match the required schema: ${structuredCapture.lastError}`
|
|
1456
1600
|
: "The agent did not report its answer through StructuredOutput."
|
|
1457
1601
|
: undefined;
|
|
1602
|
+
const failure = stale?.failure ?? finalTurnError(session, startLen) ?? structuredFailure;
|
|
1603
|
+
const streamed = collector.getText().trim() || collector.getPreviousText().trim();
|
|
1458
1604
|
return {
|
|
1459
|
-
responseText,
|
|
1605
|
+
responseText: failure ? failureText(session, startLen, streamed || text, stale?.partial ?? partialProgress(session, startLen), collector.hasLaterTool(), Boolean(streamed)) : text,
|
|
1460
1606
|
session,
|
|
1461
1607
|
aborted: run.aborted,
|
|
1462
1608
|
steered: run.steered,
|
|
1463
|
-
failure
|
|
1609
|
+
failure,
|
|
1464
1610
|
...(structuredCapture?.json !== undefined ? { structuredJson: structuredCapture.json } : {}),
|
|
1465
1611
|
...(structuredRetried ? { structuredRetried } : {}),
|
|
1466
1612
|
};
|
|
@@ -1475,7 +1621,7 @@ export async function runAgent(
|
|
|
1475
1621
|
export async function resumeAgent(
|
|
1476
1622
|
session: AgentSession,
|
|
1477
1623
|
prompt: string,
|
|
1478
|
-
options: Pick<RunOptions, "maxTurns" | "onTurnEnd" | "onTextDelta" | "onModelCall" | "onToolActivity" | "onAssistantUsage" | "onCompaction" | "signal"> & {
|
|
1624
|
+
options: Pick<RunOptions, "staleRetryDelaysMs" | "clock" | "maxTurns" | "onTurnEnd" | "onTextDelta" | "onModelCall" | "onToolActivity" | "onAssistantUsage" | "onCompaction" | "signal"> & {
|
|
1479
1625
|
/** The agent's type, for its own `max_turns`. */
|
|
1480
1626
|
type: SubagentType;
|
|
1481
1627
|
},
|
|
@@ -1491,7 +1637,8 @@ export async function resumeAgent(
|
|
|
1491
1637
|
try {
|
|
1492
1638
|
// Already aborted: not prompted, as in `runAgent`.
|
|
1493
1639
|
if (options.signal?.aborted !== true) await session.prompt(prompt);
|
|
1494
|
-
|
|
1640
|
+
await retryStaleCtx(session, startLen, options.signal, options.staleRetryDelaysMs, options.clock);
|
|
1641
|
+
if (run.steered && mayFollowUp(session, startLen, options.signal) && !endedWithReport(session, startLen)) {
|
|
1495
1642
|
run.finalizing = true;
|
|
1496
1643
|
if (await forceFinalReport(session, startLen, options.signal)) run.aborted = false;
|
|
1497
1644
|
}
|
|
@@ -1501,9 +1648,13 @@ export async function resumeAgent(
|
|
|
1501
1648
|
cleanupAbort();
|
|
1502
1649
|
}
|
|
1503
1650
|
|
|
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();
|
|
1504
1655
|
return {
|
|
1505
|
-
text:
|
|
1506
|
-
failure
|
|
1656
|
+
text: failure ? failureText(session, startLen, streamed || text, stale?.partial ?? partialProgress(session, startLen), collector.hasLaterTool(), Boolean(streamed)) : text,
|
|
1657
|
+
failure,
|
|
1507
1658
|
aborted: run.aborted,
|
|
1508
1659
|
steered: run.steered,
|
|
1509
1660
|
};
|