@kushalkhemka/opensec-pi-subagents 0.20.4 → 0.20.5

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 CHANGED
@@ -7,6 +7,61 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.20.5] - 2026-10-16
11
+
12
+ Orchestration results the main agent can act on: failed runs are error results with what they produced, unfinished
13
+ agents point at the completion notification instead of inviting polling, and a released agent's result stays readable.
14
+
15
+ > **Possibly breaking:**
16
+ > - **Failed, aborted and stopped runs return `isError: true`**, as do unknown-agent lookups and refused steers or
17
+ > resumes. Pi shows them as tool errors and the main agent reads them as failures.
18
+ > - **A model name whose closest matches span several providers is refused** instead of resolving to one of them,
19
+ > unless the parent's provider is among them. Use the exact `provider/id` to choose another provider.
20
+
21
+ ### Added
22
+
23
+ - **`get_subagent_result` reads a released agent's result from its saved session.** After the 10-minute cleanup it
24
+ used to answer `Agent not found`; it now answers with the status and model from the kept resume state and the final
25
+ result read from the session file, marked as such. Nothing is held in memory longer: the file is read on demand. The
26
+ resume state now records the run's status.
27
+ - **Each run records the model and thinking level it ran with**, a resumed run its own. The foreground result's
28
+ stats, `get_subagent_result` (`Model:`) and the completion notification (`<model>`, `<thinking>`) show them.
29
+ - **Malformed tool arguments from weak models are repaired before validation** (`prepareArguments`) on the top-level
30
+ and nested `Agent`, `get_subagent_result` and `steer_subagent` tools: the arguments object sent as a JSON string or
31
+ stuffed into one string field, arrays where a string is expected, `null` optional fields, booleans and numbers sent
32
+ as strings, and a missing `subagent_type` on the top-level `Agent` tool, which goes to the default type under the
33
+ `fallbackSubagent` policy (`Note: No agent type given — using …`). A missing prompt is never invented.
34
+
35
+ ### Changed
36
+
37
+ - **Failed runs are error results.** The foreground `Agent` result, resumed runs and `get_subagent_result`, top-level
38
+ and nested, set `isError` for `error`, `aborted` and `stopped`. The result renderer keeps the agent's status line for
39
+ these. A stopped run's headline reads `Agent was stopped by the user after …`, an aborted one `Agent was aborted
40
+ after …`, instead of `Agent completed in …`.
41
+ - **Failure results keep the partial output and name the session file.** The run's last assistant text is kept also
42
+ when the run threw or a stopped run was abandoned, bounded to the run's own turns, and the result names the agent's
43
+ saved session file, which holds every turn (`<session-file>` in the notification). No separate files are written.
44
+ - **No more polling prompts.** `get_subagent_result` on a running, queued or still-stopping agent says a completion
45
+ notification arrives on its own, to continue with other work, and not to call it again for that agent until then.
46
+ Its description and the `wait` parameter say the same; `wait` stays `false` by default. Background spawn and resume
47
+ results and the refused resume of a running agent no longer suggest `get_subagent_result` to wait.
48
+ - **Notifications preview the first 10 lines of a result**, still capped at 500 characters (300 per agent in a group)
49
+ so one long line cannot flood the context, and carry the full result's size in `<result_bytes>`. The truncation note
50
+ says how many lines and bytes were left out.
51
+ - **Model names no longer resolve across providers by guessing.** An exact `provider/id` (case-insensitive) resolves
52
+ as before. Among a short name's closest matches (`haiku`, `sonnet`), the parent's provider wins when it serves one;
53
+ matches from a single provider resolve to the tightest, as before (ties by id). Only matches that still span several
54
+ providers are an error naming up to five closest candidates. An exact id two providers serve follows the same rule.
55
+ A per-call model that does not resolve stays a tool error on every path.
56
+ - **An agent file's model that does not resolve is reported.** The run still falls back to the parent's model, but its
57
+ results say which model was asked for, why it was not used and which model ran instead (the `Agent` and nested
58
+ `Agent` results, `get_subagent_result`, `<model-note>` in the notification). Spawns that resolve the agent file's
59
+ model by exact `provider/id` only (`@mentions`, RPC, scheduled jobs) and scheduled jobs whose model no longer
60
+ resolves note it the same way; workflows show it as a warning once per run.
61
+ - **More released agents stay addressable:** 2000 top-level and 4000 nested or workflow entries (was 100 and 200).
62
+ An entry holds ids, paths and settings, about 450 bytes (700 with a worktree), never the conversation. Restoring
63
+ them at session start trims once instead of rescanning per entry.
64
+
10
65
  ## [0.20.4] - 2026-10-16
11
66
 
12
67
  The conversation viewer draws a subagent's transcript with Pi's own chat components, so it looks exactly like Pi's
package/README.md CHANGED
@@ -61,7 +61,7 @@ Agent({ subagent_type: "Explore", prompt: "Find authentication code", descriptio
61
61
  | Tool | What it does |
62
62
  |---|---|
63
63
  | `Agent` | Starts an agent. A background run returns an ID and notifies the main session when it finishes; set `run_in_background: false` when the next step must wait for its result. Also accepts session-scoped cron, interval, and one-shot schedules. |
64
- | `get_subagent_result` | Returns the full result of an agent. |
64
+ | `get_subagent_result` | Returns the full result of a finished agent, also after its record was released from memory. For an agent still running it says to wait for the completion notification instead of checking again (`wait: true` blocks, and is off by default). |
65
65
  | `steer_subagent` | Redirects a running agent. |
66
66
  | `SubagentWorkflow` | Runs repeatable multi-agent work (`agent()`, `parallel()`, `pipeline()`). |
67
67
 
@@ -82,6 +82,9 @@ When a background agent (or workflow) finishes, the main session is notified, as
82
82
  - If you interrupt the parent (`Esc`), what was waiting is kept and delivered once the interrupted turn has stopped,
83
83
  starting a turn.
84
84
  - Reading a result with `get_subagent_result` (or the RPC `consume`) before the notification is sent suppresses it.
85
+ - A notification carries the first 10 lines of the result (at most 500 characters, 300 per agent in a group), the
86
+ full result's size in `<result_bytes>`, the model and thinking level the run used (`<model>`, `<thinking>`), and,
87
+ when anything was cut, how many lines and bytes were left out. `get_subagent_result` returns the rest.
85
88
  - A result that lands while another session is open, or that is still waiting for its parent's boundary when you switch
86
89
  away, is recorded in its own session and delivered the same way when you return there (see
87
90
  [Switching sessions](#switching-sessions)).
@@ -93,6 +96,21 @@ The join mode (`/agents → Settings → Join mode`, `defaultJoinMode`) decides
93
96
  | `smart` (default) | No waiting: each result is delivered at the parent's next boundary. `async` is accepted as an older name for it. |
94
97
  | `group` | The background agents spawned in one parent turn report together, in one notification once all of them have finished (a lone spawn reports alone). A 30 s timeout after the first completion sends a partial notification; stragglers follow within 15 s. |
95
98
 
99
+ ### Failed runs
100
+
101
+ An agent that ends in an error, is aborted at its turn limit, or is stopped by the user is reported to the main agent
102
+ as an error result (`isError`), not as a completed task: the foreground `Agent` result, a resumed run, and
103
+ `get_subagent_result`, top-level and nested. So are lookups of an unknown agent and refused steers or resumes.
104
+
105
+ - The result keeps the run's last assistant text (`Partial output before the failure:` for an error), also when the
106
+ run threw or a stopped run had to be abandoned, and names the agent's saved session file, which holds every turn.
107
+ The background notification carries the file as `<session-file>`.
108
+ - A run the user stopped says so (`Agent was stopped by the user …`, `STOPPED BY THE USER`).
109
+ - Weak models' malformed tool arguments are repaired before Pi validates them: the arguments object sent as a JSON
110
+ string or stuffed into one field, arrays for strings, `null` optional fields, `"true"` or `"5"` for booleans and
111
+ numbers, and a missing `subagent_type` (the default type, under the `fallbackSubagent` policy). A missing prompt is
112
+ never invented.
113
+
96
114
  ### Sending a foreground agent to the background
97
115
 
98
116
  While a foreground `Agent` call runs (`run_in_background: false`), press `ctrl+b` at the prompt to hand it to the
@@ -167,8 +185,10 @@ Agent({ resume: "<id>", prompt: "Now check the tests too", description: "Continu
167
185
  - A worktree agent resumes in its worktree: it is restored at the same path from the agent's branch, and the new work
168
186
  is committed to that branch afterwards. If the branch is gone the resume is refused and nothing runs.
169
187
  - How long: a finished agent stays in memory for about ten minutes; after that it is reopened from its saved session
170
- file, under the same id, for as long as that file exists. Sessions are saved by default (`rememberAgents`; turn it
171
- off and agents are resumable only for those ten minutes).
188
+ file, under the same id, for as long as that file exists, and `get_subagent_result` reads its status, model and
189
+ final result from that file (marked as read from the saved session). Sessions are saved by default (`rememberAgents`;
190
+ turn it off and agents are resumable only for those ten minutes). The last 2000 such agents of a session stay
191
+ addressable, and 4000 nested and workflow children, at a few hundred bytes each.
172
192
  - What survives a restart, `/resume`, `/fork` or `/reload`: finished agents of that session (on its branch, for a fork)
173
193
  stay resumable by the model and by `@handle`. `/new` starts with none. Nested agents and workflow children are
174
194
  rebuilt too (their resume state is written to the parent session as `subagents:resume` entries, one small entry per
@@ -204,11 +224,20 @@ A subagent runs on the parent's current model at the parent's current thinking l
204
224
  tool, nested delegation, workflow `agent()`, `@mentions`, scheduled jobs and cross-extension RPC. For a nested agent the
205
225
  parent is the agent that spawned it.
206
226
 
207
- - An agent file's own `model:` or `thinking:` is your configuration and always applies.
227
+ - An agent file's own `model:` or `thinking:` is your configuration and always applies. `model:` takes an exact
228
+ `provider/id`, or a short name such as `haiku`. Among a short name's closest matches, the parent's provider wins when
229
+ it serves one, and matches from one provider resolve to the tightest. A name whose matches span several other
230
+ providers, or that matches nothing, is not guessed at: the agent runs on the parent's model and its result starts
231
+ with a note naming the model asked for, why it was not used (with up to five candidates for an ambiguous name), and
232
+ the model used instead. Workflows show the note as a warning. Spawns that pass no model of their own (`@mentions`, RPC, scheduled
233
+ jobs) match the agent file's `model:` by exact `provider/id` only, and note a fallback the same way.
234
+ - A model passed per call (with `allowModelOverride`) resolves the same way; one that is ambiguous across providers or
235
+ unknown is refused with the candidates.
208
236
  - Per-call choices are ignored by default: the `Agent` tool and the nested tool do not offer `model` or `thinking`,
209
237
  workflow `agent({ model, effort })` is ignored, and so are a scheduled job's and an RPC spawn's model and thinking
210
238
  level. This keeps the main model from picking cheaper models or lower effort on its own.
211
- - A resumed agent keeps the model and thinking level it ran with.
239
+ - A resumed agent keeps the model and thinking level it ran with. Each run records the model and level it actually ran
240
+ with; the foreground result, `get_subagent_result` and the notification show them.
212
241
  - `allowModelOverride: true` (project or global `subagents.json`, or `/agents → Settings → Model override`) restores the
213
242
  per-call overrides and the tool parameters. The resolution changes at once; the tool parameters at the next session.
214
243
 
@@ -209,6 +209,8 @@ interface SpawnOptions {
209
209
  onBeforeWorktreeCleanup?: (worktreePath: string) => Promise<void>;
210
210
  /** Resolved invocation snapshot captured for UI display. */
211
211
  invocation?: AgentInvocation;
212
+ /** The caller's note that the agent file's model did not resolve (see `AgentRecord.modelNote`). */
213
+ modelNote?: string;
212
214
  /** Parent abort signal — when aborted, the subagent is also stopped. */
213
215
  signal?: AbortSignal;
214
216
  /**
@@ -712,11 +714,20 @@ export declare class AgentManager {
712
714
  */
713
715
  private tombstone;
714
716
  private keepTombstone;
717
+ /**
718
+ * Bound the memory a long session can accumulate, per kind (top-level or
719
+ * owned). Oldest first, since the agent someone still wants to reach is the
720
+ * one they used most recently. One counting pass; the sort runs only for a
721
+ * kind that is over its cap.
722
+ */
723
+ private trimTombstones;
715
724
  /**
716
725
  * Make agents of an earlier activation (before a restart, `/resume`,
717
726
  * `/fork` or `/reload`) resumable again, from what was persisted in the
718
727
  * parent session. Later entries for the same id win. A live record keeps
719
- * precedence: it is the newer state of the same agent.
728
+ * precedence: it is the newer state of the same agent. Trimmed once at the
729
+ * end rather than per entry, so a session with thousands of entries restores
730
+ * in one pass.
720
731
  */
721
732
  restoreTombstones(states: readonly AgentResumeState[]): void;
722
733
  private cleanup;
@@ -18,6 +18,7 @@ import { existsSync, statSync } from "node:fs";
18
18
  import { isAbsolute } from "node:path";
19
19
  import { resumeAgent, runAgent } from "./agent-runner.js";
20
20
  import { getAgentConfig } from "./agent-types.js";
21
+ import { extractText } from "./context.js";
21
22
  import { assignHandle, handleBase } from "./mention.js";
22
23
  import { describeModel } from "./model-resolver.js";
23
24
  import { sessionFileProblem } from "./session-file.js";
@@ -64,16 +65,19 @@ const DEFAULT_MAX_CONCURRENT = 10;
64
65
  */
65
66
  const DEFAULT_MAX_CONCURRENT_FOREGROUND = 0;
66
67
  /**
67
- * How many evicted agents stay addressable by name. Only a bound on memory —
68
- * a session that spawns hundreds of agents shouldn't retain every one — and
69
- * far above the handful anyone keeps in their head.
68
+ * How many evicted agents stay addressable — by name, by resume, and by
69
+ * get_subagent_result, which reads a released agent's result from its saved
70
+ * session. Only a bound on memory: an entry is a few hundred bytes of ids and
71
+ * paths (about 450 B, 700 B with a worktree), never the conversation, so 2000
72
+ * cost about 1 MB. High because a session that spawns hundreds of agents
73
+ * should still be able to read the result of any of them.
70
74
  */
71
- const MAX_TOMBSTONES = 100;
75
+ const MAX_TOMBSTONES = 2000;
72
76
  /**
73
77
  * The same bound for owned children — nested agents and workflow children —
74
78
  * counted apart, so a large workflow cannot push the session's own agents out.
75
79
  */
76
- const MAX_OWNED_TOMBSTONES = 200;
80
+ const MAX_OWNED_TOMBSTONES = 4000;
77
81
  /**
78
82
  * Validate a caller-supplied SpawnOptions.cwd. `undefined`/`null` mean "unset"
79
83
  * (parent cwd). Anything else must be an absolute path to an existing
@@ -234,6 +238,72 @@ function worktreeNote(wtResult, baseCwd, customCwd) {
234
238
  lines.push(`- ${note}`);
235
239
  return lines.length > 0 ? `\n\n---\n${lines.join("\n")}` : "";
236
240
  }
241
+ /** How many messages a session holds — where a run's own turns begin. 0 for a stub. */
242
+ function messageCount(session) {
243
+ try {
244
+ return session?.messages?.length ?? 0;
245
+ }
246
+ catch {
247
+ return 0;
248
+ }
249
+ }
250
+ /**
251
+ * The last assistant text a session holds from message `start` on: what a run
252
+ * that threw or was abandoned had produced. Bounded like the runner's own
253
+ * fallback, so a failed resume never surfaces the previous run's answer
254
+ * (#144). "" for none, or for a session that cannot be read.
255
+ */
256
+ function salvageText(session, start) {
257
+ try {
258
+ const messages = session?.messages ?? [];
259
+ for (let i = messages.length - 1; i >= start; i--) {
260
+ const msg = messages[i];
261
+ if (msg.role !== "assistant")
262
+ continue;
263
+ const text = extractText(msg.content).trim();
264
+ if (text)
265
+ return text;
266
+ }
267
+ }
268
+ catch { /* a disposed or partial session has nothing to give */ }
269
+ return "";
270
+ }
271
+ /** Keep what a failed run produced on its record, unless the run already reported text. */
272
+ function salvagePartial(record, start) {
273
+ if (record.result?.trim())
274
+ return;
275
+ const text = salvageText(record.session, start);
276
+ if (text)
277
+ record.result = text;
278
+ }
279
+ /**
280
+ * Record the model and thinking level a run actually runs with, from its
281
+ * session. Both are only knowable once pi has resolved its defaults and clamped
282
+ * the level to what the model supports. Writing them back makes the record
283
+ * authoritative, so every surface (the widget, the notification, the result
284
+ * tools) reads one place instead of each re-deriving "session, else the
285
+ * request" for itself. Called at the start of every run, so a resumed run
286
+ * records its own.
287
+ */
288
+ function recordRunModel(record, session) {
289
+ if (!session.model)
290
+ return;
291
+ record.invocation ??= {};
292
+ // Read the kept request first: a caller's level survives being clamped
293
+ // AND, one line later, being replaced by the effective one.
294
+ const requested = record.invocation.requestedThinking ?? record.invocation.thinking;
295
+ Object.assign(record.invocation, describeModel(session.model));
296
+ // Guarded: a session that reports no level keeps the request rather than
297
+ // losing it. Overwriting unconditionally would turn an older or stubbed
298
+ // session into a blank `thinking:` tag, which is worse than the
299
+ // stale-but-true value it replaced.
300
+ if (session.thinkingLevel) {
301
+ record.invocation.thinking = session.thinkingLevel;
302
+ if (requested && requested !== session.thinkingLevel) {
303
+ record.invocation.requestedThinking = requested;
304
+ }
305
+ }
306
+ }
237
307
  /**
238
308
  * Whether a run is in flight or waiting for a slot. A function rather than an
239
309
  * inline test so TypeScript does not narrow `status` across an `await` that
@@ -276,6 +346,7 @@ export function resumeStateOf(record) {
276
346
  description: record.description,
277
347
  sessionFile: record.sessionFile,
278
348
  completedAt: record.completedAt ?? Date.now(),
349
+ status: record.status,
279
350
  handle: record.handle,
280
351
  alias: record.alias,
281
352
  parentAgentId: record.parentAgentId,
@@ -733,6 +804,7 @@ export class AgentManager {
733
804
  // `options` has stopped being the interesting object.
734
805
  blocking: options.blocking,
735
806
  invocation: options.invocation,
807
+ modelNote: options.modelNote,
736
808
  depth: options.depth ?? 1,
737
809
  parentAgentId: options.parentAgentId,
738
810
  workflowId: options.workflowId,
@@ -1012,6 +1084,9 @@ export class AgentManager {
1012
1084
  this.onStart?.(record);
1013
1085
  }
1014
1086
  catch { /* a start observer must not fail the run */ }
1087
+ // Where this run's turns begin in its session: past the history of a
1088
+ // reopened conversation. Set once the session exists.
1089
+ let runStart = 0;
1015
1090
  // Wire parent abort signal to stop the subagent when the parent is interrupted.
1016
1091
  // A queued spawn can start minutes after the caller handed us its signal,
1017
1092
  // by which time it may already be aborted — and `addEventListener` would
@@ -1075,6 +1150,7 @@ export class AgentManager {
1075
1150
  return;
1076
1151
  }
1077
1152
  record.session = session;
1153
+ runStart = messageCount(session);
1078
1154
  // Capture now, while the session object exists: after eviction this
1079
1155
  // path is the only thing that can reopen the conversation, and an
1080
1156
  // in-memory session reports undefined, which correctly means
@@ -1084,28 +1160,7 @@ export class AgentManager {
1084
1160
  // stubbed session must degrade to "not resumable" rather than throw
1085
1161
  // and take the whole spawn down with it.
1086
1162
  record.sessionFile = session.sessionManager?.getSessionFile?.();
1087
- // Same reason, different field: the model and thinking level are only
1088
- // knowable once pi has resolved its defaults and clamped the level to
1089
- // what the model supports. Writing them back here makes the record
1090
- // authoritative, so every surface reads one place instead of each
1091
- // re-deriving "session, else the request" for itself.
1092
- if (session.model) {
1093
- record.invocation ??= {};
1094
- // Read the kept request first: a caller's level survives being clamped
1095
- // AND, one line later, being replaced by the effective one.
1096
- const requested = record.invocation.requestedThinking ?? record.invocation.thinking;
1097
- Object.assign(record.invocation, describeModel(session.model));
1098
- // Guarded for the reason above: a session that reports no level keeps
1099
- // the request rather than losing it. Overwriting unconditionally would
1100
- // turn an older or stubbed session into a blank `thinking:` tag, which
1101
- // is worse than the stale-but-true value it replaced.
1102
- if (session.thinkingLevel) {
1103
- record.invocation.thinking = session.thinkingLevel;
1104
- if (requested && requested !== session.thinkingLevel) {
1105
- record.invocation.requestedThinking = requested;
1106
- }
1107
- }
1108
- }
1163
+ recordRunModel(record, session);
1109
1164
  // Flush any steers that arrived before the session was ready
1110
1165
  if (record.pendingSteers?.length) {
1111
1166
  for (const msg of record.pendingSteers) {
@@ -1121,12 +1176,13 @@ export class AgentManager {
1121
1176
  catch { /* ignore caller wiring errors */ }
1122
1177
  },
1123
1178
  })
1124
- .then(async ({ responseText, session, aborted, steered, failure, structuredJson, structuredRetried, maxTurns }) => {
1179
+ .then(async ({ responseText, session, aborted, steered, failure, structuredJson, structuredRetried, maxTurns, modelNote }) => {
1125
1180
  // Abandoned after a stop it ignored: the record was settled without
1126
1181
  // this run, and may already belong to a newer one.
1127
1182
  if (run.abandoned)
1128
1183
  return responseText;
1129
1184
  record.maxTurns = maxTurns;
1185
+ record.modelNote ??= modelNote;
1130
1186
  // Don't overwrite status if externally stopped via abort()
1131
1187
  if (record.status !== "stopped") {
1132
1188
  // Precedence: a hard abort keeps "aborted"; then a failed final turn
@@ -1189,6 +1245,8 @@ export class AgentManager {
1189
1245
  }
1190
1246
  record.error = err instanceof Error ? err.message : String(err);
1191
1247
  record.completedAt ??= Date.now();
1248
+ // A run that threw reported no text; keep what its turns produced.
1249
+ salvagePartial(record, runStart);
1192
1250
  detach();
1193
1251
  // Final flush of streaming output file on error
1194
1252
  if (record.outputCleanup) {
@@ -1221,6 +1279,7 @@ export class AgentManager {
1221
1279
  // done, minus the result it never produced.
1222
1280
  run.onAbandon = async () => {
1223
1281
  detach();
1282
+ salvagePartial(record, runStart);
1224
1283
  if (record.outputCleanup) {
1225
1284
  try {
1226
1285
  record.outputCleanup();
@@ -1539,6 +1598,8 @@ export class AgentManager {
1539
1598
  record.result = undefined;
1540
1599
  record.error = undefined;
1541
1600
  const session = record.session;
1601
+ recordRunModel(record, session);
1602
+ const runStart = messageCount(session);
1542
1603
  const abortController = new AbortController();
1543
1604
  record.abortController = abortController;
1544
1605
  const onParentAbort = () => this.abort(id);
@@ -1589,6 +1650,7 @@ export class AgentManager {
1589
1650
  record.error = err instanceof Error ? err.message : String(err);
1590
1651
  }
1591
1652
  record.completedAt ??= Date.now();
1653
+ salvagePartial(record, runStart);
1592
1654
  }
1593
1655
  signal?.removeEventListener("abort", onParentAbort);
1594
1656
  this.abortOwnedChildren(id);
@@ -1598,6 +1660,7 @@ export class AgentManager {
1598
1660
  return record.result ?? "";
1599
1661
  })();
1600
1662
  run.onAbandon = async () => {
1663
+ salvagePartial(record, runStart);
1601
1664
  signal?.removeEventListener("abort", onParentAbort);
1602
1665
  this.abortOwnedChildren(id);
1603
1666
  await this.finishResumedWorktree(record);
@@ -1763,6 +1826,8 @@ export class AgentManager {
1763
1826
  return;
1764
1827
  record.status = "running";
1765
1828
  record.startedAt = Date.now();
1829
+ const runStart = messageCount(record.session);
1830
+ recordRunModel(record, record.session);
1766
1831
  const run = this.beginRun(id, record, occupiesPoolSlot(record) ? "background" : undefined);
1767
1832
  try {
1768
1833
  this.onStart?.(record);
@@ -1793,6 +1858,10 @@ export class AgentManager {
1793
1858
  const windDown = async () => {
1794
1859
  detachParentSignal?.();
1795
1860
  detachParentSignal = undefined;
1861
+ // What a run that threw or was abandoned had produced; a settled run's
1862
+ // own text is already on the record.
1863
+ if (record.status === "error" || record.status === "stopped")
1864
+ salvagePartial(record, runStart);
1796
1865
  // Final flush of streaming output file
1797
1866
  if (record.outputCleanup) {
1798
1867
  try {
@@ -2033,26 +2102,36 @@ export class AgentManager {
2033
2102
  }
2034
2103
  keepTombstone(state) {
2035
2104
  this.tombstones.set(state.id, state);
2036
- // Bound the memory a long session can accumulate, per kind (top-level or
2037
- // owned). Oldest first, since the agent someone still wants to reach is the
2038
- // one they used most recently.
2039
- const topLevel = isTopLevelAgent(state);
2040
- const sameKind = [...this.tombstones.values()].filter(entry => isTopLevelAgent(entry) === topLevel);
2041
- let excess = sameKind.length - (topLevel ? MAX_TOMBSTONES : MAX_OWNED_TOMBSTONES);
2042
- if (excess <= 0)
2043
- return;
2044
- sameKind.sort((a, b) => a.completedAt - b.completedAt);
2045
- for (const entry of sameKind) {
2046
- if (excess-- <= 0)
2047
- break;
2048
- this.tombstones.delete(entry.id);
2105
+ this.trimTombstones();
2106
+ }
2107
+ /**
2108
+ * Bound the memory a long session can accumulate, per kind (top-level or
2109
+ * owned). Oldest first, since the agent someone still wants to reach is the
2110
+ * one they used most recently. One counting pass; the sort runs only for a
2111
+ * kind that is over its cap.
2112
+ */
2113
+ trimTombstones() {
2114
+ let topLevel = 0;
2115
+ for (const entry of this.tombstones.values())
2116
+ if (isTopLevelAgent(entry))
2117
+ topLevel++;
2118
+ const owned = this.tombstones.size - topLevel;
2119
+ for (const [kindIsTopLevel, excessCount] of [[true, topLevel - MAX_TOMBSTONES], [false, owned - MAX_OWNED_TOMBSTONES]]) {
2120
+ if (excessCount <= 0)
2121
+ continue;
2122
+ const sameKind = [...this.tombstones.values()].filter(entry => isTopLevelAgent(entry) === kindIsTopLevel);
2123
+ sameKind.sort((a, b) => a.completedAt - b.completedAt);
2124
+ for (const entry of sameKind.slice(0, excessCount))
2125
+ this.tombstones.delete(entry.id);
2049
2126
  }
2050
2127
  }
2051
2128
  /**
2052
2129
  * Make agents of an earlier activation (before a restart, `/resume`,
2053
2130
  * `/fork` or `/reload`) resumable again, from what was persisted in the
2054
2131
  * parent session. Later entries for the same id win. A live record keeps
2055
- * precedence: it is the newer state of the same agent.
2132
+ * precedence: it is the newer state of the same agent. Trimmed once at the
2133
+ * end rather than per entry, so a session with thousands of entries restores
2134
+ * in one pass.
2056
2135
  */
2057
2136
  restoreTombstones(states) {
2058
2137
  for (const state of states) {
@@ -2062,8 +2141,9 @@ export class AgentManager {
2062
2141
  const current = this.tombstones.get(state.id);
2063
2142
  if (current && current.completedAt > state.completedAt)
2064
2143
  continue;
2065
- this.keepTombstone(state);
2144
+ this.tombstones.set(state.id, state);
2066
2145
  }
2146
+ this.trimTombstones();
2067
2147
  }
2068
2148
  cleanup() {
2069
2149
  const cutoff = Date.now() - 10 * 60_000;
@@ -359,6 +359,11 @@ export interface RunResult {
359
359
  structuredRetried?: boolean;
360
360
  /** The turn limit this run enforced (undefined = unlimited), for later resumes. */
361
361
  maxTurns?: number;
362
+ /**
363
+ * Set when the agent file's `model:` could not be used and the run fell back
364
+ * to the parent's model: the visible note for the record (`modelNote`).
365
+ */
366
+ modelNote?: string;
362
367
  }
363
368
  export declare function runAgent(ctx: ExtensionContext, type: SubagentType, prompt: string, options: RunOptions): Promise<RunResult>;
364
369
  /** Callbacks and limits for one run of prompts on a session (first run or resume). */
@@ -11,6 +11,7 @@ import { buildParentContext, extractText } from "./context.js";
11
11
  import { DEFAULT_AGENTS } from "./default-agents.js";
12
12
  import { detectEnv } from "./env.js";
13
13
  import { buildMemoryBlock, buildReadOnlyMemoryBlock } from "./memory.js";
14
+ import { unresolvedModelNote } from "./model-resolver.js";
14
15
  import { createNestedSubagentTools, getMaxSubagentDepth } from "./nested-tools.js";
15
16
  import { isProjectCodeTrusted, isProjectTrustedBy } from "./project-trust.js";
16
17
  import { buildAgentPrompt } from "./prompts.js";
@@ -909,6 +910,15 @@ export async function runAgent(ctx, type, prompt, options) {
909
910
  }
910
911
  // Resolve model: explicit option > config.model > parent model
911
912
  const model = options.model ?? resolveDefaultModel(parent.model, parent.modelRegistry, agentConfig?.model);
913
+ // Spawns that pass no model (mentions, RPC, the scheduler) resolve the agent
914
+ // file's here, by exact provider/id only. A pin that falls back to the parent
915
+ // still runs, but says so rather than passing the parent's model off as it.
916
+ const configModel = agentConfig?.model;
917
+ const configModelUnused = options.model === undefined && configModel !== undefined && model === parent.model
918
+ && !(model && `${model.provider}/${model.id}`.toLowerCase() === configModel.toLowerCase());
919
+ const modelNote = configModelUnused
920
+ ? unresolvedModelNote(configModel, "did not match an available model's exact provider/id", model ?? "the session's default model")
921
+ : undefined;
912
922
  // Omitting this SDK option uses saved settings, not the live parent's effort.
913
923
  // A reopened session keeps the level it ran with — saved in its file —
914
924
  // unless explicitly overridden: neither the parent's current level nor the
@@ -1148,6 +1158,7 @@ export async function runAgent(ctx, type, prompt, options) {
1148
1158
  steered: run.steered,
1149
1159
  failure: run.failure ?? structuredFailure,
1150
1160
  maxTurns,
1161
+ ...(modelNote !== undefined ? { modelNote } : {}),
1151
1162
  ...(structuredCapture?.json !== undefined ? { structuredJson: structuredCapture.json } : {}),
1152
1163
  ...(structuredRetried ? { structuredRetried } : {}),
1153
1164
  };
@@ -71,7 +71,7 @@ export function registerRpcHandlers(deps) {
71
71
  // means "inherit" — not an override to resolve or scope-check.
72
72
  const override = normalizedOptions.model;
73
73
  if (override != null) {
74
- const { modelRegistry, cwd } = ctx;
74
+ const { modelRegistry, cwd, model: parentModel } = ctx;
75
75
  // Names the override the same way in both messages below; an object
76
76
  // override would otherwise interpolate as "[object Object]".
77
77
  const label = typeof override === "string" ? override : `${override.provider}/${override.id}`;
@@ -80,7 +80,7 @@ export function registerRpcHandlers(deps) {
80
80
  }
81
81
  let model = override;
82
82
  if (typeof override === "string") {
83
- const resolved = resolveModel(override, modelRegistry);
83
+ const resolved = resolveModel(override, modelRegistry, parentModel?.provider);
84
84
  if (typeof resolved === "string") {
85
85
  // resolveModel returns a human-readable error string when the
86
86
  // input doesn't match any available model. Surface it instead of
package/dist/index.d.ts CHANGED
@@ -16,6 +16,13 @@ import { type Theme } from "./ui/agent-widget.js";
16
16
  import { FOREIGN_WORKFLOW_TOOL_NAMES } from "./workflow/collisions.js";
17
17
  import { WORKFLOW_ENTRY_TYPE, type WorkflowEntryData, workflowEntryData } from "./workflow/entry.js";
18
18
  export declare function renderRunningAgentStatus(frame: string, statsText: string, activity: string, theme: Pick<Theme, "fg">): Container;
19
+ /**
20
+ * The head of a result for a notification: its first `maxLines` lines, cut at
21
+ * `maxLen` characters as well so one huge line cannot flood the parent's
22
+ * context. When anything is left out, a note says how much and where the rest
23
+ * is.
24
+ */
25
+ export declare function previewResult(result: string, maxLen: number, maxLines: number): string;
19
26
  /**
20
27
  * The `pending` results among a session's entries that no later
21
28
  * `subagents:delivered` entry covers, in order, as record-shaped objects a