@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 +3 -1
- package/src/agents/claude/claude-event-mapper.ts +3 -1
- package/src/agents/claude/claude-provider.ts +1 -0
- package/src/agents/claude/claude-run.ts +12 -0
- package/src/agents/codex/codex-event-mapper.ts +4 -2
- package/src/protocol/agent-event.ts +1 -1
- package/src/provenance.gen.ts +3 -3
- package/src/steps/contract.ts +94 -0
- package/src/steps/projector.ts +245 -0
- package/src/supervisor/agent-provider-factory.ts +32 -2
- package/src/supervisor/provider-factory.ts +2 -0
- package/src/supervisor/runtime-handler.ts +2 -0
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.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
|
-
|
|
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 }] }
|
|
@@ -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.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
|
-
|
|
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.
|
|
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) {
|