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 8ab890bdc..1752e2aac 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/extension-contracts/compatibility/dynamicInstructions/v3.ts b/packages/eve/extension-contracts/compatibility/dynamicInstructions/v3.ts new file mode 100644 index 000000000..e0433660b --- /dev/null +++ b/packages/eve/extension-contracts/compatibility/dynamicInstructions/v3.ts @@ -0,0 +1,10 @@ +import { defineDynamic, defineInstructions } from "#public/instructions/index.js"; + +export default defineDynamic({ + events: { + "session.started": () => + defineInstructions({ + markdown: "Answer with evidence from the current session.", + }), + }, +}); diff --git a/packages/eve/extension-contracts/compatibility/dynamicSkill/v3.ts b/packages/eve/extension-contracts/compatibility/dynamicSkill/v3.ts new file mode 100644 index 000000000..942404504 --- /dev/null +++ b/packages/eve/extension-contracts/compatibility/dynamicSkill/v3.ts @@ -0,0 +1,11 @@ +import { defineDynamic, defineSkill } from "#public/skills/index.js"; + +export default defineDynamic({ + events: { + "session.started": () => + defineSkill({ + description: "Triage incoming requests.", + markdown: "# Triage\n\nInspect the request before acting.", + }), + }, +}); diff --git a/packages/eve/extension-contracts/compatibility/dynamicTool/v7.ts b/packages/eve/extension-contracts/compatibility/dynamicTool/v7.ts new file mode 100644 index 000000000..624876617 --- /dev/null +++ b/packages/eve/extension-contracts/compatibility/dynamicTool/v7.ts @@ -0,0 +1,12 @@ +import { defineDynamic, defineTool } from "#public/tools/index.js"; + +export default defineDynamic({ + events: { + "session.started": () => + defineTool({ + description: "Return the current status.", + inputSchema: { type: "object", properties: {} }, + execute: () => ({ status: "ready" }), + }), + }, +}); diff --git a/packages/eve/extension-contracts/compatibility/hook/v4.ts b/packages/eve/extension-contracts/compatibility/hook/v4.ts new file mode 100644 index 000000000..98cd2e775 --- /dev/null +++ b/packages/eve/extension-contracts/compatibility/hook/v4.ts @@ -0,0 +1,12 @@ +import { defineHook } from "#public/hooks/index.js"; + +export default defineHook({ + events: { + "input.requested"(event) { + console.info( + "input requested", + event.data.requests.map((request) => request.requestId), + ); + }, + }, +}); diff --git a/packages/eve/extension-contracts/reports/dynamicInstructions/v4.json b/packages/eve/extension-contracts/reports/dynamicInstructions/v4.json new file mode 100644 index 000000000..5df1fce38 --- /dev/null +++ b/packages/eve/extension-contracts/reports/dynamicInstructions/v4.json @@ -0,0 +1,7 @@ +{ + "kind": "eve-extension-capability-contract", + "capability": "dynamicInstructions", + "epoch": 4, + "sha256": "bc663add7df2d5f5a81b8d796ff24012fa56c6b3e874a9d322d918aefb711008", + "exports": ["defineDynamic"] +} diff --git a/packages/eve/extension-contracts/reports/dynamicSkill/v4.json b/packages/eve/extension-contracts/reports/dynamicSkill/v4.json new file mode 100644 index 000000000..c67fc78f8 --- /dev/null +++ b/packages/eve/extension-contracts/reports/dynamicSkill/v4.json @@ -0,0 +1,7 @@ +{ + "kind": "eve-extension-capability-contract", + "capability": "dynamicSkill", + "epoch": 4, + "sha256": "bc663add7df2d5f5a81b8d796ff24012fa56c6b3e874a9d322d918aefb711008", + "exports": ["defineDynamic"] +} diff --git a/packages/eve/extension-contracts/reports/dynamicTool/v8.json b/packages/eve/extension-contracts/reports/dynamicTool/v8.json new file mode 100644 index 000000000..ab831477a --- /dev/null +++ b/packages/eve/extension-contracts/reports/dynamicTool/v8.json @@ -0,0 +1,13 @@ +{ + "kind": "eve-extension-capability-contract", + "capability": "dynamicTool", + "epoch": 8, + "sha256": "ec3389a248b942d9080056c17747a08556abf4b1addf185ad803ba27edb735ef", + "exports": [ + "DynamicToolEntry", + "DynamicToolEvents", + "DynamicToolResult", + "DynamicToolSet", + "defineDynamic" + ] +} diff --git a/packages/eve/extension-contracts/reports/hook/v5.json b/packages/eve/extension-contracts/reports/hook/v5.json new file mode 100644 index 000000000..05d3659dc --- /dev/null +++ b/packages/eve/extension-contracts/reports/hook/v5.json @@ -0,0 +1,7 @@ +{ + "kind": "eve-extension-capability-contract", + "capability": "hook", + "epoch": 5, + "sha256": "4b994ceb0e64318c40afbf17de07e09b059822a20a9eb5c0a92d7a9c86832535", + "exports": ["defineHook"] +} diff --git a/packages/eve/src/compiler/extension-compatibility.ts b/packages/eve/src/compiler/extension-compatibility.ts index 7b1ee3e69..37fb313d1 100644 --- a/packages/eve/src/compiler/extension-compatibility.ts +++ b/packages/eve/src/compiler/extension-compatibility.ts @@ -22,13 +22,13 @@ interface ExtensionCapabilityContract { const EXTENSION_CAPABILITY_CONTRACTS = { extension: { current: 1, supported: [1], dropped: {} }, tool: { current: 4, supported: [1, 2, 3, 4], dropped: {} }, - dynamicTool: { current: 7, supported: [1, 2, 3, 4, 5, 6, 7], dropped: {} }, + dynamicTool: { current: 8, supported: [1, 2, 3, 4, 5, 6, 7, 8], dropped: {} }, connection: { current: 3, supported: [1, 2, 3], dropped: {} }, - hook: { current: 4, supported: [1, 2, 3, 4], dropped: {} }, + hook: { current: 5, supported: [1, 2, 3, 4, 5], dropped: {} }, skill: { current: 1, supported: [1], dropped: {} }, - dynamicSkill: { current: 3, supported: [1, 2, 3], dropped: {} }, + dynamicSkill: { current: 4, supported: [1, 2, 3, 4], dropped: {} }, instructions: { current: 1, supported: [1], dropped: {} }, - dynamicInstructions: { current: 3, supported: [1, 2, 3], dropped: {} }, + dynamicInstructions: { current: 4, supported: [1, 2, 3, 4], dropped: {} }, config: { current: 1, supported: [1], dropped: {} }, state: { current: 2, supported: [1, 2], dropped: {} }, } as const satisfies Record;