diff --git a/frontend/src/lib/components/AgentActions.svelte b/frontend/src/lib/components/AgentActions.svelte new file mode 100644 index 0000000000..3ff1be8ea2 --- /dev/null +++ b/frontend/src/lib/components/AgentActions.svelte @@ -0,0 +1,134 @@ + + +
+ {#each entries as entry, index (index)} + {#if entry.kind === 'assistant'} +
+ + {#if entry.sources} +
+ +
+ {/if} +
+ {:else if entry.kind === 'search'} + toggle(index, entry)} + > + {#if entry.sources} + + {:else} + + {/if} + + {:else} + {@const job = jobOf(entry.jobId)} + toggle(index, entry)} + contentClass="space-y-3" + > + {#if entry.args} + + {/if} + {#if job?.logs} + + {/if} + + {#if entry.resourcePath} +
+ + {entry.resourcePath} +
+ {:else if entry.jobId} + + + Open job + + {/if} +
+ {/if} + {/each} +
diff --git a/frontend/src/lib/components/AgentResultDisplay.svelte b/frontend/src/lib/components/AgentResultDisplay.svelte index dab8865f16..80df9d530a 100644 --- a/frontend/src/lib/components/AgentResultDisplay.svelte +++ b/frontend/src/lib/components/AgentResultDisplay.svelte @@ -2,13 +2,13 @@ import type { Snippet } from 'svelte' import { Badge } from '$lib/components/common' import GfmMarkdown from './GfmMarkdown.svelte' - import AgentTranscript from './AgentTranscript.svelte' + import AgentActions from './AgentActions.svelte' import { formatTokenCount, summarizeAgentResult, type AgentResult } from './aiAgentResult' interface Props { result: AgentResult - /** Answer or the conversation behind it; JSON is the viewer's own toggle. */ - view: 'answer' | 'transcript' + /** The answer, or what the agent did to get there; JSON is the viewer's own toggle. */ + view: 'answer' | 'actions' workspaceId?: string /** * How to render an answer that is not text. An `output_schema` makes `output` @@ -27,8 +27,8 @@
- {#if view === 'transcript'} - + {#if view === 'actions'} + {:else if textOutput !== undefined} {#if textOutput === ''} The agent returned no answer diff --git a/frontend/src/lib/components/AgentTranscript.svelte b/frontend/src/lib/components/AgentTranscript.svelte deleted file mode 100644 index cc9ac194c8..0000000000 --- a/frontend/src/lib/components/AgentTranscript.svelte +++ /dev/null @@ -1,164 +0,0 @@ - - -
- {#each entries as entry, index (index)} - {#if entry.kind === 'user'} - -
-
- {entry.content} -
-
- {:else if entry.kind === 'assistant'} -
- - {#if entry.sources} -
- -
- {/if} -
- {:else if entry.kind === 'search'} -
- toggle(index, entry)} - > - {#if entry.sources} - - {:else} - - {/if} - -
- {:else} - {@const job = jobOf(entry.jobId)} -
- toggle(index, entry)} - contentClass="space-y-3" - > - {#if entry.args} - - {/if} - {#if job?.logs} - - {/if} - - {#if entry.resourcePath} -
- - {entry.resourcePath} -
- {:else if entry.jobId} - - - Open job - - {/if} -
-
- {/if} - {/each} - {#if systemPrompt} -
- toggle(SYSTEM_PROMPT_KEY, systemPrompt)} - > -
{systemPrompt.content}
-
-
- {/if} -
diff --git a/frontend/src/lib/components/DisplayResult.svelte b/frontend/src/lib/components/DisplayResult.svelte index 385d7f14b5..ed1bb9664d 100644 --- a/frontend/src/lib/components/DisplayResult.svelte +++ b/frontend/src/lib/components/DisplayResult.svelte @@ -20,7 +20,7 @@ Highlighter, ArrowDownFromLine, Bot, - MessagesSquare, + ListTree, Database, Loader2 } from 'lucide-svelte' @@ -58,7 +58,7 @@ import type { MarkupTrust } from './apps/markupTrust' import AgentResultDisplay from './AgentResultDisplay.svelte' import AgentStreamDisplay from './AgentStreamDisplay.svelte' - import AgentTranscript from './AgentTranscript.svelte' + import AgentActions from './AgentActions.svelte' import { isAgentStream, parseAgentErrorMessages, parseAgentResult } from './aiAgentResult' const TABLE_MAX_SIZE = 5000000 @@ -165,7 +165,7 @@ }: Props = $props() let s3FileDisplayRawMode = $state(false) /** Which half of an agent result is showing; JSON is `forceJson`, as for any kind. */ - let agentView: 'answer' | 'transcript' = $state('answer') + let agentView: 'answer' | 'actions' = $state('answer') /** The partial conversation a max-iterations failure carries, if this is one. */ let agentErrorMessages = $derived(parseAgentErrorMessages(result)) @@ -308,7 +308,7 @@ } // Classified before the size caps below: an agent's answer stays small - // however long its conversation grows, so a run with a big transcript + // however long its conversation grows, so a run with many actions // must not fall back to the JSON tree that hides the answer inside it. // `largeObject` is still set honestly, so switching to JSON gets the // same too-big handling as any other oversized result. @@ -818,15 +818,15 @@ {#if !hideAsJson && !['json', 's3object'].includes(resultKind ?? '') && typeof result === 'object'} { forceJson = ev.detail === 'json' - if (ev.detail === 'transcript' || ev.detail === 'pretty') { - agentView = ev.detail === 'transcript' ? 'transcript' : 'answer' + if (ev.detail === 'actions' || ev.detail === 'pretty') { + agentView = ev.detail === 'actions' ? 'actions' : 'answer' } }} > @@ -835,13 +835,7 @@ {:else if resultKind === 'aiagent'} - + {:else} {/if} @@ -1024,12 +1018,12 @@
{#if agentErrorMessages} + returned. What it managed to do rides inside that error and is the + whole reason to look at such a run, so it is added under the error + rather than replacing it. -->
- Transcript - + Actions +
{/if} {#if !isTest && language === 'bun'} diff --git a/frontend/src/lib/components/agentTranscript.test.ts b/frontend/src/lib/components/agentActions.test.ts similarity index 75% rename from frontend/src/lib/components/agentTranscript.test.ts rename to frontend/src/lib/components/agentActions.test.ts index 6a5d701963..66eed1d91d 100644 --- a/frontend/src/lib/components/agentTranscript.test.ts +++ b/frontend/src/lib/components/agentActions.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { buildTranscript } from './agentTranscript' +import { buildAgentActions } from './agentActions' import type { AgentMessage } from './aiAgentResult' // The worker splits one tool call across two messages: the assistant message @@ -34,11 +34,9 @@ const messages: AgentMessage[] = [ { role: 'assistant', content: 'eu-central-1 is down.', agent_action: { type: 'message' } } ] -describe('buildTranscript', () => { +describe('buildAgentActions', () => { it('joins a tool call to the arguments on the message that requested it', () => { - expect(buildTranscript(messages)).toEqual([ - { kind: 'system', content: 'You are an SRE assistant.' }, - { kind: 'user', content: 'Which region is broken?' }, + expect(buildAgentActions(messages)).toEqual([ { kind: 'tool', name: 'query_metrics', @@ -51,7 +49,7 @@ describe('buildTranscript', () => { }) it('keeps an MCP call, whose arguments live on the action itself', () => { - const entries = buildTranscript([ + const entries = buildAgentActions([ { role: 'tool', content: 'sunny', @@ -76,7 +74,7 @@ describe('buildTranscript', () => { }) it('carries web search citations onto the entry', () => { - const entries = buildTranscript([ + const entries = buildAgentActions([ { role: 'assistant', content: 'Postgres 17 changed the default.', @@ -93,13 +91,17 @@ describe('buildTranscript', () => { ]) }) - // Memory replays messages back without their tags, and an assistant message - // that only asked for a tool has no text of its own. - it('drops messages with nothing to show', () => { + // The prompt and the question are the step's inputs, shown as inputs. A replayed + // turn comes back from memory without its tag, and crediting this run with an + // answer a previous one gave would be a lie about what happened. + it('keeps only what this run did', () => { expect( - buildTranscript([ + buildAgentActions([ + { role: 'system', content: 'You are an SRE assistant.' }, + { role: 'user', content: 'Which region is broken?' }, + { role: 'assistant', content: 'Answered in an earlier turn, replayed from memory.' }, { role: 'assistant', tool_calls: [{ id: 'c1', function: { name: 'x', arguments: '{}' } }] }, - { role: 'assistant', content: '' } + { role: 'assistant', content: '', agent_action: { type: 'message' } } ]) ).toEqual([]) }) diff --git a/frontend/src/lib/components/agentTranscript.ts b/frontend/src/lib/components/agentActions.ts similarity index 71% rename from frontend/src/lib/components/agentTranscript.ts rename to frontend/src/lib/components/agentActions.ts index 7ccb570b68..418db8520f 100644 --- a/frontend/src/lib/components/agentTranscript.ts +++ b/frontend/src/lib/components/agentActions.ts @@ -2,15 +2,17 @@ import type { WebSearchSource } from './copilot/chat/shared' import type { AgentMessage } from './aiAgentResult' /** - * One readable turn of an agent run. Built from the envelope alone: the tool - * arguments come from the assistant message that asked for the call, and the - * result from the `tool` message that answered it, so a transcript renders - * without waiting on any request. A tool's child job is enrichment (logs, - * duration, whether it succeeded), not what makes the row. + * One thing an agent did. Built from the envelope alone: the tool arguments come + * from the assistant message that asked for the call, and the result from the + * `tool` message that answered it, so the list renders without waiting on any + * request. A tool's child job is enrichment (logs, duration, whether it + * succeeded), not what makes the row. + * + * The prompt and the user's question are deliberately absent. They are inputs to + * the step, shown as inputs, and a run is not a conversation the viewer is part + * of — it is a record of what the agent did with them. */ -export type TranscriptEntry = - | { kind: 'system'; content: string } - | { kind: 'user'; content: string } +export type AgentActionEntry = | { kind: 'assistant'; content: string; sources?: WebSearchSource[] } | { kind: 'search'; content: string; sources?: WebSearchSource[] } | { @@ -52,7 +54,7 @@ function sourcesOf(message: AgentMessage): WebSearchSource[] | undefined { return sources.length > 0 ? sources : undefined } -export function buildTranscript(messages: AgentMessage[]): TranscriptEntry[] { +export function buildAgentActions(messages: AgentMessage[]): AgentActionEntry[] { // The arguments live on the assistant message that requested the call, while // the action tag and the result live on the `tool` message answering it, so // the two are joined by `tool_call_id`. @@ -65,7 +67,7 @@ export function buildTranscript(messages: AgentMessage[]): TranscriptEntry[] { } } - const entries: TranscriptEntry[] = [] + const entries: AgentActionEntry[] = [] for (const message of messages) { const action = message.agent_action if (action?.type === 'tool_call') { @@ -98,23 +100,19 @@ export function buildTranscript(messages: AgentMessage[]): TranscriptEntry[] { }) continue } - // Messages with no action are the conversation itself: the prompt, what the - // user asked, and anything loaded back from memory. - const content = contentText(message.content) - if (content === '') { + // Every message this run produced is tagged, including the agent narrating + // its next move and its final answer. Untagged ones are the prompt, the + // question, or a previous turn replayed out of memory — history loses its + // tags on the way back, and attributing it to this run would credit it with + // answers it never gave. + if (action?.type !== 'message') { continue } - if (message.role === 'system') { - entries.push({ kind: 'system', content }) - } else if (message.role === 'user') { - entries.push({ kind: 'user', content }) - } else if (message.role === 'assistant') { + const content = contentText(message.content) + if (message.role === 'assistant' && content !== '') { entries.push({ kind: 'assistant', content, sources: sourcesOf(message) }) } } return entries } -export function transcriptJobIds(entries: TranscriptEntry[]): string[] { - return entries.flatMap((entry) => (entry.kind === 'tool' && entry.jobId ? [entry.jobId] : [])) -}