@4onstudios/iris-agent 0.1.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 (216) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +690 -0
  3. package/dist/api/acp/acpServer.d.ts +23 -0
  4. package/dist/api/acp/acpServer.js +814 -0
  5. package/dist/api/acp/index.d.ts +2 -0
  6. package/dist/api/acp/index.js +2 -0
  7. package/dist/api/acp/irisClient.d.ts +58 -0
  8. package/dist/api/acp/irisClient.js +214 -0
  9. package/dist/api/agent.d.ts +14 -0
  10. package/dist/api/agent.js +4171 -0
  11. package/dist/api/backendService.d.ts +4 -0
  12. package/dist/api/backendService.js +14 -0
  13. package/dist/api/core/agent/host/AgentContract.d.ts +82 -0
  14. package/dist/api/core/agent/host/AgentContract.js +1 -0
  15. package/dist/api/core/agent/host/AgentRegistry.d.ts +24 -0
  16. package/dist/api/core/agent/host/AgentRegistry.js +125 -0
  17. package/dist/api/core/agent/host/defaultRegistry.d.ts +15 -0
  18. package/dist/api/core/agent/host/defaultRegistry.js +46 -0
  19. package/dist/api/core/agent/host/externalAgentLifecycle.d.ts +65 -0
  20. package/dist/api/core/agent/host/externalAgentLifecycle.js +117 -0
  21. package/dist/api/core/agent/host/hostSessionManager.d.ts +111 -0
  22. package/dist/api/core/agent/host/hostSessionManager.js +352 -0
  23. package/dist/api/core/agent/host/index.d.ts +5 -0
  24. package/dist/api/core/agent/host/index.js +5 -0
  25. package/dist/api/core/agent/index.d.ts +87 -0
  26. package/dist/api/core/agent/index.js +1024 -0
  27. package/dist/api/core/agent/publicContracts.d.ts +35 -0
  28. package/dist/api/core/agent/publicContracts.js +103 -0
  29. package/dist/api/core/agent/tools/applyDiff.d.ts +51 -0
  30. package/dist/api/core/agent/tools/applyDiff.js +131 -0
  31. package/dist/api/core/agent/tools/backgroundTasks.d.ts +240 -0
  32. package/dist/api/core/agent/tools/backgroundTasks.js +313 -0
  33. package/dist/api/core/agent/tools/createDirectory.d.ts +43 -0
  34. package/dist/api/core/agent/tools/createDirectory.js +77 -0
  35. package/dist/api/core/agent/tools/deleteFile.d.ts +37 -0
  36. package/dist/api/core/agent/tools/deleteFile.js +66 -0
  37. package/dist/api/core/agent/tools/editFile.d.ts +73 -0
  38. package/dist/api/core/agent/tools/editFile.js +173 -0
  39. package/dist/api/core/agent/tools/executeCommand.d.ts +59 -0
  40. package/dist/api/core/agent/tools/executeCommand.js +250 -0
  41. package/dist/api/core/agent/tools/fileContent.d.ts +54 -0
  42. package/dist/api/core/agent/tools/fileContent.js +227 -0
  43. package/dist/api/core/agent/tools/findDefinition.d.ts +72 -0
  44. package/dist/api/core/agent/tools/findDefinition.js +109 -0
  45. package/dist/api/core/agent/tools/findReferences.d.ts +86 -0
  46. package/dist/api/core/agent/tools/findReferences.js +135 -0
  47. package/dist/api/core/agent/tools/formatDocument.d.ts +67 -0
  48. package/dist/api/core/agent/tools/formatDocument.js +90 -0
  49. package/dist/api/core/agent/tools/getCodeActions.d.ts +77 -0
  50. package/dist/api/core/agent/tools/getCodeActions.js +97 -0
  51. package/dist/api/core/agent/tools/getCodeCompletion.d.ts +77 -0
  52. package/dist/api/core/agent/tools/getCodeCompletion.js +174 -0
  53. package/dist/api/core/agent/tools/getCodeContext.d.ts +14 -0
  54. package/dist/api/core/agent/tools/getCodeContext.js +282 -0
  55. package/dist/api/core/agent/tools/getSignatureHelp.d.ts +61 -0
  56. package/dist/api/core/agent/tools/getSignatureHelp.js +85 -0
  57. package/dist/api/core/agent/tools/getSymbols.d.ts +50 -0
  58. package/dist/api/core/agent/tools/getSymbols.js +164 -0
  59. package/dist/api/core/agent/tools/getSymbolsLSP.d.ts +58 -0
  60. package/dist/api/core/agent/tools/getSymbolsLSP.js +147 -0
  61. package/dist/api/core/agent/tools/getTypeInfo.d.ts +70 -0
  62. package/dist/api/core/agent/tools/getTypeInfo.js +84 -0
  63. package/dist/api/core/agent/tools/getWorkspaceInfo.d.ts +80 -0
  64. package/dist/api/core/agent/tools/getWorkspaceInfo.js +281 -0
  65. package/dist/api/core/agent/tools/getWorkspaceSymbols.d.ts +53 -0
  66. package/dist/api/core/agent/tools/getWorkspaceSymbols.js +97 -0
  67. package/dist/api/core/agent/tools/grepSearch.d.ts +100 -0
  68. package/dist/api/core/agent/tools/grepSearch.js +211 -0
  69. package/dist/api/core/agent/tools/languageModelToolsIntegration.d.ts +13 -0
  70. package/dist/api/core/agent/tools/languageModelToolsIntegration.js +22 -0
  71. package/dist/api/core/agent/tools/listDirectory.d.ts +66 -0
  72. package/dist/api/core/agent/tools/listDirectory.js +161 -0
  73. package/dist/api/core/agent/tools/mcpTools.d.ts +48 -0
  74. package/dist/api/core/agent/tools/mcpTools.js +442 -0
  75. package/dist/api/core/agent/tools/queryKnowledgeGraph.d.ts +120 -0
  76. package/dist/api/core/agent/tools/queryKnowledgeGraph.js +306 -0
  77. package/dist/api/core/agent/tools/readFile.d.ts +92 -0
  78. package/dist/api/core/agent/tools/readFile.js +337 -0
  79. package/dist/api/core/agent/tools/renameFile.d.ts +49 -0
  80. package/dist/api/core/agent/tools/renameFile.js +86 -0
  81. package/dist/api/core/agent/tools/renameSymbol.d.ts +70 -0
  82. package/dist/api/core/agent/tools/renameSymbol.js +106 -0
  83. package/dist/api/core/agent/tools/runTerminalCommand.d.ts +72 -0
  84. package/dist/api/core/agent/tools/runTerminalCommand.js +69 -0
  85. package/dist/api/core/agent/tools/searchFiles.d.ts +82 -0
  86. package/dist/api/core/agent/tools/searchFiles.js +280 -0
  87. package/dist/api/core/agent/tools/terminalAutoApprove.d.ts +3 -0
  88. package/dist/api/core/agent/tools/terminalAutoApprove.js +676 -0
  89. package/dist/api/core/agent/tools/treeTraversal.d.ts +60 -0
  90. package/dist/api/core/agent/tools/treeTraversal.js +217 -0
  91. package/dist/api/core/agent/tools/webSearch.d.ts +37 -0
  92. package/dist/api/core/agent/tools/webSearch.js +80 -0
  93. package/dist/api/core/agent/tools/writeFile.d.ts +59 -0
  94. package/dist/api/core/agent/tools/writeFile.js +97 -0
  95. package/dist/api/core/agent/utils/capturedWorkspaceMutationBridge.d.ts +12 -0
  96. package/dist/api/core/agent/utils/capturedWorkspaceMutationBridge.js +67 -0
  97. package/dist/api/core/agent/utils/diffUtils.d.ts +14 -0
  98. package/dist/api/core/agent/utils/diffUtils.js +39 -0
  99. package/dist/api/core/agent/utils/environmentSnapshot.d.ts +42 -0
  100. package/dist/api/core/agent/utils/environmentSnapshot.js +213 -0
  101. package/dist/api/core/agent/utils/errorRecovery.d.ts +49 -0
  102. package/dist/api/core/agent/utils/errorRecovery.js +229 -0
  103. package/dist/api/core/agent/utils/multimodalTokenLimiter.d.ts +8 -0
  104. package/dist/api/core/agent/utils/multimodalTokenLimiter.js +238 -0
  105. package/dist/api/core/agent/utils/openRouterModelSettings.d.ts +12 -0
  106. package/dist/api/core/agent/utils/openRouterModelSettings.js +34 -0
  107. package/dist/api/core/agent/utils/pathRecovery.d.ts +1 -0
  108. package/dist/api/core/agent/utils/pathRecovery.js +29 -0
  109. package/dist/api/core/agent/utils/repoMapIndex.d.ts +3 -0
  110. package/dist/api/core/agent/utils/repoMapIndex.js +197 -0
  111. package/dist/api/core/agent/utils/skillsDiscovery.d.ts +10 -0
  112. package/dist/api/core/agent/utils/skillsDiscovery.js +50 -0
  113. package/dist/api/core/agent/utils/toolCallBudget.d.ts +9 -0
  114. package/dist/api/core/agent/utils/toolCallBudget.js +55 -0
  115. package/dist/api/core/agent/utils/toolLifecycle.d.ts +30 -0
  116. package/dist/api/core/agent/utils/toolLifecycle.js +361 -0
  117. package/dist/api/core/agent/utils/toolResultSafetyProcessor.d.ts +12 -0
  118. package/dist/api/core/agent/utils/toolResultSafetyProcessor.js +57 -0
  119. package/dist/api/core/agent/utils/workspaceMutationBridge.d.ts +13 -0
  120. package/dist/api/core/agent/utils/workspaceMutationBridge.js +149 -0
  121. package/dist/api/core/agent/utils/workspacePathGuard.d.ts +7 -0
  122. package/dist/api/core/agent/utils/workspacePathGuard.js +79 -0
  123. package/dist/api/core/containers/chat/toolResultSerialization.d.ts +8 -0
  124. package/dist/api/core/containers/chat/toolResultSerialization.js +71 -0
  125. package/dist/api/core/library/BrowserManager.d.ts +11 -0
  126. package/dist/api/core/library/BrowserManager.js +66 -0
  127. package/dist/api/core/library/desktopWorkspace.d.ts +1 -0
  128. package/dist/api/core/library/desktopWorkspace.js +3 -0
  129. package/dist/api/core/library/extensionManager.d.ts +10 -0
  130. package/dist/api/core/library/extensionManager.js +27 -0
  131. package/dist/api/core/library/knowledgeGraph.d.ts +268 -0
  132. package/dist/api/core/library/knowledgeGraph.js +989 -0
  133. package/dist/api/core/library/languageModelTools.d.ts +141 -0
  134. package/dist/api/core/library/languageModelTools.js +207 -0
  135. package/dist/api/core/library/localRuntime.d.ts +23 -0
  136. package/dist/api/core/library/localRuntime.js +144 -0
  137. package/dist/api/core/library/lsp/coreLsp.d.ts +367 -0
  138. package/dist/api/core/library/lsp/coreLsp.js +2076 -0
  139. package/dist/api/core/library/lsp/protocol.d.ts +25 -0
  140. package/dist/api/core/library/lsp/protocol.js +1 -0
  141. package/dist/api/core/library/lsp/serverManager.d.ts +37 -0
  142. package/dist/api/core/library/lsp/serverManager.js +427 -0
  143. package/dist/api/core/library/mcpServerProvider.d.ts +69 -0
  144. package/dist/api/core/library/mcpServerProvider.js +152 -0
  145. package/dist/api/core/library/mcpSettings.d.ts +20 -0
  146. package/dist/api/core/library/mcpSettings.js +131 -0
  147. package/dist/api/core/library/patternMatcher.d.ts +66 -0
  148. package/dist/api/core/library/patternMatcher.js +284 -0
  149. package/dist/api/core/library/regexEscape.d.ts +4 -0
  150. package/dist/api/core/library/regexEscape.js +4 -0
  151. package/dist/api/core/library/runtimeEventBus.d.ts +27 -0
  152. package/dist/api/core/library/runtimeEventBus.js +78 -0
  153. package/dist/api/core/library/safetyMiddleware.d.ts +98 -0
  154. package/dist/api/core/library/safetyMiddleware.js +215 -0
  155. package/dist/api/core/library/tauri.d.ts +71 -0
  156. package/dist/api/core/library/tauri.js +222 -0
  157. package/dist/api/core/library/tauriImport.d.ts +5 -0
  158. package/dist/api/core/library/tauriImport.js +7 -0
  159. package/dist/api/core/library/terminalAutoApproveSettings.d.ts +32 -0
  160. package/dist/api/core/library/terminalAutoApproveSettings.js +529 -0
  161. package/dist/api/core/library/workspaceIdentity.d.ts +10 -0
  162. package/dist/api/core/library/workspaceIdentity.js +49 -0
  163. package/dist/api/core/library/workspaceSummary.d.ts +19 -0
  164. package/dist/api/core/library/workspaceSummary.js +35 -0
  165. package/dist/api/core/skills/agent-customization/SKILL.md +27 -0
  166. package/dist/api/core/skills/bug-fix/SKILL.md +21 -0
  167. package/dist/api/core/skills/create-pr/SKILL.md +32 -0
  168. package/dist/api/core/skills/dev-server/SKILL.md +20 -0
  169. package/dist/api/core/skills/fix-suggestions/SKILL.md +21 -0
  170. package/dist/api/core/skills/github-search/SKILL.md +21 -0
  171. package/dist/api/core/skills/github-summary/SKILL.md +25 -0
  172. package/dist/api/core/skills/integration-tests/SKILL.md +29 -0
  173. package/dist/api/core/skills/pr-comments/SKILL.md +23 -0
  174. package/dist/api/core/skills/project-setup/SKILL.md +20 -0
  175. package/dist/api/core/skills/search-integration/SKILL.md +19 -0
  176. package/dist/api/core/skills/search-results/SKILL.md +19 -0
  177. package/dist/api/core/skills/typescript-upgrade/SKILL.md +21 -0
  178. package/dist/api/data/runStore.d.ts +46 -0
  179. package/dist/api/data/runStore.js +244 -0
  180. package/dist/api/helpers/agentUtils.d.ts +11 -0
  181. package/dist/api/helpers/agentUtils.js +23 -0
  182. package/dist/api/helpers/modelTokenLimits.d.ts +2 -0
  183. package/dist/api/helpers/modelTokenLimits.js +158 -0
  184. package/dist/api/helpers/observationalMemory.d.ts +10 -0
  185. package/dist/api/helpers/observationalMemory.js +36 -0
  186. package/dist/api/helpers/promptBudget.d.ts +30 -0
  187. package/dist/api/helpers/promptBudget.js +216 -0
  188. package/dist/api/helpers/resolveImageMessageParts.d.ts +9 -0
  189. package/dist/api/helpers/resolveImageMessageParts.js +140 -0
  190. package/dist/api/helpers/slashCommands.d.ts +31 -0
  191. package/dist/api/helpers/slashCommands.js +129 -0
  192. package/dist/api/helpers/tokenUsage.d.ts +19 -0
  193. package/dist/api/helpers/tokenUsage.js +81 -0
  194. package/dist/api/routes/fileRoutes.d.ts +2 -0
  195. package/dist/api/routes/fileRoutes.js +108 -0
  196. package/dist/api/routes/lspDocumentRoutes.d.ts +4 -0
  197. package/dist/api/routes/lspDocumentRoutes.js +84 -0
  198. package/dist/api/routes/lspHierarchyRoutes.d.ts +4 -0
  199. package/dist/api/routes/lspHierarchyRoutes.js +122 -0
  200. package/dist/api/routes/lspPositionRoutes.d.ts +4 -0
  201. package/dist/api/routes/lspPositionRoutes.js +263 -0
  202. package/dist/api/routes/lspQueryRoutes.d.ts +4 -0
  203. package/dist/api/routes/lspQueryRoutes.js +679 -0
  204. package/dist/api/routes/lspResolveRoutes.d.ts +4 -0
  205. package/dist/api/routes/lspResolveRoutes.js +143 -0
  206. package/dist/api/routes/lspSemanticDocumentRoutes.d.ts +4 -0
  207. package/dist/api/routes/lspSemanticDocumentRoutes.js +98 -0
  208. package/dist/api/routes/semanticRoutes.d.ts +2 -0
  209. package/dist/api/routes/semanticRoutes.js +126 -0
  210. package/dist/cli.d.ts +8 -0
  211. package/dist/cli.js +160 -0
  212. package/dist/index.d.ts +1 -0
  213. package/dist/index.js +1 -0
  214. package/dist/server.d.ts +2 -0
  215. package/dist/server.js +38 -0
  216. package/package.json +100 -0
