Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .changeset/curly-rivers-march.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions examples/with-hitl/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
OPENAI_API_KEY=your_openai_api_key_here
3 changes: 3 additions & 0 deletions examples/with-hitl/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules
dist
.DS_Store
Comment on lines +1 to +3

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add .env to ignore list to avoid leaking API keys.

The README instructs users to create examples/with-hitl/.env containing OPENAI_API_KEY. Without ignoring it here, the key can be committed accidentally.

🔒 Suggested fix
 node_modules
 dist
 .DS_Store
+.env
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
node_modules
dist
.DS_Store
node_modules
dist
.DS_Store
.env
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/with-hitl/.gitignore` around lines 1 - 3, The .gitignore in
examples/with-hitl currently lists node_modules, dist, and .DS_Store but misses
.env which may contain sensitive API keys; update the .gitignore by adding a
line for .env so the examples/with-hitl/.env file is ignored and cannot be
accidentally committed (add ".env" to the existing list).

87 changes: 87 additions & 0 deletions examples/with-hitl/README.md
Original file line number Diff line number Diff line change
@@ -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`.
38 changes: 38 additions & 0 deletions examples/with-hitl/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
86 changes: 86 additions & 0 deletions examples/with-hitl/src/index.ts
Original file line number Diff line number Diff line change
@@ -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,
});
14 changes: 14 additions & 0 deletions examples/with-hitl/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}
Loading