@studio-foundation/runner 0.3.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (268) hide show
  1. package/ARCHITECTURE.md +53 -0
  2. package/LICENSE +663 -0
  3. package/README.md +125 -0
  4. package/configs/agents/analyst.agent.yaml +31 -0
  5. package/configs/agents/code-generator.agent.yaml +31 -0
  6. package/configs/agents/generic.agent.yaml +23 -0
  7. package/dist/__tests__/script-executor.test.d.ts +2 -0
  8. package/dist/__tests__/script-executor.test.d.ts.map +1 -0
  9. package/dist/__tests__/script-executor.test.js +173 -0
  10. package/dist/__tests__/script-executor.test.js.map +1 -0
  11. package/dist/index.d.ts +38 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +76 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/integrations/integration-loader.d.ts +20 -0
  16. package/dist/integrations/integration-loader.d.ts.map +1 -0
  17. package/dist/integrations/integration-loader.js +71 -0
  18. package/dist/integrations/integration-loader.js.map +1 -0
  19. package/dist/integrations/integration-loader.test.d.ts +2 -0
  20. package/dist/integrations/integration-loader.test.d.ts.map +1 -0
  21. package/dist/integrations/integration-loader.test.js +77 -0
  22. package/dist/integrations/integration-loader.test.js.map +1 -0
  23. package/dist/middleware/anonymization.d.ts +22 -0
  24. package/dist/middleware/anonymization.d.ts.map +1 -0
  25. package/dist/middleware/anonymization.js +37 -0
  26. package/dist/middleware/anonymization.js.map +1 -0
  27. package/dist/plugins/index.d.ts +5 -0
  28. package/dist/plugins/index.d.ts.map +1 -0
  29. package/dist/plugins/index.js +10 -0
  30. package/dist/plugins/index.js.map +1 -0
  31. package/dist/plugins/mcp-client.d.ts +19 -0
  32. package/dist/plugins/mcp-client.d.ts.map +1 -0
  33. package/dist/plugins/mcp-client.js +126 -0
  34. package/dist/plugins/mcp-client.js.map +1 -0
  35. package/dist/plugins/mcp-client.test.d.ts +2 -0
  36. package/dist/plugins/mcp-client.test.d.ts.map +1 -0
  37. package/dist/plugins/mcp-client.test.js +160 -0
  38. package/dist/plugins/mcp-client.test.js.map +1 -0
  39. package/dist/plugins/oauth-provider.d.ts +27 -0
  40. package/dist/plugins/oauth-provider.d.ts.map +1 -0
  41. package/dist/plugins/oauth-provider.js +140 -0
  42. package/dist/plugins/oauth-provider.js.map +1 -0
  43. package/dist/plugins/oauth-provider.test.d.ts +2 -0
  44. package/dist/plugins/oauth-provider.test.d.ts.map +1 -0
  45. package/dist/plugins/oauth-provider.test.js +144 -0
  46. package/dist/plugins/oauth-provider.test.js.map +1 -0
  47. package/dist/plugins/plugin-loader.d.ts +29 -0
  48. package/dist/plugins/plugin-loader.d.ts.map +1 -0
  49. package/dist/plugins/plugin-loader.js +71 -0
  50. package/dist/plugins/plugin-loader.js.map +1 -0
  51. package/dist/plugins/plugin-loader.test.d.ts +2 -0
  52. package/dist/plugins/plugin-loader.test.d.ts.map +1 -0
  53. package/dist/plugins/plugin-loader.test.js +87 -0
  54. package/dist/plugins/plugin-loader.test.js.map +1 -0
  55. package/dist/prompt-builder.d.ts +47 -0
  56. package/dist/prompt-builder.d.ts.map +1 -0
  57. package/dist/prompt-builder.js +263 -0
  58. package/dist/prompt-builder.js.map +1 -0
  59. package/dist/prompt-builder.test.d.ts +2 -0
  60. package/dist/prompt-builder.test.d.ts.map +1 -0
  61. package/dist/prompt-builder.test.js +154 -0
  62. package/dist/prompt-builder.test.js.map +1 -0
  63. package/dist/providers/anthropic.d.ts +14 -0
  64. package/dist/providers/anthropic.d.ts.map +1 -0
  65. package/dist/providers/anthropic.js +118 -0
  66. package/dist/providers/anthropic.js.map +1 -0
  67. package/dist/providers/anthropic.test.d.ts +2 -0
  68. package/dist/providers/anthropic.test.d.ts.map +1 -0
  69. package/dist/providers/anthropic.test.js +76 -0
  70. package/dist/providers/anthropic.test.js.map +1 -0
  71. package/dist/providers/mock.d.ts +17 -0
  72. package/dist/providers/mock.d.ts.map +1 -0
  73. package/dist/providers/mock.js +39 -0
  74. package/dist/providers/mock.js.map +1 -0
  75. package/dist/providers/ollama.d.ts +18 -0
  76. package/dist/providers/ollama.d.ts.map +1 -0
  77. package/dist/providers/ollama.js +155 -0
  78. package/dist/providers/ollama.js.map +1 -0
  79. package/dist/providers/ollama.test.d.ts +2 -0
  80. package/dist/providers/ollama.test.d.ts.map +1 -0
  81. package/dist/providers/ollama.test.js +123 -0
  82. package/dist/providers/ollama.test.js.map +1 -0
  83. package/dist/providers/openai-responses.d.ts +18 -0
  84. package/dist/providers/openai-responses.d.ts.map +1 -0
  85. package/dist/providers/openai-responses.js +175 -0
  86. package/dist/providers/openai-responses.js.map +1 -0
  87. package/dist/providers/openai.d.ts +16 -0
  88. package/dist/providers/openai.d.ts.map +1 -0
  89. package/dist/providers/openai.js +132 -0
  90. package/dist/providers/openai.js.map +1 -0
  91. package/dist/providers/openai.test.d.ts +2 -0
  92. package/dist/providers/openai.test.d.ts.map +1 -0
  93. package/dist/providers/openai.test.js +51 -0
  94. package/dist/providers/openai.test.js.map +1 -0
  95. package/dist/providers/provider.d.ts +42 -0
  96. package/dist/providers/provider.d.ts.map +1 -0
  97. package/dist/providers/provider.js +10 -0
  98. package/dist/providers/provider.js.map +1 -0
  99. package/dist/providers/registry.d.ts +42 -0
  100. package/dist/providers/registry.d.ts.map +1 -0
  101. package/dist/providers/registry.js +65 -0
  102. package/dist/providers/registry.js.map +1 -0
  103. package/dist/runner.d.ts +46 -0
  104. package/dist/runner.d.ts.map +1 -0
  105. package/dist/runner.js +324 -0
  106. package/dist/runner.js.map +1 -0
  107. package/dist/runner.test.d.ts +2 -0
  108. package/dist/runner.test.d.ts.map +1 -0
  109. package/dist/runner.test.js +280 -0
  110. package/dist/runner.test.js.map +1 -0
  111. package/dist/script-executor.d.ts +11 -0
  112. package/dist/script-executor.d.ts.map +1 -0
  113. package/dist/script-executor.js +85 -0
  114. package/dist/script-executor.js.map +1 -0
  115. package/dist/tools/builtin/git.d.ts +6 -0
  116. package/dist/tools/builtin/git.d.ts.map +1 -0
  117. package/dist/tools/builtin/git.js +294 -0
  118. package/dist/tools/builtin/git.js.map +1 -0
  119. package/dist/tools/builtin/patch.d.ts +6 -0
  120. package/dist/tools/builtin/patch.d.ts.map +1 -0
  121. package/dist/tools/builtin/patch.js +238 -0
  122. package/dist/tools/builtin/patch.js.map +1 -0
  123. package/dist/tools/builtin/repo-manager.d.ts +6 -0
  124. package/dist/tools/builtin/repo-manager.d.ts.map +1 -0
  125. package/dist/tools/builtin/repo-manager.js +171 -0
  126. package/dist/tools/builtin/repo-manager.js.map +1 -0
  127. package/dist/tools/builtin/search.d.ts +6 -0
  128. package/dist/tools/builtin/search.d.ts.map +1 -0
  129. package/dist/tools/builtin/search.js +104 -0
  130. package/dist/tools/builtin/search.js.map +1 -0
  131. package/dist/tools/builtin/shell.d.ts +6 -0
  132. package/dist/tools/builtin/shell.d.ts.map +1 -0
  133. package/dist/tools/builtin/shell.js +78 -0
  134. package/dist/tools/builtin/shell.js.map +1 -0
  135. package/dist/tools/builtin/studio-run.d.ts +12 -0
  136. package/dist/tools/builtin/studio-run.d.ts.map +1 -0
  137. package/dist/tools/builtin/studio-run.js +61 -0
  138. package/dist/tools/builtin/studio-run.js.map +1 -0
  139. package/dist/tools/builtin/web-search.d.ts +7 -0
  140. package/dist/tools/builtin/web-search.d.ts.map +1 -0
  141. package/dist/tools/builtin/web-search.js +94 -0
  142. package/dist/tools/builtin/web-search.js.map +1 -0
  143. package/dist/tools/builtin/web-search.test.d.ts +5 -0
  144. package/dist/tools/builtin/web-search.test.d.ts.map +1 -0
  145. package/dist/tools/builtin/web-search.test.js +90 -0
  146. package/dist/tools/builtin/web-search.test.js.map +1 -0
  147. package/dist/tools/errors.d.ts +4 -0
  148. package/dist/tools/errors.d.ts.map +1 -0
  149. package/dist/tools/errors.js +11 -0
  150. package/dist/tools/errors.js.map +1 -0
  151. package/dist/tools/errors.test.d.ts +2 -0
  152. package/dist/tools/errors.test.d.ts.map +1 -0
  153. package/dist/tools/errors.test.js +14 -0
  154. package/dist/tools/errors.test.js.map +1 -0
  155. package/dist/tools/plugin-loader.d.ts +31 -0
  156. package/dist/tools/plugin-loader.d.ts.map +1 -0
  157. package/dist/tools/plugin-loader.js +183 -0
  158. package/dist/tools/plugin-loader.js.map +1 -0
  159. package/dist/tools/plugin-loader.test.d.ts +2 -0
  160. package/dist/tools/plugin-loader.test.d.ts.map +1 -0
  161. package/dist/tools/plugin-loader.test.js +116 -0
  162. package/dist/tools/plugin-loader.test.js.map +1 -0
  163. package/dist/tools/skills/skill-loader.d.ts +23 -0
  164. package/dist/tools/skills/skill-loader.d.ts.map +1 -0
  165. package/dist/tools/skills/skill-loader.js +58 -0
  166. package/dist/tools/skills/skill-loader.js.map +1 -0
  167. package/dist/tools/skills/skill-loader.test.d.ts +2 -0
  168. package/dist/tools/skills/skill-loader.test.d.ts.map +1 -0
  169. package/dist/tools/skills/skill-loader.test.js +92 -0
  170. package/dist/tools/skills/skill-loader.test.js.map +1 -0
  171. package/dist/tools/tool-executor.d.ts +12 -0
  172. package/dist/tools/tool-executor.d.ts.map +1 -0
  173. package/dist/tools/tool-executor.js +76 -0
  174. package/dist/tools/tool-executor.js.map +1 -0
  175. package/dist/tools/tool-executor.test.d.ts +2 -0
  176. package/dist/tools/tool-executor.test.d.ts.map +1 -0
  177. package/dist/tools/tool-executor.test.js +83 -0
  178. package/dist/tools/tool-executor.test.js.map +1 -0
  179. package/dist/tools/tool-registry.d.ts +65 -0
  180. package/dist/tools/tool-registry.d.ts.map +1 -0
  181. package/dist/tools/tool-registry.js +112 -0
  182. package/dist/tools/tool-registry.js.map +1 -0
  183. package/dist/tools/yaml-executor.d.ts +22 -0
  184. package/dist/tools/yaml-executor.d.ts.map +1 -0
  185. package/dist/tools/yaml-executor.js +80 -0
  186. package/dist/tools/yaml-executor.js.map +1 -0
  187. package/dist/utils/race-signal.d.ts +6 -0
  188. package/dist/utils/race-signal.d.ts.map +1 -0
  189. package/dist/utils/race-signal.js +21 -0
  190. package/dist/utils/race-signal.js.map +1 -0
  191. package/dist/utils/race-signal.test.d.ts +2 -0
  192. package/dist/utils/race-signal.test.d.ts.map +1 -0
  193. package/dist/utils/race-signal.test.js +48 -0
  194. package/dist/utils/race-signal.test.js.map +1 -0
  195. package/package.json +36 -0
  196. package/src/__tests__/script-executor.test.ts +180 -0
  197. package/src/index.ts +68 -0
  198. package/src/integrations/integration-loader.test.ts +88 -0
  199. package/src/integrations/integration-loader.ts +68 -0
  200. package/src/middleware/anonymization.ts +38 -0
  201. package/src/plugins/index.ts +4 -0
  202. package/src/plugins/mcp-client.test.ts +148 -0
  203. package/src/plugins/mcp-client.ts +128 -0
  204. package/src/plugins/oauth-provider.test.ts +167 -0
  205. package/src/plugins/oauth-provider.ts +175 -0
  206. package/src/plugins/plugin-loader.test.ts +114 -0
  207. package/src/plugins/plugin-loader.ts +90 -0
  208. package/src/prompt-builder.test.ts +167 -0
  209. package/src/prompt-builder.ts +332 -0
  210. package/src/providers/anthropic.test.ts +101 -0
  211. package/src/providers/anthropic.ts +135 -0
  212. package/src/providers/mock.ts +57 -0
  213. package/src/providers/ollama.test.ts +166 -0
  214. package/src/providers/ollama.ts +152 -0
  215. package/src/providers/openai-responses.ts +212 -0
  216. package/src/providers/openai.test.ts +67 -0
  217. package/src/providers/openai.ts +139 -0
  218. package/src/providers/provider.ts +54 -0
  219. package/src/providers/registry.ts +77 -0
  220. package/src/runner.test.ts +343 -0
  221. package/src/runner.ts +396 -0
  222. package/src/script-executor.ts +107 -0
  223. package/src/tools/builtin/git.ts +311 -0
  224. package/src/tools/builtin/patch.ts +257 -0
  225. package/src/tools/builtin/repo-manager.ts +142 -0
  226. package/src/tools/builtin/search.ts +108 -0
  227. package/src/tools/builtin/shell.ts +82 -0
  228. package/src/tools/builtin/studio-run.ts +73 -0
  229. package/src/tools/builtin/web-search.test.ts +122 -0
  230. package/src/tools/builtin/web-search.ts +101 -0
  231. package/src/tools/errors.test.ts +12 -0
  232. package/src/tools/errors.ts +6 -0
  233. package/src/tools/plugin-loader.test.ts +130 -0
  234. package/src/tools/plugin-loader.ts +203 -0
  235. package/src/tools/skills/README.md +49 -0
  236. package/src/tools/skills/skill-loader.test.ts +106 -0
  237. package/src/tools/skills/skill-loader.ts +62 -0
  238. package/src/tools/tool-executor.test.ts +88 -0
  239. package/src/tools/tool-executor.ts +84 -0
  240. package/src/tools/tool-registry.ts +130 -0
  241. package/src/tools/yaml-executor.ts +120 -0
  242. package/src/utils/race-signal.test.ts +50 -0
  243. package/src/utils/race-signal.ts +17 -0
  244. package/templates/integrations/linear.integration.yaml +35 -0
  245. package/templates/integrations/slack.integration.yaml +22 -0
  246. package/templates/integrations/webhook.integration.yaml +17 -0
  247. package/templates/tools/git.tool.yaml +80 -0
  248. package/templates/tools/repo-manager.tool.yaml +64 -0
  249. package/templates/tools/search.tool.yaml +22 -0
  250. package/templates/tools/shell.tool.yaml +19 -0
  251. package/templates/tools/web-search.tool.yaml +24 -0
  252. package/tests/anonymization-middleware.test.ts +61 -0
  253. package/tests/anthropic.test.ts +87 -0
  254. package/tests/apply-patch.test.ts +355 -0
  255. package/tests/fixtures/tools/test-builtin.tool.yaml +14 -0
  256. package/tests/fixtures/tools/test-shell.tool.yaml +19 -0
  257. package/tests/mock-provider.test.ts +104 -0
  258. package/tests/openai.test.ts +72 -0
  259. package/tests/plugin-loader.test.ts +54 -0
  260. package/tests/prompt-builder.test.ts +468 -0
  261. package/tests/runner-anonymization.test.ts +89 -0
  262. package/tests/runner.test.ts +885 -0
  263. package/tests/studio-run.test.ts +94 -0
  264. package/tests/tool-executor.test.ts +115 -0
  265. package/tests/tool-registry.test.ts +84 -0
  266. package/tests/yaml-executor.test.ts +76 -0
  267. package/tsconfig.json +20 -0
  268. package/vitest.config.ts +7 -0
