@polpo-ai/node 0.15.143 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/adapters/composite-spawner.d.ts +1 -0
  2. package/dist/adapters/composite-spawner.d.ts.map +1 -1
  3. package/dist/adapters/composite-spawner.js +3 -0
  4. package/dist/adapters/composite-spawner.js.map +1 -1
  5. package/dist/adapters/in-process-spawner.d.ts +10 -2
  6. package/dist/adapters/in-process-spawner.d.ts.map +1 -1
  7. package/dist/adapters/in-process-spawner.js +9 -7
  8. package/dist/adapters/in-process-spawner.js.map +1 -1
  9. package/dist/adapters/loop-engine.d.ts.map +1 -1
  10. package/dist/adapters/loop-engine.js +510 -491
  11. package/dist/adapters/loop-engine.js.map +1 -1
  12. package/dist/adapters/node-spawner.d.ts +2 -0
  13. package/dist/adapters/node-spawner.d.ts.map +1 -1
  14. package/dist/adapters/node-spawner.js +15 -0
  15. package/dist/adapters/node-spawner.js.map +1 -1
  16. package/dist/adapters/spawn-helpers.d.ts +8 -2
  17. package/dist/adapters/spawn-helpers.d.ts.map +1 -1
  18. package/dist/adapters/spawn-helpers.js +33 -13
  19. package/dist/adapters/spawn-helpers.js.map +1 -1
  20. package/dist/core/deadlock-resolver.d.ts +8 -10
  21. package/dist/core/deadlock-resolver.d.ts.map +1 -1
  22. package/dist/core/deadlock-resolver.js +125 -132
  23. package/dist/core/deadlock-resolver.js.map +1 -1
  24. package/dist/core/index.d.ts +1 -1
  25. package/dist/core/index.d.ts.map +1 -1
  26. package/dist/core/index.js +1 -1
  27. package/dist/core/index.js.map +1 -1
  28. package/dist/core/orchestrator.d.ts +8 -4
  29. package/dist/core/orchestrator.d.ts.map +1 -1
  30. package/dist/core/orchestrator.js +43 -79
  31. package/dist/core/orchestrator.js.map +1 -1
  32. package/dist/core/run-lifecycle.d.ts +11 -1
  33. package/dist/core/run-lifecycle.d.ts.map +1 -1
  34. package/dist/core/run-lifecycle.js +197 -44
  35. package/dist/core/run-lifecycle.js.map +1 -1
  36. package/dist/core/runner-capability-bootstrap.d.ts +19 -0
  37. package/dist/core/runner-capability-bootstrap.d.ts.map +1 -0
  38. package/dist/core/runner-capability-bootstrap.js +98 -0
  39. package/dist/core/runner-capability-bootstrap.js.map +1 -0
  40. package/dist/core/runner.js +10 -0
  41. package/dist/core/runner.js.map +1 -1
  42. package/dist/custom-tools/runtime.d.ts +1 -0
  43. package/dist/custom-tools/runtime.d.ts.map +1 -1
  44. package/dist/custom-tools/runtime.js +1 -0
  45. package/dist/custom-tools/runtime.js.map +1 -1
  46. package/dist/data/index.d.ts +17 -0
  47. package/dist/data/index.d.ts.map +1 -0
  48. package/dist/data/index.js +125 -0
  49. package/dist/data/index.js.map +1 -0
  50. package/dist/index.d.ts +1 -0
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +1 -0
  53. package/dist/index.js.map +1 -1
  54. package/dist/sandbox/lease.d.ts +7 -0
  55. package/dist/sandbox/lease.d.ts.map +1 -1
  56. package/dist/sandbox/lease.js +35 -5
  57. package/dist/sandbox/lease.js.map +1 -1
  58. package/dist/server/app.d.ts +6 -1
  59. package/dist/server/app.d.ts.map +1 -1
  60. package/dist/server/app.js +39 -14
  61. package/dist/server/app.js.map +1 -1
  62. package/dist/server/deps.d.ts +5 -4
  63. package/dist/server/deps.d.ts.map +1 -1
  64. package/dist/server/deps.js +0 -7
  65. package/dist/server/deps.js.map +1 -1
  66. package/dist/server/ephemeral-run-store.d.ts +2 -1
  67. package/dist/server/ephemeral-run-store.d.ts.map +1 -1
  68. package/dist/server/ephemeral-run-store.js +14 -1
  69. package/dist/server/ephemeral-run-store.js.map +1 -1
  70. package/dist/server/index.d.ts +1 -1
  71. package/dist/server/index.d.ts.map +1 -1
  72. package/dist/server/index.js +6 -2
  73. package/dist/server/index.js.map +1 -1
  74. package/dist/server/types.d.ts +3 -0
  75. package/dist/server/types.d.ts.map +1 -1
  76. package/package.json +9 -9
