fix(agent): recover when action intent has no tool call
This commit is contained in:
@@ -958,6 +958,89 @@ describe('NativeAgent tool loop', () => {
|
||||
expect(seenCommands[0]).toBe('grep -r "createCouncilRunTool" /home/will/lab/flynn/src --include="*.ts" | head -20');
|
||||
});
|
||||
|
||||
it('nudges once when model claims it will act but emits no tool call, then recovers', async () => {
|
||||
let callCount = 0;
|
||||
const mockClient: ModelClient = {
|
||||
chat: vi.fn().mockImplementation((request: ChatRequest) => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
return {
|
||||
content: 'I am going to create that filter now. Proceeding.',
|
||||
stopReason: 'end_turn',
|
||||
usage: { inputTokens: 10, outputTokens: 5 },
|
||||
};
|
||||
}
|
||||
if (callCount === 2) {
|
||||
expect(JSON.stringify(request.messages)).toContain('no tool call was emitted');
|
||||
return {
|
||||
content: '',
|
||||
stopReason: 'tool_use',
|
||||
usage: { inputTokens: 10, outputTokens: 5 },
|
||||
toolCalls: [{ id: 'call_2', name: 'test.echo', args: { text: 'filter-created' } }],
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: 'Filter created.',
|
||||
stopReason: 'end_turn',
|
||||
usage: { inputTokens: 12, outputTokens: 6 },
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
const registry = new ToolRegistry();
|
||||
registry.register(echoTool);
|
||||
const hooks = new HookEngine({ confirm: [], log: [], silent: [] });
|
||||
const executor = new ToolExecutor(registry, hooks);
|
||||
|
||||
const agent = new NativeAgent({
|
||||
modelClient: mockClient,
|
||||
systemPrompt: 'You are helpful.',
|
||||
toolRegistry: registry,
|
||||
toolExecutor: executor,
|
||||
});
|
||||
|
||||
const response = await agent.process('Create the filter');
|
||||
expect(response).toBe('Filter created.');
|
||||
expect(mockClient.chat).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('returns follow-up text after one missing-tool nudge when still no tool call', async () => {
|
||||
let callCount = 0;
|
||||
const mockClient: ModelClient = {
|
||||
chat: vi.fn().mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
return {
|
||||
content: 'I will run the lookup now.',
|
||||
stopReason: 'end_turn',
|
||||
usage: { inputTokens: 10, outputTokens: 5 },
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: 'Blocked: tool unavailable in current mode.',
|
||||
stopReason: 'end_turn',
|
||||
usage: { inputTokens: 11, outputTokens: 6 },
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
const registry = new ToolRegistry();
|
||||
registry.register(echoTool);
|
||||
const hooks = new HookEngine({ confirm: [], log: [], silent: [] });
|
||||
const executor = new ToolExecutor(registry, hooks);
|
||||
|
||||
const agent = new NativeAgent({
|
||||
modelClient: mockClient,
|
||||
systemPrompt: 'You are helpful.',
|
||||
toolRegistry: registry,
|
||||
toolExecutor: executor,
|
||||
});
|
||||
|
||||
const response = await agent.process('Do the lookup');
|
||||
expect(response).toBe('Blocked: tool unavailable in current mode.');
|
||||
expect(mockClient.chat).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('works without tools (backward compatible)', async () => {
|
||||
const mockClient: ModelClient = {
|
||||
chat: vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -259,6 +259,7 @@ export class NativeAgent {
|
||||
let sameToolStreak = 0;
|
||||
const maxSameToolStreak = 4; // nudge after 4 calls to the same tool
|
||||
let nudged = false;
|
||||
let actionIntentNudged = false;
|
||||
|
||||
for (let iteration = 0; iteration < this.maxIterations; iteration++) {
|
||||
try {
|
||||
@@ -305,6 +306,16 @@ export class NativeAgent {
|
||||
const wantsToolUse = toolCalls.length > 0;
|
||||
if (!wantsToolUse) {
|
||||
const pseudoToolUse = this.extractPseudoToolUse(response.content);
|
||||
if (this.shouldNudgeForMissingToolCall(response.content, pseudoToolUse) && !actionIntentNudged) {
|
||||
actionIntentNudged = true;
|
||||
const normalized = this.normalizeAssistantContent(response.content);
|
||||
loopMessages.push({ role: 'assistant', content: normalized });
|
||||
loopMessages.push({
|
||||
role: 'user',
|
||||
content: 'You said you would perform an action now, but no tool call was emitted. If a tool is needed, call it now. If blocked, explain the exact blocker.',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const baseContent = pseudoToolUse
|
||||
? this.buildPseudoToolUseWarning(response.content, pseudoToolUse)
|
||||
: this.normalizeAssistantContent(response.content);
|
||||
@@ -472,6 +483,21 @@ export class NativeAgent {
|
||||
return warningMsg;
|
||||
}
|
||||
|
||||
private shouldNudgeForMissingToolCall(content: string, pseudoToolUse: PseudoToolUse | null): boolean {
|
||||
if (!content || pseudoToolUse) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = content.toLowerCase();
|
||||
const intentRegex = /\b(i(?:'m| am)? going to|i(?:'ll| will)|let me|proceeding(?: now)?|i can(?: now)?)\b/;
|
||||
if (!intentRegex.test(normalized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const actionRegex = /\b(create|run|execute|call|use|check|fetch|search|read|write|send|retry|proceed|attempt|apply|delete|update|list)\b/;
|
||||
return actionRegex.test(normalized);
|
||||
}
|
||||
|
||||
private async chatWithRouter(request: ChatRequest): Promise<ChatResponse> {
|
||||
const runSignal = this._runAbortController?.signal;
|
||||
const requestSignal = request.signal;
|
||||
|
||||
Reference in New Issue
Block a user