Skip to content

Commit 5a7b984

Browse files
committed
Parse and prettify agent timeline messages
- Add parse-agent-message.ts utility to extract readable content from Python repr() strings - Update agent-timeline.tsx to use parsed messages for display - Add special terminal-style formatting for bash commands - Add TOOL_RESULT event type with green CheckCircle icon - Improve overall readability of agent execution traces in dashboard
1 parent 3fa5353 commit 5a7b984

File tree

2 files changed

+239
-9
lines changed

2 files changed

+239
-9
lines changed

dashboard/components/agent-timeline.tsx

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useState } from 'react'
44
import type { AgentEvent } from '@/lib/types'
5+
import { parseAgentMessage } from '@/lib/parse-agent-message'
56
import {
67
Brain,
78
Wrench,
@@ -12,7 +13,9 @@ import {
1213
ChevronRight,
1314
Code,
1415
FileEdit,
15-
Search
16+
Search,
17+
Terminal,
18+
CheckCircle
1619
} from 'lucide-react'
1720
import { formatDistanceToNow } from 'date-fns'
1821

@@ -33,14 +36,19 @@ export default function AgentTimeline({ events }: AgentTimelineProps) {
3336
setExpandedEvents(newExpanded)
3437
}
3538

36-
const getEventIcon = (type: string, tool?: string) => {
39+
const getEventIcon = (type: string, tool?: string, parsedType?: string) => {
40+
// For TOOL_RESULT events, show check icon
41+
if (type === 'TOOL_RESULT') {
42+
return <CheckCircle className="w-5 h-5 text-green-600 dark:text-green-400" />
43+
}
44+
3745
switch (type) {
3846
case 'REASONING':
3947
return <Brain className="w-5 h-5 text-purple-600 dark:text-purple-400" />
4048
case 'TOOL_CALL':
4149
if (tool === 'Read') return <Search className="w-5 h-5 text-blue-600 dark:text-blue-400" />
4250
if (tool === 'Edit' || tool === 'Write') return <FileEdit className="w-5 h-5 text-green-600 dark:text-green-400" />
43-
if (tool === 'Bash') return <Code className="w-5 h-5 text-orange-600 dark:text-orange-400" />
51+
if (tool === 'Bash') return <Terminal className="w-5 h-5 text-orange-600 dark:text-orange-400" />
4452
return <Wrench className="w-5 h-5 text-blue-600 dark:text-blue-400" />
4553
case 'ACTION':
4654
return <Activity className="w-5 h-5 text-green-600 dark:text-green-400" />
@@ -59,6 +67,8 @@ export default function AgentTimeline({ events }: AgentTimelineProps) {
5967
return 'border-purple-200 dark:border-purple-800 bg-purple-50 dark:bg-purple-900/10'
6068
case 'TOOL_CALL':
6169
return 'border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-900/10'
70+
case 'TOOL_RESULT':
71+
return 'border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/10'
6272
case 'ACTION':
6373
return 'border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/10'
6474
case 'ERROR':
@@ -96,6 +106,10 @@ export default function AgentTimeline({ events }: AgentTimelineProps) {
96106
const isExpanded = expandedEvents.has(event.seq)
97107
const showDetails = hasDetails(event)
98108

109+
// Parse the content to make it more readable
110+
const parsed = parseAgentMessage(event.content)
111+
const displayContent = parsed.content
112+
99113
return (
100114
<div
101115
key={event.seq}
@@ -109,7 +123,7 @@ export default function AgentTimeline({ events }: AgentTimelineProps) {
109123
<div className="flex items-start space-x-3">
110124
{/* Icon */}
111125
<div className="flex-shrink-0 mt-0.5">
112-
{getEventIcon(event.type, event.tool)}
126+
{getEventIcon(event.type, event.tool || parsed.metadata?.tool, parsed.type)}
113127
</div>
114128

115129
{/* Content */}
@@ -120,18 +134,28 @@ export default function AgentTimeline({ events }: AgentTimelineProps) {
120134
<span className="text-sm font-medium text-gray-900 dark:text-white">
121135
{event.type.replace('_', ' ')}
122136
</span>
123-
{event.tool && (
137+
{(event.tool || parsed.metadata?.tool) && (
124138
<span className="text-xs font-mono px-2 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300">
125-
{event.tool}
139+
{event.tool || parsed.metadata?.tool}
126140
</span>
127141
)}
128142
<span className="text-xs text-gray-500 dark:text-gray-400">
129143
#{event.seq}
130144
</span>
131145
</div>
132-
<p className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap break-words">
133-
{event.content}
134-
</p>
146+
147+
{/* Display parsed content */}
148+
{parsed.type === 'tool_use' && parsed.metadata?.parameters?.command ? (
149+
<div className="bg-gray-900 dark:bg-black rounded p-3 overflow-x-auto">
150+
<code className="text-xs text-green-400 font-mono">
151+
{displayContent}
152+
</code>
153+
</div>
154+
) : (
155+
<p className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap break-words">
156+
{displayContent}
157+
</p>
158+
)}
135159
</div>
136160

137161
{/* Expand button */}
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
/**
2+
* Parse agent SDK message string representations to extract readable content
3+
*/
4+
5+
export interface ParsedMessage {
6+
type: 'text' | 'tool_use' | 'tool_result' | 'system'
7+
content: string
8+
metadata?: {
9+
tool?: string
10+
toolUseId?: string
11+
isError?: boolean
12+
[key: string]: any
13+
}
14+
}
15+
16+
/**
17+
* Extract content from TextBlock string
18+
* Example: TextBlock(text="Some text here")
19+
*/
20+
function parseTextBlock(input: string): ParsedMessage | null {
21+
const match = input.match(/TextBlock\(text=["'](.*)["']\)$/s)
22+
if (match) {
23+
return {
24+
type: 'text',
25+
content: match[1]
26+
.replace(/\\n/g, '\n')
27+
.replace(/\\'/g, "'")
28+
.replace(/\\"/g, '"')
29+
}
30+
}
31+
return null
32+
}
33+
34+
/**
35+
* Extract content from ToolUseBlock string
36+
* Example: ToolUseBlock(id='toolu_123', name='Bash', input={'command': 'ls -la'})
37+
*/
38+
function parseToolUseBlock(input: string): ParsedMessage | null {
39+
// Extract tool name
40+
const nameMatch = input.match(/name=['"](\w+)['"]/)
41+
const tool = nameMatch ? nameMatch[1] : 'Unknown'
42+
43+
// Extract input parameters
44+
const inputMatch = input.match(/input=(\{.*\})/)
45+
let content = `Tool: ${tool}`
46+
let metadata: any = { tool }
47+
48+
if (inputMatch) {
49+
try {
50+
// Try to parse the input as JSON-like structure
51+
const inputStr = inputMatch[1]
52+
.replace(/'/g, '"')
53+
.replace(/True/g, 'true')
54+
.replace(/False/g, 'false')
55+
.replace(/None/g, 'null')
56+
57+
const params = JSON.parse(inputStr)
58+
metadata.parameters = params
59+
60+
// Format common parameters nicely
61+
if (params.command) {
62+
content = `$ ${params.command}`
63+
} else if (params.file_path) {
64+
content = `Read: ${params.file_path}`
65+
} else if (params.old_string && params.new_string) {
66+
content = `Edit file: ${params.file_path || 'unknown'}`
67+
} else {
68+
content = `${tool}: ${JSON.stringify(params, null, 2)}`
69+
}
70+
} catch (e) {
71+
// If parsing fails, just show the tool name
72+
content = `Tool: ${tool}`
73+
}
74+
}
75+
76+
return {
77+
type: 'tool_use',
78+
content,
79+
metadata
80+
}
81+
}
82+
83+
/**
84+
* Extract content from ToolResultBlock string
85+
* Example: ToolResultBlock(tool_use_id='toolu_123', content='Result here', is_error=False)
86+
*/
87+
function parseToolResultBlock(input: string): ParsedMessage | null {
88+
// Extract content
89+
const contentMatch = input.match(/content=['"](.*)['"],?\s*is_error/)
90+
if (!contentMatch) {
91+
// Try simpler match without is_error
92+
const simpleMatch = input.match(/content=['"](.*)['"]/)
93+
if (simpleMatch) {
94+
return {
95+
type: 'tool_result',
96+
content: simpleMatch[1]
97+
.replace(/\\n/g, '\n')
98+
.replace(/\\'/g, "'")
99+
.replace(/\\"/g, '"')
100+
.substring(0, 500) // Limit length for display
101+
}
102+
}
103+
return null
104+
}
105+
106+
// Check if it's an error
107+
const isErrorMatch = input.match(/is_error=(True|False)/)
108+
const isError = isErrorMatch ? isErrorMatch[1] === 'True' : false
109+
110+
let content = contentMatch[1]
111+
.replace(/\\n/g, '\n')
112+
.replace(/\\'/g, "'")
113+
.replace(/\\"/g, '"')
114+
115+
// Truncate very long results
116+
if (content.length > 1000) {
117+
content = content.substring(0, 1000) + '\n... (truncated)'
118+
}
119+
120+
return {
121+
type: 'tool_result',
122+
content,
123+
metadata: { isError }
124+
}
125+
}
126+
127+
/**
128+
* Extract content from SystemMessage string
129+
*/
130+
function parseSystemMessage(input: string): ParsedMessage | null {
131+
const match = input.match(/SystemMessage\(subtype=['"](\w+)['"]/)
132+
if (match) {
133+
return {
134+
type: 'system',
135+
content: `System: ${match[1]}`,
136+
metadata: { subtype: match[1] }
137+
}
138+
}
139+
return null
140+
}
141+
142+
/**
143+
* Extract content from ResultMessage string
144+
*/
145+
function parseResultMessage(input: string): ParsedMessage | null {
146+
const subtypeMatch = input.match(/subtype=['"](\w+)['"]/)
147+
const durationMatch = input.match(/duration_ms=(\d+)/)
148+
const turnsMatch = input.match(/num_turns=(\d+)/)
149+
150+
if (subtypeMatch) {
151+
const parts = []
152+
parts.push(`Result: ${subtypeMatch[1]}`)
153+
154+
if (durationMatch) {
155+
const seconds = (parseInt(durationMatch[1]) / 1000).toFixed(1)
156+
parts.push(`Duration: ${seconds}s`)
157+
}
158+
159+
if (turnsMatch) {
160+
parts.push(`Turns: ${turnsMatch[1]}`)
161+
}
162+
163+
return {
164+
type: 'system',
165+
content: parts.join(' • '),
166+
metadata: {
167+
subtype: subtypeMatch[1],
168+
duration: durationMatch ? parseInt(durationMatch[1]) : undefined,
169+
turns: turnsMatch ? parseInt(turnsMatch[1]) : undefined
170+
}
171+
}
172+
}
173+
return null
174+
}
175+
176+
/**
177+
* Main parser function - tries different parsers and returns parsed content
178+
*/
179+
export function parseAgentMessage(input: string): ParsedMessage {
180+
// Try different parsers
181+
const parsers = [
182+
parseTextBlock,
183+
parseToolUseBlock,
184+
parseToolResultBlock,
185+
parseSystemMessage,
186+
parseResultMessage
187+
]
188+
189+
for (const parser of parsers) {
190+
const result = parser(input)
191+
if (result) {
192+
return result
193+
}
194+
}
195+
196+
// Fallback: return input as-is but truncate if too long
197+
let content = input
198+
if (content.length > 500) {
199+
content = content.substring(0, 500) + '... (truncated)'
200+
}
201+
202+
return {
203+
type: 'text',
204+
content
205+
}
206+
}

0 commit comments

Comments
 (0)