@@ -0,0 +1,885 @@
1
+ /**
2
+ * Runner tests with mock provider
3
+ */
4
+
5
+ import { describe, it, expect } from 'vitest';
6
+ import { runAgent } from '../src/runner.js';
7
+ import type { Provider } from '../src/providers/provider.js';
8
+ import type { LLMRequest, LLMResponse } from '@studio-foundation/contracts';
9
+ import { ProviderRegistry } from '../src/providers/registry.js';
10
+ import { ToolRegistry } from '../src/tools/tool-registry.js';
11
+
12
+ // Mock provider for testing
13
+ class MockProvider implements Provider {
14
+ readonly name = 'mock';
15
+ private responses: LLMResponse[];
16
+ private currentIndex = 0;
17
+
18
+ constructor(responses: LLMResponse[]) {
19
+ this.responses = responses;
20
+ }
21
+
22
+ async call(request: LLMRequest, _onToken?: (token: string) => void): Promise<LLMResponse> {
23
+ if (this.currentIndex >= this.responses.length) {
24
+ throw new Error('Mock provider ran out of responses');
25
+ }
26
+ return this.responses[this.currentIndex++];
27
+ }
28
+ }
29
+
30
+ // Provider that always returns a tool call (infinite loop simulation)
31
+ class InfiniteToolCallProvider implements Provider {
32
+ readonly name = 'mock';
33
+
34
+ async call(_request: LLMRequest, _onToken?: (token: string) => void): Promise<LLMResponse> {
35
+ return {
36
+ content: '',
37
+ tool_calls: [{ id: 'call-1', name: 'infinite_tool', arguments: {} }],
38
+ finish_reason: 'tool_calls',
39
+ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
40
+ };
41
+ }
42
+ }
43
+
44
+ describe('runAgent', () => {
45
+ it('should run a simple agent task without tools', async () => {
46
+ // Setup mock provider with a simple response
47
+ const mockProvider = new MockProvider([
48
+ {
49
+ content: '{"result": "Hello World"}',
50
+ tool_calls: [],
51
+ finish_reason: 'stop',
52
+ usage: {
53
+ prompt_tokens: 10,
54
+ completion_tokens: 5,
55
+ total_tokens: 15
56
+ }
57
+ }
58
+ ]);
59
+
60
+ const providerRegistry = new ProviderRegistry();
61
+ providerRegistry.register(mockProvider);
62
+
63
+ const toolRegistry = new ToolRegistry();
64
+
65
+ const result = await runAgent({
66
+ agent: {
67
+ name: 'test-agent',
68
+ provider: 'mock',
69
+ model: 'test-model'
70
+ },
71
+ task: {
72
+ description: 'Say hello'
73
+ },
74
+ context: {},
75
+ toolRegistry,
76
+ providerRegistry
77
+ });
78
+
79
+ expect(result.output).toEqual({ result: 'Hello World' });
80
+ expect(result.tool_calls_count).toBe(0);
81
+ expect(result.tool_calls).toEqual([]);
82
+ });
83
+
84
+ it('should execute tools in multi-turn conversation', async () => {
85
+ // Setup a tool
86
+ const toolRegistry = new ToolRegistry();
87
+ toolRegistry.register({
88
+ name: 'get_weather',
89
+ description: 'Get weather for a city',
90
+ parameters: {
91
+ type: 'object',
92
+ properties: { city: { type: 'string' } },
93
+ required: ['city'],
94
+ },
95
+ execute: async ({ city }) => ({
96
+ success: true,
97
+ output: { city, temperature: 20, condition: 'sunny' }
98
+ })
99
+ });
100
+
101
+ // Setup mock provider with tool call then final response
102
+ const mockProvider = new MockProvider([
103
+ // First response: make a tool call
104
+ {
105
+ content: '',
106
+ tool_calls: [
107
+ {
108
+ id: 'call-1',
109
+ name: 'get_weather',
110
+ arguments: { city: 'Paris' }
111
+ }
112
+ ],
113
+ finish_reason: 'tool_calls',
114
+ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }
115
+ },
116
+ // Second response: final answer
117
+ {
118
+ content: '{"result": "The weather in Paris is sunny, 20 degrees"}',
119
+ tool_calls: [],
120
+ finish_reason: 'stop',
121
+ usage: { prompt_tokens: 20, completion_tokens: 10, total_tokens: 30 }
122
+ }
123
+ ]);
124
+
125
+ const providerRegistry = new ProviderRegistry();
126
+ providerRegistry.register(mockProvider);
127
+
128
+ const result = await runAgent({
129
+ agent: {
130
+ name: 'test-agent',
131
+ provider: 'mock',
132
+ model: 'test-model'
133
+ },
134
+ task: {
135
+ description: 'Get weather for Paris'
136
+ },
137
+ context: {},
138
+ toolRegistry,
139
+ providerRegistry
140
+ });
141
+
142
+ expect(result.tool_calls_count).toBe(1);
143
+ expect(result.tool_calls[0].name).toBe('get_weather');
144
+ expect(result.tool_calls[0].result).toEqual({
145
+ city: 'Paris',
146
+ temperature: 20,
147
+ condition: 'sunny'
148
+ });
149
+ expect(result.output).toEqual({
150
+ result: 'The weather in Paris is sunny, 20 degrees'
151
+ });
152
+ });
153
+
154
+ it('should track multiple tool calls', async () => {
155
+ const toolRegistry = new ToolRegistry();
156
+ toolRegistry.register({
157
+ name: 'tool_a',
158
+ description: 'Tool A',
159
+ parameters: {},
160
+ execute: async () => ({ success: true, output: 'A result' })
161
+ });
162
+ toolRegistry.register({
163
+ name: 'tool_b',
164
+ description: 'Tool B',
165
+ parameters: {},
166
+ execute: async () => ({ success: true, output: 'B result' })
167
+ });
168
+
169
+ const mockProvider = new MockProvider([
170
+ {
171
+ content: '',
172
+ tool_calls: [
173
+ { id: 'call-1', name: 'tool_a', arguments: {} }
174
+ ],
175
+ finish_reason: 'tool_calls',
176
+ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }
177
+ },
178
+ {
179
+ content: '',
180
+ tool_calls: [
181
+ { id: 'call-2', name: 'tool_b', arguments: {} }
182
+ ],
183
+ finish_reason: 'tool_calls',
184
+ usage: { prompt_tokens: 15, completion_tokens: 5, total_tokens: 20 }
185
+ },
186
+ {
187
+ content: '"Done"',
188
+ tool_calls: [],
189
+ finish_reason: 'stop',
190
+ usage: { prompt_tokens: 20, completion_tokens: 3, total_tokens: 23 }
191
+ }
192
+ ]);
193
+
194
+ const providerRegistry = new ProviderRegistry();
195
+ providerRegistry.register(mockProvider);
196
+
197
+ const result = await runAgent({
198
+ agent: {
199
+ name: 'test-agent',
200
+ provider: 'mock',
201
+ model: 'test-model'
202
+ },
203
+ task: {
204
+ description: 'Run tools'
205
+ },
206
+ context: {},
207
+ toolRegistry,
208
+ providerRegistry
209
+ });
210
+
211
+ expect(result.tool_calls_count).toBe(2);
212
+ expect(result.tool_calls[0].name).toBe('tool_a');
213
+ expect(result.tool_calls[1].name).toBe('tool_b');
214
+ });
215
+
216
+ it('should track token usage from single response', async () => {
217
+ const mockProvider = new MockProvider([
218
+ {
219
+ content: '{"result": "ok"}',
220
+ tool_calls: [],
221
+ finish_reason: 'stop',
222
+ usage: { prompt_tokens: 100, completion_tokens: 50, total_tokens: 150 },
223
+ },
224
+ ]);
225
+
226
+ const providerRegistry = new ProviderRegistry();
227
+ providerRegistry.register(mockProvider);
228
+ const toolRegistry = new ToolRegistry();
229
+
230
+ const result = await runAgent({
231
+ agent: { name: 'test-agent', provider: 'mock', model: 'test-model' },
232
+ task: { description: 'Test tokens' },
233
+ context: {},
234
+ toolRegistry,
235
+ providerRegistry,
236
+ });
237
+
238
+ expect(result.token_usage).toEqual({
239
+ prompt_tokens: 100,
240
+ completion_tokens: 50,
241
+ total_tokens: 150,
242
+ });
243
+ });
244
+
245
+ it('should return an error result (not throw) at the custom maxToolCalls limit', async () => {
246
+ const toolRegistry = new ToolRegistry();
247
+ toolRegistry.register({
248
+ name: 'infinite_tool',
249
+ description: 'A tool that always runs',
250
+ parameters: {},
251
+ execute: async () => ({ success: true, output: 'running' }),
252
+ });
253
+
254
+ const providerRegistry = new ProviderRegistry();
255
+ providerRegistry.register(new InfiniteToolCallProvider());
256
+
257
+ const result = await runAgent({
258
+ agent: { name: 'test-agent', provider: 'mock', model: 'test-model' },
259
+ task: { description: 'Run forever' },
260
+ context: {},
261
+ toolRegistry,
262
+ providerRegistry,
263
+ maxToolCalls: 3,
264
+ });
265
+
266
+ expect(result.error).toBeDefined();
267
+ expect(result.error).toContain('Maximum tool calling iterations (3) reached');
268
+ expect(result.tool_calls).toHaveLength(3);
269
+ });
270
+
271
+ it('should accumulate token usage across multi-turn tool calls', async () => {
272
+ const toolRegistry = new ToolRegistry();
273
+ toolRegistry.register({
274
+ name: 'tool_a',
275
+ description: 'Tool A',
276
+ parameters: {},
277
+ execute: async () => ({ success: true, output: 'result' }),
278
+ });
279
+
280
+ const mockProvider = new MockProvider([
281
+ {
282
+ content: '',
283
+ tool_calls: [{ id: 'call-1', name: 'tool_a', arguments: {} }],
284
+ finish_reason: 'tool_calls',
285
+ usage: { prompt_tokens: 100, completion_tokens: 20, total_tokens: 120 },
286
+ },
287
+ {
288
+ content: '"done"',
289
+ tool_calls: [],
290
+ finish_reason: 'stop',
291
+ usage: { prompt_tokens: 150, completion_tokens: 30, total_tokens: 180 },
292
+ },
293
+ ]);
294
+
295
+ const providerRegistry = new ProviderRegistry();
296
+ providerRegistry.register(mockProvider);
297
+
298
+ const result = await runAgent({
299
+ agent: { name: 'test-agent', provider: 'mock', model: 'test-model' },
300
+ task: { description: 'Test accumulation' },
301
+ context: {},
302
+ toolRegistry,
303
+ providerRegistry,
304
+ });
305
+
306
+ expect(result.token_usage).toEqual({
307
+ prompt_tokens: 250,
308
+ completion_tokens: 50,
309
+ total_tokens: 300,
310
+ });
311
+ });
312
+ });
313
+
314
+ describe('runAgent — callbacks', () => {
315
+ it('calls onToolCallStart and onToolCallComplete for each tool call', async () => {
316
+ const toolRegistry = new ToolRegistry();
317
+ toolRegistry.register({
318
+ name: 'echo_tool',
319
+ description: 'Echoes input',
320
+ parameters: {
321
+ type: 'object',
322
+ properties: { msg: { type: 'string' } },
323
+ },
324
+ execute: async (args) => ({ success: true, output: args }),
325
+ });
326
+
327
+ const mockProvider = new MockProvider([
328
+ {
329
+ content: '',
330
+ tool_calls: [{ id: 'tc-1', name: 'echo_tool', arguments: { msg: 'hello' } }],
331
+ finish_reason: 'tool_calls',
332
+ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
333
+ },
334
+ {
335
+ content: '"done"',
336
+ tool_calls: [],
337
+ finish_reason: 'stop',
338
+ usage: { prompt_tokens: 20, completion_tokens: 5, total_tokens: 25 },
339
+ },
340
+ ]);
341
+
342
+ const providerRegistry = new ProviderRegistry();
343
+ providerRegistry.register(mockProvider);
344
+
345
+ const startEvents: import('@studio-foundation/contracts').ToolCallStartEvent[] = [];
346
+ const completeEvents: import('@studio-foundation/contracts').ToolCallCompleteEvent[] = [];
347
+
348
+ await runAgent({
349
+ agent: { name: 'test-agent', provider: 'mock', model: 'test-model' },
350
+ task: { description: 'Test callbacks' },
351
+ context: {},
352
+ toolRegistry,
353
+ providerRegistry,
354
+ callbacks: {
355
+ onToolCallStart: (e) => startEvents.push(e),
356
+ onToolCallComplete: (e) => completeEvents.push(e),
357
+ },
358
+ });
359
+
360
+ expect(startEvents).toHaveLength(1);
361
+ expect(startEvents[0].tool).toBe('echo_tool');
362
+ expect(startEvents[0].params).toEqual({ msg: 'hello' });
363
+ expect(startEvents[0].timestamp).toBeTypeOf('number');
364
+
365
+ expect(completeEvents).toHaveLength(1);
366
+ expect(completeEvents[0].tool).toBe('echo_tool');
367
+ expect(completeEvents[0].result).toEqual({ msg: 'hello' });
368
+ expect(completeEvents[0].error).toBeUndefined();
369
+ expect(completeEvents[0].duration_ms).toBeGreaterThanOrEqual(0);
370
+ });
371
+
372
+ it('includes error in onToolCallComplete when tool fails', async () => {
373
+ const toolRegistry = new ToolRegistry();
374
+ toolRegistry.register({
375
+ name: 'broken_tool',
376
+ description: 'Always fails',
377
+ parameters: {},
378
+ execute: async () => ({ success: false, error: 'something went wrong' }),
379
+ });
380
+
381
+ const mockProvider = new MockProvider([
382
+ {
383
+ content: '',
384
+ tool_calls: [{ id: 'tc-1', name: 'broken_tool', arguments: {} }],
385
+ finish_reason: 'tool_calls',
386
+ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
387
+ },
388
+ {
389
+ content: '"recovered"',
390
+ tool_calls: [],
391
+ finish_reason: 'stop',
392
+ usage: { prompt_tokens: 20, completion_tokens: 5, total_tokens: 25 },
393
+ },
394
+ ]);
395
+
396
+ const providerRegistry = new ProviderRegistry();
397
+ providerRegistry.register(mockProvider);
398
+
399
+ const completeEvents: import('@studio-foundation/contracts').ToolCallCompleteEvent[] = [];
400
+
401
+ await runAgent({
402
+ agent: { name: 'test-agent', provider: 'mock', model: 'test-model' },
403
+ task: { description: 'Test error callback' },
404
+ context: {},
405
+ toolRegistry,
406
+ providerRegistry,
407
+ callbacks: {
408
+ onToolCallComplete: (e) => completeEvents.push(e),
409
+ },
410
+ });
411
+
412
+ expect(completeEvents[0].error).toBe('something went wrong');
413
+ expect(completeEvents[0].result).toBeUndefined();
414
+ });
415
+
416
+ it('calls callbacks in agent-loop provider path', async () => {
417
+ // AgentLoopProvider mock — owns the full loop and calls executeTool
418
+ const agentLoopProvider: import('../src/providers/provider.js').AgentLoopProvider = {
419
+ name: 'mock-loop',
420
+ call: async () => { throw new Error('not used'); },
421
+ runAgentLoop: async (_req, executeTool) => {
422
+ const outcome = await executeTool('loop_tool', { x: 1 }, 'call-loop-1');
423
+ return {
424
+ content: '"loop done"',
425
+ tool_calls: [{ id: 'call-loop-1', name: 'loop_tool', arguments: { x: 1 }, ...outcome }],
426
+ finish_reason: 'stop',
427
+ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
428
+ };
429
+ },
430
+ };
431
+
432
+ const toolRegistry = new ToolRegistry();
433
+ toolRegistry.register({
434
+ name: 'loop_tool',
435
+ description: 'Loop path tool',
436
+ parameters: {
437
+ type: 'object',
438
+ properties: { x: { type: 'number' } },
439
+ },
440
+ execute: async (args) => ({ success: true, output: args }),
441
+ });
442
+
443
+ const providerRegistry = new ProviderRegistry();
444
+ providerRegistry.register(agentLoopProvider);
445
+
446
+ const startEvents: import('@studio-foundation/contracts').ToolCallStartEvent[] = [];
447
+ const completeEvents: import('@studio-foundation/contracts').ToolCallCompleteEvent[] = [];
448
+
449
+ await runAgent({
450
+ agent: { name: 'test-agent', provider: 'mock-loop', model: 'test-model' },
451
+ task: { description: 'Test loop callbacks' },
452
+ context: {},
453
+ toolRegistry,
454
+ providerRegistry,
455
+ callbacks: {
456
+ onToolCallStart: (e) => startEvents.push(e),
457
+ onToolCallComplete: (e) => completeEvents.push(e),
458
+ },
459
+ });
460
+
461
+ expect(startEvents).toHaveLength(1);
462
+ expect(startEvents[0].tool).toBe('loop_tool');
463
+ expect(startEvents[0].params).toEqual({ x: 1 });
464
+ expect(startEvents[0].timestamp).toBeTypeOf('number');
465
+ expect(completeEvents).toHaveLength(1);
466
+ expect(completeEvents[0].tool).toBe('loop_tool');
467
+ expect(completeEvents[0].result).toEqual({ x: 1 });
468
+ expect(completeEvents[0].error).toBeUndefined();
469
+ expect(completeEvents[0].duration_ms).toBeGreaterThanOrEqual(0);
470
+ });
471
+
472
+ it('emits onAgentThinking when first-turn LLM text accompanies tool calls', async () => {
473
+ const toolRegistry = new ToolRegistry();
474
+ toolRegistry.register({
475
+ name: 'fetch_data',
476
+ description: 'Fetches data',
477
+ parameters: {},
478
+ execute: async () => ({ success: true, output: 'data' }),
479
+ });
480
+
481
+ const mockProvider = new MockProvider([
482
+ {
483
+ content: 'Let me fetch the data for you.',
484
+ tool_calls: [{ id: 'tc-1', name: 'fetch_data', arguments: {} }],
485
+ finish_reason: 'tool_calls',
486
+ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
487
+ },
488
+ {
489
+ content: '"done"',
490
+ tool_calls: [],
491
+ finish_reason: 'stop',
492
+ usage: { prompt_tokens: 20, completion_tokens: 5, total_tokens: 25 },
493
+ },
494
+ ]);
495
+
496
+ const providerRegistry = new ProviderRegistry();
497
+ providerRegistry.register(mockProvider);
498
+
499
+ const thinkingEvents: import('@studio-foundation/contracts').AgentThinkingEvent[] = [];
500
+ const progressEvents: import('@studio-foundation/contracts').AgentProgressEvent[] = [];
501
+
502
+ await runAgent({
503
+ agent: { name: 'test-agent', provider: 'mock', model: 'test-model' },
504
+ task: { description: 'Fetch data' },
505
+ context: {},
506
+ toolRegistry,
507
+ providerRegistry,
508
+ callbacks: {
509
+ onAgentThinking: (e) => thinkingEvents.push(e),
510
+ onAgentProgress: (e) => progressEvents.push(e),
511
+ },
512
+ });
513
+
514
+ expect(thinkingEvents).toHaveLength(1);
515
+ expect(thinkingEvents[0].thought).toBe('Let me fetch the data for you.');
516
+ expect(thinkingEvents[0].timestamp).toBeTypeOf('number');
517
+ expect(progressEvents).toHaveLength(0);
518
+ });
519
+
520
+ it('emits onAgentProgress (not onAgentThinking) for text in subsequent turns', async () => {
521
+ const toolRegistry = new ToolRegistry();
522
+ toolRegistry.register({
523
+ name: 'step_a',
524
+ description: 'Step A',
525
+ parameters: {},
526
+ execute: async () => ({ success: true, output: 'a' }),
527
+ });
528
+ toolRegistry.register({
529
+ name: 'step_b',
530
+ description: 'Step B',
531
+ parameters: {},
532
+ execute: async () => ({ success: true, output: 'b' }),
533
+ });
534
+
535
+ const mockProvider = new MockProvider([
536
+ // Turn 0: thinking text + first tool call
537
+ {
538
+ content: 'Starting with step A.',
539
+ tool_calls: [{ id: 'tc-1', name: 'step_a', arguments: {} }],
540
+ finish_reason: 'tool_calls',
541
+ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
542
+ },
543
+ // Turn 1: progress text + second tool call
544
+ {
545
+ content: 'Now moving to step B.',
546
+ tool_calls: [{ id: 'tc-2', name: 'step_b', arguments: {} }],
547
+ finish_reason: 'tool_calls',
548
+ usage: { prompt_tokens: 20, completion_tokens: 8, total_tokens: 28 },
549
+ },
550
+ // Final
551
+ {
552
+ content: '"complete"',
553
+ tool_calls: [],
554
+ finish_reason: 'stop',
555
+ usage: { prompt_tokens: 30, completion_tokens: 5, total_tokens: 35 },
556
+ },
557
+ ]);
558
+
559
+ const providerRegistry = new ProviderRegistry();
560
+ providerRegistry.register(mockProvider);
561
+
562
+ const thinkingEvents: import('@studio-foundation/contracts').AgentThinkingEvent[] = [];
563
+ const progressEvents: import('@studio-foundation/contracts').AgentProgressEvent[] = [];
564
+
565
+ await runAgent({
566
+ agent: { name: 'test-agent', provider: 'mock', model: 'test-model' },
567
+ task: { description: 'Two-step task' },
568
+ context: {},
569
+ toolRegistry,
570
+ providerRegistry,
571
+ callbacks: {
572
+ onAgentThinking: (e) => thinkingEvents.push(e),
573
+ onAgentProgress: (e) => progressEvents.push(e),
574
+ },
575
+ });
576
+
577
+ expect(thinkingEvents).toHaveLength(1);
578
+ expect(thinkingEvents[0].thought).toBe('Starting with step A.');
579
+
580
+ expect(progressEvents).toHaveLength(1);
581
+ expect(progressEvents[0].message).toBe('Now moving to step B.');
582
+ });
583
+
584
+ it('does not emit thinking/progress when LLM text is empty alongside tool calls', async () => {
585
+ const toolRegistry = new ToolRegistry();
586
+ toolRegistry.register({
587
+ name: 'silent_tool',
588
+ description: 'Silent tool',
589
+ parameters: {},
590
+ execute: async () => ({ success: true, output: 'done' }),
591
+ });
592
+
593
+ const mockProvider = new MockProvider([
594
+ {
595
+ content: '',
596
+ tool_calls: [{ id: 'tc-1', name: 'silent_tool', arguments: {} }],
597
+ finish_reason: 'tool_calls',
598
+ usage: { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12 },
599
+ },
600
+ {
601
+ content: '"ok"',
602
+ tool_calls: [],
603
+ finish_reason: 'stop',
604
+ usage: { prompt_tokens: 15, completion_tokens: 3, total_tokens: 18 },
605
+ },
606
+ ]);
607
+
608
+ const providerRegistry = new ProviderRegistry();
609
+ providerRegistry.register(mockProvider);
610
+
611
+ const thinkingEvents: import('@studio-foundation/contracts').AgentThinkingEvent[] = [];
612
+
613
+ await runAgent({
614
+ agent: { name: 'test-agent', provider: 'mock', model: 'test-model' },
615
+ task: { description: 'Silent' },
616
+ context: {},
617
+ toolRegistry,
618
+ providerRegistry,
619
+ callbacks: { onAgentThinking: (e) => thinkingEvents.push(e) },
620
+ });
621
+
622
+ expect(thinkingEvents).toHaveLength(0);
623
+ });
624
+
625
+ it('should propagate onAgentToken when provider emits tokens', async () => {
626
+ const receivedTokens: string[] = [];
627
+
628
+ class TokenStreamingProvider implements Provider {
629
+ readonly name = 'mock';
630
+ async call(_request: LLMRequest, onToken?: (token: string) => void): Promise<LLMResponse> {
631
+ onToken?.('Hello');
632
+ onToken?.(' world');
633
+ return {
634
+ content: '{"result": "streamed"}',
635
+ tool_calls: [],
636
+ finish_reason: 'stop',
637
+ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
638
+ };
639
+ }
640
+ }
641
+
642
+ const providerRegistry = new ProviderRegistry();
643
+ providerRegistry.register(new TokenStreamingProvider());
644
+ const toolRegistry = new ToolRegistry();
645
+
646
+ await runAgent({
647
+ agent: { name: 'test-agent', provider: 'mock', model: 'test-model' },
648
+ task: { description: 'stream test' },
649
+ context: {},
650
+ toolRegistry,
651
+ providerRegistry,
652
+ callbacks: {
653
+ onAgentToken: (event) => receivedTokens.push(event.token),
654
+ },
655
+ });
656
+
657
+ expect(receivedTokens).toEqual(['Hello', ' world']);
658
+ });
659
+
660
+ it('works fine when no callbacks are provided', async () => {
661
+ const mockProvider = new MockProvider([
662
+ {
663
+ content: '"ok"',
664
+ tool_calls: [],
665
+ finish_reason: 'stop',
666
+ usage: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 },
667
+ },
668
+ ]);
669
+ const providerRegistry = new ProviderRegistry();
670
+ providerRegistry.register(mockProvider);
671
+
672
+ await expect(
673
+ runAgent({
674
+ agent: { name: 'test-agent', provider: 'mock', model: 'test-model' },
675
+ task: { description: 'No callbacks' },
676
+ context: {},
677
+ toolRegistry: new ToolRegistry(),
678
+ providerRegistry,
679
+ })
680
+ ).resolves.toBeDefined();
681
+ });
682
+ });
683
+
684
+ describe('runAgent — abort signal', () => {
685
+ it('throws AbortError when signal is aborted before LLM call', async () => {
686
+ const controller = new AbortController();
687
+ controller.abort();
688
+
689
+ const mockProvider = new MockProvider([
690
+ {
691
+ content: '"ok"',
692
+ tool_calls: [],
693
+ finish_reason: 'stop',
694
+ usage: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 },
695
+ },
696
+ ]);
697
+ const providerRegistry = new ProviderRegistry();
698
+ providerRegistry.register(mockProvider);
699
+
700
+ await expect(
701
+ runAgent({
702
+ agent: { name: 'test-agent', provider: 'mock', model: 'test-model' },
703
+ task: { description: 'test' },
704
+ context: {},
705
+ toolRegistry: new ToolRegistry(),
706
+ providerRegistry,
707
+ signal: controller.signal,
708
+ })
709
+ ).rejects.toThrow();
710
+ });
711
+
712
+ it('throws when signal aborts between tool call turns', async () => {
713
+ const controller = new AbortController();
714
+
715
+ const toolRegistry = new ToolRegistry();
716
+ toolRegistry.register({
717
+ name: 'test_tool',
718
+ description: 'Test',
719
+ parameters: {},
720
+ execute: async () => {
721
+ controller.abort();
722
+ return { success: true, output: 'done' };
723
+ },
724
+ });
725
+
726
+ const mockProvider = new MockProvider([
727
+ {
728
+ content: '',
729
+ tool_calls: [{ id: 'tc1', name: 'test_tool', arguments: {} }],
730
+ finish_reason: 'tool_use',
731
+ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
732
+ },
733
+ {
734
+ content: '"ok"',
735
+ tool_calls: [],
736
+ finish_reason: 'stop',
737
+ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
738
+ },
739
+ ]);
740
+ const providerRegistry = new ProviderRegistry();
741
+ providerRegistry.register(mockProvider);
742
+
743
+ await expect(
744
+ runAgent({
745
+ agent: { name: 'test-agent', provider: 'mock', model: 'test-model' },
746
+ task: { description: 'test' },
747
+ context: {},
748
+ toolRegistry,
749
+ providerRegistry,
750
+ signal: controller.signal,
751
+ })
752
+ ).rejects.toThrow();
753
+ });
754
+ });
755
+
756
+ describe('runAgent — tool_calls_count semantics', () => {
757
+ it('excludes failed tool calls from tool_calls_count (standard path)', async () => {
758
+ const toolRegistry = new ToolRegistry();
759
+ toolRegistry.register({
760
+ name: 'broken_tool',
761
+ description: 'Always fails',
762
+ parameters: {},
763
+ execute: async () => ({ success: false, error: 'tool error' }),
764
+ });
765
+
766
+ const mockProvider = new MockProvider([
767
+ {
768
+ content: '',
769
+ tool_calls: [{ id: 'tc-1', name: 'broken_tool', arguments: {} }],
770
+ finish_reason: 'tool_calls',
771
+ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
772
+ },
773
+ {
774
+ content: '"recovered"',
775
+ tool_calls: [],
776
+ finish_reason: 'stop',
777
+ usage: { prompt_tokens: 20, completion_tokens: 5, total_tokens: 25 },
778
+ },
779
+ ]);
780
+
781
+ const providerRegistry = new ProviderRegistry();
782
+ providerRegistry.register(mockProvider);
783
+
784
+ const result = await runAgent({
785
+ agent: { name: 'test-agent', provider: 'mock', model: 'test-model' },
786
+ task: { description: 'test failed tool' },
787
+ context: {},
788
+ toolRegistry,
789
+ providerRegistry,
790
+ });
791
+
792
+ // tool_calls still records the attempt
793
+ expect(result.tool_calls).toHaveLength(1);
794
+ expect(result.tool_calls[0].error).toBe('tool error');
795
+ // tool_calls_count only counts successful calls
796
+ expect(result.tool_calls_count).toBe(0);
797
+ });
798
+
799
+ it('counts only successful calls in a mixed-result batch (standard path)', async () => {
800
+ const toolRegistry = new ToolRegistry();
801
+ toolRegistry.register({
802
+ name: 'good_tool',
803
+ description: 'Succeeds',
804
+ parameters: {},
805
+ execute: async () => ({ success: true, output: 'ok' }),
806
+ });
807
+ toolRegistry.register({
808
+ name: 'bad_tool',
809
+ description: 'Fails',
810
+ parameters: {},
811
+ execute: async () => ({ success: false, error: 'bad' }),
812
+ });
813
+
814
+ const mockProvider = new MockProvider([
815
+ {
816
+ content: '',
817
+ tool_calls: [
818
+ { id: 'tc-1', name: 'good_tool', arguments: {} },
819
+ { id: 'tc-2', name: 'bad_tool', arguments: {} },
820
+ ],
821
+ finish_reason: 'tool_calls',
822
+ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
823
+ },
824
+ {
825
+ content: '"done"',
826
+ tool_calls: [],
827
+ finish_reason: 'stop',
828
+ usage: { prompt_tokens: 20, completion_tokens: 5, total_tokens: 25 },
829
+ },
830
+ ]);
831
+
832
+ const providerRegistry = new ProviderRegistry();
833
+ providerRegistry.register(mockProvider);
834
+
835
+ const result = await runAgent({
836
+ agent: { name: 'test-agent', provider: 'mock', model: 'test-model' },
837
+ task: { description: 'mixed tools' },
838
+ context: {},
839
+ toolRegistry,
840
+ providerRegistry,
841
+ });
842
+
843
+ expect(result.tool_calls).toHaveLength(2);
844
+ expect(result.tool_calls_count).toBe(1); // only good_tool succeeded
845
+ });
846
+
847
+ it('excludes failed tool calls from tool_calls_count (agent-loop path)', async () => {
848
+ const agentLoopProvider: import('../src/providers/provider.js').AgentLoopProvider = {
849
+ name: 'mock-loop',
850
+ call: async () => { throw new Error('not used'); },
851
+ runAgentLoop: async (_req, executeTool) => {
852
+ const outcome = await executeTool('failing_loop_tool', {}, 'call-fail-1');
853
+ return {
854
+ content: '"recovered"',
855
+ tool_calls: [{ id: 'call-fail-1', name: 'failing_loop_tool', arguments: {}, ...outcome }],
856
+ finish_reason: 'stop',
857
+ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
858
+ };
859
+ },
860
+ };
861
+
862
+ const toolRegistry = new ToolRegistry();
863
+ toolRegistry.register({
864
+ name: 'failing_loop_tool',
865
+ description: 'Always fails in loop',
866
+ parameters: {},
867
+ execute: async () => ({ success: false, error: 'loop tool failed' }),
868
+ });
869
+
870
+ const providerRegistry = new ProviderRegistry();
871
+ providerRegistry.register(agentLoopProvider);
872
+
873
+ const result = await runAgent({
874
+ agent: { name: 'test-agent', provider: 'mock-loop', model: 'test-model' },
875
+ task: { description: 'test loop fail' },
876
+ context: {},
877
+ toolRegistry,
878
+ providerRegistry,
879
+ });
880
+
881
+ expect(result.tool_calls).toHaveLength(1);
882
+ expect(result.tool_calls[0].error).toBe('loop tool failed');
883
+ expect(result.tool_calls_count).toBe(0);
884
+ });
885
+ });