@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,195 @@
|
|
|
1
|
+
import { connectAcpWebSocket } from '../transport/websocket'
|
|
2
|
+
import type {
|
|
3
|
+
SandboxChannel,
|
|
4
|
+
SandboxHandle,
|
|
5
|
+
SpawnHandle,
|
|
6
|
+
} from '@tanstack/ai-sandbox'
|
|
7
|
+
import type { AcpMessageFraming } from '../transport/types'
|
|
8
|
+
import type { AcpWebSocketConnection } from '../transport/websocket'
|
|
9
|
+
|
|
10
|
+
export interface AcpSandboxServer {
|
|
11
|
+
/** WebSocket URL the orchestrator uses to reach the in-sandbox ACP server. */
|
|
12
|
+
wsUrl: string
|
|
13
|
+
/** Sandbox channel used to build {@link wsUrl} (auth headers, when issued). */
|
|
14
|
+
channel: SandboxChannel
|
|
15
|
+
/** Open the ACP JSON-RPC stream over WebSocket. */
|
|
16
|
+
connect: (signal?: AbortSignal) => Promise<AcpWebSocketConnection>
|
|
17
|
+
/** Stop the in-sandbox server process. */
|
|
18
|
+
dispose: () => Promise<void>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface StartAcpServerOptions {
|
|
22
|
+
port: number
|
|
23
|
+
cwd: string
|
|
24
|
+
/** Full shell command that starts the harness ACP server inside the sandbox. */
|
|
25
|
+
command: string
|
|
26
|
+
/** Build the WebSocket URL once the server is ready and the port is exposed. */
|
|
27
|
+
buildWsUrl: (input: {
|
|
28
|
+
channel: SandboxChannel
|
|
29
|
+
port: number
|
|
30
|
+
stdout: string
|
|
31
|
+
}) => string
|
|
32
|
+
/**
|
|
33
|
+
* Return true once {@link buildWsUrl} can be called. Receives accumulated
|
|
34
|
+
* stdout from the server process.
|
|
35
|
+
*/
|
|
36
|
+
isReady?: (stdout: string) => boolean
|
|
37
|
+
/** Substring/regex marker used when {@link isReady} is omitted. */
|
|
38
|
+
readyMarker?: string
|
|
39
|
+
env?: Record<string, string>
|
|
40
|
+
timeoutMs?: number
|
|
41
|
+
signal?: AbortSignal
|
|
42
|
+
framing?: AcpMessageFraming
|
|
43
|
+
hostname?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const DEFAULT_READY_MARKER = 'WebSocket URL:'
|
|
47
|
+
|
|
48
|
+
function waitForReady(
|
|
49
|
+
proc: SpawnHandle,
|
|
50
|
+
options: Pick<StartAcpServerOptions, 'isReady' | 'readyMarker' | 'timeoutMs'>,
|
|
51
|
+
): Promise<{ stdout: string; stderr: string }> {
|
|
52
|
+
const readyMarker = options.readyMarker ?? DEFAULT_READY_MARKER
|
|
53
|
+
const isReady =
|
|
54
|
+
options.isReady ?? ((output: string) => output.includes(readyMarker))
|
|
55
|
+
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
let stdout = ''
|
|
58
|
+
let stderr = ''
|
|
59
|
+
const state = { settled: false }
|
|
60
|
+
const settle = (fn: () => void): void => {
|
|
61
|
+
if (state.settled) return
|
|
62
|
+
state.settled = true
|
|
63
|
+
clearTimeout(timer)
|
|
64
|
+
fn()
|
|
65
|
+
}
|
|
66
|
+
const diagnostics = (): string =>
|
|
67
|
+
[stdout, stderr]
|
|
68
|
+
.map((s) => s.trim())
|
|
69
|
+
.filter(Boolean)
|
|
70
|
+
.join('\n')
|
|
71
|
+
.slice(-500)
|
|
72
|
+
const combined = (): string => stdout + stderr
|
|
73
|
+
let stdoutDone = false
|
|
74
|
+
let stderrDone = false
|
|
75
|
+
const tryFinish = (): void => {
|
|
76
|
+
if (state.settled) return
|
|
77
|
+
if (isReady(combined())) {
|
|
78
|
+
settle(() => resolve({ stdout, stderr }))
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
if (stdoutDone && stderrDone) {
|
|
82
|
+
settle(() =>
|
|
83
|
+
reject(
|
|
84
|
+
new Error(
|
|
85
|
+
`ACP server exited before becoming ready${
|
|
86
|
+
diagnostics() ? `: ${diagnostics()}` : ' (no output)'
|
|
87
|
+
}`,
|
|
88
|
+
),
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const timer = setTimeout(
|
|
95
|
+
() =>
|
|
96
|
+
settle(() =>
|
|
97
|
+
reject(
|
|
98
|
+
new Error(
|
|
99
|
+
`ACP server did not become ready within ${options.timeoutMs ?? 30_000}ms${
|
|
100
|
+
diagnostics() ? `: ${diagnostics()}` : ''
|
|
101
|
+
}`,
|
|
102
|
+
),
|
|
103
|
+
),
|
|
104
|
+
),
|
|
105
|
+
options.timeoutMs ?? 30_000,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
void (async () => {
|
|
109
|
+
try {
|
|
110
|
+
for await (const chunk of proc.stderr) {
|
|
111
|
+
stderr += chunk
|
|
112
|
+
tryFinish()
|
|
113
|
+
if (state.settled) return
|
|
114
|
+
}
|
|
115
|
+
} catch {
|
|
116
|
+
// non-fatal
|
|
117
|
+
} finally {
|
|
118
|
+
stderrDone = true
|
|
119
|
+
tryFinish()
|
|
120
|
+
}
|
|
121
|
+
})()
|
|
122
|
+
|
|
123
|
+
void (async () => {
|
|
124
|
+
try {
|
|
125
|
+
for await (const chunk of proc.stdout) {
|
|
126
|
+
stdout += chunk
|
|
127
|
+
tryFinish()
|
|
128
|
+
if (state.settled) return
|
|
129
|
+
}
|
|
130
|
+
} catch (error) {
|
|
131
|
+
settle(() => reject(error))
|
|
132
|
+
return
|
|
133
|
+
} finally {
|
|
134
|
+
stdoutDone = true
|
|
135
|
+
tryFinish()
|
|
136
|
+
}
|
|
137
|
+
})()
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Boot a harness ACP WebSocket server inside a sandbox and expose its port via
|
|
143
|
+
* {@link SandboxHandle.ports.connect}. Mirrors the `opencode serve` pattern.
|
|
144
|
+
*/
|
|
145
|
+
export async function startAcpServerInSandbox(
|
|
146
|
+
sandbox: SandboxHandle,
|
|
147
|
+
options: StartAcpServerOptions,
|
|
148
|
+
): Promise<AcpSandboxServer> {
|
|
149
|
+
const proc = await sandbox.process.spawn(options.command, {
|
|
150
|
+
cwd: options.cwd,
|
|
151
|
+
...(options.env ? { env: options.env } : {}),
|
|
152
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
const { stdout } = await waitForReady(proc, options)
|
|
156
|
+
const channel = await sandbox.ports.connect(options.port)
|
|
157
|
+
const wsUrl = options.buildWsUrl({
|
|
158
|
+
channel,
|
|
159
|
+
port: options.port,
|
|
160
|
+
stdout,
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
const headers =
|
|
164
|
+
channel.headers ??
|
|
165
|
+
(channel.token ? { Authorization: `Bearer ${channel.token}` } : undefined)
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
wsUrl,
|
|
169
|
+
channel,
|
|
170
|
+
connect: (signal?: AbortSignal) =>
|
|
171
|
+
connectAcpWebSocket(wsUrl, {
|
|
172
|
+
...(headers ? { headers } : {}),
|
|
173
|
+
...(signal ? { signal } : {}),
|
|
174
|
+
framing: options.framing ?? 'frame',
|
|
175
|
+
}),
|
|
176
|
+
dispose: () => proc.kill(),
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Parse a `WebSocket URL: ws://…` line printed by `grok agent serve`. */
|
|
181
|
+
export function parseWebSocketUrlFromServeOutput(
|
|
182
|
+
output: string,
|
|
183
|
+
): string | undefined {
|
|
184
|
+
const match = output.match(/WebSocket URL:\s*(ws\S+)/i)
|
|
185
|
+
return match?.[1]
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Build a Grok `agent serve` WebSocket URL from a sandbox channel + secret. */
|
|
189
|
+
export function buildGrokServeWebSocketUrl(
|
|
190
|
+
channelUrl: string,
|
|
191
|
+
secret: string,
|
|
192
|
+
): string {
|
|
193
|
+
const wsBase = channelUrl.replace(/^http/i, 'ws').replace(/\/$/, '')
|
|
194
|
+
return `${wsBase}/ws?server-key=${encodeURIComponent(secret)}`
|
|
195
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal promise-based async queue bridging callback-style ACP notifications
|
|
3
|
+
* into the async-iterable world the stream translator consumes.
|
|
4
|
+
*/
|
|
5
|
+
export class AsyncQueue<T> implements AsyncIterable<T> {
|
|
6
|
+
private readonly values: Array<T> = []
|
|
7
|
+
private readonly waiters: Array<{
|
|
8
|
+
resolve: (result: IteratorResult<T>) => void
|
|
9
|
+
reject: (error: unknown) => void
|
|
10
|
+
}> = []
|
|
11
|
+
private ended = false
|
|
12
|
+
private error: unknown = undefined
|
|
13
|
+
private failed = false
|
|
14
|
+
|
|
15
|
+
push(value: T): void {
|
|
16
|
+
if (this.ended || this.failed) return
|
|
17
|
+
const waiter = this.waiters.shift()
|
|
18
|
+
if (waiter) {
|
|
19
|
+
waiter.resolve({ value, done: false })
|
|
20
|
+
} else {
|
|
21
|
+
this.values.push(value)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
end(): void {
|
|
26
|
+
if (this.ended || this.failed) return
|
|
27
|
+
this.ended = true
|
|
28
|
+
for (const waiter of this.waiters.splice(0)) {
|
|
29
|
+
waiter.resolve({ value: undefined, done: true })
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
fail(error: unknown): void {
|
|
34
|
+
if (this.ended || this.failed) return
|
|
35
|
+
this.failed = true
|
|
36
|
+
this.error = error
|
|
37
|
+
for (const waiter of this.waiters.splice(0)) {
|
|
38
|
+
waiter.reject(error)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
[Symbol.asyncIterator](): AsyncIterator<T> {
|
|
43
|
+
return {
|
|
44
|
+
next: (): Promise<IteratorResult<T>> => {
|
|
45
|
+
if (this.values.length > 0) {
|
|
46
|
+
return Promise.resolve({
|
|
47
|
+
value: this.values.shift() as T,
|
|
48
|
+
done: false,
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
if (this.failed) return Promise.reject(this.error)
|
|
52
|
+
if (this.ended) {
|
|
53
|
+
return Promise.resolve({ value: undefined, done: true })
|
|
54
|
+
}
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
this.waiters.push({ resolve, reject })
|
|
57
|
+
})
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import { EventType, buildBaseUsage } from '@tanstack/ai'
|
|
2
|
+
import type { StreamChunk, TokenUsage } from '@tanstack/ai'
|
|
3
|
+
import type {
|
|
4
|
+
AcpSessionUpdate,
|
|
5
|
+
AcpStopReason,
|
|
6
|
+
AcpToolCallUpdate,
|
|
7
|
+
AcpUsage,
|
|
8
|
+
} from '../types/acp-types'
|
|
9
|
+
|
|
10
|
+
export const BRIDGED_MCP_SERVER_NAME = 'tanstack'
|
|
11
|
+
|
|
12
|
+
export type AcpStreamEvent =
|
|
13
|
+
| { kind: 'session'; sessionId: string }
|
|
14
|
+
| { kind: 'update'; update: AcpSessionUpdate }
|
|
15
|
+
| { kind: 'done'; stopReason: AcpStopReason; usage?: AcpUsage }
|
|
16
|
+
|
|
17
|
+
export interface AcpTranslateLabels {
|
|
18
|
+
sessionIdEvent: string
|
|
19
|
+
planEvent?: string
|
|
20
|
+
refusalMessage?: string
|
|
21
|
+
/**
|
|
22
|
+
* When set, non-text agent message content (image / audio / resource /
|
|
23
|
+
* resource_link blocks) is surfaced as a CUSTOM event under this name instead
|
|
24
|
+
* of being dropped. Each event's `value` is `{ content: <ACP content block> }`.
|
|
25
|
+
* Omit to keep the text-only behavior.
|
|
26
|
+
*/
|
|
27
|
+
contentEvent?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface TranslateContext {
|
|
31
|
+
model: string
|
|
32
|
+
runId: string
|
|
33
|
+
threadId: string
|
|
34
|
+
parentRunId?: string
|
|
35
|
+
genId: () => string
|
|
36
|
+
labels: AcpTranslateLabels
|
|
37
|
+
bridgedToolNames?: ReadonlySet<string>
|
|
38
|
+
onAcpEvent?: (event: AcpStreamEvent) => void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function matchBridgedToolName(
|
|
42
|
+
title: string | null | undefined,
|
|
43
|
+
bridgedToolNames: ReadonlySet<string> | undefined,
|
|
44
|
+
): string | undefined {
|
|
45
|
+
if (!title || !bridgedToolNames) return undefined
|
|
46
|
+
if (bridgedToolNames.has(title)) return title
|
|
47
|
+
for (const name of bridgedToolNames) {
|
|
48
|
+
if (title.startsWith(`${name} (`)) return name
|
|
49
|
+
}
|
|
50
|
+
return undefined
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function resolveToolName(
|
|
54
|
+
update: AcpToolCallUpdate,
|
|
55
|
+
bridgedToolNames: ReadonlySet<string> | undefined,
|
|
56
|
+
): string {
|
|
57
|
+
return (
|
|
58
|
+
matchBridgedToolName(update.title, bridgedToolNames) ??
|
|
59
|
+
update.kind ??
|
|
60
|
+
'tool'
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function stringifyToolOutput(update: AcpToolCallUpdate): string {
|
|
65
|
+
if (update.rawOutput !== undefined) {
|
|
66
|
+
return typeof update.rawOutput === 'string'
|
|
67
|
+
? update.rawOutput
|
|
68
|
+
: JSON.stringify(update.rawOutput)
|
|
69
|
+
}
|
|
70
|
+
const blocks = update.content ?? []
|
|
71
|
+
const text = blocks
|
|
72
|
+
.map((block) =>
|
|
73
|
+
block.content && typeof block.content.text === 'string'
|
|
74
|
+
? block.content.text
|
|
75
|
+
: '',
|
|
76
|
+
)
|
|
77
|
+
.join('')
|
|
78
|
+
// Preserve non-text tool content (diff / terminal / image / resource blocks)
|
|
79
|
+
// by serializing the structured array, rather than collapsing to a stub.
|
|
80
|
+
const hasNonText = blocks.some(
|
|
81
|
+
(block) =>
|
|
82
|
+
block.type !== 'content' ||
|
|
83
|
+
(block.content !== undefined && block.content.type !== 'text'),
|
|
84
|
+
)
|
|
85
|
+
if (hasNonText) return JSON.stringify(blocks)
|
|
86
|
+
if (text !== '') return text
|
|
87
|
+
return JSON.stringify({ status: update.status ?? 'completed' })
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function buildUsage(usage: AcpUsage | undefined): TokenUsage | undefined {
|
|
91
|
+
if (!usage) return undefined
|
|
92
|
+
const promptTokens = usage.inputTokens ?? 0
|
|
93
|
+
const completionTokens = usage.outputTokens ?? 0
|
|
94
|
+
const result = buildBaseUsage({
|
|
95
|
+
promptTokens,
|
|
96
|
+
completionTokens,
|
|
97
|
+
totalTokens: usage.totalTokens ?? promptTokens + completionTokens,
|
|
98
|
+
})
|
|
99
|
+
if (usage.cachedReadTokens) {
|
|
100
|
+
result.promptTokensDetails = { cachedTokens: usage.cachedReadTokens }
|
|
101
|
+
}
|
|
102
|
+
if (usage.thoughtTokens) {
|
|
103
|
+
result.completionTokensDetails = { reasoningTokens: usage.thoughtTokens }
|
|
104
|
+
}
|
|
105
|
+
return result
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function* translateAcpStream(
|
|
109
|
+
events: AsyncIterable<AcpStreamEvent>,
|
|
110
|
+
ctx: TranslateContext,
|
|
111
|
+
): AsyncIterable<StreamChunk> {
|
|
112
|
+
const { model, runId, threadId, genId, labels } = ctx
|
|
113
|
+
const now = () => Date.now()
|
|
114
|
+
|
|
115
|
+
let runStarted = false
|
|
116
|
+
const unresolvedToolCalls = new Set<string>()
|
|
117
|
+
const knownToolCalls = new Set<string>()
|
|
118
|
+
|
|
119
|
+
let textMessageId: string | null = null
|
|
120
|
+
let textContent = ''
|
|
121
|
+
let reasoningId: string | null = null
|
|
122
|
+
|
|
123
|
+
function* startRun(): Generator<StreamChunk> {
|
|
124
|
+
if (runStarted) return
|
|
125
|
+
runStarted = true
|
|
126
|
+
yield {
|
|
127
|
+
type: EventType.RUN_STARTED,
|
|
128
|
+
runId,
|
|
129
|
+
threadId,
|
|
130
|
+
model,
|
|
131
|
+
timestamp: now(),
|
|
132
|
+
...(ctx.parentRunId !== undefined && { parentRunId: ctx.parentRunId }),
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function* closeText(): Generator<StreamChunk> {
|
|
137
|
+
if (textMessageId !== null) {
|
|
138
|
+
yield {
|
|
139
|
+
type: EventType.TEXT_MESSAGE_END,
|
|
140
|
+
messageId: textMessageId,
|
|
141
|
+
model,
|
|
142
|
+
timestamp: now(),
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
textMessageId = null
|
|
146
|
+
textContent = ''
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function* closeReasoning(): Generator<StreamChunk> {
|
|
150
|
+
if (reasoningId !== null) {
|
|
151
|
+
yield {
|
|
152
|
+
type: EventType.REASONING_MESSAGE_END,
|
|
153
|
+
messageId: reasoningId,
|
|
154
|
+
model,
|
|
155
|
+
timestamp: now(),
|
|
156
|
+
}
|
|
157
|
+
yield {
|
|
158
|
+
type: EventType.REASONING_END,
|
|
159
|
+
messageId: reasoningId,
|
|
160
|
+
model,
|
|
161
|
+
timestamp: now(),
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
reasoningId = null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function* synthesizeUnresolvedResults(): Generator<StreamChunk> {
|
|
168
|
+
for (const toolCallId of unresolvedToolCalls) {
|
|
169
|
+
yield {
|
|
170
|
+
type: EventType.TOOL_CALL_RESULT,
|
|
171
|
+
toolCallId,
|
|
172
|
+
messageId: genId(),
|
|
173
|
+
model,
|
|
174
|
+
timestamp: now(),
|
|
175
|
+
content: JSON.stringify({ status: 'interrupted' }),
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
unresolvedToolCalls.clear()
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function* openToolCall(update: AcpToolCallUpdate): Generator<StreamChunk> {
|
|
182
|
+
if (knownToolCalls.has(update.toolCallId)) return
|
|
183
|
+
knownToolCalls.add(update.toolCallId)
|
|
184
|
+
const toolCallName = resolveToolName(update, ctx.bridgedToolNames)
|
|
185
|
+
const input = {
|
|
186
|
+
...(update.title != null && { title: update.title }),
|
|
187
|
+
...(update.rawInput !== undefined && update.rawInput !== null
|
|
188
|
+
? typeof update.rawInput === 'object'
|
|
189
|
+
? (update.rawInput as Record<string, unknown>)
|
|
190
|
+
: { input: update.rawInput }
|
|
191
|
+
: {}),
|
|
192
|
+
}
|
|
193
|
+
const args = JSON.stringify(input)
|
|
194
|
+
yield {
|
|
195
|
+
type: EventType.TOOL_CALL_START,
|
|
196
|
+
toolCallId: update.toolCallId,
|
|
197
|
+
toolCallName,
|
|
198
|
+
toolName: toolCallName,
|
|
199
|
+
model,
|
|
200
|
+
timestamp: now(),
|
|
201
|
+
}
|
|
202
|
+
yield {
|
|
203
|
+
type: EventType.TOOL_CALL_ARGS,
|
|
204
|
+
toolCallId: update.toolCallId,
|
|
205
|
+
model,
|
|
206
|
+
timestamp: now(),
|
|
207
|
+
delta: args,
|
|
208
|
+
args,
|
|
209
|
+
}
|
|
210
|
+
yield {
|
|
211
|
+
type: EventType.TOOL_CALL_END,
|
|
212
|
+
toolCallId: update.toolCallId,
|
|
213
|
+
toolCallName,
|
|
214
|
+
toolName: toolCallName,
|
|
215
|
+
model,
|
|
216
|
+
timestamp: now(),
|
|
217
|
+
input,
|
|
218
|
+
}
|
|
219
|
+
unresolvedToolCalls.add(update.toolCallId)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function* resolveToolCall(update: AcpToolCallUpdate): Generator<StreamChunk> {
|
|
223
|
+
yield* openToolCall(update)
|
|
224
|
+
unresolvedToolCalls.delete(update.toolCallId)
|
|
225
|
+
yield {
|
|
226
|
+
type: EventType.TOOL_CALL_RESULT,
|
|
227
|
+
toolCallId: update.toolCallId,
|
|
228
|
+
messageId: genId(),
|
|
229
|
+
model,
|
|
230
|
+
timestamp: now(),
|
|
231
|
+
content: stringifyToolOutput(update),
|
|
232
|
+
...(update.status === 'failed' && { state: 'output-error' as const }),
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function* handleUpdate(update: AcpSessionUpdate): Generator<StreamChunk> {
|
|
237
|
+
if (update.sessionUpdate === 'agent_message_chunk') {
|
|
238
|
+
yield* closeReasoning()
|
|
239
|
+
// Non-text content (image / audio / resource / resource_link): surface it
|
|
240
|
+
// as a CUSTOM event when the harness opted in, instead of dropping it.
|
|
241
|
+
if (update.content.type !== 'text') {
|
|
242
|
+
if (labels.contentEvent !== undefined) {
|
|
243
|
+
yield* closeText()
|
|
244
|
+
yield {
|
|
245
|
+
type: EventType.CUSTOM,
|
|
246
|
+
model,
|
|
247
|
+
timestamp: now(),
|
|
248
|
+
name: labels.contentEvent,
|
|
249
|
+
value: { content: update.content },
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
const text =
|
|
255
|
+
typeof update.content.text === 'string' ? update.content.text : ''
|
|
256
|
+
if (text === '') return
|
|
257
|
+
if (textMessageId === null) {
|
|
258
|
+
textMessageId = genId()
|
|
259
|
+
yield {
|
|
260
|
+
type: EventType.TEXT_MESSAGE_START,
|
|
261
|
+
messageId: textMessageId,
|
|
262
|
+
model,
|
|
263
|
+
timestamp: now(),
|
|
264
|
+
role: 'assistant',
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
textContent += text
|
|
268
|
+
yield {
|
|
269
|
+
type: EventType.TEXT_MESSAGE_CONTENT,
|
|
270
|
+
messageId: textMessageId,
|
|
271
|
+
model,
|
|
272
|
+
timestamp: now(),
|
|
273
|
+
delta: text,
|
|
274
|
+
content: textContent,
|
|
275
|
+
}
|
|
276
|
+
} else if (update.sessionUpdate === 'agent_thought_chunk') {
|
|
277
|
+
yield* closeText()
|
|
278
|
+
const thought =
|
|
279
|
+
typeof update.content.text === 'string' ? update.content.text : ''
|
|
280
|
+
if (thought === '') return
|
|
281
|
+
if (reasoningId === null) {
|
|
282
|
+
reasoningId = genId()
|
|
283
|
+
yield {
|
|
284
|
+
type: EventType.REASONING_START,
|
|
285
|
+
messageId: reasoningId,
|
|
286
|
+
model,
|
|
287
|
+
timestamp: now(),
|
|
288
|
+
}
|
|
289
|
+
yield {
|
|
290
|
+
type: EventType.REASONING_MESSAGE_START,
|
|
291
|
+
messageId: reasoningId,
|
|
292
|
+
role: 'reasoning' as const,
|
|
293
|
+
model,
|
|
294
|
+
timestamp: now(),
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
yield {
|
|
298
|
+
type: EventType.REASONING_MESSAGE_CONTENT,
|
|
299
|
+
messageId: reasoningId,
|
|
300
|
+
delta: thought,
|
|
301
|
+
model,
|
|
302
|
+
timestamp: now(),
|
|
303
|
+
}
|
|
304
|
+
} else if (update.sessionUpdate === 'tool_call') {
|
|
305
|
+
yield* closeText()
|
|
306
|
+
yield* closeReasoning()
|
|
307
|
+
yield* openToolCall(update)
|
|
308
|
+
if (update.status === 'completed' || update.status === 'failed') {
|
|
309
|
+
yield* resolveToolCall(update)
|
|
310
|
+
}
|
|
311
|
+
} else if (update.sessionUpdate === 'tool_call_update') {
|
|
312
|
+
if (update.status === 'completed' || update.status === 'failed') {
|
|
313
|
+
yield* resolveToolCall(update)
|
|
314
|
+
} else if (
|
|
315
|
+
update.status === 'in_progress' &&
|
|
316
|
+
update.rawInput !== undefined
|
|
317
|
+
) {
|
|
318
|
+
yield* closeText()
|
|
319
|
+
yield* closeReasoning()
|
|
320
|
+
if (!knownToolCalls.has(update.toolCallId)) {
|
|
321
|
+
yield* openToolCall(update)
|
|
322
|
+
} else {
|
|
323
|
+
const input = {
|
|
324
|
+
...(update.title != null && { title: update.title }),
|
|
325
|
+
...(typeof update.rawInput === 'object' && update.rawInput !== null
|
|
326
|
+
? (update.rawInput as Record<string, unknown>)
|
|
327
|
+
: { input: update.rawInput }),
|
|
328
|
+
}
|
|
329
|
+
const args = JSON.stringify(input)
|
|
330
|
+
yield {
|
|
331
|
+
type: EventType.TOOL_CALL_ARGS,
|
|
332
|
+
toolCallId: update.toolCallId,
|
|
333
|
+
model,
|
|
334
|
+
timestamp: now(),
|
|
335
|
+
delta: args,
|
|
336
|
+
args,
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
} else if (
|
|
341
|
+
update.sessionUpdate === 'plan' &&
|
|
342
|
+
labels.planEvent !== undefined
|
|
343
|
+
) {
|
|
344
|
+
yield {
|
|
345
|
+
type: EventType.CUSTOM,
|
|
346
|
+
model,
|
|
347
|
+
timestamp: now(),
|
|
348
|
+
name: labels.planEvent,
|
|
349
|
+
value: { entries: update.entries },
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
try {
|
|
355
|
+
for await (const event of events) {
|
|
356
|
+
ctx.onAcpEvent?.(event)
|
|
357
|
+
|
|
358
|
+
if (event.kind === 'session') {
|
|
359
|
+
yield* startRun()
|
|
360
|
+
yield {
|
|
361
|
+
type: EventType.CUSTOM,
|
|
362
|
+
model,
|
|
363
|
+
timestamp: now(),
|
|
364
|
+
name: labels.sessionIdEvent,
|
|
365
|
+
value: { sessionId: event.sessionId },
|
|
366
|
+
}
|
|
367
|
+
} else if (event.kind === 'update') {
|
|
368
|
+
yield* startRun()
|
|
369
|
+
yield* handleUpdate(event.update)
|
|
370
|
+
} else {
|
|
371
|
+
yield* startRun()
|
|
372
|
+
yield* closeText()
|
|
373
|
+
yield* closeReasoning()
|
|
374
|
+
yield* synthesizeUnresolvedResults()
|
|
375
|
+
|
|
376
|
+
if (event.stopReason === 'refusal') {
|
|
377
|
+
const message =
|
|
378
|
+
labels.refusalMessage ?? 'The harness refused the request.'
|
|
379
|
+
yield {
|
|
380
|
+
type: EventType.RUN_ERROR,
|
|
381
|
+
model,
|
|
382
|
+
timestamp: now(),
|
|
383
|
+
message,
|
|
384
|
+
code: 'refusal',
|
|
385
|
+
error: { message, code: 'refusal' },
|
|
386
|
+
}
|
|
387
|
+
} else {
|
|
388
|
+
const usage = buildUsage(event.usage)
|
|
389
|
+
const finishReason =
|
|
390
|
+
event.stopReason === 'max_tokens' ||
|
|
391
|
+
event.stopReason === 'max_turn_requests'
|
|
392
|
+
? ('length' as const)
|
|
393
|
+
: ('stop' as const)
|
|
394
|
+
yield {
|
|
395
|
+
type: EventType.RUN_FINISHED,
|
|
396
|
+
runId,
|
|
397
|
+
threadId,
|
|
398
|
+
model,
|
|
399
|
+
timestamp: now(),
|
|
400
|
+
finishReason,
|
|
401
|
+
...(usage !== undefined && { usage }),
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
} catch (error) {
|
|
407
|
+
yield* closeText()
|
|
408
|
+
yield* closeReasoning()
|
|
409
|
+
yield* synthesizeUnresolvedResults()
|
|
410
|
+
throw error
|
|
411
|
+
}
|
|
412
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { SandboxHandle } from '@tanstack/ai-sandbox'
|
|
2
|
+
import type { AcpTransportPreference } from './types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pick stdio vs WebSocket for ACP based on sandbox capabilities and caller
|
|
6
|
+
* preference.
|
|
7
|
+
*/
|
|
8
|
+
export function resolveAcpTransportMode(
|
|
9
|
+
sandbox: SandboxHandle,
|
|
10
|
+
preference: AcpTransportPreference = 'auto',
|
|
11
|
+
): 'stdio' | 'websocket' {
|
|
12
|
+
if (preference === 'stdio') {
|
|
13
|
+
if (sandbox.capabilities.writableStdin !== true) {
|
|
14
|
+
throw new Error(
|
|
15
|
+
`${sandbox.provider}: ACP stdio transport requires capabilities.writableStdin, but this sandbox cannot write to process stdin. Use transport: 'websocket' or 'auto'.`,
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
return 'stdio'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (preference === 'websocket') {
|
|
22
|
+
if (sandbox.capabilities.ports !== true) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
`${sandbox.provider}: ACP WebSocket transport requires capabilities.ports so the harness server can be reached from the orchestrator.`,
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
if (sandbox.capabilities.backgroundProcesses !== true) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`${sandbox.provider}: ACP WebSocket transport requires capabilities.backgroundProcesses to run the in-sandbox harness server.`,
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
return 'websocket'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (sandbox.capabilities.writableStdin === true) return 'stdio'
|
|
36
|
+
if (
|
|
37
|
+
sandbox.capabilities.ports === true &&
|
|
38
|
+
sandbox.capabilities.backgroundProcesses === true
|
|
39
|
+
) {
|
|
40
|
+
return 'websocket'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
throw new Error(
|
|
44
|
+
`${sandbox.provider}: cannot run ACP — no writable stdin and no port-exposed background-process support. Use a Docker/local-process sandbox or enable ports on the provider.`,
|
|
45
|
+
)
|
|
46
|
+
}
|