@cad0p/pi-subagents-tintinweb 0.14.2 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -1
- package/CONTRIBUTING.md +8 -0
- package/README.md +98 -58
- package/docs/fork-differences.md +51 -0
- package/docs/verification-checklist.md +132 -0
- package/examples/agent-tool-description.md +3 -9
- package/package.json +13 -11
- package/pnpm-workspace.yaml +9 -0
- package/src/abortable.ts +43 -0
- package/src/agent-manager.ts +404 -140
- package/src/agent-runner.ts +462 -89
- package/src/agent-types.ts +269 -23
- package/src/checkpoint.ts +49 -0
- package/src/child-context.ts +30 -0
- package/src/cross-extension-rpc.ts +65 -5
- package/src/custom-agents.ts +161 -14
- package/src/default-agents.ts +1 -1
- package/src/index.ts +1014 -980
- package/src/invocation-config.ts +81 -12
- package/src/model-resolver.ts +18 -0
- package/src/model-scope.ts +62 -0
- package/src/nested-tools.ts +406 -0
- package/src/output-file.ts +18 -1
- package/src/prompts.ts +71 -0
- package/src/result-report.ts +170 -0
- package/src/schedule-store.ts +300 -13
- package/src/schedule.ts +222 -77
- package/src/settings.ts +206 -32
- package/src/status-note.ts +18 -1
- package/src/text-safety.ts +69 -0
- package/src/types.ts +101 -58
- package/src/ui/agent-widget.ts +104 -36
- package/src/ui/conversation-viewer.ts +36 -18
- package/src/ui/fleet-list.ts +19 -8
- package/src/ui/schedule-menu.ts +63 -19
- package/src/usage.ts +128 -8
- package/src/worktree.ts +19 -0
- package/src/group-join.ts +0 -141
package/src/agent-runner.ts
CHANGED
|
@@ -5,26 +5,34 @@
|
|
|
5
5
|
import { readFileSync } from "node:fs";
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
8
|
-
import type { Model } from "@earendil-works/pi-ai";
|
|
9
|
-
import type { ExtensionContext, LoadExtensionsResult } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import type { AssistantMessage, Model } from "@earendil-works/pi-ai";
|
|
9
|
+
import type { ExtensionContext, LoadExtensionsResult, ScopedModel, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
10
10
|
import {
|
|
11
11
|
type AgentSession,
|
|
12
12
|
type AgentSessionEvent,
|
|
13
13
|
createAgentSession,
|
|
14
|
+
createCodemodeExtension,
|
|
15
|
+
createMcpExtension,
|
|
16
|
+
createToolSearchExtension,
|
|
14
17
|
DefaultResourceLoader,
|
|
15
18
|
type ExtensionAPI,
|
|
16
19
|
getAgentDir,
|
|
20
|
+
type InlineExtension,
|
|
17
21
|
SessionManager,
|
|
18
22
|
SettingsManager,
|
|
19
23
|
} from "@earendil-works/pi-coding-agent";
|
|
20
|
-
import { BUILTIN_TOOL_NAMES, getAgentConfig, getConfig, getMemoryToolNames, getReadOnlyMemoryToolNames, getToolNamesForType } from "./agent-types.js";
|
|
24
|
+
import { BUILTIN_TOOL_NAMES, getAgentConfig, getBuiltinToolSnippets, getConfig, getMemoryToolNames, getReadOnlyMemoryToolNames, getToolNamesForType } from "./agent-types.js";
|
|
25
|
+
import { runInChildSessionContext } from "./child-context.js";
|
|
21
26
|
import { buildParentContext, extractText } from "./context.js";
|
|
22
27
|
import { DEFAULT_AGENTS } from "./default-agents.js";
|
|
23
28
|
import { detectEnv } from "./env.js";
|
|
24
29
|
import { buildMemoryBlock, buildReadOnlyMemoryBlock } from "./memory.js";
|
|
25
|
-
import {
|
|
30
|
+
import { type ModelRegistry as ModelLookupRegistry, resolveModel } from "./model-resolver.js";
|
|
31
|
+
import { createNestedSubagentTools, getMaxSubagentDepth, type NestedAgentManager } from "./nested-tools.js";
|
|
32
|
+
import { buildAgentPrompt, type PromptExtras, retargetToolsSection } from "./prompts.js";
|
|
26
33
|
import { preloadSkills } from "./skill-loader.js";
|
|
27
|
-
import type { SubagentType, ThinkingLevel } from "./types.js";
|
|
34
|
+
import type { AgentConfig, SubagentType, ThinkingLevel } from "./types.js";
|
|
35
|
+
import type { LifetimeUsage } from "./usage.js";
|
|
28
36
|
|
|
29
37
|
/**
|
|
30
38
|
* Tool names registered by THIS extension. Single source of truth so the
|
|
@@ -76,6 +84,8 @@ export function extensionCanonicalName(extPath: string): string {
|
|
|
76
84
|
* every co-located file to `pi-subagents-tintinweb`.
|
|
77
85
|
*/
|
|
78
86
|
function extensionPackageName(extPath: string): string | undefined {
|
|
87
|
+
// `builtin:<name>` names no file; climbing from it would read the process cwd's manifest.
|
|
88
|
+
if (extPath.startsWith("builtin:")) return undefined;
|
|
79
89
|
const entry = resolve(extPath);
|
|
80
90
|
let dir = dirname(extPath);
|
|
81
91
|
for (;;) {
|
|
@@ -197,6 +207,51 @@ export function parseExtSelectors(entries: string[]): {
|
|
|
197
207
|
return { extNames, narrowing };
|
|
198
208
|
}
|
|
199
209
|
|
|
210
|
+
/**
|
|
211
|
+
* Mirror of pi's `createToolNameMatcher` (mcp-servers.js): exact names plus `*`
|
|
212
|
+
* globs. `disallowed_tools` is handed to pi verbatim, so pi removes a wildcard
|
|
213
|
+
* match from the registry — this side has to agree, or the prompt advertises a
|
|
214
|
+
* tool pi already dropped and the scope gate would let it through.
|
|
215
|
+
*/
|
|
216
|
+
function toolNameMatcher(entries: readonly string[]): (name: string) => boolean {
|
|
217
|
+
const exact = new Set(entries.filter((entry) => !entry.includes("*")));
|
|
218
|
+
const patterns = entries
|
|
219
|
+
.filter((entry) => entry.includes("*"))
|
|
220
|
+
.map((entry) => new RegExp(`^${entry.split("*").map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`));
|
|
221
|
+
return (name) => exact.has(name) || patterns.some((pattern) => pattern.test(name));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Set once the child's tool scope is known; until then the gate admits everything. */
|
|
225
|
+
type NestedCallGate = { allows?: (name: string) => boolean };
|
|
226
|
+
|
|
227
|
+
/** Name of the inline scope-gate extension; its loader path is `<inline:${SCOPE_GATE_NAME}>`. */
|
|
228
|
+
const SCOPE_GATE_NAME = "pi-subagents-scope";
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Inline extension that applies the subagent's tool scope to every tool call, wherever
|
|
232
|
+
* it comes from.
|
|
233
|
+
*
|
|
234
|
+
* pi gates registration by the session's `tools:` allowlist, but keeps a registered
|
|
235
|
+
* MCP tool callable through `ctx.executeTool()` whether or not the agent selected it —
|
|
236
|
+
* so a codemode script could reach a tool outside its scope. Those nested calls run
|
|
237
|
+
* past extensions' `tool_call` handlers only, never `agent.beforeToolCall`, and a
|
|
238
|
+
* `tool_search`-activated tool reaches the model the same way. One `tool_call` handler
|
|
239
|
+
* covers both.
|
|
240
|
+
*/
|
|
241
|
+
function scopeGateExtension(gate: NestedCallGate): InlineExtension {
|
|
242
|
+
return {
|
|
243
|
+
name: SCOPE_GATE_NAME,
|
|
244
|
+
hidden: true,
|
|
245
|
+
factory: (ext) => {
|
|
246
|
+
ext.on("tool_call", (event) =>
|
|
247
|
+
gate.allows?.(event.toolName) === false
|
|
248
|
+
? { block: true, reason: `Tool "${event.toolName}" is not available to this subagent.` }
|
|
249
|
+
: undefined,
|
|
250
|
+
);
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
200
255
|
/** Default max turns. undefined = unlimited (no turn limit). */
|
|
201
256
|
let defaultMaxTurns: number | undefined;
|
|
202
257
|
|
|
@@ -250,6 +305,43 @@ function resolveDefaultModel(
|
|
|
250
305
|
return parentModel;
|
|
251
306
|
}
|
|
252
307
|
|
|
308
|
+
/**
|
|
309
|
+
* Resolve the child session's model scope from `scoped_models` frontmatter.
|
|
310
|
+
*
|
|
311
|
+
* undefined/true → inherit the parent session's resolved scope, so in-session
|
|
312
|
+
* extensions that read `ctx.scopedModels` — notably pi-fallback-provider's
|
|
313
|
+
* settle-boundary fallback — work inside subagents too. false → no scope;
|
|
314
|
+
* string[] → an explicit replacement list resolved like `model:` (exact, then
|
|
315
|
+
* fuzzy, then the same model under another provider). Unresolved entries are
|
|
316
|
+
* skipped with a warning; they never fail the spawn.
|
|
317
|
+
*/
|
|
318
|
+
export function resolveScopedModels(
|
|
319
|
+
configured: true | string[] | false | undefined,
|
|
320
|
+
parentScoped: readonly ScopedModel[] | undefined,
|
|
321
|
+
registry: ModelLookupRegistry,
|
|
322
|
+
warn: (message: string) => void = () => {},
|
|
323
|
+
): ScopedModel[] {
|
|
324
|
+
if (configured === undefined || configured === true) {
|
|
325
|
+
return parentScoped ? [...parentScoped] : [];
|
|
326
|
+
}
|
|
327
|
+
if (configured === false) return [];
|
|
328
|
+
|
|
329
|
+
const resolved: ScopedModel[] = [];
|
|
330
|
+
const seen = new Set<string>();
|
|
331
|
+
for (const entry of configured) {
|
|
332
|
+
const model = resolveModel(entry, registry);
|
|
333
|
+
if (typeof model === "string") {
|
|
334
|
+
warn(`scoped_models: "${entry}" did not resolve — skipping`);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
const key = `${model.provider}/${model.id}`;
|
|
338
|
+
if (seen.has(key)) continue;
|
|
339
|
+
seen.add(key);
|
|
340
|
+
resolved.push({ model });
|
|
341
|
+
}
|
|
342
|
+
return resolved;
|
|
343
|
+
}
|
|
344
|
+
|
|
253
345
|
/** Info about a tool event in the subagent. */
|
|
254
346
|
export interface ToolActivity {
|
|
255
347
|
type: "start" | "end";
|
|
@@ -289,6 +381,14 @@ export interface RunOptions {
|
|
|
289
381
|
* parent's repo, so config resolving inside it is correct.)
|
|
290
382
|
*/
|
|
291
383
|
configCwd?: string;
|
|
384
|
+
/**
|
|
385
|
+
* The agent config to run, when the caller already resolved it from its own
|
|
386
|
+
* registry. A nested spawn resolves agents from its branch's config root,
|
|
387
|
+
* which is not necessarily what the process-global registry holds — passing
|
|
388
|
+
* the resolved config keeps the run from silently falling back to the
|
|
389
|
+
* unknown-type default (general-purpose, every tool).
|
|
390
|
+
*/
|
|
391
|
+
agentConfig?: AgentConfig;
|
|
292
392
|
/** Called on tool start/end with activity info. */
|
|
293
393
|
onToolActivity?: (activity: ToolActivity) => void;
|
|
294
394
|
/** Called on streaming text deltas from the assistant response. */
|
|
@@ -300,13 +400,30 @@ export interface RunOptions {
|
|
|
300
400
|
* Called once per assistant message_end with that message's usage delta.
|
|
301
401
|
* Lets callers maintain a lifetime accumulator that survives compaction
|
|
302
402
|
* (which replaces session.state.messages and resets stats-derived sums).
|
|
403
|
+
*
|
|
404
|
+
* `cost` is pi's own `usage.cost.total` for that message — priced from the
|
|
405
|
+
* model's rates, so it is 0 (not missing) for a model pi has no pricing for.
|
|
406
|
+
* We never price anything ourselves; every dollar figure this extension shows
|
|
407
|
+
* or reports traces back to this field.
|
|
303
408
|
*/
|
|
304
|
-
onAssistantUsage?: (usage:
|
|
409
|
+
onAssistantUsage?: (usage: LifetimeUsage) => void;
|
|
305
410
|
/**
|
|
306
411
|
* Called when the session successfully compacts. `tokensBefore` is upstream's
|
|
307
412
|
* pre-compaction context size estimate. Aborted compactions don't fire.
|
|
308
413
|
*/
|
|
309
414
|
onCompaction?: (info: { reason: "manual" | "threshold" | "overflow"; tokensBefore: number }) => void;
|
|
415
|
+
/**
|
|
416
|
+
* Runtime bridge for opt-in nested delegation. Present only for agents the
|
|
417
|
+
* manager spawned (a top-level run has no parent to scope children to).
|
|
418
|
+
*/
|
|
419
|
+
nestedRuntime?: {
|
|
420
|
+
manager: NestedAgentManager;
|
|
421
|
+
parentAgentId: string;
|
|
422
|
+
/** This agent's own depth; its children spawn at depth + 1. */
|
|
423
|
+
depth: number;
|
|
424
|
+
/** Branch cap inherited from the spawn, else the project setting. */
|
|
425
|
+
maxSubagentDepth?: number;
|
|
426
|
+
};
|
|
310
427
|
}
|
|
311
428
|
|
|
312
429
|
export interface RunResult {
|
|
@@ -329,11 +446,13 @@ export interface RunResult {
|
|
|
329
446
|
}
|
|
330
447
|
|
|
331
448
|
/**
|
|
332
|
-
* Subscribe to a session and collect the last assistant message text
|
|
333
|
-
*
|
|
449
|
+
* Subscribe to a session and collect the last assistant message text, plus the
|
|
450
|
+
* last assistant message itself as reported by its `message_end` event.
|
|
451
|
+
* Returns `getText()` and `getLastAssistant()` getters and an `unsubscribe` function.
|
|
334
452
|
*/
|
|
335
453
|
function collectResponseText(session: AgentSession) {
|
|
336
454
|
let text = "";
|
|
455
|
+
let lastAssistant: AssistantMessage | undefined;
|
|
337
456
|
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
|
|
338
457
|
// message_start also fires for user and toolResult messages — resetting on
|
|
339
458
|
// those would wipe assistant text already collected. Reset only when a new
|
|
@@ -344,8 +463,11 @@ function collectResponseText(session: AgentSession) {
|
|
|
344
463
|
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
|
345
464
|
text += event.assistantMessageEvent.delta;
|
|
346
465
|
}
|
|
466
|
+
if (event.type === "message_end" && event.message.role === "assistant") {
|
|
467
|
+
lastAssistant = event.message;
|
|
468
|
+
}
|
|
347
469
|
});
|
|
348
|
-
return { getText: () => text, unsubscribe };
|
|
470
|
+
return { getText: () => text, getLastAssistant: () => lastAssistant, unsubscribe };
|
|
349
471
|
}
|
|
350
472
|
|
|
351
473
|
/**
|
|
@@ -376,20 +498,31 @@ function getLastAssistantText(session: AgentSession, startIndex = 0): string {
|
|
|
376
498
|
* Everything else completes: a clean "stop"/"toolUse" final, and — crucially — a
|
|
377
499
|
* "length" stop that DID produce text (a legitimate truncated-but-useful answer).
|
|
378
500
|
* "aborted" is handled by the manager's abort flag / "stopped" guard, not here.
|
|
379
|
-
*
|
|
380
|
-
*
|
|
501
|
+
* `observed` is the final assistant message seen via `message_end` during this
|
|
502
|
+
* invocation, and it is authoritative: a context_edit can hide the failed turn
|
|
503
|
+
* from session.messages, and a walk-back would then report an older turn's
|
|
504
|
+
* success. The session.messages walk is only a fallback for runs that emitted no
|
|
505
|
+
* assistant message_end. Bounded by `startIndex` (like the text fallback) so a
|
|
506
|
+
* resume with no assistant message of its own never inherits a PRIOR turn's stop
|
|
507
|
+
* reason.
|
|
381
508
|
*/
|
|
382
|
-
function finalTurnError(session: AgentSession, startIndex = 0): string | undefined {
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
509
|
+
function finalTurnError(session: AgentSession, startIndex = 0, observed?: AssistantMessage): string | undefined {
|
|
510
|
+
let final = observed;
|
|
511
|
+
if (!final) {
|
|
512
|
+
for (let i = session.messages.length - 1; i >= startIndex; i--) {
|
|
513
|
+
const msg = session.messages[i];
|
|
514
|
+
if (msg.role === "assistant") {
|
|
515
|
+
final = msg;
|
|
516
|
+
break;
|
|
517
|
+
}
|
|
391
518
|
}
|
|
392
|
-
|
|
519
|
+
}
|
|
520
|
+
if (!final) return undefined;
|
|
521
|
+
if (final.stopReason === "error") {
|
|
522
|
+
return final.errorMessage?.trim() || "provider error with no output";
|
|
523
|
+
}
|
|
524
|
+
if (final.stopReason === "length" && !extractText(final.content).trim()) {
|
|
525
|
+
return "run hit the output token limit before producing any text";
|
|
393
526
|
}
|
|
394
527
|
return undefined;
|
|
395
528
|
}
|
|
@@ -401,6 +534,12 @@ function finalTurnError(session: AgentSession, startIndex = 0): string | undefin
|
|
|
401
534
|
function forwardAbortSignal(session: AgentSession, signal?: AbortSignal): () => void {
|
|
402
535
|
if (!signal) return () => {};
|
|
403
536
|
const onAbort = () => session.abort();
|
|
537
|
+
// An already-aborted signal never fires its event, so abort now instead of
|
|
538
|
+
// letting the child run on with a signal its owner believes is dead.
|
|
539
|
+
if (signal.aborted) {
|
|
540
|
+
onAbort();
|
|
541
|
+
return () => {};
|
|
542
|
+
}
|
|
404
543
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
405
544
|
return () => signal.removeEventListener("abort", onAbort);
|
|
406
545
|
}
|
|
@@ -418,8 +557,23 @@ export async function runAgent(
|
|
|
418
557
|
prompt: string,
|
|
419
558
|
options: RunOptions,
|
|
420
559
|
): Promise<RunResult> {
|
|
421
|
-
const
|
|
422
|
-
const agentConfig = getAgentConfig(type);
|
|
560
|
+
const resolvedConfig = options.agentConfig;
|
|
561
|
+
const agentConfig = resolvedConfig ?? getAgentConfig(type);
|
|
562
|
+
// Every derived field comes from the same config: a nested spawn resolves the
|
|
563
|
+
// agent from its branch's config root, and re-deriving tools or extension
|
|
564
|
+
// policy from the process-global registry would run a different agent than the
|
|
565
|
+
// one the caller validated (all built-ins, general-purpose's extensions).
|
|
566
|
+
const config = resolvedConfig
|
|
567
|
+
? {
|
|
568
|
+
displayName: resolvedConfig.displayName ?? resolvedConfig.name,
|
|
569
|
+
description: resolvedConfig.description,
|
|
570
|
+
builtinToolNames: resolvedConfig.builtinToolNames ?? [...BUILTIN_TOOL_NAMES],
|
|
571
|
+
extensions: resolvedConfig.extensions,
|
|
572
|
+
excludeExtensions: resolvedConfig.excludeExtensions,
|
|
573
|
+
skills: resolvedConfig.skills,
|
|
574
|
+
promptMode: resolvedConfig.promptMode,
|
|
575
|
+
}
|
|
576
|
+
: getConfig(type);
|
|
423
577
|
|
|
424
578
|
// Resolve working directory: worktree override > parent cwd
|
|
425
579
|
const effectiveCwd = options.cwd ?? ctx.cwd;
|
|
@@ -450,7 +604,7 @@ export async function runAgent(
|
|
|
450
604
|
}
|
|
451
605
|
}
|
|
452
606
|
|
|
453
|
-
let toolNames = getToolNamesForType(type);
|
|
607
|
+
let toolNames = resolvedConfig ? [...config.builtinToolNames] : getToolNamesForType(type);
|
|
454
608
|
|
|
455
609
|
// Persistent memory: detect write capability and branch accordingly.
|
|
456
610
|
// Account for disallowedTools — a tool in the base set but on the denylist is not truly available.
|
|
@@ -512,6 +666,9 @@ export async function runAgent(
|
|
|
512
666
|
options.isolated ? [] : (agentConfig?.extSelectors ?? []),
|
|
513
667
|
);
|
|
514
668
|
const noExtensions = extensions === false;
|
|
669
|
+
const disallowed = agentConfig?.disallowedTools?.length
|
|
670
|
+
? toolNameMatcher(agentConfig.disallowedTools)
|
|
671
|
+
: undefined;
|
|
515
672
|
|
|
516
673
|
const extensionsSpec = Array.isArray(extensions)
|
|
517
674
|
? parseExtensionsSpec(extensions, configCwd)
|
|
@@ -540,6 +697,7 @@ export async function runAgent(
|
|
|
540
697
|
return {
|
|
541
698
|
...base,
|
|
542
699
|
extensions: base.extensions.filter((e) => {
|
|
700
|
+
if (e.path === `<inline:${SCOPE_GATE_NAME}>`) return true; // ours, not the agent's
|
|
543
701
|
const canons = extensionCanonicalNames(e.path);
|
|
544
702
|
if (canons.some((n) => excludeNames.has(n))) return false; // exclude wins
|
|
545
703
|
return loadAll || canons.some((n) => keepNames.has(n));
|
|
@@ -547,7 +705,169 @@ export async function runAgent(
|
|
|
547
705
|
};
|
|
548
706
|
};
|
|
549
707
|
|
|
550
|
-
|
|
708
|
+
// The child inherits the parent's project-trust decision. pi's SettingsManager
|
|
709
|
+
// defaults to trusted, so without this a subagent in a project the user declined to
|
|
710
|
+
// trust would load its `.pi` extensions and settings, and pi's built-in MCP would
|
|
711
|
+
// start the stdio servers in its `.pi/mcp.json`.
|
|
712
|
+
const projectTrusted = ctx.isProjectTrusted();
|
|
713
|
+
const settingsManager = SettingsManager.create(configCwd, agentDir, { projectTrusted });
|
|
714
|
+
// That trust covers the parent's project (`configCwd`), not a directory a spawn was
|
|
715
|
+
// sent into. The session answers `ctx.isProjectTrusted()` for its own cwd, which
|
|
716
|
+
// pi's MCP uses to read `<cwd>/.pi/mcp.json` — so a cross-directory session keeps the
|
|
717
|
+
// parent project's settings but reports its cwd as untrusted. The loader keeps the
|
|
718
|
+
// trusted manager: it loads the parent project's extensions.
|
|
719
|
+
const sessionSettingsManager =
|
|
720
|
+
resolve(effectiveCwd) === resolve(configCwd)
|
|
721
|
+
? settingsManager
|
|
722
|
+
: Object.assign(SettingsManager.create(configCwd, agentDir, { projectTrusted }), {
|
|
723
|
+
isProjectTrusted: () => false,
|
|
724
|
+
});
|
|
725
|
+
const nestedCallGate: NestedCallGate = {};
|
|
726
|
+
|
|
727
|
+
// Nested delegation tools (opt-in, ownership-scoped). Empty unless the agent
|
|
728
|
+
// set `allowed_subagents`, the manager handed down a nestedRuntime, and this
|
|
729
|
+
// agent sits below the branch cap. Their names collide with EXCLUDED_TOOL_NAMES
|
|
730
|
+
// by design, so the scoping below re-admits them explicitly.
|
|
731
|
+
const inheritedMaxDepth = options.nestedRuntime?.maxSubagentDepth ?? getMaxSubagentDepth();
|
|
732
|
+
// A per-agent cap may only tighten the inherited one — an agent can never
|
|
733
|
+
// grant itself more nesting room than the project allows.
|
|
734
|
+
const effectiveMaxDepth = agentConfig?.maxSubagentDepth === undefined
|
|
735
|
+
? inheritedMaxDepth
|
|
736
|
+
: Math.min(inheritedMaxDepth, agentConfig.maxSubagentDepth);
|
|
737
|
+
// At (or past) the cap this agent can never spawn, so it can never own a child
|
|
738
|
+
// to fetch from or steer either — inject nothing rather than four tools whose
|
|
739
|
+
// every call is an error. This is also what makes maxSubagentDepth 0/1 mean
|
|
740
|
+
// "nesting off" instead of "nesting always fails".
|
|
741
|
+
const nestedRuntime = options.nestedRuntime && options.nestedRuntime.depth < effectiveMaxDepth
|
|
742
|
+
? options.nestedRuntime
|
|
743
|
+
: undefined;
|
|
744
|
+
const nestedTools = agentConfig?.allowedSubagents && nestedRuntime && !options.isolated
|
|
745
|
+
? createNestedSubagentTools({
|
|
746
|
+
manager: nestedRuntime.manager,
|
|
747
|
+
pi: options.pi,
|
|
748
|
+
parentAgentId: nestedRuntime.parentAgentId,
|
|
749
|
+
depth: nestedRuntime.depth,
|
|
750
|
+
maxSubagentDepth: effectiveMaxDepth,
|
|
751
|
+
allowedSubagents: agentConfig.allowedSubagents,
|
|
752
|
+
configCwd,
|
|
753
|
+
})
|
|
754
|
+
: [];
|
|
755
|
+
const nestedToolNames = new Set(nestedTools.map((tool) => tool.name));
|
|
756
|
+
|
|
757
|
+
// Extension-registered tools the subagent may use, keyed by name, read from the
|
|
758
|
+
// loader's LIVE extension tool maps: extensions register tools long after load
|
|
759
|
+
// (pi's MCP registers a server's tools once it connects) and `registerTool` writes
|
|
760
|
+
// into the very maps this reads. The prompt override and the allowlist below take a
|
|
761
|
+
// snapshot; the scope gate re-derives it per call, so a late tool is in scope from
|
|
762
|
+
// the moment it exists.
|
|
763
|
+
//
|
|
764
|
+
// Opt-in flip: when any `ext:` selector is present, extension tools become an
|
|
765
|
+
// explicit allowlist — a loaded extension not named by a selector contributes
|
|
766
|
+
// no tools (its handlers still ran), and `ext:foo/bar` narrows `foo` to `bar`.
|
|
767
|
+
// Tools the parent session currently declares. A `defaultActive: false` tool —
|
|
768
|
+
// pi's `codemode` and `tool_search`, and anything an extension registers dormant —
|
|
769
|
+
// is activated in the child only when the parent has it active or this agent names
|
|
770
|
+
// its extension. Otherwise every subagent would carry codemode's tool spec, and the
|
|
771
|
+
// capability that comes with it, for a user who never turned it on: "as in the main
|
|
772
|
+
// session" has to mean the main session's actual tool set, not every tool that
|
|
773
|
+
// happens to be loaded. Read per run: the parent's set changes with `--tools` and
|
|
774
|
+
// with whatever `tool_search` activated.
|
|
775
|
+
const parentActiveTools = new Set(options.pi.getActiveTools?.() ?? []);
|
|
776
|
+
// Tools an `ext:` selector names explicitly. Naming an extension is the agent
|
|
777
|
+
// saying it wants its tools, which is the same consent `--tools` gives pi.
|
|
778
|
+
const explicitlySelected = new Set<string>();
|
|
779
|
+
const scopedExtensionTools = (loaded: LoadExtensionsResult): Map<string, ToolDefinition> => {
|
|
780
|
+
const tools = new Map<string, ToolDefinition>();
|
|
781
|
+
if (noExtensions) return tools;
|
|
782
|
+
const optInActive = extNames.size > 0;
|
|
783
|
+
for (const extension of loaded.extensions) {
|
|
784
|
+
const canons = extensionCanonicalNames(extension.path);
|
|
785
|
+
if (optInActive && !canons.some((c) => extNames.has(c))) continue;
|
|
786
|
+
// First alias that carries a narrowing set — a user won't narrow one
|
|
787
|
+
// extension under two different names, so first-match is correct.
|
|
788
|
+
const narrowed = canons.map((c) => narrowing.get(c)).find(Boolean);
|
|
789
|
+
for (const [toolName, tool] of extension.tools) {
|
|
790
|
+
if (narrowed && !narrowed.has(toolName)) continue;
|
|
791
|
+
tools.set(toolName, tool.definition);
|
|
792
|
+
// Every tool reaching this line belongs to an extension the agent named,
|
|
793
|
+
// so it is selected on purpose rather than merely loaded.
|
|
794
|
+
if (optInActive) explicitlySelected.add(toolName);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
return tools;
|
|
798
|
+
};
|
|
799
|
+
|
|
800
|
+
// The master tool allowlist applied at session construction. pi-mono's
|
|
801
|
+
// `allowedToolNames` gates registration and activates what it names, so listing the
|
|
802
|
+
// exact final set here means the session is correctly scoped from the first instant.
|
|
803
|
+
// Naming a tool is also how pi activates one registered `defaultActive: false` (its
|
|
804
|
+
// `codemode` and `tool_search` among them), which is what a subagent wants for its
|
|
805
|
+
// own scope.
|
|
806
|
+
const scopedToolNames = (tools: Map<string, ToolDefinition>): string[] =>
|
|
807
|
+
[...toolNames, ...tools.keys(), ...nestedToolNames].filter((t) => {
|
|
808
|
+
// The orchestration tools stay stripped from every subagent — except the
|
|
809
|
+
// nested ones this agent opted into, which carry the same names on purpose.
|
|
810
|
+
if ((EXCLUDED_TOOL_NAMES.includes(t) && !nestedToolNames.has(t)) || disallowed?.(t)) return false;
|
|
811
|
+
// Built-ins carry no definition here and are always declared; the rule is
|
|
812
|
+
// about extension tools pi would leave dormant.
|
|
813
|
+
if (tools.get(t)?.defaultActive !== false) return true;
|
|
814
|
+
return explicitlySelected.has(t) || parentActiveTools.has(t);
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
// Names pi would DECLARE to the model: `direct`/`model-only` exposure only. A
|
|
818
|
+
// `codemode`/`deferred` tool (pi's MCP server tools by default) stays reachable
|
|
819
|
+
// through codemode and `tool_search` without being advertised.
|
|
820
|
+
const declarableTools = (tools: Map<string, ToolDefinition>): string[] =>
|
|
821
|
+
scopedToolNames(tools).filter((name) => {
|
|
822
|
+
const exposure = tools.get(name)?.exposure ?? "direct";
|
|
823
|
+
return exposure === "direct" || exposure === "model-only";
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* The child's prompt, with the `<tools>` block it inherits from the parent
|
|
828
|
+
* retargeted at the tools it actually declares — see `retargetToolsSection`.
|
|
829
|
+
*/
|
|
830
|
+
const childPrompt = (loaded: LoadExtensionsResult): string => {
|
|
831
|
+
const tools = scopedExtensionTools(loaded);
|
|
832
|
+
// Built-ins first: the inherited block only describes the parent's *active*
|
|
833
|
+
// tools, so an agent whose `tools:` reaches beyond that set would otherwise
|
|
834
|
+
// be advertised fewer tools than it declares. Child definitions win on a
|
|
835
|
+
// name both have.
|
|
836
|
+
const snippets: Record<string, string> = { ...getBuiltinToolSnippets() };
|
|
837
|
+
for (const [name, definition] of tools) {
|
|
838
|
+
if (definition.promptSnippet) snippets[name] = definition.promptSnippet;
|
|
839
|
+
}
|
|
840
|
+
// Injected nested tools are not extension tools, so their snippets have to
|
|
841
|
+
// be added here — otherwise the child is handed Agent/get_subagent_result
|
|
842
|
+
// and told nothing about them.
|
|
843
|
+
for (const definition of nestedTools) {
|
|
844
|
+
if (definition.promptSnippet) snippets[definition.name] = definition.promptSnippet;
|
|
845
|
+
}
|
|
846
|
+
return retargetToolsSection(systemPrompt, declarableTools(tools), snippets);
|
|
847
|
+
};
|
|
848
|
+
|
|
849
|
+
const loader: DefaultResourceLoader = new DefaultResourceLoader({
|
|
850
|
+
settingsManager,
|
|
851
|
+
// pi's CLI hands its built-in extensions to the loader it builds; this one started
|
|
852
|
+
// with none, so a `builtin:<name>` entry in `extensions:` resolved to nothing and
|
|
853
|
+
// pi's MCP, codemode, and tool search were out of reach for every subagent. As
|
|
854
|
+
// `builtin:` resources they load as in the main session — on by default, disabled by
|
|
855
|
+
// `-builtin:<name>` in settings — and the per-agent allowlist filters them like any
|
|
856
|
+
// other extension. Fresh factories per spawn: MCP state lives in the closure.
|
|
857
|
+
extensionFactories: [
|
|
858
|
+
{ name: "codemode", factory: createCodemodeExtension(), builtin: true, replaceable: true },
|
|
859
|
+
{ name: "tool-search", factory: createToolSearchExtension(), builtin: true, replaceable: true },
|
|
860
|
+
{ name: "mcp", factory: createMcpExtension(), builtin: true, replaceable: true },
|
|
861
|
+
// Under `noExtensions` the static allowlist gates the registry itself.
|
|
862
|
+
...(noExtensions ? [] : [scopeGateExtension(nestedCallGate)]),
|
|
863
|
+
],
|
|
864
|
+
// Mirrors pi's CLI: `--no-mcp` keeps MCP out of subagents too, and `--no-extensions`
|
|
865
|
+
// keeps all the built-ins out. Discovered extensions still follow `extensions:`.
|
|
866
|
+
disabledBuiltinExtensions: process.argv.some((a) => a === "--no-extensions" || a === "-ne")
|
|
867
|
+
? ["codemode", "tool-search", "mcp"]
|
|
868
|
+
: process.argv.includes("--no-mcp")
|
|
869
|
+
? ["mcp"]
|
|
870
|
+
: undefined,
|
|
551
871
|
cwd: configCwd,
|
|
552
872
|
agentDir,
|
|
553
873
|
noExtensions,
|
|
@@ -557,10 +877,15 @@ export async function runAgent(
|
|
|
557
877
|
noPromptTemplates: true,
|
|
558
878
|
noThemes: true,
|
|
559
879
|
noContextFiles: true,
|
|
560
|
-
|
|
880
|
+
// pi reads this once, at the end of `reload()`, with the extension set already
|
|
881
|
+
// filtered — so the prompt is retargeted at what the child really loads.
|
|
882
|
+
systemPromptOverride: () => childPrompt(loader.getExtensions()),
|
|
561
883
|
appendSystemPromptOverride: () => [],
|
|
562
884
|
});
|
|
563
|
-
|
|
885
|
+
// Marked as a child session: the extension factory runs inside reload(), and a
|
|
886
|
+
// subagent activation must not register the parent-facing tools (see
|
|
887
|
+
// child-context.ts).
|
|
888
|
+
await runInChildSessionContext(() => loader.reload());
|
|
564
889
|
|
|
565
890
|
// Plain entries in `tools:` are expected to be built-in names (extension tools
|
|
566
891
|
// go through `ext:`), so an unknown name there is unambiguously a typo. Previously
|
|
@@ -639,78 +964,98 @@ export async function runAgent(
|
|
|
639
964
|
// Resolve thinking level: explicit option > agent config > undefined (inherit)
|
|
640
965
|
const thinkingLevel = options.thinkingLevel ?? agentConfig?.thinking;
|
|
641
966
|
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
// explicit
|
|
652
|
-
//
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
if (optInActive && !canons.some((c) => extNames.has(c))) continue;
|
|
659
|
-
// First alias that carries a narrowing set — a user won't narrow one
|
|
660
|
-
// extension under two different names, so first-match is correct.
|
|
661
|
-
const narrowed = canons.map((c) => narrowing.get(c)).find(Boolean);
|
|
662
|
-
for (const toolName of extension.tools.keys()) {
|
|
663
|
-
if (narrowed && !narrowed.has(toolName)) continue;
|
|
664
|
-
extensionToolNames.push(toolName);
|
|
665
|
-
}
|
|
666
|
-
}
|
|
967
|
+
// Child-session model scope: agent frontmatter override, else inherit the
|
|
968
|
+
// parent's resolved scope. Isolated agents load no extensions, so the
|
|
969
|
+
// fallback consumer can never bind — an empty scope is the honest value.
|
|
970
|
+
const scopedModels = resolveScopedModels(
|
|
971
|
+
options.isolated ? false : agentConfig?.scopedModels,
|
|
972
|
+
ctx.scopedModels,
|
|
973
|
+
ctx.modelRegistry,
|
|
974
|
+
(message) => options.onToolActivity?.({ type: "end", toolName: `scoped-models-error:${message}` }),
|
|
975
|
+
);
|
|
976
|
+
// An explicit list under isolation is silently unreachable — flag it the
|
|
977
|
+
// same way the loader flags contradictory extensions:/exclude_extensions:.
|
|
978
|
+
if (options.isolated && Array.isArray(agentConfig?.scopedModels)) {
|
|
979
|
+
options.onToolActivity?.({
|
|
980
|
+
type: "end",
|
|
981
|
+
toolName: `scoped-models-error:scoped_models has no effect for agent "${type}" — isolated agents load no extensions`,
|
|
982
|
+
});
|
|
667
983
|
}
|
|
668
984
|
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
//
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
// at the loader (extensionsOverride / noExtensions) and at enumeration
|
|
680
|
-
// (`ext:` opt-in flip), so any extension tool in `extensionToolNames` is allowed.
|
|
681
|
-
return !noExtensions;
|
|
682
|
-
});
|
|
985
|
+
const extensionTools = scopedExtensionTools(loader.getExtensions());
|
|
986
|
+
const allowedTools = scopedToolNames(extensionTools);
|
|
987
|
+
const declaredTools = declarableTools(extensionTools);
|
|
988
|
+
// The same scope, for calls that skip the allowlist: pi keeps a registered MCP tool
|
|
989
|
+
// callable through `ctx.executeTool()` (a codemode script) and through `tool_search`
|
|
990
|
+
// even when the agent never selected it.
|
|
991
|
+
nestedCallGate.allows = (name) =>
|
|
992
|
+
((!EXCLUDED_TOOL_NAMES.includes(name) || nestedToolNames.has(name)) &&
|
|
993
|
+
!disallowed?.(name) &&
|
|
994
|
+
(toolNames.includes(name) || nestedToolNames.has(name) || scopedExtensionTools(loader.getExtensions()).has(name)));
|
|
683
995
|
|
|
684
|
-
const settingsManager = SettingsManager.create(configCwd, agentDir);
|
|
685
996
|
const configuredSessionDir = resolveConfiguredSessionDir(agentConfig?.sessionDir, effectiveCwd);
|
|
686
997
|
const defaultSessionDir = process.env.PI_CODING_AGENT_SESSION_DIR ?? settingsManager.getSessionDir?.();
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
//
|
|
692
|
-
//
|
|
693
|
-
//
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
998
|
+
// A subagent session is ALWAYS written to disk while it runs: a crashed or
|
|
999
|
+
// killed run must leave a transcript pi's /resume can recover. Retention
|
|
1000
|
+
// after the run settles is the manager's decision (`persist_session`), never a
|
|
1001
|
+
// creation-time one.
|
|
1002
|
+
//
|
|
1003
|
+
// The exception is a host session that is itself in-memory: pi's own
|
|
1004
|
+
// `--no-session`, or any SDK host that built `SessionManager.inMemory`. Its
|
|
1005
|
+
// absence of a session file is the exact signal — an argv scan would miss the
|
|
1006
|
+
// SDK host and let an ephemeral session leave children on disk.
|
|
1007
|
+
// The same path is `parentSession`, which nests the child under its spawner.
|
|
1008
|
+
const parentSession = ctx.sessionManager.getSessionFile();
|
|
1009
|
+
const sessionManager = parentSession === undefined
|
|
1010
|
+
? SessionManager.inMemory(effectiveCwd)
|
|
1011
|
+
: SessionManager.create(
|
|
1012
|
+
effectiveCwd,
|
|
1013
|
+
configuredSessionDir ?? defaultSessionDir,
|
|
1014
|
+
{ parentSession },
|
|
1015
|
+
);
|
|
1016
|
+
|
|
1017
|
+
type AgentSessionOptions = NonNullable<Parameters<typeof createAgentSession>[0]>;
|
|
1018
|
+
// ExtensionContext exposes only the ModelRegistry facade; the runtime it wraps
|
|
1019
|
+
// is what createAgentSession needs to keep extension-registered providers
|
|
1020
|
+
// (test/e2e/isolated-provider.e2e.test.ts guards the private `.runtime` reach).
|
|
1021
|
+
const parentModelRuntime = (
|
|
1022
|
+
ctx.modelRegistry as unknown as { runtime?: AgentSessionOptions["modelRuntime"] }
|
|
1023
|
+
).runtime;
|
|
1024
|
+
const sessionOpts: AgentSessionOptions = {
|
|
699
1025
|
cwd: effectiveCwd,
|
|
700
1026
|
agentDir,
|
|
701
1027
|
sessionManager,
|
|
702
|
-
settingsManager,
|
|
703
|
-
modelRegistry: ctx.modelRegistry,
|
|
1028
|
+
settingsManager: sessionSettingsManager,
|
|
704
1029
|
...(parentModelRuntime !== undefined && { modelRuntime: parentModelRuntime }),
|
|
705
1030
|
model,
|
|
1031
|
+
scopedModels,
|
|
706
1032
|
tools: allowedTools,
|
|
1033
|
+
// The denylist has to reach pi as well as the allowlist math: MCP tools are
|
|
1034
|
+
// kept registered even when the allowlist does not name them (pi only filters
|
|
1035
|
+
// MCP when the allowlist mentions `mcp__`), so a `disallowed_tools: mcp__…`
|
|
1036
|
+
// entry would otherwise stay registered, listable by `tool_search`, and merely
|
|
1037
|
+
// blocked at call time by the scope gate. Excluding it here removes it from the
|
|
1038
|
+
// registry instead.
|
|
1039
|
+
...(disallowed && { excludeTools: agentConfig?.disallowedTools }),
|
|
1040
|
+
...(nestedTools.length > 0 && { customTools: nestedTools }),
|
|
707
1041
|
resourceLoader: loader,
|
|
708
1042
|
};
|
|
709
1043
|
if (thinkingLevel) {
|
|
710
1044
|
sessionOpts.thinkingLevel = thinkingLevel;
|
|
711
1045
|
}
|
|
712
1046
|
|
|
713
|
-
const { session } = await createAgentSession(sessionOpts);
|
|
1047
|
+
const { session } = await runInChildSessionContext(() => createAgentSession(sessionOpts));
|
|
1048
|
+
|
|
1049
|
+
// pi activates every tool its allowlist names, `codemode`/`deferred` exposure
|
|
1050
|
+
// included. Those must stay undeclared, as in the main session, so take back the
|
|
1051
|
+
// ones pi would not declare. They stay registered and re-activatable: codemode and
|
|
1052
|
+
// `tool_search` reach them, and the scope gate above still applies.
|
|
1053
|
+
const undeclared = allowedTools.filter((name) => !declaredTools.includes(name));
|
|
1054
|
+
if (undeclared.length > 0) {
|
|
1055
|
+
const keep = new Set(session.getActiveToolNames());
|
|
1056
|
+
for (const name of undeclared) keep.delete(name);
|
|
1057
|
+
session.setActiveToolsByName([...keep]);
|
|
1058
|
+
}
|
|
714
1059
|
|
|
715
1060
|
const baseSessionName = agentConfig?.name ?? type;
|
|
716
1061
|
session.setSessionName(
|
|
@@ -772,6 +1117,8 @@ export async function runAgent(
|
|
|
772
1117
|
input: u.input ?? 0,
|
|
773
1118
|
output: u.output ?? 0,
|
|
774
1119
|
cacheWrite: u.cacheWrite ?? 0,
|
|
1120
|
+
cacheRead: u.cacheRead ?? 0,
|
|
1121
|
+
cost: u.cost?.total ?? 0,
|
|
775
1122
|
});
|
|
776
1123
|
}
|
|
777
1124
|
if (event.type === "compaction_end" && !event.aborted && event.result) {
|
|
@@ -779,9 +1126,6 @@ export async function runAgent(
|
|
|
779
1126
|
}
|
|
780
1127
|
});
|
|
781
1128
|
|
|
782
|
-
const collector = collectResponseText(session);
|
|
783
|
-
const cleanupAbort = forwardAbortSignal(session, options.signal);
|
|
784
|
-
|
|
785
1129
|
// Build the effective prompt: optionally prepend parent context
|
|
786
1130
|
let effectivePrompt = prompt;
|
|
787
1131
|
if (options.inheritContext) {
|
|
@@ -794,6 +1138,9 @@ export async function runAgent(
|
|
|
794
1138
|
// Boundary for the history fallback: only assistant text produced from here
|
|
795
1139
|
// on counts as this run's output (a fresh session, so usually 0).
|
|
796
1140
|
const startLen = session.messages.length;
|
|
1141
|
+
// Subscribed after the parent-context build so a throw there cannot leak them.
|
|
1142
|
+
const collector = collectResponseText(session);
|
|
1143
|
+
const cleanupAbort = forwardAbortSignal(session, options.signal);
|
|
797
1144
|
try {
|
|
798
1145
|
await session.prompt(effectivePrompt);
|
|
799
1146
|
} finally {
|
|
@@ -803,7 +1150,13 @@ export async function runAgent(
|
|
|
803
1150
|
}
|
|
804
1151
|
|
|
805
1152
|
const responseText = collector.getText().trim() || getLastAssistantText(session, startLen);
|
|
806
|
-
return {
|
|
1153
|
+
return {
|
|
1154
|
+
responseText,
|
|
1155
|
+
session,
|
|
1156
|
+
aborted,
|
|
1157
|
+
steered: softLimitReached,
|
|
1158
|
+
failure: finalTurnError(session, startLen, collector.getLastAssistant()),
|
|
1159
|
+
};
|
|
807
1160
|
}
|
|
808
1161
|
|
|
809
1162
|
/**
|
|
@@ -814,7 +1167,9 @@ export async function resumeAgent(
|
|
|
814
1167
|
prompt: string,
|
|
815
1168
|
options: {
|
|
816
1169
|
onToolActivity?: (activity: ToolActivity) => void;
|
|
817
|
-
|
|
1170
|
+
onTextDelta?: (delta: string, fullText: string) => void;
|
|
1171
|
+
onTurnEnd?: (turnCount: number) => void;
|
|
1172
|
+
onAssistantUsage?: (usage: LifetimeUsage) => void;
|
|
818
1173
|
onCompaction?: (info: { reason: "manual" | "threshold" | "overflow"; tokensBefore: number }) => void;
|
|
819
1174
|
signal?: AbortSignal;
|
|
820
1175
|
} = {},
|
|
@@ -826,8 +1181,24 @@ export async function resumeAgent(
|
|
|
826
1181
|
const collector = collectResponseText(session);
|
|
827
1182
|
const cleanupAbort = forwardAbortSignal(session, options.signal);
|
|
828
1183
|
|
|
829
|
-
|
|
1184
|
+
// Per-message bookkeeping mirrors runAgent: reset the accumulated text on
|
|
1185
|
+
// each new message_start and count completed turns, so the resumed run's
|
|
1186
|
+
// streaming/stat readouts are not stale.
|
|
1187
|
+
let currentMessageText = "";
|
|
1188
|
+
let turnCount = 0;
|
|
1189
|
+
const unsubEvents = (options.onToolActivity || options.onTextDelta || options.onTurnEnd || options.onAssistantUsage || options.onCompaction)
|
|
830
1190
|
? session.subscribe((event: AgentSessionEvent) => {
|
|
1191
|
+
if (event.type === "turn_end") {
|
|
1192
|
+
turnCount++;
|
|
1193
|
+
options.onTurnEnd?.(turnCount);
|
|
1194
|
+
}
|
|
1195
|
+
if (event.type === "message_start") {
|
|
1196
|
+
currentMessageText = "";
|
|
1197
|
+
}
|
|
1198
|
+
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
|
1199
|
+
currentMessageText += event.assistantMessageEvent.delta;
|
|
1200
|
+
options.onTextDelta?.(event.assistantMessageEvent.delta, currentMessageText);
|
|
1201
|
+
}
|
|
831
1202
|
if (event.type === "tool_execution_start") options.onToolActivity?.({ type: "start", toolName: event.toolName });
|
|
832
1203
|
if (event.type === "tool_execution_end") options.onToolActivity?.({ type: "end", toolName: event.toolName });
|
|
833
1204
|
if (event.type === "message_end" && event.message.role === "assistant") {
|
|
@@ -836,6 +1207,8 @@ export async function resumeAgent(
|
|
|
836
1207
|
input: u.input ?? 0,
|
|
837
1208
|
output: u.output ?? 0,
|
|
838
1209
|
cacheWrite: u.cacheWrite ?? 0,
|
|
1210
|
+
cacheRead: u.cacheRead ?? 0,
|
|
1211
|
+
cost: u.cost?.total ?? 0,
|
|
839
1212
|
});
|
|
840
1213
|
}
|
|
841
1214
|
if (event.type === "compaction_end" && !event.aborted && event.result) {
|
|
@@ -854,7 +1227,7 @@ export async function resumeAgent(
|
|
|
854
1227
|
|
|
855
1228
|
return {
|
|
856
1229
|
text: collector.getText().trim() || getLastAssistantText(session, startLen),
|
|
857
|
-
failure: finalTurnError(session, startLen),
|
|
1230
|
+
failure: finalTurnError(session, startLen, collector.getLastAssistant()),
|
|
858
1231
|
};
|
|
859
1232
|
}
|
|
860
1233
|
|