import { EventType, normalizeSystemPrompts } from '@tanstack/ai' import { appendOutputSchemaInstruction, parseJsonFromAssistantText, structuredOutputCompleteChunk, structuredOutputStartChunk, toRunErrorRawEvent, } from '@tanstack/ai/adapter-internals' import { BaseTextAdapter } from '@tanstack/ai/adapters' import { DurableAttachNotSupportedError, SandboxCapability, buildApprovalRequestedEvent, createBridgeEventChannel, getSandbox, getSandboxDurability, getToolBridgeProvisioner, getWorkspaceProjection, mergeChunkStreams, nodeHttpBridgeProvisioner, resolveDurableRunId, resolveHarnessCwd, } from '@tanstack/ai-sandbox' import { AsyncQueue } from '../stream/queue' import { startAcpSession } from '../session/acp-client' import { translateAcpStream } from '../stream/translate' import { resolveInteractivePermission, resolvePermission } from '../permissions' import { buildAcpPrompt } from '../messages/prompt' import { projectAcpWorkspace, workspaceMcpServers } from './projection' import type { AcpMcpServer } from './projection' import type { HostToolBridge, SandboxHandle } from '@tanstack/ai-sandbox' import type { StructuredOutputOptions, StructuredOutputResult, } from '@tanstack/ai/adapters' import type { DefaultMessageMetadataByModality, Modality, ModelMessage, AdapterYieldChunk, TextOptions, } from '@tanstack/ai' import type { AcpSessionHandle } from '../session/acp-client' import type { AcpStreamEvent } from '../stream/translate' import type { AcpSessionTransport } from '../transport/types' import type { AcpPermissionMode, AcpSessionUpdate, PermissionHandler, } from '../types/acp-types' import type { BuiltAcpPrompt } from '../messages/prompt' const DEFAULT_WORKDIR = '/workspace' /** * Everything a harness needs to know to launch its ACP server inside the * sandbox. Passed to {@link AcpCompatibleConfig.command} / * {@link AcpCompatibleConfig.openTransport}. */ export interface AcpHarnessContext< TModelOptions extends Record = AcpCompatibleProviderOptions, > { /** The sandbox the harness runs in (from `withSandbox(...)` middleware). */ sandbox: SandboxHandle /** The selected model id. */ model: string /** Virtual cwd for `sandbox.process.spawn` (the provider maps `/workspace`). */ cwd: string /** Literal cwd for the harness's own `--cwd` flag / ACP `newSession`. */ harnessCwd: string /** Extra env vars configured for the harness process. */ env: Record | undefined /** * Per-call options from `chat({ modelOptions })` — the base ACP options plus * whatever you declared via {@link AcpCompatibleConfig.modelOptions}. Read * these to turn options into CLI flags / transport choices. */ modelOptions: TModelOptions | undefined /** Abort signal for the run, when one was provided. */ signal: AbortSignal | undefined } /** Union of selectable model names from a `models` tuple (any string if omitted). */ export type AcpModelNameOf> = TModels[number] export interface AcpCompatibleConfig< TModels extends ReadonlyArray = ReadonlyArray, TModelOptions extends Record = AcpCompatibleProviderOptions, > { /** * Harness name. Used as the provider label, the log prefix, and the CUSTOM * session-id event name (`.session-id`). */ name: string /** * The models this harness accepts. Declaring them makes the returned factory * type-safe — `harness('known-model')` is checked, unknown ids are rejected. * Omit to accept any string. */ models?: TModels /** * Type-only brand for the per-call options accepted via `chat({ modelOptions })`. * Declare your harness's options here with `{} as { ... }` (the value is unused * at runtime); they are merged with the base {@link AcpCompatibleProviderOptions} * and exposed on {@link AcpHarnessContext.modelOptions} so `command` / * `openTransport` can turn them into CLI flags. * * @example modelOptions: {} as { reasoningEffort?: 'low' | 'high' } */ modelOptions?: TModelOptions /** * Build the shell command that launches the harness's ACP server over * **stdio** inside the sandbox (e.g. `` `pi --acp -m ${model}` ``). Required * unless {@link openTransport} is provided. */ command?: ( ctx: AcpHarnessContext, ) => string /** * Full transport escape hatch — open any {@link AcpSessionTransport} yourself * (e.g. boot a `serve` process and connect over WebSocket, as Grok Build * does). Overrides {@link command}. Put ALL teardown in the returned * transport's `dispose` (stream) / process (stdio); it is disposed when the * session ends. */ openTransport?: ( ctx: AcpHarnessContext, ) => Promise | AcpSessionTransport /** Working directory inside the sandbox. Defaults to `/workspace`. */ cwd?: string /** * The harness's skills directory, relative to the workspace root (e.g. * `'.pi/skills'`) — its native convention for where it auto-discovers skills, * the way Claude Code uses `.claude/skills`. When set, `withSandbox` workspace * `gitSkill`s are linked here. MCP skills don't need this: they're passed to * the agent over ACP natively. Omit and `gitSkill`s are left unlinked (warned). */ skillsDir?: string /** Extra environment variables for the harness process. */ env?: Record /** * `'api-key'` (default) uses {@link authMethodId} (or `modelOptions.authMethodId`). * `'host'` skips ACP authenticate (use the CLI login on the machine). * Not inferred from the sandbox. */ authMode?: 'host' | 'api-key' /** * ACP auth method to select before the session starts, when the harness * advertises one (e.g. `'pi-api-key'`). Overridable per call via * `modelOptions.authMethodId`. Ignored when {@link authMode} is `'host'`. */ authMethodId?: string /** ACP permission policy. Defaults to `'bypassPermissions'`. */ permissionMode?: AcpPermissionMode /** * Permission strategy: * - `'headless'` (default) — auto-resolve via {@link permissionMode}; the * sandbox is the boundary, so the agent runs without prompting. * - `'interactive'` — same policy, but `ask`-style prompts emit an * approval-requested event so a client can approve and re-run. */ permissions?: 'headless' | 'interactive' /** Custom permission handler; overrides {@link permissions}/{@link permissionMode}. */ onPermissionRequest?: PermissionHandler /** Message used for `RUN_ERROR` when the harness refuses a request. */ refusalMessage?: string /** Emit ACP `plan` updates as a CUSTOM event under this name (off by default). */ planEventName?: string /** * After the run, emit the `git diff` of the working dir as a `file.changed` * CUSTOM event. Requires a git repo at `cwd`. Off by default. */ emitDiff?: boolean /** * Harness-specific JSON-RPC notifications (vendor `_x/...` extensions). Must * return without throwing — unknown extensions must not tear down the session. */ onExtNotification?: (method: string, params: Record) => void /** * Convert chat history into the harness prompt + resume inputs. Defaults to * {@link buildAcpPrompt} (trailing user message + flattened transcript). */ buildPrompt?: ( messages: Array, sessionId: string | undefined, ) => BuiltAcpPrompt } /** Per-call provider options, passed via `modelOptions` on `chat()`. */ export interface AcpCompatibleProviderOptions { /** * Resume an existing harness session. The adapter emits the session id of * every run via a CUSTOM `.session-id` event; thread it back here to * continue (only the trailing user message is sent). */ sessionId?: string /** Per-call override of the harness working directory. */ cwd?: string /** * `'api-key'` (default) uses {@link authMethodId}. * `'host'` skips ACP authenticate. * Not inferred from the sandbox. */ authMode?: 'host' | 'api-key' /** Per-call override of the ACP auth method. Ignored when authMode is `'host'`. */ authMethodId?: string /** Per-call override of the ACP permission policy. */ permissionMode?: AcpPermissionMode } /** Per-call options the adapter sees: the base ACP options + the harness's own. */ type ResolvedOptions> = AcpCompatibleProviderOptions & TModelOptions function q(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'` } async function disposeTransport(transport: AcpSessionTransport): Promise { if (transport.kind === 'stdio') { await transport.process.kill() return } await transport.dispose() } /** * A generic ACP harness adapter built from {@link AcpCompatibleConfig}. Runs the * configured coding-agent CLI inside the sandbox provided by `withSandbox(...)` * and translates its ACP session into AG-UI `StreamChunk`s. */ export class AcpCompatibleTextAdapter< TModel extends string, TModelOptions extends Record = AcpCompatibleProviderOptions, > extends BaseTextAdapter< TModel, ResolvedOptions, ReadonlyArray & readonly ['text'], DefaultMessageMetadataByModality, ReadonlyArray, unknown, never > { override readonly name: string override readonly requires = [SandboxCapability] as const private readonly harness: AcpCompatibleConfig< ReadonlyArray, TModelOptions > /** `toolChoice` warnings that this instance already logged. */ private readonly toolChoiceWarnings = new Set() constructor( config: AcpCompatibleConfig, TModelOptions>, model: TModel, ) { super({}, model) if (config.command === undefined && config.openTransport === undefined) { throw new Error( `acpCompatible("${config.name}") needs either a "command" or an "openTransport".`, ) } this.harness = config this.name = config.name } supportsCombinedToolsAndSchema(): boolean { return true } combinedStructuredOutputSource(): 'event' { return 'event' } private sandboxFrom( options: TextOptions>, ): SandboxHandle { const ctx = options.capabilities if (!ctx) { throw new Error( `Adapter "${this.name}" requires a sandbox. Add withSandbox(defineSandbox({ ... })) to chat() middleware.`, ) } return getSandbox(ctx) } /** * The chat() tools to bridge for `toolChoice`: none for `'none'`, and only * the named tool for `{ type: 'tool', name }`. ACP has no field to turn off * the built-in agent tools or to force a tool call, so every value except * `'auto'` logs a warning once. */ private toolsForChoice(options: TextOptions>) { const { toolChoice, tools = [] } = options if (toolChoice === undefined || toolChoice === 'auto') return tools const warning = toolChoice === 'required' ? `${this.name}: toolChoice 'required' is not supported. The agent decides which tools to call.` : `${this.name}: toolChoice limits only the chat() tools. The built-in agent tools stay on.` if (!this.toolChoiceWarnings.has(warning)) { this.toolChoiceWarnings.add(warning) options.logger.warn(warning) } if (toolChoice === 'none') return [] if (toolChoice === 'required') return tools return tools.filter((tool) => tool.name === toolChoice.name) } private buildPrompt( messages: Array, sessionId: string | undefined, ): BuiltAcpPrompt { return this.harness.buildPrompt ? this.harness.buildPrompt(messages, sessionId) : buildAcpPrompt(messages, sessionId, this.name) } private applySystemPrompts( systemPrompts: Array, prompt: string, ): string { if (systemPrompts.length === 0) return prompt return `${systemPrompts.join('\n\n')}\n\n${prompt}` } private makePermissionHandler(input: { mode: AcpPermissionMode bridgedToolNames: ReadonlySet approvals: ReadonlyMap | undefined approvalRequests: Array threadId: string runId: string }): PermissionHandler { if (this.harness.onPermissionRequest) return this.harness.onPermissionRequest if (this.harness.permissions === 'interactive') { return (request) => { const result = resolveInteractivePermission( request, input.mode, input.bridgedToolNames, input.approvals, this.name, ) if (result.approvalId !== undefined) { input.approvalRequests.push( buildApprovalRequestedEvent({ approvalId: result.approvalId, title: result.title ?? request.toolCall.title ?? request.toolCall.toolCallId, threadId: input.threadId, runId: input.runId, detail: { provider: this.name }, }), ) } return result.outcome } } return (request) => resolvePermission(request, input.mode, input.bridgedToolNames) } async *chatStream( options: TextOptions>, ): AsyncIterable { const { logger } = options let handle: AcpSessionHandle | undefined let bridge: HostToolBridge | undefined let transport: AcpSessionTransport | undefined const externalSignal = options.abortController?.signal ?? options.request?.signal ?? undefined let onAbort: (() => void) | undefined try { const tools = this.toolsForChoice(options) const sandbox = this.sandboxFrom(options) const modelOptions = options.modelOptions const cwd = modelOptions?.cwd ?? this.harness.cwd ?? DEFAULT_WORKDIR const harnessCwd = resolveHarnessCwd(sandbox, cwd) // This adapter does not journal yet, so a generated id is still fine. // Routed through the helper anyway so that whenever it gains journaling // it inherits the caller-supplied-runId requirement instead of // re-deriving it (see `packages/ai-sandbox/src/durability.ts`). const runId = resolveDurableRunId(options.runId, { durable: false, adapter: 'acp', fallback: () => this.generateId(), }) const threadId = options.threadId // Durability wired onto a path that cannot deliver it. Two outcomes, split // by whether a first attempt has already run. // // ATTACH is fatal. `sandboxRunDriver`'s `drive()` sets `attach: true` only // when a previous host was already streaming this run, so continuing past // here reaches `startAcpSession` + `session.prompt(...)` and re-runs the // agent from scratch against the workspace that attempt already mutated, // appending its whole output to a log that still holds the first // attempt's. This adapter has no journal to tail, no // `awaitAttachableJournal` to refuse the attach up front, and no // `alignedIfAttaching` to suppress the already-delivered prefix — so there // is nothing between here and that corruption except this throw. // // A FRESH durable run only fails to be recoverable LATER, which an app may // knowingly accept (it can wire `withSandbox({ runs, durability })` once at // the middleware level and still route some runs through this adapter). So // that is a warn, not a throw: audible, not fatal. Once per run, not per // chunk — a per-chunk warning would be worse than none. Mirrors // `ai-grok-build`'s `chatStreamAcp`. const durability = options.capabilities ? getSandboxDurability(options.capabilities, { optional: true }) : undefined if (durability !== undefined) { if (durability.attach) { throw new DurableAttachNotSupportedError( 'acp', 'this adapter drives the harness over a bidirectional ACP ' + 'connection and does not journal', ) } logger.warn( 'acp: sandbox durability is wired but this adapter never journals — ' + 'this run will not be recoverable on reconnect. Use a journaling ' + 'harness adapter for runs that must survive a host restart, or drop ' + 'durability if these runs are not meant to.', { runId, adapter: 'acp' }, ) } const channel = createBridgeEventChannel({ model: this.model, threadId, runId, }) const sessionId = modelOptions?.sessionId const { prompt: resumePrompt } = this.buildPrompt( options.messages, sessionId, ) // Bridge chat()-provided tools into the agent over MCP (ACP http server). const bridgedToolNames = new Set(tools.map((tool) => tool.name)) if (tools.length > 0) { const provisioner = (options.capabilities ? getToolBridgeProvisioner(options.capabilities, { optional: true }) : undefined) ?? nodeHttpBridgeProvisioner bridge = await provisioner.provision(tools, { provider: sandbox.provider, threadId, context: options.context, emitCustomEvent: channel.emitCustomEvent, ...(externalSignal ? { signal: externalSignal } : {}), }) } // Project workspace skills declared via withSandbox. MCP skills ride ACP's // native `mcpServers` (below); gitSkills are linked into `skillsDir`. let workspaceServers: Array = [] const projection = options.capabilities ? getWorkspaceProjection(options.capabilities, { optional: true }) : undefined if (projection !== undefined) { await projectAcpWorkspace(sandbox, projection, { ...(this.harness.skillsDir !== undefined && { skillsDir: this.harness.skillsDir, }), harnessName: this.name, }) workspaceServers = workspaceMcpServers(projection) } const ctx: AcpHarnessContext> = { sandbox, model: this.model, cwd, harnessCwd, env: this.harness.env, modelOptions, signal: externalSignal, } transport = this.harness.openTransport ? await this.harness.openTransport(ctx) : await this.openStdioTransport(ctx) const mode = modelOptions?.permissionMode ?? this.harness.permissionMode ?? 'bypassPermissions' const authMode = modelOptions?.authMode ?? this.harness.authMode ?? 'api-key' const authMethodId = authMode === 'host' ? undefined : (modelOptions?.authMethodId ?? this.harness.authMethodId) const approvalRequests: Array = [] const permissionHandler = this.makePermissionHandler({ mode, bridgedToolNames, approvals: options.approvals, approvalRequests, threadId, runId, }) const queue = new AsyncQueue() logger.request( `activity=chat provider=${this.name} model=${this.model} sandbox=${sandbox.provider} messages=${options.messages.length} resume=${sessionId ?? 'none'}`, { provider: this.name, model: this.model }, ) // The host tool-bridge (chat() tools) + workspace MCP skills, both over // ACP's native MCP channel. const mcpServers: Array = [ ...(bridge !== undefined ? [ { name: bridge.name, url: bridge.url, headers: [ { name: 'Authorization', value: `Bearer ${bridge.token}` }, ], }, ] : []), ...workspaceServers, ] const onAcpUpdate = (update: AcpSessionUpdate) => queue.push({ kind: 'update', update }) handle = await startAcpSession({ transport, cwd: harnessCwd, ...(authMethodId !== undefined && { authMethodId }), ...(sessionId !== undefined && { resumeSessionId: sessionId }), ...(mcpServers.length > 0 && { mcpServers }), onUpdate: onAcpUpdate, ...(this.harness.onExtNotification && { onExtNotification: this.harness.onExtNotification, }), onPermissionRequest: permissionHandler, }) const session = handle if (externalSignal !== undefined) { onAbort = () => void session.cancel().catch(() => undefined) if (externalSignal.aborted) onAbort() else externalSignal.addEventListener('abort', onAbort, { once: true }) } queue.push({ kind: 'session', sessionId: session.sessionId }) const systemPrompts = normalizeSystemPrompts(options.systemPrompts) .map((p) => p.content) .filter((c) => c.trim() !== '') let promptText = this.applySystemPrompts( systemPrompts, session.resumed || sessionId === undefined ? resumePrompt : this.buildPrompt(options.messages, undefined).prompt, ) if (options.outputSchema) { promptText = appendOutputSchemaInstruction( promptText, options.outputSchema, ) } session .prompt(promptText) .then(({ stopReason, usage }) => { queue.push({ kind: 'done', stopReason, ...(usage !== undefined && { usage }), }) queue.end() }) .catch((error: unknown) => queue.fail(error)) const wantsStructured = options.outputSchema !== undefined let lastAssistantText = '' let lastTextMessageId: string | undefined let heldFinished: AdapterYieldChunk | undefined for await (const chunk of mergeChunkStreams( translateAcpStream(queue, { model: this.model, runId, threadId, ...(options.parentRunId !== undefined && { parentRunId: options.parentRunId, }), genId: () => this.generateId(), bridgedToolNames, labels: { sessionIdEvent: `${this.name}.session-id`, // Surface non-text agent content (image/audio/resource) instead of // dropping it — emitted as a CUSTOM `.message-content` event. contentEvent: `${this.name}.message-content`, ...(this.harness.planEventName !== undefined && { planEvent: this.harness.planEventName, }), ...(this.harness.refusalMessage !== undefined && { refusalMessage: this.harness.refusalMessage, }), }, onAcpEvent: (event) => logger.provider(`provider=${this.name} kind=${event.kind}`, { chunk: event, }), }), channel.stream, )) { if (wantsStructured && chunk.type === EventType.RUN_FINISHED) { heldFinished = chunk continue } if (wantsStructured) { if (chunk.type === EventType.TEXT_MESSAGE_START) { lastAssistantText = '' if (typeof chunk.messageId === 'string' && chunk.messageId !== '') { lastTextMessageId = chunk.messageId } } else if ( chunk.type === EventType.TEXT_MESSAGE_CONTENT && typeof chunk.delta === 'string' ) { lastAssistantText += chunk.delta } } yield chunk } if (options.outputSchema) { yield* this.emitParsedStructuredOutput( lastAssistantText, threadId, runId, lastTextMessageId, ) } if (heldFinished) yield heldFinished // Surface any pending approval requests (interactive ask-policy actions // awaiting a client decision); the client approves and re-runs to continue. for (const event of approvalRequests) yield event if (this.harness.emitDiff) { yield* this.emitDiffChunks(sandbox, cwd, threadId, runId) } } catch (error: unknown) { const err = error as Error & { code?: string } const rawEvent = toRunErrorRawEvent(error) logger.errors(`${this.name}.chatStream fatal`, { error, source: `${this.name}.chatStream`, }) yield { type: EventType.RUN_ERROR, model: options.model, timestamp: Date.now(), message: err.message || 'Unknown error occurred', ...(err.code !== undefined && { code: err.code }), ...(rawEvent !== undefined && { rawEvent }), error: { message: err.message || 'Unknown error occurred', ...(err.code !== undefined && { code: err.code }), }, } } finally { if (externalSignal !== undefined && onAbort !== undefined) { externalSignal.removeEventListener('abort', onAbort) } // startAcpSession owns transport teardown once a handle exists (and tears // it down itself on a failed init). Only dispose here if we opened a // transport but never reached a session. if (handle !== undefined) await handle.dispose() else if (transport !== undefined) await disposeTransport(transport) await bridge?.close() } } private async openStdioTransport( ctx: AcpHarnessContext>, ): Promise { const build = this.harness.command if (build === undefined) { // Unreachable — the constructor requires `command` or `openTransport`, // and this path only runs when `openTransport` is absent. throw new Error( `acpCompatible("${this.name}") has no "command" to launch over stdio.`, ) } const command = build(ctx) const proc = await ctx.sandbox.process.spawn(command, { cwd: ctx.cwd, ...(this.harness.env ? { env: this.harness.env } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}), }) return { kind: 'stdio', process: proc } } private async *emitDiffChunks( sandbox: SandboxHandle, cwd: string, threadId: string, runId: string, ): AsyncIterable { try { const diff = await sandbox.process.exec(`git -C ${q(cwd)} diff`, { cwd }) if (diff.exitCode === 0 && diff.stdout.trim() !== '') { yield { type: EventType.CUSTOM, name: 'file.changed', value: { path: '.', diff: diff.stdout }, timestamp: Date.now(), threadId, runId, } } } catch { // ignore — diff is best-effort } } private *emitParsedStructuredOutput( raw: string, threadId: string, runId: string, messageId = this.generateId(), ): Generator { try { const object = parseJsonFromAssistantText(raw) yield structuredOutputStartChunk({ messageId, model: this.model, threadId, runId, }) yield structuredOutputCompleteChunk({ messageId, model: this.model, threadId, runId, object, raw, }) } catch (error: unknown) { const parserMessage = error instanceof Error ? error.message : 'Failed to parse structured output' const preview = raw.trim().slice(0, 200) const message = preview === '' ? parserMessage : `${parserMessage} Content: ${preview}` yield { type: EventType.RUN_ERROR, model: this.model, timestamp: Date.now(), message, code: 'structured-output-parse-failed', error: { message, code: 'structured-output-parse-failed' }, } } } structuredOutput( _options: StructuredOutputOptions>, ): Promise> { return Promise.reject( new Error( 'This harness honors outputSchema on chat() in the same turn. ' + 'Pass outputSchema to chat(), or use a model adapter for a one-shot extract.', ), ) } } /** * Configure an ACP-compatible harness once, then select a model per call. * * Mirrors `openaiCompatible`: it lets you plug ANY Agent Client Protocol agent * into a TanStack AI sandbox without a dedicated adapter package. * * @example * ```ts * import { acpCompatible } from '@tanstack/ai-acp' * import { chat } from '@tanstack/ai' * import { defineSandbox, withSandbox } from '@tanstack/ai-sandbox' * * const pi = acpCompatible({ * name: 'pi', * // declaring `models` makes pi('…') type-safe; omit to accept any string * models: ['pi-fast', 'pi-pro'], * // declare per-call options; merged with the base ACP options and exposed * // on ctx.modelOptions inside `command` / `openTransport` * modelOptions: {} as { reasoningEffort?: 'low' | 'high' }, * command: ({ model, harnessCwd, modelOptions }) => * `pi --acp -m ${model} --cwd ${harnessCwd}` + * (modelOptions?.reasoningEffort ? ` --effort ${modelOptions.reasoningEffort}` : ''), * authMethodId: 'pi-api-key', * }) * * chat({ * adapter: pi('pi-pro'), * modelOptions: { reasoningEffort: 'high' }, // typed * messages, * middleware: [withSandbox(defineSandbox({ /* provider, install pi *\/ }))], * }) * ``` */ export function acpCompatible< const TModels extends ReadonlyArray = ReadonlyArray, TModelOptions extends Record = AcpCompatibleProviderOptions, >(config: AcpCompatibleConfig) { return >( model: TModel, overrides?: Partial>, ): AcpCompatibleTextAdapter => new AcpCompatibleTextAdapter( overrides ? { ...config, ...overrides } : config, model, ) } /** * One-shot helper: build a single-model ACP-compatible harness adapter inline. * * @example * ```ts * chat({ * adapter: acpCompatibleText('pi-fast', { * name: 'pi', * command: ({ model }) => `pi --acp -m ${model}`, * }), * messages, * middleware: [withSandbox(defineSandbox({ ... }))], * }) * ``` */ export function acpCompatibleText< TModel extends string, TModelOptions extends Record = AcpCompatibleProviderOptions, >( model: TModel, config: AcpCompatibleConfig, TModelOptions>, ): AcpCompatibleTextAdapter { return new AcpCompatibleTextAdapter(config, model) }