@@ -32,15 +32,6 @@ type uiAIJobsPage struct {
3232 CurrentWorkflow string
3333}
3434
35- type uiAIJobPage struct {
36- Header * uiHeader
37- Job * uiAIJob
38- // The slice contains the same single Job, just for HTML templates convenience.
39- Jobs []* uiAIJob
40- CrashReport template.HTML
41- Trajectory []* uiAITrajectorySpan
42- }
43-
4435type uiAIJob struct {
4536 ID string
4637 Link string
@@ -63,6 +54,20 @@ type uiAIResult struct {
6354 Value any
6455}
6556
57+ type uiAIJobPage struct {
58+ Header * uiHeader
59+ Job * uiAIJob
60+ // The slice contains the same single Job, just for HTML templates convenience.
61+ Jobs []* uiAIJob
62+ CrashReport template.HTML
63+ Trajectory []* uiAITrajectoryNode
64+ }
65+
66+ type uiAITrajectoryNode struct {
67+ * uiAITrajectorySpan
68+ Children []* uiAITrajectoryNode
69+ }
70+
6671type uiAITrajectorySpan struct {
6772 Started time.Time
6873 Seq int64
@@ -228,34 +233,81 @@ func makeUIAIJob(job *aidb.Job) *uiAIJob {
228233 }
229234}
230235
231- func makeUIAITrajectory (trajetory []* aidb.TrajectorySpan ) []* uiAITrajectorySpan {
232- var res []* uiAITrajectorySpan
233- for _ , span := range trajetory {
236+ func makeUIAITrajectory (trajectory []* aidb.TrajectorySpan ) []* uiAITrajectoryNode {
237+ // We need to reconstruct the tree from the flat list of spans.
238+ // We assume that the spans are sorted by Seq and Nesting is consistent.
239+ // We use a stack to keep track of the current path in the tree.
240+ // The stack contains pointers to the nodes.
241+ // stack[0] is the root (virtual).
242+ // stack[i] is the parent of stack[i+1].
243+ // root is a virtual node to hold top-level nodes.
244+ root := & uiAITrajectoryNode {}
245+ stack := []* uiAITrajectoryNode {root }
246+
247+ for _ , span := range trajectory {
234248 var duration time.Duration
235249 if span .Finished .Valid {
236250 duration = span .Finished .Time .Sub (span .Started )
237251 }
238- res = append (res , & uiAITrajectorySpan {
239- Started : span .Started ,
240- Seq : span .Seq ,
241- Nesting : span .Nesting ,
242- Type : span .Type ,
243- Name : span .Name ,
244- Model : span .Model ,
245- Duration : duration ,
246- Error : nullString (span .Error ),
247- Args : nullJSON (span .Args ),
248- Results : nullJSON (span .Results ),
249- Instruction : nullString (span .Instruction ),
250- Prompt : nullString (span .Prompt ),
251- Reply : nullString (span .Reply ),
252- Thoughts : nullString (span .Thoughts ),
253- InputTokens : nullInt64 (span .InputTokens ),
254- OutputTokens : nullInt64 (span .OutputTokens ),
255- OutputThoughtsTokens : nullInt64 (span .OutputThoughtsTokens ),
256- })
257- }
258- return res
252+ node := & uiAITrajectoryNode {
253+ uiAITrajectorySpan : & uiAITrajectorySpan {
254+ Started : span .Started ,
255+ Seq : span .Seq ,
256+ Nesting : span .Nesting ,
257+ Type : span .Type ,
258+ Name : span .Name ,
259+ Model : span .Model ,
260+ Duration : duration ,
261+ Error : nullString (span .Error ),
262+ Args : nullJSON (span .Args ),
263+ Results : nullJSON (span .Results ),
264+ Instruction : nullString (span .Instruction ),
265+ Prompt : nullString (span .Prompt ),
266+ Reply : nullString (span .Reply ),
267+ Thoughts : nullString (span .Thoughts ),
268+ InputTokens : nullInt64 (span .InputTokens ),
269+ OutputTokens : nullInt64 (span .OutputTokens ),
270+ OutputThoughtsTokens : nullInt64 (span .OutputThoughtsTokens ),
271+ },
272+ }
273+
274+ // Adjust stack to the correct nesting level.
275+ // Nesting 0 means direct children of root (stack len 1)
276+ // Nesting N means direct children of stack[N] (stack len N+1)
277+ targetLen := int (span .Nesting ) + 1
278+ targetLen = max (targetLen , 1 )
279+ // This implies missing intermediate levels or jump in nesting.
280+ // Ideally shouldn't happen for strict tree traversal, but we just append to current parent.
281+ targetLen = min (targetLen , len (stack ))
282+ stack = stack [:targetLen ]
283+
284+ parent := stack [len (stack )- 1 ]
285+ parent .Children = append (parent .Children , node )
286+ stack = append (stack , node )
287+ }
288+
289+ // Propagate errors specifically for:
290+ // "If an agent only has a single entry that's a failed tool call, it should be considered failed / red as well."
291+ var propagateErrors func (nodes []* uiAITrajectoryNode )
292+ propagateErrors = func (nodes []* uiAITrajectoryNode ) {
293+ for _ , node := range nodes {
294+ if len (node .Children ) > 0 {
295+ propagateErrors (node .Children )
296+ // If this node is an Agent (or just a parent) and has exactly 1 child
297+ // and that child has an Error, propagate it if the parent doesn't have an error.
298+ if len (node .Children ) == 1 && node .Children [0 ].Error != "" && node .Error == "" {
299+ // We copy the child's error to indicate failure at this level too.
300+ // Or maybe we want a distinct message? The user said "considered failed / red as well".
301+ // Copying the error makes it show up in the UI as if this node failed.
302+ // Let's use a pointer or just copy the string.
303+ node .Error = node .Children [0 ].Error
304+ }
305+ }
306+ }
307+ }
308+ propagateErrors (root .Children )
309+
310+ return root .Children
259311}
260312
261313func apiAIJobPoll (ctx context.Context , req * dashapi.AIJobPollReq ) (any , error ) {
@@ -646,6 +698,9 @@ func nullJSON(v spanner.NullJSON) string {
646698 if ! v .Valid {
647699 return ""
648700 }
701+ if b , err := json .Marshal (v .Value ); err == nil {
702+ return string (b )
703+ }
649704 return fmt .Sprint (v .Value )
650705}
651706
0 commit comments