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

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,332 +0,0 @@
1
- /**
2
- * Prompt builder - constructs messages for LLM with retry escalation
3
- */
4
-
5
- import type { Message, AgentConfig, OutputContract, ResolvedContextPack, ToolCall } from '@studio-foundation/contracts';
6
- import type { SkillContent } from './tools/skills/skill-loader.js';
7
-
8
- export interface ExecutionContext {
9
- attempt: number;
10
- previous_failures?: Array<{
11
- error: string;
12
- tool_calls_count: number;
13
- }>;
14
- }
15
-
16
- export interface TaskInput {
17
- description: string;
18
- expected_output?: string;
19
- contract_name?: string;
20
- }
21
-
22
- export interface GroupFeedbackContext {
23
- iteration: number;
24
- max_iterations: number;
25
- rejection_reason: string;
26
- rejection_details?: string[];
27
- }
28
-
29
- export interface AgentContext {
30
- previous_outputs?: Record<string, unknown>;
31
- previous_tool_results?: Record<string, ToolCall[]>;
32
- repo_files?: string[];
33
- additional_context?: string;
34
- group_feedback?: GroupFeedbackContext;
35
- context_packs?: ResolvedContextPack[];
36
- startup_context?: Record<string, string>;
37
- stage_name?: string;
38
- }
39
-
40
- export interface PromptBuildConfig {
41
- agent: AgentConfig;
42
- task: TaskInput;
43
- context: AgentContext;
44
- executionContext?: ExecutionContext;
45
- outputContract?: OutputContract;
46
- promptSnippets?: string[];
47
- skills?: SkillContent[];
48
- }
49
-
50
- /**
51
- * Build prompt messages with retry escalation support
52
- */
53
- export function buildPrompt(config: PromptBuildConfig): Message[] {
54
- const { agent, task, context, executionContext } = config;
55
- const messages: Message[] = [];
56
-
57
- // Build system message
58
- let systemContent = agent.system_prompt || 'You are a helpful AI assistant.';
59
-
60
- // Add output format with concrete schema when available
61
- const contract = config.outputContract;
62
- if (contract?.schema?.required_fields?.length) {
63
- const fields = contract.schema.required_fields;
64
- const requiresToolCalls = (contract.tool_calls?.minimum ?? 0) > 0;
65
- systemContent += `
66
-
67
- ## REQUIRED OUTPUT FORMAT
68
-
69
- You MUST ${requiresToolCalls ? 'end with' : 'respond with'} a valid JSON object. ${requiresToolCalls ? 'Your final message (after all tool calls)' : 'Your entire response'} must be parseable JSON — no markdown, no code blocks, no explanatory text${requiresToolCalls ? ' in that final message' : ' before or after'}.
70
-
71
- The JSON object MUST contain these fields:
72
- ${fields.map((f: string) => `- "${f}" — ${getFieldTypeHint(f)}`).join('\n')}
73
-
74
- Example structure:
75
- {
76
- ${fields.map((f: string) => ` "${f}": ${getFieldExample(f)}`).join(',\n')}
77
- }
78
-
79
- CRITICAL: If your response is not valid JSON with ALL required fields, it will be rejected and you will be asked to retry.
80
- Pay attention to field types: arrays must be arrays, objects must be objects. Do NOT flatten structured fields into plain strings.`;
81
-
82
- // Inject accepted values for rejection detection fields
83
- const rd = contract.post_validation?.rejection_detection;
84
- if (rd?.field && rd?.approved_values?.length) {
85
- systemContent += `
86
-
87
- The "${rd.field}" field MUST be one of: ${rd.approved_values.map((v: string) => `"${v}"`).join(', ')}.
88
- Any other value means rejection.`;
89
- }
90
- } else if (task.contract_name || task.expected_output) {
91
- systemContent += `
92
-
93
- ## Output Format
94
-
95
- ${task.expected_output || `Provide your response according to the ${task.contract_name} contract.`}
96
- `;
97
- }
98
- // Inject prompt snippets from active tool plugins
99
- if (config.promptSnippets && config.promptSnippets.length > 0) {
100
- systemContent += '\n\n' + config.promptSnippets.join('\n\n');
101
- }
102
-
103
- // Inject skills (.studio/skills/*.skill.md) declared by the agent
104
- if (config.skills && config.skills.length > 0) {
105
- const skillChunks = config.skills.map(s => `## Skill: ${s.name}\n\n${s.content}`);
106
- systemContent += '\n\n' + skillChunks.join('\n\n---\n\n');
107
- }
108
-
109
- messages.push({
110
- role: 'system',
111
- content: systemContent
112
- });
113
-
114
- // Build user message with context
115
- let userContent = '';
116
-
117
- // Group feedback FIRST — must be the most prominent thing the model sees
118
- if (context.group_feedback) {
119
- const fb = context.group_feedback;
120
- userContent += `## ⚠️ REVISION REQUIRED — Iteration ${fb.iteration + 1}/${fb.max_iterations}\n\n`;
121
- userContent += `Your previous implementation was **REJECTED**. You MUST address ALL issues below before proceeding.\n\n`;
122
- userContent += `**Reason:** ${fb.rejection_reason}\n\n`;
123
- if (fb.rejection_details?.length) {
124
- userContent += `**Issues to fix:**\n`;
125
- for (const detail of fb.rejection_details) {
126
- userContent += `- ${detail}\n`;
127
- }
128
- userContent += '\n';
129
- }
130
- userContent += `DO NOT repeat the same approach. Each issue above MUST be resolved in your new implementation.\n\n---\n\n`;
131
- }
132
-
133
- // Add previous outputs if any
134
- if (context.previous_outputs && Object.keys(context.previous_outputs).length > 0) {
135
- userContent += '## Previous Stage Outputs\n\n';
136
- for (const [stage, output] of Object.entries(context.previous_outputs)) {
137
- userContent += `### ${stage}\n\`\`\`json\n${JSON.stringify(output, null, 2)}\n\`\`\`\n\n`;
138
- }
139
- }
140
-
141
- // Add repository context if provided
142
- if (context.repo_files && context.repo_files.length > 0) {
143
- userContent += `## Repository Files\n\nRelevant files:\n${context.repo_files.map(f => `- ${f}`).join('\n')}\n\n`;
144
- }
145
-
146
- // Add additional context
147
- if (context.additional_context) {
148
- userContent += `## Additional Context\n\n${context.additional_context}\n\n`;
149
- }
150
-
151
- // Add stage name (used in parallel groups so agents can identify their slot)
152
- if (context.stage_name) {
153
- userContent += `## Stage Name\n\n${context.stage_name}\n\n`;
154
- }
155
-
156
- // Render pipeline startup context — each key as a ### section
157
- if (context.startup_context && Object.keys(context.startup_context).length > 0) {
158
- userContent += '## Pipeline Startup Context\n\n';
159
- for (const [key, value] of Object.entries(context.startup_context)) {
160
- userContent += `### ${key}\n\`\`\`\n${value}\n\`\`\`\n\n`;
161
- }
162
- }
163
-
164
- // Add context packs — each as a top-level ## section, sections as ###
165
- if (context.context_packs?.length) {
166
- for (const pack of context.context_packs) {
167
- userContent += `## ${pack.name}`;
168
- if (pack.description) userContent += ` — ${pack.description}`;
169
- userContent += '\n\n';
170
- for (const section of pack.sections) {
171
- userContent += `### ${section.title}\n\n${section.content}\n\n`;
172
- }
173
- }
174
- }
175
-
176
- // Add previous stage tool results (discoveries) — placed last before the task
177
- // so they have high recency salience for the LLM
178
- if (context.previous_tool_results && Object.keys(context.previous_tool_results).length > 0) {
179
- userContent += renderToolResults(context.previous_tool_results);
180
- }
181
-
182
- // Add task description
183
- userContent += `## Task\n\n${task.description}`;
184
-
185
- // Add retry escalation if this is a retry attempt
186
- if (executionContext && executionContext.attempt > 1) {
187
- userContent += getRetryEscalationMessage(executionContext);
188
- }
189
-
190
- messages.push({
191
- role: 'user',
192
- content: userContent
193
- });
194
-
195
- return messages;
196
- }
197
-
198
- /**
199
- * Get retry escalation message based on attempt number
200
- * Progressively stronger messaging to enforce tool usage
201
- */
202
- function getRetryEscalationMessage(executionContext: ExecutionContext): string {
203
- const { attempt, previous_failures } = executionContext;
204
- let message = '\n\n---\n\n';
205
-
206
- const hasStringError = previous_failures?.some(f => f.error.includes('Expected object output, got string'));
207
- const hasMissingTool = previous_failures?.some(f => f.error.includes('Required tool'));
208
-
209
- if (attempt === 2) {
210
- message += `⚠️ **RETRY ATTEMPT ${attempt}**
211
-
212
- Your previous attempt failed. Please review the errors below and fix the issues:
213
-
214
- `;
215
- previous_failures?.forEach((failure, idx) => {
216
- message += `Attempt ${idx + 1}: ${failure.error}\n`;
217
- if (failure.tool_calls_count === 0) {
218
- message += ` → Problem: No tool calls were made. You need to use the available tools.\n`;
219
- }
220
- });
221
-
222
- if (hasStringError) {
223
- message += `\n⚠️ Your response was plain text, not JSON. Your FINAL message (after all tool calls) must be ONLY a raw JSON object — no explanations, no markdown.`;
224
- }
225
- if (hasMissingTool) {
226
- message += `\n⚠️ You must use the required tools to make the actual changes, not just read or explore.`;
227
- }
228
-
229
- message += `\nMake sure to use the tools available to you to complete the task.`;
230
- } else if (attempt === 3) {
231
- message += `🚨 **CRITICAL: RETRY ATTEMPT ${attempt}**
232
-
233
- Multiple previous attempts have failed. This is your ${attempt}rd attempt.
234
-
235
- Previous errors:
236
- `;
237
- previous_failures?.forEach((failure, idx) => {
238
- message += `- Attempt ${idx + 1}: ${failure.error} (tool_calls: ${failure.tool_calls_count})\n`;
239
- });
240
-
241
- message += `
242
- **YOU MUST:**
243
- 1. Use the available tools to make the requested changes — reading and exploring is NOT enough
244
- 2. After all tool calls are done, send ONE final message that is ONLY a raw JSON object
245
- 3. No markdown, no explanations, no code fences — just {"summary": "...", ...}
246
-
247
- DO NOT just provide instructions or descriptions. EXECUTE the changes, then return JSON.`;
248
- } else if (attempt >= 4) {
249
- message += `⛔ **FINAL WARNING: RETRY ATTEMPT ${attempt}**
250
-
251
- This is attempt ${attempt}. Previous attempts all failed:
252
-
253
- `;
254
- previous_failures?.forEach((failure, idx) => {
255
- message += `Attempt ${idx + 1}:\n Error: ${failure.error}\n Tool calls made: ${failure.tool_calls_count}\n\n`;
256
- });
257
-
258
- message += `
259
- 🔴 **ABSOLUTE REQUIREMENTS:**
260
-
261
- 1. Use the required tools to make the actual changes — DO NOT just describe them
262
- 2. tool_calls = 0 is an AUTOMATIC FAILURE
263
- 3. You must make ACTUAL changes, not describe them
264
- 4. Read existing files before modifying them
265
- 5. Your FINAL message must be ONLY a JSON object
266
- 6. If your final message contains ANY text outside the JSON, it WILL be rejected
267
-
268
- If you do not make real tool calls AND return valid JSON, this task will fail permanently.`;
269
- }
270
-
271
- return message;
272
- }
273
-
274
- /** Known field type hints for required_fields schema injection */
275
- const FIELD_TYPE_HINTS: Record<string, string> = {
276
- summary: 'string',
277
- requirements: 'array of objects, each with relevant keys (e.g. {id, description, priority})',
278
- acceptance_criteria: 'array of strings',
279
- files_changed: 'array of objects with {path, status, changes}',
280
- files_to_modify: 'array of strings (file paths)',
281
- steps: 'array of strings',
282
- issues: 'array of strings or objects',
283
- };
284
-
285
- /** Known field example values for required_fields schema injection */
286
- const FIELD_EXAMPLES: Record<string, string> = {
287
- summary: '"A concise summary of the result"',
288
- requirements: '[{"id": "REQ-1", "description": "...", "priority": "high"}]',
289
- acceptance_criteria: '["criterion 1", "criterion 2"]',
290
- files_changed: '[{"path": "src/file.tsx", "status": "modified", "changes": "description of changes"}]',
291
- files_to_modify: '["src/file.tsx", "src/utils/helper.ts"]',
292
- steps: '["step 1", "step 2", "step 3"]',
293
- issues: '["issue 1", "issue 2"]',
294
- };
295
-
296
- const TOOL_RESULT_MAX_CHARS = 2000;
297
-
298
- function renderToolResults(previous_tool_results: Record<string, ToolCall[]>): string {
299
- let out = '';
300
- for (const [stage, toolCalls] of Object.entries(previous_tool_results)) {
301
- out += `## Previous Stage Discoveries (${stage})\n\n`;
302
- for (const tc of toolCalls) {
303
- // Use first string argument value as the display label
304
- const label = Object.values(tc.arguments).find(v => typeof v === 'string') ?? JSON.stringify(tc.arguments);
305
- out += `### ${tc.name}(${label})\n`;
306
- if (tc.error) {
307
- out += `Error: ${tc.error}\n\n`;
308
- } else {
309
- // For write operations, render the content being written from arguments
310
- // (the result is just {written: true} which is not useful for reviewers).
311
- // For all other operations, render the result as usual.
312
- const writeContent = typeof tc.arguments?.content === 'string'
313
- ? tc.arguments.content as string
314
- : null;
315
- const raw = writeContent ?? JSON.stringify(tc.result, null, 2);
316
- const body = raw.length > TOOL_RESULT_MAX_CHARS
317
- ? raw.slice(0, TOOL_RESULT_MAX_CHARS) + '\n[truncated]'
318
- : raw;
319
- out += `\`\`\`\n${body}\n\`\`\`\n\n`;
320
- }
321
- }
322
- }
323
- return out;
324
- }
325
-
326
- function getFieldTypeHint(field: string): string {
327
- return FIELD_TYPE_HINTS[field] || 'string';
328
- }
329
-
330
- function getFieldExample(field: string): string {
331
- return FIELD_EXAMPLES[field] || '"..."';
332
- }
@@ -1,101 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { AnthropicProvider } from './anthropic.js';
3
-
4
- // We mock the entire SDK so no real HTTP calls are made.
5
- // The fake stream hangs on finalMessage() to simulate the bug scenario.
6
- vi.mock('@anthropic-ai/sdk', () => {
7
- return {
8
- default: class FakeAnthropic {
9
- messages = {
10
- stream: (_params: unknown, _opts: unknown) => {
11
- const listeners = new Map<string, ((...args: unknown[]) => void)[]>();
12
- return {
13
- on(event: string, handler: (...args: unknown[]) => void) {
14
- if (!listeners.has(event)) listeners.set(event, []);
15
- listeners.get(event)!.push(handler);
16
- return this;
17
- },
18
- // finalMessage() hangs forever — this is the bug we're fixing
19
- finalMessage: () => new Promise(() => {}),
20
- };
21
- },
22
- create: (_params: unknown, _opts: unknown) => new Promise(() => {}),
23
- };
24
- },
25
- };
26
- });
27
-
28
- describe('AnthropicProvider', () => {
29
- let provider: AnthropicProvider;
30
-
31
- beforeEach(() => {
32
- provider = new AnthropicProvider('test-key');
33
- });
34
-
35
- it('aborts streaming call when signal fires', async () => {
36
- const controller = new AbortController();
37
- const onToken = vi.fn();
38
-
39
- const callPromise = provider.call(
40
- {
41
- model: 'claude-haiku-4-20250514',
42
- messages: [{ role: 'user', content: 'hello' }],
43
- },
44
- onToken,
45
- controller.signal,
46
- );
47
-
48
- // Fire signal after a tick to let the stream start
49
- await Promise.resolve();
50
- controller.abort();
51
-
52
- await expect(callPromise).rejects.toSatisfy(
53
- (e: unknown) => e instanceof DOMException && (e as DOMException).name === 'AbortError',
54
- );
55
- });
56
-
57
- it('aborts non-streaming call when signal fires', async () => {
58
- const controller = new AbortController();
59
-
60
- const callPromise = provider.call(
61
- {
62
- model: 'claude-haiku-4-20250514',
63
- messages: [{ role: 'user', content: 'hello' }],
64
- },
65
- undefined,
66
- controller.signal,
67
- );
68
-
69
- await Promise.resolve();
70
- controller.abort();
71
-
72
- await expect(callPromise).rejects.toSatisfy(
73
- (e: unknown) => e instanceof DOMException && (e as DOMException).name === 'AbortError',
74
- );
75
- });
76
-
77
- it('resolves normally when signal is not aborted', async () => {
78
- // Directly override stream on the already-created client instance.
79
- // (prototype override won't work because messages is an instance property.)
80
- const client = (provider as unknown as { client: { messages: Record<string, unknown> } }).client;
81
- client.messages.stream = () => ({
82
- on: (_: string, __: unknown) => ({}),
83
- finalMessage: () => Promise.resolve({
84
- content: [{ type: 'text', text: '{"ok":true}' }],
85
- stop_reason: 'end_turn',
86
- usage: { input_tokens: 10, output_tokens: 5 },
87
- }),
88
- });
89
-
90
- const controller = new AbortController();
91
- const result = await provider.call(
92
- {
93
- model: 'claude-haiku-4-20250514',
94
- messages: [{ role: 'user', content: 'hello' }],
95
- },
96
- vi.fn(),
97
- controller.signal,
98
- );
99
- expect(result).toBeDefined();
100
- });
101
- });
@@ -1,135 +0,0 @@
1
- /**
2
- * Anthropic 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 Anthropic from '@anthropic-ai/sdk';
8
- import type {
9
- Message,
10
- MessageParam,
11
- Tool,
12
- TextBlockParam
13
- } from '@anthropic-ai/sdk/resources/messages';
14
- import { raceSignal } from '../utils/race-signal.js';
15
-
16
- export class AnthropicProvider implements Provider {
17
- readonly name = 'anthropic';
18
- private client: Anthropic;
19
-
20
- constructor(apiKey?: string) {
21
- this.client = new Anthropic({
22
- apiKey: apiKey || process.env.ANTHROPIC_API_KEY
23
- });
24
- }
25
-
26
- async call(request: LLMRequest, onToken?: (token: string) => void, signal?: AbortSignal): Promise<LLMResponse> {
27
- const params = this.buildParams(request);
28
-
29
- if (onToken) {
30
- // Streaming path
31
- const stream = this.client.messages.stream(params, { signal });
32
- stream.on('text', (textDelta: string) => {
33
- if (signal?.aborted) return; // guard: don't emit after abort
34
- onToken(textDelta);
35
- });
36
- // KEY FIX: race finalMessage() against signal abort.
37
- // Without this, finalMessage() hangs forever when the HTTP connection
38
- // is killed mid-stream (the stream 'end' event never fires).
39
- const response = await raceSignal(stream.finalMessage(), signal);
40
- return this.parseResponse(response);
41
- }
42
-
43
- // Non-streaming path
44
- const response = await raceSignal(this.client.messages.create(params, { signal }), signal);
45
- return this.parseResponse(response);
46
- }
47
-
48
- private buildParams(request: LLMRequest) {
49
- // Extract system messages (Anthropic handles them separately)
50
- const systemMessages = request.messages.filter(m => m.role === 'system');
51
- const systemContent = systemMessages.map(m => m.content).join('\n\n');
52
-
53
- // Convert remaining messages to Anthropic format
54
- const anthropicMessages: MessageParam[] = request.messages
55
- .filter(m => m.role !== 'system')
56
- .map(msg => {
57
- if (msg.role === 'user') {
58
- return { role: 'user' as const, content: msg.content };
59
- }
60
- if (msg.role === 'assistant') {
61
- return { role: 'assistant' as const, content: msg.content };
62
- }
63
- throw new Error(`Unsupported message role: ${msg.role}`);
64
- });
65
-
66
- // Convert tool definitions to Anthropic format
67
- // Mark the last tool with cache_control so Anthropic caches system + tools block
68
- const rawTools = request.tools ?? [];
69
- const tools: Tool[] | undefined = rawTools.length > 0
70
- ? rawTools.map((tool, index) => ({
71
- name: tool.name,
72
- description: tool.description,
73
- input_schema: {
74
- type: 'object',
75
- ...tool.parameters
76
- } as Tool['input_schema'],
77
- ...(index === rawTools.length - 1
78
- ? { cache_control: { type: 'ephemeral' as const } }
79
- : {})
80
- }))
81
- : undefined;
82
-
83
- // Mark system prompt with cache_control — stable across retries and group iterations
84
- const systemParam: TextBlockParam[] | undefined = systemContent
85
- ? [{ type: 'text', text: systemContent, cache_control: { type: 'ephemeral' as const } }]
86
- : undefined;
87
-
88
- return {
89
- model: request.model,
90
- max_tokens: request.max_tokens || 4096,
91
- system: systemParam,
92
- messages: anthropicMessages,
93
- tools: tools,
94
- temperature: request.temperature
95
- };
96
- }
97
-
98
- private parseResponse(response: Message): LLMResponse {
99
- // Parse tool calls and text content from response
100
- const tool_calls: Array<{
101
- id: string;
102
- name: string;
103
- arguments: Record<string, unknown>;
104
- }> = [];
105
- let textContent = '';
106
-
107
- for (const block of response.content) {
108
- if (block.type === 'text') {
109
- textContent += block.text;
110
- } else if (block.type === 'tool_use') {
111
- tool_calls.push({
112
- id: block.id,
113
- name: block.name,
114
- arguments: block.input as Record<string, unknown>
115
- });
116
- }
117
- }
118
-
119
- const cachedInputTokens = response.usage.cache_read_input_tokens ?? 0;
120
- const cacheCreationTokens = response.usage.cache_creation_input_tokens ?? 0;
121
-
122
- return {
123
- content: textContent,
124
- tool_calls,
125
- finish_reason: response.stop_reason || 'stop',
126
- usage: {
127
- prompt_tokens: response.usage.input_tokens,
128
- completion_tokens: response.usage.output_tokens,
129
- total_tokens: response.usage.input_tokens + response.usage.output_tokens,
130
- cached_input_tokens: cachedInputTokens > 0 ? cachedInputTokens : undefined,
131
- cache_creation_tokens: cacheCreationTokens > 0 ? cacheCreationTokens : undefined
132
- }
133
- };
134
- }
135
- }
@@ -1,57 +0,0 @@
1
- import { randomUUID } from 'node:crypto';
2
- import type { LLMRequest, LLMResponse } from '@studio-foundation/contracts';
3
- import type { AgentLoopProvider, AgentLoopResult, ToolCallOutcome } from './provider.js';
4
-
5
- export interface MockStageConfig {
6
- output: Record<string, unknown>;
7
- tool_calls: Array<{
8
- name: string;
9
- arguments: Record<string, unknown>;
10
- }>;
11
- }
12
-
13
- export class MockProvider implements AgentLoopProvider {
14
- readonly name = 'mock';
15
-
16
- constructor(private readonly stages: Map<string, MockStageConfig>) {}
17
-
18
- async call(_request: LLMRequest, _onToken?: (token: string) => void, _signal?: AbortSignal): Promise<LLMResponse> {
19
- throw new Error('MockProvider: use runAgentLoop, not call()');
20
- }
21
-
22
- async runAgentLoop(
23
- request: LLMRequest,
24
- executeTool: (name: string, args: Record<string, unknown>, callId: string) => Promise<ToolCallOutcome>,
25
- onToken?: (token: string) => void,
26
- _signal?: AbortSignal
27
- ): Promise<AgentLoopResult> {
28
- if (!request.stage_name) {
29
- throw new Error('MockProvider requires stage_name in LLMRequest');
30
- }
31
-
32
- const config = this.stages.get(request.stage_name);
33
- if (!config) {
34
- throw new Error(
35
- `Unknown mock stage: "${request.stage_name}". Add it to mock.yaml.`
36
- );
37
- }
38
-
39
- // Emit a fake token to exercise the streaming pipeline in tests
40
- onToken?.('...');
41
-
42
- const toolCallResults: AgentLoopResult['tool_calls'] = [];
43
-
44
- for (const tc of config.tool_calls) {
45
- const callId = randomUUID();
46
- const outcome = await executeTool(tc.name, tc.arguments, callId);
47
- toolCallResults.push({ id: callId, name: tc.name, arguments: tc.arguments, ...outcome });
48
- }
49
-
50
- return {
51
- content: JSON.stringify(config.output),
52
- tool_calls: toolCallResults,
53
- finish_reason: 'stop',
54
- usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
55
- };
56
- }
57
- }