@lleverage-ai/agent-sdk 0.0.6 → 0.0.8

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 (51) hide show
  1. package/dist/agent.d.ts.map +1 -1
  2. package/dist/agent.js +383 -341
  3. package/dist/agent.js.map +1 -1
  4. package/dist/index.d.ts +4 -3
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +5 -4
  7. package/dist/index.js.map +1 -1
  8. package/dist/mcp/manager.d.ts +14 -0
  9. package/dist/mcp/manager.d.ts.map +1 -1
  10. package/dist/mcp/manager.js +19 -0
  11. package/dist/mcp/manager.js.map +1 -1
  12. package/dist/plugins.d.ts.map +1 -1
  13. package/dist/plugins.js +2 -0
  14. package/dist/plugins.js.map +1 -1
  15. package/dist/prompt-builder/components.d.ts.map +1 -1
  16. package/dist/prompt-builder/components.js +2 -0
  17. package/dist/prompt-builder/components.js.map +1 -1
  18. package/dist/prompt-builder/delegation-component.d.ts +27 -0
  19. package/dist/prompt-builder/delegation-component.d.ts.map +1 -0
  20. package/dist/prompt-builder/delegation-component.js +53 -0
  21. package/dist/prompt-builder/delegation-component.js.map +1 -0
  22. package/dist/testing/mock-agent.d.ts.map +1 -1
  23. package/dist/testing/mock-agent.js +7 -4
  24. package/dist/testing/mock-agent.js.map +1 -1
  25. package/dist/testing/recorder.d.ts.map +1 -1
  26. package/dist/testing/recorder.js +3 -3
  27. package/dist/testing/recorder.js.map +1 -1
  28. package/dist/tools/call-tool.d.ts +59 -0
  29. package/dist/tools/call-tool.d.ts.map +1 -0
  30. package/dist/tools/call-tool.js +93 -0
  31. package/dist/tools/call-tool.js.map +1 -0
  32. package/dist/tools/factory.d.ts +10 -2
  33. package/dist/tools/factory.d.ts.map +1 -1
  34. package/dist/tools/factory.js +10 -1
  35. package/dist/tools/factory.js.map +1 -1
  36. package/dist/tools/index.d.ts +2 -2
  37. package/dist/tools/index.d.ts.map +1 -1
  38. package/dist/tools/index.js +2 -2
  39. package/dist/tools/index.js.map +1 -1
  40. package/dist/tools/search.d.ts +8 -0
  41. package/dist/tools/search.d.ts.map +1 -1
  42. package/dist/tools/search.js +41 -2
  43. package/dist/tools/search.js.map +1 -1
  44. package/dist/types.d.ts +132 -44
  45. package/dist/types.d.ts.map +1 -1
  46. package/dist/types.js.map +1 -1
  47. package/package.json +5 -3
  48. package/dist/tools/tool-registry.d.ts +0 -424
  49. package/dist/tools/tool-registry.d.ts.map +0 -1
  50. package/dist/tools/tool-registry.js +0 -607
  51. package/dist/tools/tool-registry.js.map +0 -1
package/dist/agent.js CHANGED
@@ -15,9 +15,10 @@ import { MCPManager } from "./mcp/manager.js";
15
15
  import { applyMiddleware, mergeHooks, setupMiddleware } from "./middleware/index.js";
16
16
  import { createDefaultPromptBuilder } from "./prompt-builder/components.js";
17
17
  import { ACCEPT_EDITS_BLOCKED_PATTERNS } from "./security/index.js";
18
+ import { createSubagent } from "./subagents.js";
18
19
  import { TaskManager } from "./task-manager.js";
20
+ import { createCallToolTool } from "./tools/call-tool.js";
19
21
  import { coreToolsToToolSet, createCoreTools, createSearchToolsTool, createTaskOutputTool, createTaskTool, } from "./tools/factory.js";
20
- import { createUseToolsTool, ToolRegistry } from "./tools/tool-registry.js";
21
22
  let agentIdCounter = 0;
