@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.
- package/package.json +6 -3
- package/ARCHITECTURE.md +0 -53
- package/configs/agents/analyst.agent.yaml +0 -31
- package/configs/agents/code-generator.agent.yaml +0 -31
- package/configs/agents/generic.agent.yaml +0 -23
- package/src/__tests__/script-executor.test.ts +0 -180
- package/src/index.ts +0 -68
- package/src/integrations/integration-loader.test.ts +0 -88
- package/src/integrations/integration-loader.ts +0 -68
- package/src/middleware/anonymization.ts +0 -38
- package/src/plugins/index.ts +0 -4
- package/src/plugins/mcp-client.test.ts +0 -148
- package/src/plugins/mcp-client.ts +0 -128
- package/src/plugins/oauth-provider.test.ts +0 -167
- package/src/plugins/oauth-provider.ts +0 -175
- package/src/plugins/plugin-loader.test.ts +0 -114
- package/src/plugins/plugin-loader.ts +0 -90
- package/src/prompt-builder.test.ts +0 -167
- package/src/prompt-builder.ts +0 -332
- package/src/providers/anthropic.test.ts +0 -101
- package/src/providers/anthropic.ts +0 -135
- package/src/providers/mock.ts +0 -57
- package/src/providers/ollama.test.ts +0 -166
- package/src/providers/ollama.ts +0 -152
- package/src/providers/openai-responses.ts +0 -212
- package/src/providers/openai.test.ts +0 -67
- package/src/providers/openai.ts +0 -139
- package/src/providers/provider.ts +0 -54
- package/src/providers/registry.ts +0 -77
- package/src/runner.test.ts +0 -343
- package/src/runner.ts +0 -396
- package/src/script-executor.ts +0 -107
- package/src/tools/builtin/git.ts +0 -311
- package/src/tools/builtin/patch.ts +0 -257
- package/src/tools/builtin/repo-manager.ts +0 -142
- package/src/tools/builtin/search.ts +0 -108
- package/src/tools/builtin/shell.ts +0 -82
- package/src/tools/builtin/studio-run.ts +0 -73
- package/src/tools/builtin/web-search.test.ts +0 -122
- package/src/tools/builtin/web-search.ts +0 -101
- package/src/tools/errors.test.ts +0 -12
- package/src/tools/errors.ts +0 -6
- package/src/tools/plugin-loader.test.ts +0 -130
- package/src/tools/plugin-loader.ts +0 -203
- package/src/tools/skills/README.md +0 -49
- package/src/tools/skills/skill-loader.test.ts +0 -106
- package/src/tools/skills/skill-loader.ts +0 -62
- package/src/tools/tool-executor.test.ts +0 -88
- package/src/tools/tool-executor.ts +0 -84
- package/src/tools/tool-registry.ts +0 -130
- package/src/tools/yaml-executor.ts +0 -120
- package/src/utils/race-signal.test.ts +0 -50
- package/src/utils/race-signal.ts +0 -17
- package/templates/integrations/linear.integration.yaml +0 -35
- package/templates/integrations/slack.integration.yaml +0 -22
- package/templates/integrations/webhook.integration.yaml +0 -17
- package/templates/tools/git.tool.yaml +0 -80
- package/templates/tools/repo-manager.tool.yaml +0 -64
- package/templates/tools/search.tool.yaml +0 -22
- package/templates/tools/shell.tool.yaml +0 -19
- package/templates/tools/web-search.tool.yaml +0 -24
- package/tests/anonymization-middleware.test.ts +0 -61
- package/tests/anthropic.test.ts +0 -87
- package/tests/apply-patch.test.ts +0 -355
- package/tests/fixtures/tools/test-builtin.tool.yaml +0 -14
- package/tests/fixtures/tools/test-shell.tool.yaml +0 -19
- package/tests/mock-provider.test.ts +0 -104
- package/tests/openai.test.ts +0 -72
- package/tests/plugin-loader.test.ts +0 -54
- package/tests/prompt-builder.test.ts +0 -468
- package/tests/runner-anonymization.test.ts +0 -89
- package/tests/runner.test.ts +0 -885
- package/tests/studio-run.test.ts +0 -94
- package/tests/tool-executor.test.ts +0 -115
- package/tests/tool-registry.test.ts +0 -84
- package/tests/yaml-executor.test.ts +0 -76
- package/tsconfig.json +0 -20
- package/vitest.config.ts +0 -7
package/src/prompt-builder.ts
DELETED
|
@@ -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
|
-
}
|
package/src/providers/mock.ts
DELETED
|
@@ -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
|
-
}
|