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:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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' {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user