@narumitw/pi-subagents 0.49.2 → 0.51.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.
Files changed (81) hide show
  1. package/README.md +313 -53
  2. package/package.json +11 -8
  3. package/src/adaptive-scheduler.ts +196 -0
  4. package/src/admission-benchmark.ts +95 -0
  5. package/src/admission-policy.ts +78 -0
  6. package/src/agent-projection.ts +53 -0
  7. package/src/agents.ts +58 -1
  8. package/src/auto-transport.ts +114 -0
  9. package/src/blocking-status.ts +63 -0
  10. package/src/capabilities.ts +145 -0
  11. package/src/capability-grant.ts +115 -0
  12. package/src/capability-router.ts +107 -0
  13. package/src/completion-delivery.ts +257 -0
  14. package/src/config-status.ts +221 -0
  15. package/src/config-ui.ts +215 -236
  16. package/src/consult-resources.ts +4 -27
  17. package/src/consult.ts +9 -1
  18. package/src/create-stateful-transport.ts +55 -0
  19. package/src/delegation-contract.ts +417 -0
  20. package/src/execution-plan.ts +322 -0
  21. package/src/execution-profiles.ts +95 -0
  22. package/src/execution-ui.ts +320 -0
  23. package/src/execution.ts +848 -158
  24. package/src/in-process-transport.ts +269 -25
  25. package/src/inspect-render.ts +101 -1
  26. package/src/inspect.ts +296 -3
  27. package/src/integration-controller.ts +98 -0
  28. package/src/limits.ts +3 -0
  29. package/src/orchestration-metrics.ts +78 -0
  30. package/src/outcome.ts +61 -0
  31. package/src/panel-child-group.ts +35 -0
  32. package/src/panel-contract.ts +343 -0
  33. package/src/panel-evidence.ts +59 -0
  34. package/src/panel-execution.ts +772 -0
  35. package/src/panel-failure.ts +56 -0
  36. package/src/panel-planning.ts +175 -0
  37. package/src/panel-prompts.ts +132 -0
  38. package/src/panel-reconciliation.ts +57 -0
  39. package/src/panel-render.ts +103 -0
  40. package/src/parallel-limit-ui.ts +112 -0
  41. package/src/params.ts +172 -3
  42. package/src/persistence.ts +182 -32
  43. package/src/prompt-resources.ts +38 -0
  44. package/src/registry-types.ts +175 -0
  45. package/src/registry.ts +466 -143
  46. package/src/render.ts +72 -6
  47. package/src/result-contract.ts +416 -0
  48. package/src/retained-semantic-state.ts +100 -0
  49. package/src/rpc-timeout-finalization.ts +207 -0
  50. package/src/rpc-transport-metadata.ts +65 -0
  51. package/src/rpc-transport.ts +990 -0
  52. package/src/rpc-turn-capture.ts +142 -0
  53. package/src/runner-result.ts +55 -0
  54. package/src/runner-usage.ts +48 -0
  55. package/src/runner.ts +325 -73
  56. package/src/semantic-snapshot.ts +214 -0
  57. package/src/settings.ts +254 -35
  58. package/src/spawn-idempotency.ts +61 -0
  59. package/src/stateful-config.ts +13 -0
  60. package/src/stateful-guidance.ts +1 -0
  61. package/src/stateful-lifecycle.ts +45 -2
  62. package/src/stateful-limit-ui.ts +246 -0
  63. package/src/stateful-limits.ts +96 -0
  64. package/src/stateful-prompt.ts +11 -2
  65. package/src/stateful-render.ts +48 -3
  66. package/src/stateful.ts +467 -357
  67. package/src/subagents.ts +114 -46
  68. package/src/subprocess-transport.ts +64 -5
  69. package/src/supervision.ts +103 -0
  70. package/src/timeout-checkpoint.ts +305 -0
  71. package/src/timeout-finalization.ts +75 -0
  72. package/src/transport-types.ts +68 -0
  73. package/src/transport-ui.ts +169 -0
  74. package/src/transport.ts +16 -4
  75. package/src/turn-budget.ts +109 -0
  76. package/src/verification-policy.ts +17 -0
  77. package/src/work-item-ledger.ts +682 -0
  78. package/src/work-item-persistence.ts +218 -0
  79. package/src/workflow-planning.ts +150 -0
  80. package/src/workflow-ui.ts +61 -0
  81. package/src/workspace.ts +69 -12
package/src/registry.ts CHANGED
@@ -1,119 +1,54 @@
1
+ /**
2
+ * AgentRegistry intentionally owns its full state machine in one module so queue, tree,
3
+ * mailbox, generation, grant, transport, persistence, and completion transitions are atomic.
4
+ */
1
5
  import { randomUUID } from "node:crypto";
6
+ import { projectAgentRecords } from "./agent-projection.js";
2
7
  import type { SubagentThinkingLevel } from "./agents.js";
8
+ import {
9
+ type CapabilityGrant,
10
+ isCapabilityGrantActive,
11
+ revokeCapabilityGrant,
12
+ } from "./capability-grant.js";
3
13
  import type { TargetPolicyAudit } from "./cwd-policy.js";
