mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
feat(cli): add agent logs command
Add `paseo agent logs <id>` command to view agent activity/timeline. Features: - View agent timeline entries (messages, tool calls, errors) - `-f, --follow` flag for real-time streaming - `--tail <n>` to limit entries shown - Proper formatting of different timeline item types
This commit is contained in:
@@ -3,6 +3,7 @@ import { runPsCommand } from './ps.js'
|
||||
import { runRunCommand } from './run.js'
|
||||
import { runSendCommand } from './send.js'
|
||||
import { runStopCommand } from './stop.js'
|
||||
import { runLogsCommand } from './logs.js'
|
||||
import { runModeCommand } from './mode.js'
|
||||
import { runInspectCommand } from './inspect.js'
|
||||
import { withOutput } from '../../output/index.js'
|
||||
@@ -58,6 +59,15 @@ export function createAgentCommand(): Command {
|
||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||
.action(withOutput(runModeCommand))
|
||||
|
||||
agent
|
||||
.command('logs')
|
||||
.description('View agent activity/timeline')
|
||||
.argument('<id>', 'Agent ID (or prefix)')
|
||||
.option('-f, --follow', 'Follow log output (streaming)')
|
||||
.option('--tail <n>', 'Show last n entries')
|
||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||
.action(withOutput(runLogsCommand))
|
||||
|
||||
agent
|
||||
.command('inspect')
|
||||
.description('Show detailed information about an agent')
|
||||
|
||||
398
packages/cli/src/commands/agent/logs.ts
Normal file
398
packages/cli/src/commands/agent/logs.ts
Normal file
@@ -0,0 +1,398 @@
|
||||
import type { Command } from 'commander'
|
||||
import { connectToDaemon, getDaemonHost, resolveAgentId } from '../../utils/client.js'
|
||||
import type { CommandOptions, ListResult, OutputSchema, CommandError } from '../../output/index.js'
|
||||
import type { DaemonClientV2 } from '@paseo/server'
|
||||
|
||||
/** Message type for agent_stream_snapshot */
|
||||
interface AgentStreamSnapshotMessage {
|
||||
type: 'agent_stream_snapshot'
|
||||
payload: {
|
||||
agentId: string
|
||||
events: Array<{ event: { type: string; item?: unknown }; timestamp: string }>
|
||||
}
|
||||
}
|
||||
|
||||
/** Message type for agent_stream */
|
||||
interface AgentStreamMessage {
|
||||
type: 'agent_stream'
|
||||
payload: {
|
||||
agentId: string
|
||||
event: { type: string; item?: unknown }
|
||||
timestamp: string
|
||||
}
|
||||
}
|
||||
|
||||
/** Timeline item for display */
|
||||
export interface LogEntry {
|
||||
timestamp: string
|
||||
type: string
|
||||
summary: string
|
||||
}
|
||||
|
||||
/** Schema for logs output */
|
||||
export const logsSchema: OutputSchema<LogEntry> = {
|
||||
idField: 'timestamp',
|
||||
columns: [
|
||||
{ header: 'TIME', field: 'timestamp', width: 12 },
|
||||
{ header: 'TYPE', field: 'type', width: 15 },
|
||||
{ header: 'SUMMARY', field: 'summary', width: 60 },
|
||||
],
|
||||
}
|
||||
|
||||
export interface AgentLogsOptions extends CommandOptions {
|
||||
follow?: boolean
|
||||
tail?: string
|
||||
}
|
||||
|
||||
export type AgentLogsResult = ListResult<LogEntry>
|
||||
|
||||
/** Format a timeline item into a log entry */
|
||||
function formatTimelineItem(item: {
|
||||
type: string
|
||||
text?: string
|
||||
name?: string
|
||||
input?: unknown
|
||||
status?: string
|
||||
items?: { text: string; completed: boolean }[]
|
||||
message?: string
|
||||
}): LogEntry {
|
||||
const now = new Date().toISOString().slice(11, 19) // HH:MM:SS
|
||||
|
||||
switch (item.type) {
|
||||
case 'user_message':
|
||||
return {
|
||||
timestamp: now,
|
||||
type: 'user',
|
||||
summary: truncate(item.text ?? '', 60),
|
||||
}
|
||||
case 'assistant_message':
|
||||
return {
|
||||
timestamp: now,
|
||||
type: 'assistant',
|
||||
summary: truncate(item.text ?? '', 60),
|
||||
}
|
||||
case 'reasoning':
|
||||
return {
|
||||
timestamp: now,
|
||||
type: 'reasoning',
|
||||
summary: truncate(item.text ?? '', 60),
|
||||
}
|
||||
case 'tool_call': {
|
||||
const toolName = item.name ?? 'unknown'
|
||||
const status = item.status ?? ''
|
||||
let inputSummary = ''
|
||||
if (item.input && typeof item.input === 'object') {
|
||||
const inp = item.input as Record<string, unknown>
|
||||
// Common input fields for summarization
|
||||
if (inp.command) {
|
||||
inputSummary = truncate(String(inp.command), 40)
|
||||
} else if (inp.file_path) {
|
||||
inputSummary = truncate(String(inp.file_path), 40)
|
||||
} else if (inp.pattern) {
|
||||
inputSummary = truncate(String(inp.pattern), 40)
|
||||
}
|
||||
}
|
||||
return {
|
||||
timestamp: now,
|
||||
type: `tool:${toolName}`,
|
||||
summary: inputSummary ? `${status} ${inputSummary}`.trim() : status,
|
||||
}
|
||||
}
|
||||
case 'todo': {
|
||||
const items = item.items ?? []
|
||||
const completed = items.filter((i) => i.completed).length
|
||||
return {
|
||||
timestamp: now,
|
||||
type: 'todo',
|
||||
summary: `${completed}/${items.length} completed`,
|
||||
}
|
||||
}
|
||||
case 'error':
|
||||
return {
|
||||
timestamp: now,
|
||||
type: 'error',
|
||||
summary: truncate(item.message ?? '', 60),
|
||||
}
|
||||
default:
|
||||
return {
|
||||
timestamp: now,
|
||||
type: item.type,
|
||||
summary: '',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function truncate(str: string, maxLen: number): string {
|
||||
const cleaned = str.replace(/\n/g, ' ').trim()
|
||||
if (cleaned.length <= maxLen) return cleaned
|
||||
return cleaned.slice(0, maxLen - 3) + '...'
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract timeline items from an agent_stream_snapshot message
|
||||
*/
|
||||
function extractTimelineFromSnapshot(
|
||||
message: { type: string; payload: unknown }
|
||||
): Array<{ type: string; [key: string]: unknown }> {
|
||||
if (message.type !== 'agent_stream_snapshot') return []
|
||||
|
||||
const payload = message.payload as {
|
||||
agentId: string
|
||||
events: Array<{ event: { type: string; item?: unknown }; timestamp: string }>
|
||||
}
|
||||
|
||||
const items: Array<{ type: string; [key: string]: unknown }> = []
|
||||
for (const e of payload.events) {
|
||||
if (e.event.type === 'timeline' && e.event.item) {
|
||||
items.push(e.event.item as { type: string; [key: string]: unknown })
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a timeline item from an agent_stream message
|
||||
*/
|
||||
function extractTimelineFromStream(
|
||||
message: { type: string; payload: unknown }
|
||||
): { type: string; [key: string]: unknown } | null {
|
||||
if (message.type !== 'agent_stream') return null
|
||||
|
||||
const payload = message.payload as {
|
||||
agentId: string
|
||||
event: { type: string; item?: unknown }
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
if (payload.event.type === 'timeline' && payload.event.item) {
|
||||
return payload.event.item as { type: string; [key: string]: unknown }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function runLogsCommand(
|
||||
id: string,
|
||||
options: AgentLogsOptions,
|
||||
_command: Command
|
||||
): Promise<AgentLogsResult> {
|
||||
const host = getDaemonHost({ host: options.host as string | undefined })
|
||||
|
||||
if (!id) {
|
||||
const error: CommandError = {
|
||||
code: 'MISSING_ARGUMENT',
|
||||
message: 'Agent ID required',
|
||||
details: 'Usage: paseo agent logs <id>',
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
let client: DaemonClientV2
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host as string | undefined })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const error: CommandError = {
|
||||
code: 'DAEMON_NOT_RUNNING',
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
details: 'Start the daemon with: paseo daemon start',
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
try {
|
||||
// Request session state to get agent information
|
||||
client.requestSessionState()
|
||||
|
||||
// Wait for session state to be populated
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
|
||||
const agents = client.listAgents()
|
||||
const resolvedId = resolveAgentId(id, agents)
|
||||
|
||||
if (!resolvedId) {
|
||||
const error: CommandError = {
|
||||
code: 'AGENT_NOT_FOUND',
|
||||
message: `No agent found matching: ${id}`,
|
||||
details: 'Use `paseo agent ps` to list available agents',
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
// For follow mode, we stream events continuously
|
||||
if (options.follow) {
|
||||
return await runFollowMode(client, resolvedId, options)
|
||||
}
|
||||
|
||||
// For non-follow mode, initialize the agent to get timeline snapshot
|
||||
const logEntries: LogEntry[] = []
|
||||
|
||||
// Set up handler for timeline events before initializing
|
||||
const snapshotPromise = new Promise<Array<{ type: string; [key: string]: unknown }>>((resolve) => {
|
||||
const timeout = setTimeout(() => resolve([]), 10000)
|
||||
|
||||
const unsubscribe = client.on('agent_stream_snapshot', (msg: unknown) => {
|
||||
const message = msg as AgentStreamSnapshotMessage
|
||||
if (message.type !== 'agent_stream_snapshot') return
|
||||
const payload = message.payload
|
||||
if (payload.agentId !== resolvedId) return
|
||||
|
||||
clearTimeout(timeout)
|
||||
unsubscribe()
|
||||
resolve(extractTimelineFromSnapshot(message))
|
||||
})
|
||||
})
|
||||
|
||||
// Initialize agent to trigger timeline snapshot
|
||||
try {
|
||||
await client.initializeAgent(resolvedId)
|
||||
} catch {
|
||||
// Agent might already be initialized, continue to collect from queue
|
||||
}
|
||||
|
||||
// Get timeline from snapshot
|
||||
const timelineItems = await snapshotPromise
|
||||
|
||||
// Also check message queue for any stream events
|
||||
const queue = client.getMessageQueue()
|
||||
for (const msg of queue) {
|
||||
if (msg.type === 'agent_stream') {
|
||||
const payload = msg.payload as { agentId: string }
|
||||
if (payload.agentId === resolvedId) {
|
||||
const item = extractTimelineFromStream(msg)
|
||||
if (item) {
|
||||
timelineItems.push(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to log entries
|
||||
for (const item of timelineItems) {
|
||||
logEntries.push(formatTimelineItem(item))
|
||||
}
|
||||
|
||||
await client.close()
|
||||
|
||||
// Apply tail limit
|
||||
let entries = logEntries
|
||||
if (options.tail) {
|
||||
const tailCount = parseInt(options.tail, 10)
|
||||
if (!isNaN(tailCount) && tailCount > 0) {
|
||||
entries = entries.slice(-tailCount)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'list',
|
||||
data: entries,
|
||||
schema: logsSchema,
|
||||
}
|
||||
} catch (err) {
|
||||
await client.close().catch(() => {})
|
||||
// Re-throw if already a CommandError
|
||||
if (err && typeof err === 'object' && 'code' in err) {
|
||||
throw err
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const error: CommandError = {
|
||||
code: 'LOGS_FAILED',
|
||||
message: `Failed to get logs: ${message}`,
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow mode: stream logs in real-time until interrupted
|
||||
*/
|
||||
async function runFollowMode(
|
||||
client: DaemonClientV2,
|
||||
agentId: string,
|
||||
options: AgentLogsOptions
|
||||
): Promise<AgentLogsResult> {
|
||||
const logEntries: LogEntry[] = []
|
||||
|
||||
// First, get existing timeline
|
||||
const snapshotPromise = new Promise<Array<{ type: string; [key: string]: unknown }>>((resolve) => {
|
||||
const timeout = setTimeout(() => resolve([]), 10000)
|
||||
|
||||
const unsubscribe = client.on('agent_stream_snapshot', (msg: unknown) => {
|
||||
const message = msg as AgentStreamSnapshotMessage
|
||||
if (message.type !== 'agent_stream_snapshot') return
|
||||
const payload = message.payload
|
||||
if (payload.agentId !== agentId) return
|
||||
|
||||
clearTimeout(timeout)
|
||||
unsubscribe()
|
||||
resolve(extractTimelineFromSnapshot(message))
|
||||
})
|
||||
})
|
||||
|
||||
// Initialize agent to trigger timeline snapshot
|
||||
try {
|
||||
await client.initializeAgent(agentId)
|
||||
} catch {
|
||||
// Agent might already be initialized
|
||||
}
|
||||
|
||||
// Get existing timeline
|
||||
const existingItems = await snapshotPromise
|
||||
|
||||
// Apply tail to existing items
|
||||
let itemsToShow = existingItems
|
||||
if (options.tail) {
|
||||
const tailCount = parseInt(options.tail, 10)
|
||||
if (!isNaN(tailCount) && tailCount > 0) {
|
||||
itemsToShow = itemsToShow.slice(-tailCount)
|
||||
}
|
||||
}
|
||||
|
||||
// Print existing entries
|
||||
for (const item of itemsToShow) {
|
||||
const entry = formatTimelineItem(item)
|
||||
logEntries.push(entry)
|
||||
printLogEntry(entry)
|
||||
}
|
||||
|
||||
// Subscribe to new events
|
||||
console.log('\n--- Following logs (Ctrl+C to stop) ---\n')
|
||||
|
||||
const unsubscribe = client.on('agent_stream', (msg: unknown) => {
|
||||
const message = msg as AgentStreamMessage
|
||||
if (message.type !== 'agent_stream') return
|
||||
const payload = message.payload
|
||||
if (payload.agentId !== agentId) return
|
||||
|
||||
if (payload.event.type === 'timeline' && payload.event.item) {
|
||||
const entry = formatTimelineItem(payload.event.item as { type: string; [key: string]: unknown })
|
||||
logEntries.push(entry)
|
||||
printLogEntry(entry)
|
||||
}
|
||||
})
|
||||
|
||||
// Wait for interrupt
|
||||
await new Promise<void>((resolve) => {
|
||||
const cleanup = () => {
|
||||
unsubscribe()
|
||||
resolve()
|
||||
}
|
||||
|
||||
process.on('SIGINT', cleanup)
|
||||
process.on('SIGTERM', cleanup)
|
||||
})
|
||||
|
||||
await client.close()
|
||||
|
||||
return {
|
||||
type: 'list',
|
||||
data: logEntries,
|
||||
schema: logsSchema,
|
||||
}
|
||||
}
|
||||
|
||||
function printLogEntry(entry: LogEntry): void {
|
||||
// Simple format for streaming output
|
||||
const typeWidth = 15
|
||||
const paddedType = entry.type.padEnd(typeWidth)
|
||||
console.log(`${entry.timestamp} ${paddedType} ${entry.summary}`)
|
||||
}
|
||||
148
packages/cli/tests/08-agent-logs.test.ts
Normal file
148
packages/cli/tests/08-agent-logs.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
/**
|
||||
* Phase 7: Agent Logs Command Tests
|
||||
*
|
||||
* Tests the agent logs command - viewing agent activity/timeline.
|
||||
* Since daemon may not be running, we test both:
|
||||
* - Help and argument parsing
|
||||
* - Graceful error handling when daemon not running
|
||||
* - All flags are accepted
|
||||
*
|
||||
* Tests:
|
||||
* - agent logs --help shows options
|
||||
* - agent logs requires ID argument
|
||||
* - agent logs handles daemon not running
|
||||
* - agent logs -f (follow) flag is accepted
|
||||
* - agent logs --tail flag is accepted
|
||||
*/
|
||||
|
||||
import assert from 'node:assert'
|
||||
import { $ } from 'zx'
|
||||
import { mkdtemp, rm } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
|
||||
$.verbose = false
|
||||
|
||||
console.log('=== Agent Logs Command Tests ===\n')
|
||||
|
||||
// Get random port that's definitely not in use (never 6767)
|
||||
const port = 10000 + Math.floor(Math.random() * 50000)
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
|
||||
|
||||
try {
|
||||
// Test 1: agent logs --help shows options
|
||||
{
|
||||
console.log('Test 1: agent logs --help shows options')
|
||||
const result = await $`npx paseo agent logs --help`.nothrow()
|
||||
assert.strictEqual(result.exitCode, 0, 'agent logs --help should exit 0')
|
||||
assert(result.stdout.includes('-f') || result.stdout.includes('--follow'), 'help should mention -f/--follow flag')
|
||||
assert(result.stdout.includes('--tail'), 'help should mention --tail option')
|
||||
assert(result.stdout.includes('--host'), 'help should mention --host option')
|
||||
assert(result.stdout.includes('<id>'), 'help should mention required id argument')
|
||||
console.log('✓ agent logs --help shows options\n')
|
||||
}
|
||||
|
||||
// Test 2: agent logs requires ID argument
|
||||
{
|
||||
console.log('Test 2: agent logs requires ID argument')
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent logs`.nothrow()
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail without id')
|
||||
const output = result.stdout + result.stderr
|
||||
const hasError =
|
||||
output.toLowerCase().includes('missing') ||
|
||||
output.toLowerCase().includes('required') ||
|
||||
output.toLowerCase().includes('argument') ||
|
||||
output.toLowerCase().includes('id')
|
||||
assert(hasError, 'error should mention missing argument')
|
||||
console.log('✓ agent logs requires ID argument\n')
|
||||
}
|
||||
|
||||
// Test 3: agent logs handles daemon not running
|
||||
{
|
||||
console.log('Test 3: agent logs handles daemon not running')
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent logs abc123`.nothrow()
|
||||
// Should fail because daemon not running
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
|
||||
const output = result.stdout + result.stderr
|
||||
const hasError =
|
||||
output.toLowerCase().includes('daemon') ||
|
||||
output.toLowerCase().includes('connect') ||
|
||||
output.toLowerCase().includes('cannot')
|
||||
assert(hasError, 'error message should mention connection issue')
|
||||
console.log('✓ agent logs handles daemon not running\n')
|
||||
}
|
||||
|
||||
// Test 4: agent logs -f (follow) flag is accepted
|
||||
{
|
||||
console.log('Test 4: agent logs -f (follow) flag is accepted')
|
||||
// Use timeout to avoid hanging on follow mode
|
||||
const result =
|
||||
await $`timeout 1 bash -c 'PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent logs -f abc123' || true`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept -f flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ agent logs -f (follow) flag is accepted\n')
|
||||
}
|
||||
|
||||
// Test 5: agent logs --follow flag is accepted
|
||||
{
|
||||
console.log('Test 5: agent logs --follow flag is accepted')
|
||||
const result =
|
||||
await $`timeout 1 bash -c 'PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent logs --follow abc123' || true`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept --follow flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ agent logs --follow flag is accepted\n')
|
||||
}
|
||||
|
||||
// Test 6: agent logs --tail flag is accepted
|
||||
{
|
||||
console.log('Test 6: agent logs --tail flag is accepted')
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent logs --tail 50 abc123`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept --tail flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ agent logs --tail flag is accepted\n')
|
||||
}
|
||||
|
||||
// Test 7: agent logs with ID and --host flag is accepted
|
||||
{
|
||||
console.log('Test 7: agent logs with ID and --host flag is accepted')
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent logs abc123 --host localhost:${port}`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept --host flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ agent logs with ID and --host flag is accepted\n')
|
||||
}
|
||||
|
||||
// Test 8: agent shows logs in subcommands
|
||||
{
|
||||
console.log('Test 8: agent --help shows logs subcommand')
|
||||
const result = await $`npx paseo agent --help`.nothrow()
|
||||
assert.strictEqual(result.exitCode, 0, 'agent --help should exit 0')
|
||||
assert(result.stdout.includes('logs'), 'help should mention logs subcommand')
|
||||
console.log('✓ agent --help shows logs subcommand\n')
|
||||
}
|
||||
|
||||
// Test 9: -q (quiet) flag is accepted with agent logs
|
||||
{
|
||||
console.log('Test 9: -q (quiet) flag is accepted with agent logs')
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q agent logs abc123`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept -q flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ -q (quiet) flag is accepted with agent logs\n')
|
||||
}
|
||||
} finally {
|
||||
// Clean up temp directory
|
||||
await rm(paseoHome, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
console.log('=== All agent logs tests passed ===')
|
||||
Reference in New Issue
Block a user