@@ -188,6 +188,7 @@ export function spawnLoopEngine(agentConfig, task, cwd, ctx) {
188
188
  const start = Date.now();
189
189
  let alive = true;
190
190
  const abortController = new AbortController();
191
+ const toolCleanups = new Set();
191
192
  const steering = ctx?.steering
192
193
  ?? (ctx?.resumeState?.steering
193
194
  ? InMemorySteeringController.fromSnapshot(ctx.resumeState.steering)
@@ -321,549 +322,562 @@ export function spawnLoopEngine(agentConfig, task, cwd, ctx) {
321
322
  */
322
323
  async function runLoopSession(options) {
323
324
  const { sessionAgent, loopName, loop, contextPrompt } = options;
324
- const resume = usableResumeState(options.resume, loopName);
325
- const inject = ctx?.inject;
326
- // Inject path (chat-via-executeRun, F1c): use the completions route's
327
- // already-resolved model/prompt/tools/messages instead of re-resolving from
328
- // the AgentConfig — parity by construction. Every branch below is
329
- // inject-gated so the task path stays byte-identical.
330
- let model;
331
- let providerOptions;
332
- let systemPrompt;
333
- let maxTurns;
334
- let toolSet;
335
- let toolByName = new Map();
336
- // Tools value handed to compaction's token estimator (JSON.stringify'd); MUST
337
- // match the chat path's shape so the compaction trigger point is identical.
338
- let compactionTools;
339
- let resolvedModelSelection;
340
- const compactionMode = inject?.compactionMode ?? "task";
341
- if (inject) {
342
- model = inject.model;
343
- providerOptions = inject.providerOptions;
344
- systemPrompt = inject.systemPrompt;
345
- maxTurns = inject.maxTurns;
346
- toolSet = inject.toolSet;
347
- compactionTools = inject.compactionTools;
348
- }
349
- else {
350
- const prep = prepareSpawn(sessionAgent, cwd, ctx);
351
- model = prep.model;
352
- resolvedModelSelection = prep.modelSelection;
353
- providerOptions = prep.providerOptions;
354
- systemPrompt = contextPrompt
355
- ? `${prep.systemPrompt}\n\n${contextPrompt}`
356
- : prep.systemPrompt;
357
- maxTurns = loop.maxTurns ?? prep.maxTurns;
358
- const allPolpoTools = await buildAgentTools(sessionAgent, cwd, prep, ctx);
359
- toolByName = new Map(allPolpoTools.map((t) => [t.name, t]));
360
- toolSet = toToolDeclarations(allPolpoTools);
361
- compactionTools = allPolpoTools.map((t) => ({ description: t.description ?? "" }));
362
- }
363
- const primaryResolved = { model, providerOptions };
364
- const modelSelection = inject?.modelSelection ??
365
- resolvedModelSelection ??
366
- modelSelectionForResolvedModel(model);
367
- const contextTrust = inject?.contextTrust ?? ctx?.contextTrust ?? "off";
368
- const outputSchema = loop.output?.schema;
369
- const modelOutput = inject?.output ?? (outputSchema !== undefined
370
- ? modelOutputForJsonSchema(outputSchema, `loop_${loopName}_output`)
371
- : undefined);
372
- const hasStructuredOutput = modelOutput !== undefined;
373
- // Conversation state owned by the host, exactly like the legacy loop.
374
- // On resume the recorded history (already containing tool-call and
375
- // tool-result pairs from completed turns) replaces the fresh prompt —
376
- // side-effects replay from recorded results, they never re-execute.
377
- let messages = normalizeResponseMessagesForHistory(inject
378
- ? [...inject.seedMessages]
379
- : resume
380
- ? [...resume.history]
381
- : [{
382
- role: "user",
383
- content: options.initialUserMessage ?? buildPrompt(task, ctx?.promptContextSegments, ctx?.contextTrust),
384
- }]);
385
- if (contextTrust === "enforce") {
386
- messages = protectRuntimeToolResultMessages(messages);
387
- }
388
- let lastStepText = "";
389
- let accumText = resume?.accumText ?? "";
390
- let completedStructuredOutput = false;
391
- const providerToolResults = new Map();
392
- const renderProviderResult = (value) => {
393
- if (typeof value === "string")
394
- return value;
395
- if (value === undefined)
396
- return "";
397
- try {
398
- return JSON.stringify(value);
399
- }
400
- catch {
401
- return String(value);
402
- }
403
- };
404
- const summarize = async (msgs, compactionPrompt) => {
405
- const response = await generateText({
406
- model: model.aiModel,
407
- system: compactionPrompt,
408
- messages: prepareModelMessagesForTransport(msgs, model.aiModel),
409
- maxOutputTokens: model.maxTokens,
410
- abortSignal: abortController.signal,
411
- providerOptions,
412
- });
413
- return response.text;
414
- };
415
- // ── LoopRunner model callback: one model turn per runner step ──
416
- const modelStep = async () => {
417
- // Abort between turns mirrors the legacy `if (aborted) break`:
418
- // report no tool calls so the runner stops cleanly (exit 0).
419
- if (abortController.signal.aborted) {
420
- return { text: "", toolCalls: [] };
325
+ let sessionCleanup;
326
+ try {
327
+ const resume = usableResumeState(options.resume, loopName);
328
+ const inject = ctx?.inject;
329
+ // Inject path (chat-via-executeRun, F1c): use the completions route's
330
+ // already-resolved model/prompt/tools/messages instead of re-resolving from
331
+ // the AgentConfig — parity by construction. Every branch below is
332
+ // inject-gated so the task path stays byte-identical.
333
+ let model;
334
+ let providerOptions;
335
+ let systemPrompt;
336
+ let maxTurns;
337
+ let toolSet;
338
+ let toolByName = new Map();
339
+ // Tools value handed to compaction's token estimator (JSON.stringify'd); MUST
340
+ // match the chat path's shape so the compaction trigger point is identical.
341
+ let compactionTools;
342
+ let resolvedModelSelection;
343
+ const compactionMode = inject?.compactionMode ?? "task";
344
+ if (inject) {
345
+ model = inject.model;
346
+ providerOptions = inject.providerOptions;
347
+ systemPrompt = inject.systemPrompt;
348
+ maxTurns = inject.maxTurns;
349
+ toolSet = inject.toolSet;
350
+ compactionTools = inject.compactionTools;
421
351
  }
422
- // Context compaction — same call, same config, same quirks as the
423
- // legacy engine (see engine-behavior characterization tests).
424
- const compactionResult = await compactIfNeeded({
425
- systemPrompt,
426
- messages,
427
- tools: (inject?.activeCompactionTools?.() ?? compactionTools),
428
- config: {
429
- contextWindow: model.contextWindow ?? 200_000,
430
- maxOutputTokens: model.maxTokens ?? 8192,
431
- },
432
- summarize,
433
- mode: compactionMode,
434
- onCompaction: (event) => {
435
- emitTranscript({
436
- type: "compaction",
437
- phase: event.phase,
438
- tokensBefore: event.tokensBefore,
439
- tokensAfter: event.tokensAfter,
440
- tokensReclaimed: event.tokensReclaimed,
441
- messagesBefore: event.messagesBefore,
442
- messagesAfter: event.messagesAfter,
443
- toolOutputsPruned: event.toolOutputsPruned,
444
- summary: event.summary,
445
- });
446
- },
447
- });
448
- if (compactionResult.compacted) {
449
- messages = normalizeResponseMessagesForHistory(compactionResult.messages);
352
+ else {
353
+ const prep = prepareSpawn(sessionAgent, cwd, ctx);
354
+ model = prep.model;
355
+ resolvedModelSelection = prep.modelSelection;
356
+ providerOptions = prep.providerOptions;
357
+ systemPrompt = contextPrompt
358
+ ? `${prep.systemPrompt}\n\n${contextPrompt}`
359
+ : prep.systemPrompt;
360
+ maxTurns = loop.maxTurns ?? prep.maxTurns;
361
+ const resolved = await buildAgentTools(sessionAgent, cwd, prep, ctx, { signal: abortController.signal,
362
+ policy: { loop: options.projectAllowedTools, step: loop.allowedTools ?? loop.tools } });
363
+ toolCleanups.add(resolved.cleanup);
364
+ sessionCleanup = resolved.cleanup;
365
+ const allPolpoTools = resolved.tools;
366
+ toolByName = new Map(allPolpoTools.map((t) => [t.name, t]));
367
+ toolSet = toToolDeclarations(allPolpoTools);
368
+ compactionTools = allPolpoTools.map((t) => ({ description: t.description ?? "" }));
450
369
  }
451
- messages = normalizeResponseMessagesForHistory(messages);
370
+ const primaryResolved = { model, providerOptions };
371
+ const modelSelection = inject?.modelSelection ??
372
+ resolvedModelSelection ??
373
+ modelSelectionForResolvedModel(model);
374
+ const contextTrust = inject?.contextTrust ?? ctx?.contextTrust ?? "off";
375
+ const outputSchema = loop.output?.schema;
376
+ const modelOutput = inject?.output ?? (outputSchema !== undefined
377
+ ? modelOutputForJsonSchema(outputSchema, `loop_${loopName}_output`)
378
+ : undefined);
379
+ const hasStructuredOutput = modelOutput !== undefined;
380
+ // Conversation state owned by the host, exactly like the legacy loop.
381
+ // On resume the recorded history (already containing tool-call and
382
+ // tool-result pairs from completed turns) replaces the fresh prompt —
383
+ // side-effects replay from recorded results, they never re-execute.
384
+ let messages = normalizeResponseMessagesForHistory(inject
385
+ ? [...inject.seedMessages]
386
+ : resume
387
+ ? [...resume.history]
388
+ : [{
389
+ role: "user",
390
+ content: options.initialUserMessage ?? buildPrompt(task, ctx?.promptContextSegments, ctx?.contextTrust),
391
+ }]);
452
392
  if (contextTrust === "enforce") {
453
393
  messages = protectRuntimeToolResultMessages(messages);
454
394
  }
455
- let stepText = "";
456
- // Chat injections stream a structured answer's raw JSON as it arrives;
457
- // the validated serialization still follows once the step completes.
458
- const structuredGate = inject && hasStructuredOutput
459
- ? new StructuredOutputDeltaGate()
460
- : undefined;
461
- const toolCalls = [];
462
- const seenModelToolCallIds = new Set();
463
- const resolvedAttempts = new Map();
464
- let turn;
465
- try {
466
- turn = await runModelPolicyTurn({
467
- selection: modelSelection,
468
- resolveAttempt: async (attempt) => {
469
- const resolvedAttempt = attempt.index === 0
470
- ? primaryResolved
471
- : inject
472
- ? await inject.resolveModelAttempt?.(attempt.model)
473
- : resolveSpawnModelAttempt(sessionAgent, attempt.model, ctx);
474
- if (!resolvedAttempt) {
475
- throw new Error(`No model resolver is available for fallback "${attempt.model}"`);
476
- }
477
- resolvedAttempts.set(attempt.index, resolvedAttempt);
478
- return {
479
- model: resolvedAttempt.model.aiModel,
480
- maxOutputTokens: resolvedAttempt.model.maxTokens,
481
- providerOptions: resolvedAttempt.providerOptions,
482
- };
483
- },
484
- preserveSingleAttemptError: true,
485
- system: systemPrompt,
486
- messages,
487
- tools: toolSet,
488
- ...(inject?.activeToolNames ? { activeTools: inject.activeToolNames() } : {}),
489
- ...(inject?.toolChoice ? { toolChoice: inject.toolChoice } : {}),
490
- ...(inject?.parallelToolCalls !== undefined
491
- ? { parallelToolCalls: inject.parallelToolCalls }
492
- : {}),
493
- ...(hasStructuredOutput ? { output: modelOutput } : {}),
395
+ let lastStepText = "";
396
+ let accumText = resume?.accumText ?? "";
397
+ let completedStructuredOutput = false;
398
+ const providerToolResults = new Map();
399
+ const renderProviderResult = (value) => {
400
+ if (typeof value === "string")
401
+ return value;
402
+ if (value === undefined)
403
+ return "";
404
+ try {
405
+ return JSON.stringify(value);
406
+ }
407
+ catch {
408
+ return String(value);
409
+ }
410
+ };
411
+ const summarize = async (msgs, compactionPrompt) => {
412
+ const response = await generateText({
413
+ model: model.aiModel,
414
+ system: compactionPrompt,
415
+ messages: prepareModelMessagesForTransport(msgs, model.aiModel),
416
+ maxOutputTokens: model.maxTokens,
494
417
  abortSignal: abortController.signal,
495
- }, (event) => {
496
- switch (event.type) {
497
- case "reasoning-delta": {
498
- // Chat parity (F1c): surface model reasoning as a thinking delta.
499
- if (inject) {
500
- try {
501
- ctx?.onDelta?.({ text: event.text, kind: "reasoning" });
502
- }
503
- catch { /* subscriber can't sink the run */ }
418
+ providerOptions,
419
+ });
420
+ return response.text;
421
+ };
422
+ // ── LoopRunner model callback: one model turn per runner step ──
423
+ const modelStep = async () => {
424
+ // Abort between turns mirrors the legacy `if (aborted) break`:
425
+ // report no tool calls so the runner stops cleanly (exit 0).
426
+ if (abortController.signal.aborted) {
427
+ return { text: "", toolCalls: [] };
428
+ }
429
+ // Context compaction — same call, same config, same quirks as the
430
+ // legacy engine (see engine-behavior characterization tests).
431
+ const compactionResult = await compactIfNeeded({
432
+ systemPrompt,
433
+ messages,
434
+ tools: (inject?.activeCompactionTools?.() ?? compactionTools),
435
+ config: {
436
+ contextWindow: model.contextWindow ?? 200_000,
437
+ maxOutputTokens: model.maxTokens ?? 8192,
438
+ },
439
+ summarize,
440
+ mode: compactionMode,
441
+ onCompaction: (event) => {
442
+ emitTranscript({
443
+ type: "compaction",
444
+ phase: event.phase,
445
+ tokensBefore: event.tokensBefore,
446
+ tokensAfter: event.tokensAfter,
447
+ tokensReclaimed: event.tokensReclaimed,
448
+ messagesBefore: event.messagesBefore,
449
+ messagesAfter: event.messagesAfter,
450
+ toolOutputsPruned: event.toolOutputsPruned,
451
+ summary: event.summary,
452
+ });
453
+ },
454
+ });
455
+ if (compactionResult.compacted) {
456
+ messages = normalizeResponseMessagesForHistory(compactionResult.messages);
457
+ }
458
+ messages = normalizeResponseMessagesForHistory(messages);
459
+ if (contextTrust === "enforce") {
460
+ messages = protectRuntimeToolResultMessages(messages);
461
+ }
462
+ let stepText = "";
463
+ // Chat injections stream a structured answer's raw JSON as it arrives;
464
+ // the validated serialization still follows once the step completes.
465
+ const structuredGate = inject && hasStructuredOutput
466
+ ? new StructuredOutputDeltaGate()
467
+ : undefined;
468
+ const toolCalls = [];
469
+ const seenModelToolCallIds = new Set();
470
+ const resolvedAttempts = new Map();
471
+ let turn;
472
+ try {
473
+ turn = await runModelPolicyTurn({
474
+ selection: modelSelection,
475
+ resolveAttempt: async (attempt) => {
476
+ const resolvedAttempt = attempt.index === 0
477
+ ? primaryResolved
478
+ : inject
479
+ ? await inject.resolveModelAttempt?.(attempt.model)
480
+ : resolveSpawnModelAttempt(sessionAgent, attempt.model, ctx);
481
+ if (!resolvedAttempt) {
482
+ throw new Error(`No model resolver is available for fallback "${attempt.model}"`);
504
483
  }
505
- break;
506
- }
507
- case "text-delta": {
508
- stepText += event.text;
509
- // F1b: token-level streaming sink (separate from onTranscript, which
510
- // stays turn-granularity for persistence). Best-effort.
511
- if (!hasStructuredOutput) {
512
- try {
513
- ctx?.onDelta?.({ text: event.text });
484
+ resolvedAttempts.set(attempt.index, resolvedAttempt);
485
+ return {
486
+ model: resolvedAttempt.model.aiModel,
487
+ maxOutputTokens: resolvedAttempt.model.maxTokens,
488
+ providerOptions: resolvedAttempt.providerOptions,
489
+ };
490
+ },
491
+ preserveSingleAttemptError: true,
492
+ system: systemPrompt,
493
+ messages,
494
+ tools: toolSet,
495
+ ...(inject?.activeToolNames ? { activeTools: inject.activeToolNames() } : {}),
496
+ ...(inject?.toolChoice ? { toolChoice: inject.toolChoice } : {}),
497
+ ...(inject?.parallelToolCalls !== undefined
498
+ ? { parallelToolCalls: inject.parallelToolCalls }
499
+ : {}),
500
+ ...(hasStructuredOutput ? { output: modelOutput } : {}),
501
+ abortSignal: abortController.signal,
502
+ }, (event) => {
503
+ switch (event.type) {
504
+ case "reasoning-delta": {
505
+ // Chat parity (F1c): surface model reasoning as a thinking delta.
506
+ if (inject) {
507
+ try {
508
+ ctx?.onDelta?.({ text: event.text, kind: "reasoning" });
509
+ }
510
+ catch { /* subscriber can't sink the run */ }
514
511
  }
515
- catch { /* a delta subscriber can't sink the run */ }
512
+ break;
516
513
  }
517
- else {
518
- const structuredText = structuredGate?.push(event.text);
519
- if (structuredText) {
514
+ case "text-delta": {
515
+ stepText += event.text;
516
+ // F1b: token-level streaming sink (separate from onTranscript, which
517
+ // stays turn-granularity for persistence). Best-effort.
518
+ if (!hasStructuredOutput) {
520
519
  try {
521
- ctx?.onDelta?.({ text: structuredText, kind: "structured" });
520
+ ctx?.onDelta?.({ text: event.text });
522
521
  }
523
522
  catch { /* a delta subscriber can't sink the run */ }
524
523
  }
524
+ else {
525
+ const structuredText = structuredGate?.push(event.text);
526
+ if (structuredText) {
527
+ try {
528
+ ctx?.onDelta?.({ text: structuredText, kind: "structured" });
529
+ }
530
+ catch { /* a delta subscriber can't sink the run */ }
531
+ }
532
+ }
533
+ break;
525
534
  }
526
- break;
527
- }
528
- case "tool-input-start": {
529
- // Chat parity (F1c): "preparing" tool state before args stream in.
530
- if (inject)
531
- emitTranscript({ type: "tool_input_start", toolId: event.id, tool: event.name });
532
- break;
533
- }
534
- case "tool-input-delta": {
535
- // Chat parity (F1c): forward raw argument text while it streams so
536
- // the run path exposes the same live tool-call details as inline.
537
- if (inject)
538
- emitTranscript({ type: "tool_input_delta", toolId: event.id, delta: event.delta });
539
- break;
540
- }
541
- case "tool-input-aborted": {
542
- if (inject) {
543
- emitTranscript({
544
- type: "tool_input_interrupted",
545
- toolId: event.id,
546
- tool: event.name,
547
- error: event.error,
548
- });
535
+ case "tool-input-start": {
536
+ // Chat parity (F1c): "preparing" tool state before args stream in.
537
+ if (inject)
538
+ emitTranscript({ type: "tool_input_start", toolId: event.id, tool: event.name });
539
+ break;
549
540
  }
550
- break;
551
- }
552
- case "tool-call": {
553
- if (!event.id || seenModelToolCallIds.has(event.id))
541
+ case "tool-input-delta": {
542
+ // Chat parity (F1c): forward raw argument text while it streams so
543
+ // the run path exposes the same live tool-call details as inline.
544
+ if (inject)
545
+ emitTranscript({ type: "tool_input_delta", toolId: event.id, delta: event.delta });
554
546
  break;
555
- seenModelToolCallIds.add(event.id);
556
- activity.toolCalls++;
557
- activity.lastTool = event.name;
558
- activity.lastUpdate = new Date().toISOString();
559
- if (event.invalid) {
560
- const detail = event.error instanceof Error
561
- ? event.error.message
562
- : typeof event.error === "string"
563
- ? event.error
564
- : "Tool arguments do not match the declared input schema.";
565
- // Keep the call in the loop so the SDK-provided tool error in
566
- // responseMessages reaches the next model turn, but never emit a
567
- // local calling event or dispatch it to an executor.
547
+ }
548
+ case "tool-input-aborted": {
549
+ if (inject) {
550
+ emitTranscript({
551
+ type: "tool_input_interrupted",
552
+ toolId: event.id,
553
+ tool: event.name,
554
+ error: event.error,
555
+ });
556
+ }
557
+ break;
558
+ }
559
+ case "tool-call": {
560
+ if (!event.id || seenModelToolCallIds.has(event.id))
561
+ break;
562
+ seenModelToolCallIds.add(event.id);
563
+ activity.toolCalls++;
564
+ activity.lastTool = event.name;
565
+ activity.lastUpdate = new Date().toISOString();
566
+ if (event.invalid) {
567
+ const detail = event.error instanceof Error
568
+ ? event.error.message
569
+ : typeof event.error === "string"
570
+ ? event.error
571
+ : "Tool arguments do not match the declared input schema.";
572
+ // Keep the call in the loop so the SDK-provided tool error in
573
+ // responseMessages reaches the next model turn, but never emit a
574
+ // local calling event or dispatch it to an executor.
575
+ toolCalls.push({
576
+ id: event.id,
577
+ name: event.name,
578
+ args: (event.args ?? {}),
579
+ inputValidationError: `Error: Invalid tool arguments: ${detail}`,
580
+ });
581
+ break;
582
+ }
583
+ // Chat parity (F1c): a client-side tool ends the server loop and is
584
+ // returned to the caller to execute — never dispatched here. Not
585
+ // pushing it to toolCalls ⇒ the turn ends with no executable tools.
586
+ if (inject && inject.clientSideToolNames.has(event.name)) {
587
+ emitTranscript({
588
+ type: "client_tool_call",
589
+ toolId: event.id,
590
+ tool: event.name,
591
+ input: event.args,
592
+ });
593
+ break;
594
+ }
595
+ // Background task transcripts keep their historical use,use,result
596
+ // ordering. Interactive chat defers tool_use until executeTool so
597
+ // local calls are emitted calling,result one at a time, exactly like
598
+ // the inline completions handler. Provider tools never emit a local
599
+ // calling event because the model provider executes them.
600
+ if (!inject) {
601
+ emitTranscript({
602
+ type: "tool_use",
603
+ tool: event.name,
604
+ toolId: event.id,
605
+ input: event.args,
606
+ });
607
+ }
568
608
  toolCalls.push({
569
609
  id: event.id,
570
610
  name: event.name,
571
611
  args: (event.args ?? {}),
572
- inputValidationError: `Error: Invalid tool arguments: ${detail}`,
573
612
  });
574
613
  break;
575
614
  }
576
- // Chat parity (F1c): a client-side tool ends the server loop and is
577
- // returned to the caller to execute — never dispatched here. Not
578
- // pushing it to toolCalls ⇒ the turn ends with no executable tools.
579
- if (inject && inject.clientSideToolNames.has(event.name)) {
580
- emitTranscript({
581
- type: "client_tool_call",
582
- toolId: event.id,
583
- tool: event.name,
584
- input: event.args,
585
- });
615
+ case "tool-result": {
616
+ if (inject?.providerToolNames.has(event.name)) {
617
+ providerToolResults.set(event.id, {
618
+ content: renderProviderResult(event.output),
619
+ isError: false,
620
+ });
621
+ }
586
622
  break;
587
623
  }
588
- // Background task transcripts keep their historical use,use,result
589
- // ordering. Interactive chat defers tool_use until executeTool so
590
- // local calls are emitted calling,result one at a time, exactly like
591
- // the inline completions handler. Provider tools never emit a local
592
- // calling event because the model provider executes them.
593
- if (!inject) {
594
- emitTranscript({
595
- type: "tool_use",
596
- tool: event.name,
597
- toolId: event.id,
598
- input: event.args,
599
- });
600
- }
601
- toolCalls.push({
602
- id: event.id,
603
- name: event.name,
604
- args: (event.args ?? {}),
605
- });
606
- break;
607
- }
608
- case "tool-result": {
609
- if (inject?.providerToolNames.has(event.name)) {
610
- providerToolResults.set(event.id, {
611
- content: renderProviderResult(event.output),
612
- isError: false,
613
- });
624
+ case "tool-error": {
625
+ if (inject?.providerToolNames.has(event.name)) {
626
+ providerToolResults.set(event.id, {
627
+ content: renderProviderResult(event.error),
628
+ isError: true,
629
+ });
630
+ }
631
+ break;
614
632
  }
615
- break;
616
- }
617
- case "tool-error": {
618
- if (inject?.providerToolNames.has(event.name)) {
619
- providerToolResults.set(event.id, {
620
- content: renderProviderResult(event.error),
621
- isError: true,
622
- });
633
+ case "finish": {
634
+ if (inject && event.finishReason === "error") {
635
+ emitTranscript({
636
+ type: "error",
637
+ message: "Model returned an error",
638
+ error: { message: "Model returned an error", type: "model_error" },
639
+ });
640
+ }
641
+ break;
623
642
  }
624
- break;
625
- }
626
- case "finish": {
627
- if (inject && event.finishReason === "error") {
643
+ case "error": {
644
+ const normalized = classifyRuntimeError(event.error);
628
645
  emitTranscript({
629
646
  type: "error",
630
- message: "Model returned an error",
631
- error: { message: "Model returned an error", type: "model_error" },
647
+ message: normalized.message ?? "Unknown model runtime error",
648
+ error: runtimeErrorMetadata(event.error),
632
649
  });
650
+ break;
633
651
  }
634
- break;
635
- }
636
- case "error": {
637
- const normalized = classifyRuntimeError(event.error);
638
- emitTranscript({
639
- type: "error",
640
- message: normalized.message ?? "Unknown model runtime error",
641
- error: runtimeErrorMetadata(event.error),
642
- });
643
- break;
644
652
  }
653
+ });
654
+ }
655
+ catch (error) {
656
+ if (outputSchema !== undefined && isStructuredModelOutputError(error)) {
657
+ throw new LoopAgentOutputError("loop_agent_output_invalid", loopName, `Agent step ${JSON.stringify(loopName)} did not produce valid structured output`, { phase: "model_output" }, { cause: error });
645
658
  }
646
- });
647
- }
648
- catch (error) {
649
- if (outputSchema !== undefined && isStructuredModelOutputError(error)) {
650
- throw new LoopAgentOutputError("loop_agent_output_invalid", loopName, `Agent step ${JSON.stringify(loopName)} did not produce valid structured output`, { phase: "model_output" }, { cause: error });
659
+ throw error;
651
660
  }
652
- throw error;
653
- }
654
- const stepUsage = turn.usage;
655
- const selectedResolved = resolvedAttempts.get(turn.selectedAttempt.index);
656
- if (selectedResolved) {
657
- model = selectedResolved.model;
658
- providerOptions = selectedResolved.providerOptions;
659
- }
660
- if (stepUsage) {
661
- activity.totalTokens += (stepUsage.totalTokens ?? 0);
662
- }
663
- activity.lastUpdate = new Date().toISOString();
664
- if (structuredGate?.streamed && toolCalls.length > 0) {
665
- try {
666
- ctx?.onDelta?.({ text: "", kind: "structured-reset" });
661
+ const stepUsage = turn.usage;
662
+ const selectedResolved = resolvedAttempts.get(turn.selectedAttempt.index);
663
+ if (selectedResolved) {
664
+ model = selectedResolved.model;
665
+ providerOptions = selectedResolved.providerOptions;
667
666
  }
668
- catch { /* a delta subscriber can't sink the run */ }
669
- }
670
- let rawText;
671
- if (hasStructuredOutput) {
672
- if (toolCalls.length === 0 && turn.output !== undefined) {
673
- if (outputSchema !== undefined) {
674
- const prepared = prepareLoopAgentOutput(loopName, turn.output, outputSchema);
675
- rawText = prepared.serialized;
676
- completedStructuredOutput = true;
667
+ if (stepUsage) {
668
+ activity.totalTokens += (stepUsage.totalTokens ?? 0);
669
+ }
670
+ activity.lastUpdate = new Date().toISOString();
671
+ if (structuredGate?.streamed && toolCalls.length > 0) {
672
+ try {
673
+ ctx?.onDelta?.({ text: "", kind: "structured-reset" });
674
+ }
675
+ catch { /* a delta subscriber can't sink the run */ }
676
+ }
677
+ let rawText;
678
+ if (hasStructuredOutput) {
679
+ if (toolCalls.length === 0 && turn.output !== undefined) {
680
+ if (outputSchema !== undefined) {
681
+ const prepared = prepareLoopAgentOutput(loopName, turn.output, outputSchema);
682
+ rawText = prepared.serialized;
683
+ completedStructuredOutput = true;
684
+ }
685
+ else {
686
+ rawText = JSON.stringify(turn.output);
687
+ }
677
688
  }
678
689
  else {
679
- rawText = JSON.stringify(turn.output);
690
+ rawText = "";
680
691
  }
681
692
  }
682
693
  else {
683
- rawText = "";
694
+ rawText = stepText || (toolCalls.length === 0 ? turn.text : "");
684
695
  }
685
- }
686
- else {
687
- rawText = stepText || (toolCalls.length === 0 ? turn.text : "");
688
- }
689
- const guardedText = rawText && ctx?.runOutputPolicy
690
- ? (await ctx.runOutputPolicy.evaluate({
691
- output: rawText,
692
- mode: ctx.runOutputPolicyMode ?? "enforce",
693
- context: {
694
- agent: sessionAgent.name,
695
- runId: ctx.runId,
696
- source: "task",
697
- surface: "task",
698
- },
699
- signal: abortController.signal,
700
- })).output
701
- : rawText;
702
- if (hasStructuredOutput && toolCalls.length === 0 && guardedText) {
703
- try {
704
- ctx?.onDelta?.({ text: guardedText });
696
+ const guardedText = rawText && ctx?.runOutputPolicy
697
+ ? (await ctx.runOutputPolicy.evaluate({
698
+ output: rawText,
699
+ mode: ctx.runOutputPolicyMode ?? "enforce",
700
+ context: {
701
+ agent: sessionAgent.name,
702
+ runId: ctx.runId,
703
+ source: "task",
704
+ surface: "task",
705
+ },
706
+ signal: abortController.signal,
707
+ })).output
708
+ : rawText;
709
+ if (hasStructuredOutput && toolCalls.length === 0 && guardedText) {
710
+ try {
711
+ ctx?.onDelta?.({ text: guardedText });
712
+ }
713
+ catch { /* a delta subscriber can't sink the run */ }
705
714
  }
706
- catch { /* a delta subscriber can't sink the run */ }
707
- }
708
- if (guardedText) {
709
- activity.summary = guardedText.slice(0, 200);
710
- emitTranscript({ type: "assistant", text: guardedText });
711
- accumText += guardedText;
712
- }
713
- lastStepText = guardedText;
714
- // Append the assistant message (with its tool-call parts) to history.
715
- const normalizedResponseMessages = normalizeResponseMessagesForHistory(turn.responseMessages);
716
- const responseMessages = guardedText !== rawText
717
- ? replaceAssistantText(normalizedResponseMessages, guardedText)
718
- : normalizedResponseMessages;
719
- messages.push(...(contextTrust === "enforce"
720
- ? protectRuntimeToolResultMessages(responseMessages)
721
- : responseMessages));
722
- // Chat parity (F1c): surface per-turn usage + provider metadata so the
723
- // driver can accumulate them for onCompletionFinished, matching chat.
724
- if (inject) {
725
- emitTranscript({
726
- type: "usage",
727
- usage: stepUsage,
728
- providerMetadata: turn.providerMetadata,
729
- });
730
- }
731
- return { text: guardedText, toolCalls, usage: stepUsage };
732
- };
733
- // ── LoopRunner tool executor: dispatch + history append ──
734
- const executeTool = async (toolCall) => {
735
- if (toolCall.inputValidationError) {
736
- emitTranscript({
737
- type: "tool_result",
738
- toolId: toolCall.id,
739
- tool: toolCall.name,
740
- content: toolCall.inputValidationError,
741
- isError: true,
742
- invalid: true,
743
- });
744
- // AI SDK already included the corresponding tool-error output in
745
- // turn.responseMessages; do not append a duplicate history message.
746
- return toolCall.inputValidationError;
747
- }
748
- let llmText;
749
- let isError;
750
- if (inject) {
751
- // Chat parity (F1c): dispatch via the route's own executor and emit the
752
- // FULL result (no 2000-char task truncation). Provider-executed tools
753
- // are already resolved by the model provider: record their result for
754
- // observability, but never dispatch them through the local executor.
755
- if (inject.providerToolNames.has(toolCall.name)) {
756
- const providerResult = providerToolResults.get(toolCall.id) ?? { content: "", isError: false };
715
+ if (guardedText) {
716
+ activity.summary = guardedText.slice(0, 200);
717
+ emitTranscript({ type: "assistant", text: guardedText });
718
+ accumText += guardedText;
719
+ }
720
+ lastStepText = guardedText;
721
+ // Append the assistant message (with its tool-call parts) to history.
722
+ const normalizedResponseMessages = normalizeResponseMessagesForHistory(turn.responseMessages);
723
+ const responseMessages = guardedText !== rawText
724
+ ? replaceAssistantText(normalizedResponseMessages, guardedText)
725
+ : normalizedResponseMessages;
726
+ messages.push(...(contextTrust === "enforce"
727
+ ? protectRuntimeToolResultMessages(responseMessages)
728
+ : responseMessages));
729
+ // Chat parity (F1c): surface per-turn usage + provider metadata so the
730
+ // driver can accumulate them for onCompletionFinished, matching chat.
731
+ if (inject) {
732
+ emitTranscript({
733
+ type: "usage",
734
+ usage: stepUsage,
735
+ providerMetadata: turn.providerMetadata,
736
+ });
737
+ }
738
+ return { text: guardedText, toolCalls, usage: stepUsage };
739
+ };
740
+ // ── LoopRunner tool executor: dispatch + history append ──
741
+ const executeTool = async (toolCall) => {
742
+ if (toolCall.inputValidationError) {
757
743
  emitTranscript({
758
744
  type: "tool_result",
759
745
  toolId: toolCall.id,
760
746
  tool: toolCall.name,
761
- input: toolCall.args,
762
- content: providerResult.content,
763
- isError: providerResult.isError,
764
- providerExecuted: true,
747
+ content: toolCall.inputValidationError,
748
+ isError: true,
749
+ invalid: true,
765
750
  });
766
- return "";
751
+ // AI SDK already included the corresponding tool-error output in
752
+ // turn.responseMessages; do not append a duplicate history message.
753
+ return toolCall.inputValidationError;
767
754
  }
768
- emitTranscript({
769
- type: "tool_use",
770
- tool: toolCall.name,
771
- toolId: toolCall.id,
772
- input: toolCall.args,
773
- });
774
- llmText = await inject.executor(toolCall.name, toolCall.args, {
775
- callId: toolCall.id,
776
- signal: abortController.signal,
777
- });
778
- isError = llmText.startsWith("Error:");
779
- emitTranscript({ type: "tool_result", toolId: toolCall.id, tool: toolCall.name, content: llmText, isError });
780
- }
781
- else {
782
- ({ llmText, isError } = await performToolCall(toolByName.get(toolCall.name), toolCall));
783
- }
784
- return llmText;
785
- };
786
- // ── Durable turns: one checkpoint write per completed turn ──
787
- // Emitted at end-of-turn, so `messages` is post-tool-execution and
788
- // post-compaction by construction (compaction rewrites `messages` at
789
- // the START of a model step). The JSON round-trip here pins store
790
- // fidelity: what a resumed run sees is exactly what survives JSON.
791
- const checkpointCreatedAt = resume?.createdAt ?? new Date().toISOString();
792
- const onTurnCheckpoint = options.onCheckpoint
793
- ? async ({ turn, steering }) => {
794
- const serialized = JSON.stringify({ messages, steering });
795
- if (serialized.length > MAX_CHECKPOINT_BYTES)
796
- return; // keep the previous checkpoint
797
- const checkpoint = JSON.parse(serialized);
798
- await options.onCheckpoint({
799
- context: {},
800
- steps: [],
801
- loopName,
802
- turn,
803
- history: checkpoint.messages,
804
- accumText,
805
- steering: checkpoint.steering,
806
- createdAt: checkpointCreatedAt,
807
- updatedAt: new Date().toISOString(),
808
- });
809
- }
810
- : undefined;
811
- const runner = new LoopRunner();
812
- await runner.run({
813
- agent: sessionAgent,
814
- loop: { ...loop, name: loopName },
815
- maxTurns,
816
- startTurn: resume ? resume.turn + 1 : 0,
817
- model: modelStep,
818
- executeTool,
819
- parallelToolCalls: inject?.parallelToolCalls,
820
- onToolResults: (toolResults) => {
821
- for (const { toolCall, result, isError } of toolResults) {
822
- // AI SDK already persisted provider-executed results and invalid
823
- // argument errors in responseMessages. Only local results belong
824
- // in this host-owned ordered append.
825
- if (toolCall.inputValidationError || inject?.providerToolNames.has(toolCall.name)) {
826
- continue;
755
+ let llmText;
756
+ let isError;
757
+ if (inject) {
758
+ // Chat parity (F1c): dispatch via the route's own executor and emit the
759
+ // FULL result (no 2000-char task truncation). Provider-executed tools
760
+ // are already resolved by the model provider: record their result for
761
+ // observability, but never dispatch them through the local executor.
762
+ if (inject.providerToolNames.has(toolCall.name)) {
763
+ const providerResult = providerToolResults.get(toolCall.id) ?? { content: "", isError: false };
764
+ emitTranscript({
765
+ type: "tool_result",
766
+ toolId: toolCall.id,
767
+ tool: toolCall.name,
768
+ input: toolCall.args,
769
+ content: providerResult.content,
770
+ isError: providerResult.isError,
771
+ providerExecuted: true,
772
+ });
773
+ return "";
827
774
  }
828
- const modelResult = contextTrust === "enforce"
829
- ? renderRuntimeToolResult(toolCall.name, toolCall.id, result)
830
- : result;
831
- messages.push({
832
- role: "tool",
833
- content: [{
834
- type: "tool-result",
835
- toolCallId: toolCall.id,
836
- toolName: toolCall.name,
837
- output: isError
838
- ? { type: "error-text", value: modelResult }
839
- : { type: "text", value: modelResult },
840
- }],
775
+ emitTranscript({
776
+ type: "tool_use",
777
+ tool: toolCall.name,
778
+ toolId: toolCall.id,
779
+ input: toolCall.args,
780
+ });
781
+ llmText = await inject.executor(toolCall.name, toolCall.args, {
782
+ callId: toolCall.id,
783
+ signal: abortController.signal,
841
784
  });
785
+ isError = llmText.startsWith("Error:");
786
+ emitTranscript({ type: "tool_result", toolId: toolCall.id, tool: toolCall.name, content: llmText, isError });
842
787
  }
843
- },
844
- steering,
845
- onSteering: async (steeringMessages) => {
846
- for (const message of steeringMessages) {
847
- messages.push(toSteeringModelMessage(message));
848
- emitTranscript({
849
- type: "steering",
850
- messageId: message.id,
851
- mode: message.mode,
852
- text: message.content.text,
853
- attachments: (message.content.attachments ?? []).map((attachment) => ({
854
- type: attachment.type,
855
- mediaType: attachment.mediaType,
856
- name: attachment.name,
857
- })),
788
+ else {
789
+ ({ llmText, isError } = await performToolCall(toolByName.get(toolCall.name), toolCall));
790
+ }
791
+ return llmText;
792
+ };
793
+ // ── Durable turns: one checkpoint write per completed turn ──
794
+ // Emitted at end-of-turn, so `messages` is post-tool-execution and
795
+ // post-compaction by construction (compaction rewrites `messages` at
796
+ // the START of a model step). The JSON round-trip here pins store
797
+ // fidelity: what a resumed run sees is exactly what survives JSON.
798
+ const checkpointCreatedAt = resume?.createdAt ?? new Date().toISOString();
799
+ const onTurnCheckpoint = options.onCheckpoint
800
+ ? async ({ turn, steering }) => {
801
+ const serialized = JSON.stringify({ messages, steering });
802
+ if (serialized.length > MAX_CHECKPOINT_BYTES)
803
+ return; // keep the previous checkpoint
804
+ const checkpoint = JSON.parse(serialized);
805
+ await options.onCheckpoint({
806
+ context: {},
807
+ steps: [],
808
+ loopName,
809
+ turn,
810
+ history: checkpoint.messages,
811
+ accumText,
812
+ steering: checkpoint.steering,
813
+ createdAt: checkpointCreatedAt,
814
+ updatedAt: new Date().toISOString(),
858
815
  });
859
816
  }
860
- },
861
- onTurnCheckpoint,
862
- });
863
- if (outputSchema !== undefined && !completedStructuredOutput) {
864
- throw new LoopAgentOutputError("loop_agent_output_invalid", loopName, `Agent step ${JSON.stringify(loopName)} exhausted maxTurns before producing structured output`, { phase: "max_turns" });
817
+ : undefined;
818
+ const runner = new LoopRunner();
819
+ await runner.run({
820
+ agent: sessionAgent,
821
+ loop: { ...loop, name: loopName },
822
+ maxTurns,
823
+ startTurn: resume ? resume.turn + 1 : 0,
824
+ model: modelStep,
825
+ executeTool,
826
+ parallelToolCalls: inject?.parallelToolCalls,
827
+ onToolResults: (toolResults) => {
828
+ for (const { toolCall, result, isError } of toolResults) {
829
+ // AI SDK already persisted provider-executed results and invalid
830
+ // argument errors in responseMessages. Only local results belong
831
+ // in this host-owned ordered append.
832
+ if (toolCall.inputValidationError || inject?.providerToolNames.has(toolCall.name)) {
833
+ continue;
834
+ }
835
+ const modelResult = contextTrust === "enforce"
836
+ ? renderRuntimeToolResult(toolCall.name, toolCall.id, result)
837
+ : result;
838
+ messages.push({
839
+ role: "tool",
840
+ content: [{
841
+ type: "tool-result",
842
+ toolCallId: toolCall.id,
843
+ toolName: toolCall.name,
844
+ output: isError
845
+ ? { type: "error-text", value: modelResult }
846
+ : { type: "text", value: modelResult },
847
+ }],
848
+ });
849
+ }
850
+ },
851
+ steering,
852
+ onSteering: async (steeringMessages) => {
853
+ for (const message of steeringMessages) {
854
+ messages.push(toSteeringModelMessage(message));
855
+ emitTranscript({
856
+ type: "steering",
857
+ messageId: message.id,
858
+ mode: message.mode,
859
+ text: message.content.text,
860
+ attachments: (message.content.attachments ?? []).map((attachment) => ({
861
+ type: attachment.type,
862
+ mediaType: attachment.mediaType,
863
+ name: attachment.name,
864
+ })),
865
+ });
866
+ }
867
+ },
868
+ onTurnCheckpoint,
869
+ });
870
+ if (outputSchema !== undefined && !completedStructuredOutput) {
871
+ throw new LoopAgentOutputError("loop_agent_output_invalid", loopName, `Agent step ${JSON.stringify(loopName)} exhausted maxTurns before producing structured output`, { phase: "max_turns" });
872
+ }
873
+ return { lastText: lastStepText, accumText };
874
+ }
875
+ finally {
876
+ if (sessionCleanup) {
877
+ await sessionCleanup().catch(() => undefined);
878
+ toolCleanups.delete(sessionCleanup);
879
+ }
865
880
  }
866
- return { lastText: lastStepText, accumText };
867
881
  }
868
882
  /**
869
883
  * Run a project loop graph (.polpo/loops/<name>.json) through the
@@ -893,8 +907,10 @@ export function spawnLoopEngine(agentConfig, task, cwd, ctx) {
893
907
  const getBaseTools = () => {
894
908
  baseToolsPromise ??= (async () => {
895
909
  const prep = prepareSpawn(agentConfig, cwd, ctx);
896
- const tools = await buildAgentTools(agentConfig, cwd, prep, ctx);
897
- return new Map(tools.map((t) => [t.name, t]));
910
+ const resolved = await buildAgentTools(agentConfig, cwd, prep, ctx, { signal: abortController.signal,
911
+ policy: { loop: projectLoop.allowedTools } });
912
+ toolCleanups.add(resolved.cleanup);
913
+ return new Map(resolved.tools.map((t) => [t.name, t]));
898
914
  })();
899
915
  return baseToolsPromise;
900
916
  };
@@ -983,6 +999,7 @@ export function spawnLoopEngine(agentConfig, task, cwd, ctx) {
983
999
  const stepAgent = buildLoopStepAgent(agentConfig, name, loop);
984
1000
  const session = await runLoopSession({
985
1001
  sessionAgent: stepAgent,
1002
+ projectAllowedTools: projectLoop.allowedTools,
986
1003
  loopName: name,
987
1004
  loop,
988
1005
  contextPrompt: agentInput
@@ -1127,6 +1144,8 @@ export function spawnLoopEngine(agentConfig, task, cwd, ctx) {
1127
1144
  };
1128
1145
  }
1129
1146
  finally {
1147
+ await Promise.allSettled([...toolCleanups].map(cleanup => cleanup()));
1148
+ toolCleanups.clear();
1130
1149
  steering.signal.removeEventListener("abort", onSteeringAbort);
1131
1150
  steering.close();
1132
1151
  // Close agent-browser session (profile data auto-persisted by --profile).