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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bright-owls-approve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": minor
---

Standardize authored tools and connections on an `approval` function that receives the active session context and returns AI SDK 7 approval statuses, with synchronous and asynchronous policies supported. Boolean results remain supported as aliases for user approval and no approval, schedules no longer accept approval configuration, and no AI SDK 6 `needsApproval` adapter remains.
2 changes: 1 addition & 1 deletion apps/fixtures/weather-agent/agent/tools/get_weather.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { z } from "zod";
const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms));

export default defineTool({
needsApproval: never(),
approval: never(),
description: "Get the current weather for a city.",
inputSchema: z.object({
city: z.string(),
Expand Down
2 changes: 1 addition & 1 deletion apps/frameworks/nuxt/agent/tools/get_weather.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { z } from "zod";
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

export default defineTool({
needsApproval: never(),
approval: never(),
description: "Get the current weather for a city.",
inputSchema: z.object({
city: z.string(),
Expand Down
2 changes: 1 addition & 1 deletion apps/frameworks/sveltekit/agent/tools/get_weather.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { z } from "zod";
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

export default defineTool({
needsApproval: never(),
approval: never(),
description: "Get the current weather for a city.",
inputSchema: z.object({
city: z.string(),
Expand Down
2 changes: 1 addition & 1 deletion apps/templates/web-chat-next/agent/tools/randomize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { never } from "eve/tools/approval";
import { z } from "zod";

export default defineTool({
needsApproval: never(),
approval: never(),
description:
"Generate a random result: pick one of the given choices, or a random number between min and max.",
inputSchema: z.object({
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/frontend/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ Assistant text, reasoning, tool calls, and tool results stream into `data` as th

## Human-in-the-loop prompts

Tools opt into approval with `needsApproval`, and the model can also ask a question with `ask_question` — see [Human-in-the-loop](/docs/human-in-the-loop) for the server-side model. Either way the stream emits an `input.requested` event, and the pending request rides on a `dynamic-tool` part of the latest message at `part.toolMetadata?.eve?.inputRequest`. Read it, then answer through the same session with `send()`:
Tools opt into approval with `approval`, and the model can also ask a question with `ask_question` — see [Human-in-the-loop](/docs/human-in-the-loop) for the server-side model. Either way the stream emits an `input.requested` event, and the pending request rides on a `dynamic-tool` part of the latest message at `part.toolMetadata?.eve?.inputRequest`. Read it, then answer through the same session with `send()`:

```tsx
const request = agent.data.messages
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/frontend/use-eve-agent-svelte.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ await agent.send({

## Human-in-the-loop prompts

A tool opts into approval with `needsApproval` ([Tools](../../tools)). When one fires, the pending request rides along on a `dynamic-tool` part of the latest message at `part.toolMetadata?.eve?.inputRequest`. Read it, then answer through the same session with `agent.send({ inputResponses })`:
A tool opts into approval with `approval` ([Tools](../../tools)). When one fires, the pending request rides along on a `dynamic-tool` part of the latest message at `part.toolMetadata?.eve?.inputRequest`. Read it, then answer through the same session with `agent.send({ inputResponses })`:

```ts
import type { EveDynamicToolPart, EveMessagePart } from "eve/svelte";
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/frontend/use-eve-agent-vue.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ async function onFileChange(event: Event) {

## Human-in-the-loop prompts

A tool opts into approval with `needsApproval` ([Tools](../../tools)). When it triggers, the pending request shows up as a `dynamic-tool` part on the latest message at `part.toolMetadata?.eve?.inputRequest`. Read it, then answer through the same session with `send({ inputResponses })`:
A tool opts into approval with `approval` ([Tools](../../tools)). When it triggers, the pending request shows up as a `dynamic-tool` part on the latest message at `part.toolMetadata?.eve?.inputRequest`. Read it, then answer through the same session with `send({ inputResponses })`:

```ts
import type { EveDynamicToolPart, EveMessagePart } from "eve/vue";
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/typescript-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export default defineTool({
| `defineEvalConfig` | `eve/evals` | `evals/evals.config.ts` | [Evals](../evals/overview) |
| `useEveAgent` | `eve/react`, `eve/vue`, `eve/svelte` | frontend | [Frontend](../guides/frontend/overview) |

A few non-`define*` helpers round out the set: `disableTool` and `ExperimentalWorkflow` from `eve/tools` (see [Default harness](../concepts/default-harness)), the route verbs `GET`/`POST`/`PUT`/`PATCH`/`DELETE`/`WS` from `eve/channels`, the approval predicates `always`/`once`/`never` from `eve/tools/approval`, and the channel auth helpers `localDev`/`vercelOidc`/`placeholderAuth` from `eve/channels/auth`. To wrap a built-in tool, import its default value from `eve/tools/defaults` (`bash`, `readFile`, `writeFile`, `glob`, `grep`, `webFetch`, `webSearch`, `todo`, `loadSkill`). `AgentReasoningDefinition` is exported from `eve` for the top-level `defineAgent({ reasoning })` setting. `AgentWorkflowDefinition` and `AgentWorkflowWorldDefinition` are exported from `eve` for the `defineAgent({ experimental: { workflow } })` config shape.
A few non-`define*` helpers round out the set: `disableTool` and `ExperimentalWorkflow` from `eve/tools` (see [Default harness](../concepts/default-harness)), the route verbs `GET`/`POST`/`PUT`/`PATCH`/`DELETE`/`WS` from `eve/channels`, the approval policies `always`/`once`/`never` from `eve/tools/approval`, and the channel auth helpers `localDev`/`vercelOidc`/`placeholderAuth` from `eve/channels/auth`. To wrap a built-in tool, import its default value from `eve/tools/defaults` (`bash`, `readFile`, `writeFile`, `glob`, `grep`, `webFetch`, `webSearch`, `todo`, `loadSkill`). `AgentReasoningDefinition` is exported from `eve` for the top-level `defineAgent({ reasoning })` setting. `AgentWorkflowDefinition` and `AgentWorkflowWorldDefinition` are exported from `eve` for the `defineAgent({ experimental: { workflow } })` config shape.

## Runtime context (`ctx`)

Expand Down
2 changes: 1 addition & 1 deletion docs/subagents.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ A declared subagent's tool name is the bare path-derived name, with no prefix. `

Because the name lives in the same runtime tool namespace as authored tools, a subagent named `researcher` collides with a tool named `researcher`. eve rejects the build rather than picking a winner, so keep subagent directory names distinct from tool names.

Do not rely on subagent delegation by itself as an approval boundary. Put sensitive tools behind `needsApproval`, connection approval, route/session authorization, or other controls wherever those tools can be called.
Do not rely on subagent delegation by itself as an approval boundary. Put sensitive tools behind `approval`, connection approval, route/session authorization, or other controls wherever those tools can be called.

Each delegated subagent spins up its own child session and stream. The parent stream carries only the control-plane events `subagent.called` and `subagent.completed`. To follow the child's full progress, read `subagent.called.data.childSessionId` and subscribe at `GET /eve/v1/session/:childSessionId/stream`.

Expand Down
22 changes: 16 additions & 6 deletions docs/tools/human-in-the-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Either way the run parks at `session.waiting`, durably, for as long as it takes

## Approvals

Approval is a property of a [tool](/docs/tools) that pauses for a person before it runs. Gate a tool with `needsApproval` and the helpers from `eve/tools/approval`:
Approval is a property of a [tool](/docs/tools) that pauses for a person before it runs. Gate a tool with `approval` and the helpers from `eve/tools/approval`:

```ts title="agent/tools/refund_charge.ts"
import { defineTool } from "eve/tools";
Expand All @@ -22,8 +22,8 @@ import { z } from "zod";

export default defineTool({
description: "Refund a charge.",
inputSchema: z.object({ chargeId: z.string(), amount: z.number() }),
needsApproval: always(), // or once() / never() / a predicate
inputSchema: z.object({ tenantId: z.string(), chargeId: z.string(), amount: z.number() }),
approval: always(), // or once() / never() / a policy
async execute(input) {
return refund(input);
},
Expand All @@ -36,14 +36,24 @@ export default defineTool({
| `once()` | Require approval only the first time the tool runs in a session; auto-allow after. |
| `always()` | Require approval before every call. |

By default, omitted `needsApproval` behaves like `never()`, so tool calls may execute without human approval. Require human approval or other safeguards for sensitive, irreversible, regulated, financial, healthcare, employment, housing, legal, safety-impacting, user-impacting, or external side-effecting actions.
By default, omitted `approval` behaves like `never()`, so tool calls may execute without human approval. Require human approval or other safeguards for sensitive, irreversible, regulated, financial, healthcare, employment, housing, legal, safety-impacting, user-impacting, or external side-effecting actions.

When the decision depends on the input, pass your own predicate instead of a helper. It receives `{ toolName, toolInput, approvedTools }` and returns a boolean. `toolInput` can be undefined, so guard the access. To require approval only when an amount crosses a threshold:
When the decision depends on the input, pass your own policy instead of a helper. It receives the same session context as tool execution, plus `{ toolName, toolInput, approvedTools }`, and returns an AI SDK 7 approval status synchronously or as a promise. Use `ctx.session.auth.current` to guard by the caller of the current turn and `ctx.session.auth.initiator` to guard by the caller that created the session. Return `"user-approval"` to pause for a person or `"not-applicable"` to continue without a prompt. `toolInput` can be undefined, so guard the access. This policy denies cross-tenant calls, then requires approval only when an amount crosses a threshold:

```ts
needsApproval: ({ toolInput }) => (toolInput?.amount ?? 0) > 1000,
approval: ({ session, toolInput }) => {
const callerTenant = session.auth.current?.attributes.tenantId;
if (callerTenant === undefined || callerTenant !== toolInput?.tenantId) {
return { type: "denied", reason: "Caller cannot access this tenant." };
}
return (toolInput?.amount ?? 0) > 1000 ? "user-approval" : "not-applicable";
},
```

For compatibility with the previous predicate shape, policies may return booleans: `true` is treated as `"user-approval"` and `false` as `"not-applicable"`. Boolean promises are supported too.

Policies can also return `"approved"` or `"denied"` to decide automatically. Use `{ type: "approved" | "denied", reason }` when the model should receive a reason. The `Approval`, `ApprovalContext`, and `ApprovalStatus` types are exported from both `eve/tools` and `eve/tools/approval`.

Gating a side effect on approval is also how you make non-idempotent work safe across replays: a charge or email that sits behind `always()` can't fire from a re-run step without a fresh human decision.

## Questions
Expand Down
6 changes: 3 additions & 3 deletions docs/tools/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ eve never runs authored tools during discovery. The model sees descriptors first

## Gate a tool on human approval

A tool can require a person to sign off before it runs. Set `needsApproval` with the helpers from `eve/tools/approval`:
A tool can require a person to sign off before it runs. Set `approval` with the helpers from `eve/tools/approval`:

```ts title="agent/tools/refund_charge.ts"
import { defineTool } from "eve/tools";
Expand All @@ -56,14 +56,14 @@ import { z } from "zod";
export default defineTool({
description: "Refund a charge.",
inputSchema: z.object({ chargeId: z.string(), amount: z.number() }),
needsApproval: always(), // or once() / never() / a predicate
approval: always(), // or once() / never() / a policy
async execute(input) {
return refund(input);
},
});
```

Approval is one half of eve's [human-in-the-loop](./human-in-the-loop) model — the page covers the `always/once/never` helpers, input-dependent predicates, and how a gated call pauses and resumes durably.
Approval is one half of eve's [human-in-the-loop](./human-in-the-loop) model — the page covers the `always/once/never` helpers, input-dependent policies, and how a gated call pauses and resumes durably.

## Shape what the model sees with `toModelOutput`

Expand Down
7 changes: 4 additions & 3 deletions docs/tutorial/guard-the-spend.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: "Part 8 of the Build an Agent tutorial. Gate expensive queries with

A single warehouse query can scan terabytes and run up the bill. So before the analytics assistant fires off an expensive scan, make it stop and check with you. The agent pauses, asks you, and resumes with your answer. That's human-in-the-loop, and you wire it up with one field on the tool.

`needsApproval` runs before `execute`. Return `true` and the turn parks on an approval request; you answer, and the run picks up from that exact step. The function gets the tool input, so you can make the decision cost-based.
`approval` runs before `execute`. Return `"user-approval"` and the turn parks on an approval request; you answer, and the run picks up from that exact step. The function gets the tool input, so you can make the decision cost-based.

## Estimate, then gate

Expand All @@ -32,7 +32,8 @@ export default defineTool({
description: "Run a read-only SQL query against the analytics tables.",
inputSchema: z.object({ sql: z.string() }),
// Cost-based gate: only the expensive queries need a human yes.
needsApproval: ({ toolInput }) => estimateScanGb(toolInput?.sql ?? "") > THRESHOLD_GB,
approval: ({ toolInput }) =>
estimateScanGb(toolInput?.sql ?? "") > THRESHOLD_GB ? "user-approval" : "not-applicable",
async execute({ sql }) {
const { columns, rows } = await runReadOnlySql(sql);
return { columns, rows: rows.slice(0, 500), truncated: rows.length > 500 };
Expand All @@ -50,7 +51,7 @@ Ask for something that forces a large unfiltered scan:
Total revenue across all customers, all time, broken out by day.
```

The model proposes the query, `needsApproval` returns `true`, and the turn parks. The stream emits `input.requested`, then `session.waiting`. How the prompt looks depends on the channel, whether buttons in the TUI, Block Kit in Slack, or a UI control on the web. Approve it and the run resumes from exactly that step, then the query runs. Deny it and the tool is skipped, with the model told why.
The model proposes the query, `approval` returns `"user-approval"`, and the turn parks. The stream emits `input.requested`, then `session.waiting`. How the prompt looks depends on the channel, whether buttons in the TUI, Block Kit in Slack, or a UI control on the web. Approve it and the run resumes from exactly that step, then the query runs. Deny it and the tool is skipped, with the model told why.

Each session has exactly one active continuation. Answer an approval against a stale handle and it's rejected, so there's no way to double-resume the same parked turn.

Expand Down
2 changes: 1 addition & 1 deletion docs/tutorial/ship-it.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ Across the nine steps you built and shipped one agent, and along the way you use
- **The sandbox** to compute and chart beyond SQL in an isolated `/workspace`.
- **State** (`defineState`) to remember the team's glossary across turns.
- **Dynamic skills** (`defineDynamic`) to load the right team playbook per caller.
- **Human-in-the-loop** approval (`needsApproval`) to gate expensive queries.
- **Human-in-the-loop** approval (`approval`) to gate expensive queries.
- **Channel auth** to turn a request into an authenticated principal.
- **Deployment** to Vercel, with the runtime behind your web app.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export default defineTool({
inputSchema: z.object({
ticker: z.string().describe("Stock ticker symbol"),
}),
needsApproval: () => true,
approval: () => "user-approval",
async execute(input) {
const ticker = input.ticker.toUpperCase();
const data = MOCK_PRICES[ticker];
Expand Down
2 changes: 1 addition & 1 deletion e2e/fixtures/agent-subagents-hitl/evals/hitl.eval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ function subagentOutputs(events: readonly HandleMessageStreamEvent[]): string[]

/**
* Parent/child HITL proxying: the stock-price subagent's tool approval
* (`needsApproval: () => true`) surfaces on the parent stream, the approval
* (`approval: () => "user-approval"`) surfaces on the parent stream, the approval
* routes back down, and the child's result splices into the parent reply.
* Parking is server-side.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export default defineDynamic({
inputSchema: z.object({
note: z.string().optional(),
}),
needsApproval: () => true,
approval: () => "user-approval",
async execute(input) {
return {
echoed: input.note ?? null,
Expand Down
2 changes: 1 addition & 1 deletion e2e/fixtures/agent-tools-hitl/agent/tools/guarded-echo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export default defineTool({
inputSchema: z.object({
note: z.string().optional().describe("Any note string."),
}),
needsApproval: once(),
approval: once(),
async execute(input) {
return {
echoed: input.note ?? null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ const TOOL_NAME = "dynamic_guarded_echo";

/**
* HITL flow: a session-scoped dynamic tool's approval gate survives durable
* replay. If replay drops `needsApproval`, the tool executes immediately and
* replay. If replay drops `approval`, the tool executes immediately and
* this eval fails before approval.
*/
export default defineEval({
description: "HITL smoke: replayed dynamic tools preserve needsApproval.",
description: "HITL smoke: replayed dynamic tools preserve approval.",
async test(t) {
await t.send(`Call the \`${TOOL_NAME}\` tool with note "before-approval".`);
const [request] = t.expectInputRequests({
Expand Down
4 changes: 2 additions & 2 deletions e2e/fixtures/agent-tools-sandbox/agent/tools/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { never } from "eve/tools/approval";

/**
* Bash tool exposed to the model for the sandbox-bootstrap smoke
* test. `needsApproval: never()` keeps the smoke test single-turn
* test. `approval: never()` keeps the smoke test single-turn
* and avoids tripping the HITL machinery already exercised by
* `tool-approval.ts` / `tool-denial.ts`.
*
Expand All @@ -14,5 +14,5 @@ import { never } from "eve/tools/approval";
*/
export default defineTool({
...defineBashTool(),
needsApproval: never(),
approval: never(),
});
4 changes: 2 additions & 2 deletions e2e/fixtures/agent-tools/agent/tools/always-throws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { z } from "zod";
* tool-error result), the harness does NOT emit `turn.failed`, and the
* session remains usable for a follow-up message.
*
* `needsApproval: never()` keeps the test single-turn from a HITL
* `approval: never()` keeps the test single-turn from a HITL
* perspective so the throw path is the only thing under test.
*/
export default defineTool({
Expand All @@ -20,7 +20,7 @@ export default defineTool({
inputSchema: z.object({
reason: z.string().describe("Free-form reason for the call. The tool ignores it and throws."),
}),
needsApproval: never(),
approval: never(),
async execute(_input) {
throw new Error("always-throws: intentional failure for smoke-test coverage");
},
Expand Down
Loading
Loading