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
8 changes: 8 additions & 0 deletions docs/guides/client/messages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,14 @@ const resumed = await session.send({
await resumed.result();
```

For response-authorized approvals, submitting `approve` creates a responder-bound candidate; it does not immediately close the shared request. Keep consuming the durable stream:

- `approval.candidate` reports safe pending, rejected, failed, timed-out, and stale outcomes. Candidate-local UI should be private to its responder.
- candidate-correlated `authorization.required` carries a private sign-in challenge.
- `approval.settled` is the terminal authority for removing shared controls and shows whether the request was approved or cancelled.

Delivery only acknowledges ingestion. OAuth may resume the same candidate later, so reconnecting clients should replay settlement history to repair stale UI.

You can send `message`, `inputResponses`, and `clientContext` together when the resumed turn needs both a human answer and follow-up text.

## Single-use responses
Expand Down
27 changes: 27 additions & 0 deletions docs/tools/human-in-the-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,33 @@ Policies can also return `"approved"` or `"denied"` to decide automatically. Use

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.

### Authorize who may approve

Request-time approval decides whether a tool call prompts. To also authorize the person responding, use the object form:

```ts
approval: {
policy: always(),
async authorizeResponse({ request, responder, session, auth }) {
const identity = await auth.getToken(approverAuth);
return await canApprove({ identity, request, responder, session })
? "allowed"
: {
status: "rejected",
safeReason: "You are not permitted to approve this action.",
};
},
},
```

`request` contains the stable request, call, tool name, and tool input. `responder` is the identity derived by the authenticated channel ingress. `session` exposes read-only identity and lineage. `auth` exposes only `getToken` and `requireAuth`; it has no sandbox, skill, model, channel, or tool-execution capability.

Calling `auth.getToken()` uses the same durable `authorization.required` flow as tool execution. If sign-in is needed, eve keeps the shared approval pending, binds the private challenge to that responder, and re-runs the currently deployed authorizer after callback. Return an authored `safeReason` only when it is safe to show privately to the responder. Thrown errors and the ten-second authorizer timeout fail closed with generic retry feedback.

Approval controls are **Approve / Cancel**. Approve runs `authorizeResponse`. Cancel means “do not run this proposed call” and uses the channel's ordinary authenticated interaction boundary without running the authorizer. Multiple responders may validate concurrently; the first approved candidate or Cancel settles atomically, and late candidates become stale.

The stream exposes `approval.candidate` progress and terminal `approval.settled` events. Candidate submission acknowledges ingestion only; clients keep shared controls open until settlement. Credentials, OAuth URLs, and raw provider errors are not included in candidate audit history.

### Skipping approval for schedule-dispatched turns

`session.auth.current` identifies the caller of this turn. Markdown schedules use the app principal (`authenticator: "app"`, `principalId: "eve:app"`, `principalType: "runtime"`) automatically. A `run` schedule must pass its `appAuth` to `receive(...)` for the child session to use that principal. Match all three fields to skip approval for automated turns while still prompting when a person calls the same tool:
Expand Down
45 changes: 45 additions & 0 deletions packages/eve/src/client/message-reducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
createActionsRequestedEvent,
createAuthorizationCompletedEvent,
createAuthorizationRequiredEvent,
createApprovalSettledEvent,
createInputRequestedEvent,
createMessageAppendedEvent,
createMessageCompletedEvent,
Expand Down Expand Up @@ -427,6 +428,50 @@ describe("defaultMessageReducer", () => {
]);
});

it("marks shared approval UI only after durable settlement", () => {
const reducer = defaultMessageReducer();
let data = reducer.reduce(
reducer.initial(),
createInputRequestedEvent({
requests: [
{
action: { callId: "call_1", input: {}, kind: "tool-call", toolName: "bash" },
options: [
{ id: "approve", label: "Approve" },
{ id: "cancel", label: "Cancel" },
],
prompt: "Approve?",
requestId: "approval_1",
},
],
sequence: 0,
stepIndex: 0,
turnId: "turn_1",
}),
);

data = reducer.reduce(
data,
createApprovalSettledEvent({
outcome: "approved",
requestId: "approval_1",
responderPrincipalId: "slack:T1:U1",
sequence: 1,
stepIndex: 0,
turnId: "turn_1",
}),
);

expect(data.messages[0]?.parts).toEqual(
expect.arrayContaining([
expect.objectContaining({
approval: { approved: true, id: "approval_1", reason: undefined },
state: "approval-responded",
}),
]),
);
});

it("marks input requests as responded when the client submits a response", () => {
const reducer = defaultMessageReducer();
let data = reducer.reduce(
Expand Down
36 changes: 36 additions & 0 deletions packages/eve/src/client/message-reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,42 @@ function reduceMessageData(data: EveMessageData, event: EveAgentReducerEvent): E
return next;
}

case "approval.candidate":
// Candidate progress is responder-specific. Applications can consume the
// raw stream event for private UI without changing the shared tool part.
return data;

case "approval.settled": {
const existing = findToolPartByApprovalId(data, event.data.requestId);
if (existing === undefined) return data;
if (event.data.outcome === "approved") {
return updateToolPart(data, existing.toolCallId, {
approval: { approved: true, id: event.data.requestId, reason: undefined },
input: existing.input,
state: "approval-responded",
stepIndex: existing.stepIndex,
toolCallId: existing.toolCallId,
toolMetadata: existing.toolMetadata,
toolName: existing.toolName,
type: "dynamic-tool",
});
}
return updateToolPart(data, existing.toolCallId, {
approval: {
approved: false,
id: event.data.requestId,
reason: "Tool execution was cancelled.",
},
input: existing.input,
state: "output-denied",
stepIndex: existing.stepIndex,
toolCallId: existing.toolCallId,
toolMetadata: existing.toolMetadata,
toolName: existing.toolName,
type: "dynamic-tool",
});
}

case "action.result": {
const descriptor = normalizeActionResult(event.data.result);
const existing = findToolPart(data, event.data.result.callId);
Expand Down
Loading