forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathagentThreadCards.ts
More file actions
374 lines (346 loc) · 14.7 KB
/
Copy pathagentThreadCards.ts
File metadata and controls
374 lines (346 loc) · 14.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
// Shared-thread agent cards (B4) — INV-CHAT-1.
//
// A background agent spawned from a chat/voice surface leaves EXACTLY TWO durable
// artifacts in that PRODUCING surface's shared thread: an `agentSpawn` card at
// launch and one `agentCompletion` card at terminal. Never live in-between
// chatter — those two writes are the whole contract.
//
// Faithful port of macOS' boundary (upstream FloatingControlBar/AgentPill.swift
// `recordPillTerminalCompletion` -> Chat/KernelTurnProjection.swift
// `appendAgentCompletion`, durability scanned by `hasMaterializedAgentCompletion`).
// DELIBERATE DEVIATION: macOS folds the `.agentSpawn`/`.agentCompletion` block
// INTO the producing assistant turn (its ChatProvider array carries blocks
// natively). Windows' renderer is text-only and reads a projection of the kernel
// store, so each card is its own conversation turn — the block rides `metadataJson`
// and a plain-text `content` marker keeps the text-only projections (context tail,
// backend echo) coherent. The invariant we port is the BOUNDARY (exactly two
// artifacts, no live chatter), not Mac's turn-nesting.
//
// The kernel `conversation_turns` table (omi-agentd.sqlite3) is the ONE
// authoritative transcript store (INV-CHAT-1 MUST-NOT #1). These helpers write
// and read cards THERE — no second store; the renderer projects from it.
import { randomUUID } from 'node:crypto'
import type { ChatContentBlock } from '../../shared/chatContent'
import type { AgentStore, ConversationTurn } from './types'
import { appendConversationTurn, listRecentConversationTurns } from './conversationTurns'
/** The two block kinds that ride a shared-thread agent card. */
export type AgentThreadCardBlock = Extract<
ChatContentBlock,
{ type: 'agentSpawn' | 'agentCompletion' }
>
/** One materialized card, as read back for the renderer projection. */
export interface AgentThreadCardRecord {
turnId: string
createdAtMs: number
block: AgentThreadCardBlock
}
/** A freshly-written card plus the chat id to broadcast it to (the producing
* surface's chat ref, matched against the renderer's active thread). */
export interface MaterializedAgentCard {
record: AgentThreadCardRecord
chatId: string | null
}
/** The stamp `spawn_agent` writes onto a background run's metadata so the terminal
* subscriber (which sees only sessionId/runId) can resolve the producing surface
* and title/objective long after the launch call returned. Namespaced under
* `omiAgentCard` so it never collides with other run metadata. */
export interface AgentCardStamp {
producingConversationId: string
/** The producing surface's chat id (session `externalRefId` when `externalRefKind
* === 'chat'`), used to match the renderer's active thread. Null for a producing
* surface with no chat ref. */
producingChatId: string | null
producingSurfaceKind: string
pillId: string | null
title: string
objective: string
}
/** Metadata key the stamp lives under, inside a run's `inputJson.metadata`. */
export const AGENT_CARD_STAMP_KEY = 'omiAgentCard'
const AGENT_CARD_TURN_KIND = 'agentCard'
// Recent-turn scan window for idempotency + the renderer read. A producing
// conversation accrues at most two card turns per background run; 200 covers a
// long chat's worth of interleaved normal turns without an unbounded scan.
//
// BOUNDED-RECENT-SCAN ASSUMPTION: idempotency (has*Card), the renderer read
// (listAgentThreadCards), and the orphan sweep all look back at most this many
// turns. A card whose turn has since been pushed older than 200 turns in one
// conversation would not be found — acceptable given the two-cards-per-run cadence
// (a run's spawn + completion land close together; a heal happens near the
// terminal, not hundreds of turns later). If a conversation ever needs cards
// resolved across a very deep history, switch these reads to a runId-indexed
// lookup rather than widening this window.
export const AGENT_CARD_SCAN_LIMIT = 200
interface AgentCardTurnMetadata {
kind: typeof AGENT_CARD_TURN_KIND
runId: string
card: AgentThreadCardBlock
}
/** Build the `omiAgentCard`-namespaced metadata patch for a spawn. */
export function agentCardStampMetadata(stamp: AgentCardStamp): Record<string, unknown> {
return { [AGENT_CARD_STAMP_KEY]: stamp }
}
/** Read the stamp back off a run's parsed `inputJson.metadata`, or null when the
* run was not spawned from a card-producing surface. */
export function readAgentCardStamp(metadata: unknown): AgentCardStamp | null {
if (!metadata || typeof metadata !== 'object') return null
const raw = (metadata as Record<string, unknown>)[AGENT_CARD_STAMP_KEY]
if (!raw || typeof raw !== 'object') return null
const s = raw as Record<string, unknown>
if (typeof s.producingConversationId !== 'string' || !s.producingConversationId) return null
return {
producingConversationId: s.producingConversationId,
producingChatId: typeof s.producingChatId === 'string' ? s.producingChatId : null,
producingSurfaceKind:
typeof s.producingSurfaceKind === 'string' ? s.producingSurfaceKind : 'main_chat',
pillId: typeof s.pillId === 'string' ? s.pillId : null,
title: typeof s.title === 'string' && s.title ? s.title : 'Background agent',
objective: typeof s.objective === 'string' ? s.objective : ''
}
}
/** Parse a conversation turn back into its card block, or null when the turn is a
* normal (non-card) turn. */
export function parseAgentCardTurn(
turn: Pick<ConversationTurn, 'metadataJson'>
): AgentThreadCardBlock | null {
try {
const meta = JSON.parse(turn.metadataJson) as Partial<AgentCardTurnMetadata>
const card = meta?.kind === AGENT_CARD_TURN_KIND ? meta.card : undefined
if (card && (card.type === 'agentSpawn' || card.type === 'agentCompletion')) return card
} catch {
// Malformed / non-card metadata — treat as a normal turn.
}
return null
}
function findCardBlock(
store: AgentStore,
conversationId: string,
predicate: (block: AgentThreadCardBlock) => boolean
): boolean {
for (const turn of listRecentConversationTurns(store, conversationId, AGENT_CARD_SCAN_LIMIT)) {
const card = parseAgentCardTurn(turn)
if (card && predicate(card)) return true
}
return false
}
/** True when the producing conversation already carries the spawn card for `runId`. */
export function hasAgentSpawnCard(
store: AgentStore,
conversationId: string,
runId: string
): boolean {
return findCardBlock(store, conversationId, (b) => b.type === 'agentSpawn' && b.runId === runId)
}
/** True when the producing conversation already carries the completion card for
* `runId` — the exactly-one-completion idempotency guard (survives terminal
* retries and duplicate terminal events). */
export function hasAgentCompletionCard(
store: AgentStore,
conversationId: string,
runId: string
): boolean {
return findCardBlock(
store,
conversationId,
(b) => b.type === 'agentCompletion' && b.runId === runId
)
}
function appendCardTurn(
store: AgentStore,
input: {
conversationId: string
surfaceKind: string
content: string
block: AgentThreadCardBlock
nowMs: number
}
): AgentThreadCardRecord {
const metadata: AgentCardTurnMetadata = {
kind: AGENT_CARD_TURN_KIND,
runId: input.block.runId ?? '',
card: input.block
}
const turn = appendConversationTurn(store, {
conversationId: input.conversationId,
role: 'assistant',
surfaceKind: input.surfaceKind,
content: input.content,
createdAtMs: input.nowMs,
metadataJson: JSON.stringify(metadata)
})
return { turnId: turn.turnId, createdAtMs: turn.createdAtMs, block: input.block }
}
/** The run fields the completion card needs (a store-agnostic view of AgentRun). */
export interface AgentCardRunView {
runId: string
sessionId: string
status: string
finalText: string | null
errorMessage: string | null
}
export type CompletionStatus = 'succeeded' | 'stopped' | 'failed'
/** Normalize a kernel terminal run status to the card's display vocabulary. The
* five terminal db states collapse to three: succeeded; cancelled → stopped;
* failed / timed_out / orphaned → failed (the raw status still drives the
* interrupted/timed-out message via {@link fallbackInterruptedOutput}). */
export function normalizeCompletionStatus(runStatus: string): CompletionStatus {
if (runStatus === 'succeeded') return 'succeeded'
if (runStatus === 'cancelled') return 'stopped'
return 'failed'
}
function completionStatusLabel(status: CompletionStatus): string {
return status === 'succeeded' ? 'finished' : status === 'stopped' ? 'was stopped' : 'failed'
}
const OUTPUT_MAX = 4000
const SNIPPET_MAX = 160
/** A clear reason when a non-succeeded run carries no explicit error text — the
* crash-interrupted / timed-out / silently-orphaned cases, so the card never
* renders "Failed" with an empty body. */
function fallbackInterruptedOutput(runStatus: string): string {
switch (runStatus) {
case 'orphaned':
return 'The background agent was interrupted before it finished.'
case 'timed_out':
return 'The background agent timed out before it finished.'
case 'cancelled':
return '' // the "Stopped" label already conveys this
default:
return 'The background agent did not finish.'
}
}
function completionOutput(run: AgentCardRunView, status: CompletionStatus): string {
if (status === 'succeeded') return (run.finalText ?? '').slice(0, OUTPUT_MAX)
const explicit = (run.errorMessage ?? '').trim()
if (explicit) return explicit.slice(0, OUTPUT_MAX)
return fallbackInterruptedOutput(run.status)
}
/** Write the launch (`agentSpawn`) card into the producing conversation, once.
* Idempotent: a repeat for the same runId is a no-op returning null. */
export function materializeAgentSpawnCard(
store: AgentStore,
input: { runId: string; sessionId: string; stamp: AgentCardStamp; nowMs: number }
): AgentThreadCardRecord | null {
const { stamp } = input
if (hasAgentSpawnCard(store, stamp.producingConversationId, input.runId)) return null
const block: AgentThreadCardBlock = {
type: 'agentSpawn',
id: randomUUID(),
...(stamp.pillId ? { pillId: stamp.pillId } : {}),
sessionId: input.sessionId,
runId: input.runId,
title: stamp.title,
objective: stamp.objective
}
return appendCardTurn(store, {
conversationId: stamp.producingConversationId,
surfaceKind: stamp.producingSurfaceKind,
content: `Started background agent: ${stamp.title}`,
block,
nowMs: input.nowMs
})
}
/** Write the terminal (`agentCompletion`) card into the producing conversation,
* once. Idempotent on runId — exactly one completion even under terminal retry or
* a duplicate terminal event. */
export function materializeAgentCompletionCard(
store: AgentStore,
input: { run: AgentCardRunView; stamp: AgentCardStamp; nowMs: number }
): AgentThreadCardRecord | null {
const { run, stamp } = input
if (hasAgentCompletionCard(store, stamp.producingConversationId, run.runId)) return null
const status = normalizeCompletionStatus(run.status)
const output = completionOutput(run, status)
const block: AgentThreadCardBlock = {
type: 'agentCompletion',
id: randomUUID(),
...(stamp.pillId ? { pillId: stamp.pillId } : {}),
sessionId: run.sessionId,
runId: run.runId,
title: stamp.title,
promptSnippet: stamp.objective.slice(0, SNIPPET_MAX),
output,
status
}
return appendCardTurn(store, {
conversationId: stamp.producingConversationId,
surfaceKind: stamp.producingSurfaceKind,
content: `Background agent "${stamp.title}" ${completionStatusLabel(status)}`,
block,
nowMs: input.nowMs
})
}
/** All cards in a conversation, oldest-first — the renderer projection read. */
export function listAgentThreadCards(
store: AgentStore,
conversationId: string,
limit = AGENT_CARD_SCAN_LIMIT
): AgentThreadCardRecord[] {
const out: AgentThreadCardRecord[] = []
for (const turn of listRecentConversationTurns(store, conversationId, limit)) {
const block = parseAgentCardTurn(turn)
if (block) out.push({ turnId: turn.turnId, createdAtMs: turn.createdAtMs, block })
}
return out
}
/** The five terminal run db states (matches TERMINAL_STATUSES in kernelSupport). */
const TERMINAL_RUN_DB_STATUSES = ['succeeded', 'failed', 'cancelled', 'timed_out', 'orphaned']
/**
* Load-time heal: any card-producing run that has reached a TERMINAL db state
* while holding a spawn card but NO completion card gets its completion card
* materialized now. This closes the crash-mid-run / reconcile-before-subscribe
* gap — startup reconciliation flips abandoned runs to `orphaned` and writes
* `run.orphaned` straight into the events table (below the kernel), so the live
* subscriber NEVER sees it; without this sweep those spawn cards would sit stuck
* on "Running" forever.
*
* Bounded (most-recent `limit` card-producing terminal runs) and idempotent
* (materializeAgentCompletionCard is a no-op when a completion already exists),
* so it is safe to run on every registration. The spawn-card guard scopes it to
* runs a card producer actually launched — a plain terminal run with no spawn
* card is left alone.
*/
export function sweepOrphanedAgentCompletionCards(
store: AgentStore,
opts?: { nowMs?: () => number; limit?: number }
): MaterializedAgentCard[] {
const nowMs = opts?.nowMs ?? (() => Date.now())
const limit = opts?.limit ?? AGENT_CARD_SCAN_LIMIT
const rows = store.allRows(
`SELECT run_id, session_id, status, final_text, error_message, input_json
FROM runs
WHERE status IN (${TERMINAL_RUN_DB_STATUSES.map(() => '?').join(', ')})
AND input_json LIKE '%"${AGENT_CARD_STAMP_KEY}"%'
ORDER BY updated_at_ms DESC
LIMIT ?`,
[...TERMINAL_RUN_DB_STATUSES, limit]
)
const healed: MaterializedAgentCard[] = []
for (const row of rows) {
let metadata: unknown
try {
metadata = (JSON.parse(String(row.input_json ?? '{}')) as { metadata?: unknown }).metadata
} catch {
continue
}
const stamp = readAgentCardStamp(metadata)
if (!stamp) continue
const runId = String(row.run_id)
// Heal only a run that launched a card (has a spawn card) but never got its
// completion card — never fabricate a completion for an unrelated terminal run.
if (!hasAgentSpawnCard(store, stamp.producingConversationId, runId)) continue
if (hasAgentCompletionCard(store, stamp.producingConversationId, runId)) continue
const record = materializeAgentCompletionCard(store, {
run: {
runId,
sessionId: String(row.session_id),
status: String(row.status),
finalText: row.final_text == null ? null : String(row.final_text),
errorMessage: row.error_message == null ? null : String(row.error_message)
},
stamp,
nowMs: nowMs()
})
if (record) healed.push({ record, chatId: stamp.producingChatId })
}
return healed
}