@studio-foundation/runner 0.3.0-beta.1 → 0.3.0-beta.6

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 (78) hide show
  1. package/package.json +6 -3
  2. package/ARCHITECTURE.md +0 -53
  3. package/configs/agents/analyst.agent.yaml +0 -31
  4. package/configs/agents/code-generator.agent.yaml +0 -31
  5. package/configs/agents/generic.agent.yaml +0 -23
  6. package/src/__tests__/script-executor.test.ts +0 -180
  7. package/src/index.ts +0 -68
  8. package/src/integrations/integration-loader.test.ts +0 -88
  9. package/src/integrations/integration-loader.ts +0 -68
  10. package/src/middleware/anonymization.ts +0 -38
  11. package/src/plugins/index.ts +0 -4
  12. package/src/plugins/mcp-client.test.ts +0 -148
  13. package/src/plugins/mcp-client.ts +0 -128
  14. package/src/plugins/oauth-provider.test.ts +0 -167
  15. package/src/plugins/oauth-provider.ts +0 -175
  16. package/src/plugins/plugin-loader.test.ts +0 -114
  17. package/src/plugins/plugin-loader.ts +0 -90
  18. package/src/prompt-builder.test.ts +0 -167
  19. package/src/prompt-builder.ts +0 -332
  20. package/src/providers/anthropic.test.ts +0 -101
  21. package/src/providers/anthropic.ts +0 -135
  22. package/src/providers/mock.ts +0 -57
  23. package/src/providers/ollama.test.ts +0 -166
  24. package/src/providers/ollama.ts +0 -152
  25. package/src/providers/openai-responses.ts +0 -212
  26. package/src/providers/openai.test.ts +0 -67
  27. package/src/providers/openai.ts +0 -139
  28. package/src/providers/provider.ts +0 -54
  29. package/src/providers/registry.ts +0 -77
  30. package/src/runner.test.ts +0 -343
  31. package/src/runner.ts +0 -396
  32. package/src/script-executor.ts +0 -107
  33. package/src/tools/builtin/git.ts +0 -311
  34. package/src/tools/builtin/patch.ts +0 -257
  35. package/src/tools/builtin/repo-manager.ts +0 -142
  36. package/src/tools/builtin/search.ts +0 -108
  37. package/src/tools/builtin/shell.ts +0 -82
  38. package/src/tools/builtin/studio-run.ts +0 -73
  39. package/src/tools/builtin/web-search.test.ts +0 -122
  40. package/src/tools/builtin/web-search.ts +0 -101
  41. package/src/tools/errors.test.ts +0 -12
  42. package/src/tools/errors.ts +0 -6
  43. package/src/tools/plugin-loader.test.ts +0 -130
  44. package/src/tools/plugin-loader.ts +0 -203
  45. package/src/tools/skills/README.md +0 -49
  46. package/src/tools/skills/skill-loader.test.ts +0 -106
  47. package/src/tools/skills/skill-loader.ts +0 -62
  48. package/src/tools/tool-executor.test.ts +0 -88
  49. package/src/tools/tool-executor.ts +0 -84
  50. package/src/tools/tool-registry.ts +0 -130
  51. package/src/tools/yaml-executor.ts +0 -120
  52. package/src/utils/race-signal.test.ts +0 -50
  53. package/src/utils/race-signal.ts +0 -17
  54. package/templates/integrations/linear.integration.yaml +0 -35
  55. package/templates/integrations/slack.integration.yaml +0 -22
  56. package/templates/integrations/webhook.integration.yaml +0 -17
  57. package/templates/tools/git.tool.yaml +0 -80
  58. package/templates/tools/repo-manager.tool.yaml +0 -64
  59. package/templates/tools/search.tool.yaml +0 -22
  60. package/templates/tools/shell.tool.yaml +0 -19
  61. package/templates/tools/web-search.tool.yaml +0 -24
  62. package/tests/anonymization-middleware.test.ts +0 -61
  63. package/tests/anthropic.test.ts +0 -87
  64. package/tests/apply-patch.test.ts +0 -355
  65. package/tests/fixtures/tools/test-builtin.tool.yaml +0 -14
  66. package/tests/fixtures/tools/test-shell.tool.yaml +0 -19
  67. package/tests/mock-provider.test.ts +0 -104
  68. package/tests/openai.test.ts +0 -72
  69. package/tests/plugin-loader.test.ts +0 -54
  70. package/tests/prompt-builder.test.ts +0 -468
  71. package/tests/runner-anonymization.test.ts +0 -89
  72. package/tests/runner.test.ts +0 -885
  73. package/tests/studio-run.test.ts +0 -94
  74. package/tests/tool-executor.test.ts +0 -115
  75. package/tests/tool-registry.test.ts +0 -84
  76. package/tests/yaml-executor.test.ts +0 -76
  77. package/tsconfig.json +0 -20
  78. package/vitest.config.ts +0 -7
