From c5418bae1dd54f725b0510a3ecb720fec85482a5 Mon Sep 17 00:00:00 2001 From: Omer Aplak Date: Mon, 23 Feb 2026 19:38:26 -0800 Subject: [PATCH 1/3] fix(core): improve delegated HITL approval resume and docs --- .changeset/curly-rivers-march.md | 64 ++ examples/README.md | 1 + examples/with-hitl/.env.example | 1 + examples/with-hitl/.gitignore | 3 + examples/with-hitl/README.md | 87 ++ examples/with-hitl/package.json | 38 + examples/with-hitl/src/index.ts | 86 ++ examples/with-hitl/tsconfig.json | 14 + packages/core/src/agent/agent.spec.ts | 322 ++++++++ packages/core/src/agent/agent.ts | 752 +++++++++++++++++- .../core/src/agent/subagent/index.spec.ts | 154 ++++ packages/core/src/agent/subagent/index.ts | 179 ++++- .../subagent/stream-metadata-enricher.ts | 10 +- packages/core/src/agent/types.ts | 4 +- packages/core/src/tool/manager/ToolManager.ts | 2 - .../core/src/utils/message-converter.spec.ts | 99 +++ packages/core/src/utils/message-converter.ts | 157 ++-- packages/core/src/utils/tool-approval.ts | 91 +++ pnpm-lock.yaml | 121 ++- website/docs/agents/overview.md | 6 +- website/docs/agents/subagents.md | 35 +- website/docs/agents/tools.md | 130 ++- website/docs/api/endpoints/agents.md | 50 ++ website/recipes/hitl.md | 142 ++++ website/recipes/overview.md | 1 + website/recipes/tools.md | 2 + website/sidebarsRecipes.ts | 5 + 27 files changed, 2383 insertions(+), 173 deletions(-) create mode 100644 .changeset/curly-rivers-march.md create mode 100644 examples/with-hitl/.env.example create mode 100644 examples/with-hitl/.gitignore create mode 100644 examples/with-hitl/README.md create mode 100644 examples/with-hitl/package.json create mode 100644 examples/with-hitl/src/index.ts create mode 100644 examples/with-hitl/tsconfig.json create mode 100644 packages/core/src/utils/tool-approval.ts create mode 100644 website/recipes/hitl.md diff --git a/.changeset/curly-rivers-march.md b/.changeset/curly-rivers-march.md new file mode 100644 index 000000000..63cb1b83f --- /dev/null +++ b/.changeset/curly-rivers-march.md @@ -0,0 +1,64 @@ +--- +"@voltagent/core": patch +--- + +fix: make delegated `needsApproval` resume correctly in sub-agent flows + +This patch improves Human-in-the-Loop behavior when a supervisor delegates work via `delegate_task`. + +### What changed + +- Approval responses attached to `tool-delegate_task` UI parts are now matched to the guarded sub-agent tool call and correctly resume execution. +- Parent tool-context messages are forwarded to delegated sub-agents as shared context, so required arguments (for example `userId`) are not lost during handoff. +- Sub-agent forwarding defaults now include approval events (`tool-approval-request`, `tool-approval-response`) in addition to tool call/result events. + +### No DX change for tool authors + +You still define approvals the same way: + +```ts +const deleteCrmUser = createTool({ + name: "deleteCrmUser", + parameters: z.object({ + userId: z.string(), + reason: z.string().optional(), + }), + needsApproval: true, + execute: async ({ userId }) => ({ ok: true, userId }), +}); +``` + +### Delegated flow example + +```ts +const crmAgent = new Agent({ + name: "CRM Agent", + model: "openai/gpt-4o-mini", + instructions: "Handle CRM mutations.", + tools: [deleteCrmUser], +}); + +const triageAgent = new Agent({ + name: "Triage Agent", + model: "openai/gpt-4o-mini", + instructions: "Route CRM delete requests to CRM Agent.", + subAgents: [crmAgent], +}); +``` + +When the UI sends approval on a delegated part: + +```ts +{ + role: "assistant", + parts: [ + { + type: "tool-delegate_task", + state: "approval-responded", + approval: { id: "approval-call_123", approved: true } + } + ] +} +``` + +VoltAgent now resumes the pending guarded tool call in the sub-agent instead of re-requesting approval. diff --git a/examples/README.md b/examples/README.md index 1857294d7..6d6044b51 100644 --- a/examples/README.md +++ b/examples/README.md @@ -87,6 +87,7 @@ Create a multi-agent research workflow where different AI agents collaborate to - [Summarization](./with-summarization) — Agent summarization with a low trigger window for easy testing. - [Retries and Fallbacks](./with-retries-fallback) — Model fallback list with per-model retries and agent-level defaults. - [Middleware](./with-middleware) — Input/output middleware with retry feedback. +- [Human-in-the-Loop](./with-hitl) — Tool approvals with `needsApproval` and approval-response resume flow. - [PlanAgents](./with-planagents) — Quickstart for PlanAgents with planning, filesystem tools, and subagent tasks. - [Slack](./with-slack) — Slack app mention bot that replies in the same channel/thread via VoltOps Slack actions. - [Airtable](./with-airtable) — React to new Airtable records and write updates back using VoltOps Airtable actions. diff --git a/examples/with-hitl/.env.example b/examples/with-hitl/.env.example new file mode 100644 index 000000000..3cac181df --- /dev/null +++ b/examples/with-hitl/.env.example @@ -0,0 +1 @@ +OPENAI_API_KEY=your_openai_api_key_here diff --git a/examples/with-hitl/.gitignore b/examples/with-hitl/.gitignore new file mode 100644 index 000000000..0ca39c007 --- /dev/null +++ b/examples/with-hitl/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +.DS_Store diff --git a/examples/with-hitl/README.md b/examples/with-hitl/README.md new file mode 100644 index 000000000..728c40230 --- /dev/null +++ b/examples/with-hitl/README.md @@ -0,0 +1,87 @@ +# VoltAgent Human-in-the-Loop (HITL) Example + +This example shows how to use tool-level approvals with `needsApproval` while keeping agent DX unchanged. + +## What It Demonstrates + +- `crmHitlAgent`: direct CRM delete flow with `needsApproval`. +- `triageAgent` + `crmAgent`: subagent delegation flow where CRM delete uses `needsApproval`. +- Approval pause/resume semantics for both direct and delegated execution. + +## Setup + +```bash +pnpm install +``` + +Copy environment file: + +```bash +cp .env.example .env +``` + +Set: + +```bash +OPENAI_API_KEY=your_openai_api_key_here +``` + +## Run the Server + +```bash +pnpm dev +``` + +Registered agents: + +- `crmHitlAgent` +- `triageAgent` +- `crmAgent` + +## Manual Testing + +### 1) Direct agent (`crmHitlAgent`) + +Trigger approval: + +```bash +curl -X POST http://localhost:3141/agents/crmHitlAgent/chat \ + -H "Content-Type: application/json" \ + -d '{"input":"CRMdeki user_123 kullanıcısını kalıcı olarak sil."}' +``` + +You should see a pending `deleteCrmUser` approval state (`approval-requested`) and approve/deny actions in compatible UIs. + +### 2) Subagent path (`triageAgent`) + +Trigger delegation + CRM approval: + +```bash +curl -X POST http://localhost:3141/agents/triageAgent/chat \ + -H "Content-Type: application/json" \ + -d '{"input":"CRM tarafında user_123 hesabını tamamen kaldır."}' +``` + +Triage should delegate to `CRM Agent`, and approval should be required on CRM-side delete execution. +In this delegated path, approval UI can surface on the `delegate_task` tool part. This is expected; it still controls the underlying CRM delete approval. + +## Run Smoke Test + +From this directory: + +```bash +pnpm test:smoke +``` + +Or from repo root: + +```bash +pnpm --filter voltagent-example-with-hitl test:smoke +``` + +Smoke test validates both flows: + +- direct HITL (`crmHitlAgent`) +- subagent HITL (`triageAgent -> crmAgent`) + +It tries `examples/with-hitl/.env` first and falls back to `examples/base/.env` for `OPENAI_API_KEY`. diff --git a/examples/with-hitl/package.json b/examples/with-hitl/package.json new file mode 100644 index 000000000..51e43d676 --- /dev/null +++ b/examples/with-hitl/package.json @@ -0,0 +1,38 @@ +{ + "name": "voltagent-example-with-hitl", + "author": "", + "dependencies": { + "@ai-sdk/openai": "^3.0.0", + "@voltagent/cli": "^0.1.21", + "@voltagent/core": "^2.6.1", + "@voltagent/logger": "^2.0.2", + "@voltagent/server-hono": "^2.0.7", + "ai": "^6.0.0", + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/node": "^24.2.1", + "tsx": "^4.19.3", + "typescript": "^5.8.2" + }, + "keywords": [ + "agent", + "ai", + "voltagent" + ], + "license": "MIT", + "private": true, + "repository": { + "type": "git", + "url": "https://github.com/VoltAgent/voltagent.git", + "directory": "examples/with-hitl" + }, + "scripts": { + "build": "tsc", + "dev": "tsx watch --env-file=.env ./src", + "start": "node dist/index.js", + "test:smoke": "node ./scripts/smoke-test.mjs", + "volt": "volt" + }, + "type": "module" +} diff --git a/examples/with-hitl/src/index.ts b/examples/with-hitl/src/index.ts new file mode 100644 index 000000000..af0bc03e5 --- /dev/null +++ b/examples/with-hitl/src/index.ts @@ -0,0 +1,86 @@ +import { Agent, VoltAgent, createTool } from "@voltagent/core"; +import { createPinoLogger } from "@voltagent/logger"; +import { honoServer } from "@voltagent/server-hono"; +import { z } from "zod"; + +const logger = createPinoLogger({ + name: "with-hitl", + level: "info", +}); + +const deleteCrmUserTool = createTool({ + name: "deleteCrmUser", + description: "Permanently deletes a user record from CRM.", + parameters: z.object({ + userId: z.string().min(1), + reason: z.string().min(3).optional(), + }), + // Destructive action: always requires human approval. + needsApproval: true, + execute: async ({ userId, reason }) => { + return { + ok: true, + action: "user-deleted", + userId, + reason: reason || "not provided", + executedAt: new Date().toISOString(), + }; + }, +}); + +const crmHitlAgent = new Agent({ + name: "CRM HITL Agent", + instructions: [ + "You are a CRM operations assistant.", + "Execute account-management changes through available actions.", + "When a user asks to delete an account and provides a user ID, perform the deletion action first, then report status.", + "Deletion reason is optional; if it is not provided, use a short default reason like 'user-requested deletion'.", + "Do not claim a deletion succeeded unless the action actually ran.", + ].join("\n"), + model: "openai/gpt-4o-mini", + tools: [deleteCrmUserTool], +}); + +const crmAgent = new Agent({ + name: "CRM Agent", + instructions: [ + "You handle CRM account operations.", + "Execute requested account changes through available actions.", + "For delete-account requests, if user ID exists in the request or shared context, run the delete action before reporting status.", + "Deletion reason is optional; if missing, use 'user-requested deletion'.", + "Do not report deletion completion unless it actually happened.", + "Use concise operator-style responses.", + ].join("\n"), + model: "openai/gpt-4o-mini", + tools: [deleteCrmUserTool], +}); + +const triageAgent = new Agent({ + name: "Triage Agent", + instructions: [ + "You triage incoming support and operations requests.", + "Route CRM-specific account mutations to the CRM specialist.", + "Provide the user a short, clear final response.", + ].join("\n"), + model: "openai/gpt-4o-mini", + subAgents: [ + { + agent: crmAgent, + method: "streamText", + options: { + temperature: 0, + maxSteps: 4, + }, + }, + ], +}); + +new VoltAgent({ + agents: { + crmHitlAgent, + triageAgent, + crmAgent, + }, + server: honoServer(), + logger, +}); diff --git a/examples/with-hitl/tsconfig.json b/examples/with-hitl/tsconfig.json new file mode 100644 index 000000000..cee90c6f3 --- /dev/null +++ b/examples/with-hitl/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "outDir": "dist", + "skipLibCheck": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/core/src/agent/agent.spec.ts b/packages/core/src/agent/agent.spec.ts index 554c8b547..5f259f048 100644 --- a/packages/core/src/agent/agent.spec.ts +++ b/packages/core/src/agent/agent.spec.ts @@ -14,6 +14,7 @@ import { InMemoryStorageAdapter } from "../memory/adapters/storage/in-memory"; import { AgentRegistry } from "../registries/agent-registry"; import { ModelProviderRegistry } from "../registries/model-provider-registry"; import { Tool } from "../tool"; +import { convertModelMessagesToUIMessages } from "../utils/message-converter"; import { Workspace } from "../workspace"; import { Agent, renameProviderOptions } from "./agent"; import { ConversationBuffer } from "./conversation-buffer"; @@ -2331,6 +2332,327 @@ Use pandas and summarize findings.`.split("\n"), }); }); + describe("Tool Approval", () => { + it("returns approval-requested tool state when tool needs approval", async () => { + const executeSpy = vi.fn().mockResolvedValue({ ok: true }); + const tool = new Tool({ + name: "dangerousAction", + description: "Requires explicit user approval", + parameters: z.object({ target: z.string() }), + needsApproval: true, + execute: executeSpy, + }); + + const agent = new Agent({ + name: "ApprovalAgent", + instructions: "Use tools when needed.", + model: mockModel as any, + tools: [tool], + }); + + vi.mocked(ai.generateText).mockImplementation(async (args: any) => { + const output = await args.tools?.dangerousAction?.execute( + { target: "db" }, + { toolCallId: "call-approval-1", messages: args.messages }, + ); + + return { + text: "Approval required before running dangerousAction.", + usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, + finishReason: "stop", + toolCalls: [ + { + toolCallId: "call-approval-1", + toolName: "dangerousAction", + input: { target: "db" }, + }, + ], + toolResults: [ + { + toolCallId: "call-approval-1", + toolName: "dangerousAction", + output, + }, + ], + response: { + id: "resp-approval-1", + modelId: "test-model", + timestamp: new Date(), + messages: [ + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call-approval-1", + toolName: "dangerousAction", + input: { target: "db" }, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-approval-1", + toolName: "dangerousAction", + output, + }, + ], + }, + ], + }, + } as any; + }); + + const result = await agent.generateText("Run dangerous action."); + + expect(executeSpy).not.toHaveBeenCalled(); + expect(result.response?.messages).toBeDefined(); + + const uiMessages = convertModelMessagesToUIMessages(result.response?.messages || []); + expect(uiMessages).toHaveLength(1); + expect(uiMessages[0].parts[0]).toEqual({ + type: "tool-dangerousAction", + toolCallId: "call-approval-1", + state: "approval-requested", + input: { target: "db" }, + approval: { id: "approval-call-approval-1" }, + providerExecuted: false, + }); + }); + + it("executes approved tool calls using approval response history", async () => { + const executeSpy = vi.fn().mockResolvedValue({ ok: true, target: "db" }); + const tool = new Tool({ + name: "dangerousAction", + description: "Requires explicit user approval", + parameters: z.object({ target: z.string() }), + needsApproval: true, + execute: executeSpy, + }); + + const agent = new Agent({ + name: "ApprovalAgent", + instructions: "Use tools when needed.", + model: mockModel as any, + tools: [tool], + }); + + vi.mocked(ai.generateText).mockImplementation(async (args: any) => { + const output = await args.tools?.dangerousAction?.execute( + { target: "db" }, + { toolCallId: "call-approval-2", messages: args.messages }, + ); + + return { + text: "Action completed.", + usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, + finishReason: "stop", + toolCalls: [ + { + toolCallId: "call-approval-2", + toolName: "dangerousAction", + input: { target: "db" }, + }, + ], + toolResults: [ + { + toolCallId: "call-approval-2", + toolName: "dangerousAction", + output, + }, + ], + response: { + id: "resp-approval-2", + modelId: "test-model", + timestamp: new Date(), + messages: [ + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call-approval-2", + toolName: "dangerousAction", + input: { target: "db" }, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-approval-2", + toolName: "dangerousAction", + output, + }, + ], + }, + ], + }, + } as any; + }); + + const inputMessages: ModelMessage[] = [ + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call-approval-1", + toolName: "dangerousAction", + input: { target: "db" }, + }, + { + type: "tool-approval-request", + approvalId: "approval-call-approval-1", + toolCallId: "call-approval-1", + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-approval-response", + approvalId: "approval-call-approval-1", + approved: true, + }, + ], + }, + { + role: "user", + content: "Continue and run approved action.", + }, + ]; + + await agent.generateText(inputMessages as any); + + expect(executeSpy).toHaveBeenCalledTimes(1); + expect(executeSpy).toHaveBeenCalledWith( + { target: "db" }, + expect.objectContaining({ + toolContext: expect.objectContaining({ + callId: "call-approval-2", + }), + }), + ); + }); + + it("executes approved tool calls when approval comes from delegate_task UI part", async () => { + const executeSpy = vi.fn().mockResolvedValue({ ok: true, target: "db" }); + const tool = new Tool({ + name: "dangerousAction", + description: "Requires explicit user approval", + parameters: z.object({ target: z.string() }), + needsApproval: true, + execute: executeSpy, + }); + + const agent = new Agent({ + name: "ApprovalAgent", + instructions: "Use tools when needed.", + model: mockModel as any, + tools: [tool], + }); + + vi.mocked(ai.generateText).mockImplementation(async (args: any) => { + const output = await args.tools?.dangerousAction?.execute( + { target: "db" }, + { toolCallId: "call-approval-2", messages: args.messages }, + ); + + return { + text: "Action completed.", + usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, + finishReason: "stop", + toolCalls: [ + { + toolCallId: "call-approval-2", + toolName: "dangerousAction", + input: { target: "db" }, + }, + ], + toolResults: [ + { + toolCallId: "call-approval-2", + toolName: "dangerousAction", + output, + }, + ], + response: { + id: "resp-approval-2", + modelId: "test-model", + timestamp: new Date(), + messages: [ + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call-approval-2", + toolName: "dangerousAction", + input: { target: "db" }, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-approval-2", + toolName: "dangerousAction", + output, + }, + ], + }, + ], + }, + } as any; + }); + + const inputMessages: UIMessage[] = [ + { + id: "approval-msg-1", + role: "assistant", + parts: [ + { + type: "tool-delegate_task", + toolCallId: "delegate-call-1", + state: "approval-responded", + input: { target: "db" }, + approval: { + id: "approval-call-approval-1", + approved: true, + }, + } as any, + ], + }, + { + id: "approval-msg-2", + role: "user", + parts: [{ type: "text", text: "Continue and run approved action." }], + }, + ]; + + await agent.generateText(inputMessages as any); + + expect(executeSpy).toHaveBeenCalledTimes(1); + expect(executeSpy).toHaveBeenCalledWith( + { target: "db" }, + expect.objectContaining({ + toolContext: expect.objectContaining({ + callId: "call-approval-2", + }), + }), + ); + }); + }); + describe("Middleware", () => { it("runs input middleware before input guardrails", async () => { const inputMiddleware = ({ input }: { input: string | UIMessage[] }) => { diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts index 0d3987832..3cf6161cb 100644 --- a/packages/core/src/agent/agent.ts +++ b/packages/core/src/agent/agent.ts @@ -80,6 +80,11 @@ import type { import { randomUUID } from "../utils/id"; import { convertModelMessagesToUIMessages } from "../utils/message-converter"; import { NodeType, createNodeId } from "../utils/node-utils"; +import { + createToolApprovalFingerprint, + createToolApprovalOutput, + extractToolApprovalOutput, +} from "../utils/tool-approval"; import { zodSchemaToJsonUI } from "../utils/toolParser"; import { convertUsage } from "../utils/usage-converter"; import { normalizeFinishUsageStream, resolveFinishUsage } from "../utils/usage-normalizer"; @@ -505,6 +510,7 @@ export type StreamTextResultWithContext< readonly fullStream: AsyncIterable>; readonly usage: AIStreamTextResult["usage"]; readonly finishReason: AIStreamTextResult["finishReason"]; + readonly steps?: PromiseLike }> | undefined>; // Partial output stream for streaming structured objects readonly partialOutputStream?: AIStreamTextResult["partialOutputStream"]; toUIMessageStream: AIStreamTextResult["toUIMessageStream"]; @@ -1077,6 +1083,7 @@ export class Agent { feedback: _feedback, maxSteps: userMaxSteps, tools: userTools, + stopWhen: userStopWhen, conversationPersistence: _conversationPersistence, output, providerOptions, @@ -1131,7 +1138,10 @@ export class Agent { // Default values temperature: this.temperature, maxOutputTokens: this.maxOutputTokens, - stopWhen: options?.stopWhen ?? this.stopWhen ?? stepCountIs(maxSteps), + stopWhen: this.resolveStopWhen({ + override: userStopWhen, + maxSteps, + }), // User overrides from AI SDK options ...aiSDKOptions, maxRetries: 0, @@ -1656,6 +1666,7 @@ export class Agent { feedback: _feedback, maxSteps: userMaxSteps, tools: userTools, + stopWhen: userStopWhen, onFinish: userOnFinish, conversationPersistence: _conversationPersistence, output, @@ -1717,7 +1728,10 @@ export class Agent { // Default values temperature: this.temperature, maxOutputTokens: this.maxOutputTokens, - stopWhen: options?.stopWhen ?? this.stopWhen ?? stepCountIs(maxSteps), + stopWhen: this.resolveStopWhen({ + override: userStopWhen, + maxSteps, + }), // User overrides from AI SDK options ...aiSDKOptions, maxRetries: 0, @@ -2290,6 +2304,62 @@ export class Agent { }); }; + const normalizeToolApprovalChunks = ( + baseStream: ToUIMessageStreamReturn, + ): ToUIMessageStreamReturn => { + return createAsyncIterableReadable(async (controller) => { + const reader = (baseStream as ReadableStream).getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + if ( + value && + typeof value === "object" && + (value as Record).type === "tool-output-available" + ) { + const chunk = value as { + toolCallId?: string; + output?: unknown; + }; + const approvalOutput = extractToolApprovalOutput(chunk.output); + const chunkToolCallId = + typeof chunk.toolCallId === "string" && chunk.toolCallId.trim().length > 0 + ? chunk.toolCallId + : undefined; + + if (approvalOutput?.status === "requested") { + controller.enqueue({ + type: "tool-approval-request", + approvalId: approvalOutput.approvalId, + toolCallId: chunkToolCallId ?? approvalOutput.toolCallId, + } as UIStreamChunk); + continue; + } + + if (approvalOutput?.status === "denied") { + controller.enqueue({ + type: "tool-output-denied", + toolCallId: chunkToolCallId ?? approvalOutput.toolCallId, + } as UIStreamChunk); + continue; + } + } + + if (value !== undefined) { + controller.enqueue(value); + } + } + controller.close(); + } catch (error) { + controller.error(error); + } finally { + reader.releaseLock(); + } + }); + }; + const attachFeedbackMetadata = ( baseStream: ToUIMessageStreamReturn, ): ToUIMessageStreamReturn => { @@ -2334,7 +2404,8 @@ export class Agent { const baseStream = agent.subAgentManager.hasSubAgents() ? createMergedUIStream(resolvedStreamOptions) : getGuardrailAwareUIStream(resolvedStreamOptions); - return attachFeedbackMetadata(baseStream); + const approvalNormalizedStream = normalizeToolApprovalChunks(baseStream); + return attachFeedbackMetadata(approvalNormalizedStream); }; const toUIMessageStreamResponseSanitized = ( @@ -2374,6 +2445,15 @@ export class Agent { }, usage: result.usage, finishReason: result.finishReason, + get steps() { + return ( + result as unknown as { + steps?: + | PromiseLike }> | undefined> + | undefined; + } + ).steps; + }, get partialOutputStream() { return result.partialOutputStream; }, @@ -5623,6 +5703,39 @@ export class Agent { }; const handleToolError = async (errorValue: unknown) => { + const toolApprovalError = this.getToolApprovalErrorDetails(errorValue); + if (toolApprovalError) { + const approvalOutput = createToolApprovalOutput({ + status: toolApprovalError.status === "pending" ? "requested" : "denied", + approvalId: toolApprovalError.approvalId, + toolCallId: toolApprovalError.toolCallId, + toolName: toolApprovalError.toolName, + input: toolApprovalError.input, + reason: toolApprovalError.reason, + }); + + spanOutcome = { status: "completed", output: approvalOutput }; + + await tool.hooks?.onEnd?.({ + tool, + args, + output: approvalOutput, + error: undefined, + options: executionOptions, + }); + + await hooks.onToolEnd?.({ + agent: this, + tool, + output: approvalOutput, + error: undefined, + context: oc, + options: executionOptions, + }); + + return approvalOutput; + } + const error = errorValue instanceof Error ? errorValue : new Error(String(errorValue)); const voltAgentError = createVoltAgentError(error, { stage: "tool_execution", @@ -5692,6 +5805,12 @@ export class Agent { if (execute && isAsyncGeneratorFunction(execute)) { return async function* (this: Agent): AsyncGenerator { try { + await this.ensureToolApproval( + tool as Tool, + args, + executionOptions, + toolCallId, + ); await oc.traceContext.withSpan(toolSpan, async () => { await runToolStartHooks(); }); @@ -5752,6 +5871,8 @@ export class Agent { return oc.traceContext.withSpan(toolSpan, async () => { try { + await this.ensureToolApproval(tool as Tool, args, executionOptions, toolCallId); + // Call tool start hook - can throw ToolDeniedError await runToolStartHooks(); @@ -6236,13 +6357,6 @@ export class Agent { } } - await this.ensureToolApproval( - target as Tool, - parsedArgs, - executionOptions, - toolCallId, - ); - const execute = this.createToolExecutionFactory(oc, hooks)(target as Tool); return await execute(parsedArgs, { toolCallId, @@ -6383,6 +6497,541 @@ export class Agent { return toolResult.output; } + private createToolApprovalId(toolCallId: string): string { + return `approval-${toolCallId}`; + } + + private createToolApprovalInputFingerprint(args: Record): string { + return safeStringify(args); + } + + private hasPendingToolApprovalInSteps(steps: Array>): boolean { + const lastStep = steps.at(-1) as { toolResults?: Array<{ output?: unknown }> } | undefined; + if (!lastStep || !Array.isArray(lastStep.toolResults) || lastStep.toolResults.length === 0) { + return false; + } + + return lastStep.toolResults.some((toolResult) => { + const approvalOutput = extractToolApprovalOutput(toolResult?.output); + return approvalOutput?.status === "requested"; + }); + } + + private resolveStopWhen(params: { override?: StopWhen; maxSteps: number }): StopWhen { + const { override, maxSteps } = params; + const baseStopWhen = override ?? this.stopWhen ?? stepCountIs(maxSteps); + + const stopWhenPendingApproval = ({ steps }: { steps: Array> }): boolean => { + return this.hasPendingToolApprovalInSteps(steps); + }; + + if (Array.isArray(baseStopWhen)) { + return [...baseStopWhen, stopWhenPendingApproval]; + } + + return [baseStopWhen, stopWhenPendingApproval]; + } + + private createToolApprovalError(params: { + status: "pending" | "denied"; + toolName: string; + toolCallId: string; + approvalId: string; + input: Record; + reason?: string; + }): ToolDeniedError { + const error = new ToolDeniedError({ + toolName: params.toolName, + message: + params.status === "pending" + ? `Tool ${params.toolName} requires approval.` + : params.reason || `Tool ${params.toolName} execution denied.`, + code: "TOOL_FORBIDDEN", + httpStatus: 403, + }); + + Object.assign(error, { + toolApproval: { + status: params.status, + approvalId: params.approvalId, + toolCallId: params.toolCallId, + toolName: params.toolName, + input: params.input, + reason: params.reason, + }, + }); + + return error; + } + + private getToolApprovalErrorDetails(error: unknown): { + status: "pending" | "denied"; + approvalId: string; + toolCallId: string; + toolName: string; + input: Record; + reason?: string; + } | null { + if (!isToolDeniedError(error) || !isRecord(error)) { + return null; + } + + const approval = error.toolApproval; + if (!isRecord(approval)) { + return null; + } + + const status = + approval.status === "pending" || approval.status === "denied" ? approval.status : null; + const approvalId = hasNonEmptyString(approval.approvalId) ? approval.approvalId : null; + const toolCallId = hasNonEmptyString(approval.toolCallId) ? approval.toolCallId : null; + const toolName = hasNonEmptyString(approval.toolName) ? approval.toolName : null; + const input = + isRecord(approval.input) && !Array.isArray(approval.input) + ? (approval.input as Record) + : null; + + if (!status || !approvalId || !toolCallId || !toolName || !input) { + return null; + } + + return { + status, + approvalId, + toolCallId, + toolName, + input, + reason: hasNonEmptyString(approval.reason) ? approval.reason : undefined, + }; + } + + private resolveToolApprovalDecisionFromMessages(params: { + messages: ModelMessage[]; + toolName: string; + args: Record; + approvalId: string; + }): { approved: boolean; reason?: string } | null { + const { messages, toolName, args, approvalId } = params; + if (!messages.length) { + return null; + } + + const toolCallFingerprintById = new Map(); + const toolCallInputFingerprintById = new Map(); + const toolCallNameById = new Map(); + const approvalIdToToolCallId = new Map(); + const approvalIdToFingerprint = new Map(); + const approvalIdToInputFingerprint = new Map(); + const approvalIdToToolName = new Map(); + const decisionByApprovalId = new Map(); + + for (const message of messages) { + if (message.role === "assistant") { + if (!Array.isArray(message.content)) { + continue; + } + + for (const rawPart of message.content as unknown[]) { + if (!isRecord(rawPart)) { + continue; + } + + const part = rawPart as Record; + const partType = hasNonEmptyString(part.type) ? part.type : null; + if (partType === "tool-call") { + const toolCallId = hasNonEmptyString(part.toolCallId) ? part.toolCallId : null; + const toolCallName = hasNonEmptyString(part.toolName) ? part.toolName : null; + const input = + isRecord(part.input) && !Array.isArray(part.input) + ? (part.input as Record) + : {}; + if (toolCallId && toolCallName) { + toolCallFingerprintById.set( + toolCallId, + createToolApprovalFingerprint(toolCallName, input), + ); + toolCallInputFingerprintById.set( + toolCallId, + this.createToolApprovalInputFingerprint(input), + ); + toolCallNameById.set(toolCallId, toolCallName); + } + continue; + } + + if (partType === "tool-approval-request") { + const approvalRequestId = hasNonEmptyString(part.approvalId) ? part.approvalId : null; + const toolCallId = hasNonEmptyString(part.toolCallId) ? part.toolCallId : null; + if (!approvalRequestId || !toolCallId) { + continue; + } + + approvalIdToToolCallId.set(approvalRequestId, toolCallId); + const fingerprint = toolCallFingerprintById.get(toolCallId); + if (fingerprint) { + approvalIdToFingerprint.set(approvalRequestId, fingerprint); + } + const inputFingerprint = toolCallInputFingerprintById.get(toolCallId); + if (inputFingerprint) { + approvalIdToInputFingerprint.set(approvalRequestId, inputFingerprint); + } + const toolCallName = toolCallNameById.get(toolCallId); + if (toolCallName) { + approvalIdToToolName.set(approvalRequestId, toolCallName); + } + } + } + continue; + } + + if (message.role !== "tool" || !Array.isArray(message.content)) { + continue; + } + + for (const rawPart of message.content as unknown[]) { + if (!isRecord(rawPart)) { + continue; + } + + const part = rawPart as Record; + const partType = hasNonEmptyString(part.type) ? part.type : null; + if (partType === "tool-result") { + const approvalOutput = extractToolApprovalOutput(part.output); + if (!approvalOutput) { + continue; + } + + approvalIdToToolCallId.set(approvalOutput.approvalId, approvalOutput.toolCallId); + approvalIdToFingerprint.set( + approvalOutput.approvalId, + createToolApprovalFingerprint(approvalOutput.toolName, approvalOutput.input || {}), + ); + approvalIdToInputFingerprint.set( + approvalOutput.approvalId, + this.createToolApprovalInputFingerprint(approvalOutput.input || {}), + ); + approvalIdToToolName.set(approvalOutput.approvalId, approvalOutput.toolName); + + if (approvalOutput.status === "denied") { + decisionByApprovalId.set(approvalOutput.approvalId, { + approved: false, + reason: approvalOutput.reason, + }); + } + continue; + } + + if (partType !== "tool-approval-response") { + continue; + } + + const approvalResponseId = hasNonEmptyString(part.approvalId) ? part.approvalId : null; + if (!approvalResponseId || typeof part.approved !== "boolean") { + continue; + } + + decisionByApprovalId.set(approvalResponseId, { + approved: part.approved, + reason: hasNonEmptyString(part.reason) ? part.reason : undefined, + }); + } + } + + if (!approvalIdToFingerprint.has(approvalId)) { + const toolCallId = approvalIdToToolCallId.get(approvalId); + if (toolCallId) { + const fingerprint = toolCallFingerprintById.get(toolCallId); + if (fingerprint) { + approvalIdToFingerprint.set(approvalId, fingerprint); + } + const inputFingerprint = toolCallInputFingerprintById.get(toolCallId); + if (inputFingerprint) { + approvalIdToInputFingerprint.set(approvalId, inputFingerprint); + } + const toolNameFromCall = toolCallNameById.get(toolCallId); + if (toolNameFromCall) { + approvalIdToToolName.set(approvalId, toolNameFromCall); + } + } + } + + const directDecision = decisionByApprovalId.get(approvalId); + if (directDecision) { + return directDecision; + } + + const decisionByFingerprint = new Map(); + const decisionByDelegateInputFingerprint = new Map< + string, + { approved: boolean; reason?: string } + >(); + for (const [approvalResponseId, decision] of decisionByApprovalId.entries()) { + if (!approvalIdToFingerprint.has(approvalResponseId)) { + const toolCallId = approvalIdToToolCallId.get(approvalResponseId); + if (toolCallId) { + const fingerprint = toolCallFingerprintById.get(toolCallId); + if (fingerprint) { + approvalIdToFingerprint.set(approvalResponseId, fingerprint); + } + const inputFingerprint = toolCallInputFingerprintById.get(toolCallId); + if (inputFingerprint) { + approvalIdToInputFingerprint.set(approvalResponseId, inputFingerprint); + } + const toolNameFromCall = toolCallNameById.get(toolCallId); + if (toolNameFromCall) { + approvalIdToToolName.set(approvalResponseId, toolNameFromCall); + } + } + } + + const fingerprint = approvalIdToFingerprint.get(approvalResponseId); + if (fingerprint) { + decisionByFingerprint.set(fingerprint, decision); + } + + const inputFingerprint = approvalIdToInputFingerprint.get(approvalResponseId); + const sourceToolName = approvalIdToToolName.get(approvalResponseId); + if (inputFingerprint && sourceToolName === "delegate_task") { + decisionByDelegateInputFingerprint.set(inputFingerprint, decision); + } + } + + const currentFingerprint = createToolApprovalFingerprint(toolName, args); + const directFingerprintDecision = decisionByFingerprint.get(currentFingerprint); + if (directFingerprintDecision) { + return directFingerprintDecision; + } + + if (toolName !== "delegate_task") { + const currentInputFingerprint = this.createToolApprovalInputFingerprint(args); + const delegateDecision = decisionByDelegateInputFingerprint.get(currentInputFingerprint); + if (delegateDecision) { + return delegateDecision; + } + } + + return null; + } + + private resolveToolApprovalDecisionFromInput(params: { + input: unknown; + toolName: string; + args: Record; + approvalId: string; + }): { approved: boolean; reason?: string } | null { + const { input, toolName, args, approvalId } = params; + if (!Array.isArray(input) || input.length === 0) { + return null; + } + + const first = input[0]; + if (!isRecord(first)) { + return null; + } + + if (Array.isArray((first as { parts?: unknown }).parts)) { + return this.resolveToolApprovalDecisionFromUIMessages({ + messages: input as UIMessage[], + toolName, + args, + approvalId, + }); + } + + if ("role" in first && "content" in first) { + return this.resolveToolApprovalDecisionFromMessages({ + messages: input as ModelMessage[], + toolName, + args, + approvalId, + }); + } + + return null; + } + + private resolveToolApprovalDecisionFromUIMessages(params: { + messages: UIMessage[]; + toolName: string; + args: Record; + approvalId: string; + }): { approved: boolean; reason?: string } | null { + const { messages, toolName, args, approvalId } = params; + if (!messages.length) { + return null; + } + + const approvalIdToFingerprint = new Map(); + const approvalIdToInputFingerprint = new Map(); + const approvalIdToToolName = new Map(); + const decisionByApprovalId = new Map(); + + const resolveReasonFromOutput = (output: unknown): string | undefined => { + if (!isRecord(output)) { + return undefined; + } + if (hasNonEmptyString(output.reason)) { + return output.reason; + } + if (hasNonEmptyString(output.message)) { + return output.message; + } + return undefined; + }; + + for (const message of messages) { + if (!Array.isArray((message as { parts?: unknown }).parts)) { + continue; + } + + const parts = (message as { parts: unknown[] }).parts; + for (const part of parts) { + if (!isRecord(part) || !hasNonEmptyString(part.type)) { + continue; + } + + if (!part.type.startsWith("tool-")) { + continue; + } + + const partToolName = part.type.slice("tool-".length); + if (!partToolName) { + continue; + } + + const partInput = + isRecord(part.input) && !Array.isArray(part.input) + ? (part.input as Record) + : {}; + + const partFingerprint = createToolApprovalFingerprint(partToolName, partInput); + const partInputFingerprint = this.createToolApprovalInputFingerprint(partInput); + + const approval = isRecord(part.approval) ? part.approval : null; + if (!approval) { + continue; + } + + const approvalEntryId = hasNonEmptyString(approval.id) ? approval.id : null; + if (!approvalEntryId) { + continue; + } + + approvalIdToFingerprint.set(approvalEntryId, partFingerprint); + approvalIdToInputFingerprint.set(approvalEntryId, partInputFingerprint); + approvalIdToToolName.set(approvalEntryId, partToolName); + + if (typeof approval.approved === "boolean") { + decisionByApprovalId.set(approvalEntryId, { + approved: approval.approved, + reason: hasNonEmptyString(approval.reason) ? approval.reason : undefined, + }); + continue; + } + + if (part.state === "output-denied") { + decisionByApprovalId.set(approvalEntryId, { + approved: false, + reason: + (hasNonEmptyString(approval.reason) ? approval.reason : undefined) ?? + resolveReasonFromOutput(part.output), + }); + } + } + } + + const directDecision = decisionByApprovalId.get(approvalId); + if (directDecision) { + return directDecision; + } + + const decisionByFingerprint = new Map(); + const decisionByDelegateInputFingerprint = new Map< + string, + { approved: boolean; reason?: string } + >(); + for (const [approvalEntryId, decision] of decisionByApprovalId.entries()) { + const fingerprint = approvalIdToFingerprint.get(approvalEntryId); + if (fingerprint) { + decisionByFingerprint.set(fingerprint, decision); + } + + const inputFingerprint = approvalIdToInputFingerprint.get(approvalEntryId); + const sourceToolName = approvalIdToToolName.get(approvalEntryId); + if (inputFingerprint && sourceToolName === "delegate_task") { + decisionByDelegateInputFingerprint.set(inputFingerprint, decision); + } + } + + const currentFingerprint = createToolApprovalFingerprint(toolName, args); + const directFingerprintDecision = decisionByFingerprint.get(currentFingerprint); + if (directFingerprintDecision) { + return directFingerprintDecision; + } + + if (toolName !== "delegate_task") { + const currentInputFingerprint = this.createToolApprovalInputFingerprint(args); + const delegateDecision = decisionByDelegateInputFingerprint.get(currentInputFingerprint); + if (delegateDecision) { + return delegateDecision; + } + } + + return null; + } + + private resolveToolApprovalDecisionFromElicitation( + result: unknown, + ): { approved: boolean; reason?: string } | null { + if (typeof result === "boolean") { + return { approved: result }; + } + + if (!isRecord(result)) { + return null; + } + + if (typeof result.approved === "boolean") { + return { + approved: result.approved, + reason: hasNonEmptyString(result.reason) ? result.reason : undefined, + }; + } + + const action = hasNonEmptyString(result.action) ? result.action.toLowerCase() : null; + const content = isRecord(result.content) ? result.content : null; + const contentReason = content && hasNonEmptyString(content.reason) ? content.reason : undefined; + + if (content && typeof content.approved === "boolean") { + return { + approved: content.approved, + reason: contentReason || (hasNonEmptyString(result.reason) ? result.reason : undefined), + }; + } + + if (content && typeof content.confirm === "boolean") { + return { + approved: content.confirm, + reason: contentReason || (hasNonEmptyString(result.reason) ? result.reason : undefined), + }; + } + + if (action === "accept") { + return { + approved: true, + reason: contentReason || (hasNonEmptyString(result.reason) ? result.reason : undefined), + }; + } + if (action === "decline" || action === "cancel" || action === "reject") { + return { + approved: false, + reason: contentReason || (hasNonEmptyString(result.reason) ? result.reason : undefined), + }; + } + + return null; + } + private async ensureToolApproval( tool: Tool | ProviderTool, args: Record, @@ -6404,14 +7053,87 @@ export class Agent { }) : needsApproval; - if (requiresApproval) { - throw new ToolDeniedError({ + if (!requiresApproval) { + return; + } + + const approvalId = this.createToolApprovalId(toolCallId); + const messages = (options.toolContext?.messages ?? []) as ModelMessage[]; + const decisionFromMessages = this.resolveToolApprovalDecisionFromMessages({ + messages, + toolName: tool.name, + args, + approvalId, + }); + + const decisionFromInput = this.resolveToolApprovalDecisionFromInput({ + input: options.input, + toolName: tool.name, + args, + approvalId, + }); + + const approvalDecision = decisionFromMessages ?? decisionFromInput; + + if (approvalDecision?.approved) { + return; + } + + if (approvalDecision && !approvalDecision.approved) { + throw this.createToolApprovalError({ + status: "denied", toolName: tool.name, - message: `Tool ${tool.name} requires approval.`, - code: "TOOL_FORBIDDEN", - httpStatus: 403, + toolCallId, + approvalId, + input: args, + reason: approvalDecision.reason || "Tool execution denied by user.", }); } + + const elicitation = options.elicitation; + if (typeof elicitation === "function") { + const elicitationResult = await elicitation({ + type: "tool-approval", + approvalId, + toolCallId, + toolName: tool.name, + args, + message: `Approve tool "${tool.name}" before execution.`, + requestedSchema: { + type: "object", + properties: { + approved: { type: "boolean", description: "Whether to approve tool execution." }, + reason: { type: "string", description: "Optional reason for denial." }, + }, + required: ["approved"], + }, + }); + const decisionFromElicitation = + this.resolveToolApprovalDecisionFromElicitation(elicitationResult); + + if (decisionFromElicitation?.approved) { + return; + } + + if (decisionFromElicitation && !decisionFromElicitation.approved) { + throw this.createToolApprovalError({ + status: "denied", + toolName: tool.name, + toolCallId, + approvalId, + input: args, + reason: decisionFromElicitation.reason || "Tool execution denied by user.", + }); + } + } + + throw this.createToolApprovalError({ + status: "pending", + toolName: tool.name, + toolCallId, + approvalId, + input: args, + }); } private async runInternalGenerateText(params: { diff --git a/packages/core/src/agent/subagent/index.spec.ts b/packages/core/src/agent/subagent/index.spec.ts index 20b5404d7..2e2f92f70 100644 --- a/packages/core/src/agent/subagent/index.spec.ts +++ b/packages/core/src/agent/subagent/index.spec.ts @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createToolApprovalOutput } from "../../utils/tool-approval"; import type { Agent } from "../agent"; +import { isToolDeniedError } from "../errors"; import { AGENT_METADATA_CONTEXT_KEY } from "../memory-persist-queue"; import { SubAgentManager } from "./index"; import { @@ -435,6 +437,158 @@ describe("SubAgentManager", () => { ); }); + it("should propagate sub-agent pending approvals to delegate_task", async () => { + const approvalAgent = createMockAgent({ + id: "crm-agent", + name: "CRM Agent", + }); + const pendingApprovalOutput = createToolApprovalOutput({ + status: "requested", + approvalId: "approval-call-delete-1", + toolCallId: "call-delete-1", + toolName: "delete_crm_user", + input: { userId: "user_123" }, + }); + + vi.spyOn(approvalAgent, "streamText").mockResolvedValue({ + fullStream: createMockStream([ + mockStreamEvents.toolCall("call-delete-1", "delete_crm_user", { + userId: "user_123", + }), + mockStreamEvents.toolResult("call-delete-1", "delete_crm_user", pendingApprovalOutput), + mockStreamEvents.finish(), + ]), + toUIMessageStream: vi.fn(), + text: Promise.resolve(""), + usage: Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + totalTokens: 15, + }), + steps: Promise.resolve([ + { + toolResults: [{ output: pendingApprovalOutput }], + }, + ]), + } as any); + + subAgentManager.addSubAgent(approvalAgent); + const tool = subAgentManager.createDelegateTool(mockDelegateToolOptions); + + let thrown: unknown; + try { + await tool.execute({ + targetAgents: ["CRM Agent"], + task: "Delete account user_123", + }); + } catch (error) { + thrown = error; + } + + expect(isToolDeniedError(thrown)).toBe(true); + expect(thrown).toMatchObject({ + toolApproval: { + status: "pending", + approvalId: "approval-call-delete-1", + toolCallId: "call-delete-1", + toolName: "delete_crm_user", + input: { userId: "user_123" }, + }, + }); + }); + + it("should forward parent user messages as shared context to delegated agent", async () => { + const contextAwareAgent = createMockAgentWithStubs({ + id: "crm-context-agent", + name: "CRM Agent", + }); + const streamTextSpy = vi.spyOn(contextAwareAgent, "streamText"); + + subAgentManager.addSubAgent(contextAwareAgent); + const tool = subAgentManager.createDelegateTool(mockDelegateToolOptions); + + await tool.execute( + { + targetAgents: ["CRM Agent"], + task: "Delete account", + }, + { + toolContext: { + name: "delegate_task", + callId: "delegate-call-context-1", + messages: [ + { + role: "user", + content: [ + { + type: "text", + text: 'CRMdeki "user_123" kullanicisini kalici olarak sil.', + }, + ], + }, + { + role: "user", + content: [{ type: "text", text: 'userid: "user_123"' }], + }, + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "delegate-call-context-1", + toolName: "delegate_task", + input: { + task: "Delete account", + targetAgents: ["CRM Agent"], + }, + }, + { + type: "tool-approval-request", + approvalId: "approval-call-delete-1", + toolCallId: "delegate-call-context-1", + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-approval-response", + approvalId: "approval-call-delete-1", + approved: true, + }, + ], + }, + ], + }, + } as any, + ); + + expect(streamTextSpy).toHaveBeenCalledTimes(1); + const [forwardedMessages] = streamTextSpy.mock.calls[0]; + const flattenedText = (forwardedMessages as any[]) + .flatMap((message) => (Array.isArray(message?.parts) ? message.parts : [])) + .filter((part) => part?.type === "text") + .map((part) => part.text) + .join("\n"); + + expect(flattenedText).toContain("user_123"); + expect(flattenedText).toContain("Delete account"); + + const hasApprovalResponse = (forwardedMessages as any[]).some( + (message) => + message?.role === "assistant" && + Array.isArray(message?.parts) && + message.parts.some( + (part: any) => + part?.toolCallId === "delegate-call-context-1" && + part?.approval?.id === "approval-call-delete-1" && + part?.approval?.approved === true, + ), + ); + expect(hasApprovalResponse).toBe(true); + }); + it("should return error when no valid agents found", async () => { const tool = subAgentManager.createDelegateTool(mockDelegateToolOptions); diff --git a/packages/core/src/agent/subagent/index.ts b/packages/core/src/agent/subagent/index.ts index 2ed79d573..8cba5c05e 100644 --- a/packages/core/src/agent/subagent/index.ts +++ b/packages/core/src/agent/subagent/index.ts @@ -6,6 +6,11 @@ import { getGlobalLogger } from "../../logger"; import { AgentRegistry } from "../../registries/agent-registry"; import { createTool } from "../../tool"; import type { Tool } from "../../tool"; +import { convertModelMessagesToUIMessages } from "../../utils/message-converter"; +import { + type ToolApprovalOutputPayload, + extractToolApprovalOutput, +} from "../../utils/tool-approval"; import type { Agent } from "../agent"; import type { GenerateObjectOptions, @@ -13,6 +18,7 @@ import type { StreamObjectOptions, StreamTextOptions, } from "../agent"; +import { ToolDeniedError, isToolDeniedError } from "../errors"; import { AGENT_METADATA_CONTEXT_KEY, type AgentMetadataContextValue, @@ -405,26 +411,38 @@ ${task}\n\nContext: ${safeStringify(contextObj, { indentation: 2 })}`; if (this.isDirectAgent(targetAgentConfig)) { // Direct agent - use streamText by default - const { finalResult: streamResult, usage: streamUsage } = - await this.streamTextWithForwarding( - targetAgent, - messages, - baseOptions, - parentOperationContext, - ); + const { + finalResult: streamResult, + usage: streamUsage, + pendingApproval, + } = await this.streamTextWithForwarding( + targetAgent, + messages, + baseOptions, + parentOperationContext, + ); + if (pendingApproval) { + throw this.createSubagentToolApprovalError(pendingApproval); + } finalResult = streamResult; usage = streamUsage; finalMessages = [taskMessage, this.createAssistantMessage(finalResult)]; } else if (this.isStreamTextConfig(targetAgentConfig)) { // StreamText configuration const options: StreamTextOptions = { ...baseOptions, ...targetAgentConfig.options }; - const { finalResult: streamResult, usage: streamUsage } = - await this.streamTextWithForwarding( - targetAgent, - messages, - options, - parentOperationContext, - ); + const { + finalResult: streamResult, + usage: streamUsage, + pendingApproval, + } = await this.streamTextWithForwarding( + targetAgent, + messages, + options, + parentOperationContext, + ); + if (pendingApproval) { + throw this.createSubagentToolApprovalError(pendingApproval); + } finalResult = streamResult; usage = streamUsage; finalMessages = [taskMessage, this.createAssistantMessage(finalResult)]; @@ -432,6 +450,12 @@ ${task}\n\nContext: ${safeStringify(contextObj, { indentation: 2 })}`; // GenerateText configuration const options: GenerateTextOptions = { ...baseOptions, ...targetAgentConfig.options }; const response = await targetAgent.generateText(messages, options); + const pendingApproval = this.extractPendingApprovalFromToolResults( + (response as { toolResults?: Array<{ output?: unknown }> }).toolResults, + ); + if (pendingApproval) { + throw this.createSubagentToolApprovalError(pendingApproval); + } finalResult = response.text; usage = response.usage; finalMessages = [taskMessage, this.createAssistantMessage(finalResult)]; @@ -539,6 +563,10 @@ ${task}\n\nContext: ${safeStringify(contextObj, { indentation: 2 })}`; usage, }; } catch (error) { + if (isToolDeniedError(error)) { + throw error; + } + // If this is the stream error we marked for rethrowing, rethrow it if (streamErrorToThrow && error === streamErrorToThrow) { throw error; @@ -619,7 +647,12 @@ ${task}\n\nContext: ${safeStringify(contextObj, { indentation: 2 })}`; const enrichedStream = createMetadataEnrichedStream( subagentUIStream, forwardingMetadata, - this.supervisorConfig?.fullStreamEventForwarding?.types || ["tool-call", "tool-result"], + this.supervisorConfig?.fullStreamEventForwarding?.types || [ + "tool-call", + "tool-result", + "tool-approval-request", + "tool-approval-response", + ], ); // Use the writer to merge the enriched stream @@ -650,6 +683,8 @@ ${task}\n\nContext: ${safeStringify(contextObj, { indentation: 2 })}`; const allowedTypes = this.supervisorConfig?.fullStreamEventForwarding?.types || [ "tool-call", "tool-result", + "tool-approval-request", + "tool-approval-response", ]; // Write subagent's fullStream events with metadata @@ -683,13 +718,20 @@ ${task}\n\nContext: ${safeStringify(contextObj, { indentation: 2 })}`; private async resolveStreamResult( response: Awaited>, parentOperationContext?: OperationContext, - ): Promise<{ finalResult: string; usage: any }> { + ): Promise<{ + finalResult: string; + usage: any; + pendingApproval: ToolApprovalOutputPayload | null; + }> { const bailedResultFromContext = parentOperationContext?.systemContext?.get("bailedResult") as | { agentName: string; response: string } | undefined; + const [pendingApproval, usage] = await Promise.all([ + this.resolvePendingApprovalFromStream(response), + response.usage, + ]); const finalResult = bailedResultFromContext?.response || (await response.text); - const usage = await response.usage; - return { finalResult, usage }; + return { finalResult, usage, pendingApproval }; } private async streamTextWithForwarding( @@ -697,13 +739,88 @@ ${task}\n\nContext: ${safeStringify(contextObj, { indentation: 2 })}`; messages: UIMessage[], options: StreamTextOptions, parentOperationContext: OperationContext | undefined, - ): Promise<{ finalResult: string; usage: any }> { + ): Promise<{ + finalResult: string; + usage: any; + pendingApproval: ToolApprovalOutputPayload | null; + }> { const response = await targetAgent.streamText(messages, options); const forwardingMetadata = this.buildForwardingMetadata(targetAgent, parentOperationContext); this.forwardStreamEvents(response, forwardingMetadata, parentOperationContext, targetAgent); return this.resolveStreamResult(response, parentOperationContext); } + private async resolvePendingApprovalFromStream( + response: Awaited>, + ): Promise { + const stepsPromise = ( + response as { + steps?: + | PromiseLike }> | undefined> + | undefined; + } + ).steps; + + if (!stepsPromise) { + return null; + } + + try { + const steps = await stepsPromise; + if (!Array.isArray(steps) || steps.length === 0) { + return null; + } + + const lastStep = steps.at(-1); + return this.extractPendingApprovalFromToolResults(lastStep?.toolResults); + } catch { + return null; + } + } + + private extractPendingApprovalFromToolResults( + toolResults: Array<{ output?: unknown }> | undefined, + ): ToolApprovalOutputPayload | null { + if (!Array.isArray(toolResults) || toolResults.length === 0) { + return null; + } + + for (const toolResult of toolResults) { + const approvalOutput = extractToolApprovalOutput(toolResult?.output); + if (approvalOutput?.status === "requested") { + return approvalOutput; + } + } + + return null; + } + + private createSubagentToolApprovalError(approval: ToolApprovalOutputPayload): ToolDeniedError { + const status = approval.status === "requested" ? "pending" : "denied"; + const error = new ToolDeniedError({ + toolName: approval.toolName, + message: + status === "pending" + ? `Tool ${approval.toolName} requires approval.` + : approval.reason || `Tool ${approval.toolName} execution denied.`, + code: "TOOL_FORBIDDEN", + httpStatus: 403, + }); + + Object.assign(error, { + toolApproval: { + status, + approvalId: approval.approvalId, + toolCallId: approval.toolCallId, + toolName: approval.toolName, + input: approval.input || {}, + reason: approval.reason, + }, + }); + + return error; + } + /** * Hand off a task to multiple agents in parallel */ @@ -822,12 +939,14 @@ ${task}\n\nContext: ${safeStringify(contextObj, { indentation: 2 })}`; // Convert context from record to Map const contextMap = new Map(Object.entries(context)); + const sharedContext = this.resolveSharedContextFromExecuteOptions(executeOptions); // Execute handoffToMultiple - Agent.ts wrapper handles span creation const results = await this.handoffToMultiple({ task, targetAgents: agents, context: contextMap, + sharedContext, sourceAgent, // Pass parent context for event propagation parentAgentId: sourceAgent?.id, @@ -859,6 +978,10 @@ ${task}\n\nContext: ${safeStringify(contextObj, { indentation: 2 })}`; // Always return array for consistent API return structuredResults; } catch (error) { + if (isToolDeniedError(error)) { + throw error; + } + logger.error("Error in delegate_task tool execution", { error }); // Return structured error to the LLM @@ -871,6 +994,24 @@ ${task}\n\nContext: ${safeStringify(contextObj, { indentation: 2 })}`; }); } + private resolveSharedContextFromExecuteOptions( + executeOptions: Record | undefined, + ): UIMessage[] { + const toolMessages = (executeOptions as { toolContext?: { messages?: unknown } })?.toolContext + ?.messages; + + if (!Array.isArray(toolMessages) || toolMessages.length === 0) { + return []; + } + + try { + const uiMessages = convertModelMessagesToUIMessages(toolMessages as any); + return uiMessages; + } catch { + return []; + } + } + /** * Get sub-agent details for API exposure */ diff --git a/packages/core/src/agent/subagent/stream-metadata-enricher.ts b/packages/core/src/agent/subagent/stream-metadata-enricher.ts index 4d14bc37c..0ae9bffdf 100644 --- a/packages/core/src/agent/subagent/stream-metadata-enricher.ts +++ b/packages/core/src/agent/subagent/stream-metadata-enricher.ts @@ -13,6 +13,7 @@ export type AsyncIterableStream = AsyncIterable & ReadableStream; * Stream event type from AI SDK */ export type StreamEventType = TextStreamPart["type"]; +export type ForwardableStreamEventType = StreamEventType | "tool-approval-response"; /** * Metadata to be added to stream parts @@ -38,7 +39,7 @@ const SUBAGENT_DATA_EVENT_TYPE = "data-subagent-stream" as const; export function createMetadataEnrichedStream( originalStream: AsyncIterableStream, metadata: StreamMetadata, - allowedTypes?: StreamEventType[], + allowedTypes?: ForwardableStreamEventType[], ): AsyncIterableStream { // Create a ReadableStream that prefixes event types const readableStream = new ReadableStream({ @@ -104,11 +105,14 @@ export function createMetadataEnrichedStream( * @param allowedTypes - Optional list of allowed event types * @returns Whether the chunk should be forwarded */ -export function shouldForwardChunk(chunk: any, allowedTypes?: StreamEventType[]): boolean { +export function shouldForwardChunk( + chunk: any, + allowedTypes?: ForwardableStreamEventType[], +): boolean { if (!allowedTypes || allowedTypes.length === 0) { return true; // Forward all chunks if no filter } - const chunkType = chunk?.type as StreamEventType | undefined; + const chunkType = chunk?.type as ForwardableStreamEventType | undefined; return chunkType ? allowedTypes.includes(chunkType) : false; } diff --git a/packages/core/src/agent/types.ts b/packages/core/src/agent/types.ts index 78b0434eb..4723bdb10 100644 --- a/packages/core/src/agent/types.ts +++ b/packages/core/src/agent/types.ts @@ -335,7 +335,7 @@ export type FullStreamEventForwardingConfig = { * 'tool-call', 'tool-result', 'tool-error' * - Other: 'source', 'file', 'start-step', 'finish-step', * 'start', 'finish', 'abort', 'error', 'raw' - * @default ['tool-call', 'tool-result'] + * @default ['tool-call', 'tool-result', 'tool-approval-request', 'tool-approval-response'] * @example ['tool-call', 'tool-result', 'text-delta'] */ types?: StreamEventType[]; @@ -363,7 +363,7 @@ export type SupervisorConfig = { /** * Configuration for forwarding events from subagents to the parent agent's full stream * Controls which event types are forwarded - * @default { types: ['tool-call', 'tool-result'] } + * @default { types: ['tool-call', 'tool-result', 'tool-approval-request', 'tool-approval-response'] } */ fullStreamEventForwarding?: FullStreamEventForwardingConfig; diff --git a/packages/core/src/tool/manager/ToolManager.ts b/packages/core/src/tool/manager/ToolManager.ts index ce74d37bf..7c48da540 100644 --- a/packages/core/src/tool/manager/ToolManager.ts +++ b/packages/core/src/tool/manager/ToolManager.ts @@ -54,7 +54,6 @@ export class ToolManager extends BaseToolManager ToolExecutionResult; - needsApproval?: AgentTool["needsApproval"]; providerOptions?: AgentTool["providerOptions"]; toModelOutput?: AgentTool["toModelOutput"]; outputSchema?: AgentTool["outputSchema"]; @@ -66,7 +65,6 @@ export class ToolManager extends BaseToolManager { }); }); + it("maps internal approval output markers to approval-requested tool state", async () => { + const messages: (AssistantModelMessage | ToolModelMessage)[] = [ + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call-internal-approval", + toolName: "deleteFile", + input: { path: "/tmp/a.txt" }, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-internal-approval", + toolName: "deleteFile", + output: { + __voltagentToolApproval: { + status: "requested", + approvalId: "approval-call-internal-approval", + toolCallId: "call-internal-approval", + toolName: "deleteFile", + input: { path: "/tmp/a.txt" }, + }, + }, + }, + ], + }, + ]; + + const result = await convertResponseMessagesToUIMessages(messages); + + expect(result).toHaveLength(1); + expect(result[0].parts).toHaveLength(1); + expect(result[0].parts[0]).toEqual({ + type: "tool-deleteFile", + toolCallId: "call-internal-approval", + state: "approval-requested", + input: { path: "/tmp/a.txt" }, + approval: { id: "approval-call-internal-approval" }, + providerExecuted: false, + }); + }); + it("should merge tool results with tool calls", async () => { const messages: (AssistantModelMessage | ToolModelMessage)[] = [ { @@ -750,6 +798,57 @@ describe("convertModelMessagesToUIMessages (AI SDK v5)", () => { }); }); + it("maps internal denied approval marker to output-denied state", () => { + const messages: ModelMessage[] = [ + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call-denied", + toolName: "deleteFile", + input: { path: "/tmp/a.txt" }, + }, + { + type: "tool-result", + toolCallId: "call-denied", + toolName: "deleteFile", + output: { + __voltagentToolApproval: { + status: "denied", + approvalId: "approval-call-denied", + toolCallId: "call-denied", + toolName: "deleteFile", + input: { path: "/tmp/a.txt" }, + reason: "Denied by reviewer", + }, + }, + }, + ], + }, + ]; + + const ui = convertModelMessagesToUIMessages(messages); + expect(ui).toHaveLength(1); + expect(ui[0].parts).toHaveLength(1); + expect(ui[0].parts[0]).toEqual({ + type: "tool-deleteFile", + toolCallId: "call-denied", + state: "output-denied", + input: { path: "/tmp/a.txt" }, + output: { + error: true, + message: "Denied by reviewer", + }, + approval: { + id: "approval-call-denied", + approved: false, + reason: "Denied by reviewer", + }, + providerExecuted: false, + }); + }); + it("should correctly handle tool messages for AI SDK convertToModelMessages", async () => { // Simulate the response messages from an LLM that called a tool const responseMessages: (AssistantModelMessage | ToolModelMessage)[] = [ diff --git a/packages/core/src/utils/message-converter.ts b/packages/core/src/utils/message-converter.ts index bef9f3f68..13beb8b0f 100644 --- a/packages/core/src/utils/message-converter.ts +++ b/packages/core/src/utils/message-converter.ts @@ -6,6 +6,7 @@ import type { AssistantModelMessage, ModelMessage, ToolModelMessage } from "@ai- import type { FileUIPart, ReasoningUIPart, TextUIPart, ToolUIPart, UIMessage } from "ai"; import { bytesToBase64 } from "./base64"; import { randomUUID } from "./id"; +import { extractToolApprovalOutput } from "./tool-approval"; const hasOpenAIReasoningProviderOptions = (providerOptions: unknown): boolean => { if (!providerOptions || typeof providerOptions !== "object") { @@ -147,9 +148,7 @@ export async function convertResponseMessagesToUIMessages( } case "tool-result": { const assignOutput = (target: ToolUIPart) => { - target.state = "output-available"; - target.output = contentPart.output; - target.providerExecuted = true; + applyToolResultToPart(target, contentPart.output, true); }; const existing = toolPartsById.get(contentPart.toolCallId); @@ -165,14 +164,12 @@ export async function convertResponseMessagesToUIMessages( break; } - const resultPart = { - type: `tool-${contentPart.toolName}` as const, - toolCallId: contentPart.toolCallId, - state: "output-available" as const, - input: {}, - output: contentPart.output, - providerExecuted: true, - } satisfies ToolUIPart; + const resultPart = createToolPartFromResult( + contentPart.toolName, + contentPart.toolCallId, + contentPart.output, + true, + ); uiMessage.parts.push(resultPart); toolPartsById.set(contentPart.toolCallId, resultPart); break; @@ -201,18 +198,14 @@ export async function convertResponseMessagesToUIMessages( if (toolResult.type === "tool-result") { const existing = toolPartsById.get(toolResult.toolCallId); if (existing) { - existing.state = "output-available"; - existing.output = toolResult.output; - existing.providerExecuted = false; + applyToolResultToPart(existing, toolResult.output, false); } else { - const resultPart = { - type: `tool-${toolResult.toolName}` as const, - toolCallId: toolResult.toolCallId, - state: "output-available" as const, - input: {}, - output: toolResult.output, - providerExecuted: false, - } satisfies ToolUIPart; + const resultPart = createToolPartFromResult( + toolResult.toolName, + toolResult.toolCallId, + toolResult.output, + false, + ); uiMessage.parts.push(resultPart); toolPartsById.set(toolResult.toolCallId, resultPart); } @@ -308,6 +301,89 @@ function applyApprovalResponseToToolPart( } } +function applyToolResultToPart( + toolPart: ToolUIPart, + output: unknown, + providerExecuted: boolean, +): void { + const approvalOutput = extractToolApprovalOutput(output); + if (!approvalOutput) { + toolPart.state = "output-available"; + toolPart.output = output; + toolPart.providerExecuted = providerExecuted; + return; + } + + if (approvalOutput.status === "requested") { + applyApprovalRequestToToolPart(toolPart, approvalOutput.approvalId); + if (approvalOutput.input) { + toolPart.input = approvalOutput.input as any; + } + toolPart.providerExecuted = false; + (toolPart as any).output = undefined; + return; + } + + applyApprovalResponseToToolPart(toolPart, { + id: approvalOutput.approvalId, + approved: false, + ...(approvalOutput.reason ? { reason: approvalOutput.reason } : {}), + }); + (toolPart as any).state = "output-denied"; + toolPart.output = { + error: true, + message: approvalOutput.reason || `Tool ${approvalOutput.toolName} execution denied.`, + } as any; + toolPart.providerExecuted = false; +} + +function createToolPartFromResult( + toolName: string, + toolCallId: string, + output: unknown, + providerExecuted: boolean, +): ToolUIPart { + const approvalOutput = extractToolApprovalOutput(output); + if (!approvalOutput) { + return { + type: `tool-${toolName}` as const, + toolCallId, + state: "output-available" as const, + input: {}, + output, + providerExecuted, + } satisfies ToolUIPart; + } + + if (approvalOutput.status === "requested") { + return { + type: `tool-${toolName}` as const, + toolCallId, + state: "approval-requested" as const, + input: (approvalOutput.input || {}) as any, + approval: { id: approvalOutput.approvalId } as any, + providerExecuted: false, + } satisfies ToolUIPart; + } + + return { + type: `tool-${toolName}` as const, + toolCallId, + state: "output-denied" as const, + input: (approvalOutput.input || {}) as any, + output: { + error: true, + message: approvalOutput.reason || `Tool ${toolName} execution denied.`, + } as any, + approval: { + id: approvalOutput.approvalId, + approved: false, + ...(approvalOutput.reason ? { reason: approvalOutput.reason } : {}), + } as any, + providerExecuted: false, + } satisfies ToolUIPart; +} + /** * Convert input ModelMessages (AI SDK) to UIMessage array used by VoltAgent. * - Preserves roles (user/assistant/system). Tool messages are represented as @@ -328,18 +404,14 @@ export function convertModelMessagesToUIMessages(messages: ModelMessage[]): UIMe ) => { const existing = toolPartsById.get(toolCallId); if (existing) { - existing.state = "output-available"; - existing.output = output; - existing.providerExecuted = providerExecuted; + applyToolResultToPart(existing, output, providerExecuted); return true; } if (partsToSearch) { const fallback = findExistingToolPart(partsToSearch, toolCallId); if (fallback) { - fallback.state = "output-available"; - fallback.output = output; - fallback.providerExecuted = providerExecuted; + applyToolResultToPart(fallback, output, providerExecuted); toolPartsById.set(toolCallId, fallback); return true; } @@ -350,9 +422,7 @@ export function convertModelMessagesToUIMessages(messages: ModelMessage[]): UIMe toolCallId, ); if (globalFallback) { - globalFallback.state = "output-available"; - globalFallback.output = output; - globalFallback.providerExecuted = providerExecuted; + applyToolResultToPart(globalFallback, output, providerExecuted); toolPartsById.set(toolCallId, globalFallback); return true; } @@ -375,16 +445,7 @@ export function convertModelMessagesToUIMessages(messages: ModelMessage[]): UIMe const toolMessage: UIMessage = { id: randomUUID(), role: "assistant", - parts: [ - { - type: `tool-${part.toolName}` as const, - toolCallId: part.toolCallId, - state: "output-available" as const, - input: {}, - output: part.output, - providerExecuted: false, - } satisfies ToolUIPart, - ], + parts: [createToolPartFromResult(part.toolName, part.toolCallId, part.output, false)], }; uiMessages.push(toolMessage); toolPartsById.set(part.toolCallId, toolMessage.parts[0] as ToolUIPart); @@ -522,14 +583,12 @@ export function convertModelMessagesToUIMessages(messages: ModelMessage[]): UIMe ); if (!merged) { - const resultPart = { - type: `tool-${contentPart.toolName}` as const, - toolCallId: contentPart.toolCallId, - state: "output-available" as const, - input: {}, - output: contentPart.output, - providerExecuted: true, - } satisfies ToolUIPart; + const resultPart = createToolPartFromResult( + contentPart.toolName, + contentPart.toolCallId, + contentPart.output, + true, + ); ui.parts.push(resultPart); toolPartsById.set(contentPart.toolCallId, resultPart); } diff --git a/packages/core/src/utils/tool-approval.ts b/packages/core/src/utils/tool-approval.ts new file mode 100644 index 000000000..f8d309304 --- /dev/null +++ b/packages/core/src/utils/tool-approval.ts @@ -0,0 +1,91 @@ +import { safeStringify } from "@voltagent/internal/utils"; + +export const TOOL_APPROVAL_OUTPUT_MARKER = "__voltagentToolApproval"; + +export type ToolApprovalOutputStatus = "requested" | "denied"; + +export type ToolApprovalOutputPayload = { + status: ToolApprovalOutputStatus; + approvalId: string; + toolCallId: string; + toolName: string; + input?: Record; + reason?: string; +}; + +export type ToolApprovalOutputEnvelope = { + [TOOL_APPROVAL_OUTPUT_MARKER]: ToolApprovalOutputPayload; +}; + +export function createToolApprovalOutput( + payload: ToolApprovalOutputPayload, +): ToolApprovalOutputEnvelope { + return { + [TOOL_APPROVAL_OUTPUT_MARKER]: payload, + }; +} + +export function extractToolApprovalOutput(value: unknown): ToolApprovalOutputPayload | null { + if (!value || typeof value !== "object") { + return null; + } + + const jsonWrappedValue = value as Record; + if ( + jsonWrappedValue.type === "json" && + "value" in jsonWrappedValue && + jsonWrappedValue.value !== value + ) { + return extractToolApprovalOutput(jsonWrappedValue.value); + } + + const markerValue = (value as Record)[TOOL_APPROVAL_OUTPUT_MARKER]; + if (!markerValue || typeof markerValue !== "object") { + return null; + } + + const payload = markerValue as Record; + const status = payload.status; + const approvalId = payload.approvalId; + const toolCallId = payload.toolCallId; + const toolName = payload.toolName; + + if ( + (status !== "requested" && status !== "denied") || + typeof approvalId !== "string" || + approvalId.trim().length === 0 || + typeof toolCallId !== "string" || + toolCallId.trim().length === 0 || + typeof toolName !== "string" || + toolName.trim().length === 0 + ) { + return null; + } + + const inputValue = payload.input; + const input = + inputValue && typeof inputValue === "object" && !Array.isArray(inputValue) + ? (inputValue as Record) + : undefined; + + const reason = + typeof payload.reason === "string" && payload.reason.trim().length > 0 + ? payload.reason + : undefined; + + return { + status, + approvalId, + toolCallId, + toolName, + input, + reason, + }; +} + +export function createToolApprovalFingerprint( + toolName: string, + args: Record, +): string { + return `${toolName.trim().toLowerCase()}::${safeStringify(args)}`; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a73727fbf..c3ab5f927 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1355,6 +1355,40 @@ importers: specifier: ^5.8.2 version: 5.9.2 + examples/with-hitl: + dependencies: + '@ai-sdk/openai': + specifier: ^3.0.0 + version: 3.0.12(zod@3.25.76) + '@voltagent/cli': + specifier: ^0.1.21 + version: link:../../packages/cli + '@voltagent/core': + specifier: ^2.6.1 + version: link:../../packages/core + '@voltagent/logger': + specifier: ^2.0.2 + version: link:../../packages/logger + '@voltagent/server-hono': + specifier: ^2.0.7 + version: link:../../packages/server-hono + ai: + specifier: ^6.0.0 + version: 6.0.3(zod@3.25.76) + zod: + specifier: ^3.25.76 + version: 3.25.76 + devDependencies: + '@types/node': + specifier: ^24.2.1 + version: 24.6.2 + tsx: + specifier: ^4.19.3 + version: 4.20.4 + typescript: + specifier: ^5.8.2 + version: 5.9.3 + examples/with-hooks: dependencies: '@voltagent/cli': @@ -4730,16 +4764,16 @@ packages: '@vercel/oidc': 3.0.5 zod: 3.25.76 - /@ai-sdk/gateway@2.0.21(zod@4.1.13): + /@ai-sdk/gateway@2.0.21(zod@4.3.5): resolution: {integrity: sha512-BwV7DU/lAm3Xn6iyyvZdWgVxgLu3SNXzl5y57gMvkW4nGhAOV5269IrJzQwGt03bb107sa6H6uJwWxc77zXoGA==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 dependencies: '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.19(zod@4.1.13) + '@ai-sdk/provider-utils': 3.0.19(zod@4.3.5) '@vercel/oidc': 3.0.5 - zod: 4.1.13 + zod: 4.3.5 dev: false /@ai-sdk/gateway@3.0.16(zod@3.25.76): @@ -5069,7 +5103,7 @@ packages: eventsource-parser: 3.0.6 zod: 3.25.76 - /@ai-sdk/provider-utils@3.0.19(zod@4.1.13): + /@ai-sdk/provider-utils@3.0.19(zod@4.3.5): resolution: {integrity: sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA==} engines: {node: '>=18'} peerDependencies: @@ -5078,7 +5112,7 @@ packages: '@ai-sdk/provider': 2.0.0 '@standard-schema/spec': 1.1.0 eventsource-parser: 3.0.6 - zod: 4.1.13 + zod: 4.3.5 dev: false /@ai-sdk/provider-utils@3.0.3(zod@3.25.76): @@ -5196,7 +5230,7 @@ packages: zod: 3.25.76 dev: false - /@ai-sdk/react@2.0.115(react@19.2.3)(zod@4.1.13): + /@ai-sdk/react@2.0.115(react@19.2.3)(zod@4.3.5): resolution: {integrity: sha512-Etu7gWSEi2dmXss1PoR5CAZGwGShXsF9+Pon1eRO6EmatjYaBMhq1CfHPyYhGzWrint8jJIK2VaAhiMef29qZw==} engines: {node: '>=18'} peerDependencies: @@ -5206,12 +5240,12 @@ packages: zod: optional: true dependencies: - '@ai-sdk/provider-utils': 3.0.19(zod@4.1.13) - ai: 5.0.113(zod@4.1.13) + '@ai-sdk/provider-utils': 3.0.19(zod@4.3.5) + ai: 5.0.113(zod@4.3.5) react: 19.2.3 swr: 2.3.6(react@19.2.3) throttleit: 2.1.0 - zod: 4.1.13 + zod: 4.3.5 dev: false /@ai-sdk/react@3.0.3(react@19.2.3)(zod@3.25.76): @@ -5526,15 +5560,15 @@ packages: optional: true dependencies: '@ai-sdk/provider': 2.0.0 - '@ai-sdk/react': 2.0.115(react@19.2.3)(zod@4.1.13) + '@ai-sdk/react': 2.0.115(react@19.2.3)(zod@4.3.5) '@assistant-ui/react': 0.11.50(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.2.3) '@types/json-schema': 7.0.15 '@types/react': 19.2.7 - ai: 5.0.113(zod@4.1.13) + ai: 5.0.113(zod@4.3.5) assistant-stream: 0.2.45 react: 19.2.3 - zod: 4.1.13 + zod: 4.3.5 zustand: 5.0.9(@types/react@19.2.7)(react@19.2.3) transitivePeerDependencies: - immer @@ -5596,7 +5630,7 @@ packages: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) react-textarea-autosize: 8.5.9(@types/react@19.2.7)(react@19.2.3) - zod: 4.1.13 + zod: 4.3.5 zustand: 5.0.9(@types/react@19.2.7)(react@19.2.3) transitivePeerDependencies: - immer @@ -11413,7 +11447,7 @@ packages: strong-log-transformer: 2.1.0 dev: true - /@lerna/create@7.4.2(@swc-node/register@1.9.2)(@swc/core@1.5.29)(@types/node@24.2.1)(typescript@5.9.2): + /@lerna/create@7.4.2(@swc-node/register@1.9.2)(@swc/core@1.5.29)(@types/node@24.2.1)(typescript@5.9.3): resolution: {integrity: sha512-1wplFbQ52K8E/unnqB0Tq39Z4e+NEoNrpovEnl6GpsTUrC6WDp8+w0Le2uCBV0hXyemxChduCkLz4/y1H1wTeg==} engines: {node: '>=16.0.0'} dependencies: @@ -11429,7 +11463,7 @@ packages: columnify: 1.6.0 conventional-changelog-core: 5.0.1 conventional-recommended-bump: 7.0.1 - cosmiconfig: 8.3.6(typescript@5.9.2) + cosmiconfig: 8.3.6(typescript@5.9.3) dedent: 0.7.0 execa: 5.0.0 fs-extra: 11.3.1 @@ -22051,17 +22085,17 @@ packages: '@opentelemetry/api': 1.9.0 zod: 3.25.76 - /ai@5.0.113(zod@4.1.13): + /ai@5.0.113(zod@4.3.5): resolution: {integrity: sha512-26vivpSO/mzZj0k1Si2IpsFspp26ttQICHRySQiMrtWcRd5mnJMX2a8sG28vmZ38C+JUn1cWmfZrsLMxkSMw9g==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 dependencies: - '@ai-sdk/gateway': 2.0.21(zod@4.1.13) + '@ai-sdk/gateway': 2.0.21(zod@4.3.5) '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.19(zod@4.1.13) + '@ai-sdk/provider-utils': 3.0.19(zod@4.3.5) '@opentelemetry/api': 1.9.0 - zod: 4.1.13 + zod: 4.3.5 dev: false /ai@5.0.63(zod@3.25.76): @@ -22543,7 +22577,7 @@ packages: /axios@1.11.0: resolution: {integrity: sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==} dependencies: - follow-redirects: 1.15.11(debug@4.3.2) + follow-redirects: 1.15.11(debug@4.4.1) form-data: 4.0.4 proxy-from-env: 1.1.0 transitivePeerDependencies: @@ -22552,7 +22586,7 @@ packages: /axios@1.13.2: resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} dependencies: - follow-redirects: 1.15.11(debug@4.3.2) + follow-redirects: 1.15.11(debug@4.4.1) form-data: 4.0.4 proxy-from-env: 1.1.0 transitivePeerDependencies: @@ -24459,6 +24493,22 @@ packages: typescript: 5.9.2 dev: true + /cosmiconfig@8.3.6(typescript@5.9.3): + resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + import-fresh: 3.3.1 + js-yaml: 4.1.0 + parse-json: 5.2.0 + path-type: 4.0.0 + typescript: 5.9.3 + dev: true + /cosmiconfig@9.0.0(typescript@5.9.2): resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} engines: {node: '>=14'} @@ -25091,6 +25141,7 @@ packages: optional: true dependencies: ms: 2.1.2 + dev: false /debug@4.4.1: resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} @@ -27138,6 +27189,7 @@ packages: optional: true dependencies: debug: 4.3.2 + dev: false /follow-redirects@1.15.11(debug@4.4.1): resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} @@ -27149,7 +27201,6 @@ packages: optional: true dependencies: debug: 4.4.1 - dev: true /fontaine@0.6.0: resolution: {integrity: sha512-cfKqzB62GmztJhwJ0YXtzNsmpqKAcFzTqsakJ//5COTzbou90LU7So18U+4D8z+lDXr4uztaAUZBonSoPDcj1w==} @@ -28575,17 +28626,6 @@ packages: - debug dev: true - /http-proxy@1.18.1: - resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} - engines: {node: '>=8.0.0'} - dependencies: - eventemitter3: 4.0.7 - follow-redirects: 1.15.11(debug@4.3.2) - requires-port: 1.0.0 - transitivePeerDependencies: - - debug - dev: true - /http-proxy@1.18.1(debug@4.4.1): resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} engines: {node: '>=8.0.0'} @@ -28607,7 +28647,7 @@ packages: corser: 2.0.1 he: 1.2.0 html-encoding-sniffer: 3.0.0 - http-proxy: 1.18.1 + http-proxy: 1.18.1(debug@4.4.1) mime: 1.6.0 minimist: 1.2.8 opener: 1.5.2 @@ -30559,7 +30599,7 @@ packages: hasBin: true dependencies: '@lerna/child-process': 7.4.2 - '@lerna/create': 7.4.2(@swc-node/register@1.9.2)(@swc/core@1.5.29)(@types/node@24.2.1)(typescript@5.9.2) + '@lerna/create': 7.4.2(@swc-node/register@1.9.2)(@swc/core@1.5.29)(@types/node@24.2.1)(typescript@5.9.3) '@npmcli/run-script': 6.0.2 '@nx/devkit': 16.10.0(nx@16.10.0) '@octokit/plugin-enterprise-rest': 6.0.1 @@ -30572,7 +30612,7 @@ packages: conventional-changelog-angular: 7.0.0 conventional-changelog-core: 5.0.1 conventional-recommended-bump: 7.0.1 - cosmiconfig: 8.3.6(typescript@5.9.2) + cosmiconfig: 8.3.6(typescript@5.9.3) dedent: 0.7.0 envinfo: 7.8.1 execa: 5.0.0 @@ -30624,7 +30664,7 @@ packages: strong-log-transformer: 2.1.0 tar: 6.1.11 temp-dir: 1.0.0 - typescript: 5.9.2 + typescript: 5.9.3 upath: 2.0.1 uuid: 9.0.1 validate-npm-package-license: 3.0.4 @@ -32651,6 +32691,7 @@ packages: /ms@2.1.2: resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + dev: false /ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -41576,7 +41617,7 @@ packages: /zod-from-json-schema@0.5.0: resolution: {integrity: sha512-W1v1YIoimOJfvuorGGp1QroizLL3jEGELJtgrHiVg/ytxVZdh/BTTVyPypGB7YK30LHrCkkebbjuyHIjBGCEzw==} dependencies: - zod: 4.1.13 + zod: 4.3.5 dev: false /zod-to-json-schema@3.25.0(zod@3.25.76): @@ -41620,10 +41661,6 @@ packages: resolution: {integrity: sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==} dev: false - /zod@4.1.13: - resolution: {integrity: sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==} - dev: false - /zod@4.3.5: resolution: {integrity: sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==} diff --git a/website/docs/agents/overview.md b/website/docs/agents/overview.md index 414b7cc2d..eb8962529 100644 --- a/website/docs/agents/overview.md +++ b/website/docs/agents/overview.md @@ -580,7 +580,7 @@ const coordinator = new Agent({ #### Event Filtering -When streaming with sub-agents, by default only `tool-call` and `tool-result` events are forwarded from sub-agents to reduce noise. +When streaming with sub-agents, by default `tool-call`, `tool-result`, `tool-approval-request`, and `tool-approval-response` events are forwarded from sub-agents to reduce noise. **Enable all event types:** @@ -595,6 +595,8 @@ const coordinator = new Agent({ types: [ "tool-call", "tool-result", + "tool-approval-request", + "tool-approval-response", "text-start", "text-delta", "text-end", @@ -618,6 +620,8 @@ for await (const chunk of response.fullStream) { } ``` +If you provide a custom `types` list and delegated tools use `needsApproval`, include `tool-approval-request` and `tool-approval-response`. + [Sub-Agents documentation](./subagents.md) ### Hooks diff --git a/website/docs/agents/subagents.md b/website/docs/agents/subagents.md index 5db8a4583..712e0fa3b 100644 --- a/website/docs/agents/subagents.md +++ b/website/docs/agents/subagents.md @@ -113,7 +113,7 @@ const supervisorAgent = new Agent({ ### Stream Event Forwarding Configuration -Control which events from sub-agents are forwarded to the parent stream. By default, only `tool-call` and `tool-result` events are forwarded. +Control which events from sub-agents are forwarded to the parent stream. By default, `tool-call`, `tool-result`, `tool-approval-request`, and `tool-approval-response` events are forwarded. ```ts const supervisorAgent = new Agent({ @@ -125,10 +125,12 @@ const supervisorAgent = new Agent({ supervisorConfig: { // Configure which sub-agent events to forward fullStreamEventForwarding: { - // Default: ['tool-call', 'tool-result'] + // Default: ['tool-call', 'tool-result', 'tool-approval-request', 'tool-approval-response'] types: [ "tool-call", "tool-result", + "tool-approval-request", + "tool-approval-response", "text-delta", "reasoning-start", "reasoning-delta", @@ -145,14 +147,14 @@ const supervisorAgent = new Agent({ **Common Configurations:** ```ts -// Minimal - Only tool events (default) +// Minimal - Tool + approval events (default) fullStreamEventForwarding: { - types: ['tool-call', 'tool-result'], + types: ['tool-call', 'tool-result', 'tool-approval-request', 'tool-approval-response'], } // Text + Tools - Include text generation fullStreamEventForwarding: { - types: ['tool-call', 'tool-result', 'text-delta'], + types: ['tool-call', 'tool-result', 'tool-approval-request', 'tool-approval-response', 'text-delta'], } // Full visibility - All events including reasoning @@ -160,6 +162,8 @@ fullStreamEventForwarding: { types: [ 'tool-call', 'tool-result', + 'tool-approval-request', + 'tool-approval-response', 'text-delta', 'reasoning-start', 'reasoning-delta', @@ -172,11 +176,12 @@ fullStreamEventForwarding: { // Clean tool names - No agent prefix (add prefix manually when consuming events if desired) fullStreamEventForwarding: { - types: ['tool-call', 'tool-result'], + types: ['tool-call', 'tool-result', 'tool-approval-request', 'tool-approval-response'], } ``` This configuration balances stream performance and information detail for sub-agent interactions. +If you override `types` and delegated tools use `needsApproval`, keep `tool-approval-request` and `tool-approval-response` in the forwarded list. ### Error Handling Configuration @@ -460,7 +465,7 @@ const writerAgent = new Agent({ ```ts supervisorConfig: { fullStreamEventForwarding: { - types: ['tool-call', 'tool-result', 'text-delta'], // Control which events to forward + types: ['tool-call', 'tool-result', 'tool-approval-request', 'tool-approval-response', 'text-delta'], // Control which events to forward } } ``` @@ -521,6 +526,8 @@ This tool is automatically added to supervisor agents and handles delegation. When `bailed: true`, the supervisor's execution is terminated immediately and the subagent's response is returned to the user. See [Early Termination (Bail)](#early-termination-bail) for details. + When a delegated sub-agent tool requests approval (`needsApproval`), `delegate_task` does not immediately return this array. Instead, the run emits an approval request (`tool-approval-request` / `approval-requested`) and waits for an approval response. After approval, execution resumes and then returns the normal result array. + 5. Sub-agents process their delegated tasks independently. They can use their own tools or delegate further if they are also supervisors. 6. Each sub-agent returns its result to the `delegate_task` tool execution context. 7. The supervisor receives the results from the `delegate_task` tool. @@ -780,13 +787,13 @@ When using bail with `toUIMessageStream()` or consuming `fullStream` events, you **Default Behavior:** -By default, only `tool-call` and `tool-result` events are forwarded from subagents. This means **subagent text chunks are NOT visible** in the stream: +By default, `tool-call`, `tool-result`, `tool-approval-request`, and `tool-approval-response` events are forwarded from subagents. This means **subagent text chunks are NOT visible** in the stream: ```ts // Default configuration (implicit) supervisorConfig: { fullStreamEventForwarding: { - types: ['tool-call', 'tool-result'], // ⚠️ text-delta NOT included + types: ['tool-call', 'tool-result', 'tool-approval-request', 'tool-approval-response'], // ⚠️ text-delta NOT included } } ``` @@ -801,7 +808,13 @@ const supervisor = new Agent({ subAgents: [workoutBuilder], supervisorConfig: { fullStreamEventForwarding: { - types: ["tool-call", "tool-result", "text-delta"], // ✅ Include text-delta + types: [ + "tool-call", + "tool-result", + "tool-approval-request", + "tool-approval-response", + "text-delta", + ], // ✅ Include text-delta }, }, hooks: { @@ -834,6 +847,8 @@ for await (const message of result.toUIMessageStream()) { | ------------------------------------------------------- | ---------------------------------------- | --------------- | | `tool-call` | Tool invocations | ✅ Included | | `tool-result` | Tool results | ✅ Included | +| `tool-approval-request` | Tool approval requested | ✅ Included | +| `tool-approval-response` | Tool approval response | ✅ Included | | `text-delta` | Text chunk generation | ❌ NOT included | | `reasoning-start` / `reasoning-delta` / `reasoning-end` | Model reasoning lifecycle (if available) | ❌ NOT included | | `source` | Retrieved sources (if available) | ❌ NOT included | diff --git a/website/docs/agents/tools.md b/website/docs/agents/tools.md index 17a628102..cb7536c99 100644 --- a/website/docs/agents/tools.md +++ b/website/docs/agents/tools.md @@ -513,37 +513,45 @@ function Chat() { {messages.map((message) => (
{message.parts.map((part) => { - if (part.type === "tool-runCommand") { - if (part.state === "approval-requested") { - return ( -
-

Approve command: {part.input.command}?

- - -
- ); - } + if (!("state" in part) || part.state !== "approval-requested") { + return null; } - return null; + + const approval = "approval" in part ? part.approval : undefined; + if (!approval?.id) { + return null; + } + + const toolLabel = part.type.startsWith("tool-") + ? part.type.slice("tool-".length) + : "tool"; + + return ( +
+

Approve {toolLabel} execution?

+ + +
+ ); })}
))} @@ -552,8 +560,70 @@ function Chat() { } ``` +In delegated sub-agent flows, the approval part can be emitted as `tool-delegate_task` while the guarded tool runs inside the sub-agent. Match approvals using `part.state` and `part.approval.id`, not a hardcoded tool part type. + If you do not use `sendAutomaticallyWhen`, call `sendMessage` manually after approval to continue the flow. +### Raw API Flow (`/agents/:id/chat`) + +If you are not using AI SDK helpers, you can continue the approval flow by sending a `tool-approval-response` part in the next request. + +1. First call triggers a guarded tool and returns `approval-requested`. + +```bash +curl -N -X POST http://localhost:3141/agents/assistant/chat \ + -H "Content-Type: application/json" \ + -d '{ + "input": "Delete CRM account user_123", + "options": { "conversationId": "conv-hitl-1", "userId": "user-1" } + }' +``` + +2. Extract the returned `approval.id` from the assistant tool part. +3. Send a follow-up call with previous messages plus a `tool-approval-response` message: + +```bash +curl -N -X POST http://localhost:3141/agents/assistant/chat \ + -H "Content-Type: application/json" \ + -d '{ + "input": [ + { + "id": "asst-1", + "role": "assistant", + "parts": [ + { + "type": "tool-deleteCrmUser", + "toolCallId": "call_123", + "state": "approval-requested", + "input": { "userId": "user_123" }, + "approval": { "id": "approval-call_123" } + } + ] + }, + { + "id": "tool-approval-1", + "role": "tool", + "parts": [ + { + "type": "tool-approval-response", + "approvalId": "approval-call_123", + "approved": true, + "reason": "approved by operator" + } + ] + }, + { + "id": "user-continue-1", + "role": "user", + "parts": [{ "type": "text", "text": "Continue." }] + } + ], + "options": { "conversationId": "conv-hitl-1", "userId": "user-1" } + }' +``` + +VoltAgent resumes execution, runs the approved tool call, and continues the same conversation. + ## Client-Side Tools Client-side tools execute in the browser or client application instead of on the server. They're useful for accessing browser APIs, user permissions, or client-specific features. diff --git a/website/docs/api/endpoints/agents.md b/website/docs/api/endpoints/agents.md index a26a04759..99dde910c 100644 --- a/website/docs/api/endpoints/agents.md +++ b/website/docs/api/endpoints/agents.md @@ -530,6 +530,56 @@ function ChatComponent({ agentId }) { **Note:** VoltAgent requires a custom transport with `prepareSendMessagesRequest` to format the request body correctly. The endpoint expects `input` as an array of UIMessage objects and options in a specific format. +### Tool Approval Resume (`tool-approval-response`) + +For tools configured with `needsApproval`, `/agents/:id/chat` may return a tool part with `state: "approval-requested"`. +To continue, send another chat request that includes a `tool-approval-response` part. + +```bash +curl -N -X POST http://localhost:3141/agents/assistant/chat \ + -H "Content-Type: application/json" \ + -d '{ + "input": [ + { + "id": "asst-approval-1", + "role": "assistant", + "parts": [ + { + "type": "tool-deleteCrmUser", + "toolCallId": "call_123", + "state": "approval-requested", + "input": { "userId": "user_123" }, + "approval": { "id": "approval-call_123" } + } + ] + }, + { + "id": "tool-approval-1", + "role": "tool", + "parts": [ + { + "type": "tool-approval-response", + "approvalId": "approval-call_123", + "approved": true, + "reason": "approved by operator" + } + ] + }, + { + "id": "user-continue-1", + "role": "user", + "parts": [{ "type": "text", "text": "Continue." }] + } + ], + "options": { + "conversationId": "conv-hitl-1", + "userId": "user-1" + } + }' +``` + +This resumes the pending call and executes the guarded tool if approved. + ## Generate Object Generate a structured object that conforms to a JSON schema. diff --git a/website/recipes/hitl.md b/website/recipes/hitl.md new file mode 100644 index 000000000..90b6e7877 --- /dev/null +++ b/website/recipes/hitl.md @@ -0,0 +1,142 @@ +--- +id: hitl +title: Human-in-the-Loop (HITL) +slug: hitl +description: Tool approval flows with needsApproval for both direct agent and subagent delegation. +--- + +# Human-in-the-Loop (HITL) + +This recipe shows tool-level approval with `needsApproval` in two paths: + +- direct agent execution +- supervisor -> subagent delegation + +## Quick Setup + +```typescript +import { Agent, VoltAgent, createTool } from "@voltagent/core"; +import { honoServer } from "@voltagent/server-hono"; +import { z } from "zod"; + +const deleteCrmUser = createTool({ + name: "deleteCrmUser", + description: "Permanently delete a CRM user.", + parameters: z.object({ + userId: z.string().min(1), + reason: z.string().optional(), + }), + needsApproval: true, + execute: async ({ userId, reason }) => ({ + ok: true, + action: "user-deleted", + userId, + reason: reason || "user-requested deletion", + }), +}); + +// 1) Direct HITL agent +const crmHitlAgent = new Agent({ + name: "CRM HITL Agent", + instructions: "Handle CRM operations and use tools for destructive actions.", + model: "openai/gpt-4o-mini", + tools: [deleteCrmUser], +}); + +// 2) Subagent HITL path +const crmAgent = new Agent({ + name: "CRM Agent", + instructions: "Handle CRM account operations.", + model: "openai/gpt-4o-mini", + tools: [deleteCrmUser], +}); + +const triageAgent = new Agent({ + name: "Triage Agent", + instructions: "Route CRM account mutations to CRM Agent.", + model: "openai/gpt-4o-mini", + subAgents: [crmAgent], +}); + +new VoltAgent({ + agents: { + crmHitlAgent, + crmAgent, + triageAgent, + }, + server: honoServer({ port: 3141 }), +}); +``` + +## Test Direct Agent + +```bash +curl -N -X POST http://localhost:3141/agents/crmHitlAgent/chat \ + -H "Content-Type: application/json" \ + -d '{"input":"Delete CRM user user_123."}' +``` + +Expected behavior: + +1. Tool call is paused with `approval-requested`. +2. UI/API sends approval response. +3. Tool executes and agent continues. + +## Resume with Raw API (`tool-approval-response`) + +After you receive `approval.id` from the first response, resume like this: + +```bash +curl -N -X POST http://localhost:3141/agents/crmHitlAgent/chat \ + -H "Content-Type: application/json" \ + -d '{ + "input": [ + { + "id": "asst-approval-1", + "role": "assistant", + "parts": [ + { + "type": "tool-deleteCrmUser", + "toolCallId": "call_123", + "state": "approval-requested", + "input": { "userId": "user_123" }, + "approval": { "id": "approval-call_123" } + } + ] + }, + { + "id": "tool-approval-1", + "role": "tool", + "parts": [ + { + "type": "tool-approval-response", + "approvalId": "approval-call_123", + "approved": true, + "reason": "approved by operator" + } + ] + }, + { + "id": "user-continue-1", + "role": "user", + "parts": [{ "type": "text", "text": "Continue." }] + } + ], + "options": { "conversationId": "conv-hitl-1", "userId": "user-1" } + }' +``` + +## Test Subagent Flow + +```bash +curl -N -X POST http://localhost:3141/agents/triageAgent/chat \ + -H "Content-Type: application/json" \ + -d '{"input":"Delete CRM user user_123 permanently."}' +``` + +In delegated runs, approval UI may appear on `tool-delegate_task`. This is expected and still controls the guarded subagent tool call. + +## Full Example + +- [with-hitl example on GitHub](https://github.com/VoltAgent/voltagent/tree/main/examples/with-hitl) +- [Tools docs: needsApproval](https://voltagent.dev/docs/agents/tools/#tool-execution-approval-needsapproval) diff --git a/website/recipes/overview.md b/website/recipes/overview.md index 86122d604..4f98809be 100644 --- a/website/recipes/overview.md +++ b/website/recipes/overview.md @@ -33,6 +33,7 @@ Quick reference guides for common VoltAgent features. - [Calling Agents](./calling-agents) - Invoke agents via code, REST API, or frameworks. - [Tools](./tools) - Add custom tools to your agents. +- [Human-in-the-Loop](./hitl) - Require explicit approval for sensitive tool calls in direct and subagent flows. - [Authentication](./authentication) - Protect endpoints with JWT auth. - [Hooks](./hooks) - Lifecycle hooks for agent behavior. - [Retrying](./retrying) - Automatic retries for transient model errors. diff --git a/website/recipes/tools.md b/website/recipes/tools.md index 370722f80..681b6dc9a 100644 --- a/website/recipes/tools.md +++ b/website/recipes/tools.md @@ -68,3 +68,5 @@ const agent = new Agent({ ## Full Example See the complete example: [with-tools on GitHub](https://github.com/VoltAgent/voltagent/tree/main/examples/with-tools) + +For approval-gated tools (`needsApproval`) including subagent delegation, see [Human-in-the-Loop](./hitl). diff --git a/website/sidebarsRecipes.ts b/website/sidebarsRecipes.ts index c5a04c78c..6697595d2 100644 --- a/website/sidebarsRecipes.ts +++ b/website/sidebarsRecipes.ts @@ -76,6 +76,11 @@ const sidebars: SidebarsConfig = { id: "tools", label: "Tools", }, + { + type: "doc", + id: "hitl", + label: "Human-in-the-Loop", + }, { type: "doc", id: "tool-routing", From 2537dede29430703fc115a6ccbb87325d723b6ed Mon Sep 17 00:00:00 2001 From: Omer Aplak Date: Mon, 23 Feb 2026 19:45:06 -0800 Subject: [PATCH 2/3] fix(core): normalize denied tool approvals to output-denied UI parts --- .../core/src/utils/message-converter.spec.ts | 61 ++++++++++++++- packages/core/src/utils/message-converter.ts | 76 +++++++++++++++---- 2 files changed, 119 insertions(+), 18 deletions(-) diff --git a/packages/core/src/utils/message-converter.spec.ts b/packages/core/src/utils/message-converter.spec.ts index db17c8adb..96dd406f5 100644 --- a/packages/core/src/utils/message-converter.spec.ts +++ b/packages/core/src/utils/message-converter.spec.ts @@ -836,10 +836,6 @@ describe("convertModelMessagesToUIMessages (AI SDK v5)", () => { toolCallId: "call-denied", state: "output-denied", input: { path: "/tmp/a.txt" }, - output: { - error: true, - message: "Denied by reviewer", - }, approval: { id: "approval-call-denied", approved: false, @@ -849,6 +845,63 @@ describe("convertModelMessagesToUIMessages (AI SDK v5)", () => { }); }); + it("keeps denied approvals in output-denied state when approval response precedes tool-result", () => { + const messages: ModelMessage[] = [ + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call-delegate-denied", + toolName: "delegate_task", + input: { task: "delete account", targetAgents: ["CRM Agent"] }, + }, + { + type: "tool-approval-request", + approvalId: "approval-delegate-denied", + toolCallId: "call-delegate-denied", + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-approval-response", + approvalId: "approval-delegate-denied", + approved: false, + reason: "User denied", + }, + { + type: "tool-result", + toolCallId: "call-delegate-denied", + toolName: "delegate_task", + output: { + type: "error-text", + value: "User denied", + }, + }, + ], + }, + ]; + + const ui = convertModelMessagesToUIMessages(messages); + expect(ui).toHaveLength(1); + expect(ui[0].parts).toHaveLength(1); + expect(ui[0].parts[0]).toEqual({ + type: "tool-delegate_task", + toolCallId: "call-delegate-denied", + state: "output-denied", + input: { task: "delete account", targetAgents: ["CRM Agent"] }, + approval: { + id: "approval-delegate-denied", + approved: false, + reason: "User denied", + }, + providerExecuted: false, + }); + }); + it("should correctly handle tool messages for AI SDK convertToModelMessages", async () => { // Simulate the response messages from an LLM that called a tool const responseMessages: (AssistantModelMessage | ToolModelMessage)[] = [ diff --git a/packages/core/src/utils/message-converter.ts b/packages/core/src/utils/message-converter.ts index 13beb8b0f..99384440b 100644 --- a/packages/core/src/utils/message-converter.ts +++ b/packages/core/src/utils/message-converter.ts @@ -274,6 +274,41 @@ function findToolPartByApprovalId(parts: UIMessage["parts"], approvalId: string) return undefined; } +function extractErrorTextFromToolOutput(output: unknown): string | undefined { + if (!output || typeof output !== "object" || Array.isArray(output)) { + return undefined; + } + + const value = output as Record; + if ( + value.type === "error-text" && + typeof value.value === "string" && + value.value.trim().length > 0 + ) { + return value.value; + } + + if (typeof value.message === "string" && value.message.trim().length > 0) { + return value.message; + } + + return undefined; +} + +function applyOutputDeniedToToolPart( + toolPart: ToolUIPart, + approval: { + id: string; + approved: false; + reason?: string; + }, +) { + (toolPart as any).state = "output-denied"; + (toolPart as any).output = undefined; + (toolPart as any).errorText = undefined; + (toolPart as any).approval = approval; +} + function applyApprovalRequestToToolPart(toolPart: ToolUIPart, approvalId: string) { const currentState = (toolPart as any).state as string | undefined; (toolPart as any).approval = { id: approvalId }; @@ -290,13 +325,18 @@ function applyApprovalResponseToToolPart( toolPart: ToolUIPart, approval: { id: string; approved: boolean; reason?: string }, ) { + if (!approval.approved) { + applyOutputDeniedToToolPart(toolPart, { + id: approval.id, + approved: false, + ...(approval.reason ? { reason: approval.reason } : {}), + }); + return; + } + const currentState = (toolPart as any).state as string | undefined; (toolPart as any).approval = approval; - if ( - currentState !== "output-available" && - currentState !== "output-error" && - currentState !== "output-denied" - ) { + if (currentState !== "output-available" && currentState !== "output-error") { (toolPart as any).state = "approval-responded"; } } @@ -306,6 +346,22 @@ function applyToolResultToPart( output: unknown, providerExecuted: boolean, ): void { + const existingApproval = (toolPart as any).approval as + | { id?: string; approved?: boolean; reason?: string } + | undefined; + if (existingApproval?.approved === false && typeof existingApproval.id === "string") { + const outputReason = extractErrorTextFromToolOutput(output); + applyOutputDeniedToToolPart(toolPart, { + id: existingApproval.id, + approved: false, + ...(outputReason || existingApproval.reason + ? { reason: outputReason || existingApproval.reason } + : {}), + }); + toolPart.providerExecuted = false; + return; + } + const approvalOutput = extractToolApprovalOutput(output); if (!approvalOutput) { toolPart.state = "output-available"; @@ -329,11 +385,7 @@ function applyToolResultToPart( approved: false, ...(approvalOutput.reason ? { reason: approvalOutput.reason } : {}), }); - (toolPart as any).state = "output-denied"; - toolPart.output = { - error: true, - message: approvalOutput.reason || `Tool ${approvalOutput.toolName} execution denied.`, - } as any; + (toolPart as any).output = undefined; toolPart.providerExecuted = false; } @@ -371,10 +423,6 @@ function createToolPartFromResult( toolCallId, state: "output-denied" as const, input: (approvalOutput.input || {}) as any, - output: { - error: true, - message: approvalOutput.reason || `Tool ${toolName} execution denied.`, - } as any, approval: { id: approvalOutput.approvalId, approved: false, From 317690fea9e6be4e71f70235c8102767de7c9042 Mon Sep 17 00:00:00 2001 From: Omer Aplak Date: Mon, 23 Feb 2026 19:48:16 -0800 Subject: [PATCH 3/3] fix(core): strip OpenAI item ids from delegated shared context --- .../core/src/agent/subagent/index.spec.ts | 16 ++++ packages/core/src/agent/subagent/index.ts | 86 ++++++++++++++++++- 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/packages/core/src/agent/subagent/index.spec.ts b/packages/core/src/agent/subagent/index.spec.ts index 2e2f92f70..cdcab1daf 100644 --- a/packages/core/src/agent/subagent/index.spec.ts +++ b/packages/core/src/agent/subagent/index.spec.ts @@ -541,6 +541,11 @@ describe("SubAgentManager", () => { task: "Delete account", targetAgents: ["CRM Agent"], }, + providerOptions: { + openai: { + itemId: "fc_test_duplicate_item_1", + }, + }, }, { type: "tool-approval-request", @@ -575,6 +580,17 @@ describe("SubAgentManager", () => { expect(flattenedText).toContain("user_123"); expect(flattenedText).toContain("Delete account"); + const hasOpenAIItemId = (forwardedMessages as any[]).some( + (message) => + Array.isArray(message?.parts) && + message.parts.some( + (part: any) => + typeof part?.callProviderMetadata?.openai?.itemId === "string" || + typeof part?.providerMetadata?.openai?.itemId === "string", + ), + ); + expect(hasOpenAIItemId).toBe(false); + const hasApprovalResponse = (forwardedMessages as any[]).some( (message) => message?.role === "assistant" && diff --git a/packages/core/src/agent/subagent/index.ts b/packages/core/src/agent/subagent/index.ts index 8cba5c05e..775693751 100644 --- a/packages/core/src/agent/subagent/index.ts +++ b/packages/core/src/agent/subagent/index.ts @@ -1006,12 +1006,96 @@ ${task}\n\nContext: ${safeStringify(contextObj, { indentation: 2 })}`; try { const uiMessages = convertModelMessagesToUIMessages(toolMessages as any); - return uiMessages; + return this.stripOpenAIItemIdsFromSharedContext(uiMessages); } catch { return []; } } + private stripOpenAIItemIdsFromSharedContext(messages: UIMessage[]): UIMessage[] { + return messages.map((message) => { + if (!Array.isArray(message.parts) || message.parts.length === 0) { + return message; + } + + let mutatedMessage = false; + const sanitizedParts = message.parts.map((part) => { + if (!part || typeof part !== "object" || Array.isArray(part)) { + return part; + } + + let nextPart = part as Record; + let mutatedPart = false; + + const sanitizeMetadata = (value: unknown): unknown => { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return value; + } + + const metadata = value as Record; + const openai = metadata.openai; + if (!openai || typeof openai !== "object" || Array.isArray(openai)) { + return value; + } + + const openaiRecord = openai as Record; + if (!("itemId" in openaiRecord)) { + return value; + } + + const { itemId: _itemId, ...openaiWithoutItemId } = openaiRecord; + if (Object.keys(openaiWithoutItemId).length === 0) { + const { openai: _openai, ...metadataWithoutOpenai } = metadata; + return metadataWithoutOpenai; + } + + return { + ...metadata, + openai: openaiWithoutItemId, + }; + }; + + const applySanitizedMetadata = (key: "callProviderMetadata" | "providerMetadata") => { + const current = nextPart[key]; + const sanitized = sanitizeMetadata(current); + if (sanitized === current) { + return; + } + + mutatedPart = true; + if (sanitized === undefined) { + const { [key]: _ignored, ...rest } = nextPart; + nextPart = rest; + } else { + nextPart = { + ...nextPart, + [key]: sanitized, + }; + } + }; + + applySanitizedMetadata("callProviderMetadata"); + applySanitizedMetadata("providerMetadata"); + + if (!mutatedPart) { + return part; + } + + mutatedMessage = true; + return nextPart as typeof part; + }); + + if (!mutatedMessage) { + return message; + } + + return { + ...message, + parts: sanitizedParts, + }; + }); + } + /** * Get sub-agent details for API exposure */