4
- import { DEFAULT_MAX_CONTEXT_BYTES, DEFAULT_MAX_OUTPUT_BYTES, truncateUtf8 } from "./limits.js";
14
+ import type { DelegationContract } from "./delegation-contract.js";
15
+ import {
16
+ copyExecutionPlan,
17
+ type ExecutionPlan,
18
+ rotateExecutionPlanGeneration,
19
+ } from "./execution-plan.js";
20
+ import {
21
+ DEFAULT_MAX_CONTEXT_BYTES,
22
+ DEFAULT_MAX_OUTPUT_BYTES,
23
+ MAX_SUBAGENT_TIMEOUT_MS,
24
+ truncateUtf8,
25
+ } from "./limits.js";
26
+ import { classifyStructuredOutcome } from "./outcome.js";
27
+ import type {
28
+ AgentInspectionCounts,
29
+ AgentLifecycleState,
30
+ AgentMailboxMessage,
31
+ AgentRegistryOptions,
32
+ AgentRunInspectionDetail,
33
+ AgentRunInspectionSummary,
34
+ AgentTurnCompletion,
35
+ ManagedAgent,
36
+ } from "./registry-types.js";
37
+ import {
38
+ type AnyStructuredSubagentResult,
39
+ parseAnyStructuredSubagentResult,
40
+ type SubagentResultFormat,
41
+ } from "./result-contract.js";
42
+ import type { SemanticCompatibility, SemanticSnapshot } from "./semantic-snapshot.js";
43
+ import { resolveStatefulLimits } from "./stateful-limits.js";
44
+ import { copyTurnTerminationReport } from "./timeout-checkpoint.js";
5
45
  import { type AgentTurnRunner, normalizeTransport, type SubagentTransport } from "./transport.js";
46
+ import type { TransportTelemetry } from "./transport-types.js";
47
+ import { type TurnLimits, validateTurnLimits } from "./turn-budget.js";
6
48
 
7
- export type AgentLifecycleState =
8
- | "starting"
9
- | "running"
10
- | "idle"
11
- | "completed"
12
- | "interrupted"
13
- | "failed"
14
- | "closed";
15
-
16
- export interface AgentTurn {
17
- task: string;
18
- output: string;
19
- startedAt: number;
20
- completedAt: number;
21
- exitCode: number;
22
- truncated?: boolean;
23
- }
24
-
25
- export interface AgentMailboxMessage {
26
- id: string;
27
- senderId: string;
28
- recipientId: string;
29
- content: string;
30
- createdAt: number;
31
- readAt?: number;
32
- deduplicationKey?: string;
33
- }
34
-
35
- export interface ManagedAgent {
36
- id: string;
37
- agent: string;
38
- parentId?: string;
39
- rootId: string;
40
- depth: number;
41
- children: string[];
42
- state: AgentLifecycleState;
43
- createdAt: number;
44
- updatedAt: number;
45
- cwd: string;
46
- agentScope?: "user" | "project" | "both";
47
- thinkingLevel?: SubagentThinkingLevel;
48
- currentTask?: string;
49
- history: AgentTurn[];
50
- error?: string;
51
- context?: string;
52
- contextSourceIds?: string[];
53
- contextTruncated?: boolean;
54
- workspaceMode?: "worktree";
55
- target?: TargetPolicyAudit;
56
- policy?: { inherited: string[]; overridden: string[]; unsupported: string[] };
57
- mailbox: AgentMailboxMessage[];
58
- currentMailboxMessageIds?: string[];
59
- }
60
-
61
- export interface AgentRunInspectionSummary {
62
- id: string;
63
- agent: string;
64
- state: AgentLifecycleState;
65
- createdAt: number;
66
- updatedAt: number;
67
- historyCount: number;
68
- unreadMessages: number;
69
- }
70
-
71
- export interface AgentRunInspectionDetail extends AgentRunInspectionSummary {
72
- cwd: string;
73
- thinkingLevel?: SubagentThinkingLevel;
74
- currentTask?: string;
75
- error?: string;
76
- workspaceMode?: "worktree";
77
- target?: TargetPolicyAudit;
78
- policy?: { inherited: string[]; overridden: string[]; unsupported: string[] };
79
- }
80
-
81
- export interface AgentInspectionCounts {
82
- activeAgents: number;
83
- retainedAgents: number;
84
- }
49
+ const DEFAULT_STATEFUL_LIMITS = resolveStatefulLimits();
85
50
 
86
- export interface TurnOutcome {
87
- output: string;
88
- exitCode: number;
89
- aborted?: boolean;
90
- truncated?: boolean;
91
- error?: string;
92
- policy?: ManagedAgent["policy"];
93
- }
94
-
95
- export interface AgentTurnCompletion {
96
- agent: ManagedAgent;
97
- task: string;
98
- output: string;
99
- error?: string;
100
- }
101
-
102
- export interface AgentRegistryOptions {
103
- maxAgents?: number;
104
- maxActiveTurns?: number;
105
- maxHistoryTurns?: number;
106
- maxDepth?: number;
107
- maxChildrenPerAgent?: number;
108
- maxMailboxMessages?: number;
109
- maxMailboxMessageBytes?: number;
110
- maxTaskBytes?: number;
111
- maxTurnOutputBytes?: number;
112
- idleTtlMs?: number;
113
- now?: () => number;
114
- onChange?: (agents: ManagedAgent[]) => void | Promise<void>;
115
- onTurnComplete?: (completion: AgentTurnCompletion) => void | Promise<void>;
116
- }
51
+ export type * from "./registry-types.js";
117
52
 
