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
2 changes: 1 addition & 1 deletion packages/bundled-tools/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@forgewisp/bundled-tools",
"version": "0.4.0",
"version": "0.4.1",
"description": "Browser-safe, ready-to-register FunctionDefinition tools for Forgewisp agents",
"license": "MIT",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@forgewisp/core",
"version": "0.4.0",
"version": "0.4.1",
"description": "Safe, function-calling AI agents for the browser",
"license": "MIT",
"type": "module",
Expand Down
17 changes: 14 additions & 3 deletions packages/core/src/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,20 @@ export async function runToolLoop(
for (const result of toolResults) {
let content: string;
try {
content = result.success
? JSON.stringify(result.result)
: JSON.stringify({ error: result.error ?? result.abortReason });
if (result.success) {
content = JSON.stringify(result.result);
} else if (result.abortReason === 'confirmation_rejected') {
// The user explicitly declined this tool call in the confirmation
// dialog. This is a final user decision, not a transient failure —
// tell the model plainly so it doesn't retry or treat it as an error.
content =
'The user declined to run this tool (cancelled the confirmation ' +
'dialog). Do not retry or attempt this action again. Respond to the ' +
'user: acknowledge the cancellation, offer an alternative if ' +
'appropriate, or continue without it.';
} else {
content = JSON.stringify({ error: result.error ?? result.abortReason });
}
} catch (err) {
// Handler returned a non-serializable value (e.g. circular). Don't let
// it kill the run — fall back to a placeholder and audit the failure.
Expand Down
97 changes: 97 additions & 0 deletions packages/core/tests/loop.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { runToolLoop, ToolLoopDeps } from '../src/loop.js';
import { FunctionRegistry } from '../src/registry.js';
import { AuditLog } from '../src/audit.js';
import { ForgewispConfig } from '../src/types.js';

const baseConfig: ForgewispConfig = {
llmEndpoint: 'https://api.example.com/v1/chat/completions',
model: 'gpt-4o',
};

// A tool-call round followed by a final text round. The tool message handed
// back to the model in round 1 is captured in `seenMessages`.
function makeDeps(
registry: FunctionRegistry,
audit: AuditLog,
config: ForgewispConfig,
seenMessages: Parameters<ToolLoopDeps['callLLM']>[0][],
): ToolLoopDeps {
let round = 0;
const callLLM: ToolLoopDeps['callLLM'] = (messages) => {
seenMessages.push(messages);
if (round === 0) {
round += 1;
return Promise.resolve({
message: {
role: 'assistant',
content: null,
tool_calls: [
{
id: 'call_1',
type: 'function',
function: { name: 'writeData', arguments: JSON.stringify({ value: 'x' }) },
},
],
},
reasoning: '',
});
}
return Promise.resolve({
message: { role: 'assistant', content: 'ok, I will not retry' },
reasoning: '',
});
};
return { callLLM, registry, audit, config, maxToolRounds: 5 };
}

describe('runToolLoop — confirmation cancellation', () => {
let registry: FunctionRegistry;
let audit: AuditLog;

beforeEach(() => {
registry = new FunctionRegistry();
audit = new AuditLog();
registry.register({
name: 'writeData',
description: 'Write',
riskTier: 'write',
parameters: {
type: 'object',
properties: { value: { type: 'string' } },
required: ['value'],
},
handler: vi.fn(),
});
});

it('tells the LLM the user declined and not to retry (not a generic error)', async () => {
const seenMessages: Parameters<ToolLoopDeps['callLLM']>[0][] = [];
const deps = makeDeps(
registry,
audit,
{ ...baseConfig, onConfirmRequired: vi.fn().mockResolvedValue(false) },
seenMessages,
);

const result = await runToolLoop(deps, 'please write x');

// Round 1 receives the tool result message produced from the cancel.
const round1 = seenMessages[1]!;
const toolMessage = round1.find(
(m) => m.role === 'tool' && 'tool_call_id' in m && m.tool_call_id === 'call_1',
);

expect(toolMessage).toBeDefined();
const content = (toolMessage as { content: string }).content;
// Plain, directive text — not the old {"error":"confirmation_rejected"} shape.
expect(content).not.toBe(JSON.stringify({ error: 'confirmation_rejected' }));
expect(content.toLowerCase()).toContain('declined');
expect(content.toLowerCase()).toContain('do not retry');

// Loop still terminated normally with the model's final response.
expect(result.truncated).toBe(false);
expect(result.response).toBe('ok, I will not retry');
expect(result.toolCallsAborted[0]?.reason).toBe('confirmation_rejected');
});
});
Loading