@cat-factory/app 0.232.2 → 0.233.0

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.
@@ -1,22 +1,40 @@
1
1
  <script setup lang="ts">
2
- import { computed, reactive, ref, watch } from 'vue'
2
+ import { computed, nextTick, reactive, ref, watch } from 'vue'
3
3
  import { onKeyStroke } from '@vueuse/core'
4
+ import { isLlmWarningFinishReason } from '@cat-factory/contracts'
4
5
  import type {
5
6
  AgentContextSnapshot,
6
7
  AgentSearchQuery,
7
8
  LlmCallMetric,
9
+ RunToolCallFailures,
10
+ RunToolCallTrajectory,
8
11
  WebSearchProvider,
9
12
  } from '~/types/execution'
10
13
  import { agentKindMeta } from '~/utils/catalog'
14
+ import type { CallOutcomeFilter, ToolOutcomeFilter } from '~/utils/observability'
11
15
  import {
16
+ countCallOutcomes,
17
+ deriveRunFailureEvidence,
18
+ filterCallsByOutcome,
12
19
  foldRunPhaseMetrics,
13
20
  formatCost,
14
21
  formatMs,
15
22
  formatTokens,
23
+ hasFailureEvidence,
16
24
  pct,
25
+ sinkAnswer,
17
26
  sumCosts,
18
27
  totalInputTokens,
19
28
  } from '~/utils/observability'
29
+ import OutcomeFilterChips from '~/components/observability/OutcomeFilterChips.vue'
30
+ import RunFailureSummary from '~/components/observability/RunFailureSummary.vue'
31
+ import ToolCallList from '~/components/observability/ToolCallList.vue'
32
+
33
+ /** No run selected: the same empty, NOT-truncated trajectory the store answers with. */
34
+ const EMPTY_TRAJECTORY: RunToolCallTrajectory = Object.freeze({
35
+ toolCalls: Object.freeze([]) as never,
36
+ truncated: false,
37
+ })
20
38
 
21
39
  // Drill-down overlay for a run's LLM activity. Opened via
22
40
  // `ui.openObservability(instanceId)` from a step surface; loads the full per-call
