{{ message }}
Repository navigation
Expand file tree
/
Copy pathagent-child-row-model.ts
More file actions
353 lines (334 loc) · 12.6 KB
/
Copy pathagent-child-row-model.ts
File metadata and controls
353 lines (334 loc) · 12.6 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
/** Share child labels/details across sidebar and chat. Prefer host children views; retain subagents/task-roster builders until no supported publisher/paired host requires each legacy shape. Delete each legacy builder when its last publisher is gone. */
import type { AgentSessionBackgroundTask } from './agent-session-background-task-wire'
import type { AgentChildWorkKind } from './agent-status-child-work'
import { resolveAgentChildWorkFreshness } from './agent-status-child-work-freshness'
import {
agentChildWorkOwnedLiveness,
deriveAgentChildDisplayState,
type AgentChildDisplayState
} from './agent-status-child-work-display'
import { agentChildWorkViewOffersStop } from './agent-child-work-stop-targets'
import type { AgentChildWorkView } from './agent-status-child-work-view'
import {
agentStatusAuthorityObservedAt,
agentStatusEvidenceObservedAt
} from './agent-status-freshness'
import type { AgentStatusEntry, AgentSubagentSnapshot } from './agent-status-types'
/** What a child row says beside its name. Surfaces format it; they never decide it. */
export type AgentChildRowDetail =
/** The tool the child is running now, as `tool: input` — the line a CLI agent row shows. */
| { kind: 'operation'; toolName: string; input?: string }
/** Its own work is over and only work it owns still runs; the tool line is stale. */
| { kind: 'monitoring' }
| { kind: 'message'; text: string }
/** Settled, and the lane cannot say how. */
| { kind: 'ended' }
/** How long since the last evidence for this child (see `recencyAt`). */
| { kind: 'no-update' }
/** The child's role, when nothing more specific is known. */
| { kind: 'role'; agentType: string }
/** A host that publishes only a run state: that state's reason word. */
| { kind: 'reason'; state: 'waiting' | 'blocked' | 'unverifiable' }
/** One child row, for every surface that lists child work (sidebar and chat strip). */
export type AgentChildRowModel = {
/** Row identity: the host's child id, or the provider id an old host names it by. */
id: string
/** The id a targeted stop names; absent when the host holds none. */
providerId?: string
kind: AgentChildWorkKind
displayState: AgentChildDisplayState
/** '' when the child reported no label; a surface then names the row by its state. */
name: string
agentType?: string
model?: string
detail: AgentChildRowDetail | null
/** When the child was first seen: the elapsed anchor. */
firstObservedAt: number
/** Last evidence for THIS child. Absent from a host that reports none. */
observedAt?: number
/** Reader-clock anchor for no-update age: child evidence, otherwise parent evidence. */
recencyAt: number
settledAt?: number
totalTokens?: number
canStop: boolean
settled: boolean
/** Work this child owns (a nested agent, a shell it launched), in host order. */
owned: AgentChildRowModel[]
}
export type AgentChildRowContext = {
/** A stale parent makes every live claim beneath it unverifiable. */
parentEvidenceFresh: boolean
transportObservation: 'live' | 'unverifiable'
/** Parent evidence on the reader clock; fallback recency when the child has no clock. */
parentObservedAt: number
/** Reader minus host clock, or zero locally; convert child stamps before computing age. */
hostClockOffsetMs: number
}
/** Derive every surface's child context from the same parent evidence. */
export function agentChildRowContextForParent(
parent: Pick<
AgentStatusEntry,
'updatedAt' | 'evidenceObservedAt' | 'mirroredEvidenceReceivedAt' | 'subagentObservation'
>,
parentEvidenceFresh: boolean
): AgentChildRowContext {
// The parent row decays on this same clock, so a child's silence reads the same as its parent's.
const parentObservedAt = agentStatusEvidenceObservedAt(parent)
return {
parentEvidenceFresh,
transportObservation: parent.subagentObservation ?? 'live',
parentObservedAt,
hostClockOffsetMs: parentObservedAt - agentStatusAuthorityObservedAt(parent)
}
}
/** Provider strings that carry no identity; the next label wins. */
const PLACEHOLDER_LABELS = new Set(['unknown', 'untitled', 'task', 'subagent'])
/** A provider label that names the child, or undefined for an empty or placeholder one. */
export function usableAgentChildLabel(value: string | undefined): string | undefined {
const trimmed = value?.trim()
return trimmed && !PLACEHOLDER_LABELS.has(trimmed.toLowerCase()) ? trimmed : undefined
}
// A live claim beneath a parent Orca no longer hears from asserts nothing; settled history stands.
function withFreshness(
displayState: AgentChildDisplayState,
context: AgentChildRowContext
): AgentChildDisplayState {
if (
displayState === 'done' ||
displayState === 'failed' ||
displayState === 'interrupted' ||
displayState === 'idle'
) {
return displayState
}
return resolveAgentChildWorkFreshness({
state: displayState,
membership: 'live',
parentEvidenceFresh: context.parentEvidenceFresh,
transportObservation: context.transportObservation
})
}
type AgentChildRowDetailSource = {
/** `run-state`: the host reported only the child's run state (the legacy task roster). */
evidence: 'child' | 'run-state'
kind: AgentChildWorkKind
name: string
agentType?: string
operation?: { toolName: string; input?: string }
lastMessage?: string
settled: boolean
}
// A default role or one that restates the name adds nothing.
function roleDetail(source: AgentChildRowDetailSource): AgentChildRowDetail | null {
if (source.kind !== 'agent') {
return null
}
const agentType = source.agentType ?? 'unknown'
return agentType.trim() === 'default' || source.name.trim() === agentType.trim()
? null
: { kind: 'role', agentType }
}
function messageOrRole(source: AgentChildRowDetailSource): AgentChildRowDetail | null {
const text = source.lastMessage?.trim()
return text ? { kind: 'message', text } : roleDetail(source)
}
/** The one rule for what a child row says, in the order a CLI agent row decides it. */
function agentChildRowDetail(
source: AgentChildRowDetailSource,
displayState: AgentChildDisplayState
): AgentChildRowDetail | null {
if (source.evidence === 'run-state') {
return displayState === 'waiting' ||
displayState === 'blocked' ||
displayState === 'unverifiable'
? { kind: 'reason', state: displayState }
: null
}
switch (displayState) {
case 'unverifiable':
return { kind: 'no-update' }
case 'monitoring':
// A shell or monitor names itself; only an agent's own tool line goes stale.
return source.kind === 'agent' ? { kind: 'monitoring' } : null
case 'working':
case 'waiting':
return source.operation
? {
kind: 'operation',
toolName: source.operation.toolName,
...(source.operation.input !== undefined ? { input: source.operation.input } : {})
}
: messageOrRole(source)
case 'blocked':
case 'done':
case 'failed':
return messageOrRole(source)
case 'interrupted':
return roleDetail(source)
case 'idle':
return source.settled ? { kind: 'ended' } : roleDetail(source)
}
}
function viewName(view: AgentChildWorkView): string {
return (
usableAgentChildLabel(view.description) ??
usableAgentChildLabel(view.name) ??
usableAgentChildLabel(view.agentType) ??
''
)
}
function rowFromView(
view: AgentChildWorkView,
views: readonly AgentChildWorkView[],
ownedByOwner: ReadonlyMap<string, AgentChildWorkView[]>,
context: AgentChildRowContext,
path: ReadonlySet<string>
): AgentChildRowModel {
const displayState = withFreshness(
deriveAgentChildDisplayState(view, agentChildWorkOwnedLiveness(views, view.id)),
context
)
const settled = view.membership === 'settled'
const name = viewName(view)
const nextPath = new Set(path).add(view.id)
return {
id: view.id,
...(view.providerId !== undefined ? { providerId: view.providerId } : {}),
kind: view.kind,
displayState,
name,
...(view.agentType !== undefined ? { agentType: view.agentType } : {}),
...(view.model !== undefined ? { model: view.model } : {}),
detail: agentChildRowDetail(
{
evidence: 'child',
kind: view.kind,
name,
agentType: view.agentType,
operation: view.operation,
lastMessage: view.lastMessage,
settled
},
displayState
),
firstObservedAt: view.firstObservedAt,
observedAt: view.observedAt,
recencyAt: view.observedAt + context.hostClockOffsetMs,
...(view.settledAt !== undefined ? { settledAt: view.settledAt } : {}),
...(view.totalTokens !== undefined ? { totalTokens: view.totalTokens } : {}),
canStop: agentChildWorkViewOffersStop(view),
settled,
owned: (ownedByOwner.get(view.id) ?? [])
.filter((owned) => !nextPath.has(owned.id))
.map((owned) => rowFromView(owned, views, ownedByOwner, context, nextPath))
}
}
/** Nest host views by ownership; the shared fold keeps finished agents monitoring their live work. */
export function buildAgentChildRowModels(
views: readonly AgentChildWorkView[],
context: AgentChildRowContext
): AgentChildRowModel[] {
const ownedByOwner = new Map<string, AgentChildWorkView[]>()
for (const view of views) {
if (view.parentChildWorkId !== undefined) {
ownedByOwner.set(view.parentChildWorkId, [
...(ownedByOwner.get(view.parentChildWorkId) ?? []),
view
])
}
}
return views
.filter((view) => view.parentChildWorkId === undefined)
.map((view) => rowFromView(view, views, ownedByOwner, context, new Set()))
}
/** Legacy subagents from older hosts/CLI panes have lifecycle/spawn times but no child evidence clock. */
export function buildLegacyAgentChildRowModels(
subagents: readonly AgentSubagentSnapshot[],
context: AgentChildRowContext
): AgentChildRowModel[] {
return subagents.map((subagent) => {
const displayState = withFreshness(
deriveAgentChildDisplayState({ state: subagent.state, membership: 'live' }, null),
context
)
const name =
usableAgentChildLabel(subagent.description) ?? usableAgentChildLabel(subagent.agentType) ?? ''
return {
id: subagent.id,
providerId: subagent.id,
kind: 'agent',
displayState,
name,
...(subagent.agentType !== undefined ? { agentType: subagent.agentType } : {}),
...(subagent.model !== undefined ? { model: subagent.model } : {}),
detail: agentChildRowDetail(
{ evidence: 'child', kind: 'agent', name, agentType: subagent.agentType, settled: false },
displayState
),
firstObservedAt: subagent.startedAt,
recencyAt: context.parentObservedAt,
canStop: false,
settled: false,
owned: []
}
})
}
function legacyTaskDisplayState(
task: AgentSessionBackgroundTask,
settled: boolean
): AgentChildDisplayState {
if (task.state) {
return task.state
}
if (settled) {
return 'done'
}
return task.kind === 'monitor' ? 'monitoring' : 'working'
}
// The host decided each row's state; the row keeps it.
function legacyTaskRow(task: AgentSessionBackgroundTask, settled: boolean): AgentChildRowModel {
const displayState = legacyTaskDisplayState(task, settled)
const name = usableAgentChildLabel(task.description) ?? usableAgentChildLabel(task.name) ?? ''
const startedAt = task.startedAt ?? 0
return {
id: task.id,
providerId: task.id,
kind: task.kind,
displayState,
name,
detail: agentChildRowDetail(
{ evidence: 'run-state', kind: task.kind, name, settled },
displayState
),
firstObservedAt: startedAt,
recencyAt: startedAt,
...(task.totalTokens !== undefined ? { totalTokens: task.totalTokens } : {}),
// Absent means stoppable: a host predating the field published only rows its stop could act on.
canStop: task.stoppable !== false,
settled,
owned: []
}
}
/** Legacy session task rosters are flat, retain host-decided state and have only start times. */
export function buildLegacyTaskRowModels(
tasks: readonly AgentSessionBackgroundTask[],
settledTasks: readonly AgentSessionBackgroundTask[]
): AgentChildRowModel[] {
// Older hosts can retain a previous turn beside its resumed live task; the live row wins.
const rows = new Map<string, AgentChildRowModel>()
for (const [roster, settled] of [
[settledTasks, true],
[tasks, false]
] as const) {
for (const task of roster) {
rows.set(task.id, legacyTaskRow(task, settled))
}
}
return [...rows.values()]
}
/** Every row of a tree, owners before what they own. */
export function flattenAgentChildRowModels(
rows: readonly AgentChildRowModel[]
): AgentChildRowModel[] {
return rows.flatMap((row) => [row, ...flattenAgentChildRowModels(row.owned)])
}
You can’t perform that action at this time.
