@bermudi/pi-delegate 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -8
- package/agents.ts +13 -2
- package/constants.ts +3 -0
- package/delegate.ts +13 -1
- package/dispatch.ts +33 -3
- package/extension.ts +66 -2
- package/host-compat.ts +47 -12
- package/host.ts +93 -16
- package/lifecycle.ts +220 -85
- package/manual.ts +10 -3
- package/model.ts +3 -4
- package/package.json +22 -20
- package/pool.ts +169 -51
- package/render-branches.ts +21 -3
- package/runner.ts +18 -9
- package/schema.ts +58 -26
- package/status.ts +203 -0
- package/task-resolution.ts +127 -44
- package/types.ts +8 -0
package/lifecycle.ts
CHANGED
|
@@ -11,13 +11,14 @@ import type {
|
|
|
11
11
|
TaskProgress,
|
|
12
12
|
TaskResult,
|
|
13
13
|
TaskRunEnv,
|
|
14
|
+
ToolActivity,
|
|
14
15
|
} from "./types.ts";
|
|
15
16
|
import * as pool from "./pool.ts";
|
|
16
17
|
import { isSessionBusy } from "./tickets.ts";
|
|
17
18
|
import {
|
|
18
19
|
createSubagentSessionManager,
|
|
19
|
-
setParentSession,
|
|
20
20
|
persistSessionHeader,
|
|
21
|
+
setParentSession,
|
|
21
22
|
} from "./sessions.ts";
|
|
22
23
|
import { runAgentSession } from "./runner.ts";
|
|
23
24
|
import { getGitChangedFiles } from "./file-tracking.ts";
|
|
@@ -26,6 +27,16 @@ import { resolveCwd, validateResumeFromPath } from "./utils.ts";
|
|
|
26
27
|
import { getWholeTaskMaxRetries, getWholeTaskBaseDelayMs } from "./config.ts";
|
|
27
28
|
import { addUsage, emptyUsage } from "./usage.ts";
|
|
28
29
|
|
|
30
|
+
/** Internal seam for lifecycle-level tests without replacing session ownership. */
|
|
31
|
+
type RunAgentSession = typeof runAgentSession;
|
|
32
|
+
let runAgentSessionForTesting: RunAgentSession = runAgentSession;
|
|
33
|
+
|
|
34
|
+
export function _setRunAgentSessionForTesting(
|
|
35
|
+
override: RunAgentSession | undefined,
|
|
36
|
+
): void {
|
|
37
|
+
runAgentSessionForTesting = override ?? runAgentSession;
|
|
38
|
+
}
|
|
39
|
+
|
|
29
40
|
/**
|
|
30
41
|
* Test-only overrides for whole-task retry settings. When set, these bypass
|
|
31
42
|
* the config-driven values so retry integration tests don't sleep real seconds.
|
|
@@ -104,19 +115,57 @@ function disposeOwnedSession(acquired: AcquiredSession): void {
|
|
|
104
115
|
}
|
|
105
116
|
}
|
|
106
117
|
|
|
107
|
-
/**
|
|
118
|
+
/** Merge per-attempt activities into a live history list while preserving
|
|
119
|
+
* prior-attempt evidence. Activity IDs are used as a stable handle so in-flight
|
|
120
|
+
* updates can replace earlier skeletons for the same call. */
|
|
121
|
+
function mergeToolActivities(
|
|
122
|
+
existing: TaskProgress["activities"],
|
|
123
|
+
incoming: ToolActivity[],
|
|
124
|
+
): ToolActivity[] {
|
|
125
|
+
const byId = new Map<string, number>();
|
|
126
|
+
for (let i = 0; i < existing.length; i++) {
|
|
127
|
+
byId.set(existing[i]!.id, i);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const merged = [...existing];
|
|
131
|
+
for (const incomingActivity of incoming) {
|
|
132
|
+
const index = byId.get(incomingActivity.id);
|
|
133
|
+
if (index === undefined) {
|
|
134
|
+
byId.set(incomingActivity.id, merged.length);
|
|
135
|
+
merged.push(incomingActivity);
|
|
136
|
+
} else {
|
|
137
|
+
merged[index] = { ...merged[index], ...incomingActivity };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return merged;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Optional counters are used by retry-aware accounting to keep live progress
|
|
144
|
+
* monotonic across attempts. */
|
|
145
|
+
interface RunUpdateOffset {
|
|
146
|
+
tokensOffset?: number;
|
|
147
|
+
toolUsesOffset?: number;
|
|
148
|
+
}
|
|
149
|
+
/** Mirror a progress update from runAgent into a TaskProgress row.
|
|
150
|
+
*
|
|
151
|
+
* Runner callbacks report attempt-local counters. Offset/merge them so a single
|
|
152
|
+
* TaskProgress row is monotonic across whole-task retries.
|
|
153
|
+
*/
|
|
108
154
|
export function updateProgressFromRun(
|
|
109
155
|
p: TaskProgress,
|
|
110
156
|
u: AgentProgressUpdate,
|
|
157
|
+
offsets: RunUpdateOffset = {},
|
|
111
158
|
): void {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
p.
|
|
159
|
+
const cumulativeTokens = (offsets.tokensOffset ?? 0) + u.tokens;
|
|
160
|
+
const cumulativeTools = (offsets.toolUsesOffset ?? 0) + u.toolUses;
|
|
161
|
+
p.tokens = Math.max(p.tokens, cumulativeTokens);
|
|
162
|
+
p.toolUses = Math.max(p.toolUses, cumulativeTools);
|
|
163
|
+
|
|
164
|
+
p.durationMs = Math.max(p.durationMs, u.durationMs);
|
|
115
165
|
p.lastActivityAt = u.lastActivityAt;
|
|
116
|
-
p.activities = u.activities;
|
|
166
|
+
p.activities = mergeToolActivities(p.activities, u.activities);
|
|
117
167
|
p.failureKind = u.failureKind;
|
|
118
168
|
}
|
|
119
|
-
|
|
120
169
|
/** Mirror a completed TaskResult into a TaskProgress row (status/duration/error). */
|
|
121
170
|
function updateProgressFromResult(p: TaskProgress, r: TaskResult): void {
|
|
122
171
|
p.status = r.error ? "failed" : "done";
|
|
@@ -195,19 +244,32 @@ function isClearlyTransientFinalError(error: string | undefined): boolean {
|
|
|
195
244
|
);
|
|
196
245
|
}
|
|
197
246
|
|
|
198
|
-
function canRetryWholeTask(
|
|
247
|
+
function canRetryWholeTask(
|
|
248
|
+
task: ResolvedTask,
|
|
249
|
+
result: TaskResult,
|
|
250
|
+
hasBashExecution = false,
|
|
251
|
+
): boolean {
|
|
199
252
|
// Whole-task retry can repeat tool side effects. Keep it to stateless fresh
|
|
200
253
|
// tasks, and only when our touched-file accounting says the failed attempt
|
|
201
254
|
// did not write/edit anything. A `model_error` (usage limit, auth, quota) is
|
|
202
255
|
// not transient for the resolved model — retrying with the same model just
|
|
203
256
|
// hits the same wall, so skip it and let the parent resume with a different
|
|
204
|
-
// model (see the hint in formatFailedTask).
|
|
257
|
+
// model (see the hint in formatFailedTask). Similarly, once bash executes,
|
|
258
|
+
// any retry would replay non-idempotent side effects, so suppress it.
|
|
259
|
+
// We gate on *observed* bash activity plus touchedFiles, not on the tool set
|
|
260
|
+
// itself: the default tool set includes `bash` for most tasks, and suppressing
|
|
261
|
+
// retry for every bash-capable task would disable the useful transient-error
|
|
262
|
+
// retry path even when no side effects occurred. The stricter “any bash tool
|
|
263
|
+
// → no retry” rule matches the “no filesystem isolation” stance, but is too
|
|
264
|
+
// restrictive for retry safety — touched-file accounting and observed activity
|
|
265
|
+
// are the direct side-effect signals.
|
|
205
266
|
return (
|
|
206
267
|
result.failureKind !== "stalled" &&
|
|
207
268
|
result.failureKind !== "model_error" &&
|
|
208
269
|
!task.sessionId &&
|
|
209
270
|
!task.resumeFrom &&
|
|
210
271
|
result.touchedFiles.length === 0 &&
|
|
272
|
+
!hasBashExecution &&
|
|
211
273
|
isClearlyTransientFinalError(result.error)
|
|
212
274
|
);
|
|
213
275
|
}
|
|
@@ -244,7 +306,7 @@ async function buildDelegateSession(
|
|
|
244
306
|
// resource loaders are cached after the first call; provider-configured or
|
|
245
307
|
// allowlisted-extension loaders are deliberately fresh per session because
|
|
246
308
|
// their extension runtime is mutable. The resourceLoader is cwd-scoped (it
|
|
247
|
-
// scans for AGENTS.md/skills) and the system prompt is per named-agent.
|
|
309
|
+
// scans for project AGENTS.md/skills) and the system prompt is per named-agent.
|
|
248
310
|
// The custom prompt overrides the default AgentSession system prompt.
|
|
249
311
|
// Pass only the provider needed by this task. This keeps a non-Kilo task
|
|
250
312
|
// from receiving Kilo's provider/auth adapter merely because Kilo is also
|
|
@@ -504,7 +566,10 @@ async function runResolvedTaskUnlocked(
|
|
|
504
566
|
failTask(task, "action='close' requires sessionId."),
|
|
505
567
|
);
|
|
506
568
|
}
|
|
507
|
-
|
|
569
|
+
// The per-session lock for action-based operations is already held by the
|
|
570
|
+
// outer runResolvedTask() wrapper. Use the internal close helper to avoid a
|
|
571
|
+
// reentrant deadlock on the same key.
|
|
572
|
+
const closed = await pool._closePooledAgentWithoutLock(task.sessionId);
|
|
508
573
|
return finishTask(
|
|
509
574
|
env,
|
|
510
575
|
p,
|
|
@@ -530,15 +595,49 @@ async function runResolvedTaskUnlocked(
|
|
|
530
595
|
);
|
|
531
596
|
}
|
|
532
597
|
|
|
598
|
+
let hasBashExecution = false;
|
|
599
|
+
let cumulativeTokens = 0;
|
|
600
|
+
let cumulativeToolUses = 0;
|
|
601
|
+
const taskStartedAt = Date.now();
|
|
602
|
+
let accumulatedUsage = emptyUsage();
|
|
603
|
+
|
|
604
|
+
const onAttemptProgress = (u: AgentProgressUpdate): void => {
|
|
605
|
+
if (
|
|
606
|
+
!hasBashExecution &&
|
|
607
|
+
u.activities.some((activity) => activity.name === "bash")
|
|
608
|
+
) {
|
|
609
|
+
hasBashExecution = true;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const mapped: AgentProgressUpdate = {
|
|
613
|
+
...u,
|
|
614
|
+
tokens: cumulativeTokens + u.tokens,
|
|
615
|
+
toolUses: cumulativeToolUses + u.toolUses,
|
|
616
|
+
durationMs: Date.now() - taskStartedAt,
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
// Keep live totals monotonic across attempts.
|
|
620
|
+
p.tokens = Math.max(p.tokens, mapped.tokens);
|
|
621
|
+
p.toolUses = Math.max(p.toolUses, mapped.toolUses);
|
|
622
|
+
if (mapped.durationMs > p.durationMs) {
|
|
623
|
+
p.durationMs = mapped.durationMs;
|
|
624
|
+
}
|
|
625
|
+
env.onProgress(p, mapped);
|
|
626
|
+
};
|
|
627
|
+
|
|
533
628
|
const runAttempt = async (): Promise<TaskResult> => {
|
|
629
|
+
let attemptToolUsesObserved = 0;
|
|
630
|
+
const onProgress = (u: AgentProgressUpdate): void => {
|
|
631
|
+
attemptToolUsesObserved = Math.max(attemptToolUsesObserved, u.toolUses);
|
|
632
|
+
onAttemptProgress(u);
|
|
633
|
+
};
|
|
634
|
+
|
|
534
635
|
// ── Pool / resume / fresh-agent resolution ────────────────────────
|
|
535
636
|
const acquired = await acquireAgentSession(env, task, p);
|
|
536
637
|
if ("error" in acquired) return acquired.error;
|
|
537
638
|
|
|
538
639
|
// A pool hit is already owned by the pool. Fresh/resumed sessions belong
|
|
539
|
-
// to this attempt until commit
|
|
540
|
-
// this state local makes cleanup a finally invariant rather than a list
|
|
541
|
-
// of special cases for aborts, stalls, and provider failures.
|
|
640
|
+
// to this attempt until commit/recordUse/close logic runs.
|
|
542
641
|
let sessionReleased = !acquired.lifecycleOwnsSession;
|
|
543
642
|
try {
|
|
544
643
|
// Re-check abort after acquisition. The pre-acquire check at the top can
|
|
@@ -546,22 +645,25 @@ async function runResolvedTaskUnlocked(
|
|
|
546
645
|
// baseline. runAgentSession re-checks after attaching its listener, but a
|
|
547
646
|
// cancelled ticket should not even start the subagent (no file writes, no
|
|
548
647
|
// pool insert).
|
|
549
|
-
if (env.signal?.aborted)
|
|
648
|
+
if (env.signal?.aborted) {
|
|
649
|
+
return failTask(task, "Aborted");
|
|
650
|
+
}
|
|
550
651
|
|
|
551
652
|
// Snapshot git status before the run so touchedFiles can diff after.
|
|
552
653
|
// AgentSession owns retry/compaction internally — runAgentSession just
|
|
553
654
|
// drives the prompt and maps events to the progress model.
|
|
554
655
|
const gitBaseline = await getGitChangedFiles(task.cwd);
|
|
555
|
-
let r = await
|
|
656
|
+
let r = await runAgentSessionForTesting(
|
|
556
657
|
acquired.session,
|
|
557
658
|
task.prompt,
|
|
558
659
|
{ cwd: task.cwd },
|
|
559
660
|
env.signal,
|
|
560
|
-
|
|
661
|
+
onProgress,
|
|
561
662
|
gitBaseline,
|
|
562
663
|
Date.now(),
|
|
563
664
|
);
|
|
564
665
|
|
|
666
|
+
cumulativeTokens += r.tokens;
|
|
565
667
|
// The signal can fire after the pre-run check or while the runner is
|
|
566
668
|
// collecting post-prompt evidence. Keep cancellation from looking like
|
|
567
669
|
// success; finally below releases any uncommitted session.
|
|
@@ -569,62 +671,72 @@ async function runResolvedTaskUnlocked(
|
|
|
569
671
|
r = { ...r, error: "Aborted" };
|
|
570
672
|
}
|
|
571
673
|
|
|
572
|
-
// A stalled prompt was explicitly aborted and is no longer a safe
|
|
573
|
-
// continuation. Close a pooled hit; a fresh/resumed session is still
|
|
574
|
-
// released by the finally below. closePooledAgent removes the pooled
|
|
575
|
-
// entry before surfacing cleanup errors, so a pool-owned session is
|
|
576
|
-
// never directly disposed here.
|
|
577
|
-
if (r.failureKind === "stalled" && task.sessionId) {
|
|
578
|
-
try {
|
|
579
|
-
if (await pool.closePooledAgent(task.sessionId)) {
|
|
580
|
-
sessionReleased = true;
|
|
581
|
-
}
|
|
582
|
-
} catch (error) {
|
|
583
|
-
// Preserve the primary stalled result while logging the cleanup
|
|
584
|
-
// failure explicitly. A pooled session is already removed by the
|
|
585
|
-
// pool; a pool miss remains lifecycle-owned and is handled below.
|
|
586
|
-
console.error(
|
|
587
|
-
`[delegate] failed to dispose stalled pooled session '${task.sessionId}'`,
|
|
588
|
-
error,
|
|
589
|
-
);
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
|
|
593
674
|
const sessionFile = resolveResumableSessionFile(
|
|
594
675
|
acquired.sessionFile,
|
|
595
676
|
acquired.sessionManager,
|
|
596
677
|
r.error,
|
|
597
678
|
);
|
|
598
679
|
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
680
|
+
if (task.sessionId) {
|
|
681
|
+
if (acquired.lifecycleOwnsSession) {
|
|
682
|
+
// Pool misses (including resumeFrom) transfer ownership only on
|
|
683
|
+
// successful completion; failures are owned by lifecycle and must
|
|
684
|
+
// be disposed in this finally path.
|
|
685
|
+
if (!r.error && r.failureKind !== "stalled") {
|
|
686
|
+
const committed = pool.commit(task.sessionId, {
|
|
687
|
+
session: acquired.session,
|
|
688
|
+
sessionManager: acquired.sessionManager,
|
|
689
|
+
sessionFile: acquired.sessionFile,
|
|
690
|
+
frozen: {
|
|
691
|
+
systemPrompt: task.systemPrompt,
|
|
692
|
+
model: task.model,
|
|
693
|
+
thinking: task.thinking,
|
|
694
|
+
tools: task.tools,
|
|
695
|
+
cwd: task.cwd,
|
|
696
|
+
},
|
|
697
|
+
tokens: r.tokens,
|
|
698
|
+
});
|
|
699
|
+
sessionReleased = sessionReleased || committed;
|
|
700
|
+
}
|
|
701
|
+
} else {
|
|
702
|
+
// A stalled pooled attempt is not safe to keep; remove from the
|
|
703
|
+
// pool and let the lifecycle-owned finalizer handle disposal.
|
|
704
|
+
if (r.failureKind === "stalled") {
|
|
705
|
+
try {
|
|
706
|
+
sessionReleased =
|
|
707
|
+
(await pool._closePooledAgentWithoutLock(task.sessionId)) ||
|
|
708
|
+
sessionReleased;
|
|
709
|
+
} catch (error) {
|
|
710
|
+
// Preserve the primary stalled result while logging the cleanup
|
|
711
|
+
// failure explicitly. A pooled session may still be removed by
|
|
712
|
+
// the pool; a pool-miss remains lifecycle-owned and is handled
|
|
713
|
+
// by the finally path above.
|
|
714
|
+
console.error(
|
|
715
|
+
`[delegate] failed to dispose stalled pooled session '${task.sessionId}'`,
|
|
716
|
+
error,
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
} else {
|
|
720
|
+
// Pool hits stay owned by the pool, and non-stalled completions
|
|
721
|
+
// (including failed attempts) must still count usage.
|
|
722
|
+
pool.recordUse(task.sessionId, r.tokens);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
617
725
|
}
|
|
618
726
|
|
|
727
|
+
accumulatedUsage = addUsage(accumulatedUsage, r.usage);
|
|
728
|
+
cumulativeToolUses += attemptToolUsesObserved;
|
|
729
|
+
|
|
619
730
|
return {
|
|
620
731
|
agent: task.agentName,
|
|
621
732
|
output: r.output,
|
|
622
733
|
error: r.error,
|
|
623
|
-
// Classify the failure: the runner sets `stalled` for the
|
|
624
|
-
// watchdog; here we add `model_error` for failures
|
|
625
|
-
// the resolved model (usage limit, auth, quota) so the
|
|
626
|
-
// "switch model" hint instead of a same-model retry
|
|
627
|
-
// canRetryWholeTask skips the pointless same-model
|
|
734
|
+
// Classify the failure: the runner sets `stalled` for the
|
|
735
|
+
// inactivity watchdog; here we add `model_error` for failures
|
|
736
|
+
// attributable to the resolved model (usage limit, auth, quota) so the
|
|
737
|
+
// parent gets a "switch model" hint instead of a same-model retry
|
|
738
|
+
// hint, and so canRetryWholeTask skips the pointless same-model
|
|
739
|
+
// retry.
|
|
628
740
|
failureKind:
|
|
629
741
|
r.failureKind ??
|
|
630
742
|
(r.error && isModelAttributableError(r.error)
|
|
@@ -644,28 +756,31 @@ async function runResolvedTaskUnlocked(
|
|
|
644
756
|
}
|
|
645
757
|
};
|
|
646
758
|
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
759
|
+
let result: TaskResult;
|
|
760
|
+
try {
|
|
761
|
+
result = await runAttempt();
|
|
762
|
+
} catch (err) {
|
|
763
|
+
const failure = failTask(
|
|
764
|
+
task,
|
|
765
|
+
err instanceof Error ? err.message : String(err),
|
|
766
|
+
);
|
|
767
|
+
return finishTask(env, p, {
|
|
768
|
+
...failure,
|
|
769
|
+
durationMs: Math.max(failure.durationMs, Date.now() - taskStartedAt),
|
|
770
|
+
tokens: accumulatedUsage.totalTokens,
|
|
771
|
+
usage: accumulatedUsage,
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
|
|
661
775
|
const maxRetries = resolvedWholeTaskMaxRetries();
|
|
662
776
|
const baseDelayMs = resolvedWholeTaskBaseDelayMs();
|
|
663
777
|
for (
|
|
664
778
|
let retry = 0;
|
|
665
|
-
retry < maxRetries && canRetryWholeTask(task, result);
|
|
779
|
+
retry < maxRetries && canRetryWholeTask(task, result, hasBashExecution);
|
|
666
780
|
retry++
|
|
667
781
|
) {
|
|
668
782
|
const delayMs = baseDelayMs * 2 ** retry;
|
|
783
|
+
p.durationMs = Math.max(p.durationMs, Date.now() - taskStartedAt);
|
|
669
784
|
await sleepForWholeTaskRetry(env.signal, delayMs);
|
|
670
785
|
if (env.signal?.aborted) {
|
|
671
786
|
// Preserve any partial output/session path from the last failed attempt
|
|
@@ -674,27 +789,47 @@ async function runResolvedTaskUnlocked(
|
|
|
674
789
|
result = {
|
|
675
790
|
...result,
|
|
676
791
|
error: "Aborted",
|
|
792
|
+
durationMs: Date.now() - taskStartedAt,
|
|
677
793
|
tokens: accumulatedUsage.totalTokens,
|
|
678
794
|
usage: accumulatedUsage,
|
|
795
|
+
touchedFiles: result.touchedFiles,
|
|
796
|
+
sessionFile: result.sessionFile,
|
|
679
797
|
};
|
|
680
798
|
break;
|
|
681
799
|
}
|
|
800
|
+
|
|
682
801
|
p.status = "running";
|
|
683
802
|
p.error = undefined;
|
|
684
803
|
p.failureKind = undefined;
|
|
685
804
|
env.onStatusChange?.();
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
805
|
+
try {
|
|
806
|
+
result = await runAttempt();
|
|
807
|
+
} catch (err) {
|
|
808
|
+
const failure = failTask(
|
|
809
|
+
task,
|
|
810
|
+
err instanceof Error ? err.message : String(err),
|
|
811
|
+
);
|
|
812
|
+
result = {
|
|
813
|
+
...failure,
|
|
814
|
+
durationMs: Math.max(failure.durationMs, Date.now() - taskStartedAt),
|
|
815
|
+
tokens: accumulatedUsage.totalTokens,
|
|
816
|
+
usage: accumulatedUsage,
|
|
817
|
+
};
|
|
818
|
+
break;
|
|
819
|
+
}
|
|
693
820
|
}
|
|
694
|
-
|
|
821
|
+
|
|
822
|
+
return finishTask(env, p, {
|
|
823
|
+
...result,
|
|
824
|
+
durationMs: Math.max(result.durationMs, Date.now() - taskStartedAt),
|
|
825
|
+
tokens: Math.max(cumulativeTokens, accumulatedUsage.totalTokens),
|
|
826
|
+
usage: accumulatedUsage,
|
|
827
|
+
});
|
|
695
828
|
} catch (err) {
|
|
696
829
|
// Any acquired session is released by runAttempt's finally before an
|
|
697
|
-
// exception reaches this boundary.
|
|
830
|
+
// exception reaches this boundary. This outer catch handles unexpected
|
|
831
|
+
// throws before retry accounting is initialized, so report a minimal
|
|
832
|
+
// failure without accumulated usage.
|
|
698
833
|
return finishTask(
|
|
699
834
|
env,
|
|
700
835
|
p,
|
package/manual.ts
CHANGED
|
@@ -70,10 +70,16 @@ export function getSubagentManualMarkdown(
|
|
|
70
70
|
"**Why you're seeing this:** no tasks were provided, so the tool returned help instead of dispatching. Nothing is broken. To dispatch subagents, put task fields inside `tasks: [{ ... }]`.",
|
|
71
71
|
"",
|
|
72
72
|
"```ts",
|
|
73
|
-
'delegate({ tasks: [{ prompt: "Investigate the auth module" }] })',
|
|
73
|
+
'delegate({ tasks: [{ agent: "default", prompt: "Investigate the auth module" }] })',
|
|
74
74
|
"```",
|
|
75
75
|
"",
|
|
76
|
-
"Delegate subagents to execute tasks in parallel. Each subagent gets an independent context
|
|
76
|
+
"Delegate subagents to execute tasks in parallel. Each subagent gets an independent context. Use the built-in `default` profile when it should mirror the live parent's model, thinking level, delegatable native tools, and base system prompt. Custom agents can be defined inline in a task or persisted as Markdown files.",
|
|
77
|
+
"",
|
|
78
|
+
"## Built-in Agent",
|
|
79
|
+
"",
|
|
80
|
+
"- **default**: mirrors the live parent model, thinking level, delegatable native tools, and base system prompt.",
|
|
81
|
+
"",
|
|
82
|
+
"Parent extension/MCP tools are not copied, and project context is rebuilt safely for the task's `cwd`. Per-task fields remain explicit overrides.",
|
|
77
83
|
"",
|
|
78
84
|
"## Available Custom Agents",
|
|
79
85
|
"",
|
|
@@ -170,7 +176,8 @@ export function getSubagentManualMarkdown(
|
|
|
170
176
|
"",
|
|
171
177
|
"- `*` means read/write/edit/bash, not every tool. `grep`, `find`, and `ls` are valid explicit tools and are the `ro` preset.",
|
|
172
178
|
'- `tasks` is an array. The tool recovers common stringified calls for compatibility, but canonical calls use `{ tasks: [{ prompt: "..." }] }`.',
|
|
173
|
-
|
|
179
|
+
'- Use `agent: "default"` for the parent\'s live model/thinking/native tools/base prompt. Omitting `agent` creates an ad-hoc task with delegate defaults.',
|
|
180
|
+
"- An ad-hoc task with no `tools` uses `*`; a named custom task uses its profile; a profile with no tools uses `*`.",
|
|
174
181
|
"- Subagents inherit all skills discovered in their `cwd` (via AgentSession's resource loader). Per-task skill filtering is not supported — curate the cwd's skill set instead.",
|
|
175
182
|
`- Sync \`delegate\` runs at most ${getMaxConcurrent()} tasks at once (the rest queue, not fail). Use \`async: true\` to move work to the background.`,
|
|
176
183
|
"",
|
package/model.ts
CHANGED
|
@@ -6,8 +6,7 @@ import { VALID_THINKING } from "./constants.ts";
|
|
|
6
6
|
export interface ResolvedModelRequest {
|
|
7
7
|
model: Model<Api> | undefined;
|
|
8
8
|
/** Pi-style `:<thinking-level>` suffix stripped to make the reference
|
|
9
|
-
* resolve.
|
|
10
|
-
* thinking level; the task's `thinking` field is the only thinking input. */
|
|
9
|
+
* resolve. The caller may use it as a last-resort thinking default. */
|
|
11
10
|
strippedSuffix?: ThinkingLevel;
|
|
12
11
|
}
|
|
13
12
|
|
|
@@ -32,8 +31,8 @@ function resolveModelReference(
|
|
|
32
31
|
* stripped and the base reference is resolved — models learned this syntax
|
|
33
32
|
* from Pi's CLI (e.g. `openai-codex/gpt-5.6-luna:max`) and keep emitting it,
|
|
34
33
|
* so hard-failing the whole call over it is worse than tolerating it. The
|
|
35
|
-
* suffix is
|
|
36
|
-
*
|
|
34
|
+
* suffix is returned separately so task resolution can use it as a
|
|
35
|
+
* last-resort default while keeping explicit `thinking` authoritative. */
|
|
37
36
|
export function resolveModelRequest(
|
|
38
37
|
spec: string | undefined,
|
|
39
38
|
registry: ModelRegistry,
|
package/package.json
CHANGED
|
@@ -1,25 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bermudi/pi-delegate",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"devDependencies": {
|
|
5
|
-
"@earendil-works/pi-agent-core": "^0.80.9",
|
|
6
|
-
"@earendil-works/pi-ai": "^0.80.9",
|
|
7
|
-
"@earendil-works/pi-coding-agent": "^0.80.9",
|
|
8
|
-
"@earendil-works/pi-tui": "^0.80.9",
|
|
9
|
-
"@marcfargas/pi-test-harness": "^0.6.1",
|
|
10
|
-
"@sinclair/typebox": "^0.34.0",
|
|
11
|
-
"esbuild": "^0.27.0",
|
|
12
|
-
"prettier": "^3.8.4",
|
|
13
|
-
"typescript": "^5.9.0"
|
|
14
|
-
},
|
|
15
|
-
"private": false,
|
|
16
|
-
"scripts": {
|
|
17
|
-
"test": "bun test",
|
|
18
|
-
"typecheck": "tsc --noEmit",
|
|
19
|
-
"build": "esbuild delegate.ts --bundle --platform=neutral --packages=external --format=esm --banner:js=\"// @ts-nocheck\" --outfile=delegate.bundle.ts",
|
|
20
|
-
"format": "prettier --write \"**/*.ts\""
|
|
21
|
-
},
|
|
22
|
-
"type": "module",
|
|
3
|
+
"version": "0.1.1",
|
|
23
4
|
"description": "Delegate tool for the Pi coding agent.",
|
|
24
5
|
"keywords": [
|
|
25
6
|
"pi-package"
|
|
@@ -28,6 +9,7 @@
|
|
|
28
9
|
"type": "git",
|
|
29
10
|
"url": "git+https://github.com/bermudi/pi-delegate.git"
|
|
30
11
|
},
|
|
12
|
+
"type": "module",
|
|
31
13
|
"files": [
|
|
32
14
|
"*.ts",
|
|
33
15
|
"!*.test.ts",
|
|
@@ -39,5 +21,25 @@
|
|
|
39
21
|
"extensions": [
|
|
40
22
|
"./delegate.ts"
|
|
41
23
|
]
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@earendil-works/pi-agent-core": "^0.80.9",
|
|
27
|
+
"@earendil-works/pi-ai": "^0.80.9",
|
|
28
|
+
"@earendil-works/pi-coding-agent": "^0.80.9",
|
|
29
|
+
"@earendil-works/pi-tui": "^0.80.9",
|
|
30
|
+
"@marcfargas/pi-test-harness": "^0.6.1",
|
|
31
|
+
"esbuild": "^0.27.0",
|
|
32
|
+
"prettier": "^3.8.4",
|
|
33
|
+
"typescript": "^5.9.0"
|
|
34
|
+
},
|
|
35
|
+
"private": false,
|
|
36
|
+
"scripts": {
|
|
37
|
+
"test": "bun test",
|
|
38
|
+
"typecheck": "tsc --noEmit",
|
|
39
|
+
"build": "esbuild delegate.ts --bundle --platform=neutral --packages=external --format=esm --banner:js=\"// @ts-nocheck\" --outfile=.build/delegate.bundle.ts",
|
|
40
|
+
"format": "prettier --write \"**/*.ts\""
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@sinclair/typebox": "0.34.52"
|
|
42
44
|
}
|
|
43
45
|
}
|