diff --git a/docs/guides/client/messages.mdx b/docs/guides/client/messages.mdx index c5539fb55..00d8c0725 100644 --- a/docs/guides/client/messages.mdx +++ b/docs/guides/client/messages.mdx @@ -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 diff --git a/docs/tools/human-in-the-loop.md b/docs/tools/human-in-the-loop.md index 01e008f2a..2cbe33587 100644 --- a/docs/tools/human-in-the-loop.md +++ b/docs/tools/human-in-the-loop.md @@ -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: diff --git a/packages/eve/src/client/message-reducer.test.ts b/packages/eve/src/client/message-reducer.test.ts index 1adf4f961..1fc13ad3d 100644 --- a/packages/eve/src/client/message-reducer.test.ts +++ b/packages/eve/src/client/message-reducer.test.ts @@ -6,6 +6,7 @@ import { createActionsRequestedEvent, createAuthorizationCompletedEvent, createAuthorizationRequiredEvent, + createApprovalSettledEvent, createInputRequestedEvent, createMessageAppendedEvent, createMessageCompletedEvent, @@ -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( diff --git a/packages/eve/src/client/message-reducer.ts b/packages/eve/src/client/message-reducer.ts index a033a6085..b80d0628b 100644 --- a/packages/eve/src/client/message-reducer.ts +++ b/packages/eve/src/client/message-reducer.ts @@ -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);