118
53
  function positiveInteger(value: number, label: string): number {
119
54
  if (!Number.isSafeInteger(value) || value < 1) {
@@ -129,6 +64,22 @@ function nonNegativeInteger(value: number, label: string): number {
129
64
  return value;
130
65
  }
131
66
 
67
+ function validateTurnTimeout(value: number): number {
68
+ if (!Number.isSafeInteger(value) || value < 1 || value > MAX_SUBAGENT_TIMEOUT_MS) {
69
+ throw new Error(`Subagent timeoutMs must be between 1 and ${MAX_SUBAGENT_TIMEOUT_MS}`);
70
+ }
71
+ return value;
72
+ }
73
+
74
+ function clearCurrentTurn(agent: ManagedAgent): void {
75
+ agent.currentTask = undefined;
76
+ agent.currentTimeoutMs = undefined;
77
+ agent.currentIdleTimeoutMs = undefined;
78
+ agent.currentMaxTurns = undefined;
79
+ agent.currentMaxToolCalls = undefined;
80
+ agent.currentMailboxMessageIds = undefined;
81
+ }
82
+
132
83
  function waitAbortError(): Error {
133
84
  const error = new Error("Subagent wait was aborted");
134
85
  error.name = "AbortError";
@@ -163,12 +114,21 @@ export class AgentRegistry {
163
114
  private readonly options: AgentRegistryOptions = {},
164
115
  ) {
165
116
  this.transport = normalizeTransport(transport);
166
- this.maxAgents = positiveInteger(options.maxAgents ?? 16, "maxAgents");
167
- this.maxActiveTurns = positiveInteger(options.maxActiveTurns ?? 4, "maxActiveTurns");
117
+ this.maxAgents = positiveInteger(
118
+ options.maxAgents ?? DEFAULT_STATEFUL_LIMITS.maxAgents,
119
+ "maxAgents",
120
+ );
121
+ this.maxActiveTurns = positiveInteger(
122
+ options.maxActiveTurns ?? DEFAULT_STATEFUL_LIMITS.maxActiveTurns,
123
+ "maxActiveTurns",
124
+ );
168
125
  this.maxHistoryTurns = positiveInteger(options.maxHistoryTurns ?? 20, "maxHistoryTurns");
169
- this.maxDepth = nonNegativeInteger(options.maxDepth ?? 3, "maxDepth");
126
+ this.maxDepth = nonNegativeInteger(
127
+ options.maxDepth ?? DEFAULT_STATEFUL_LIMITS.maxDepth,
128
+ "maxDepth",
129
+ );
170
130
  this.maxChildrenPerAgent = positiveInteger(
171
- options.maxChildrenPerAgent ?? 8,
131
+ options.maxChildrenPerAgent ?? DEFAULT_STATEFUL_LIMITS.maxChildrenPerAgent,
172
132
  "maxChildrenPerAgent",
173
133
  );
174
134
  this.maxMailboxMessages = positiveInteger(
@@ -193,10 +153,10 @@ export class AgentRegistry {
193
153
 
194
154
  restore(records: readonly ManagedAgent[]): void {
195
155
  const candidates = new Map(
196
- records
197
- .slice(-this.maxAgents)
198
- .filter((record) => record.id && record.state !== "closed")
199
- .map((record) => [record.id, record]),
156
+ projectAgentRecords(
157
+ records.filter((record) => record.id && record.state !== "closed"),
158
+ { maxAgents: this.maxAgents, maxDepth: this.maxDepth },
159
+ ).map((record) => [record.id, record]),
200
160
  );
201
161
  for (const record of candidates.values()) {
202
162
  if (record.parentId && !candidates.has(record.parentId)) continue;
@@ -218,13 +178,22 @@ export class AgentRegistry {
218
178
  if (cyclic || depth > this.maxDepth) continue;
219
179
  this.agents.set(record.id, {
220
180
  ...record,
221
- state: "idle",
181
+ state:
182
+ record.state === "running" || record.state === "starting" ? "interrupted" : record.state,
222
183
  rootId,
223
184
  depth,
224
185
  currentTask: undefined,
186
+ currentTimeoutMs: undefined,
187
+ currentIdleTimeoutMs: undefined,
188
+ currentMaxTurns: undefined,
189
+ currentMaxToolCalls: undefined,
225
190
  currentMailboxMessageIds: undefined,
226
191
  children: [],
227
192
  contextSourceIds: [...(record.contextSourceIds ?? [])],
193
+ capabilityGrant:
194
+ record.capabilityGrant?.state === "active"
195
+ ? revokeCapabilityGrant(record.capabilityGrant, "restore-boundary", this.now())
196
+ : record.capabilityGrant,
228
197
  mailbox: (record.mailbox ?? [])
229
198
  .slice(-this.maxMailboxMessages)
230
199
  .map((message) => ({ ...message, recipientId: record.id })),
@@ -244,14 +213,35 @@ export class AgentRegistry {
244
213
  cwd: string;
245
214
  agentScope?: "user" | "project" | "both";
246
215
  thinkingLevel?: SubagentThinkingLevel;
216
+ timeoutMs?: number;
217
+ idleTimeoutMs?: number;
218
+ maxTurns?: number;
219
+ maxToolCalls?: number;
247
220
  parentId?: string;
248
221
  context?: string;
249
222
  contextSourceIds?: string[];
250
223
  contextTruncated?: boolean;
224
+ contextTurns?: number;
225
+ contextBytes?: number;
251
226
  workspaceMode?: "worktree";
227
+ spawnIdempotencyKey?: string;
228
+ spawnRequestHash?: string;
229
+ contract?: DelegationContract;
230
+ resultFormat?: SubagentResultFormat;
231
+ executionPlan?: ExecutionPlan;
232
+ capabilityGrant?: CapabilityGrant;
233
+ semanticSnapshot?: SemanticSnapshot;
234
+ semanticCompatibility?: SemanticCompatibility;
252
235
  target?: TargetPolicyAudit;
253
236
  }): Promise<ManagedAgent> {
254
237
  if (!input.task.trim()) throw new Error("Subagent tasks cannot be empty");
238
+ if (input.timeoutMs !== undefined) validateTurnTimeout(input.timeoutMs);
239
+ validateTurnLimits(input);
240
+ const existing = this.findBySpawnIdempotencyKey(
241
+ input.spawnIdempotencyKey,
242
+ input.spawnRequestHash,
243
+ );
244
+ if (existing) return existing;
255
245
  const task = truncateUtf8(input.task, this.maxTaskBytes).text;
256
246
  const expired = this.evictExpired();
257
247
  let expiryReleaseError: unknown;
@@ -287,13 +277,31 @@ export class AgentRegistry {
287
277
  cwd: input.cwd,
288
278
  agentScope: input.agentScope,
289
279
  thinkingLevel: input.thinkingLevel,
280
+ timeoutMs: input.timeoutMs,
281
+ currentTimeoutMs: input.timeoutMs,
282
+ idleTimeoutMs: input.idleTimeoutMs,
283
+ currentIdleTimeoutMs: input.idleTimeoutMs,
284
+ maxTurns: input.maxTurns,
285
+ currentMaxTurns: input.maxTurns,
286
+ maxToolCalls: input.maxToolCalls,
287
+ currentMaxToolCalls: input.maxToolCalls,
290
288
  currentTask: task,
291
289
  history: [],
292
290
  mailbox: [],
293
291
  context: input.context,
294
292
  contextSourceIds: input.contextSourceIds,
295
293
  contextTruncated: input.contextTruncated,
294
+ contextTurns: input.contextTurns,
295
+ contextBytes: input.contextBytes,
296
296
  workspaceMode: input.workspaceMode,
297
+ spawnIdempotencyKey: input.spawnIdempotencyKey,
298
+ spawnRequestHash: input.spawnRequestHash,
299
+ contract: input.contract,
300
+ resultFormat: input.resultFormat,
301
+ executionPlan: input.executionPlan,
302
+ capabilityGrant: input.capabilityGrant,
303
+ semanticSnapshot: input.semanticSnapshot,
304
+ semanticCompatibility: input.semanticCompatibility,
297
305
  target: input.target,
298
306
  };
299
307
  this.agents.set(record.id, record);
@@ -302,22 +310,76 @@ export class AgentRegistry {
302
310
  parent.updatedAt = now;
303
311
  }
304
312
  await this.changed();
305
- this.startTurn(record, task);
313
+ this.startTurn(record, task, input);
306
314
  return this.copy(record);
307
315
  }
308
316
 
309
- async followUp(id: string, task: string): Promise<ManagedAgent> {
317
+ findBySpawnIdempotencyKey(
318
+ key: string | undefined,
319
+ requestHash: string | undefined,
320
+ ): ManagedAgent | undefined {
321
+ if (!key) return undefined;
322
+ const existing = [...this.agents.values()].find(
323
+ (agent) => agent.state !== "closed" && agent.spawnIdempotencyKey === key,
324
+ );
325
+ if (!existing) return undefined;
326
+ if (!requestHash || existing.spawnRequestHash !== requestHash) {
327
+ throw new Error(
328
+ "The subagent_spawn idempotencyKey was already used with different parameters",
329
+ );
330
+ }
331
+ return this.copy(existing);
332
+ }
333
+
334
+ async followUp(
335
+ id: string,
336
+ task: string,
337
+ options: TurnLimits & { timeoutMs?: number } = {},
338
+ ): Promise<ManagedAgent> {
310
339
  if (!task.trim()) throw new Error("Subagent tasks cannot be empty");
340
+ if (options.timeoutMs !== undefined) validateTurnTimeout(options.timeoutMs);
341
+ validateTurnLimits(options);
311
342
  const boundedTask = truncateUtf8(task, this.maxTaskBytes).text;
312
343
  const agent = this.require(id);
313
- if (!["idle", "completed", "interrupted", "failed"].includes(agent.state)) {
344
+ if (
345
+ ![
346
+ "idle",
347
+ "completed",
348
+ "blocked",
349
+ "needs-input",
350
+ "abstained",
351
+ "stale",
352
+ "interrupted",
353
+ "failed",
354
+ ].includes(agent.state)
355
+ ) {
314
356
  throw new Error(`Agent ${id} cannot accept follow-up while ${agent.state}`);
315
357
  }
316
358
  const unread = agent.mailbox.filter((message) => !message.readAt);
317
359
  const readAt = this.now();
318
360
  for (const message of unread) message.readAt = readAt;
319
361
  agent.currentMailboxMessageIds = unread.map((message) => message.id);
320
- this.startTurn(agent, boundedTask);
362
+ this.startTurn(agent, boundedTask, options);
363
+ return this.copy(agent);
364
+ }
365
+
366
+ async updateSemanticState(
367
+ id: string,
368
+ executionPlan: ExecutionPlan,
369
+ capabilityGrant: CapabilityGrant,
370
+ snapshot: SemanticSnapshot,
371
+ compatibility: SemanticCompatibility,
372
+ ): Promise<ManagedAgent> {
373
+ const agent = this.require(id);
374
+ if (agent.state === "running" || agent.state === "starting" || agent.state === "closed") {
375
+ throw new Error(`Agent ${id} cannot update semantic state while ${agent.state}`);
376
+ }
377
+ agent.executionPlan = copyExecutionPlan(executionPlan);
378
+ agent.capabilityGrant = structuredClone(capabilityGrant);
379
+ agent.semanticSnapshot = structuredClone(snapshot);
380
+ agent.semanticCompatibility = structuredClone(compatibility);
381
+ agent.updatedAt = this.now();
382
+ await this.changed();
321
383
  return this.copy(agent);
322
384
  }
323
385
 
@@ -410,13 +472,22 @@ export class AgentRegistry {
410
472
  const agent = this.require(id);
411
473
  if (agent.state !== "running" && agent.state !== "starting")
412
474
  throw new Error(`Agent ${id} is not running`);
475
+ if (agent.capabilityGrant?.state === "active") {
476
+ agent.capabilityGrant = revokeCapabilityGrant(
477
+ agent.capabilityGrant,
478
+ "interrupted",
479
+ this.now(),
480
+ );
481
+ }
482
+ if (agent.executionPlan) {
483
+ agent.executionPlan = rotateExecutionPlanGeneration(agent.executionPlan);
484
+ }
413
485
  if (agent.state === "starting") {
414
486
  const index = this.queue.findIndex((entry) => entry.agent.id === id);
415
487
  if (index >= 0) {
416
488
  const [entry] = this.queue.splice(index, 1);
417
489
  agent.state = "interrupted";
418
- agent.currentTask = undefined;
419
- agent.currentMailboxMessageIds = undefined;
490
+ clearCurrentTurn(agent);
420
491
  agent.updatedAt = this.now();
421
492
  const completion: AgentTurnCompletion = {
422
493
  agent: this.copy(agent),
@@ -462,6 +533,14 @@ export class AgentRegistry {
462
533
  if (agent.children.some((childId) => this.agents.get(childId)?.state !== "closed")) {
463
534
  throw new Error(`Agent ${id} has active descendants; close the subtree instead`);
464
535
  }
536
+ if (agent.state === "starting" || agent.state === "running") {
537
+ if (agent.capabilityGrant?.state === "active") {
538
+ agent.capabilityGrant = revokeCapabilityGrant(agent.capabilityGrant, "closed", this.now());
539
+ }
540
+ if (agent.executionPlan) {
541
+ agent.executionPlan = rotateExecutionPlanGeneration(agent.executionPlan);
542
+ }
543
+ }
465
544
  if (agent.state === "starting") {
466
545
  const index = this.queue.findIndex((entry) => entry.agent.id === id);
467
546
  if (index >= 0) {
@@ -478,8 +557,7 @@ export class AgentRegistry {
478
557
  const parent = this.agents.get(agent.parentId);
479
558
  if (parent) parent.children = parent.children.filter((childId) => childId !== id);
480
559
  }
481
- agent.currentTask = undefined;
482
- agent.currentMailboxMessageIds = undefined;
560
+ clearCurrentTurn(agent);
483
561
  let releaseError: unknown;
484
562
  try {
485
563
  await this.transport.release?.(this.copy(agent));
@@ -507,19 +585,42 @@ export class AgentRegistry {
507
585
 
508
586
  async shutdown(): Promise<void> {
509
587
  for (const entry of this.queue.splice(0)) {
510
- entry.agent.state = "idle";
511
- entry.agent.currentTask = undefined;
512
- entry.agent.currentMailboxMessageIds = undefined;
588
+ if (entry.agent.capabilityGrant?.state === "active") {
589
+ entry.agent.capabilityGrant = revokeCapabilityGrant(
590
+ entry.agent.capabilityGrant,
591
+ "shutdown",
592
+ this.now(),
593
+ );
594
+ }
595
+ if (entry.agent.executionPlan) {
596
+ entry.agent.executionPlan = rotateExecutionPlanGeneration(entry.agent.executionPlan);
597
+ }
598
+ entry.agent.state = "interrupted";
599
+ clearCurrentTurn(entry.agent);
513
600
  entry.resolve(entry.agent);
514
601
  this.running.delete(entry.agent.id);
515
602
  }
603
+ for (const id of this.controllers.keys()) {
604
+ const agent = this.agents.get(id);
605
+ if (agent?.capabilityGrant?.state === "active") {
606
+ agent.capabilityGrant = revokeCapabilityGrant(
607
+ agent.capabilityGrant,
608
+ "shutdown",
609
+ this.now(),
610
+ );
611
+ }
612
+ if (agent?.executionPlan) {
613
+ agent.executionPlan = rotateExecutionPlanGeneration(agent.executionPlan);
614
+ }
615
+ }
516
616
  for (const controller of this.controllers.values()) controller.abort();
517
617
  await Promise.all([...this.running.values()].map((turn) => turn.catch(() => undefined)));
518
618
  for (const agent of this.agents.values()) {
519
619
  if (agent.state !== "closed") {
520
- agent.state = "idle";
521
- agent.currentTask = undefined;
522
- agent.currentMailboxMessageIds = undefined;
620
+ if (agent.state === "running" || agent.state === "starting") {
621
+ agent.state = "interrupted";
622
+ }
623
+ clearCurrentTurn(agent);
523
624
  }
524
625
  }
525
626
  let shutdownError: unknown;
@@ -556,9 +657,23 @@ export class AgentRegistry {
556
657
  ...this.inspectSummary(agent),
557
658
  cwd: agent.cwd,
558
659
  thinkingLevel: agent.thinkingLevel,
660
+ timeoutMs: agent.timeoutMs,
661
+ currentTimeoutMs: agent.currentTimeoutMs,
662
+ idleTimeoutMs: agent.idleTimeoutMs,
663
+ currentIdleTimeoutMs: agent.currentIdleTimeoutMs,
664
+ maxTurns: agent.maxTurns,
665
+ currentMaxTurns: agent.currentMaxTurns,
666
+ maxToolCalls: agent.maxToolCalls,
667
+ currentMaxToolCalls: agent.currentMaxToolCalls,
559
668
  currentTask: agent.currentTask,
560
669
  error: agent.error,
561
670
  workspaceMode: agent.workspaceMode,
671
+ contextTurns: agent.contextTurns,
672
+ contextBytes: agent.contextBytes,
673
+ contextSources: agent.contextSourceIds?.length,
674
+ contextTruncated: agent.contextTruncated,
675
+ contract: agent.contract ? structuredClone(agent.contract) : undefined,
676
+ resultFormat: agent.resultFormat,
562
677
  target: agent.target ? { ...agent.target, trust: { ...agent.target.trust } } : undefined,
563
678
  policy: agent.policy
564
679
  ? {
@@ -567,6 +682,20 @@ export class AgentRegistry {
567
682
  unsupported: [...agent.policy.unsupported],
568
683
  }
569
684
  : undefined,
685
+ structuredResult: agent.structuredResult
686
+ ? copyStructuredResult(agent.structuredResult)
687
+ : undefined,
688
+ termination: agent.termination ? copyTurnTerminationReport(agent.termination) : undefined,
689
+ outcome: agent.outcome ? structuredClone(agent.outcome) : undefined,
690
+ executionPlan: agent.executionPlan ? copyExecutionPlan(agent.executionPlan) : undefined,
691
+ capabilityGrant: agent.capabilityGrant ? structuredClone(agent.capabilityGrant) : undefined,
692
+ semanticSnapshot: agent.semanticSnapshot
693
+ ? structuredClone(agent.semanticSnapshot)
694
+ : undefined,
695
+ semanticCompatibility: agent.semanticCompatibility
696
+ ? structuredClone(agent.semanticCompatibility)
697
+ : undefined,
698
+ telemetry: agent.telemetry ? copyTelemetry(agent.telemetry) : undefined,
570
699
  };
571
700
  }
572
701
 
@@ -583,6 +712,16 @@ export class AgentRegistry {
583
712
  return agent ? this.copy(agent) : undefined;
584
713
  }
585
714
 
715
+ markCompletionDelivered(id: string, deliveredAt: number): void {
716
+ const agent = this.agents.get(id);
717
+ if (!agent?.telemetry) return;
718
+ agent.telemetry = {
719
+ ...agent.telemetry,
720
+ updatedAt: deliveredAt,
721
+ timing: { ...agent.telemetry.timing, completionDeliveredAt: deliveredAt },
722
+ };
723
+ }
724
+
586
725
  async sweepExpired(): Promise<number> {
587
726
  const removed = this.evictExpired();
588
727
  let releaseError: unknown;
@@ -596,17 +735,35 @@ export class AgentRegistry {
596
735
  return removed.length;
597
736
  }
598
737
 
599
- private startTurn(agent: ManagedAgent, task: string): void {
738
+ private startTurn(
739
+ agent: ManagedAgent,
740
+ task: string,
741
+ limits: TurnLimits & { timeoutMs?: number } = {},
742
+ ): void {
600
743
  agent.state = "starting";
601
744
  agent.error = undefined;
602
745
  agent.currentTask = task;
746
+ agent.currentTimeoutMs = limits.timeoutMs ?? agent.timeoutMs;
747
+ agent.currentIdleTimeoutMs = limits.idleTimeoutMs ?? agent.idleTimeoutMs;
748
+ agent.currentMaxTurns = limits.maxTurns ?? agent.maxTurns;
749
+ agent.currentMaxToolCalls = limits.maxToolCalls ?? agent.maxToolCalls;
750
+ agent.structuredResult = undefined;
751
+ agent.termination = undefined;
752
+ agent.outcome = undefined;
603
753
  agent.updatedAt = this.now();
754
+ agent.telemetry = {
755
+ phase: "queued",
756
+ queuePosition: this.queue.length + 1,
757
+ updatedAt: agent.updatedAt,
758
+ timing: { queuedAt: agent.updatedAt },
759
+ };
604
760
  let resolveQueued!: (agent: ManagedAgent) => void;
605
761
  const completion = new Promise<ManagedAgent>((resolve) => {
606
762
  resolveQueued = resolve;
607
763
  });
608
764
  this.running.set(agent.id, completion);
609
765
  this.queue.push({ agent, task, resolve: resolveQueued });
766
+ this.updateQueuePositions();
610
767
  void this.changed();
611
768
  this.pumpQueue();
612
769
  }
@@ -616,6 +773,7 @@ export class AgentRegistry {
616
773
  const next = this.queue.shift();
617
774
  if (!next) return;
618
775
  this.runQueuedTurn(next.agent, next.task, next.resolve);
776
+ this.updateQueuePositions();
619
777
  }
620
778
  }
621
779
 
@@ -624,17 +782,48 @@ export class AgentRegistry {
624
782
  task: string,
625
783
  resolveQueued: (agent: ManagedAgent) => void,
626
784
  ): void {
785
+ if (
786
+ agent.capabilityGrant &&
787
+ agent.executionPlan &&
788
+ !isCapabilityGrantActive(agent.capabilityGrant, agent.executionPlan, this.now())
789
+ ) {
790
+ agent.state = "failed";
791
+ agent.error = "Capability grant expired or no longer matches the accepted plan";
792
+ agent.outcome = classifyStructuredOutcome("failed", "capability-grant-invalid");
793
+ agent.currentTask = undefined;
794
+ agent.currentTimeoutMs = undefined;
795
+ agent.updatedAt = this.now();
796
+ resolveQueued(agent);
797
+ this.running.delete(agent.id);
798
+ void this.notifyTurnComplete({
799
+ agent: this.copy(agent),
800
+ task,
801
+ output: "",
802
+ error: agent.error,
803
+ }).then(() => this.changed());
804
+ return;
805
+ }
627
806
  const controller = new AbortController();
628
807
  this.controllers.set(agent.id, controller);
629
808
  agent.state = "running";
630
809
  agent.updatedAt = this.now();
631
810
  const startedAt = this.now();
632
811
  const completionKey = `completion:${agent.id}:${randomUUID()}`;
812
+ const acceptedPlanId = agent.executionPlan?.id;
633
813
  let completionContent = "";
634
814
  let completionOutput = "";
635
815
  let completionError: string | undefined;
636
816
  void this.transport
637
- .runTurn(this.copy(agent), task, controller.signal)
817
+ .runTurn(this.copy(agent), task, controller.signal, (progress) => {
818
+ agent.telemetry = {
819
+ ...progress,
820
+ queuePosition: undefined,
821
+ timing: {
822
+ queuedAt: agent.telemetry?.timing.queuedAt,
823
+ ...progress.timing,
824
+ },
825
+ };
826
+ })
638
827
  .then(async (outcome) => {
639
828
  const output = truncateUtf8(outcome.output, this.maxTurnOutputBytes).text;
640
829
  const error = outcome.error
@@ -647,22 +836,90 @@ export class AgentRegistry {
647
836
  completedAt: this.now(),
648
837
  exitCode: outcome.exitCode,
649
838
  truncated: outcome.truncated,
839
+ termination: outcome.termination
840
+ ? copyTurnTerminationReport(outcome.termination)
841
+ : undefined,
650
842
  });
651
843
  agent.history = agent.history.slice(-this.maxHistoryTurns);
652
- agent.state = outcome.aborted
653
- ? "interrupted"
654
- : outcome.exitCode === 0
655
- ? "completed"
656
- : "failed";
844
+ agent.structuredResult =
845
+ outcome.structuredResult ?? parseAnyStructuredSubagentResult(output, agent.resultFormat);
846
+ agent.outcome =
847
+ outcome.outcome ??
848
+ (outcome.aborted
849
+ ? classifyStructuredOutcome("interrupted", "transport-aborted")
850
+ : agent.structuredResult?.version === "pi-subagents:result:v2"
851
+ ? classifyStructuredOutcome(
852
+ agent.structuredResult.status,
853
+ agent.structuredResult.reasonCode,
854
+ )
855
+ : agent.resultFormat !== undefined &&
856
+ agent.resultFormat !== "text" &&
857
+ agent.structuredResult === undefined
858
+ ? classifyStructuredOutcome("contract-invalid", "malformed-structured-result")
859
+ : undefined);
860
+ const staleGeneration = Boolean(
861
+ acceptedPlanId && agent.executionPlan?.id !== acceptedPlanId,
862
+ );
863
+ if (staleGeneration) {
864
+ agent.outcome = classifyStructuredOutcome("stale", "cancelled-generation");
865
+ }
866
+ agent.state = staleGeneration
867
+ ? "stale"
868
+ : outcome.aborted
869
+ ? "interrupted"
870
+ : outcome.exitCode !== 0
871
+ ? "failed"
872
+ : lifecycleStateForOutcome(agent.outcome?.status);
657
873
  agent.error = error;
874
+ if (agent.capabilityGrant?.state === "active") {
875
+ agent.capabilityGrant = revokeCapabilityGrant(
876
+ agent.capabilityGrant,
877
+ "turn-settled",
878
+ this.now(),
879
+ );
880
+ }
658
881
  agent.policy = outcome.policy;
882
+ agent.telemetry = outcome.telemetry
883
+ ? {
884
+ ...outcome.telemetry,
885
+ timing: {
886
+ queuedAt: agent.telemetry?.timing.queuedAt,
887
+ ...outcome.telemetry.timing,
888
+ },
889
+ }
890
+ : agent.telemetry;
891
+ agent.termination = outcome.termination
892
+ ? copyTurnTerminationReport(outcome.termination)
893
+ : undefined;
659
894
  completionOutput = output;
660
895
  completionError = error;
661
896
  completionContent = output || error || `${agent.id} ${agent.state}`;
662
897
  return agent;
663
898
  })
664
899
  .catch((error) => {
665
- agent.state = controller.signal.aborted ? "interrupted" : "failed";
900
+ const staleGeneration = Boolean(
901
+ acceptedPlanId && agent.executionPlan?.id !== acceptedPlanId,
902
+ );
903
+ agent.state = staleGeneration
904
+ ? "stale"
905
+ : controller.signal.aborted
906
+ ? "interrupted"
907
+ : "failed";
908
+ agent.outcome = classifyStructuredOutcome(
909
+ staleGeneration ? "stale" : controller.signal.aborted ? "interrupted" : "failed",
910
+ staleGeneration
911
+ ? "cancelled-generation"
912
+ : controller.signal.aborted
913
+ ? "transport-aborted"
914
+ : "transport-error",
915
+ );
916
+ if (agent.capabilityGrant?.state === "active") {
917
+ agent.capabilityGrant = revokeCapabilityGrant(
918
+ agent.capabilityGrant,
919
+ "turn-failed",
920
+ this.now(),
921
+ );
922
+ }
666
923
  agent.error = truncateUtf8(
667
924
  error instanceof Error ? error.message : String(error),
668
925
  this.maxTurnOutputBytes,
@@ -675,6 +932,16 @@ export class AgentRegistry {
675
932
  exitCode: controller.signal.aborted ? 130 : 1,
676
933
  });
677
934
  agent.history = agent.history.slice(-this.maxHistoryTurns);
935
+ agent.telemetry = {
936
+ ...(agent.telemetry ?? {
937
+ phase: "failed",
938
+ updatedAt: this.now(),
939
+ timing: { queuedAt: startedAt },
940
+ }),
941
+ phase: controller.signal.aborted ? "interrupted" : "failed",
942
+ failurePhase: agent.telemetry?.phase ?? "running",
943
+ updatedAt: this.now(),
944
+ };
678
945
  completionError = agent.error;
679
946
  completionContent = agent.error;
680
947
  return agent;
@@ -692,8 +959,7 @@ export class AgentRegistry {
692
959
  this.enqueueMessage(parent, completionContent, agent.id, completionKey);
693
960
  }
694
961
  }
695
- agent.currentTask = undefined;
696
- agent.currentMailboxMessageIds = undefined;
962
+ clearCurrentTurn(agent);
697
963
  agent.updatedAt = this.now();
698
964
  this.controllers.delete(agent.id);
699
965
  this.running.delete(agent.id);
@@ -704,6 +970,17 @@ export class AgentRegistry {
704
970
  });
705
971
  }
706
972
 
973
+ private updateQueuePositions(): void {
974
+ for (const [index, entry] of this.queue.entries()) {
975
+ if (!entry.agent.telemetry) continue;
976
+ entry.agent.telemetry = {
977
+ ...entry.agent.telemetry,
978
+ queuePosition: index + 1,
979
+ updatedAt: this.now(),
980
+ };
981
+ }
982
+ }
983
+
707
984
  private enqueueMessage(
708
985
  recipient: ManagedAgent,
709
986
  content: string,
@@ -851,6 +1128,7 @@ export class AgentRegistry {
851
1128
  : undefined,
852
1129
  history: agent.history.map((turn) => ({ ...turn })),
853
1130
  mailbox: agent.mailbox.map((message) => ({ ...message })),
1131
+ contract: agent.contract ? structuredClone(agent.contract) : undefined,
854
1132
  target: agent.target ? { ...agent.target, trust: { ...agent.target.trust } } : undefined,
855
1133
  policy: agent.policy
856
1134
  ? {
@@ -859,6 +1137,51 @@ export class AgentRegistry {
859
1137
  unsupported: [...agent.policy.unsupported],
860
1138
  }
861
1139
  : undefined,
1140
+ structuredResult: agent.structuredResult
1141
+ ? copyStructuredResult(agent.structuredResult)
1142
+ : undefined,
1143
+ termination: agent.termination ? copyTurnTerminationReport(agent.termination) : undefined,
1144
+ outcome: agent.outcome ? structuredClone(agent.outcome) : undefined,
1145
+ executionPlan: agent.executionPlan ? copyExecutionPlan(agent.executionPlan) : undefined,
1146
+ capabilityGrant: agent.capabilityGrant ? structuredClone(agent.capabilityGrant) : undefined,
1147
+ semanticSnapshot: agent.semanticSnapshot
1148
+ ? structuredClone(agent.semanticSnapshot)
1149
+ : undefined,
1150
+ semanticCompatibility: agent.semanticCompatibility
1151
+ ? structuredClone(agent.semanticCompatibility)
1152
+ : undefined,
1153
+ telemetry: agent.telemetry ? copyTelemetry(agent.telemetry) : undefined,
862
1154
  };
863
1155
  }
864
1156
  }
1157
+
1158
+ function lifecycleStateForOutcome(
1159
+ status: import("./result-contract.js").SubagentOutcomeStatus | undefined,
1160
+ ): AgentLifecycleState {
1161
+ switch (status) {
1162
+ case "blocked":
1163
+ case "needs-input":
1164
+ case "abstained":
1165
+ case "stale":
1166
+ return status;
1167
+ case "failed":
1168
+ case "contract-invalid":
1169
+ return "failed";
1170
+ case "interrupted":
1171
+ return "interrupted";
1172
+ default:
1173
+ return "completed";
1174
+ }
1175
+ }
1176
+
1177
+ function copyStructuredResult(value: AnyStructuredSubagentResult): AnyStructuredSubagentResult {
1178
+ return structuredClone(value);
1179
+ }
1180
+
1181
+ function copyTelemetry(value: TransportTelemetry): TransportTelemetry {
1182
+ return {
1183
+ ...value,
1184
+ timing: { ...value.timing },
1185
+ usage: value.usage ? { ...value.usage } : undefined,
1186
+ };
1187
+ }