@herbertgao/pi-subagents 0.22.0-local.2 → 0.22.1-local.1
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 +6 -0
- package/README.md +142 -45
- package/docs/rpc.md +17 -7
- package/package.json +4 -4
- package/src/agent-manager.ts +111 -59
- package/src/agent-runner.ts +135 -117
- package/src/cross-extension-rpc.ts +41 -14
- package/src/index.ts +110 -19
- package/src/nested-tools.ts +8 -0
- package/src/prompts.ts +31 -0
package/docs/rpc.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Driving subagents from another extension
|
|
2
2
|
|
|
3
|
-
Another pi extension can spawn a subagent, listen for subagent completion, read the result and stop the run — all over the `pi.events` bus, without importing this package directly.
|
|
3
|
+
Another pi extension can spawn a subagent, listen for subagent completion, read the result, steer and stop the run — all over the `pi.events` bus, without importing this package directly. Five request/reply channels (`subagents:rpc:ping`, `subagents:rpc:spawn`, `subagents:rpc:stop`, `subagents:rpc:consume`, `subagents:rpc:steer`), the lifecycle events in README's [event table](../README.md#events), one per-message usage event (`subagents:usage`), one terminal `subagents:disposed`, and one in-process registry at `Symbol.for("pi-subagents:manager")`.
|
|
4
4
|
|
|
5
5
|
The thing worth understanding up front is that **the bus is in-process.** Every "RPC" call here is a synchronous `pi.events.emit` into the same event loop, and every reply comes back the same way. That single fact explains most of what follows: why `signal` and the `on*` callbacks work on a spawn payload at all, why a `consume` fired inside a `subagents:completed` handler lands _before_ the notification decision has been made, and why none of this survives a real process boundary.
|
|
6
6
|
|
|
@@ -88,6 +88,8 @@ Every failure reaches the caller as `{ success: false, error }`, where `error` i
|
|
|
88
88
|
| `Agent is owned by another agent or workflow` | stop handler |
|
|
89
89
|
| `Agent is not running` | stop handler. The record exists, so it has already settled |
|
|
90
90
|
| `Agent not found or still running` | consume handler |
|
|
91
|
+
| `Steer message must be a non-empty string` | steer handler — `message` missing, not a string, or whitespace |
|
|
92
|
+
| `Agent not found` / `Agent is owned by another agent or workflow` / `Agent is not running` | steer handler — same lookup, ownership guard and settled check as stop |
|
|
91
93
|
|
|
92
94
|
Three things the table cannot show:
|
|
93
95
|
|
|
@@ -97,14 +99,16 @@ Three things the table cannot show:
|
|
|
97
99
|
|
|
98
100
|
## Ownership
|
|
99
101
|
|
|
100
|
-
`isTopLevelAgent(record)` is `parentAgentId === undefined && workflowId === undefined` (`src/agent-manager.ts`). `subagents:rpc:stop`
|
|
102
|
+
`isTopLevelAgent(record)` is `parentAgentId === undefined && workflowId === undefined` (`src/agent-manager.ts`). `subagents:rpc:stop` and `subagents:rpc:steer` enforce it through one shared check (`assertOwned` in `src/cross-extension-rpc.ts`): a nested child or a workflow's agent is owned by something that is _waiting on it_, and aborting or redirecting it out from under that owner turns another extension's request into a failed step. It is defence in depth rather than a live hole — no RPC hands out agent ids, so a caller has no ordinary way to name one it does not own.
|
|
101
103
|
|
|
102
104
|
Two asymmetries to know about, stated as they are:
|
|
103
105
|
|
|
104
106
|
- **Stop takes an id only**: its `getRecord` is the manager's plain lookup. Consume takes an id _or_ an `@handle`: its `consumeResult` (both wired in the `session_start` handler, `src/index.ts`) goes through `resolveAgentRef`.
|
|
105
107
|
- **Consume checks `parentAgentId` but not `workflowId`** (`consumeResult`, `src/index.ts`). A workflow-owned agent's result can be marked consumed over the bus even though the same agent cannot be stopped.
|
|
106
108
|
|
|
107
|
-
The same predicate silently scopes the events. **Every lifecycle event is top-level only** — `subagents:started`, `:completed`, `:failed` and `:compacted` all return early for nested and workflow-owned agents (the `isTopLevelAgent` checks in the `AgentManager` callbacks, `src/index.ts`).
|
|
109
|
+
The same predicate silently scopes the events. **Every lifecycle event is top-level only** — `subagents:started`, `:completed`, `:failed`, `:steered` and `:compacted` all return early for nested and workflow-owned agents (the `isTopLevelAgent` checks in the `AgentManager` callbacks, `src/index.ts`). `subagents:usage` is the one event that is not scoped: it fires for every agent's messages, nested and workflow-owned included, so a ledger sees a workflow's children spend even though it never sees them start or finish.
|
|
110
|
+
|
|
111
|
+
**`subagents:disposed` is the last event of a session.** It is emitted at the end of this extension's `session_shutdown`, after every agent is stopped and every child session has shut down, so no `subagents:usage` can follow it. A listener gets only its synchronous prefix on quit: `pi.events.emit` does not await listeners, and pi calls `process.exit` once the shutdown handlers return. Flush with `writeFileSync`, not `fs.promises`. Fields, and the other events, are in README's [event table](../README.md#events).
|
|
108
112
|
|
|
109
113
|
## The notification race
|
|
110
114
|
|
|
@@ -128,6 +132,12 @@ Consumption is not terminal. An `@handle` steer un-consumes the record (the ment
|
|
|
128
132
|
|
|
129
133
|
One related thing that lives nowhere else: on every top-level settle, pi-subagents writes a session entry — not an event — via `pi.appendEntry("subagents:record", …)` (the manager's completion callback, `src/index.ts`), carrying `id`, `type`, `description`, `status`, `result`, `error`, `startedAt` and `completedAt`. It exists for cross-extension history reconstruction. It is append-only history, not something to react to.
|
|
130
134
|
|
|
135
|
+
## Steering a running agent
|
|
136
|
+
|
|
137
|
+
`subagents:rpc:steer` `{ requestId, agentId, message }` delivers `message` into a running or queued agent's conversation, the bus-side half of the `steer_subagent` tool. It exists for the caller that spawned an agent over the bus and then learns something that agent needs mid-run — a second CI failure on the head it is already fixing, a review that landed while it works — and would otherwise have to choose between spawning a duplicate agent for the same work and dropping the fact.
|
|
138
|
+
|
|
139
|
+
It takes an id only, enforces the same top-level ownership guard as stop, and answers `Agent is not running` for a record that has settled. A message queued before the agent's session exists is flushed when the session is created, exactly as the tool's steer is. A successful steer emits `subagents:steered` `{ id, message }`, as every steer path does. Like `consume`, it is outside the `ping` version handshake: an older build has no handler and the request times out on your side, so give it a timeout and treat expiry as "not available here".
|
|
140
|
+
|
|
131
141
|
## The manager registry
|
|
132
142
|
|
|
133
143
|
`globalThis[Symbol.for("pi-subagents:manager")]` (`MANAGER_KEY` and `registryEntry`, `src/index.ts`) is a second integration surface — the standard Node cross-package singleton pattern, no bus involved:
|
|
@@ -147,9 +157,9 @@ Prefer the bus. The registry has no reply envelope, no version, and no availabil
|
|
|
147
157
|
|
|
148
158
|
`subagents:rpc:ping` replies `{ version: PROTOCOL_VERSION }`, currently `2` (`src/cross-extension-rpc.ts`). The constant was introduced already equal to `2` in 0.5.0; "v1" is a retroactive name for the pre-envelope contract, where spawn replied with a bare `{ id }` or `{ error }`, stop replied `{ success: boolean }` with no message, and each handler caught its own errors.
|
|
149
159
|
|
|
150
|
-
Everything added since shipped **without a bump**, because all of it is additive: stop's ownership refusal, string-`model` resolution ([#59](https://github.com/tintinweb/pi-subagents/pull/59)/[#60](https://github.com/tintinweb/pi-subagents/issues/60)), `scopeModels` enforcement ([#240](https://github.com/tintinweb/pi-subagents/issues/240)), and the whole `consume`
|
|
160
|
+
Everything added since shipped **without a bump**, because all of it is additive: stop's ownership refusal, string-`model` resolution ([#59](https://github.com/tintinweb/pi-subagents/pull/59)/[#60](https://github.com/tintinweb/pi-subagents/issues/60)), `scopeModels` enforcement ([#240](https://github.com/tintinweb/pi-subagents/issues/240)), and the whole `consume` and `steer` channels.
|
|
151
161
|
|
|
152
|
-
> A `ping` that answers `2` does not tell you whether `consume` exists, whether model scope is enforced, or whether stop checks ownership.
|
|
162
|
+
> A `ping` that answers `2` does not tell you whether `consume` or `steer` exists, whether model scope is enforced, or whether stop checks ownership.
|
|
153
163
|
|
|
154
164
|
So: send `consume` unconditionally and ignore the outcome — an older build has no handler and simply keeps notifying, which is exactly why it was left outside the handshake. And treat every error envelope as authoritative rather than trying to predict which checks are in force.
|
|
155
165
|
|
|
@@ -167,7 +177,7 @@ This document has no test of its own, so it is worth knowing which claims are ac
|
|
|
167
177
|
|
|
168
178
|
| Test | Level | Pins |
|
|
169
179
|
| ------------------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
170
|
-
| `test/cross-extension-rpc.test.ts` | Mocked `SpawnCapable` | Envelope shape, per-channel error strings, model resolution and scope enforcement
|
|
180
|
+
| `test/cross-extension-rpc.test.ts` | Mocked `SpawnCapable` | Envelope shape, per-channel error strings (including steer's), model resolution and scope enforcement |
|
|
171
181
|
| `test/rpc-lifecycle-gating.test.ts` | Real extension factory | Nothing wired at factory time, everything once at `session_start`, and live widget activity for RPC spawns ([#142](https://github.com/tintinweb/pi-subagents/issues/142)/[#181](https://github.com/tintinweb/pi-subagents/pull/181)) |
|
|
172
182
|
| `test/rpc-result-consumption.test.ts` | Real delivery path | The notification firing, and not firing, around `consume` |
|
|
173
183
|
|
|
@@ -179,4 +189,4 @@ Not pinned anywhere, so treat them as descriptions rather than contracts: the `S
|
|
|
179
189
|
|
|
180
190
|
Read it for the shape of the whole loop: waiting on `subagents:ready`, keeping an id-keyed map of outstanding spawns, resolving each from the `subagents:completed` / `subagents:failed` handler, and consuming the result in the same synchronous handler that reports it.
|
|
181
191
|
|
|
182
|
-
If what you want is many coordinated agents rather than one, hand a script to [`SubagentWorkflow`](workflows.md) instead of fanning out over `subagents:rpc:spawn` — and note that a workflow's agents are not yours: they emit no lifecycle events
|
|
192
|
+
If what you want is many coordinated agents rather than one, hand a script to [`SubagentWorkflow`](workflows.md) instead of fanning out over `subagents:rpc:spawn` — and note that a workflow's agents are not yours: they emit no lifecycle events — only `subagents:usage` — and `subagents:rpc:stop` refuses them.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@herbertgao/pi-subagents",
|
|
3
|
-
"version": "0.22.
|
|
3
|
+
"version": "0.22.1-local.1",
|
|
4
4
|
"description": "Claude Code-style autonomous subagents and workflow orchestration for Pi, with HerbertGao-maintained UI extensions.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent",
|
|
@@ -69,10 +69,10 @@
|
|
|
69
69
|
},
|
|
70
70
|
"x-upstream": {
|
|
71
71
|
"package": "@tintinweb/pi-subagents",
|
|
72
|
-
"version": "0.22.
|
|
73
|
-
"reviewedVersion": "0.22.
|
|
72
|
+
"version": "0.22.1",
|
|
73
|
+
"reviewedVersion": "0.22.1",
|
|
74
74
|
"repository": "https://github.com/tintinweb/pi-subagents",
|
|
75
|
-
"commit": "
|
|
75
|
+
"commit": "fc089c4b7ab0f1185453b2e53027997c1f637e38",
|
|
76
76
|
"trackVersion": true
|
|
77
77
|
}
|
|
78
78
|
}
|
package/src/agent-manager.ts
CHANGED
|
@@ -49,6 +49,12 @@ import {
|
|
|
49
49
|
|
|
50
50
|
export type OnAgentComplete = (record: AgentRecord) => void
|
|
51
51
|
export type OnAgentStart = (record: AgentRecord) => void
|
|
52
|
+
/**
|
|
53
|
+
* Fired by `steer()` once a message is delivered or queued. Every steer path
|
|
54
|
+
* goes through it except the steer tools (`steer_subagent` in index.ts, and its
|
|
55
|
+
* nested twin in nested-tools.ts), which await the session directly.
|
|
56
|
+
*/
|
|
57
|
+
export type OnAgentSteered = (record: AgentRecord, message: string) => void
|
|
52
58
|
export type OnAgentCompact = (record: AgentRecord, info: CompactionInfo) => void
|
|
53
59
|
/**
|
|
54
60
|
* Fired once per assistant `message_end`, for EVERY agent this manager owns —
|
|
@@ -351,6 +357,14 @@ interface ResumeOptions {
|
|
|
351
357
|
* record — the historical behavior.
|
|
352
358
|
*/
|
|
353
359
|
isBackground?: boolean
|
|
360
|
+
/**
|
|
361
|
+
* Explicit turn limit for the resumed run: this, else the limit the agent
|
|
362
|
+
* was spawned with, else its type's own `max_turns`, else the default —
|
|
363
|
+
* counted afresh from the resume prompt (#368).
|
|
364
|
+
*/
|
|
365
|
+
maxTurns?: number
|
|
366
|
+
/** Called at the end of each resumed turn with the count since the resume. */
|
|
367
|
+
onTurnEnd?: (turnCount: number) => void
|
|
354
368
|
/** Called on tool start/end with activity info (for streaming progress to UI). */
|
|
355
369
|
onToolActivity?: (activity: ToolActivity) => void
|
|
356
370
|
/** Called once per assistant message_end with that message's usage delta. */
|
|
@@ -420,6 +434,7 @@ export class AgentManager {
|
|
|
420
434
|
private onStart?: OnAgentStart
|
|
421
435
|
private onCompact?: OnAgentCompact
|
|
422
436
|
private onUsage?: OnAgentUsage
|
|
437
|
+
private onSteered?: OnAgentSteered
|
|
423
438
|
private maxConcurrent: number
|
|
424
439
|
private maxConcurrentForeground = DEFAULT_MAX_CONCURRENT_FOREGROUND
|
|
425
440
|
/** Base repos worktrees were created from — so dispose() can prune them all,
|
|
@@ -476,11 +491,13 @@ export class AgentManager {
|
|
|
476
491
|
onStart?: OnAgentStart,
|
|
477
492
|
onCompact?: OnAgentCompact,
|
|
478
493
|
onUsage?: OnAgentUsage,
|
|
494
|
+
onSteered?: OnAgentSteered,
|
|
479
495
|
) {
|
|
480
496
|
this.onComplete = onComplete
|
|
481
497
|
this.onStart = onStart
|
|
482
498
|
this.onCompact = onCompact
|
|
483
499
|
this.onUsage = onUsage
|
|
500
|
+
this.onSteered = onSteered
|
|
484
501
|
this.maxConcurrent = maxConcurrent
|
|
485
502
|
// Cleanup consumed completed agents after 10 minutes
|
|
486
503
|
this.cleanupInterval = setInterval(() => this.cleanup(), 60_000)
|
|
@@ -972,28 +989,18 @@ export class AgentManager {
|
|
|
972
989
|
structuredJson,
|
|
973
990
|
structuredRetried,
|
|
974
991
|
}) => {
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
record.status = "aborted"
|
|
982
|
-
} else if (failure) {
|
|
983
|
-
record.status = "error"
|
|
984
|
-
record.error = failure
|
|
985
|
-
} else {
|
|
986
|
-
record.status = steered ? "steered" : "completed"
|
|
987
|
-
}
|
|
988
|
-
}
|
|
989
|
-
record.result = responseText
|
|
992
|
+
this.settleOutcome(record, {
|
|
993
|
+
text: responseText,
|
|
994
|
+
failure,
|
|
995
|
+
aborted,
|
|
996
|
+
steered,
|
|
997
|
+
})
|
|
990
998
|
// Kept beside `result`, never inside it: `result` is prose meant for a
|
|
991
999
|
// reader — it is previewed, transcribed, and appended to below — while
|
|
992
1000
|
// this is a machine-readable payload one caller asked for by schema.
|
|
993
1001
|
record.structuredJson = structuredJson
|
|
994
1002
|
record.structuredRetried = structuredRetried
|
|
995
1003
|
record.session = session
|
|
996
|
-
record.completedAt ??= Date.now()
|
|
997
1004
|
|
|
998
1005
|
detach()
|
|
999
1006
|
|
|
@@ -1383,36 +1390,46 @@ export class AgentManager {
|
|
|
1383
1390
|
record.completedAt = undefined
|
|
1384
1391
|
record.result = undefined
|
|
1385
1392
|
record.error = undefined
|
|
1393
|
+
// Fresh controller, as on the detached path: `abort()` fires whatever
|
|
1394
|
+
// `record.abortController` holds, and the spawn's has long settled — so a
|
|
1395
|
+
// stop from `/agents` or the caller's Esc would mark the record stopped
|
|
1396
|
+
// while the resumed prompt ran on to completion.
|
|
1397
|
+
const abortController = new AbortController()
|
|
1398
|
+
record.abortController = abortController
|
|
1399
|
+
this.armParentAbort(record, signal)
|
|
1400
|
+
// `addEventListener` never fires on a signal that is already aborted, and
|
|
1401
|
+
// the runner skips the prompt only when ITS signal is — stop now, so that
|
|
1402
|
+
// holds for a resume issued on a call that was cancelled first.
|
|
1403
|
+
if (signal?.aborted) this.abort(id)
|
|
1386
1404
|
|
|
1387
1405
|
try {
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1406
|
+
this.settleOutcome(
|
|
1407
|
+
record,
|
|
1408
|
+
await resumeAgent(record.session, prompt, {
|
|
1409
|
+
type: record.type,
|
|
1410
|
+
maxTurns: options?.maxTurns ?? record.invocation?.maxTurns,
|
|
1411
|
+
onTurnEnd: options?.onTurnEnd,
|
|
1412
|
+
onToolActivity: (activity) => {
|
|
1413
|
+
if (activity.type === "end") record.toolUses++
|
|
1414
|
+
options?.onToolActivity?.(activity)
|
|
1415
|
+
},
|
|
1416
|
+
onAssistantUsage: (usage) => {
|
|
1417
|
+
addUsage(record.lifetimeUsage, usage)
|
|
1418
|
+
this.onUsage?.(record, usage)
|
|
1419
|
+
options?.onAssistantUsage?.(usage)
|
|
1420
|
+
},
|
|
1421
|
+
onCompaction: (info) => {
|
|
1422
|
+
record.compactionCount++
|
|
1423
|
+
this.onCompact?.(record, info)
|
|
1424
|
+
options?.onCompaction?.(info)
|
|
1425
|
+
},
|
|
1426
|
+
signal: abortController.signal,
|
|
1427
|
+
}),
|
|
1428
|
+
)
|
|
1411
1429
|
} catch (err) {
|
|
1412
|
-
record
|
|
1413
|
-
record.error = err instanceof Error ? err.message : String(err)
|
|
1414
|
-
record.completedAt = Date.now()
|
|
1430
|
+
this.failResume(record, err)
|
|
1415
1431
|
}
|
|
1432
|
+
this.detachParentAbort(record)
|
|
1416
1433
|
|
|
1417
1434
|
// Same contract as the spawn settle paths: children spawned during the
|
|
1418
1435
|
// resumed turn must not outlive it — nothing else can see or reach them.
|
|
@@ -1421,6 +1438,46 @@ export class AgentManager {
|
|
|
1421
1438
|
return record
|
|
1422
1439
|
}
|
|
1423
1440
|
|
|
1441
|
+
/**
|
|
1442
|
+
* Write a run's outcome onto its record — a spawn's or a resume's, with one
|
|
1443
|
+
* precedence: an external stop keeps "stopped"; a hard abort (turn limit +
|
|
1444
|
+
* grace) is "aborted"; a failed final turn (a provider error pi resolved
|
|
1445
|
+
* instead of rejecting, #144) is an "error", not a completion with an empty
|
|
1446
|
+
* or stale result; otherwise "steered" or "completed". The run's text stays
|
|
1447
|
+
* available in every case.
|
|
1448
|
+
*/
|
|
1449
|
+
private settleOutcome(
|
|
1450
|
+
record: AgentRecord,
|
|
1451
|
+
{
|
|
1452
|
+
text,
|
|
1453
|
+
failure,
|
|
1454
|
+
aborted,
|
|
1455
|
+
steered,
|
|
1456
|
+
}: Awaited<ReturnType<typeof resumeAgent>>,
|
|
1457
|
+
): void {
|
|
1458
|
+
if (record.status !== "stopped") {
|
|
1459
|
+
if (aborted) {
|
|
1460
|
+
record.status = "aborted"
|
|
1461
|
+
} else if (failure) {
|
|
1462
|
+
record.status = "error"
|
|
1463
|
+
record.error = failure
|
|
1464
|
+
} else {
|
|
1465
|
+
record.status = steered ? "steered" : "completed"
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
record.result = text
|
|
1469
|
+
record.completedAt ??= Date.now()
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
/** A resume whose runner rejected: an error, unless an external stop got there first. */
|
|
1473
|
+
private failResume(record: AgentRecord, err: unknown): void {
|
|
1474
|
+
if (record.status !== "stopped") {
|
|
1475
|
+
record.status = "error"
|
|
1476
|
+
record.error = err instanceof Error ? err.message : String(err)
|
|
1477
|
+
}
|
|
1478
|
+
record.completedAt ??= Date.now()
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1424
1481
|
/**
|
|
1425
1482
|
* Start a background resume run: detached, settling and notifying like
|
|
1426
1483
|
* startAgent's background path. Invoked immediately, or from drainQueue when
|
|
@@ -1490,6 +1547,9 @@ export class AgentManager {
|
|
|
1490
1547
|
}
|
|
1491
1548
|
|
|
1492
1549
|
const promise = resumeAgent(record.session, prompt, {
|
|
1550
|
+
type: record.type,
|
|
1551
|
+
maxTurns: options.maxTurns ?? record.invocation?.maxTurns,
|
|
1552
|
+
onTurnEnd: options.onTurnEnd,
|
|
1493
1553
|
onToolActivity: (activity) => {
|
|
1494
1554
|
if (activity.type === "end") record.toolUses++
|
|
1495
1555
|
options.onToolActivity?.(activity)
|
|
@@ -1506,25 +1566,13 @@ export class AgentManager {
|
|
|
1506
1566
|
},
|
|
1507
1567
|
signal: abortController.signal,
|
|
1508
1568
|
})
|
|
1509
|
-
.then((
|
|
1510
|
-
|
|
1511
|
-
if (record.status !== "stopped") {
|
|
1512
|
-
// Same contract as the spawn path (#144): a failed final turn is an
|
|
1513
|
-
// error, not a completion — but the resumed text stays available.
|
|
1514
|
-
record.status = failure ? "error" : "completed"
|
|
1515
|
-
if (failure) record.error = failure
|
|
1516
|
-
}
|
|
1517
|
-
record.result = text
|
|
1518
|
-
record.completedAt ??= Date.now()
|
|
1569
|
+
.then((outcome) => {
|
|
1570
|
+
this.settleOutcome(record, outcome)
|
|
1519
1571
|
settle()
|
|
1520
|
-
return text
|
|
1572
|
+
return outcome.text
|
|
1521
1573
|
})
|
|
1522
1574
|
.catch((err) => {
|
|
1523
|
-
|
|
1524
|
-
record.status = "error"
|
|
1525
|
-
record.error = err instanceof Error ? err.message : String(err)
|
|
1526
|
-
}
|
|
1527
|
-
record.completedAt ??= Date.now()
|
|
1575
|
+
this.failResume(record, err)
|
|
1528
1576
|
settle()
|
|
1529
1577
|
return ""
|
|
1530
1578
|
})
|
|
@@ -1538,7 +1586,10 @@ export class AgentManager {
|
|
|
1538
1586
|
* current tool execution and appears as a user message. If the session isn't
|
|
1539
1587
|
* ready yet, the message is queued on `pendingSteers` and flushed when the
|
|
1540
1588
|
* session is created. Returns false if the agent can't accept steering
|
|
1541
|
-
* (unknown id, or no longer running/queued).
|
|
1589
|
+
* (unknown id, or no longer running/queued). Fires `onSteered` on either
|
|
1590
|
+
* accepted path, so the UI viewers, an `@handle` and the RPC all report
|
|
1591
|
+
* the steer the same way without each emitting by hand. The steer tools
|
|
1592
|
+
* bypass this method because they await delivery.
|
|
1542
1593
|
*/
|
|
1543
1594
|
steer(id: string, message: string): boolean {
|
|
1544
1595
|
const record = this.agents.get(id)
|
|
@@ -1550,6 +1601,7 @@ export class AgentManager {
|
|
|
1550
1601
|
if (!record.pendingSteers) record.pendingSteers = []
|
|
1551
1602
|
record.pendingSteers.push(message)
|
|
1552
1603
|
}
|
|
1604
|
+
this.onSteered?.(record, message)
|
|
1553
1605
|
return true
|
|
1554
1606
|
}
|
|
1555
1607
|
|