@@ -1,166 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { OllamaProvider } from './ollama.js';
3
-
4
- // Capture constructor config so we can assert on baseURL
5
- let capturedConfig: Record<string, unknown> = {};
6
- const createMock = vi.fn();
7
-
8
- vi.mock('openai', () => ({
9
- default: class FakeOpenAI {
10
- chat = { completions: { create: createMock } };
11
- constructor(config: Record<string, unknown>) {
12
- capturedConfig = config;
13
- }
14
- },
15
- }));
16
-
17
- beforeEach(() => {
18
- vi.clearAllMocks();
19
- capturedConfig = {};
20
- });
21
-
22
- describe('OllamaProvider', () => {
23
- it('has name "ollama"', () => {
24
- const provider = new OllamaProvider();
25
- expect(provider.name).toBe('ollama');
26
- });
27
-
28
- it('passes correct baseURL to SDK (default)', () => {
29
- new OllamaProvider();
30
- expect(capturedConfig.baseURL).toBe('http://localhost:11434/v1');
31
- expect(capturedConfig.apiKey).toBe('ollama');
32
- });
33
-
34
- it('passes custom baseURL to SDK', () => {
35
- new OllamaProvider('http://my-server:11434');
36
- expect(capturedConfig.baseURL).toBe('http://my-server:11434/v1');
37
- });
38
-
39
- it('returns content and tool calls on non-streaming call', async () => {
40
- createMock.mockResolvedValueOnce({
41
- choices: [{
42
- message: {
43
- content: '{"result":"ok"}',
44
- tool_calls: [{
45
- id: 'call_1',
46
- function: { name: 'repo_manager-read_file', arguments: '{"path":"src/foo.ts"}' },
47
- }],
48
- },
49
- finish_reason: 'tool_calls',
50
- }],
51
- usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
52
- });
53
-
54
- const provider = new OllamaProvider();
55
- const result = await provider.call({
56
- model: 'llama3.2',
57
- messages: [{ role: 'user', content: 'hello' }],
58
- });
59
-
60
- expect(result.content).toBe('{"result":"ok"}');
61
- expect(result.tool_calls).toHaveLength(1);
62
- expect(result.tool_calls![0].name).toBe('repo_manager-read_file');
63
- expect(result.tool_calls![0].arguments).toEqual({ path: 'src/foo.ts' });
64
- expect(result.usage?.total_tokens).toBe(30);
65
- });
66
-
67
- it('streaming: does NOT send stream_options', async () => {
68
- async function* fakeStream() {
69
- yield { choices: [{ delta: { content: 'hi' }, finish_reason: null }] };
70
- yield { choices: [{ delta: {}, finish_reason: 'stop' }] };
71
- }
72
- createMock.mockReturnValueOnce(fakeStream());
73
-
74
- const provider = new OllamaProvider();
75
- await provider.call(
76
- { model: 'llama3.2', messages: [{ role: 'user', content: 'hi' }] },
77
- () => {},
78
- );
79
-
80
- const callArgs = createMock.mock.calls[0][0] as Record<string, unknown>;
81
- expect(callArgs.stream_options).toBeUndefined();
82
- expect(callArgs.stream).toBe(true);
83
- });
84
-
85
- it('streaming: accumulates content and calls onToken', async () => {
86
- async function* fakeStream() {
87
- yield { choices: [{ delta: { content: 'hello' }, finish_reason: null }] };
88
- yield { choices: [{ delta: { content: ' world' }, finish_reason: 'stop' }] };
89
- }
90
- createMock.mockReturnValueOnce(fakeStream());
91
-
92
- const provider = new OllamaProvider();
93
- const tokens: string[] = [];
94
- const result = await provider.call(
95
- { model: 'llama3.2', messages: [{ role: 'user', content: 'hi' }] },
96
- (t) => tokens.push(t),
97
- );
98
-
99
- expect(result.content).toBe('hello world');
100
- expect(tokens).toEqual(['hello', ' world']);
101
- });
102
-
103
- it('wraps ECONNREFUSED with a helpful message', async () => {
104
- const connErr = Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:11434'), {
105
- code: 'ECONNREFUSED',
106
- });
107
- createMock.mockRejectedValueOnce(connErr);
108
-
109
- const provider = new OllamaProvider();
110
- await expect(
111
- provider.call({ model: 'llama3.2', messages: [{ role: 'user', content: 'hi' }] }),
112
- ).rejects.toThrow('Ollama is not running at http://localhost:11434');
113
- });
114
-
115
- it('wraps ECONNREFUSED from error.cause too', async () => {
116
- const cause = Object.assign(new Error('connect ECONNREFUSED'), { code: 'ECONNREFUSED' });
117
- const wrappedErr = new Error('fetch failed');
118
- (wrappedErr as Error & { cause: unknown }).cause = cause;
119
- createMock.mockRejectedValueOnce(wrappedErr);
120
-
121
- const provider = new OllamaProvider();
122
- await expect(
123
- provider.call({ model: 'llama3.2', messages: [{ role: 'user', content: 'hi' }] }),
124
- ).rejects.toThrow('ollama serve');
125
- });
126
-
127
- it('streaming: usage is undefined (Ollama does not return usage in streaming)', async () => {
128
- async function* fakeStream() {
129
- yield { choices: [{ delta: { content: 'hi' }, finish_reason: 'stop' }] };
130
- }
131
- createMock.mockReturnValueOnce(fakeStream());
132
-
133
- const provider = new OllamaProvider();
134
- const result = await provider.call(
135
- { model: 'llama3.2', messages: [{ role: 'user', content: 'hi' }] },
136
- () => {},
137
- );
138
-
139
- expect(result.usage).toBeUndefined();
140
- });
141
-
142
- it('aborts streaming when signal fires mid-iteration', async () => {
143
- async function* slowStream() {
144
- await new Promise((r) => setTimeout(r, 20));
145
- yield { choices: [{ delta: { content: 'hello' }, finish_reason: null }] };
146
- await new Promise((r) => setTimeout(r, 20));
147
- yield { choices: [{ delta: { content: ' world' }, finish_reason: 'stop' }] };
148
- }
149
- createMock.mockReturnValueOnce(slowStream());
150
-
151
- const provider = new OllamaProvider();
152
- const controller = new AbortController();
153
-
154
- const callPromise = provider.call(
155
- { model: 'llama3.2', messages: [{ role: 'user', content: 'hi' }] },
156
- () => {},
157
- controller.signal,
158
- );
159
-
160
- setTimeout(() => controller.abort(), 10);
161
-
162
- await expect(callPromise).rejects.toSatisfy(
163
- (e: unknown) => e instanceof DOMException && (e as DOMException).name === 'AbortError',
164
- );
165
- });
166
- });
@@ -1,152 +0,0 @@
1
- /**
2
- * Ollama provider — OpenAI-compatible local LLM server
3
- * Uses the openai npm SDK pointed at Ollama's /v1 endpoint.
4
- */
5
-
6
- import type { LLMRequest, LLMResponse } from '@studio-foundation/contracts';
7
- import type { Provider } from './provider.js';
8
- import OpenAI from 'openai';
9
- import type { ChatCompletionMessageParam, ChatCompletionTool, ChatCompletionChunk } from 'openai/resources/chat/completions';
10
-
11
- function isConnectionRefused(err: unknown): boolean {
12
- if (!(err instanceof Error)) return false;
13
- if ('code' in err && (err as NodeJS.ErrnoException).code === 'ECONNREFUSED') return true;
14
- const cause = (err as Error & { cause?: unknown }).cause;
15
- if (cause instanceof Error && 'code' in cause && (cause as NodeJS.ErrnoException).code === 'ECONNREFUSED') return true;
16
- if (err.message.includes('ECONNREFUSED')) return true;
17
- return false;
18
- }
19
-
20
- export class OllamaProvider implements Provider {
21
- readonly name = 'ollama';
22
- private client: OpenAI;
23
- private baseUrl: string;
24
-
25
- constructor(baseUrl = 'http://localhost:11434') {
26
- this.baseUrl = baseUrl;
27
- this.client = new OpenAI({
28
- baseURL: `${baseUrl}/v1`,
29
- apiKey: 'ollama', // required by SDK, ignored by Ollama
30
- });
31
- }
32
-
33
- async call(request: LLMRequest, onToken?: (token: string) => void, signal?: AbortSignal): Promise<LLMResponse> {
34
- try {
35
- if (onToken) {
36
- return await this.callStreaming(request, onToken, signal);
37
- }
38
- return await this.callNonStreaming(request, signal);
39
- } catch (err) {
40
- if (isConnectionRefused(err)) {
41
- throw new Error(
42
- `Ollama is not running at ${this.baseUrl}. Start it with: ollama serve`
43
- );
44
- }
45
- throw err;
46
- }
47
- }
48
-
49
- private async callNonStreaming(request: LLMRequest, signal?: AbortSignal): Promise<LLMResponse> {
50
- const completion = await this.client.chat.completions.create({
51
- model: request.model,
52
- messages: this.buildMessages(request),
53
- tools: this.buildTools(request),
54
- temperature: request.temperature,
55
- max_tokens: request.max_tokens,
56
- response_format: request.json_mode ? { type: 'json_object' } : undefined,
57
- }, { signal });
58
-
59
- const choice = completion.choices[0];
60
- if (!choice) throw new Error('Ollama returned an empty choices array');
61
- const tool_calls = choice.message.tool_calls?.map(tc => ({
62
- id: tc.id,
63
- name: tc.function.name,
64
- arguments: (() => {
65
- try { return JSON.parse(tc.function.arguments); }
66
- catch { return {}; }
67
- })(),
68
- })) || [];
69
-
70
- return {
71
- content: choice.message.content || '',
72
- tool_calls,
73
- finish_reason: choice.finish_reason ?? 'stop',
74
- usage: completion.usage ? {
75
- prompt_tokens: completion.usage.prompt_tokens,
76
- completion_tokens: completion.usage.completion_tokens,
77
- total_tokens: completion.usage.total_tokens,
78
- } : undefined,
79
- };
80
- }
81
-
82
- private async callStreaming(
83
- request: LLMRequest,
84
- onToken: (token: string) => void,
85
- signal?: AbortSignal,
86
- ): Promise<LLMResponse> {
87
- // NOTE: No stream_options here — older Ollama versions reject it
88
- const stream = this.client.chat.completions.create({
89
- model: request.model,
90
- messages: this.buildMessages(request),
91
- tools: this.buildTools(request),
92
- temperature: request.temperature,
93
- max_tokens: request.max_tokens,
94
- response_format: request.json_mode ? { type: 'json_object' } : undefined,
95
- stream: true as const,
96
- }, { signal }) as unknown as AsyncIterable<ChatCompletionChunk>;
97
-
98
- let textContent = '';
99
- const toolCallMap = new Map<number, { id: string; name: string; args: string }>();
100
- let finishReason = 'stop';
101
-
102
- for await (const chunk of stream) {
103
- if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
104
- const delta = chunk.choices[0]?.delta;
105
- if (delta?.content) {
106
- textContent += delta.content;
107
- onToken(delta.content);
108
- }
109
- if (delta?.tool_calls) {
110
- for (const tc of delta.tool_calls) {
111
- if (!toolCallMap.has(tc.index)) {
112
- toolCallMap.set(tc.index, { id: '', name: '', args: '' });
113
- }
114
- const acc = toolCallMap.get(tc.index)!;
115
- if (tc.id) acc.id = tc.id;
116
- if (tc.function?.name) acc.name += tc.function.name;
117
- if (tc.function?.arguments) acc.args += tc.function.arguments;
118
- }
119
- }
120
- if (chunk.choices[0]?.finish_reason) finishReason = chunk.choices[0].finish_reason;
121
- }
122
-
123
- const tool_calls = Array.from(toolCallMap.values()).map(tc => ({
124
- id: tc.id,
125
- name: tc.name,
126
- arguments: JSON.parse(tc.args || '{}'),
127
- }));
128
-
129
- return { content: textContent, tool_calls, finish_reason: finishReason, usage: undefined };
130
- }
131
-
132
- private buildMessages(request: LLMRequest): ChatCompletionMessageParam[] {
133
- return request.messages.map(msg => {
134
- if (msg.role === 'system') return { role: 'system', content: msg.content };
135
- if (msg.role === 'user') return { role: 'user', content: msg.content };
136
- if (msg.role === 'assistant') return { role: 'assistant', content: msg.content };
137
- throw new Error(`Unsupported message role: ${msg.role}`);
138
- });
139
- }
140
-
141
- private buildTools(request: LLMRequest): ChatCompletionTool[] | undefined {
142
- if (!request.tools || request.tools.length === 0) return undefined;
143
- return request.tools.map(tool => ({
144
- type: 'function' as const,
145
- function: {
146
- name: tool.name,
147
- description: tool.description,
148
- parameters: tool.parameters,
149
- },
150
- }));
151
- }
152
- }
@@ -1,212 +0,0 @@
1
- /**
2
- * OpenAI Responses API provider — supports models only available on /v1/responses
3
- * (e.g. gpt-5.1-codex-mini).
4
- *
5
- * Implements AgentLoopProvider to own the multi-turn tool-calling loop,
6
- * because the Responses API uses typed function_call / function_call_output
7
- * items that cannot be expressed as plain text messages.
8
- */
9
-
10
- import type { LLMRequest, LLMResponse, Message } from '@studio-foundation/contracts';
11
- import type { AgentLoopProvider, AgentLoopResult, ToolCallOutcome } from './provider.js';
12
- import OpenAI from 'openai';
13
- import type {
14
- ResponseInputItem,
15
- ResponseFunctionToolCall,
16
- ResponseOutputItem,
17
- FunctionTool,
18
- } from 'openai/resources/responses/responses.js';
19
-
20
- export class OpenAIResponsesProvider implements AgentLoopProvider {
21
- readonly name = 'openai-responses';
22
- private client: OpenAI;
23
-
24
- constructor(apiKey?: string) {
25
- this.client = new OpenAI({ apiKey: apiKey || process.env.OPENAI_API_KEY });
26
- }
27
-
28
- // Satisfy Provider interface for simple (no-tool) calls
29
- async call(request: LLMRequest, _onToken?: (token: string) => void, signal?: AbortSignal): Promise<LLMResponse> {
30
- const input = messagesToInput(request.messages);
31
- const response = await this.client.responses.create({
32
- model: request.model,
33
- input,
34
- temperature: request.temperature,
35
- max_output_tokens: request.max_tokens ?? undefined,
36
- }, { signal });
37
-
38
- // NOTE: call() is for tool-free requests only.
39
- // Tool calls in the response are intentionally discarded.
40
- // For agentic tool-calling, use runAgentLoop() instead.
41
- return {
42
- content: response.output_text ?? '',
43
- tool_calls: [],
44
- finish_reason: 'stop',
45
- usage: response.usage
46
- ? {
47
- prompt_tokens: response.usage.input_tokens,
48
- completion_tokens: response.usage.output_tokens,
49
- total_tokens: response.usage.total_tokens,
50
- }
51
- : undefined,
52
- };
53
- }
54
-
55
- async runAgentLoop(
56
- request: LLMRequest,
57
- executeTool: (name: string, args: Record<string, unknown>, callId: string) => Promise<ToolCallOutcome>,
58
- onToken?: (token: string) => void,
59
- signal?: AbortSignal
60
- ): Promise<AgentLoopResult> {
61
- const tools: FunctionTool[] = (request.tools ?? []).map(t => ({
62
- type: 'function' as const,
63
- name: t.name,
64
- description: t.description ?? null,
65
- parameters: t.parameters as Record<string, unknown> | null,
66
- strict: null,
67
- }));
68
-
69
- let input: ResponseInputItem[] = messagesToInput(request.messages);
70
- const allToolCalls: AgentLoopResult['tool_calls'] = [];
71
- const tokenAccumulator = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
72
- const MAX_ITERATIONS = 20;
73
-
74
- for (let i = 0; i < MAX_ITERATIONS; i++) {
75
- if (signal?.aborted) {
76
- throw new DOMException('The operation was aborted', 'AbortError');
77
- }
78
-
79
- let outputItems: ResponseOutputItem[];
80
- let responseText = '';
81
-
82
- if (onToken) {
83
- // Streaming path — emit text deltas as they arrive, then get the completed response
84
- const stream = this.client.responses.stream({
85
- model: request.model,
86
- input,
87
- tools: tools.length > 0 ? tools : undefined,
88
- temperature: request.temperature,
89
- max_output_tokens: request.max_tokens ?? undefined,
90
- });
91
-
92
- for await (const event of stream) {
93
- if (event.type === 'response.output_text.delta') {
94
- onToken(event.delta);
95
- responseText += event.delta;
96
- }
97
- }
98
-
99
- const finalResponse = await stream.finalResponse();
100
-
101
- if (finalResponse.usage) {
102
- tokenAccumulator.prompt_tokens += finalResponse.usage.input_tokens;
103
- tokenAccumulator.completion_tokens += finalResponse.usage.output_tokens;
104
- tokenAccumulator.total_tokens += finalResponse.usage.total_tokens;
105
- }
106
-
107
- outputItems = finalResponse.output;
108
-
109
- // Find all function calls in the output
110
- const functionCalls = outputItems.filter(
111
- (item): item is ResponseFunctionToolCall => item.type === 'function_call'
112
- );
113
-
114
- if (functionCalls.length === 0) {
115
- // No tool calls — done
116
- return {
117
- content: responseText,
118
- tool_calls: allToolCalls,
119
- finish_reason: 'stop',
120
- usage: tokenAccumulator.total_tokens > 0 ? tokenAccumulator : undefined,
121
- };
122
- }
123
-
124
- // Execute all tool calls
125
- const toolOutputs: ResponseInputItem[] = [];
126
- for (const fc of functionCalls) {
127
- const args = JSON.parse(fc.arguments) as Record<string, unknown>;
128
- const outcome = await executeTool(fc.name, args, fc.call_id);
129
-
130
- allToolCalls.push({ id: fc.call_id, name: fc.name, arguments: args, ...outcome });
131
-
132
- toolOutputs.push({
133
- type: 'function_call_output',
134
- call_id: fc.call_id,
135
- output: outcome.error ? `Error: ${outcome.error}` : JSON.stringify(outcome.result),
136
- } as ResponseInputItem.FunctionCallOutput);
137
- }
138
-
139
- // Extend input: previous input + this response's output items + tool results
140
- // ResponseOutputItem is a subset of the ResponseInputItem union, so the cast is safe
141
- input = [
142
- ...input,
143
- ...(outputItems as unknown as ResponseInputItem[]),
144
- ...toolOutputs,
145
- ];
146
- } else {
147
- // Non-streaming path
148
- const response = await this.client.responses.create({
149
- model: request.model,
150
- input,
151
- tools: tools.length > 0 ? tools : undefined,
152
- temperature: request.temperature,
153
- max_output_tokens: request.max_tokens ?? undefined,
154
- }, { signal });
155
-
156
- if (response.usage) {
157
- tokenAccumulator.prompt_tokens += response.usage.input_tokens;
158
- tokenAccumulator.completion_tokens += response.usage.output_tokens;
159
- tokenAccumulator.total_tokens += response.usage.total_tokens;
160
- }
161
-
162
- // Find all function calls in the output
163
- const functionCalls = response.output.filter(
164
- (item): item is ResponseFunctionToolCall => item.type === 'function_call'
165
- );
166
-
167
- if (functionCalls.length === 0) {
168
- // No tool calls — done
169
- return {
170
- content: response.output_text ?? '',
171
- tool_calls: allToolCalls,
172
- finish_reason: 'stop',
173
- usage: tokenAccumulator.total_tokens > 0 ? tokenAccumulator : undefined,
174
- };
175
- }
176
-
177
- // Execute all tool calls
178
- const toolOutputs: ResponseInputItem[] = [];
179
- for (const fc of functionCalls) {
180
- const args = JSON.parse(fc.arguments) as Record<string, unknown>;
181
- const outcome = await executeTool(fc.name, args, fc.call_id);
182
-
183
- allToolCalls.push({ id: fc.call_id, name: fc.name, arguments: args, ...outcome });
184
-
185
- toolOutputs.push({
186
- type: 'function_call_output',
187
- call_id: fc.call_id,
188
- output: outcome.error ? `Error: ${outcome.error}` : JSON.stringify(outcome.result),
189
- } as ResponseInputItem.FunctionCallOutput);
190
- }
191
-
192
- // Extend input: previous input + this response's output items + tool results
193
- // ResponseOutputItem is a subset of the ResponseInputItem union, so the cast is safe
194
- input = [
195
- ...input,
196
- ...(response.output as unknown as ResponseInputItem[]),
197
- ...toolOutputs,
198
- ];
199
- }
200
- }
201
-
202
- throw new Error(`Maximum tool calling iterations (${MAX_ITERATIONS}) reached.`);
203
- }
204
- }
205
-
206
- function messagesToInput(messages: Message[]): ResponseInputItem[] {
207
- return messages.map(msg => ({
208
- type: 'message' as const,
209
- role: msg.role as 'user' | 'assistant' | 'system',
210
- content: msg.content,
211
- }));
212
- }
@@ -1,67 +0,0 @@
1
- import { describe, it, expect, vi } from 'vitest';
2
- import { OpenAIProvider } from './openai.js';
3
-
4
- // Fake async iterable that yields chunks with a delay between each,
5
- // simulating a slow stream that should be interruptible.
6
- async function* slowStream(chunks: unknown[], delayMs = 10): AsyncGenerator<unknown> {
7
- for (const chunk of chunks) {
8
- await new Promise((r) => setTimeout(r, delayMs));
9
- yield chunk;
10
- }
11
- }
12
-
13
- vi.mock('openai', () => {
14
- return {
15
- default: class FakeOpenAI {
16
- chat = {
17
- completions: {
18
- create: (_params: unknown, _opts: { signal?: AbortSignal }) =>
19
- slowStream([
20
- { choices: [{ delta: { content: 'hello' }, finish_reason: null }] },
21
- { choices: [{ delta: { content: ' world' }, finish_reason: 'stop' }] },
22
- ]),
23
- },
24
- };
25
- },
26
- };
27
- });
28
-
29
- describe('OpenAIProvider', () => {
30
- it('aborts streaming when signal fires mid-iteration', async () => {
31
- const provider = new OpenAIProvider('test-key');
32
- const controller = new AbortController();
33
- const onToken = vi.fn();
34
-
35
- const callPromise = provider.call(
36
- {
37
- model: 'gpt-4o',
38
- messages: [{ role: 'user', content: 'hi' }],
39
- },
40
- onToken,
41
- controller.signal,
42
- );
43
-
44
- // Abort after first chunk has a chance to arrive
45
- setTimeout(() => controller.abort(), 5);
46
-
47
- await expect(callPromise).rejects.toSatisfy(
48
- (e: unknown) => e instanceof DOMException && (e as DOMException).name === 'AbortError',
49
- );
50
- });
51
-
52
- it('completes normally when signal is not aborted', async () => {
53
- const provider = new OpenAIProvider('test-key');
54
- const onToken = vi.fn();
55
-
56
- const result = await provider.call(
57
- {
58
- model: 'gpt-4o',
59
- messages: [{ role: 'user', content: 'hi' }],
60
- },
61
- onToken,
62
- );
63
-
64
- expect(result.content).toBe('hello world');
65
- expect(onToken).toHaveBeenCalledTimes(2);
66
- });
67
- });