@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 +55 -0
- package/README.md +34 -5
- package/dist/agent-manager.d.ts +12 -1
- package/dist/agent-manager.js +123 -43
- package/dist/agent-runner.d.ts +5 -0
- package/dist/agent-runner.js +11 -0
- package/dist/cross-extension-rpc.js +2 -2
- package/dist/index.d.ts +7 -0
- package/dist/index.js +221 -74
- package/dist/model-resolver.d.ts +43 -3
- package/dist/model-resolver.js +87 -30
- package/dist/nested-tools.d.ts +2 -0
- package/dist/nested-tools.js +26 -13
- package/dist/schedule.js +11 -6
- package/dist/session-file.d.ts +19 -1
- package/dist/session-file.js +46 -3
- package/dist/session-handoff.d.ts +1 -1
- package/dist/session-handoff.js +1 -1
- package/dist/status-note.d.ts +17 -0
- package/dist/status-note.js +28 -1
- package/dist/tool-args.d.ts +61 -0
- package/dist/tool-args.js +156 -0
- package/dist/types.d.ts +16 -1
- package/dist/workflow/host.js +26 -15
- package/package.json +1 -1
- package/src/agent-manager.ts +119 -41
- package/src/agent-runner.ts +16 -0
- package/src/cross-extension-rpc.ts +2 -2
- package/src/index.ts +233 -71
- package/src/model-resolver.ts +109 -34
- package/src/nested-tools.ts +27 -12
- package/src/schedule.ts +9 -5
- package/src/session-file.ts +57 -3
- package/src/session-handoff.ts +1 -1
- package/src/status-note.ts +30 -1
- package/src/tool-args.ts +176 -0
- package/src/types.ts +16 -1
- package/src/workflow/host.ts +25 -14
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
|
|
171
|
-
|
|
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
|
|
package/dist/agent-manager.d.ts
CHANGED
|
@@ -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;
|
package/dist/agent-manager.js
CHANGED
|
@@ -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
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
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.
|
|
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;
|
package/dist/agent-runner.d.ts
CHANGED
|
@@ -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). */
|
package/dist/agent-runner.js
CHANGED
|
@@ -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
|