@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/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 commit() on a successful run, not here — so a checkout
119
- * that bails (e.g. a resumeFrom conflict at the caller) does not affect stats. */
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
- /** Record the outcome of a run against a sessionId. Decides insert-vs-recordUse
191
- * internally by map presence:
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, …) the map-presence
196
- * decision is sound only because the lock serializes same-sessionId tasks, so
197
- * no concurrent commit can race the insert. MUST only be called on run success
198
- * (insert-only-on-success is caller-gated). Returns true when the session is
199
- * now pool-owned, false when a fresh session could not be inserted because it
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
- const existing = agentPool.get(sessionId);
203
- if (existing) {
204
- // Pool hit: session already pooled, just bump stats.
205
- existing.lastUsed = now();
206
- existing.totalTokens += payload.tokens;
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
- * while closing, so abort cannot race a reuse. All cleanup is attempted before
307
- * an error is surfaced; a removed session is never silently retained. */
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
- /** Abort, dispose, and remove one pooled session. Returns false when the id
350
- * is already absent; cleanup failures are aggregated after removal. */
351
- export async function closePooledAgent(sessionId: string): Promise<boolean> {
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 acquire each session's lock before
359
- * disposal. The lock prevents an in-flight lifecycle from committing a live
360
- * session after shutdown has removed it. Attempts all cleanup before reporting
361
- * any failures. */
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
- const aborts = new Map<string, Promise<AbortOutcome>>(
364
- [...agentPool].map(([sessionId, pooled]) => [
365
- sessionId,
366
- beginAbort(pooled.session),
367
- ]),
368
- );
369
- const results = await Promise.allSettled(
370
- [...aborts].map(([sessionId, abort]) =>
371
- withSessionLock(sessionId, () =>
372
- closePooledAgentAfterAbort(sessionId, abort),
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
  }
@@ -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
- const md = new Markdown(r.output.trim(), 0, 0, getMarkdownTheme());
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 transcriptMayHaveBeenReplaced = false;
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
- // Requiring the original array and unchanged historical prefix prevents a
430
- // replacement/compaction transcript from leaking old assistant output.
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
- transcriptMayHaveBeenReplaced ||
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
- // Even if a host mutates the transcript in place rather than replacing
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
- transcriptMayHaveBeenReplaced = true;
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: "Named agent profile; omit for an ad-hoc subagent.",
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
- "Omit to inherit; `*`=read/write/edit/bash (mutating); `ro`=read/grep/find/ls (read-only).",
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; defaults to agent/off.",
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
- "Optional live pool key for multi-turn reuse; omit for one-shot tasks.",
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, cancel, or wait. Prefer wait; do not cancel for time.",
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 and return a ticket; results auto-deliver. Wait only when blocked.",
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
- "How long wait blocks (ms); timeout does not cancel the ticket.",
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
- // Stringified (or bare-token) `tools` inside task entries → real arrays.
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
- if (typeof e.tools !== "string") return entry;
284
- return { ...e, tools: normalizeToolsField(e.tools) };
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