@tanstack/ai-acp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +428 -0
- package/dist/esm/adapters/compatible.d.ts +206 -0
- package/dist/esm/adapters/compatible.js +313 -0
- package/dist/esm/adapters/compatible.js.map +1 -0
- package/dist/esm/adapters/projection.d.ts +29 -0
- package/dist/esm/adapters/projection.js +83 -0
- package/dist/esm/adapters/projection.js.map +1 -0
- package/dist/esm/index.d.ts +21 -0
- package/dist/esm/index.js +35 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/messages/prompt.d.ts +19 -0
- package/dist/esm/messages/prompt.js +37 -0
- package/dist/esm/messages/prompt.js.map +1 -0
- package/dist/esm/permissions.d.ts +8 -0
- package/dist/esm/permissions.js +44 -0
- package/dist/esm/permissions.js.map +1 -0
- package/dist/esm/session/acp-client.d.ts +37 -0
- package/dist/esm/session/acp-client.js +140 -0
- package/dist/esm/session/acp-client.js.map +1 -0
- package/dist/esm/session/sandbox-server.d.ts +46 -0
- package/dist/esm/session/sandbox-server.js +114 -0
- package/dist/esm/session/sandbox-server.js.map +1 -0
- package/dist/esm/stream/queue.d.ts +15 -0
- package/dist/esm/stream/queue.js +54 -0
- package/dist/esm/stream/queue.js.map +1 -0
- package/dist/esm/stream/translate.d.ts +38 -0
- package/dist/esm/stream/translate.js +320 -0
- package/dist/esm/stream/translate.js.map +1 -0
- package/dist/esm/transport/resolve.d.ts +7 -0
- package/dist/esm/transport/resolve.js +34 -0
- package/dist/esm/transport/resolve.js.map +1 -0
- package/dist/esm/transport/stdio.d.ts +3 -0
- package/dist/esm/transport/stdio.js +51 -0
- package/dist/esm/transport/stdio.js.map +1 -0
- package/dist/esm/transport/types.d.ts +29 -0
- package/dist/esm/transport/websocket.d.ts +17 -0
- package/dist/esm/transport/websocket.js +135 -0
- package/dist/esm/transport/websocket.js.map +1 -0
- package/dist/esm/types/acp-types.d.ts +75 -0
- package/package.json +61 -0
- package/src/adapters/compatible.ts +675 -0
- package/src/adapters/projection.ts +164 -0
- package/src/index.ts +85 -0
- package/src/messages/prompt.ts +71 -0
- package/src/permissions.ts +71 -0
- package/src/session/acp-client.ts +238 -0
- package/src/session/sandbox-server.ts +195 -0
- package/src/stream/queue.ts +61 -0
- package/src/stream/translate.ts +412 -0
- package/src/transport/resolve.ts +46 -0
- package/src/transport/stdio.ts +63 -0
- package/src/transport/types.ts +33 -0
- package/src/transport/websocket.ts +192 -0
- package/src/types/acp-types.ts +79 -0
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
import { EventType, normalizeSystemPrompts } from '@tanstack/ai'
|
|
2
|
+
import { toRunErrorRawEvent } from '@tanstack/ai/adapter-internals'
|
|
3
|
+
import { BaseTextAdapter } from '@tanstack/ai/adapters'
|
|
4
|
+
import {
|
|
5
|
+
SandboxCapability,
|
|
6
|
+
buildApprovalRequestedEvent,
|
|
7
|
+
createBridgeEventChannel,
|
|
8
|
+
getSandbox,
|
|
9
|
+
getToolBridgeProvisioner,
|
|
10
|
+
getWorkspaceProjection,
|
|
11
|
+
mergeChunkStreams,
|
|
12
|
+
nodeHttpBridgeProvisioner,
|
|
13
|
+
resolveHarnessCwd,
|
|
14
|
+
} from '@tanstack/ai-sandbox'
|
|
15
|
+
import { AsyncQueue } from '../stream/queue'
|
|
16
|
+
import { startAcpSession } from '../session/acp-client'
|
|
17
|
+
import { translateAcpStream } from '../stream/translate'
|
|
18
|
+
import { resolveInteractivePermission, resolvePermission } from '../permissions'
|
|
19
|
+
import { buildAcpPrompt } from '../messages/prompt'
|
|
20
|
+
import { projectAcpWorkspace, workspaceMcpServers } from './projection'
|
|
21
|
+
import type { AcpMcpServer } from './projection'
|
|
22
|
+
import type { HostToolBridge, SandboxHandle } from '@tanstack/ai-sandbox'
|
|
23
|
+
import type {
|
|
24
|
+
StructuredOutputOptions,
|
|
25
|
+
StructuredOutputResult,
|
|
26
|
+
} from '@tanstack/ai/adapters'
|
|
27
|
+
import type {
|
|
28
|
+
DefaultMessageMetadataByModality,
|
|
29
|
+
Modality,
|
|
30
|
+
ModelMessage,
|
|
31
|
+
StreamChunk,
|
|
32
|
+
TextOptions,
|
|
33
|
+
} from '@tanstack/ai'
|
|
34
|
+
import type { AcpSessionHandle } from '../session/acp-client'
|
|
35
|
+
import type { AcpStreamEvent } from '../stream/translate'
|
|
36
|
+
import type { AcpSessionTransport } from '../transport/types'
|
|
37
|
+
import type {
|
|
38
|
+
AcpPermissionMode,
|
|
39
|
+
AcpSessionUpdate,
|
|
40
|
+
PermissionHandler,
|
|
41
|
+
} from '../types/acp-types'
|
|
42
|
+
import type { BuiltAcpPrompt } from '../messages/prompt'
|
|
43
|
+
|
|
44
|
+
const DEFAULT_WORKDIR = '/workspace'
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Everything a harness needs to know to launch its ACP server inside the
|
|
48
|
+
* sandbox. Passed to {@link AcpCompatibleConfig.command} /
|
|
49
|
+
* {@link AcpCompatibleConfig.openTransport}.
|
|
50
|
+
*/
|
|
51
|
+
export interface AcpHarnessContext<
|
|
52
|
+
TModelOptions extends Record<string, any> = AcpCompatibleProviderOptions,
|
|
53
|
+
> {
|
|
54
|
+
/** The sandbox the harness runs in (from `withSandbox(...)` middleware). */
|
|
55
|
+
sandbox: SandboxHandle
|
|
56
|
+
/** The selected model id. */
|
|
57
|
+
model: string
|
|
58
|
+
/** Virtual cwd for `sandbox.process.spawn` (the provider maps `/workspace`). */
|
|
59
|
+
cwd: string
|
|
60
|
+
/** Literal cwd for the harness's own `--cwd` flag / ACP `newSession`. */
|
|
61
|
+
harnessCwd: string
|
|
62
|
+
/** Extra env vars configured for the harness process. */
|
|
63
|
+
env: Record<string, string> | undefined
|
|
64
|
+
/**
|
|
65
|
+
* Per-call options from `chat({ modelOptions })` — the base ACP options plus
|
|
66
|
+
* whatever you declared via {@link AcpCompatibleConfig.modelOptions}. Read
|
|
67
|
+
* these to turn options into CLI flags / transport choices.
|
|
68
|
+
*/
|
|
69
|
+
modelOptions: TModelOptions | undefined
|
|
70
|
+
/** Abort signal for the run, when one was provided. */
|
|
71
|
+
signal: AbortSignal | undefined
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Union of selectable model names from a `models` tuple (any string if omitted). */
|
|
75
|
+
export type AcpModelNameOf<TModels extends ReadonlyArray<string>> =
|
|
76
|
+
TModels[number]
|
|
77
|
+
|
|
78
|
+
export interface AcpCompatibleConfig<
|
|
79
|
+
TModels extends ReadonlyArray<string> = ReadonlyArray<string>,
|
|
80
|
+
TModelOptions extends Record<string, any> = AcpCompatibleProviderOptions,
|
|
81
|
+
> {
|
|
82
|
+
/**
|
|
83
|
+
* Harness name. Used as the provider label, the log prefix, and the CUSTOM
|
|
84
|
+
* session-id event name (`<name>.session-id`).
|
|
85
|
+
*/
|
|
86
|
+
name: string
|
|
87
|
+
/**
|
|
88
|
+
* The models this harness accepts. Declaring them makes the returned factory
|
|
89
|
+
* type-safe — `harness('known-model')` is checked, unknown ids are rejected.
|
|
90
|
+
* Omit to accept any string.
|
|
91
|
+
*/
|
|
92
|
+
models?: TModels
|
|
93
|
+
/**
|
|
94
|
+
* Type-only brand for the per-call options accepted via `chat({ modelOptions })`.
|
|
95
|
+
* Declare your harness's options here with `{} as { ... }` (the value is unused
|
|
96
|
+
* at runtime); they are merged with the base {@link AcpCompatibleProviderOptions}
|
|
97
|
+
* and exposed on {@link AcpHarnessContext.modelOptions} so `command` /
|
|
98
|
+
* `openTransport` can turn them into CLI flags.
|
|
99
|
+
*
|
|
100
|
+
* @example modelOptions: {} as { reasoningEffort?: 'low' | 'high' }
|
|
101
|
+
*/
|
|
102
|
+
modelOptions?: TModelOptions
|
|
103
|
+
/**
|
|
104
|
+
* Build the shell command that launches the harness's ACP server over
|
|
105
|
+
* **stdio** inside the sandbox (e.g. `` `pi --acp -m ${model}` ``). Required
|
|
106
|
+
* unless {@link openTransport} is provided.
|
|
107
|
+
*/
|
|
108
|
+
command?: (
|
|
109
|
+
ctx: AcpHarnessContext<AcpCompatibleProviderOptions & TModelOptions>,
|
|
110
|
+
) => string
|
|
111
|
+
/**
|
|
112
|
+
* Full transport escape hatch — open any {@link AcpSessionTransport} yourself
|
|
113
|
+
* (e.g. boot a `serve` process and connect over WebSocket, as Grok Build
|
|
114
|
+
* does). Overrides {@link command}. Put ALL teardown in the returned
|
|
115
|
+
* transport's `dispose` (stream) / process (stdio); it is disposed when the
|
|
116
|
+
* session ends.
|
|
117
|
+
*/
|
|
118
|
+
openTransport?: (
|
|
119
|
+
ctx: AcpHarnessContext<AcpCompatibleProviderOptions & TModelOptions>,
|
|
120
|
+
) => Promise<AcpSessionTransport> | AcpSessionTransport
|
|
121
|
+
/** Working directory inside the sandbox. Defaults to `/workspace`. */
|
|
122
|
+
cwd?: string
|
|
123
|
+
/**
|
|
124
|
+
* The harness's skills directory, relative to the workspace root (e.g.
|
|
125
|
+
* `'.pi/skills'`) — its native convention for where it auto-discovers skills,
|
|
126
|
+
* the way Claude Code uses `.claude/skills`. When set, `withSandbox` workspace
|
|
127
|
+
* `gitSkill`s are linked here. MCP skills don't need this: they're passed to
|
|
128
|
+
* the agent over ACP natively. Omit and `gitSkill`s are left unlinked (warned).
|
|
129
|
+
*/
|
|
130
|
+
skillsDir?: string
|
|
131
|
+
/** Extra environment variables for the harness process. */
|
|
132
|
+
env?: Record<string, string>
|
|
133
|
+
/**
|
|
134
|
+
* ACP auth method to select before the session starts, when the harness
|
|
135
|
+
* advertises one (e.g. `'pi-api-key'`). Overridable per call via
|
|
136
|
+
* `modelOptions.authMethodId`.
|
|
137
|
+
*/
|
|
138
|
+
authMethodId?: string
|
|
139
|
+
/** ACP permission policy. Defaults to `'bypassPermissions'`. */
|
|
140
|
+
permissionMode?: AcpPermissionMode
|
|
141
|
+
/**
|
|
142
|
+
* Permission strategy:
|
|
143
|
+
* - `'headless'` (default) — auto-resolve via {@link permissionMode}; the
|
|
144
|
+
* sandbox is the boundary, so the agent runs without prompting.
|
|
145
|
+
* - `'interactive'` — same policy, but `ask`-style prompts emit an
|
|
146
|
+
* approval-requested event so a client can approve and re-run.
|
|
147
|
+
*/
|
|
148
|
+
permissions?: 'headless' | 'interactive'
|
|
149
|
+
/** Custom permission handler; overrides {@link permissions}/{@link permissionMode}. */
|
|
150
|
+
onPermissionRequest?: PermissionHandler
|
|
151
|
+
/** Message used for `RUN_ERROR` when the harness refuses a request. */
|
|
152
|
+
refusalMessage?: string
|
|
153
|
+
/** Emit ACP `plan` updates as a CUSTOM event under this name (off by default). */
|
|
154
|
+
planEventName?: string
|
|
155
|
+
/**
|
|
156
|
+
* After the run, emit the `git diff` of the working dir as a `file.changed`
|
|
157
|
+
* CUSTOM event. Requires a git repo at `cwd`. Off by default.
|
|
158
|
+
*/
|
|
159
|
+
emitDiff?: boolean
|
|
160
|
+
/**
|
|
161
|
+
* Harness-specific JSON-RPC notifications (vendor `_x/...` extensions). Must
|
|
162
|
+
* return without throwing — unknown extensions must not tear down the session.
|
|
163
|
+
*/
|
|
164
|
+
onExtNotification?: (method: string, params: Record<string, unknown>) => void
|
|
165
|
+
/**
|
|
166
|
+
* Convert chat history into the harness prompt + resume inputs. Defaults to
|
|
167
|
+
* {@link buildAcpPrompt} (trailing user message + flattened transcript).
|
|
168
|
+
*/
|
|
169
|
+
buildPrompt?: (
|
|
170
|
+
messages: Array<ModelMessage>,
|
|
171
|
+
sessionId: string | undefined,
|
|
172
|
+
) => BuiltAcpPrompt
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Per-call provider options, passed via `modelOptions` on `chat()`. */
|
|
176
|
+
export interface AcpCompatibleProviderOptions {
|
|
177
|
+
/**
|
|
178
|
+
* Resume an existing harness session. The adapter emits the session id of
|
|
179
|
+
* every run via a CUSTOM `<name>.session-id` event; thread it back here to
|
|
180
|
+
* continue (only the trailing user message is sent).
|
|
181
|
+
*/
|
|
182
|
+
sessionId?: string
|
|
183
|
+
/** Per-call override of the harness working directory. */
|
|
184
|
+
cwd?: string
|
|
185
|
+
/** Per-call override of the ACP auth method. */
|
|
186
|
+
authMethodId?: string
|
|
187
|
+
/** Per-call override of the ACP permission policy. */
|
|
188
|
+
permissionMode?: AcpPermissionMode
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Per-call options the adapter sees: the base ACP options + the harness's own. */
|
|
192
|
+
type ResolvedOptions<TModelOptions extends Record<string, any>> =
|
|
193
|
+
AcpCompatibleProviderOptions & TModelOptions
|
|
194
|
+
|
|
195
|
+
function q(value: string): string {
|
|
196
|
+
return `'${value.replace(/'/g, `'\\''`)}'`
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function disposeTransport(transport: AcpSessionTransport): Promise<void> {
|
|
200
|
+
if (transport.kind === 'stdio') {
|
|
201
|
+
await transport.process.kill()
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
await transport.dispose()
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* A generic ACP harness adapter built from {@link AcpCompatibleConfig}. Runs the
|
|
209
|
+
* configured coding-agent CLI inside the sandbox provided by `withSandbox(...)`
|
|
210
|
+
* and translates its ACP session into AG-UI `StreamChunk`s.
|
|
211
|
+
*/
|
|
212
|
+
export class AcpCompatibleTextAdapter<
|
|
213
|
+
TModel extends string,
|
|
214
|
+
TModelOptions extends Record<string, any> = AcpCompatibleProviderOptions,
|
|
215
|
+
> extends BaseTextAdapter<
|
|
216
|
+
TModel,
|
|
217
|
+
ResolvedOptions<TModelOptions>,
|
|
218
|
+
ReadonlyArray<Modality> & readonly ['text'],
|
|
219
|
+
DefaultMessageMetadataByModality,
|
|
220
|
+
ReadonlyArray<string>,
|
|
221
|
+
unknown,
|
|
222
|
+
never
|
|
223
|
+
> {
|
|
224
|
+
override readonly name: string
|
|
225
|
+
|
|
226
|
+
override readonly requires = [SandboxCapability] as const
|
|
227
|
+
|
|
228
|
+
private readonly harness: AcpCompatibleConfig<
|
|
229
|
+
ReadonlyArray<string>,
|
|
230
|
+
TModelOptions
|
|
231
|
+
>
|
|
232
|
+
|
|
233
|
+
constructor(
|
|
234
|
+
config: AcpCompatibleConfig<ReadonlyArray<string>, TModelOptions>,
|
|
235
|
+
model: TModel,
|
|
236
|
+
) {
|
|
237
|
+
super({}, model)
|
|
238
|
+
if (config.command === undefined && config.openTransport === undefined) {
|
|
239
|
+
throw new Error(
|
|
240
|
+
`acpCompatible("${config.name}") needs either a "command" or an "openTransport".`,
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
this.harness = config
|
|
244
|
+
this.name = config.name
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private sandboxFrom(
|
|
248
|
+
options: TextOptions<ResolvedOptions<TModelOptions>>,
|
|
249
|
+
): SandboxHandle {
|
|
250
|
+
const ctx = options.capabilities
|
|
251
|
+
if (!ctx) {
|
|
252
|
+
throw new Error(
|
|
253
|
+
`Adapter "${this.name}" requires a sandbox. Add withSandbox(defineSandbox({ ... })) to chat() middleware.`,
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
return getSandbox(ctx)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private buildPrompt(
|
|
260
|
+
messages: Array<ModelMessage>,
|
|
261
|
+
sessionId: string | undefined,
|
|
262
|
+
): BuiltAcpPrompt {
|
|
263
|
+
return this.harness.buildPrompt
|
|
264
|
+
? this.harness.buildPrompt(messages, sessionId)
|
|
265
|
+
: buildAcpPrompt(messages, sessionId, this.name)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private applySystemPrompts(
|
|
269
|
+
systemPrompts: Array<string>,
|
|
270
|
+
prompt: string,
|
|
271
|
+
): string {
|
|
272
|
+
if (systemPrompts.length === 0) return prompt
|
|
273
|
+
return `${systemPrompts.join('\n\n')}\n\n${prompt}`
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private makePermissionHandler(input: {
|
|
277
|
+
mode: AcpPermissionMode
|
|
278
|
+
bridgedToolNames: ReadonlySet<string>
|
|
279
|
+
approvals: ReadonlyMap<string, boolean> | undefined
|
|
280
|
+
approvalRequests: Array<StreamChunk>
|
|
281
|
+
threadId: string
|
|
282
|
+
runId: string
|
|
283
|
+
}): PermissionHandler {
|
|
284
|
+
if (this.harness.onPermissionRequest)
|
|
285
|
+
return this.harness.onPermissionRequest
|
|
286
|
+
|
|
287
|
+
if (this.harness.permissions === 'interactive') {
|
|
288
|
+
return (request) => {
|
|
289
|
+
const result = resolveInteractivePermission(
|
|
290
|
+
request,
|
|
291
|
+
input.mode,
|
|
292
|
+
input.bridgedToolNames,
|
|
293
|
+
input.approvals,
|
|
294
|
+
this.name,
|
|
295
|
+
)
|
|
296
|
+
if (result.approvalId !== undefined) {
|
|
297
|
+
input.approvalRequests.push(
|
|
298
|
+
buildApprovalRequestedEvent({
|
|
299
|
+
approvalId: result.approvalId,
|
|
300
|
+
title:
|
|
301
|
+
result.title ??
|
|
302
|
+
request.toolCall.title ??
|
|
303
|
+
request.toolCall.toolCallId,
|
|
304
|
+
threadId: input.threadId,
|
|
305
|
+
runId: input.runId,
|
|
306
|
+
detail: { provider: this.name },
|
|
307
|
+
}),
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
return result.outcome
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return (request) =>
|
|
315
|
+
resolvePermission(request, input.mode, input.bridgedToolNames)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async *chatStream(
|
|
319
|
+
options: TextOptions<ResolvedOptions<TModelOptions>>,
|
|
320
|
+
): AsyncIterable<StreamChunk> {
|
|
321
|
+
const { logger } = options
|
|
322
|
+
let handle: AcpSessionHandle | undefined
|
|
323
|
+
let bridge: HostToolBridge | undefined
|
|
324
|
+
let transport: AcpSessionTransport | undefined
|
|
325
|
+
const externalSignal =
|
|
326
|
+
options.abortController?.signal ?? options.request?.signal ?? undefined
|
|
327
|
+
let onAbort: (() => void) | undefined
|
|
328
|
+
|
|
329
|
+
try {
|
|
330
|
+
const sandbox = this.sandboxFrom(options)
|
|
331
|
+
const modelOptions = options.modelOptions
|
|
332
|
+
const cwd = modelOptions?.cwd ?? this.harness.cwd ?? DEFAULT_WORKDIR
|
|
333
|
+
const harnessCwd = resolveHarnessCwd(sandbox, cwd)
|
|
334
|
+
const runId = options.runId ?? this.generateId()
|
|
335
|
+
const threadId = options.threadId ?? this.generateId()
|
|
336
|
+
const channel = createBridgeEventChannel({
|
|
337
|
+
model: this.model,
|
|
338
|
+
threadId,
|
|
339
|
+
runId,
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
const sessionId = modelOptions?.sessionId
|
|
343
|
+
const { prompt: resumePrompt } = this.buildPrompt(
|
|
344
|
+
options.messages,
|
|
345
|
+
sessionId,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
// Bridge chat()-provided tools into the agent over MCP (ACP http server).
|
|
349
|
+
const bridgedToolNames = new Set(
|
|
350
|
+
(options.tools ?? []).map((tool) => tool.name),
|
|
351
|
+
)
|
|
352
|
+
if (options.tools && options.tools.length > 0) {
|
|
353
|
+
const provisioner =
|
|
354
|
+
(options.capabilities
|
|
355
|
+
? getToolBridgeProvisioner(options.capabilities, { optional: true })
|
|
356
|
+
: undefined) ?? nodeHttpBridgeProvisioner
|
|
357
|
+
bridge = await provisioner.provision(options.tools, {
|
|
358
|
+
provider: sandbox.provider,
|
|
359
|
+
context: options.context,
|
|
360
|
+
emitCustomEvent: channel.emitCustomEvent,
|
|
361
|
+
...(externalSignal ? { signal: externalSignal } : {}),
|
|
362
|
+
})
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Project workspace skills declared via withSandbox. MCP skills ride ACP's
|
|
366
|
+
// native `mcpServers` (below); gitSkills are linked into `skillsDir`.
|
|
367
|
+
let workspaceServers: Array<AcpMcpServer> = []
|
|
368
|
+
const projection = options.capabilities
|
|
369
|
+
? getWorkspaceProjection(options.capabilities, { optional: true })
|
|
370
|
+
: undefined
|
|
371
|
+
if (projection !== undefined) {
|
|
372
|
+
await projectAcpWorkspace(sandbox, projection, {
|
|
373
|
+
...(this.harness.skillsDir !== undefined && {
|
|
374
|
+
skillsDir: this.harness.skillsDir,
|
|
375
|
+
}),
|
|
376
|
+
harnessName: this.name,
|
|
377
|
+
})
|
|
378
|
+
workspaceServers = workspaceMcpServers(projection)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const ctx: AcpHarnessContext<ResolvedOptions<TModelOptions>> = {
|
|
382
|
+
sandbox,
|
|
383
|
+
model: this.model,
|
|
384
|
+
cwd,
|
|
385
|
+
harnessCwd,
|
|
386
|
+
env: this.harness.env,
|
|
387
|
+
modelOptions,
|
|
388
|
+
signal: externalSignal,
|
|
389
|
+
}
|
|
390
|
+
transport = this.harness.openTransport
|
|
391
|
+
? await this.harness.openTransport(ctx)
|
|
392
|
+
: await this.openStdioTransport(ctx)
|
|
393
|
+
|
|
394
|
+
const mode =
|
|
395
|
+
modelOptions?.permissionMode ??
|
|
396
|
+
this.harness.permissionMode ??
|
|
397
|
+
'bypassPermissions'
|
|
398
|
+
const authMethodId =
|
|
399
|
+
modelOptions?.authMethodId ?? this.harness.authMethodId
|
|
400
|
+
|
|
401
|
+
const approvalRequests: Array<StreamChunk> = []
|
|
402
|
+
const permissionHandler = this.makePermissionHandler({
|
|
403
|
+
mode,
|
|
404
|
+
bridgedToolNames,
|
|
405
|
+
approvals: options.approvals,
|
|
406
|
+
approvalRequests,
|
|
407
|
+
threadId,
|
|
408
|
+
runId,
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
const queue = new AsyncQueue<AcpStreamEvent>()
|
|
412
|
+
|
|
413
|
+
logger.request(
|
|
414
|
+
`activity=chat provider=${this.name} model=${this.model} sandbox=${sandbox.provider} messages=${options.messages.length} resume=${sessionId ?? 'none'}`,
|
|
415
|
+
{ provider: this.name, model: this.model },
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
// The host tool-bridge (chat() tools) + workspace MCP skills, both over
|
|
419
|
+
// ACP's native MCP channel.
|
|
420
|
+
const mcpServers: Array<AcpMcpServer> = [
|
|
421
|
+
...(bridge !== undefined
|
|
422
|
+
? [
|
|
423
|
+
{
|
|
424
|
+
name: bridge.name,
|
|
425
|
+
url: bridge.url,
|
|
426
|
+
headers: [
|
|
427
|
+
{ name: 'Authorization', value: `Bearer ${bridge.token}` },
|
|
428
|
+
],
|
|
429
|
+
},
|
|
430
|
+
]
|
|
431
|
+
: []),
|
|
432
|
+
...workspaceServers,
|
|
433
|
+
]
|
|
434
|
+
|
|
435
|
+
const onAcpUpdate = (update: AcpSessionUpdate) =>
|
|
436
|
+
queue.push({ kind: 'update', update })
|
|
437
|
+
handle = await startAcpSession({
|
|
438
|
+
transport,
|
|
439
|
+
cwd: harnessCwd,
|
|
440
|
+
...(authMethodId !== undefined && { authMethodId }),
|
|
441
|
+
...(sessionId !== undefined && { resumeSessionId: sessionId }),
|
|
442
|
+
...(mcpServers.length > 0 && { mcpServers }),
|
|
443
|
+
onUpdate: onAcpUpdate,
|
|
444
|
+
...(this.harness.onExtNotification && {
|
|
445
|
+
onExtNotification: this.harness.onExtNotification,
|
|
446
|
+
}),
|
|
447
|
+
onPermissionRequest: permissionHandler,
|
|
448
|
+
})
|
|
449
|
+
const session = handle
|
|
450
|
+
|
|
451
|
+
if (externalSignal !== undefined) {
|
|
452
|
+
onAbort = () => void session.cancel().catch(() => undefined)
|
|
453
|
+
if (externalSignal.aborted) onAbort()
|
|
454
|
+
else externalSignal.addEventListener('abort', onAbort, { once: true })
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
queue.push({ kind: 'session', sessionId: session.sessionId })
|
|
458
|
+
|
|
459
|
+
const systemPrompts = normalizeSystemPrompts(options.systemPrompts)
|
|
460
|
+
.map((p) => p.content)
|
|
461
|
+
.filter((c) => c.trim() !== '')
|
|
462
|
+
const promptText = this.applySystemPrompts(
|
|
463
|
+
systemPrompts,
|
|
464
|
+
session.resumed || sessionId === undefined
|
|
465
|
+
? resumePrompt
|
|
466
|
+
: this.buildPrompt(options.messages, undefined).prompt,
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
session
|
|
470
|
+
.prompt(promptText)
|
|
471
|
+
.then(({ stopReason, usage }) => {
|
|
472
|
+
queue.push({
|
|
473
|
+
kind: 'done',
|
|
474
|
+
stopReason,
|
|
475
|
+
...(usage !== undefined && { usage }),
|
|
476
|
+
})
|
|
477
|
+
queue.end()
|
|
478
|
+
})
|
|
479
|
+
.catch((error: unknown) => queue.fail(error))
|
|
480
|
+
|
|
481
|
+
yield* mergeChunkStreams(
|
|
482
|
+
translateAcpStream(queue, {
|
|
483
|
+
model: this.model,
|
|
484
|
+
runId,
|
|
485
|
+
threadId,
|
|
486
|
+
...(options.parentRunId !== undefined && {
|
|
487
|
+
parentRunId: options.parentRunId,
|
|
488
|
+
}),
|
|
489
|
+
genId: () => this.generateId(),
|
|
490
|
+
bridgedToolNames,
|
|
491
|
+
labels: {
|
|
492
|
+
sessionIdEvent: `${this.name}.session-id`,
|
|
493
|
+
// Surface non-text agent content (image/audio/resource) instead of
|
|
494
|
+
// dropping it — emitted as a CUSTOM `<name>.message-content` event.
|
|
495
|
+
contentEvent: `${this.name}.message-content`,
|
|
496
|
+
...(this.harness.planEventName !== undefined && {
|
|
497
|
+
planEvent: this.harness.planEventName,
|
|
498
|
+
}),
|
|
499
|
+
...(this.harness.refusalMessage !== undefined && {
|
|
500
|
+
refusalMessage: this.harness.refusalMessage,
|
|
501
|
+
}),
|
|
502
|
+
},
|
|
503
|
+
onAcpEvent: (event) =>
|
|
504
|
+
logger.provider(`provider=${this.name} kind=${event.kind}`, {
|
|
505
|
+
chunk: event,
|
|
506
|
+
}),
|
|
507
|
+
}),
|
|
508
|
+
channel.stream,
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
// Surface any pending approval requests (interactive ask-policy actions
|
|
512
|
+
// awaiting a client decision); the client approves and re-runs to continue.
|
|
513
|
+
for (const event of approvalRequests) yield event
|
|
514
|
+
|
|
515
|
+
if (this.harness.emitDiff) {
|
|
516
|
+
yield* this.emitDiffChunks(sandbox, cwd, threadId, runId)
|
|
517
|
+
}
|
|
518
|
+
} catch (error: unknown) {
|
|
519
|
+
const err = error as Error & { code?: string }
|
|
520
|
+
const rawEvent = toRunErrorRawEvent(error)
|
|
521
|
+
logger.errors(`${this.name}.chatStream fatal`, {
|
|
522
|
+
error,
|
|
523
|
+
source: `${this.name}.chatStream`,
|
|
524
|
+
})
|
|
525
|
+
yield {
|
|
526
|
+
type: EventType.RUN_ERROR,
|
|
527
|
+
model: options.model,
|
|
528
|
+
timestamp: Date.now(),
|
|
529
|
+
message: err.message || 'Unknown error occurred',
|
|
530
|
+
...(err.code !== undefined && { code: err.code }),
|
|
531
|
+
...(rawEvent !== undefined && { rawEvent }),
|
|
532
|
+
error: {
|
|
533
|
+
message: err.message || 'Unknown error occurred',
|
|
534
|
+
...(err.code !== undefined && { code: err.code }),
|
|
535
|
+
},
|
|
536
|
+
}
|
|
537
|
+
} finally {
|
|
538
|
+
if (externalSignal !== undefined && onAbort !== undefined) {
|
|
539
|
+
externalSignal.removeEventListener('abort', onAbort)
|
|
540
|
+
}
|
|
541
|
+
// startAcpSession owns transport teardown once a handle exists (and tears
|
|
542
|
+
// it down itself on a failed init). Only dispose here if we opened a
|
|
543
|
+
// transport but never reached a session.
|
|
544
|
+
if (handle !== undefined) await handle.dispose()
|
|
545
|
+
else if (transport !== undefined) await disposeTransport(transport)
|
|
546
|
+
await bridge?.close()
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
private async openStdioTransport(
|
|
551
|
+
ctx: AcpHarnessContext<ResolvedOptions<TModelOptions>>,
|
|
552
|
+
): Promise<AcpSessionTransport> {
|
|
553
|
+
const build = this.harness.command
|
|
554
|
+
if (build === undefined) {
|
|
555
|
+
// Unreachable — the constructor requires `command` or `openTransport`,
|
|
556
|
+
// and this path only runs when `openTransport` is absent.
|
|
557
|
+
throw new Error(
|
|
558
|
+
`acpCompatible("${this.name}") has no "command" to launch over stdio.`,
|
|
559
|
+
)
|
|
560
|
+
}
|
|
561
|
+
const command = build(ctx)
|
|
562
|
+
const proc = await ctx.sandbox.process.spawn(command, {
|
|
563
|
+
cwd: ctx.cwd,
|
|
564
|
+
...(this.harness.env ? { env: this.harness.env } : {}),
|
|
565
|
+
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
566
|
+
})
|
|
567
|
+
return { kind: 'stdio', process: proc }
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
private async *emitDiffChunks(
|
|
571
|
+
sandbox: SandboxHandle,
|
|
572
|
+
cwd: string,
|
|
573
|
+
threadId: string,
|
|
574
|
+
runId: string,
|
|
575
|
+
): AsyncIterable<StreamChunk> {
|
|
576
|
+
try {
|
|
577
|
+
const diff = await sandbox.process.exec(`git -C ${q(cwd)} diff`, { cwd })
|
|
578
|
+
if (diff.exitCode === 0 && diff.stdout.trim() !== '') {
|
|
579
|
+
yield {
|
|
580
|
+
type: EventType.CUSTOM,
|
|
581
|
+
name: 'file.changed',
|
|
582
|
+
value: { path: '.', diff: diff.stdout },
|
|
583
|
+
timestamp: Date.now(),
|
|
584
|
+
threadId,
|
|
585
|
+
runId,
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
} catch {
|
|
589
|
+
// ignore — diff is best-effort
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
structuredOutput(
|
|
594
|
+
_options: StructuredOutputOptions<ResolvedOptions<TModelOptions>>,
|
|
595
|
+
): Promise<StructuredOutputResult<unknown>> {
|
|
596
|
+
return Promise.reject(
|
|
597
|
+
new Error(
|
|
598
|
+
`Structured output is not supported by the in-sandbox "${this.name}" ACP harness adapter. ` +
|
|
599
|
+
'Use a model adapter for structured output, or omit outputSchema.',
|
|
600
|
+
),
|
|
601
|
+
)
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Configure an ACP-compatible harness once, then select a model per call.
|
|
607
|
+
*
|
|
608
|
+
* Mirrors `openaiCompatible`: it lets you plug ANY Agent Client Protocol agent
|
|
609
|
+
* into a TanStack AI sandbox without a dedicated adapter package.
|
|
610
|
+
*
|
|
611
|
+
* @example
|
|
612
|
+
* ```ts
|
|
613
|
+
* import { acpCompatible } from '@tanstack/ai-acp'
|
|
614
|
+
* import { chat } from '@tanstack/ai'
|
|
615
|
+
* import { defineSandbox, withSandbox } from '@tanstack/ai-sandbox'
|
|
616
|
+
*
|
|
617
|
+
* const pi = acpCompatible({
|
|
618
|
+
* name: 'pi',
|
|
619
|
+
* // declaring `models` makes pi('…') type-safe; omit to accept any string
|
|
620
|
+
* models: ['pi-fast', 'pi-pro'],
|
|
621
|
+
* // declare per-call options; merged with the base ACP options and exposed
|
|
622
|
+
* // on ctx.modelOptions inside `command` / `openTransport`
|
|
623
|
+
* modelOptions: {} as { reasoningEffort?: 'low' | 'high' },
|
|
624
|
+
* command: ({ model, harnessCwd, modelOptions }) =>
|
|
625
|
+
* `pi --acp -m ${model} --cwd ${harnessCwd}` +
|
|
626
|
+
* (modelOptions?.reasoningEffort ? ` --effort ${modelOptions.reasoningEffort}` : ''),
|
|
627
|
+
* authMethodId: 'pi-api-key',
|
|
628
|
+
* })
|
|
629
|
+
*
|
|
630
|
+
* chat({
|
|
631
|
+
* adapter: pi('pi-pro'),
|
|
632
|
+
* modelOptions: { reasoningEffort: 'high' }, // typed
|
|
633
|
+
* messages,
|
|
634
|
+
* middleware: [withSandbox(defineSandbox({ /* provider, install pi *\/ }))],
|
|
635
|
+
* })
|
|
636
|
+
* ```
|
|
637
|
+
*/
|
|
638
|
+
export function acpCompatible<
|
|
639
|
+
const TModels extends ReadonlyArray<string> = ReadonlyArray<string>,
|
|
640
|
+
TModelOptions extends Record<string, any> = AcpCompatibleProviderOptions,
|
|
641
|
+
>(config: AcpCompatibleConfig<TModels, TModelOptions>) {
|
|
642
|
+
return <TModel extends AcpModelNameOf<TModels>>(
|
|
643
|
+
model: TModel,
|
|
644
|
+
overrides?: Partial<AcpCompatibleConfig<TModels, TModelOptions>>,
|
|
645
|
+
): AcpCompatibleTextAdapter<TModel, TModelOptions> =>
|
|
646
|
+
new AcpCompatibleTextAdapter<TModel, TModelOptions>(
|
|
647
|
+
overrides ? { ...config, ...overrides } : config,
|
|
648
|
+
model,
|
|
649
|
+
)
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* One-shot helper: build a single-model ACP-compatible harness adapter inline.
|
|
654
|
+
*
|
|
655
|
+
* @example
|
|
656
|
+
* ```ts
|
|
657
|
+
* chat({
|
|
658
|
+
* adapter: acpCompatibleText('pi-fast', {
|
|
659
|
+
* name: 'pi',
|
|
660
|
+
* command: ({ model }) => `pi --acp -m ${model}`,
|
|
661
|
+
* }),
|
|
662
|
+
* messages,
|
|
663
|
+
* middleware: [withSandbox(defineSandbox({ ... }))],
|
|
664
|
+
* })
|
|
665
|
+
* ```
|
|
666
|
+
*/
|
|
667
|
+
export function acpCompatibleText<
|
|
668
|
+
TModel extends string,
|
|
669
|
+
TModelOptions extends Record<string, any> = AcpCompatibleProviderOptions,
|
|
670
|
+
>(
|
|
671
|
+
model: TModel,
|
|
672
|
+
config: AcpCompatibleConfig<ReadonlyArray<string>, TModelOptions>,
|
|
673
|
+
): AcpCompatibleTextAdapter<TModel, TModelOptions> {
|
|
674
|
+
return new AcpCompatibleTextAdapter<TModel, TModelOptions>(config, model)
|
|
675
|
+
}
|