Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ cmd/stresstest-cf-index
!frontend/scripts/**
!frontend/src/
!frontend/src/**
# `!frontend/` above re-includes the whole directory, which otherwise drags the
# developer's node_modules (and their locally built dist) into the context:
# ~900MB on every build, and — worse than slow — `COPY frontend/ ./` lands on
# top of the `npm ci` the frontend-builder stage just ran, so the image would
# build against whatever the host happened to have installed. The stage's own
# node_modules and dist are the only ones that should exist in the image.
frontend/node_modules
frontend/dist
!scripts/
!scripts/demo/
!scripts/demo/generate-demo.js
Expand Down
13 changes: 13 additions & 0 deletions api/openapi-v2.json
Original file line number Diff line number Diff line change
Expand Up @@ -2238,6 +2238,10 @@
"nullable": true,
"type": "number"
},
"model": {
"description": "The model value.",
"type": "string"
},
"prompt_tokens": {
"description": "The prompt tokens value.",
"type": "integer"
Expand Down Expand Up @@ -19227,6 +19231,11 @@
"description": "The agent owner name value.",
"type": "string"
},
"agent_run_id": {
"description": "The agent run id value.",
"nullable": true,
"type": "integer"
},
"changed_at": {
"description": "The changed at value.",
"format": "date-time",
Expand Down Expand Up @@ -19281,6 +19290,10 @@
"nullable": true,
"type": "string"
},
"source": {
"description": "The source value.",
"type": "string"
},
"user_email": {
"description": "The user email value.",
"type": "string"
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/lib/features/chat/ChatPanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,8 @@
stopReason={msg.stopReason}
needsReview={msg.needsReview}
reviewReasons={msg.reviewReasons}
model={msg.model}
usage={msg.usage}
/>
{/if}
</div>
Expand Down
131 changes: 109 additions & 22 deletions frontend/src/lib/features/chat/ChatToolTrace.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,46 @@
* 2. The agent hitting the iteration ceiling (stopReason === 'max_iterations').
* 3. Empty tool_calls when the agent went straight to a text reply.
*/
import { IconChevronRight, IconAlertTriangle, IconCircleCheck, IconCircleX } from '@tabler/icons-svelte-runes';
import { IconChevronRight, IconAlertTriangle, IconCircleCheck, IconCircleX, IconReceipt } from '@tabler/icons-svelte-runes';
import { formatCostUSD, hasMeteredUsage } from '../../utils/llmUsage.js';

/** @type {{ toolCalls?: any[], iterations?: number, maxIterations?: number, stopReason?: string, needsReview?: boolean, reviewReasons?: string[] }} */
/**
* @type {{ toolCalls?: any[], iterations?: number, maxIterations?: number, stopReason?: string, needsReview?: boolean, reviewReasons?: string[], model?: string, usage?: { prompt_tokens: number, completion_tokens: number, total_tokens: number, cost_usd: number|null, calls: number }|null }}
*/
let {
toolCalls = [],
iterations = 0,
maxIterations = 0,
stopReason = '',
needsReview = false,
reviewReasons = [],
model = '',
usage = null,
} = $props();

let expanded = $state(false);
let expandedIdx = $state(/** @type {number | null} */ (null));
// The token/cost reading is behind a click rather than inline: it is
// reference detail most turns never need, and keeping it collapsed stops the
// footer from competing with the answer for attention.
let showMeter = $state(false);

const hitLimit = $derived(stopReason === 'max_iterations');
// Nothing to report when metering never ran for the turn, or when the
// fallback client served it and there is no model to name.
const hasMeter = $derived(hasMeteredUsage(usage));
const meterLabel = $derived.by(() => {
if (!hasMeter) return '';
const parts = [];
if (model) parts.push(model);
parts.push(`${usage.total_tokens.toLocaleString()} tokens`);
parts.push(
`(${usage.prompt_tokens.toLocaleString()} in · ${usage.completion_tokens.toLocaleString()} out)`
);
const cost = formatCostUSD(usage.cost_usd);
parts.push(cost || 'cost unknown');
return parts.join(' · ');
});

