Skip to content

Commit a0dde86

Browse files
fix(core): stabilize real-api smoke tool loop and forceDirect precedence (#156)
1 parent 96d649e commit a0dde86

3 files changed

Lines changed: 93 additions & 12 deletions

File tree

packages/core/scripts/real-api-smoke.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* pnpm -C packages/core run real-api:smoke
1111
*/
1212

13-
import { CascadeAgent } from '../src/index.ts';
13+
import { CascadeAgent, ToolConfig, ToolExecutor } from '../src/index.ts';
1414
import type { Tool } from '../src/types.ts';
1515

1616
type Env = Record<string, string | undefined>;
@@ -44,6 +44,24 @@ async function main(): Promise<void> {
4444
const anthropicKey = env.ANTHROPIC_API_KEY;
4545

4646
if (openaiKey) {
47+
const toolExecutor = new ToolExecutor([
48+
new ToolConfig({
49+
name: 'get_weather',
50+
description: 'Get the weather for a location.',
51+
parameters: {
52+
type: 'object',
53+
properties: {
54+
location: { type: 'string' },
55+
},
56+
required: ['location'],
57+
},
58+
function: async ({ location }: { location?: string }) => ({
59+
location: location || 'unknown',
60+
forecast: 'sunny',
61+
}),
62+
}),
63+
]);
64+
4765
const agent = new CascadeAgent({
4866
models: [
4967
{ name: 'gpt-4o-mini', provider: 'openai', cost: 0.00015, apiKey: openaiKey },
@@ -61,11 +79,7 @@ async function main(): Promise<void> {
6179
tools: [getWeatherTool],
6280
forceDirect: true,
6381
maxSteps: 3,
64-
toolExecutor: async (call) => {
65-
const name = call.function?.name ?? call.name;
66-
if (name !== 'get_weather') return { ok: false, error: 'unknown_tool' };
67-
return { location: 'Paris', forecast: 'sunny' };
68-
},
82+
toolExecutor,
6983
});
7084
if (!r2.content || !/sunny/i.test(r2.content)) {
7185
throw new Error(`OpenAI tool-loop smoke failed: content=${JSON.stringify(r2.content)}`);

packages/core/src/__tests__/agent-tool-loop.test.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,5 +125,64 @@ describe('CascadeAgent tool loop (auto-execution)', () => {
125125
expect(assistantWithToolCalls.tool_calls?.[0]?.id).toBe('call_1');
126126
expect(toolMsg.tool_call_id).toBe('call_1');
127127
});
128-
});
129128

129+
it('respects forceDirect for tool loops even when tool routing would prefer cascade', async () => {
130+
const tools: Tool[] = [
131+
{
132+
type: 'function',
133+
function: {
134+
name: 'calculate',
135+
description: 'Calculator',
136+
parameters: {
137+
type: 'object',
138+
properties: { expression: { type: 'string' } },
139+
required: ['expression'],
140+
},
141+
},
142+
},
143+
];
144+
145+
const executor = new ToolExecutor([
146+
new ToolConfig({
147+
name: 'calculate',
148+
description: 'Calculator',
149+
parameters: {
150+
type: 'object',
151+
properties: { expression: { type: 'string' } },
152+
required: ['expression'],
153+
},
154+
function: ({ expression }: { expression: string }) => ({ result: expression === '2+2' ? 4 : null }),
155+
}),
156+
]);
157+
158+
const agent = new CascadeAgent({
159+
models: [
160+
{
161+
name: 'gpt-4o-mini',
162+
provider: 'openai',
163+
cost: 0,
164+
supportsTools: true,
165+
apiKey: 'test-key',
166+
},
167+
{
168+
name: 'gpt-4o',
169+
provider: 'openai',
170+
cost: 0.1,
171+
supportsTools: true,
172+
apiKey: 'test-key',
173+
},
174+
],
175+
});
176+
177+
const result = await agent.run('Use calculate for 2+2, then answer with the result.', {
178+
tools,
179+
toolExecutor: executor,
180+
maxSteps: 3,
181+
forceDirect: true,
182+
});
183+
184+
expect(result.content).toBe('The result is 4.');
185+
expect(result.hasToolCalls).toBe(false);
186+
expect(fetchMock).toHaveBeenCalledTimes(2);
187+
});
188+
});

packages/core/src/agent.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -738,6 +738,12 @@ export class CascadeAgent {
738738
const normalized = normalizeSystemPromptFromMessages(rawMessages, options.systemPrompt);
739739
const messages = normalized.messages;
740740
const executor = options.toolExecutor ?? this.toolExecutor;
741+
if (executor && typeof (executor as any).executeParallel !== 'function') {
742+
throw new Error(
743+
'Invalid toolExecutor: expected ToolExecutor with executeParallel(). ' +
744+
'Use new ToolExecutor([...]) with ToolConfig entries.'
745+
);
746+
}
741747

742748
// Extract query text for complexity detection (exclude system messages)
743749
const queryText = typeof input === 'string' ? input : messages.map((m) => m.content).join('\n');
@@ -852,11 +858,13 @@ export class CascadeAgent {
852858
routingDecision.strategy === RoutingStrategy.CASCADE &&
853859
availableModels.length > 1;
854860

855-
// Tool routing overrides for tool calls
856-
if (toolRoutingDecision?.strategy === 'direct') {
857-
shouldCascade = false;
858-
} else if (toolRoutingDecision?.strategy === 'cascade') {
859-
shouldCascade = true;
861+
// Tool routing overrides for tool calls (unless forceDirect is explicitly requested)
862+
if (!options.forceDirect) {
863+
if (toolRoutingDecision?.strategy === 'direct') {
864+
shouldCascade = false;
865+
} else if (toolRoutingDecision?.strategy === 'cascade') {
866+
shouldCascade = true;
867+
}
860868
}
861869

862870
let draftCost = 0;

0 commit comments

Comments
 (0)