22
23
  /**
23
24
  * Internal signal for interrupt flow control.
@@ -630,43 +631,7 @@ export function createAgent(options) {
630
631
  return `[Background task failed: ${task.id}]\nCommand: ${command}\nError: ${task.error ?? "Unknown error"}`;
631
632
  });
632
633
  // Determine plugin loading mode
633
- // Track whether it was explicitly set to distinguish from default
634
- const explicitPluginLoading = options.pluginLoading !== undefined;
635
634
  const pluginLoadingMode = options.pluginLoading ?? "eager";
636
- const preloadPlugins = new Set(options.preloadPlugins ?? []);
637
- // Initialize tool registry for lazy/explicit loading modes
638
- const toolRegistry = pluginLoadingMode !== "eager"
639
- ? new ToolRegistry({
640
- onToolRegistered: async (input) => {
641
- const hooks = effectiveHooks?.ToolRegistered ?? [];
642
- if (hooks.length === 0)
643
- return;
644
- const hookInput = {
645
- hook_event_name: "ToolRegistered",
646
- session_id: "default",
647
- cwd: process.cwd(),
648
- tool_name: input.tool_name,
649
- description: input.description,
650
- source: input.source,
651
- };
652
- await invokeHooksWithTimeout(hooks, hookInput, null, agent);
653
- },
654
- onToolLoadError: async (input) => {
655
- const hooks = effectiveHooks?.ToolLoadError ?? [];
656
- if (hooks.length === 0)
657
- return;
658
- const hookInput = {
659
- hook_event_name: "ToolLoadError",
660
- session_id: "default",
661
- cwd: process.cwd(),
662
- tool_name: input.tool_name,
663
- error: input.error,
664
- source: input.source,
665
- };
666
- await invokeHooksWithTimeout(hooks, hookInput, null, agent);
667
- },
668
- })
669
- : undefined;
670
635
  // Collect skills from options and plugins
671
636
  const skills = [...(options.skills ?? [])];
672
637
  // Initialize MCP manager for unified plugin tool handling
@@ -728,6 +693,10 @@ export function createAgent(options) {
728
693
  const toolSearchMaxResults = toolSearchConfig.maxResults ?? 10;
729
694
  // Track whether deferred loading is active
730
695
  let deferredLoadingActive = false;
696
+ // Track whether any deferred or proxy plugins exist (for call_tool creation)
697
+ let hasProxiedTools = false;
698
+ // Auto-created subagent definitions from delegated plugins
699
+ const autoSubagents = [];
731
700
  // Count total plugin tools for threshold calculation and collect plugin skills.
732
701
  // Note: Function-based (streaming) tools are not counted since we don't know
733
702
  // their count until they're invoked with a streaming context.
@@ -746,7 +715,7 @@ export function createAgent(options) {
746
715
  // Determine if we should use deferred loading based on tool search settings
747
716
  // Note: Only activate deferred loading if explicitly requested, not based on auto threshold
748
717
  // The auto threshold should only affect whether search_tools is created, not loading behavior
749
- if (toolSearchEnabled === "always") {
718
+ if (toolSearchEnabled === "always" || pluginLoadingMode === "proxy") {
750
719
  deferredLoadingActive = true;
751
720
  }
752
721
  // Removed: auto threshold no longer forces deferred loading
@@ -767,7 +736,7 @@ export function createAgent(options) {
767
736
  ...coreToolsToToolSet(autoCreatedCoreTools),
768
737
  ...(options.tools ?? {}),
769
738
  };
770
- // Process plugins based on loading mode and deferred loading
739
+ // Process plugins based on loading mode, deferred, and delegation settings
771
740
  // Note: Plugin skills are collected earlier (before createCoreTools) so
772
741
  // the skill tool can include them in progressive disclosure.
773
742
  for (const plugin of options.plugins ?? []) {
@@ -775,36 +744,18 @@ export function createAgent(options) {
775
744
  // Note: Function-based (streaming) tools are handled separately in
776
745
  // getActiveToolSetWithStreaming() and are not registered here
777
746
  if (plugin.tools && typeof plugin.tools !== "function") {
778
- const shouldPreload = preloadPlugins.has(plugin.name);
779
- // Priority order:
780
- // 1. Explicit mode - don't register
781
- // 2. Preload - always load immediately
782
- // 3. Explicit eager - always load immediately
783
- // 4. Lazy mode - register with tool registry
784
- // 5. Deferred loading - register but don't load
785
- // 6. Default eager - load immediately
786
- if (pluginLoadingMode === "explicit") {
787
- // Explicit mode: don't auto-register, user must do it manually
788
- // Skip registration entirely
789
- }
790
- else if (shouldPreload) {
791
- // Preloaded plugins: always load immediately, regardless of other settings
747
+ // Check if this plugin is deferred (proxy mode or per-plugin opt-in)
748
+ const isDeferred = plugin.deferred === true || (pluginLoadingMode === "proxy" && plugin.deferred !== false);
749
+ if (isDeferred) {
750
+ // Deferred plugin: register in MCP for discovery via search_tools + call_tool
792
751
  mcpManager.registerPluginTools(plugin.name, plugin.tools, {
793
- autoLoad: true,
794
- });
795
- }
796
- else if (explicitPluginLoading && pluginLoadingMode === "eager") {
797
- // Explicit eager mode: always load immediately, regardless of toolSearch settings
798
- mcpManager.registerPluginTools(plugin.name, plugin.tools, {
799
- autoLoad: true,
752
+ autoLoad: false,
800
753
  });
754
+ hasProxiedTools = true;
801
755
  }
802
- else if (pluginLoadingMode === "lazy" && toolRegistry) {
803
- // Lazy mode: register with registry for on-demand loading
804
- toolRegistry.registerPlugin(plugin.name, plugin.tools);
805
- }
806
- else if (deferredLoadingActive) {
756
+ else if (deferredLoadingActive && plugin.deferred !== false) {
807
757
  // Deferred loading (auto threshold or always enabled): register tools but don't load them initially
758
+ // Respect explicit deferred: false opt-out
808
759
  mcpManager.registerPluginTools(plugin.name, plugin.tools, {
809
760
  autoLoad: false,
810
761
  });
@@ -817,33 +768,61 @@ export function createAgent(options) {
817
768
  }
818
769
  }
819
770
  }
771
+ // Create subagent definitions from plugins with subagent config
772
+ for (const plugin of options.plugins ?? []) {
773
+ if (plugin.subagent) {
774
+ autoSubagents.push({
775
+ type: `plugin-${plugin.name}`,
776
+ description: plugin.subagent.description,
777
+ model: plugin.subagent.model ?? "inherit",
778
+ create: (_ctx) => createSubagent(agent, {
779
+ name: `plugin-${plugin.name}`,
780
+ description: plugin.subagent.description,
781
+ model: _ctx.model,
782
+ tools: plugin.subagent.tools,
783
+ systemPrompt: plugin.subagent.prompt ??
784
+ `You are a ${plugin.name} specialist. Complete the requested task using available tools and return a clear summary.`,
785
+ }),
786
+ });
787
+ }
788
+ }
789
+ // Merge auto-created subagents with user-provided ones
790
+ const allSubagents = [...(options.subagents ?? []), ...autoSubagents];
791
+ const hasSubagents = allSubagents.length > 0;
792
+ // In proxy mode, create call_tool and configure search_tools with schema disclosure
793
+ const isProxyMode = pluginLoadingMode === "proxy" || hasProxiedTools;
794
+ if (isProxyMode && !options.disabledCoreTools?.includes("call_tool")) {
795
+ coreTools.call_tool = createCallToolTool({
796
+ mcpManager,
797
+ });
798
+ }
820
799
  // Create search_tools for MCP tool discovery and/or plugin loading
821
800
  // New behavior:
822
801
  // - Create when auto threshold is exceeded (for lazy discovery)
823
802
  // - Create when deferred loading is active (explicitly requested)
803
+ // - Create when proxy mode is active (for call_tool discovery)
824
804
  // - Create when external MCP servers exist (for MCP tool search)
825
- // - Always auto-load tools when found (no manual load step)
805
+ // - Always auto-load tools when found (no manual load step) — unless proxy mode
826
806
  const shouldCreateSearchToolsForAutoThreshold = toolSearchEnabled === "auto" && totalPluginToolCount > toolSearchThreshold;
827
807
  const shouldCreateSearchTools = !options.disabledCoreTools?.includes("search_tools") &&
828
808
  (deferredLoadingActive ||
809
+ isProxyMode ||
829
810
  shouldCreateSearchToolsForAutoThreshold ||
830
811
  (mcpManager.hasExternalServers() && toolSearchEnabled !== "never"));
831
812
  if (shouldCreateSearchTools) {
832
813
  coreTools.search_tools = createSearchToolsTool({
833
814
  manager: mcpManager,
834
815
  maxResults: toolSearchMaxResults,
835
- enableLoad: true, // Always enable auto-loading
836
- autoLoad: true, // NEW: Auto-load tools after searching
816
+ // In proxy mode: don't auto-load, include schema for call_tool usage
817
+ enableLoad: !isProxyMode,
818
+ autoLoad: !isProxyMode,
819
+ includeSchema: isProxyMode,
837
820
  onToolsLoaded: (toolNames) => {
838
821
  // Tools are now loaded in MCPManager and will be included in getActiveToolSet()
839
822
  // This callback can be used for logging/notifications
840
823
  },
841
824
  });
842
825
  }
843
- // Add use_tools meta-tool in lazy mode
844
- if (pluginLoadingMode === "lazy" && toolRegistry) {
845
- coreTools.use_tools = createUseToolsTool({ registry: toolRegistry });
846
- }
847
826
  /**
848
827
  * Filter a tool set by the allowedTools and disallowedTools restrictions.
849
828
  * If neither is set, returns all tools.
@@ -887,9 +866,6 @@ export function createAgent(options) {
887
866
  const allTools = { ...coreTools };
888
867
  Object.assign(allTools, runtimeTools);
889
868
  Object.assign(allTools, mcpManager.getToolSet());
890
- if (toolRegistry) {
891
- Object.assign(allTools, toolRegistry.getLoadedTools());
892
- }
893
869
  return allTools;
894
870
  })());
895
871
  // Extract tool metadata for context
@@ -926,6 +902,10 @@ export function createAgent(options) {
926
902
  permissionMode,
927
903
  currentMessages: messages,
928
904
  threadId,
905
+ custom: {
906
+ hasSubagents,
907
+ delegationInstructions: options.delegationInstructions,
908
+ },
929
909
  };
930
910
  };
931
911
  // Helper to get system prompt (either static or built from context)
@@ -947,10 +927,6 @@ export function createAgent(options) {
947
927
  // Add MCP tools from plugin registrations
948
928
  const mcpTools = mcpManager.getToolSet();
949
929
  Object.assign(allTools, mcpTools);
950
- // Add dynamically loaded tools from registry (lazy mode)
951
- if (toolRegistry) {
952
- Object.assign(allTools, toolRegistry.getLoadedTools());
953
- }
954
930
  // Apply allowedTools filtering
955
931
  const filtered = filterToolsByAllowed(allTools);
956
932
  // Apply permission mode wrapping with canUseTool callback and approval state
@@ -991,10 +967,6 @@ export function createAgent(options) {
991
967
  // Add MCP tools from plugin registrations
992
968
  const mcpTools = mcpManager.getToolSet();
993
969
  Object.assign(allTools, mcpTools);
994
- // Add dynamically loaded tools from registry (lazy mode)
995
- if (toolRegistry) {
996
- Object.assign(allTools, toolRegistry.getLoadedTools());
997
- }
998
970
  // Apply allowedTools filtering
999
971
  const filtered = filterToolsByAllowed(allTools);
1000
972
  // Apply permission mode wrapping with canUseTool callback and approval state
@@ -1047,7 +1019,7 @@ export function createAgent(options) {
1047
1019
  const result = {
1048
1020
  ...tools,
1049
1021
  task: createTaskTool({
1050
- subagents: options.subagents ?? [],
1022
+ subagents: allSubagents,
1051
1023
  defaultModel: options.model,
1052
1024
  parentAgent: agent,
1053
1025
  // Always include general-purpose subagent so agents can delegate tasks
@@ -1314,6 +1286,250 @@ export function createAgent(options) {
1314
1286
  }
1315
1287
  return null;
1316
1288
  }
1289
+ /**
1290
+ * Collect all tools (core + static plugin tools + MCP tools) for
1291
+ * deterministic tool execution during resume. This is the unwrapped set
1292
+ * — no permission mode, hooks, or signal-catching wrappers applied.
1293
+ */
1294
+ function collectAllTools() {
1295
+ const allTools = { ...coreTools };
1296
+ for (const plugin of options.plugins ?? []) {
1297
+ if (plugin.tools && typeof plugin.tools !== "function") {
1298
+ Object.assign(allTools, plugin.tools);
1299
+ }
1300
+ }
1301
+ Object.assign(allTools, mcpManager.getToolSet());
1302
+ return allTools;
1303
+ }
1304
+ /**
1305
+ * Shared logic for resume() and resumeDataResponse().
1306
+ *
1307
+ * Validates the checkpoint/interrupt, stores the user response, emits hooks,
1308
+ * executes the interrupted tool (approval or custom), updates the checkpoint,
1309
+ * and returns a discriminated outcome so the caller can decide how to continue.
1310
+ */
1311
+ async function executeResumeCore(threadId, interruptId, response, genOptions) {
1312
+ if (!options.checkpointer) {
1313
+ throw new Error("Cannot resume: checkpointer is required");
1314
+ }
1315
+ const checkpoint = await options.checkpointer.load(threadId);
1316
+ if (!checkpoint) {
1317
+ throw new Error(`Cannot resume: no checkpoint found for thread ${threadId}`);
1318
+ }
1319
+ const interrupt = checkpoint.pendingInterrupt;
1320
+ if (!interrupt) {
1321
+ throw new Error(`Cannot resume: no pending interrupt found for thread ${threadId}`);
1322
+ }
1323
+ if (interrupt.id !== interruptId) {
1324
+ throw new Error(`Cannot resume: interrupt ID mismatch. Expected ${interrupt.id}, got ${interruptId}`);
1325
+ }
1326
+ // Store the response keyed by interrupt ID (format: "int_<toolCallId>").
1327
+ // The interrupt() function in the tool wrapper looks up responses using
1328
+ // this exact key format, so we must use interrupt.id — NOT the raw
1329
+ // toolCallId which would never match.
1330
+ pendingResponses.set(interrupt.id, response);
1331
+ // Emit InterruptResolved hook
1332
+ const interruptResolvedHooks = effectiveHooks?.InterruptResolved ?? [];
1333
+ if (interruptResolvedHooks.length > 0) {
1334
+ const isApproval = isApprovalInterrupt(interrupt);
1335
+ const approvalResponse = isApproval ? response : undefined;
1336
+ const hookInput = {
1337
+ hook_event_name: "InterruptResolved",
1338
+ session_id: threadId,
1339
+ cwd: process.cwd(),
1340
+ interrupt_id: interrupt.id,
1341
+ interrupt_type: interrupt.type,
1342
+ tool_call_id: interrupt.toolCallId,
1343
+ tool_name: interrupt.toolName,
1344
+ response,
1345
+ approved: approvalResponse?.approved,
1346
+ };
1347
+ await invokeHooksWithTimeout(interruptResolvedHooks, hookInput, null, agent);
1348
+ }
1349
+ // Handle approval interrupt
1350
+ if (isApprovalInterrupt(interrupt)) {
1351
+ const approvalResponse = response;
1352
+ // For backward compatibility, also store in approvalDecisions
1353
+ approvalDecisions.set(interrupt.toolCallId, approvalResponse.approved);
1354
+ // Build the assistant message with the tool call
1355
+ const assistantMessage = {
1356
+ role: "assistant",
1357
+ content: [
1358
+ {
1359
+ type: "tool-call",
1360
+ toolCallId: interrupt.toolCallId,
1361
+ toolName: interrupt.toolName,
1362
+ input: interrupt.request.args,
1363
+ },
1364
+ ],
1365
+ };
1366
+ let toolResultOutput;
1367
+ if (approvalResponse.approved) {
1368
+ // Approved: Execute the tool deterministically
1369
+ const unwrappedTools = collectAllTools();
1370
+ const tool = unwrappedTools[interrupt.toolName];
1371
+ if (!tool || !tool.execute) {
1372
+ throw new Error(`Cannot resume: tool "${interrupt.toolName}" not found or has no execute function`);
1373
+ }
1374
+ try {
1375
+ toolResultOutput = await tool.execute(interrupt.request.args, {
1376
+ toolCallId: interrupt.toolCallId,
1377
+ messages: checkpoint.messages,
1378
+ abortSignal: genOptions?.signal,
1379
+ });
1380
+ }
1381
+ catch (error) {
1382
+ toolResultOutput = `Tool execution failed: ${error instanceof Error ? error.message : String(error)}`;
1383
+ }
1384
+ }
1385
+ else {
1386
+ // Denied: Create a synthetic denial result
1387
+ toolResultOutput = `Tool "${interrupt.toolName}" was denied by user${approvalResponse.reason ? `: ${approvalResponse.reason}` : ""}`;
1388
+ }
1389
+ // Build the tool result message with proper ToolResultOutput format.
1390
+ // The AI SDK validates messages against modelMessageSchema which
1391
+ // requires output to be { type: 'text', value } or { type: 'json', value }.
1392
+ const approvalOutput = typeof toolResultOutput === "string"
1393
+ ? { type: "text", value: toolResultOutput }
1394
+ : { type: "json", value: toolResultOutput };
1395
+ const toolResultMessage = {
1396
+ role: "tool",
1397
+ content: [
1398
+ {
1399
+ type: "tool-result",
1400
+ toolCallId: interrupt.toolCallId,
1401
+ toolName: interrupt.toolName,
1402
+ output: approvalOutput,
1403
+ },
1404
+ ],
1405
+ };
1406
+ // Update checkpoint with the tool call and result messages, clear interrupt
1407
+ const updatedMessages = [
1408
+ ...checkpoint.messages,
1409
+ assistantMessage,
1410
+ toolResultMessage,
1411
+ ];
1412
+ const updatedCheckpoint = updateCheckpoint(checkpoint, {
1413
+ messages: updatedMessages,
1414
+ pendingInterrupt: undefined,
1415
+ step: checkpoint.step + 1,
1416
+ });
1417
+ await options.checkpointer.save(updatedCheckpoint);
1418
+ threadCheckpoints.set(threadId, updatedCheckpoint);
1419
+ // Clean up the response from our maps
1420
+ pendingResponses.delete(interrupt.id);
1421
+ approvalDecisions.delete(interrupt.toolCallId);
1422
+ return { type: "continue", threadId, genOptions };
1423
+ }
1424
+ // For custom interrupts (e.g. ask_user), manually execute the tool so
1425
+ // that its interrupt() call receives the stored response deterministically.
1426
+ // Re-running generate() would rely on the model re-calling the same tool,
1427
+ // but tool call IDs change each generation so pendingResponses would never
1428
+ // be matched.
1429
+ const customToolCallId = interrupt.toolCallId;
1430
+ const customToolName = interrupt.toolName;
1431
+ if (!customToolCallId || !customToolName) {
1432
+ throw new Error("Cannot resume custom interrupt: missing toolCallId or toolName on interrupt");
1433
+ }
1434
+ // Build the assistant message with the original tool call
1435
+ const customAssistantMessage = {
1436
+ role: "assistant",
1437
+ content: [
1438
+ {
1439
+ type: "tool-call",
1440
+ toolCallId: customToolCallId,
1441
+ toolName: customToolName,
1442
+ input: interrupt.request,
1443
+ },
1444
+ ],
1445
+ };
1446
+ // Collect all tools
1447
+ const customTools = collectAllTools();
1448
+ const customTool = customTools[customToolName];
1449
+ if (!customTool || !customTool.execute) {
1450
+ throw new Error(`Cannot resume: tool "${customToolName}" not found or has no execute function`);
1451
+ }
1452
+ let customToolResult;
1453
+ try {
1454
+ // Execute the tool, providing an interrupt function that returns
1455
+ // the stored user response. This mirrors what happens inside the
1456
+ // permission-mode tool wrapper when pendingResponses has a match.
1457
+ customToolResult = await customTool.execute(interrupt.request, {
1458
+ toolCallId: customToolCallId,
1459
+ messages: checkpoint.messages,
1460
+ abortSignal: genOptions?.signal,
1461
+ interrupt: async (request) => {
1462
+ // First call: return the stored user response (mirrors the
1463
+ // permission-mode wrapper when pendingResponses has a match).
1464
+ if (pendingResponses.has(interrupt.id)) {
1465
+ const stored = pendingResponses.get(interrupt.id);
1466
+ pendingResponses.delete(interrupt.id);
1467
+ return stored;
1468
+ }
1469
+ // Subsequent calls: no stored response — throw InterruptSignal
1470
+ // so the tool can pause again (e.g. multi-step wizards).
1471
+ const newInterruptData = createInterrupt({
1472
+ id: `int_${customToolCallId}`,
1473
+ threadId,
1474
+ type: "custom",
1475
+ toolCallId: customToolCallId,
1476
+ toolName: customToolName,
1477
+ request,
1478
+ step: checkpoint.step,
1479
+ });
1480
+ throw new InterruptSignal(newInterruptData);
1481
+ },
1482
+ });
1483
+ }
1484
+ catch (executeError) {
1485
+ if (isInterruptSignal(executeError)) {
1486
+ // Tool threw another interrupt — persist it and return re-interrupted
1487
+ const newInterrupt = executeError.interrupt;
1488
+ const reInterruptCheckpoint = updateCheckpoint(checkpoint, {
1489
+ pendingInterrupt: newInterrupt,
1490
+ });
1491
+ await options.checkpointer.save(reInterruptCheckpoint);
1492
+ threadCheckpoints.set(threadId, reInterruptCheckpoint);
1493
+ return {
1494
+ type: "re-interrupted",
1495
+ interrupt: newInterrupt,
1496
+ checkpoint: reInterruptCheckpoint,
1497
+ };
1498
+ }
1499
+ customToolResult = `Tool execution failed: ${executeError instanceof Error ? executeError.message : String(executeError)}`;
1500
+ }
1501
+ // Build tool result message with proper ToolResultOutput format.
1502
+ const customOutput = typeof customToolResult === "string"
1503
+ ? { type: "text", value: customToolResult }
1504
+ : { type: "json", value: customToolResult };
1505
+ const customToolResultMessage = {
1506
+ role: "tool",
1507
+ content: [
1508
+ {
1509
+ type: "tool-result",
1510
+ toolCallId: customToolCallId,
1511
+ toolName: customToolName,
1512
+ output: customOutput,
1513
+ },
1514
+ ],
1515
+ };
1516
+ // Update checkpoint with tool call + result, clear interrupt
1517
+ const customUpdatedMessages = [
1518
+ ...checkpoint.messages,
1519
+ customAssistantMessage,
1520
+ customToolResultMessage,
1521
+ ];
1522
+ const customUpdatedCheckpoint = updateCheckpoint(checkpoint, {
1523
+ messages: customUpdatedMessages,
1524
+ pendingInterrupt: undefined,
1525
+ step: checkpoint.step + 1,
1526
+ });
1527
+ await options.checkpointer.save(customUpdatedCheckpoint);
1528
+ threadCheckpoints.set(threadId, customUpdatedCheckpoint);
1529
+ // Clean up
1530
+ pendingResponses.delete(interrupt.id);
1531
+ return { type: "continue", threadId, genOptions };
1532
+ }
1317
1533
  const agent = {
1318
1534
  id,
1319
1535
  options,
@@ -1713,9 +1929,10 @@ export function createAgent(options) {
1713
1929
  const retryState = createRetryLoopState(options.model);
1714
1930
  while (retryState.retryAttempt <= retryState.maxRetries) {
1715
1931
  try {
1716
- const { messages, checkpoint } = await buildMessages(effectiveGenOptions);
1932
+ const { messages, checkpoint, forkedSessionId } = await buildMessages(effectiveGenOptions);
1717
1933
  const maxSteps = options.maxSteps ?? 10;
1718
1934
  const startStep = checkpoint?.step ?? 0;
1935
+ const checkpointThreadId = forkedSessionId ?? effectiveGenOptions.threadId;
1719
1936
  // Signal state for cooperative signal catching in streaming mode
1720
1937
  const signalState = {};
1721
1938
  // Build initial params - use active tools (core + dynamically loaded + task)
@@ -1814,12 +2031,39 @@ export function createAgent(options) {
1814
2031
  steps: mapSteps(steps),
1815
2032
  };
1816
2033
  // Save checkpoint if threadId is provided
1817
- if (effectiveGenOptions.threadId && options.checkpointer) {
2034
+ if (checkpointThreadId && options.checkpointer) {
1818
2035
  const finalMessages = [
1819
2036
  ...messages,
1820
2037
  ...(text ? [{ role: "assistant", content: text }] : []),
1821
2038
  ];
1822
- await saveCheckpoint(effectiveGenOptions.threadId, finalMessages, startStep + steps.length);
2039
+ await saveCheckpoint(checkpointThreadId, finalMessages, startStep + steps.length);
2040
+ }
2041
+ // Save pending interrupt to checkpoint (mirrors generate() pattern)
2042
+ if (signalState.interrupt && checkpointThreadId && options.checkpointer) {
2043
+ const interrupt = signalState.interrupt.interrupt;
2044
+ const savedCheckpoint = threadCheckpoints.get(checkpointThreadId);
2045
+ if (savedCheckpoint) {
2046
+ const withInterrupt = updateCheckpoint(savedCheckpoint, {
2047
+ pendingInterrupt: interrupt,
2048
+ });
2049
+ await options.checkpointer.save(withInterrupt);
2050
+ threadCheckpoints.set(checkpointThreadId, withInterrupt);
2051
+ }
2052
+ // Emit InterruptRequested hook
2053
+ const interruptRequestedHooks = effectiveHooks?.InterruptRequested ?? [];
2054
+ if (interruptRequestedHooks.length > 0) {
2055
+ const hookInput = {
2056
+ hook_event_name: "InterruptRequested",
2057
+ session_id: effectiveGenOptions.threadId ?? "default",
2058
+ cwd: process.cwd(),
2059
+ interrupt_id: interrupt.id,
2060
+ interrupt_type: interrupt.type,
2061
+ tool_call_id: interrupt.toolCallId,
2062
+ tool_name: interrupt.toolName,
2063
+ request: interrupt.request,
2064
+ };
2065
+ await invokeHooksWithTimeout(interruptRequestedHooks, hookInput, null, agent);
2066
+ }
1823
2067
  }
1824
2068
  // Invoke unified PostGenerate hooks
1825
2069
  const postGenerateHooks = effectiveHooks?.PostGenerate ?? [];
@@ -2411,8 +2655,35 @@ export function createAgent(options) {
2411
2655
  writer.merge(result.toUIMessageStream());
2412
2656
  // Wait for initial generation to complete to get final text
2413
2657
  const text = await result.text;
2658
+ // Save pending interrupt to checkpoint (mirrors stream() pattern)
2659
+ if (signalState.interrupt && effectiveGenOptions.threadId && options.checkpointer) {
2660
+ const interrupt = signalState.interrupt.interrupt;
2661
+ const savedCheckpoint = threadCheckpoints.get(effectiveGenOptions.threadId);
2662
+ if (savedCheckpoint) {
2663
+ const withInterrupt = updateCheckpoint(savedCheckpoint, {
2664
+ pendingInterrupt: interrupt,
2665
+ });
2666
+ await options.checkpointer.save(withInterrupt);
2667
+ threadCheckpoints.set(effectiveGenOptions.threadId, withInterrupt);
2668
+ }
2669
+ // Emit InterruptRequested hook
2670
+ const interruptRequestedHooks = effectiveHooks?.InterruptRequested ?? [];
2671
+ if (interruptRequestedHooks.length > 0) {
2672
+ const hookInput = {
2673
+ hook_event_name: "InterruptRequested",
2674
+ session_id: effectiveGenOptions.threadId ?? "default",
2675
+ cwd: process.cwd(),
2676
+ interrupt_id: interrupt.id,
2677
+ interrupt_type: interrupt.type,
2678
+ tool_call_id: interrupt.toolCallId,
2679
+ tool_name: interrupt.toolName,
2680
+ request: interrupt.request,
2681
+ };
2682
+ await invokeHooksWithTimeout(interruptRequestedHooks, hookInput, null, agent);
2683
+ }
2684
+ }
2414
2685
  // --- Background task completion loop ---
2415
- if (waitForBackgroundTasks) {
2686
+ if (waitForBackgroundTasks && !signalState.interrupt) {
2416
2687
  // Track accumulated steps for checkpoint saves
2417
2688
  const initialSteps = await result.steps;
2418
2689
  let accumulatedStepCount = initialSteps.length;
@@ -2526,17 +2797,6 @@ export function createAgent(options) {
2526
2797
  delete runtimeTools[name];
2527
2798
  }
2528
2799
  },
2529
- loadTools(toolNames) {
2530
- if (!toolRegistry) {
2531
- // No registry in eager mode - all tools already loaded
2532
- return { loaded: [], notFound: toolNames };
2533
- }
2534
- const result = toolRegistry.load(toolNames);
2535
- return {
2536
- loaded: result.loaded,
2537
- notFound: result.notFound,
2538
- };
2539
- },
2540
2800
  setPermissionMode(mode) {
2541
2801
  permissionMode = mode;
2542
2802
  },
@@ -2548,251 +2808,33 @@ export function createAgent(options) {
2548
2808
  return checkpoint?.pendingInterrupt;
2549
2809
  },
2550
2810
  async resume(threadId, interruptId, response, genOptions) {
2551
- if (!options.checkpointer) {
2552
- throw new Error("Cannot resume: checkpointer is required");
2553
- }
2554
- const checkpoint = await options.checkpointer.load(threadId);
2555
- if (!checkpoint) {
2556
- throw new Error(`Cannot resume: no checkpoint found for thread ${threadId}`);
2557
- }
2558
- const interrupt = checkpoint.pendingInterrupt;
2559
- if (!interrupt) {
2560
- throw new Error(`Cannot resume: no pending interrupt found for thread ${threadId}`);
2561
- }
2562
- if (interrupt.id !== interruptId) {
2563
- throw new Error(`Cannot resume: interrupt ID mismatch. Expected ${interrupt.id}, got ${interruptId}`);
2564
- }
2565
- // Store the response keyed by interrupt ID (format: "int_<toolCallId>").
2566
- // The interrupt() function in the tool wrapper looks up responses using
2567
- // this exact key format, so we must use interrupt.id — NOT the raw
2568
- // toolCallId which would never match.
2569
- pendingResponses.set(interrupt.id, response);
2570
- // Emit InterruptResolved hook
2571
- const interruptResolvedHooks = effectiveHooks?.InterruptResolved ?? [];
2572
- if (interruptResolvedHooks.length > 0) {
2573
- const isApproval = isApprovalInterrupt(interrupt);
2574
- const approvalResponse = isApproval ? response : undefined;
2575
- const hookInput = {
2576
- hook_event_name: "InterruptResolved",
2577
- session_id: threadId,
2578
- cwd: process.cwd(),
2579
- interrupt_id: interrupt.id,
2580
- interrupt_type: interrupt.type,
2581
- tool_call_id: interrupt.toolCallId,
2582
- tool_name: interrupt.toolName,
2583
- response,
2584
- approved: approvalResponse?.approved,
2811
+ const outcome = await executeResumeCore(threadId, interruptId, response, genOptions);
2812
+ if (outcome.type === "re-interrupted") {
2813
+ return {
2814
+ status: "interrupted",
2815
+ interrupt: outcome.interrupt,
2816
+ partial: undefined,
2585
2817
  };
2586
- await invokeHooksWithTimeout(interruptResolvedHooks, hookInput, null, agent);
2587
2818
  }
2588
- // Handle approval interrupt
2589
- if (isApprovalInterrupt(interrupt)) {
2590
- const approvalResponse = response;
2591
- // For backward compatibility, also store in approvalDecisions
2592
- approvalDecisions.set(interrupt.toolCallId, approvalResponse.approved);
2593
- // Build the assistant message with the tool call
2594
- const assistantMessage = {
2595
- role: "assistant",
2596
- content: [
2597
- {
2598
- type: "tool-call",
2599
- toolCallId: interrupt.toolCallId,
2600
- toolName: interrupt.toolName,
2601
- input: interrupt.request.args,
2602
- },
2603
- ],
2604
- };
2605
- let toolResultOutput;
2606
- if (approvalResponse.approved) {
2607
- // Approved: Execute the tool deterministically
2608
- const unwrappedTools = { ...coreTools };
2609
- for (const plugin of options.plugins ?? []) {
2610
- if (plugin.tools && typeof plugin.tools !== "function") {
2611
- Object.assign(unwrappedTools, plugin.tools);
2612
- }
2613
- }
2614
- const mcpTools = mcpManager.getToolSet();
2615
- Object.assign(unwrappedTools, mcpTools);
2616
- const tool = unwrappedTools[interrupt.toolName];
2617
- if (!tool || !tool.execute) {
2618
- throw new Error(`Cannot resume: tool "${interrupt.toolName}" not found or has no execute function`);
2619
- }
2620
- try {
2621
- toolResultOutput = await tool.execute(interrupt.request.args, {
2622
- toolCallId: interrupt.toolCallId,
2623
- messages: checkpoint.messages,
2624
- abortSignal: genOptions?.signal,
2625
- });
2626
- }
2627
- catch (error) {
2628
- toolResultOutput = `Tool execution failed: ${error instanceof Error ? error.message : String(error)}`;
2629
- }
2630
- }
2631
- else {
2632
- // Denied: Create a synthetic denial result
2633
- toolResultOutput = `Tool "${interrupt.toolName}" was denied by user${approvalResponse.reason ? `: ${approvalResponse.reason}` : ""}`;
2634
- }
2635
- // Build the tool result message with proper ToolResultOutput format.
2636
- // The AI SDK validates messages against modelMessageSchema which
2637
- // requires output to be { type: 'text', value } or { type: 'json', value }.
2638
- const approvalOutput = typeof toolResultOutput === "string"
2639
- ? { type: "text", value: toolResultOutput }
2640
- : { type: "json", value: toolResultOutput };
2641
- const toolResultMessage = {
2642
- role: "tool",
2643
- content: [
2644
- {
2645
- type: "tool-result",
2646
- toolCallId: interrupt.toolCallId,
2647
- toolName: interrupt.toolName,
2648
- output: approvalOutput,
2649
- },
2650
- ],
2651
- };
2652
- // Update checkpoint with the tool call and result messages, clear interrupt
2653
- const updatedMessages = [
2654
- ...checkpoint.messages,
2655
- assistantMessage,
2656
- toolResultMessage,
2657
- ];
2658
- const updatedCheckpoint = updateCheckpoint(checkpoint, {
2659
- messages: updatedMessages,
2660
- pendingInterrupt: undefined,
2661
- step: checkpoint.step + 1,
2662
- });
2663
- await options.checkpointer.save(updatedCheckpoint);
2664
- // Clean up the response from our maps
2665
- pendingResponses.delete(interrupt.id);
2666
- approvalDecisions.delete(interrupt.toolCallId);
2667
- // Continue generation from the updated checkpoint
2668
- return agent.generate({
2669
- threadId,
2670
- ...genOptions,
2671
- prompt: undefined,
2672
- });
2673
- }
2674
- // For custom interrupts (e.g. ask_user), manually execute the tool so
2675
- // that its interrupt() call receives the stored response deterministically.
2676
- // Re-running generate() would rely on the model re-calling the same tool,
2677
- // but tool call IDs change each generation so pendingResponses would never
2678
- // be matched.
2679
- {
2680
- const customToolCallId = interrupt.toolCallId;
2681
- const customToolName = interrupt.toolName;
2682
- if (!customToolCallId || !customToolName) {
2683
- throw new Error("Cannot resume custom interrupt: missing toolCallId or toolName on interrupt");
2684
- }
2685
- // Build the assistant message with the original tool call
2686
- const customAssistantMessage = {
2687
- role: "assistant",
2688
- content: [
2689
- {
2690
- type: "tool-call",
2691
- toolCallId: customToolCallId,
2692
- toolName: customToolName,
2693
- input: interrupt.request,
2694
- },
2695
- ],
2696
- };
2697
- // Collect all tools (same pattern as the approval path above)
2698
- const customTools = { ...coreTools };
2699
- for (const plugin of options.plugins ?? []) {
2700
- if (plugin.tools && typeof plugin.tools !== "function") {
2701
- Object.assign(customTools, plugin.tools);
2702
- }
2703
- }
2704
- const customMcpTools = mcpManager.getToolSet();
2705
- Object.assign(customTools, customMcpTools);
2706
- const customTool = customTools[customToolName];
2707
- if (!customTool || !customTool.execute) {
2708
- throw new Error(`Cannot resume: tool "${customToolName}" not found or has no execute function`);
2709
- }
2710
- let customToolResult;
2711
- try {
2712
- // Execute the tool, providing an interrupt function that returns
2713
- // the stored user response. This mirrors what happens inside the
2714
- // permission-mode tool wrapper when pendingResponses has a match.
2715
- customToolResult = await customTool.execute(interrupt.request, {
2716
- toolCallId: customToolCallId,
2717
- messages: checkpoint.messages,
2718
- abortSignal: genOptions?.signal,
2719
- interrupt: async (request) => {
2720
- // First call: return the stored user response (mirrors the
2721
- // permission-mode wrapper when pendingResponses has a match).
2722
- if (pendingResponses.has(interrupt.id)) {
2723
- const stored = pendingResponses.get(interrupt.id);
2724
- pendingResponses.delete(interrupt.id);
2725
- return stored;
2726
- }
2727
- // Subsequent calls: no stored response — throw InterruptSignal
2728
- // so the tool can pause again (e.g. multi-step wizards).
2729
- const newInterruptData = createInterrupt({
2730
- id: `int_${customToolCallId}`,
2731
- threadId,
2732
- type: "custom",
2733
- toolCallId: customToolCallId,
2734
- toolName: customToolName,
2735
- request,
2736
- step: checkpoint.step,
2737
- });
2738
- throw new InterruptSignal(newInterruptData);
2739
- },
2740
- });
2741
- }
2742
- catch (executeError) {
2743
- if (isInterruptSignal(executeError)) {
2744
- // Tool threw another interrupt — persist it and return interrupted
2745
- const newInterrupt = executeError.interrupt;
2746
- const reInterruptCheckpoint = updateCheckpoint(checkpoint, {
2747
- pendingInterrupt: newInterrupt,
2748
- });
2749
- await options.checkpointer.save(reInterruptCheckpoint);
2750
- threadCheckpoints.set(threadId, reInterruptCheckpoint);
2751
- return {
2752
- status: "interrupted",
2753
- interrupt: newInterrupt,
2754
- partial: undefined,
2755
- };
2756
- }
2757
- customToolResult = `Tool execution failed: ${executeError instanceof Error ? executeError.message : String(executeError)}`;
2758
- }
2759
- // Build tool result message with proper ToolResultOutput format.
2760
- const customOutput = typeof customToolResult === "string"
2761
- ? { type: "text", value: customToolResult }
2762
- : { type: "json", value: customToolResult };
2763
- const customToolResultMessage = {
2764
- role: "tool",
2765
- content: [
2766
- {
2767
- type: "tool-result",
2768
- toolCallId: customToolCallId,
2769
- toolName: customToolName,
2770
- output: customOutput,
2771
- },
2772
- ],
2773
- };
2774
- // Update checkpoint with tool call + result, clear interrupt
2775
- const customUpdatedMessages = [
2776
- ...checkpoint.messages,
2777
- customAssistantMessage,
2778
- customToolResultMessage,
2779
- ];
2780
- const customUpdatedCheckpoint = updateCheckpoint(checkpoint, {
2781
- messages: customUpdatedMessages,
2782
- pendingInterrupt: undefined,
2783
- step: checkpoint.step + 1,
2784
- });
2785
- await options.checkpointer.save(customUpdatedCheckpoint);
2786
- threadCheckpoints.set(threadId, customUpdatedCheckpoint);
2787
- // Clean up
2788
- pendingResponses.delete(interrupt.id);
2789
- // Continue generation from the updated checkpoint
2790
- return agent.generate({
2791
- threadId,
2792
- ...genOptions,
2793
- prompt: undefined,
2794
- });
2819
+ return agent.generate({
2820
+ threadId: outcome.threadId,
2821
+ ...outcome.genOptions,
2822
+ prompt: undefined,
2823
+ });
2824
+ },
2825
+ async resumeDataResponse(threadId, interruptId, response, genOptions) {
2826
+ const outcome = await executeResumeCore(threadId, interruptId, response, genOptions);
2827
+ if (outcome.type === "re-interrupted") {
2828
+ // Return an empty response — the client already has the interrupt widget.
2829
+ // The new interrupt is persisted to the checkpoint and retrievable
2830
+ // via getInterrupt().
2831
+ return new Response(null, { status: 204 });
2795
2832
  }
2833
+ return agent.streamDataResponse({
2834
+ threadId: outcome.threadId,
2835
+ ...outcome.genOptions,
2836
+ prompt: undefined,
2837
+ });
2796
2838
  },
2797
2839
  async dispose() {
2798
2840
  // Kill all running background tasks