@@ -0,0 +1,4171 @@
1
+ import express from "express";
2
+ import os from "os";
3
+ import { randomUUID } from "node:crypto";
4
+ import fs from "fs/promises";
5
+ import path from "path";
6
+ import { truncateEnvironmentResponse } from "./helpers/agentUtils.js";
7
+ import { getEnvironmentSnapshot, formatSnapshotAsMarkdown, } from "./core/agent/utils/environmentSnapshot.js";
8
+ import { sanitizeMcpServers, toStableMcpFingerprint, } from "./core/library/mcpSettings.js";
9
+ import { getTerminalAutoApproveRulesFingerprint, sanitizeTerminalAutoApproveRules, } from "./core/library/terminalAutoApproveSettings.js";
10
+ import { executeMcpToolByKey, listMcpServerTools, } from "./core/agent/tools/mcpTools.js";
11
+ import { initializeLanguageModelTools, getAvailableTools, } from "./core/agent/tools/languageModelToolsIntegration.js";
12
+ import { executeCommand } from "./core/agent/tools/executeCommand.js";
13
+ import { countUniqueToolCalls, getToolCallSignature, normalizeToolLifecycle, resolveToolExecutionStatus, } from "./core/agent/utils/toolLifecycle.js";
14
+ import { redactToolResult } from "./core/agent/utils/toolResultSafetyProcessor.js";
15
+ import { serializeToolResultsForContinuation } from "./core/containers/chat/toolResultSerialization.js";
16
+ import { createDefaultAgentRegistry, ExternalAgentLifecycleManager, HostSessionManager, } from "./core/agent/host/index.js";
17
+ import { truncateText, buildPromptWithinTokenBudget, resolveModelInputTokenLimit, resolveModelSupportsVision, } from "./helpers/promptBudget.js";
18
+ import { isLikelyImageFile, resolveImageMessageParts, } from "./helpers/resolveImageMessageParts.js";
19
+ import { parseSlashCommandRequest, getSlashCommandDescriptors, executeRegisteredSlashCommand, isSlashCommandsFeatureEnabled, } from "./helpers/slashCommands.js";
20
+ import { normalizeTokenUsage, mergeTokenUsage, extractTokenUsageFromChunkPayload, logTokenUsageSource, buildTokenUsageDebug, } from "./helpers/tokenUsage.js";
21
+ import { sanitizeObservationalMemorySettings, } from "./helpers/observationalMemory.js";
22
+ import { registerFileRoutes } from "./routes/fileRoutes.js";
23
+ import { registerLspDocumentRoutes } from "./routes/lspDocumentRoutes.js";
24
+ import { registerLspHierarchyRoutes } from "./routes/lspHierarchyRoutes.js";
25
+ import { registerLspPositionRoutes } from "./routes/lspPositionRoutes.js";
26
+ import { registerLspQueryRoutes } from "./routes/lspQueryRoutes.js";
27
+ import { registerLspResolveRoutes } from "./routes/lspResolveRoutes.js";
28
+ import { registerLspSemanticDocumentRoutes } from "./routes/lspSemanticDocumentRoutes.js";
29
+ import { registerSemanticRoutes } from "./routes/semanticRoutes.js";
30
+ import { asRunLifecycleState, deleteRunDataBatch, getRunSnapshot, isRunCancellationRequested, isSafeRunId, listRunEvents, requestRunCancellation, safePersistRunLifecycleEvent, } from "./data/runStore.js";
31
+ const router = express.Router();
32
+ const activeChatSessionTurns = new Set();
33
+ /**
34
+ * Safely extract error message from any thrown value.
35
+ * Handles Error objects, strings, null/undefined, and other values.
36
+ */
37
+ const getErrorMessage = (err) => {
38
+ if (err instanceof Error) {
39
+ return err.message;
40
+ }
41
+ if (typeof err === "string") {
42
+ return err;
43
+ }
44
+ if (err === null || err === undefined) {
45
+ return String(err);
46
+ }
47
+ return String(err);
48
+ };
49
+ const sanitizeToolArgsForWorkspace = (toolName, args, isWebWorkspace) => {
50
+ const normalizedArgs = args || {};
51
+ if (!isWebWorkspace || toolName !== "getWorkspaceInfo") {
52
+ return normalizedArgs;
53
+ }
54
+ const sanitizedArgs = { ...normalizedArgs };
55
+ delete sanitizedArgs.workspacePath;
56
+ return sanitizedArgs;
57
+ };
58
+ const isRecordValue = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
59
+ const resolveToolResultArgs = (toolName, toolCallId, explicitArgs, argsByCallId, anonymousCallArgs) => {
60
+ const explicitRecord = isRecordValue(explicitArgs)
61
+ ? explicitArgs
62
+ : undefined;
63
+ if (toolCallId) {
64
+ const mappedArgs = argsByCallId.get(toolCallId);
65
+ if (explicitRecord &&
66
+ (!mappedArgs ||
67
+ getToolCallSignature(toolName, explicitRecord) !==
68
+ getToolCallSignature(toolName, mappedArgs))) {
69
+ return explicitRecord;
70
+ }
71
+ return mappedArgs || explicitRecord || {};
72
+ }
73
+ if (explicitRecord) {
74
+ const explicitSignature = getToolCallSignature(toolName, explicitRecord);
75
+ const matchingAnonymousCall = anonymousCallArgs.find((call) => !call.consumed &&
76
+ call.toolName === toolName &&
77
+ getToolCallSignature(call.toolName, call.args || {}) ===
78
+ explicitSignature);
79
+ if (matchingAnonymousCall) {
80
+ matchingAnonymousCall.consumed = true;
81
+ return matchingAnonymousCall.args || {};
82
+ }
83
+ return explicitRecord;
84
+ }
85
+ const matchingAnonymousCall = anonymousCallArgs.find((call) => !call.consumed && call.toolName === toolName);
86
+ if (!matchingAnonymousCall)
87
+ return {};
88
+ matchingAnonymousCall.consumed = true;
89
+ return matchingAnonymousCall.args || {};
90
+ };
91
+ const unwrapProcessedToolResult = (result) => {
92
+ let current = result;
93
+ while (current &&
94
+ typeof current === "object" &&
95
+ "value" in current &&
96
+ typeof current.type === "string") {
97
+ current = current.value;
98
+ }
99
+ return current;
100
+ };
101
+ const extractProcessedToolResults = (steps) => {
102
+ const argsByCallId = new Map();
103
+ const anonymousCallArgs = [];
104
+ const results = [];
105
+ for (const step of steps) {
106
+ for (const item of step.content || []) {
107
+ if (item.type !== "tool-call" || !item.toolName)
108
+ continue;
109
+ const args = item.args || item.input || {};
110
+ if (item.toolCallId) {
111
+ argsByCallId.set(item.toolCallId, args);
112
+ }
113
+ else {
114
+ anonymousCallArgs.push({
115
+ toolName: item.toolName,
116
+ args,
117
+ consumed: false,
118
+ });
119
+ }
120
+ }
121
+ for (const item of step.content || []) {
122
+ if (item.type !== "tool-result" || !item.toolName)
123
+ continue;
124
+ const result = unwrapProcessedToolResult(item.result ?? item.output ?? item.content ?? item.data);
125
+ results.push({
126
+ name: item.toolName,
127
+ args: resolveToolResultArgs(item.toolName, item.toolCallId, item.args || item.input, argsByCallId, anonymousCallArgs),
128
+ result: redactToolResult(result).result,
129
+ toolCallId: item.toolCallId,
130
+ });
131
+ }
132
+ }
133
+ return results;
134
+ };
135
+ const buildOrderedPersistedToolActions = (steps, pendingToolCalls, executedToolResults, sanitizeArgs) => {
136
+ const pendingEntries = pendingToolCalls.map((action) => ({
137
+ action,
138
+ consumed: false,
139
+ }));
140
+ const executedEntries = executedToolResults.map((action) => ({
141
+ action,
142
+ consumed: false,
143
+ }));
144
+ const actions = [];
145
+ const takeById = (entries, toolCallId) => {
146
+ if (!toolCallId)
147
+ return undefined;
148
+ const entry = entries.find((candidate) => !candidate.consumed && candidate.action.toolCallId === toolCallId);
149
+ if (entry)
150
+ entry.consumed = true;
151
+ return entry;
152
+ };
153
+ const takeBySignature = (entries, toolName, args) => {
154
+ const signature = getToolCallSignature(toolName, args);
155
+ const entry = entries.find((candidate) => !candidate.consumed &&
156
+ getToolCallSignature(candidate.action.name, candidate.action.args || {}) === signature);
157
+ if (entry)
158
+ entry.consumed = true;
159
+ return entry;
160
+ };
161
+ const takeByName = (entries, toolName) => {
162
+ const entry = entries.find((candidate) => !candidate.consumed && candidate.action.name === toolName);
163
+ if (entry)
164
+ entry.consumed = true;
165
+ return entry;
166
+ };
167
+ for (const step of steps || []) {
168
+ for (const item of step.content || []) {
169
+ if (!item.toolName)
170
+ continue;
171
+ if (item.type === "tool-call") {
172
+ const args = sanitizeArgs(item.toolName, item.input || item.args);
173
+ const entry = takeById(pendingEntries, item.toolCallId) ||
174
+ takeBySignature(pendingEntries, item.toolName, args);
175
+ if (!entry)
176
+ continue;
177
+ actions.push({
178
+ eventType: "tool_call",
179
+ ...entry.action,
180
+ });
181
+ continue;
182
+ }
183
+ if (item.type === "tool-result") {
184
+ const explicitArgs = item.args || item.input;
185
+ const hasExplicitArgs = isRecordValue(explicitArgs);
186
+ const args = sanitizeArgs(item.toolName, explicitArgs);
187
+ const entry = takeById(executedEntries, item.toolCallId) ||
188
+ (hasExplicitArgs
189
+ ? takeBySignature(executedEntries, item.toolName, args)
190
+ : undefined) ||
191
+ takeByName(executedEntries, item.toolName);
192
+ if (!entry)
193
+ continue;
194
+ actions.push({
195
+ eventType: "tool_result",
196
+ ...entry.action,
197
+ });
198
+ }
199
+ }
200
+ }
201
+ for (const entry of pendingEntries) {
202
+ if (!entry.consumed) {
203
+ actions.push({ eventType: "tool_call", ...entry.action });
204
+ }
205
+ }
206
+ for (const entry of executedEntries) {
207
+ if (!entry.consumed) {
208
+ actions.push({ eventType: "tool_result", ...entry.action });
209
+ }
210
+ }
211
+ return actions;
212
+ };
213
+ const reconcileToolLifecycleSnapshots = (streamedPending, snapshotPending, streamedResults, snapshotResults) => {
214
+ const reconcile = (streamed, snapshot) => {
215
+ const merged = [...streamed];
216
+ const claimedStreamedIndexes = new Set();
217
+ const streamedById = new Map(streamed
218
+ .map((entry, index) => entry.toolCallId
219
+ ? [
220
+ `${entry.toolCallId}:${getToolCallSignature(entry.name, entry.args || {})}`,
221
+ index,
222
+ ]
223
+ : undefined)
224
+ .filter((entry) => entry !== undefined));
225
+ for (const entry of snapshot) {
226
+ if (entry.toolCallId) {
227
+ const matchingIndex = streamedById.get(`${entry.toolCallId}:${getToolCallSignature(entry.name, entry.args || {})}`);
228
+ if (matchingIndex !== undefined) {
229
+ merged[matchingIndex] = entry;
230
+ claimedStreamedIndexes.add(matchingIndex);
231
+ continue;
232
+ }
233
+ }
234
+ const matchingIndex = streamed.findIndex((streamedEntry, index) => !claimedStreamedIndexes.has(index) &&
235
+ (!streamedEntry.toolCallId || !entry.toolCallId) &&
236
+ getToolCallSignature(streamedEntry.name, streamedEntry.args || {}) ===
237
+ getToolCallSignature(entry.name, entry.args || {}));
238
+ if (matchingIndex === -1) {
239
+ merged.push(entry);
240
+ continue;
241
+ }
242
+ claimedStreamedIndexes.add(matchingIndex);
243
+ merged[matchingIndex] = {
244
+ ...merged[matchingIndex],
245
+ ...entry,
246
+ toolCallId: entry.toolCallId ?? merged[matchingIndex].toolCallId,
247
+ };
248
+ }
249
+ return merged;
250
+ };
251
+ return {
252
+ pendingToolCalls: reconcile(streamedPending, snapshotPending),
253
+ executedToolResults: reconcile(streamedResults, snapshotResults),
254
+ };
255
+ };
256
+ // Cache environment snapshots per workspace to avoid regenerating
257
+ const envSnapshotCache = new Map();
258
+ // Cache agent instances keyed by "modelId:workspacePath" to avoid repeating
259
+ // workspace.init on every request. Keyed by "modelId:workspacePath" only —
260
+ // enabled-skill filtering is applied per-request (injected into contextInfo)
261
+ // so it never inflates the key space.
262
+ //
263
+ // ── Cache contract ────────────────────────────────────────────────────────────
264
+ // A cached agent MUST be safe to reuse across distinct requests that share the
265
+ // same (modelId, workspacePath) tuple. This means:
266
+ //
267
+ // SAFE to capture at construction time (part of the key or truly static):
268
+ // • modelId – part of the cache key
269
+ // • workspacePath – part of the cache key; baked into tool path-resolvers
270
+ // • Mastra Workspace instance (LocalFilesystem + BM25 index)
271
+ // • Wrapped tool implementations (path already resolved via closure)
272
+ // • Static system-prompt instructions
273
+ //
274
+ // MUST NOT be captured at construction time (varies per-request/per-user):
275
+ // • Enabled-skills list → inject via contextInfo prefix
276
+ // • Auth tokens / API keys → read from env at request time, not constructor
277
+ // • Per-session or per-user identity
278
+ // • Anything derived from the HTTP request (headers, body, caller context)
279
+ //
280
+ // If future code needs to vary any of the "MUST NOT" items, inject it through
281
+ // the `contextInfo` argument of agent.generate() rather than expanding the
282
+ // constructor or the cache key.
283
+ // ─────────────────────────────────────────────────────────────────────────────
284
+ const AGENT_CACHE_MAX_SIZE = 50;
285
+ const MAX_INLINE_FILE_CONTENT_CHARS = 6000;
286
+ const MAX_CONVERSATION_MESSAGES = 12;
287
+ const MAX_CONVERSATION_MESSAGE_CHARS = 4000;
288
+ const MAX_CONVERSATION_MESSAGE_TOKENS = 1200;
289
+ const MAX_CONTEXT_FILES = 8;
290
+ const DEFAULT_PROMPT_TOKEN_BUDGET_RATIO = 0.55;
291
+ const MIN_PROMPT_TOKEN_BUDGET = 4096;
292
+ const PROMPT_TOKEN_RESERVE = 6144;
293
+ const agentCache = new Map();
294
+ let agentCoreModulePromise;
295
+ const loadAgentCoreModule = () => {
296
+ if (!agentCoreModulePromise) {
297
+ agentCoreModulePromise = import("./core/agent/index.js");
298
+ }
299
+ return agentCoreModulePromise;
300
+ };
301
+ const REMOTE_CHAT_SESSIONS_DIR = path.join(os.homedir(), ".iris", "chat-sessions");
302
+ const EDIT_TOOL_NAMES = new Set(["writeFile", "editFile", "applyDiff"]);
303
+ const toRunStopReasonFromReflection = (reflectionStopReason) => {
304
+ if (reflectionStopReason === "resolved")
305
+ return "completed";
306
+ if (reflectionStopReason === "no_progress")
307
+ return "no_progress";
308
+ if (reflectionStopReason === "max_attempts")
309
+ return "max_attempts";
310
+ return "none";
311
+ };
312
+ const parsePositiveIntQuery = (value, fallback, min, max) => {
313
+ const first = Array.isArray(value) ? value[0] : value;
314
+ if (typeof first !== "string") {
315
+ return fallback;
316
+ }
317
+ const parsed = Number.parseInt(first, 10);
318
+ if (!Number.isFinite(parsed)) {
319
+ return fallback;
320
+ }
321
+ return Math.max(min, Math.min(max, parsed));
322
+ };
323
+ const isSafeSessionId = (sessionId) => /^[A-Za-z0-9._:-]+$/.test(sessionId);
324
+ const getRemoteChatSessionPath = (sessionId) => path.join(REMOTE_CHAT_SESSIONS_DIR, `${sessionId}.json`);
325
+ const extractRunIdsFromRemoteSession = (session) => {
326
+ if (!session || !Array.isArray(session.messages)) {
327
+ return [];
328
+ }
329
+ const collected = new Set();
330
+ const visit = (value, depth) => {
331
+ if (depth > 12 || value === null || value === undefined) {
332
+ return;
333
+ }
334
+ if (typeof value === "string") {
335
+ return;
336
+ }
337
+ if (Array.isArray(value)) {
338
+ value.forEach((item) => visit(item, depth + 1));
339
+ return;
340
+ }
341
+ if (typeof value !== "object") {
342
+ return;
343
+ }
344
+ const record = value;
345
+ const candidateRunId = typeof record.runId === "string"
346
+ ? record.runId.trim()
347
+ : typeof record.run_id === "string"
348
+ ? record.run_id.trim()
349
+ : "";
350
+ if (candidateRunId && isSafeRunId(candidateRunId)) {
351
+ collected.add(candidateRunId);
352
+ }
353
+ Object.values(record).forEach((entry) => visit(entry, depth + 1));
354
+ };
355
+ session.messages.forEach((message) => visit(message, 0));
356
+ return Array.from(collected);
357
+ };
358
+ const sleep = (ms) => new Promise((resolve) => {
359
+ setTimeout(resolve, ms);
360
+ });
361
+ const isSynthesisOnlyContinuationMessage = (message) => {
362
+ if (!message || message.role !== "user") {
363
+ return false;
364
+ }
365
+ return (message.continuationType === "tool_results" ||
366
+ message.continuationType === "final_synthesis");
367
+ };
368
+ const normalizeEnabledSkills = (input) => {
369
+ if (!Array.isArray(input)) {
370
+ return [];
371
+ }
372
+ const normalized = input
373
+ .map((value) => (typeof value === "string" ? value.trim() : ""))
374
+ .filter((value) => /^[a-zA-Z0-9._-]{1,80}$/.test(value));
375
+ return Array.from(new Set(normalized));
376
+ };
377
+ const isRetryableModelError = (error) => {
378
+ const err = error;
379
+ if (err?.isRetryable)
380
+ return true;
381
+ const statusCode = err?.statusCode;
382
+ const code = err?.data?.error?.code;
383
+ const type = err?.data?.error?.type;
384
+ return Boolean((typeof statusCode === "number" && statusCode >= 500) ||
385
+ code === "server_error" ||
386
+ type === "server_error");
387
+ };
388
+ const serializeToolResultsForConversation = (toolResults) => {
389
+ if (!Array.isArray(toolResults) || toolResults.length === 0) {
390
+ return null;
391
+ }
392
+ const body = serializeToolResultsForContinuation(toolResults, "unknown_tool");
393
+ return `Tool execution results:\n\n${body}`;
394
+ };
395
+ const isSlashCommandExecutionAllowed = (req, isTauri, enableSlashCommands) => {
396
+ if (!enableSlashCommands)
397
+ return false;
398
+ if (!isSlashCommandsFeatureEnabled())
399
+ return false;
400
+ return Boolean(isTauri && hasDesktopAuth(req));
401
+ };
402
+ router.get("/slash-commands", (req, res) => {
403
+ const enableSlashCommandsRaw = String(req.query.enableSlashCommands || "true");
404
+ const clientEnabled = !["0", "false", "off", "no"].includes(enableSlashCommandsRaw.trim().toLowerCase());
405
+ if (!clientEnabled || !isSlashCommandsFeatureEnabled()) {
406
+ return res.json({
407
+ success: true,
408
+ enabled: false,
409
+ commands: [],
410
+ });
411
+ }
412
+ return res.json({
413
+ success: true,
414
+ enabled: true,
415
+ commands: getSlashCommandDescriptors(),
416
+ });
417
+ });
418
+ const generateWithRetry = async (agent, prompt, generateOptions, abortSignal, maxAttempts = 3) => {
419
+ let lastError;
420
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
421
+ if (abortSignal.aborted) {
422
+ throw new Error("Run cancelled");
423
+ }
424
+ try {
425
+ return await agent.generate(prompt, generateOptions);
426
+ }
427
+ catch (error) {
428
+ lastError = error;
429
+ if (abortSignal.aborted) {
430
+ throw error;
431
+ }
432
+ const shouldRetry = isRetryableModelError(error) && attempt < maxAttempts;
433
+ if (!shouldRetry) {
434
+ throw error;
435
+ }
436
+ const backoffMs = 600 * 2 ** (attempt - 1);
437
+ console.warn(`[agent] transient model error (attempt ${attempt}/${maxAttempts}); retrying in ${backoffMs}ms`);
438
+ await sleep(backoffMs);
439
+ }
440
+ }
441
+ throw lastError;
442
+ };
443
+ const asGenerateResultFromTurn = (turnResult) => {
444
+ if (turnResult.raw && typeof turnResult.raw === "object") {
445
+ return turnResult.raw;
446
+ }
447
+ return {
448
+ text: turnResult.text,
449
+ };
450
+ };
451
+ const createGeneratedAgentRuntimeAdapter = (agent) => {
452
+ const turnAbortControllers = new Map();
453
+ const createTurnAbortController = (sessionId) => {
454
+ const controller = new AbortController();
455
+ turnAbortControllers.set(sessionId, controller);
456
+ return controller;
457
+ };
458
+ const clearTurnAbortController = (sessionId, controller) => {
459
+ const active = turnAbortControllers.get(sessionId);
460
+ if (active === controller) {
461
+ turnAbortControllers.delete(sessionId);
462
+ }
463
+ };
464
+ return {
465
+ descriptor: {
466
+ id: "generated-agent-runtime",
467
+ name: "Generated Agent Runtime",
468
+ version: "0.1.0",
469
+ source: "external",
470
+ },
471
+ async startSession(context) {
472
+ return {
473
+ sessionId: context.sessionId,
474
+ agentId: "generated-agent-runtime",
475
+ createdAt: Date.now(),
476
+ };
477
+ },
478
+ async runTurn(request) {
479
+ const metadata = request.metadata && typeof request.metadata === "object"
480
+ ? request.metadata
481
+ : {};
482
+ const modelInput = metadata.modelInput !== undefined
483
+ ? metadata.modelInput
484
+ : request.input;
485
+ const generateOptions = metadata.generateOptions && typeof metadata.generateOptions === "object"
486
+ ? metadata.generateOptions
487
+ : {};
488
+ const requestContext = generateOptions.requestContext;
489
+ requestContext?.set?.("onPreToolUse", request.onPreToolUse);
490
+ const turnAbortController = createTurnAbortController(request.sessionId);
491
+ const generateOptionsWithAbort = {
492
+ ...generateOptions,
493
+ abortSignal: turnAbortController.signal,
494
+ };
495
+ let result;
496
+ try {
497
+ result = await agent.generate(modelInput, generateOptionsWithAbort);
498
+ }
499
+ finally {
500
+ clearTurnAbortController(request.sessionId, turnAbortController);
501
+ }
502
+ return {
503
+ text: result.text || "",
504
+ toolCalls: Array.isArray(result.toolCalls)
505
+ ? result.toolCalls
506
+ : undefined,
507
+ raw: result,
508
+ };
509
+ },
510
+ async runTurnStream(request) {
511
+ const metadata = request.metadata && typeof request.metadata === "object"
512
+ ? request.metadata
513
+ : {};
514
+ const modelInput = metadata.modelInput !== undefined
515
+ ? metadata.modelInput
516
+ : request.input;
517
+ const generateOptions = metadata.generateOptions && typeof metadata.generateOptions === "object"
518
+ ? metadata.generateOptions
519
+ : {};
520
+ const requestContext = generateOptions.requestContext;
521
+ requestContext?.set?.("onPreToolUse", request.onPreToolUse);
522
+ if (typeof agent.stream !== "function") {
523
+ throw new Error("Generated agent runtime does not support streaming");
524
+ }
525
+ const turnAbortController = createTurnAbortController(request.sessionId);
526
+ const generateOptionsWithAbort = {
527
+ ...generateOptions,
528
+ abortSignal: turnAbortController.signal,
529
+ };
530
+ let rawStreamResult;
531
+ try {
532
+ rawStreamResult = await agent.stream(modelInput, generateOptionsWithAbort);
533
+ }
534
+ catch (error) {
535
+ clearTurnAbortController(request.sessionId, turnAbortController);
536
+ throw error;
537
+ }
538
+ const tee = rawStreamResult.fullStream.tee;
539
+ const [hostEventSource, transportSource] = typeof tee === "function"
540
+ ? tee.call(rawStreamResult.fullStream)
541
+ : [
542
+ new ReadableStream({
543
+ start(controller) {
544
+ controller.close();
545
+ },
546
+ }),
547
+ rawStreamResult.fullStream,
548
+ ];
549
+ const mappedStream = hostEventSource.pipeThrough(new TransformStream({
550
+ transform(chunk, controller) {
551
+ if (!chunk?.type)
552
+ return;
553
+ if (chunk.type === "text-delta" || chunk.type === "reasoning-delta") {
554
+ const text = String(chunk.payload?.text || "");
555
+ if (text)
556
+ controller.enqueue({ type: "text-delta", text });
557
+ return;
558
+ }
559
+ if (chunk.type === "tool-call") {
560
+ const toolName = typeof chunk.payload?.toolName === "string"
561
+ ? chunk.payload.toolName
562
+ : "unknown_tool";
563
+ controller.enqueue({
564
+ type: "tool-call",
565
+ name: toolName,
566
+ args: chunk.payload?.args && typeof chunk.payload.args === "object"
567
+ ? chunk.payload.args
568
+ : undefined,
569
+ });
570
+ return;
571
+ }
572
+ if (chunk.type === "tool-result") {
573
+ const toolName = typeof chunk.payload?.toolName === "string"
574
+ ? chunk.payload.toolName
575
+ : "unknown_tool";
576
+ controller.enqueue({
577
+ type: "tool-result",
578
+ name: toolName,
579
+ result: chunk.payload?.result ??
580
+ chunk.payload?.output ??
581
+ chunk.payload?.content ??
582
+ chunk.payload?.data,
583
+ });
584
+ return;
585
+ }
586
+ if (chunk.type === "tool-suspended" || chunk.type === "tool_suspended") {
587
+ controller.enqueue({
588
+ type: "approval-required",
589
+ reason: "Tool suspended and awaiting user input",
590
+ payload: chunk.payload,
591
+ });
592
+ }
593
+ },
594
+ flush(controller) {
595
+ controller.enqueue({ type: "done" });
596
+ },
597
+ }));
598
+ const transportStreamResult = {
599
+ ...rawStreamResult,
600
+ fullStream: transportSource,
601
+ };
602
+ const result = {
603
+ stream: mappedStream,
604
+ getFinalResult: async () => {
605
+ const [text, toolCalls, usage, steps] = await Promise.all([
606
+ rawStreamResult.text,
607
+ rawStreamResult.toolCalls,
608
+ Promise.resolve(rawStreamResult.usage).catch(() => undefined),
609
+ Promise.resolve(rawStreamResult.steps).catch(() => undefined),
610
+ ]);
611
+ return {
612
+ text: text || "",
613
+ toolCalls: Array.isArray(toolCalls)
614
+ ? toolCalls
615
+ .filter((item) => typeof item?.toolName === "string")
616
+ .map((item) => ({
617
+ name: item.toolName,
618
+ args: item.args || {},
619
+ toolCallId: item.toolCallId,
620
+ }))
621
+ : undefined,
622
+ raw: {
623
+ text,
624
+ toolCalls,
625
+ usage,
626
+ steps,
627
+ streamResult: rawStreamResult,
628
+ },
629
+ };
630
+ },
631
+ rawStreamResult: transportStreamResult,
632
+ };
633
+ return {
634
+ ...result,
635
+ getFinalResult: async () => {
636
+ try {
637
+ return await result.getFinalResult();
638
+ }
639
+ finally {
640
+ clearTurnAbortController(request.sessionId, turnAbortController);
641
+ }
642
+ },
643
+ };
644
+ },
645
+ async cancelTurn(sessionId) {
646
+ const controller = turnAbortControllers.get(sessionId);
647
+ if (!controller)
648
+ return;
649
+ controller.abort();
650
+ turnAbortControllers.delete(sessionId);
651
+ },
652
+ async endSession(sessionId) {
653
+ // No-op: GeneratedAgent lifecycle is managed by agent cache.
654
+ turnAbortControllers.delete(sessionId);
655
+ },
656
+ };
657
+ };
658
+ const generateWithSessionRetry = async (session, prompt, generateOptions, abortSignal, maxAttempts = 3) => {
659
+ let lastError;
660
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
661
+ if (abortSignal.aborted) {
662
+ throw new Error("Run cancelled");
663
+ }
664
+ try {
665
+ const turnResult = await session.sendAndWait({
666
+ prompt: typeof prompt === "string" ? prompt : "[multimodal-prompt]",
667
+ metadata: {
668
+ modelInput: prompt,
669
+ generateOptions,
670
+ },
671
+ });
672
+ return asGenerateResultFromTurn(turnResult);
673
+ }
674
+ catch (error) {
675
+ lastError = error;
676
+ if (abortSignal.aborted) {
677
+ throw error;
678
+ }
679
+ const shouldRetry = isRetryableModelError(error) && attempt < maxAttempts;
680
+ if (!shouldRetry) {
681
+ throw error;
682
+ }
683
+ const backoffMs = 600 * 2 ** (attempt - 1);
684
+ console.warn(`[agent] transient model error (attempt ${attempt}/${maxAttempts}); retrying in ${backoffMs}ms`);
685
+ await sleep(backoffMs);
686
+ }
687
+ }
688
+ throw lastError;
689
+ };
690
+ async function getOrCreateAgent(modelId, workspacePath, mcpServers = [], preferredAgentId, terminalAutoApproveRules, useMastraObservationalMemory, observationalMemorySettings, streamErrorRetry) {
691
+ // Initialize Language Model Tools system with MCP servers
692
+ try {
693
+ await initializeLanguageModelTools(mcpServers, workspacePath);
694
+ console.log("✅ Language Model Tools initialized");
695
+ }
696
+ catch (error) {
697
+ console.warn("⚠️ Failed to initialize Language Model Tools:", error);
698
+ }
699
+ const mcpFingerprint = toStableMcpFingerprint(mcpServers);
700
+ const normalizedPreferredAgentId = preferredAgentId?.trim() || "";
701
+ const terminalRulesFingerprint = getTerminalAutoApproveRulesFingerprint(terminalAutoApproveRules);
702
+ const memorySettingsFingerprint = JSON.stringify(observationalMemorySettings || {});
703
+ const streamErrorRetryFingerprint = JSON.stringify(streamErrorRetry || {});
704
+ const memoryFingerprint = useMastraObservationalMemory
705
+ ? `om:on:${memorySettingsFingerprint}`
706
+ : "om:off";
707
+ const key = `${modelId}:${workspacePath}:${mcpFingerprint}:${normalizedPreferredAgentId}:${terminalRulesFingerprint}:${memoryFingerprint}:retry:${streamErrorRetryFingerprint}`;
708
+ if (agentCache.has(key)) {
709
+ // Move to end to mark as recently used (LRU semantics via Map insertion order).
710
+ const existing = agentCache.get(key);
711
+ agentCache.delete(key);
712
+ agentCache.set(key, existing);
713
+ return existing;
714
+ }
715
+ let manifestExternalRegistration;
716
+ const externalManifestPath = process.env.IRIS_AGENT_EXTERNAL_AGENT_MANIFEST_PATH?.trim();
717
+ if (externalManifestPath) {
718
+ try {
719
+ const lifecycle = new ExternalAgentLifecycleManager(externalManifestPath);
720
+ const externalRegistration = await lifecycle.createRegistration({
721
+ modelId,
722
+ workspacePath,
723
+ mcpServers,
724
+ });
725
+ manifestExternalRegistration = {
726
+ id: externalRegistration.descriptor.id,
727
+ name: externalRegistration.descriptor.name,
728
+ description: externalRegistration.descriptor.description ||
729
+ "Manifest-based external agent runtime.",
730
+ runtimeFactory: () => Promise.resolve(externalRegistration.runtimeFactory()),
731
+ };
732
+ }
733
+ catch (error) {
734
+ const err = error;
735
+ console.warn("[api] external manifest runtime unavailable, falling back to iris:", err.message);
736
+ }
737
+ }
738
+ const agentCore = await loadAgentCoreModule();
739
+ const registry = createDefaultAgentRegistry({
740
+ irisFactory: () => agentCore.createCodingAgent(modelId, workspacePath, {
741
+ mcpServers,
742
+ terminalAutoApproveRules,
743
+ // Non-observational requests already include the bounded conversation
744
+ // history in the prompt. Enabling Mastra memory there would replay it
745
+ // a second time and can exceed provider context windows.
746
+ enableMemory: useMastraObservationalMemory === true,
747
+ goalMaxRuns: 12,
748
+ useMastraObservationalMemory,
749
+ observationalMemorySettings,
750
+ streamErrorRetry,
751
+ }),
752
+ externalRegistration: manifestExternalRegistration,
753
+ });
754
+ const agent = await registry.createRuntime(normalizedPreferredAgentId || undefined, {
755
+ requiredCapabilities: ["tool_calling", "streaming"],
756
+ requiredPermissions: ["workspace_read"],
757
+ });
758
+ if (agentCache.size >= AGENT_CACHE_MAX_SIZE) {
759
+ // Evict the least-recently-used entry (first key in insertion order).
760
+ const firstKey = agentCache.keys().next().value;
761
+ if (firstKey !== undefined) {
762
+ agentCache.delete(firstKey);
763
+ }
764
+ }
765
+ agentCache.set(key, agent);
766
+ return agent;
767
+ }
768
+ const inspectMcpServersForChat = async (servers, workspacePath) => {
769
+ const summaries = [];
770
+ for (const server of servers) {
771
+ try {
772
+ const tools = await listMcpServerTools(server, workspacePath);
773
+ summaries.push({
774
+ name: server.name,
775
+ command: server.command,
776
+ toolNames: tools.map((tool) => tool.name),
777
+ });
778
+ }
779
+ catch (error) {
780
+ const err = error;
781
+ summaries.push({
782
+ name: server.name,
783
+ command: server.command,
784
+ toolNames: [],
785
+ error: err.message || "Failed to inspect MCP server",
786
+ });
787
+ }
788
+ }
789
+ return summaries;
790
+ };
791
+ const formatMcpContext = (summaries) => {
792
+ if (summaries.length === 0)
793
+ return "";
794
+ const lines = ["", "**MCP SERVER STATUS:**"];
795
+ for (const summary of summaries) {
796
+ if (summary.error) {
797
+ lines.push(`- **${summary.name}** (${summary.command}): unavailable - ${summary.error}`);
798
+ continue;
799
+ }
800
+ if (summary.toolNames.length === 0) {
801
+ lines.push(`- **${summary.name}** (${summary.command}): connected but exposed no tools`);
802
+ continue;
803
+ }
804
+ lines.push(`- **${summary.name}** (${summary.command}): ${summary.toolNames.length} tool(s) available - ${summary.toolNames.join(", ")}`);
805
+ }
806
+ lines.push("- If the user asks why an MCP action failed, explain using the status above before suggesting a retry.");
807
+ return lines.join("\n");
808
+ };
809
+ const hasTerminalToolResults = (toolResults) => toolResults.some((toolResult) => {
810
+ const status = resolveToolExecutionStatus(toolResult.result);
811
+ return status === "completed" || status === "failed";
812
+ });
813
+ const hasNonterminalToolResults = (toolResults) => toolResults.some((toolResult) => {
814
+ const status = resolveToolExecutionStatus(toolResult.result);
815
+ return (status === "pending" ||
816
+ status === "in_progress" ||
817
+ status === "unknown");
818
+ });
819
+ const getToolResultPayload = (result) => {
820
+ if (!result || typeof result !== "object") {
821
+ return null;
822
+ }
823
+ const direct = result;
824
+ const nestedValue = direct.value;
825
+ if (nestedValue && typeof nestedValue === "object") {
826
+ return nestedValue;
827
+ }
828
+ return direct;
829
+ };
830
+ const extractValidationFailures = (executedToolResults) => {
831
+ const failures = [];
832
+ for (const toolResult of executedToolResults) {
833
+ const payload = getToolResultPayload(toolResult.result);
834
+ if (!payload)
835
+ continue;
836
+ const validation = payload.validation;
837
+ if (!validation || typeof validation !== "object")
838
+ continue;
839
+ for (const phase of ["lint", "test"]) {
840
+ const phaseResult = validation[phase];
841
+ if (!phaseResult || typeof phaseResult !== "object")
842
+ continue;
843
+ const record = phaseResult;
844
+ if (record.enabled !== true || record.success !== false)
845
+ continue;
846
+ failures.push({
847
+ phase,
848
+ command: typeof record.command === "string" ? record.command : undefined,
849
+ error: typeof record.error === "string" ? record.error : undefined,
850
+ stdout: typeof record.stdout === "string" ? record.stdout : undefined,
851
+ stderr: typeof record.stderr === "string" ? record.stderr : undefined,
852
+ });
853
+ }
854
+ }
855
+ return failures;
856
+ };
857
+ const isAutoFixValidationEnabled = () => {
858
+ const raw = process.env.IRIS_AGENT_AUTO_FIX_VALIDATION;
859
+ if (!raw)
860
+ return true;
861
+ const normalized = raw.trim().toLowerCase();
862
+ if (["1", "true", "yes", "on"].includes(normalized))
863
+ return true;
864
+ if (["0", "false", "no", "off"].includes(normalized))
865
+ return false;
866
+ return true;
867
+ };
868
+ const asPositiveInt = (value, fallback, min, max) => {
869
+ if (!value)
870
+ return fallback;
871
+ const parsed = Number.parseInt(value, 10);
872
+ if (!Number.isFinite(parsed))
873
+ return fallback;
874
+ return Math.max(min, Math.min(max, parsed));
875
+ };
876
+ const sanitizeStreamErrorRetryRequest = (value) => {
877
+ if (!value || typeof value !== "object") {
878
+ return undefined;
879
+ }
880
+ const input = value;
881
+ const normalized = {};
882
+ if (typeof input.enabled === "boolean") {
883
+ normalized.enabled = input.enabled;
884
+ }
885
+ if (typeof input.retryUnknownErrors === "boolean") {
886
+ normalized.retryUnknownErrors = input.retryUnknownErrors;
887
+ }
888
+ if (typeof input.maxRetries === "number" &&
889
+ Number.isFinite(input.maxRetries)) {
890
+ normalized.maxRetries = Math.max(0, Math.min(10, Math.floor(input.maxRetries)));
891
+ }
892
+ if (typeof input.baseDelayMs === "number" &&
893
+ Number.isFinite(input.baseDelayMs)) {
894
+ normalized.baseDelayMs = Math.max(50, Math.min(30_000, Math.floor(input.baseDelayMs)));
895
+ }
896
+ if (typeof input.maxDelayMs === "number" &&
897
+ Number.isFinite(input.maxDelayMs)) {
898
+ normalized.maxDelayMs = Math.max(50, Math.min(120_000, Math.floor(input.maxDelayMs)));
899
+ }
900
+ return Object.keys(normalized).length > 0 ? normalized : undefined;
901
+ };
902
+ const readWorkspaceAirisConfigForAgent = async (workspaceRoot) => {
903
+ const normalizedRoot = workspaceRoot.trim();
904
+ if (!normalizedRoot) {
905
+ return null;
906
+ }
907
+ const airisDirPath = path.join(normalizedRoot, ".airis");
908
+ const settingsPath = path.join(airisDirPath, "settings.json");
909
+ let airisStat = null;
910
+ try {
911
+ airisStat = await fs.stat(airisDirPath);
912
+ }
913
+ catch (error) {
914
+ const fsError = error;
915
+ if (!fsError || fsError.code !== "ENOENT") {
916
+ throw error;
917
+ }
918
+ }
919
+ let raw = "";
920
+ if (airisStat?.isDirectory()) {
921
+ raw = await fs.readFile(settingsPath, "utf8").catch((error) => {
922
+ const fsError = error;
923
+ if (fsError && fsError.code === "ENOENT") {
924
+ return "";
925
+ }
926
+ throw error;
927
+ });
928
+ }
929
+ else if (airisStat?.isFile()) {
930
+ // Backward compatibility: old installs used a single .airis JSON file.
931
+ raw = await fs.readFile(airisDirPath, "utf8");
932
+ }
933
+ else {
934
+ return null;
935
+ }
936
+ if (!raw) {
937
+ return null;
938
+ }
939
+ const parsed = JSON.parse(raw);
940
+ if (!parsed || typeof parsed !== "object") {
941
+ return null;
942
+ }
943
+ return parsed;
944
+ };
945
+ const shouldUseMastraManagedContextMode = (useMastraObservationalMemory) => {
946
+ return useMastraObservationalMemory === true;
947
+ };
948
+ const isCodingObjectiveRequest = (message, hasToolResults, isSynthesisOnlyContinuation) => {
949
+ if (hasToolResults || isSynthesisOnlyContinuation)
950
+ return false;
951
+ return /\b(add|build|change|create|debug|edit|fix|implement|integrate|migrate|modify|refactor|remove|replace|test|update)\b/i.test(message);
952
+ };
953
+ const asBoundedFloat = (value, fallback, min, max) => {
954
+ if (!value)
955
+ return fallback;
956
+ const parsed = Number.parseFloat(value);
957
+ if (!Number.isFinite(parsed))
958
+ return fallback;
959
+ return Math.max(min, Math.min(max, parsed));
960
+ };
961
+ const getModelExecutionProfile = (modelId) => {
962
+ const normalized = modelId.toLowerCase();
963
+ const baseProfile = {
964
+ generateRetryAttempts: 3,
965
+ reflectionMaxAttempts: 2,
966
+ reflectionRetryAttempts: 2,
967
+ reflectionMaxSteps: 8,
968
+ maxStepsCapDesktop: 50,
969
+ maxOutputTokens: 8192,
970
+ };
971
+ if (normalized.includes("gpt-5") ||
972
+ normalized.includes("o3") ||
973
+ normalized.includes("o4")) {
974
+ baseProfile.reflectionMaxAttempts = 3;
975
+ baseProfile.reflectionMaxSteps = 10;
976
+ }
977
+ else if (normalized.includes("claude")) {
978
+ baseProfile.reflectionMaxAttempts = 2;
979
+ baseProfile.reflectionMaxSteps = 8;
980
+ }
981
+ else if (normalized.includes("gemini")) {
982
+ baseProfile.reflectionMaxAttempts = 2;
983
+ baseProfile.reflectionMaxSteps = 7;
984
+ }
985
+ baseProfile.generateRetryAttempts = asPositiveInt(process.env.IRIS_AGENT_MODEL_RETRY_ATTEMPTS, baseProfile.generateRetryAttempts, 1, 5);
986
+ baseProfile.reflectionMaxAttempts = asPositiveInt(process.env.IRIS_AGENT_REFLECTION_MAX, baseProfile.reflectionMaxAttempts, 1, 4);
987
+ baseProfile.reflectionRetryAttempts = asPositiveInt(process.env.IRIS_AGENT_REFLECTION_RETRY_ATTEMPTS, baseProfile.reflectionRetryAttempts, 1, 4);
988
+ baseProfile.reflectionMaxSteps = asPositiveInt(process.env.IRIS_AGENT_REFLECTION_MAX_STEPS, baseProfile.reflectionMaxSteps, 1, 20);
989
+ baseProfile.maxOutputTokens = asPositiveInt(process.env.IRIS_AGENT_MAX_OUTPUT_TOKENS, baseProfile.maxOutputTokens, 256, 65536);
990
+ return baseProfile;
991
+ };
992
+ const isHuggingFaceModelId = (candidateModelId) => {
993
+ const normalized = (candidateModelId || "").toLowerCase();
994
+ if (!normalized)
995
+ return false;
996
+ if (normalized.startsWith("huggingface/"))
997
+ return true;
998
+ return normalized.endsWith(":fireworks-ai");
999
+ };
1000
+ const isOpenRouterModelId = (candidateModelId) => {
1001
+ const normalized = (candidateModelId || "").toLowerCase();
1002
+ if (!normalized)
1003
+ return false;
1004
+ if (normalized.startsWith("openrouter/"))
1005
+ return true;
1006
+ if (!normalized.includes("/"))
1007
+ return false;
1008
+ if (isHuggingFaceModelId(normalized))
1009
+ return false;
1010
+ if (normalized.startsWith("google/"))
1011
+ return false;
1012
+ if (normalized.startsWith("ollama/"))
1013
+ return false;
1014
+ if (normalized.startsWith("local/"))
1015
+ return false;
1016
+ return true;
1017
+ };
1018
+ const extractProviderErrorDetails = (error) => {
1019
+ const errorRecord = error;
1020
+ const providerError = errorRecord?.data
1021
+ ?.error;
1022
+ const code = providerError?.code ?? providerError?.type;
1023
+ const message = providerError?.metadata?.raw ||
1024
+ providerError?.message ||
1025
+ (errorRecord instanceof Error ? errorRecord.message : "") ||
1026
+ "Failed to generate response";
1027
+ return {
1028
+ code: code === undefined ? undefined : String(code),
1029
+ message,
1030
+ };
1031
+ };
1032
+ const getRateLimitErrorMessage = (modelId) => isOpenRouterModelId(modelId)
1033
+ ? "⚠️ OpenRouter rate limit reached. Please wait a moment and try again, or switch models/providers."
1034
+ : "⚠️ Provider rate limit reached. Please wait a moment and try again, or switch providers.";
1035
+ const normalizeProviderRequestError = (modelId, message, code) => {
1036
+ const normalizedMessage = (message || "").trim();
1037
+ const lowerMessage = normalizedMessage.toLowerCase();
1038
+ const lowerCode = (code || "").toLowerCase();
1039
+ const isRateLimitError = lowerCode === "429" ||
1040
+ lowerCode.includes("rate_limit") ||
1041
+ lowerCode.includes("rate limit") ||
1042
+ lowerMessage.includes("rate_limit") ||
1043
+ lowerMessage.includes("rate limit") ||
1044
+ lowerMessage.includes("rate-limited") ||
1045
+ lowerMessage.includes("too many requests");
1046
+ const looksLikeProviderAuthFailure = lowerMessage.includes("user not found") ||
1047
+ lowerMessage.includes("invalid api key") ||
1048
+ lowerMessage.includes("unauthorized") ||
1049
+ lowerCode.includes("401");
1050
+ if (isRateLimitError) {
1051
+ return getRateLimitErrorMessage(modelId);
1052
+ }
1053
+ if (!looksLikeProviderAuthFailure) {
1054
+ return normalizedMessage || "Failed to generate response";
1055
+ }
1056
+ if (isOpenRouterModelId(modelId)) {
1057
+ return "OpenRouter rejected the configured API key. Replace OPENROUTER_API_KEY in Settings and click Save & Activate Keys.";
1058
+ }
1059
+ if (isHuggingFaceModelId(modelId)) {
1060
+ return "HuggingFace rejected the configured token. Replace HF_TOKEN in Settings and click Save & Activate Keys.";
1061
+ }
1062
+ return normalizedMessage || "Failed to generate response";
1063
+ };
1064
+ const extractToolExecutionFailures = (executedToolResults) => {
1065
+ const failures = [];
1066
+ for (const toolResult of executedToolResults) {
1067
+ if (toolResult.status !== "failed")
1068
+ continue;
1069
+ const payload = getToolResultPayload(toolResult.result) || {};
1070
+ const error = typeof payload.error === "string"
1071
+ ? payload.error
1072
+ : typeof toolResult.result?.error === "string"
1073
+ ? toolResult.result.error || ""
1074
+ : undefined;
1075
+ failures.push({
1076
+ toolName: toolResult.name,
1077
+ args: toolResult.args,
1078
+ error,
1079
+ status: toolResult.status,
1080
+ });
1081
+ }
1082
+ return failures;
1083
+ };
1084
+ const buildFailureSignature = (validationFailures, toolFailures) => {
1085
+ const validation = validationFailures
1086
+ .map((failure) => ({
1087
+ phase: failure.phase,
1088
+ command: failure.command || "",
1089
+ error: failure.error || "",
1090
+ stderr: failure.stderr || "",
1091
+ }))
1092
+ .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
1093
+ const tools = toolFailures
1094
+ .map((failure) => ({
1095
+ toolName: failure.toolName,
1096
+ status: failure.status,
1097
+ error: failure.error || "",
1098
+ }))
1099
+ .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
1100
+ return JSON.stringify({ validation, tools });
1101
+ };
1102
+ const resolveGitSafetyMode = () => {
1103
+ const raw = (process.env.IRIS_AGENT_GIT_SAFETY_MODE || "suggest")
1104
+ .trim()
1105
+ .toLowerCase();
1106
+ if (raw === "off")
1107
+ return "off";
1108
+ if (raw === "enforce")
1109
+ return "enforce";
1110
+ return "suggest";
1111
+ };
1112
+ const getReflectionNoProgressRepeatThreshold = () => asPositiveInt(process.env.IRIS_AGENT_REFLECTION_NO_PROGRESS_REPEATS, 1, 1, 5);
1113
+ const detectGitRepo = async (workspacePath) => {
1114
+ try {
1115
+ await fs.access(path.join(workspacePath, ".git"));
1116
+ return true;
1117
+ }
1118
+ catch {
1119
+ return false;
1120
+ }
1121
+ };
1122
+ const hasSuccessfulEditExecution = (executedToolResults) => executedToolResults.some((result) => EDIT_TOOL_NAMES.has(result.name) && result.status === "completed");
1123
+ const MAX_PERSISTED_TOOL_RESULT_BYTES = 16 * 1024;
1124
+ const PERSISTED_TOOL_RESULT_PREVIEW_CHARS = 4 * 1024;
1125
+ const PERSISTED_TOOL_RESULT_SUMMARY_FIELD_CHARS = 1024;
1126
+ const truncatePersistedSummaryString = (value) => {
1127
+ if (value.length <= PERSISTED_TOOL_RESULT_SUMMARY_FIELD_CHARS) {
1128
+ return value;
1129
+ }
1130
+ return value.slice(0, PERSISTED_TOOL_RESULT_SUMMARY_FIELD_CHARS);
1131
+ };
1132
+ const getJsonByteLength = (value) => {
1133
+ const serialized = JSON.stringify(value);
1134
+ if (serialized === undefined)
1135
+ return null;
1136
+ return Buffer.byteLength(serialized, "utf8");
1137
+ };
1138
+ const enforcePersistedSummaryByteLimit = (summary) => {
1139
+ const bounded = { ...summary };
1140
+ const currentByteLength = () => getJsonByteLength(bounded) ?? 0;
1141
+ const shrinkStringField = (key) => {
1142
+ const value = bounded[key];
1143
+ if (typeof value !== "string" || value.length === 0)
1144
+ return;
1145
+ while (currentByteLength() > MAX_PERSISTED_TOOL_RESULT_BYTES) {
1146
+ const current = bounded[key];
1147
+ if (typeof current !== "string" || current.length === 0)
1148
+ break;
1149
+ const overage = currentByteLength() - MAX_PERSISTED_TOOL_RESULT_BYTES;
1150
+ const nextLength = Math.max(0, current.length - Math.max(1, overage));
1151
+ bounded[key] = current.slice(0, nextLength);
1152
+ if (nextLength === 0)
1153
+ break;
1154
+ }
1155
+ };
1156
+ shrinkStringField("preview");
1157
+ const stringKeys = Object.keys(bounded)
1158
+ .filter((key) => key !== "preview" && typeof bounded[key] === "string")
1159
+ .sort((a, b) => String(bounded[b]).length - String(bounded[a]).length);
1160
+ for (const key of stringKeys) {
1161
+ if (currentByteLength() <= MAX_PERSISTED_TOOL_RESULT_BYTES)
1162
+ break;
1163
+ shrinkStringField(key);
1164
+ }
1165
+ if (currentByteLength() > MAX_PERSISTED_TOOL_RESULT_BYTES) {
1166
+ for (const key of ["preview", ...stringKeys]) {
1167
+ if (currentByteLength() <= MAX_PERSISTED_TOOL_RESULT_BYTES)
1168
+ break;
1169
+ delete bounded[key];
1170
+ }
1171
+ }
1172
+ return bounded;
1173
+ };
1174
+ const summarizeToolResultForPersistence = (result) => {
1175
+ let serialized;
1176
+ try {
1177
+ serialized = JSON.stringify(result);
1178
+ }
1179
+ catch {
1180
+ return {
1181
+ truncated: true,
1182
+ reason: "Tool result could not be serialized for persistence",
1183
+ };
1184
+ }
1185
+ if (serialized === undefined) {
1186
+ return {
1187
+ truncated: true,
1188
+ reason: "Tool result has no JSON representation for persistence",
1189
+ };
1190
+ }
1191
+ const originalByteLength = Buffer.byteLength(serialized, "utf8");
1192
+ if (originalByteLength <= MAX_PERSISTED_TOOL_RESULT_BYTES) {
1193
+ return result;
1194
+ }
1195
+ const summary = {
1196
+ truncated: true,
1197
+ originalByteLength,
1198
+ preview: serialized.slice(0, PERSISTED_TOOL_RESULT_PREVIEW_CHARS),
1199
+ };
1200
+ if (!result || typeof result !== "object" || Array.isArray(result)) {
1201
+ return enforcePersistedSummaryByteLimit(summary);
1202
+ }
1203
+ const record = result;
1204
+ for (const key of [
1205
+ "success",
1206
+ "status",
1207
+ "error",
1208
+ "exitCode",
1209
+ "filePath",
1210
+ "path",
1211
+ "totalFiles",
1212
+ "filesWithMatches",
1213
+ "totalMatches",
1214
+ ]) {
1215
+ const value = record[key];
1216
+ if (typeof value === "string") {
1217
+ summary[key] = truncatePersistedSummaryString(value);
1218
+ }
1219
+ else if (typeof value === "number" || typeof value === "boolean") {
1220
+ summary[key] = value;
1221
+ }
1222
+ }
1223
+ return enforcePersistedSummaryByteLimit(summary);
1224
+ };
1225
+ const PERSISTED_TOOL_ARG_METADATA_KEYS = [
1226
+ "filePath",
1227
+ "path",
1228
+ "targetPath",
1229
+ "oldPath",
1230
+ "newPath",
1231
+ "startLine",
1232
+ "endLine",
1233
+ "line",
1234
+ "limit",
1235
+ "query",
1236
+ "pattern",
1237
+ "command",
1238
+ "cwd",
1239
+ "replaceAll",
1240
+ ];
1241
+ const summarizeToolArgsForPersistence = (args) => {
1242
+ let serialized;
1243
+ try {
1244
+ serialized = JSON.stringify(args);
1245
+ }
1246
+ catch {
1247
+ return {
1248
+ truncated: true,
1249
+ reason: "Tool arguments could not be serialized for persistence",
1250
+ };
1251
+ }
1252
+ if (serialized === undefined) {
1253
+ return {
1254
+ truncated: true,
1255
+ reason: "Tool arguments have no JSON representation for persistence",
1256
+ };
1257
+ }
1258
+ const originalByteLength = Buffer.byteLength(serialized, "utf8");
1259
+ if (originalByteLength <= MAX_PERSISTED_TOOL_RESULT_BYTES) {
1260
+ return args;
1261
+ }
1262
+ const summary = {
1263
+ truncated: true,
1264
+ originalByteLength,
1265
+ preview: serialized.slice(0, PERSISTED_TOOL_RESULT_PREVIEW_CHARS),
1266
+ };
1267
+ if (!isRecordValue(args)) {
1268
+ return enforcePersistedSummaryByteLimit(summary);
1269
+ }
1270
+ for (const key of PERSISTED_TOOL_ARG_METADATA_KEYS) {
1271
+ const value = args[key];
1272
+ if (typeof value === "string") {
1273
+ summary[key] = truncatePersistedSummaryString(value);
1274
+ }
1275
+ else if (typeof value === "number" || typeof value === "boolean") {
1276
+ summary[key] = value;
1277
+ }
1278
+ }
1279
+ return enforcePersistedSummaryByteLimit(summary);
1280
+ };
1281
+ const normalizeToolActionPayload = (eventType, payload) => {
1282
+ if (!payload ||
1283
+ (eventType !== "tool_call" && eventType !== "tool_result")) {
1284
+ return payload;
1285
+ }
1286
+ const name = typeof payload.name === "string"
1287
+ ? payload.name
1288
+ : typeof payload.toolName === "string"
1289
+ ? payload.toolName
1290
+ : undefined;
1291
+ if (!name)
1292
+ return payload;
1293
+ return {
1294
+ ...payload,
1295
+ name,
1296
+ toolName: typeof payload.toolName === "string" ? payload.toolName : name,
1297
+ };
1298
+ };
1299
+ /**
1300
+ * Format directory tree for display
1301
+ */
1302
+ function formatDirectoryTree(tree, prefix = "", isLast = true) {
1303
+ if (!tree)
1304
+ return "";
1305
+ let result = "";
1306
+ const connector = isLast ? "└── " : "├── ";
1307
+ const extension = isLast ? " " : "│ ";
1308
+ result += prefix + connector + tree.name;
1309
+ if (tree.type === "directory") {
1310
+ result += "/";
1311
+ }
1312
+ result += "\n";
1313
+ if (tree.children && tree.children.length > 0) {
1314
+ // Sort: directories first, then files
1315
+ const sorted = [...tree.children].sort((a, b) => {
1316
+ if (a.type === b.type)
1317
+ return a.name.localeCompare(b.name);
1318
+ return a.type === "directory" ? -1 : 1;
1319
+ });
1320
+ sorted.forEach((child, index) => {
1321
+ const childIsLast = index === sorted.length - 1;
1322
+ result += formatDirectoryTree(child, prefix + extension, childIsLast);
1323
+ });
1324
+ }
1325
+ return result;
1326
+ }
1327
+ // POST /api/agent/chat - Send a message to the agent
1328
+ router.post("/chat", async (req, res) => {
1329
+ console.log("🔍 === REQUEST RECEIVED ===");
1330
+ console.log("Body keys:", Object.keys(req.body));
1331
+ console.log("Message:", req.body.message?.substring(0, 100));
1332
+ console.log("FilesInContext:", req.body.filesInContext);
1333
+ let requestedModelId = "gpt-4o";
1334
+ let activeRunId;
1335
+ let activeHostSession;
1336
+ let activeHostSessionManager;
1337
+ // Set longer timeout for this specific route (5 minutes)
1338
+ req.setTimeout(5 * 60 * 1000);
1339
+ res.setTimeout(5 * 60 * 1000);
1340
+ try {
1341
+ const { message, chatSessionId: rawChatSessionId, runId, conversationHistory, toolResults, modelId = "gpt-4o", filesInContext, workspaceRoot, workspaceStructure, isTauri, contextSummary, // Accumulated context from previous interactions
1342
+ enabledSkills: rawEnabledSkills, enableSlashCommands = true, approvalMode, preferredAgentId, mcpServers: rawMcpServers, terminalAutoApproveRules: rawTerminalAutoApproveRules, useMastraObservationalMemory: rawUseMastraObservationalMemory, observationalMemorySettings: rawObservationalMemorySettings, streamErrorRetry: rawStreamErrorRetry, stream = false, } = req.body;
1343
+ requestedModelId = modelId;
1344
+ if (typeof runId === "string" &&
1345
+ runId.trim().length > 0 &&
1346
+ !isSafeRunId(runId.trim())) {
1347
+ return res
1348
+ .status(400)
1349
+ .json({ success: false, error: "Invalid run id" });
1350
+ }
1351
+ const chatSessionId = typeof rawChatSessionId === "string" ? rawChatSessionId.trim() : "";
1352
+ if (chatSessionId && !isSafeSessionId(chatSessionId)) {
1353
+ return res
1354
+ .status(400)
1355
+ .json({ success: false, error: "Invalid chat session id" });
1356
+ }
1357
+ const resolvedRunId = typeof runId === "string" && runId.trim().length > 0
1358
+ ? runId.trim()
1359
+ : `run_${randomUUID()}`;
1360
+ const workspaceMutationGenerationId = randomUUID();
1361
+ activeRunId = resolvedRunId;
1362
+ const mastraThreadId = chatSessionId || resolvedRunId;
1363
+ if (chatSessionId) {
1364
+ const activeTurnKey = `chat:${mastraThreadId}`;
1365
+ if (activeChatSessionTurns.has(activeTurnKey)) {
1366
+ return res.status(409).json({
1367
+ success: false,
1368
+ runId: resolvedRunId,
1369
+ lifecycleState: "queued",
1370
+ stopReason: "none",
1371
+ error: "Another prompt is already running for this chat session. Wait for it to finish before starting a new turn.",
1372
+ });
1373
+ }
1374
+ activeChatSessionTurns.add(activeTurnKey);
1375
+ const releaseActiveTurn = () => {
1376
+ activeChatSessionTurns.delete(activeTurnKey);
1377
+ };
1378
+ res.once("finish", releaseActiveTurn);
1379
+ res.once("close", releaseActiveTurn);
1380
+ }
1381
+ const mastraMemoryScope = rawUseMastraObservationalMemory === true
1382
+ ? {
1383
+ thread: mastraThreadId,
1384
+ resource: `iris-chat:${mastraThreadId}`,
1385
+ }
1386
+ : undefined;
1387
+ let lifecycleState = "queued";
1388
+ let stopReason = "none";
1389
+ const persistLifecycle = async (nextState, nextStopReason, eventType, payload) => {
1390
+ lifecycleState = nextState;
1391
+ stopReason = nextStopReason;
1392
+ await safePersistRunLifecycleEvent({
1393
+ runId: resolvedRunId,
1394
+ lifecycleState: nextState,
1395
+ stopReason: nextStopReason,
1396
+ eventType,
1397
+ payload,
1398
+ objective: typeof message === "string" ? message.slice(0, 2000) : "",
1399
+ workspacePath: workspaceRoot || process.cwd(),
1400
+ modelId,
1401
+ });
1402
+ };
1403
+ let isWebWorkspaceForPersistence = false;
1404
+ const persistToolEvent = async (eventType, payload, eventLifecycleState = lifecycleState) => {
1405
+ const toolName = typeof payload.name === "string"
1406
+ ? payload.name
1407
+ : typeof payload.toolName === "string"
1408
+ ? payload.toolName
1409
+ : "";
1410
+ const effectiveArgs = "args" in payload && isRecordValue(payload.args)
1411
+ ? sanitizeToolArgsForWorkspace(toolName, payload.args, isWebWorkspaceForPersistence)
1412
+ : payload.args;
1413
+ const args = "args" in payload
1414
+ ? summarizeToolArgsForPersistence(redactToolResult(effectiveArgs).result)
1415
+ : undefined;
1416
+ const result = "result" in payload
1417
+ ? summarizeToolResultForPersistence(payload.result)
1418
+ : undefined;
1419
+ const persistedPayload = {
1420
+ ...payload,
1421
+ ...("args" in payload ? { args } : {}),
1422
+ ...("result" in payload ? { result } : {}),
1423
+ };
1424
+ await safePersistRunLifecycleEvent({
1425
+ runId: resolvedRunId,
1426
+ lifecycleState: eventLifecycleState,
1427
+ stopReason,
1428
+ eventType,
1429
+ payload: persistedPayload,
1430
+ objective: typeof message === "string" ? message.slice(0, 2000) : "",
1431
+ workspacePath: workspaceRoot || process.cwd(),
1432
+ modelId,
1433
+ });
1434
+ };
1435
+ const transitionToCancelled = async (phase) => {
1436
+ await persistLifecycle("cancelled", "cancelled", "cancelled", {
1437
+ phase,
1438
+ });
1439
+ };
1440
+ const isCancelled = async () => {
1441
+ try {
1442
+ return await isRunCancellationRequested(resolvedRunId);
1443
+ }
1444
+ catch (error) {
1445
+ const err = error;
1446
+ // Fail-closed: if we can't confirm the cancellation state, treat the
1447
+ // run as cancelled rather than letting it continue (fail-open) or
1448
+ // throwing (which would abort the whole in-flight stream/response
1449
+ // with an unhandled 500 on every per-chunk check).
1450
+ console.warn("[agent] Failed to read run cancellation state, treating run as cancelled:", err.message);
1451
+ return true;
1452
+ }
1453
+ };
1454
+ const turnAbortController = new AbortController();
1455
+ let turnCancellationForwarded = false;
1456
+ const requestTurnCancellation = async () => {
1457
+ if (!turnAbortController.signal.aborted) {
1458
+ turnAbortController.abort();
1459
+ }
1460
+ if (turnCancellationForwarded) {
1461
+ return;
1462
+ }
1463
+ turnCancellationForwarded = true;
1464
+ if (!activeHostSession) {
1465
+ return;
1466
+ }
1467
+ try {
1468
+ await activeHostSession.cancel();
1469
+ }
1470
+ catch (error) {
1471
+ console.warn("[agent] Failed to propagate turn cancellation to host session:", getErrorMessage(error));
1472
+ }
1473
+ };
1474
+ const cancelledDuringWait = {
1475
+ __cancelledDuringWait: true,
1476
+ };
1477
+ const isCancelledWaitResult = (value) => Boolean(value &&
1478
+ typeof value === "object" &&
1479
+ "__cancelledDuringWait" in value &&
1480
+ value
1481
+ .__cancelledDuringWait === true);
1482
+ const waitForResultOrCancellation = async (work) => {
1483
+ let stopped = false;
1484
+ const waitForCancellation = (async () => {
1485
+ while (!stopped) {
1486
+ if (await isCancelled()) {
1487
+ await requestTurnCancellation();
1488
+ return cancelledDuringWait;
1489
+ }
1490
+ await sleep(75);
1491
+ }
1492
+ return cancelledDuringWait;
1493
+ })();
1494
+ try {
1495
+ const settled = await Promise.race([work, waitForCancellation]);
1496
+ return settled;
1497
+ }
1498
+ finally {
1499
+ stopped = true;
1500
+ }
1501
+ };
1502
+ await persistLifecycle("queued", "none", "request_received", {
1503
+ hasConversationHistory: Array.isArray(conversationHistory) && conversationHistory.length > 0,
1504
+ hasToolResults: Array.isArray(toolResults) && toolResults.length > 0,
1505
+ });
1506
+ const enabledSkills = normalizeEnabledSkills(rawEnabledSkills);
1507
+ if (isTauri && approvalMode && approvalMode !== "Default Approvals") {
1508
+ return res.status(400).json({
1509
+ success: false,
1510
+ error: "Invalid approval mode for Tauri: only 'Default Approvals' is allowed",
1511
+ toolCalls: [],
1512
+ executedToolResults: [],
1513
+ });
1514
+ }
1515
+ const requestedMcpServers = sanitizeMcpServers(rawMcpServers);
1516
+ const terminalAutoApproveRules = sanitizeTerminalAutoApproveRules(rawTerminalAutoApproveRules);
1517
+ const useMastraObservationalMemory = typeof rawUseMastraObservationalMemory === "boolean"
1518
+ ? rawUseMastraObservationalMemory
1519
+ : undefined;
1520
+ const observationalMemorySettings = sanitizeObservationalMemorySettings(rawObservationalMemorySettings);
1521
+ const streamErrorRetry = sanitizeStreamErrorRetryRequest(rawStreamErrorRetry);
1522
+ const allowMcp = Boolean(isTauri && hasDesktopAuth(req));
1523
+ const mcpServers = allowMcp ? requestedMcpServers : [];
1524
+ if (!allowMcp && requestedMcpServers.length > 0) {
1525
+ console.warn("[mcp] Ignoring MCP servers without desktop auth");
1526
+ }
1527
+ console.log("📨 Request:", {
1528
+ messageLength: message?.length,
1529
+ hasHistory: !!conversationHistory,
1530
+ historyLength: conversationHistory?.length || 0,
1531
+ hasToolResults: Array.isArray(toolResults) && toolResults.length > 0,
1532
+ enabledSkillsCount: enabledSkills.length,
1533
+ isTauri,
1534
+ });
1535
+ // Build messages array with optional structured tool-result continuation.
1536
+ let messages = Array.isArray(conversationHistory)
1537
+ ? [...conversationHistory]
1538
+ : [];
1539
+ if (messages.length === 0 &&
1540
+ typeof message === "string" &&
1541
+ message.length > 0) {
1542
+ messages.push({ role: "user", content: message });
1543
+ }
1544
+ const toolResultsContinuationMessage = serializeToolResultsForConversation(toolResults);
1545
+ if (toolResultsContinuationMessage) {
1546
+ messages.push({
1547
+ role: "user",
1548
+ content: toolResultsContinuationMessage,
1549
+ continuationType: "tool_results",
1550
+ });
1551
+ }
1552
+ // If Tauri mode, add note about tool execution
1553
+ if (isTauri) {
1554
+ console.log("🖥️ Tauri mode - tools will be executed in frontend");
1555
+ }
1556
+ if (messages.length === 0) {
1557
+ return res.status(400).json({ error: "Message is required" });
1558
+ }
1559
+ const effectiveMessage = typeof message === "string" && message.length > 0
1560
+ ? message
1561
+ : messages[messages.length - 1]?.content || "";
1562
+ // Use provided workspace root or default to current directory
1563
+ const workspacePath = workspaceRoot || process.cwd();
1564
+ const workspaceAirisConfig = await readWorkspaceAirisConfigForAgent(workspacePath).catch((error) => {
1565
+ const err = error;
1566
+ console.warn("[agent] failed to read workspace AIRIS config:", err.message);
1567
+ return null;
1568
+ });
1569
+ const workspaceStreamErrorRetry = sanitizeStreamErrorRetryRequest(workspaceAirisConfig?.agent?.streamErrorRetry);
1570
+ const effectiveStreamErrorRetry = streamErrorRetry ?? workspaceStreamErrorRetry;
1571
+ await persistLifecycle("running", "none", "run_started", {
1572
+ workspacePath,
1573
+ modelId,
1574
+ });
1575
+ if (await isCancelled()) {
1576
+ if (!stream) {
1577
+ await transitionToCancelled("run_started");
1578
+ return res.status(409).json({
1579
+ success: false,
1580
+ runId: resolvedRunId,
1581
+ lifecycleState,
1582
+ stopReason,
1583
+ error: "Run was cancelled",
1584
+ });
1585
+ }
1586
+ }
1587
+ const modelProfile = getModelExecutionProfile(modelId);
1588
+ const gitSafetyMode = resolveGitSafetyMode();
1589
+ const gitDetected = await detectGitRepo(workspacePath);
1590
+ const parsedSlashCommand = parseSlashCommandRequest(effectiveMessage);
1591
+ if (parsedSlashCommand) {
1592
+ if (!enableSlashCommands) {
1593
+ // User disabled slash command execution in settings; treat this as normal chat input.
1594
+ }
1595
+ else if (!isSlashCommandExecutionAllowed(req, isTauri, enableSlashCommands)) {
1596
+ return res.status(403).json({
1597
+ success: false,
1598
+ error: "Slash command execution is disabled or not allowed in this context",
1599
+ toolCalls: [],
1600
+ executedToolResults: [],
1601
+ });
1602
+ }
1603
+ else {
1604
+ const builtInSlashResult = executeRegisteredSlashCommand({
1605
+ parsedCommand: parsedSlashCommand,
1606
+ conversationHistory,
1607
+ contextSummary,
1608
+ });
1609
+ if (builtInSlashResult) {
1610
+ return res.json(builtInSlashResult);
1611
+ }
1612
+ const slashCommand = parsedSlashCommand.shellCommand;
1613
+ if (!slashCommand) {
1614
+ return res.status(400).json({
1615
+ success: false,
1616
+ error: `Slash command '/${parsedSlashCommand.name}' requires a shell command argument`,
1617
+ toolCalls: [],
1618
+ executedToolResults: [],
1619
+ });
1620
+ }
1621
+ const toolArgs = {
1622
+ command: slashCommand,
1623
+ cwd: workspacePath,
1624
+ description: `Slash command: ${slashCommand}`,
1625
+ };
1626
+ const commandResult = await executeCommand({
1627
+ ...toolArgs,
1628
+ workspaceRoot: workspacePath,
1629
+ });
1630
+ const status = resolveToolExecutionStatus(commandResult);
1631
+ if (status === "pending_confirmation") {
1632
+ const confirmationId = commandResult.confirmationId ||
1633
+ "";
1634
+ if (!confirmationId) {
1635
+ return res.status(500).json({
1636
+ success: false,
1637
+ error: "Command entered pending confirmation state without confirmationId",
1638
+ toolCalls: [],
1639
+ executedToolResults: [
1640
+ {
1641
+ name: "executeCommand",
1642
+ args: toolArgs,
1643
+ result: commandResult,
1644
+ status,
1645
+ },
1646
+ ],
1647
+ });
1648
+ }
1649
+ return res.json({
1650
+ success: true,
1651
+ response: `Pending approval for command: ${slashCommand}`,
1652
+ requiresConfirmation: true,
1653
+ pendingConfirmations: [
1654
+ {
1655
+ confirmationId,
1656
+ command: commandResult.command ||
1657
+ slashCommand,
1658
+ action: commandResult.action,
1659
+ target: commandResult.target,
1660
+ toolName: "executeCommand",
1661
+ toolArgs,
1662
+ },
1663
+ ],
1664
+ toolCalls: [],
1665
+ executedToolResults: [
1666
+ {
1667
+ name: "executeCommand",
1668
+ args: toolArgs,
1669
+ result: commandResult,
1670
+ status,
1671
+ },
1672
+ ],
1673
+ });
1674
+ }
1675
+ if (status === "pending" || status === "in_progress") {
1676
+ return res.json({
1677
+ success: false,
1678
+ response: status === "in_progress"
1679
+ ? "Command is still running"
1680
+ : "Command is pending",
1681
+ status,
1682
+ taskId: typeof commandResult.taskId ===
1683
+ "string"
1684
+ ? commandResult.taskId
1685
+ : undefined,
1686
+ toolCalls: [],
1687
+ executedToolResults: [
1688
+ {
1689
+ name: "executeCommand",
1690
+ args: toolArgs,
1691
+ result: commandResult,
1692
+ status,
1693
+ },
1694
+ ],
1695
+ });
1696
+ }
1697
+ const commandSucceeded = status === "completed";
1698
+ const commandError = commandResult.error &&
1699
+ typeof commandResult.error === "string"
1700
+ ? commandResult.error || "Command failed"
1701
+ : "Command failed";
1702
+ const stdoutText = typeof commandResult.stdout === "string"
1703
+ ? commandResult.stdout || ""
1704
+ : "";
1705
+ const stderrText = typeof commandResult.stderr === "string"
1706
+ ? commandResult.stderr || ""
1707
+ : "";
1708
+ const responseText = commandSucceeded
1709
+ ? stdoutText || "Command executed"
1710
+ : stderrText || commandError;
1711
+ return res.json({
1712
+ success: commandSucceeded,
1713
+ response: responseText,
1714
+ ...(commandSucceeded ? {} : { error: commandError }),
1715
+ toolCalls: [],
1716
+ executedToolResults: [
1717
+ {
1718
+ name: "executeCommand",
1719
+ args: toolArgs,
1720
+ result: commandResult,
1721
+ status,
1722
+ },
1723
+ ],
1724
+ });
1725
+ }
1726
+ }
1727
+ const mcpDiscovery = allowMcp
1728
+ ? await inspectMcpServersForChat(mcpServers, workspacePath)
1729
+ : [];
1730
+ // Detect if this is a web-based workspace (virtual path)
1731
+ const isWebWorkspace = workspacePath.startsWith("/workspace/");
1732
+ isWebWorkspaceForPersistence = isWebWorkspace;
1733
+ // Generate environment snapshot for first message (non-web workspaces only)
1734
+ let envSnapshotMarkdown = "";
1735
+ const isFirstMessage = !conversationHistory || conversationHistory.length <= 1;
1736
+ if (!isWebWorkspace && isFirstMessage) {
1737
+ console.log("📸 Generating environment snapshot for first message...");
1738
+ try {
1739
+ // Check cache first
1740
+ const cacheKey = workspacePath;
1741
+ let snapshot = envSnapshotCache.get(cacheKey);
1742
+ if (!snapshot) {
1743
+ snapshot = await getEnvironmentSnapshot(workspacePath);
1744
+ // Cache for 5 minutes
1745
+ envSnapshotCache.set(cacheKey, snapshot);
1746
+ const evictionTimer = setTimeout(() => envSnapshotCache.delete(cacheKey), 5 * 60 * 1000);
1747
+ evictionTimer.unref();
1748
+ }
1749
+ envSnapshotMarkdown = formatSnapshotAsMarkdown(snapshot);
1750
+ console.log("✅ Environment snapshot generated");
1751
+ }
1752
+ catch (error) {
1753
+ const err = error;
1754
+ console.warn("⚠️ Failed to generate environment snapshot:", err.message);
1755
+ // Continue without snapshot
1756
+ }
1757
+ }
1758
+ // Get or create agent for this model/workspace combination
1759
+ const effectivePreferredAgentId = preferredAgentId ||
1760
+ process.env.IRIS_AGENT_PREFERRED_AGENT_ID ||
1761
+ undefined;
1762
+ const agent = await getOrCreateAgent(modelId, workspacePath, mcpServers, effectivePreferredAgentId, terminalAutoApproveRules, useMastraObservationalMemory, observationalMemorySettings, effectiveStreamErrorRetry);
1763
+ const hostSessionId = chatSessionId || resolvedRunId;
1764
+ activeHostSessionManager = new HostSessionManager(() => Promise.resolve(createGeneratedAgentRuntimeAdapter(agent)), {
1765
+ workspacePath,
1766
+ modelId,
1767
+ metadata: {
1768
+ runId: resolvedRunId,
1769
+ preferredAgentId: effectivePreferredAgentId || "iris",
1770
+ },
1771
+ });
1772
+ activeHostSession = await activeHostSessionManager.resumeSession(hostSessionId, {
1773
+ modelId,
1774
+ metadata: {
1775
+ runId: resolvedRunId,
1776
+ threadId: mastraThreadId,
1777
+ },
1778
+ });
1779
+ // Build workspace context
1780
+ let contextInfo = `
1781
+
1782
+ **WORKSPACE:**`;
1783
+ if (isWebWorkspace) {
1784
+ contextInfo += `
1785
+ - **Environment:** Web workspace
1786
+ - **Name:** ${workspacePath.split("/").pop()}`;
1787
+ if (workspaceStructure) {
1788
+ contextInfo += `
1789
+ - **Structure:**
1790
+ \`\`\`
1791
+ ${formatDirectoryTree(workspaceStructure)}
1792
+ \`\`\``;
1793
+ }
1794
+ contextInfo += `
1795
+ - Treat paths as workspace-relative (for example, \`src/index.js\`).
1796
+ - Use the provided structure and file context; request missing file contents only when needed.
1797
+ - Answer directly; avoid generic follow-up questions.`;
1798
+ }
1799
+ else {
1800
+ contextInfo += `
1801
+ - **Root:** ${workspacePath}
1802
+ - Use \`getWorkspaceInfo\` with \`{ "workspacePath": "${workspacePath}", "includeTree": true }\`.
1803
+ - Use workspace-relative paths with file tools (for example, \`src/index.js\`).`;
1804
+ if (gitDetected && gitSafetyMode !== "off") {
1805
+ contextInfo += `
1806
+ - **Git Safety:** repository detected.
1807
+ - Preserve existing worktree changes; do not stage, commit, stash, or revert them unless the user explicitly requests it.
1808
+ - Keep edits scoped and reversible, and avoid destructive git commands.
1809
+ - If edits fail, provide undo guidance and a minimal recovery plan.`;
1810
+ }
1811
+ // Add environment snapshot if this is the first message
1812
+ if (envSnapshotMarkdown) {
1813
+ contextInfo += `
1814
+
1815
+ ${envSnapshotMarkdown}`;
1816
+ }
1817
+ }
1818
+ if (mcpDiscovery.length > 0) {
1819
+ contextInfo += `
1820
+ ${formatMcpContext(mcpDiscovery)}`;
1821
+ }
1822
+ // Add accumulated context summary if available
1823
+ if (contextSummary && contextSummary.length > 0) {
1824
+ contextInfo += `
1825
+
1826
+ **ACCUMULATED KNOWLEDGE (from previous interactions):**
1827
+ _You have discovered the following in earlier interactions. Use this to avoid repeating searches._
1828
+
1829
+ `;
1830
+ for (const ctx of contextSummary.slice(-10)) {
1831
+ // Last 10 contexts only
1832
+ contextInfo += `- ${ctx.id}: ${ctx.preview}...\n`;
1833
+ }
1834
+ contextInfo +=
1835
+ "\n_Use this accumulated knowledge to build upon previous work._\n";
1836
+ }
1837
+ // Add safety check for filesInContext
1838
+ const safeFilesInContext = (filesInContext || []).slice(0, MAX_CONTEXT_FILES);
1839
+ if ((filesInContext || []).length > MAX_CONTEXT_FILES) {
1840
+ contextInfo += `\n\n*Only the first ${MAX_CONTEXT_FILES} files in context are included for prompt budget safety.*`;
1841
+ }
1842
+ if (safeFilesInContext.length > 0) {
1843
+ contextInfo += "\n\n**Files in Context:**";
1844
+ // Check if files already have content (from web workspace)
1845
+ const filesHaveContent = safeFilesInContext.some((f) => f.content !== undefined);
1846
+ if (filesHaveContent) {
1847
+ // Files from web workspace with content already provided
1848
+ for (const file of safeFilesInContext) {
1849
+ contextInfo += `\n\n### File: ${file.name}`;
1850
+ contextInfo += `\n**Path:** \`${file.path}\`\n`;
1851
+ if (file.content) {
1852
+ const inlineContent = truncateText(file.content, MAX_INLINE_FILE_CONTENT_CHARS);
1853
+ contextInfo += `\`\`\`\n${inlineContent}\n\`\`\`\n`;
1854
+ }
1855
+ else if (file.skippedReason) {
1856
+ contextInfo += `*Skipped inline file: ${file.skippedReason}*\n`;
1857
+ }
1858
+ else if (file.error) {
1859
+ contextInfo += `*Error reading file: ${file.error}*\n`;
1860
+ }
1861
+ else {
1862
+ contextInfo += "*Content not available*\n";
1863
+ }
1864
+ }
1865
+ }
1866
+ else if (!isWebWorkspace) {
1867
+ // For non-web workspaces, read actual file contents from filesystem
1868
+ for (const file of safeFilesInContext) {
1869
+ contextInfo += `\n\n### File: ${file.name}`;
1870
+ contextInfo += `\n**Path:** \`${file.path}\`\n`;
1871
+ try {
1872
+ // Resolve file path relative to workspace
1873
+ const filePath = path.isAbsolute(file.path)
1874
+ ? file.path
1875
+ : path.join(workspacePath, file.path);
1876
+ const content = await fs.readFile(filePath, "utf8");
1877
+ const inlineContent = truncateText(content, MAX_INLINE_FILE_CONTENT_CHARS);
1878
+ contextInfo += `\`\`\`\n${inlineContent}\n\`\`\`\n`;
1879
+ }
1880
+ catch (err) {
1881
+ contextInfo += `*Error reading file: ${getErrorMessage(err)}*\n`;
1882
+ }
1883
+ }
1884
+ }
1885
+ else {
1886
+ // For web workspaces without content
1887
+ contextInfo += `\n${safeFilesInContext
1888
+ .map((f) => `- ${f.name}: ${f.path}`)
1889
+ .join("\n")}`;
1890
+ contextInfo +=
1891
+ "\n\n**Note:** File contents should be provided by the user or requested through the interface.";
1892
+ }
1893
+ }
1894
+ const modelInputTokenLimit = resolveModelInputTokenLimit(modelId);
1895
+ const promptBudgetRatio = asBoundedFloat(process.env.IRIS_AGENT_PROMPT_TOKEN_BUDGET_RATIO, DEFAULT_PROMPT_TOKEN_BUDGET_RATIO, 0.2, 0.8);
1896
+ const promptTokenBudget = Math.max(MIN_PROMPT_TOKEN_BUDGET, Math.min(Math.floor(modelInputTokenLimit * promptBudgetRatio), Math.max(MIN_PROMPT_TOKEN_BUDGET, modelInputTokenLimit - PROMPT_TOKEN_RESERVE)));
1897
+ const mastraManagedContextMode = shouldUseMastraManagedContextMode(useMastraObservationalMemory);
1898
+ const promptBuild = buildPromptWithinTokenBudget({
1899
+ effectiveMessage: truncateText(typeof messages[messages.length - 1]?.content === "string"
1900
+ ? messages[messages.length - 1].content
1901
+ : effectiveMessage, MAX_CONVERSATION_MESSAGE_CHARS),
1902
+ conversationHistory: mastraManagedContextMode ? undefined : messages,
1903
+ contextInfo,
1904
+ maxPromptTokens: promptTokenBudget,
1905
+ maxConversationMessages: MAX_CONVERSATION_MESSAGES,
1906
+ maxConversationMessageTokens: MAX_CONVERSATION_MESSAGE_TOKENS,
1907
+ continuationInstruction: isSynthesisOnlyContinuationMessage(messages[messages.length - 1])
1908
+ ? "**IMPORTANT:** The tool results above contain the information needed to answer the user's question. Please analyze these results and provide a clear, detailed response that directly answers what the user asked. Do not request more tools unless absolutely necessary."
1909
+ : undefined,
1910
+ });
1911
+ const budgetedContextInfo = promptBuild.budgetedContextInfo;
1912
+ const budgetedConversationHistory = promptBuild.budgetedConversationHistory;
1913
+ const prompt = promptBuild.prompt;
1914
+ const multimodalImageParts = resolveModelSupportsVision(modelId)
1915
+ ? await resolveImageMessageParts(safeFilesInContext, workspacePath, isWebWorkspace, hasDesktopAuth(req))
1916
+ : [];
1917
+ const modelInput = multimodalImageParts.length > 0
1918
+ ? [
1919
+ {
1920
+ role: "user",
1921
+ content: [
1922
+ {
1923
+ type: "text",
1924
+ text: prompt,
1925
+ },
1926
+ ...multimodalImageParts,
1927
+ ],
1928
+ },
1929
+ ]
1930
+ : prompt;
1931
+ console.log("📏 Prompt budget", {
1932
+ modelInputTokenLimit,
1933
+ promptBudgetRatio,
1934
+ promptTokenBudget,
1935
+ contextInfoChars: budgetedContextInfo.length,
1936
+ promptChars: prompt.length,
1937
+ promptEstimatedTokens: promptBuild.promptEstimatedTokens,
1938
+ historyMessages: budgetedConversationHistory.length,
1939
+ contextFilesIncluded: safeFilesInContext.length,
1940
+ multimodalImagesIncluded: multimodalImageParts.length,
1941
+ });
1942
+ // Generate response with agent
1943
+ // When tools are executed on frontend (web or Tauri), we need to handle continuation
1944
+ // - If conversation history contains tool results, we want a TEXT RESPONSE, not more tools
1945
+ // - Otherwise, get tool calls and return them to frontend
1946
+ // Check whether the last message requires a synthesis-only continuation.
1947
+ const lastMessage = messages[messages.length - 1];
1948
+ const isSynthesisOnlyContinuation = isSynthesisOnlyContinuationMessage(lastMessage);
1949
+ if (typeof effectiveMessage === "string" &&
1950
+ isCodingObjectiveRequest(effectiveMessage, Array.isArray(toolResults) && toolResults.length > 0, isSynthesisOnlyContinuation) &&
1951
+ typeof agent.setObjective === "function") {
1952
+ await agent.setObjective(effectiveMessage, {
1953
+ threadId: mastraThreadId,
1954
+ resourceId: `iris-chat:${mastraThreadId}`,
1955
+ maxRuns: 12,
1956
+ });
1957
+ }
1958
+ // Extract any relevant options from the request body
1959
+ // Note: isWebWorkspace already declared at line 89
1960
+ // Get maxSteps from request body (sourced from settings.agentMaxSteps in the client),
1961
+ // falling back to the desktop cap. The same hard cap applies to both web and desktop
1962
+ // so the user-configured value is always honoured.
1963
+ const requestedMaxSteps = req.body.maxSteps;
1964
+ const defaultMaxSteps = modelProfile.maxStepsCapDesktop;
1965
+ const hardCap = 100;
1966
+ let maxSteps = Math.max(1, Math.min(hardCap, typeof requestedMaxSteps === "number"
1967
+ ? requestedMaxSteps
1968
+ : defaultMaxSteps));
1969
+ const maxToolCalls = maxSteps;
1970
+ // Cap completion tokens so providers don't bill for the model's full max
1971
+ // context window. Honors a per-request override, clamped to a safe range.
1972
+ const requestedMaxOutputTokens = req.body.maxOutputTokens;
1973
+ const maxOutputTokens = Math.max(256, Math.min(65536, typeof requestedMaxOutputTokens === "number" &&
1974
+ Number.isFinite(requestedMaxOutputTokens)
1975
+ ? requestedMaxOutputTokens
1976
+ : modelProfile.maxOutputTokens));
1977
+ const toolCallBudget = {
1978
+ limit: maxToolCalls,
1979
+ admitted: 0,
1980
+ };
1981
+ const stopWhenToolBudgetReached = ({ steps, }) => toolCallBudget.admitted >= maxToolCalls ||
1982
+ toolCallBudget.stopReason === "repeated_call" ||
1983
+ steps.reduce((total, step) => total + (Array.isArray(step.toolCalls) ? step.toolCalls.length : 0), 0) >= maxToolCalls;
1984
+ const { createAgentRequestContext } = await loadAgentCoreModule();
1985
+ const agentRequestContext = createAgentRequestContext(enabledSkills, {
1986
+ workspaceMutationGenerationId,
1987
+ toolCallBudget,
1988
+ workspaceRoot: workspacePath,
1989
+ isWebWorkspace,
1990
+ gitDetected,
1991
+ modelId,
1992
+ contextFilesMeta: safeFilesInContext.map((file) => ({
1993
+ name: typeof file?.name === "string" ? file.name : undefined,
1994
+ path: typeof file?.path === "string" ? file.path : undefined,
1995
+ type: typeof file?.type === "string" ? file.type : undefined,
1996
+ size: typeof file?.size === "number" ? file.size : undefined,
1997
+ isImage: isLikelyImageFile(file),
1998
+ })),
1999
+ multimodalImageCount: multimodalImageParts.length,
2000
+ });
2001
+ const generateOptions = {
2002
+ maxSteps: maxSteps,
2003
+ maxOutputTokens,
2004
+ ...(mastraMemoryScope ? { memory: mastraMemoryScope } : {}),
2005
+ toolChoice: "auto",
2006
+ toolCallConcurrency: 1,
2007
+ stopWhen: stopWhenToolBudgetReached,
2008
+ requestContext: agentRequestContext,
2009
+ abortSignal: turnAbortController.signal,
2010
+ };
2011
+ if (isSynthesisOnlyContinuation) {
2012
+ generateOptions.maxSteps = 1;
2013
+ generateOptions.toolChoice = "none";
2014
+ }
2015
+ const generateBackendOnlySynthesis = async (completedToolResults, stopReason) => {
2016
+ const completedResults = completedToolResults.length > 0
2017
+ ? serializeToolResultsForContinuation(completedToolResults.map(({ name, result }) => ({
2018
+ name,
2019
+ result,
2020
+ })), "unknown_tool")
2021
+ : "No tools completed before the action budget was exhausted.";
2022
+ const stopReasonExplanation = stopReason === "repeated_call"
2023
+ ? "The same tool was called with identical arguments several times in a row, so tool execution was stopped to avoid a repetitive loop. Briefly tell the user this happened (which tool, and that it was called repeatedly with the same arguments) before summarizing whatever was accomplished, based on the actual outcomes in the completed tool results below — do not assume the repeated calls failed."
2024
+ : stopReason === "empty_final_response"
2025
+ ? "Tool execution finished, but the model returned no final response. Summarize the completed work from the tool results below in a concise final answer."
2026
+ : "The server-side tool-action budget is exhausted.";
2027
+ const synthesisPrompt = `${prompt}\n\n${stopReasonExplanation} Do not call any tools. Produce the final answer now using only the conversation and completed tool results below. Clearly distinguish verified findings from uncertainty.\n\nCompleted tool results:\n${completedResults}`;
2028
+ const synthesisOptions = {
2029
+ maxSteps: 1,
2030
+ maxOutputTokens,
2031
+ ...(mastraMemoryScope ? { memory: mastraMemoryScope } : {}),
2032
+ toolChoice: "none",
2033
+ toolCallConcurrency: 1,
2034
+ requestContext: agentRequestContext,
2035
+ abortSignal: turnAbortController.signal,
2036
+ };
2037
+ if (activeHostSession) {
2038
+ return await waitForResultOrCancellation(generateWithSessionRetry(activeHostSession, synthesisPrompt, synthesisOptions, turnAbortController.signal, modelProfile.generateRetryAttempts));
2039
+ }
2040
+ return await waitForResultOrCancellation(generateWithRetry(agent, synthesisPrompt, synthesisOptions, turnAbortController.signal, modelProfile.generateRetryAttempts));
2041
+ };
2042
+ if (stream) {
2043
+ res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
2044
+ res.setHeader("Cache-Control", "no-cache, no-transform");
2045
+ res.setHeader("Connection", "keep-alive");
2046
+ // Prevent idle intermediaries (and strict clients) from closing long-running
2047
+ // streams while tools execute without emitting deltas.
2048
+ const STREAM_KEEPALIVE_MS = 15_000;
2049
+ let keepAliveTimer = null;
2050
+ const stopKeepAlive = () => {
2051
+ if (keepAliveTimer) {
2052
+ clearInterval(keepAliveTimer);
2053
+ keepAliveTimer = null;
2054
+ }
2055
+ };
2056
+ const startKeepAlive = () => {
2057
+ stopKeepAlive();
2058
+ keepAliveTimer = setInterval(() => {
2059
+ if (res.writableEnded || res.destroyed) {
2060
+ stopKeepAlive();
2061
+ return;
2062
+ }
2063
+ try {
2064
+ res.write(`: keepalive ${Date.now()}\n\n`);
2065
+ }
2066
+ catch {
2067
+ stopKeepAlive();
2068
+ }
2069
+ }, STREAM_KEEPALIVE_MS);
2070
+ };
2071
+ res.on("close", stopKeepAlive);
2072
+ startKeepAlive();
2073
+ const writeEvent = (event, data) => {
2074
+ res.write(`event: ${event}\n`);
2075
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
2076
+ };
2077
+ try {
2078
+ if (await isCancelled()) {
2079
+ await transitionToCancelled("before_stream_start");
2080
+ writeEvent("lifecycle", {
2081
+ runId: resolvedRunId,
2082
+ lifecycleState,
2083
+ stopReason,
2084
+ });
2085
+ writeEvent("done", {
2086
+ success: false,
2087
+ runId: resolvedRunId,
2088
+ lifecycleState,
2089
+ stopReason,
2090
+ cancelled: true,
2091
+ response: "Run cancelled",
2092
+ toolCalls: [],
2093
+ executedToolResults: [],
2094
+ suspendedTools: [],
2095
+ thoughtSteps: [],
2096
+ model: modelId,
2097
+ autoFixAttempted: false,
2098
+ autoFixFailureCount: 0,
2099
+ maxStepsReached: false,
2100
+ stepsUsed: 0,
2101
+ maxSteps,
2102
+ });
2103
+ stopKeepAlive();
2104
+ res.end();
2105
+ return;
2106
+ }
2107
+ await persistLifecycle("running", "none", "model_stream_started", {
2108
+ maxSteps,
2109
+ maxOutputTokens,
2110
+ });
2111
+ writeEvent("lifecycle", {
2112
+ runId: resolvedRunId,
2113
+ lifecycleState,
2114
+ stopReason,
2115
+ });
2116
+ const streamResultPromise = activeHostSession
2117
+ ? (async () => {
2118
+ const hostStreamResult = await activeHostSession.sendStream({
2119
+ prompt: typeof modelInput === "string"
2120
+ ? modelInput
2121
+ : "[multimodal-prompt]",
2122
+ metadata: {
2123
+ modelInput,
2124
+ generateOptions,
2125
+ },
2126
+ });
2127
+ const rawFromSession = hostStreamResult
2128
+ .rawStreamResult;
2129
+ if (rawFromSession) {
2130
+ void hostStreamResult.stream.cancel().catch((error) => {
2131
+ console.error("Failed to cancel host stream adapter:", error);
2132
+ });
2133
+ return rawFromSession;
2134
+ }
2135
+ throw new Error("Host session stream transport unavailable for SSE chunk pipeline");
2136
+ })()
2137
+ : agent.stream(modelInput, generateOptions);
2138
+ const streamResultOrCancellation = await waitForResultOrCancellation(streamResultPromise);
2139
+ if (isCancelledWaitResult(streamResultOrCancellation)) {
2140
+ await transitionToCancelled("before_stream_reader");
2141
+ writeEvent("lifecycle", {
2142
+ runId: resolvedRunId,
2143
+ lifecycleState,
2144
+ stopReason,
2145
+ });
2146
+ writeEvent("done", {
2147
+ success: false,
2148
+ runId: resolvedRunId,
2149
+ lifecycleState,
2150
+ stopReason,
2151
+ cancelled: true,
2152
+ response: "Run cancelled",
2153
+ toolCalls: [],
2154
+ executedToolResults: [],
2155
+ suspendedTools: [],
2156
+ thoughtSteps: [],
2157
+ model: modelId,
2158
+ autoFixAttempted: false,
2159
+ autoFixFailureCount: 0,
2160
+ maxStepsReached: false,
2161
+ stepsUsed: 0,
2162
+ maxSteps,
2163
+ });
2164
+ stopKeepAlive();
2165
+ res.end();
2166
+ return;
2167
+ }
2168
+ const streamResult = streamResultOrCancellation;
2169
+ const reader = streamResult.fullStream.getReader();
2170
+ const thoughtBuffer = [];
2171
+ const streamedPendingToolCalls = [];
2172
+ const streamedExecutedToolResults = [];
2173
+ const streamedSuspendedTools = [];
2174
+ const streamedToolCallArgs = new Map();
2175
+ const streamedAnonymousToolCallArgs = [];
2176
+ const emittedPendingCounts = new Map();
2177
+ let streamTokenUsage;
2178
+ let streamUsageSeenInChunks = false;
2179
+ const getPendingKey = (call) => {
2180
+ const signature = getToolCallSignature(call.name, call.args || {});
2181
+ if (call.toolCallId)
2182
+ return `id:${call.toolCallId}:${signature}`;
2183
+ return signature;
2184
+ };
2185
+ const getEmittedInvocationKey = (toolName, toolCallId, args) => toolCallId
2186
+ ? `id:${toolCallId}:${getToolCallSignature(toolName, args)}`
2187
+ : getToolCallSignature(toolName, args);
2188
+ const emittedToolCallKeys = new Set();
2189
+ const persistedStreamToolActionKeys = new Set();
2190
+ const getPersistedStreamToolActionKey = (eventType, toolName, toolCallId, args) => `${eventType}:${getEmittedInvocationKey(toolName, toolCallId, args)}`;
2191
+ const resolveStreamToolResultArgs = (toolName, toolCallId, chunkArgs) => resolveToolResultArgs(toolName, toolCallId, chunkArgs, streamedToolCallArgs, streamedAnonymousToolCallArgs);
2192
+ while (true) {
2193
+ const streamReadOrCancellation = await waitForResultOrCancellation(reader.read());
2194
+ if (isCancelledWaitResult(streamReadOrCancellation)) {
2195
+ await reader.cancel().catch(() => undefined);
2196
+ await transitionToCancelled("stream_read_wait");
2197
+ writeEvent("lifecycle", {
2198
+ runId: resolvedRunId,
2199
+ lifecycleState,
2200
+ stopReason,
2201
+ });
2202
+ writeEvent("done", {
2203
+ success: false,
2204
+ runId: resolvedRunId,
2205
+ lifecycleState,
2206
+ stopReason,
2207
+ cancelled: true,
2208
+ response: "Run cancelled",
2209
+ toolCalls: [],
2210
+ executedToolResults: [],
2211
+ suspendedTools: streamedSuspendedTools,
2212
+ thoughtSteps: thoughtBuffer.length > 0 ? [thoughtBuffer.join("")] : [],
2213
+ model: modelId,
2214
+ autoFixAttempted: false,
2215
+ autoFixFailureCount: 0,
2216
+ maxStepsReached: false,
2217
+ stepsUsed: 0,
2218
+ maxSteps,
2219
+ });
2220
+ stopKeepAlive();
2221
+ res.end();
2222
+ return;
2223
+ }
2224
+ const { value, done } = streamReadOrCancellation;
2225
+ if (done)
2226
+ break;
2227
+ if (await isCancelled()) {
2228
+ await requestTurnCancellation();
2229
+ await reader.cancel().catch(() => undefined);
2230
+ await transitionToCancelled("stream_iteration");
2231
+ writeEvent("lifecycle", {
2232
+ runId: resolvedRunId,
2233
+ lifecycleState,
2234
+ stopReason,
2235
+ });
2236
+ writeEvent("done", {
2237
+ success: false,
2238
+ runId: resolvedRunId,
2239
+ lifecycleState,
2240
+ stopReason,
2241
+ cancelled: true,
2242
+ response: "Run cancelled",
2243
+ toolCalls: [],
2244
+ executedToolResults: [],
2245
+ suspendedTools: streamedSuspendedTools,
2246
+ thoughtSteps: thoughtBuffer.length > 0 ? [thoughtBuffer.join("")] : [],
2247
+ model: modelId,
2248
+ autoFixAttempted: false,
2249
+ autoFixFailureCount: 0,
2250
+ maxStepsReached: false,
2251
+ stepsUsed: 0,
2252
+ maxSteps,
2253
+ });
2254
+ stopKeepAlive();
2255
+ res.end();
2256
+ return;
2257
+ }
2258
+ const chunk = value;
2259
+ if (!chunk?.type)
2260
+ continue;
2261
+ if (chunk.payload) {
2262
+ const chunkUsage = extractTokenUsageFromChunkPayload(chunk.payload);
2263
+ if (chunkUsage) {
2264
+ streamUsageSeenInChunks = true;
2265
+ }
2266
+ streamTokenUsage = mergeTokenUsage(streamTokenUsage, chunkUsage);
2267
+ }
2268
+ if (chunk.type === "reasoning-delta") {
2269
+ const delta = String(chunk.payload?.text || "");
2270
+ if (delta) {
2271
+ thoughtBuffer.push(delta);
2272
+ writeEvent("thought_delta", { text: delta });
2273
+ }
2274
+ continue;
2275
+ }
2276
+ if (chunk.type === "text-delta") {
2277
+ const delta = String(chunk.payload?.text || "");
2278
+ if (delta) {
2279
+ writeEvent("text_delta", { text: delta });
2280
+ }
2281
+ continue;
2282
+ }
2283
+ if (chunk.type === "tool-call") {
2284
+ const toolName = chunk.payload?.toolName;
2285
+ if (typeof toolName !== "string" || toolName.length === 0) {
2286
+ continue;
2287
+ }
2288
+ const pendingCall = {
2289
+ name: toolName,
2290
+ args: chunk.payload?.args || {},
2291
+ toolCallId: typeof chunk.payload?.toolCallId === "string"
2292
+ ? chunk.payload.toolCallId
2293
+ : undefined,
2294
+ };
2295
+ streamedPendingToolCalls.push(pendingCall);
2296
+ if (pendingCall.toolCallId) {
2297
+ streamedToolCallArgs.set(pendingCall.toolCallId, pendingCall.args || {});
2298
+ }
2299
+ else {
2300
+ streamedAnonymousToolCallArgs.push({
2301
+ toolName,
2302
+ args: pendingCall.args || {},
2303
+ consumed: false,
2304
+ });
2305
+ }
2306
+ const persistedActionKey = getPersistedStreamToolActionKey("tool_call", toolName, pendingCall.toolCallId, pendingCall.args || {});
2307
+ persistedStreamToolActionKeys.add(persistedActionKey);
2308
+ await persistToolEvent("tool_call", {
2309
+ name: toolName,
2310
+ toolName,
2311
+ args: pendingCall.args,
2312
+ toolCallId: pendingCall.toolCallId,
2313
+ status: "pending",
2314
+ }, "waiting_tool");
2315
+ const normalized = normalizeToolLifecycle(streamedPendingToolCalls, streamedExecutedToolResults);
2316
+ // Anonymous calls sharing a signature are distinct invocations,
2317
+ // so track how many of each key were emitted instead of a boolean.
2318
+ const pendingCountsInSnapshot = new Map();
2319
+ for (const call of normalized.pendingToolCalls) {
2320
+ const key = getPendingKey(call);
2321
+ const occurrence = (pendingCountsInSnapshot.get(key) || 0) + 1;
2322
+ pendingCountsInSnapshot.set(key, occurrence);
2323
+ if (occurrence <= (emittedPendingCounts.get(key) || 0))
2324
+ continue;
2325
+ emittedPendingCounts.set(key, occurrence);
2326
+ if (call.toolCallId) {
2327
+ emittedToolCallKeys.add(getEmittedInvocationKey(call.name, call.toolCallId, call.args || {}));
2328
+ }
2329
+ writeEvent("tool_call", {
2330
+ name: call.name,
2331
+ args: call.args,
2332
+ toolCallId: typeof call.toolCallId === "string"
2333
+ ? call.toolCallId
2334
+ : undefined,
2335
+ status: "pending",
2336
+ });
2337
+ }
2338
+ continue;
2339
+ }
2340
+ if (chunk.type === "tool-result") {
2341
+ const toolName = chunk.payload?.toolName;
2342
+ if (typeof toolName !== "string" || toolName.length === 0) {
2343
+ continue;
2344
+ }
2345
+ const toolResult = chunk.payload?.result ??
2346
+ chunk.payload?.output ??
2347
+ chunk.payload?.content ??
2348
+ chunk.payload?.data;
2349
+ const safeToolResult = redactToolResult(toolResult).result;
2350
+ const toolCallId = typeof chunk.payload?.toolCallId === "string"
2351
+ ? chunk.payload.toolCallId
2352
+ : undefined;
2353
+ const resultArgs = resolveStreamToolResultArgs(toolName, toolCallId, chunk.payload?.args);
2354
+ console.log("[DIFF-TRACE] API raw tool result", {
2355
+ toolName,
2356
+ toolCallId: chunk.payload?.toolCallId,
2357
+ resultType: typeof toolResult,
2358
+ resultKeys: toolResult && typeof toolResult === "object"
2359
+ ? Object.keys(toolResult)
2360
+ : [],
2361
+ });
2362
+ streamedExecutedToolResults.push({
2363
+ name: toolName,
2364
+ args: resultArgs,
2365
+ result: safeToolResult,
2366
+ toolCallId,
2367
+ });
2368
+ const emittedInvocationKey = getEmittedInvocationKey(toolName, toolCallId, resultArgs);
2369
+ if (toolCallId && !emittedToolCallKeys.has(emittedInvocationKey)) {
2370
+ emittedToolCallKeys.add(emittedInvocationKey);
2371
+ writeEvent("tool_call", {
2372
+ name: toolName,
2373
+ args: resultArgs,
2374
+ toolCallId,
2375
+ status: "pending",
2376
+ });
2377
+ }
2378
+ writeEvent("tool_result", {
2379
+ name: toolName,
2380
+ args: resultArgs,
2381
+ result: safeToolResult,
2382
+ toolCallId,
2383
+ status: resolveToolExecutionStatus(safeToolResult),
2384
+ });
2385
+ const persistedResultActionKey = getPersistedStreamToolActionKey("tool_result", toolName, toolCallId, resultArgs);
2386
+ persistedStreamToolActionKeys.add(persistedResultActionKey);
2387
+ await persistToolEvent("tool_result", {
2388
+ name: toolName,
2389
+ toolName,
2390
+ args: resultArgs,
2391
+ result: safeToolResult,
2392
+ toolCallId,
2393
+ status: resolveToolExecutionStatus(safeToolResult),
2394
+ }, "running");
2395
+ continue;
2396
+ }
2397
+ if (chunk.type === "tool-suspended" ||
2398
+ chunk.type === "tool_suspended") {
2399
+ const toolName = chunk.payload?.toolName;
2400
+ if (typeof toolName !== "string" || toolName.length === 0) {
2401
+ continue;
2402
+ }
2403
+ const suspended = {
2404
+ name: toolName,
2405
+ toolCallId: typeof chunk.payload?.toolCallId === "string"
2406
+ ? chunk.payload.toolCallId
2407
+ : undefined,
2408
+ suspendPayload: chunk.payload?.suspendPayload &&
2409
+ typeof chunk.payload.suspendPayload === "object"
2410
+ ? chunk.payload.suspendPayload
2411
+ : undefined,
2412
+ };
2413
+ streamedSuspendedTools.push(suspended);
2414
+ writeEvent("tool_suspended", {
2415
+ name: suspended.name,
2416
+ toolCallId: suspended.toolCallId,
2417
+ suspendPayload: suspended.suspendPayload,
2418
+ });
2419
+ void safePersistRunLifecycleEvent({
2420
+ runId: resolvedRunId,
2421
+ lifecycleState: "waiting_user_input",
2422
+ stopReason: "awaiting_user_input",
2423
+ eventType: "tool_suspended",
2424
+ payload: {
2425
+ toolName: suspended.name,
2426
+ toolCallId: suspended.toolCallId,
2427
+ },
2428
+ });
2429
+ }
2430
+ }
2431
+ const finalTextOrCancellation = await waitForResultOrCancellation(streamResult.text);
2432
+ if (isCancelledWaitResult(finalTextOrCancellation)) {
2433
+ await transitionToCancelled("before_stream_final_text");
2434
+ writeEvent("lifecycle", {
2435
+ runId: resolvedRunId,
2436
+ lifecycleState,
2437
+ stopReason,
2438
+ });
2439
+ writeEvent("done", {
2440
+ success: false,
2441
+ runId: resolvedRunId,
2442
+ lifecycleState,
2443
+ stopReason,
2444
+ cancelled: true,
2445
+ response: "Run cancelled",
2446
+ toolCalls: [],
2447
+ executedToolResults: [],
2448
+ suspendedTools: streamedSuspendedTools,
2449
+ thoughtSteps: thoughtBuffer.length > 0 ? [thoughtBuffer.join("")] : [],
2450
+ model: modelId,
2451
+ autoFixAttempted: false,
2452
+ autoFixFailureCount: 0,
2453
+ maxStepsReached: false,
2454
+ stepsUsed: 0,
2455
+ maxSteps,
2456
+ });
2457
+ stopKeepAlive();
2458
+ res.end();
2459
+ return;
2460
+ }
2461
+ const finalText = finalTextOrCancellation;
2462
+ const streamedToolCallsOrCancellation = await waitForResultOrCancellation(streamResult.toolCalls);
2463
+ if (isCancelledWaitResult(streamedToolCallsOrCancellation)) {
2464
+ await transitionToCancelled("before_stream_tool_calls");
2465
+ writeEvent("lifecycle", {
2466
+ runId: resolvedRunId,
2467
+ lifecycleState,
2468
+ stopReason,
2469
+ });
2470
+ writeEvent("done", {
2471
+ success: false,
2472
+ runId: resolvedRunId,
2473
+ lifecycleState,
2474
+ stopReason,
2475
+ cancelled: true,
2476
+ response: "Run cancelled",
2477
+ toolCalls: [],
2478
+ executedToolResults: [],
2479
+ suspendedTools: streamedSuspendedTools,
2480
+ thoughtSteps: thoughtBuffer.length > 0 ? [thoughtBuffer.join("")] : [],
2481
+ model: modelId,
2482
+ autoFixAttempted: false,
2483
+ autoFixFailureCount: 0,
2484
+ maxStepsReached: false,
2485
+ stepsUsed: 0,
2486
+ maxSteps,
2487
+ });
2488
+ stopKeepAlive();
2489
+ res.end();
2490
+ return;
2491
+ }
2492
+ const streamedToolCalls = streamedToolCallsOrCancellation;
2493
+ const streamStepsOrCancellation = await waitForResultOrCancellation(Promise.resolve(streamResult.steps).catch(() => undefined));
2494
+ if (isCancelledWaitResult(streamStepsOrCancellation)) {
2495
+ await transitionToCancelled("before_stream_steps");
2496
+ writeEvent("lifecycle", {
2497
+ runId: resolvedRunId,
2498
+ lifecycleState,
2499
+ stopReason,
2500
+ });
2501
+ writeEvent("done", {
2502
+ success: false,
2503
+ runId: resolvedRunId,
2504
+ lifecycleState,
2505
+ stopReason,
2506
+ cancelled: true,
2507
+ response: "Run cancelled",
2508
+ toolCalls: [],
2509
+ executedToolResults: [],
2510
+ suspendedTools: streamedSuspendedTools,
2511
+ thoughtSteps: thoughtBuffer.length > 0 ? [thoughtBuffer.join("")] : [],
2512
+ model: modelId,
2513
+ autoFixAttempted: false,
2514
+ autoFixFailureCount: 0,
2515
+ maxStepsReached: false,
2516
+ stepsUsed: 0,
2517
+ maxSteps,
2518
+ });
2519
+ stopKeepAlive();
2520
+ res.end();
2521
+ return;
2522
+ }
2523
+ const streamSteps = streamStepsOrCancellation;
2524
+ const streamStepsUsed = Array.isArray(streamSteps)
2525
+ ? streamSteps.length
2526
+ : 0;
2527
+ const streamToolCallsFromSteps = Array.isArray(streamSteps)
2528
+ ? streamSteps.reduce((total, step) => total +
2529
+ (Array.isArray(step.toolCalls) ? step.toolCalls.length : 0), 0)
2530
+ : undefined;
2531
+ const streamLastStep = Array.isArray(streamSteps)
2532
+ ? streamSteps[streamSteps.length - 1]
2533
+ : undefined;
2534
+ const streamLastStepHadToolCalls = Array.isArray(streamLastStep?.toolCalls) &&
2535
+ streamLastStep.toolCalls.length > 0;
2536
+ const streamUsageRawOrCancellation = await waitForResultOrCancellation(Promise.resolve(streamResult.usage).catch(() => undefined));
2537
+ if (isCancelledWaitResult(streamUsageRawOrCancellation)) {
2538
+ await transitionToCancelled("before_stream_usage");
2539
+ writeEvent("lifecycle", {
2540
+ runId: resolvedRunId,
2541
+ lifecycleState,
2542
+ stopReason,
2543
+ });
2544
+ writeEvent("done", {
2545
+ success: false,
2546
+ runId: resolvedRunId,
2547
+ lifecycleState,
2548
+ stopReason,
2549
+ cancelled: true,
2550
+ response: "Run cancelled",
2551
+ toolCalls: [],
2552
+ executedToolResults: [],
2553
+ suspendedTools: streamedSuspendedTools,
2554
+ thoughtSteps: thoughtBuffer.length > 0 ? [thoughtBuffer.join("")] : [],
2555
+ model: modelId,
2556
+ autoFixAttempted: false,
2557
+ autoFixFailureCount: 0,
2558
+ maxStepsReached: false,
2559
+ stepsUsed: 0,
2560
+ maxSteps,
2561
+ });
2562
+ stopKeepAlive();
2563
+ res.end();
2564
+ return;
2565
+ }
2566
+ const streamUsageFromResult = normalizeTokenUsage(streamUsageRawOrCancellation);
2567
+ const streamUsageSeenInFinal = Boolean(streamUsageFromResult);
2568
+ streamTokenUsage = mergeTokenUsage(streamTokenUsage, streamUsageFromResult);
2569
+ const streamUsageSource = streamUsageSeenInFinal
2570
+ ? "final"
2571
+ : streamUsageSeenInChunks
2572
+ ? "stream"
2573
+ : "none";
2574
+ logTokenUsageSource({
2575
+ mode: "stream",
2576
+ source: streamUsageSource,
2577
+ usage: streamTokenUsage,
2578
+ modelId,
2579
+ });
2580
+ const typedStreamedToolCalls = (streamedToolCalls || []);
2581
+ const mappedToolCalls = typedStreamedToolCalls.reduce((calls, call) => {
2582
+ const toolName = call?.toolName;
2583
+ if (typeof toolName !== "string" || toolName.length === 0) {
2584
+ return calls;
2585
+ }
2586
+ calls.push({
2587
+ name: toolName,
2588
+ args: call.args || {},
2589
+ toolCallId: typeof call.toolCallId === "string"
2590
+ ? call.toolCallId
2591
+ : undefined,
2592
+ });
2593
+ return calls;
2594
+ }, []);
2595
+ const processedStreamToolResults = Array.isArray(streamSteps)
2596
+ ? extractProcessedToolResults(streamSteps)
2597
+ : [];
2598
+ const toolResultMetadata = new Map([...streamedExecutedToolResults, ...processedStreamToolResults]
2599
+ .filter((entry) => entry.toolCallId)
2600
+ .map((entry) => [entry.toolCallId, entry]));
2601
+ const bridgedStreamToolResults = agent
2602
+ .takeProcessedWorkspaceResults?.(workspaceMutationGenerationId, Array.from(toolResultMetadata.keys()))
2603
+ .map(({ toolCallId, result }) => {
2604
+ const metadata = toolResultMetadata.get(toolCallId);
2605
+ return {
2606
+ name: metadata?.name || "workspaceMutation",
2607
+ args: metadata?.args || {},
2608
+ toolCallId,
2609
+ result,
2610
+ };
2611
+ }) || [];
2612
+ const reconciledStreamLifecycleSnapshots = reconcileToolLifecycleSnapshots(streamedPendingToolCalls, mappedToolCalls, streamedExecutedToolResults, [...processedStreamToolResults, ...bridgedStreamToolResults]);
2613
+ const reconciledStreamToolResults = reconciledStreamLifecycleSnapshots.executedToolResults;
2614
+ console.log("[DIFF-TRACE] API reconciled tool results", {
2615
+ rawCount: streamedExecutedToolResults.length,
2616
+ processedCount: processedStreamToolResults.length,
2617
+ bridgedCount: bridgedStreamToolResults.length,
2618
+ resultKeys: reconciledStreamToolResults.map((entry) => entry.result && typeof entry.result === "object"
2619
+ ? Object.keys(entry.result)
2620
+ : []),
2621
+ });
2622
+ const streamInputToolCalls = reconciledStreamLifecycleSnapshots.pendingToolCalls;
2623
+ console.log("DEBUG_STREAM_LIFECYCLE_INPUT", {
2624
+ streamedPendingCount: streamInputToolCalls.length,
2625
+ streamedPendingToolNames: streamInputToolCalls.map((call) => call.name),
2626
+ reconciledResultCount: reconciledStreamToolResults.length,
2627
+ reconciledResultKeys: reconciledStreamToolResults.map((entry) => entry.result && typeof entry.result === "object"
2628
+ ? Object.keys(entry.result)
2629
+ : []),
2630
+ });
2631
+ const normalizedStreamLifecycle = normalizeToolLifecycle(streamInputToolCalls, reconciledStreamToolResults);
2632
+ let normalizedStreamToolCalls = normalizedStreamLifecycle.pendingToolCalls.map((call) => ({
2633
+ name: call.name,
2634
+ args: call.args,
2635
+ toolCallId: call.toolCallId,
2636
+ status: "pending",
2637
+ }));
2638
+ const normalizedStreamExecutedResults = normalizedStreamLifecycle.executedToolResults.map((toolResult) => ({
2639
+ name: toolResult.name,
2640
+ args: toolResult.args,
2641
+ result: toolResult.result,
2642
+ toolCallId: toolResult.toolCallId,
2643
+ status: resolveToolExecutionStatus(toolResult.result),
2644
+ }));
2645
+ const normalizedStreamToolCallsUsed = countUniqueToolCalls(streamInputToolCalls, reconciledStreamToolResults);
2646
+ const streamToolCallsUsed = typeof streamToolCallsFromSteps === "number"
2647
+ ? Math.max(streamToolCallsFromSteps, normalizedStreamToolCallsUsed)
2648
+ : normalizedStreamToolCallsUsed;
2649
+ const hasStreamToolActivity = normalizedStreamToolCalls.length > 0 ||
2650
+ normalizedStreamExecutedResults.length > 0 ||
2651
+ Array.isArray(streamSteps);
2652
+ const hasStreamPendingToolCalls = normalizedStreamToolCalls.length > 0;
2653
+ const hasStreamBudgetOverflow = streamToolCallsUsed > maxToolCalls ||
2654
+ toolCallBudget.stopReason === "repeated_call";
2655
+ const streamBudgetReached = hasStreamBudgetOverflow ||
2656
+ (streamToolCallsUsed >= maxToolCalls &&
2657
+ (hasStreamPendingToolCalls || streamLastStepHadToolCalls));
2658
+ console.log("DEBUG_STREAM_BUDGET", {
2659
+ streamToolCallsUsed,
2660
+ maxToolCalls,
2661
+ toolCallBudgetStopReason: toolCallBudget.stopReason,
2662
+ streamLastStepHadToolCalls,
2663
+ hasStreamToolActivity,
2664
+ pendingToolCallCount: normalizedStreamToolCalls.length,
2665
+ executedResultCount: normalizedStreamExecutedResults.length,
2666
+ streamBudgetReached,
2667
+ finalTextPresent: typeof finalText === "string" && finalText.trim().length > 0,
2668
+ });
2669
+ const streamPendingConfirmations = normalizedStreamExecutedResults.filter((toolResult) => resolveToolExecutionStatus(toolResult.result) ===
2670
+ "pending_confirmation");
2671
+ let finalResponseText = finalText;
2672
+ let backendSynthesisPerformed = false;
2673
+ const streamEndedWithoutFinalText = typeof finalText !== "string" || finalText.trim().length === 0;
2674
+ const hasStreamTerminalToolResults = hasTerminalToolResults(normalizedStreamExecutedResults);
2675
+ const hasStreamNonterminalToolResults = hasNonterminalToolResults(normalizedStreamExecutedResults);
2676
+ const hasStreamResultSnapshots = normalizedStreamExecutedResults.length > 0;
2677
+ const shouldSynthesizeAfterCompletedToolWork = streamedSuspendedTools.length === 0 &&
2678
+ streamPendingConfirmations.length === 0 &&
2679
+ ((hasStreamPendingToolCalls &&
2680
+ !hasStreamNonterminalToolResults &&
2681
+ (hasStreamBudgetOverflow ||
2682
+ toolCallBudget.stopReason === "repeated_call" ||
2683
+ streamToolCallsUsed >= maxToolCalls)) ||
2684
+ (hasStreamTerminalToolResults &&
2685
+ streamEndedWithoutFinalText &&
2686
+ !hasStreamPendingToolCalls));
2687
+ if (shouldSynthesizeAfterCompletedToolWork) {
2688
+ const synthesisStopReason = streamBudgetReached ||
2689
+ toolCallBudget.stopReason === "repeated_call" ||
2690
+ streamToolCallsUsed >= maxToolCalls
2691
+ ? (toolCallBudget.stopReason ?? "limit")
2692
+ : "empty_final_response";
2693
+ const synthesisResultOrCancellation = await generateBackendOnlySynthesis(normalizedStreamExecutedResults, synthesisStopReason);
2694
+ if (isCancelledWaitResult(synthesisResultOrCancellation)) {
2695
+ await transitionToCancelled("during_stream_backend_synthesis");
2696
+ writeEvent("lifecycle", {
2697
+ runId: resolvedRunId,
2698
+ lifecycleState,
2699
+ stopReason,
2700
+ });
2701
+ writeEvent("done", {
2702
+ success: false,
2703
+ runId: resolvedRunId,
2704
+ lifecycleState,
2705
+ stopReason,
2706
+ cancelled: true,
2707
+ response: "Run cancelled",
2708
+ toolCalls: [],
2709
+ executedToolResults: [],
2710
+ suspendedTools: streamedSuspendedTools,
2711
+ thoughtSteps: thoughtBuffer.length > 0 ? [thoughtBuffer.join("")] : [],
2712
+ model: modelId,
2713
+ autoFixAttempted: false,
2714
+ autoFixFailureCount: 0,
2715
+ maxStepsReached: false,
2716
+ stepsUsed: 0,
2717
+ maxSteps,
2718
+ });
2719
+ stopKeepAlive();
2720
+ res.end();
2721
+ return;
2722
+ }
2723
+ const synthesisResult = synthesisResultOrCancellation;
2724
+ const synthesizedText = typeof synthesisResult.text === "string" &&
2725
+ synthesisResult.text.trim().length > 0
2726
+ ? synthesisResult.text
2727
+ : null;
2728
+ if (!synthesizedText && streamEndedWithoutFinalText) {
2729
+ throw new Error("The agent completed its tool work but returned no final response.");
2730
+ }
2731
+ if (synthesizedText) {
2732
+ finalResponseText = synthesizedText;
2733
+ normalizedStreamToolCalls = [];
2734
+ backendSynthesisPerformed = true;
2735
+ }
2736
+ streamTokenUsage = mergeTokenUsage(streamTokenUsage, normalizeTokenUsage(synthesisResult.usage));
2737
+ }
2738
+ let streamLifecycleState = "succeeded";
2739
+ let streamStopReason = "completed";
2740
+ if (streamedSuspendedTools.length > 0) {
2741
+ streamLifecycleState = "waiting_user_input";
2742
+ streamStopReason = "awaiting_user_input";
2743
+ }
2744
+ else if (streamPendingConfirmations.length > 0) {
2745
+ streamLifecycleState = "waiting_confirmation";
2746
+ streamStopReason = "awaiting_approval";
2747
+ }
2748
+ else if (normalizedStreamToolCalls.length > 0) {
2749
+ streamLifecycleState = "waiting_tool";
2750
+ streamStopReason = "none";
2751
+ }
2752
+ else if (streamBudgetReached && !backendSynthesisPerformed) {
2753
+ streamLifecycleState = "paused";
2754
+ streamStopReason = "max_steps_reached";
2755
+ }
2756
+ const finalStreamReplayActions = buildOrderedPersistedToolActions(streamSteps, streamInputToolCalls.map((call) => ({
2757
+ name: call.name,
2758
+ args: call.args,
2759
+ toolCallId: call.toolCallId,
2760
+ status: "pending",
2761
+ })), normalizedStreamExecutedResults, (toolName, args) => sanitizeToolArgsForWorkspace(toolName, args, isWebWorkspace));
2762
+ for (const action of finalStreamReplayActions) {
2763
+ const persistedActionKey = getPersistedStreamToolActionKey(action.eventType, action.name, action.toolCallId, action.args);
2764
+ if (persistedStreamToolActionKeys.has(persistedActionKey)) {
2765
+ continue;
2766
+ }
2767
+ persistedStreamToolActionKeys.add(persistedActionKey);
2768
+ await persistToolEvent(action.eventType, {
2769
+ name: action.name,
2770
+ toolName: action.name,
2771
+ args: action.args,
2772
+ ...(action.eventType === "tool_result"
2773
+ ? { result: action.result }
2774
+ : {}),
2775
+ toolCallId: action.toolCallId,
2776
+ status: action.status,
2777
+ }, "running");
2778
+ }
2779
+ await persistLifecycle(streamLifecycleState, streamStopReason, "stream_completed", {
2780
+ pendingToolCalls: normalizedStreamToolCalls.length,
2781
+ suspendedTools: streamedSuspendedTools.length,
2782
+ pendingConfirmations: streamPendingConfirmations.length,
2783
+ stepsUsed: streamStepsUsed,
2784
+ toolCallsUsed: streamToolCallsUsed,
2785
+ budgetReached: streamBudgetReached,
2786
+ toolBudgetStopReason: toolCallBudget.stopReason,
2787
+ });
2788
+ writeEvent("lifecycle", {
2789
+ runId: resolvedRunId,
2790
+ lifecycleState,
2791
+ stopReason,
2792
+ });
2793
+ writeEvent("done", {
2794
+ success: true,
2795
+ runId: resolvedRunId,
2796
+ lifecycleState,
2797
+ stopReason,
2798
+ response: finalResponseText,
2799
+ toolCalls: normalizedStreamToolCalls,
2800
+ executedToolResults: normalizedStreamExecutedResults,
2801
+ suspendedTools: streamedSuspendedTools,
2802
+ thoughtSteps: thoughtBuffer.length > 0 ? [thoughtBuffer.join("")] : [],
2803
+ model: modelId,
2804
+ autoFixAttempted: false,
2805
+ autoFixFailureCount: 0,
2806
+ maxStepsReached: streamBudgetReached && !backendSynthesisPerformed,
2807
+ stepsUsed: streamStepsUsed,
2808
+ maxSteps: maxSteps,
2809
+ toolCallsUsed: streamToolCallsUsed,
2810
+ maxToolCalls,
2811
+ usage: streamTokenUsage,
2812
+ ...(buildTokenUsageDebug({
2813
+ mode: "stream",
2814
+ source: streamUsageSource,
2815
+ })
2816
+ ? {
2817
+ tokenUsageDebug: buildTokenUsageDebug({
2818
+ mode: "stream",
2819
+ source: streamUsageSource,
2820
+ }),
2821
+ }
2822
+ : {}),
2823
+ });
2824
+ stopKeepAlive();
2825
+ res.end();
2826
+ return;
2827
+ }
2828
+ catch (streamError) {
2829
+ const { code, message: providerMessage } = extractProviderErrorDetails(streamError);
2830
+ await persistLifecycle("failed", "error", "stream_error", {
2831
+ message: providerMessage,
2832
+ code,
2833
+ });
2834
+ writeEvent("lifecycle", {
2835
+ runId: resolvedRunId,
2836
+ lifecycleState,
2837
+ stopReason,
2838
+ });
2839
+ writeEvent("error", {
2840
+ success: false,
2841
+ runId: resolvedRunId,
2842
+ lifecycleState,
2843
+ stopReason,
2844
+ errorCode: code,
2845
+ error: normalizeProviderRequestError(requestedModelId, providerMessage, code),
2846
+ });
2847
+ stopKeepAlive();
2848
+ res.end();
2849
+ return;
2850
+ }
2851
+ finally {
2852
+ agent.clearProcessedWorkspaceResults?.(workspaceMutationGenerationId);
2853
+ }
2854
+ }
2855
+ await persistLifecycle("running", "none", "model_request_started", {
2856
+ stream: false,
2857
+ maxSteps,
2858
+ maxOutputTokens,
2859
+ });
2860
+ if (await isCancelled()) {
2861
+ await transitionToCancelled("before_model_request");
2862
+ return res.status(409).json({
2863
+ success: false,
2864
+ runId: resolvedRunId,
2865
+ lifecycleState,
2866
+ stopReason,
2867
+ error: "Run was cancelled",
2868
+ });
2869
+ }
2870
+ let result;
2871
+ try {
2872
+ const resultOrCancellation = await waitForResultOrCancellation(activeHostSession
2873
+ ? generateWithSessionRetry(activeHostSession, modelInput, generateOptions, turnAbortController.signal, modelProfile.generateRetryAttempts)
2874
+ : generateWithRetry(agent, modelInput, generateOptions, turnAbortController.signal, modelProfile.generateRetryAttempts));
2875
+ if (isCancelledWaitResult(resultOrCancellation)) {
2876
+ await transitionToCancelled("during_model_request");
2877
+ return res.status(409).json({
2878
+ success: false,
2879
+ runId: resolvedRunId,
2880
+ lifecycleState,
2881
+ stopReason,
2882
+ error: "Run was cancelled",
2883
+ });
2884
+ }
2885
+ result = resultOrCancellation;
2886
+ }
2887
+ finally {
2888
+ agent.clearProcessedWorkspaceResults?.(workspaceMutationGenerationId);
2889
+ }
2890
+ if (await isCancelled()) {
2891
+ await transitionToCancelled("after_model_response");
2892
+ return res.status(409).json({
2893
+ success: false,
2894
+ runId: resolvedRunId,
2895
+ lifecycleState,
2896
+ stopReason,
2897
+ error: "Run was cancelled",
2898
+ });
2899
+ }
2900
+ await persistLifecycle("running", "none", "model_response_received", {
2901
+ stepsCount: result.steps?.length || 0,
2902
+ toolCallsCount: result.toolCalls?.length || 0,
2903
+ });
2904
+ let usageSummary = normalizeTokenUsage(result.usage);
2905
+ let usageSeenFromFinal = Boolean(usageSummary);
2906
+ console.log("=== AGENT RESULT ===");
2907
+ console.log("Text:", result.text);
2908
+ console.log("Steps:", result.steps?.length || 0);
2909
+ console.log("Tool calls:", result.toolCalls?.length || 0);
2910
+ // Truncate response if it's too long to prevent context overflow
2911
+ let responseText = result.text || "";
2912
+ if (responseText.length > 12000) {
2913
+ console.log(`⚠️ Truncating long response: ${responseText.length} chars -> 12000 chars`);
2914
+ responseText = truncateEnvironmentResponse(responseText);
2915
+ }
2916
+ const toolCalls = [];
2917
+ const executedToolResults = []; // Track all tools that were executed
2918
+ const suspendedTools = [];
2919
+ const toolCallArgs = new Map(); // Map to store tool call args by toolCallId
2920
+ const anonymousToolCallArgs = [];
2921
+ const thoughtSteps = []; // Intermediate agent text steps for UI display
2922
+ // Check steps for tool execution details
2923
+ if (result.steps && result.steps.length > 0) {
2924
+ // Log all steps for debugging
2925
+ result.steps.forEach((step, index) => {
2926
+ console.log(`Step ${index}:`, JSON.stringify(step, null, 2));
2927
+ // Collect ALL executed tools from all steps (not just last step)
2928
+ if (step.content && Array.isArray(step.content)) {
2929
+ // Capture intermediate text/thought content emitted in step stream
2930
+ step.content.forEach((item) => {
2931
+ const maybeText = item.text;
2932
+ if (item.type === "text" && typeof maybeText === "string") {
2933
+ const text = maybeText.trim();
2934
+ if (text.length > 0) {
2935
+ thoughtSteps.push(text);
2936
+ }
2937
+ }
2938
+ });
2939
+ // First pass: collect tool-call args
2940
+ step.content.forEach((item) => {
2941
+ if (item.type === "tool-call" && item.toolName) {
2942
+ const args = item.args || item.input || {};
2943
+ if (item.toolCallId) {
2944
+ toolCallArgs.set(item.toolCallId, args);
2945
+ }
2946
+ else {
2947
+ anonymousToolCallArgs.push({
2948
+ toolName: item.toolName,
2949
+ args,
2950
+ consumed: false,
2951
+ });
2952
+ }
2953
+ }
2954
+ });
2955
+ // Second pass: collect tool results with their args
2956
+ step.content.forEach((item) => {
2957
+ // Capture tool results (tools that were already executed)
2958
+ if (item.type === "tool-result" && item.toolName) {
2959
+ // Get args from the corresponding tool-call
2960
+ const args = resolveToolResultArgs(item.toolName, item.toolCallId, item.args || item.input, toolCallArgs, anonymousToolCallArgs);
2961
+ // Result can be in different properties depending on AI SDK version
2962
+ // Use 'in' operator to check property existence, not truthiness
2963
+ // Priority: result > output > content > data
2964
+ let toolResult;
2965
+ if ("result" in item) {
2966
+ toolResult = item.result;
2967
+ }
2968
+ else if ("output" in item) {
2969
+ toolResult = item.output;
2970
+ }
2971
+ else if ("content" in item && item.type === "tool-result") {
2972
+ // Only use content if it's not the type discriminator
2973
+ toolResult = item.content;
2974
+ }
2975
+ else if ("data" in item) {
2976
+ toolResult = item.data;
2977
+ }
2978
+ else {
2979
+ toolResult = undefined;
2980
+ }
2981
+ console.log(` Tool result name: ${item.toolName}`);
2982
+ console.log(" Tool result raw item:", JSON.stringify(item, null, 2));
2983
+ console.log(" Tool result value:", toolResult);
2984
+ console.log(" Tool args:", args);
2985
+ const safeToolResult = redactToolResult(toolResult).result;
2986
+ executedToolResults.push({
2987
+ name: item.toolName,
2988
+ args: args,
2989
+ result: safeToolResult,
2990
+ toolCallId: item.toolCallId,
2991
+ lifecycleStepIndex: index,
2992
+ });
2993
+ }
2994
+ if ((item.type === "tool-suspended" ||
2995
+ item.type === "tool_suspended") &&
2996
+ item.toolName) {
2997
+ let suspendPayload;
2998
+ if ("suspendPayload" in item &&
2999
+ item.suspendPayload &&
3000
+ typeof item.suspendPayload === "object") {
3001
+ suspendPayload = item.suspendPayload;
3002
+ }
3003
+ else if ("result" in item &&
3004
+ item.result &&
3005
+ typeof item.result === "object") {
3006
+ suspendPayload = item.result;
3007
+ }
3008
+ else if ("output" in item &&
3009
+ item.output &&
3010
+ typeof item.output === "object") {
3011
+ suspendPayload = item.output;
3012
+ }
3013
+ else if ("data" in item &&
3014
+ item.data &&
3015
+ typeof item.data === "object") {
3016
+ suspendPayload = item.data;
3017
+ }
3018
+ suspendedTools.push({
3019
+ name: item.toolName,
3020
+ toolCallId: item.toolCallId,
3021
+ suspendPayload,
3022
+ });
3023
+ }
3024
+ });
3025
+ }
3026
+ });
3027
+ // Only collect PENDING tool calls from the LAST step
3028
+ // If the agent executed tools in previous steps, we don't want to send them back to the client
3029
+ const lastStep = result.steps[result.steps.length - 1];
3030
+ // Tool calls are in the content array
3031
+ const lastStepCallCounts = new Map();
3032
+ const lastStepCallKey = (name, args, toolCallId) => toolCallId
3033
+ ? `id:${toolCallId}:${getToolCallSignature(name, args)}`
3034
+ : getToolCallSignature(name, args);
3035
+ if (lastStep.content && Array.isArray(lastStep.content)) {
3036
+ lastStep.content.forEach((item) => {
3037
+ if (item.type === "tool-call" && item.toolName) {
3038
+ console.log(` Tool call: ${item.toolName}`, item.input || item.args);
3039
+ const args = sanitizeToolArgsForWorkspace(item.toolName, item.input || item.args, isWebWorkspace);
3040
+ const key = lastStepCallKey(item.toolName, args, item.toolCallId);
3041
+ lastStepCallCounts.set(key, (lastStepCallCounts.get(key) || 0) + 1);
3042
+ toolCalls.push({
3043
+ name: item.toolName,
3044
+ args: args,
3045
+ toolCallId: item.toolCallId,
3046
+ });
3047
+ }
3048
+ });
3049
+ }
3050
+ // Fallback: Check toolCalls property. Track multiplicity per key so
3051
+ // repeated anonymous invocations are preserved while entries already
3052
+ // collected from `content` above are not double-counted.
3053
+ if (lastStep.toolCalls && lastStep.toolCalls.length > 0) {
3054
+ const fallbackCallCounts = new Map();
3055
+ lastStep.toolCalls.forEach((call) => {
3056
+ // Only add if it has a valid toolName
3057
+ if (call && call.toolName) {
3058
+ const args = sanitizeToolArgsForWorkspace(call.toolName, call.args, isWebWorkspace);
3059
+ const key = lastStepCallKey(call.toolName, args, call.toolCallId);
3060
+ const occurrence = (fallbackCallCounts.get(key) || 0) + 1;
3061
+ fallbackCallCounts.set(key, occurrence);
3062
+ if (occurrence <= (lastStepCallCounts.get(key) || 0)) {
3063
+ return;
3064
+ }
3065
+ console.log(" Tool call (fallback):", call.toolName, call.args);
3066
+ toolCalls.push({
3067
+ name: call.toolName,
3068
+ args: args,
3069
+ toolCallId: call.toolCallId,
3070
+ });
3071
+ }
3072
+ });
3073
+ }
3074
+ }
3075
+ // Fallback to top-level toolCalls if no steps
3076
+ if (toolCalls.length === 0 &&
3077
+ result.toolCalls &&
3078
+ result.toolCalls.length > 0) {
3079
+ result.toolCalls.forEach((call) => {
3080
+ // Only add if it has a valid toolName
3081
+ if (call && call.toolName) {
3082
+ console.log("Direct tool call:", call.toolName, call.args);
3083
+ const args = sanitizeToolArgsForWorkspace(call.toolName, call.args, isWebWorkspace);
3084
+ toolCalls.push({
3085
+ name: call.toolName,
3086
+ args: args,
3087
+ toolCallId: call.toolCallId,
3088
+ });
3089
+ }
3090
+ });
3091
+ }
3092
+ const normalizedLifecycle = normalizeToolLifecycle(toolCalls, executedToolResults);
3093
+ let lifecycleToolCallsForCounting = toolCalls;
3094
+ let lifecycleResultsForCounting = executedToolResults;
3095
+ let normalizedToolCalls = normalizedLifecycle.pendingToolCalls.map((call) => ({
3096
+ name: call.name,
3097
+ args: call.args,
3098
+ toolCallId: call.toolCallId,
3099
+ status: "pending",
3100
+ }));
3101
+ const normalizedExecutedToolResults = normalizedLifecycle.executedToolResults.map((toolResult) => ({
3102
+ name: toolResult.name,
3103
+ args: toolResult.args,
3104
+ result: toolResult.result,
3105
+ toolCallId: toolResult.toolCallId,
3106
+ status: resolveToolExecutionStatus(toolResult.result),
3107
+ }));
3108
+ const replayToolActions = buildOrderedPersistedToolActions(result.steps, normalizedToolCalls, normalizedExecutedToolResults, (toolName, args) => sanitizeToolArgsForWorkspace(toolName, args, isWebWorkspace));
3109
+ const persistedReplayActions = new Set();
3110
+ const persistReplayToolActions = async (actions) => {
3111
+ for (const action of actions) {
3112
+ if (persistedReplayActions.has(action))
3113
+ continue;
3114
+ persistedReplayActions.add(action);
3115
+ await persistToolEvent(action.eventType, {
3116
+ name: action.name,
3117
+ toolName: action.name,
3118
+ args: action.args,
3119
+ ...(action.eventType === "tool_result"
3120
+ ? { result: action.result }
3121
+ : {}),
3122
+ toolCallId: action.toolCallId,
3123
+ status: action.status,
3124
+ });
3125
+ }
3126
+ };
3127
+ await persistReplayToolActions(replayToolActions);
3128
+ const uniqueThoughtSteps = Array.from(new Set(thoughtSteps
3129
+ .map((t) => t.trim())
3130
+ .filter((t) => t.length > 0 && t !== responseText.trim())));
3131
+ console.log("Final tool calls count:", normalizedToolCalls.length);
3132
+ console.log("Executed tool results count:", normalizedExecutedToolResults.length);
3133
+ console.log("Thought steps count:", uniqueThoughtSteps.length);
3134
+ // Check if any tool results are pending user confirmation
3135
+ let pendingConfirmations = normalizedExecutedToolResults.filter((toolResult) => resolveToolExecutionStatus(toolResult.result) ===
3136
+ "pending_confirmation");
3137
+ let stepsCount = result.steps?.length || 0;
3138
+ let effectiveMaxSteps = maxSteps;
3139
+ let autoFixAttempted = false;
3140
+ let autoFixFailureCount = 0;
3141
+ let reflectionAttemptCount = 0;
3142
+ let reflectionStopReason = "none";
3143
+ let backendSynthesisPerformed = false;
3144
+ const countResultToolCalls = (generationResult) => generationResult.steps?.reduce((total, step) => total + (Array.isArray(step.toolCalls) ? step.toolCalls.length : 0), 0) ??
3145
+ generationResult.toolCalls?.length ??
3146
+ 0;
3147
+ let cumulativeToolCallsUsed = Math.max(toolCallBudget.admitted, countUniqueToolCalls(lifecycleToolCallsForCounting, lifecycleResultsForCounting), countResultToolCalls(result));
3148
+ // Bounded reflection loop: target failed validation/tool execution with explicit retry context.
3149
+ if (isAutoFixValidationEnabled() &&
3150
+ pendingConfirmations.length === 0 &&
3151
+ normalizedToolCalls.length === 0) {
3152
+ let reflectionAttempt = 0;
3153
+ const reflectionCap = modelProfile.reflectionMaxAttempts;
3154
+ const noProgressRepeatThreshold = getReflectionNoProgressRepeatThreshold();
3155
+ let previousFailureSignature = null;
3156
+ let repeatedFailureSignatures = 0;
3157
+ while (reflectionAttempt < reflectionCap) {
3158
+ if (await isCancelled()) {
3159
+ await transitionToCancelled("reflection_loop");
3160
+ return res.status(409).json({
3161
+ success: false,
3162
+ runId: resolvedRunId,
3163
+ lifecycleState,
3164
+ stopReason,
3165
+ error: "Run was cancelled",
3166
+ });
3167
+ }
3168
+ const validationFailures = extractValidationFailures(normalizedExecutedToolResults);
3169
+ const toolFailures = extractToolExecutionFailures(normalizedExecutedToolResults);
3170
+ const detectedFailureCount = validationFailures.length + toolFailures.length;
3171
+ autoFixFailureCount = Math.max(autoFixFailureCount, detectedFailureCount);
3172
+ if (detectedFailureCount === 0) {
3173
+ reflectionStopReason = "resolved";
3174
+ break;
3175
+ }
3176
+ const remainingToolCalls = maxToolCalls - cumulativeToolCallsUsed;
3177
+ if (remainingToolCalls <= 0) {
3178
+ reflectionStopReason = "max_attempts";
3179
+ break;
3180
+ }
3181
+ const failureSignature = buildFailureSignature(validationFailures, toolFailures);
3182
+ if (previousFailureSignature === failureSignature) {
3183
+ repeatedFailureSignatures += 1;
3184
+ }
3185
+ else {
3186
+ repeatedFailureSignatures = 0;
3187
+ }
3188
+ previousFailureSignature = failureSignature;
3189
+ if (repeatedFailureSignatures >= noProgressRepeatThreshold) {
3190
+ reflectionStopReason = "no_progress";
3191
+ console.warn(`[agent] reflection halted due to repeated failure signature (${repeatedFailureSignatures}/${noProgressRepeatThreshold})`);
3192
+ break;
3193
+ }
3194
+ reflectionAttempt += 1;
3195
+ reflectionAttemptCount = reflectionAttempt;
3196
+ autoFixAttempted = true;
3197
+ const validationFailureReport = validationFailures
3198
+ .map((failure, index) => {
3199
+ const chunks = [
3200
+ `${index + 1}. ${failure.phase.toUpperCase()} failed`,
3201
+ failure.command ? `Command: ${failure.command}` : undefined,
3202
+ failure.error ? `Error: ${failure.error}` : undefined,
3203
+ failure.stderr ? `stderr:\n${failure.stderr}` : undefined,
3204
+ failure.stdout ? `stdout:\n${failure.stdout}` : undefined,
3205
+ ].filter(Boolean);
3206
+ return chunks.join("\n");
3207
+ })
3208
+ .join("\n\n");
3209
+ const toolFailureReport = toolFailures
3210
+ .map((failure, index) => {
3211
+ const chunks = [
3212
+ `${index + 1}. Tool \`${failure.toolName}\` failed`,
3213
+ `Status: ${failure.status}`,
3214
+ failure.error ? `Error: ${failure.error}` : undefined,
3215
+ failure.args
3216
+ ? `Args: ${JSON.stringify(failure.args)}`
3217
+ : undefined,
3218
+ ].filter(Boolean);
3219
+ return chunks.join("\n");
3220
+ })
3221
+ .join("\n\n");
3222
+ const autoFixPrompt = `${prompt}\n\nA prior attempt produced failures. Perform a targeted repair pass for attempt ${reflectionAttempt}/${reflectionCap}.\n\n${validationFailureReport
3223
+ ? `Validation failures:\n${validationFailureReport}\n\n`
3224
+ : ""}${toolFailureReport
3225
+ ? `Tool execution failures:\n${toolFailureReport}\n\n`
3226
+ : ""}Requirements:\n- Focus only on the listed failures.\n- If patch/tool matching failed, retry with tighter file targeting and explicit paths.\n- Keep edits minimal and reversible.\n- Stop after this repair pass.`;
3227
+ const autoFixOptions = {
3228
+ maxSteps: Math.min(modelProfile.reflectionMaxSteps, remainingToolCalls),
3229
+ maxOutputTokens,
3230
+ ...(mastraMemoryScope ? { memory: mastraMemoryScope } : {}),
3231
+ toolChoice: "auto",
3232
+ toolCallConcurrency: 1,
3233
+ stopWhen: stopWhenToolBudgetReached,
3234
+ requestContext: agentRequestContext,
3235
+ abortSignal: turnAbortController.signal,
3236
+ };
3237
+ let autoFixResultOrCancellation;
3238
+ const admittedBeforeReflection = toolCallBudget.admitted;
3239
+ try {
3240
+ autoFixResultOrCancellation = await waitForResultOrCancellation(activeHostSession
3241
+ ? generateWithSessionRetry(activeHostSession, autoFixPrompt, autoFixOptions, turnAbortController.signal, modelProfile.reflectionRetryAttempts)
3242
+ : generateWithRetry(agent, autoFixPrompt, autoFixOptions, turnAbortController.signal, modelProfile.reflectionRetryAttempts));
3243
+ }
3244
+ finally {
3245
+ agent.clearProcessedWorkspaceResults?.(workspaceMutationGenerationId);
3246
+ }
3247
+ if (isCancelledWaitResult(autoFixResultOrCancellation)) {
3248
+ await transitionToCancelled("during_reflection_model_request");
3249
+ return res.status(409).json({
3250
+ success: false,
3251
+ runId: resolvedRunId,
3252
+ lifecycleState,
3253
+ stopReason,
3254
+ error: "Run was cancelled",
3255
+ });
3256
+ }
3257
+ const autoFixResult = autoFixResultOrCancellation;
3258
+ if (await isCancelled()) {
3259
+ await transitionToCancelled("after_reflection_response");
3260
+ return res.status(409).json({
3261
+ success: false,
3262
+ runId: resolvedRunId,
3263
+ lifecycleState,
3264
+ stopReason,
3265
+ error: "Run was cancelled",
3266
+ });
3267
+ }
3268
+ usageSummary = mergeTokenUsage(usageSummary, normalizeTokenUsage(autoFixResult.usage));
3269
+ if (!usageSeenFromFinal && usageSummary) {
3270
+ usageSeenFromFinal = true;
3271
+ }
3272
+ let autoFixResponseText = autoFixResult.text || "";
3273
+ if (autoFixResponseText.length > 12000) {
3274
+ autoFixResponseText =
3275
+ truncateEnvironmentResponse(autoFixResponseText);
3276
+ }
3277
+ responseText = autoFixResponseText;
3278
+ const autoFixToolCalls = [];
3279
+ const autoFixExecutedToolResults = [];
3280
+ const autoFixToolCallArgs = new Map();
3281
+ const autoFixAnonymousToolCallArgs = [];
3282
+ const autoFixThoughtSteps = [];
3283
+ if (autoFixResult.steps && autoFixResult.steps.length > 0) {
3284
+ const lastStep = autoFixResult.steps[autoFixResult.steps.length - 1];
3285
+ autoFixResult.steps.forEach((step, index) => {
3286
+ if (!step.content || !Array.isArray(step.content))
3287
+ return;
3288
+ step.content.forEach((item) => {
3289
+ const maybeText = item.text;
3290
+ if (item.type === "text" && typeof maybeText === "string") {
3291
+ const text = maybeText.trim();
3292
+ if (text.length > 0) {
3293
+ autoFixThoughtSteps.push(text);
3294
+ }
3295
+ }
3296
+ });
3297
+ step.content.forEach((item) => {
3298
+ if (item.type === "tool-call" && item.toolName) {
3299
+ const args = sanitizeToolArgsForWorkspace(item.toolName, item.input || item.args, isWebWorkspace);
3300
+ if (item.toolCallId) {
3301
+ autoFixToolCallArgs.set(item.toolCallId, args);
3302
+ }
3303
+ else {
3304
+ autoFixAnonymousToolCallArgs.push({
3305
+ toolName: item.toolName,
3306
+ args,
3307
+ consumed: false,
3308
+ });
3309
+ }
3310
+ }
3311
+ });
3312
+ step.content.forEach((item) => {
3313
+ if (item.type === "tool-result" && item.toolName) {
3314
+ const args = resolveToolResultArgs(item.toolName, item.toolCallId, item.args || item.input, autoFixToolCallArgs, autoFixAnonymousToolCallArgs);
3315
+ let toolResult;
3316
+ if ("result" in item) {
3317
+ toolResult = item.result;
3318
+ }
3319
+ else if ("output" in item) {
3320
+ toolResult = item.output;
3321
+ }
3322
+ else if ("content" in item && item.type === "tool-result") {
3323
+ toolResult = item.content;
3324
+ }
3325
+ else if ("data" in item) {
3326
+ toolResult = item.data;
3327
+ }
3328
+ const safeToolResult = redactToolResult(toolResult).result;
3329
+ autoFixExecutedToolResults.push({
3330
+ name: item.toolName,
3331
+ args,
3332
+ result: safeToolResult,
3333
+ toolCallId: item.toolCallId,
3334
+ lifecycleStepIndex: index,
3335
+ });
3336
+ }
3337
+ });
3338
+ });
3339
+ if (lastStep.content && Array.isArray(lastStep.content)) {
3340
+ lastStep.content.forEach((item) => {
3341
+ if (item.type === "tool-call" && item.toolName) {
3342
+ const args = sanitizeToolArgsForWorkspace(item.toolName, item.input || item.args, isWebWorkspace);
3343
+ autoFixToolCalls.push({
3344
+ name: item.toolName,
3345
+ args,
3346
+ toolCallId: item.toolCallId,
3347
+ });
3348
+ }
3349
+ });
3350
+ }
3351
+ }
3352
+ if (autoFixToolCalls.length === 0 &&
3353
+ autoFixResult.toolCalls &&
3354
+ autoFixResult.toolCalls.length > 0) {
3355
+ autoFixResult.toolCalls.forEach((call) => {
3356
+ if (!call || !call.toolName)
3357
+ return;
3358
+ const args = sanitizeToolArgsForWorkspace(call.toolName, call.args, isWebWorkspace);
3359
+ autoFixToolCalls.push({
3360
+ name: call.toolName,
3361
+ args,
3362
+ toolCallId: call.toolCallId,
3363
+ });
3364
+ });
3365
+ }
3366
+ const autoFixNormalizedLifecycle = normalizeToolLifecycle(autoFixToolCalls, autoFixExecutedToolResults);
3367
+ const autoFixNormalizedToolCalls = autoFixNormalizedLifecycle.pendingToolCalls.map((call) => ({
3368
+ name: call.name,
3369
+ args: redactToolResult(call.args).result,
3370
+ toolCallId: call.toolCallId,
3371
+ status: "pending",
3372
+ }));
3373
+ const autoFixNormalizedExecutedResults = autoFixNormalizedLifecycle.executedToolResults.map((toolResult) => ({
3374
+ name: toolResult.name,
3375
+ args: redactToolResult(toolResult.args).result,
3376
+ result: toolResult.result,
3377
+ toolCallId: toolResult.toolCallId,
3378
+ status: resolveToolExecutionStatus(toolResult.result),
3379
+ }));
3380
+ const reflectionToolCallsUsed = countUniqueToolCalls(autoFixToolCalls, autoFixExecutedToolResults);
3381
+ const admissionDelta = toolCallBudget.admitted - admittedBeforeReflection;
3382
+ cumulativeToolCallsUsed +=
3383
+ admissionDelta > 0 ? admissionDelta : reflectionToolCallsUsed;
3384
+ const uniqueAutoFixThoughtSteps = Array.from(new Set(autoFixThoughtSteps
3385
+ .map((t) => t.trim())
3386
+ .filter((t) => t.length > 0 && t !== responseText.trim())));
3387
+ const autoFixReplayActions = buildOrderedPersistedToolActions(autoFixResult.steps, autoFixNormalizedToolCalls, autoFixNormalizedExecutedResults, (toolName, args) => sanitizeToolArgsForWorkspace(toolName, args, isWebWorkspace));
3388
+ replayToolActions.push(...autoFixReplayActions);
3389
+ await persistReplayToolActions(autoFixReplayActions);
3390
+ normalizedToolCalls.length = 0;
3391
+ normalizedToolCalls.push(...autoFixNormalizedToolCalls);
3392
+ normalizedExecutedToolResults.length = 0;
3393
+ normalizedExecutedToolResults.push(...autoFixNormalizedExecutedResults);
3394
+ lifecycleToolCallsForCounting = autoFixToolCalls;
3395
+ lifecycleResultsForCounting = autoFixExecutedToolResults;
3396
+ uniqueThoughtSteps.length = 0;
3397
+ uniqueThoughtSteps.push(...uniqueAutoFixThoughtSteps);
3398
+ pendingConfirmations = normalizedExecutedToolResults.filter((toolResult) => resolveToolExecutionStatus(toolResult.result) ===
3399
+ "pending_confirmation");
3400
+ stepsCount = autoFixResult.steps?.length || 0;
3401
+ effectiveMaxSteps = autoFixOptions.maxSteps;
3402
+ if (pendingConfirmations.length > 0 ||
3403
+ normalizedToolCalls.length > 0) {
3404
+ break;
3405
+ }
3406
+ }
3407
+ if (autoFixAttempted && reflectionStopReason === "none") {
3408
+ const remainingValidationFailures = extractValidationFailures(normalizedExecutedToolResults);
3409
+ const remainingToolFailures = extractToolExecutionFailures(normalizedExecutedToolResults);
3410
+ reflectionStopReason =
3411
+ remainingValidationFailures.length +
3412
+ remainingToolFailures.length ===
3413
+ 0
3414
+ ? "resolved"
3415
+ : "max_attempts";
3416
+ }
3417
+ }
3418
+ const gitSafety = {
3419
+ enabled: gitSafetyMode !== "off",
3420
+ gitDetected,
3421
+ policyMode: gitSafetyMode,
3422
+ };
3423
+ await persistReplayToolActions(replayToolActions);
3424
+ logTokenUsageSource({
3425
+ mode: "non_stream",
3426
+ source: usageSeenFromFinal ? "final" : "none",
3427
+ usage: usageSummary,
3428
+ modelId,
3429
+ });
3430
+ const nonStreamUsageSource = usageSeenFromFinal
3431
+ ? "final"
3432
+ : "none";
3433
+ const nonStreamTokenUsageDebug = buildTokenUsageDebug({
3434
+ mode: "non_stream",
3435
+ source: nonStreamUsageSource,
3436
+ });
3437
+ if (gitDetected &&
3438
+ gitSafetyMode !== "off" &&
3439
+ hasSuccessfulEditExecution(normalizedExecutedToolResults)) {
3440
+ gitSafety.checkpointHint =
3441
+ 'Create a checkpoint commit: git add -A && git commit -m "checkpoint: agent changes"';
3442
+ gitSafety.undoHint =
3443
+ "Undo unstaged edits safely: git restore -- <file>; inspect with git diff first.";
3444
+ }
3445
+ if (pendingConfirmations.length > 0) {
3446
+ await persistLifecycle("waiting_confirmation", "awaiting_approval", "awaiting_confirmation", {
3447
+ pendingConfirmations: pendingConfirmations.length,
3448
+ });
3449
+ // Return special response for command confirmation
3450
+ return res.json({
3451
+ success: true,
3452
+ runId: resolvedRunId,
3453
+ lifecycleState,
3454
+ stopReason,
3455
+ requiresConfirmation: true,
3456
+ autoFixAttempted,
3457
+ autoFixFailureCount,
3458
+ reflectionAttemptCount,
3459
+ reflectionStopReason,
3460
+ gitSafety,
3461
+ usage: usageSummary,
3462
+ ...(nonStreamTokenUsageDebug
3463
+ ? { tokenUsageDebug: nonStreamTokenUsageDebug }
3464
+ : {}),
3465
+ pendingConfirmations: pendingConfirmations.map((tool) => {
3466
+ const resultPayload = getToolResultPayload(tool.result) || {};
3467
+ return {
3468
+ confirmationId: typeof resultPayload.confirmationId === "string"
3469
+ ? resultPayload.confirmationId
3470
+ : undefined,
3471
+ command: typeof resultPayload.command === "string"
3472
+ ? resultPayload.command
3473
+ : undefined,
3474
+ action: typeof resultPayload.action === "string"
3475
+ ? resultPayload.action
3476
+ : undefined,
3477
+ target: typeof resultPayload.target === "string"
3478
+ ? resultPayload.target
3479
+ : undefined,
3480
+ toolName: tool.name,
3481
+ toolArgs: tool.args,
3482
+ };
3483
+ }),
3484
+ response: "The following commands require your approval before execution:",
3485
+ });
3486
+ }
3487
+ if (suspendedTools.length > 0) {
3488
+ const primarySuspension = suspendedTools[0];
3489
+ const payload = primarySuspension.suspendPayload || {};
3490
+ const question = typeof payload.question === "string" &&
3491
+ payload.question.trim().length > 0
3492
+ ? payload.question
3493
+ : typeof payload.prompt === "string" &&
3494
+ payload.prompt.trim().length > 0
3495
+ ? payload.prompt
3496
+ : primarySuspension.name === "submit_plan"
3497
+ ? "A plan requires your review. Please approve, reject, or provide feedback."
3498
+ : "Additional input is required to continue. Please provide your response.";
3499
+ await persistLifecycle("waiting_user_input", "awaiting_user_input", "awaiting_user_input", {
3500
+ suspendedTools: suspendedTools.length,
3501
+ });
3502
+ return res.json({
3503
+ success: true,
3504
+ runId: resolvedRunId,
3505
+ lifecycleState,
3506
+ stopReason,
3507
+ response: question,
3508
+ waitingForUserInput: true,
3509
+ suspendedTools,
3510
+ toolCalls: [],
3511
+ executedToolResults: normalizedExecutedToolResults,
3512
+ thoughtSteps: uniqueThoughtSteps,
3513
+ model: modelId,
3514
+ autoFixAttempted,
3515
+ autoFixFailureCount,
3516
+ reflectionAttemptCount,
3517
+ reflectionStopReason,
3518
+ gitSafety,
3519
+ usage: usageSummary,
3520
+ ...(nonStreamTokenUsageDebug
3521
+ ? { tokenUsageDebug: nonStreamTokenUsageDebug }
3522
+ : {}),
3523
+ maxStepsReached: false,
3524
+ stepsUsed: stepsCount,
3525
+ maxSteps: effectiveMaxSteps,
3526
+ });
3527
+ }
3528
+ const toolCallsUsed = Math.max(cumulativeToolCallsUsed, countUniqueToolCalls(lifecycleToolCallsForCounting, lifecycleResultsForCounting));
3529
+ const endedWithoutFinalText = typeof responseText !== "string" || responseText.trim().length === 0;
3530
+ const hasPendingToolCalls = normalizedToolCalls.length > 0;
3531
+ const hasTerminalExecutedToolResults = hasTerminalToolResults(normalizedExecutedToolResults);
3532
+ const hasNonterminalExecutedToolResults = hasNonterminalToolResults(normalizedExecutedToolResults);
3533
+ const budgetLimitReached = toolCallsUsed >= maxToolCalls ||
3534
+ toolCallBudget.stopReason === "repeated_call";
3535
+ const shouldSynthesizeAfterCompletedToolWork = pendingConfirmations.length === 0 &&
3536
+ ((hasPendingToolCalls &&
3537
+ !hasNonterminalExecutedToolResults &&
3538
+ budgetLimitReached) ||
3539
+ (hasTerminalExecutedToolResults &&
3540
+ endedWithoutFinalText &&
3541
+ !hasPendingToolCalls));
3542
+ if (shouldSynthesizeAfterCompletedToolWork) {
3543
+ const synthesisStopReason = budgetLimitReached
3544
+ ? (toolCallBudget.stopReason ?? "limit")
3545
+ : "empty_final_response";
3546
+ const synthesisResultOrCancellation = await generateBackendOnlySynthesis(normalizedExecutedToolResults, synthesisStopReason);
3547
+ if (isCancelledWaitResult(synthesisResultOrCancellation)) {
3548
+ await transitionToCancelled("during_backend_synthesis");
3549
+ return res.status(409).json({
3550
+ success: false,
3551
+ runId: resolvedRunId,
3552
+ lifecycleState,
3553
+ stopReason,
3554
+ error: "Run was cancelled",
3555
+ });
3556
+ }
3557
+ const synthesisResult = synthesisResultOrCancellation;
3558
+ const synthesizedText = typeof synthesisResult.text === "string" &&
3559
+ synthesisResult.text.trim().length > 0
3560
+ ? synthesisResult.text
3561
+ : null;
3562
+ if (!synthesizedText && endedWithoutFinalText) {
3563
+ throw new Error("The agent completed its tool work but returned no final response.");
3564
+ }
3565
+ if (synthesizedText) {
3566
+ responseText = synthesizedText;
3567
+ normalizedToolCalls = [];
3568
+ backendSynthesisPerformed = true;
3569
+ }
3570
+ usageSummary = mergeTokenUsage(usageSummary, normalizeTokenUsage(synthesisResult.usage));
3571
+ }
3572
+ const maxStepsReached = !backendSynthesisPerformed &&
3573
+ (toolCallsUsed >= maxToolCalls ||
3574
+ toolCallBudget.stopReason === "repeated_call") &&
3575
+ (endedWithoutFinalText || toolCallsUsed > 0);
3576
+ let completionState = "succeeded";
3577
+ let completionStopReason = "completed";
3578
+ if (normalizedToolCalls.length > 0) {
3579
+ completionState = "waiting_tool";
3580
+ completionStopReason = maxStepsReached ? "max_steps_reached" : "none";
3581
+ }
3582
+ else if (reflectionStopReason !== "none") {
3583
+ completionStopReason =
3584
+ toRunStopReasonFromReflection(reflectionStopReason);
3585
+ }
3586
+ await persistLifecycle(completionState, completionStopReason, "response_ready", {
3587
+ stepsUsed: stepsCount,
3588
+ toolCallsUsed,
3589
+ maxSteps: effectiveMaxSteps,
3590
+ pendingToolCalls: normalizedToolCalls.length,
3591
+ reflectionStopReason,
3592
+ });
3593
+ console.log(`📊 Steps used: ${stepsCount}/${effectiveMaxSteps} - Max reached: ${maxStepsReached}`);
3594
+ // ✅ FIX: Return the correct response object
3595
+ res.json({
3596
+ success: true,
3597
+ runId: resolvedRunId,
3598
+ lifecycleState,
3599
+ stopReason,
3600
+ response: responseText, // Use result.text, not aiResponse.content
3601
+ toolCalls: normalizedToolCalls, // Pending tool calls that need client execution
3602
+ executedToolResults: normalizedExecutedToolResults, // Tools that were already executed with results
3603
+ suspendedTools,
3604
+ thoughtSteps: uniqueThoughtSteps,
3605
+ model: modelId,
3606
+ autoFixAttempted,
3607
+ autoFixFailureCount,
3608
+ reflectionAttemptCount,
3609
+ reflectionStopReason,
3610
+ gitSafety,
3611
+ usage: usageSummary,
3612
+ ...(nonStreamTokenUsageDebug
3613
+ ? { tokenUsageDebug: nonStreamTokenUsageDebug }
3614
+ : {}),
3615
+ maxStepsReached: maxStepsReached, // Indicate if max steps was reached
3616
+ stepsUsed: stepsCount,
3617
+ toolCallsUsed,
3618
+ maxSteps: effectiveMaxSteps,
3619
+ });
3620
+ }
3621
+ catch (error) {
3622
+ const err = error;
3623
+ console.error("❌ === AGENT ERROR ===");
3624
+ console.error("Error name:", err.name);
3625
+ console.error("Error message:", err.message);
3626
+ console.error("Error stack:", err.stack);
3627
+ console.error("Error details:", error);
3628
+ const { code, message: providerMessage } = extractProviderErrorDetails(error);
3629
+ if (activeRunId) {
3630
+ await safePersistRunLifecycleEvent({
3631
+ runId: activeRunId,
3632
+ lifecycleState: "failed",
3633
+ stopReason: "error",
3634
+ eventType: "route_error",
3635
+ payload: {
3636
+ message: providerMessage,
3637
+ code,
3638
+ },
3639
+ });
3640
+ }
3641
+ const message = providerMessage.toLowerCase();
3642
+ const isInsufficientQuota = code === "insufficient_quota" || message.includes("insufficient_quota");
3643
+ const isRateLimitError = code === "429" ||
3644
+ code?.toLowerCase().includes("rate_limit") ||
3645
+ message.includes("rate_limit") ||
3646
+ message.includes("rate limit") ||
3647
+ message.includes("rate-limited") ||
3648
+ message.includes("too many requests");
3649
+ if (isInsufficientQuota) {
3650
+ return res.status(429).json({
3651
+ success: false,
3652
+ runId: activeRunId,
3653
+ lifecycleState: "failed",
3654
+ stopReason: "error",
3655
+ error: "API quota exceeded. Please check your plan and billing details.",
3656
+ errorCode: "insufficient_quota",
3657
+ });
3658
+ }
3659
+ if (isRateLimitError) {
3660
+ return res.status(429).json({
3661
+ success: false,
3662
+ runId: activeRunId,
3663
+ lifecycleState: "failed",
3664
+ stopReason: "error",
3665
+ error: getRateLimitErrorMessage(requestedModelId),
3666
+ errorCode: "rate_limit_exceeded",
3667
+ });
3668
+ }
3669
+ res.status(500).json({
3670
+ success: false,
3671
+ runId: activeRunId,
3672
+ lifecycleState: "failed",
3673
+ stopReason: "error",
3674
+ error: normalizeProviderRequestError(requestedModelId, providerMessage, code),
3675
+ errorDetails: process.env.NODE_ENV === "development" ? err.stack : undefined,
3676
+ });
3677
+ }
3678
+ finally {
3679
+ try {
3680
+ if (activeHostSession) {
3681
+ await activeHostSession.disconnect();
3682
+ }
3683
+ else if (activeHostSessionManager) {
3684
+ await activeHostSessionManager.stopAll();
3685
+ }
3686
+ }
3687
+ catch (disconnectError) {
3688
+ const disconnectMessage = getErrorMessage(disconnectError);
3689
+ console.warn("[agent] failed to clean up host session:", disconnectMessage);
3690
+ }
3691
+ }
3692
+ });
3693
+ router.get("/runs/:runId", async (req, res) => {
3694
+ const runId = req.params.runId?.trim();
3695
+ if (!runId || !isSafeRunId(runId)) {
3696
+ return res.status(400).json({ success: false, error: "Invalid run id" });
3697
+ }
3698
+ const snapshot = await getRunSnapshot(runId);
3699
+ if (!snapshot) {
3700
+ return res.status(404).json({ success: false, error: "Run not found" });
3701
+ }
3702
+ const parsePayload = (raw) => {
3703
+ if (!raw)
3704
+ return null;
3705
+ try {
3706
+ const parsed = JSON.parse(raw);
3707
+ return parsed && typeof parsed === "object"
3708
+ ? parsed
3709
+ : null;
3710
+ }
3711
+ catch {
3712
+ return null;
3713
+ }
3714
+ };
3715
+ return res.json({
3716
+ success: true,
3717
+ run: {
3718
+ runId: snapshot.run.run_id,
3719
+ lifecycleState: snapshot.run.status,
3720
+ stopReason: snapshot.run.stop_reason,
3721
+ objective: snapshot.run.objective,
3722
+ workspacePath: snapshot.run.workspace_path,
3723
+ modelId: snapshot.run.model_id,
3724
+ cancelRequested: Number(snapshot.run.cancel_requested || 0) === 1,
3725
+ cancelRequestedAt: snapshot.run.cancel_requested_at || null,
3726
+ createdAt: snapshot.run.created_at,
3727
+ updatedAt: snapshot.run.updated_at,
3728
+ },
3729
+ latestCheckpoint: snapshot.latestCheckpoint
3730
+ ? {
3731
+ sequence: snapshot.latestCheckpoint.sequence,
3732
+ lifecycleState: snapshot.latestCheckpoint.lifecycle_state,
3733
+ stopReason: snapshot.latestCheckpoint.stop_reason,
3734
+ eventType: snapshot.latestCheckpoint.event_type,
3735
+ payload: normalizeToolActionPayload(snapshot.latestCheckpoint.event_type, parsePayload(snapshot.latestCheckpoint.payload_json)),
3736
+ createdAt: snapshot.latestCheckpoint.created_at,
3737
+ }
3738
+ : null,
3739
+ });
3740
+ });
3741
+ router.get("/runs/:runId/events", async (req, res) => {
3742
+ const runId = req.params.runId?.trim();
3743
+ if (!runId || !isSafeRunId(runId)) {
3744
+ return res.status(400).json({ success: false, error: "Invalid run id" });
3745
+ }
3746
+ const afterSequence = parsePositiveIntQuery(req.query.afterSequence, 0, 0, 10_000_000);
3747
+ const limit = parsePositiveIntQuery(req.query.limit, 200, 1, 500);
3748
+ const events = await listRunEvents(runId, afterSequence, limit);
3749
+ const serialized = events.map((event) => {
3750
+ let payload = null;
3751
+ if (event.payload_json) {
3752
+ try {
3753
+ const parsed = JSON.parse(event.payload_json);
3754
+ payload =
3755
+ parsed && typeof parsed === "object"
3756
+ ? parsed
3757
+ : null;
3758
+ }
3759
+ catch {
3760
+ payload = null;
3761
+ }
3762
+ }
3763
+ return {
3764
+ runId: event.run_id,
3765
+ sequence: event.sequence,
3766
+ lifecycleState: event.lifecycle_state,
3767
+ stopReason: event.stop_reason,
3768
+ eventType: event.event_type,
3769
+ payload: normalizeToolActionPayload(event.event_type, payload),
3770
+ createdAt: event.created_at,
3771
+ };
3772
+ });
3773
+ return res.json({
3774
+ success: true,
3775
+ runId,
3776
+ afterSequence,
3777
+ count: serialized.length,
3778
+ events: serialized,
3779
+ });
3780
+ });
3781
+ router.post("/runs/:runId/cancel", async (req, res) => {
3782
+ const runId = req.params.runId?.trim();
3783
+ if (!runId || !isSafeRunId(runId)) {
3784
+ return res.status(400).json({ success: false, error: "Invalid run id" });
3785
+ }
3786
+ const exists = await getRunSnapshot(runId);
3787
+ if (!exists) {
3788
+ return res.status(404).json({ success: false, error: "Run not found" });
3789
+ }
3790
+ const updated = await requestRunCancellation(runId);
3791
+ if (!updated) {
3792
+ return res
3793
+ .status(409)
3794
+ .json({ success: false, error: "Unable to request cancellation" });
3795
+ }
3796
+ const currentState = asRunLifecycleState(exists.run.status);
3797
+ await safePersistRunLifecycleEvent({
3798
+ runId,
3799
+ lifecycleState: currentState,
3800
+ stopReason: "cancelled",
3801
+ eventType: "cancel_requested",
3802
+ payload: {
3803
+ requestedAt: new Date().toISOString(),
3804
+ },
3805
+ });
3806
+ return res.json({
3807
+ success: true,
3808
+ runId,
3809
+ cancelRequested: true,
3810
+ lifecycleState: currentState,
3811
+ stopReason: "cancelled",
3812
+ });
3813
+ });
3814
+ // POST /api/agent/command-confirmation - Handle user approval/skip for command execution
3815
+ router.post("/command-confirmation", async (req, res) => {
3816
+ try {
3817
+ const { confirmationId, approved, toolArgs, workspaceRoot } = req.body;
3818
+ if (!confirmationId || typeof approved !== "boolean") {
3819
+ return res.status(400).json({
3820
+ error: "confirmationId and approved (boolean) are required",
3821
+ });
3822
+ }
3823
+ console.log(`📋 Command confirmation ${confirmationId}: ${approved ? "✅ Approved" : "❌ Skipped"}`);
3824
+ if (!approved) {
3825
+ return res.json({
3826
+ success: true,
3827
+ skipped: true,
3828
+ output: "Command execution skipped by user",
3829
+ });
3830
+ }
3831
+ // User approved - execute the command
3832
+ // Import executeCommand function
3833
+ const { executeCommand } = await import("./core/agent/tools/executeCommand.js");
3834
+ // Execute with approval bypass (since user already approved)
3835
+ const result = await executeCommand({
3836
+ ...toolArgs,
3837
+ workspaceRoot: typeof workspaceRoot === "string" && workspaceRoot.trim().length > 0
3838
+ ? workspaceRoot
3839
+ : typeof toolArgs?.cwd === "string" &&
3840
+ toolArgs.cwd.trim().length > 0
3841
+ ? toolArgs.cwd
3842
+ : process.cwd(),
3843
+ skipConfirmation: true, // Flag to bypass confirmation check
3844
+ });
3845
+ res.json({
3846
+ success: true,
3847
+ approved: true,
3848
+ result,
3849
+ });
3850
+ }
3851
+ catch (error) {
3852
+ const err = error;
3853
+ console.error("❌ Command execution error:", error);
3854
+ res.status(500).json({
3855
+ success: false,
3856
+ error: err.message || "Failed to execute command",
3857
+ });
3858
+ }
3859
+ });
3860
+ registerFileRoutes(router, requireDesktopAuth);
3861
+ registerSemanticRoutes(router);
3862
+ registerLspDocumentRoutes(router, requireDesktopAuth);
3863
+ registerLspSemanticDocumentRoutes(router, requireDesktopAuth);
3864
+ registerLspHierarchyRoutes(router, requireDesktopAuth);
3865
+ registerLspResolveRoutes(router, requireDesktopAuth);
3866
+ registerLspPositionRoutes(router, requireDesktopAuth);
3867
+ registerLspQueryRoutes(router, requireDesktopAuth);
3868
+ // ── Desktop security helpers ────────────────────────────────────────────────
3869
+ /** True only for IPv4/IPv6 loopback connections. */
3870
+ function isLoopback(req) {
3871
+ const ip = req.socket.remoteAddress ?? "";
3872
+ return ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1";
3873
+ }
3874
+ function hasDesktopAuth(req) {
3875
+ const token = process.env.IRIS_DESKTOP_TOKEN;
3876
+ if (!process.env.TAURI_BUNDLED || !token) {
3877
+ return false;
3878
+ }
3879
+ if (!isLoopback(req)) {
3880
+ return false;
3881
+ }
3882
+ const raw = req.headers["x-desktop-token"];
3883
+ const provided = Array.isArray(raw) ? raw[0] : raw;
3884
+ return Boolean(provided && provided === token);
3885
+ }
3886
+ /**
3887
+ * Reject with 403 unless ALL of:
3888
+ * 1. The server was started as a Tauri desktop sidecar (TAURI_BUNDLED=1)
3889
+ * 2. The request originates from the loopback interface
3890
+ * 3. The X-Desktop-Token header matches the per-launch secret
3891
+ *
3892
+ * This gates the mutable /keys endpoint so arbitrary web pages or local
3893
+ * processes cannot change API keys on behalf of the user.
3894
+ */
3895
+ export function requireDesktopAuth(req, res, next) {
3896
+ if (!hasDesktopAuth(req)) {
3897
+ return res.status(403).json({ error: "Forbidden" });
3898
+ }
3899
+ next();
3900
+ }
3901
+ // ── API Key management ─────────────────────────────────────────────
3902
+ // Allowed env vars that the frontend can set via this endpoint.
3903
+ const ALLOWED_KEY_NAMES = new Set([
3904
+ "OPENAI_API_KEY",
3905
+ "ANTHROPIC_API_KEY",
3906
+ "GOOGLE_GENERATIVE_AI_API_KEY",
3907
+ "OPENROUTER_API_KEY",
3908
+ "OPENROUTER_BASE_URL",
3909
+ "OLLAMA_BASE_URL",
3910
+ "OLLAMA_API_KEY",
3911
+ "HF_TOKEN",
3912
+ ]);
3913
+ // POST /api/agent/keys - Set API keys at runtime (desktop app only)
3914
+ router.post("/keys", requireDesktopAuth, (req, res) => {
3915
+ const keys = req.body?.keys;
3916
+ if (!keys || typeof keys !== "object") {
3917
+ return res
3918
+ .status(400)
3919
+ .json({ success: false, error: "Missing keys object" });
3920
+ }
3921
+ const applied = [];
3922
+ for (const [name, value] of Object.entries(keys)) {
3923
+ if (!ALLOWED_KEY_NAMES.has(name))
3924
+ continue;
3925
+ if (typeof value === "string") {
3926
+ const trimmedValue = value.trim();
3927
+ if (trimmedValue.length > 0) {
3928
+ process.env[name] = trimmedValue;
3929
+ applied.push(name);
3930
+ continue;
3931
+ }
3932
+ }
3933
+ if (value === "" ||
3934
+ value === null ||
3935
+ (typeof value === "string" && value.trim().length === 0)) {
3936
+ delete process.env[name];
3937
+ applied.push(name);
3938
+ }
3939
+ }
3940
+ // Clear cached agents so they pick up the new keys on next request
3941
+ if (applied.length > 0) {
3942
+ agentCache.clear();
3943
+ }
3944
+ res.json({ success: true, applied });
3945
+ });
3946
+ // GET /api/agent/keys - Check which keys are configured (never returns values)
3947
+ router.get("/keys", requireDesktopAuth, (_req, res) => {
3948
+ const status = {};
3949
+ for (const name of ALLOWED_KEY_NAMES) {
3950
+ status[name] = Boolean(process.env[name]?.trim());
3951
+ }
3952
+ res.json({ success: true, keys: status });
3953
+ });
3954
+ // GET /api/agent/keys/values - Return runtime key values (desktop app only)
3955
+ router.get("/keys/values", requireDesktopAuth, (_req, res) => {
3956
+ const keys = {};
3957
+ for (const name of ALLOWED_KEY_NAMES) {
3958
+ keys[name] = process.env[name]?.trim() || "";
3959
+ }
3960
+ res.json({ success: true, keys });
3961
+ });
3962
+ router.get("/chat-sessions/:sessionId", requireDesktopAuth, async (req, res) => {
3963
+ const sessionId = req.params.sessionId?.trim();
3964
+ if (!sessionId || !isSafeSessionId(sessionId)) {
3965
+ return res
3966
+ .status(400)
3967
+ .json({ success: false, error: "Invalid session id" });
3968
+ }
3969
+ const sessionPath = getRemoteChatSessionPath(sessionId);
3970
+ try {
3971
+ const raw = await fs.readFile(sessionPath, "utf8");
3972
+ const parsed = JSON.parse(raw);
3973
+ if (!Array.isArray(parsed?.messages)) {
3974
+ return res
3975
+ .status(404)
3976
+ .json({ success: false, error: "Session not found" });
3977
+ }
3978
+ return res.json({
3979
+ id: sessionId,
3980
+ title: typeof parsed.title === "string" ? parsed.title : undefined,
3981
+ createdAt: typeof parsed.createdAt === "number" ? parsed.createdAt : undefined,
3982
+ updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : undefined,
3983
+ messages: parsed.messages,
3984
+ });
3985
+ }
3986
+ catch (error) {
3987
+ const err = error;
3988
+ if (err.code === "ENOENT") {
3989
+ return res
3990
+ .status(404)
3991
+ .json({ success: false, error: "Session not found" });
3992
+ }
3993
+ return res
3994
+ .status(500)
3995
+ .json({ success: false, error: "Failed to read session" });
3996
+ }
3997
+ });
3998
+ router.put("/chat-sessions/:sessionId", requireDesktopAuth, async (req, res) => {
3999
+ const sessionId = req.params.sessionId?.trim();
4000
+ if (!sessionId || !isSafeSessionId(sessionId)) {
4001
+ return res
4002
+ .status(400)
4003
+ .json({ success: false, error: "Invalid session id" });
4004
+ }
4005
+ if (!Array.isArray(req.body?.messages)) {
4006
+ return res
4007
+ .status(400)
4008
+ .json({ success: false, error: "messages must be an array" });
4009
+ }
4010
+ const timestamp = Date.now();
4011
+ const payload = {
4012
+ id: sessionId,
4013
+ title: typeof req.body.title === "string" ? req.body.title : "New Chat",
4014
+ createdAt: typeof req.body.createdAt === "number" ? req.body.createdAt : timestamp,
4015
+ updatedAt: typeof req.body.updatedAt === "number" ? req.body.updatedAt : timestamp,
4016
+ messages: req.body.messages,
4017
+ };
4018
+ try {
4019
+ await fs.mkdir(REMOTE_CHAT_SESSIONS_DIR, { recursive: true });
4020
+ await fs.writeFile(getRemoteChatSessionPath(sessionId), JSON.stringify(payload, null, 2), "utf8");
4021
+ return res.json({ success: true, syncedAt: timestamp });
4022
+ }
4023
+ catch {
4024
+ return res
4025
+ .status(500)
4026
+ .json({ success: false, error: "Failed to store session" });
4027
+ }
4028
+ });
4029
+ router.delete("/chat-sessions/:sessionId", requireDesktopAuth, async (req, res) => {
4030
+ const sessionId = req.params.sessionId?.trim();
4031
+ if (!sessionId || !isSafeSessionId(sessionId)) {
4032
+ return res
4033
+ .status(400)
4034
+ .json({ success: false, error: "Invalid session id" });
4035
+ }
4036
+ try {
4037
+ const sessionPath = getRemoteChatSessionPath(sessionId);
4038
+ const rawSession = await fs.readFile(sessionPath, "utf8");
4039
+ const parsed = JSON.parse(rawSession);
4040
+ const runIds = extractRunIdsFromRemoteSession(parsed);
4041
+ if (runIds.length > 0) {
4042
+ await deleteRunDataBatch(runIds);
4043
+ }
4044
+ await fs.unlink(sessionPath);
4045
+ return res.json({ success: true });
4046
+ }
4047
+ catch (error) {
4048
+ const err = error;
4049
+ if (err.code === "ENOENT") {
4050
+ return res.json({ success: true });
4051
+ }
4052
+ if (error instanceof SyntaxError) {
4053
+ return res
4054
+ .status(500)
4055
+ .json({ success: false, error: "Failed to parse session" });
4056
+ }
4057
+ return res
4058
+ .status(500)
4059
+ .json({ success: false, error: "Failed to delete session" });
4060
+ }
4061
+ });
4062
+ router.post("/mcp/inspect", requireDesktopAuth, async (req, res) => {
4063
+ const serverList = sanitizeMcpServers([req.body?.server], 1);
4064
+ const server = serverList[0];
4065
+ if (!server) {
4066
+ return res
4067
+ .status(400)
4068
+ .json({ success: false, error: "Invalid MCP server configuration" });
4069
+ }
4070
+ try {
4071
+ const tools = await listMcpServerTools(server, process.cwd());
4072
+ return res.json({
4073
+ success: true,
4074
+ server: {
4075
+ id: server.id,
4076
+ name: server.name,
4077
+ command: server.command,
4078
+ },
4079
+ tools: tools.map((tool) => ({
4080
+ name: tool.name,
4081
+ description: tool.description || "",
4082
+ })),
4083
+ toolCount: tools.length,
4084
+ });
4085
+ }
4086
+ catch (error) {
4087
+ const err = error;
4088
+ return res.status(500).json({
4089
+ success: false,
4090
+ error: err.message || "Failed to inspect MCP server",
4091
+ });
4092
+ }
4093
+ });
4094
+ router.post("/mcp/call", requireDesktopAuth, async (req, res) => {
4095
+ console.log("🧩 === MCP TOOL CALL RECEIVED ===");
4096
+ console.log("Tool name:", req.body?.toolName);
4097
+ console.log("Args keys:", Object.keys(req.body?.args || {}));
4098
+ console.log("Workspace root:", req.body?.workspaceRoot);
4099
+ console.log("MCP servers:", req.body?.mcpServers);
4100
+ const toolName = typeof req.body?.toolName === "string" ? req.body.toolName.trim() : "";
4101
+ const args = req.body?.args &&
4102
+ typeof req.body.args === "object" &&
4103
+ !Array.isArray(req.body.args)
4104
+ ? req.body.args
4105
+ : {};
4106
+ const workspaceRoot = req.body?.workspaceRoot || process.cwd();
4107
+ const mcpServers = sanitizeMcpServers(req.body?.mcpServers);
4108
+ console.log("Sanitized MCP servers count:", mcpServers.length);
4109
+ if (!toolName.startsWith("mcp_")) {
4110
+ console.warn("⚠️ Invalid MCP tool name:", toolName);
4111
+ return res
4112
+ .status(400)
4113
+ .json({ success: false, error: "Invalid MCP tool name" });
4114
+ }
4115
+ if (mcpServers.length === 0) {
4116
+ console.warn("⚠️ No MCP servers configured");
4117
+ return res
4118
+ .status(400)
4119
+ .json({ success: false, error: "No MCP servers configured" });
4120
+ }
4121
+ try {
4122
+ console.log("🚀 Executing MCP tool:", toolName);
4123
+ const result = await executeMcpToolByKey(mcpServers, workspaceRoot, toolName, args);
4124
+ console.log("✅ MCP tool execution result:", result);
4125
+ return res.json(result);
4126
+ }
4127
+ catch (error) {
4128
+ const err = error;
4129
+ console.error("❌ MCP tool execution error:", err.message);
4130
+ console.error("Stack:", err.stack);
4131
+ return res.status(500).json({
4132
+ success: false,
4133
+ error: err.message || "Failed to execute MCP tool",
4134
+ });
4135
+ }
4136
+ });
4137
+ router.get("/skills", async (_req, res) => {
4138
+ try {
4139
+ const { getSkillsList } = await loadAgentCoreModule();
4140
+ const skills = await getSkillsList();
4141
+ res.json({ success: true, skills });
4142
+ }
4143
+ catch {
4144
+ res.status(500).json({ success: false, error: "Failed to load skills" });
4145
+ }
4146
+ });
4147
+ // GET /api/agent/tools - List all available Language Model Tools (native + MCP)
4148
+ router.get("/tools", async (req, res) => {
4149
+ try {
4150
+ const tools = await getAvailableTools();
4151
+ res.json({
4152
+ success: true,
4153
+ count: tools.length,
4154
+ tools: tools.map((tool) => ({
4155
+ name: tool.name,
4156
+ description: tool.description,
4157
+ tags: tool.tags,
4158
+ inputSchema: tool.inputSchema,
4159
+ })),
4160
+ });
4161
+ }
4162
+ catch (error) {
4163
+ const err = error;
4164
+ console.error("❌ Failed to list tools:", error);
4165
+ res.status(500).json({
4166
+ success: false,
4167
+ error: err.message || "Failed to list tools",
4168
+ });
4169
+ }
4170
+ });
4171
+ export default router;