function toolStatus(tc) {
// Soft-error convention: tool returned a JSON body with an "error"
Expand Down Expand Up @@ -71,27 +95,52 @@
</div>
{/if}

{#if toolCalls.length > 0 || hitLimit}
{#if toolCalls.length > 0 || hitLimit || hasMeter}
<div class="trace">
<button
class="summary"
class:warn={hitLimit}
onclick={() => (expanded = !expanded)}
aria-expanded={expanded}
type="button"
>
<IconChevronRight
size={12}
stroke={1.5}
class={expanded ? 'chev open' : 'chev'}
/>
{#if hitLimit}
<IconAlertTriangle size={12} stroke={1.5} class="icon-warn" />
<span>Ran out of steps after {iterations}/{maxIterations} — {toolCalls.length} tool call{toolCalls.length === 1 ? '' : 's'}</span>
{:else}
<span>{toolCalls.length} tool call{toolCalls.length === 1 ? '' : 's'} · {iterations}/{maxIterations} steps</span>
{/if}
</button>
{#if toolCalls.length > 0 || hitLimit}
<button
class="summary"
class:warn={hitLimit}
onclick={() => (expanded = !expanded)}
aria-expanded={expanded}
type="button"
>
<IconChevronRight
size={12}
stroke={1.5}
class={expanded ? 'chev open' : 'chev'}
/>
{#if hitLimit}
<IconAlertTriangle size={12} stroke={1.5} class="icon-warn" />
<span>Ran out of steps after {iterations}/{maxIterations} — {toolCalls.length} tool call{toolCalls.length === 1 ? '' : 's'}</span>
{:else}
<span>{toolCalls.length} tool call{toolCalls.length === 1 ? '' : 's'} · {iterations}/{maxIterations} steps</span>
{/if}
</button>
{/if}

{#if hasMeter}
<!-- No aria-label: the accessible name comes from the content, so the
expanded reading is announced rather than overridden by a label
that says only "usage". The collapsed icon gets its name from the
visually-hidden span instead. -->
<button
class="meter"
class:on={showMeter}
onclick={() => (showMeter = !showMeter)}
aria-expanded={showMeter}
title="Model and token usage"
data-testid="chat-usage-toggle"
type="button"
>
<IconReceipt size={12} stroke={1.5} />
{#if showMeter}
<span class="meter-detail" data-testid="chat-usage-detail">{meterLabel}</span>
{:else}
<span class="sr-only">Model and token usage</span>
{/if}
</button>
{/if}

{#if expanded}
<ol class="calls">
Expand Down Expand Up @@ -156,6 +205,44 @@
margin-top: 6px;
font-size: 11px;
color: var(--ds-text-subtle);
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
}
.meter {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
border-radius: 4px;
background: var(--ds-surface);
border: 1px solid var(--ds-border);
color: var(--ds-text-subtle);
cursor: pointer;
font: inherit;
}
.meter:hover {
background: var(--ds-background-neutral-hovered);
}
.meter.on {
/* Expanded: the reading is the button's content, so it reads as one
control rather than a label paired with loose text. */
align-items: baseline;
}
.meter-detail {
color: var(--ds-text);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.summary {
display: inline-flex;
Expand Down
13 changes: 4 additions & 9 deletions frontend/src/lib/features/items/ItemAgentLog.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import Lozenge from '../../components/Lozenge.svelte';
import EmptyState from '../../components/EmptyState.svelte';
import { formatAuthenticatedDateTime as formatDateTimeLocale } from '../../utils/authenticatedDateFormatter.js';
import { formatCostUSD, hasMeteredUsage } from '../../utils/llmUsage.js';
import { t } from '../../stores/i18n.svelte.js';
import { workspacePermissions } from '../../stores';
import { confirm } from '../../composables/useConfirm.js';
Expand Down Expand Up @@ -89,12 +90,6 @@
}
}

function formatCost(usd) {
if (usd == null) return null;
if (usd > 0 && usd < 0.01) return '<$0.01';
return `$${usd.toFixed(usd < 1 ? 4 : 2)}`;
}

function statusAppearance(status) {
switch (status) {
case 'succeeded': return 'success';
Expand Down Expand Up @@ -397,12 +392,12 @@
{selectedRun.error}
</div>
{/if}
{#if usage && usage.calls > 0}
{#if hasMeteredUsage(usage)}
<div data-testid="agent-run-usage" class="flex items-center flex-wrap gap-x-3 gap-y-1 text-xs" style="color: var(--ds-text-subtle);">
<span>{usage.total_tokens.toLocaleString()} tokens</span>
<span>({usage.prompt_tokens.toLocaleString()} in · {usage.completion_tokens.toLocaleString()} out)</span>
{#if formatCost(usage.cost_usd)}
<span style="color: var(--ds-text);">{formatCost(usage.cost_usd)}</span>
{#if formatCostUSD(usage.cost_usd)}
<span style="color: var(--ds-text);">{formatCostUSD(usage.cost_usd)}</span>
{:else}
<span>cost unknown</span>
{/if}
Expand Down
Loading
Loading