@meistrari/agent-core 0.1.2 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/package.json +1 -1
- package/scripts/probe-codex-diagnostic-storage.ts +110 -0
- package/src/agents/claude/claude-event-mapper.ts +3 -1
- package/src/agents/claude/claude-persisted-output.ts +65 -0
- package/src/agents/claude/claude-provider.ts +10 -0
- package/src/agents/claude/claude-run.ts +55 -3
- package/src/agents/claude/claude-startup-timing.ts +34 -0
- package/src/agents/codex/codex-auth.ts +11 -0
- package/src/agents/codex/codex-bedrock-diagnostics.ts +6 -0
- package/src/agents/codex/codex-bedrock-session-auth.ts +79 -0
- package/src/agents/codex/codex-event-mapper.ts +4 -2
- package/src/agents/codex/codex-json-rpc-client.ts +2 -1
- package/src/agents/codex/codex-provider.ts +81 -12
- package/src/agents/codex/codex-run.ts +23 -1
- package/src/agents/model-rebinding-run.ts +166 -0
- package/src/protocol/agent-command.ts +2 -0
- package/src/protocol/agent-event.ts +1 -1
- package/src/provenance.gen.ts +3 -3
- package/src/steps/projector.ts +60 -4
- package/src/supervisor/agent-provider-factory.ts +1 -0
- package/src/supervisor-protocol/command-body.ts +1 -0
package/README.md
CHANGED
|
@@ -21,6 +21,8 @@ The package ships TypeScript source and targets Bun `>=1.3.10`. There is no wire
|
|
|
21
21
|
|
|
22
22
|
agent-core does not own a universal E2B template. Each consuming service builds and onboards its own template against the shared contracts and records that template's agent-core provenance. agent-api and Remy may therefore use different base images, harness versions, system packages, and startup hooks without forking the control protocol.
|
|
23
23
|
|
|
24
|
+
Prompt commands may carry an application-resolved `model`. Applications validate harness compatibility and persist the selection with command admission; they should send the resolved model on every prompt so recovery does not depend on mutable harness memory. Claude applies a changed model through its control channel before input; Codex supplies it to `turn/start`. Neither adapter changes models on an active turn, and unchanged Claude selections add no control request. This is not an effort override contract. Existing immutable sandboxes must not be advertised as supporting this field unless their baked runtime includes it.
|
|
25
|
+
|
|
24
26
|
## Development
|
|
25
27
|
|
|
26
28
|
```bash
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meistrari/agent-core",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.8",
|
|
5
5
|
"packageManager": "bun@1.3.12",
|
|
6
6
|
"description": "Shared contracts and runtime modules for Tela coding-agent sandboxes: agent protocol, supervisor wire protocol, resident supervisor, worker runtime client, and Claude/Codex harness adapters.",
|
|
7
7
|
"license": "UNLICENSED",
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { mkdtemp, readdir, readFile, stat } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { createInterface } from 'node:readline'
|
|
6
|
+
import { Database } from 'bun:sqlite'
|
|
7
|
+
|
|
8
|
+
// Offline, synthetic-only probe. Never inherit model or AWS credentials.
|
|
9
|
+
const executable = process.argv[2]
|
|
10
|
+
if (!executable)
|
|
11
|
+
throw new Error('Pass the absolute path to the pinned Codex executable.')
|
|
12
|
+
|
|
13
|
+
for (const mode of ['default', 'rust-log-off', 'feedback-off'] as const) {
|
|
14
|
+
const directory = await mkdtemp(join(tmpdir(), `codex-diagnostic-${mode}-`))
|
|
15
|
+
const canary = 'SYNTHETIC_CODEX_DIAGNOSTIC_CANARY_NO_AUTHORITY'
|
|
16
|
+
const child = spawn(executable, [
|
|
17
|
+
'app-server',
|
|
18
|
+
'--listen',
|
|
19
|
+
'stdio://',
|
|
20
|
+
'-c',
|
|
21
|
+
'cli_auth_credentials_store="ephemeral"',
|
|
22
|
+
...(mode === 'feedback-off' ? ['-c', 'feedback.enabled=false'] : []),
|
|
23
|
+
], {
|
|
24
|
+
env: {
|
|
25
|
+
PATH: process.env.PATH,
|
|
26
|
+
HOME: directory,
|
|
27
|
+
CODEX_HOME: directory,
|
|
28
|
+
HTTP_PROXY: 'http://127.0.0.1:1',
|
|
29
|
+
HTTPS_PROXY: 'http://127.0.0.1:1',
|
|
30
|
+
ALL_PROXY: 'http://127.0.0.1:1',
|
|
31
|
+
NO_PROXY: '',
|
|
32
|
+
...(mode === 'rust-log-off' ? { RUST_LOG: 'off' } : {}),
|
|
33
|
+
},
|
|
34
|
+
stdio: 'pipe',
|
|
35
|
+
})
|
|
36
|
+
const exited = new Promise<number | null>((resolve, reject) => {
|
|
37
|
+
child.once('close', resolve)
|
|
38
|
+
child.once('error', reject)
|
|
39
|
+
})
|
|
40
|
+
let stderrHasCanary = false
|
|
41
|
+
child.stderr.on('data', (data) => {
|
|
42
|
+
stderrHasCanary ||= String(data).includes(canary)
|
|
43
|
+
})
|
|
44
|
+
const send = (value: unknown) => child.stdin.write(`${JSON.stringify(value)}\n`)
|
|
45
|
+
let loginResponded = false
|
|
46
|
+
let turnAccepted = false
|
|
47
|
+
const protocolErrors: { id: unknown, code: unknown }[] = []
|
|
48
|
+
const lines = createInterface({ input: child.stdout })
|
|
49
|
+
lines.on('line', (line) => {
|
|
50
|
+
const value = JSON.parse(line)
|
|
51
|
+
if (value.error)
|
|
52
|
+
protocolErrors.push({ id: value.id, code: value.error.code })
|
|
53
|
+
if (value.id === 1) {
|
|
54
|
+
send({ method: 'initialized' })
|
|
55
|
+
send({ id: 2, method: 'account/login/start', params: { type: 'amazonBedrock', apiKey: canary, region: 'us-east-1' } })
|
|
56
|
+
}
|
|
57
|
+
if (value.id === 2) {
|
|
58
|
+
loginResponded = true
|
|
59
|
+
send({ id: 3, method: 'thread/start', params: { modelProvider: 'amazon-bedrock', model: 'openai.gpt-5.6-terra', cwd: directory, approvalPolicy: 'never', sandbox: 'danger-full-access' } })
|
|
60
|
+
}
|
|
61
|
+
if (value.id === 3 && value.result?.thread?.id) {
|
|
62
|
+
send({ id: 4, method: 'turn/start', params: { threadId: value.result.thread.id, input: [{ type: 'text', text: 'Synthetic offline diagnostic probe. Do not use tools.' }] } })
|
|
63
|
+
}
|
|
64
|
+
if (value.id === 4 || value.error) {
|
|
65
|
+
turnAccepted = value.id === 4 && Boolean(value.result?.turn?.id)
|
|
66
|
+
// Allow the runtime's asynchronous diagnostic writer to flush.
|
|
67
|
+
setTimeout(() => child.stdin.end(), 2_000)
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
send({ id: 1, method: 'initialize', params: { clientInfo: { name: 'diagnostic-canary-probe', version: '1' }, capabilities: { experimentalApi: true } } })
|
|
71
|
+
const timeout = setTimeout(() => child.kill('SIGTERM'), 10_000)
|
|
72
|
+
const exitCode = await exited
|
|
73
|
+
clearTimeout(timeout)
|
|
74
|
+
lines.close()
|
|
75
|
+
const files = await readdir(directory)
|
|
76
|
+
const logs = files.filter(name => /^logs_\d+\.sqlite$/.test(name))
|
|
77
|
+
const databases = logs.map((name) => {
|
|
78
|
+
// These synthetic-only files belong to this probe. SQLite may need to
|
|
79
|
+
// recreate its WAL shared-memory file after the child exits.
|
|
80
|
+
const db = new Database(join(directory, name), { readwrite: true, create: false })
|
|
81
|
+
try {
|
|
82
|
+
return { name, ...db.query('SELECT count(*) AS records, coalesce(sum(instr(feedback_log_body, ?) > 0), 0) AS canaryRecords FROM logs').get(canary) as { records: number, canaryRecords: number } }
|
|
83
|
+
}
|
|
84
|
+
finally { db.close() }
|
|
85
|
+
})
|
|
86
|
+
const canaryFiles: string[] = []
|
|
87
|
+
const skippedFiles: string[] = []
|
|
88
|
+
let scannedFiles = 0
|
|
89
|
+
async function scan(relative = ''): Promise<void> {
|
|
90
|
+
for (const entry of await readdir(join(directory, relative), { withFileTypes: true })) {
|
|
91
|
+
const path = join(relative, entry.name)
|
|
92
|
+
if (entry.isDirectory()) {
|
|
93
|
+
await scan(path)
|
|
94
|
+
}
|
|
95
|
+
else if (entry.isFile()) {
|
|
96
|
+
if ((await stat(join(directory, path))).size > 16 * 1024 * 1024) {
|
|
97
|
+
skippedFiles.push(path)
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
scannedFiles++
|
|
101
|
+
if ((await readFile(join(directory, path))).includes(canary))
|
|
102
|
+
canaryFiles.push(path)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
await scan()
|
|
107
|
+
console.log(JSON.stringify({ mode, directory, loginResponded, turnAccepted, protocolErrors, exitCode, stderrHasCanary, databases, scannedFiles, canaryFiles, skippedFiles }))
|
|
108
|
+
if (!turnAccepted || protocolErrors.length || exitCode !== 0 || stderrHasCanary || canaryFiles.length || skippedFiles.length)
|
|
109
|
+
process.exitCode = 1
|
|
110
|
+
}
|
|
@@ -111,6 +111,7 @@ export interface ClaudeEventMapperState {
|
|
|
111
111
|
interrupt: { status: 'none' } | { status: 'requesting' | 'accepted', turnId: string }
|
|
112
112
|
sessionState?: 'running' | 'idle'
|
|
113
113
|
currentTurnId?: string
|
|
114
|
+
currentCommandId?: string
|
|
114
115
|
currentMessageId?: string
|
|
115
116
|
currentCompactionId?: string
|
|
116
117
|
}
|
|
@@ -213,6 +214,7 @@ export function mapClaudeMessage(message: SDKMessage, state: ClaudeEventMapperSt
|
|
|
213
214
|
state.turnStarted = false
|
|
214
215
|
state.interrupt = { status: 'none' }
|
|
215
216
|
state.currentTurnId = undefined
|
|
217
|
+
state.currentCommandId = undefined
|
|
216
218
|
state.currentMessageId = undefined
|
|
217
219
|
state.currentCompactionId = undefined
|
|
218
220
|
return drafts
|
|
@@ -522,7 +524,7 @@ function ensureTurnStarted(drafts: AgentEventDraft[], state: ClaudeEventMapperSt
|
|
|
522
524
|
state.currentTurnId = createRuntimeTurnId()
|
|
523
525
|
state.currentMessageId = undefined
|
|
524
526
|
state.contentBlockTypeByIndex.clear()
|
|
525
|
-
drafts.push({ type: 'turn.started', turnId: state.currentTurnId, actor: mainActor, payload: {} })
|
|
527
|
+
drafts.push({ type: 'turn.started', turnId: state.currentTurnId, actor: mainActor, payload: removeUndefined({ commandId: state.currentCommandId }) })
|
|
526
528
|
ensureSessionRunning(drafts, state)
|
|
527
529
|
return state.currentTurnId
|
|
528
530
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'
|
|
2
|
+
import { Buffer } from 'node:buffer'
|
|
3
|
+
import { constants } from 'node:fs'
|
|
4
|
+
import { open, realpath } from 'node:fs/promises'
|
|
5
|
+
import { basename, dirname, join, resolve } from 'node:path'
|
|
6
|
+
|
|
7
|
+
const MAX_OUTPUT_BYTES = 16 * 1024 * 1024
|
|
8
|
+
|
|
9
|
+
export async function hydrateClaudeToolOutput(message: SDKMessage, input: {
|
|
10
|
+
transcriptPath: string
|
|
11
|
+
toolNameById: ReadonlyMap<string, string>
|
|
12
|
+
}): Promise<SDKMessage> {
|
|
13
|
+
if (message.type !== 'user' || !message.tool_use_result || typeof message.tool_use_result !== 'object')
|
|
14
|
+
return message
|
|
15
|
+
const result = message.tool_use_result as Record<string, unknown>
|
|
16
|
+
if (result.persistedOutputPath === undefined)
|
|
17
|
+
return message
|
|
18
|
+
const content = message.message.content
|
|
19
|
+
if (!Array.isArray(content))
|
|
20
|
+
throw new Error('Claude persisted output has no correlated tool result.')
|
|
21
|
+
const blocks = content.filter(block => block.type === 'tool_result')
|
|
22
|
+
if (blocks.length !== 1 || input.toolNameById.get(blocks[0]!.tool_use_id) !== 'bash')
|
|
23
|
+
throw new Error('Claude persisted output has ambiguous tool correlation.')
|
|
24
|
+
if (typeof result.persistedOutputPath !== 'string' || typeof result.persistedOutputSize !== 'number')
|
|
25
|
+
throw new Error('Claude persisted output metadata is malformed.')
|
|
26
|
+
const text = await readClaudePersistedOutput({ transcriptPath: input.transcriptPath, path: result.persistedOutputPath, byteSize: result.persistedOutputSize })
|
|
27
|
+
return { ...message, message: { ...message.message, content: content.map(block => block === blocks[0] ? { ...block, content: text } : block) } }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Read only the SDK-owned tool-results file, never a path parsed from model text. */
|
|
31
|
+
export async function readClaudePersistedOutput(input: {
|
|
32
|
+
transcriptPath: string
|
|
33
|
+
path: string
|
|
34
|
+
byteSize: number
|
|
35
|
+
}): Promise<string> {
|
|
36
|
+
const transcript = resolve(input.transcriptPath)
|
|
37
|
+
const root = join(dirname(transcript), basename(transcript, '.jsonl'), 'tool-results')
|
|
38
|
+
if (!Number.isSafeInteger(input.byteSize) || input.byteSize < 0 || input.byteSize > MAX_OUTPUT_BYTES)
|
|
39
|
+
throw new Error('Claude persisted tool output exceeds the bounded capture budget.')
|
|
40
|
+
if (input.path !== resolve(input.path) || dirname(input.path) !== root || await realpath(root) !== root)
|
|
41
|
+
throw new Error('Claude persisted tool output is outside its provider session.')
|
|
42
|
+
const file = await open(input.path, constants.O_RDONLY | constants.O_NOFOLLOW)
|
|
43
|
+
try {
|
|
44
|
+
const before = await file.stat()
|
|
45
|
+
if (!before.isFile() || before.nlink !== 1 || before.size !== input.byteSize)
|
|
46
|
+
throw new Error('Claude persisted tool output metadata does not match its file.')
|
|
47
|
+
// Bounded reads also detect growth between stat and read; readFile could
|
|
48
|
+
// allocate arbitrarily if the file is concurrently extended.
|
|
49
|
+
const bytes = Buffer.alloc(input.byteSize + 1)
|
|
50
|
+
let offset = 0
|
|
51
|
+
while (offset < bytes.length) {
|
|
52
|
+
const result = await file.read(bytes, offset, bytes.length - offset, offset)
|
|
53
|
+
if (!result.bytesRead)
|
|
54
|
+
break
|
|
55
|
+
offset += result.bytesRead
|
|
56
|
+
}
|
|
57
|
+
const after = await file.stat()
|
|
58
|
+
if (offset !== input.byteSize || after.size !== before.size || after.mtimeMs !== before.mtimeMs)
|
|
59
|
+
throw new Error('Claude persisted tool output changed during capture.')
|
|
60
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, offset))
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
await file.close()
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -4,6 +4,7 @@ import type { AgentOperationOptions } from '../agent-operation'
|
|
|
4
4
|
import type { AgentProvider, AgentSessionOpenInput, AgentSessionResumeInput } from '../agent-provider'
|
|
5
5
|
import type { AgentTool } from '../agent-tool'
|
|
6
6
|
import type { InstructionComposer } from '../instructions'
|
|
7
|
+
import type { ClaudeStartupTiming } from './claude-startup-timing'
|
|
7
8
|
import { homedir } from 'node:os'
|
|
8
9
|
import { join } from 'node:path'
|
|
9
10
|
import { query as queryClaude } from '@anthropic-ai/claude-agent-sdk'
|
|
@@ -15,6 +16,7 @@ import { removeUndefined } from '../normalize'
|
|
|
15
16
|
import { UserInputRequestStore, userInputRequestStoreRootFromTranscriptPath } from '../user-input-request'
|
|
16
17
|
import { createClaudeEventMapperState } from './claude-event-mapper'
|
|
17
18
|
import { ClaudeRun } from './claude-run'
|
|
19
|
+
import { createClaudeStartupTiming, observeClaudeControlInitialization } from './claude-startup-timing'
|
|
18
20
|
import { claudeAllowedToolNames, claudeToolActorHooks, createClaudeToolRuntimeRef, toClaudeMcpServers } from './claude-tool-mapper'
|
|
19
21
|
|
|
20
22
|
export interface ClaudeProviderConfig {
|
|
@@ -22,6 +24,7 @@ export interface ClaudeProviderConfig {
|
|
|
22
24
|
executable?: string
|
|
23
25
|
environment?: Record<string, string | undefined>
|
|
24
26
|
instructions?: InstructionComposer
|
|
27
|
+
onStartupTiming?: (timing: ClaudeStartupTiming) => void
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
export class ClaudeProvider implements AgentProvider<'claude'> {
|
|
@@ -30,12 +33,14 @@ export class ClaudeProvider implements AgentProvider<'claude'> {
|
|
|
30
33
|
private readonly executable: string | undefined
|
|
31
34
|
private readonly environment: Record<string, string | undefined>
|
|
32
35
|
private readonly instructions: InstructionComposer
|
|
36
|
+
private readonly onStartupTiming: ClaudeProviderConfig['onStartupTiming']
|
|
33
37
|
|
|
34
38
|
private constructor(config: ClaudeProviderConfig) {
|
|
35
39
|
this.tools = config.tools ?? []
|
|
36
40
|
this.executable = config.executable
|
|
37
41
|
this.environment = config.environment ?? {}
|
|
38
42
|
this.instructions = config.instructions ?? emptyInstructionComposer
|
|
43
|
+
this.onStartupTiming = config.onStartupTiming
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
static create(config: ClaudeProviderConfig = {}): ClaudeProvider {
|
|
@@ -96,7 +101,11 @@ export class ClaudeProvider implements AgentProvider<'claude'> {
|
|
|
96
101
|
pathToClaudeCodeExecutable: this.executable,
|
|
97
102
|
})
|
|
98
103
|
|
|
104
|
+
const recordStartup = createClaudeStartupTiming(this.onStartupTiming)
|
|
99
105
|
const query = queryClaude({ prompt: inputQueue, options: sdkOptions })
|
|
106
|
+
recordStartup('query_created')
|
|
107
|
+
if (this.onStartupTiming)
|
|
108
|
+
observeClaudeControlInitialization(query.initializationResult(), recordStartup)
|
|
100
109
|
try {
|
|
101
110
|
// Binding metadata comes from pre-input facts only: the live runtime emits system/init
|
|
102
111
|
// only after the first user message, so the run validates identity/config on the first
|
|
@@ -122,6 +131,7 @@ export class ClaudeProvider implements AgentProvider<'claude'> {
|
|
|
122
131
|
userInputRequests,
|
|
123
132
|
input: inputQueue,
|
|
124
133
|
eventMapperState: state,
|
|
134
|
+
recordStartup,
|
|
125
135
|
init: {
|
|
126
136
|
expectedSessionId,
|
|
127
137
|
requestedModel: input.model,
|
|
@@ -13,14 +13,16 @@ import type { AgentOperationOptions } from '../agent-operation'
|
|
|
13
13
|
import type { AgentPromptAcceptance, AgentRun, AgentUserInputSubmission } from '../agent-run'
|
|
14
14
|
import type { UserInputRequestInput, UserInputRequestStore } from '../user-input-request'
|
|
15
15
|
import type { ClaudeEventMapperState } from './claude-event-mapper'
|
|
16
|
+
import type { ClaudeStartupPhase } from './claude-startup-timing'
|
|
16
17
|
import z from 'zod'
|
|
17
18
|
import { serializeAgentError } from '../agent-error-serializer'
|
|
18
19
|
import { AgentEventStream } from '../agent-event-stream'
|
|
19
|
-
import { AgentDeliveryUnknownError, AgentNotAcceptedError, AgentProviderMetadataError, AgentProviderProtocolError, AgentRunStateError } from '../agent-runtime-error'
|
|
20
|
+
import { AgentDeliveryUnknownError, AgentNotAcceptedError, AgentPromptDeferredError, AgentProviderMetadataError, AgentProviderProtocolError, AgentRunStateError } from '../agent-runtime-error'
|
|
20
21
|
import { sessionConfiguredEvent } from '../agent-session-events'
|
|
21
22
|
import { deriveMessageId } from '../message-id'
|
|
22
23
|
import { mapClaudeUserMessage, toClaudeUserMessage } from './claude-command-mapper'
|
|
23
24
|
import { mapClaudeMessage } from './claude-event-mapper'
|
|
25
|
+
import { hydrateClaudeToolOutput } from './claude-persisted-output'
|
|
24
26
|
|
|
25
27
|
// The ONE long-lived SDK input stream created with the query. SDK 0.3.207 Query.streamInput ends
|
|
26
28
|
// stdin after any finite iterable and the transport silently drops later writes, so every user
|
|
@@ -77,6 +79,7 @@ interface ClaudeRunInput {
|
|
|
77
79
|
input: ClaudeInputStream
|
|
78
80
|
eventMapperState: ClaudeEventMapperState
|
|
79
81
|
init: ClaudeInitExpectation
|
|
82
|
+
recordStartup?: (phase: ClaudeStartupPhase) => void
|
|
80
83
|
}
|
|
81
84
|
|
|
82
85
|
export class ClaudeRun implements AgentRun {
|
|
@@ -87,22 +90,27 @@ export class ClaudeRun implements AgentRun {
|
|
|
87
90
|
private readonly input: ClaudeInputStream
|
|
88
91
|
private readonly eventMapperState: ClaudeEventMapperState
|
|
89
92
|
private readonly init: ClaudeInitExpectation
|
|
93
|
+
private readonly recordStartup: ClaudeRunInput['recordStartup']
|
|
90
94
|
private readonly initialization: Promise<void>
|
|
91
95
|
private initializationResolve: () => void = () => undefined
|
|
92
96
|
private initializationReject: (error: unknown) => void = () => undefined
|
|
93
97
|
private initialized = false
|
|
94
98
|
private firstInputPushed = false
|
|
99
|
+
private modelChangeReady = true
|
|
100
|
+
private modelChangeInFlight = false
|
|
95
101
|
private finished = false
|
|
96
102
|
private interruptRequest?: { turnId: string, promise: Promise<void> }
|
|
97
103
|
private readonly acceptanceWaiters = new Map<string, AcceptanceWaiter>()
|
|
104
|
+
private readonly commandByClientMessageId = new Map<string, string>()
|
|
98
105
|
|
|
99
|
-
constructor({ metadata, query, userInputRequests, input, eventMapperState, init }: ClaudeRunInput) {
|
|
106
|
+
constructor({ metadata, query, userInputRequests, input, eventMapperState, init, recordStartup }: ClaudeRunInput) {
|
|
100
107
|
this.metadata = metadata
|
|
101
108
|
this.query = query
|
|
102
109
|
this.userInputRequests = userInputRequests
|
|
103
110
|
this.input = input
|
|
104
111
|
this.eventMapperState = eventMapperState
|
|
105
112
|
this.init = init
|
|
113
|
+
this.recordStartup = recordStartup
|
|
106
114
|
this.initialization = new Promise<void>((resolve, reject) => {
|
|
107
115
|
this.initializationResolve = resolve
|
|
108
116
|
this.initializationReject = reject
|
|
@@ -129,6 +137,10 @@ export class ClaudeRun implements AgentRun {
|
|
|
129
137
|
pushEvent(draft: AgentEventDraft): void {
|
|
130
138
|
if (this.finished)
|
|
131
139
|
return
|
|
140
|
+
if (draft.type === 'turn.started')
|
|
141
|
+
this.modelChangeReady = false
|
|
142
|
+
if (draft.type === 'session.state.changed' && draft.payload.state === 'idle')
|
|
143
|
+
this.modelChangeReady = true
|
|
132
144
|
const decorated = this.userInputRequests.decorateTurnEnded(draft)
|
|
133
145
|
if (decorated.type === 'session.state.changed' && decorated.payload.state === 'idle' && this.userInputRequests.shouldHoldIdle()) {
|
|
134
146
|
this.events.push({ type: 'session.state.changed', payload: { state: 'waiting_for_user_input' } })
|
|
@@ -160,10 +172,31 @@ export class ClaudeRun implements AgentRun {
|
|
|
160
172
|
const input = { prompt: command.prompt, mode: command.mode, commandId: command.commandId, origin: command.origin, author: command.author, senderContext: options.senderContext }
|
|
161
173
|
const message = await mapClaudeUserMessage(input, options.inputAttachmentPreparation)
|
|
162
174
|
options.signal?.throwIfAborted()
|
|
175
|
+
if (this.modelChangeInFlight)
|
|
176
|
+
throw new AgentPromptDeferredError({ message: 'Claude model configuration is still in progress.' })
|
|
177
|
+
const currentModel = this.init.requestedModel ?? this.metadata.model
|
|
178
|
+
if (command.model && command.model !== currentModel) {
|
|
179
|
+
if (!this.modelChangeReady || this.eventMapperState.currentTurnId || this.commandByClientMessageId.size > 0)
|
|
180
|
+
throw new AgentPromptDeferredError({ message: 'Claude model changes require an idle turn boundary.' })
|
|
181
|
+
// The control receipt must precede the input. Reapplying the same resolved model
|
|
182
|
+
// after a crash is safe; never retry an uncertain prompt to retry configuration.
|
|
183
|
+
this.modelChangeInFlight = true
|
|
184
|
+
try {
|
|
185
|
+
await this.query.setModel(command.model)
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
this.modelChangeInFlight = false
|
|
189
|
+
}
|
|
190
|
+
this.metadata.model = command.model
|
|
191
|
+
this.init.requestedModel = command.model
|
|
192
|
+
options.signal?.throwIfAborted()
|
|
193
|
+
}
|
|
194
|
+
this.modelChangeReady = false
|
|
163
195
|
// Register before the push: the CLI's queued receipt can outrun the local push resolution,
|
|
164
196
|
// and a receipt with no registered waiter would be lost.
|
|
165
197
|
const clientMessageId = deriveMessageId(command.commandId)
|
|
166
198
|
const receipt = this.registerAcceptanceWaiter(clientMessageId)
|
|
199
|
+
this.commandByClientMessageId.set(clientMessageId, command.commandId)
|
|
167
200
|
try {
|
|
168
201
|
await this.deliverInput(message)
|
|
169
202
|
// `submitted` requires the provider's positive acceptance receipt — the pinned
|
|
@@ -257,6 +290,7 @@ export class ClaudeRun implements AgentRun {
|
|
|
257
290
|
}))
|
|
258
291
|
}
|
|
259
292
|
this.acceptanceWaiters.clear()
|
|
293
|
+
this.commandByClientMessageId.clear()
|
|
260
294
|
this.events.push({ type: 'session.ended', payload: error ? { reason, error } : { reason } })
|
|
261
295
|
this.events.push({ type: 'session.state.changed', payload: { state: reason === 'failed' ? 'failed' : 'stopped' } })
|
|
262
296
|
this.events.end()
|
|
@@ -306,8 +340,17 @@ export class ClaudeRun implements AgentRun {
|
|
|
306
340
|
this.acceptanceWaiters.get(parsed.data.command_uuid)?.reject(new AgentDeliveryUnknownError({
|
|
307
341
|
message: `Claude command ended as ${parsed.data.state} before acceptance was observed.`,
|
|
308
342
|
}))
|
|
343
|
+
this.commandByClientMessageId.delete(parsed.data.command_uuid)
|
|
309
344
|
return
|
|
310
345
|
}
|
|
346
|
+
// A queued receipt acknowledges admission, not turn ownership. Keep the
|
|
347
|
+
// mapping after acceptance settles: the provider's started receipt is
|
|
348
|
+
// the authority for the next root turn, even with several queued inputs.
|
|
349
|
+
if (parsed.data.state === 'started') {
|
|
350
|
+
this.eventMapperState.currentCommandId = this.commandByClientMessageId.get(parsed.data.command_uuid)
|
|
351
|
+
}
|
|
352
|
+
if (parsed.data.state === 'completed')
|
|
353
|
+
this.commandByClientMessageId.delete(parsed.data.command_uuid)
|
|
311
354
|
this.acceptanceWaiters.get(parsed.data.command_uuid)?.resolve()
|
|
312
355
|
}
|
|
313
356
|
|
|
@@ -362,6 +405,7 @@ export class ClaudeRun implements AgentRun {
|
|
|
362
405
|
private async pushOwnedStream(message: SDKUserMessage): Promise<void> {
|
|
363
406
|
try {
|
|
364
407
|
await this.input.push(message)
|
|
408
|
+
this.recordStartup?.('first_input_queued')
|
|
365
409
|
}
|
|
366
410
|
catch (error) {
|
|
367
411
|
if (error instanceof AgentRunStateError) {
|
|
@@ -389,7 +433,13 @@ export class ClaudeRun implements AgentRun {
|
|
|
389
433
|
this.completeInitialization(message)
|
|
390
434
|
continue
|
|
391
435
|
}
|
|
392
|
-
|
|
436
|
+
const completeMessage = message.type === 'user'
|
|
437
|
+
? await hydrateClaudeToolOutput(message, {
|
|
438
|
+
transcriptPath: this.metadata.transcriptPath,
|
|
439
|
+
toolNameById: this.eventMapperState.toolNameById,
|
|
440
|
+
})
|
|
441
|
+
: message
|
|
442
|
+
for (const draft of mapClaudeMessage(completeMessage, this.eventMapperState)) this.pushEvent(draft)
|
|
393
443
|
}
|
|
394
444
|
// An intentional stop already finalized the run; the ended stream is expected then.
|
|
395
445
|
if (this.finished)
|
|
@@ -425,6 +475,7 @@ export class ClaudeRun implements AgentRun {
|
|
|
425
475
|
if (!effectiveModel) {
|
|
426
476
|
throw new AgentProviderMetadataError({ message: 'Claude SDK init did not include model.', details: { provider: 'claude' } })
|
|
427
477
|
}
|
|
478
|
+
this.metadata.model = effectiveModel
|
|
428
479
|
|
|
429
480
|
this.pushEvent({ type: 'session.started', payload: this.metadata })
|
|
430
481
|
this.pushEvent(sessionConfiguredEvent({
|
|
@@ -437,6 +488,7 @@ export class ClaudeRun implements AgentRun {
|
|
|
437
488
|
this.pushEvent({ type: 'session.state.changed', payload: { state: 'ready' } })
|
|
438
489
|
for (const draft of mapClaudeMessage(init, this.eventMapperState)) this.pushEvent(draft)
|
|
439
490
|
this.initialized = true
|
|
491
|
+
this.recordStartup?.('session_ready')
|
|
440
492
|
this.initializationResolve()
|
|
441
493
|
}
|
|
442
494
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type ClaudeStartupPhase = 'query_created' | 'control_ready' | 'control_failed' | 'first_input_queued' | 'session_ready'
|
|
2
|
+
|
|
3
|
+
export interface ClaudeStartupTiming {
|
|
4
|
+
phase: ClaudeStartupPhase
|
|
5
|
+
elapsedMs: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Payload-free diagnostics only; never becomes an execution readiness barrier. */
|
|
9
|
+
export function createClaudeStartupTiming(
|
|
10
|
+
observer?: (timing: ClaudeStartupTiming) => void,
|
|
11
|
+
now: () => number = () => performance.now(),
|
|
12
|
+
): (phase: ClaudeStartupPhase) => void {
|
|
13
|
+
const started = now()
|
|
14
|
+
const seen = new Set<ClaudeStartupPhase>()
|
|
15
|
+
return (phase) => {
|
|
16
|
+
if (!observer || seen.has(phase))
|
|
17
|
+
return
|
|
18
|
+
seen.add(phase)
|
|
19
|
+
try {
|
|
20
|
+
observer({ phase, elapsedMs: Math.max(0, now() - started) })
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// Observability must not reject a command or stop the provider stream.
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function observeClaudeControlInitialization(
|
|
29
|
+
initialization: Promise<unknown>,
|
|
30
|
+
record: (phase: ClaudeStartupPhase) => void,
|
|
31
|
+
): void {
|
|
32
|
+
// Discard account/configuration response and error bodies, which may be sensitive.
|
|
33
|
+
void initialization.then(() => record('control_ready'), () => record('control_failed'))
|
|
34
|
+
}
|
|
@@ -29,6 +29,17 @@ export interface CodexApiKeyAuthProvider {
|
|
|
29
29
|
getApiKey: (input?: CodexAuthTokenRequest) => Promise<CodexApiKeyCredentials>
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
export interface CodexBedrockCredentials {
|
|
33
|
+
bearerToken: string
|
|
34
|
+
expiresAt: number
|
|
35
|
+
generation: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CodexBedrockAuthProvider {
|
|
39
|
+
getCredentials: (input?: CodexAuthTokenRequest) => Promise<CodexBedrockCredentials>
|
|
40
|
+
}
|
|
41
|
+
|
|
32
42
|
export type CodexAuthentication
|
|
33
43
|
= | { kind: 'chatgpt', provider: CodexAuthProvider }
|
|
34
44
|
| { kind: 'api-key', provider: CodexApiKeyAuthProvider, modelProvider?: string }
|
|
45
|
+
| { kind: 'amazon-bedrock', region: string, provider: CodexBedrockAuthProvider }
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Raw runtime diagnostics can contain bearer credentials and request bodies. */
|
|
2
|
+
export function bedrockDiagnosticMessage(source: 'stderr' | 'invalid-json-rpc'): string {
|
|
3
|
+
return source === 'stderr'
|
|
4
|
+
? 'Codex emitted a provider diagnostic; raw content withheld.'
|
|
5
|
+
: 'Codex emitted an invalid JSON-RPC line; raw content withheld.'
|
|
6
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { CodexBedrockAuthProvider, CodexBedrockCredentials } from './codex-auth'
|
|
2
|
+
import { AgentPromptDeferredError, AgentProviderRuntimeError } from '../agent-runtime-error'
|
|
3
|
+
|
|
4
|
+
export function validateBedrockCredentials(credentials: CodexBedrockCredentials, now = Date.now()): void {
|
|
5
|
+
if (!credentials.bearerToken || !Number.isFinite(credentials.expiresAt) || credentials.expiresAt <= now
|
|
6
|
+
|| !Number.isSafeInteger(credentials.generation) || credentials.generation < 1) {
|
|
7
|
+
throw new AgentProviderRuntimeError({ message: 'Codex Bedrock credentials are invalid or expired.' })
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Updates authentication only; never dispatches or retries a provider prompt. */
|
|
12
|
+
export class CodexBedrockSessionAuth {
|
|
13
|
+
private current: CodexBedrockCredentials
|
|
14
|
+
private pending: Promise<void> | undefined
|
|
15
|
+
private terminalFailure: Error | undefined
|
|
16
|
+
|
|
17
|
+
constructor(private readonly input: {
|
|
18
|
+
initial: CodexBedrockCredentials
|
|
19
|
+
provider: CodexBedrockAuthProvider
|
|
20
|
+
login: (credentials: CodexBedrockCredentials) => Promise<void>
|
|
21
|
+
isActive: () => boolean
|
|
22
|
+
now?: () => number
|
|
23
|
+
refreshTimeoutMs?: number
|
|
24
|
+
}) {
|
|
25
|
+
this.current = { ...input.initial }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async beforePrompt(signal?: AbortSignal): Promise<void> {
|
|
29
|
+
signal?.throwIfAborted()
|
|
30
|
+
if (this.terminalFailure)
|
|
31
|
+
throw this.terminalFailure
|
|
32
|
+
const now = this.input.now ?? Date.now
|
|
33
|
+
if (this.current.expiresAt - now() > 5 * 60_000)
|
|
34
|
+
return
|
|
35
|
+
if (this.input.isActive())
|
|
36
|
+
throw new AgentPromptDeferredError({ message: 'Bedrock credential refresh requires an idle turn boundary.' })
|
|
37
|
+
this.pending ??= this.boundedRefresh(now).finally(() => {
|
|
38
|
+
this.pending = undefined
|
|
39
|
+
})
|
|
40
|
+
await this.pending
|
|
41
|
+
signal?.throwIfAborted()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private async boundedRefresh(now: () => number): Promise<void> {
|
|
45
|
+
const abort = new AbortController()
|
|
46
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
47
|
+
const deadline = new Promise<never>((_resolve, reject) => {
|
|
48
|
+
timer = setTimeout(() => {
|
|
49
|
+
this.terminalFailure = new AgentProviderRuntimeError({ message: 'Bedrock authentication refresh timed out; the provider connection must be reopened before dispatch.' })
|
|
50
|
+
abort.abort(this.terminalFailure)
|
|
51
|
+
reject(this.terminalFailure)
|
|
52
|
+
}, this.input.refreshTimeoutMs ?? 30_000)
|
|
53
|
+
})
|
|
54
|
+
try {
|
|
55
|
+
await Promise.race([this.refresh(now, abort.signal), deadline])
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
clearTimeout(timer)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private async refresh(now: () => number, signal: AbortSignal): Promise<void> {
|
|
63
|
+
// A caller abort must not cancel refresh shared by other pending callers.
|
|
64
|
+
const credentials = await this.input.provider.getCredentials({ signal })
|
|
65
|
+
signal.throwIfAborted()
|
|
66
|
+
validateBedrockCredentials(credentials, now())
|
|
67
|
+
if (credentials.expiresAt - now() <= 5 * 60_000)
|
|
68
|
+
throw new AgentProviderRuntimeError({ message: 'Bedrock credentials expire inside the refresh window.' })
|
|
69
|
+
if (credentials.generation < this.current.generation
|
|
70
|
+
|| (credentials.generation === this.current.generation && (credentials.bearerToken !== this.current.bearerToken || credentials.expiresAt !== this.current.expiresAt))) {
|
|
71
|
+
throw new AgentProviderRuntimeError({ message: 'Bedrock credential generation is stale or conflicting.' })
|
|
72
|
+
}
|
|
73
|
+
if (this.input.isActive())
|
|
74
|
+
throw new AgentPromptDeferredError({ message: 'Bedrock credential refresh raced an active turn.' })
|
|
75
|
+
await this.input.login(credentials)
|
|
76
|
+
signal.throwIfAborted()
|
|
77
|
+
this.current = { ...credentials }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -399,8 +399,10 @@ function hookPromptText(item: CodexHookPromptItem): string | undefined {
|
|
|
399
399
|
}
|
|
400
400
|
|
|
401
401
|
function commandExecutionResult(item: Extract<CodexCompletedItem, { type: 'commandExecution' }>, status: 'completed' | 'failed'): AgentToolResult | undefined {
|
|
402
|
-
|
|
403
|
-
|
|
402
|
+
// Stdout is content, not a label: whitespace and an explicitly empty
|
|
403
|
+
// successful output must survive normalization into the complete step.
|
|
404
|
+
const text = item.aggregatedOutput
|
|
405
|
+
if (typeof text !== 'string')
|
|
404
406
|
return undefined
|
|
405
407
|
|
|
406
408
|
return { success: status === 'completed', content: [{ type: 'text', text }] }
|
|
@@ -70,12 +70,13 @@ export class CodexJsonRpcClient {
|
|
|
70
70
|
options: {
|
|
71
71
|
cwd: string
|
|
72
72
|
environment?: Record<string, string | undefined>
|
|
73
|
+
configOverrides?: readonly string[]
|
|
73
74
|
onMessage: (message: CodexJsonRpcMessage) => void
|
|
74
75
|
onInvalidLine: (line: string, error: unknown) => void
|
|
75
76
|
onStderr: (line: string) => void
|
|
76
77
|
},
|
|
77
78
|
) {
|
|
78
|
-
this.child = spawn(executable, ['app-server', '--listen', 'stdio://'], {
|
|
79
|
+
this.child = spawn(executable, ['app-server', '--listen', 'stdio://', ...(options.configOverrides ?? []).flatMap(value => ['-c', value])], {
|
|
79
80
|
cwd: options.cwd,
|
|
80
81
|
env: options.environment,
|
|
81
82
|
stdio: 'pipe',
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { AgentReasoningEffort, AgentToolDefinition, CodexModelId } from '../../protocol'
|
|
2
2
|
import type { AgentOperationOptions } from '../agent-operation'
|
|
3
3
|
import type { AgentProvider, AgentSessionOpenInput, AgentSessionResumeInput } from '../agent-provider'
|
|
4
|
+
import type { AgentRun } from '../agent-run'
|
|
4
5
|
import type { AgentTool } from '../agent-tool'
|
|
5
6
|
import type { InstructionComposer } from '../instructions'
|
|
6
|
-
import type { CodexAuthentication, CodexAuthProvider, CodexAuthRefreshInput, CodexAuthTokens } from './codex-auth'
|
|
7
|
+
import type { CodexAuthentication, CodexAuthProvider, CodexAuthRefreshInput, CodexAuthTokens, CodexBedrockCredentials } from './codex-auth'
|
|
7
8
|
import type { CodexEventMapperState, CodexNotification } from './codex-event-mapper'
|
|
8
9
|
import type { CodexJsonRpcMessage, CodexServerRequest } from './codex-json-rpc-client'
|
|
9
10
|
import type { ClientRequestResponsesByMethod, CodexChatGPTAuthTokensRefreshParams } from './codex-protocol'
|
|
@@ -16,7 +17,10 @@ import { AgentNotAcceptedError, AgentProviderMetadataError, AgentProviderProtoco
|
|
|
16
17
|
import { sessionConfiguredEvent } from '../agent-session-events'
|
|
17
18
|
import { selectAgentTools, toolByName } from '../agent-tool'
|
|
18
19
|
import { emptyInstructionComposer } from '../instructions'
|
|
20
|
+
import { ModelRebindingRun } from '../model-rebinding-run'
|
|
19
21
|
import { UserInputRequestStore, userInputRequestStoreRootFromTranscriptPath } from '../user-input-request'
|
|
22
|
+
import { bedrockDiagnosticMessage } from './codex-bedrock-diagnostics'
|
|
23
|
+
import { CodexBedrockSessionAuth, validateBedrockCredentials } from './codex-bedrock-session-auth'
|
|
20
24
|
import { cacheCodexThreadMetadata, codexNotificationFromMessage, createCodexEventMapperState, mapCodexNotification } from './codex-event-mapper'
|
|
21
25
|
import { CodexJsonRpcClient, isCodexServerRequest } from './codex-json-rpc-client'
|
|
22
26
|
import { CodexRun } from './codex-run'
|
|
@@ -38,17 +42,21 @@ export interface CodexProviderConfig {
|
|
|
38
42
|
environment?: Record<string, string | undefined>
|
|
39
43
|
onStartupStage?: (fact: CodexStartupStageFact) => void
|
|
40
44
|
instructions?: InstructionComposer
|
|
45
|
+
/** Service-owned public model to inference model mapping. */
|
|
46
|
+
resolveInferenceModel?: (model: string) => string
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
interface CodexRunHolder {
|
|
44
50
|
toolsByName: Map<string, AgentTool>
|
|
45
51
|
run?: CodexRun
|
|
46
52
|
authTokens?: CodexAuthTokens
|
|
53
|
+
bedrockAuth?: CodexBedrockSessionAuth
|
|
47
54
|
startupFailure?: Error
|
|
48
55
|
}
|
|
49
56
|
type CodexAuthMaterial
|
|
50
57
|
= | { kind: 'chatgpt', tokens: CodexAuthTokens }
|
|
51
58
|
| { kind: 'api-key', apiKey: string }
|
|
59
|
+
| { kind: 'amazon-bedrock', region: string, credentials: CodexBedrockCredentials }
|
|
52
60
|
interface CodexConnection {
|
|
53
61
|
client: CodexJsonRpcClient
|
|
54
62
|
abortController: AbortController
|
|
@@ -78,6 +86,8 @@ export class CodexProvider implements AgentProvider<'codex'> {
|
|
|
78
86
|
private readonly auth: CodexAuthentication
|
|
79
87
|
private readonly onStartupStage: ((fact: CodexStartupStageFact) => void) | undefined
|
|
80
88
|
private readonly instructions: InstructionComposer
|
|
89
|
+
private readonly resolveInferenceModel: (model: string) => string
|
|
90
|
+
private readonly hasInferenceModelMapping: boolean
|
|
81
91
|
|
|
82
92
|
private constructor(config: CodexProviderConfig) {
|
|
83
93
|
this.auth = normalizeCodexAuthentication(config.auth)
|
|
@@ -86,21 +96,23 @@ export class CodexProvider implements AgentProvider<'codex'> {
|
|
|
86
96
|
this.environment = config.environment ?? {}
|
|
87
97
|
this.onStartupStage = config.onStartupStage
|
|
88
98
|
this.instructions = config.instructions ?? emptyInstructionComposer
|
|
99
|
+
this.resolveInferenceModel = config.resolveInferenceModel ?? (model => model)
|
|
100
|
+
this.hasInferenceModelMapping = config.resolveInferenceModel !== undefined
|
|
89
101
|
}
|
|
90
102
|
|
|
91
103
|
static create(config: CodexProviderConfig): CodexProvider {
|
|
92
104
|
return new CodexProvider(config)
|
|
93
105
|
}
|
|
94
106
|
|
|
95
|
-
async openSession(input: AgentSessionOpenInput<'codex'>, options: AgentOperationOptions = {}): Promise<
|
|
96
|
-
|
|
107
|
+
async openSession(input: AgentSessionOpenInput<'codex'>, options: AgentOperationOptions = {}): Promise<AgentRun> {
|
|
108
|
+
const run = await this.startSession({
|
|
97
109
|
config: input,
|
|
98
110
|
options,
|
|
99
111
|
stage: 'thread-start',
|
|
100
112
|
requestThread: async ({ client, tools }): Promise<CodexThreadBootstrapResult> => await client.request('thread/start', {
|
|
101
113
|
cwd: input.cwd,
|
|
102
|
-
model: input.model,
|
|
103
|
-
modelProvider: this.
|
|
114
|
+
model: input.model ? this.resolveInferenceModel(input.model) : undefined,
|
|
115
|
+
modelProvider: this.modelProvider(),
|
|
104
116
|
ephemeral: false,
|
|
105
117
|
approvalPolicy: 'never',
|
|
106
118
|
sandbox: 'danger-full-access',
|
|
@@ -108,9 +120,26 @@ export class CodexProvider implements AgentProvider<'codex'> {
|
|
|
108
120
|
dynamicTools: toCodexDynamicTools(tools),
|
|
109
121
|
}),
|
|
110
122
|
})
|
|
123
|
+
return this.wrapBedrockRun(run, input)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async resumeSession(input: AgentSessionResumeInput<'codex'>, options: AgentOperationOptions = {}): Promise<AgentRun> {
|
|
127
|
+
return this.wrapBedrockRun(await this.resumeRawSession(input, options), input)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private wrapBedrockRun(run: CodexRun, input: AgentSessionOpenInput<'codex'>): AgentRun {
|
|
131
|
+
if (this.auth.kind !== 'amazon-bedrock')
|
|
132
|
+
return run
|
|
133
|
+
const roots = new Set(input.workspaceRepositoryRoots)
|
|
134
|
+
return new ModelRebindingRun(run, async (model, signal) => await this.resumeRawSession({
|
|
135
|
+
...input,
|
|
136
|
+
model: codexModelIdSchema.parse(model),
|
|
137
|
+
providerSessionId: run.metadata.providerSessionId,
|
|
138
|
+
workspaceRepositoryRoots: [...roots],
|
|
139
|
+
}, { signal }), root => roots.add(root))
|
|
111
140
|
}
|
|
112
141
|
|
|
113
|
-
async
|
|
142
|
+
private async resumeRawSession(input: AgentSessionResumeInput<'codex'>, options: AgentOperationOptions = {}): Promise<CodexRun> {
|
|
114
143
|
return await this.startSession({
|
|
115
144
|
config: input,
|
|
116
145
|
options,
|
|
@@ -120,8 +149,8 @@ export class CodexProvider implements AgentProvider<'codex'> {
|
|
|
120
149
|
cwd: input.cwd,
|
|
121
150
|
approvalPolicy: 'never',
|
|
122
151
|
sandbox: 'danger-full-access',
|
|
123
|
-
model: input.model,
|
|
124
|
-
modelProvider: this.
|
|
152
|
+
model: input.model ? this.resolveInferenceModel(input.model) : undefined,
|
|
153
|
+
modelProvider: this.modelProvider(),
|
|
125
154
|
developerInstructions: this.instructions({ provider: 'codex', tools }),
|
|
126
155
|
}),
|
|
127
156
|
validateThread: (result) => {
|
|
@@ -320,6 +349,12 @@ export class CodexProvider implements AgentProvider<'codex'> {
|
|
|
320
349
|
}
|
|
321
350
|
|
|
322
351
|
private async getAuthMaterial(signal: AbortSignal): Promise<CodexAuthMaterial> {
|
|
352
|
+
if (this.auth.kind === 'amazon-bedrock') {
|
|
353
|
+
const credentials = await this.auth.provider.getCredentials({ signal })
|
|
354
|
+
signal.throwIfAborted()
|
|
355
|
+
validateBedrockCredentials(credentials)
|
|
356
|
+
return { kind: 'amazon-bedrock', region: this.auth.region, credentials }
|
|
357
|
+
}
|
|
323
358
|
if (this.auth.kind === 'api-key') {
|
|
324
359
|
const credentials = await this.auth.provider.getApiKey({ signal })
|
|
325
360
|
return { kind: 'api-key', apiKey: credentials.apiKey }
|
|
@@ -328,6 +363,10 @@ export class CodexProvider implements AgentProvider<'codex'> {
|
|
|
328
363
|
return { kind: 'chatgpt', tokens }
|
|
329
364
|
}
|
|
330
365
|
|
|
366
|
+
private modelProvider(): string | undefined {
|
|
367
|
+
return this.auth.kind === 'amazon-bedrock' ? 'amazon-bedrock' : this.auth.kind === 'api-key' ? this.auth.modelProvider : undefined
|
|
368
|
+
}
|
|
369
|
+
|
|
331
370
|
private async openConnection(input: {
|
|
332
371
|
sessionId: string
|
|
333
372
|
cwd: string
|
|
@@ -347,11 +386,19 @@ export class CodexProvider implements AgentProvider<'codex'> {
|
|
|
347
386
|
client = new CodexJsonRpcClient(this.executable, {
|
|
348
387
|
cwd,
|
|
349
388
|
environment: this.environment,
|
|
389
|
+
configOverrides: this.auth.kind === 'amazon-bedrock'
|
|
390
|
+
? ['cli_auth_credentials_store="ephemeral"', 'model_provider="amazon-bedrock"', `model_providers.amazon-bedrock.aws.region=${JSON.stringify(this.auth.region)}`, 'model_providers.amazon-bedrock.aws.wire_api="responses"']
|
|
391
|
+
: undefined,
|
|
350
392
|
onMessage: (message) => {
|
|
351
393
|
messageQueue = this.enqueueMessageHandling(messageQueue, { message, holder, state, client, signal: abortController.signal })
|
|
352
394
|
},
|
|
353
|
-
onInvalidLine: (line, error) => holder.run?.pushEvent({
|
|
354
|
-
|
|
395
|
+
onInvalidLine: (line, error) => holder.run?.pushEvent({
|
|
396
|
+
type: 'error',
|
|
397
|
+
payload: this.auth.kind === 'amazon-bedrock'
|
|
398
|
+
? { message: bedrockDiagnosticMessage('invalid-json-rpc'), fatal: false, source: 'transport' }
|
|
399
|
+
: { message: `Invalid Codex JSON-RPC line: ${line.slice(0, 200)}`, fatal: false, source: 'transport', error: serializeAgentError(error) },
|
|
400
|
+
}),
|
|
401
|
+
onStderr: line => holder.run?.pushEvent({ type: 'error', payload: { message: this.auth.kind === 'amazon-bedrock' ? bedrockDiagnosticMessage('stderr') : line, fatal: false, source: 'provider' } }),
|
|
355
402
|
})
|
|
356
403
|
}
|
|
357
404
|
catch (error) {
|
|
@@ -483,12 +530,15 @@ export class CodexProvider implements AgentProvider<'codex'> {
|
|
|
483
530
|
})
|
|
484
531
|
}
|
|
485
532
|
|
|
533
|
+
if (this.hasInferenceModelMapping && config.model && result.model && result.model !== this.resolveInferenceModel(config.model))
|
|
534
|
+
throw new AgentProviderProtocolError({ message: 'Codex returned a model that does not match the configured inference binding.' })
|
|
535
|
+
|
|
486
536
|
const metadata = {
|
|
487
537
|
provider: 'codex' as const,
|
|
488
538
|
sessionId: config.sessionId,
|
|
489
539
|
providerSessionId: thread.id,
|
|
490
540
|
cwd: thread.cwd ?? config.cwd,
|
|
491
|
-
model: firstCodexModelId(effectiveModel),
|
|
541
|
+
model: firstCodexModelId(this.hasInferenceModelMapping ? config.model ?? effectiveModel : effectiveModel),
|
|
492
542
|
transcriptPath: thread.path,
|
|
493
543
|
}
|
|
494
544
|
throwIfStartupFailed(holder)
|
|
@@ -507,8 +557,10 @@ export class CodexProvider implements AgentProvider<'codex'> {
|
|
|
507
557
|
])
|
|
508
558
|
startupSignal?.throwIfAborted()
|
|
509
559
|
const run = new CodexRun({
|
|
560
|
+
beforePrompt: holder.bedrockAuth ? signal => holder.bedrockAuth!.beforePrompt(signal) : undefined,
|
|
510
561
|
metadata,
|
|
511
562
|
reasoningEffort: config.reasoningEffort,
|
|
563
|
+
resolveInferenceModel: this.resolveInferenceModel,
|
|
512
564
|
client,
|
|
513
565
|
requestedCwd: config.cwd,
|
|
514
566
|
workspaceRepositoryRoots: config.workspaceRepositoryRoots,
|
|
@@ -529,7 +581,7 @@ export class CodexProvider implements AgentProvider<'codex'> {
|
|
|
529
581
|
holder.run = run
|
|
530
582
|
|
|
531
583
|
run.pushEvent({ type: 'session.started', payload: run.metadata })
|
|
532
|
-
run.pushEvent(sessionConfiguredEvent({ model: effectiveModel, cwd: run.metadata.cwd, reasoningEffort: config.reasoningEffort, tools: config.tools, environment: this.environment }))
|
|
584
|
+
run.pushEvent(sessionConfiguredEvent({ model: run.metadata.model ?? effectiveModel, cwd: run.metadata.cwd, reasoningEffort: config.reasoningEffort, tools: config.tools, environment: this.environment }))
|
|
533
585
|
run.pushEvent({ type: 'session.state.changed', payload: { state: 'ready' } })
|
|
534
586
|
return run
|
|
535
587
|
}
|
|
@@ -615,6 +667,23 @@ export class CodexProvider implements AgentProvider<'codex'> {
|
|
|
615
667
|
}): Promise<void> {
|
|
616
668
|
const { client, holder, authMaterial } = input
|
|
617
669
|
throwIfStartupFailed(holder)
|
|
670
|
+
if (authMaterial.kind === 'amazon-bedrock') {
|
|
671
|
+
if (authMaterial.credentials.expiresAt <= Date.now())
|
|
672
|
+
throw new AgentProviderRuntimeError({ message: 'Codex Bedrock credentials expired before login.' })
|
|
673
|
+
await client.request('account/login/start', { type: 'amazonBedrock', apiKey: authMaterial.credentials.bearerToken, region: authMaterial.region })
|
|
674
|
+
throwIfStartupFailed(holder)
|
|
675
|
+
if (this.auth.kind !== 'amazon-bedrock')
|
|
676
|
+
throw new AgentProviderProtocolError({ message: 'Bedrock authentication mode changed during login.' })
|
|
677
|
+
holder.bedrockAuth = new CodexBedrockSessionAuth({
|
|
678
|
+
initial: authMaterial.credentials,
|
|
679
|
+
provider: this.auth.provider,
|
|
680
|
+
isActive: () => holder.run?.hasActiveTurn() ?? false,
|
|
681
|
+
login: async (credentials) => {
|
|
682
|
+
await client.request('account/login/start', { type: 'amazonBedrock', apiKey: credentials.bearerToken, region: authMaterial.region })
|
|
683
|
+
},
|
|
684
|
+
})
|
|
685
|
+
return
|
|
686
|
+
}
|
|
618
687
|
if (authMaterial.kind === 'api-key') {
|
|
619
688
|
await client.request('account/login/start', { type: 'apiKey', apiKey: authMaterial.apiKey })
|
|
620
689
|
throwIfStartupFailed(holder)
|
|
@@ -35,9 +35,12 @@ interface PreparedCodexTurn {
|
|
|
35
35
|
input: CodexUserInput[]
|
|
36
36
|
commandId: string
|
|
37
37
|
origin: 'user' | 'supervisor'
|
|
38
|
+
model?: string
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
interface CodexRunInput {
|
|
42
|
+
beforePrompt?: (signal?: AbortSignal) => Promise<void>
|
|
43
|
+
resolveInferenceModel?: (model: string) => string
|
|
41
44
|
metadata: AgentRunMetadata
|
|
42
45
|
reasoningEffort?: AgentReasoningEffort
|
|
43
46
|
client: CodexJsonRpcClient
|
|
@@ -56,6 +59,8 @@ export class CodexRun implements AgentRun {
|
|
|
56
59
|
readonly events: AgentEventStream
|
|
57
60
|
readonly metadata: AgentRunMetadata
|
|
58
61
|
private readonly client: CodexJsonRpcClient
|
|
62
|
+
private readonly beforePrompt: ((signal?: AbortSignal) => Promise<void>) | undefined
|
|
63
|
+
private readonly resolveInferenceModel: (model: string) => string
|
|
59
64
|
private readonly reasoningEffort: AgentReasoningEffort | undefined
|
|
60
65
|
private readonly onStop: () => void
|
|
61
66
|
private readonly userInputRequests: UserInputRequestStore
|
|
@@ -74,6 +79,8 @@ export class CodexRun implements AgentRun {
|
|
|
74
79
|
constructor(input: CodexRunInput) {
|
|
75
80
|
this.metadata = input.metadata
|
|
76
81
|
this.client = input.client
|
|
82
|
+
this.beforePrompt = input.beforePrompt
|
|
83
|
+
this.resolveInferenceModel = input.resolveInferenceModel ?? (model => model)
|
|
77
84
|
this.reasoningEffort = input.reasoningEffort
|
|
78
85
|
this.onStop = input.onStop
|
|
79
86
|
this.userInputRequests = input.userInputRequests
|
|
@@ -225,6 +232,10 @@ export class CodexRun implements AgentRun {
|
|
|
225
232
|
// and answering a phantom turn id, so the next-vs-active race must be refused from adapter
|
|
226
233
|
// state before any request — nothing is prepared or retained.
|
|
227
234
|
this.deferNextWhileTurnActive(command.mode)
|
|
235
|
+
if (this.beforePrompt)
|
|
236
|
+
await this.beforePrompt(options.signal)
|
|
237
|
+
this.assertRunning()
|
|
238
|
+
this.deferNextWhileTurnActive(command.mode)
|
|
228
239
|
const prepared = await this.prepareTurn({
|
|
229
240
|
prompt: command.prompt,
|
|
230
241
|
commandId: command.commandId,
|
|
@@ -232,10 +243,13 @@ export class CodexRun implements AgentRun {
|
|
|
232
243
|
author: command.author,
|
|
233
244
|
options,
|
|
234
245
|
})
|
|
246
|
+
prepared.model = command.model
|
|
235
247
|
// A turn/started fact may have reduced during attachment preparation.
|
|
236
248
|
this.deferNextWhileTurnActive(command.mode)
|
|
237
249
|
|
|
238
250
|
const activeTurnId = this.activeTurnId
|
|
251
|
+
if (activeTurnId && command.model && command.model !== this.metadata.model)
|
|
252
|
+
throw new AgentPromptDeferredError({ message: 'Codex model changes require an idle turn boundary.' })
|
|
239
253
|
if (command.mode === 'next' || !activeTurnId)
|
|
240
254
|
return await this.startPreparedTurn(prepared)
|
|
241
255
|
|
|
@@ -261,6 +275,10 @@ export class CodexRun implements AgentRun {
|
|
|
261
275
|
return { kind: 'turn', turnId: result.turnId, placement: 'steered' }
|
|
262
276
|
}
|
|
263
277
|
|
|
278
|
+
hasActiveTurn(): boolean {
|
|
279
|
+
return this.activeTurnId !== undefined
|
|
280
|
+
}
|
|
281
|
+
|
|
264
282
|
private deferNextWhileTurnActive(mode: SendPromptCommand['mode']): void {
|
|
265
283
|
if (mode !== 'next' || !this.activeTurnId)
|
|
266
284
|
return
|
|
@@ -336,6 +354,8 @@ export class CodexRun implements AgentRun {
|
|
|
336
354
|
}
|
|
337
355
|
|
|
338
356
|
private async startPreparedTurn(prepared: PreparedCodexTurn): Promise<AgentPromptAcceptance> {
|
|
357
|
+
const publicModel = prepared.model ?? this.metadata.model
|
|
358
|
+
const inferenceModel = publicModel ? this.resolveInferenceModel(publicModel) : undefined
|
|
339
359
|
const clientUserMessageId = this.rememberPromptCommand(prepared.commandId, prepared.origin)
|
|
340
360
|
let result: ClientRequestResponsesByMethod['turn/start']
|
|
341
361
|
try {
|
|
@@ -343,7 +363,7 @@ export class CodexRun implements AgentRun {
|
|
|
343
363
|
threadId: this.metadata.providerSessionId,
|
|
344
364
|
input: prepared.input,
|
|
345
365
|
cwd: this.metadata.cwd,
|
|
346
|
-
model:
|
|
366
|
+
model: inferenceModel,
|
|
347
367
|
effort: codexEffort(this.reasoningEffort),
|
|
348
368
|
summary: codexReasoningSummary,
|
|
349
369
|
approvalPolicy: 'never',
|
|
@@ -355,6 +375,8 @@ export class CodexRun implements AgentRun {
|
|
|
355
375
|
throw mapTurnTransportLoss(error)
|
|
356
376
|
}
|
|
357
377
|
this.activeTurnId = result.turn.id
|
|
378
|
+
if (prepared.model)
|
|
379
|
+
this.metadata.model = prepared.model
|
|
358
380
|
return { kind: 'turn', turnId: result.turn.id, placement: 'started' }
|
|
359
381
|
}
|
|
360
382
|
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type { AgentEvent, AgentRunMetadata, SendPromptCommand } from '../protocol'
|
|
2
|
+
import type { AgentOperationOptions } from './agent-operation'
|
|
3
|
+
import type { AgentPromptAcceptance, AgentRun } from './agent-run'
|
|
4
|
+
import { AgentEventStream } from './agent-event-stream'
|
|
5
|
+
import { AgentNotAcceptedError, AgentPromptDeferredError, AgentRunStateError } from './agent-runtime-error'
|
|
6
|
+
|
|
7
|
+
/** Keeps one public event stream while replacing an idle provider connection. */
|
|
8
|
+
export class ModelRebindingRun implements AgentRun {
|
|
9
|
+
readonly events: AgentEventStream
|
|
10
|
+
readonly metadata: AgentRunMetadata
|
|
11
|
+
private run: AgentRun
|
|
12
|
+
private pump: Promise<void>
|
|
13
|
+
private activeTurn: string | undefined
|
|
14
|
+
private lastEndedTurn: string | undefined
|
|
15
|
+
private dispatching: Promise<AgentPromptAcceptance> | undefined
|
|
16
|
+
private changing = false
|
|
17
|
+
private needsRebind = false
|
|
18
|
+
private stopped = false
|
|
19
|
+
private workspacePrepared = false
|
|
20
|
+
private workspaceChanging = false
|
|
21
|
+
private readonly lifetime = new AbortController()
|
|
22
|
+
|
|
23
|
+
constructor(run: AgentRun, private readonly reopen: (model: string, signal: AbortSignal) => Promise<AgentRun>, private readonly rootAdded?: (root: string) => void) {
|
|
24
|
+
this.run = run
|
|
25
|
+
this.metadata = { ...run.metadata }
|
|
26
|
+
this.events = new AgentEventStream(this.metadata)
|
|
27
|
+
this.pump = this.consume(run)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
prepareWorkspace: AgentRun['prepareWorkspace'] = async (options) => {
|
|
31
|
+
await this.withWorkspaceChange(async () => {
|
|
32
|
+
await this.run.prepareWorkspace(options)
|
|
33
|
+
this.workspacePrepared = true
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
addWorkspaceRepositoryRoot: AgentRun['addWorkspaceRepositoryRoot'] = async (root, options) => {
|
|
38
|
+
await this.withWorkspaceChange(async () => {
|
|
39
|
+
await this.run.addWorkspaceRepositoryRoot(root, options)
|
|
40
|
+
this.rootAdded?.(root)
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
respondUserInput: AgentRun['respondUserInput'] = async (command, options) => await this.run.respondUserInput(command, options)
|
|
45
|
+
interrupt: AgentRun['interrupt'] = async (command, options) => await this.run.interrupt(command, options)
|
|
46
|
+
|
|
47
|
+
async sendPrompt(command: SendPromptCommand, options: AgentOperationOptions = {}): Promise<AgentPromptAcceptance> {
|
|
48
|
+
if (this.dispatching || this.workspaceChanging)
|
|
49
|
+
throw new AgentPromptDeferredError({ message: 'A prompt admission is already in progress.' })
|
|
50
|
+
const task = this.dispatchPrompt(command, options)
|
|
51
|
+
this.dispatching = task
|
|
52
|
+
try {
|
|
53
|
+
return await task
|
|
54
|
+
}
|
|
55
|
+
finally { this.dispatching = undefined }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private async dispatchPrompt(command: SendPromptCommand, options: AgentOperationOptions): Promise<AgentPromptAcceptance> {
|
|
59
|
+
if (this.stopped)
|
|
60
|
+
throw new AgentRunStateError({ message: 'Cannot send a prompt after the run stopped.' })
|
|
61
|
+
if (command.sessionId !== this.metadata.sessionId || command.providerSessionId !== this.metadata.providerSessionId || command.provider !== this.metadata.provider)
|
|
62
|
+
throw new AgentRunStateError({ message: 'Prompt does not target the bound run.' })
|
|
63
|
+
if (this.changing)
|
|
64
|
+
throw new AgentPromptDeferredError({ message: 'The provider model is being rebound.' })
|
|
65
|
+
const model = command.model ?? this.metadata.model
|
|
66
|
+
if (model && (this.needsRebind || model !== this.metadata.model)) {
|
|
67
|
+
if (this.activeTurn)
|
|
68
|
+
throw new AgentPromptDeferredError({ message: 'Model changes require an idle turn boundary.' })
|
|
69
|
+
this.changing = true
|
|
70
|
+
const signal = AbortSignal.any([this.lifetime.signal, ...(options.signal ? [options.signal] : []), AbortSignal.timeout(10_000)])
|
|
71
|
+
let replacement: AgentRun | undefined
|
|
72
|
+
let identityMismatch = false
|
|
73
|
+
try {
|
|
74
|
+
signal.throwIfAborted()
|
|
75
|
+
this.needsRebind = true
|
|
76
|
+
await this.run.stop({ type: 'agent.stop', ...this.target(), reason: 'model_rebind' }, { signal })
|
|
77
|
+
await this.pump
|
|
78
|
+
signal.throwIfAborted()
|
|
79
|
+
replacement = await this.reopen(model, signal)
|
|
80
|
+
signal.throwIfAborted()
|
|
81
|
+
if (replacement.metadata.providerSessionId !== this.metadata.providerSessionId || replacement.metadata.sessionId !== this.metadata.sessionId || replacement.metadata.provider !== this.metadata.provider || replacement.metadata.model !== model) {
|
|
82
|
+
identityMismatch = true
|
|
83
|
+
throw new Error('Provider rebind changed the persisted identity or model.')
|
|
84
|
+
}
|
|
85
|
+
if (this.workspacePrepared)
|
|
86
|
+
await replacement.prepareWorkspace({ signal })
|
|
87
|
+
signal.throwIfAborted()
|
|
88
|
+
this.run = replacement
|
|
89
|
+
this.metadata.model = replacement.metadata.model
|
|
90
|
+
this.needsRebind = false
|
|
91
|
+
this.pump = this.consume(replacement, true)
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
if (replacement)
|
|
95
|
+
await replacement.stop({ type: 'agent.stop', provider: replacement.metadata.provider, sessionId: replacement.metadata.sessionId, providerSessionId: replacement.metadata.providerSessionId, reason: 'model_rebind_failed' }).catch(() => undefined)
|
|
96
|
+
throw new AgentNotAcceptedError({ message: 'Provider model rebind failed before prompt dispatch.', recovery: identityMismatch ? { kind: 'terminal', reason: 'model_rebind_identity_mismatch' } : { kind: 'retry', reason: 'model_rebind_failed', retryAfterMs: 1_000 } })
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
this.changing = false
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
this.lifetime.signal.throwIfAborted()
|
|
103
|
+
options.signal?.throwIfAborted()
|
|
104
|
+
const acceptance = await this.run.sendPrompt(command, { ...options, signal: AbortSignal.any([this.lifetime.signal, ...(options.signal ? [options.signal] : [])]) })
|
|
105
|
+
if (acceptance.kind === 'turn' && acceptance.turnId !== this.lastEndedTurn)
|
|
106
|
+
this.activeTurn = acceptance.turnId
|
|
107
|
+
return acceptance
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
stop: AgentRun['stop'] = async (command, options) => {
|
|
111
|
+
if (command.sessionId !== this.metadata.sessionId || command.providerSessionId !== this.metadata.providerSessionId || command.provider !== this.metadata.provider)
|
|
112
|
+
throw new AgentRunStateError({ message: 'Stop does not target the bound run.' })
|
|
113
|
+
this.stopped = true
|
|
114
|
+
this.lifetime.abort()
|
|
115
|
+
await this.run.stop(command, options)
|
|
116
|
+
await this.dispatching?.catch(() => undefined)
|
|
117
|
+
await this.pump
|
|
118
|
+
this.events.end()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private target() {
|
|
122
|
+
return { provider: this.metadata.provider, sessionId: this.metadata.sessionId, providerSessionId: this.metadata.providerSessionId }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private async withWorkspaceChange(operation: () => Promise<void>): Promise<void> {
|
|
126
|
+
if (this.stopped)
|
|
127
|
+
throw new AgentRunStateError({ message: 'The run stopped.' })
|
|
128
|
+
if (this.dispatching || this.workspaceChanging)
|
|
129
|
+
throw new AgentPromptDeferredError({ message: 'A provider operation is already in progress.' })
|
|
130
|
+
this.workspaceChanging = true
|
|
131
|
+
try {
|
|
132
|
+
await operation()
|
|
133
|
+
}
|
|
134
|
+
finally { this.workspaceChanging = false }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private async consume(run: AgentRun, rebound = false): Promise<void> {
|
|
138
|
+
try {
|
|
139
|
+
for await (const event of run.events) {
|
|
140
|
+
if (event.type === 'turn.started' && event.actor.type === 'main')
|
|
141
|
+
this.activeTurn = event.turnId
|
|
142
|
+
if (event.type === 'turn.ended' && event.actor.type === 'main') {
|
|
143
|
+
this.lastEndedTurn = event.turnId
|
|
144
|
+
if (event.turnId === this.activeTurn)
|
|
145
|
+
this.activeTurn = undefined
|
|
146
|
+
}
|
|
147
|
+
// Internal connection teardown is not product-session completion.
|
|
148
|
+
if (this.changing && (event.type === 'session.ended' || event.type === 'session.state.changed'))
|
|
149
|
+
continue
|
|
150
|
+
if (rebound && event.type === 'session.started')
|
|
151
|
+
continue
|
|
152
|
+
this.emit(event)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
this.stopped = true
|
|
157
|
+
this.lifetime.abort()
|
|
158
|
+
this.events.push({ type: 'error', payload: { message: 'Provider event stream failed.', fatal: true, source: 'provider' } })
|
|
159
|
+
this.events.end()
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private emit(event: AgentEvent): void {
|
|
164
|
+
this.events.push(event)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -26,6 +26,8 @@ export const sendPromptCommandSchema = runningCommandBaseSchema.extend({
|
|
|
26
26
|
origin: z.enum(['user', 'supervisor']).default('user'),
|
|
27
27
|
prompt: agentPromptSchema,
|
|
28
28
|
commandId: commandIdSchema,
|
|
29
|
+
/** Resolved by the application for this prompt, including after runtime recovery. */
|
|
30
|
+
model: z.string().trim().min(1).max(256).optional(),
|
|
29
31
|
author: agentAuthorSchema.optional(),
|
|
30
32
|
}).strict()
|
|
31
33
|
|
|
@@ -135,7 +135,7 @@ export const sessionEndedAgentEventSchema = agentEventBaseSchema.extend({
|
|
|
135
135
|
|
|
136
136
|
export const turnStartedAgentEventSchema = agentTurnEventBaseSchema.extend({
|
|
137
137
|
type: z.literal('turn.started'),
|
|
138
|
-
payload: z.object({}).strict(),
|
|
138
|
+
payload: z.object({ commandId: z.string().min(1).optional() }).strict(),
|
|
139
139
|
}).strict()
|
|
140
140
|
|
|
141
141
|
export const turnEndedAgentEventSchema = agentTurnEventBaseSchema.extend({
|
package/src/provenance.gen.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Generated by scripts/write-provenance.ts. Do not edit by hand.
|
|
2
2
|
export const generatedProvenance = {
|
|
3
|
-
version: "0.1.
|
|
4
|
-
sha: "
|
|
5
|
-
buildTime: "2026-09-
|
|
3
|
+
version: "0.1.8",
|
|
4
|
+
sha: "ff5b7ece6b3b115d420a21046bd47b465813132b",
|
|
5
|
+
buildTime: "2026-09-10T20:15:33.140Z",
|
|
6
6
|
} as const
|
package/src/steps/projector.ts
CHANGED
|
@@ -23,6 +23,15 @@ export class DurableStepProjector {
|
|
|
23
23
|
this.db.exec(`pragma journal_mode=WAL; pragma synchronous=FULL;
|
|
24
24
|
create table if not exists steps(id text primary key, body text not null, last_event_id text);
|
|
25
25
|
create table if not exists journal(id text primary key, event text, frames text);
|
|
26
|
+
create table if not exists turn_commands(
|
|
27
|
+
session_id text not null, provider_session_id text not null,
|
|
28
|
+
actor_id text not null, turn_id text not null, command_id text not null,
|
|
29
|
+
primary key(session_id, provider_session_id, actor_id, turn_id)
|
|
30
|
+
);
|
|
31
|
+
create index if not exists steps_tool_command on steps(
|
|
32
|
+
json_extract(body,'$.sessionId'), json_extract(body,'$.actor.actorId'),
|
|
33
|
+
json_extract(body,'$.kind'), json_extract(body,'$.sourceId')
|
|
34
|
+
);
|
|
26
35
|
`)
|
|
27
36
|
if (!this.db.query<{ name: string }, []>('pragma table_info(steps)').all().some(column => column.name === 'last_event_id'))
|
|
28
37
|
this.db.exec('alter table steps add column last_event_id text')
|
|
@@ -42,7 +51,13 @@ export class DurableStepProjector {
|
|
|
42
51
|
}
|
|
43
52
|
|
|
44
53
|
async process(event: AgentEvent, emit: (body: WireEventBody) => void): Promise<void> {
|
|
45
|
-
|
|
54
|
+
// Tool stdout is not assistant text. Its canonical completion carries
|
|
55
|
+
// full content to detail storage and emits only a bounded preview.
|
|
56
|
+
// Splitting stdout into small frames would still stream heavy bytes
|
|
57
|
+
// through the API and defeat the individual-details boundary.
|
|
58
|
+
if (event.type === 'tool.output.delta')
|
|
59
|
+
return
|
|
60
|
+
if (['message.delta', 'reasoning.summary.delta'].includes(event.type)) {
|
|
46
61
|
// Live deltas never become historical tool payloads. Large transport
|
|
47
62
|
// deltas are split rather than dropped by the supervisor frame limit.
|
|
48
63
|
const body = wrapAgentEvent(event)
|
|
@@ -76,6 +91,7 @@ export class DurableStepProjector {
|
|
|
76
91
|
}
|
|
77
92
|
|
|
78
93
|
private async project(event: AgentEvent): Promise<WireEventBody[]> {
|
|
94
|
+
const commandId = this.correlateCommand(event)
|
|
79
95
|
const kind = event.type.startsWith('message.')
|
|
80
96
|
? 'message'
|
|
81
97
|
: event.type.startsWith('tool.call.')
|
|
@@ -86,6 +102,8 @@ export class DurableStepProjector {
|
|
|
86
102
|
if (event.type === 'turn.ended' || event.type === 'session.ended') {
|
|
87
103
|
for (const row of this.db.query<{ body: string, last_event_id: string | null }, []>('select body, last_event_id from steps').all()) {
|
|
88
104
|
const step = JSON.parse(row.body) as StepDetailV1
|
|
105
|
+
if (step.sessionId !== event.sessionId || step.providerSessionId !== event.providerSessionId)
|
|
106
|
+
continue
|
|
89
107
|
if ((step.status !== 'running' && row.last_event_id !== event.eventId) || (event.type === 'turn.ended' && (step.turnId !== event.turnId || step.actor.actorId !== event.actor.actorId)))
|
|
90
108
|
continue
|
|
91
109
|
step.status = event.type === 'turn.ended' && event.payload.status === 'failed' ? 'failed' : 'interrupted'
|
|
@@ -109,7 +127,7 @@ export class DurableStepProjector {
|
|
|
109
127
|
stepId,
|
|
110
128
|
version: 1,
|
|
111
129
|
provider: event.provider,
|
|
112
|
-
commandId
|
|
130
|
+
commandId,
|
|
113
131
|
role: kind === 'message' ? payload.role as StepSummary['role'] : null,
|
|
114
132
|
toolName: typeof payload.toolName === 'string' ? payload.toolName : null,
|
|
115
133
|
status: 'running',
|
|
@@ -121,6 +139,7 @@ export class DurableStepProjector {
|
|
|
121
139
|
}
|
|
122
140
|
if (step.status !== 'running' && row?.last_event_id !== event.eventId)
|
|
123
141
|
return []
|
|
142
|
+
step.commandId ??= commandId
|
|
124
143
|
if (event.type.endsWith('started')) {
|
|
125
144
|
if (row && row.last_event_id !== event.eventId)
|
|
126
145
|
return []
|
|
@@ -158,12 +177,16 @@ export class DurableStepProjector {
|
|
|
158
177
|
}
|
|
159
178
|
|
|
160
179
|
private async finish(step: StepDetailV1): Promise<WireEventBody[]> {
|
|
180
|
+
// The provider can correlate a turn after its first content start. A
|
|
181
|
+
// terminal fact must resolve that binding too, not only normal closes.
|
|
182
|
+
step.commandId ??= this.correlateCommand(step)
|
|
161
183
|
if (step.kind === 'message') {
|
|
162
184
|
// References, not repeated complete tool payloads, connect message content.
|
|
163
|
-
step.content.toolSteps = this.db.query<{ id: string }, [string, string]>(`
|
|
185
|
+
step.content.toolSteps = this.db.query<{ id: string }, [string, string, string, string]>(`
|
|
164
186
|
select id from steps where json_extract(body,'$.kind')='tool'
|
|
187
|
+
and json_extract(body,'$.sessionId')=? and json_extract(body,'$.providerSessionId')=?
|
|
165
188
|
and json_extract(body,'$.turnId')=? and json_extract(body,'$.actor.actorId')=? order by rowid
|
|
166
|
-
`).all(step.turnId, step.actor.actorId).map(row => row.id)
|
|
189
|
+
`).all(step.sessionId, step.providerSessionId, step.turnId, step.actor.actorId).map(row => row.id)
|
|
167
190
|
}
|
|
168
191
|
this.save(step)
|
|
169
192
|
const body = serializeStepDetail(step)
|
|
@@ -179,6 +202,39 @@ export class DurableStepProjector {
|
|
|
179
202
|
]
|
|
180
203
|
}
|
|
181
204
|
|
|
205
|
+
private correlateCommand(event: AgentEvent | StepDetailV1): string | null {
|
|
206
|
+
if (!('turnId' in event))
|
|
207
|
+
return null
|
|
208
|
+
const key = [event.sessionId, event.providerSessionId, event.actor.actorId, event.turnId] as const
|
|
209
|
+
const previous = this.db.query<{ command_id: string }, [string, string, string, string]>(
|
|
210
|
+
'select command_id from turn_commands where session_id=? and provider_session_id=? and actor_id=? and turn_id=?',
|
|
211
|
+
).get(...key)?.command_id
|
|
212
|
+
const payload = 'payload' in event ? event.payload as Record<string, unknown> : event
|
|
213
|
+
const explicit = typeof payload.commandId === 'string' ? payload.commandId : undefined
|
|
214
|
+
if (previous && explicit && previous !== explicit)
|
|
215
|
+
throw new Error('Conflicting command identity for a provider turn.')
|
|
216
|
+
let inherited: string | undefined
|
|
217
|
+
if (!previous && !explicit && event.actor.type === 'subagent') {
|
|
218
|
+
const toolCallId = event.actor.parentToolCallId
|
|
219
|
+
?? (event.actor.origin.type === 'tool_call' ? event.actor.origin.toolCallId : undefined)
|
|
220
|
+
if (toolCallId) {
|
|
221
|
+
const parents = this.db.query<{ command_id: string }, [string, string, string]>(`
|
|
222
|
+
select distinct json_extract(body,'$.commandId') as command_id from steps
|
|
223
|
+
where json_extract(body,'$.sessionId')=? and json_extract(body,'$.actor.actorId')=?
|
|
224
|
+
and json_extract(body,'$.kind')='tool' and json_extract(body,'$.sourceId')=?
|
|
225
|
+
and json_extract(body,'$.commandId') is not null limit 2
|
|
226
|
+
`).all(event.sessionId, event.actor.parentActorId, toolCallId)
|
|
227
|
+
if (parents.length === 1)
|
|
228
|
+
inherited = parents[0]!.command_id
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const commandId = previous ?? explicit ?? inherited
|
|
232
|
+
if (commandId && !previous) {
|
|
233
|
+
this.db.query('insert into turn_commands values (?,?,?,?,?)').run(...key, commandId)
|
|
234
|
+
}
|
|
235
|
+
return commandId ?? null
|
|
236
|
+
}
|
|
237
|
+
|
|
182
238
|
private save(step: StepDetailV1): void {
|
|
183
239
|
this.db.query('insert into steps(id,body,last_event_id) values (?,?,?) on conflict(id) do update set body=excluded.body,last_event_id=excluded.last_event_id')
|
|
184
240
|
.run(step.stepId, JSON.stringify(step), this.currentEventId)
|
|
@@ -128,6 +128,7 @@ class AgentSupervisorRuntime implements SupervisorAgentRuntime {
|
|
|
128
128
|
providerSessionId: metadata.providerSessionId,
|
|
129
129
|
commandId: input.commandId,
|
|
130
130
|
mode: input.body.mode,
|
|
131
|
+
model: input.body.model,
|
|
131
132
|
origin: 'user',
|
|
132
133
|
prompt: input.body.prompt,
|
|
133
134
|
author: input.body.user
|
|
@@ -40,6 +40,7 @@ export const wireCommandExtensionsSchema = jsonObjectSchema.refine(
|
|
|
40
40
|
const wireSendPromptCommandSchema = z.object({
|
|
41
41
|
type: z.literal('agent.send-prompt'),
|
|
42
42
|
mode: agentPromptModeSchema,
|
|
43
|
+
model: z.string().trim().min(1).max(256).optional(),
|
|
43
44
|
prompt: agentPromptSchema,
|
|
44
45
|
attachments: z.array(wireInputAttachmentRefSchema).superRefine((attachments, context) => {
|
|
45
46
|
const attachmentIds = new Set<string>()
|