@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/pool.ts
CHANGED
|
@@ -97,10 +97,25 @@ let poolAbortTimeoutMs = DEFAULT_POOL_ABORT_TIMEOUT_MS;
|
|
|
97
97
|
* delegate calls with the same sessionId queue instead of interleaving. */
|
|
98
98
|
const sessionLocks = new Map<string, Promise<void>>();
|
|
99
99
|
|
|
100
|
+
type PoolShutdownState = "open" | "closing" | "closed";
|
|
101
|
+
let poolState: PoolShutdownState = "open";
|
|
102
|
+
let closePromise: Promise<void> | null = null;
|
|
103
|
+
|
|
100
104
|
function now(): number {
|
|
101
105
|
return Date.now();
|
|
102
106
|
}
|
|
103
107
|
|
|
108
|
+
function assertPoolOpenForNormalWork(): void {
|
|
109
|
+
if (poolState !== "open") {
|
|
110
|
+
throw new Error("Session pool is not accepting new work.");
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function waitForActiveSessionLocks(): Promise<void> {
|
|
115
|
+
const locks = [...sessionLocks.values()];
|
|
116
|
+
return Promise.all(locks).then(() => undefined);
|
|
117
|
+
}
|
|
118
|
+
|
|
104
119
|
// ── Read + validate ───────────────────────────────────────────────────────
|
|
105
120
|
|
|
106
121
|
/** Look up a pooled session and validate a reuse request against its frozen
|
|
@@ -115,8 +130,8 @@ function now(): number {
|
|
|
115
130
|
* - mismatch → pooled but its immutable configuration conflicts with this
|
|
116
131
|
* request. Caller formats the structured diff into an error.
|
|
117
132
|
*
|
|
118
|
-
* lastUsed is bumped by
|
|
119
|
-
* that bails (e.g. a resumeFrom conflict
|
|
133
|
+
* lastUsed is bumped by recordUse() after a completed pool hit, not here — so
|
|
134
|
+
* a checkout that bails (e.g. a resumeFrom conflict) does not affect stats. */
|
|
120
135
|
export function checkout(
|
|
121
136
|
sessionId: string,
|
|
122
137
|
candidate: ConfigCandidate,
|
|
@@ -187,28 +202,26 @@ export function checkout(
|
|
|
187
202
|
|
|
188
203
|
// ── The sole mutator (besides close) ──────────────────────────────────────
|
|
189
204
|
|
|
190
|
-
/**
|
|
191
|
-
*
|
|
192
|
-
* - present (pool hit) → bump lastUsed, totalTokens, promptCount.
|
|
193
|
-
* - absent (fresh/resume success) → insert with the frozen config.
|
|
205
|
+
/**
|
|
206
|
+
* Insert the first successful prompt for a fresh/resumed session into the pool.
|
|
194
207
|
*
|
|
195
|
-
* MUST be called inside withSessionLock(sessionId, …)
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
* (
|
|
199
|
-
|
|
200
|
-
* lacks the manager/file required by a pooled entry. */
|
|
208
|
+
* MUST be called inside withSessionLock(sessionId, …) and only when this run
|
|
209
|
+
* should transfer ownership from lifecycle to the pool. Returns true when the
|
|
210
|
+
* session is now pool-owned, false when insertion is blocked (shutdown) or
|
|
211
|
+
* impossible (missing manager/file).
|
|
212
|
+
*/
|
|
201
213
|
export function commit(sessionId: string, payload: CommitPayload): boolean {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
existing.promptCount++;
|
|
208
|
-
return true;
|
|
214
|
+
// Shutdown-aware policy: once shutdown has started, inserting a new pool entry
|
|
215
|
+
// is unsafe. The lifecycle handles the still-owned session (abort/cleanup) and
|
|
216
|
+
// should dispose it instead of inserting it after the barrier begins.
|
|
217
|
+
if (poolState !== "open") {
|
|
218
|
+
return false;
|
|
209
219
|
}
|
|
220
|
+
|
|
210
221
|
// Miss → success: insert. A pooled entry needs a concrete file/manager.
|
|
211
222
|
if (!payload.sessionManager || !payload.sessionFile) return false;
|
|
223
|
+
|
|
224
|
+
if (agentPool.has(sessionId)) return false;
|
|
212
225
|
agentPool.set(sessionId, {
|
|
213
226
|
session: payload.session,
|
|
214
227
|
sessionManager: payload.sessionManager,
|
|
@@ -222,6 +235,20 @@ export function commit(sessionId: string, payload: CommitPayload): boolean {
|
|
|
222
235
|
return true;
|
|
223
236
|
}
|
|
224
237
|
|
|
238
|
+
/** Record a completed run against an existing pooled session. This is separate
|
|
239
|
+
* from commit() so lifecycle can distinguish ownership transfer from hit
|
|
240
|
+
* accounting. Returns true only when the sessionId is currently pooled.
|
|
241
|
+
*/
|
|
242
|
+
export function recordUse(sessionId: string, tokens: number): boolean {
|
|
243
|
+
const existing = agentPool.get(sessionId);
|
|
244
|
+
if (!existing) return false;
|
|
245
|
+
|
|
246
|
+
existing.lastUsed = now();
|
|
247
|
+
existing.totalTokens += tokens;
|
|
248
|
+
existing.promptCount++;
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
|
|
225
252
|
// ── Read-only defaults (for task-resolution) ──────────────────────────────
|
|
226
253
|
|
|
227
254
|
/** Frozen config for a pooled session, or undefined if not pooled. Lock-free —
|
|
@@ -242,10 +269,26 @@ export function configFor(
|
|
|
242
269
|
* (lifecycle) can bracket the ENTIRE acquire/run/commit flow; checkout/commit
|
|
243
270
|
* do NOT lock internally because their only caller is already inside this
|
|
244
271
|
* bracket. Close is also invoked under this lock, so it never disposes an
|
|
245
|
-
* in-flight prompt.
|
|
272
|
+
* in-flight prompt.
|
|
273
|
+
*
|
|
274
|
+
* External callers must go through this exported path only while the pool is
|
|
275
|
+
* open. Internal callers that are already synchronized by a higher-level lock
|
|
276
|
+
* can use `withSessionLockInternal`.
|
|
277
|
+
*/
|
|
246
278
|
export async function withSessionLock<T>(
|
|
247
279
|
sessionId: string,
|
|
248
280
|
fn: () => Promise<T>,
|
|
281
|
+
): Promise<T> {
|
|
282
|
+
assertPoolOpenForNormalWork();
|
|
283
|
+
return withSessionLockInternal(sessionId, fn);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Internal lock variant that does not reject during shutdown. Use only from
|
|
287
|
+
* lifecycle/control paths that already account for pool shutdown state.
|
|
288
|
+
*/
|
|
289
|
+
async function withSessionLockInternal<T>(
|
|
290
|
+
sessionId: string,
|
|
291
|
+
fn: () => Promise<T>,
|
|
249
292
|
): Promise<T> {
|
|
250
293
|
const prev = sessionLocks.get(sessionId);
|
|
251
294
|
let resolve!: () => void;
|
|
@@ -303,8 +346,8 @@ function beginAbort(session: AgentSession): Promise<AbortOutcome> {
|
|
|
303
346
|
}
|
|
304
347
|
|
|
305
348
|
/** Close and dispose one pooled session. A caller holds the per-session lock
|
|
306
|
-
*
|
|
307
|
-
*
|
|
349
|
+
* while closing, so abort cannot race a reuse. All cleanup is attempted before
|
|
350
|
+
* an error is surfaced; a removed session is never silently retained. */
|
|
308
351
|
async function closePooledAgentAfterAbort(
|
|
309
352
|
sessionId: string,
|
|
310
353
|
abort: Promise<AbortOutcome>,
|
|
@@ -346,44 +389,117 @@ async function closePooledAgentAfterAbort(
|
|
|
346
389
|
return true;
|
|
347
390
|
}
|
|
348
391
|
|
|
349
|
-
/**
|
|
350
|
-
*
|
|
351
|
-
|
|
392
|
+
/** Internal close helper for callers that already own (or are obtaining) the
|
|
393
|
+
* session lock. */
|
|
394
|
+
async function closePooledAgentAfterLock(sessionId: string): Promise<boolean> {
|
|
352
395
|
const pooled = agentPool.get(sessionId);
|
|
353
396
|
if (!pooled) return false;
|
|
354
397
|
return closePooledAgentAfterAbort(sessionId, beginAbort(pooled.session));
|
|
355
398
|
}
|
|
356
399
|
|
|
400
|
+
/** @internal Close while the caller already owns the per-session lock. */
|
|
401
|
+
export async function _closePooledAgentWithoutLock(
|
|
402
|
+
sessionId: string,
|
|
403
|
+
): Promise<boolean> {
|
|
404
|
+
return closePooledAgentAfterLock(sessionId);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Abort, dispose, and remove one pooled session. Returns false when the id
|
|
408
|
+
* is already absent; cleanup failures are aggregated after removal.
|
|
409
|
+
* During shutdown or after close, this waits for shutdown to finish and
|
|
410
|
+
* returns `false` instead of throwing.
|
|
411
|
+
*/
|
|
412
|
+
export async function closePooledAgent(sessionId: string): Promise<boolean> {
|
|
413
|
+
if (poolState !== "open") {
|
|
414
|
+
const existing = closePromise;
|
|
415
|
+
if (existing) {
|
|
416
|
+
try {
|
|
417
|
+
await existing;
|
|
418
|
+
} catch {
|
|
419
|
+
// Keep public close idempotent during and after shutdown.
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
let awaitShutdown: Promise<void> | undefined;
|
|
426
|
+
let closed = false;
|
|
427
|
+
|
|
428
|
+
await withSessionLockInternal(sessionId, async () => {
|
|
429
|
+
// A shutdown can begin while this call waits for its lock. Avoid waiting
|
|
430
|
+
// for closePromise inside this lock: do that work after releasing it, so
|
|
431
|
+
// closeAll can acquire the lock and dispose the same session.
|
|
432
|
+
if (poolState !== "open") {
|
|
433
|
+
awaitShutdown = closePromise ?? undefined;
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
closed = await closePooledAgentAfterLock(sessionId);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
if (awaitShutdown) {
|
|
440
|
+
try {
|
|
441
|
+
await awaitShutdown;
|
|
442
|
+
} catch {
|
|
443
|
+
// Keep public close idempotent during and after shutdown.
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return closed;
|
|
448
|
+
}
|
|
449
|
+
|
|
357
450
|
/** Dispose every live pooled session when the parent Pi session ends. First
|
|
358
|
-
* request cancellation immediately, then
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
*
|
|
451
|
+
* request cancellation immediately, then wait for all in-flight session locks,
|
|
452
|
+
* then acquire each remaining session's lock before disposal. Attempts are
|
|
453
|
+
* executed for all sessions before reporting failures. Idempotent callers share
|
|
454
|
+
* the same completion promise, including failures.
|
|
455
|
+
*/
|
|
362
456
|
export async function closeAllPooledAgents(): Promise<void> {
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
457
|
+
if (closePromise) {
|
|
458
|
+
return closePromise;
|
|
459
|
+
}
|
|
460
|
+
if (poolState === "closed") {
|
|
461
|
+
const completed = Promise.resolve();
|
|
462
|
+
closePromise = completed;
|
|
463
|
+
return completed;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
poolState = "closing";
|
|
467
|
+
|
|
468
|
+
const completion = (async () => {
|
|
469
|
+
const aborts = new Map<string, Promise<AbortOutcome>>(
|
|
470
|
+
[...agentPool].map(([sessionId, pooled]) => [
|
|
471
|
+
sessionId,
|
|
472
|
+
beginAbort(pooled.session),
|
|
473
|
+
]),
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
await waitForActiveSessionLocks();
|
|
477
|
+
|
|
478
|
+
const results = await Promise.allSettled(
|
|
479
|
+
[...aborts].map(([sessionId, abort]) =>
|
|
480
|
+
withSessionLockInternal(sessionId, () =>
|
|
481
|
+
closePooledAgentAfterAbort(sessionId, abort),
|
|
482
|
+
),
|
|
373
483
|
),
|
|
374
|
-
),
|
|
375
|
-
);
|
|
376
|
-
const failures = results
|
|
377
|
-
.filter(
|
|
378
|
-
(result): result is PromiseRejectedResult => result.status === "rejected",
|
|
379
|
-
)
|
|
380
|
-
.map((result) => result.reason);
|
|
381
|
-
if (failures.length) {
|
|
382
|
-
throw new AggregateError(
|
|
383
|
-
failures,
|
|
384
|
-
"Failed to close one or more pooled sessions.",
|
|
385
484
|
);
|
|
386
|
-
|
|
485
|
+
const failures = results
|
|
486
|
+
.filter(
|
|
487
|
+
(result): result is PromiseRejectedResult =>
|
|
488
|
+
result.status === "rejected",
|
|
489
|
+
)
|
|
490
|
+
.map((result) => result.reason);
|
|
491
|
+
if (failures.length) {
|
|
492
|
+
throw new AggregateError(
|
|
493
|
+
failures,
|
|
494
|
+
"Failed to close one or more pooled sessions.",
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
})();
|
|
498
|
+
|
|
499
|
+
closePromise = completion.finally(() => {
|
|
500
|
+
poolState = "closed";
|
|
501
|
+
});
|
|
502
|
+
return closePromise;
|
|
387
503
|
}
|
|
388
504
|
|
|
389
505
|
/** List live pooled agents. Sessions remain available until explicit close or
|
|
@@ -416,5 +532,7 @@ export function _setPoolAbortTimeoutForTesting(
|
|
|
416
532
|
export function _resetPoolForTesting(): void {
|
|
417
533
|
agentPool.clear();
|
|
418
534
|
sessionLocks.clear();
|
|
535
|
+
poolState = "open";
|
|
536
|
+
closePromise = null;
|
|
419
537
|
poolAbortTimeoutMs = DEFAULT_POOL_ABORT_TIMEOUT_MS;
|
|
420
538
|
}
|
package/render-branches.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Markdown } from "@earendil-works/pi-tui";
|
|
1
|
+
import { Markdown, type MarkdownTheme } from "@earendil-works/pi-tui";
|
|
2
2
|
import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import {
|
|
4
4
|
fmtDuration,
|
|
@@ -17,6 +17,25 @@ import { stripAnsi, resolveCarriageReturn } from "./utils.ts";
|
|
|
17
17
|
import { getMaxConcurrent } from "./config.ts";
|
|
18
18
|
import type { TaskProgress, TaskResult } from "./types.ts";
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Render markdown output when a compatible host theme hook exists. If the host
|
|
22
|
+
* does not expose `getMarkdownTheme`, return plain text lines as a safe fallback
|
|
23
|
+
* so a single missing host hook cannot crash the renderer.
|
|
24
|
+
*/
|
|
25
|
+
function renderOutputLines(raw: string, width: number): string[] {
|
|
26
|
+
const trimmed = raw.trim();
|
|
27
|
+
if (!trimmed) return [];
|
|
28
|
+
try {
|
|
29
|
+
if (typeof getMarkdownTheme !== "function") return trimmed.split("\n");
|
|
30
|
+
const theme = getMarkdownTheme();
|
|
31
|
+
if (typeof theme !== "object" || !theme) return trimmed.split("\n");
|
|
32
|
+
const md = new Markdown(trimmed, 0, 0, theme as MarkdownTheme);
|
|
33
|
+
return md.render(width);
|
|
34
|
+
} catch (_error) {
|
|
35
|
+
return trimmed.split("\n");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
20
39
|
/** Renderer state — the live subset of Pi's `ToolRenderContext.state`. */
|
|
21
40
|
export interface RenderState {
|
|
22
41
|
startedAt?: number;
|
|
@@ -365,8 +384,7 @@ export function renderFinalBranch(ctx: BranchCtx, h: RenderHelpers): void {
|
|
|
365
384
|
let mdLines: string[] | undefined = state[cacheKey] as
|
|
366
385
|
string[] | undefined;
|
|
367
386
|
if (!mdLines || state[`${cacheKey}_src`] !== r.output) {
|
|
368
|
-
|
|
369
|
-
mdLines = md.render(Math.max(20, w - ind.length));
|
|
387
|
+
mdLines = renderOutputLines(r.output, Math.max(20, w - ind.length));
|
|
370
388
|
state[`${cacheKey}_src`] = r.output;
|
|
371
389
|
state[cacheKey] = mdLines;
|
|
372
390
|
}
|
package/runner.ts
CHANGED
|
@@ -296,7 +296,8 @@ export async function runAgentSession(
|
|
|
296
296
|
const initialMessages = session.messages;
|
|
297
297
|
const initialMessageCount = initialMessages.length;
|
|
298
298
|
const initialMessageSnapshot = initialMessages.slice();
|
|
299
|
-
let
|
|
299
|
+
let compactionInProgress = false;
|
|
300
|
+
let completedCompaction = false;
|
|
300
301
|
const assistantMessagesForAttempt: AgentMessage[] = [];
|
|
301
302
|
let partialAssistantMessage: AgentMessage | undefined;
|
|
302
303
|
type AttemptCapture = {
|
|
@@ -426,11 +427,13 @@ export async function runAgentSession(
|
|
|
426
427
|
// Keep an append-only fallback for providers/fakes that reject before
|
|
427
428
|
// emitting message_end. It is deliberately used only when event capture
|
|
428
429
|
// is empty; event capture is authoritative across compaction and retries.
|
|
429
|
-
//
|
|
430
|
-
//
|
|
430
|
+
// An aborted compaction may leave the original append-only transcript
|
|
431
|
+
// untouched. Completed or indeterminate compaction remains fail-closed even
|
|
432
|
+
// if a host happened to preserve the array identity.
|
|
431
433
|
const currentMessages = session.messages;
|
|
432
434
|
if (
|
|
433
|
-
|
|
435
|
+
compactionInProgress ||
|
|
436
|
+
completedCompaction ||
|
|
434
437
|
currentMessages !== initialMessages ||
|
|
435
438
|
currentMessages.length < initialMessageCount
|
|
436
439
|
) {
|
|
@@ -525,17 +528,23 @@ export async function runAgentSession(
|
|
|
525
528
|
// delay before ordinary inactivity detection resumes.
|
|
526
529
|
noteActivity("waiting to retry", event.delayMs);
|
|
527
530
|
break;
|
|
528
|
-
case "auto_retry_end":
|
|
531
|
+
case "auto_retry_end": {
|
|
532
|
+
const autoRetry = event as {
|
|
533
|
+
success?: unknown;
|
|
534
|
+
};
|
|
535
|
+
if (autoRetry.success === false && pendingCompactionAttempt) {
|
|
536
|
+
pendingCompactionAttempt.omitFinalAssistant = false;
|
|
537
|
+
}
|
|
529
538
|
noteActivity("waiting for model output");
|
|
530
539
|
break;
|
|
540
|
+
}
|
|
531
541
|
case "compaction_start":
|
|
532
|
-
|
|
533
|
-
// its array, historical messages are no longer a safe fallback source.
|
|
534
|
-
transcriptMayHaveBeenReplaced = true;
|
|
542
|
+
compactionInProgress = true;
|
|
535
543
|
noteActivity("compacting context");
|
|
536
544
|
break;
|
|
537
545
|
case "compaction_end":
|
|
538
|
-
|
|
546
|
+
compactionInProgress = false;
|
|
547
|
+
if (!event.aborted) completedCompaction = true;
|
|
539
548
|
// Context-overflow agent_end is intentionally emitted with
|
|
540
549
|
// willRetry=false because Pi's retry decision belongs to compaction.
|
|
541
550
|
// Only an overflow compaction that actually retries may retract that
|
package/schema.ts
CHANGED
|
@@ -26,7 +26,8 @@ export const delegateTaskSchema = Type.Object({
|
|
|
26
26
|
),
|
|
27
27
|
agent: Type.Optional(
|
|
28
28
|
Type.String({
|
|
29
|
-
description:
|
|
29
|
+
description:
|
|
30
|
+
"Use `default`: parent model/thinking/native tools/base prompt. Omit=ad-hoc; unknown fails call.",
|
|
30
31
|
}),
|
|
31
32
|
),
|
|
32
33
|
cwd: Type.Optional(
|
|
@@ -54,19 +55,19 @@ export const delegateTaskSchema = Type.Object({
|
|
|
54
55
|
tools: Type.Optional(
|
|
55
56
|
Type.Array(Type.String(), {
|
|
56
57
|
description:
|
|
57
|
-
"
|
|
58
|
+
"`default`=parent natives; ad-hoc=*; *=read/write/edit/bash (mutating); ro=read/grep/find/ls (read-only).",
|
|
58
59
|
}),
|
|
59
60
|
),
|
|
60
61
|
thinking: Type.Optional(
|
|
61
62
|
StringEnum(VALID_THINKING_LEVELS, {
|
|
62
63
|
description:
|
|
63
|
-
"Thinking: off/minimal/low/medium/high/xhigh/max;
|
|
64
|
+
"Thinking: off/minimal/low/medium/high/xhigh/max; default=parent; others=agent/off.",
|
|
64
65
|
}),
|
|
65
66
|
),
|
|
66
67
|
sessionId: Type.Optional(
|
|
67
68
|
Type.String({
|
|
68
69
|
description:
|
|
69
|
-
"
|
|
70
|
+
"Live pool key for multi-turn reuse; omit for one-shot tasks.",
|
|
70
71
|
}),
|
|
71
72
|
),
|
|
72
73
|
action: Type.Optional(
|
|
@@ -90,13 +91,13 @@ export const delegateArgumentsSchema = Type.Object({
|
|
|
90
91
|
action: Type.Optional(
|
|
91
92
|
StringEnum(["poll", "cancel", "wait"], {
|
|
92
93
|
description:
|
|
93
|
-
"Ticket control: poll
|
|
94
|
+
"Ticket control: poll=snapshot; wait=block until settled; cancel=abort. Prefer wait; never cancel for time.",
|
|
94
95
|
}),
|
|
95
96
|
),
|
|
96
97
|
async: Type.Optional(
|
|
97
98
|
Type.Boolean({
|
|
98
99
|
description:
|
|
99
|
-
"Detach work
|
|
100
|
+
"Detach work, return a ticket; applies to ALL tasks. Results auto-deliver. Wait only if blocked.",
|
|
100
101
|
default: false,
|
|
101
102
|
}),
|
|
102
103
|
),
|
|
@@ -116,7 +117,7 @@ export const delegateArgumentsSchema = Type.Object({
|
|
|
116
117
|
Type.Number({
|
|
117
118
|
minimum: 0,
|
|
118
119
|
description:
|
|
119
|
-
"
|
|
120
|
+
"Bounds wait (ms); omit to block until settled. Timeout never cancels the ticket.",
|
|
120
121
|
}),
|
|
121
122
|
),
|
|
122
123
|
tasks: Type.Optional(
|
|
@@ -128,6 +129,28 @@ export const delegateArgumentsSchema = Type.Object({
|
|
|
128
129
|
),
|
|
129
130
|
});
|
|
130
131
|
|
|
132
|
+
/** Fields that belong to a task entry. Models sometimes place these at the
|
|
133
|
+
* top level of the arguments; the shim folds them back into a single task. */
|
|
134
|
+
const TASK_FIELD_NAMES = [
|
|
135
|
+
"prompt",
|
|
136
|
+
"agent",
|
|
137
|
+
"cwd",
|
|
138
|
+
"systemPrompt",
|
|
139
|
+
"context",
|
|
140
|
+
"model",
|
|
141
|
+
"tools",
|
|
142
|
+
"thinking",
|
|
143
|
+
"sessionId",
|
|
144
|
+
"resumeFrom",
|
|
145
|
+
] as const;
|
|
146
|
+
|
|
147
|
+
/** Every field a task entry may carry. Anything else is a model mistake —
|
|
148
|
+
* e.g. `async` placed inside a task — and must fail loudly with a
|
|
149
|
+
* corrective message instead of being silently ignored (observed in the
|
|
150
|
+
* wild: a task-level `async: true` the caller believed had backgrounded
|
|
151
|
+
* the work while the call in fact ran synchronously). */
|
|
152
|
+
const VALID_TASK_KEYS = new Set<string>([...TASK_FIELD_NAMES, "action"]);
|
|
153
|
+
|
|
131
154
|
/** Validate the three operation modes after compatibility reshaping. */
|
|
132
155
|
export function validateDelegateOperation(
|
|
133
156
|
params: DelegateArguments,
|
|
@@ -165,6 +188,20 @@ export function validateDelegateOperation(
|
|
|
165
188
|
}
|
|
166
189
|
|
|
167
190
|
for (const [index, task] of tasks.entries()) {
|
|
191
|
+
const unknownKeys = Object.keys(task).filter(
|
|
192
|
+
(key) => !VALID_TASK_KEYS.has(key),
|
|
193
|
+
);
|
|
194
|
+
if (unknownKeys.length) {
|
|
195
|
+
const asyncHint = unknownKeys.includes("async")
|
|
196
|
+
? " 'async' is a top-level flag; move it out of the task entry."
|
|
197
|
+
: "";
|
|
198
|
+
return (
|
|
199
|
+
`task ${index + 1}: unknown field(s) ${unknownKeys
|
|
200
|
+
.map((key) => `'${key}'`)
|
|
201
|
+
.join(", ")}.${asyncHint} ` +
|
|
202
|
+
`Valid task fields: ${[...VALID_TASK_KEYS].join(", ")}.`
|
|
203
|
+
);
|
|
204
|
+
}
|
|
168
205
|
if (task.action === "close") {
|
|
169
206
|
if (!task.sessionId) {
|
|
170
207
|
return `task ${index + 1}: action 'close' requires sessionId.`;
|
|
@@ -187,21 +224,6 @@ export function validateDelegateOperation(
|
|
|
187
224
|
return undefined;
|
|
188
225
|
}
|
|
189
226
|
|
|
190
|
-
/** Fields that belong to a task entry. Models sometimes place these at the
|
|
191
|
-
* top level of the arguments; the shim folds them back into a single task. */
|
|
192
|
-
const TASK_FIELD_NAMES = [
|
|
193
|
-
"prompt",
|
|
194
|
-
"agent",
|
|
195
|
-
"cwd",
|
|
196
|
-
"systemPrompt",
|
|
197
|
-
"context",
|
|
198
|
-
"model",
|
|
199
|
-
"tools",
|
|
200
|
-
"thinking",
|
|
201
|
-
"sessionId",
|
|
202
|
-
"resumeFrom",
|
|
203
|
-
] as const;
|
|
204
|
-
|
|
205
227
|
/** Actions that are only valid at task level. The top-level `action` is
|
|
206
228
|
* ticket-scoped (poll/cancel/wait), so a flat close/list/prompt belongs to
|
|
207
229
|
* the wrapped task. */
|
|
@@ -233,7 +255,8 @@ function normalizeToolsField(value: string): unknown {
|
|
|
233
255
|
* - `tasks` as a JSON string instead of an array;
|
|
234
256
|
* - task fields (`prompt`, `systemPrompt`, `tools`, ...) placed at the top
|
|
235
257
|
* level instead of inside a `tasks` entry — wrapped into a single task;
|
|
236
|
-
* - `tools` as a JSON string (or bare token) inside a task entry
|
|
258
|
+
* - `tools` as a JSON string (or bare token) inside a task entry;
|
|
259
|
+
* - `agent: ""` inside a task entry — treated as omitted (ad-hoc).
|
|
237
260
|
* Skipped when a ticket action is in play. All other invalid input is left
|
|
238
261
|
* for normal schema validation to reject loudly.
|
|
239
262
|
*
|
|
@@ -275,13 +298,22 @@ export function normalizeDelegateArguments(args: unknown): DelegateArguments {
|
|
|
275
298
|
if (Object.keys(task).length > 0) record.tasks = [task];
|
|
276
299
|
}
|
|
277
300
|
|
|
278
|
-
//
|
|
301
|
+
// Per-entry recovery: stringified (or bare-token) `tools` → real arrays,
|
|
302
|
+
// and `agent: ""` → omitted (models emit an empty string to mean "ad-hoc";
|
|
303
|
+
// without this it only works by truthiness accident downstream).
|
|
279
304
|
if (Array.isArray(record.tasks)) {
|
|
280
305
|
record.tasks = record.tasks.map((entry: unknown) => {
|
|
281
306
|
if (!entry || typeof entry !== "object") return entry;
|
|
282
307
|
const e = entry as Record<string, unknown>;
|
|
283
|
-
|
|
284
|
-
|
|
308
|
+
const rawTools = e.tools;
|
|
309
|
+
const fixAgent = e.agent === "";
|
|
310
|
+
if (typeof rawTools !== "string" && !fixAgent) return entry;
|
|
311
|
+
const out = { ...e };
|
|
312
|
+
if (typeof rawTools === "string") {
|
|
313
|
+
out.tools = normalizeToolsField(rawTools);
|
|
314
|
+
}
|
|
315
|
+
if (fixAgent) delete out.agent;
|
|
316
|
+
return out;
|
|
285
317
|
});
|
|
286
318
|
}
|
|
287
319
|
|