@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.
- package/ARCHITECTURE.md +53 -0
- package/LICENSE +663 -0
- package/README.md +125 -0
- package/configs/agents/analyst.agent.yaml +31 -0
- package/configs/agents/code-generator.agent.yaml +31 -0
- package/configs/agents/generic.agent.yaml +23 -0
- package/dist/__tests__/script-executor.test.d.ts +2 -0
- package/dist/__tests__/script-executor.test.d.ts.map +1 -0
- package/dist/__tests__/script-executor.test.js +173 -0
- package/dist/__tests__/script-executor.test.js.map +1 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +76 -0
- package/dist/index.js.map +1 -0
- package/dist/integrations/integration-loader.d.ts +20 -0
- package/dist/integrations/integration-loader.d.ts.map +1 -0
- package/dist/integrations/integration-loader.js +71 -0
- package/dist/integrations/integration-loader.js.map +1 -0
- package/dist/integrations/integration-loader.test.d.ts +2 -0
- package/dist/integrations/integration-loader.test.d.ts.map +1 -0
- package/dist/integrations/integration-loader.test.js +77 -0
- package/dist/integrations/integration-loader.test.js.map +1 -0
- package/dist/middleware/anonymization.d.ts +22 -0
- package/dist/middleware/anonymization.d.ts.map +1 -0
- package/dist/middleware/anonymization.js +37 -0
- package/dist/middleware/anonymization.js.map +1 -0
- package/dist/plugins/index.d.ts +5 -0
- package/dist/plugins/index.d.ts.map +1 -0
- package/dist/plugins/index.js +10 -0
- package/dist/plugins/index.js.map +1 -0
- package/dist/plugins/mcp-client.d.ts +19 -0
- package/dist/plugins/mcp-client.d.ts.map +1 -0
- package/dist/plugins/mcp-client.js +126 -0
- package/dist/plugins/mcp-client.js.map +1 -0
- package/dist/plugins/mcp-client.test.d.ts +2 -0
- package/dist/plugins/mcp-client.test.d.ts.map +1 -0
- package/dist/plugins/mcp-client.test.js +160 -0
- package/dist/plugins/mcp-client.test.js.map +1 -0
- package/dist/plugins/oauth-provider.d.ts +27 -0
- package/dist/plugins/oauth-provider.d.ts.map +1 -0
- package/dist/plugins/oauth-provider.js +140 -0
- package/dist/plugins/oauth-provider.js.map +1 -0
- package/dist/plugins/oauth-provider.test.d.ts +2 -0
- package/dist/plugins/oauth-provider.test.d.ts.map +1 -0
- package/dist/plugins/oauth-provider.test.js +144 -0
- package/dist/plugins/oauth-provider.test.js.map +1 -0
- package/dist/plugins/plugin-loader.d.ts +29 -0
- package/dist/plugins/plugin-loader.d.ts.map +1 -0
- package/dist/plugins/plugin-loader.js +71 -0
- package/dist/plugins/plugin-loader.js.map +1 -0
- package/dist/plugins/plugin-loader.test.d.ts +2 -0
- package/dist/plugins/plugin-loader.test.d.ts.map +1 -0
- package/dist/plugins/plugin-loader.test.js +87 -0
- package/dist/plugins/plugin-loader.test.js.map +1 -0
- package/dist/prompt-builder.d.ts +47 -0
- package/dist/prompt-builder.d.ts.map +1 -0
- package/dist/prompt-builder.js +263 -0
- package/dist/prompt-builder.js.map +1 -0
- package/dist/prompt-builder.test.d.ts +2 -0
- package/dist/prompt-builder.test.d.ts.map +1 -0
- package/dist/prompt-builder.test.js +154 -0
- package/dist/prompt-builder.test.js.map +1 -0
- package/dist/providers/anthropic.d.ts +14 -0
- package/dist/providers/anthropic.d.ts.map +1 -0
- package/dist/providers/anthropic.js +118 -0
- package/dist/providers/anthropic.js.map +1 -0
- package/dist/providers/anthropic.test.d.ts +2 -0
- package/dist/providers/anthropic.test.d.ts.map +1 -0
- package/dist/providers/anthropic.test.js +76 -0
- package/dist/providers/anthropic.test.js.map +1 -0
- package/dist/providers/mock.d.ts +17 -0
- package/dist/providers/mock.d.ts.map +1 -0
- package/dist/providers/mock.js +39 -0
- package/dist/providers/mock.js.map +1 -0
- package/dist/providers/ollama.d.ts +18 -0
- package/dist/providers/ollama.d.ts.map +1 -0
- package/dist/providers/ollama.js +155 -0
- package/dist/providers/ollama.js.map +1 -0
- package/dist/providers/ollama.test.d.ts +2 -0
- package/dist/providers/ollama.test.d.ts.map +1 -0
- package/dist/providers/ollama.test.js +123 -0
- package/dist/providers/ollama.test.js.map +1 -0
- package/dist/providers/openai-responses.d.ts +18 -0
- package/dist/providers/openai-responses.d.ts.map +1 -0
- package/dist/providers/openai-responses.js +175 -0
- package/dist/providers/openai-responses.js.map +1 -0
- package/dist/providers/openai.d.ts +16 -0
- package/dist/providers/openai.d.ts.map +1 -0
- package/dist/providers/openai.js +132 -0
- package/dist/providers/openai.js.map +1 -0
- package/dist/providers/openai.test.d.ts +2 -0
- package/dist/providers/openai.test.d.ts.map +1 -0
- package/dist/providers/openai.test.js +51 -0
- package/dist/providers/openai.test.js.map +1 -0
- package/dist/providers/provider.d.ts +42 -0
- package/dist/providers/provider.d.ts.map +1 -0
- package/dist/providers/provider.js +10 -0
- package/dist/providers/provider.js.map +1 -0
- package/dist/providers/registry.d.ts +42 -0
- package/dist/providers/registry.d.ts.map +1 -0
- package/dist/providers/registry.js +65 -0
- package/dist/providers/registry.js.map +1 -0
- package/dist/runner.d.ts +46 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +324 -0
- package/dist/runner.js.map +1 -0
- package/dist/runner.test.d.ts +2 -0
- package/dist/runner.test.d.ts.map +1 -0
- package/dist/runner.test.js +280 -0
- package/dist/runner.test.js.map +1 -0
- package/dist/script-executor.d.ts +11 -0
- package/dist/script-executor.d.ts.map +1 -0
- package/dist/script-executor.js +85 -0
- package/dist/script-executor.js.map +1 -0
- package/dist/tools/builtin/git.d.ts +6 -0
- package/dist/tools/builtin/git.d.ts.map +1 -0
- package/dist/tools/builtin/git.js +294 -0
- package/dist/tools/builtin/git.js.map +1 -0
- package/dist/tools/builtin/patch.d.ts +6 -0
- package/dist/tools/builtin/patch.d.ts.map +1 -0
- package/dist/tools/builtin/patch.js +238 -0
- package/dist/tools/builtin/patch.js.map +1 -0
- package/dist/tools/builtin/repo-manager.d.ts +6 -0
- package/dist/tools/builtin/repo-manager.d.ts.map +1 -0
- package/dist/tools/builtin/repo-manager.js +171 -0
- package/dist/tools/builtin/repo-manager.js.map +1 -0
- package/dist/tools/builtin/search.d.ts +6 -0
- package/dist/tools/builtin/search.d.ts.map +1 -0
- package/dist/tools/builtin/search.js +104 -0
- package/dist/tools/builtin/search.js.map +1 -0
- package/dist/tools/builtin/shell.d.ts +6 -0
- package/dist/tools/builtin/shell.d.ts.map +1 -0
- package/dist/tools/builtin/shell.js +78 -0
- package/dist/tools/builtin/shell.js.map +1 -0
- package/dist/tools/builtin/studio-run.d.ts +12 -0
- package/dist/tools/builtin/studio-run.d.ts.map +1 -0
- package/dist/tools/builtin/studio-run.js +61 -0
- package/dist/tools/builtin/studio-run.js.map +1 -0
- package/dist/tools/builtin/web-search.d.ts +7 -0
- package/dist/tools/builtin/web-search.d.ts.map +1 -0
- package/dist/tools/builtin/web-search.js +94 -0
- package/dist/tools/builtin/web-search.js.map +1 -0
- package/dist/tools/builtin/web-search.test.d.ts +5 -0
- package/dist/tools/builtin/web-search.test.d.ts.map +1 -0
- package/dist/tools/builtin/web-search.test.js +90 -0
- package/dist/tools/builtin/web-search.test.js.map +1 -0
- package/dist/tools/errors.d.ts +4 -0
- package/dist/tools/errors.d.ts.map +1 -0
- package/dist/tools/errors.js +11 -0
- package/dist/tools/errors.js.map +1 -0
- package/dist/tools/errors.test.d.ts +2 -0
- package/dist/tools/errors.test.d.ts.map +1 -0
- package/dist/tools/errors.test.js +14 -0
- package/dist/tools/errors.test.js.map +1 -0
- package/dist/tools/plugin-loader.d.ts +31 -0
- package/dist/tools/plugin-loader.d.ts.map +1 -0
- package/dist/tools/plugin-loader.js +183 -0
- package/dist/tools/plugin-loader.js.map +1 -0
- package/dist/tools/plugin-loader.test.d.ts +2 -0
- package/dist/tools/plugin-loader.test.d.ts.map +1 -0
- package/dist/tools/plugin-loader.test.js +116 -0
- package/dist/tools/plugin-loader.test.js.map +1 -0
- package/dist/tools/skills/skill-loader.d.ts +23 -0
- package/dist/tools/skills/skill-loader.d.ts.map +1 -0
- package/dist/tools/skills/skill-loader.js +58 -0
- package/dist/tools/skills/skill-loader.js.map +1 -0
- package/dist/tools/skills/skill-loader.test.d.ts +2 -0
- package/dist/tools/skills/skill-loader.test.d.ts.map +1 -0
- package/dist/tools/skills/skill-loader.test.js +92 -0
- package/dist/tools/skills/skill-loader.test.js.map +1 -0
- package/dist/tools/tool-executor.d.ts +12 -0
- package/dist/tools/tool-executor.d.ts.map +1 -0
- package/dist/tools/tool-executor.js +76 -0
- package/dist/tools/tool-executor.js.map +1 -0
- package/dist/tools/tool-executor.test.d.ts +2 -0
- package/dist/tools/tool-executor.test.d.ts.map +1 -0
- package/dist/tools/tool-executor.test.js +83 -0
- package/dist/tools/tool-executor.test.js.map +1 -0
- package/dist/tools/tool-registry.d.ts +65 -0
- package/dist/tools/tool-registry.d.ts.map +1 -0
- package/dist/tools/tool-registry.js +112 -0
- package/dist/tools/tool-registry.js.map +1 -0
- package/dist/tools/yaml-executor.d.ts +22 -0
- package/dist/tools/yaml-executor.d.ts.map +1 -0
- package/dist/tools/yaml-executor.js +80 -0
- package/dist/tools/yaml-executor.js.map +1 -0
- package/dist/utils/race-signal.d.ts +6 -0
- package/dist/utils/race-signal.d.ts.map +1 -0
- package/dist/utils/race-signal.js +21 -0
- package/dist/utils/race-signal.js.map +1 -0
- package/dist/utils/race-signal.test.d.ts +2 -0
- package/dist/utils/race-signal.test.d.ts.map +1 -0
- package/dist/utils/race-signal.test.js +48 -0
- package/dist/utils/race-signal.test.js.map +1 -0
- package/package.json +36 -0
- package/src/__tests__/script-executor.test.ts +180 -0
- package/src/index.ts +68 -0
- package/src/integrations/integration-loader.test.ts +88 -0
- package/src/integrations/integration-loader.ts +68 -0
- package/src/middleware/anonymization.ts +38 -0
- package/src/plugins/index.ts +4 -0
- package/src/plugins/mcp-client.test.ts +148 -0
- package/src/plugins/mcp-client.ts +128 -0
- package/src/plugins/oauth-provider.test.ts +167 -0
- package/src/plugins/oauth-provider.ts +175 -0
- package/src/plugins/plugin-loader.test.ts +114 -0
- package/src/plugins/plugin-loader.ts +90 -0
- package/src/prompt-builder.test.ts +167 -0
- package/src/prompt-builder.ts +332 -0
- package/src/providers/anthropic.test.ts +101 -0
- package/src/providers/anthropic.ts +135 -0
- package/src/providers/mock.ts +57 -0
- package/src/providers/ollama.test.ts +166 -0
- package/src/providers/ollama.ts +152 -0
- package/src/providers/openai-responses.ts +212 -0
- package/src/providers/openai.test.ts +67 -0
- package/src/providers/openai.ts +139 -0
- package/src/providers/provider.ts +54 -0
- package/src/providers/registry.ts +77 -0
- package/src/runner.test.ts +343 -0
- package/src/runner.ts +396 -0
- package/src/script-executor.ts +107 -0
- package/src/tools/builtin/git.ts +311 -0
- package/src/tools/builtin/patch.ts +257 -0
- package/src/tools/builtin/repo-manager.ts +142 -0
- package/src/tools/builtin/search.ts +108 -0
- package/src/tools/builtin/shell.ts +82 -0
- package/src/tools/builtin/studio-run.ts +73 -0
- package/src/tools/builtin/web-search.test.ts +122 -0
- package/src/tools/builtin/web-search.ts +101 -0
- package/src/tools/errors.test.ts +12 -0
- package/src/tools/errors.ts +6 -0
- package/src/tools/plugin-loader.test.ts +130 -0
- package/src/tools/plugin-loader.ts +203 -0
- package/src/tools/skills/README.md +49 -0
- package/src/tools/skills/skill-loader.test.ts +106 -0
- package/src/tools/skills/skill-loader.ts +62 -0
- package/src/tools/tool-executor.test.ts +88 -0
- package/src/tools/tool-executor.ts +84 -0
- package/src/tools/tool-registry.ts +130 -0
- package/src/tools/yaml-executor.ts +120 -0
- package/src/utils/race-signal.test.ts +50 -0
- package/src/utils/race-signal.ts +17 -0
- package/templates/integrations/linear.integration.yaml +35 -0
- package/templates/integrations/slack.integration.yaml +22 -0
- package/templates/integrations/webhook.integration.yaml +17 -0
- package/templates/tools/git.tool.yaml +80 -0
- package/templates/tools/repo-manager.tool.yaml +64 -0
- package/templates/tools/search.tool.yaml +22 -0
- package/templates/tools/shell.tool.yaml +19 -0
- package/templates/tools/web-search.tool.yaml +24 -0
- package/tests/anonymization-middleware.test.ts +61 -0
- package/tests/anthropic.test.ts +87 -0
- package/tests/apply-patch.test.ts +355 -0
- package/tests/fixtures/tools/test-builtin.tool.yaml +14 -0
- package/tests/fixtures/tools/test-shell.tool.yaml +19 -0
- package/tests/mock-provider.test.ts +104 -0
- package/tests/openai.test.ts +72 -0
- package/tests/plugin-loader.test.ts +54 -0
- package/tests/prompt-builder.test.ts +468 -0
- package/tests/runner-anonymization.test.ts +89 -0
- package/tests/runner.test.ts +885 -0
- package/tests/studio-run.test.ts +94 -0
- package/tests/tool-executor.test.ts +115 -0
- package/tests/tool-registry.test.ts +84 -0
- package/tests/yaml-executor.test.ts +76 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI provider implementation with full tool calling support
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { LLMRequest, LLMResponse } from '@studio-foundation/contracts';
|
|
6
|
+
import type { Provider } from './provider.js';
|
|
7
|
+
import OpenAI from 'openai';
|
|
8
|
+
import type { ChatCompletionMessageParam, ChatCompletionTool, ChatCompletionChunk } from 'openai/resources/chat/completions';
|
|
9
|
+
|
|
10
|
+
export class OpenAIProvider implements Provider {
|
|
11
|
+
readonly name = 'openai';
|
|
12
|
+
private client: OpenAI;
|
|
13
|
+
|
|
14
|
+
constructor(apiKey?: string, private baseUrl?: string) {
|
|
15
|
+
this.client = new OpenAI({
|
|
16
|
+
apiKey: apiKey || process.env.OPENAI_API_KEY,
|
|
17
|
+
baseURL: baseUrl
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async call(request: LLMRequest, onToken?: (token: string) => void, signal?: AbortSignal): Promise<LLMResponse> {
|
|
22
|
+
const openaiMessages = this.buildMessages(request);
|
|
23
|
+
const tools = this.buildTools(request);
|
|
24
|
+
|
|
25
|
+
if (onToken) {
|
|
26
|
+
return this.callStreaming(request, openaiMessages, tools, onToken, signal);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Non-streaming path (existing behavior)
|
|
30
|
+
const completion = await this.client.chat.completions.create({
|
|
31
|
+
model: request.model,
|
|
32
|
+
messages: openaiMessages,
|
|
33
|
+
tools: tools && tools.length > 0 ? tools : undefined,
|
|
34
|
+
temperature: request.temperature,
|
|
35
|
+
max_completion_tokens: request.max_tokens
|
|
36
|
+
}, { signal });
|
|
37
|
+
|
|
38
|
+
const choice = completion.choices[0];
|
|
39
|
+
|
|
40
|
+
// Extract tool calls if any
|
|
41
|
+
const tool_calls = choice.message.tool_calls?.map(tc => ({
|
|
42
|
+
id: tc.id,
|
|
43
|
+
name: tc.function.name,
|
|
44
|
+
arguments: JSON.parse(tc.function.arguments)
|
|
45
|
+
})) || [];
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
content: choice.message.content || '',
|
|
49
|
+
tool_calls,
|
|
50
|
+
finish_reason: choice.finish_reason,
|
|
51
|
+
usage: {
|
|
52
|
+
prompt_tokens: completion.usage?.prompt_tokens || 0,
|
|
53
|
+
completion_tokens: completion.usage?.completion_tokens || 0,
|
|
54
|
+
total_tokens: completion.usage?.total_tokens || 0
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private async callStreaming(
|
|
60
|
+
request: LLMRequest,
|
|
61
|
+
openaiMessages: ChatCompletionMessageParam[],
|
|
62
|
+
tools: ChatCompletionTool[] | undefined,
|
|
63
|
+
onToken: (token: string) => void,
|
|
64
|
+
signal?: AbortSignal
|
|
65
|
+
): Promise<LLMResponse> {
|
|
66
|
+
const stream = this.client.chat.completions.create({
|
|
67
|
+
model: request.model,
|
|
68
|
+
messages: openaiMessages,
|
|
69
|
+
tools: tools && tools.length > 0 ? tools : undefined,
|
|
70
|
+
temperature: request.temperature,
|
|
71
|
+
max_completion_tokens: request.max_tokens,
|
|
72
|
+
stream: true as const,
|
|
73
|
+
stream_options: { include_usage: true },
|
|
74
|
+
}, { signal }) as unknown as AsyncIterable<ChatCompletionChunk>;
|
|
75
|
+
|
|
76
|
+
let textContent = '';
|
|
77
|
+
const toolCallMap = new Map<number, { id: string; name: string; args: string }>();
|
|
78
|
+
let finishReason = 'stop';
|
|
79
|
+
let usage: LLMResponse['usage'];
|
|
80
|
+
|
|
81
|
+
for await (const chunk of stream) {
|
|
82
|
+
// KEY FIX: check signal at the start of each chunk so we don't keep
|
|
83
|
+
// consuming buffered data after Ctrl-C.
|
|
84
|
+
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
|
|
85
|
+
const delta = chunk.choices[0]?.delta;
|
|
86
|
+
if (delta?.content) {
|
|
87
|
+
textContent += delta.content;
|
|
88
|
+
onToken(delta.content);
|
|
89
|
+
}
|
|
90
|
+
if (delta?.tool_calls) {
|
|
91
|
+
for (const tc of delta.tool_calls) {
|
|
92
|
+
if (!toolCallMap.has(tc.index)) {
|
|
93
|
+
toolCallMap.set(tc.index, { id: '', name: '', args: '' });
|
|
94
|
+
}
|
|
95
|
+
const acc = toolCallMap.get(tc.index)!;
|
|
96
|
+
if (tc.id) acc.id = tc.id;
|
|
97
|
+
if (tc.function?.name) acc.name += tc.function.name;
|
|
98
|
+
if (tc.function?.arguments) acc.args += tc.function.arguments;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (chunk.choices[0]?.finish_reason) finishReason = chunk.choices[0].finish_reason;
|
|
102
|
+
if (chunk.usage) {
|
|
103
|
+
usage = {
|
|
104
|
+
prompt_tokens: chunk.usage.prompt_tokens,
|
|
105
|
+
completion_tokens: chunk.usage.completion_tokens,
|
|
106
|
+
total_tokens: chunk.usage.total_tokens,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const tool_calls = Array.from(toolCallMap.values()).map(tc => ({
|
|
112
|
+
id: tc.id,
|
|
113
|
+
name: tc.name,
|
|
114
|
+
arguments: JSON.parse(tc.args || '{}'),
|
|
115
|
+
}));
|
|
116
|
+
|
|
117
|
+
return { content: textContent, tool_calls, finish_reason: finishReason, usage };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private buildMessages(request: LLMRequest): ChatCompletionMessageParam[] {
|
|
121
|
+
return request.messages.map(msg => {
|
|
122
|
+
if (msg.role === 'system') return { role: 'system', content: msg.content };
|
|
123
|
+
if (msg.role === 'user') return { role: 'user', content: msg.content };
|
|
124
|
+
if (msg.role === 'assistant') return { role: 'assistant', content: msg.content };
|
|
125
|
+
throw new Error(`Unsupported message role: ${msg.role}`);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private buildTools(request: LLMRequest): ChatCompletionTool[] | undefined {
|
|
130
|
+
return request.tools?.map(tool => ({
|
|
131
|
+
type: 'function' as const,
|
|
132
|
+
function: {
|
|
133
|
+
name: tool.name,
|
|
134
|
+
description: tool.description,
|
|
135
|
+
parameters: tool.parameters
|
|
136
|
+
}
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider interface - aligned with @studio/contracts
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { LLMRequest, LLMResponse } from '@studio-foundation/contracts';
|
|
6
|
+
|
|
7
|
+
export interface Provider {
|
|
8
|
+
readonly name: string;
|
|
9
|
+
call(request: LLMRequest, onToken?: (token: string) => void, signal?: AbortSignal): Promise<LLMResponse>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ToolCallOutcome {
|
|
13
|
+
result?: unknown;
|
|
14
|
+
error?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Extended interface for providers that own the full agent loop.
|
|
19
|
+
* Used by providers (e.g. OpenAI Responses API) where multi-turn
|
|
20
|
+
* tool calling cannot be expressed as plain text messages.
|
|
21
|
+
*/
|
|
22
|
+
export interface AgentLoopProvider extends Provider {
|
|
23
|
+
runAgentLoop(
|
|
24
|
+
request: LLMRequest,
|
|
25
|
+
executeTool: (name: string, args: Record<string, unknown>, callId: string) => Promise<ToolCallOutcome>,
|
|
26
|
+
onToken?: (token: string) => void,
|
|
27
|
+
signal?: AbortSignal
|
|
28
|
+
): Promise<AgentLoopResult>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Full result of an agent loop execution.
|
|
33
|
+
* Distinct from LLMResponse (in @studio/contracts) because it includes
|
|
34
|
+
* per-tool-call execution outcomes (result/error) that are only known
|
|
35
|
+
* after the runner has actually invoked each tool.
|
|
36
|
+
*/
|
|
37
|
+
export interface AgentLoopResult {
|
|
38
|
+
content: string;
|
|
39
|
+
tool_calls: Array<{
|
|
40
|
+
id: string;
|
|
41
|
+
name: string;
|
|
42
|
+
arguments: Record<string, unknown>;
|
|
43
|
+
} & ToolCallOutcome>;
|
|
44
|
+
finish_reason: string;
|
|
45
|
+
usage?: {
|
|
46
|
+
prompt_tokens: number;
|
|
47
|
+
completion_tokens: number;
|
|
48
|
+
total_tokens: number;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function isAgentLoopProvider(p: Provider): p is AgentLoopProvider {
|
|
53
|
+
return typeof (p as AgentLoopProvider).runAgentLoop === 'function';
|
|
54
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider registry - factory for LLM providers
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Provider } from './provider.js';
|
|
6
|
+
import { OpenAIProvider } from './openai.js';
|
|
7
|
+
import { AnthropicProvider } from './anthropic.js';
|
|
8
|
+
import { OpenAIResponsesProvider } from './openai-responses.js';
|
|
9
|
+
import { OllamaProvider } from './ollama.js';
|
|
10
|
+
|
|
11
|
+
export class ProviderRegistry {
|
|
12
|
+
private providers: Map<string, Provider> = new Map();
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Register a provider instance
|
|
16
|
+
*/
|
|
17
|
+
register(provider: Provider): void {
|
|
18
|
+
this.providers.set(provider.name, provider);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Get provider by name
|
|
23
|
+
*/
|
|
24
|
+
get(name: string): Provider {
|
|
25
|
+
const provider = this.providers.get(name);
|
|
26
|
+
if (!provider) {
|
|
27
|
+
const available = this.list();
|
|
28
|
+
const detail = available.length > 0 ? available.join(', ') : '(none registered)';
|
|
29
|
+
throw new Error(`Provider not found: ${name}. Available providers: ${detail}`);
|
|
30
|
+
}
|
|
31
|
+
return provider;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Check if provider exists
|
|
36
|
+
*/
|
|
37
|
+
has(name: string): boolean {
|
|
38
|
+
return this.providers.has(name);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* List available provider names
|
|
43
|
+
*/
|
|
44
|
+
list(): string[] {
|
|
45
|
+
return Array.from(this.providers.keys());
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Factory function to create a registry with default providers
|
|
51
|
+
*/
|
|
52
|
+
export function createDefaultRegistry(config: {
|
|
53
|
+
openai?: { apiKey: string; baseUrl?: string };
|
|
54
|
+
anthropic?: { apiKey: string };
|
|
55
|
+
openaiResponses?: { apiKey: string };
|
|
56
|
+
ollama?: { baseUrl?: string };
|
|
57
|
+
}): ProviderRegistry {
|
|
58
|
+
const registry = new ProviderRegistry();
|
|
59
|
+
|
|
60
|
+
if (config.openai) {
|
|
61
|
+
registry.register(new OpenAIProvider(config.openai.apiKey, config.openai.baseUrl));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (config.anthropic) {
|
|
65
|
+
registry.register(new AnthropicProvider(config.anthropic.apiKey));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (config.openaiResponses) {
|
|
69
|
+
registry.register(new OpenAIResponsesProvider(config.openaiResponses.apiKey));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (config.ollama) {
|
|
73
|
+
registry.register(new OllamaProvider(config.ollama.baseUrl));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return registry;
|
|
77
|
+
}
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { runAgent } from './runner.js';
|
|
3
|
+
import { ToolRegistry } from './tools/tool-registry.js';
|
|
4
|
+
import { ProviderRegistry } from './providers/registry.js';
|
|
5
|
+
import { MockProvider } from './providers/mock.js';
|
|
6
|
+
import type { ResolvedAgentConfig, LLMRequest, LLMResponse } from '@studio-foundation/contracts';
|
|
7
|
+
import type { Provider } from './providers/provider.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A minimal Chat Completions-style provider (NOT AgentLoopProvider).
|
|
11
|
+
* First call returns one tool call; second call returns the final content.
|
|
12
|
+
* Tracks the messages received on each call so tests can assert on them.
|
|
13
|
+
*/
|
|
14
|
+
class StandardProvider implements Provider {
|
|
15
|
+
readonly name = 'standard-mock';
|
|
16
|
+
private callCount = 0;
|
|
17
|
+
public receivedMessages: unknown[] = [];
|
|
18
|
+
|
|
19
|
+
async call(request: LLMRequest): Promise<LLMResponse> {
|
|
20
|
+
this.receivedMessages = request.messages as unknown[];
|
|
21
|
+
this.callCount++;
|
|
22
|
+
if (this.callCount === 1) {
|
|
23
|
+
return {
|
|
24
|
+
content: '',
|
|
25
|
+
tool_calls: [{ id: 'call-1', name: 'repo_manager-write_file', arguments: { path: '/tmp/foo.ts', content: 'hello' } }],
|
|
26
|
+
finish_reason: 'tool_calls',
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
// Second call: final response
|
|
30
|
+
return {
|
|
31
|
+
content: JSON.stringify({ summary: 'done' }),
|
|
32
|
+
tool_calls: [],
|
|
33
|
+
finish_reason: 'stop',
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function makeConfig(toolCallName: string, toolCallArgs: Record<string, unknown>) {
|
|
39
|
+
const toolRegistry = new ToolRegistry();
|
|
40
|
+
const mockExecute = vi.fn().mockResolvedValue({ success: true, output: 'wrote file' });
|
|
41
|
+
toolRegistry.register({
|
|
42
|
+
name: toolCallName,
|
|
43
|
+
description: 'A test tool',
|
|
44
|
+
parameters: {
|
|
45
|
+
type: 'object',
|
|
46
|
+
properties: Object.fromEntries(
|
|
47
|
+
Object.keys(toolCallArgs).map(k => [k, { type: 'string' }])
|
|
48
|
+
),
|
|
49
|
+
},
|
|
50
|
+
execute: mockExecute,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const mockProvider = new MockProvider(
|
|
54
|
+
new Map([
|
|
55
|
+
['test-stage', {
|
|
56
|
+
output: { summary: 'done' },
|
|
57
|
+
tool_calls: [{ name: toolCallName, arguments: toolCallArgs }],
|
|
58
|
+
}],
|
|
59
|
+
])
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const providerRegistry = new ProviderRegistry();
|
|
63
|
+
providerRegistry.register(mockProvider);
|
|
64
|
+
|
|
65
|
+
const agent: ResolvedAgentConfig = {
|
|
66
|
+
name: 'test-agent',
|
|
67
|
+
provider: 'mock',
|
|
68
|
+
model: 'mock',
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
return { agent, toolRegistry, providerRegistry, mockExecute };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A Chat Completions-style provider that always returns a tool call — simulates an infinite loop.
|
|
76
|
+
*/
|
|
77
|
+
class LoopingProvider implements Provider {
|
|
78
|
+
readonly name = 'looping-mock';
|
|
79
|
+
public callCount = 0;
|
|
80
|
+
|
|
81
|
+
async call(_request: LLMRequest): Promise<LLMResponse> {
|
|
82
|
+
this.callCount++;
|
|
83
|
+
return {
|
|
84
|
+
content: '',
|
|
85
|
+
tool_calls: [{ id: `call-${this.callCount}`, name: 'repo_manager-write_file', arguments: { path: '/tmp/foo.ts', content: 'hello' } }],
|
|
86
|
+
finish_reason: 'tool_calls',
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe('runner — max tool iterations', () => {
|
|
92
|
+
it('returns an error result instead of throwing when max iterations is reached', async () => {
|
|
93
|
+
const toolRegistry = new ToolRegistry();
|
|
94
|
+
toolRegistry.register({
|
|
95
|
+
name: 'repo_manager-write_file',
|
|
96
|
+
description: 'Write a file',
|
|
97
|
+
parameters: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } } },
|
|
98
|
+
execute: vi.fn().mockResolvedValue({ success: true }),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const loopingProvider = new LoopingProvider();
|
|
102
|
+
const providerRegistry = new ProviderRegistry();
|
|
103
|
+
providerRegistry.register(loopingProvider);
|
|
104
|
+
|
|
105
|
+
const agent: ResolvedAgentConfig = { name: 'test-agent', provider: 'looping-mock', model: 'mock' };
|
|
106
|
+
|
|
107
|
+
const result = await runAgent({
|
|
108
|
+
agent,
|
|
109
|
+
task: { description: 'write a file' },
|
|
110
|
+
context: {},
|
|
111
|
+
toolRegistry,
|
|
112
|
+
providerRegistry,
|
|
113
|
+
maxToolCalls: 3,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
expect(result.error).toBeDefined();
|
|
117
|
+
expect(result.error).toContain('Maximum tool calling iterations');
|
|
118
|
+
expect(result.error).toContain('3');
|
|
119
|
+
expect(loopingProvider.callCount).toBe(3);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('includes tool calls made before hitting the limit in the error result', async () => {
|
|
123
|
+
const toolRegistry = new ToolRegistry();
|
|
124
|
+
const mockExecute = vi.fn().mockResolvedValue({ success: true });
|
|
125
|
+
toolRegistry.register({
|
|
126
|
+
name: 'repo_manager-write_file',
|
|
127
|
+
description: 'Write a file',
|
|
128
|
+
parameters: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } } },
|
|
129
|
+
execute: mockExecute,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const providerRegistry = new ProviderRegistry();
|
|
133
|
+
providerRegistry.register(new LoopingProvider());
|
|
134
|
+
|
|
135
|
+
const agent: ResolvedAgentConfig = { name: 'test-agent', provider: 'looping-mock', model: 'mock' };
|
|
136
|
+
|
|
137
|
+
const result = await runAgent({
|
|
138
|
+
agent,
|
|
139
|
+
task: { description: 'write a file' },
|
|
140
|
+
context: {},
|
|
141
|
+
toolRegistry,
|
|
142
|
+
providerRegistry,
|
|
143
|
+
maxToolCalls: 2,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expect(result.error).toBeDefined();
|
|
147
|
+
expect(result.tool_calls).toHaveLength(2);
|
|
148
|
+
expect(mockExecute).toHaveBeenCalledTimes(2);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('runner — onPreToolUse callback', () => {
|
|
153
|
+
it('blocks tool execution when callback returns blocked: true', async () => {
|
|
154
|
+
const { agent, toolRegistry, providerRegistry, mockExecute } = makeConfig(
|
|
155
|
+
'repo_manager-write_file',
|
|
156
|
+
{ path: '/tmp/foo.ts', content: 'hello' }
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const onPreToolUse = vi.fn().mockResolvedValue({ blocked: true, error: 'pre-hook blocked' });
|
|
160
|
+
|
|
161
|
+
const result = await runAgent({
|
|
162
|
+
agent,
|
|
163
|
+
task: { description: 'write a file', contract_name: 'test-stage' },
|
|
164
|
+
context: {},
|
|
165
|
+
toolRegistry,
|
|
166
|
+
providerRegistry,
|
|
167
|
+
callbacks: { onPreToolUse },
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// Tool should appear in tool_calls with error (not actually executed)
|
|
171
|
+
expect(result.tool_calls).toHaveLength(1);
|
|
172
|
+
expect(result.tool_calls[0].error).toContain('pre-hook blocked');
|
|
173
|
+
expect(result.tool_calls[0].result).toBeUndefined();
|
|
174
|
+
expect(mockExecute).not.toHaveBeenCalled();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('allows tool execution when callback returns blocked: false', async () => {
|
|
178
|
+
const { agent, toolRegistry, providerRegistry, mockExecute } = makeConfig(
|
|
179
|
+
'repo_manager-write_file',
|
|
180
|
+
{ path: '/tmp/foo.ts', content: 'hello' }
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
const onPreToolUse = vi.fn().mockResolvedValue({ blocked: false });
|
|
184
|
+
|
|
185
|
+
const result = await runAgent({
|
|
186
|
+
agent,
|
|
187
|
+
task: { description: 'write a file', contract_name: 'test-stage' },
|
|
188
|
+
context: {},
|
|
189
|
+
toolRegistry,
|
|
190
|
+
providerRegistry,
|
|
191
|
+
callbacks: { onPreToolUse },
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
expect(result.tool_calls).toHaveLength(1);
|
|
195
|
+
expect(result.tool_calls[0].result).toBe('wrote file');
|
|
196
|
+
expect(mockExecute).toHaveBeenCalledOnce();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('blocks tool execution in standard (Chat Completions) path', async () => {
|
|
200
|
+
const toolRegistry = new ToolRegistry();
|
|
201
|
+
const mockExecute = vi.fn().mockResolvedValue({ success: true, output: 'wrote file' });
|
|
202
|
+
toolRegistry.register({
|
|
203
|
+
name: 'repo_manager-write_file',
|
|
204
|
+
description: 'A test tool',
|
|
205
|
+
parameters: {
|
|
206
|
+
type: 'object',
|
|
207
|
+
properties: {
|
|
208
|
+
path: { type: 'string' },
|
|
209
|
+
content: { type: 'string' },
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
execute: mockExecute,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const standardProvider = new StandardProvider();
|
|
216
|
+
const providerRegistry = new ProviderRegistry();
|
|
217
|
+
providerRegistry.register(standardProvider);
|
|
218
|
+
|
|
219
|
+
const agent: ResolvedAgentConfig = {
|
|
220
|
+
name: 'test-agent',
|
|
221
|
+
provider: 'standard-mock',
|
|
222
|
+
model: 'mock',
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const onPreToolUse = vi.fn().mockResolvedValue({ blocked: true, error: 'standard-path blocked' });
|
|
226
|
+
|
|
227
|
+
const result = await runAgent({
|
|
228
|
+
agent,
|
|
229
|
+
task: { description: 'write a file', contract_name: 'test-stage' },
|
|
230
|
+
context: {},
|
|
231
|
+
toolRegistry,
|
|
232
|
+
providerRegistry,
|
|
233
|
+
callbacks: { onPreToolUse },
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
expect(result.tool_calls).toHaveLength(1);
|
|
237
|
+
expect(result.tool_calls[0].error).toContain('standard-path blocked');
|
|
238
|
+
expect(result.tool_calls[0].result).toBeUndefined();
|
|
239
|
+
expect(mockExecute).not.toHaveBeenCalled();
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe('runner — onPostToolUse callback', () => {
|
|
244
|
+
it('is called after successful tool execution', async () => {
|
|
245
|
+
const { agent, toolRegistry, providerRegistry } = makeConfig(
|
|
246
|
+
'repo_manager-write_file',
|
|
247
|
+
{ path: '/tmp/foo.ts', content: 'hello' }
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
const onPostToolUse = vi.fn().mockResolvedValue({});
|
|
251
|
+
|
|
252
|
+
await runAgent({
|
|
253
|
+
agent,
|
|
254
|
+
task: { description: 'write a file', contract_name: 'test-stage' },
|
|
255
|
+
context: {},
|
|
256
|
+
toolRegistry,
|
|
257
|
+
providerRegistry,
|
|
258
|
+
callbacks: { onPostToolUse },
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
expect(onPostToolUse).toHaveBeenCalledOnce();
|
|
262
|
+
expect(onPostToolUse.mock.calls[0][0]).toMatchObject({
|
|
263
|
+
tool: 'repo_manager-write_file',
|
|
264
|
+
params: { path: '/tmp/foo.ts', content: 'hello' },
|
|
265
|
+
result: 'wrote file',
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('appends hook message to conversation when returned (standard path — no-op in agent loop)', async () => {
|
|
270
|
+
// The MockProvider uses the agent loop path, so append_message is a no-op here.
|
|
271
|
+
// This test verifies onPostToolUse IS called and no error is thrown.
|
|
272
|
+
const { agent, toolRegistry, providerRegistry } = makeConfig(
|
|
273
|
+
'repo_manager-write_file',
|
|
274
|
+
{ path: '/tmp/foo.ts', content: 'hello' }
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
const onPostToolUse = vi.fn().mockResolvedValue({
|
|
278
|
+
append_message: 'prettier ran successfully',
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const result = await runAgent({
|
|
282
|
+
agent,
|
|
283
|
+
task: { description: 'write a file', contract_name: 'test-stage' },
|
|
284
|
+
context: {},
|
|
285
|
+
toolRegistry,
|
|
286
|
+
providerRegistry,
|
|
287
|
+
callbacks: { onPostToolUse },
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
expect(onPostToolUse).toHaveBeenCalled();
|
|
291
|
+
// Result still succeeds
|
|
292
|
+
expect(result.tool_calls_count).toBe(1);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('injects append_message into conversation in standard (Chat Completions) path', async () => {
|
|
296
|
+
const toolRegistry = new ToolRegistry();
|
|
297
|
+
const mockExecute = vi.fn().mockResolvedValue({ success: true, output: 'wrote file' });
|
|
298
|
+
toolRegistry.register({
|
|
299
|
+
name: 'repo_manager-write_file',
|
|
300
|
+
description: 'A test tool',
|
|
301
|
+
parameters: {
|
|
302
|
+
type: 'object',
|
|
303
|
+
properties: {
|
|
304
|
+
path: { type: 'string' },
|
|
305
|
+
content: { type: 'string' },
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
execute: mockExecute,
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
const standardProvider = new StandardProvider();
|
|
312
|
+
const providerRegistry = new ProviderRegistry();
|
|
313
|
+
providerRegistry.register(standardProvider);
|
|
314
|
+
|
|
315
|
+
const agent: ResolvedAgentConfig = {
|
|
316
|
+
name: 'test-agent',
|
|
317
|
+
provider: 'standard-mock',
|
|
318
|
+
model: 'mock',
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const onPostToolUse = vi.fn().mockResolvedValue({
|
|
322
|
+
append_message: 'prettier ran and formatted the file',
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
const result = await runAgent({
|
|
326
|
+
agent,
|
|
327
|
+
task: { description: 'write a file', contract_name: 'test-stage' },
|
|
328
|
+
context: {},
|
|
329
|
+
toolRegistry,
|
|
330
|
+
providerRegistry,
|
|
331
|
+
callbacks: { onPostToolUse },
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
expect(onPostToolUse).toHaveBeenCalledOnce();
|
|
335
|
+
expect(result.tool_calls_count).toBe(1);
|
|
336
|
+
|
|
337
|
+
// Verify the post-hook message was injected into the conversation.
|
|
338
|
+
// The second call to provider.call() should include the tool result message with the append.
|
|
339
|
+
const secondCallMessages = standardProvider.receivedMessages as Array<{ role: string; content: string }>;
|
|
340
|
+
const toolResultMessage = secondCallMessages.find(m => m.role === 'user' && m.content.includes('Tool execution results'));
|
|
341
|
+
expect(toolResultMessage?.content).toContain('Post-hook note: prettier ran and formatted the file');
|
|
342
|
+
});
|
|
343
|
+
});
|