Skip to content

Commit 99941fc

Browse files
committed
feat: add nextTurnParams feature for dynamic conversation steering
Implements configuration-based nextTurnParams allowing tools to influence subsequent conversation turns by modifying request parameters. Key features: - Tools can specify nextTurnParams functions in their configuration - Functions receive tool input params and current request state - Multiple tools' params compose in tools array order - Support for modifying input, model, temperature, and other parameters New files: - src/lib/claude-constants.ts - Claude-specific content type constants - src/lib/claude-type-guards.ts - Type guards for Claude message format - src/lib/next-turn-params.ts - NextTurnParams execution logic - src/lib/turn-context.ts - Turn context building helpers Updates: - src/lib/tool-types.ts - Add NextTurnParamsContext and NextTurnParamsFunctions - src/lib/tool.ts - Add nextTurnParams to all tool config types - src/lib/tool-orchestrator.ts - Execute nextTurnParams after tool execution - src/index.ts - Export new types and functions
1 parent 11145ec commit 99941fc

File tree

8 files changed

+392
-5
lines changed

8 files changed

+392
-5
lines changed

src/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ export type {
6161
ChatStreamEvent,
6262
EnhancedResponseStreamEvent,
6363
ToolPreliminaryResultEvent,
64+
NextTurnParamsContext,
65+
NextTurnParamsFunctions,
66+
ToolCallInfo,
6467
} from "./lib/tool-types.js";
6568

