feat: keep managed SSH prompts running on Hosts

Managed SSH prompts previously depended on Desktop-owned provider and transcript state, so accepted work could stop or become uncertain when the Desktop, network, or power disappeared. The Agent now owns accepted OpenCode prompt execution, model and tool rounds, cancellation, and the durable semantic transcript, while Desktop remains authoritative for permanent projects, conversations, tasks, and messages.

Recovered work attaches to the original conversation without replay. The project switcher also groups managed SSH projects by saved Host and shows live Agent connectivity beside each group without opening a connection merely to read status. Remote project subtitles now show only the path, and the Agent lock advances to 0.11.14 with Runtime ACP v4.

Release note: 托管 SSH 任务在桌面端、网络或电源中断后可继续在主机上运行,并能回到原会话精确恢复;项目菜单现按主机分组并显示实时连接状态。
This commit is contained in:
mesalogo
2026-08-31 11:04:41 +08:00
parent f6f8d5e053
commit 561de29f00
75 changed files with 13439 additions and 1327 deletions
@@ -0,0 +1,266 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AgentPromptModelProfile } from '../shared/model-bridge-contracts'
import {
AgentModelCallLedger,
AgentModelGateway
} from './agent-model-gateway'
const temporary: string[] = []
const secret = 'provider-secret-never-persist'
function setup(fetcher: typeof fetch) {
const root = mkdtempSync(join(tmpdir(), 'goodbuddy-agent-model-'))
temporary.push(root)
mkdirSync(join(root, 'state'), { mode: 0o700 })
const path = join(root, 'state', 'calls.sqlite')
const ledger = new AgentModelCallLedger(path)
return {
path,
ledger,
gateway: new AgentModelGateway({ ledger, fetcher })
}
}
const profile: AgentPromptModelProfile = {
profileId: 'profile-1',
modelProfileDigest: `sha256:${'a'.repeat(64)}`,
provider: 'openai',
baseUrl: 'https://provider.example/v1',
model: 'model-1',
protocol: 'openai-responses',
authentication: 'api-key',
apiKey: secret,
capabilities: { imageInput: false },
limits: {
maximumOutputTokens: 4_096,
maximumModelCalls: 8,
maximumTotalOutputTokens: 32_768,
requestTimeoutMilliseconds: 5_000
}
}
const request = {
method: 'POST' as const,
path: '/v1/responses' as const,
headers: { 'content-type': 'application/json' },
bodyBase64: Buffer.from(JSON.stringify({ model: 'model-1' })).toString(
'base64'
)
}
const context = {
bindingId: 'binding-1',
operationId: 'operation-1',
promptSequence: 0,
roundIndex: 0,
profileDigest: profile.modelProfileDigest,
profile
}
afterEach(() => {
for (const path of temporary.splice(0)) {
rmSync(path, { recursive: true, force: true })
}
})
describe('AgentModelGateway', () => {
it('dispatches a stable call exactly once and records delivery ACK without persisting credentials', async () => {
const fetcher = vi.fn<typeof fetch>(async (_url, init) => {
expect(new Headers(init?.headers).get('authorization')).toBe(
`Bearer ${secret}`
)
expect(JSON.parse(String(init?.body))).toMatchObject({
max_output_tokens: 4_096
})
return new Response(JSON.stringify({ id: 'response-1' }), {
status: 200,
headers: { 'content-type': 'application/json' }
})
})
const { gateway, ledger, path } = setup(fetcher)
const result = await gateway.dispatch(
context,
request,
new AbortController().signal
)
await result.acknowledgeDelivery()
await expect(
gateway.dispatch(context, request, new AbortController().signal)
).rejects.toMatchObject({ code: 'already-dispatched' })
expect(fetcher).toHaveBeenCalledOnce()
ledger.close()
expect(readFileSync(path).includes(Buffer.from(secret))).toBe(false)
})
it('caps provider output hints at the prompt-scoped limit', async () => {
const fetcher = vi.fn<typeof fetch>(async (_url, init) => {
expect(JSON.parse(String(init?.body))).toMatchObject({
max_output_tokens: 4_096
})
return new Response(JSON.stringify({ id: 'response-1' }), {
status: 200,
headers: { 'content-type': 'application/json' }
})
})
const { gateway, ledger } = setup(fetcher)
const result = await gateway.dispatch(
context,
{
...request,
bodyBase64: Buffer.from(
JSON.stringify({
model: 'model-1',
max_output_tokens: 32_000
})
).toString('base64')
},
new AbortController().signal
)
await result.acknowledgeDelivery()
expect(fetcher).toHaveBeenCalledOnce()
ledger.close()
})
it('caps OpenCode chat-completions max_tokens before dispatch', async () => {
const fetcher = vi.fn<typeof fetch>(async (_url, init) => {
expect(JSON.parse(String(init?.body))).toMatchObject({
max_tokens: 4_096
})
return new Response(JSON.stringify({ id: 'response-1' }), {
status: 200,
headers: { 'content-type': 'application/json' }
})
})
const { gateway, ledger } = setup(fetcher)
const result = await gateway.dispatch(
{
...context,
profile: {
...profile,
protocol: 'openai-chat-completions'
}
},
{
method: 'POST',
path: '/v1/chat/completions',
headers: { 'content-type': 'application/json' },
bodyBase64: Buffer.from(
JSON.stringify({
model: 'model-1',
max_tokens: 32_000
})
).toString('base64')
},
new AbortController().signal
)
await result.acknowledgeDelivery()
expect(fetcher).toHaveBeenCalledOnce()
ledger.close()
})
it.each([
{ background: true },
{ store: true },
{ web_search_options: {} },
{ mcp_servers: [] },
{ previous_response_id: 'response-previous' },
{ tools: [{ type: 'web_search' }] },
{ include: ['web_search_call.results'] },
{ modalities: ['text', 'audio'] }
])('rejects independently billed provider capabilities %#', async (extra) => {
const fetcher = vi.fn<typeof fetch>()
const { gateway, ledger } = setup(fetcher)
await expect(
gateway.dispatch(
context,
{
...request,
bodyBase64: Buffer.from(
JSON.stringify({ model: 'model-1', ...extra })
).toString('base64')
},
new AbortController().signal
)
).rejects.toMatchObject({ code: 'policy' })
expect(fetcher).not.toHaveBeenCalled()
ledger.close()
})
it('marks an in-flight provider failure outcome-unknown and never retries it', async () => {
const fetcher = vi.fn<typeof fetch>(async () => {
throw new Error('connection lost after write')
})
const { gateway, ledger } = setup(fetcher)
await expect(
gateway.dispatch(context, request, new AbortController().signal)
).rejects.toMatchObject({ code: 'outcome-unknown' })
await expect(
gateway.dispatch(context, request, new AbortController().signal)
).rejects.toMatchObject({ code: 'outcome-unknown' })
expect(fetcher).toHaveBeenCalledOnce()
ledger.close()
})
it('rejects an oversized declared provider response before buffering it', async () => {
const fetcher = vi.fn<typeof fetch>(async () =>
new Response('{}', {
status: 200,
headers: {
'content-length': String(768 * 1024 + 1)
}
})
)
const { gateway, ledger } = setup(fetcher)
await expect(
gateway.dispatch(context, request, new AbortController().signal)
).rejects.toMatchObject({ code: 'response-too-large' })
ledger.close()
})
it('marks a truncated provider response outcome-unknown', async () => {
const fetcher = vi.fn<typeof fetch>(async () =>
new Response('{}', {
status: 200,
headers: { 'content-length': '100' }
})
)
const { gateway, ledger } = setup(fetcher)
await expect(
gateway.dispatch(context, request, new AbortController().signal)
).rejects.toMatchObject({ code: 'outcome-unknown' })
ledger.close()
})
})
describe('AgentModelCallLedger', () => {
it('prunes delivered terminal calls while the daemon remains running', () => {
const root = mkdtempSync(join(tmpdir(), 'goodbuddy-agent-ledger-'))
temporary.push(root)
const ledger = new AgentModelCallLedger(
join(root, 'calls.sqlite'),
{ maximumRetainedTerminalCalls: 2 }
)
for (let index = 1; index <= 3; index += 1) {
const callId = `call-${index}`
ledger.claim({
callId,
bindingId: 'binding-1',
operationId: `operation-${index}`,
promptSequence: index,
roundIndex: 0,
profileDigest: `sha256:${'a'.repeat(64)}`,
requestDigest: `sha256:${String(index).repeat(64)}`
})
ledger.complete(callId)
ledger.delivered(callId)
}
expect(ledger.get('call-1')).toBeUndefined()
expect(ledger.get('call-2')).toMatchObject({ status: 'completed' })
expect(ledger.get('call-3')).toMatchObject({ status: 'completed' })
ledger.close()
})
})
+654
View File
@@ -0,0 +1,654 @@
import { createHash } from 'node:crypto'
import type { DatabaseSync } from 'node:sqlite'
import type { AgentPromptModelProfile } from '../shared/model-bridge-contracts'
import {
REMOTE_MODEL_GATEWAY_LIMITS,
remoteModelGatewayRequestSchema,
remoteModelGatewayResponseSchema,
type RemoteModelGatewayRequest,
type RemoteModelGatewayResponse
} from '../shared/remote-model-gateway-contracts'
import {
assertTextModelRequestPolicy,
ModelRequestPolicyError
} from '../shared/model-request-policy'
import {
BoundedResponseTruncatedError,
BoundedResponseTooLargeError,
readBoundedResponseBytes
} from '../shared/node/bounded-response'
import { openPrivateSqliteDatabase } from '../shared/node/private-sqlite-database'
const RESPONSE_HEADERS = [
'content-type',
'openai-request-id',
'request-id',
'x-request-id'
] as const
type CallRow = {
call_id: string
binding_id: string
operation_id: string
prompt_sequence: number
round_index: number
profile_digest: string
request_digest: string
status: 'dispatched' | 'completed' | 'outcome-unknown'
response_delivered: number
}
export class AgentModelGatewayError extends Error {
constructor(
readonly code:
| 'already-dispatched'
| 'cancelled'
| 'conflict'
| 'outcome-unknown'
| 'policy'
| 'response-too-large'
| 'timeout',
message: string
) {
super(message)
this.name = 'AgentModelGatewayError'
}
}
export class AgentModelCallLedger {
readonly #database: DatabaseSync
readonly #maximumRetainedTerminalCalls: number
#closed = false
constructor(
path: string,
options: { maximumRetainedTerminalCalls?: number } = {}
) {
this.#maximumRetainedTerminalCalls =
options.maximumRetainedTerminalCalls ?? 10_000
if (
!Number.isSafeInteger(this.#maximumRetainedTerminalCalls) ||
this.#maximumRetainedTerminalCalls < 1
) {
throw new AgentModelGatewayError(
'conflict',
'Model call ledger retention limit is invalid'
)
}
this.#database = openPrivateSqliteDatabase(path)
this.#database.exec(`
PRAGMA journal_mode = WAL;
PRAGMA synchronous = FULL;
PRAGMA busy_timeout = 5000;
CREATE TABLE IF NOT EXISTS agent_model_calls (
call_id TEXT PRIMARY KEY,
binding_id TEXT NOT NULL,
operation_id TEXT NOT NULL,
prompt_sequence INTEGER NOT NULL CHECK (prompt_sequence >= 0),
round_index INTEGER NOT NULL CHECK (round_index >= 0),
profile_digest TEXT NOT NULL,
request_digest TEXT NOT NULL,
status TEXT NOT NULL CHECK (
status IN ('dispatched', 'completed', 'outcome-unknown')
),
response_delivered INTEGER NOT NULL DEFAULT 0 CHECK (
response_delivered IN (0, 1)
),
terminal_code TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE (binding_id, operation_id, round_index)
) STRICT;
CREATE INDEX IF NOT EXISTS agent_model_calls_dispatched
ON agent_model_calls(status) WHERE status = 'dispatched';
CREATE INDEX IF NOT EXISTS agent_model_calls_terminal_retention
ON agent_model_calls(updated_at DESC)
WHERE status <> 'dispatched';
`)
// A provider dispatch that survived an Agent restart cannot be retried.
this.#database.exec(`
UPDATE agent_model_calls
SET status = 'outcome-unknown', terminal_code = 'agent-restarted',
updated_at = CAST(unixepoch('subsec') * 1000 AS INTEGER)
WHERE status = 'dispatched'
`)
this.#pruneTerminalCalls()
}
#pruneTerminalCalls(protectedCallId?: string): void {
if (protectedCallId === undefined) {
this.#database.prepare(`
DELETE FROM agent_model_calls
WHERE call_id IN (
SELECT call_id
FROM agent_model_calls
WHERE status <> 'dispatched'
ORDER BY updated_at DESC, created_at DESC, call_id DESC
LIMIT -1 OFFSET ?
);
`).run(this.#maximumRetainedTerminalCalls)
return
}
this.#database.prepare(`
DELETE FROM agent_model_calls
WHERE call_id IN (
SELECT call_id
FROM agent_model_calls
WHERE status <> 'dispatched' AND call_id <> ?
ORDER BY updated_at DESC, created_at DESC, call_id DESC
LIMIT -1 OFFSET ?
);
`).run(
protectedCallId,
this.#maximumRetainedTerminalCalls - 1
)
}
claim(input: {
callId: string
bindingId: string
operationId: string
promptSequence: number
roundIndex: number
profileDigest: string
requestDigest: string
}): void {
this.#assertOpen()
const existing = this.get(input.callId)
if (existing !== undefined) {
if (
existing.binding_id !== input.bindingId ||
existing.operation_id !== input.operationId ||
existing.prompt_sequence !== input.promptSequence ||
existing.round_index !== input.roundIndex ||
existing.profile_digest !== input.profileDigest ||
existing.request_digest !== input.requestDigest
) {
throw new AgentModelGatewayError(
'conflict',
'Stable model call identity conflicts with prior evidence'
)
}
throw new AgentModelGatewayError(
existing.status === 'outcome-unknown'
? 'outcome-unknown'
: 'already-dispatched',
'Stable model call was already dispatched'
)
}
const previous = this.#database.prepare(`
SELECT status, response_delivered
FROM agent_model_calls
WHERE binding_id = ? AND operation_id = ?
ORDER BY round_index DESC LIMIT 1
`).get(input.bindingId, input.operationId) as
| { status: string; response_delivered: number }
| undefined
if (
input.roundIndex !== (previous === undefined ? 0 : this.#highestRound(
input.bindingId,
input.operationId
) + 1) ||
(previous !== undefined &&
(previous.status !== 'completed' ||
previous.response_delivered !== 1))
) {
throw new AgentModelGatewayError(
'conflict',
'Model call rounds must be contiguous and delivery-acknowledged'
)
}
const now = Date.now()
this.#database.prepare(`
INSERT INTO agent_model_calls (
call_id, binding_id, operation_id, prompt_sequence, round_index,
profile_digest, request_digest, status, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, 'dispatched', ?, ?)
`).run(
input.callId,
input.bindingId,
input.operationId,
input.promptSequence,
input.roundIndex,
input.profileDigest,
input.requestDigest,
now,
now
)
}
complete(callId: string): void {
this.#transition(callId, 'completed', null)
}
outcomeUnknown(callId: string, code: string): void {
this.#transition(callId, 'outcome-unknown', code)
}
delivered(callId: string): void {
this.#assertOpen()
const result = this.#database.prepare(`
UPDATE agent_model_calls
SET response_delivered = 1, updated_at = ?
WHERE call_id = ? AND status = 'completed'
AND response_delivered = 0
`).run(Date.now(), callId)
if (
Number(result.changes) !== 1 &&
this.get(callId)?.response_delivered !== 1
) {
throw new AgentModelGatewayError(
'conflict',
'Only a completed model response can be acknowledged'
)
}
}
get(callId: string): CallRow | undefined {
this.#assertOpen()
return this.#database.prepare(`
SELECT call_id, binding_id, operation_id, prompt_sequence, round_index,
profile_digest, request_digest, status, response_delivered
FROM agent_model_calls WHERE call_id = ?
`).get(callId) as CallRow | undefined
}
close(): void {
if (!this.#closed) {
this.#database.close()
this.#closed = true
}
}
#transition(
callId: string,
status: 'completed' | 'outcome-unknown',
code: string | null
): void {
this.#assertOpen()
const result = this.#database.prepare(`
UPDATE agent_model_calls
SET status = ?, terminal_code = ?, updated_at = ?
WHERE call_id = ? AND status = 'dispatched'
`).run(status, code, Date.now(), callId)
const changed = Number(result.changes) === 1
if (!changed && this.get(callId)?.status !== status) {
throw new AgentModelGatewayError(
'conflict',
'Model call terminal state conflicts with prior evidence'
)
}
if (changed) {
this.#pruneTerminalCalls(callId)
}
}
#highestRound(bindingId: string, operationId: string): number {
const row = this.#database.prepare(`
SELECT MAX(round_index) AS value FROM agent_model_calls
WHERE binding_id = ? AND operation_id = ?
`).get(bindingId, operationId) as { value: number }
return row.value
}
#assertOpen(): void {
if (this.#closed) {
throw new AgentModelGatewayError(
'conflict',
'Model call ledger is closed'
)
}
}
}
export type AgentModelGatewayContext = {
bindingId: string
operationId: string
promptSequence: number
roundIndex: number
profileDigest: string
profile: AgentPromptModelProfile
}
export class AgentModelGateway {
readonly #ledger: AgentModelCallLedger
readonly #fetch: typeof fetch
readonly #promptOutputTokens = new Map<string, number>()
constructor(options: {
ledger: AgentModelCallLedger
fetcher?: typeof fetch
}) {
this.#ledger = options.ledger
this.#fetch = options.fetcher ?? fetch
}
async dispatch(
context: AgentModelGatewayContext,
requestInput: RemoteModelGatewayRequest,
signal: AbortSignal
): Promise<{
response: RemoteModelGatewayResponse
acknowledgeDelivery(): Promise<void>
failDelivery(): void
}> {
signal.throwIfAborted()
const request = remoteModelGatewayRequestSchema.parse(requestInput)
const promptKey = `${context.bindingId}\0${context.operationId}`
const usedOutputTokens = this.#promptOutputTokens.get(promptKey) ?? 0
const remainingOutputTokens =
context.profile.limits.maximumTotalOutputTokens - usedOutputTokens
if (remainingOutputTokens < 1) {
throw new AgentModelGatewayError(
'policy',
'Prompt total output token limit is exhausted'
)
}
const reservedOutputTokens = Math.min(
context.profile.limits.maximumOutputTokens,
remainingOutputTokens
)
const prepared = prepareProviderRequest(
context.profile,
request,
reservedOutputTokens
)
const requestDigest = sha256(prepared.canonicalRequest)
const callId = createAgentModelCallId(context)
this.#ledger.claim({
callId,
bindingId: context.bindingId,
operationId: context.operationId,
promptSequence: context.promptSequence,
roundIndex: context.roundIndex,
profileDigest: context.profileDigest,
requestDigest
})
const timeout = new AbortController()
const timer = setTimeout(
() => timeout.abort(new Error('provider timeout')),
context.profile.limits.requestTimeoutMilliseconds
)
timer.unref?.()
let response: Response
try {
response = await this.#fetch(prepared.url, {
method: 'POST',
redirect: 'error',
headers: prepared.headers,
body: prepared.body,
signal: AbortSignal.any([signal, timeout.signal])
})
} catch {
clearTimeout(timer)
const code = timeout.signal.aborted
? 'timeout'
: signal.aborted
? 'cancelled'
: 'outcome-unknown'
this.#ledger.outcomeUnknown(callId, code)
throw new AgentModelGatewayError(
code,
'Provider dispatch outcome is unknown'
)
}
let bytes: Uint8Array
try {
bytes = await readBoundedResponseBytes(response, {
maxBytes: REMOTE_MODEL_GATEWAY_LIMITS.maximumResponseBodyBytes,
tooLargeMessage:
'Provider response exceeds the configured byte limit',
truncatedMessage: 'Provider response body was truncated'
})
} catch (error) {
clearTimeout(timer)
const code =
error instanceof BoundedResponseTooLargeError
? 'response-too-large'
: 'outcome-unknown'
this.#ledger.outcomeUnknown(callId, code)
throw error instanceof AgentModelGatewayError
? error
: new AgentModelGatewayError(
code,
error instanceof BoundedResponseTruncatedError
? error.message
: code === 'response-too-large'
? (error as Error).message
: 'Provider response could not be read completely'
)
}
clearTimeout(timer)
if (signal.aborted || timeout.signal.aborted) {
const code = timeout.signal.aborted ? 'timeout' : 'cancelled'
this.#ledger.outcomeUnknown(callId, code)
throw new AgentModelGatewayError(
code,
'Provider response arrived after cancellation'
)
}
const headers: Record<string, string> = {}
for (const name of RESPONSE_HEADERS) {
const value = response.headers.get(name)
if (
value !== null &&
Buffer.byteLength(value, 'utf8') <=
REMOTE_MODEL_GATEWAY_LIMITS.maximumHeaderValueBytes &&
!/[\r\n]/u.test(value)
) {
headers[name] = value
}
}
const bridgeResponse = remoteModelGatewayResponseSchema.parse({
status: response.status,
headers,
bodyBase64: Buffer.from(bytes).toString('base64')
})
this.#promptOutputTokens.set(
promptKey,
usedOutputTokens +
Math.min(
reservedOutputTokens,
responseOutputTokens(bytes) ?? reservedOutputTokens
)
)
this.#ledger.complete(callId)
let finalized = false
return {
response: bridgeResponse,
acknowledgeDelivery: async () => {
if (finalized) {
throw new AgentModelGatewayError(
'conflict',
'Provider response delivery was already finalized'
)
}
finalized = true
this.#ledger.delivered(callId)
},
failDelivery: () => {
finalized = true
// The completed response cannot be reconstructed from the metadata-only
// ledger, so an unacknowledged handoff is intentionally non-retryable.
}
}
}
finalizePrompt(bindingId: string, operationId: string): void {
this.#promptOutputTokens.delete(`${bindingId}\0${operationId}`)
}
}
export function createAgentModelCallId(
context: Pick<
AgentModelGatewayContext,
'bindingId' | 'operationId' | 'roundIndex'
>
): string {
return `model-call-${createHash('sha256')
.update(
JSON.stringify({
bindingId: context.bindingId,
operationId: context.operationId,
roundIndex: context.roundIndex
})
)
.digest('hex')}`
}
function prepareProviderRequest(
profile: AgentPromptModelProfile,
request: RemoteModelGatewayRequest,
maximumOutputTokens: number
): {
url: URL
headers: Record<string, string>
body: Uint8Array
canonicalRequest: string
} {
if (!pathMatches(profile.protocol, request.path)) {
throw new AgentModelGatewayError(
'policy',
'Provider path does not match the prompt profile'
)
}
const inputBody = Buffer.from(request.bodyBase64, 'base64')
let json: unknown
try {
json = JSON.parse(
new TextDecoder('utf-8', { fatal: true }).decode(inputBody)
)
} catch {
throw new AgentModelGatewayError(
'policy',
'Provider request body is not UTF-8 JSON'
)
}
try {
assertTextModelRequestPolicy(
{
protocol: profile.protocol,
model: profile.model,
supportsImageInput: profile.capabilities.imageInput
},
json
)
} catch (error) {
if (!(error instanceof ModelRequestPolicyError)) {
throw error
}
throw new AgentModelGatewayError(
'policy',
'Provider request does not match the prompt profile'
)
}
const normalized = { ...(json as Record<string, unknown>) }
const outputField =
profile.protocol === 'openai-responses'
? 'max_output_tokens'
: 'max_tokens'
const requestedOutput = normalized[outputField]
if (
requestedOutput !== undefined &&
(
!Number.isSafeInteger(requestedOutput) ||
(requestedOutput as number) < 1
)
) {
throw new AgentModelGatewayError(
'policy',
'Provider request has an invalid output token limit'
)
}
normalized[outputField] =
requestedOutput === undefined
? maximumOutputTokens
: Math.min(requestedOutput as number, maximumOutputTokens)
const body = Buffer.from(JSON.stringify(normalized), 'utf8')
const url = providerUrl(profile)
const headers = { ...request.headers }
if (profile.protocol === 'anthropic-messages') {
headers['anthropic-version'] = '2023-06-01'
if (profile.authentication === 'api-key') {
headers['x-api-key'] = profile.apiKey!
}
} else if (profile.authentication === 'api-key') {
headers.authorization = `Bearer ${profile.apiKey!}`
}
return {
url,
headers,
body,
canonicalRequest: JSON.stringify({
profileId: profile.profileId,
provider: profile.provider,
protocol: profile.protocol,
model: profile.model,
url: url.toString(),
request: {
path: request.path,
headers: Object.fromEntries(
Object.entries(request.headers).sort(([left], [right]) =>
left.localeCompare(right)
)
),
bodyBase64: body.toString('base64')
}
})
}
}
function responseOutputTokens(body: Uint8Array): number | undefined {
let value: unknown
try {
value = JSON.parse(new TextDecoder().decode(body))
} catch {
return undefined
}
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
return undefined
}
const usage = (value as { usage?: unknown }).usage
if (usage === null || typeof usage !== 'object' || Array.isArray(usage)) {
return undefined
}
const record = usage as Record<string, unknown>
const tokens = record.output_tokens ?? record.completion_tokens
return typeof tokens === 'number' &&
Number.isSafeInteger(tokens) &&
tokens >= 0
? tokens
: undefined
}
function providerUrl(profile: AgentPromptModelProfile): URL {
const url = new URL(profile.baseUrl)
const path = url.pathname.replace(/\/+$/u, '')
if (profile.protocol === 'anthropic-messages') {
url.pathname = `${path.endsWith('/v1') ? path : `${path}/v1`}/messages`
} else if (profile.protocol === 'openai-chat-completions') {
url.pathname = `${path}/chat/completions`
} else {
url.pathname = `${path}/responses`
}
url.hash = ''
return url
}
function pathMatches(
protocol: AgentPromptModelProfile['protocol'],
path: string
): boolean {
if (protocol === 'anthropic-messages') {
return path === '/v1/messages'
}
if (protocol === 'openai-chat-completions') {
return path === '/chat/completions' || path === '/v1/chat/completions'
}
return path === '/responses' || path === '/v1/responses'
}
function sha256(value: string | Uint8Array): string {
return `sha256:${createHash('sha256').update(value).digest('hex')}`
}
@@ -0,0 +1,494 @@
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type {
Client,
ClientSideConnection,
PromptResponse
} from '@agentclientprotocol/sdk'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type {
RuntimeAcpProcessOutput,
RuntimeAcpProcessOwner
} from './runtime-acp-backend'
import { AgentOwnedAcpPrompt } from './agent-owned-acp-prompt'
import { SemanticPromptStore } from './semantic-prompt-store'
const temporary: string[] = []
afterEach(() => {
for (const path of temporary.splice(0)) {
rmSync(path, { recursive: true, force: true })
}
})
describe('AgentOwnedAcpPrompt', () => {
it('keeps the original prompt promise through Desktop detach and recovers the final response', async () => {
const root = mkdtempSync(join(tmpdir(), 'goodbuddy-owned-acp-'))
temporary.push(root)
mkdirSync(join(root, 'state'), { mode: 0o700 })
const transcript = new SemanticPromptStore(
join(root, 'state', 'prompts.sqlite')
)
transcript.prepare({
bindingId: 'binding-1',
operationId: 'operation-1',
requestId: 'operation-1',
controllerId: 'controller-1',
preparationDigest: `sha256:${'a'.repeat(64)}`,
promptSequence: 0
})
let client: Client | undefined
let resolvePrompt!: (response: PromptResponse) => void
const prompt = vi.fn(
async () =>
await new Promise<PromptResponse>((resolve) => {
resolvePrompt = resolve
})
)
const newSession = vi.fn(async () => ({ sessionId: 'session-1' }))
const expectedModel = 'goodbuddy-openai-chat/model-1'
const setSessionConfigOption = vi.fn(async () => ({
configOptions: [
{
type: 'select',
id: 'model',
name: 'Model',
currentValue: expectedModel,
options: [
{
name: 'Model 1',
value: expectedModel
}
]
}
]
}))
const initialize = vi.fn(async () => ({
protocolVersion: 1,
agentCapabilities: {}
}))
const process = new MemoryProcess()
const complete = vi.fn(async () => {
await process.completePrompt()
})
const owner = new AgentOwnedAcpPrompt({
bindingId: 'binding-1',
controllerId: 'controller-1',
workspaceDirectory: '/workspace',
workMode: 'execute',
expectedModel,
process,
transcript,
completePrompt: async (_operationId, status) => {
expect(status).toBe('completed')
await complete()
},
createConnection: (factory) => {
client = factory()
return {
initialize,
newSession,
setSessionConfigOption,
prompt,
cancel: vi.fn(async () => undefined)
} as unknown as ClientSideConnection
}
})
const request = {
bindingId: 'binding-1',
operationId: 'operation-1',
requestId: 'operation-1',
prompt: [{ type: 'text' as const, text: 'finish independently' }]
}
await expect(owner.start(request)).resolves.toMatchObject({
state: 'running',
sessionId: 'session-1'
})
// The controller transport can disappear; it is intentionally absent from
// the owner lifetime and therefore cannot cancel this Promise.
const detachedDesktop = new AbortController()
detachedDesktop.abort()
await client!.sessionUpdate({
sessionId: 'session-1',
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'done' }
}
})
resolvePrompt({ stopReason: 'end_turn' })
await vi.waitFor(() => expect(complete).toHaveBeenCalledOnce())
expect(initialize).toHaveBeenCalledOnce()
expect(newSession).toHaveBeenCalledOnce()
expect(setSessionConfigOption).toHaveBeenCalledWith({
sessionId: 'session-1',
configId: 'model',
value: expectedModel
})
expect(prompt).toHaveBeenCalledOnce()
expect(process.completions).toBe(1)
expect(
transcript.attach('binding-1', 'operation-1', 'controller-1')
).toMatchObject({ state: 'completed' })
expect(
transcript.page({
bindingId: 'binding-1',
operationId: 'operation-1',
controllerId: 'controller-1',
afterSequence: '0',
limit: 10
}).events.map((event) => event.kind)
).toEqual(['session-update', 'prompt-terminal'])
await expect(owner.start(request)).resolves.toMatchObject({
state: 'completed'
})
expect(prompt).toHaveBeenCalledOnce()
transcript.close()
})
it('records one outcome-unknown terminal when Runtime completion fails', async () => {
const root = mkdtempSync(join(tmpdir(), 'goodbuddy-owned-acp-'))
temporary.push(root)
mkdirSync(join(root, 'state'), { mode: 0o700 })
const transcript = new SemanticPromptStore(
join(root, 'state', 'prompts.sqlite')
)
transcript.prepare({
bindingId: 'binding-1',
operationId: 'operation-1',
requestId: 'operation-1',
controllerId: 'controller-1',
preparationDigest: `sha256:${'a'.repeat(64)}`,
promptSequence: 0
})
const completePrompt = vi.fn(async () => {
throw new Error('Runtime completion failed')
})
const owner = new AgentOwnedAcpPrompt({
bindingId: 'binding-1',
controllerId: 'controller-1',
workspaceDirectory: '/workspace',
workMode: 'execute',
process: new MemoryProcess(),
transcript,
completePrompt,
createConnection: () =>
({
initialize: async () => ({
protocolVersion: 1,
agentCapabilities: {}
}),
newSession: async () => ({ sessionId: 'session-1' }),
prompt: async () => ({ stopReason: 'end_turn' }),
cancel: vi.fn(async () => undefined)
}) as unknown as ClientSideConnection
})
await owner.start({
bindingId: 'binding-1',
operationId: 'operation-1',
requestId: 'operation-1',
prompt: [{ type: 'text', text: 'finish independently' }]
})
await vi.waitFor(() =>
expect(
transcript.attach('binding-1', 'operation-1', 'controller-1')
).toMatchObject({ state: 'outcome-unknown' })
)
const terminalEvents = transcript
.page({
bindingId: 'binding-1',
operationId: 'operation-1',
controllerId: 'controller-1',
afterSequence: '0',
limit: 10
})
.events.filter((event) => event.kind === 'prompt-terminal')
expect(completePrompt).toHaveBeenCalledOnce()
expect(terminalEvents).toHaveLength(1)
expect(terminalEvents[0]?.payload).toMatchObject({
status: 'outcome-unknown',
completionError: {
message: 'Runtime completion failed'
}
})
transcript.close()
})
it('terminalizes an active prompt when its Runtime process exits', async () => {
const root = mkdtempSync(join(tmpdir(), 'goodbuddy-owned-acp-'))
temporary.push(root)
mkdirSync(join(root, 'state'), { mode: 0o700 })
const transcript = new SemanticPromptStore(
join(root, 'state', 'prompts.sqlite')
)
transcript.prepare({
bindingId: 'binding-1',
operationId: 'operation-1',
requestId: 'operation-1',
controllerId: 'controller-1',
preparationDigest: `sha256:${'a'.repeat(64)}`,
promptSequence: 0
})
const process = new MemoryProcess()
const completePrompt = vi.fn(async () => undefined)
const owner = new AgentOwnedAcpPrompt({
bindingId: 'binding-1',
controllerId: 'controller-1',
workspaceDirectory: '/workspace',
workMode: 'execute',
process,
transcript,
completePrompt,
createConnection: () =>
({
initialize: async () => ({
protocolVersion: 1,
agentCapabilities: {}
}),
newSession: async () => ({ sessionId: 'session-1' }),
prompt: async () =>
await new Promise<PromptResponse>(() => undefined),
cancel: vi.fn(async () => undefined)
}) as unknown as ClientSideConnection
})
await owner.start({
bindingId: 'binding-1',
operationId: 'operation-1',
requestId: 'operation-1',
prompt: [{ type: 'text', text: 'wait for Runtime exit' }]
})
await process.emitExit()
await vi.waitFor(() =>
expect(
transcript.attach('binding-1', 'operation-1', 'controller-1')
).toMatchObject({ state: 'failed' })
)
expect(completePrompt).toHaveBeenCalledWith(
'operation-1',
'failed'
)
expect(
transcript.page({
bindingId: 'binding-1',
operationId: 'operation-1',
controllerId: 'controller-1',
afterSequence: '0',
limit: 10
}).events
).toEqual([
expect.objectContaining({
kind: 'prompt-terminal',
payload: expect.objectContaining({
status: 'failed',
error: expect.objectContaining({
message: 'ACP Runtime process exited'
})
})
})
])
transcript.close()
})
it('answers permission requests autonomously from the accepted work mode', async () => {
const root = mkdtempSync(join(tmpdir(), 'goodbuddy-owned-acp-'))
temporary.push(root)
mkdirSync(join(root, 'state'), { mode: 0o700 })
const transcript = new SemanticPromptStore(
join(root, 'state', 'prompts.sqlite')
)
transcript.prepare({
bindingId: 'binding-1',
operationId: 'operation-1',
requestId: 'operation-1',
controllerId: 'controller-1',
preparationDigest: `sha256:${'a'.repeat(64)}`,
promptSequence: 0
})
let client!: Client
const owner = new AgentOwnedAcpPrompt({
bindingId: 'binding-1',
controllerId: 'controller-1',
workspaceDirectory: '/workspace',
workMode: 'execute',
process: new MemoryProcess(),
transcript,
completePrompt: async () => undefined,
createConnection: (factory) => {
client = factory()
return {
initialize: async () => ({
protocolVersion: 1,
agentCapabilities: {}
}),
newSession: async () => ({ sessionId: 'session-1' }),
prompt: async () =>
await new Promise<PromptResponse>(() => undefined),
cancel: async () => undefined
} as unknown as ClientSideConnection
}
})
await owner.start({
bindingId: 'binding-1',
operationId: 'operation-1',
requestId: 'operation-1',
prompt: [{ type: 'text', text: 'run one tool' }]
})
await expect(
client.requestPermission({
sessionId: 'session-1',
toolCall: {
sessionUpdate: 'tool_call',
toolCallId: 'tool-1',
title: 'write',
kind: 'edit',
status: 'pending'
},
options: [
{
optionId: 'reject',
name: 'Reject',
kind: 'reject_once'
},
{
optionId: 'allow',
name: 'Allow',
kind: 'allow_once'
}
]
})
).resolves.toEqual({
outcome: { outcome: 'selected', optionId: 'allow' }
})
transcript.close()
})
it('allows only one-shot reads while Ask is detached', async () => {
const root = mkdtempSync(join(tmpdir(), 'goodbuddy-owned-acp-'))
temporary.push(root)
mkdirSync(join(root, 'state'), { mode: 0o700 })
const transcript = new SemanticPromptStore(
join(root, 'state', 'prompts.sqlite')
)
transcript.prepare({
bindingId: 'binding-1',
operationId: 'operation-1',
requestId: 'operation-1',
controllerId: 'controller-1',
preparationDigest: `sha256:${'a'.repeat(64)}`,
promptSequence: 0
})
let client!: Client
const owner = new AgentOwnedAcpPrompt({
bindingId: 'binding-1',
controllerId: 'controller-1',
workspaceDirectory: '/workspace',
workMode: 'ask',
process: new MemoryProcess(),
transcript,
completePrompt: async () => undefined,
createConnection: (factory) => {
client = factory()
return {
initialize: async () => ({
protocolVersion: 1,
agentCapabilities: {}
}),
newSession: async () => ({ sessionId: 'session-1' }),
prompt: async () =>
await new Promise<PromptResponse>(() => undefined),
cancel: async () => undefined
} as unknown as ClientSideConnection
}
})
await owner.start({
bindingId: 'binding-1',
operationId: 'operation-1',
requestId: 'operation-1',
prompt: [{ type: 'text', text: 'inspect one file' }]
})
const permission = (
kind: 'read' | 'edit'
): Parameters<Client['requestPermission']>[0] => ({
sessionId: 'session-1',
toolCall: {
sessionUpdate: 'tool_call',
toolCallId: `tool-${kind}`,
title: kind,
kind,
status: 'pending'
},
options: [
{
optionId: `${kind}-reject`,
name: 'Reject',
kind: 'reject_once'
},
{
optionId: `${kind}-allow`,
name: 'Allow',
kind: 'allow_once'
}
]
})
await expect(client.requestPermission(permission('read'))).resolves.toEqual({
outcome: { outcome: 'selected', optionId: 'read-allow' }
})
await expect(client.requestPermission(permission('edit'))).resolves.toEqual({
outcome: { outcome: 'selected', optionId: 'edit-reject' }
})
transcript.close()
})
})
class MemoryProcess implements RuntimeAcpProcessOwner {
readonly identity = {
launchId: 'launch-1',
processId: 'process-1',
supervisorIdentityDigest: `sha256:${'a'.repeat(64)}`
}
completions = 0
readonly #listeners = new Set<
(output: RuntimeAcpProcessOutput) => void | Promise<void>
>()
readonly #exitListeners = new Set<
() => void | Promise<void>
>()
beginPrompt(): void {}
completePrompt(): void {
this.completions += 1
}
writeStdin(): void {}
stop(): void {}
reconcile() {
return {
identity: this.identity,
state: 'running' as const,
processTree: 'running' as const
}
}
subscribeOutput(
listener: (output: RuntimeAcpProcessOutput) => void | Promise<void>
): () => void {
this.#listeners.add(listener)
return () => this.#listeners.delete(listener)
}
subscribeExit(listener: () => void | Promise<void>): () => void {
this.#exitListeners.add(listener)
return () => this.#exitListeners.delete(listener)
}
async emitExit(): Promise<void> {
for (const listener of [...this.#exitListeners]) {
await listener()
}
}
}
+402
View File
@@ -0,0 +1,402 @@
import { createHash } from 'node:crypto'
import {
ClientSideConnection,
PROTOCOL_VERSION,
ndJsonStream,
type Client,
type PromptResponse,
type RequestPermissionRequest,
type RequestPermissionResponse,
type SessionNotification
} from '@agentclientprotocol/sdk'
import { canonicalJson } from '../shared/agent-protocol/canonical'
import type {
RemoteOwnedPromptStartRequest,
RemoteOwnedPromptStartResult
} from '../shared/remote-agent-contracts'
import type { RuntimeAcpProcessOwner } from './runtime-acp-backend'
import {
SemanticPromptStore,
SemanticPromptStoreError
} from './semantic-prompt-store'
export type AgentOwnedAcpPromptOptions = {
bindingId: string
controllerId: string
workspaceDirectory: string
workMode: 'ask' | 'execute'
expectedModel?: string
process: RuntimeAcpProcessOwner
transcript: SemanticPromptStore
completePrompt: (
operationId: string,
status: 'completed' | 'failed' | 'cancelled' | 'outcome-unknown',
response?: PromptResponse
) => void | Promise<void>
resolveTerminalState?: (
proposed: 'completed' | 'failed' | 'cancelled'
) => 'completed' | 'failed' | 'cancelled' | 'outcome-unknown'
createConnection?: (
client: () => Client,
stream: ReturnType<typeof ndJsonStream>
) => ClientSideConnection
}
/**
* Owns the ACP ClientSideConnection and the original prompt Promise. No
* transport-connection signal is part of this lifetime.
*/
export class AgentOwnedAcpPrompt {
readonly #options: AgentOwnedAcpPromptOptions
readonly #connection: ClientSideConnection
readonly #input: ReadableStream<Uint8Array>
readonly #unsubscribeOutput: () => void
readonly #unsubscribeExit: () => void
readonly #processExit: Promise<never>
#sessionId?: string
#promptPromise?: Promise<void>
#closed = false
#initialized = false
#active?: {
operationId: string
}
constructor(options: AgentOwnedAcpPromptOptions) {
this.#options = options
let controller: ReadableStreamDefaultController<Uint8Array> | undefined
let rejectProcessExit!: (error: Error) => void
this.#processExit = new Promise<never>((_resolve, reject) => {
rejectProcessExit = reject
})
void this.#processExit.catch(() => undefined)
this.#input = new ReadableStream<Uint8Array>({
start(value) {
controller = value
}
})
const output = new WritableStream<Uint8Array>({
write: async (chunk) => {
await options.process.writeStdin(chunk)
}
})
const unsubscribe = options.process.subscribeOutput((event) => {
if (
event.stream === 'stdout' &&
event.data.byteLength > 0 &&
!this.#closed
) {
controller?.enqueue(event.data.slice())
}
})
this.#unsubscribeOutput = unsubscribe ?? (() => undefined)
const unsubscribeExit = options.process.subscribeExit?.(() => {
if (this.#closed) {
return
}
const error = new Error('ACP Runtime process exited')
controller?.error(error)
controller = undefined
rejectProcessExit(error)
})
this.#unsubscribeExit = unsubscribeExit ?? (() => undefined)
const stream = ndJsonStream(output, this.#input)
this.#connection =
options.createConnection?.(() => this.#client(), stream) ??
new ClientSideConnection(() => this.#client(), stream)
}
async start(
request: RemoteOwnedPromptStartRequest
): Promise<RemoteOwnedPromptStartResult> {
if (
request.bindingId !== this.#options.bindingId
) {
throw new SemanticPromptStoreError(
'Owned ACP prompt identity does not match its preparation',
'conflict'
)
}
const startDigest = digest(canonicalJson(request))
const existing = this.#options.transcript.findStarted({
bindingId: request.bindingId,
operationId: request.operationId,
controllerId: this.#options.controllerId,
startDigest
})
if (existing !== undefined) {
return existing
}
if (this.#active !== undefined) {
throw new SemanticPromptStoreError(
'Another ACP prompt is still active',
'conflict'
)
}
if (!this.#initialized) {
const initialization = await this.#whileProcessAlive(
this.#connection.initialize({
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: {
terminal: false,
fs: {
readTextFile: false,
writeTextFile: false
}
},
clientInfo: {
name: 'GoodBuddy Agent',
version: '1'
}
})
)
if (initialization.protocolVersion !== PROTOCOL_VERSION) {
throw new Error('ACP Runtime protocol version is incompatible')
}
if (request.acpSessionId !== undefined && this.#sessionId === undefined) {
if (initialization.agentCapabilities?.loadSession === true) {
await this.#whileProcessAlive(
this.#connection.loadSession({
sessionId: request.acpSessionId,
cwd: this.#options.workspaceDirectory,
mcpServers: []
})
)
} else if (
initialization.agentCapabilities?.sessionCapabilities?.resume === true
) {
await this.#whileProcessAlive(
this.#connection.resumeSession({
sessionId: request.acpSessionId,
cwd: this.#options.workspaceDirectory,
mcpServers: []
})
)
} else {
throw new Error('ACP Runtime cannot resume the requested session')
}
this.#sessionId = request.acpSessionId
} else if (this.#sessionId === undefined) {
const created = await this.#whileProcessAlive(
this.#connection.newSession({
cwd: this.#options.workspaceDirectory,
mcpServers: []
})
)
this.#sessionId = created.sessionId
}
if (this.#options.expectedModel !== undefined) {
const configured = await this.#whileProcessAlive(
this.#connection.setSessionConfigOption({
sessionId: this.#sessionId!,
configId: 'model',
value: this.#options.expectedModel
})
)
const selectedModel = configured.configOptions.find(
(option) => option.id === 'model' && option.type === 'select'
)
if (
selectedModel === undefined ||
selectedModel.currentValue !== this.#options.expectedModel
) {
throw new Error('ACP Runtime did not select the prepared model')
}
}
this.#initialized = true
} else if (
request.acpSessionId !== undefined &&
request.acpSessionId !== this.#sessionId
) {
throw new Error('ACP session identity cannot change within a binding')
}
this.#active = {
operationId: request.operationId
}
const begun = this.#options.transcript.begin({
bindingId: this.#options.bindingId,
operationId: request.operationId,
startDigest,
sessionId: this.#sessionId!
})
// Keep this exact Promise alive on Agent. The caller receives only the
// durable operation identity and may disconnect immediately.
this.#promptPromise = this.#whileProcessAlive(
this.#connection.prompt({
sessionId: this.#sessionId,
prompt: request.prompt
})
)
.then(
async (response) => {
const proposed =
response.stopReason === 'cancelled'
? 'cancelled'
: 'completed'
let state =
this.#options.resolveTerminalState?.(proposed) ?? proposed
let completionError: unknown
try {
await this.#options.completePrompt(
request.operationId,
state,
response
)
} catch (error) {
state = 'outcome-unknown'
completionError = error
}
this.#appendTerminal(state, {
status: state,
response,
...(completionError === undefined
? {}
: { completionError: boundedError(completionError) })
})
},
async (error: unknown) => {
let state =
this.#options.resolveTerminalState?.('failed') ?? 'failed'
let completionError: unknown
try {
await this.#options.completePrompt(
request.operationId,
state
)
} catch (completionFailure) {
state = 'outcome-unknown'
completionError = completionFailure
}
this.#appendTerminal(state, {
status: state,
error: boundedError(error),
...(completionError === undefined
? {}
: { completionError: boundedError(completionError) })
})
}
)
.finally(() => {
this.#active = undefined
})
void this.#promptPromise.catch(() => undefined)
return begun.result
}
async cancel(): Promise<void> {
if (this.#sessionId !== undefined && !this.#closed) {
await this.#connection.cancel({ sessionId: this.#sessionId })
}
}
close(): void {
this.#clear()
}
#client(): Client {
return {
requestPermission: async (request) =>
this.#handlePermission(request),
sessionUpdate: async (notification) => {
this.#handleSessionUpdate(notification)
},
extMethod: async () => ({}),
extNotification: async () => undefined
}
}
#handleSessionUpdate(notification: SessionNotification): void {
if (
notification.sessionId !== this.#sessionId &&
this.#sessionId !== undefined
) {
throw new Error('ACP session notification identity changed')
}
this.#options.transcript.append({
bindingId: this.#options.bindingId,
operationId: this.#requireActive().operationId,
kind: 'session-update',
payload: notification
})
}
#handlePermission(
request: RequestPermissionRequest
): RequestPermissionResponse {
const selected =
this.#options.workMode === 'execute'
? request.options.find((option) => option.kind === 'allow_always') ??
request.options.find((option) => option.kind === 'allow_once')
: request.toolCall.kind === 'read'
? request.options.find((option) => option.kind === 'allow_once')
: request.options.find((option) => option.kind === 'reject_always') ??
request.options.find((option) => option.kind === 'reject_once')
const response: RequestPermissionResponse =
selected === undefined
? { outcome: { outcome: 'cancelled' } }
: {
outcome: {
outcome: 'selected',
optionId: selected.optionId
}
}
this.#options.transcript.append({
bindingId: this.#options.bindingId,
operationId: this.#requireActive().operationId,
kind: 'permission-decision',
payload: {
sessionId: request.sessionId,
toolCallId: request.toolCall.toolCallId,
outcome: response.outcome
}
})
return response
}
#appendTerminal(
state: 'completed' | 'failed' | 'cancelled' | 'outcome-unknown',
payload: unknown
): void {
this.#options.transcript.append({
bindingId: this.#options.bindingId,
operationId: this.#requireActive().operationId,
kind: 'prompt-terminal',
payload,
terminalState: state
})
}
#clear(): void {
if (this.#closed) {
return
}
this.#closed = true
this.#unsubscribeOutput()
this.#unsubscribeExit()
}
async #whileProcessAlive<T>(operation: Promise<T>): Promise<T> {
return await Promise.race([operation, this.#processExit])
}
#requireActive(): NonNullable<AgentOwnedAcpPrompt['#active']> {
if (this.#active === undefined) {
throw new Error('ACP notification has no active prompt')
}
return this.#active
}
}
function digest(value: string): string {
return `sha256:${createHash('sha256').update(value).digest('hex')}`
}
function boundedError(error: unknown): { name: string; message: string } {
const name =
error instanceof Error ? error.name.slice(0, 256) : 'Error'
const message =
error instanceof Error
? error.message.slice(0, 8 * 1024)
: 'ACP prompt failed'
return { name, message }
}
+3
View File
@@ -1,4 +1,6 @@
export * from './bundle-file-verification'
export * from './agent-model-gateway'
export * from './agent-owned-acp-prompt'
export * from './channel-state'
export * from './cli'
export * from './controller-registry'
@@ -25,6 +27,7 @@ export * from './runtime-composition'
export * from './runtime-acp-backend'
export * from './runtime-owner-registry'
export * from './runtime-process-identity'
export * from './semantic-prompt-store'
export * from './structured-command-runner'
export * from './workspace-git-service'
export * from './workspace-path-access'
+19 -2
View File
@@ -499,7 +499,7 @@ export function createOpenCodeModelBridgeProviderConfig(input: {
const providerId = descriptor.id
const baseURL = `${origin}/v1`
return {
model: `${providerId}/${model}`,
model: openCodeModelBridgeModelId(input.protocol, model),
// GoodBuddy owns conversation titles. A first-prompt title request can
// race the real ACP prompt, abandon its Provider response, and correctly
// poison the no-replay ledger before the user request can run.
@@ -537,6 +537,14 @@ export function createOpenCodeModelBridgeProviderConfig(input: {
}
}
export function openCodeModelBridgeModelId(
protocol: ModelBridgeProtocol,
modelInput: string
): string {
const model = boundedMetadataText(modelInput, 'Model name')
return `${providerDescriptor(protocol).id}/${model}`
}
async function parseHttpRequest(
incoming: IncomingMessage,
routeToken: string
@@ -667,6 +675,15 @@ function isExpectedSdkAuthenticationHeader(
value === `Bearer ${MODEL_BRIDGE_SDK_AUTH_SENTINEL}`
)
}
if (
path === '/chat/completions' ||
path === '/v1/chat/completions'
) {
return (
name === 'authorization' &&
value === `Bearer ${MODEL_BRIDGE_SDK_AUTH_SENTINEL}`
)
}
return false
}
@@ -765,7 +782,7 @@ function providerDescriptor(protocol: ModelBridgeProtocol): {
return {
id: 'goodbuddy-openai-chat',
npm: '@ai-sdk/openai-compatible',
requiresSdkAuthSentinel: false
requiresSdkAuthSentinel: true
}
case 'openai-responses':
return {
+13 -2
View File
@@ -284,6 +284,17 @@ describe('model bridge loopback helper', () => {
expect(exchange).toHaveBeenCalledOnce()
expect(exchange.mock.calls[0]![0].headers).toEqual({})
const acceptedChat = await sendHttp(origin, {
path: '/v1/chat/completions',
headers: {
authorization: `Bearer ${MODEL_BRIDGE_SDK_AUTH_SENTINEL}`
},
body: '{}'
})
expect(acceptedChat.status).toBe(201)
expect(exchange).toHaveBeenCalledTimes(2)
expect(exchange.mock.calls[1]![0].headers).toEqual({})
for (const authorization of [
'Bearer remote-secret',
`Basic ${MODEL_BRIDGE_SDK_AUTH_SENTINEL}`
@@ -295,7 +306,7 @@ describe('model bridge loopback helper', () => {
})
expect(rejected.status).toBe(400)
}
expect(exchange).toHaveBeenCalledOnce()
expect(exchange).toHaveBeenCalledTimes(2)
} finally {
await proxy.close()
}
@@ -437,7 +448,7 @@ describe('model bridge loopback helper', () => {
protocol: 'openai-chat-completions' as const,
providerId: 'goodbuddy-openai-chat',
npm: '@ai-sdk/openai-compatible',
requiresSdkAuthSentinel: false
requiresSdkAuthSentinel: true
},
{
protocol: 'openai-responses' as const,
+12 -1
View File
@@ -78,6 +78,12 @@ const RUNTIME_ACP_METHODS = [
'runtime/escalateCancellation',
'runtime/reconcilePrompt'
] as const
const AUTONOMOUS_PROMPT_METHODS = [
'runtime/startPrompt',
'runtime/attachPrompt',
'runtime/pagePromptTranscript',
'runtime/ackPromptTranscript'
] as const
export type ProtocolMethodContext = {
controller: ControllerLease
@@ -183,6 +189,11 @@ export class AgentProtocolServer {
RUNTIME_ACP_METHODS.every(
(method) => this.#methods[method] !== undefined
)
const autonomousPromptsImplemented =
runtimeAcpImplemented &&
AUTONOMOUS_PROMPT_METHODS.every(
(method) => this.#methods[method] !== undefined
)
const modelBridgeImplemented =
runtimeAcpImplemented &&
this.#onBlobFrame !== undefined &&
@@ -209,7 +220,7 @@ export class AgentProtocolServer {
? [
{
name: 'runtime/acp',
version: 3,
version: autonomousPromptsImplemented ? 4 : 3,
critical: true
},
...(modelBridgeImplemented
+268 -1
View File
@@ -6,6 +6,7 @@ import {
} from '../shared/remote-agent-contracts'
import type { RemoteRuntimeBundleManifest } from '../shared/remote-runtime-launch-contracts'
import type { ControllerLease } from './controller-registry'
import { AgentModelGatewayError } from './agent-model-gateway'
import type { ProtocolMethodContext } from './protocol-server'
import type {
ModelBridgeBrokerDispatch,
@@ -168,6 +169,9 @@ class FakeProcess implements RuntimeAcpProcessOwner {
#listener?: (
output: RuntimeAcpProcessOutput
) => void | Promise<void>
readonly #exitListeners = new Set<
() => void | Promise<void>
>()
constructor(identity = '1') {
this.identity = {
@@ -226,6 +230,11 @@ class FakeProcess implements RuntimeAcpProcessOwner {
}
}
subscribeExit(listener: () => void | Promise<void>): () => void {
this.#exitListeners.add(listener)
return () => this.#exitListeners.delete(listener)
}
async emit(
stream: 'stdout' | 'stderr',
value: string
@@ -235,6 +244,19 @@ class FakeProcess implements RuntimeAcpProcessOwner {
data: Buffer.from(value)
})
}
async emitExit(
state: RuntimeAcpProcessReconciliation['state'] = 'failed'
): Promise<void> {
this.reconciliation = {
identity: this.identity,
state,
processTree: 'empty'
}
for (const listener of [...this.#exitListeners]) {
await listener()
}
}
}
type Harness = ReturnType<typeof harness>
@@ -246,6 +268,11 @@ function harness(input: {
bridgeCloseError?: boolean
uniqueProcesses?: boolean
outputGate?: Promise<void>
agentOwned?: boolean
modelGateway?: {
dispatch: ReturnType<typeof vi.fn>
finalizePrompt: ReturnType<typeof vi.fn>
}
} = {}) {
const workMode = input.workMode ?? 'ask'
let now = input.now ?? 1_000
@@ -266,6 +293,16 @@ function harness(input: {
let bridgeDispatch: ModelBridgeBrokerDispatch | undefined
const launchModelBridges: unknown[] = []
const resolved = resolvedBundle()
const semanticPrompts = input.agentOwned
? {
prepare: vi.fn(() => ({ created: true })),
append: vi.fn(),
findStarted: vi.fn(),
attach: vi.fn(),
page: vi.fn(),
acknowledge: vi.fn()
}
: undefined
const backend = new RuntimeAcpBackend({
journal,
resolveRuntimeBundle: vi.fn(async () => resolved),
@@ -302,7 +339,17 @@ function harness(input: {
}),
close: bridgeCloses
} as unknown as ModelBridgeBrokerServer
}
},
...(semanticPrompts
? {
semanticPrompts: semanticPrompts as never,
modelGateway:
(input.modelGateway ?? {
dispatch: vi.fn(),
finalizePrompt: vi.fn()
}) as never
}
: {})
})
const context = protocolContext()
const openRequest = {
@@ -326,6 +373,7 @@ function harness(input: {
},
launchModelBridges,
outputFrames,
semanticPrompts,
openRequest,
preparation: (
overrides: Partial<RemotePromptOperationPreparation> = {}
@@ -346,6 +394,24 @@ function harness(input: {
runtimeId: openRequest.runtimeId,
runtimeBundleDigest: openRequest.runtimeBundleDigest,
runtimeAdapterDigest: digest('2'),
modelProfile: {
profileId: 'profile-1',
modelProfileDigest: digest('3'),
provider: 'openai',
baseUrl: 'https://model.example/v1',
model: 'test-model',
protocol: 'openai-responses',
authentication: 'api-key',
apiKey: 'test-api-key',
capabilities: { imageInput: false },
limits: {
maximumOutputTokens: 4_096,
maximumModelCalls: 100,
maximumTotalOutputTokens: 409_600,
requestTimeoutMilliseconds: 60_000
}
},
promptSequence: 0,
deadlineAt: new Date(now + 60_000).toISOString(),
budget: {
maximumInputBytes: 1_024,
@@ -430,6 +496,42 @@ describe('RuntimeAcpBackend', () => {
expect(fixture.bridgeCloses).toHaveBeenCalledOnce()
})
it('starts the Agent-local model bridge from a prompt profile without a Main blob channel', async () => {
const fixture = harness({ agentOwned: true })
await invoke(
fixture,
'runtime/openAcpChannel',
fixture.openRequest
)
const preparation = fixture.preparation()
await invoke(
fixture,
'runtime/preparePrompt',
preparation
)
expect(fixture.lifecycle).toEqual([
'bridge-listen',
'launch'
])
expect(fixture.launchModelBridges).toEqual([
{
socketPath: '/bridge/model-bridge.sock',
policy: {
protocol: preparation.modelProfile!.protocol,
model: preparation.modelProfile!.model,
modelProfileDigest:
preparation.modelProfile!.modelProfileDigest,
supportsImageInput:
preparation.modelProfile!.capabilities.imageInput
}
}
])
expect(fixture.bridgeDispatch).toEqual(expect.any(Function))
expect(fixture.blobSink).not.toHaveBeenCalled()
})
it('runs open, prepare, ACP delivery, durable output, cursors, and close', async () => {
const fixture = harness()
const opened = await invoke(
@@ -693,6 +795,171 @@ describe('RuntimeAcpBackend', () => {
})
})
it('reattaches a detached Agent-owned operation without replacing its process or raw output channel', async () => {
const fixture = harness({ agentOwned: true })
await open(fixture)
await invoke(
fixture,
'runtime/preparePrompt',
fixture.preparation()
)
await fixture.process.emit('stdout', 'semantic-only-output')
expect(fixture.outputFrames).toEqual([])
fixture.context.abort.abort()
await new Promise<void>((resolve) => setImmediate(resolve))
const replacement = protocolContext({
generation: 2,
connectionId: 'connection-2'
})
replacement.controllerTakeoverProven = true
const reopened = await fixture.backend.methods[
'runtime/openAcpChannel'
]!(fixture.openRequest, replacement)
expect(reopened).toMatchObject({
bindingId: 'binding-1',
channelEpoch: '1000'
})
expect(fixture.process.stops).toEqual([])
await fixture.backend.dispose()
})
it('revokes an Agent-owned preparation that never starts', async () => {
vi.useFakeTimers()
try {
const fixture = harness({ agentOwned: true })
await open(fixture)
await invoke(
fixture,
'runtime/preparePrompt',
fixture.preparation({
deadlineAt: UNBOUNDED_REMOTE_PROMPT_DEADLINE
})
)
await vi.advanceTimersByTimeAsync(2 * 60_000)
await vi.waitFor(() =>
expect(fixture.semanticPrompts?.append).toHaveBeenCalledWith(
expect.objectContaining({
terminalState: 'outcome-unknown'
})
)
)
expect(fixture.process.stops).toContain('identity-conflict')
await fixture.backend.dispose()
} finally {
vi.useRealTimers()
}
})
it('records a failed semantic terminal when an owned Runtime exits before a model round', async () => {
const fixture = harness({ agentOwned: true })
await open(fixture)
await invoke(
fixture,
'runtime/preparePrompt',
fixture.preparation()
)
await fixture.process.emitExit('failed')
await vi.waitFor(() =>
expect(fixture.semanticPrompts?.append).toHaveBeenCalledWith(
expect.objectContaining({
terminalState: 'failed',
payload: expect.objectContaining({
error: expect.objectContaining({
name: 'RuntimeExited'
})
})
})
)
)
expect(fixture.bridgeCloses).toHaveBeenCalledOnce()
await fixture.backend.dispose()
})
it('records a semantic terminal before stopping an owned prompt at its deadline', async () => {
vi.useFakeTimers()
try {
const fixture = harness({ agentOwned: true })
await open(fixture)
await invoke(
fixture,
'runtime/preparePrompt',
fixture.preparation({
deadlineAt: new Date(1_100).toISOString()
})
)
await vi.advanceTimersByTimeAsync(100)
await vi.waitFor(() =>
expect(fixture.semanticPrompts?.append).toHaveBeenCalledWith(
expect.objectContaining({
terminalState: 'failed',
payload: expect.objectContaining({
error: expect.objectContaining({
name: 'PromptDeadlineExceeded'
})
})
})
)
)
expect(fixture.process.stops).toContain('deadline-exceeded')
await fixture.backend.dispose()
} finally {
vi.useRealTimers()
}
})
it('terminalizes outcome-unknown after an uncertain Agent gateway dispatch', async () => {
const modelGateway = {
dispatch: vi.fn(async () => {
throw new AgentModelGatewayError(
'timeout',
'Provider dispatch outcome is unknown'
)
}),
finalizePrompt: vi.fn()
}
const fixture = harness({
agentOwned: true,
modelGateway
})
await open(fixture)
await invoke(
fixture,
'runtime/preparePrompt',
fixture.preparation()
)
await expect(
fixture.bridgeDispatch?.(
{
method: 'POST',
path: '/v1/responses',
headers: { 'content-type': 'application/json' },
bodyBase64: Buffer.from(
JSON.stringify({ model: 'test-model' })
).toString('base64')
},
{
requestId: 'model-request-1',
signal: new AbortController().signal
}
)
).rejects.toMatchObject({ code: 'timeout' })
await vi.waitFor(() =>
expect(fixture.semanticPrompts?.append).toHaveBeenCalledWith(
expect.objectContaining({
terminalState: 'outcome-unknown'
})
)
)
expect(fixture.process.stops).toContain('identity-conflict')
await fixture.backend.dispose()
})
it('drains the detached old epoch before replacing an idle binding', async () => {
let releaseOutput!: () => void
const outputGate = new Promise<void>((resolve) => {
+695 -34
View File
@@ -1,4 +1,5 @@
import { z } from 'zod'
import { createHash } from 'node:crypto'
import {
AGENT_PROTOCOL_LIMITS,
AGENT_PROTOCOL_VERSION,
@@ -23,6 +24,10 @@ import {
import { canonicalJson } from '../shared/agent-protocol/canonical'
import {
acpJournalCursorSchema,
remoteOwnedPromptAttachRequestSchema,
remoteOwnedPromptStartRequestSchema,
remoteSemanticTranscriptAckRequestSchema,
remoteSemanticTranscriptPageRequestSchema,
remotePromptOperationAcceptanceSchema,
remotePromptOperationPreparationSchema,
UNBOUNDED_REMOTE_PROMPT_DEADLINE,
@@ -31,7 +36,11 @@ import {
type RemotePromptOperationPreparation
} from '../shared/remote-agent-contracts'
import type { RemoteRuntimeBundleManifest } from '../shared/remote-runtime-launch-contracts'
import type { ModelBridgePolicy } from '../shared/model-bridge-contracts'
import {
modelBridgePolicySchema,
type AgentPromptModelProfile,
type ModelBridgePolicy
} from '../shared/model-bridge-contracts'
import {
ModelBridgeBlobClient,
ModelBridgeClientError
@@ -44,6 +53,13 @@ import type {
ProtocolMethodContext,
ProtocolMethodHandler
} from './protocol-server'
import { AgentOwnedAcpPrompt } from './agent-owned-acp-prompt'
import type { SemanticPromptStore } from './semantic-prompt-store'
import {
AgentModelGatewayError,
type AgentModelGateway
} from './agent-model-gateway'
import { openCodeModelBridgeModelId } from './model-bridge-helper'
const ZERO_CURSOR = '0'
const DEFAULT_MAXIMUM_BINDINGS = 128
@@ -60,6 +76,8 @@ const DEFAULT_MAXIMUM_PENDING_OUTPUT_BYTES_GLOBAL =
AGENT_PROTOCOL_LIMITS.maximumBufferedProtocolBytes
const DEFAULT_MAXIMUM_OPERATIONS_PER_BINDING = 1_000
const DEFAULT_DISPOSE_TIMEOUT_MS = 10_000
const OWNED_PROMPT_CANCEL_GRACE_MS = 1_000
const OWNED_PROMPT_START_TIMEOUT_MS = 2 * 60_000
export type RuntimeAcpBackendErrorCode =
| 'capacity'
@@ -259,15 +277,21 @@ export type RuntimeAcpBackendOptions = {
bridgeDirectory: string
dispatch: ModelBridgeBrokerDispatch
}) => ModelBridgeBrokerServer
semanticPrompts?: SemanticPromptStore
modelGateway?: AgentModelGateway
now?: () => number
limits?: Partial<RuntimeAcpBackendLimits>
}
type PreparedOperation = {
canonicalPreparation: string
preparationDigest: string
acceptance: RemotePromptOperationAcceptance
budget: RemotePromptOperationPreparation['budget']
completion?: z.infer<typeof acpCompletePromptResultSchema>
terminalState?: 'completed' | 'failed' | 'cancelled' | 'interrupted'
modelProfile?: AgentPromptModelProfile
promptSequence: number
ownedPromptStarted?: boolean
}
type BindingState = {
@@ -297,9 +321,13 @@ type BindingState = {
inputTail: Promise<void>
outputTail: Promise<void>
deadlineTimer?: NodeJS.Timeout
ownedPromptStartTimer?: NodeJS.Timeout
modelBridgePolicy?: ModelBridgePolicy
modelBridgeClient?: ModelBridgeBlobClient
modelBridgeBroker?: ModelBridgeBrokerServer
ownedAcp?: AgentOwnedAcpPrompt
workspaceDirectory?: string
nextModelRound: number
poisoned: boolean
}
@@ -383,6 +411,14 @@ export class RuntimeAcpBackend {
this.#enqueuePublicControl(() => this.#replay(params, context)),
'runtime/preparePrompt': (params, context) =>
this.#enqueuePublicControl(() => this.#prepare(params, context)),
'runtime/startPrompt': (params, context) =>
this.#enqueuePublicControl(() => this.#startOwnedPrompt(params, context)),
'runtime/attachPrompt': (params, context) =>
this.#enqueuePublicControl(() => this.#attachOwnedPrompt(params, context)),
'runtime/pagePromptTranscript': (params, context) =>
this.#enqueuePublicControl(() => this.#pageOwnedPrompt(params, context)),
'runtime/ackPromptTranscript': (params, context) =>
this.#enqueuePublicControl(() => this.#ackOwnedPrompt(params, context)),
'runtime/completePrompt': (params, context) =>
this.#enqueuePublicControl(() => this.#complete(params, context)),
'runtime/getAcpCursors': (params, context) =>
@@ -638,6 +674,23 @@ export class RuntimeAcpBackend {
existing.activeOperationId === undefined &&
(existing.state === 'open' ||
existing.state === 'running')
const canReattachDetachedOwnedPrompt =
context.controllerTakeoverProven === true &&
context.controller.generation >
existing.controllerGeneration &&
existing.transportState === 'detached' &&
existing.activeOperationId !== undefined &&
this.#options.semanticPrompts !== undefined &&
existing.operations.get(existing.activeOperationId)
?.modelProfile !== undefined &&
existing.state === 'running'
if (canReattachDetachedOwnedPrompt) {
existing.controllerGeneration = context.controller.generation
existing.connectionId = context.controller.connectionId
existing.transportState = 'live'
this.#bindConnectionLifetime(existing, context.signal)
return acpOpenChannelResultSchema.parse(existing.openResult)
}
if (!canReplaceDetachedIdleBinding) {
this.#assertBindingOwner(existing, context)
}
@@ -671,6 +724,7 @@ export class RuntimeAcpBackend {
existing.pendingOutputBytes = 0
existing.inputTail = Promise.resolve()
existing.outputTail = Promise.resolve()
existing.nextModelRound = 0
this.#bindConnectionLifetime(existing, context.signal)
return existing.openResult
}
@@ -722,6 +776,7 @@ export class RuntimeAcpBackend {
pendingOutputBytes: 0,
inputTail: Promise.resolve(),
outputTail: Promise.resolve(),
nextModelRound: 0,
poisoned: false
}
this.#bindings.set(request.bindingId, binding)
@@ -908,10 +963,10 @@ export class RuntimeAcpBackend {
const binding = this.#bindingForContext(preparation.bindingId, context)
this.#assertPreparationIdentity(binding, preparation, context)
this.#assertBeforeDeadline(binding, preparation.deadlineAt)
const canonicalPreparation = canonicalJson(preparation)
const preparationDigest = digestSecretBearingPreparation(preparation)
const existing = binding.operations.get(preparation.operationId)
if (existing !== undefined) {
if (existing.canonicalPreparation !== canonicalPreparation) {
if (existing.preparationDigest !== preparationDigest) {
throw new RuntimeAcpBackendError(
'Prompt operation identity is already bound to different content',
'conflict'
@@ -983,18 +1038,46 @@ export class RuntimeAcpBackend {
'process'
)
}
const agentGatewayPolicy =
this.#options.modelGateway !== undefined &&
preparation.modelProfile !== undefined
? modelBridgePolicySchema.parse({
protocol: preparation.modelProfile.protocol,
model: preparation.modelProfile.model,
modelProfileDigest:
preparation.modelProfile.modelProfileDigest,
supportsImageInput:
preparation.modelProfile.capabilities.imageInput
})
: undefined
if (
agentGatewayPolicy !== undefined &&
preparation.modelBridge !== undefined &&
canonicalJson(agentGatewayPolicy) !==
canonicalJson(preparation.modelBridge.policy)
) {
throw new RuntimeAcpBackendError(
'Prompt model profile does not match its persisted policy',
'identity'
)
}
const requestedModelBridgePolicy =
agentGatewayPolicy ?? preparation.modelBridge?.policy
if (
requestedModelBridgePolicy !== undefined &&
binding.modelBridgePolicy !== undefined &&
canonicalJson(binding.modelBridgePolicy) !==
canonicalJson(preparation.modelBridge.policy)
canonicalJson(requestedModelBridgePolicy)
) {
throw new RuntimeAcpBackendError(
'Model bridge policy cannot change within a Runtime binding',
'conflict'
)
}
if (preparation.modelBridge !== undefined) {
if (
preparation.modelBridge !== undefined &&
agentGatewayPolicy === undefined
) {
for (const candidate of this.#bindings.values()) {
if (
candidate !== binding &&
@@ -1007,6 +1090,8 @@ export class RuntimeAcpBackend {
)
}
}
}
if (requestedModelBridgePolicy !== undefined) {
await this.#startModelBridge(binding, preparation, workspace)
}
const acceptedAt = new Date(this.#now()).toISOString()
@@ -1030,13 +1115,13 @@ export class RuntimeAcpBackend {
workMode: preparation.workMode,
deadlineAt: preparation.deadlineAt,
budget: preparation.budget,
...(preparation.modelBridge === undefined ||
...(binding.modelBridgePolicy === undefined ||
binding.modelBridgeBroker === undefined
? {}
: {
modelBridge: {
socketPath: binding.modelBridgeBroker.socketPath,
policy: preparation.modelBridge.policy
policy: binding.modelBridgePolicy
}
})
})
@@ -1073,23 +1158,32 @@ export class RuntimeAcpBackend {
binding.request.bindingId
)
binding.process = process
binding.workspaceDirectory = workspace.workspaceDirectory
binding.workMode = preparation.workMode
binding.state = 'running'
binding.operations.set(preparation.operationId, {
canonicalPreparation,
preparationDigest,
acceptance,
budget: preparation.budget
budget: preparation.budget,
modelProfile: preparation.modelProfile,
promptSequence: preparation.promptSequence
})
try {
const unsubscribe = process.subscribeOutput((output) =>
this.#acceptProcessOutput(binding, output)
)
if (unsubscribe !== undefined) {
binding.unsubscribeOutput = unsubscribe
if (
preparation.modelProfile === undefined ||
this.#options.modelGateway === undefined ||
this.#options.semanticPrompts === undefined
) {
const unsubscribe = process.subscribeOutput((output) =>
this.#acceptProcessOutput(binding, output)
)
if (unsubscribe !== undefined) {
binding.unsubscribeOutput = unsubscribe
}
}
const unsubscribeExit = process.subscribeExit?.(() => {
void this.#enqueueControl(async () => {
await this.#closeModelBridge(binding, true)
await this.#handleProcessExit(binding)
}).catch(() => undefined)
})
if (unsubscribeExit !== undefined) {
@@ -1129,12 +1223,33 @@ export class RuntimeAcpBackend {
)
if (!binding.operations.has(preparation.operationId)) {
binding.operations.set(preparation.operationId, {
canonicalPreparation,
preparationDigest,
acceptance,
budget: preparation.budget
budget: preparation.budget,
modelProfile: preparation.modelProfile,
promptSequence: preparation.promptSequence
})
}
this.#options.semanticPrompts?.prepare({
bindingId: preparation.bindingId,
operationId: preparation.operationId,
requestId: preparation.requestId,
controllerId: binding.controllerId,
preparationDigest,
promptSequence: preparation.promptSequence
})
binding.activeOperationId = preparation.operationId
binding.nextModelRound = 0
if (
preparation.modelProfile !== undefined &&
this.#options.modelGateway !== undefined &&
this.#options.semanticPrompts !== undefined
) {
this.#scheduleOwnedPromptStart(
binding,
preparation.operationId
)
}
this.#scheduleDeadline(binding, preparation.deadlineAt)
return acceptance
}
@@ -1168,7 +1283,17 @@ export class RuntimeAcpBackend {
'conflict'
)
}
if (binding.ownedAcp !== undefined) {
throw new RuntimeAcpBackendError(
'Agent-owned prompt completion is independent of Desktop ACK',
'conflict'
)
}
await this.#closeModelBridge(binding, true)
this.#options.modelGateway?.finalizePrompt(
binding.request.bindingId,
operation.operationId
)
if (binding.poisoned) {
throw new RuntimeAcpBackendError(
'Model response delivery was not proven before prompt completion',
@@ -1199,20 +1324,273 @@ export class RuntimeAcpBackend {
clearTimeout(binding.deadlineTimer)
binding.deadlineTimer = undefined
}
if (binding.ownedPromptStartTimer !== undefined) {
clearTimeout(binding.ownedPromptStartTimer)
binding.ownedPromptStartTimer = undefined
}
return completion
}
async #startOwnedPrompt(
params: unknown,
context: ProtocolMethodContext
) {
const request = remoteOwnedPromptStartRequestSchema.parse(params)
const binding = this.#bindingForContext(request.bindingId, context)
const prepared = binding.operations.get(request.operationId)
const existing = this.#options.semanticPrompts?.findStarted({
bindingId: request.bindingId,
operationId: request.operationId,
controllerId: context.controller.controllerId,
startDigest: `sha256:${createHash('sha256')
.update(canonicalJson(request))
.digest('hex')}`
})
if (existing !== undefined) {
if (prepared !== undefined) {
prepared.ownedPromptStarted = true
}
if (binding.ownedPromptStartTimer !== undefined) {
clearTimeout(binding.ownedPromptStartTimer)
binding.ownedPromptStartTimer = undefined
}
return existing
}
if (
this.#options.semanticPrompts === undefined ||
prepared === undefined ||
binding.activeOperationId !== request.operationId ||
binding.process === undefined ||
binding.workspaceDirectory === undefined ||
binding.state !== 'running'
) {
throw new RuntimeAcpBackendError(
'Agent-owned prompt was not prepared',
'conflict'
)
}
if (binding.ownedAcp === undefined) {
binding.ownedAcp = new AgentOwnedAcpPrompt({
bindingId: binding.request.bindingId,
controllerId: binding.controllerId,
workspaceDirectory: binding.workspaceDirectory,
workMode: binding.workMode!,
...(binding.modelBridgePolicy === undefined
? {}
: {
expectedModel: openCodeModelBridgeModelId(
binding.modelBridgePolicy.protocol,
binding.modelBridgePolicy.model
)
}),
process: binding.process,
transcript: this.#options.semanticPrompts,
completePrompt: async (operationId, status) => {
await this.#enqueueControl(async () => {
await this.#terminalizeOwnedPrompt(
binding,
operationId,
status
)
})
},
resolveTerminalState: (status) =>
binding.poisoned ? 'outcome-unknown' : status
})
}
const result = await binding.ownedAcp.start(request)
prepared.ownedPromptStarted = true
if (binding.ownedPromptStartTimer !== undefined) {
clearTimeout(binding.ownedPromptStartTimer)
binding.ownedPromptStartTimer = undefined
}
return result
}
#attachOwnedPrompt(
params: unknown,
context: ProtocolMethodContext
) {
const request = remoteOwnedPromptAttachRequestSchema.parse(params)
this.#assertControllerLive(context)
if (request.operationId !== request.requestId) {
throw new RuntimeAcpBackendError(
'Prompt attach identity does not match',
'identity'
)
}
if (this.#options.semanticPrompts === undefined) {
throw new RuntimeAcpBackendError(
'Semantic prompt recovery is unavailable',
'process'
)
}
const binding = this.#bindings.get(request.bindingId)
if (
binding === undefined ||
binding.controllerId !== context.controller.controllerId
) {
throw new RuntimeAcpBackendError(
'Prompt attach binding does not exist for this controller',
'not-found'
)
}
if (
binding.controllerGeneration !== context.controller.generation ||
binding.connectionId !== context.controller.connectionId
) {
if (
context.controllerTakeoverProven !== true ||
binding.transportState !== 'detached' ||
context.controller.generation <= binding.controllerGeneration
) {
throw new RuntimeAcpBackendError(
'Prompt attach cannot take over this binding generation',
'stale-controller'
)
}
binding.controllerGeneration = context.controller.generation
binding.connectionId = context.controller.connectionId
}
return this.#options.semanticPrompts.attach(
request.bindingId,
request.operationId,
context.controller.controllerId
)
}
#pageOwnedPrompt(
params: unknown,
context: ProtocolMethodContext
) {
const request = remoteSemanticTranscriptPageRequestSchema.parse(params)
this.#assertControllerLive(context)
if (this.#options.semanticPrompts === undefined) {
throw new RuntimeAcpBackendError(
'Semantic prompt recovery is unavailable',
'process'
)
}
return this.#options.semanticPrompts.page({
...request,
controllerId: context.controller.controllerId
})
}
#ackOwnedPrompt(
params: unknown,
context: ProtocolMethodContext
) {
const request = remoteSemanticTranscriptAckRequestSchema.parse(params)
this.#assertControllerLive(context)
if (this.#options.semanticPrompts === undefined) {
throw new RuntimeAcpBackendError(
'Semantic prompt recovery is unavailable',
'process'
)
}
return this.#options.semanticPrompts.acknowledge({
...request,
controllerId: context.controller.controllerId
})
}
async #terminalizeOwnedPrompt(
binding: BindingState,
operationId: string,
status: 'completed' | 'failed' | 'cancelled' | 'outcome-unknown'
): Promise<void> {
const prepared = binding.operations.get(operationId)
if (
prepared === undefined ||
binding.activeOperationId !== operationId ||
binding.process === undefined
) {
return
}
await this.#closeModelBridge(binding, true)
this.#options.modelGateway?.finalizePrompt(
binding.request.bindingId,
operationId
)
try {
await binding.process.completePrompt()
} catch {
binding.state = 'outcome-unknown'
prepared.modelProfile = undefined
throw new RuntimeAcpBackendError(
'Runtime prompt completion failed',
'process'
)
}
if (binding.poisoned || status === 'outcome-unknown') {
binding.state = 'outcome-unknown'
prepared.modelProfile = undefined
await this.#stopAndReconcile(
binding,
'identity-conflict'
).catch(() => undefined)
return
}
prepared.terminalState =
status === 'cancelled'
? 'cancelled'
: status === 'failed'
? 'failed'
: 'completed'
if (status === 'completed') {
prepared.completion = acpCompletePromptResultSchema.parse({
bindingId: binding.request.bindingId,
operationId,
requestId: prepared.acceptance.requestId,
status: 'completed',
processTree: 'running'
})
}
prepared.modelProfile = undefined
binding.activeOperationId = undefined
binding.inputBytes = 0
binding.outputBytes = 0
binding.maximumOutputBytes = undefined
if (binding.deadlineTimer !== undefined) {
clearTimeout(binding.deadlineTimer)
binding.deadlineTimer = undefined
}
if (binding.ownedPromptStartTimer !== undefined) {
clearTimeout(binding.ownedPromptStartTimer)
binding.ownedPromptStartTimer = undefined
}
}
async #startModelBridge(
binding: BindingState,
preparation: RemotePromptOperationPreparation,
workspace: RuntimeAcpWorkspace
): Promise<void> {
const bridge = preparation.modelBridge
const useAgentGateway =
this.#options.modelGateway !== undefined &&
preparation.modelProfile !== undefined
const policy = useAgentGateway
? modelBridgePolicySchema.parse({
protocol: preparation.modelProfile!.protocol,
model: preparation.modelProfile!.model,
modelProfileDigest:
preparation.modelProfile!.modelProfileDigest,
supportsImageInput:
preparation.modelProfile!.capabilities.imageInput
})
: bridge?.policy
if (
bridge === undefined ||
this.#options.blobSink === undefined ||
workspace.bridgeDirectory === undefined ||
bridge.channelId === binding.request.bindingId
policy === undefined ||
(
!useAgentGateway &&
(
bridge === undefined ||
bridge.channelId === binding.request.bindingId
)
)
) {
throw new RuntimeAcpBackendError(
'Managed model bridge composition is unavailable or invalid',
@@ -1228,7 +1606,7 @@ export class RuntimeAcpBackend {
'conflict'
)
}
const client = new ModelBridgeBlobClient({
const client = useAgentGateway ? undefined : new ModelBridgeBlobClient({
binding: {
bindingId: binding.request.bindingId,
promptOperationId: preparation.operationId,
@@ -1236,11 +1614,11 @@ export class RuntimeAcpBackend {
controllerGeneration: binding.controllerGeneration,
connectionId: binding.connectionId,
acpChannelEpoch: binding.openResult.channelEpoch,
channelId: bridge.channelId,
channelEpoch: bridge.channelEpoch,
channelId: bridge!.channelId,
channelEpoch: bridge!.channelEpoch,
runtimeId: binding.request.runtimeId,
workspaceIdentity: binding.request.workspaceIdentity,
policy: bridge.policy,
policy,
deadlineAt: preparation.deadlineAt
},
sendBlobFrame: async (frame) =>
@@ -1261,26 +1639,99 @@ export class RuntimeAcpBackend {
},
now: this.#options.now
})
const dispatch: ModelBridgeBrokerDispatch = useAgentGateway
? async (request, context) => {
const operation = binding.operations.get(
preparation.operationId
)
const profile =
operation?.modelProfile ?? preparation.modelProfile
if (
profile === undefined ||
binding.activeOperationId !== preparation.operationId
) {
throw new RuntimeAcpBackendError(
'Prompt model credential is no longer active',
'closed'
)
}
if (binding.nextModelRound >= profile.limits.maximumModelCalls) {
throw new RuntimeAcpBackendError(
'Prompt model call limit reached',
'capacity'
)
}
const roundIndex = binding.nextModelRound
let exchanged
try {
exchanged = await this.#options.modelGateway!.dispatch(
{
bindingId: binding.request.bindingId,
operationId: preparation.operationId,
promptSequence: preparation.promptSequence,
roundIndex,
profileDigest: policy.modelProfileDigest,
profile
},
request,
context.signal
)
} catch (error) {
if (
error instanceof AgentModelGatewayError &&
['cancelled', 'outcome-unknown', 'response-too-large', 'timeout']
.includes(error.code)
) {
binding.poisoned = true
binding.state = 'outcome-unknown'
void this.#enqueueControl(async () => {
await this.#stopAndReconcile(
binding,
'identity-conflict'
)
}).catch(() => undefined)
}
throw error
}
return {
response: exchanged.response,
acknowledgeDelivery: async () => {
try {
await exchanged.acknowledgeDelivery()
binding.nextModelRound += 1
} catch (error) {
binding.poisoned = true
binding.state = 'outcome-unknown'
throw error
}
},
failDelivery: () => {
binding.poisoned = true
binding.state = 'outcome-unknown'
exchanged.failDelivery()
}
}
}
: async (request, context) =>
await client!.exchange(request, context)
const broker =
this.#options.createModelBridgeBroker?.({
bridgeDirectory: workspace.bridgeDirectory,
dispatch: async (request, context) =>
await client.exchange(request, context)
dispatch
}) ??
new ModelBridgeBrokerServer({
scratchDirectory: workspace.bridgeDirectory,
dispatch: async (request, context) =>
await client.exchange(request, context)
dispatch
})
binding.modelBridgeClient = client
binding.modelBridgeBroker = broker
binding.modelBridgePolicy = bridge.policy
binding.modelBridgePolicy = policy
try {
await broker.listen()
} catch {
binding.modelBridgeClient = undefined
binding.modelBridgeBroker = undefined
await client.close({ poisonIfActive: false })
await client?.close({ poisonIfActive: false })
throw new RuntimeAcpBackendError(
'Managed model bridge broker could not start',
'process'
@@ -1375,6 +1826,32 @@ export class RuntimeAcpBackend {
'identity'
)
}
if (
binding.activeOperationId !== undefined &&
this.#options.semanticPrompts !== undefined
) {
if (binding.ownedAcp !== undefined) {
await rejectAfter(
binding.ownedAcp.cancel(),
OWNED_PROMPT_CANCEL_GRACE_MS,
'Runtime prompt cancellation timed out'
).catch(() => undefined)
}
try {
this.#options.semanticPrompts.append({
bindingId: binding.request.bindingId,
operationId: binding.activeOperationId,
kind: 'prompt-terminal',
payload: {
status: 'cancelled',
reason: escalation.reason
},
terminalState: 'cancelled'
})
} catch {
// A concurrently completed prompt already committed terminal evidence.
}
}
await this.#stopAndReconcile(binding, 'user-cancelled')
return { bindingId: escalation.bindingId, stopped: true }
}
@@ -1419,6 +1896,14 @@ export class RuntimeAcpBackend {
processTree: prepared.completion.processTree
}
}
if (prepared.terminalState !== undefined) {
return {
status: 'terminal',
terminalState: prepared.terminalState,
processTree:
prepared.terminalState === 'completed' ? 'running' : 'empty'
}
}
if (binding.activeOperationId !== operation.operationId) {
return { status: 'outcome-unknown', processTree: 'unknown' }
}
@@ -1581,9 +2066,11 @@ export class RuntimeAcpBackend {
return
}
binding.transportState = 'detached'
await this.#closeModelBridge(binding, false, true).catch(
() => undefined
)
if (binding.modelBridgeClient !== undefined) {
await this.#closeModelBridge(binding, false, true).catch(
() => undefined
)
}
}
async #stopAndReconcile(
@@ -1593,6 +2080,26 @@ export class RuntimeAcpBackend {
if (binding.state === 'closed') {
return
}
this.#appendAbnormalOwnedPromptTerminal(
binding,
reason === 'user-cancelled'
? {
status: 'cancelled',
name: 'PromptCancelled',
message: 'Prompt execution was cancelled'
}
: {
status: this.#abnormalOwnedPromptState(binding),
name:
reason === 'deadline-exceeded'
? 'PromptDeadlineExceeded'
: 'RuntimeStopped',
message:
reason === 'deadline-exceeded'
? 'Prompt execution exceeded its Runtime deadline'
: 'Runtime stopped before the prompt reached a terminal state'
}
)
await this.#closeModelBridge(binding, true)
if (binding.process === undefined) {
this.#finishBinding(binding, 'interrupted')
@@ -1641,6 +2148,91 @@ export class RuntimeAcpBackend {
this.#finishBinding(binding, terminalState(reconciliation.state))
}
async #handleProcessExit(binding: BindingState): Promise<void> {
if (binding.process === undefined) {
return
}
this.#appendAbnormalOwnedPromptTerminal(binding, {
status: this.#abnormalOwnedPromptState(binding),
name: 'RuntimeExited',
message: 'Runtime exited before the prompt reached a terminal state'
})
await this.#closeModelBridge(binding, true).catch(
() => undefined
)
let state: 'completed' | 'failed' | 'cancelled' | 'interrupted' =
'interrupted'
try {
const reconciliation = await binding.process.reconcile()
if (
sameProcessIdentity(
binding.process.identity,
reconciliation.identity
) &&
reconciliation.processTree === 'empty'
) {
state = terminalState(reconciliation.state)
}
} catch {
binding.state = 'outcome-unknown'
}
this.#finishBinding(binding, state)
}
#abnormalOwnedPromptState(
binding: BindingState
): 'failed' | 'outcome-unknown' {
return binding.poisoned || binding.nextModelRound > 0
? 'outcome-unknown'
: 'failed'
}
#appendAbnormalOwnedPromptTerminal(
binding: BindingState,
terminal: {
status: 'failed' | 'cancelled' | 'outcome-unknown'
name: string
message: string
}
): void {
const operationId = binding.activeOperationId
const prepared =
operationId === undefined
? undefined
: binding.operations.get(operationId)
if (
operationId === undefined ||
prepared === undefined ||
this.#options.semanticPrompts === undefined ||
(
prepared.modelProfile === undefined &&
binding.ownedAcp === undefined
)
) {
return
}
try {
this.#options.semanticPrompts.append({
bindingId: binding.request.bindingId,
operationId,
kind: 'prompt-terminal',
payload: {
status: terminal.status,
error: {
name: terminal.name,
message: terminal.message
}
},
terminalState: terminal.status
})
if (terminal.status !== 'outcome-unknown') {
prepared.terminalState = terminal.status
}
} catch {
// A concurrent Agent-owned terminal transition already won.
}
}
#finishBinding(
binding: BindingState,
state: 'completed' | 'failed' | 'cancelled' | 'interrupted'
@@ -1656,6 +2248,12 @@ export class RuntimeAcpBackend {
}
#finishProcess(binding: BindingState): void {
if (binding.activeOperationId !== undefined) {
this.#options.modelGateway?.finalizePrompt(
binding.request.bindingId,
binding.activeOperationId
)
}
if (binding.process !== undefined) {
this.#processIdentityBindings.delete(
processIdentityKey(binding.process.identity)
@@ -1666,12 +2264,22 @@ export class RuntimeAcpBackend {
binding.unsubscribeExit?.()
binding.unsubscribeExit = undefined
binding.process = undefined
binding.ownedAcp?.close()
binding.ownedAcp = undefined
binding.workspaceDirectory = undefined
for (const operation of binding.operations.values()) {
operation.modelProfile = undefined
}
binding.stopRequested = false
binding.activeOperationId = undefined
if (binding.deadlineTimer !== undefined) {
clearTimeout(binding.deadlineTimer)
binding.deadlineTimer = undefined
}
if (binding.ownedPromptStartTimer !== undefined) {
clearTimeout(binding.ownedPromptStartTimer)
binding.ownedPromptStartTimer = undefined
}
}
#scheduleDeadline(binding: BindingState, deadlineAt: string): void {
@@ -1694,6 +2302,51 @@ export class RuntimeAcpBackend {
binding.deadlineTimer.unref?.()
}
#scheduleOwnedPromptStart(
binding: BindingState,
operationId: string
): void {
if (binding.ownedPromptStartTimer !== undefined) {
clearTimeout(binding.ownedPromptStartTimer)
}
binding.ownedPromptStartTimer = setTimeout(() => {
void this.#enqueueControl(async () => {
const operation = binding.operations.get(operationId)
if (
binding.activeOperationId !== operationId ||
operation?.ownedPromptStarted === true
) {
return
}
binding.poisoned = true
binding.state = 'outcome-unknown'
try {
this.#options.semanticPrompts?.append({
bindingId: binding.request.bindingId,
operationId,
kind: 'prompt-terminal',
payload: {
status: 'outcome-unknown',
error: {
name: 'PromptStartTimeout',
message:
'Desktop disconnected after preparation but before prompt start'
}
},
terminalState: 'outcome-unknown'
})
} catch {
// Another terminal transition won the race.
}
await this.#stopAndReconcile(
binding,
'identity-conflict'
).catch(() => undefined)
}).catch(() => undefined)
}, OWNED_PROMPT_START_TIMEOUT_MS)
binding.ownedPromptStartTimer.unref?.()
}
#bindConnectionLifetime(
binding: BindingState,
signal: AbortSignal | undefined
@@ -2058,6 +2711,14 @@ function processIdentityKey(identity: RuntimeAcpProcessIdentity): string {
].join('\0')
}
function digestSecretBearingPreparation(
preparation: RemotePromptOperationPreparation
): string {
return `sha256:${createHash('sha256')
.update(canonicalJson(preparation))
.digest('hex')}`
}
function terminalState(
state: RuntimeAcpProcessReconciliation['state']
): 'completed' | 'failed' | 'cancelled' | 'interrupted' {
+6 -2
View File
@@ -128,7 +128,7 @@ describe('production Runtime composition', () => {
events.close()
})
it('composes all ACP v3 methods but advertises no Runtime when prerequisites fail', async () => {
it('composes all autonomous ACP methods but advertises no Runtime when prerequisites fail', async () => {
const root = temporaryDirectory()
const runtimeRoot = resolve(root, 'runtimes')
const stateDirectory = resolve(root, 'state')
@@ -161,15 +161,19 @@ describe('production Runtime composition', () => {
})
expect(Object.keys(protocol!.methods).sort()).toEqual([
'runtime/ackPromptTranscript',
'runtime/attachPrompt',
'runtime/closeAcpChannel',
'runtime/completePrompt',
'runtime/escalateCancellation',
'runtime/getAcpCursors',
'runtime/openAcpChannel',
'runtime/pagePromptTranscript',
'runtime/preparePrompt',
'runtime/reconcilePrompt',
'runtime/replayAcpChannel',
'runtime/resumeAcpChannel'
'runtime/resumeAcpChannel',
'runtime/startPrompt'
])
await expect(protocol!.runtimes()).resolves.toEqual([])
await expect(protocol!.dispose()).resolves.toBeUndefined()
+33 -6
View File
@@ -10,6 +10,9 @@ import type { AgentFrame } from '../shared/agent-protocol'
import type {
RuntimeRegistryEntry
} from '../shared/remote-environment-registry-contracts'
import type {
RemotePromptOperationPreparation
} from '../shared/remote-agent-contracts'
import type { RemoteRuntimeLock } from '../shared/remote-runtime-launch-contracts'
import type { EventJournal } from './event-journal'
import {
@@ -36,6 +39,11 @@ import {
} from './direct-linux-stdio-process-owner'
import { RuntimeOwnerRegistry } from './runtime-owner-registry'
import type { WorkspaceRegistry } from './workspace-registry'
import { SemanticPromptStore } from './semantic-prompt-store'
import {
AgentModelCallLedger,
AgentModelGateway
} from './agent-model-gateway'
const BWRAP_EXECUTABLE = '/usr/bin/bwrap'
@@ -108,6 +116,15 @@ export async function createProductionRuntimeProtocol(
const ownerRegistry =
options.ownerRegistry ??
new RuntimeOwnerRegistry(join(stateDirectory, 'runtime-owners.sqlite'))
const semanticPrompts = new SemanticPromptStore(
join(stateDirectory, 'semantic-prompts.sqlite')
)
const modelCallLedger = new AgentModelCallLedger(
join(stateDirectory, 'model-calls.sqlite')
)
const modelGateway = new AgentModelGateway({
ledger: modelCallLedger
})
let registry = options.registry
const currentRegistry = (): RuntimeBundleRegistry => {
if (registry === undefined) {
@@ -285,11 +302,15 @@ export async function createProductionRuntimeProtocol(
maximumInputBytes: launch.budget.maximumInputBytes
}),
outputSink: options.outputSink,
semanticPrompts,
modelGateway,
...(options.blobSink === undefined
? {}
: { blobSink: options.blobSink })
})
} catch (error) {
semanticPrompts.close()
modelCallLedger.close()
if (options.ownerRegistry === undefined) {
ownerRegistry.close()
}
@@ -337,6 +358,8 @@ export async function createProductionRuntimeProtocol(
}),
dispose: async () => {
await backend.dispose()
semanticPrompts.close()
modelCallLedger.close()
if (options.ownerRegistry === undefined) {
ownerRegistry.close()
}
@@ -356,11 +379,14 @@ async function executableAvailable(path: string): Promise<boolean> {
async function resolveCurrentWorkspace(
workspaces: WorkspaceRegistry,
stateDirectory: string,
preparation: {
bindingId: string
runtimeBundleDigest: string
workspaceIdentity: string
},
preparation: Pick<
RemotePromptOperationPreparation,
| 'bindingId'
| 'runtimeBundleDigest'
| 'workspaceIdentity'
| 'modelBridge'
| 'modelProfile'
>,
context: ProtocolMethodContext
): Promise<{
workspaceIdentity: string
@@ -382,7 +408,8 @@ async function resolveCurrentWorkspace(
}
)
const bridgeDirectory =
preparation.modelBridge === undefined
preparation.modelBridge === undefined &&
preparation.modelProfile === undefined
? undefined
: derivePrivateModelBridgeDirectory(
stateDirectory,
@@ -0,0 +1,435 @@
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
REMOTE_SEMANTIC_TRANSCRIPT_LIMITS
} from '../shared/remote-agent-contracts'
import { SemanticPromptStore } from './semantic-prompt-store'
const temporary: string[] = []
function store(options: {
maximumEventsPerPrompt?: number
maximumTranscriptBytes?: number
maximumRetainedAcknowledgedOperations?: number
} = {}): SemanticPromptStore {
const root = mkdtempSync(join(tmpdir(), 'goodbuddy-semantic-prompt-'))
temporary.push(root)
mkdirSync(join(root, 'state'), { mode: 0o700 })
return new SemanticPromptStore(join(root, 'state', 'prompts.sqlite'), {
now: () => 1_000,
...options
})
}
afterEach(() => {
for (const path of temporary.splice(0)) {
rmSync(path, { recursive: true, force: true })
}
})
describe('SemanticPromptStore', () => {
it('replays unacknowledged output and prunes it after durable acknowledgement', () => {
const first = store()
first.prepare({
bindingId: 'binding-1',
operationId: 'operation-1',
requestId: 'operation-1',
controllerId: 'controller-1',
preparationDigest: `sha256:${'a'.repeat(64)}`,
promptSequence: 0
})
first.begin({
bindingId: 'binding-1',
operationId: 'operation-1',
startDigest: `sha256:${'b'.repeat(64)}`,
sessionId: 'session-1'
})
first.append({
bindingId: 'binding-1',
operationId: 'operation-1',
kind: 'session-update',
payload: { update: { sessionUpdate: 'agent_message_chunk' } }
})
first.append({
bindingId: 'binding-1',
operationId: 'operation-1',
kind: 'prompt-terminal',
payload: {
status: 'completed',
response: { stopReason: 'end_turn' }
},
terminalState: 'completed'
})
expect(
first.page({
bindingId: 'binding-1',
operationId: 'operation-1',
controllerId: 'controller-1',
afterSequence: '1',
limit: 10
}).events.map((event) => event.sequence)
).toEqual(['2'])
expect(
first.acknowledge({
bindingId: 'binding-1',
operationId: 'operation-1',
controllerId: 'controller-1',
acknowledgedSequence: '2'
})
).toMatchObject({ acknowledgedSequence: '2' })
const path = (first as unknown as { close(): void })
path.close()
// Reopen the durable authority as a brand-new Desktop-facing owner.
const root = temporary[0]!
const second = new SemanticPromptStore(
join(root, 'state', 'prompts.sqlite')
)
expect(
second.attach('binding-1', 'operation-1', 'controller-1')
).toMatchObject({
state: 'completed',
sessionId: 'session-1',
latestSemanticSequence: '2'
})
expect(
second.page({
bindingId: 'binding-1',
operationId: 'operation-1',
controllerId: 'controller-1',
afterSequence: '0',
limit: 10
})
).toMatchObject({
events: [],
acknowledgedSequence: '2',
latestSequence: '2',
state: 'completed'
})
second.close()
})
it('is idempotent for one canonical operation and rejects divergent starts', () => {
const prompts = store()
const preparation = {
bindingId: 'binding-1',
operationId: 'operation-1',
requestId: 'operation-1',
controllerId: 'controller-1',
preparationDigest: `sha256:${'a'.repeat(64)}`,
promptSequence: 0
}
expect(prompts.prepare(preparation)).toEqual({ created: true })
expect(prompts.prepare(preparation)).toEqual({ created: false })
const start = {
bindingId: 'binding-1',
operationId: 'operation-1',
startDigest: `sha256:${'b'.repeat(64)}`,
sessionId: 'session-1'
}
expect(prompts.begin(start).created).toBe(true)
expect(prompts.begin(start).created).toBe(false)
expect(
prompts.findStarted({
bindingId: 'binding-1',
operationId: 'operation-1',
controllerId: 'controller-1',
startDigest: start.startDigest
})
).toMatchObject({ state: 'running', sessionId: 'session-1' })
expect(() =>
prompts.begin({
...start,
startDigest: `sha256:${'c'.repeat(64)}`
})
).toThrow(/conflicts/u)
prompts.close()
})
it('rejects transcript events beyond the per-event bound', () => {
const prompts = store()
prompts.prepare({
bindingId: 'binding-1',
operationId: 'operation-1',
requestId: 'operation-1',
controllerId: 'controller-1',
preparationDigest: `sha256:${'a'.repeat(64)}`,
promptSequence: 0
})
prompts.begin({
bindingId: 'binding-1',
operationId: 'operation-1',
startDigest: `sha256:${'b'.repeat(64)}`,
sessionId: 'session-1'
})
expect(() =>
prompts.append({
bindingId: 'binding-1',
operationId: 'operation-1',
kind: 'session-update',
payload: { text: 'x'.repeat(1024 * 1024) }
})
).toThrow(/byte limit/u)
prompts.close()
})
it('reserves event-count capacity for durable terminal evidence', () => {
const prompts = store({ maximumEventsPerPrompt: 3 })
prompts.prepare({
bindingId: 'binding-1',
operationId: 'operation-1',
requestId: 'operation-1',
controllerId: 'controller-1',
preparationDigest: `sha256:${'a'.repeat(64)}`,
promptSequence: 0
})
prompts.begin({
bindingId: 'binding-1',
operationId: 'operation-1',
startDigest: `sha256:${'b'.repeat(64)}`,
sessionId: 'session-1'
})
for (let index = 0; index < 2; index += 1) {
prompts.append({
bindingId: 'binding-1',
operationId: 'operation-1',
kind: 'session-update',
payload: { index }
})
}
expect(() =>
prompts.append({
bindingId: 'binding-1',
operationId: 'operation-1',
kind: 'session-update',
payload: { index: 3 }
})
).toThrow(/quota/u)
expect(
prompts.append({
bindingId: 'binding-1',
operationId: 'operation-1',
kind: 'prompt-terminal',
payload: { status: 'completed' },
terminalState: 'completed'
})
).toMatchObject({ sequence: '3', kind: 'prompt-terminal' })
prompts.close()
})
it('reserves byte capacity for terminal evidence across restart', () => {
const first = store({
maximumTranscriptBytes:
REMOTE_SEMANTIC_TRANSCRIPT_LIMITS.maximumEventBytes + 256
})
first.prepare({
bindingId: 'binding-1',
operationId: 'operation-1',
requestId: 'operation-1',
controllerId: 'controller-1',
preparationDigest: `sha256:${'a'.repeat(64)}`,
promptSequence: 0
})
first.begin({
bindingId: 'binding-1',
operationId: 'operation-1',
startDigest: `sha256:${'b'.repeat(64)}`,
sessionId: 'session-1'
})
expect(() =>
first.append({
bindingId: 'binding-1',
operationId: 'operation-1',
kind: 'session-update',
payload: { text: 'x'.repeat(512) }
})
).toThrow(/quota/u)
first.close()
const reopened = new SemanticPromptStore(
join(temporary[0]!, 'state', 'prompts.sqlite'),
{
maximumTranscriptBytes:
REMOTE_SEMANTIC_TRANSCRIPT_LIMITS.maximumEventBytes + 256
}
)
expect(
reopened.attach('binding-1', 'operation-1', 'controller-1')
).toMatchObject({ state: 'outcome-unknown' })
reopened.close()
})
it('keeps the first terminal transition authoritative', () => {
const prompts = store()
prompts.prepare({
bindingId: 'binding-1',
operationId: 'operation-1',
requestId: 'operation-1',
controllerId: 'controller-1',
preparationDigest: `sha256:${'a'.repeat(64)}`,
promptSequence: 0
})
prompts.begin({
bindingId: 'binding-1',
operationId: 'operation-1',
startDigest: `sha256:${'b'.repeat(64)}`,
sessionId: 'session-1'
})
prompts.append({
bindingId: 'binding-1',
operationId: 'operation-1',
kind: 'prompt-terminal',
payload: { status: 'cancelled' },
terminalState: 'cancelled'
})
expect(() =>
prompts.append({
bindingId: 'binding-1',
operationId: 'operation-1',
kind: 'prompt-terminal',
payload: { status: 'completed' },
terminalState: 'completed'
})
).toThrow(/terminal evidence/u)
expect(
prompts.attach('binding-1', 'operation-1', 'controller-1')
).toMatchObject({ state: 'cancelled' })
prompts.close()
})
it('marks nonterminal operations outcome-unknown after Agent restart', () => {
const first = store()
first.prepare({
bindingId: 'binding-1',
operationId: 'operation-1',
requestId: 'operation-1',
controllerId: 'controller-1',
preparationDigest: `sha256:${'a'.repeat(64)}`,
promptSequence: 0
})
first.begin({
bindingId: 'binding-1',
operationId: 'operation-1',
startDigest: `sha256:${'b'.repeat(64)}`,
sessionId: 'session-1'
})
first.close()
const second = new SemanticPromptStore(
join(temporary[0]!, 'state', 'prompts.sqlite')
)
expect(
second.attach('binding-1', 'operation-1', 'controller-1')
).toMatchObject({ state: 'outcome-unknown' })
expect(
second.page({
bindingId: 'binding-1',
operationId: 'operation-1',
controllerId: 'controller-1',
afterSequence: '0',
limit: 10
}).events
).toEqual([
expect.objectContaining({
kind: 'prompt-terminal',
payload: expect.objectContaining({
status: 'outcome-unknown'
})
})
])
second.close()
})
it('bounds transcript pages by aggregate control-frame bytes', () => {
const prompts = store()
prompts.prepare({
bindingId: 'binding-1',
operationId: 'operation-1',
requestId: 'operation-1',
controllerId: 'controller-1',
preparationDigest: `sha256:${'a'.repeat(64)}`,
promptSequence: 0
})
prompts.begin({
bindingId: 'binding-1',
operationId: 'operation-1',
startDigest: `sha256:${'b'.repeat(64)}`,
sessionId: 'session-1'
})
for (let index = 0; index < 2; index += 1) {
prompts.append({
bindingId: 'binding-1',
operationId: 'operation-1',
kind: 'session-update',
payload: { text: 'x'.repeat(400 * 1024), index }
})
}
const first = prompts.page({
bindingId: 'binding-1',
operationId: 'operation-1',
controllerId: 'controller-1',
afterSequence: '0',
limit: 10
})
expect(first.events).toHaveLength(1)
expect(first.hasMore).toBe(true)
expect(Buffer.byteLength(JSON.stringify(first), 'utf8')).toBeLessThan(
768 * 1024
)
prompts.close()
})
it('prunes acknowledged terminal operations while the daemon remains running', () => {
const prompts = store({
maximumRetainedAcknowledgedOperations: 2
})
for (let index = 1; index <= 3; index += 1) {
const operationId = `operation-${index}`
prompts.prepare({
bindingId: 'binding-1',
operationId,
requestId: operationId,
controllerId: 'controller-1',
preparationDigest: `sha256:${String(index).repeat(64)}`,
promptSequence: index
})
prompts.begin({
bindingId: 'binding-1',
operationId,
startDigest: `sha256:${'b'.repeat(64)}`,
sessionId: `session-${index}`
})
prompts.append({
bindingId: 'binding-1',
operationId,
kind: 'prompt-terminal',
payload: {
status: 'completed',
response: { stopReason: 'end_turn' }
},
terminalState: 'completed'
})
prompts.acknowledge({
bindingId: 'binding-1',
operationId,
controllerId: 'controller-1',
acknowledgedSequence: '1'
})
}
expect(() =>
prompts.attach('binding-1', 'operation-1', 'controller-1')
).toThrow(/does not exist/u)
expect(
prompts.attach('binding-1', 'operation-2', 'controller-1')
).toMatchObject({ state: 'completed' })
expect(
prompts.attach('binding-1', 'operation-3', 'controller-1')
).toMatchObject({ state: 'completed' })
prompts.close()
})
})
+627
View File
@@ -0,0 +1,627 @@
import type { DatabaseSync } from 'node:sqlite'
import { openPrivateSqliteDatabase } from '../shared/node/private-sqlite-database'
import {
REMOTE_SEMANTIC_TRANSCRIPT_LIMITS,
remoteOwnedPromptStartResultSchema,
remoteSemanticTranscriptAckResultSchema,
remoteSemanticTranscriptEventSchema,
remoteSemanticTranscriptPageResultSchema,
type RemoteOwnedPromptStartResult,
type RemoteSemanticTranscriptEvent
} from '../shared/remote-agent-contracts'
type OperationRow = {
binding_id: string
operation_id: string
request_id: string
controller_id: string
preparation_digest: string
start_digest: string | null
prompt_sequence: number
state: string
session_id: string | null
latest_sequence: number
acknowledged_sequence: number
transcript_bytes: number
created_at: number
updated_at: number
}
type EventRow = {
sequence: number
kind: RemoteSemanticTranscriptEvent['kind']
payload_json: string
payload_bytes: number
created_at: number
}
export class SemanticPromptStoreError extends Error {
constructor(
message: string,
readonly code: 'capacity' | 'conflict' | 'not-found' | 'state'
) {
super(message)
this.name = 'SemanticPromptStoreError'
}
}
/**
* Durable semantic recovery authority. Credentials, prompts, provider
* requests, raw ACP bytes, and model responses are intentionally absent from
* this database.
*/
export class SemanticPromptStore {
readonly #database: DatabaseSync
readonly #now: () => number
readonly #maximumEventsPerPrompt: number
readonly #maximumTranscriptBytes: number
readonly #maximumRetainedAcknowledgedOperations: number
#closed = false
constructor(
path: string,
options: {
now?: () => number
maximumEventsPerPrompt?: number
maximumTranscriptBytes?: number
maximumRetainedAcknowledgedOperations?: number
} = {}
) {
this.#now = options.now ?? Date.now
this.#maximumEventsPerPrompt =
options.maximumEventsPerPrompt ??
REMOTE_SEMANTIC_TRANSCRIPT_LIMITS.maximumEventsPerPrompt
this.#maximumTranscriptBytes =
options.maximumTranscriptBytes ??
REMOTE_SEMANTIC_TRANSCRIPT_LIMITS.maximumTranscriptBytes
this.#maximumRetainedAcknowledgedOperations =
options.maximumRetainedAcknowledgedOperations ?? 4_096
if (
!Number.isSafeInteger(this.#maximumEventsPerPrompt) ||
this.#maximumEventsPerPrompt < 2 ||
!Number.isSafeInteger(this.#maximumTranscriptBytes) ||
this.#maximumTranscriptBytes <=
REMOTE_SEMANTIC_TRANSCRIPT_LIMITS.maximumEventBytes ||
!Number.isSafeInteger(
this.#maximumRetainedAcknowledgedOperations
) ||
this.#maximumRetainedAcknowledgedOperations < 1
) {
throw new SemanticPromptStoreError(
'Semantic prompt retention limit is invalid',
'state'
)
}
this.#database = openPrivateSqliteDatabase(path)
this.#database.exec(`
PRAGMA journal_mode = WAL;
PRAGMA synchronous = FULL;
PRAGMA busy_timeout = 5000;
CREATE TABLE IF NOT EXISTS semantic_prompt_operations (
binding_id TEXT NOT NULL,
operation_id TEXT NOT NULL,
request_id TEXT NOT NULL,
controller_id TEXT NOT NULL,
preparation_digest TEXT NOT NULL,
start_digest TEXT,
prompt_sequence INTEGER NOT NULL CHECK (prompt_sequence >= 0),
state TEXT NOT NULL CHECK (state IN (
'starting', 'running', 'completed', 'failed', 'cancelled',
'outcome-unknown'
)),
session_id TEXT,
latest_sequence INTEGER NOT NULL DEFAULT 0 CHECK (latest_sequence >= 0),
acknowledged_sequence INTEGER NOT NULL DEFAULT 0
CHECK (acknowledged_sequence >= 0),
transcript_bytes INTEGER NOT NULL DEFAULT 0 CHECK (transcript_bytes >= 0),
created_at INTEGER NOT NULL CHECK (created_at >= 0),
updated_at INTEGER NOT NULL CHECK (updated_at >= created_at),
PRIMARY KEY (binding_id, operation_id),
CHECK (acknowledged_sequence <= latest_sequence)
) STRICT;
CREATE TABLE IF NOT EXISTS semantic_prompt_events (
binding_id TEXT NOT NULL,
operation_id TEXT NOT NULL,
sequence INTEGER NOT NULL CHECK (sequence > 0),
kind TEXT NOT NULL CHECK (kind IN (
'session-update', 'permission-decision', 'prompt-terminal'
)),
payload_json TEXT NOT NULL CHECK (json_valid(payload_json)),
payload_bytes INTEGER NOT NULL CHECK (payload_bytes > 0),
created_at INTEGER NOT NULL CHECK (created_at >= 0),
PRIMARY KEY (binding_id, operation_id, sequence),
FOREIGN KEY (binding_id, operation_id)
REFERENCES semantic_prompt_operations(binding_id, operation_id)
ON DELETE CASCADE
) STRICT;
CREATE INDEX IF NOT EXISTS semantic_prompt_events_page
ON semantic_prompt_events(binding_id, operation_id, sequence);
CREATE INDEX IF NOT EXISTS semantic_prompt_operations_retention
ON semantic_prompt_operations(updated_at DESC)
WHERE state NOT IN ('starting', 'running')
AND acknowledged_sequence = latest_sequence;
`)
const orphaned = this.#database
.prepare(
`SELECT binding_id, operation_id
FROM semantic_prompt_operations
WHERE state IN ('starting', 'running')`
)
.all() as Array<{
binding_id: string
operation_id: string
}>
for (const operation of orphaned) {
this.append({
bindingId: operation.binding_id,
operationId: operation.operation_id,
kind: 'prompt-terminal',
payload: {
status: 'outcome-unknown',
error: {
name: 'AgentRestarted',
message:
'Agent restarted before the prompt reached a durable terminal state'
}
},
terminalState: 'outcome-unknown'
})
}
this.#pruneAcknowledgedOperations()
}
#pruneAcknowledgedOperations(protectedOperation?: {
bindingId: string
operationId: string
}): void {
if (protectedOperation === undefined) {
this.#database.prepare(`
DELETE FROM semantic_prompt_operations
WHERE (binding_id, operation_id) IN (
SELECT binding_id, operation_id
FROM semantic_prompt_operations
WHERE state NOT IN ('starting', 'running')
AND acknowledged_sequence = latest_sequence
ORDER BY updated_at DESC, created_at DESC,
binding_id DESC, operation_id DESC
LIMIT -1 OFFSET ?
);
`).run(this.#maximumRetainedAcknowledgedOperations)
return
}
this.#database.prepare(`
DELETE FROM semantic_prompt_operations
WHERE (binding_id, operation_id) IN (
SELECT binding_id, operation_id
FROM semantic_prompt_operations
WHERE state NOT IN ('starting', 'running')
AND acknowledged_sequence = latest_sequence
AND NOT (binding_id = ? AND operation_id = ?)
ORDER BY updated_at DESC, created_at DESC,
binding_id DESC, operation_id DESC
LIMIT -1 OFFSET ?
);
`).run(
protectedOperation.bindingId,
protectedOperation.operationId,
this.#maximumRetainedAcknowledgedOperations - 1
)
}
prepare(input: {
bindingId: string
operationId: string
requestId: string
controllerId: string
preparationDigest: string
promptSequence: number
}): { created: boolean } {
this.#assertOpen()
const existing = this.#get(input.bindingId, input.operationId)
if (existing !== undefined) {
if (
existing.request_id !== input.requestId ||
existing.controller_id !== input.controllerId ||
existing.preparation_digest !== input.preparationDigest ||
existing.prompt_sequence !== input.promptSequence
) {
throw new SemanticPromptStoreError(
'Prompt operation is already bound to different preparation',
'conflict'
)
}
return { created: false }
}
const now = this.#timestamp()
this.#database.prepare(`
INSERT INTO semantic_prompt_operations (
binding_id, operation_id, request_id, controller_id,
preparation_digest, prompt_sequence, state, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, 'starting', ?, ?)
`).run(
input.bindingId,
input.operationId,
input.requestId,
input.controllerId,
input.preparationDigest,
input.promptSequence,
now,
now
)
return { created: true }
}
begin(input: {
bindingId: string
operationId: string
startDigest: string
sessionId: string
}): { created: boolean; result: RemoteOwnedPromptStartResult } {
return this.#transaction(() => {
const row = this.#require(input.bindingId, input.operationId)
if (row.start_digest !== null) {
if (
row.start_digest !== input.startDigest ||
row.session_id !== input.sessionId
) {
throw new SemanticPromptStoreError(
'Prompt start identity conflicts with the existing operation',
'conflict'
)
}
return { created: false, result: rowToStartResult(row) }
}
const now = this.#timestamp()
const result = this.#database.prepare(`
UPDATE semantic_prompt_operations
SET start_digest = ?, session_id = ?, state = 'running',
updated_at = ?
WHERE binding_id = ? AND operation_id = ?
AND start_digest IS NULL AND state = 'starting'
`).run(
input.startDigest,
input.sessionId,
now,
input.bindingId,
input.operationId
)
if (Number(result.changes) !== 1) {
throw new SemanticPromptStoreError(
'Prompt operation cannot transition to running',
'state'
)
}
return {
created: true,
result: rowToStartResult(
this.#require(input.bindingId, input.operationId)
)
}
})
}
append(input: {
bindingId: string
operationId: string
kind: RemoteSemanticTranscriptEvent['kind']
payload: unknown
terminalState?: 'completed' | 'failed' | 'cancelled' | 'outcome-unknown'
}): RemoteSemanticTranscriptEvent {
const payloadJson = JSON.stringify(input.payload)
const payloadBytes = Buffer.byteLength(payloadJson, 'utf8')
if (
payloadBytes < 1 ||
payloadBytes > REMOTE_SEMANTIC_TRANSCRIPT_LIMITS.maximumEventBytes
) {
throw new SemanticPromptStoreError(
'Semantic transcript event exceeds its byte limit',
'capacity'
)
}
return this.#transaction(() => {
const row = this.#require(input.bindingId, input.operationId)
const terminal = input.terminalState !== undefined
if (
(
terminal
? row.latest_sequence >= this.#maximumEventsPerPrompt
: row.latest_sequence >= this.#maximumEventsPerPrompt - 1
) ||
row.transcript_bytes + payloadBytes >
(
terminal
? this.#maximumTranscriptBytes
: this.#maximumTranscriptBytes -
REMOTE_SEMANTIC_TRANSCRIPT_LIMITS.maximumEventBytes
)
) {
throw new SemanticPromptStoreError(
'Semantic transcript prompt quota reached',
'capacity'
)
}
if (!['starting', 'running'].includes(row.state)) {
throw new SemanticPromptStoreError(
'Semantic events cannot follow terminal evidence',
'state'
)
}
const sequence = row.latest_sequence + 1
const now = this.#timestamp()
const kind =
remoteSemanticTranscriptEventSchema.shape.kind.parse(input.kind)
const payload =
remoteSemanticTranscriptEventSchema.shape.payload.parse(input.payload)
this.#database.prepare(`
INSERT INTO semantic_prompt_events (
binding_id, operation_id, sequence, kind, payload_json,
payload_bytes, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
input.bindingId,
input.operationId,
sequence,
kind,
payloadJson,
payloadBytes,
now
)
this.#database.prepare(`
UPDATE semantic_prompt_operations
SET latest_sequence = ?, transcript_bytes = transcript_bytes + ?,
state = COALESCE(?, state),
updated_at = ?
WHERE binding_id = ? AND operation_id = ?
`).run(
sequence,
payloadBytes,
input.terminalState ?? null,
now,
input.bindingId,
input.operationId
)
return remoteSemanticTranscriptEventSchema.parse({
sequence: String(sequence),
kind,
payload,
createdAt: now
})
})
}
attach(
bindingId: string,
operationId: string,
controllerId: string
): RemoteOwnedPromptStartResult {
const row = this.#require(bindingId, operationId)
this.#assertController(row, controllerId)
if (row.session_id === null) {
throw new SemanticPromptStoreError(
'Prompt session has not started',
'state'
)
}
return rowToStartResult(row)
}
findStarted(input: {
bindingId: string
operationId: string
controllerId: string
startDigest: string
}): RemoteOwnedPromptStartResult | undefined {
const row = this.#get(input.bindingId, input.operationId)
if (row === undefined || row.start_digest === null) {
return undefined
}
this.#assertController(row, input.controllerId)
if (row.start_digest !== input.startDigest) {
throw new SemanticPromptStoreError(
'Duplicate prompt start has different canonical content',
'conflict'
)
}
return rowToStartResult(row)
}
page(input: {
bindingId: string
operationId: string
controllerId: string
afterSequence: string
limit: number
}) {
const row = this.#require(input.bindingId, input.operationId)
this.#assertController(row, input.controllerId)
const after = Number(BigInt(input.afterSequence))
const rows = this.#database.prepare(`
SELECT sequence, kind, payload_json, payload_bytes, created_at
FROM semantic_prompt_events
WHERE binding_id = ? AND operation_id = ? AND sequence > ?
ORDER BY sequence ASC LIMIT ?
`).iterate(
input.bindingId,
input.operationId,
after,
input.limit + 1
) as unknown as IterableIterator<EventRow>
const events: EventRow[] = []
let pageBytes = 1_024
let hasMore = false
for (const event of rows) {
const estimatedBytes = event.payload_bytes + 1_024
if (
events.length >= input.limit ||
(events.length > 0 &&
pageBytes + estimatedBytes >
REMOTE_SEMANTIC_TRANSCRIPT_LIMITS.maximumPageBytes)
) {
hasMore = true
break
}
events.push(event)
pageBytes += estimatedBytes
}
return remoteSemanticTranscriptPageResultSchema.parse({
bindingId: input.bindingId,
operationId: input.operationId,
events: events.map(rowToEvent),
latestSequence: String(row.latest_sequence),
acknowledgedSequence: String(row.acknowledged_sequence),
state: row.state,
...(row.session_id === null ? {} : { sessionId: row.session_id }),
hasMore
})
}
acknowledge(input: {
bindingId: string
operationId: string
controllerId: string
acknowledgedSequence: string
}) {
return this.#transaction(() => {
const row = this.#require(input.bindingId, input.operationId)
this.#assertController(row, input.controllerId)
const sequence = Number(BigInt(input.acknowledgedSequence))
if (sequence > row.latest_sequence) {
throw new SemanticPromptStoreError(
'Semantic transcript ACK exceeds committed output',
'conflict'
)
}
if (sequence > row.acknowledged_sequence) {
this.#database.prepare(`
UPDATE semantic_prompt_operations
SET acknowledged_sequence = ?, updated_at = ?
WHERE binding_id = ? AND operation_id = ?
`).run(
sequence,
this.#timestamp(),
input.bindingId,
input.operationId
)
this.#database.prepare(`
DELETE FROM semantic_prompt_events
WHERE binding_id = ? AND operation_id = ? AND sequence <= ?
`).run(
input.bindingId,
input.operationId,
sequence
)
if (
sequence === row.latest_sequence &&
!['starting', 'running'].includes(row.state)
) {
this.#pruneAcknowledgedOperations({
bindingId: input.bindingId,
operationId: input.operationId
})
}
}
return remoteSemanticTranscriptAckResultSchema.parse({
bindingId: input.bindingId,
operationId: input.operationId,
acknowledgedSequence: String(
Math.max(sequence, row.acknowledged_sequence)
)
})
})
}
close(): void {
if (!this.#closed) {
this.#database.close()
this.#closed = true
}
}
#get(bindingId: string, operationId: string): OperationRow | undefined {
return this.#database.prepare(`
SELECT * FROM semantic_prompt_operations
WHERE binding_id = ? AND operation_id = ?
`).get(bindingId, operationId) as OperationRow | undefined
}
#require(bindingId: string, operationId: string): OperationRow {
this.#assertOpen()
const row = this.#get(bindingId, operationId)
if (row === undefined) {
throw new SemanticPromptStoreError(
'Semantic prompt operation does not exist',
'not-found'
)
}
return row
}
#assertController(row: OperationRow, controllerId: string): void {
if (row.controller_id !== controllerId) {
throw new SemanticPromptStoreError(
'Semantic prompt belongs to another controller',
'conflict'
)
}
}
#transaction<T>(action: () => T): T {
this.#assertOpen()
this.#database.exec('BEGIN IMMEDIATE')
try {
const result = action()
this.#database.exec('COMMIT')
return result
} catch (error) {
try {
this.#database.exec('ROLLBACK')
} catch {
// Preserve the original state transition error.
}
throw error
}
}
#timestamp(): number {
const value = this.#now()
if (!Number.isSafeInteger(value) || value < 0) {
throw new SemanticPromptStoreError('Clock is invalid', 'state')
}
return value
}
#assertOpen(): void {
if (this.#closed) {
throw new SemanticPromptStoreError(
'Semantic prompt store is closed',
'state'
)
}
}
}
function rowToStartResult(row: OperationRow): RemoteOwnedPromptStartResult {
if (row.session_id === null) {
throw new SemanticPromptStoreError(
'Prompt session has not started',
'state'
)
}
return remoteOwnedPromptStartResultSchema.parse({
bindingId: row.binding_id,
operationId: row.operation_id,
requestId: row.request_id,
sessionId: row.session_id,
state: row.state,
latestSemanticSequence: String(row.latest_sequence)
})
}
function rowToEvent(row: EventRow): RemoteSemanticTranscriptEvent {
return remoteSemanticTranscriptEventSchema.parse({
sequence: String(row.sequence),
kind: row.kind,
payload: JSON.parse(row.payload_json) as unknown,
createdAt: row.created_at
})
}