Skip to content

Commit 4e8d313

Browse files
tylergibbs1claude
andcommitted
Add phase 6 features: tool timeout/isEnabled, RunHooks, tool guardrails, handoff inputType/inputFilter/isEnabled, hosted tool streaming, fileSearchTool, computerUseTool, new model settings, toolUseBehavior function variant, toolErrorFormatter, callModelInputFilter, resetToolChoice, errorHandlers
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 07fe36f commit 4e8d313

16 files changed

Lines changed: 2562 additions & 79 deletions

src/azure/chat-completions-model.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,11 @@ export class AzureChatCompletionsModel implements Model {
205205
if (s.seed !== undefined) body.seed = s.seed;
206206
if (s.reasoningEffort !== undefined) body.reasoning_effort = s.reasoningEffort;
207207
if (s.promptCacheKey !== undefined) body.prompt_cache_key = s.promptCacheKey;
208+
if (s.store !== undefined) body.store = s.store;
209+
if (s.metadata !== undefined) body.metadata = s.metadata;
210+
if (s.user !== undefined) body.user = s.user;
211+
if (s.logprobs !== undefined) body.logprobs = s.logprobs;
212+
if (s.topLogprobs !== undefined) body.top_logprobs = s.topLogprobs;
208213
}
209214

210215
return body;
@@ -351,15 +356,15 @@ function serializeMessage(msg: ChatMessage): Record<string, unknown> {
351356
interface AzureChatResponse {
352357
choices: {
353358
message: {
354-
role: string;
359+
role: "assistant";
355360
content: string | null;
356361
tool_calls?: {
357362
id: string;
358-
type: string;
363+
type: "function";
359364
function: { name: string; arguments: string };
360365
}[];
361366
};
362-
finish_reason: string;
367+
finish_reason: FinishReason | string;
363368
}[];
364369
usage?: {
365370
prompt_tokens: number;
@@ -384,19 +389,19 @@ interface AzureErrorResponse {
384389
interface AzureStreamChunk {
385390
choices?: {
386391
delta?: {
387-
role?: string;
392+
role?: "assistant";
388393
content?: string;
389394
tool_calls?: {
390395
index: number;
391396
id?: string;
392-
type?: string;
397+
type?: "function";
393398
function?: {
394399
name?: string;
395400
arguments?: string;
396401
};
397402
}[];
398403
};
399-
finish_reason?: string;
404+
finish_reason?: FinishReason | string;
400405
}[];
401406
usage?: {
402407
prompt_tokens: number;

src/azure/responses-model.ts

Lines changed: 93 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,22 @@ export interface AzureResponsesModelConfig {
3131

3232
const DEFAULT_API_VERSION = "2025-04-01-preview";
3333

34+
type HostedToolStatus = "in_progress" | "completed" | "searching" | "generating" | "interpreting";
35+
const HOSTED_TOOL_EVENT_MAP = new Map<string, { toolType: string; status: HostedToolStatus }>([
36+
["response.web_search_call.in_progress", { toolType: "web_search", status: "in_progress" }],
37+
["response.web_search_call.searching", { toolType: "web_search", status: "searching" }],
38+
["response.web_search_call.completed", { toolType: "web_search", status: "completed" }],
39+
["response.file_search_call.in_progress", { toolType: "file_search", status: "in_progress" }],
40+
["response.file_search_call.searching", { toolType: "file_search", status: "searching" }],
41+
["response.file_search_call.completed", { toolType: "file_search", status: "completed" }],
42+
["response.code_interpreter_call.in_progress", { toolType: "code_interpreter", status: "in_progress" }],
43+
["response.code_interpreter_call.interpreting", { toolType: "code_interpreter", status: "interpreting" }],
44+
["response.code_interpreter_call.completed", { toolType: "code_interpreter", status: "completed" }],
45+
["response.image_generation_call.in_progress", { toolType: "image_generation", status: "in_progress" }],
46+
["response.image_generation_call.generating", { toolType: "image_generation", status: "generating" }],
47+
["response.image_generation_call.completed", { toolType: "image_generation", status: "completed" }],
48+
]);
49+
3450
export class AzureResponsesModel implements Model {
3551
private readonly url: string;
3652
private readonly apiKey?: string;
@@ -149,6 +165,25 @@ export class AzureResponsesModel implements Model {
149165
}
150166
break;
151167
}
168+
// Hosted tool streaming events
169+
case "response.web_search_call.in_progress":
170+
case "response.web_search_call.searching":
171+
case "response.web_search_call.completed":
172+
case "response.file_search_call.in_progress":
173+
case "response.file_search_call.searching":
174+
case "response.file_search_call.completed":
175+
case "response.code_interpreter_call.in_progress":
176+
case "response.code_interpreter_call.interpreting":
177+
case "response.code_interpreter_call.completed":
178+
case "response.image_generation_call.in_progress":
179+
case "response.image_generation_call.generating":
180+
case "response.image_generation_call.completed": {
181+
const mapped = HOSTED_TOOL_EVENT_MAP.get(event.type);
182+
if (mapped) {
183+
yield { type: "hosted_tool_call", toolType: mapped.toolType, status: mapped.status };
184+
}
185+
break;
186+
}
152187
case "response.completed": {
153188
const resp = event.response;
154189
if (resp?.id) {
@@ -170,6 +205,29 @@ export class AzureResponsesModel implements Model {
170205
finishReason = mapStatus(resp?.status);
171206
break;
172207
}
208+
case "response.failed": {
209+
const errorMsg = event.response?.error?.message
210+
?? "Response failed";
211+
throw new ModelError(
212+
`Azure API response failed: ${errorMsg}`,
213+
{ status: 200 },
214+
);
215+
}
216+
case "error": {
217+
const err = event.error;
218+
const errorType = err?.type ?? "unknown";
219+
const errorMsg = err?.message ?? "Unknown error";
220+
if (errorType === "too_many_requests") {
221+
throw new ModelError(
222+
`Azure API rate limited (SSE): ${errorMsg}`,
223+
{ status: 429 },
224+
);
225+
}
226+
throw new ModelError(
227+
`Azure API stream error (${errorType}): ${errorMsg}`,
228+
{ status: 200 },
229+
);
230+
}
173231
}
174232
}
175233

@@ -195,9 +253,11 @@ export class AzureResponsesModel implements Model {
195253
request: ModelRequest,
196254
stream: boolean,
197255
): Record<string, unknown> {
256+
// ModelSettings.store overrides config-level store
257+
const effectiveStore = request.modelSettings?.store ?? this.store;
198258
const body: Record<string, unknown> = {
199259
model: this.deployment,
200-
store: this.store,
260+
store: effectiveStore,
201261
};
202262

203263
const { instructions, input } = convertMessages(request.messages);
@@ -227,23 +287,33 @@ export class AzureResponsesModel implements Model {
227287
}
228288

229289
// Only send previous_response_id when store is enabled (API needs to persist responses)
230-
if (this.store && request.previousResponseId) {
290+
if (effectiveStore && request.previousResponseId) {
231291
body.previous_response_id = request.previousResponseId;
232292
}
233293

234294
const s = request.modelSettings;
235295
if (s) {
236296
if (s.temperature !== undefined) body.temperature = s.temperature;
237297
if (s.topP !== undefined) body.top_p = s.topP;
238-
if (s.maxTokens !== undefined) body.max_output_tokens = s.maxTokens;
239-
if (s.maxCompletionTokens !== undefined)
298+
if (s.maxCompletionTokens !== undefined) {
240299
body.max_output_tokens = s.maxCompletionTokens;
300+
} else if (s.maxTokens !== undefined) {
301+
body.max_output_tokens = s.maxTokens;
302+
}
241303
if (s.toolChoice !== undefined) body.tool_choice = convertToolChoice(s.toolChoice);
242304
if (s.parallelToolCalls !== undefined) body.parallel_tool_calls = s.parallelToolCalls;
243-
if (s.reasoningEffort !== undefined)
244-
body.reasoning = { effort: s.reasoningEffort };
305+
if (s.reasoningEffort !== undefined || s.reasoningSummary !== undefined) {
306+
const reasoning: Record<string, unknown> = {};
307+
if (s.reasoningEffort !== undefined) reasoning.effort = s.reasoningEffort;
308+
if (s.reasoningSummary !== undefined) reasoning.summary = s.reasoningSummary;
309+
body.reasoning = reasoning;
310+
}
245311
if (s.promptCacheKey !== undefined)
246312
body.prompt_cache_key = s.promptCacheKey;
313+
if (s.truncation !== undefined) body.truncation = s.truncation;
314+
if (s.store !== undefined) body.store = s.store;
315+
if (s.metadata !== undefined) body.metadata = s.metadata;
316+
if (s.user !== undefined) body.user = s.user;
247317
}
248318

249319
return body;
@@ -569,4 +639,20 @@ type ResponsesStreamEvent =
569639
| { type: "response.output_item.added"; item?: ResponsesStreamItem }
570640
| { type: "response.function_call_arguments.delta"; item_id?: string; delta?: string }
571641
| { type: "response.output_item.done"; item?: ResponsesStreamItem }
572-
| { type: "response.completed"; response?: { id?: string; status?: string; usage?: ResponsesUsage } };
642+
| { type: "response.completed"; response?: { id?: string; status?: string; usage?: ResponsesUsage } }
643+
// Hosted tool streaming events
644+
| { type: "response.web_search_call.in_progress" }
645+
| { type: "response.web_search_call.searching" }
646+
| { type: "response.web_search_call.completed" }
647+
| { type: "response.file_search_call.in_progress" }
648+
| { type: "response.file_search_call.searching" }
649+
| { type: "response.file_search_call.completed" }
650+
| { type: "response.code_interpreter_call.in_progress" }
651+
| { type: "response.code_interpreter_call.interpreting" }
652+
| { type: "response.code_interpreter_call.completed" }
653+
| { type: "response.image_generation_call.in_progress" }
654+
| { type: "response.image_generation_call.generating" }
655+
| { type: "response.image_generation_call.completed" }
656+
// Error / failure events (e.g. SSE-level 429)
657+
| { type: "response.failed"; response?: { id?: string; status?: string; error?: { message?: string; type?: string; code?: string } } }
658+
| { type: "error"; error?: { type?: string; code?: string; message?: string } };

src/core/builtin-tools.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,42 @@ export function imageGenerationTool(): HostedTool {
8181
},
8282
};
8383
}
84+
85+
export interface FileSearchToolConfig {
86+
vectorStoreIds: string[];
87+
maxNumResults?: number;
88+
}
89+
90+
export function fileSearchTool(config: FileSearchToolConfig): HostedTool {
91+
const definition: HostedToolDefinition = {
92+
type: "file_search",
93+
vector_store_ids: config.vectorStoreIds,
94+
};
95+
if (config.maxNumResults !== undefined) {
96+
definition.max_num_results = config.maxNumResults;
97+
}
98+
return {
99+
type: "hosted",
100+
name: "file_search",
101+
definition,
102+
};
103+
}
104+
105+
export interface ComputerUseToolConfig {
106+
displayWidth: number;
107+
displayHeight: number;
108+
environment?: "windows" | "mac" | "linux";
109+
}
110+
111+
export function computerUseTool(config: ComputerUseToolConfig): HostedTool {
112+
return {
113+
type: "hosted",
114+
name: "computer_use_preview",
115+
definition: {
116+
type: "computer_use_preview",
117+
display_width: config.displayWidth,
118+
display_height: config.displayHeight,
119+
environment: config.environment ?? "linux",
120+
},
121+
};
122+
}

src/core/errors.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,18 @@ export class MaxBudgetExceededError extends StratusError {
7373
}
7474
}
7575

76+
export class ToolTimeoutError extends StratusError {
77+
readonly toolName: string;
78+
readonly timeoutMs: number;
79+
80+
constructor(toolName: string, timeoutMs: number) {
81+
super(`Tool "${toolName}" timed out after ${timeoutMs}ms`);
82+
this.name = "ToolTimeoutError";
83+
this.toolName = toolName;
84+
this.timeoutMs = timeoutMs;
85+
}
86+
}
87+
7688
export class OutputGuardrailTripwireTriggered extends StratusError {
7789
readonly guardrailName: string;
7890
readonly outputInfo?: unknown;

src/core/guardrails.ts

Lines changed: 69 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ export interface GuardrailResult {
88
outputInfo?: unknown;
99
}
1010

11+
export interface GuardrailRunResult {
12+
guardrailName: string;
13+
result: GuardrailResult;
14+
}
15+
1116
export interface InputGuardrail<TContext = unknown> {
1217
name: string;
1318
execute: (input: string, context: TContext) => GuardrailResult | Promise<GuardrailResult>;
@@ -18,40 +23,96 @@ export interface OutputGuardrail<TContext = unknown> {
1823
execute: (output: string, context: TContext) => GuardrailResult | Promise<GuardrailResult>;
1924
}
2025

26+
/** Guardrail that runs before a tool call */
27+
export interface ToolInputGuardrail<TContext = unknown> {
28+
name: string;
29+
execute: (params: {
30+
toolName: string;
31+
toolArgs: Record<string, unknown>;
32+
context: TContext;
33+
}) => GuardrailResult | Promise<GuardrailResult>;
34+
}
35+
36+
/** Guardrail that runs after a tool call */
37+
export interface ToolOutputGuardrail<TContext = unknown> {
38+
name: string;
39+
execute: (params: {
40+
toolName: string;
41+
toolResult: string;
42+
context: TContext;
43+
}) => GuardrailResult | Promise<GuardrailResult>;
44+
}
45+
2146
export async function runInputGuardrails<TContext>(
2247
guardrails: InputGuardrail<TContext>[],
2348
input: string,
2449
context: TContext,
25-
): Promise<void> {
50+
): Promise<GuardrailRunResult[]> {
2651
const results = await Promise.all(
2752
guardrails.map(async (g) => {
2853
const result = await g.execute(input, context);
29-
return { guardrail: g, result };
54+
return { guardrailName: g.name, result };
3055
}),
3156
);
3257

33-
for (const { guardrail, result } of results) {
58+
for (const { guardrailName, result } of results) {
3459
if (result.tripwireTriggered) {
35-
throw new InputGuardrailTripwireTriggered(guardrail.name, result.outputInfo);
60+
throw new InputGuardrailTripwireTriggered(guardrailName, result.outputInfo);
3661
}
3762
}
63+
64+
return results;
3865
}
3966

4067
export async function runOutputGuardrails<TContext>(
4168
guardrails: OutputGuardrail<TContext>[],
4269
output: string,
4370
context: TContext,
44-
): Promise<void> {
71+
): Promise<GuardrailRunResult[]> {
4572
const results = await Promise.all(
4673
guardrails.map(async (g) => {
4774
const result = await g.execute(output, context);
48-
return { guardrail: g, result };
75+
return { guardrailName: g.name, result };
4976
}),
5077
);
5178

52-
for (const { guardrail, result } of results) {
79+
for (const { guardrailName, result } of results) {
5380
if (result.tripwireTriggered) {
54-
throw new OutputGuardrailTripwireTriggered(guardrail.name, result.outputInfo);
81+
throw new OutputGuardrailTripwireTriggered(guardrailName, result.outputInfo);
5582
}
5683
}
84+
85+
return results;
86+
}
87+
88+
export async function runToolInputGuardrails<TContext>(
89+
guardrails: ToolInputGuardrail<TContext>[],
90+
toolName: string,
91+
toolArgs: Record<string, unknown>,
92+
context: TContext,
93+
): Promise<GuardrailRunResult[]> {
94+
const results = await Promise.all(
95+
guardrails.map(async (g) => {
96+
const result = await g.execute({ toolName, toolArgs, context });
97+
return { guardrailName: g.name, result };
98+
}),
99+
);
100+
101+
return results;
102+
}
103+
104+
export async function runToolOutputGuardrails<TContext>(
105+
guardrails: ToolOutputGuardrail<TContext>[],
106+
toolName: string,
107+
toolResult: string,
108+
context: TContext,
109+
): Promise<GuardrailRunResult[]> {
110+
const results = await Promise.all(
111+
guardrails.map(async (g) => {
112+
const result = await g.execute({ toolName, toolResult, context });
113+
return { guardrailName: g.name, result };
114+
}),
115+
);
116+
117+
return results;
57118
}

0 commit comments

Comments
 (0)