Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .changeset/next-turn-tool-choice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
"@openrouter/agent": minor
---

Add `toolChoice` to `nextTurnParams`, so a tool can change which tools the model may call on the following turn without touching the `tools` array.

This is what a tool-search tool needs: declare every tool up front, keep the not-yet-needed ones out of reach behind an `allowed_tools` choice, and widen that choice as the model discovers what it wants. Because `tools` is byte-identical across turns, the provider's prompt-cache prefix survives — which is the whole reason to withhold tools rather than send them all.

```ts
import { callModel, OpenRouter, tool } from '@openrouter/agent';
import { z } from 'zod/v4';

const allowed = (names: string[]) => ({
type: 'allowed_tools' as const,
mode: 'auto' as const,
tools: names.map((name) => ({ type: 'function', name })),
});

const toolSearch = tool({
name: 'tool_search',
inputSchema: z.object({ pattern: z.string() }),
execute: ({ pattern }) => findMatchingToolNames(pattern),
nextTurnParams: {
// Append, never rebuild: dropping a name revokes a tool the model may
// already have used, and reordering churns the request for nothing.
toolChoice: ({ pattern }, context) =>
allowed([...namesIn(context.toolChoice), ...findMatchingToolNames(pattern)]),
},
});

const client = new OpenRouter({ apiKey: process.env['OPENROUTER_API_KEY'] });

const result = callModel(client, {
model: 'openai/gpt-4o-mini',
input: 'What is the weather in Tokyo?',
tools: [toolSearch, getWeather, sendEmail, listRepos],
// Only the search tool is reachable until it finds something.
toolChoice: allowed(['tool_search']),
});
```
20 changes: 18 additions & 2 deletions packages/agent/src/lib/model-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4386,9 +4386,25 @@ export class ModelResult<
this.resolvedRequest,
);

if (Object.keys(computedParams).length > 0) {
this.resolvedRequest = applyNextTurnParamsToRequest(this.resolvedRequest, computedParams);
if (Object.keys(computedParams).length === 0) {
return;
}

const nextRequest = applyNextTurnParamsToRequest(this.resolvedRequest, computedParams);

/*
* A tool-computed `toolChoice` becomes the new caller-level policy, not a
* one-turn override. Merging it onto the request alone is not enough:
* `makeFollowupRequest` re-derives the wire choice from
* `configuredToolChoice` via `applyForcedToolChoicePolicy`, which would
* discard the tool's value before dispatch. Re-running the resolved-policy
* bookkeeping re-stamps the configured choice and its forced-choice
* consumption key together, so relaxation stays consistent for later turns.
*/
this.resolvedRequest =
'toolChoice' in computedParams
? this.applyResolvedForcedToolChoicePolicy(nextRequest)
: nextRequest;
}

/**
Expand Down
86 changes: 86 additions & 0 deletions packages/agent/src/lib/next-turn-params.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,89 @@ describe('applyNextTurnParamsToRequest', () => {
expect(result.instructions).toBe('');
});
});

/*
* `toolChoice` is how a tool-search tool widens the model's reach mid-run: the
* `tools` array stays byte-identical (preserving the provider's prompt-cache
* prefix) while `{ type: 'allowed_tools', tools: [...] }` grows.
*/
describe('applyNextTurnParamsToRequest with allowed_tools', () => {
const allowed = (names: string[]): models.ResponsesRequest['toolChoice'] => ({
type: 'allowed_tools',
mode: 'auto',
tools: names.map((name) => ({
type: 'function',
name,
})),
});

it('replaces toolChoice with a widened allowed_tools set', () => {
const request = createBaseRequest({
toolChoice: allowed([
'tool_search',
]),
});

const result = applyNextTurnParamsToRequest(request, {
toolChoice: allowed([
'tool_search',
'get_weather',
]),
});

expect(result.toolChoice).toEqual(
allowed([
'tool_search',
'get_weather',
]),
);
});

it('leaves the tools array untouched so the prompt-cache prefix survives', () => {
const tools = [
{
type: 'function' as const,
name: 'tool_search',
parameters: {},
},
{
type: 'function' as const,
name: 'get_weather',
parameters: {},
},
];
const request = createBaseRequest({
tools,
toolChoice: allowed([
'tool_search',
]),
});

const result = applyNextTurnParamsToRequest(request, {
toolChoice: allowed([
'tool_search',
'get_weather',
]),
});

expect(result.tools).toBe(tools);
});

it('leaves toolChoice alone when no tool computed one', () => {
const request = createBaseRequest({
toolChoice: allowed([
'tool_search',
]),
});

const result = applyNextTurnParamsToRequest(request, {
temperature: 0.2,
});

expect(result.toolChoice).toEqual(
allowed([
'tool_search',
]),
);
});
});
4 changes: 3 additions & 1 deletion packages/agent/src/lib/next-turn-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export function buildNextTurnParamsContext(
): NextTurnParamsContext {
return {
input: request.input ?? [],
toolChoice: request.toolChoice,
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
model: request.model ?? '',
models: request.models ?? [],
temperature: request.temperature ?? null,
Expand Down Expand Up @@ -115,7 +116,7 @@ async function processNextTurnParamsForCall(
if (process.env['NODE_ENV'] !== 'production') {
console.warn(
`Invalid nextTurnParams key "${paramKey}" in tool "${toolName}". ` +
'Valid keys: input, model, models, temperature, maxOutputTokens, topP, topK, instructions',
'Valid keys: input, toolChoice, model, models, temperature, maxOutputTokens, topP, topK, instructions',
);
}
continue;
Expand All @@ -137,6 +138,7 @@ async function processNextTurnParamsForCall(
function isValidNextTurnParamKey(key: string): key is keyof NextTurnParamsContext {
const validKeys: ReadonlySet<string> = new Set([
'input',
'toolChoice',
'model',
'models',
'temperature',
Expand Down
11 changes: 11 additions & 0 deletions packages/agent/src/lib/tool-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,17 @@ export const SHARED_CONTEXT_KEY = 'shared' as const;
export type NextTurnParamsContext = {
/** Current input (messages) */
input: models.InputsUnion;
/**
* Current tool choice.
*
* Returning a new value changes which tools the model may call next turn
* without touching the `tools` array — the hook a tool-search tool uses to
* widen `{ type: 'allowed_tools', tools: [...] }` once it has found what it
* was looking for. Leaving `tools` alone is the point: rewriting it would
* invalidate the provider's prompt-cache prefix, which is usually the reason
* the caller is withholding tools in the first place.
*/
toolChoice: models.ResponsesRequest['toolChoice'];
/** Current model selection */
model: string;
/** Current models array */
Expand Down
Loading
Loading