@cat-factory/app 0.232.1 → 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.
- package/app/components/layout/BoardSwitcher.vue +7 -0
- package/app/components/observability/OutcomeFilterChips.vue +41 -0
- package/app/components/observability/RunFailureSummary.vue +243 -0
- package/app/components/observability/ToolCallList.vue +290 -0
- package/app/components/palettes/AgentPalette.vue +1 -0
- package/app/components/panels/AgentStepDetail.vue +4 -0
- package/app/components/panels/ObservabilityPanel.vue +396 -129
- package/app/components/pipeline/PipelineBuilder.vue +5 -0
- package/app/composables/api/execution.ts +22 -0
- package/app/stores/observability/toolCalls.ts +173 -0
- package/app/stores/observability.ts +13 -0
- package/app/types/execution.ts +6 -0
- package/app/utils/observability.spec.ts +313 -2
- package/app/utils/observability.ts +232 -1
- package/i18n/locales/de.json +45 -0
- package/i18n/locales/en.json +45 -0
- package/i18n/locales/es.json +45 -0
- package/i18n/locales/fr.json +45 -0
- package/i18n/locales/he.json +45 -0
- package/i18n/locales/it.json +45 -0
- package/i18n/locales/ja.json +45 -0
- package/i18n/locales/pl.json +45 -0
- package/i18n/locales/tr.json +45 -0
- package/i18n/locales/uk.json +45 -0
- package/package.json +2 -2
|
@@ -107,12 +107,17 @@ const boardItems = computed<DropdownMenuItem[][]>(() => [
|
|
|
107
107
|
// manages membership can see at a glance which boards are scoped vs open to the account.
|
|
108
108
|
icon: w.accessMode === 'restricted' ? 'i-lucide-lock' : 'i-lucide-layout-dashboard',
|
|
109
109
|
trailingIcon: w.id === workspace.workspaceId ? 'i-lucide-check' : undefined,
|
|
110
|
+
// Addressable per board id: which board a row opens is the one thing about this menu a test
|
|
111
|
+
// (or a screen reader's user testing a switch) cannot get from the label, which is a name a
|
|
112
|
+
// person chose and two boards may share.
|
|
113
|
+
'data-testid': `board-option-${w.id}`,
|
|
110
114
|
onSelect: () => void switchBoard(w.id),
|
|
111
115
|
})),
|
|
112
116
|
[
|
|
113
117
|
{
|
|
114
118
|
label: t('layout.boardSwitcher.board.new'),
|
|
115
119
|
icon: 'i-lucide-plus',
|
|
120
|
+
'data-testid': 'board-new',
|
|
116
121
|
onSelect: () => openPrompt('board'),
|
|
117
122
|
},
|
|
118
123
|
// Rename/delete of the active board is `settings.manage` — omit for a member/viewer,
|
|
@@ -290,6 +295,8 @@ async function submitPrompt() {
|
|
|
290
295
|
class="flex w-full items-center gap-2 rounded-lg border border-slate-800 bg-slate-900/60 px-2.5 py-1.5 text-start transition hover:bg-slate-800/60"
|
|
291
296
|
:class="collapsed ? 'justify-center' : ''"
|
|
292
297
|
:disabled="busy"
|
|
298
|
+
data-testid="board-switcher"
|
|
299
|
+
:data-board-id="workspace.workspaceId ?? ''"
|
|
293
300
|
>
|
|
294
301
|
<UIcon name="i-lucide-layout-dashboard" class="h-4 w-4 shrink-0 text-indigo-400" />
|
|
295
302
|
<span v-if="!collapsed" class="truncate text-sm font-medium text-white">
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<script setup lang="ts" generic="T extends string">
|
|
2
|
+
// The ok/failure narrowing shared by the observability panel's two drill-downs (model calls and
|
|
3
|
+
// the tool-call trajectory).
|
|
4
|
+
//
|
|
5
|
+
// Every option carries its COUNT and stays rendered at zero. A chip that disappeared when its
|
|
6
|
+
// class was empty would answer "are there any failures?" by absence, which reads exactly like a
|
|
7
|
+
// list that has not finished loading — and "no tool call failed" is precisely the fact this
|
|
8
|
+
// panel exists to state out loud rather than imply.
|
|
9
|
+
const props = defineProps<{
|
|
10
|
+
/** The options, in display order. `count` is what each would show if selected. */
|
|
11
|
+
options: readonly { value: T; label: string; count: number; tone?: 'error' | 'warning' }[]
|
|
12
|
+
modelValue: T
|
|
13
|
+
}>()
|
|
14
|
+
const emit = defineEmits<{ 'update:modelValue': [value: T] }>()
|
|
15
|
+
|
|
16
|
+
/** Colour a chip by what it selects, so the failing one reads as failing even unselected. */
|
|
17
|
+
function toneClass(option: (typeof props.options)[number], active: boolean): string {
|
|
18
|
+
if (active) return 'bg-slate-800 text-slate-100'
|
|
19
|
+
if (option.count === 0) return 'text-slate-600 hover:text-slate-400'
|
|
20
|
+
if (option.tone === 'error') return 'text-rose-400 hover:text-rose-300'
|
|
21
|
+
if (option.tone === 'warning') return 'text-amber-400 hover:text-amber-300'
|
|
22
|
+
return 'text-slate-400 hover:text-slate-200'
|
|
23
|
+
}
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
<template>
|
|
27
|
+
<div class="flex rounded-lg border border-slate-800 p-0.5 text-[12px]">
|
|
28
|
+
<button
|
|
29
|
+
v-for="option in options"
|
|
30
|
+
:key="option.value"
|
|
31
|
+
type="button"
|
|
32
|
+
class="rounded-md px-2.5 py-1 transition"
|
|
33
|
+
:class="toneClass(option, option.value === modelValue)"
|
|
34
|
+
:aria-pressed="option.value === modelValue"
|
|
35
|
+
@click="emit('update:modelValue', option.value)"
|
|
36
|
+
>
|
|
37
|
+
{{ option.label }}
|
|
38
|
+
<span class="tabular-nums opacity-70">{{ option.count }}</span>
|
|
39
|
+
</button>
|
|
40
|
+
</div>
|
|
41
|
+
</template>
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import type { RunFailureEvidence } from '~/utils/observability'
|
|
4
|
+
import { noFailingCallReason } from '~/utils/observability'
|
|
5
|
+
import { agentKindMeta } from '~/utils/catalog'
|
|
6
|
+
import { FAILURE_KIND_KEYS } from '~/utils/failureKinds'
|
|
7
|
+
import FailureDetail from '~/components/board/FailureDetail.vue'
|
|
8
|
+
|
|
9
|
+
// The panel's FIRST section: what broke, pinned above the call list instead of found by
|
|
10
|
+
// scrolling it.
|
|
11
|
+
//
|
|
12
|
+
// Three things are shown together because each answers a question the others cannot:
|
|
13
|
+
//
|
|
14
|
+
// - the run's own structured failure record (`agent_runs.failure`), which names the class of
|
|
15
|
+
// death and where to look, but never which call;
|
|
16
|
+
// - the last MODEL call that failed, the transport / proxy / spend-gate side;
|
|
17
|
+
// - the last TOOL call that failed, the side no rollup counts — a tool that errors inside the
|
|
18
|
+
// container leaves the model call that requested it reporting `ok` with a clean finish
|
|
19
|
+
// reason, so every LLM number on this panel reads healthy right up to the moment the run dies.
|
|
20
|
+
//
|
|
21
|
+
// The two evidence rows are shown in a FIXED order and are not ranked against each other. They
|
|
22
|
+
// come from different clocks (a call's recorded `createdAt`, a tool span's harness-stamped
|
|
23
|
+
// `startedAt`), so "which happened last" is not a comparison this can make honestly, and a
|
|
24
|
+
// confident wrong ordering here is worse than none: the whole section exists to be believed.
|
|
25
|
+
//
|
|
26
|
+
// Every count it prints is a run-level SQL aggregate rather than the length of a list the panel
|
|
27
|
+
// happens to hold, and any bound it renders under says so. Counting off the loaded rows would be
|
|
28
|
+
// the same mistake one layer up as filtering a bounded prefix in JS, and it fails the same way:
|
|
29
|
+
// silently, on exactly the long runs worth opening this panel for.
|
|
30
|
+
const props = defineProps<{ evidence: RunFailureEvidence }>()
|
|
31
|
+
const emit = defineEmits<{
|
|
32
|
+
/** Open the model call with this id in the call list. */
|
|
33
|
+
showCall: [callId: string]
|
|
34
|
+
/** Open the tool-call trajectory, narrowed to the failures. */
|
|
35
|
+
showFailingTools: []
|
|
36
|
+
/** Re-request whichever telemetry read failed. */
|
|
37
|
+
retry: []
|
|
38
|
+
}>()
|
|
39
|
+
|
|
40
|
+
const { t, d } = useI18n()
|
|
41
|
+
|
|
42
|
+
/** Why no failing call could be pinned, or null when one was. See `noFailingCallReason`. */
|
|
43
|
+
const emptyReason = computed(() => noFailingCallReason(props.evidence))
|
|
44
|
+
|
|
45
|
+
function clock(ms: number): string {
|
|
46
|
+
return d(new Date(ms), 'long')
|
|
47
|
+
}
|
|
48
|
+
function agentMeta(kind: string) {
|
|
49
|
+
return agentKindMeta(kind)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The failure's kind as translated copy.
|
|
54
|
+
*
|
|
55
|
+
* Through the shared `FAILURE_KIND_KEYS` map, never the raw enum: `job_failed` and
|
|
56
|
+
* `companion_rejected` are storage spellings, and the map is an exhaustive
|
|
57
|
+
* `Record<AgentFailureKind, …>` so a kind added to the contract fails the typecheck here instead
|
|
58
|
+
* of surfacing as a code in the middle of a sentence.
|
|
59
|
+
*/
|
|
60
|
+
const failureKindLabel = computed(() => {
|
|
61
|
+
const failure = props.evidence.failure
|
|
62
|
+
return failure ? t(FAILURE_KIND_KEYS[failure.kind]) : ''
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A tool call's result text, or null when there is nothing honest to show.
|
|
67
|
+
*
|
|
68
|
+
* `withheld` is NOT an empty result: the bodies were never captured (the deployment switch or
|
|
69
|
+
* the workspace opt-out), so rendering `''` would present the failing call as a tool that said
|
|
70
|
+
* nothing about why it failed. The template says which of the two it is.
|
|
71
|
+
*/
|
|
72
|
+
const failedToolResult = computed(() => {
|
|
73
|
+
const call = props.evidence.lastFailedToolCall
|
|
74
|
+
if (!call || call.bodies !== 'stored') return null
|
|
75
|
+
return call.result || null
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
/** How many failing tool calls precede the pinned one, or null when it is the only one. */
|
|
79
|
+
const earlierFailedToolCalls = computed(() =>
|
|
80
|
+
props.evidence.failedToolCallCount > 1 ? props.evidence.failedToolCallCount - 1 : null,
|
|
81
|
+
)
|
|
82
|
+
</script>
|
|
83
|
+
|
|
84
|
+
<template>
|
|
85
|
+
<section class="rounded-xl border border-rose-900/60 bg-rose-950/20 p-4">
|
|
86
|
+
<div class="flex items-start gap-3">
|
|
87
|
+
<UIcon name="i-lucide-siren" class="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
|
|
88
|
+
<div class="min-w-0 flex-1">
|
|
89
|
+
<h2 class="text-[13px] font-semibold text-rose-200">
|
|
90
|
+
{{ t('observability.failure.title') }}
|
|
91
|
+
</h2>
|
|
92
|
+
|
|
93
|
+
<!-- The run's own structured record. Absent on a run that is still going, or that
|
|
94
|
+
failed without one; the evidence below stands on its own either way. -->
|
|
95
|
+
<template v-if="evidence.failure">
|
|
96
|
+
<p class="mt-1 text-[13px] text-slate-200">{{ evidence.failure.message }}</p>
|
|
97
|
+
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-0.5 text-[11px] text-slate-500">
|
|
98
|
+
<span>{{ t('observability.failure.kind', { kind: failureKindLabel }) }}</span>
|
|
99
|
+
<span v-if="evidence.failure.stepIndex != null">
|
|
100
|
+
{{ t('observability.failure.atStep', { index: evidence.failure.stepIndex + 1 }) }}
|
|
101
|
+
</span>
|
|
102
|
+
<span>{{ clock(evidence.failure.occurredAt) }}</span>
|
|
103
|
+
</div>
|
|
104
|
+
<!-- The shared disclosure the failure banner and the prior-errors history use:
|
|
105
|
+
collapsed, copyable, and silent when the detail merely repeats the message. A
|
|
106
|
+
pinned triage header is the last place an unbounded stack trace should sit
|
|
107
|
+
expanded by default, pushing the failing call it exists to surface off-screen. -->
|
|
108
|
+
<FailureDetail
|
|
109
|
+
:detail="evidence.failure.detail"
|
|
110
|
+
:message="evidence.failure.message"
|
|
111
|
+
summary-class="text-[11px] text-slate-500 hover:text-slate-300"
|
|
112
|
+
pre-class="bg-slate-950/60 text-[11px] text-slate-400"
|
|
113
|
+
/>
|
|
114
|
+
<p v-if="evidence.failure.hint" class="mt-1.5 text-[12px] text-slate-300">
|
|
115
|
+
{{ evidence.failure.hint }}
|
|
116
|
+
</p>
|
|
117
|
+
</template>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
<!-- The failing calls themselves. -->
|
|
122
|
+
<div class="mt-3 space-y-2">
|
|
123
|
+
<!-- Last model call that FAILED outright. -->
|
|
124
|
+
<button
|
|
125
|
+
v-if="evidence.lastErroredCall"
|
|
126
|
+
type="button"
|
|
127
|
+
class="flex w-full items-start gap-3 rounded-lg border border-rose-900/50 bg-slate-950/50 px-3 py-2 text-start transition hover:bg-slate-900/70"
|
|
128
|
+
@click="emit('showCall', evidence.lastErroredCall.id)"
|
|
129
|
+
>
|
|
130
|
+
<UIcon
|
|
131
|
+
:name="agentMeta(evidence.lastErroredCall.agentKind).icon"
|
|
132
|
+
class="mt-0.5 h-4 w-4 shrink-0"
|
|
133
|
+
:style="{ color: agentMeta(evidence.lastErroredCall.agentKind).color }"
|
|
134
|
+
/>
|
|
135
|
+
<div class="min-w-0 flex-1">
|
|
136
|
+
<div class="flex flex-wrap items-baseline gap-x-2 text-[12px]">
|
|
137
|
+
<span class="font-medium text-slate-200">
|
|
138
|
+
{{ t('observability.failure.lastErroredCall') }}
|
|
139
|
+
</span>
|
|
140
|
+
<span class="text-slate-500">
|
|
141
|
+
{{ agentMeta(evidence.lastErroredCall.agentKind).label }} ·
|
|
142
|
+
{{ evidence.lastErroredCall.provider }}:{{ evidence.lastErroredCall.model }}
|
|
143
|
+
</span>
|
|
144
|
+
<UBadge color="error" variant="subtle" size="sm">
|
|
145
|
+
{{ evidence.lastErroredCall.httpStatus ?? t('observability.call.error') }}
|
|
146
|
+
</UBadge>
|
|
147
|
+
</div>
|
|
148
|
+
<p v-if="evidence.lastErroredCall.errorMessage" class="mt-0.5 text-[12px] text-rose-300">
|
|
149
|
+
{{ evidence.lastErroredCall.errorMessage }}
|
|
150
|
+
</p>
|
|
151
|
+
<p v-if="evidence.erroredCallCount > 1" class="mt-0.5 text-[11px] text-slate-500">
|
|
152
|
+
{{
|
|
153
|
+
t(
|
|
154
|
+
'observability.failure.moreErroredCalls',
|
|
155
|
+
{ count: evidence.erroredCallCount - 1 },
|
|
156
|
+
evidence.erroredCallCount - 1,
|
|
157
|
+
)
|
|
158
|
+
}}
|
|
159
|
+
</p>
|
|
160
|
+
</div>
|
|
161
|
+
<UIcon name="i-lucide-chevron-right" class="mt-0.5 h-4 w-4 shrink-0 text-slate-600" />
|
|
162
|
+
</button>
|
|
163
|
+
|
|
164
|
+
<!-- Last TOOL call that failed: the row no rollup counts. -->
|
|
165
|
+
<button
|
|
166
|
+
v-if="evidence.lastFailedToolCall"
|
|
167
|
+
type="button"
|
|
168
|
+
class="flex w-full items-start gap-3 rounded-lg border border-rose-900/50 bg-slate-950/50 px-3 py-2 text-start transition hover:bg-slate-900/70"
|
|
169
|
+
@click="emit('showFailingTools')"
|
|
170
|
+
>
|
|
171
|
+
<UIcon name="i-lucide-wrench" class="mt-0.5 h-4 w-4 shrink-0 text-rose-400" />
|
|
172
|
+
<div class="min-w-0 flex-1">
|
|
173
|
+
<div class="flex flex-wrap items-baseline gap-x-2 text-[12px]">
|
|
174
|
+
<span class="font-medium text-slate-200">
|
|
175
|
+
<!-- "One of the failing calls" when even the failures were bounded: the row is
|
|
176
|
+
real either way, but calling it the LAST would be a claim about rows this
|
|
177
|
+
read never saw. -->
|
|
178
|
+
{{
|
|
179
|
+
evidence.failedToolCallsTruncated
|
|
180
|
+
? t('observability.failure.aFailedToolCall')
|
|
181
|
+
: t('observability.failure.lastFailedToolCall')
|
|
182
|
+
}}
|
|
183
|
+
</span>
|
|
184
|
+
<span class="font-mono text-slate-300">{{ evidence.lastFailedToolCall.tool }}</span>
|
|
185
|
+
<span class="text-slate-500">
|
|
186
|
+
{{ agentMeta(evidence.lastFailedToolCall.agentKind).label }}
|
|
187
|
+
</span>
|
|
188
|
+
</div>
|
|
189
|
+
<pre
|
|
190
|
+
v-if="failedToolResult"
|
|
191
|
+
class="mt-1 max-h-32 overflow-auto whitespace-pre-wrap text-[11px] leading-relaxed text-rose-300"
|
|
192
|
+
>{{ failedToolResult }}</pre>
|
|
193
|
+
<p v-else class="mt-0.5 text-[11px] italic text-slate-500">
|
|
194
|
+
{{
|
|
195
|
+
evidence.lastFailedToolCall.bodies === 'stored'
|
|
196
|
+
? t('observability.failure.toolReturnedNothing')
|
|
197
|
+
: t('observability.failure.toolBodiesWithheld')
|
|
198
|
+
}}
|
|
199
|
+
</p>
|
|
200
|
+
<p v-if="earlierFailedToolCalls" class="mt-0.5 text-[11px] text-slate-500">
|
|
201
|
+
{{
|
|
202
|
+
t(
|
|
203
|
+
'observability.failure.moreFailedToolCalls',
|
|
204
|
+
{ count: earlierFailedToolCalls },
|
|
205
|
+
earlierFailedToolCalls,
|
|
206
|
+
)
|
|
207
|
+
}}
|
|
208
|
+
</p>
|
|
209
|
+
</div>
|
|
210
|
+
<UIcon name="i-lucide-chevron-right" class="mt-0.5 h-4 w-4 shrink-0 text-slate-600" />
|
|
211
|
+
</button>
|
|
212
|
+
|
|
213
|
+
<!-- Nothing failing to point at. Which of the reasons it is decides what an operator should
|
|
214
|
+
do next, so each gets its own sentence rather than one shared shrug. `emptyReason` is
|
|
215
|
+
null whenever EITHER sink held a failure, so it already covers the model-call arm
|
|
216
|
+
above, and null while a sink is still loading, which is what stops a read that has not
|
|
217
|
+
come back from being reported as one that came back clean. -->
|
|
218
|
+
<div
|
|
219
|
+
v-else-if="emptyReason"
|
|
220
|
+
class="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-dashed px-3 py-2 text-[12px]"
|
|
221
|
+
:class="
|
|
222
|
+
emptyReason === 'sink-unreachable'
|
|
223
|
+
? 'border-amber-900/60 text-amber-300'
|
|
224
|
+
: 'border-slate-800 text-slate-400'
|
|
225
|
+
"
|
|
226
|
+
>
|
|
227
|
+
<span>{{ t(`observability.failure.noFailingCall.${emptyReason}`) }}</span>
|
|
228
|
+
<!-- The one empty state with an action attached: the others are answers, this one is the
|
|
229
|
+
absence of one. -->
|
|
230
|
+
<UButton
|
|
231
|
+
v-if="emptyReason === 'sink-unreachable'"
|
|
232
|
+
icon="i-lucide-rotate-cw"
|
|
233
|
+
color="neutral"
|
|
234
|
+
variant="soft"
|
|
235
|
+
size="xs"
|
|
236
|
+
@click="emit('retry')"
|
|
237
|
+
>
|
|
238
|
+
{{ t('common.retry') }}
|
|
239
|
+
</UButton>
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
</section>
|
|
243
|
+
</template>
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, reactive } from 'vue'
|
|
3
|
+
import type { AgentToolCall, RunToolCallFailures, RunToolCallTrajectory } from '~/types/execution'
|
|
4
|
+
import type { ToolOutcomeFilter } from '~/utils/observability'
|
|
5
|
+
import { filterToolCallsByOutcome, formatMs } from '~/utils/observability'
|
|
6
|
+
import { agentKindMeta } from '~/utils/catalog'
|
|
7
|
+
import OutcomeFilterChips from '~/components/observability/OutcomeFilterChips.vue'
|
|
8
|
+
|
|
9
|
+
// The tool-call TRAJECTORY drill-down: what the run's agents DID, oldest first, in the order
|
|
10
|
+
// they did it. The sibling of the model-call list, and the one that holds the failures no LLM
|
|
11
|
+
// rollup counts — a tool that errors inside the container leaves the call that requested it
|
|
12
|
+
// reporting `ok`.
|
|
13
|
+
//
|
|
14
|
+
// Rows keep their trajectory order under every filter. Narrowing to the failures and reading
|
|
15
|
+
// them in sequence is what tells one tool that failed and was worked around from an edit loop
|
|
16
|
+
// stuck repeating the same failing call, and re-sorting by anything else destroys exactly that.
|
|
17
|
+
//
|
|
18
|
+
// TWO sources, because they answer at different bounds. `trajectory` is a bounded PREFIX of the
|
|
19
|
+
// run, so counting or narrowing it here would repeat, in JavaScript, the exact mistake the
|
|
20
|
+
// stores refuse to make in SQL: a run whose failures came after its opening moves would be shown
|
|
21
|
+
// as one whose tools all worked. So the failing rows and every count come from `failures`, which
|
|
22
|
+
// the backend narrowed and aggregated over the whole run, and only the browse view reads the
|
|
23
|
+
// prefix — under a banner that says it is one.
|
|
24
|
+
const props = defineProps<{
|
|
25
|
+
trajectory: RunToolCallTrajectory
|
|
26
|
+
/** The run-level failure read: exact counts, and the failing rows in trajectory order. */
|
|
27
|
+
failures: RunToolCallFailures | null
|
|
28
|
+
loading: boolean
|
|
29
|
+
error: string | null
|
|
30
|
+
failuresLoading: boolean
|
|
31
|
+
failuresError: string | null
|
|
32
|
+
}>()
|
|
33
|
+
const emit = defineEmits<{
|
|
34
|
+
/** Re-request the trajectory. */
|
|
35
|
+
retry: []
|
|
36
|
+
/** Re-request the failure read. */
|
|
37
|
+
retryFailures: []
|
|
38
|
+
}>()
|
|
39
|
+
|
|
40
|
+
/** Which calls the list is narrowed to. Two-way bound so the failure summary can jump here. */
|
|
41
|
+
const filter = defineModel<ToolOutcomeFilter>('filter', { required: true })
|
|
42
|
+
|
|
43
|
+
const { t, d } = useI18n()
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Chip counts, taken from the run-level aggregate rather than the loaded rows.
|
|
47
|
+
*
|
|
48
|
+
* A chip is a claim about the run ("4 failed"), so counting the prefix would make it a claim
|
|
49
|
+
* about the first two thousand calls wearing the run's name. Null until the aggregate answers:
|
|
50
|
+
* the chips then read 0, which the loading state above them already accounts for.
|
|
51
|
+
*/
|
|
52
|
+
const counts = computed(() => {
|
|
53
|
+
const total = props.failures?.total ?? 0
|
|
54
|
+
const failed = props.failures?.failed ?? 0
|
|
55
|
+
return { all: total, error: failed, ok: total - failed }
|
|
56
|
+
})
|
|
57
|
+
const filterOptions = computed(
|
|
58
|
+
() =>
|
|
59
|
+
[
|
|
60
|
+
{ value: 'all', label: t('observability.filter.all'), count: counts.value.all },
|
|
61
|
+
{
|
|
62
|
+
value: 'error',
|
|
63
|
+
label: t('observability.filter.failed'),
|
|
64
|
+
count: counts.value.error,
|
|
65
|
+
tone: 'error',
|
|
66
|
+
},
|
|
67
|
+
{ value: 'ok', label: t('observability.filter.ok'), count: counts.value.ok },
|
|
68
|
+
] as const,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The rows on screen.
|
|
73
|
+
*
|
|
74
|
+
* `error` is served from the run-level failure read, so narrowing to the failures shows the
|
|
75
|
+
* run's failures rather than the prefix's. `all` and `ok` browse the prefix, which is what the
|
|
76
|
+
* banner below is for.
|
|
77
|
+
*/
|
|
78
|
+
const visible = computed(() =>
|
|
79
|
+
filter.value === 'error'
|
|
80
|
+
? (props.failures?.failures ?? [])
|
|
81
|
+
: filterToolCallsByOutcome(props.trajectory.toolCalls, filter.value),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Whether what is on screen is bounded, and by which read.
|
|
86
|
+
*
|
|
87
|
+
* Stated per view rather than once for the component: narrowing to the failures escapes the
|
|
88
|
+
* trajectory's prefix entirely, so carrying that bound's warning into the failure view would
|
|
89
|
+
* cast doubt on a list that has none.
|
|
90
|
+
*/
|
|
91
|
+
/**
|
|
92
|
+
* Which read backs the current view, with its own loading and error state.
|
|
93
|
+
*
|
|
94
|
+
* Per view, not per component. The failure view does not read the trajectory at all, so a
|
|
95
|
+
* trajectory that is still loading (or failed to) must not blank out failing rows already in
|
|
96
|
+
* hand — reporting an unrelated read's trouble as this view's emptiness is the same class of
|
|
97
|
+
* mistake as reporting a prefix as a run.
|
|
98
|
+
*/
|
|
99
|
+
const source = computed(() =>
|
|
100
|
+
filter.value === 'error'
|
|
101
|
+
? {
|
|
102
|
+
loading: props.failuresLoading,
|
|
103
|
+
error: props.failuresError,
|
|
104
|
+
retry: () => emit('retryFailures'),
|
|
105
|
+
}
|
|
106
|
+
: { loading: props.loading, error: props.error, retry: () => emit('retry') },
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
const boundedNotice = computed(() => {
|
|
110
|
+
if (filter.value === 'error') {
|
|
111
|
+
return props.failures?.failuresTruncated
|
|
112
|
+
? t('observability.toolCalls.failuresTruncated', { shown: visible.value.length })
|
|
113
|
+
: null
|
|
114
|
+
}
|
|
115
|
+
return props.trajectory.truncated
|
|
116
|
+
? t('observability.toolCalls.truncated', { shown: props.trajectory.toolCalls.length })
|
|
117
|
+
: null
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
const expanded = reactive<Record<string, boolean>>({})
|
|
121
|
+
function toggle(call: AgentToolCall) {
|
|
122
|
+
expanded[call.id] = !expanded[call.id]
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function agentMeta(kind: string) {
|
|
126
|
+
return agentKindMeta(kind)
|
|
127
|
+
}
|
|
128
|
+
function clock(ms: number): string {
|
|
129
|
+
return d(new Date(ms), 'long')
|
|
130
|
+
}
|
|
131
|
+
/** Pretty-print JSON arguments; fall back to the raw string when they are not JSON. */
|
|
132
|
+
function prettyArgs(raw: string): string {
|
|
133
|
+
try {
|
|
134
|
+
return JSON.stringify(JSON.parse(raw), null, 2)
|
|
135
|
+
} catch {
|
|
136
|
+
return raw
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
</script>
|
|
140
|
+
|
|
141
|
+
<template>
|
|
142
|
+
<div class="space-y-4">
|
|
143
|
+
<div class="flex flex-wrap items-center justify-between gap-2">
|
|
144
|
+
<div>
|
|
145
|
+
<h2 class="text-[11px] uppercase tracking-wide text-slate-500">
|
|
146
|
+
{{ t('observability.toolCalls.title') }}
|
|
147
|
+
</h2>
|
|
148
|
+
<p class="text-[11px] text-slate-600">{{ t('observability.toolCalls.subtitle') }}</p>
|
|
149
|
+
</div>
|
|
150
|
+
<OutcomeFilterChips v-model="filter" :options="filterOptions" />
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<!-- Every state below is gated on having NOTHING to show: rows already in hand outrank a
|
|
154
|
+
read still in flight behind them, and outrank one that failed. -->
|
|
155
|
+
<p
|
|
156
|
+
v-if="source.loading && !visible.length"
|
|
157
|
+
class="flex items-center justify-center gap-2 py-8 text-center text-sm text-slate-500"
|
|
158
|
+
>
|
|
159
|
+
<UIcon name="i-lucide-loader-circle" class="h-4 w-4 animate-spin" />
|
|
160
|
+
{{ t('observability.toolCalls.loading') }}
|
|
161
|
+
</p>
|
|
162
|
+
<div
|
|
163
|
+
v-else-if="source.error && !visible.length"
|
|
164
|
+
class="flex flex-col items-center gap-3 rounded-lg border border-dashed border-rose-900/60 py-6 text-center text-sm text-rose-400"
|
|
165
|
+
>
|
|
166
|
+
{{ t('observability.toolCalls.error') }}
|
|
167
|
+
<UButton
|
|
168
|
+
icon="i-lucide-rotate-cw"
|
|
169
|
+
color="neutral"
|
|
170
|
+
variant="soft"
|
|
171
|
+
size="xs"
|
|
172
|
+
:loading="source.loading"
|
|
173
|
+
@click="source.retry()"
|
|
174
|
+
>
|
|
175
|
+
{{ t('common.retry') }}
|
|
176
|
+
</UButton>
|
|
177
|
+
</div>
|
|
178
|
+
<!-- "The run made no tool calls" is a claim about the RUN, so it comes off the aggregate,
|
|
179
|
+
never off an empty prefix that may simply not have loaded. -->
|
|
180
|
+
<p
|
|
181
|
+
v-else-if="!counts.all"
|
|
182
|
+
class="rounded-lg border border-dashed border-slate-800 py-8 text-center text-sm text-slate-500"
|
|
183
|
+
>
|
|
184
|
+
{{ t('observability.toolCalls.none') }}
|
|
185
|
+
</p>
|
|
186
|
+
<!-- Narrowed to nothing is a different statement from recorded nothing, and it is the more
|
|
187
|
+
reassuring of the two: the operator asked for the failures and there are none. -->
|
|
188
|
+
<p
|
|
189
|
+
v-else-if="!visible.length"
|
|
190
|
+
class="rounded-lg border border-dashed border-slate-800 py-8 text-center text-sm text-slate-500"
|
|
191
|
+
>
|
|
192
|
+
{{ t('observability.toolCalls.noneMatching') }}
|
|
193
|
+
</p>
|
|
194
|
+
|
|
195
|
+
<template v-else>
|
|
196
|
+
<!-- What this view is bounded by, when it is. A cap nobody can see is a prefix read as a
|
|
197
|
+
whole run: the count beside every chip is the run's, so a shorter list than the count
|
|
198
|
+
implies has to explain itself here. -->
|
|
199
|
+
<p
|
|
200
|
+
v-if="boundedNotice"
|
|
201
|
+
class="rounded-lg border border-dashed border-amber-900/50 px-3 py-2 text-[11px] text-amber-300/90"
|
|
202
|
+
>
|
|
203
|
+
{{ boundedNotice }}
|
|
204
|
+
</p>
|
|
205
|
+
|
|
206
|
+
<ul class="space-y-2">
|
|
207
|
+
<li
|
|
208
|
+
v-for="call in visible"
|
|
209
|
+
:key="call.id"
|
|
210
|
+
class="overflow-hidden rounded-xl border border-slate-800 bg-slate-900/40"
|
|
211
|
+
:class="!call.ok ? 'border-rose-900/60' : ''"
|
|
212
|
+
>
|
|
213
|
+
<button
|
|
214
|
+
class="flex w-full items-center gap-3 px-4 py-2.5 text-start transition hover:bg-slate-900/70"
|
|
215
|
+
@click="toggle(call)"
|
|
216
|
+
>
|
|
217
|
+
<UIcon
|
|
218
|
+
name="i-lucide-chevron-right"
|
|
219
|
+
class="h-4 w-4 shrink-0 text-slate-500 transition-transform"
|
|
220
|
+
:class="expanded[call.id] ? 'rotate-90' : ''"
|
|
221
|
+
/>
|
|
222
|
+
<UIcon
|
|
223
|
+
:name="agentMeta(call.agentKind).icon"
|
|
224
|
+
class="h-4 w-4 shrink-0"
|
|
225
|
+
:style="{ color: agentMeta(call.agentKind).color }"
|
|
226
|
+
:title="agentMeta(call.agentKind).label"
|
|
227
|
+
/>
|
|
228
|
+
<span class="font-mono text-[13px] text-slate-200">{{ call.tool }}</span>
|
|
229
|
+
<div class="ms-auto flex items-center gap-2.5 text-[11px] tabular-nums text-slate-400">
|
|
230
|
+
<span :title="t('observability.toolCalls.durationHint')">
|
|
231
|
+
{{ formatMs(Math.max(0, call.endedAt - call.startedAt)) }}
|
|
232
|
+
</span>
|
|
233
|
+
<UBadge v-if="!call.ok" color="error" variant="subtle" size="sm">
|
|
234
|
+
{{ t('observability.toolCalls.failed') }}
|
|
235
|
+
</UBadge>
|
|
236
|
+
<span class="hidden text-slate-600 md:inline">{{ clock(call.startedAt) }}</span>
|
|
237
|
+
</div>
|
|
238
|
+
</button>
|
|
239
|
+
|
|
240
|
+
<div v-if="expanded[call.id]" class="border-t border-slate-800 px-4 py-3 space-y-3">
|
|
241
|
+
<div class="flex flex-wrap gap-x-5 gap-y-1 text-[11px] text-slate-500">
|
|
242
|
+
<span>{{ t('observability.toolCalls.dispatch', { jobId: call.jobId }) }}</span>
|
|
243
|
+
<span>{{ t('observability.toolCalls.seq', { seq: call.seq }) }}</span>
|
|
244
|
+
</div>
|
|
245
|
+
<!-- `withheld` is not an empty body: nothing was captured, so an empty `args` here
|
|
246
|
+
must not read as a tool that took none. -->
|
|
247
|
+
<p v-if="call.bodies !== 'stored'" class="text-[12px] italic text-slate-500">
|
|
248
|
+
{{ t('observability.toolCalls.bodiesWithheld') }}
|
|
249
|
+
</p>
|
|
250
|
+
<template v-else>
|
|
251
|
+
<div>
|
|
252
|
+
<div
|
|
253
|
+
class="mb-1 flex items-center gap-2 text-[11px] uppercase tracking-wide text-slate-500"
|
|
254
|
+
>
|
|
255
|
+
<span>{{ t('observability.toolCalls.arguments') }}</span>
|
|
256
|
+
<span
|
|
257
|
+
v-if="call.argsDropped > 0"
|
|
258
|
+
class="normal-case tracking-normal text-slate-600"
|
|
259
|
+
>
|
|
260
|
+
{{ t('observability.toolCalls.dropped', { chars: call.argsDropped }) }}
|
|
261
|
+
</span>
|
|
262
|
+
</div>
|
|
263
|
+
<pre
|
|
264
|
+
class="max-h-60 overflow-auto rounded-lg bg-slate-950/70 p-3 text-[11px] leading-relaxed text-slate-300"
|
|
265
|
+
>{{ call.args ? prettyArgs(call.args) : '—' }}</pre>
|
|
266
|
+
</div>
|
|
267
|
+
<div>
|
|
268
|
+
<div
|
|
269
|
+
class="mb-1 flex items-center gap-2 text-[11px] uppercase tracking-wide text-slate-500"
|
|
270
|
+
>
|
|
271
|
+
<span>{{ t('observability.toolCalls.result') }}</span>
|
|
272
|
+
<span
|
|
273
|
+
v-if="call.resultDropped > 0"
|
|
274
|
+
class="normal-case tracking-normal text-slate-600"
|
|
275
|
+
>
|
|
276
|
+
{{ t('observability.toolCalls.dropped', { chars: call.resultDropped }) }}
|
|
277
|
+
</span>
|
|
278
|
+
</div>
|
|
279
|
+
<pre
|
|
280
|
+
class="max-h-60 overflow-auto rounded-lg bg-slate-950/70 p-3 text-[11px] leading-relaxed"
|
|
281
|
+
:class="call.ok ? 'text-slate-300' : 'text-rose-300'"
|
|
282
|
+
>{{ call.result || '—' }}</pre>
|
|
283
|
+
</div>
|
|
284
|
+
</template>
|
|
285
|
+
</div>
|
|
286
|
+
</li>
|
|
287
|
+
</ul>
|
|
288
|
+
</template>
|
|
289
|
+
</div>
|
|
290
|
+
</template>
|
|
@@ -86,6 +86,7 @@ function toggle(id: string) {
|
|
|
86
86
|
type="button"
|
|
87
87
|
class="flex w-full items-center gap-2.5 rounded-lg border border-slate-700 bg-slate-800/60 p-2 text-start transition hover:border-slate-500 hover:bg-slate-800"
|
|
88
88
|
:title="a.description"
|
|
89
|
+
:data-testid="`palette-agent-${a.kind}`"
|
|
89
90
|
@click="$emit('add', a.kind)"
|
|
90
91
|
>
|
|
91
92
|
<div
|
|
@@ -304,9 +304,13 @@ async function copyOutput() {
|
|
|
304
304
|
<template>
|
|
305
305
|
<Teleport to="body">
|
|
306
306
|
<Transition name="reader-fade">
|
|
307
|
+
<!-- `data-agent-kind` names WHICH step this window is scoped to, as the kind rather than as
|
|
308
|
+
the heading beside it: the heading is a translated display label, so it is the wrong
|
|
309
|
+
thing to read for anything that needs to know which agent's step is open. -->
|
|
307
310
|
<div
|
|
308
311
|
v-if="open && step && agent"
|
|
309
312
|
data-testid="step-detail"
|
|
313
|
+
:data-agent-kind="step?.agentKind"
|
|
310
314
|
class="fixed inset-0 z-50 flex max-h-[100dvh] bg-slate-950/96 backdrop-blur-sm"
|
|
311
315
|
role="dialog"
|
|
312
316
|
aria-modal="true"
|