diff --git a/packages/acpx-ai-provider/README.md b/packages/acpx-ai-provider/README.md index 72e63e1..0d23cf1 100644 --- a/packages/acpx-ai-provider/README.md +++ b/packages/acpx-ai-provider/README.md @@ -222,6 +222,62 @@ const provider = createAcpxProvider({ > TCP-callback story from `acp-ai-provider`) are **not** supported in > v0.1. See [Known limitations](#known-limitations). +## Per-call permissions + +By default, every permission request the agent issues (write a file, +run a shell command, delete, etc.) is resolved by the up-front +`permissionMode` setting. To intercept individual requests with your +own UI, pass an `onPermissionRequest` callback: + +```ts +const provider = createAcpxProvider({ + agent: 'codex', + cwd: '/path/to/repo', + permissionMode: 'approve-reads', // fallback for unhandled cases + onPermissionRequest: async (req, { signal }) => { + // The agent is paused mid-turn waiting for your decision. + // Honor `signal` so a turn cancel doesn't leave it hanging. + const decision = await myUi.prompt({ + title: req.raw.toolCall.title, + kind: req.inferredKind, // 'edit' | 'shell' | 'delete' | … + args: req.raw.toolCall.input, + }) + return decision + // Returning `undefined` falls through to the mode-based resolver. + }, +}) +``` + +The callback receives: + +| Field | Meaning | +|---|---| +| `req.sessionId` | ACP session id (handy for multi-session hosts) | +| `req.raw` | Full original `RequestPermissionRequest` from the ACP SDK | +| `req.inferredKind` | One of `'read' \| 'search' \| 'edit' \| 'delete' \| 'move' \| 'execute' \| 'fetch' \| 'think' \| 'other'` — best-effort classification from the tool's title | +| `ctx.signal` | Aborts when the turn is cancelled or the session closes | + +Return one of: + +- `{ outcome: 'allow_once' }` — approve this single call +- `{ outcome: 'allow_always' }` — approve this kind for the rest of the turn +- `{ outcome: 'reject_once' }` — deny this call; agent continues with the rest of its task +- `{ outcome: 'reject_always' }` — deny and remember for the rest of the turn +- `{ outcome: 'cancel' }` — agent treats the call as cancelled (often ends the turn) +- `undefined` — fall through to the mode-based resolver + +**Important caveats:** + +- The callback is invoked **only** when the provider builds its own + runtime. If you pass a pre-built `runtime` via the `runtime` + setting, set `onPermissionRequest` on that runtime instead. +- Throwing inside the callback falls through to mode-based logic and + is logged by the runtime. Don't let UI errors take the whole turn + down. +- The agent is **paused** until your promise resolves. There's no + timeout enforced by the provider — wire your own (or rely on the + agent's internal timeout, typically 5–10 minutes). + ## Structured output (JSON) `generateObject` / `streamObject` work via JSON mode. The provider @@ -277,9 +333,11 @@ This is alpha software. Most rough edges flow through from are `undefined`. Per-token cost calculation won't work. - **No streaming usage updates.** Only the most recent `usage_update` from the runtime survives onto the `finish` part. -- **Permissions are mode-based, not callback-based.** No per-call - user prompt — pick `approve-all`, `approve-reads`, or `deny-all` up - front. +- **Permission policy is mode-based by default.** When you don't + provide an `onPermissionRequest` callback, requests fall through to + `permissionMode` + `nonInteractivePermissions` — same as before. + Hosts wanting per-call gating should set the callback (see + [Per-call permissions](#per-call-permissions)). - **Auth is env-var / config-file driven, no lazy retry.** Missing credentials throw at first use. - **`npx` cold start on first agent use.** Built-in agents diff --git a/packages/acpx-ai-provider/package.json b/packages/acpx-ai-provider/package.json index 4605b0a..6ff0bca 100644 --- a/packages/acpx-ai-provider/package.json +++ b/packages/acpx-ai-provider/package.json @@ -1,6 +1,6 @@ { "name": "acpx-ai-provider", - "version": "0.0.1", + "version": "0.1.0", "description": "Vercel AI SDK provider on top of acpx/runtime — bring any ACP agent (Claude, Codex, Gemini, Copilot, Cursor…) to AI SDK with one install.", "license": "MIT", "author": "Dani Akash", @@ -48,13 +48,13 @@ "typecheck": "tsc --noEmit" }, "peerDependencies": { - "acpx": ">=0.6.1", + "acpx": ">=0.8.0", "ai": ">=6.0.0" }, "devDependencies": { "@ai-sdk/provider": "^3.0.10", "@ai-sdk/provider-utils": "^4.0.26", - "acpx": "^0.6.1", + "acpx": ">=0.8.0", "ai": "^6.0.175", "bunup": "^0.16.31", "typescript": "^6.0.3", diff --git a/packages/acpx-ai-provider/src/index.ts b/packages/acpx-ai-provider/src/index.ts index 7565b8c..73f5693 100644 --- a/packages/acpx-ai-provider/src/index.ts +++ b/packages/acpx-ai-provider/src/index.ts @@ -26,6 +26,8 @@ export { AcpxLanguageModel } from './language-model.ts' export type { EnsureHandleResult } from './provider.ts' export { AcpxProvider, createAcpxProvider } from './provider.ts' export type { + AcpPermissionDecision, + AcpPermissionRequest, AcpRuntime, AcpRuntimeDoctorReport, AcpRuntimeEvent, diff --git a/packages/acpx-ai-provider/src/provider.ts b/packages/acpx-ai-provider/src/provider.ts index be51fba..37b6734 100644 --- a/packages/acpx-ai-provider/src/provider.ts +++ b/packages/acpx-ai-provider/src/provider.ts @@ -157,6 +157,7 @@ export class AcpxProvider { DEFAULT_NON_INTERACTIVE) as AcpRuntimeOptions['nonInteractivePermissions'], timeoutMs: this.settings.turnTimeoutMs, mcpServers: this.settings.mcpServers as AcpRuntimeOptions['mcpServers'], + onPermissionRequest: this.settings.onPermissionRequest, } } } diff --git a/packages/acpx-ai-provider/src/types.ts b/packages/acpx-ai-provider/src/types.ts index d9c4f91..c86299f 100644 --- a/packages/acpx-ai-provider/src/types.ts +++ b/packages/acpx-ai-provider/src/types.ts @@ -1,4 +1,6 @@ import type { + AcpPermissionDecision, + AcpPermissionRequest, AcpRuntime, AcpRuntimeDoctorReport, AcpRuntimeEvent, @@ -35,6 +37,25 @@ export interface AcpxProviderSettings { sessionMode?: AcpxSessionMode permissionMode?: AcpxPermissionMode nonInteractivePermissions?: AcpxNonInteractivePermissions + /** + * Async callback invoked when the agent issues a per-call permission + * request (e.g. write, shell, delete). Return a decision to gate the + * call with host UI. Return `undefined` to fall through to the + * existing `permissionMode` + `nonInteractivePermissions` logic. + * + * The callback is invoked while the agent is paused mid-turn waiting + * for the JSON-RPC response — resolve quickly or honor the abort + * signal so the agent doesn't hang. + * + * Note: this option is *only* honored when `runtime` is left + * undefined (so the provider builds its own runtime). When the host + * passes a pre-built `runtime`, the callback must be set on that + * runtime directly. + */ + onPermissionRequest?: ( + req: AcpPermissionRequest, + ctx: { signal: AbortSignal }, + ) => Promise mcpServers?: AcpxMcpServerConfig[] agentRegistryOverrides?: Record stateDir?: string @@ -54,6 +75,8 @@ export interface AcpxLanguageModelOptions { } export type { + AcpPermissionDecision, + AcpPermissionRequest, AcpRuntime, AcpRuntimeDoctorReport, AcpRuntimeEvent, diff --git a/packages/acpx-ai-provider/test/unit/provider.test.ts b/packages/acpx-ai-provider/test/unit/provider.test.ts new file mode 100644 index 0000000..9225657 --- /dev/null +++ b/packages/acpx-ai-provider/test/unit/provider.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import type { + AcpPermissionDecision, + AcpPermissionRequest, + AcpRuntime, + AcpRuntimeOptions, +} from 'acpx/runtime' + +const createAcpRuntimeMock = mock( + (_options: AcpRuntimeOptions): AcpRuntime => + ({ + ensureSession: async () => ({}), + startTurn: () => ({ + requestId: 'r', + events: { [Symbol.asyncIterator]: async function* () {} }, + result: Promise.resolve({ status: 'completed' }), + cancel: async () => {}, + closeStream: async () => {}, + }), + cancel: async () => {}, + close: async () => {}, + }) as unknown as AcpRuntime, +) + +mock.module('acpx/runtime', () => ({ + createAcpRuntime: createAcpRuntimeMock, + createAgentRegistry: () => ({}), + createFileSessionStore: () => ({}), +})) + +// Imported AFTER `mock.module` so the provider sees our stubs. +const { createAcpxProvider } = await import('../../src/provider.ts') + +beforeEach(() => { + createAcpRuntimeMock.mockClear() +}) + +afterEach(() => { + createAcpRuntimeMock.mockClear() +}) + +describe('AcpxProvider — onPermissionRequest', () => { + test('forwards the callback into AcpRuntimeOptions', () => { + const cb = async ( + _req: AcpPermissionRequest, + ): Promise => undefined + const provider = createAcpxProvider({ + agent: 'codex', + onPermissionRequest: cb, + }) + void provider.runtime // force lazy build + + expect(createAcpRuntimeMock).toHaveBeenCalledTimes(1) + const opts = createAcpRuntimeMock.mock.calls[0]?.[0] + expect(opts?.onPermissionRequest).toBe(cb) + }) + + test('omits the callback when not configured', () => { + const provider = createAcpxProvider({ agent: 'codex' }) + void provider.runtime + + const opts = createAcpRuntimeMock.mock.calls.at(-1)?.[0] + expect(opts?.onPermissionRequest).toBeUndefined() + }) + + test('skips runtime construction when a pre-built runtime is provided', () => { + const fakeRuntime = { + ensureSession: async () => ({}), + } as unknown as AcpRuntime + const cb = async ( + _req: AcpPermissionRequest, + ): Promise => undefined + + const provider = createAcpxProvider({ + agent: 'codex', + runtime: fakeRuntime, + onPermissionRequest: cb, + }) + + expect(provider.runtime).toBe(fakeRuntime) + expect(createAcpRuntimeMock).not.toHaveBeenCalled() + }) +})