@@ -24,6 +42,11 @@ import {
24
42
  // transport-vs-execution latency split) from the observability store and lists
25
43
  // every model call, each expandable to its full prompt + response. Offers the
26
44
  // LLM-friendly JSON export for handing a run to a model to analyse.
45
+ //
46
+ // Failing-call FIRST: when the run failed (or any call did), the panel opens with a pinned
47
+ // summary naming the structured failure and the two calls that actually failed, so the cause is
48
+ // visible before anything is read. The lists below it narrow by outcome for the same reason:
49
+ // a tool-execution error is a row nothing aggregates, so finding one used to mean scrolling.
27
50
  const ui = useUiStore()
28
51
  const execution = useExecutionStore()
29
52
  const board = useBoardStore()
@@ -56,9 +79,9 @@ function retryContext() {
56
79
  if (executionId.value) void observability.loadContext(executionId.value)
57
80
  }
58
81
 
59
- // Which view is shown: per-call model activity, the complete provided context, or the
60
- // performed web searches.
61
- const view = ref<'calls' | 'context' | 'search'>('calls')
82
+ // Which view is shown: per-call model activity, the tool-call trajectory, the complete provided
83
+ // context, or the performed web searches.
84
+ const view = ref<'calls' | 'tools' | 'context' | 'search'>('calls')
62
85
 
63
86
  const contextSnapshots = computed<AgentContextSnapshot[]>(() =>
64
87
  executionId.value ? observability.contextFor(executionId.value) : [],
@@ -74,6 +97,171 @@ const searchLoading = computed(
74
97
  () => !!executionId.value && observability.isSearchQueriesLoading(executionId.value),
75
98
  )
76
99
 
100
+ // The tool-call sink is read TWICE, at two different bounds, and the split is the point.
101
+ //
102
+ // `toolFailures` is the run-level answer: exact `{ total, failed }` counted in SQL plus the
103
+ // failing rows themselves, cheap enough to front the panel. `trajectory` is the browse view: a
104
+ // bounded prefix carrying every argument and result the run captured, loaded only when someone
105
+ // opens it. Folding them into one read would either make the headline wait on megabytes or make
106
+ // its numbers a statement about the prefix, and the second one is a false all-clear.
107
+ const trajectory = computed<RunToolCallTrajectory>(() =>
108
+ executionId.value ? observability.toolCallsFor(executionId.value) : EMPTY_TRAJECTORY,
109
+ )
110
+ const toolCallsLoading = computed(
111
+ () => !!executionId.value && observability.isToolCallsLoading(executionId.value),
112
+ )
113
+ const toolCallError = computed(() =>
114
+ executionId.value ? (observability.toolCallErrors[executionId.value] ?? null) : null,
115
+ )
116
+ const toolFailures = computed<RunToolCallFailures | null>(() =>
117
+ executionId.value ? observability.toolCallFailuresFor(executionId.value) : null,
118
+ )
119
+ const toolFailuresLoading = computed(
120
+ () => !!executionId.value && observability.isToolCallFailuresLoading(executionId.value),
121
+ )
122
+ const toolFailureError = computed(() =>
123
+ executionId.value ? (observability.toolCallFailureErrors[executionId.value] ?? null) : null,
124
+ )
125
+ function retryToolCalls() {
126
+ if (executionId.value) void observability.loadToolCalls(executionId.value)
127
+ }
128
+ function retryToolCallFailures() {
129
+ if (executionId.value) void observability.loadToolCallFailures(executionId.value)
130
+ }
131
+ /** Re-request whatever the pinned summary could not read. Both, when both failed. */
132
+ function retryFailureEvidence() {
133
+ const id = executionId.value
134
+ if (!id) return
135
+ if (observability.toolCallFailureErrors[id]) void observability.loadToolCallFailures(id)
136
+ if (observability.errors[id]) void observability.load(id)
137
+ }
138
+
139
+ /**
140
+ * Show the trajectory tab, loading it if this is the first look.
141
+ *
142
+ * A named handler rather than two statements in the template: an inline handler is parsed as a
143
+ * single expression, so the multi-statement form is a build-time syntax error that neither the
144
+ * typecheck nor the unit tests compile a template to catch.
145
+ */
146
+ function openToolsView() {
147
+ view.value = 'tools'
148
+ ensureTrajectoryLoaded()
149
+ }
150
+
151
+ /**
152
+ * Load the trajectory the first time it is actually looked at.
153
+ *
154
+ * Deferred because it is the one read on this panel whose size scales with how much the run DID
155
+ * rather than with how it ended, and an operator who opens the panel to see what broke may never
156
+ * scroll it. What they do see immediately is the failure read, which is issued on open.
157
+ */
158
+ function ensureTrajectoryLoaded() {
159
+ const id = executionId.value
160
+ if (!id || observability.hasToolCalls(id) || observability.isToolCallsLoading(id)) return
161
+ void observability.loadToolCalls(id)
162
+ }
163
+
164
+ // --- failing-call-first triage ------------------------------------------------------------
165
+ // Both drill-downs narrow by outcome. The state lives HERE rather than in each list so the
166
+ // pinned summary's "show me the failing tool calls" can set it, and so switching views does not
167
+ // silently drop a narrowing the operator is still reading under.
168
+ const callFilter = ref<CallOutcomeFilter>('all')
169
+ const toolFilter = ref<ToolOutcomeFilter>('all')
170
+
171
+ const callOutcomeCounts = computed(() => countCallOutcomes(calls.value))
172
+ const callFilterOptions = computed(
173
+ () =>
174
+ [
175
+ { value: 'all', label: t('observability.filter.all'), count: callOutcomeCounts.value.all },
176
+ {
177
+ value: 'error',
178
+ label: t('observability.filter.failed'),
179
+ count: callOutcomeCounts.value.error,
180
+ tone: 'error',
181
+ },
182
+ {
183
+ value: 'warning',
184
+ label: t('observability.filter.warning'),
185
+ count: callOutcomeCounts.value.warning,
186
+ tone: 'warning',
187
+ },
188
+ { value: 'ok', label: t('observability.filter.ok'), count: callOutcomeCounts.value.ok },
189
+ ] as const,
190
+ )
191
+ /** The rows the call list actually renders, after the outcome narrowing. */
192
+ const visibleCalls = computed(() => filterCallsByOutcome(calls.value, callFilter.value))
193
+
194
+ /**
195
+ * What the panel pins at the top: the run's structured failure record plus the last call that
196
+ * failed in each sink. Sharpens as each read lands rather than blocking on both.
197
+ *
198
+ * Each sink is passed its own ANSWER, not merely its rows, because zero rows is what a loading
199
+ * read, a failed read, an unwired sink and a genuinely quiet run all look like from here, and
200
+ * only the first two must never be rendered as "nothing failed".
201
+ */
202
+ const failureEvidence = computed(() =>
203
+ deriveRunFailureEvidence({
204
+ failure: instance.value?.failure ?? null,
205
+ calls: calls.value,
206
+ callsAnswer: sinkAnswer({
207
+ loading: loading.value,
208
+ error: error.value,
209
+ loaded: !!executionId.value && executionId.value in observability.callsByExecution,
210
+ rows: calls.value.length,
211
+ }),
212
+ toolFailures: toolFailures.value,
213
+ toolsAnswer: sinkAnswer({
214
+ loading: toolFailuresLoading.value,
215
+ error: toolFailureError.value,
216
+ loaded: !!toolFailures.value,
217
+ rows: toolFailures.value?.total ?? 0,
218
+ }),
219
+ }),
220
+ )
221
+ /**
222
+ * Which call rows are expanded.
223
+ *
224
+ * Declared here rather than beside `toggle` below because `revealCall` writes it: the pinned
225
+ * summary's jump opens the row it scrolls to, so the state has to exist above its first use.
226
+ */
227
+ const expanded = reactive<Record<string, boolean>>({})
228
+
229
+ /**
230
+ * Whether to pin the section at all.
231
+ *
232
+ * Deliberately NOT gated on `status === 'failed'`: a run still in flight whose calls are already
233
+ * erroring is exactly the one worth interrupting, and a run that ended `done` after recovering
234
+ * from a failure still has the failures worth reading. What the section never does is appear
235
+ * with nothing to say: `hasFailureEvidence` is false when there is no record and nothing failed.
236
+ */
237
+ const showFailureSummary = computed(() => hasFailureEvidence(failureEvidence.value))
238
+
239
+ /** Open one call's row in the list below, expanded, from the pinned summary. */
240
+ async function revealCall(callId: string) {
241
+ view.value = 'calls'
242
+ // Clear a narrowing that would hide the row we are about to scroll to. Every filter but
243
+ // `error` can do that, and a jump to a row the list is not rendering silently does nothing.
244
+ if (callFilter.value !== 'all' && callFilter.value !== 'error') callFilter.value = 'all'
245
+ expanded[callId] = true
246
+ await nextTick()
247
+ document.getElementById(callRowId(callId))?.scrollIntoView({ block: 'center' })
248
+ }
249
+ /**
250
+ * Open the trajectory narrowed to the failures, from the pinned summary.
251
+ *
252
+ * The failing rows are already in hand (they come from the failure read), so this renders
253
+ * immediately; the prefix load it kicks off is what fills in the surrounding calls an operator
254
+ * widens to when they want the context around one.
255
+ */
256
+ function revealFailingToolCalls() {
257
+ view.value = 'tools'
258
+ toolFilter.value = 'error'
259
+ ensureTrajectoryLoaded()
260
+ }
261
+ function callRowId(callId: string): string {
262
+ return `obs-call-${callId}`
263
+ }
264
+
77
265
  // Brand names, kept verbatim across locales (not translatable prose).
78
266
  const PROVIDER_LABEL: Record<WebSearchProvider, string> = { brave: 'Brave', searxng: 'SearXNG' }
79
267
  function providerLabel(provider: WebSearchProvider | null): string {
@@ -107,9 +295,16 @@ watch(
107
295
  (id) => {
108
296
  if (id) {
109
297
  view.value = 'calls'
298
+ callFilter.value = 'all'
299
+ toolFilter.value = 'all'
110
300
  void observability.load(id)
111
301
  void observability.loadContext(id)
112
302
  void observability.loadSearchQueries(id)
303
+ // The FAILURE read on open, the trajectory on demand. This one is what the pinned summary
304
+ // speaks from — the failure class no other number on the panel reveals — and it is two
305
+ // aggregates and a handful of rows, so the headline answer costs a tab-click less than the
306
+ // browse view it used to ride along with.
307
+ void observability.loadToolCallFailures(id)
113
308
  }
114
309
  },
115
310
  // Lazy v-if mount: the panel mounts with executionId already set, so load immediately.
@@ -209,11 +404,17 @@ function carryShare(carryCostTokens: number): number | null {
209
404
  function phaseLabel(phase: string): string {
210
405
  return phase || t('observability.phase.unattributed')
211
406
  }
407
+ /**
408
+ * Whether a successful call's finish reason is a warning (cut short, or filtered).
409
+ *
410
+ * The rule itself lives in `@cat-factory/contracts` beside the backend's own classification: a
411
+ * hand-copied list here was fine while it only picked a badge colour, and stopped being fine the
412
+ * moment the outcome FILTER decides which rows the operator is shown.
413
+ */
212
414
  function isWarning(finishReason: string | null): boolean {
213
- return finishReason === 'length' || finishReason === 'content_filter'
415
+ return isLlmWarningFinishReason(finishReason)
214
416
  }
215
417
 
216
- const expanded = reactive<Record<string, boolean>>({})
217
418
  function toggle(c: LlmCallMetric) {
218
419
  expanded[c.id] = !expanded[c.id]
219
420
  // A live-streamed row arrives without its prompt/response bodies (the event stays
@@ -288,6 +489,17 @@ function exportJson() {
288
489
  >
289
490
  {{ t('observability.modelActivity') }}
290
491
  </button>
492
+ <button
493
+ class="rounded-md px-2.5 py-1 transition"
494
+ :class="
495
+ view === 'tools'
496
+ ? 'bg-slate-800 text-slate-100'
497
+ : 'text-slate-400 hover:text-slate-200'
498
+ "
499
+ @click="openToolsView()"
500
+ >
501
+ {{ t('observability.toolCalls.title') }}
502
+ </button>
291
503
  <button
292
504
  class="rounded-md px-2.5 py-1 transition"
293
505
  :class="
@@ -337,6 +549,17 @@ function exportJson() {
337
549
 
338
550
  <div class="flex-1 overflow-auto px-6 py-6">
339
551
  <div v-if="view === 'calls'" class="mx-auto max-w-4xl space-y-5">
552
+ <!-- What broke, pinned ABOVE everything: the structured failure record plus the last
553
+ call that failed in each sink. The point of the panel's first screen is that the
554
+ cause is read, not hunted. -->
555
+ <RunFailureSummary
556
+ v-if="showFailureSummary"
557
+ :evidence="failureEvidence"
558
+ @show-call="revealCall"
559
+ @show-failing-tools="revealFailingToolCalls"
560
+ @retry="retryFailureEvidence"
561
+ />
562
+
340
563
  <!-- run-level summary -->
341
564
  <section class="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
342
565
  <dl class="grid grid-cols-2 gap-x-6 gap-y-3 text-[13px] sm:grid-cols-4">
@@ -576,144 +799,188 @@ function exportJson() {
576
799
  {{ t('observability.noCalls') }}
577
800
  </p>
578
801
 
579
- <!-- per-call list -->
580
- <ul v-else class="space-y-2">
581
- <li
582
- v-for="c in calls"
583
- :key="c.id"
584
- class="overflow-hidden rounded-xl border border-slate-800 bg-slate-900/40"
585
- :class="!c.ok ? 'border-rose-900/60' : ''"
802
+ <!-- per-call list, narrowable by outcome -->
803
+ <template v-else>
804
+ <div class="flex flex-wrap items-center justify-between gap-2">
805
+ <h2 class="text-[11px] uppercase tracking-wide text-slate-500">
806
+ {{ t('observability.callsTitle') }}
807
+ </h2>
808
+ <OutcomeFilterChips v-model="callFilter" :options="callFilterOptions" />
809
+ </div>
810
+
811
+ <!-- Narrowed to nothing reads differently from recorded nothing, and on this
812
+ surface it is the good news: the operator asked for the failures and there
813
+ are none. -->
814
+ <p
815
+ v-if="!visibleCalls.length"
816
+ class="rounded-lg border border-dashed border-slate-800 py-8 text-center text-sm text-slate-500"
586
817
  >
587
- <button
588
- class="flex w-full items-center gap-3 px-4 py-2.5 text-start transition hover:bg-slate-900/70"
589
- @click="toggle(c)"
818
+ {{ t('observability.noCallsMatching') }}
819
+ </p>
820
+
821
+ <ul v-else class="space-y-2">
822
+ <li
823
+ v-for="c in visibleCalls"
824
+ :id="callRowId(c.id)"
825
+ :key="c.id"
826
+ class="overflow-hidden rounded-xl border border-slate-800 bg-slate-900/40"
827
+ :class="!c.ok ? 'border-rose-900/60' : ''"
590
828
  >
591
- <UIcon
592
- name="i-lucide-chevron-right"
593
- class="h-4 w-4 shrink-0 text-slate-500 transition-transform"
594
- :class="expanded[c.id] ? 'rotate-90' : ''"
595
- />
596
- <UIcon
597
- :name="agentMeta(c.agentKind).icon"
598
- class="h-4 w-4 shrink-0"
599
- :style="{ color: agentMeta(c.agentKind).color }"
600
- />
601
- <span class="text-[13px] text-slate-200">{{ agentMeta(c.agentKind).label }}</span>
602
- <span
603
- class="hidden truncate text-[11px] text-slate-500 sm:inline"
604
- :title="c.model"
829
+ <button
830
+ class="flex w-full items-center gap-3 px-4 py-2.5 text-start transition hover:bg-slate-900/70"
831
+ @click="toggle(c)"
605
832
  >
606
- {{ c.provider }}:{{ c.model }}
607
- </span>
608
- <div
609
- class="ms-auto flex items-center gap-2.5 text-[11px] tabular-nums text-slate-400"
610
- >
611
- <span
612
- :title="
613
- t('observability.call.tokensTitle', {
614
- input: totalInputTokens(c),
615
- fresh: c.promptTokens,
616
- completion: c.completionTokens,
617
- })
618
- "
619
- >
620
- {{ formatTokens(totalInputTokens(c)) }}↑
621
- {{ formatTokens(c.completionTokens) }}↓
622
- </span>
833
+ <UIcon
834
+ name="i-lucide-chevron-right"
835
+ class="h-4 w-4 shrink-0 text-slate-500 transition-transform"
836
+ :class="expanded[c.id] ? 'rotate-90' : ''"
837
+ />
838
+ <UIcon
839
+ :name="agentMeta(c.agentKind).icon"
840
+ class="h-4 w-4 shrink-0"
841
+ :style="{ color: agentMeta(c.agentKind).color }"
842
+ />
843
+ <span class="text-[13px] text-slate-200">{{
844
+ agentMeta(c.agentKind).label
845
+ }}</span>
623
846
  <span
624
- v-if="headroomOf(c) !== null"
625
- :title="t('observability.call.outputUsedVsLimit')"
847
+ class="hidden truncate text-[11px] text-slate-500 sm:inline"
848
+ :title="c.model"
626
849
  >
627
- {{ headroomOf(c) }}%
850
+ {{ c.provider }}:{{ c.model }}
628
851
  </span>
629
- <span :title="t('observability.call.transportVsExecution')">
630
- {{ formatMs(c.overheadMs) }} / {{ formatMs(c.upstreamMs) }}
631
- </span>
632
- <UBadge v-if="!c.ok" color="error" variant="subtle" size="sm">
633
- {{ c.httpStatus ?? t('observability.call.error') }}
634
- </UBadge>
635
- <UBadge
636
- v-else-if="isWarning(c.finishReason)"
637
- color="warning"
638
- variant="subtle"
639
- size="sm"
640
- >
641
- {{ c.finishReason }}
642
- </UBadge>
643
- <span v-else class="text-slate-600">{{
644
- c.finishReason ?? t('observability.call.ok')
645
- }}</span>
646
- <span class="hidden text-slate-600 md:inline">{{ clock(c.createdAt) }}</span>
647
- </div>
648
- </button>
649
-
650
- <div v-if="expanded[c.id]" class="border-t border-slate-800 px-4 py-3 space-y-3">
651
- <p v-if="c.errorMessage" class="text-[12px] text-rose-400">
652
- {{ c.errorMessage }}
653
- </p>
654
- <div class="flex flex-wrap gap-x-5 gap-y-1 text-[11px] text-slate-500">
655
- <span>{{ t('observability.call.messages', { count: c.messageCount }) }}</span>
656
- <span>{{ t('observability.call.tools', { count: c.toolCount }) }}</span>
657
- <span>{{
658
- c.streaming
659
- ? t('observability.call.streamed')
660
- : t('observability.call.buffered')
661
- }}</span>
662
- <span v-if="c.requestMaxTokens != null">{{
663
- t('observability.call.maxTokens', { value: c.requestMaxTokens })
664
- }}</span>
665
- <span v-if="c.cacheReadTokens > 0 || c.cacheWriteTokens > 0">{{
666
- t('observability.call.fresh', { tokens: c.promptTokens })
667
- }}</span>
668
- <span v-if="c.cacheReadTokens > 0" class="text-emerald-400">{{
669
- t('observability.call.cacheRead', { tokens: c.cacheReadTokens })
670
- }}</span>
671
- <span v-if="c.cacheWriteTokens > 0" class="text-amber-400">{{
672
- t('observability.call.cacheWrite', { tokens: c.cacheWriteTokens })
673
- }}</span>
674
- <span>{{
675
- t('observability.call.total', { duration: formatMs(c.totalMs) })
676
- }}</span>
677
- </div>
678
- <div>
679
852
  <div
680
- class="mb-1 flex items-center gap-2 text-[11px] uppercase tracking-wide text-slate-500"
853
+ class="ms-auto flex items-center gap-2.5 text-[11px] tabular-nums text-slate-400"
681
854
  >
682
- <span>{{ t('observability.call.prompt') }}</span>
683
855
  <span
684
- v-if="c.promptPrefixCount > 0"
685
- class="normal-case tracking-normal text-slate-600"
686
- >
687
- {{
688
- t('observability.call.promptPrefixOmitted', {
689
- count: c.promptPrefixCount,
856
+ :title="
857
+ t('observability.call.tokensTitle', {
858
+ input: totalInputTokens(c),
859
+ fresh: c.promptTokens,
860
+ completion: c.completionTokens,
690
861
  })
691
- }}
862
+ "
863
+ >
864
+ {{ formatTokens(totalInputTokens(c)) }}↑
865
+ {{ formatTokens(c.completionTokens) }}↓
866
+ </span>
867
+ <span
868
+ v-if="headroomOf(c) !== null"
869
+ :title="t('observability.call.outputUsedVsLimit')"
870
+ >
871
+ {{ headroomOf(c) }}%
872
+ </span>
873
+ <span :title="t('observability.call.transportVsExecution')">
874
+ {{ formatMs(c.overheadMs) }} / {{ formatMs(c.upstreamMs) }}
692
875
  </span>
876
+ <UBadge v-if="!c.ok" color="error" variant="subtle" size="sm">
877
+ {{ c.httpStatus ?? t('observability.call.error') }}
878
+ </UBadge>
879
+ <UBadge
880
+ v-else-if="isWarning(c.finishReason)"
881
+ color="warning"
882
+ variant="subtle"
883
+ size="sm"
884
+ >
885
+ {{ c.finishReason }}
886
+ </UBadge>
887
+ <span v-else class="text-slate-600">{{
888
+ c.finishReason ?? t('observability.call.ok')
889
+ }}</span>
890
+ <span class="hidden text-slate-600 md:inline">{{ clock(c.createdAt) }}</span>
693
891
  </div>
694
- <pre
695
- class="max-h-72 overflow-auto rounded-lg bg-slate-950/70 p-3 text-[11px] leading-relaxed text-slate-300"
696
- >{{ prettyPrompt(c.promptText) }}</pre>
697
- </div>
698
- <div>
699
- <div class="mb-1 text-[11px] uppercase tracking-wide text-slate-500">
700
- {{ t('observability.call.response') }}
892
+ </button>
893
+
894
+ <div v-if="expanded[c.id]" class="border-t border-slate-800 px-4 py-3 space-y-3">
895
+ <p v-if="c.errorMessage" class="text-[12px] text-rose-400">
896
+ {{ c.errorMessage }}
897
+ </p>
898
+ <div class="flex flex-wrap gap-x-5 gap-y-1 text-[11px] text-slate-500">
899
+ <span>{{ t('observability.call.messages', { count: c.messageCount }) }}</span>
900
+ <span>{{ t('observability.call.tools', { count: c.toolCount }) }}</span>
901
+ <span>{{
902
+ c.streaming
903
+ ? t('observability.call.streamed')
904
+ : t('observability.call.buffered')
905
+ }}</span>
906
+ <span v-if="c.requestMaxTokens != null">{{
907
+ t('observability.call.maxTokens', { value: c.requestMaxTokens })
908
+ }}</span>
909
+ <span v-if="c.cacheReadTokens > 0 || c.cacheWriteTokens > 0">{{
910
+ t('observability.call.fresh', { tokens: c.promptTokens })
911
+ }}</span>
912
+ <span v-if="c.cacheReadTokens > 0" class="text-emerald-400">{{
913
+ t('observability.call.cacheRead', { tokens: c.cacheReadTokens })
914
+ }}</span>
915
+ <span v-if="c.cacheWriteTokens > 0" class="text-amber-400">{{
916
+ t('observability.call.cacheWrite', { tokens: c.cacheWriteTokens })
917
+ }}</span>
918
+ <span>{{
919
+ t('observability.call.total', { duration: formatMs(c.totalMs) })
920
+ }}</span>
701
921
  </div>
702
- <pre
703
- class="max-h-72 overflow-auto rounded-lg bg-slate-950/70 p-3 text-[11px] leading-relaxed text-slate-300"
704
- >{{ c.responseText || '—' }}</pre>
705
- </div>
706
- <div v-if="c.reasoningText">
707
- <div class="mb-1 text-[11px] uppercase tracking-wide text-slate-500">
708
- {{ t('observability.call.reasoning') }}
922
+ <div>
923
+ <div
924
+ class="mb-1 flex items-center gap-2 text-[11px] uppercase tracking-wide text-slate-500"
925
+ >
926
+ <span>{{ t('observability.call.prompt') }}</span>
927
+ <span
928
+ v-if="c.promptPrefixCount > 0"
929
+ class="normal-case tracking-normal text-slate-600"
930
+ >
931
+ {{
932
+ t('observability.call.promptPrefixOmitted', {
933
+ count: c.promptPrefixCount,
934
+ })
935
+ }}
936
+ </span>
937
+ </div>
938
+ <pre
939
+ class="max-h-72 overflow-auto rounded-lg bg-slate-950/70 p-3 text-[11px] leading-relaxed text-slate-300"
940
+ >{{ prettyPrompt(c.promptText) }}</pre>
941
+ </div>
942
+ <div>
943
+ <div class="mb-1 text-[11px] uppercase tracking-wide text-slate-500">
944
+ {{ t('observability.call.response') }}
945
+ </div>
946
+ <pre
947
+ class="max-h-72 overflow-auto rounded-lg bg-slate-950/70 p-3 text-[11px] leading-relaxed text-slate-300"
948
+ >{{ c.responseText || '—' }}</pre>
949
+ </div>
950
+ <div v-if="c.reasoningText">
951
+ <div class="mb-1 text-[11px] uppercase tracking-wide text-slate-500">
952
+ {{ t('observability.call.reasoning') }}
953
+ </div>
954
+ <pre
955
+ class="max-h-72 overflow-auto rounded-lg bg-slate-950/70 p-3 text-[11px] leading-relaxed text-slate-400"
956
+ >{{ c.reasoningText }}</pre>
709
957
  </div>
710
- <pre
711
- class="max-h-72 overflow-auto rounded-lg bg-slate-950/70 p-3 text-[11px] leading-relaxed text-slate-400"
712
- >{{ c.reasoningText }}</pre>
713
958
  </div>
714
- </div>
715
- </li>
716
- </ul>
959
+ </li>
960
+ </ul>
961
+ </template>
962
+ </div>
963
+
964
+ <!-- Tool-call trajectory: what the run's agents DID, in the order they did it. -->
965
+ <div v-else-if="view === 'tools'" class="mx-auto max-w-4xl space-y-5">
966
+ <RunFailureSummary
967
+ v-if="showFailureSummary"
968
+ :evidence="failureEvidence"
969
+ @show-call="revealCall"
970
+ @show-failing-tools="revealFailingToolCalls"
971
+ @retry="retryFailureEvidence"
972
+ />
973
+ <ToolCallList
974
+ v-model:filter="toolFilter"
975
+ :trajectory="trajectory"
976
+ :failures="toolFailures"
977
+ :loading="toolCallsLoading"
978
+ :error="toolCallError"
979
+ :failures-loading="toolFailuresLoading"
980
+ :failures-error="toolFailureError"
981
+ @retry="retryToolCalls"
982
+ @retry-failures="retryToolCallFailures"
983
+ />
717
984
  </div>
718
985
 
719
986
  <!-- Provided context: the complete context each container agent was given. -->
@@ -5,6 +5,8 @@ import {
5
5
  getExecutionAgentContextContract,
6
6
  getExecutionLlmMetricsContract,
7
7
  getExecutionSearchQueriesContract,
8
+ getExecutionToolCallFailuresContract,
9
+ getExecutionToolCallsContract,
8
10
  getWorkspaceUsageContract,
9
11
  mergeBlockContract,
10
12
  rejectStepContract,
@@ -165,6 +167,26 @@ export function executionApi({ send, sendWith, ws, pwHeaders }: ApiContext) {
165
167
  pathParams: { executionId },
166
168
  }),
167
169
 
170
+ // The tool-call trajectory: what the run's agents DID, oldest first. The half of a
171
+ // failure no model call reports: a tool that errors leaves the call that asked for it
172
+ // reporting `ok`. Bounded, and says so via `truncated`. Empty when the sink is not wired /
173
+ // storing is off. The BROWSE read: fetched when the trajectory is opened, since it carries
174
+ // every argument and result the run captured.
175
+ getToolCalls: (workspaceId: string, executionId: string) =>
176
+ send(getExecutionToolCallsContract, {
177
+ pathPrefix: ws(workspaceId),
178
+ pathParams: { executionId },
179
+ }),
180
+
181
+ // The run's failing tool calls plus its exact `{ total, failed }`, counted in SQL rather
182
+ // than off any list. The panel's headline read, made on open: cheap enough to front the
183
+ // page, and exact enough that it never disagrees with the debug overview on a long run.
184
+ getToolCallFailures: (workspaceId: string, executionId: string) =>
185
+ send(getExecutionToolCallFailuresContract, {
186
+ pathPrefix: ws(workspaceId),
187
+ pathParams: { executionId },
188
+ }),
189
+
168
190
  // ---- spend safeguard --------------------------------------------------
169
191
  resumeSpend: (workspaceId: string) =>
170
192
  send(resumeSpendContract, { pathPrefix: ws(workspaceId) }),