@meistrari/agent-core 0.1.1 → 0.1.3

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@meistrari/agent-core",
3
3
  "type": "module",
4
- "version": "0.1.1",
4
+ "version": "0.1.3",
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",
@@ -14,6 +14,8 @@
14
14
  "registry": "https://registry.npmjs.org"
15
15
  },
16
16
  "exports": {
17
+ "./steps/contract": "./src/steps/contract.ts",
18
+ "./steps/projector": "./src/steps/projector.ts",
17
19
  "./errors": "./src/errors/index.ts",
18
20
  "./errors/application-error": "./src/errors/application-error.ts",
19
21
  "./logger": "./src/logger/index.ts",
@@ -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
  }
@@ -71,6 +71,7 @@ export class ClaudeProvider implements AgentProvider<'claude'> {
71
71
  const sdkOptions: Options = removeUndefined({
72
72
  abortController,
73
73
  cwd: input.cwd,
74
+ settingSources: ['project', 'local'],
74
75
  env: this.environment,
75
76
  model: input.model,
76
77
  thinking: { type: 'adaptive', display: 'summarized' },
@@ -95,6 +95,7 @@ export class ClaudeRun implements AgentRun {
95
95
  private finished = false
96
96
  private interruptRequest?: { turnId: string, promise: Promise<void> }
97
97
  private readonly acceptanceWaiters = new Map<string, AcceptanceWaiter>()
98
+ private readonly commandByClientMessageId = new Map<string, string>()
98
99
 
99
100
  constructor({ metadata, query, userInputRequests, input, eventMapperState, init }: ClaudeRunInput) {
100
101
  this.metadata = metadata
@@ -164,6 +165,7 @@ export class ClaudeRun implements AgentRun {
164
165
  // and a receipt with no registered waiter would be lost.
165
166
  const clientMessageId = deriveMessageId(command.commandId)
166
167
  const receipt = this.registerAcceptanceWaiter(clientMessageId)
168
+ this.commandByClientMessageId.set(clientMessageId, command.commandId)
167
169
  try {
168
170
  await this.deliverInput(message)
169
171
  // `submitted` requires the provider's positive acceptance receipt — the pinned
@@ -257,6 +259,7 @@ export class ClaudeRun implements AgentRun {
257
259
  }))
258
260
  }
259
261
  this.acceptanceWaiters.clear()
262
+ this.commandByClientMessageId.clear()
260
263
  this.events.push({ type: 'session.ended', payload: error ? { reason, error } : { reason } })
261
264
  this.events.push({ type: 'session.state.changed', payload: { state: reason === 'failed' ? 'failed' : 'stopped' } })
262
265
  this.events.end()
@@ -306,8 +309,17 @@ export class ClaudeRun implements AgentRun {
306
309
  this.acceptanceWaiters.get(parsed.data.command_uuid)?.reject(new AgentDeliveryUnknownError({
307
310
  message: `Claude command ended as ${parsed.data.state} before acceptance was observed.`,
308
311
  }))
312
+ this.commandByClientMessageId.delete(parsed.data.command_uuid)
309
313
  return
310
314
  }
315
+ // A queued receipt acknowledges admission, not turn ownership. Keep the
316
+ // mapping after acceptance settles: the provider's started receipt is
317
+ // the authority for the next root turn, even with several queued inputs.
318
+ if (parsed.data.state === 'started') {
319
+ this.eventMapperState.currentCommandId = this.commandByClientMessageId.get(parsed.data.command_uuid)
320
+ }
321
+ if (parsed.data.state === 'completed')
322
+ this.commandByClientMessageId.delete(parsed.data.command_uuid)
311
323
  this.acceptanceWaiters.get(parsed.data.command_uuid)?.resolve()
312
324
  }
313
325
 
@@ -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
- const text = optionalNonEmpty(item.aggregatedOutput ?? undefined)
403
- if (!text)
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 }] }
@@ -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({
@@ -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.1",
4
- sha: "00ac4425fb84e86e0adabba1067485b94f45c100",
5
- buildTime: "2026-09-08T17:04:18.818Z",
3
+ version: "0.1.3",
4
+ sha: "8b997b992439bb6300854fed35d461ca0fdbd3e2",
5
+ buildTime: "2026-09-09T19:31:26.657Z",
6
6
  } as const
@@ -0,0 +1,94 @@
1
+ import { Buffer } from 'node:buffer'
2
+ import { createHash } from 'node:crypto'
3
+ import { z } from 'zod'
4
+ import { agentEventActorSchema } from '../protocol/agent-event'
5
+ import { jsonValueSchema } from '../protocol/agent-json'
6
+
7
+ export const stepSummarySchema = z.object({
8
+ version: z.literal(1),
9
+ stepId: z.string().uuid(),
10
+ sessionId: z.string(),
11
+ provider: z.enum(['claude', 'codex']),
12
+ providerSessionId: z.string(),
13
+ sourceId: z.string(),
14
+ actor: agentEventActorSchema,
15
+ turnId: z.string(),
16
+ commandId: z.string().nullable(),
17
+ kind: z.enum(['message', 'tool', 'reasoning']),
18
+ role: z.enum(['assistant', 'user', 'system']).nullable(),
19
+ toolName: z.string().nullable(),
20
+ status: z.enum(['running', 'completed', 'failed', 'interrupted']),
21
+ incomplete: z.boolean(),
22
+ startedAt: z.string().datetime(),
23
+ endedAt: z.string().datetime().nullable(),
24
+ preview: z.string(),
25
+ }).strict()
26
+ export const stepDetailSchema = stepSummarySchema.extend({
27
+ content: z.record(z.string(), jsonValueSchema),
28
+ }).strict()
29
+ export type StepSummary = z.infer<typeof stepSummarySchema>
30
+ export type StepDetailV1 = z.infer<typeof stepDetailSchema>
31
+
32
+ /** Stable bytes across JSONB round trips and deterministic upload retries. */
33
+ export function serializeStepDetail(value: unknown): string {
34
+ return JSON.stringify(value, (_key, item: unknown) => {
35
+ if (item && typeof item === 'object' && !Array.isArray(item))
36
+ return Object.fromEntries(Object.entries(item).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0))
37
+ return item
38
+ })
39
+ }
40
+
41
+ export const stepClosedSchema = z.object({
42
+ step: stepSummarySchema,
43
+ sha256: z.string().regex(/^[a-f0-9]{64}$/u),
44
+ byteSize: z.number().int().positive(),
45
+ textParts: z.number().int().nonnegative(),
46
+ detail: stepDetailSchema.optional(),
47
+ reference: z.string().startsWith('vault://').optional(),
48
+ }).strict().refine(value => Boolean(value.detail) !== Boolean(value.reference), 'Exactly one detail source is required')
49
+
50
+ export function stepIdFor(input: Pick<StepSummary, 'sessionId' | 'providerSessionId' | 'actor' | 'turnId' | 'kind' | 'sourceId'>): string {
51
+ const hex = createHash('sha256').update(JSON.stringify([
52
+ input.sessionId,
53
+ input.providerSessionId,
54
+ input.actor.actorId,
55
+ input.turnId,
56
+ input.kind,
57
+ input.sourceId,
58
+ ])).digest('hex')
59
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-5${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`
60
+ }
61
+
62
+ /** Both decoded UTF-8 and JSON representation stay bounded without dropping text. */
63
+ export function splitStepText(text: string): string[] {
64
+ const parts: string[] = []
65
+ let part = ''
66
+ let bytes = 2
67
+ for (const character of text) {
68
+ const size = Buffer.byteLength(JSON.stringify(character)) - 2
69
+ if (bytes + size > 16 * 1024) {
70
+ parts.push(part)
71
+ part = ''
72
+ bytes = 2
73
+ }
74
+ part += character
75
+ bytes += size
76
+ }
77
+ if (part)
78
+ parts.push(part)
79
+ return parts
80
+ }
81
+
82
+ export function stepPreview(value: unknown): string {
83
+ const text = (typeof value === 'string' ? value : JSON.stringify(value) ?? '').replaceAll('\0', '\\0')
84
+ let preview = ''
85
+ let bytes = 0
86
+ for (const character of text) {
87
+ const size = Buffer.byteLength(JSON.stringify(character)) - 2
88
+ if (bytes + size > 2048)
89
+ return `${preview}…`
90
+ bytes += size
91
+ preview += character
92
+ }
93
+ return preview
94
+ }
@@ -0,0 +1,245 @@
1
+ import type { AgentEvent } from '../protocol'
2
+ import type { WireEventBody } from '../supervisor-protocol/event-body'
3
+ import type { StepDetailV1, StepSummary } from './contract'
4
+ import { Buffer } from 'node:buffer'
5
+ import { createHash } from 'node:crypto'
6
+ import { Database } from 'bun:sqlite'
7
+ import { wrapAgentEvent } from '../supervisor-protocol/agent-event-wrapper'
8
+ import { serializeStepDetail, splitStepText, stepIdFor, stepPreview } from './contract'
9
+
10
+ /**
11
+ * Same-binary local recovery. The journal is marked delivered only after emit
12
+ * synchronously commits all frames into the supervisor's own durable outbox.
13
+ */
14
+ export class DurableStepProjector {
15
+ private readonly db: Database
16
+ private currentEventId = ''
17
+
18
+ constructor(private readonly options: {
19
+ path: string
20
+ upload: (input: { stepId: string, sha256: string, body: string, byteSize: number }) => Promise<string>
21
+ }) {
22
+ this.db = new Database(options.path, { create: true })
23
+ this.db.exec(`pragma journal_mode=WAL; pragma synchronous=FULL;
24
+ create table if not exists steps(id text primary key, body text not null, last_event_id text);
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
+ );
35
+ `)
36
+ if (!this.db.query<{ name: string }, []>('pragma table_info(steps)').all().some(column => column.name === 'last_event_id'))
37
+ this.db.exec('alter table steps add column last_event_id text')
38
+ }
39
+
40
+ close(): void { this.db.close() }
41
+
42
+ async recover(emit: (body: WireEventBody) => void): Promise<void> {
43
+ for (;;) {
44
+ const row = this.db.query<{ id: string, event: string, frames: string | null }, []>(
45
+ 'select id, event, frames from journal where event is not null order by rowid limit 1',
46
+ ).get()
47
+ if (!row)
48
+ return
49
+ await this.deliver(row.id, JSON.parse(row.event) as AgentEvent, row.frames, emit)
50
+ }
51
+ }
52
+
53
+ async process(event: AgentEvent, emit: (body: WireEventBody) => void): Promise<void> {
54
+ if (['message.delta', 'tool.output.delta', 'reasoning.summary.delta'].includes(event.type)) {
55
+ // Live deltas never become historical tool payloads. Large transport
56
+ // deltas are split rather than dropped by the supervisor frame limit.
57
+ const body = wrapAgentEvent(event)
58
+ const key = event.type === 'reasoning.summary.delta' ? 'text' : 'delta'
59
+ const payload = event.payload as Record<string, unknown>
60
+ for (const part of splitStepText(String(payload[key] ?? ''))) {
61
+ // Use smaller slices for envelope headroom within the 16 KiB cap.
62
+ for (const chunk of splitStepText(part).flatMap(part => [...part].length > 2000 ? part.match(/.{1,2000}/gsu)! : [part]))
63
+ emit({ ...body, payload: { ...payload, [key]: chunk } } as WireEventBody)
64
+ }
65
+ return
66
+ }
67
+ const found = this.db.query<{ event: string | null, frames: string | null }, [string]>(
68
+ 'select event, frames from journal where id=?',
69
+ ).get(event.eventId)
70
+ if (found?.event === null)
71
+ return
72
+ if (!found)
73
+ this.db.query('insert into journal(id,event) values (?,?)').run(event.eventId, JSON.stringify(event))
74
+ await this.deliver(event.eventId, event, found?.frames ?? null, emit)
75
+ }
76
+
77
+ private async deliver(id: string, event: AgentEvent, saved: string | null, emit: (body: WireEventBody) => void): Promise<void> {
78
+ this.currentEventId = id
79
+ const frames = saved ? JSON.parse(saved) as WireEventBody[] : await this.project(event)
80
+ if (!saved)
81
+ this.db.query('update journal set frames=? where id=?').run(JSON.stringify(frames), id)
82
+ for (const frame of frames)
83
+ emit(frame)
84
+ this.db.query('update journal set event=null, frames=null where id=?').run(id)
85
+ }
86
+
87
+ private async project(event: AgentEvent): Promise<WireEventBody[]> {
88
+ const commandId = this.correlateCommand(event)
89
+ const kind = event.type.startsWith('message.')
90
+ ? 'message'
91
+ : event.type.startsWith('tool.call.')
92
+ ? 'tool'
93
+ : event.type.startsWith('reasoning.') ? 'reasoning' : undefined
94
+ if (!kind || !('turnId' in event)) {
95
+ const frames: WireEventBody[] = []
96
+ if (event.type === 'turn.ended' || event.type === 'session.ended') {
97
+ for (const row of this.db.query<{ body: string, last_event_id: string | null }, []>('select body, last_event_id from steps').all()) {
98
+ const step = JSON.parse(row.body) as StepDetailV1
99
+ if (step.sessionId !== event.sessionId || step.providerSessionId !== event.providerSessionId)
100
+ continue
101
+ if ((step.status !== 'running' && row.last_event_id !== event.eventId) || (event.type === 'turn.ended' && (step.turnId !== event.turnId || step.actor.actorId !== event.actor.actorId)))
102
+ continue
103
+ step.status = event.type === 'turn.ended' && event.payload.status === 'failed' ? 'failed' : 'interrupted'
104
+ step.incomplete = true
105
+ step.endedAt = event.timestamp
106
+ frames.push(...await this.finish(step))
107
+ }
108
+ }
109
+ frames.push(wrapAgentEvent(event))
110
+ return frames
111
+ }
112
+ const payload = event.payload as Record<string, unknown>
113
+ const sourceId = String(payload.messageId ?? payload.toolCallId ?? payload.reasoningId)
114
+ const identity = { sessionId: event.sessionId, providerSessionId: event.providerSessionId, actor: event.actor, turnId: event.turnId, kind: kind as StepSummary['kind'], sourceId }
115
+ const stepId = stepIdFor(identity)
116
+ const row = this.db.query<{ body: string, last_event_id: string | null }, [string]>('select body, last_event_id from steps where id=?').get(stepId)
117
+ const step: StepDetailV1 = row
118
+ ? JSON.parse(row.body)
119
+ : {
120
+ ...identity,
121
+ stepId,
122
+ version: 1,
123
+ provider: event.provider,
124
+ commandId,
125
+ role: kind === 'message' ? payload.role as StepSummary['role'] : null,
126
+ toolName: typeof payload.toolName === 'string' ? payload.toolName : null,
127
+ status: 'running',
128
+ incomplete: !event.type.endsWith('started'),
129
+ startedAt: event.timestamp,
130
+ endedAt: null,
131
+ preview: '',
132
+ content: {},
133
+ }
134
+ if (step.status !== 'running' && row?.last_event_id !== event.eventId)
135
+ return []
136
+ step.commandId ??= commandId
137
+ if (event.type.endsWith('started')) {
138
+ if (row && row.last_event_id !== event.eventId)
139
+ return []
140
+ if (step.status !== 'running')
141
+ return []
142
+ if (kind === 'tool') {
143
+ step.content.input = payload.input as StepDetailV1['content'][string] ?? null
144
+ step.preview = stepPreview(payload.input)
145
+ }
146
+ this.save(step)
147
+ return [this.frame('product.step.started', { step: summary(step) })]
148
+ }
149
+ if (kind === 'message') {
150
+ step.content.text = typeof payload.text === 'string' ? payload.text : ''
151
+ step.preview = ''
152
+ }
153
+ else if (kind === 'tool') {
154
+ step.content.output = payload.output as StepDetailV1['content'][string] ?? null
155
+ step.content.error = payload.error as StepDetailV1['content'][string] ?? null
156
+ step.preview = stepPreview(payload.output ?? payload.error)
157
+ }
158
+ else {
159
+ step.content.summary = typeof payload.summary === 'string' ? payload.summary : ''
160
+ step.preview = stepPreview(payload.summary)
161
+ }
162
+ step.status = payload.status === 'failed' ? 'failed' : payload.status === 'cancelled' ? 'interrupted' : 'completed'
163
+ if (kind === 'tool' && payload.output === undefined && payload.error === undefined) {
164
+ step.incomplete = true
165
+ step.status = 'interrupted'
166
+ }
167
+ step.endedAt = event.timestamp
168
+ if (payload.overflow)
169
+ throw new Error('Step projection requires complete provider content before overflow truncation.')
170
+ return await this.finish(step)
171
+ }
172
+
173
+ private async finish(step: StepDetailV1): Promise<WireEventBody[]> {
174
+ // The provider can correlate a turn after its first content start. A
175
+ // terminal fact must resolve that binding too, not only normal closes.
176
+ step.commandId ??= this.correlateCommand(step)
177
+ if (step.kind === 'message') {
178
+ // References, not repeated complete tool payloads, connect message content.
179
+ step.content.toolSteps = this.db.query<{ id: string }, [string, string, string, string]>(`
180
+ select id from steps where json_extract(body,'$.kind')='tool'
181
+ and json_extract(body,'$.sessionId')=? and json_extract(body,'$.providerSessionId')=?
182
+ and json_extract(body,'$.turnId')=? and json_extract(body,'$.actor.actorId')=? order by rowid
183
+ `).all(step.sessionId, step.providerSessionId, step.turnId, step.actor.actorId).map(row => row.id)
184
+ }
185
+ this.save(step)
186
+ const body = serializeStepDetail(step)
187
+ const sha256 = createHash('sha256').update(body).digest('hex')
188
+ const byteSize = Buffer.byteLength(body)
189
+ const text = step.kind === 'message' && typeof step.content.text === 'string' ? splitStepText(step.content.text) : []
190
+ const detailSource = byteSize <= 32 * 1024
191
+ ? { detail: step }
192
+ : { reference: await this.options.upload({ stepId: step.stepId, sha256, byteSize, body }) }
193
+ return [
194
+ ...text.map((part, index) => this.frame('product.step.text', { step: summary(step), index, count: text.length, text: part })),
195
+ this.frame('product.step.closed', { step: summary(step), sha256, byteSize, textParts: text.length, ...detailSource }),
196
+ ]
197
+ }
198
+
199
+ private correlateCommand(event: AgentEvent | StepDetailV1): string | null {
200
+ if (!('turnId' in event))
201
+ return null
202
+ const key = [event.sessionId, event.providerSessionId, event.actor.actorId, event.turnId] as const
203
+ const previous = this.db.query<{ command_id: string }, [string, string, string, string]>(
204
+ 'select command_id from turn_commands where session_id=? and provider_session_id=? and actor_id=? and turn_id=?',
205
+ ).get(...key)?.command_id
206
+ const payload = 'payload' in event ? event.payload as Record<string, unknown> : event
207
+ const explicit = typeof payload.commandId === 'string' ? payload.commandId : undefined
208
+ if (previous && explicit && previous !== explicit)
209
+ throw new Error('Conflicting command identity for a provider turn.')
210
+ let inherited: string | undefined
211
+ if (!previous && !explicit && event.actor.type === 'subagent') {
212
+ const toolCallId = event.actor.parentToolCallId
213
+ ?? (event.actor.origin.type === 'tool_call' ? event.actor.origin.toolCallId : undefined)
214
+ if (toolCallId) {
215
+ const parents = this.db.query<{ command_id: string }, [string, string, string]>(`
216
+ select distinct json_extract(body,'$.commandId') as command_id from steps
217
+ where json_extract(body,'$.sessionId')=? and json_extract(body,'$.actor.actorId')=?
218
+ and json_extract(body,'$.kind')='tool' and json_extract(body,'$.sourceId')=?
219
+ and json_extract(body,'$.commandId') is not null limit 2
220
+ `).all(event.sessionId, event.actor.parentActorId, toolCallId)
221
+ if (parents.length === 1)
222
+ inherited = parents[0]!.command_id
223
+ }
224
+ }
225
+ const commandId = previous ?? explicit ?? inherited
226
+ if (commandId && !previous) {
227
+ this.db.query('insert into turn_commands values (?,?,?,?,?)').run(...key, commandId)
228
+ }
229
+ return commandId ?? null
230
+ }
231
+
232
+ private save(step: StepDetailV1): void {
233
+ 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')
234
+ .run(step.stepId, JSON.stringify(step), this.currentEventId)
235
+ }
236
+
237
+ private frame(type: `product.${string}`, payload: unknown): WireEventBody {
238
+ return { type, payload } as WireEventBody
239
+ }
240
+ }
241
+
242
+ function summary(step: StepDetailV1): StepSummary {
243
+ const { content: _content, ...value } = step
244
+ return value
245
+ }
@@ -16,6 +16,10 @@ export interface AgentProviderBindingStore {
16
16
  }
17
17
 
18
18
  export interface AgentSupervisorProviderFactoryOptions {
19
+ eventProcessor?: {
20
+ recover: (emit: (body: WireEventBody) => void) => Promise<void>
21
+ process: (event: AgentEvent, emit: (body: WireEventBody) => void) => Promise<void>
22
+ }
19
23
  createProvider: (input: {
20
24
  bootstrap: SessionBootstrapBody
21
25
  credentials?: EphemeralCredentials
@@ -69,6 +73,9 @@ export function createAgentSupervisorProviderFactory(
69
73
 
70
74
  return new AgentSupervisorRuntime({
71
75
  run,
76
+ emit: input.emit,
77
+ onFatal: input.onFatal,
78
+ eventProcessor: options.eventProcessor,
72
79
  prepareInputAttachments: options.prepareInputAttachments,
73
80
  onSecretsRefreshed: options.onSecretsRefreshed,
74
81
  })
@@ -76,17 +83,27 @@ export function createAgentSupervisorProviderFactory(
76
83
  }
77
84
 
78
85
  class AgentSupervisorRuntime implements SupervisorAgentRuntime {
86
+ private readonly attached = Promise.withResolvers<void>()
79
87
  private state: SupervisorAgentRunSnapshot = { status: 'attached', agentState: null }
80
88
  private emit: ((body: WireEventBody) => void) | undefined
81
89
  private readonly pendingEvents: WireEventBody[] = []
82
90
  private readonly eventPump: Promise<void>
91
+ private eventFailure: unknown
83
92
 
84
93
  constructor(private readonly input: {
85
94
  run: AgentRun
95
+ emit?: (body: WireEventBody) => void
96
+ onFatal?: (error: unknown) => void
97
+ eventProcessor?: AgentSupervisorProviderFactoryOptions['eventProcessor']
86
98
  prepareInputAttachments?: AgentSupervisorProviderFactoryOptions['prepareInputAttachments']
87
99
  onSecretsRefreshed?: AgentSupervisorProviderFactoryOptions['onSecretsRefreshed']
88
100
  }) {
89
- this.eventPump = this.consumeEvents()
101
+ if (input.emit)
102
+ this.attachEmitter(input.emit)
103
+ this.eventPump = this.consumeEvents().catch((error: unknown) => {
104
+ this.eventFailure = error
105
+ input.onFatal?.(error)
106
+ })
90
107
  }
91
108
 
92
109
  snapshot(): SupervisorAgentRunSnapshot {
@@ -94,6 +111,8 @@ class AgentSupervisorRuntime implements SupervisorAgentRuntime {
94
111
  }
95
112
 
96
113
  async handle(input: Parameters<SupervisorAgentRuntime['handle']>[0]): Promise<void> {
114
+ if (this.eventFailure)
115
+ throw this.eventFailure
97
116
  this.attachEmitter(input.emit)
98
117
  const metadata = this.input.run.metadata
99
118
  if (input.body.type === 'agent.send-prompt') {
@@ -154,6 +173,7 @@ class AgentSupervisorRuntime implements SupervisorAgentRuntime {
154
173
  }
155
174
 
156
175
  async close(input: { signal: AbortSignal }): Promise<void> {
176
+ this.attached.resolve()
157
177
  const metadata = this.input.run.metadata
158
178
  await this.input.run.stop({
159
179
  type: 'agent.stop',
@@ -167,15 +187,25 @@ class AgentSupervisorRuntime implements SupervisorAgentRuntime {
167
187
 
168
188
  private attachEmitter(emit: (body: WireEventBody) => void): void {
169
189
  this.emit = emit
190
+ this.attached.resolve()
170
191
  for (const event of this.pendingEvents.splice(0))
171
192
  emit(event)
172
193
  }
173
194
 
174
195
  private async consumeEvents(): Promise<void> {
196
+ if (this.input.eventProcessor) {
197
+ await this.attached.promise
198
+ if (!this.emit)
199
+ return
200
+ await this.input.eventProcessor.recover(body => this.emit!(body))
201
+ }
175
202
  for await (const event of this.input.run.events) {
176
203
  if (event.type === 'session.state.changed')
177
204
  this.state = { status: 'attached', agentState: event.payload.state }
178
- this.emitEvent(event)
205
+ if (this.input.eventProcessor)
206
+ await this.input.eventProcessor.process(event, body => this.emit!(body))
207
+ else
208
+ this.emitEvent(event)
179
209
  }
180
210
  }
181
211
 
@@ -27,6 +27,8 @@ export interface SupervisorProviderFactory {
27
27
  gitToken?: SessionBootstrapGitToken
28
28
  rpc: SupervisorRpcClient
29
29
  signal: AbortSignal
30
+ emit?: (body: WireEventBody) => void
31
+ onFatal?: (error: unknown) => void
30
32
  }) => Promise<SupervisorAgentRuntime>
31
33
  }
32
34
 
@@ -185,6 +185,8 @@ export class SupervisorRuntimeHandler implements RuntimeConnectionHandler {
185
185
  ...initialSecrets,
186
186
  rpc: this.rpc,
187
187
  signal: this.lifecycleController.signal,
188
+ emit: body => this.emit(body),
189
+ onFatal: error => this.dependencies.onFatal(error),
188
190
  }).then(async (agent) => {
189
191
  if ((this.dependencies.credentials !== initialSecrets.credentials
190
192
  || this.dependencies.gitToken !== initialSecrets.gitToken) && agent.refreshSecrets) {