6669
export {
@@ -70,3 +73,18 @@ export {
7073
isRegularExecuteTool,
7174
isToolPreliminaryResultEvent,
7275
} from "./lib/tool-types.js";
76+
77+
// Turn context helpers
78+
export { buildTurnContext, normalizeInputToArray } from "./lib/turn-context.js";
79+
export type { BuildTurnContextOptions } from "./lib/turn-context.js";
80+
81+
// Next turn params helpers
82+
export {
83+
buildNextTurnParamsContext,
84+
executeNextTurnParamsFunctions,
85+
applyNextTurnParamsToRequest,
86+
} from "./lib/next-turn-params.js";
87+
88+
// Claude constants and type guards
89+
export { ClaudeContentBlockType, NonClaudeMessageRole } from "./lib/claude-constants.js";
90+
export { isClaudeStyleMessages } from "./lib/claude-type-guards.js";

src/lib/claude-constants.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* Claude-specific content block types
3+
* Used for detecting Claude message format
4+
*/
5+
export const ClaudeContentBlockType = {
6+
Text: "text",
7+
Image: "image",
8+
ToolUse: "tool_use",
9+
ToolResult: "tool_result",
10+
} as const;
11+
12+
export type ClaudeContentBlockType =
13+
(typeof ClaudeContentBlockType)[keyof typeof ClaudeContentBlockType];
14+
15+
/**
16+
* Message roles that are NOT supported in Claude format
17+
* Used for distinguishing Claude vs OpenAI format
18+
*/
19+
export const NonClaudeMessageRole = {
20+
System: "system",
21+
Developer: "developer",
22+
Tool: "tool",
23+
} as const;
24+
25+
export type NonClaudeMessageRole =
26+
(typeof NonClaudeMessageRole)[keyof typeof NonClaudeMessageRole];

src/lib/claude-type-guards.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import type * as models from "../models/index.js";
2+
import {
3+
ClaudeContentBlockType,
4+
NonClaudeMessageRole,
5+
} from "./claude-constants.js";
6+
7+
function isRecord(value: unknown): value is Record<string, unknown> {
8+
return value !== null && typeof value === "object" && !Array.isArray(value);
9+
}
10+
11+
function isNonClaudeRole(role: unknown): boolean {
12+
return (
13+
role === NonClaudeMessageRole.System ||
14+
role === NonClaudeMessageRole.Developer ||
15+
role === NonClaudeMessageRole.Tool
16+
);
17+
}
18+
19+
function isClaudeToolResultBlock(block: unknown): boolean {
20+
if (!isRecord(block)) return false;
21+
return block["type"] === ClaudeContentBlockType.ToolResult;
22+
}
23+
24+
function isClaudeImageBlockWithSource(block: unknown): boolean {
25+
if (!isRecord(block)) return false;
26+
return (
27+
block["type"] === ClaudeContentBlockType.Image &&
28+
"source" in block &&
29+
isRecord(block["source"])
30+
);
31+
}
32+
33+
function isClaudeToolUseBlockWithId(block: unknown): boolean {
34+
if (!isRecord(block)) return false;
35+
return (
36+
block["type"] === ClaudeContentBlockType.ToolUse &&
37+
"id" in block &&
38+
typeof block["id"] === "string"
39+
);
40+
}
41+
42+
function hasClaudeSpecificBlocks(content: unknown[]): boolean {
43+
for (const block of content) {
44+
if (isClaudeToolResultBlock(block)) return true;
45+
if (isClaudeImageBlockWithSource(block)) return true;
46+
if (isClaudeToolUseBlockWithId(block)) return true;
47+
}
48+
return false;
49+
}
50+
51+
/**
52+
* Check if input is in Claude message format
53+
* Uses structural analysis to detect Claude-specific patterns
54+
*
55+
* @param input - Input to check
56+
* @returns True if input appears to be Claude format
57+
*/
58+
export function isClaudeStyleMessages(
59+
input: unknown
60+
): input is models.ClaudeMessageParam[] {
61+
if (!Array.isArray(input) || input.length === 0) {
62+
return false;
63+
}
64+
65+
for (const msg of input) {
66+
if (!isRecord(msg)) continue;
67+
if (!("role" in msg)) continue;
68+
if ("type" in msg) continue; // Claude messages don't have top-level "type"
69+
70+
// If we find a non-Claude role, it's not Claude format
71+
if (isNonClaudeRole(msg["role"])) {
72+
return false;
73+
}
74+
75+
// If we find Claude-specific content blocks, it's Claude format
76+
const content = msg["content"];
77+
if (Array.isArray(content) && hasClaudeSpecificBlocks(content)) {
78+
return true;
79+
}
80+
}
81+
82+
return false;
83+
}

src/lib/next-turn-params.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import type * as models from '../models/index.js';
2+
import type { NextTurnParamsContext, ParsedToolCall, Tool } from './tool-types.js';
3+
4+
/**
5+
* Build a NextTurnParamsContext from the current request
6+
* Extracts relevant fields that can be modified by nextTurnParams functions
7+
*
8+
* @param request - The current OpenResponsesRequest
9+
* @returns Context object with current parameter values
10+
*/
11+
export function buildNextTurnParamsContext(
12+
request: models.OpenResponsesRequest
13+
): NextTurnParamsContext {
14+
return {
15+
input: request.input ?? [],
16+
model: request.model ?? '',
17+
models: request.models ?? [],
18+
temperature: request.temperature ?? null,
19+
maxOutputTokens: request.maxOutputTokens ?? null,
20+
topP: request.topP ?? null,
21+
topK: request.topK ?? 0,
22+
instructions: request.instructions ?? null,
23+
};
24+
}
25+
26+
/**
27+
* Execute nextTurnParams functions for all called tools
28+
* Composes functions when multiple tools modify the same parameter
29+
*
30+
* @param toolCalls - Tool calls that were executed in this turn
31+
* @param tools - All available tools
32+
* @param currentRequest - The current request
33+
* @returns Object with computed parameter values
34+
*/
35+
export async function executeNextTurnParamsFunctions(
36+
toolCalls: ParsedToolCall[],
37+
tools: Tool[],
38+
currentRequest: models.OpenResponsesRequest
39+
): Promise<Partial<NextTurnParamsContext>> {
40+
// Build initial context from current request
41+
const context = buildNextTurnParamsContext(currentRequest);
42+
43+
// Group tool calls by parameter they modify
44+
const paramFunctions = new Map<
45+
keyof NextTurnParamsContext,
46+
Array<{ params: unknown; fn: Function }>
47+
>();
48+
49+
// Collect all nextTurnParams functions from tools (in tools array order)
50+
for (const tool of tools) {
51+
if (!tool.function.nextTurnParams) continue;
52+
53+
// Find tool calls for this tool
54+
const callsForTool = toolCalls.filter(tc => tc.name === tool.function.name);
55+
56+
for (const call of callsForTool) {
57+
// For each parameter function in this tool's nextTurnParams
58+
for (const [paramKey, fn] of Object.entries(tool.function.nextTurnParams)) {
59+
if (!paramFunctions.has(paramKey as keyof NextTurnParamsContext)) {
60+
paramFunctions.set(paramKey as keyof NextTurnParamsContext, []);
61+
}
62+
paramFunctions.get(paramKey as keyof NextTurnParamsContext)!.push({
63+
params: call.arguments,
64+
fn,
65+
});
66+
}
67+
}
68+
}
69+
70+
// Compose and execute functions for each parameter
71+
const result: Partial<NextTurnParamsContext> = {};
72+
let workingContext = { ...context };
73+
74+
for (const [paramKey, functions] of paramFunctions.entries()) {
75+
// Compose all functions for this parameter
76+
let currentValue = workingContext[paramKey];
77+
78+
for (const { params, fn } of functions) {
79+
// Update context with current value
80+
workingContext = { ...workingContext, [paramKey]: currentValue };
81+
82+
// Execute function with composition
83+
currentValue = await Promise.resolve(fn(params, workingContext));
84+
}
85+
86+
// TypeScript can't infer that paramKey corresponds to the correct value type
87+
// so we use a type assertion here
88+
(result as any)[paramKey] = currentValue;
89+
}
90+
91+
return result;
92+
}
93+
94+
/**
95+
* Apply computed nextTurnParams to the current request
96+
* Returns a new request object with updated parameters
97+
*
98+
* @param request - The current request
99+
* @param computedParams - Computed parameter values from nextTurnParams functions
100+
* @returns New request with updated parameters
101+
*/
102+
export function applyNextTurnParamsToRequest(
103+
request: models.OpenResponsesRequest,
104+
computedParams: Partial<NextTurnParamsContext>
105+
): models.OpenResponsesRequest {
106+
return {
107+
...request,
108+
...computedParams,
109+
};
110+
}

src/lib/tool-orchestrator.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import type { APITool, Tool, ToolExecutionResult } from './tool-types.js';
44
import { extractToolCallsFromResponse, responseHasToolCalls } from './stream-transformers.js';
55
import { executeTool, findToolByName } from './tool-executor.js';
66
import { hasExecuteFunction } from './tool-types.js';
7+
import { buildTurnContext } from './turn-context.js';
8+
import { executeNextTurnParamsFunctions, applyNextTurnParamsToRequest } from './next-turn-params.js';
79

810
/**
911
* Options for tool execution
@@ -29,6 +31,7 @@ export interface ToolOrchestrationResult {
2931
*
3032
* @param sendRequest - Function to send a request and get a response
3133
* @param initialInput - Starting input for the conversation
34+
* @param initialRequest - Full initial request with all parameters
3235
* @param tools - Enhanced tools with Zod schemas and execute functions
3336
* @param apiTools - Converted tools in API format (JSON Schema)
3437
* @param options - Execution options
@@ -40,6 +43,7 @@ export async function executeToolLoop(
4043
tools: APITool[],
4144
) => Promise<models.OpenResponsesNonStreamingResponse>,
4245
initialInput: models.OpenResponsesInput,
46+
initialRequest: models.OpenResponsesRequest,
4347
tools: Tool[],
4448
apiTools: APITool[],
4549
options: ToolExecutionOptions = {},
@@ -50,6 +54,7 @@ export async function executeToolLoop(
5054
const allResponses: models.OpenResponsesNonStreamingResponse[] = [];
5155
const toolExecutionResults: ToolExecutionResult[] = [];
5256
let conversationInput: models.OpenResponsesInput = initialInput;
57+
let currentRequest: models.OpenResponsesRequest = { ...initialRequest };
5358

5459
let currentRound = 0;
5560
let currentResponse: models.OpenResponsesNonStreamingResponse;
@@ -100,10 +105,12 @@ export async function executeToolLoop(
100105
}
101106

102107
// Build turn context
103-
const turnContext: import('./tool-types.js').TurnContext = {
108+
const turnContext = buildTurnContext({
104109
numberOfTurns: currentRound,
105110
messageHistory: conversationInput,
106-
};
111+
model: currentRequest.model,
112+
models: currentRequest.models,
113+
});
107114

108115
// Execute the tool
109116
return executeTool(tool, toolCall, turnContext, onPreliminaryResult);
@@ -137,10 +144,23 @@ export async function executeToolLoop(
137144

138145
toolExecutionResults.push(...roundResults);
139146

147+
// Execute nextTurnParams functions for tools that were called
148+
const computedParams = await executeNextTurnParamsFunctions(
149+
toolCalls,
150+
tools,
151+
currentRequest
152+
);
153+
154+
// Apply computed parameters to request
155+
if (Object.keys(computedParams).length > 0) {
156+
currentRequest = applyNextTurnParamsToRequest(currentRequest, computedParams);
157+
conversationInput = currentRequest.input ?? conversationInput;
158+
}
159+
140160
// Build array input with all output from previous response plus tool results
141161
// The API expects continuation via previousResponseId, not by including outputs
142162
// For now, we'll keep the conversation going via previousResponseId
143-
conversationInput = initialInput; // Keep original input
163+
// conversationInput is updated above if nextTurnParams modified it
144164

145165
// Note: The OpenRouter Responses API uses previousResponseId for continuation
146166
// Tool results are automatically associated with the previous response's tool calls

src/lib/tool-types.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,54 @@ export interface TurnContext {
2020
/** Current message history being sent to the API */
2121
messageHistory: models.OpenResponsesInput;
2222
/** Model name if request.model is set */
23-
model?: string;
23+
model?: string | undefined;
2424
/** Model names if request.models is set */
25-
models?: string[];
25+
models?: string[] | undefined;
26+
}
27+
28+
/**
29+
* Context passed to nextTurnParams functions
30+
* Contains current request state for parameter computation
31+
* Allows modification of key request parameters between turns
32+
*/
33+
export type NextTurnParamsContext = {
34+
/** Current input (messages) */
35+
input: models.OpenResponsesInput;
36+
/** Current model selection */
37+
model: string;
38+
/** Current models array */
39+
models: string[];
40+
/** Current temperature */
41+
temperature: number | null;
42+
/** Current maxOutputTokens */
43+
maxOutputTokens: number | null;
44+
/** Current topP */
45+
topP: number | null;
46+
/** Current topK */
47+
topK: number;
48+
/** Current instructions */
49+
instructions: string | null;
50+
};
51+
52+
/**
53+
* Functions to compute next turn parameters
54+
* Each function receives the tool's input params and current request context
55+
*/
56+
export type NextTurnParamsFunctions<TInput> = {
57+
[K in keyof NextTurnParamsContext]?: (
58+
params: TInput,
59+
context: NextTurnParamsContext
60+
) => NextTurnParamsContext[K] | Promise<NextTurnParamsContext[K]>;
61+
};
62+
63+
/**
64+
* Information about a tool call needed for nextTurnParams execution
65+
*/
66+
export interface ToolCallInfo {
67+
id: string;
68+
name: string;
69+
arguments: unknown;
70+
tool: Tool;
2671
}
2772

2873
/**
@@ -32,6 +77,7 @@ export interface BaseToolFunction<TInput extends ZodObject<ZodRawShape>> {
3277
name: string;
3378
description?: string;
3479
inputSchema: TInput;
80+
nextTurnParams?: NextTurnParamsFunctions<z.infer<TInput>>;
3581
}
3682

3783
/**

0 commit comments

Comments
 (0)