@herbertgao/pi-subagents 0.16.0 → 0.17.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/CHANGELOG.md +18 -0
- package/README.md +36 -17
- package/package.json +3 -3
- package/src/agent-file-toggle.ts +11 -8
- package/src/agent-manager.ts +184 -106
- package/src/cross-extension-rpc.ts +46 -15
- package/src/custom-agents.ts +9 -1
- package/src/index.ts +172 -31
- package/src/invocation-config.ts +17 -0
- package/src/model-resolver.ts +14 -0
- package/src/schedule.ts +8 -0
- package/src/settings.ts +32 -1
- package/src/types.ts +17 -2
- package/src/ui/agent-widget.ts +23 -2
- package/src/ui/conversation-viewer.ts +264 -25
package/src/agent-manager.ts
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* agent-manager.ts — Tracks agents, background execution, resume support.
|
|
3
3
|
*
|
|
4
|
-
* Background
|
|
5
|
-
*
|
|
6
|
-
* Foreground agents bypass the queue (they block the parent anyway), and so do
|
|
7
|
-
* nested children — see `occupiesPoolSlot`.
|
|
4
|
+
* Background and blocking foreground agents use independent concurrency pools.
|
|
5
|
+
* Nested children use neither pool, avoiding parent/child deadlocks.
|
|
8
6
|
*/
|
|
9
7
|
|
|
10
8
|
import { randomUUID } from "node:crypto"
|
|
@@ -17,6 +15,7 @@ import type {
|
|
|
17
15
|
ExtensionContext,
|
|
18
16
|
} from "@earendil-works/pi-coding-agent"
|
|
19
17
|
import { resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js"
|
|
18
|
+
import { describeModel } from "./model-resolver.js"
|
|
20
19
|
import type {
|
|
21
20
|
AgentInvocation,
|
|
22
21
|
AgentRecord,
|
|
@@ -43,6 +42,8 @@ export type CompactionInfo = {
|
|
|
43
42
|
|
|
44
43
|
/** Default max concurrent background agents. */
|
|
45
44
|
const DEFAULT_MAX_CONCURRENT = 10
|
|
45
|
+
/** Default max concurrent blocking agents. 0 means unlimited. */
|
|
46
|
+
const DEFAULT_MAX_CONCURRENT_FOREGROUND = 0
|
|
46
47
|
|
|
47
48
|
/**
|
|
48
49
|
* Validate a caller-supplied SpawnOptions.cwd. `undefined`/`null` mean "unset"
|
|
@@ -85,6 +86,14 @@ function occupiesPoolSlot(
|
|
|
85
86
|
return !!record.isBackground && record.parentAgentId === undefined
|
|
86
87
|
}
|
|
87
88
|
|
|
89
|
+
function occupiesForegroundSlot(
|
|
90
|
+
record: Pick<AgentRecord, "blocking" | "parentAgentId">,
|
|
91
|
+
): boolean {
|
|
92
|
+
return !!record.blocking && record.parentAgentId === undefined
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
type Pool = "background" | "foreground"
|
|
96
|
+
|
|
88
97
|
interface SpawnArgs {
|
|
89
98
|
pi: ExtensionAPI
|
|
90
99
|
ctx: ExtensionContext
|
|
@@ -107,6 +116,8 @@ interface SpawnOptions {
|
|
|
107
116
|
* scheduler so a fired job can't be deferred past its trigger window.
|
|
108
117
|
*/
|
|
109
118
|
bypassQueue?: boolean
|
|
119
|
+
/** Set only by spawnAndWait; detached/RPC spawns do not occupy the foreground pool. */
|
|
120
|
+
blocking?: boolean
|
|
110
121
|
/** Isolation mode — "worktree" creates a temp git worktree for the agent. */
|
|
111
122
|
isolation?: IsolationMode
|
|
112
123
|
/**
|
|
@@ -122,6 +133,10 @@ interface SpawnOptions {
|
|
|
122
133
|
invocation?: AgentInvocation
|
|
123
134
|
/** Parent abort signal — when aborted, the subagent is also stopped. */
|
|
124
135
|
signal?: AbortSignal
|
|
136
|
+
/** Called when this specific record has started and has a promise. */
|
|
137
|
+
onSpawned?: (id: string) => void
|
|
138
|
+
/** Called when this record enters its pool's queue. */
|
|
139
|
+
onQueued?: (id: string, ahead: number) => void
|
|
125
140
|
/** Called on tool start/end with activity info (for streaming progress to UI). */
|
|
126
141
|
onToolActivity?: (activity: ToolActivity) => void
|
|
127
142
|
/** Called on streaming text deltas from the assistant response. */
|
|
@@ -207,14 +222,20 @@ export class AgentManager {
|
|
|
207
222
|
private onCompact?: OnAgentCompact
|
|
208
223
|
private onUsage?: OnAgentUsage
|
|
209
224
|
private maxConcurrent: number
|
|
225
|
+
private maxConcurrentForeground = DEFAULT_MAX_CONCURRENT_FOREGROUND
|
|
210
226
|
/** Base repos worktrees were created from — so dispose() can prune them all,
|
|
211
227
|
* not just the parent repo (caller-supplied cwd can target other repos). */
|
|
212
228
|
private worktreeRepos = new Set<string>()
|
|
213
229
|
|
|
214
|
-
/**
|
|
215
|
-
private queue: {
|
|
216
|
-
|
|
230
|
+
/** Agents waiting on either independent concurrency pool. */
|
|
231
|
+
private queue: {
|
|
232
|
+
id: string
|
|
233
|
+
pool: Pool
|
|
234
|
+
start: () => void
|
|
235
|
+
release: () => void
|
|
236
|
+
}[] = []
|
|
217
237
|
private runningBackground = 0
|
|
238
|
+
private runningForeground = 0
|
|
218
239
|
|
|
219
240
|
constructor(
|
|
220
241
|
onComplete?: OnAgentComplete,
|
|
@@ -244,6 +265,31 @@ export class AgentManager {
|
|
|
244
265
|
return this.maxConcurrent
|
|
245
266
|
}
|
|
246
267
|
|
|
268
|
+
/** Update the max concurrent blocking agents limit. 0 means unlimited. */
|
|
269
|
+
setMaxConcurrentForeground(n: number) {
|
|
270
|
+
this.maxConcurrentForeground = Math.max(0, n)
|
|
271
|
+
this.drainQueue()
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
getMaxConcurrentForeground(): number {
|
|
275
|
+
return this.maxConcurrentForeground
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private poolFor(record: AgentRecord): Pool | undefined {
|
|
279
|
+
if (occupiesPoolSlot(record)) return "background"
|
|
280
|
+
if (this.maxConcurrentForeground > 0 && occupiesForegroundSlot(record)) {
|
|
281
|
+
return "foreground"
|
|
282
|
+
}
|
|
283
|
+
return undefined
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private poolHasRoom(pool: Pool): boolean {
|
|
287
|
+
return pool === "background"
|
|
288
|
+
? this.runningBackground < this.maxConcurrent
|
|
289
|
+
: this.maxConcurrentForeground === 0 ||
|
|
290
|
+
this.runningForeground < this.maxConcurrentForeground
|
|
291
|
+
}
|
|
292
|
+
|
|
247
293
|
/**
|
|
248
294
|
* Spawn an agent and return its ID immediately (for background use).
|
|
249
295
|
* If the concurrency limit is reached, the agent is queued.
|
|
@@ -278,6 +324,7 @@ export class AgentManager {
|
|
|
278
324
|
// only filter excludes only explicit `false`, so undefined agents — which
|
|
279
325
|
// have no inline surface — stay visible instead of vanishing.
|
|
280
326
|
isBackground: options.isBackground,
|
|
327
|
+
blocking: options.blocking,
|
|
281
328
|
invocation: options.invocation,
|
|
282
329
|
depth: options.depth ?? 1,
|
|
283
330
|
parentAgentId: options.parentAgentId,
|
|
@@ -288,13 +335,24 @@ export class AgentManager {
|
|
|
288
335
|
|
|
289
336
|
const args: SpawnArgs = { pi, ctx, type, prompt, options }
|
|
290
337
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
this.
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
338
|
+
const pool = this.poolFor(record)
|
|
339
|
+
if (pool && !options.bypassQueue && !this.poolHasRoom(pool)) {
|
|
340
|
+
record.status = "queued"
|
|
341
|
+
if (!this.armQueuedAbort(id, options.signal)) return id
|
|
342
|
+
let release!: () => void
|
|
343
|
+
record.startGate = new Promise<void>((resolve) => {
|
|
344
|
+
release = resolve
|
|
345
|
+
})
|
|
346
|
+
this.queue.push({
|
|
347
|
+
id,
|
|
348
|
+
pool,
|
|
349
|
+
start: () => this.startAgent(id, record, args),
|
|
350
|
+
release,
|
|
351
|
+
})
|
|
352
|
+
options.onQueued?.(
|
|
353
|
+
id,
|
|
354
|
+
this.queue.filter((entry) => entry.pool === pool).length - 1,
|
|
355
|
+
)
|
|
298
356
|
return id
|
|
299
357
|
}
|
|
300
358
|
|
|
@@ -309,6 +367,20 @@ export class AgentManager {
|
|
|
309
367
|
return id
|
|
310
368
|
}
|
|
311
369
|
|
|
370
|
+
private armQueuedAbort(id: string, signal?: AbortSignal): boolean {
|
|
371
|
+
if (!signal) return true
|
|
372
|
+
if (signal.aborted) {
|
|
373
|
+
const record = this.agents.get(id)
|
|
374
|
+
if (record) {
|
|
375
|
+
record.status = "stopped"
|
|
376
|
+
record.completedAt = Date.now()
|
|
377
|
+
}
|
|
378
|
+
return false
|
|
379
|
+
}
|
|
380
|
+
signal.addEventListener("abort", () => this.abort(id), { once: true })
|
|
381
|
+
return true
|
|
382
|
+
}
|
|
383
|
+
|
|
312
384
|
/** Actually start an agent (called immediately or from queue drain). */
|
|
313
385
|
private startAgent(
|
|
314
386
|
id: string,
|
|
@@ -349,16 +421,23 @@ export class AgentManager {
|
|
|
349
421
|
|
|
350
422
|
record.status = "running"
|
|
351
423
|
record.startedAt = Date.now()
|
|
352
|
-
|
|
424
|
+
record.startGate = undefined
|
|
425
|
+
const pool = this.poolFor(record)
|
|
426
|
+
if (pool === "background") this.runningBackground++
|
|
427
|
+
else if (pool === "foreground") this.runningForeground++
|
|
353
428
|
this.onStart?.(record)
|
|
354
429
|
|
|
355
430
|
// Wire parent abort signal to stop the subagent when the parent is interrupted
|
|
356
431
|
let detachParentSignal: (() => void) | undefined
|
|
357
432
|
if (options.signal) {
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
433
|
+
if (options.signal.aborted) {
|
|
434
|
+
this.abort(id)
|
|
435
|
+
} else {
|
|
436
|
+
const onParentAbort = () => this.abort(id)
|
|
437
|
+
options.signal.addEventListener("abort", onParentAbort, { once: true })
|
|
438
|
+
detachParentSignal = () =>
|
|
439
|
+
options.signal!.removeEventListener("abort", onParentAbort)
|
|
440
|
+
}
|
|
362
441
|
}
|
|
363
442
|
const detach = () => {
|
|
364
443
|
detachParentSignal?.()
|
|
@@ -407,6 +486,18 @@ export class AgentManager {
|
|
|
407
486
|
},
|
|
408
487
|
onSessionCreated: (session) => {
|
|
409
488
|
record.session = session
|
|
489
|
+
if (session.model) {
|
|
490
|
+
record.invocation ??= {}
|
|
491
|
+
const requested =
|
|
492
|
+
record.invocation.requestedThinking ?? record.invocation.thinking
|
|
493
|
+
Object.assign(record.invocation, describeModel(session.model))
|
|
494
|
+
if (session.thinkingLevel) {
|
|
495
|
+
record.invocation.thinking = session.thinkingLevel
|
|
496
|
+
if (requested && requested !== session.thinkingLevel) {
|
|
497
|
+
record.invocation.requestedThinking = requested
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
410
501
|
// Flush any steers that arrived before the session was ready
|
|
411
502
|
if (record.pendingSteers?.length) {
|
|
412
503
|
for (const msg of record.pendingSteers) {
|
|
@@ -468,24 +559,7 @@ export class AgentManager {
|
|
|
468
559
|
|
|
469
560
|
this.abortOwnedChildren(id)
|
|
470
561
|
|
|
471
|
-
|
|
472
|
-
// Mark resultConsumed so the callback skips notifications (result returned inline).
|
|
473
|
-
if (!options.isBackground) {
|
|
474
|
-
record.resultConsumed = true
|
|
475
|
-
try {
|
|
476
|
-
this.onComplete?.(record)
|
|
477
|
-
} catch {
|
|
478
|
-
/* ignore completion side-effect errors */
|
|
479
|
-
}
|
|
480
|
-
} else {
|
|
481
|
-
if (occupiesPoolSlot(record)) this.runningBackground--
|
|
482
|
-
try {
|
|
483
|
-
this.onComplete?.(record)
|
|
484
|
-
} catch {
|
|
485
|
-
/* ignore completion side-effect errors */
|
|
486
|
-
}
|
|
487
|
-
this.drainQueue()
|
|
488
|
-
}
|
|
562
|
+
this.settleRun(record, true, pool)
|
|
489
563
|
return responseText
|
|
490
564
|
})
|
|
491
565
|
.catch((err) => {
|
|
@@ -524,25 +598,35 @@ export class AgentManager {
|
|
|
524
598
|
|
|
525
599
|
this.abortOwnedChildren(id)
|
|
526
600
|
|
|
527
|
-
|
|
528
|
-
// Mark resultConsumed so the callback skips notifications (result returned inline).
|
|
529
|
-
if (!options.isBackground) {
|
|
530
|
-
record.resultConsumed = true
|
|
531
|
-
this.onComplete?.(record)
|
|
532
|
-
} else {
|
|
533
|
-
if (occupiesPoolSlot(record)) this.runningBackground--
|
|
534
|
-
this.onComplete?.(record)
|
|
535
|
-
this.drainQueue()
|
|
536
|
-
}
|
|
601
|
+
this.settleRun(record, false, pool)
|
|
537
602
|
return ""
|
|
538
603
|
})
|
|
539
604
|
|
|
540
605
|
record.promise = promise
|
|
541
606
|
|
|
542
|
-
//
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
607
|
+
// Per-call hook: safe for parallel and deferred foreground starts.
|
|
608
|
+
options.onSpawned?.(id)
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
private settleRun(
|
|
612
|
+
record: AgentRecord,
|
|
613
|
+
guardCallback: boolean,
|
|
614
|
+
pool: Pool | undefined,
|
|
615
|
+
): void {
|
|
616
|
+
if (!record.isBackground) record.resultConsumed = true
|
|
617
|
+
if (pool === "background") this.runningBackground--
|
|
618
|
+
else if (pool === "foreground") this.runningForeground--
|
|
619
|
+
|
|
620
|
+
if (guardCallback) {
|
|
621
|
+
try {
|
|
622
|
+
this.onComplete?.(record)
|
|
623
|
+
} catch {
|
|
624
|
+
/* ignore completion side-effect errors */
|
|
625
|
+
}
|
|
626
|
+
} else {
|
|
627
|
+
this.onComplete?.(record)
|
|
628
|
+
}
|
|
629
|
+
if (record.isBackground || pool) this.drainQueue()
|
|
546
630
|
}
|
|
547
631
|
|
|
548
632
|
/**
|
|
@@ -557,43 +641,41 @@ export class AgentManager {
|
|
|
557
641
|
}
|
|
558
642
|
}
|
|
559
643
|
|
|
560
|
-
/** Start queued
|
|
644
|
+
/** Start the earliest queued agent whose own pool has room. */
|
|
561
645
|
private drainQueue() {
|
|
562
|
-
|
|
563
|
-
this.queue.
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
646
|
+
for (;;) {
|
|
647
|
+
const index = this.queue.findIndex((entry) =>
|
|
648
|
+
this.poolHasRoom(entry.pool),
|
|
649
|
+
)
|
|
650
|
+
if (index === -1) return
|
|
651
|
+
const [next] = this.queue.splice(index, 1)
|
|
567
652
|
const record = this.agents.get(next.id)
|
|
568
|
-
if (record?.status !== "queued") continue
|
|
569
653
|
try {
|
|
570
|
-
next.start()
|
|
654
|
+
if (record?.status === "queued") next.start()
|
|
571
655
|
} catch (err) {
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
656
|
+
if (record) {
|
|
657
|
+
if (next.pool === "foreground") record.resultConsumed = true
|
|
658
|
+
record.status = "error"
|
|
659
|
+
record.error = err instanceof Error ? err.message : String(err)
|
|
660
|
+
record.completedAt = Date.now()
|
|
661
|
+
this.onComplete?.(record)
|
|
662
|
+
}
|
|
663
|
+
} finally {
|
|
664
|
+
next.release()
|
|
578
665
|
}
|
|
579
666
|
}
|
|
580
667
|
}
|
|
581
668
|
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
669
|
+
private dequeue(pred: (entry: { id: string; pool: Pool }) => boolean): void {
|
|
670
|
+
const kept: typeof this.queue = []
|
|
671
|
+
for (const entry of this.queue) {
|
|
672
|
+
if (pred(entry)) entry.release()
|
|
673
|
+
else kept.push(entry)
|
|
674
|
+
}
|
|
675
|
+
this.queue = kept
|
|
676
|
+
}
|
|
588
677
|
|
|
589
|
-
/**
|
|
590
|
-
* Spawn an agent and wait for completion (foreground use).
|
|
591
|
-
* Foreground agents bypass the concurrency queue.
|
|
592
|
-
* Returns { id, record } so callers can access the agent ID.
|
|
593
|
-
*
|
|
594
|
-
* @param onSpawned - Called synchronously after spawn(), before onSessionCreated fires.
|
|
595
|
-
* Use this to set record.outputFile so streamToOutputFile can pick it up.
|
|
596
|
-
*/
|
|
678
|
+
/** Spawn an agent, applying the blocking foreground pool, and await it. */
|
|
597
679
|
async spawnAndWait(
|
|
598
680
|
pi: ExtensionAPI,
|
|
599
681
|
ctx: ExtensionContext,
|
|
@@ -602,23 +684,18 @@ export class AgentManager {
|
|
|
602
684
|
options: Omit<SpawnOptions, "isBackground">,
|
|
603
685
|
onSpawned?: (id: string) => void,
|
|
604
686
|
): Promise<{ id: string; record: AgentRecord }> {
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
// shared hook immediately so unrelated concurrent spawns cannot inherit
|
|
612
|
-
// this foreground caller's callback while its run is awaited.
|
|
613
|
-
id = this.spawn(pi, ctx, type, prompt, {
|
|
614
|
-
...options,
|
|
615
|
-
isBackground: false,
|
|
616
|
-
})
|
|
617
|
-
} finally {
|
|
618
|
-
this.onSpawned = prevOnSpawned
|
|
619
|
-
}
|
|
687
|
+
const id = this.spawn(pi, ctx, type, prompt, {
|
|
688
|
+
...options,
|
|
689
|
+
isBackground: false,
|
|
690
|
+
blocking: true,
|
|
691
|
+
onSpawned,
|
|
692
|
+
})
|
|
620
693
|
const record = this.agents.get(id)!
|
|
621
|
-
await record.
|
|
694
|
+
if (record.status === "queued") await record.startGate
|
|
695
|
+
if (record.promise) await record.promise
|
|
696
|
+
if (!record.promise && record.status === "error") {
|
|
697
|
+
throw new Error(record.error ?? "Agent failed to start")
|
|
698
|
+
}
|
|
622
699
|
return { id, record }
|
|
623
700
|
}
|
|
624
701
|
|
|
@@ -661,12 +738,14 @@ export class AgentManager {
|
|
|
661
738
|
record.status = "queued"
|
|
662
739
|
|
|
663
740
|
const start = () => this.startResume(id, record, prompt, signal, options)
|
|
664
|
-
if (
|
|
665
|
-
|
|
666
|
-
this.
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
741
|
+
if (occupiesPoolSlot(record) && !this.poolHasRoom("background")) {
|
|
742
|
+
// Detached resumes remain on the background pool only.
|
|
743
|
+
this.queue.push({
|
|
744
|
+
id,
|
|
745
|
+
pool: "background",
|
|
746
|
+
start,
|
|
747
|
+
release: () => {},
|
|
748
|
+
})
|
|
670
749
|
} else {
|
|
671
750
|
start()
|
|
672
751
|
}
|
|
@@ -863,9 +942,9 @@ export class AgentManager {
|
|
|
863
942
|
const record = this.agents.get(id)
|
|
864
943
|
if (!record) return false
|
|
865
944
|
|
|
866
|
-
// Remove from queue if queued
|
|
945
|
+
// Remove from queue if queued and release any blocking waiter.
|
|
867
946
|
if (record.status === "queued") {
|
|
868
|
-
this.
|
|
947
|
+
this.dequeue((q) => q.id === id)
|
|
869
948
|
record.status = "stopped"
|
|
870
949
|
record.completedAt = Date.now()
|
|
871
950
|
return true
|
|
@@ -928,7 +1007,7 @@ export class AgentManager {
|
|
|
928
1007
|
count++
|
|
929
1008
|
}
|
|
930
1009
|
}
|
|
931
|
-
this.
|
|
1010
|
+
this.dequeue(() => true)
|
|
932
1011
|
// Abort running agents
|
|
933
1012
|
for (const record of this.agents.values()) {
|
|
934
1013
|
if (record.status === "running") {
|
|
@@ -958,8 +1037,7 @@ export class AgentManager {
|
|
|
958
1037
|
|
|
959
1038
|
async dispose(): Promise<void> {
|
|
960
1039
|
clearInterval(this.cleanupInterval)
|
|
961
|
-
|
|
962
|
-
this.queue = []
|
|
1040
|
+
this.dequeue(() => true)
|
|
963
1041
|
const sessions = [...this.agents.values()].map((record) => record.session)
|
|
964
1042
|
this.agents.clear()
|
|
965
1043
|
await Promise.all(sessions.map((session) => shutdownChildSession(session)))
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cross-extension RPC handlers for the subagents extension.
|
|
3
3
|
*
|
|
4
|
-
* Exposes ping, spawn, and
|
|
4
|
+
* Exposes ping, spawn, stop, and consume RPCs over the pi.events event bus,
|
|
5
5
|
* using per-request scoped reply channels.
|
|
6
6
|
*
|
|
7
7
|
* Reply envelope follows pi-mono convention:
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { type ModelRegistry, resolveModel } from "./model-resolver.js"
|
|
13
|
+
import { checkModelScope } from "./model-scope.js"
|
|
13
14
|
|
|
14
15
|
/** Minimal event bus interface needed by the RPC handlers. */
|
|
15
16
|
export interface EventBus {
|
|
@@ -25,7 +26,7 @@ export type RpcReply<T = void> =
|
|
|
25
26
|
/** RPC protocol version — bumped when the envelope or method contracts change. */
|
|
26
27
|
export const PROTOCOL_VERSION = 2
|
|
27
28
|
|
|
28
|
-
/** Minimal AgentManager interface needed by the spawn/stop RPCs. */
|
|
29
|
+
/** Minimal AgentManager interface needed by the spawn/stop/consume RPCs. */
|
|
29
30
|
export interface SpawnCapable {
|
|
30
31
|
spawn(
|
|
31
32
|
pi: unknown,
|
|
@@ -35,6 +36,7 @@ export interface SpawnCapable {
|
|
|
35
36
|
options: any,
|
|
36
37
|
): string
|
|
37
38
|
abort(id: string): boolean
|
|
39
|
+
consumeResult(id: string): boolean
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
export interface RpcDeps {
|
|
@@ -48,6 +50,7 @@ export interface RpcHandle {
|
|
|
48
50
|
unsubPing: () => void
|
|
49
51
|
unsubSpawn: () => void
|
|
50
52
|
unsubStop: () => void
|
|
53
|
+
unsubConsume: () => void
|
|
51
54
|
}
|
|
52
55
|
|
|
53
56
|
/**
|
|
@@ -76,7 +79,7 @@ function handleRpc<P extends { requestId: string }>(
|
|
|
76
79
|
}
|
|
77
80
|
|
|
78
81
|
/**
|
|
79
|
-
* Register ping, spawn, and
|
|
82
|
+
* Register ping, spawn, stop, and consume RPC handlers on the event bus.
|
|
80
83
|
* Returns unsub functions for cleanup.
|
|
81
84
|
*/
|
|
82
85
|
export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
|
|
@@ -102,21 +105,40 @@ export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
|
|
|
102
105
|
// agent's auth lookup doesn't crash with "No API key found for
|
|
103
106
|
// undefined".
|
|
104
107
|
let normalizedOptions = options ?? {}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
+
const override = normalizedOptions.model
|
|
109
|
+
// null means inherit, matching the runner's `model ?? default` behavior.
|
|
110
|
+
if (override != null) {
|
|
111
|
+
const { modelRegistry, cwd } = ctx as {
|
|
112
|
+
modelRegistry?: ModelRegistry
|
|
113
|
+
cwd?: string
|
|
114
|
+
}
|
|
115
|
+
const label =
|
|
116
|
+
typeof override === "string"
|
|
117
|
+
? override
|
|
118
|
+
: `${override.provider}/${override.id}`
|
|
119
|
+
if (!modelRegistry) {
|
|
108
120
|
throw new Error(
|
|
109
|
-
`Model override "${
|
|
121
|
+
`Model override "${label}" provided but ctx.modelRegistry is unavailable`,
|
|
110
122
|
)
|
|
111
123
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
124
|
+
|
|
125
|
+
let model = override
|
|
126
|
+
if (typeof override === "string") {
|
|
127
|
+
const resolved = resolveModel(override, modelRegistry)
|
|
128
|
+
if (typeof resolved === "string") throw new Error(resolved)
|
|
129
|
+
model = resolved
|
|
130
|
+
normalizedOptions = { ...normalizedOptions, model: resolved }
|
|
118
131
|
}
|
|
119
|
-
|
|
132
|
+
|
|
133
|
+
const verdict = checkModelScope({
|
|
134
|
+
model,
|
|
135
|
+
cwd: cwd ?? process.cwd(),
|
|
136
|
+
modelRegistry,
|
|
137
|
+
callerSupplied: true,
|
|
138
|
+
agentLabel: type,
|
|
139
|
+
modelInput: label,
|
|
140
|
+
})
|
|
141
|
+
if (verdict.kind === "error") throw new Error(verdict.message)
|
|
120
142
|
}
|
|
121
143
|
|
|
122
144
|
return { id: manager.spawn(pi, ctx, type, prompt, normalizedOptions) }
|
|
@@ -130,5 +152,14 @@ export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
|
|
|
130
152
|
},
|
|
131
153
|
)
|
|
132
154
|
|
|
133
|
-
|
|
155
|
+
const unsubConsume = handleRpc<{
|
|
156
|
+
requestId: string
|
|
157
|
+
agentId: string
|
|
158
|
+
}>(events, "subagents:rpc:consume", ({ agentId }) => {
|
|
159
|
+
if (!manager.consumeResult(agentId)) {
|
|
160
|
+
throw new Error("Agent not found or still running")
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
return { unsubPing, unsubSpawn, unsubStop, unsubConsume }
|
|
134
165
|
}
|
package/src/custom-agents.ts
CHANGED
|
@@ -141,13 +141,21 @@ function loadFromDir(
|
|
|
141
141
|
* Read and parse one agent file, or warn and return undefined for the caller to
|
|
142
142
|
* skip. Under strict mode the same failure aborts startup while naming the file.
|
|
143
143
|
*/
|
|
144
|
+
export function parseAgentFrontmatter<T extends Record<string, unknown>>(
|
|
145
|
+
content: string,
|
|
146
|
+
): { frontmatter: T; body: string } {
|
|
147
|
+
return parseFrontmatter<T>(
|
|
148
|
+
content.startsWith("\uFEFF") ? content.slice(1) : content,
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
144
152
|
function readAgentFile(
|
|
145
153
|
path: string,
|
|
146
154
|
strict: boolean,
|
|
147
155
|
warnings: WarningState,
|
|
148
156
|
): { frontmatter: Record<string, unknown>; body: string } | undefined {
|
|
149
157
|
try {
|
|
150
|
-
return
|
|
158
|
+
return parseAgentFrontmatter<Record<string, unknown>>(
|
|
151
159
|
readFileSync(path, "utf-8"),
|
|
152
160
|
)
|
|
153
161
|
} catch (err) {
|