From dc1c3ef7a245bc4cb2a8d47997cf644f01451c4f Mon Sep 17 00:00:00 2001 From: Angelo Date: Thu, 25 Jun 2026 11:22:01 +0200 Subject: [PATCH 1/2] fix(loop): surface confirmation rejection to the model as a final user decision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user cancels the confirmation dialog for a write/destructive tool, the tool result was previously serialized as `{"error":"confirmation_rejected"}`, indistinguishable from a transient failure — so the model would often retry the same call. Handle `abortReason === 'confirmation_rejected'` distinctly: send plain directive text telling the model the user declined and not to retry, so it acknowledges the cancellation or continues without the action. Co-Authored-By: Claude --- packages/core/src/loop.ts | 17 +++++- packages/core/tests/loop.test.ts | 97 ++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 packages/core/tests/loop.test.ts diff --git a/packages/core/src/loop.ts b/packages/core/src/loop.ts index 2c2b1db..8c64259 100644 --- a/packages/core/src/loop.ts +++ b/packages/core/src/loop.ts @@ -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. diff --git a/packages/core/tests/loop.test.ts b/packages/core/tests/loop.test.ts new file mode 100644 index 0000000..c6533fa --- /dev/null +++ b/packages/core/tests/loop.test.ts @@ -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[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[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'); + }); +}); From 8d11e267f3de9469990fefbf72762556cee81fd9 Mon Sep 17 00:00:00 2001 From: Angelo Date: Thu, 25 Jun 2026 11:23:45 +0200 Subject: [PATCH 2/2] chore: bump @forgewisp/core and @forgewisp/bundled-tools to 0.4.1 Patch release for the confirmation-rejection loop fix. Core and bundled-tools version in lockstep. Co-Authored-By: Claude --- packages/bundled-tools/package.json | 2 +- packages/core/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/bundled-tools/package.json b/packages/bundled-tools/package.json index aba8251..62a15f4 100644 --- a/packages/bundled-tools/package.json +++ b/packages/bundled-tools/package.json @@ -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", diff --git a/packages/core/package.json b/packages/core/package.json index f27971f..027ca79 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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",