@narumitw/pi-subagents 0.54.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -9
- package/package.json +1 -1
- package/src/completion-delivery.ts +80 -8
- package/src/inspect.ts +4 -0
- package/src/persistence.ts +105 -6
- package/src/registry-types.ts +21 -4
- package/src/registry.ts +204 -36
- package/src/stateful-guidance.ts +1 -1
- package/src/stateful-render.ts +0 -1
- package/src/stateful-tool-params.ts +13 -20
- package/src/stateful.ts +18 -19
package/README.md
CHANGED
|
@@ -299,8 +299,8 @@ No benchmark result in this release changes the default delegation policy or mak
|
|
|
299
299
|
| --- | --- | --- |
|
|
300
300
|
| `list_agents` | Optional `agentScope` (default `user`) and `limit` (default 32, maximum 100) | Bounded agent metadata and omission counts |
|
|
301
301
|
| `get_agent` | Required `agent`; optional `agentScope` | One resolved definition, safe source path, configured tools, and consultation-effective tools; never the system prompt |
|
|
302
|
-
| `list_runs` | Optional `includeClosed` and `limit` (default 50, maximum 100) | Metadata-only retained-run summaries and unread counts |
|
|
303
|
-
| `get_run` | Required `agentId` | Safe `cwd`, current-task/error summaries, thinking level, context footprint, protocol, effective transport, bounded timing/usage telemetry, structured result when valid, policy, history count, and unread count |
|
|
302
|
+
| `list_runs` | Optional `includeClosed` and `limit` (default 50, maximum 100) | Metadata-only retained-run summaries, turn generation, pending-completion count, and unread counts |
|
|
303
|
+
| `get_run` | Required `agentId` | Safe `cwd`, current run and turn generation, current-task/error summaries, thinking level, context footprint, protocol, effective transport, bounded timing/usage telemetry, structured result when valid, policy, history count, pending-completion count, and unread count |
|
|
304
304
|
| `list_workflows` | Optional `limit` (default 50, maximum 100) | Metadata-only persisted blocking-workflow summaries for the current session |
|
|
305
305
|
| `get_workflow` | Required `workflowId` | Bounded task states, generations, dependencies, plan identities, artifact metadata, verification state, and outcome reasons without artifact contents |
|
|
306
306
|
| `list_models` | Optional `limit` (default 50, maximum 100) | Session-scoped models, or the already-loaded available snapshot |
|
|
@@ -310,7 +310,7 @@ No benchmark result in this release changes the default delegation policy or mak
|
|
|
310
310
|
|
|
311
311
|
The schema rejects fields that do not belong to the selected action. Explicit `project` or `both` scope fails before project-agent discovery unless Pi already trusts the project. Run inspection never returns history output, stored context, or mailbox content; unread counts come from a metadata-only snapshot and do not acknowledge messages. Workflow inspection reads validated, redacted snapshots without quarantining or rewriting invalid files. Paths beneath the Pi agent directory use `~`, project paths are workspace-relative, model objects are projected through an allow-list, and model-facing text is bounded to 50 KiB or 2,000 lines.
|
|
312
312
|
|
|
313
|
-
|
|
313
|
+
`subagent_manage` no longer accepts its former compatibility `list` action. Use `subagent_inspect({ "action": "list_runs", "includeClosed": true })` for metadata-only discovery and `get_run` for detail.
|
|
314
314
|
|
|
315
315
|
## 📖 Read-only consultation
|
|
316
316
|
|
|
@@ -599,9 +599,9 @@ Legacy v1 and v2 records without acceptance fields retain their prior completed
|
|
|
599
599
|
|
|
600
600
|
## 🔁 Stateful agents
|
|
601
601
|
|
|
602
|
-
Stateful lifecycle tools are available by default. `subagent_spawn` is detached: it schedules work, returns immediately with an opaque `agentId`, and later injects a bounded `pi-subagent-completion` custom message.
|
|
602
|
+
Stateful lifecycle tools are available by default. `subagent_spawn` is detached: it schedules work, returns immediately with an opaque `agentId`, and later injects a bounded `pi-subagent-completion` custom message. Every turn receives an executor-owned `runId`, monotonically increasing agent-local generation, and unique `completionId`. The terminal completion is persisted before delivery, simultaneous completions are batched, and the broker allows at most one in-flight root wake until that parent turn starts.
|
|
603
603
|
|
|
604
|
-
Detached work follows a non-polling policy. With default `next-turn` delivery, prefer one bounded `subagent_spawn` for related asynchronous research or review only when the current response does not depend on its result. If it does, use blocking `subagent` when registered; in **Async only**, complete required work directly, opt into `auto-resume` when a later synthesis turn is appropriate, or switch workflows. With opt-in `auto-resume`, detached broad work may be final-answer-dependent because completion requests a synthesis turn after the root settles. In either mode, do useful non-overlapping main-agent work immediately, do not poll `
|
|
604
|
+
Detached work follows a non-polling policy. With default `next-turn` delivery, prefer one bounded `subagent_spawn` for related asynchronous research or review only when the current response does not depend on its result. If it does, use blocking `subagent` when registered; in **Async only**, complete required work directly, opt into `auto-resume` when a later synthesis turn is appropriate, or switch workflows. With opt-in `auto-resume`, detached broad work may be final-answer-dependent because completion requests a synthesis turn after the root settles. In either mode, do useful non-overlapping main-agent work immediately, do not poll `subagent_inspect` or `subagent_mailbox` with `action: "read"`, and do not duplicate delegated work. Add another detached agent only for truly independent work with safe workspace concurrency. Detached lifecycle work intentionally has no `subagent_wait` tool.
|
|
605
605
|
|
|
606
606
|
A detached agent additionally needs a concrete isolation or specialization benefit such as independent review, bounded context/output, a distinct model/tool profile, or workspace isolation. Simple work that the main agent can perform directly should not be delegated.
|
|
607
607
|
|
|
@@ -610,7 +610,7 @@ A detached agent additionally needs a concrete isolation or specialization benef
|
|
|
610
610
|
- `"next-turn"` (default) preserves the previous behavior: use `deliverAs: "steer"` with `triggerTurn: false`. An active root can consume completion naturally; an idle root is not awakened.
|
|
611
611
|
- `"auto-resume"` holds completion while the root is active, then requests one synthesis turn after the parent settles when no user or extension messages are already pending. Simultaneous completions share that turn, active work is not interrupted, and pending input suppresses the autonomous wake.
|
|
612
612
|
|
|
613
|
-
Auto-resume
|
|
613
|
+
The bounded persisted completion outbox provides ordered at-least-once delivery across process restart without replaying the child turn. When state must be reduced to its storage bound, persistence drops roots without pending completions first and trims old history rather than discarding an outbox-owned root. A completion is acknowledged only after parent context assembly observes its exact `completionId`; an injection that returns synchronously but never reaches context remains pending for retry. If the process exits after context assembly but before acknowledgement is persisted, the same ID can be delivered again and consumers must deduplicate it. Auto-resume wake admission remains best-effort because Pi's custom-message API is fire-and-forget, but an unacknowledged terminal completion itself remains available for redelivery on the next start of the owning session. Transient terminal-persistence failures retry with bounded exponential backoff and keep the run pending; shutdown cancels retry waits and reports a final persistence failure instead of silently resolving unsaved work.
|
|
614
614
|
|
|
615
615
|
The default `subprocess` transport preserves compatibility: each turn starts a fresh isolated `pi --mode json -p --no-session` child and receives sanitized, bounded history.
|
|
616
616
|
Set `transport` to `in-process` to retain one public Pi SDK `AgentSession` per stateful `agentId`, avoiding repeated process startup while preserving native child history in memory.
|
|
@@ -687,7 +687,7 @@ This avoids lifecycle-driven tool-schema churn and preserves a stable provider p
|
|
|
687
687
|
| --- | --- |
|
|
688
688
|
| `subagent_spawn` | Start detached work with optional task-selected thinking and retained timeout, exact-retry `idempotencyKey`, and `text`, `structured-v1`, or `structured-v2` result format; return an opaque `agentId` immediately and deliver completion asynchronously. |
|
|
689
689
|
| `subagent_send` | Send follow-up work with an optional one-turn timeout override and trigger a new turn on a reusable agent; semantic skew requires explicit `revalidate: true`, and shared-workspace write conflicts are guarded unless explicitly overridden. |
|
|
690
|
-
| `subagent_manage` | Use `
|
|
690
|
+
| `subagent_manage` | Use `"interrupt"` to retain an agent after aborting active work or `"close"` to release it; both actions accept optional `subtree`. Use `subagent_inspect` for all list and detail operations. |
|
|
691
691
|
| `subagent_mailbox` | Use `action: "send"` for queue-only messages that do not start a turn, or `"read"` to read and optionally acknowledge unread messages. |
|
|
692
692
|
|
|
693
693
|
The action schemas are flat for provider compatibility and reject parameters that belong to another action. For example:
|
|
@@ -721,13 +721,14 @@ The five replaced names are intentionally not registered as aliases. Update expl
|
|
|
721
721
|
|
|
722
722
|
| Previous call | Fixed-surface call |
|
|
723
723
|
| --- | --- |
|
|
724
|
-
| `subagent_list({ includeClosed })` | `
|
|
724
|
+
| `subagent_list({ includeClosed })` | `subagent_inspect({ action: "list_runs", includeClosed })` |
|
|
725
|
+
| `subagent_manage({ action: "list", includeClosed })` | `subagent_inspect({ action: "list_runs", includeClosed })` |
|
|
725
726
|
| `subagent_interrupt({ agentId, subtree })` | `subagent_manage({ action: "interrupt", agentId, subtree })` |
|
|
726
727
|
| `subagent_close({ agentId, subtree })` | `subagent_manage({ action: "close", agentId, subtree })` |
|
|
727
728
|
| `subagent_message({ agentId, message, ... })` | `subagent_mailbox({ action: "send", agentId, message, ... })` |
|
|
728
729
|
| `subagent_messages({ agentId, acknowledge, limit })` | `subagent_mailbox({ action: "read", agentId, acknowledge, limit })` |
|
|
729
730
|
|
|
730
|
-
Persisted agent and mailbox records require no migration. If an explicit prompt in a resumed conversation keeps requesting an old name, update it with the mapping above or start a fresh conversation. To roll back after an upgrade, pin the package version used before the upgrade; for this migration, use `pi install npm:@narumitw/pi-subagents@0.26.0`. The previous release can read the same state directory.
|
|
731
|
+
Persisted agent and mailbox records require no manual migration; older records load with an empty completion outbox and generation zero. If an explicit prompt in a resumed conversation keeps requesting an old name, update it with the mapping above or start a fresh conversation. To roll back after an upgrade, pin the package version used before the upgrade; for this migration, use `pi install npm:@narumitw/pi-subagents@0.26.0`. The previous release can read the same state directory.
|
|
731
732
|
|
|
732
733
|
A spawn can request a thinking level explicitly:
|
|
733
734
|
|
package/package.json
CHANGED
|
@@ -12,6 +12,9 @@ const COMPLETION_BATCH_DELAY_MS = 10;
|
|
|
12
12
|
|
|
13
13
|
interface CompletionMetadata {
|
|
14
14
|
protocol: typeof PI_SUBAGENTS_RPC_PROTOCOL;
|
|
15
|
+
completionId: string;
|
|
16
|
+
runId: string;
|
|
17
|
+
generation: number;
|
|
15
18
|
agentId: string;
|
|
16
19
|
agent: string;
|
|
17
20
|
state: string;
|
|
@@ -38,13 +41,15 @@ type CompletionPi = Pick<ExtensionAPI, "sendMessage">;
|
|
|
38
41
|
|
|
39
42
|
export interface CompletionDeliveryBrokerOptions {
|
|
40
43
|
onDeliveryError?: (error: unknown) => void;
|
|
41
|
-
|
|
44
|
+
onAcknowledged?: (completions: readonly AgentTurnCompletion[], acknowledgedAt: number) => void;
|
|
42
45
|
now?: () => number;
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
/** Owns bounded completion batching and at most one idle-root wake for one parent session. */
|
|
46
49
|
export class CompletionDeliveryBroker {
|
|
47
50
|
private pending: AgentTurnCompletion[] = [];
|
|
51
|
+
private readonly knownCompletionIds = new Set<string>();
|
|
52
|
+
private awaitingParentAck: AgentTurnCompletion[] = [];
|
|
48
53
|
private flushTimer?: NodeJS.Timeout;
|
|
49
54
|
private wakeInFlight = false;
|
|
50
55
|
private closed = false;
|
|
@@ -57,7 +62,8 @@ export class CompletionDeliveryBroker {
|
|
|
57
62
|
) {}
|
|
58
63
|
|
|
59
64
|
enqueue(completion: AgentTurnCompletion): void {
|
|
60
|
-
if (this.closed) return;
|
|
65
|
+
if (this.closed || this.knownCompletionIds.has(completion.completionId)) return;
|
|
66
|
+
this.knownCompletionIds.add(completion.completionId);
|
|
61
67
|
this.pending.push(completion);
|
|
62
68
|
this.scheduleFlush();
|
|
63
69
|
}
|
|
@@ -72,6 +78,14 @@ export class CompletionDeliveryBroker {
|
|
|
72
78
|
this.scheduleFlush();
|
|
73
79
|
}
|
|
74
80
|
|
|
81
|
+
onParentContext(messages: readonly unknown[]): void {
|
|
82
|
+
this.acknowledgeVisible(completionIdsFromContext(messages));
|
|
83
|
+
if (this.awaitingParentAck.length > 0) {
|
|
84
|
+
this.pending = [...this.awaitingParentAck.splice(0), ...this.pending];
|
|
85
|
+
}
|
|
86
|
+
this.scheduleFlush();
|
|
87
|
+
}
|
|
88
|
+
|
|
75
89
|
onParentSettled(): void {
|
|
76
90
|
this.wakeInFlight = false;
|
|
77
91
|
this.scheduleFlush();
|
|
@@ -88,18 +102,21 @@ export class CompletionDeliveryBroker {
|
|
|
88
102
|
let canWake = this.shouldWakeRoot();
|
|
89
103
|
for (let index = 0; index < batches.length; index++) {
|
|
90
104
|
const triggerTurn = canWake && index === batches.length - 1;
|
|
91
|
-
const
|
|
105
|
+
const batch = batches[index];
|
|
106
|
+
const message = buildCompletionMessage(batch);
|
|
92
107
|
if (triggerTurn) this.wakeInFlight = true;
|
|
108
|
+
this.awaitingParentAck.push(...batch);
|
|
93
109
|
try {
|
|
94
110
|
this.pi.sendMessage(message, { deliverAs: "steer", triggerTurn });
|
|
95
|
-
this.notifyDelivered(batches[index]);
|
|
96
111
|
} catch (primaryError) {
|
|
112
|
+
this.removeAwaiting(batch);
|
|
97
113
|
if (triggerTurn) this.wakeInFlight = false;
|
|
98
114
|
canWake = false;
|
|
115
|
+
this.awaitingParentAck.push(...batch);
|
|
99
116
|
try {
|
|
100
117
|
this.pi.sendMessage(message, { deliverAs: "nextTurn", triggerTurn: false });
|
|
101
|
-
this.notifyDelivered(batches[index]);
|
|
102
118
|
} catch (fallbackError) {
|
|
119
|
+
this.removeAwaiting(batch);
|
|
103
120
|
this.pending = [...batches.slice(index).flat(), ...this.pending];
|
|
104
121
|
try {
|
|
105
122
|
this.options.onDeliveryError?.(
|
|
@@ -122,6 +139,8 @@ export class CompletionDeliveryBroker {
|
|
|
122
139
|
if (this.flushTimer) clearTimeout(this.flushTimer);
|
|
123
140
|
this.flushTimer = undefined;
|
|
124
141
|
this.pending = [];
|
|
142
|
+
this.awaitingParentAck = [];
|
|
143
|
+
this.knownCompletionIds.clear();
|
|
125
144
|
}
|
|
126
145
|
|
|
127
146
|
private scheduleFlush(): void {
|
|
@@ -132,14 +151,34 @@ export class CompletionDeliveryBroker {
|
|
|
132
151
|
}, COMPLETION_BATCH_DELAY_MS);
|
|
133
152
|
}
|
|
134
153
|
|
|
135
|
-
private
|
|
154
|
+
private acknowledgeVisible(visibleIds: ReadonlySet<string>): void {
|
|
155
|
+
if (visibleIds.size === 0) return;
|
|
156
|
+
const completions = [...this.awaitingParentAck, ...this.pending].filter((completion) =>
|
|
157
|
+
visibleIds.has(completion.completionId),
|
|
158
|
+
);
|
|
159
|
+
if (completions.length === 0) return;
|
|
160
|
+
const acknowledgedIds = new Set(completions.map((completion) => completion.completionId));
|
|
161
|
+
this.awaitingParentAck = this.awaitingParentAck.filter(
|
|
162
|
+
(completion) => !acknowledgedIds.has(completion.completionId),
|
|
163
|
+
);
|
|
164
|
+
this.pending = this.pending.filter(
|
|
165
|
+
(completion) => !acknowledgedIds.has(completion.completionId),
|
|
166
|
+
);
|
|
167
|
+
for (const completion of completions) this.knownCompletionIds.delete(completion.completionId);
|
|
136
168
|
try {
|
|
137
|
-
this.options.
|
|
169
|
+
this.options.onAcknowledged?.(completions, (this.options.now ?? Date.now)());
|
|
138
170
|
} catch {
|
|
139
|
-
//
|
|
171
|
+
// Context assembly already observed the message, so observer failures cannot retract it.
|
|
140
172
|
}
|
|
141
173
|
}
|
|
142
174
|
|
|
175
|
+
private removeAwaiting(completions: readonly AgentTurnCompletion[]): void {
|
|
176
|
+
const removed = new Set(completions.map((completion) => completion.completionId));
|
|
177
|
+
this.awaitingParentAck = this.awaitingParentAck.filter(
|
|
178
|
+
(completion) => !removed.has(completion.completionId),
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
143
182
|
private isRootIdle(): boolean {
|
|
144
183
|
try {
|
|
145
184
|
return this.ctx.isIdle();
|
|
@@ -158,6 +197,33 @@ export class CompletionDeliveryBroker {
|
|
|
158
197
|
}
|
|
159
198
|
}
|
|
160
199
|
|
|
200
|
+
function completionIdsFromContext(messages: readonly unknown[]): Set<string> {
|
|
201
|
+
const ids = new Set<string>();
|
|
202
|
+
for (const message of messages) {
|
|
203
|
+
if (!message || typeof message !== "object" || Array.isArray(message)) continue;
|
|
204
|
+
const record = message as Record<string, unknown>;
|
|
205
|
+
if (record.role !== "custom" || record.customType !== "pi-subagent-completion") continue;
|
|
206
|
+
const details = record.details;
|
|
207
|
+
if (!details || typeof details !== "object" || Array.isArray(details)) continue;
|
|
208
|
+
const metadata = details as Record<string, unknown>;
|
|
209
|
+
if (
|
|
210
|
+
metadata.protocol === PI_SUBAGENTS_RPC_PROTOCOL &&
|
|
211
|
+
typeof metadata.completionId === "string"
|
|
212
|
+
) {
|
|
213
|
+
ids.add(metadata.completionId);
|
|
214
|
+
}
|
|
215
|
+
if (!Array.isArray(metadata.completions)) continue;
|
|
216
|
+
for (const completion of metadata.completions) {
|
|
217
|
+
if (!completion || typeof completion !== "object" || Array.isArray(completion)) continue;
|
|
218
|
+
const item = completion as Record<string, unknown>;
|
|
219
|
+
if (item.protocol === PI_SUBAGENTS_RPC_PROTOCOL && typeof item.completionId === "string") {
|
|
220
|
+
ids.add(item.completionId);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return ids;
|
|
225
|
+
}
|
|
226
|
+
|
|
161
227
|
function chunkCompletions(completions: AgentTurnCompletion[]): AgentTurnCompletion[][] {
|
|
162
228
|
const batches: AgentTurnCompletion[][] = [];
|
|
163
229
|
for (let index = 0; index < completions.length; index += MAX_COMPLETIONS_PER_MESSAGE) {
|
|
@@ -203,6 +269,9 @@ function buildCompletionMessage(completions: AgentTurnCompletion[]): CompletionM
|
|
|
203
269
|
function completionMetadata(completion: AgentTurnCompletion): CompletionMetadata {
|
|
204
270
|
return {
|
|
205
271
|
protocol: PI_SUBAGENTS_RPC_PROTOCOL,
|
|
272
|
+
completionId: completion.completionId,
|
|
273
|
+
runId: completion.runId,
|
|
274
|
+
generation: completion.generation,
|
|
206
275
|
agentId: completion.agent.id,
|
|
207
276
|
agent: completion.agent.agent,
|
|
208
277
|
state: completion.agent.state,
|
|
@@ -233,6 +302,9 @@ export function buildDetachedCompletionMessage(completion: AgentTurnCompletion):
|
|
|
233
302
|
[
|
|
234
303
|
"Message Type: SUBAGENT_COMPLETION",
|
|
235
304
|
`Protocol: ${PI_SUBAGENTS_RPC_PROTOCOL}`,
|
|
305
|
+
`Completion ID: ${completion.completionId}`,
|
|
306
|
+
`Run ID: ${completion.runId}`,
|
|
307
|
+
`Generation: ${completion.generation}`,
|
|
236
308
|
`Agent ID: ${completion.agent.id}`,
|
|
237
309
|
`Agent: ${agentName}`,
|
|
238
310
|
`Task: ${task}`,
|
package/src/inspect.ts
CHANGED
|
@@ -404,6 +404,8 @@ function projectRunSummary(run: AgentRunInspectionSummary): Record<string, unkno
|
|
|
404
404
|
updatedAt: run.updatedAt,
|
|
405
405
|
historyCount: run.historyCount,
|
|
406
406
|
unreadMessages: run.unreadMessages,
|
|
407
|
+
turnGeneration: run.turnGeneration,
|
|
408
|
+
pendingCompletionCount: run.pendingCompletionCount,
|
|
407
409
|
};
|
|
408
410
|
}
|
|
409
411
|
|
|
@@ -474,6 +476,8 @@ function projectRun(run: AgentRunInspectionDetail, ctx: ExtensionContext): Recor
|
|
|
474
476
|
: undefined,
|
|
475
477
|
semanticCompatibility: run.semanticCompatibility,
|
|
476
478
|
telemetry: run.telemetry,
|
|
479
|
+
currentRunId: run.currentRunId,
|
|
480
|
+
currentTurnGeneration: run.currentTurnGeneration,
|
|
477
481
|
currentTask: run.currentTask ? boundedPrivateText(run.currentTask, 2 * 1024) : undefined,
|
|
478
482
|
error: run.error ? boundedPrivateText(run.error, 2 * 1024) : undefined,
|
|
479
483
|
target: run.target
|
package/src/persistence.ts
CHANGED
|
@@ -16,12 +16,13 @@ import { resolveStatefulLimits } from "./stateful-limits.js";
|
|
|
16
16
|
import { copyTurnTerminationReport, type TurnTerminationReport } from "./timeout-checkpoint.js";
|
|
17
17
|
import { MAX_SUBAGENT_TOOL_CALLS, MAX_SUBAGENT_TURNS } from "./turn-budget.js";
|
|
18
18
|
|
|
19
|
-
const STATE_VERSION =
|
|
19
|
+
const STATE_VERSION = 3;
|
|
20
|
+
const MAX_STORED_COMPLETIONS_PER_AGENT = 20;
|
|
20
21
|
const DEFAULT_STATEFUL_LIMITS = resolveStatefulLimits();
|
|
21
|
-
const MAX_STATE_BYTES = 1024 * 1024;
|
|
22
|
+
const MAX_STATE_BYTES = 5 * 1024 * 1024;
|
|
22
23
|
|
|
23
24
|
interface StoredState {
|
|
24
|
-
version:
|
|
25
|
+
version: 3;
|
|
25
26
|
updatedAt: number;
|
|
26
27
|
agents: ManagedAgent[];
|
|
27
28
|
}
|
|
@@ -86,8 +87,19 @@ export class AgentPersistence {
|
|
|
86
87
|
const state: StoredState = { version: STATE_VERSION, updatedAt: Date.now(), agents: records };
|
|
87
88
|
let content = `${JSON.stringify(state, null, "\t")}\n`;
|
|
88
89
|
while (Buffer.byteLength(content, "utf8") > MAX_STATE_BYTES && state.agents.length > 0) {
|
|
89
|
-
const
|
|
90
|
-
|
|
90
|
+
const rootsWithPendingCompletions = new Set(
|
|
91
|
+
state.agents
|
|
92
|
+
.filter((agent) => (agent.pendingCompletions?.length ?? 0) > 0)
|
|
93
|
+
.map((agent) => agent.rootId),
|
|
94
|
+
);
|
|
95
|
+
const droppableRoot = state.agents.find(
|
|
96
|
+
(agent) => !rootsWithPendingCompletions.has(agent.rootId),
|
|
97
|
+
)?.rootId;
|
|
98
|
+
if (droppableRoot) {
|
|
99
|
+
state.agents = state.agents.filter((agent) => agent.rootId !== droppableRoot);
|
|
100
|
+
} else if (!trimOldestHistory(state.agents) && !clearOldestContext(state.agents)) {
|
|
101
|
+
throw new Error("Subagent state exceeds its durable completion storage limit");
|
|
102
|
+
}
|
|
91
103
|
content = `${JSON.stringify(state, null, "\t")}\n`;
|
|
92
104
|
}
|
|
93
105
|
await fs.promises.mkdir(path.dirname(this.filePath), { recursive: true });
|
|
@@ -113,6 +125,27 @@ export class AgentPersistence {
|
|
|
113
125
|
}
|
|
114
126
|
}
|
|
115
127
|
|
|
128
|
+
function trimOldestHistory(agents: ManagedAgent[]): boolean {
|
|
129
|
+
const candidate = agents
|
|
130
|
+
.filter((agent) => agent.history.length > 0)
|
|
131
|
+
.sort(
|
|
132
|
+
(left, right) =>
|
|
133
|
+
(left.history[0]?.completedAt ?? Number.POSITIVE_INFINITY) -
|
|
134
|
+
(right.history[0]?.completedAt ?? Number.POSITIVE_INFINITY),
|
|
135
|
+
)[0];
|
|
136
|
+
if (!candidate) return false;
|
|
137
|
+
candidate.history.shift();
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function clearOldestContext(agents: ManagedAgent[]): boolean {
|
|
142
|
+
const candidate = agents.find((agent) => agent.context !== undefined);
|
|
143
|
+
if (!candidate) return false;
|
|
144
|
+
candidate.context = undefined;
|
|
145
|
+
candidate.contextTruncated = true;
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
|
|
116
149
|
function sanitizeAgent(agent: ManagedAgent): ManagedAgent {
|
|
117
150
|
return {
|
|
118
151
|
...agent,
|
|
@@ -126,6 +159,17 @@ function sanitizeAgent(agent: ManagedAgent): ManagedAgent {
|
|
|
126
159
|
})),
|
|
127
160
|
state: agent.state === "running" || agent.state === "starting" ? "interrupted" : agent.state,
|
|
128
161
|
currentTask: undefined,
|
|
162
|
+
currentRunId: undefined,
|
|
163
|
+
currentTurnGeneration: undefined,
|
|
164
|
+
turnGeneration: agent.turnGeneration ?? 0,
|
|
165
|
+
pendingCompletions: (agent.pendingCompletions ?? [])
|
|
166
|
+
.slice(-MAX_STORED_COMPLETIONS_PER_AGENT)
|
|
167
|
+
.map((completion) => ({
|
|
168
|
+
...completion,
|
|
169
|
+
task: redactPrivateText(completion.task),
|
|
170
|
+
output: redactPrivateText(completion.output),
|
|
171
|
+
error: completion.error ? redactPrivateText(completion.error) : undefined,
|
|
172
|
+
})),
|
|
129
173
|
currentTimeoutMs: undefined,
|
|
130
174
|
currentIdleTimeoutMs: undefined,
|
|
131
175
|
currentMaxTurns: undefined,
|
|
@@ -184,7 +228,10 @@ function sanitizeTermination(report: TurnTerminationReport): TurnTerminationRepo
|
|
|
184
228
|
function isStoredState(value: unknown): value is StoredState {
|
|
185
229
|
if (!value || typeof value !== "object") return false;
|
|
186
230
|
const state = value as { version?: unknown; agents?: unknown };
|
|
187
|
-
if (
|
|
231
|
+
if (
|
|
232
|
+
(state.version !== 1 && state.version !== 2 && state.version !== STATE_VERSION) ||
|
|
233
|
+
!Array.isArray(state.agents)
|
|
234
|
+
) {
|
|
188
235
|
return false;
|
|
189
236
|
}
|
|
190
237
|
return state.agents.every((agent) => {
|
|
@@ -208,6 +255,9 @@ function isStoredState(value: unknown): value is StoredState {
|
|
|
208
255
|
(record.termination === undefined || isTerminationReport(record.termination)) &&
|
|
209
256
|
(record.contextTurns === undefined || isNonNegativeInteger(record.contextTurns)) &&
|
|
210
257
|
(record.contextBytes === undefined || isNonNegativeInteger(record.contextBytes)) &&
|
|
258
|
+
(record.turnGeneration === undefined || isNonNegativeInteger(record.turnGeneration)) &&
|
|
259
|
+
(record.pendingCompletions === undefined ||
|
|
260
|
+
isCompletionOutbox(record.pendingCompletions, record.turnGeneration ?? 0)) &&
|
|
211
261
|
(record.spawnIdempotencyKey === undefined ||
|
|
212
262
|
(typeof record.spawnIdempotencyKey === "string" &&
|
|
213
263
|
record.spawnIdempotencyKey.length > 0 &&
|
|
@@ -356,10 +406,59 @@ function isTargetPolicyAudit(value: unknown): boolean {
|
|
|
356
406
|
);
|
|
357
407
|
}
|
|
358
408
|
|
|
409
|
+
function isCompletionOutbox(value: unknown, turnGeneration: number): boolean {
|
|
410
|
+
if (!Array.isArray(value) || value.length > MAX_STORED_COMPLETIONS_PER_AGENT) return false;
|
|
411
|
+
const ids = new Set<string>();
|
|
412
|
+
let previousGeneration = 0;
|
|
413
|
+
let previousCreatedAt = Number.NEGATIVE_INFINITY;
|
|
414
|
+
for (const completion of value) {
|
|
415
|
+
if (!isPersistedCompletion(completion, turnGeneration)) return false;
|
|
416
|
+
if (ids.has(completion.completionId)) return false;
|
|
417
|
+
if (completion.generation <= previousGeneration || completion.createdAt <= previousCreatedAt) {
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
ids.add(completion.completionId);
|
|
421
|
+
previousGeneration = completion.generation;
|
|
422
|
+
previousCreatedAt = completion.createdAt;
|
|
423
|
+
}
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function isPersistedCompletion(
|
|
428
|
+
value: unknown,
|
|
429
|
+
turnGeneration: number,
|
|
430
|
+
): value is import("./registry.js").PersistedAgentCompletion {
|
|
431
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
432
|
+
const completion = value as Record<string, unknown>;
|
|
433
|
+
return (
|
|
434
|
+
typeof completion.completionId === "string" &&
|
|
435
|
+
completion.completionId.length > 0 &&
|
|
436
|
+
completion.completionId.length <= 256 &&
|
|
437
|
+
typeof completion.runId === "string" &&
|
|
438
|
+
completion.runId.length > 0 &&
|
|
439
|
+
completion.runId.length <= 256 &&
|
|
440
|
+
typeof completion.generation === "number" &&
|
|
441
|
+
Number.isSafeInteger(completion.generation) &&
|
|
442
|
+
completion.generation >= 1 &&
|
|
443
|
+
completion.generation <= turnGeneration &&
|
|
444
|
+
typeof completion.task === "string" &&
|
|
445
|
+
typeof completion.output === "string" &&
|
|
446
|
+
(completion.error === undefined || typeof completion.error === "string") &&
|
|
447
|
+
typeof completion.createdAt === "number" &&
|
|
448
|
+
Number.isFinite(completion.createdAt)
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
359
452
|
function isAgentTurn(value: unknown): boolean {
|
|
360
453
|
if (!value || typeof value !== "object") return false;
|
|
361
454
|
const turn = value as Record<string, unknown>;
|
|
362
455
|
return (
|
|
456
|
+
(turn.runId === undefined ||
|
|
457
|
+
(typeof turn.runId === "string" && turn.runId.length > 0 && turn.runId.length <= 256)) &&
|
|
458
|
+
(turn.generation === undefined ||
|
|
459
|
+
(typeof turn.generation === "number" &&
|
|
460
|
+
Number.isSafeInteger(turn.generation) &&
|
|
461
|
+
turn.generation >= 1)) &&
|
|
363
462
|
typeof turn.task === "string" &&
|
|
364
463
|
typeof turn.output === "string" &&
|
|
365
464
|
typeof turn.startedAt === "number" &&
|
package/src/registry-types.ts
CHANGED
|
@@ -23,6 +23,8 @@ export type AgentLifecycleState =
|
|
|
23
23
|
| "closed";
|
|
24
24
|
|
|
25
25
|
export interface AgentTurn {
|
|
26
|
+
runId?: string;
|
|
27
|
+
generation?: number;
|
|
26
28
|
task: string;
|
|
27
29
|
output: string;
|
|
28
30
|
startedAt: number;
|
|
@@ -32,6 +34,16 @@ export interface AgentTurn {
|
|
|
32
34
|
termination?: TurnTerminationReport;
|
|
33
35
|
}
|
|
34
36
|
|
|
37
|
+
export interface PersistedAgentCompletion {
|
|
38
|
+
completionId: string;
|
|
39
|
+
runId: string;
|
|
40
|
+
generation: number;
|
|
41
|
+
task: string;
|
|
42
|
+
output: string;
|
|
43
|
+
error?: string;
|
|
44
|
+
createdAt: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
35
47
|
export interface AgentMailboxMessage {
|
|
36
48
|
id: string;
|
|
37
49
|
senderId: string;
|
|
@@ -64,6 +76,10 @@ export interface ManagedAgent {
|
|
|
64
76
|
maxToolCalls?: number;
|
|
65
77
|
currentMaxToolCalls?: number;
|
|
66
78
|
currentTask?: string;
|
|
79
|
+
turnGeneration?: number;
|
|
80
|
+
currentRunId?: string;
|
|
81
|
+
currentTurnGeneration?: number;
|
|
82
|
+
pendingCompletions?: PersistedAgentCompletion[];
|
|
67
83
|
history: AgentTurn[];
|
|
68
84
|
error?: string;
|
|
69
85
|
context?: string;
|
|
@@ -98,6 +114,8 @@ export interface AgentRunInspectionSummary {
|
|
|
98
114
|
updatedAt: number;
|
|
99
115
|
historyCount: number;
|
|
100
116
|
unreadMessages: number;
|
|
117
|
+
turnGeneration: number;
|
|
118
|
+
pendingCompletionCount: number;
|
|
101
119
|
}
|
|
102
120
|
|
|
103
121
|
export interface AgentRunInspectionDetail extends AgentRunInspectionSummary {
|
|
@@ -112,6 +130,8 @@ export interface AgentRunInspectionDetail extends AgentRunInspectionSummary {
|
|
|
112
130
|
maxToolCalls?: number;
|
|
113
131
|
currentMaxToolCalls?: number;
|
|
114
132
|
currentTask?: string;
|
|
133
|
+
currentRunId?: string;
|
|
134
|
+
currentTurnGeneration?: number;
|
|
115
135
|
error?: string;
|
|
116
136
|
workspaceMode?: "worktree";
|
|
117
137
|
contextTurns?: number;
|
|
@@ -151,11 +171,8 @@ export interface TurnOutcome {
|
|
|
151
171
|
telemetry?: TransportTelemetry;
|
|
152
172
|
}
|
|
153
173
|
|
|
154
|
-
export interface AgentTurnCompletion {
|
|
174
|
+
export interface AgentTurnCompletion extends PersistedAgentCompletion {
|
|
155
175
|
agent: ManagedAgent;
|
|
156
|
-
task: string;
|
|
157
|
-
output: string;
|
|
158
|
-
error?: string;
|
|
159
176
|
}
|
|
160
177
|
|
|
161
178
|
export interface AgentRegistryOptions {
|
package/src/registry.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
DEFAULT_MAX_CONTEXT_BYTES,
|
|
22
22
|
DEFAULT_MAX_OUTPUT_BYTES,
|
|
23
23
|
MAX_SUBAGENT_TIMEOUT_MS,
|
|
24
|
+
MAX_TOOL_MESSAGE_BYTES,
|
|
24
25
|
truncateUtf8,
|
|
25
26
|
} from "./limits.js";
|
|
26
27
|
import { classifyStructuredOutcome } from "./outcome.js";
|
|
@@ -47,6 +48,9 @@ import type { TransportTelemetry } from "./transport-types.js";
|
|
|
47
48
|
import { type TurnLimits, validateTurnLimits } from "./turn-budget.js";
|
|
48
49
|
|
|
49
50
|
const DEFAULT_STATEFUL_LIMITS = resolveStatefulLimits();
|
|
51
|
+
const MAX_PENDING_COMPLETIONS_PER_AGENT = 20;
|
|
52
|
+
const INITIAL_PERSISTENCE_RETRY_DELAY_MS = 25;
|
|
53
|
+
const MAX_PERSISTENCE_RETRY_DELAY_MS = 1_000;
|
|
50
54
|
|
|
51
55
|
export type * from "./registry-types.js";
|
|
52
56
|
|
|
@@ -73,6 +77,8 @@ function validateTurnTimeout(value: number): number {
|
|
|
73
77
|
|
|
74
78
|
function clearCurrentTurn(agent: ManagedAgent): void {
|
|
75
79
|
agent.currentTask = undefined;
|
|
80
|
+
agent.currentRunId = undefined;
|
|
81
|
+
agent.currentTurnGeneration = undefined;
|
|
76
82
|
agent.currentTimeoutMs = undefined;
|
|
77
83
|
agent.currentIdleTimeoutMs = undefined;
|
|
78
84
|
agent.currentMaxTurns = undefined;
|
|
@@ -86,6 +92,21 @@ function waitAbortError(): Error {
|
|
|
86
92
|
return error;
|
|
87
93
|
}
|
|
88
94
|
|
|
95
|
+
function waitForPersistenceRetry(milliseconds: number, signal: AbortSignal): Promise<void> {
|
|
96
|
+
if (signal.aborted) return Promise.reject(signal.reason);
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
const onAbort = () => {
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
reject(signal.reason);
|
|
101
|
+
};
|
|
102
|
+
const timer = setTimeout(() => {
|
|
103
|
+
signal.removeEventListener("abort", onAbort);
|
|
104
|
+
resolve();
|
|
105
|
+
}, milliseconds);
|
|
106
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
89
110
|
export class AgentRegistry {
|
|
90
111
|
private readonly agents = new Map<string, ManagedAgent>();
|
|
91
112
|
private readonly controllers = new Map<string, AbortController>();
|
|
@@ -96,6 +117,7 @@ export class AgentRegistry {
|
|
|
96
117
|
resolve: (agent: ManagedAgent) => void;
|
|
97
118
|
}> = [];
|
|
98
119
|
private changeQueue: Promise<void> = Promise.resolve();
|
|
120
|
+
private readonly shutdownController = new AbortController();
|
|
99
121
|
private readonly maxAgents: number;
|
|
100
122
|
private readonly maxActiveTurns: number;
|
|
101
123
|
private readonly maxHistoryTurns: number;
|
|
@@ -108,6 +130,7 @@ export class AgentRegistry {
|
|
|
108
130
|
private readonly idleTtlMs: number;
|
|
109
131
|
private readonly transport: SubagentTransport;
|
|
110
132
|
private readonly now: () => number;
|
|
133
|
+
private lastCompletionAt = 0;
|
|
111
134
|
|
|
112
135
|
constructor(
|
|
113
136
|
transport: SubagentTransport | AgentTurnRunner,
|
|
@@ -176,6 +199,9 @@ export class AgentRegistry {
|
|
|
176
199
|
}
|
|
177
200
|
const depth = seen.size - 1;
|
|
178
201
|
if (cyclic || depth > this.maxDepth) continue;
|
|
202
|
+
for (const completion of record.pendingCompletions ?? []) {
|
|
203
|
+
this.lastCompletionAt = Math.max(this.lastCompletionAt, completion.createdAt);
|
|
204
|
+
}
|
|
179
205
|
this.agents.set(record.id, {
|
|
180
206
|
...record,
|
|
181
207
|
state:
|
|
@@ -183,6 +209,12 @@ export class AgentRegistry {
|
|
|
183
209
|
rootId,
|
|
184
210
|
depth,
|
|
185
211
|
currentTask: undefined,
|
|
212
|
+
turnGeneration: record.turnGeneration ?? 0,
|
|
213
|
+
currentRunId: undefined,
|
|
214
|
+
currentTurnGeneration: undefined,
|
|
215
|
+
pendingCompletions: (record.pendingCompletions ?? []).map((completion) => ({
|
|
216
|
+
...completion,
|
|
217
|
+
})),
|
|
186
218
|
currentTimeoutMs: undefined,
|
|
187
219
|
currentIdleTimeoutMs: undefined,
|
|
188
220
|
currentMaxTurns: undefined,
|
|
@@ -286,6 +318,8 @@ export class AgentRegistry {
|
|
|
286
318
|
maxToolCalls: input.maxToolCalls,
|
|
287
319
|
currentMaxToolCalls: input.maxToolCalls,
|
|
288
320
|
currentTask: task,
|
|
321
|
+
turnGeneration: 0,
|
|
322
|
+
pendingCompletions: [],
|
|
289
323
|
history: [],
|
|
290
324
|
mailbox: [],
|
|
291
325
|
context: input.context,
|
|
@@ -486,19 +520,30 @@ export class AgentRegistry {
|
|
|
486
520
|
const index = this.queue.findIndex((entry) => entry.agent.id === id);
|
|
487
521
|
if (index >= 0) {
|
|
488
522
|
const [entry] = this.queue.splice(index, 1);
|
|
523
|
+
const persistedCompletion = {
|
|
524
|
+
completionId: `completion:${agent.id}:${randomUUID()}`,
|
|
525
|
+
runId: agent.currentRunId ?? `run:${agent.id}:${randomUUID()}`,
|
|
526
|
+
generation: agent.currentTurnGeneration ?? agent.turnGeneration ?? 1,
|
|
527
|
+
task: truncateUtf8(entry.task, 256).text,
|
|
528
|
+
output: "",
|
|
529
|
+
error: "Interrupted before execution",
|
|
530
|
+
createdAt: this.completionCreatedAt(),
|
|
531
|
+
};
|
|
489
532
|
agent.state = "interrupted";
|
|
533
|
+
agent.pendingCompletions = [...(agent.pendingCompletions ?? []), persistedCompletion];
|
|
490
534
|
clearCurrentTurn(agent);
|
|
491
535
|
agent.updatedAt = this.now();
|
|
536
|
+
const persisted = await this.persistTerminalState().then(
|
|
537
|
+
() => true,
|
|
538
|
+
() => false,
|
|
539
|
+
);
|
|
492
540
|
const completion: AgentTurnCompletion = {
|
|
541
|
+
...persistedCompletion,
|
|
493
542
|
agent: this.copy(agent),
|
|
494
|
-
task: entry.task,
|
|
495
|
-
output: "",
|
|
496
|
-
error: "Interrupted before execution",
|
|
497
543
|
};
|
|
498
544
|
entry.resolve(agent);
|
|
499
545
|
this.running.delete(id);
|
|
500
|
-
await this.notifyTurnComplete(completion);
|
|
501
|
-
await this.changed();
|
|
546
|
+
if (persisted) await this.notifyTurnComplete(completion);
|
|
502
547
|
return this.copy(agent);
|
|
503
548
|
}
|
|
504
549
|
}
|
|
@@ -584,6 +629,7 @@ export class AgentRegistry {
|
|
|
584
629
|
}
|
|
585
630
|
|
|
586
631
|
async shutdown(): Promise<void> {
|
|
632
|
+
this.shutdownController.abort(new Error("Subagent registry is shutting down"));
|
|
587
633
|
for (const entry of this.queue.splice(0)) {
|
|
588
634
|
if (entry.agent.capabilityGrant?.state === "active") {
|
|
589
635
|
entry.agent.capabilityGrant = revokeCapabilityGrant(
|
|
@@ -629,7 +675,7 @@ export class AgentRegistry {
|
|
|
629
675
|
} catch (error) {
|
|
630
676
|
shutdownError = error;
|
|
631
677
|
}
|
|
632
|
-
await this.changed();
|
|
678
|
+
await this.changed(true);
|
|
633
679
|
if (shutdownError) throw shutdownError;
|
|
634
680
|
}
|
|
635
681
|
|
|
@@ -666,6 +712,8 @@ export class AgentRegistry {
|
|
|
666
712
|
maxToolCalls: agent.maxToolCalls,
|
|
667
713
|
currentMaxToolCalls: agent.currentMaxToolCalls,
|
|
668
714
|
currentTask: agent.currentTask,
|
|
715
|
+
currentRunId: agent.currentRunId,
|
|
716
|
+
currentTurnGeneration: agent.currentTurnGeneration,
|
|
669
717
|
error: agent.error,
|
|
670
718
|
workspaceMode: agent.workspaceMode,
|
|
671
719
|
contextTurns: agent.contextTurns,
|
|
@@ -712,14 +760,60 @@ export class AgentRegistry {
|
|
|
712
760
|
return agent ? this.copy(agent) : undefined;
|
|
713
761
|
}
|
|
714
762
|
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
763
|
+
listPendingCompletions(): AgentTurnCompletion[] {
|
|
764
|
+
return [...this.agents.values()]
|
|
765
|
+
.flatMap((agent) =>
|
|
766
|
+
(agent.pendingCompletions ?? []).map((completion) => ({
|
|
767
|
+
...completion,
|
|
768
|
+
agent: this.copy(agent),
|
|
769
|
+
})),
|
|
770
|
+
)
|
|
771
|
+
.sort(
|
|
772
|
+
(left, right) =>
|
|
773
|
+
left.createdAt - right.createdAt ||
|
|
774
|
+
left.generation - right.generation ||
|
|
775
|
+
left.completionId.localeCompare(right.completionId),
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
async markCompletionDelivered(completionId: string, deliveredAt: number): Promise<void> {
|
|
780
|
+
const agent = [...this.agents.values()].find((candidate) =>
|
|
781
|
+
candidate.pendingCompletions?.some((completion) => completion.completionId === completionId),
|
|
782
|
+
);
|
|
783
|
+
if (!agent) return;
|
|
784
|
+
const acknowledged = (agent.pendingCompletions ?? []).find(
|
|
785
|
+
(completion) => completion.completionId === completionId,
|
|
786
|
+
);
|
|
787
|
+
if (!acknowledged) return;
|
|
788
|
+
agent.pendingCompletions = (agent.pendingCompletions ?? []).filter(
|
|
789
|
+
(completion) => completion.completionId !== completionId,
|
|
790
|
+
);
|
|
791
|
+
if (agent.telemetry) {
|
|
792
|
+
agent.telemetry = {
|
|
793
|
+
...agent.telemetry,
|
|
794
|
+
updatedAt: deliveredAt,
|
|
795
|
+
timing: { ...agent.telemetry.timing, completionDeliveredAt: deliveredAt },
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
agent.updatedAt = Math.max(agent.updatedAt, deliveredAt);
|
|
799
|
+
try {
|
|
800
|
+
await this.changed(true);
|
|
801
|
+
} catch (error) {
|
|
802
|
+
if (
|
|
803
|
+
!agent.pendingCompletions?.some(
|
|
804
|
+
(completion) => completion.completionId === acknowledged.completionId,
|
|
805
|
+
)
|
|
806
|
+
) {
|
|
807
|
+
agent.pendingCompletions = [...(agent.pendingCompletions ?? []), acknowledged].sort(
|
|
808
|
+
(left, right) => left.createdAt - right.createdAt || left.generation - right.generation,
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
if (agent.telemetry?.timing.completionDeliveredAt === deliveredAt) {
|
|
812
|
+
const { completionDeliveredAt: _discarded, ...timing } = agent.telemetry.timing;
|
|
813
|
+
agent.telemetry = { ...agent.telemetry, timing };
|
|
814
|
+
}
|
|
815
|
+
throw error;
|
|
816
|
+
}
|
|
723
817
|
}
|
|
724
818
|
|
|
725
819
|
async sweepExpired(): Promise<number> {
|
|
@@ -740,6 +834,14 @@ export class AgentRegistry {
|
|
|
740
834
|
task: string,
|
|
741
835
|
limits: TurnLimits & { timeoutMs?: number } = {},
|
|
742
836
|
): void {
|
|
837
|
+
if ((agent.pendingCompletions?.length ?? 0) >= MAX_PENDING_COMPLETIONS_PER_AGENT) {
|
|
838
|
+
throw new Error(
|
|
839
|
+
`Agent ${agent.id} has ${MAX_PENDING_COMPLETIONS_PER_AGENT} undelivered completions; wait for delivery before another turn`,
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
agent.turnGeneration = (agent.turnGeneration ?? 0) + 1;
|
|
843
|
+
agent.currentTurnGeneration = agent.turnGeneration;
|
|
844
|
+
agent.currentRunId = `run:${agent.id}:${randomUUID()}`;
|
|
743
845
|
agent.state = "starting";
|
|
744
846
|
agent.error = undefined;
|
|
745
847
|
agent.currentTask = task;
|
|
@@ -790,17 +892,30 @@ export class AgentRegistry {
|
|
|
790
892
|
agent.state = "failed";
|
|
791
893
|
agent.error = "Capability grant expired or no longer matches the accepted plan";
|
|
792
894
|
agent.outcome = classifyStructuredOutcome("failed", "capability-grant-invalid");
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
void this.notifyTurnComplete({
|
|
799
|
-
agent: this.copy(agent),
|
|
800
|
-
task,
|
|
895
|
+
const persistedCompletion = {
|
|
896
|
+
completionId: `completion:${agent.id}:${randomUUID()}`,
|
|
897
|
+
runId: agent.currentRunId ?? `run:${agent.id}:${randomUUID()}`,
|
|
898
|
+
generation: agent.currentTurnGeneration ?? agent.turnGeneration ?? 1,
|
|
899
|
+
task: truncateUtf8(task, 256).text,
|
|
801
900
|
output: "",
|
|
802
|
-
error: agent.error,
|
|
803
|
-
|
|
901
|
+
error: truncateUtf8(agent.error, 512).text,
|
|
902
|
+
createdAt: this.completionCreatedAt(),
|
|
903
|
+
};
|
|
904
|
+
agent.pendingCompletions = [...(agent.pendingCompletions ?? []), persistedCompletion];
|
|
905
|
+
clearCurrentTurn(agent);
|
|
906
|
+
agent.updatedAt = this.now();
|
|
907
|
+
void this.persistTerminalState()
|
|
908
|
+
.then(() =>
|
|
909
|
+
this.notifyTurnComplete({
|
|
910
|
+
...persistedCompletion,
|
|
911
|
+
agent: this.copy(agent),
|
|
912
|
+
}),
|
|
913
|
+
)
|
|
914
|
+
.catch(() => undefined)
|
|
915
|
+
.finally(() => {
|
|
916
|
+
resolveQueued(agent);
|
|
917
|
+
this.running.delete(agent.id);
|
|
918
|
+
});
|
|
804
919
|
return;
|
|
805
920
|
}
|
|
806
921
|
const controller = new AbortController();
|
|
@@ -808,6 +923,8 @@ export class AgentRegistry {
|
|
|
808
923
|
agent.state = "running";
|
|
809
924
|
agent.updatedAt = this.now();
|
|
810
925
|
const startedAt = this.now();
|
|
926
|
+
const runId = agent.currentRunId ?? `run:${agent.id}:${randomUUID()}`;
|
|
927
|
+
const turnGeneration = agent.currentTurnGeneration ?? agent.turnGeneration ?? 1;
|
|
811
928
|
const completionKey = `completion:${agent.id}:${randomUUID()}`;
|
|
812
929
|
const acceptedPlanId = agent.executionPlan?.id;
|
|
813
930
|
let completionContent = "";
|
|
@@ -830,6 +947,8 @@ export class AgentRegistry {
|
|
|
830
947
|
? truncateUtf8(outcome.error, this.maxTurnOutputBytes).text
|
|
831
948
|
: undefined;
|
|
832
949
|
agent.history.push({
|
|
950
|
+
runId,
|
|
951
|
+
generation: turnGeneration,
|
|
833
952
|
task,
|
|
834
953
|
output,
|
|
835
954
|
startedAt,
|
|
@@ -925,6 +1044,8 @@ export class AgentRegistry {
|
|
|
925
1044
|
this.maxTurnOutputBytes,
|
|
926
1045
|
).text;
|
|
927
1046
|
agent.history.push({
|
|
1047
|
+
runId,
|
|
1048
|
+
generation: turnGeneration,
|
|
928
1049
|
task,
|
|
929
1050
|
output: "",
|
|
930
1051
|
startedAt,
|
|
@@ -947,12 +1068,16 @@ export class AgentRegistry {
|
|
|
947
1068
|
return agent;
|
|
948
1069
|
})
|
|
949
1070
|
.finally(async () => {
|
|
950
|
-
const
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
1071
|
+
const persistedCompletion = {
|
|
1072
|
+
completionId: completionKey,
|
|
1073
|
+
runId,
|
|
1074
|
+
generation: turnGeneration,
|
|
1075
|
+
task: truncateUtf8(task, 256).text,
|
|
1076
|
+
output: truncateUtf8(completionOutput, MAX_TOOL_MESSAGE_BYTES).text,
|
|
1077
|
+
error: completionError ? truncateUtf8(completionError, 512).text : undefined,
|
|
1078
|
+
createdAt: this.completionCreatedAt(),
|
|
955
1079
|
};
|
|
1080
|
+
agent.pendingCompletions = [...(agent.pendingCompletions ?? []), persistedCompletion];
|
|
956
1081
|
if (agent.parentId) {
|
|
957
1082
|
const parent = this.agents.get(agent.parentId);
|
|
958
1083
|
if (parent && parent.state !== "closed") {
|
|
@@ -961,12 +1086,19 @@ export class AgentRegistry {
|
|
|
961
1086
|
}
|
|
962
1087
|
clearCurrentTurn(agent);
|
|
963
1088
|
agent.updatedAt = this.now();
|
|
1089
|
+
const persisted = await this.persistTerminalState().then(
|
|
1090
|
+
() => true,
|
|
1091
|
+
() => false,
|
|
1092
|
+
);
|
|
964
1093
|
this.controllers.delete(agent.id);
|
|
1094
|
+
const turnCompletion: AgentTurnCompletion = {
|
|
1095
|
+
...persistedCompletion,
|
|
1096
|
+
agent: this.copy(agent),
|
|
1097
|
+
};
|
|
965
1098
|
this.running.delete(agent.id);
|
|
966
1099
|
resolveQueued(agent);
|
|
967
1100
|
this.pumpQueue();
|
|
968
|
-
await this.notifyTurnComplete(turnCompletion);
|
|
969
|
-
await this.changed();
|
|
1101
|
+
if (persisted) await this.notifyTurnComplete(turnCompletion);
|
|
970
1102
|
});
|
|
971
1103
|
}
|
|
972
1104
|
|
|
@@ -1032,11 +1164,22 @@ export class AgentRegistry {
|
|
|
1032
1164
|
return [...this.agents.values()].filter((agent) => agent.state !== "closed").length;
|
|
1033
1165
|
}
|
|
1034
1166
|
|
|
1167
|
+
private completionCreatedAt(): number {
|
|
1168
|
+
this.lastCompletionAt = Math.max(this.now(), this.lastCompletionAt + 1);
|
|
1169
|
+
return this.lastCompletionAt;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1035
1172
|
private evictExpired(): ManagedAgent[] {
|
|
1036
1173
|
const cutoff = this.now() - this.idleTtlMs;
|
|
1037
1174
|
const protectedIds = new Set<string>();
|
|
1038
1175
|
for (const agent of this.agents.values()) {
|
|
1039
|
-
if (
|
|
1176
|
+
if (
|
|
1177
|
+
agent.state !== "running" &&
|
|
1178
|
+
agent.state !== "starting" &&
|
|
1179
|
+
(agent.pendingCompletions?.length ?? 0) === 0
|
|
1180
|
+
) {
|
|
1181
|
+
continue;
|
|
1182
|
+
}
|
|
1040
1183
|
let current: ManagedAgent | undefined = agent;
|
|
1041
1184
|
while (current) {
|
|
1042
1185
|
protectedIds.add(current.id);
|
|
@@ -1081,6 +1224,25 @@ export class AgentRegistry {
|
|
|
1081
1224
|
for (const agent of closed.slice(this.maxAgents)) this.agents.delete(agent.id);
|
|
1082
1225
|
}
|
|
1083
1226
|
|
|
1227
|
+
private async persistTerminalState(): Promise<void> {
|
|
1228
|
+
let failures = 0;
|
|
1229
|
+
for (;;) {
|
|
1230
|
+
try {
|
|
1231
|
+
await this.changed(true);
|
|
1232
|
+
return;
|
|
1233
|
+
} catch (error) {
|
|
1234
|
+
if (this.shutdownController.signal.aborted) throw error;
|
|
1235
|
+
failures++;
|
|
1236
|
+
if (failures === 1) continue;
|
|
1237
|
+
const delay = Math.min(
|
|
1238
|
+
INITIAL_PERSISTENCE_RETRY_DELAY_MS * 2 ** (failures - 2),
|
|
1239
|
+
MAX_PERSISTENCE_RETRY_DELAY_MS,
|
|
1240
|
+
);
|
|
1241
|
+
await waitForPersistenceRetry(delay, this.shutdownController.signal);
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1084
1246
|
private async notifyTurnComplete(completion: AgentTurnCompletion): Promise<void> {
|
|
1085
1247
|
try {
|
|
1086
1248
|
await this.options.onTurnComplete?.(completion);
|
|
@@ -1089,16 +1251,17 @@ export class AgentRegistry {
|
|
|
1089
1251
|
}
|
|
1090
1252
|
}
|
|
1091
1253
|
|
|
1092
|
-
private changed(): Promise<void> {
|
|
1254
|
+
private changed(propagateError = false): Promise<void> {
|
|
1093
1255
|
const snapshot = this.list(true);
|
|
1094
1256
|
const next = this.changeQueue.then(async () => {
|
|
1095
1257
|
try {
|
|
1096
1258
|
await this.options.onChange?.(snapshot);
|
|
1097
|
-
} catch {
|
|
1098
|
-
|
|
1259
|
+
} catch (error) {
|
|
1260
|
+
if (propagateError) throw error;
|
|
1261
|
+
// Non-terminal persistence remains best-effort so lifecycle controls stay usable.
|
|
1099
1262
|
}
|
|
1100
1263
|
});
|
|
1101
|
-
this.changeQueue = next;
|
|
1264
|
+
this.changeQueue = next.catch(() => undefined);
|
|
1102
1265
|
return next;
|
|
1103
1266
|
}
|
|
1104
1267
|
|
|
@@ -1115,6 +1278,8 @@ export class AgentRegistry {
|
|
|
1115
1278
|
updatedAt: agent.updatedAt,
|
|
1116
1279
|
historyCount: agent.history.length,
|
|
1117
1280
|
unreadMessages,
|
|
1281
|
+
turnGeneration: agent.turnGeneration ?? 0,
|
|
1282
|
+
pendingCompletionCount: agent.pendingCompletions?.length ?? 0,
|
|
1118
1283
|
};
|
|
1119
1284
|
}
|
|
1120
1285
|
|
|
@@ -1126,6 +1291,9 @@ export class AgentRegistry {
|
|
|
1126
1291
|
currentMailboxMessageIds: agent.currentMailboxMessageIds
|
|
1127
1292
|
? [...agent.currentMailboxMessageIds]
|
|
1128
1293
|
: undefined,
|
|
1294
|
+
pendingCompletions: (agent.pendingCompletions ?? []).map((completion) => ({
|
|
1295
|
+
...completion,
|
|
1296
|
+
})),
|
|
1129
1297
|
history: agent.history.map((turn) => ({ ...turn })),
|
|
1130
1298
|
mailbox: agent.mailbox.map((message) => ({ ...message })),
|
|
1131
1299
|
contract: agent.contract ? structuredClone(agent.contract) : undefined,
|
package/src/stateful-guidance.ts
CHANGED
|
@@ -31,6 +31,6 @@ export function createSpawnPromptGuidelines(
|
|
|
31
31
|
"Add another subagent_spawn only for truly independent work with safe workspace concurrency.",
|
|
32
32
|
noLocalWorkGuidance,
|
|
33
33
|
'Consume and synthesize available subagent_spawn completion messages; use subagent_manage with action "interrupt" or "close" for agents that are no longer needed.',
|
|
34
|
-
'Completion from subagent_spawn is delivered automatically. Do not poll with
|
|
34
|
+
'Completion from subagent_spawn is delivered automatically. Do not poll with subagent_inspect or subagent_mailbox action "read", repeatedly check progress, or duplicate the delegated work.',
|
|
35
35
|
];
|
|
36
36
|
}
|
package/src/stateful-render.ts
CHANGED
|
@@ -75,7 +75,6 @@ function renderStatefulCall(tool: StatefulRenderTool, args: Record<string, unkno
|
|
|
75
75
|
const metadata: string[] = [];
|
|
76
76
|
if (typeof args.agentId === "string") metadata.push(`id:${safeLine(args.agentId, "", 256)}`);
|
|
77
77
|
if (args.subtree === true) metadata.push("subtree");
|
|
78
|
-
if (args.includeClosed === true) metadata.push("include closed");
|
|
79
78
|
return new Text(toolHeader(theme, "subagent_manage", args.action, metadata), 0, 0);
|
|
80
79
|
}
|
|
81
80
|
const metadata = [`id:${safeLine(args.agentId, "...", 256)}`];
|
|
@@ -1,25 +1,18 @@
|
|
|
1
1
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
|
|
4
|
-
const MANAGE_ACTIONS = ["
|
|
4
|
+
const MANAGE_ACTIONS = ["interrupt", "close"] as const;
|
|
5
5
|
const MAILBOX_ACTIONS = ["send", "read"] as const;
|
|
6
6
|
const MAX_MAILBOX_MESSAGE_LENGTH = 16 * 1024;
|
|
7
7
|
|
|
8
8
|
export const ManageParamsSchema = Type.Object(
|
|
9
9
|
{
|
|
10
10
|
action: StringEnum(MANAGE_ACTIONS, {
|
|
11
|
-
description:
|
|
12
|
-
"Use list to inspect agents, interrupt to stop active work, or close to release agents.",
|
|
11
|
+
description: "Interrupt active work or close an agent and release its resources.",
|
|
13
12
|
}),
|
|
14
13
|
agentId: Type.Optional(
|
|
15
14
|
Type.String({ minLength: 1, description: "Required for interrupt and close." }),
|
|
16
15
|
),
|
|
17
|
-
includeClosed: Type.Optional(
|
|
18
|
-
Type.Boolean({
|
|
19
|
-
default: false,
|
|
20
|
-
description: "List closed records as well as retained agents.",
|
|
21
|
-
}),
|
|
22
|
-
),
|
|
23
16
|
subtree: Type.Optional(
|
|
24
17
|
Type.Boolean({
|
|
25
18
|
default: false,
|
|
@@ -57,9 +50,11 @@ export const MailboxParamsSchema = Type.Object(
|
|
|
57
50
|
{ additionalProperties: false },
|
|
58
51
|
);
|
|
59
52
|
|
|
60
|
-
export type ValidatedManageParams =
|
|
61
|
-
|
|
62
|
-
|
|
53
|
+
export type ValidatedManageParams = {
|
|
54
|
+
action: "interrupt" | "close";
|
|
55
|
+
agentId: string;
|
|
56
|
+
subtree?: boolean;
|
|
57
|
+
};
|
|
63
58
|
|
|
64
59
|
export type ValidatedMailboxParams =
|
|
65
60
|
| {
|
|
@@ -73,21 +68,19 @@ export type ValidatedMailboxParams =
|
|
|
73
68
|
|
|
74
69
|
export function validateManageParams(params: unknown): ValidatedManageParams {
|
|
75
70
|
const values = parameterRecord(params, "subagent_manage");
|
|
76
|
-
assertKnownKeys("subagent_manage", values, ["action", "agentId", "
|
|
71
|
+
assertKnownKeys("subagent_manage", values, ["action", "agentId", "subtree"]);
|
|
77
72
|
const action = values.action;
|
|
73
|
+
if (action === "list") {
|
|
74
|
+
throw new Error(
|
|
75
|
+
'subagent_manage no longer supports "list"; use subagent_inspect action "list_runs"',
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
78
|
if (
|
|
79
79
|
typeof action !== "string" ||
|
|
80
80
|
!MANAGE_ACTIONS.includes(action as (typeof MANAGE_ACTIONS)[number])
|
|
81
81
|
) {
|
|
82
82
|
throw new Error(`subagent_manage action must be one of: ${MANAGE_ACTIONS.join(", ")}`);
|
|
83
83
|
}
|
|
84
|
-
if (action === "list") {
|
|
85
|
-
assertOptionalBoolean("subagent_manage", action, values, "includeClosed");
|
|
86
|
-
return {
|
|
87
|
-
action,
|
|
88
|
-
...(values.includeClosed === undefined ? {} : { includeClosed: values.includeClosed }),
|
|
89
|
-
} as ValidatedManageParams;
|
|
90
|
-
}
|
|
91
84
|
assertRequiredString("subagent_manage", action, values, "agentId");
|
|
92
85
|
assertOptionalBoolean("subagent_manage", action, values, "subtree");
|
|
93
86
|
return {
|
package/src/stateful.ts
CHANGED
|
@@ -52,7 +52,7 @@ import {
|
|
|
52
52
|
hashSpawnRequest,
|
|
53
53
|
MAX_SPAWN_IDEMPOTENCY_KEY_LENGTH,
|
|
54
54
|
} from "./spawn-idempotency.js";
|
|
55
|
-
import {
|
|
55
|
+
import { summarizeStatefulAgent } from "./stateful-agent-view.js";
|
|
56
56
|
import { resolveCompletionDelivery, resolveStatefulTransportKind } from "./stateful-config.js";
|
|
57
57
|
import { createSpawnPromptGuidelines } from "./stateful-guidance.js";
|
|
58
58
|
import {
|
|
@@ -300,10 +300,16 @@ export function registerStatefulSubagents(
|
|
|
300
300
|
const reason = error instanceof Error ? error.message : String(error);
|
|
301
301
|
ctx.ui.notify(`Subagent completion delivery failed: ${reason}`, "warning");
|
|
302
302
|
},
|
|
303
|
-
|
|
303
|
+
onAcknowledged: (completions, deliveredAt) => {
|
|
304
304
|
if (generation !== runtimeGeneration) return;
|
|
305
305
|
for (const completion of completions) {
|
|
306
|
-
nextRegistry
|
|
306
|
+
void nextRegistry
|
|
307
|
+
.markCompletionDelivered(completion.completionId, deliveredAt)
|
|
308
|
+
.catch((error: unknown) => {
|
|
309
|
+
if (!ctx.hasUI || generation !== runtimeGeneration) return;
|
|
310
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
311
|
+
ctx.ui.notify(`Subagent completion acknowledgement failed: ${reason}`, "warning");
|
|
312
|
+
});
|
|
307
313
|
}
|
|
308
314
|
},
|
|
309
315
|
});
|
|
@@ -378,6 +384,9 @@ export function registerStatefulSubagents(
|
|
|
378
384
|
registry = nextRegistry;
|
|
379
385
|
persistence = sessionPersistence;
|
|
380
386
|
completionBroker = sessionBroker;
|
|
387
|
+
for (const completion of nextRegistry.listPendingCompletions()) {
|
|
388
|
+
sessionBroker.enqueue(completion);
|
|
389
|
+
}
|
|
381
390
|
runtimeLimits = nextLimits;
|
|
382
391
|
refreshSpawnToolRegistration?.();
|
|
383
392
|
const sweepEveryMs = Math.max(
|
|
@@ -402,6 +411,10 @@ export function registerStatefulSubagents(
|
|
|
402
411
|
completionBroker?.onParentTurnStart();
|
|
403
412
|
});
|
|
404
413
|
|
|
414
|
+
pi.on("context", (event) => {
|
|
415
|
+
completionBroker?.onParentContext(event.messages);
|
|
416
|
+
});
|
|
417
|
+
|
|
405
418
|
pi.on("agent_settled", () => {
|
|
406
419
|
completionBroker?.onParentSettled();
|
|
407
420
|
});
|
|
@@ -835,8 +848,8 @@ export function registerStatefulSubagents(
|
|
|
835
848
|
name: "subagent_manage",
|
|
836
849
|
label: "Manage Subagents",
|
|
837
850
|
description:
|
|
838
|
-
"
|
|
839
|
-
promptSnippet: "
|
|
851
|
+
"Interrupt active work while keeping an agent reusable, or close agents and release their resources. Use subagent_inspect for every read-only list, detail, status, and diagnostic operation.",
|
|
852
|
+
promptSnippet: "Interrupt or close retained detached subagents",
|
|
840
853
|
parameters: ManageParamsSchema,
|
|
841
854
|
...createStatefulToolRenderer("manage"),
|
|
842
855
|
async execute(_id, params, signal): Promise<StatefulActionToolResult> {
|
|
@@ -848,20 +861,6 @@ export function registerStatefulSubagents(
|
|
|
848
861
|
return value;
|
|
849
862
|
};
|
|
850
863
|
const operation = validateManageParams(params);
|
|
851
|
-
if (operation.action === "list") {
|
|
852
|
-
const agents = ownedRegistry.list(operation.includeClosed);
|
|
853
|
-
return {
|
|
854
|
-
content: [
|
|
855
|
-
{
|
|
856
|
-
type: "text",
|
|
857
|
-
text: agents.length
|
|
858
|
-
? agents.map(formatStatefulAgentLine).join("\n")
|
|
859
|
-
: "No stateful subagents.",
|
|
860
|
-
},
|
|
861
|
-
],
|
|
862
|
-
details: { agents: agents.map(summarizeStatefulAgent) },
|
|
863
|
-
};
|
|
864
|
-
}
|
|
865
864
|
const agentId = operation.agentId;
|
|
866
865
|
if (operation.action === "interrupt") {
|
|
867
866
|
if (operation.subtree) {
|