Skip to content

Commit 440dcff

Browse files
committed
fix: align live external tool calls across responses
1 parent 9041c2e commit 440dcff

1 file changed

Lines changed: 123 additions & 15 deletions

File tree

src/proxy.js

Lines changed: 123 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -554,15 +554,50 @@ export function createApp(config) {
554554
return registry.find((tool) => tool.namespacedName === name || tool.originalName === name) || null;
555555
};
556556

557-
const buildExternalToolsPrompt = (registry) => {
557+
const normalizeExternalToolChoice = (toolChoice, registry) => {
558+
if (!toolChoice || !Array.isArray(registry) || registry.length === 0) {
559+
return { mode: 'auto', requiredTool: null };
560+
}
561+
if (toolChoice === 'auto' || toolChoice === 'none') {
562+
return { mode: toolChoice, requiredTool: null };
563+
}
564+
if (toolChoice === 'required') {
565+
return { mode: 'required', requiredTool: null };
566+
}
567+
const requestedName = toolChoice?.function?.name;
568+
if (toolChoice?.type === 'function' && requestedName) {
569+
const mappedTool = findExternalToolByName(registry, requestedName);
570+
return {
571+
mode: 'required',
572+
requiredTool: mappedTool?.namespacedName || `${EXTERNAL_TOOL_PREFIX}${requestedName}`
573+
};
574+
}
575+
return { mode: 'auto', requiredTool: null };
576+
};
577+
578+
const buildExternalToolsPrompt = (registry, toolChoice = null) => {
558579
if (!Array.isArray(registry) || registry.length === 0) return '';
580+
const normalizedChoice = normalizeExternalToolChoice(toolChoice, registry);
581+
const choiceInstructions = [];
582+
if (normalizedChoice.mode === 'required') {
583+
if (normalizedChoice.requiredTool) {
584+
choiceInstructions.push(`Tool use is REQUIRED for this turn. You MUST call ${normalizedChoice.requiredTool} before giving any final answer.`);
585+
} else {
586+
choiceInstructions.push('Tool use is REQUIRED for this turn. You MUST call an external tool before giving any final answer.');
587+
}
588+
} else if (normalizedChoice.mode === 'none') {
589+
choiceInstructions.push('Tool use is disabled for this turn. Do not emit <function_calls>.');
590+
}
559591
return [
560592
'External tools are virtualized by this proxy. They are not OpenCode tools.',
561-
'When you need an external tool, respond with ONLY one or more <function_calls>...</function_calls> blocks.',
593+
'When you need an external tool, your entire assistant reply MUST be ONLY one or more <function_calls>...</function_calls> blocks.',
594+
'Do NOT output <think>, explanations, markdown, prose, or any text before or after <function_calls> blocks when making a tool call.',
562595
'Each block must contain JSON with this exact shape:',
563596
'{"name":"external__tool_name","arguments":{}}',
597+
'Arguments must be a valid JSON object that matches the declared schema.',
564598
'Use only the namespaced names listed below. Do not use original client tool names inside function calls.',
565599
'If tool results are later provided as TOOL_RESULT messages, use those results to continue normally.',
600+
...choiceInstructions,
566601
`Available external tools: ${JSON.stringify(registry.map((tool) => ({
567602
name: tool.namespacedName,
568603
client_name: tool.originalName,
@@ -976,7 +1011,7 @@ export function createApp(config) {
9761011
let keepaliveInterval = null;
9771012

9781013
try {
979-
const { messages, model, tools = [], stream: requestStream, temperature, max_tokens, top_p, frequency_penalty, presence_penalty, stop, reasoning_effort, reasoning } = req.body;
1014+
const { messages, model, tools = [], tool_choice, stream: requestStream, temperature, max_tokens, top_p, frequency_penalty, presence_penalty, stop, reasoning_effort, reasoning } = req.body;
9801015
stream = Boolean(requestStream);
9811016
if (!messages || !Array.isArray(messages) || messages.length === 0) {
9821017
return res.status(400).json({ error: { message: 'messages array is required' } });
@@ -1113,7 +1148,7 @@ export function createApp(config) {
11131148

11141149
const externalToolRegistry = buildExternalToolRegistry(tools);
11151150
const { parts, system: systemMsg, fullPromptText, lastUserMsg } = await buildPromptParts(messages, externalToolRegistry);
1116-
const toolsPrompt = buildExternalToolsPrompt(externalToolRegistry);
1151+
const toolsPrompt = buildExternalToolsPrompt(externalToolRegistry, tool_choice);
11171152
const systemWithGuard = buildSystemPrompt(
11181153
[systemMsg, toolsPrompt].filter(Boolean).join('\n\n'),
11191154
requestParams.reasoning_effort,
@@ -1554,6 +1589,7 @@ export function createApp(config) {
15541589
max_output_tokens,
15551590
store = true,
15561591
tools = [],
1592+
tool_choice,
15571593
instructions,
15581594
temperature,
15591595
top_p,
@@ -1564,7 +1600,7 @@ export function createApp(config) {
15641600

15651601
const reasoningLevel = normalizeReasoningEffort(
15661602
reasoning_effort || requestReasoning?.effort,
1567-
'medium'
1603+
null
15681604
);
15691605

15701606
logDebug('Responses API request', {
@@ -1575,6 +1611,7 @@ export function createApp(config) {
15751611
});
15761612

15771613
const externalToolRegistry = buildExternalToolRegistry(tools);
1614+
const externalToolChoice = normalizeExternalToolChoice(tool_choice, externalToolRegistry);
15781615
const assistantToolCalls = new Map();
15791616

15801617
const rememberAssistantToolCall = (toolCallId, toolName) => {
@@ -1703,23 +1740,50 @@ export function createApp(config) {
17031740
const parts = [];
17041741
const systemChunks = [];
17051742
let fullPromptText = '';
1743+
const formatResponsesRoleLine = (role, text) => `${String(role || 'user').toUpperCase()}: ${text}`;
17061744
for (const msg of messages) {
17071745
if (msg.role === 'system') {
17081746
if (msg.content) systemChunks.push(msg.content);
17091747
continue;
17101748
}
17111749
if (!msg.content) continue;
1712-
parts.push({ type: 'text', text: msg.content });
1713-
fullPromptText += `${msg.role}: ${msg.content}\n\n`;
1750+
const text = msg.role === 'tool' || String(msg.content).startsWith('ASSISTANT: ') || String(msg.content).startsWith('TOOL_RESULT: ')
1751+
? msg.content
1752+
: formatResponsesRoleLine(msg.role, msg.content);
1753+
parts.push({ type: 'text', text });
1754+
fullPromptText += `${text}\n\n`;
17141755
}
17151756

1716-
const toolsPrompt = buildExternalToolsPrompt(externalToolRegistry);
1757+
const toolsPrompt = buildExternalToolsPrompt(externalToolRegistry, tool_choice);
17171758
const systemWithGuard = buildSystemPrompt(
17181759
[instructions, ...systemChunks, toolsPrompt].filter(Boolean).join('\n\n'),
17191760
reasoningLevel,
17201761
externalToolRegistry.length > 0
17211762
);
17221763

1764+
const requestForcedResponsesToolCall = async () => {
1765+
if (externalToolChoice.mode !== 'required') return null;
1766+
const requiredName = externalToolChoice.requiredTool || externalToolRegistry[0]?.namespacedName;
1767+
if (!requiredName) return null;
1768+
const forcedPromptParams = {
1769+
path: { id: sessionId },
1770+
body: {
1771+
model: { providerID: pID, modelID: mID },
1772+
...(systemWithGuard ? { system: systemWithGuard } : {}),
1773+
parts: [{
1774+
type: 'text',
1775+
text: `SYSTEM: Your previous reply did not emit the required external tool call. Reply now with ONLY <function_calls>{\"name\":\"${requiredName}\",\"arguments\":{}}</function_calls> or an array inside <function_calls>...</function_calls>. Do not output any prose, reasoning, or markdown. Infer the correct arguments from the conversation so far.`
1776+
}]
1777+
}
1778+
};
1779+
const toolOverrides = await getToolOverrides();
1780+
if (toolOverrides && Object.keys(toolOverrides).length > 0) {
1781+
forcedPromptParams.body.tools = toolOverrides;
1782+
}
1783+
await promptWithTimeout(forcedPromptParams, REQUEST_TIMEOUT_MS);
1784+
return pollForAssistantResponse(sessionId, REQUEST_TIMEOUT_MS);
1785+
};
1786+
17231787
const promptParams = {
17241788
path: { id: sessionId },
17251789
body: {
@@ -1894,6 +1958,10 @@ export function createApp(config) {
18941958
delta: filtered
18951959
});
18961960
} else {
1961+
if (!filtered.trim()) {
1962+
content += filtered;
1963+
return;
1964+
}
18971965
ensureOutputScaffold();
18981966
content += filtered;
18991967
emit({
@@ -1965,7 +2033,9 @@ export function createApp(config) {
19652033
});
19662034
}
19672035

1968-
if (announcedContent) {
2036+
const hasMeaningfulContent = Boolean(content && content.trim());
2037+
2038+
if (announcedContent && hasMeaningfulContent) {
19692039
emit({
19702040
type: 'response.output_text.done',
19712041
sequence_number: nextSeq(),
@@ -1996,11 +2066,32 @@ export function createApp(config) {
19962066
});
19972067
}
19982068

1999-
const parsedToolCalls = streamedToolCalls.length > 0
2069+
let polledForToolCalls = null;
2070+
if (externalToolRegistry.length > 0 && streamedToolCalls.length === 0) {
2071+
try {
2072+
polledForToolCalls = await pollForAssistantResponse(sessionId, REQUEST_TIMEOUT_MS);
2073+
} catch (e) { }
2074+
}
2075+
2076+
let parsedToolCalls = streamedToolCalls.length > 0
20002077
? streamedToolCalls
20012078
: (externalToolRegistry.length > 0
2002-
? parseExternalToolCallsFromText(externalToolRegistry, rawReasoning, rawContent)
2079+
? parseExternalToolCallsFromText(
2080+
externalToolRegistry,
2081+
polledForToolCalls?.reasoning || rawReasoning,
2082+
polledForToolCalls?.content || rawContent
2083+
)
20032084
: []);
2085+
if (parsedToolCalls.length === 0 && externalToolChoice.mode === 'required') {
2086+
const forcedResponse = await requestForcedResponsesToolCall();
2087+
if (forcedResponse) {
2088+
parsedToolCalls = parseExternalToolCallsFromText(
2089+
externalToolRegistry,
2090+
forcedResponse.reasoning,
2091+
forcedResponse.content
2092+
);
2093+
}
2094+
}
20042095
const safeContent = stripFunctionCallMarkup(stripFunctionCalls(content));
20052096
const safeReasoning = stripFunctionCallMarkup(stripFunctionCalls(reasoning));
20062097
if (streamedToolCalls.length === 0) {
@@ -2009,7 +2100,7 @@ export function createApp(config) {
20092100
});
20102101
}
20112102
const streamOutput = [];
2012-
const streamMessageOutputItem = buildResponsesMessageOutputItem(safeContent);
2103+
const streamMessageOutputItem = buildResponsesMessageOutputItem(safeContent && safeContent.trim() ? safeContent : '');
20132104
if (streamMessageOutputItem) streamOutput.push(streamMessageOutputItem);
20142105
parsedToolCalls.forEach((toolCall) => {
20152106
streamOutput.push(buildResponsesFunctionCallOutputItem(toolCall));
@@ -2042,18 +2133,35 @@ export function createApp(config) {
20422133

20432134
const responseRes = await client.session.prompt(promptParams);
20442135
const responseParts = responseRes.data?.parts || [];
2045-
2136+
20462137
content = responseParts.filter(p => p.type === 'text').map(p => p.text).join('\n');
20472138
reasoning = responseParts.filter(p => p.type === 'reasoning').map(p => p.text).join('\n');
20482139

2049-
if (!content && responseRes.data) {
2140+
const polledResponse = await pollForAssistantResponse(sessionId, REQUEST_TIMEOUT_MS);
2141+
if (polledResponse.error && !polledResponse.content && !polledResponse.reasoning) {
2142+
throw polledResponse.error;
2143+
}
2144+
if (polledResponse.content || polledResponse.reasoning) {
2145+
content = polledResponse.content || content;
2146+
reasoning = polledResponse.reasoning || reasoning;
2147+
}
2148+
2149+
if (!content && !reasoning && responseRes.data) {
20502150
const data = responseRes.data;
20512151
content = typeof data === 'string' ? data : data?.message || JSON.stringify(data);
20522152
}
20532153

2054-
const parsedToolCalls = externalToolRegistry.length > 0
2154+
let parsedToolCalls = externalToolRegistry.length > 0
20552155
? parseExternalToolCallsFromText(externalToolRegistry, reasoning, content)
20562156
: [];
2157+
if (parsedToolCalls.length === 0 && externalToolChoice.mode === 'required') {
2158+
const forcedResponse = await requestForcedResponsesToolCall();
2159+
if (forcedResponse) {
2160+
content = forcedResponse.content || content;
2161+
reasoning = forcedResponse.reasoning || reasoning;
2162+
parsedToolCalls = parseExternalToolCallsFromText(externalToolRegistry, reasoning, content);
2163+
}
2164+
}
20572165
const safeContent = stripFunctionCallMarkup(stripFunctionCalls(content));
20582166
const safeReasoning = stripFunctionCallMarkup(stripFunctionCalls(reasoning));
20592167

0 commit comments

Comments
 (0)