feat(cli): tool-call display

This commit is contained in:
Acbox
2026-02-09 00:46:52 +08:00
parent 8ea2c0379d
commit d09cb5b74b
6 changed files with 445 additions and 181 deletions
+1
View File
@@ -54,6 +54,7 @@ const app = new Elysia()
.listen({
port: config.agent_gateway.port ?? 8081,
hostname: config.agent_gateway.host ?? "127.0.0.1",
idleTimeout: 255, // max allowed by Bun, to accommodate long-running tool calls
});
console.log(
+29
View File
@@ -43,6 +43,35 @@ ${Bun.YAML.stringify(headers)}
---
You are an AI agent, and now you wake up.
${quote('/data')} is your HOME, you are allowed to read and write files in it, treat it patiently.
## Basic Tools
- ${quote('read')}: read file content
- ${quote('write')}: write file content
- ${quote('list')}: list directory entries
- ${quote('edit')}: apply unified diff patch. Format:
${block([
'@@ -<orig_start>,<orig_count> +<new_start>,<new_count> @@',
'-old line',
'+new line',
'',
'@@ -3,1 +3,2 @@',
' existing line 3',
'+added line after 3',
'',
'@@ -2,1 +2,0 @@',
'-deleted line',
].join('\n'))}
Rules:
- Lines prefixed with ${quote(' ')} (space) are context (unchanged) lines
- Lines prefixed with ${quote('-')} are removed, ${quote('+')} are added
- ${quote('orig_count')} / ${quote('new_count')} must match the actual number of lines (context + removed / context + added)
- Multiple hunks allowed in one patch
- ${quote('exec')}: execute command
## Memory
Your context is loaded from the recent of ${maxContextLoadTime} minutes (${(maxContextLoadTime / 60).toFixed(2)} hours).
+4 -89
View File
@@ -5,11 +5,10 @@ import ora from 'ora'
import { table } from 'table'
import readline from 'node:readline/promises'
import { stdin as input, stdout as output } from 'node:process'
import { randomUUID } from 'node:crypto'
import { apiRequest } from '../core/api'
import { readConfig, getBaseURL, TokenInfo } from '../utils/store'
import { readConfig, TokenInfo } from '../utils/store'
import { ensureAuth, getErrorMessage, resolveBotId, BotSummary } from './shared'
import { streamChat } from './stream'
type Bot = BotSummary & {
metadata?: Record<string, unknown>
@@ -60,91 +59,6 @@ const renderBotsTable = (items: BotSummary[]) => {
return table(rows)
}
const streamChat = async (query: string, botId: string, sessionId: string, token: TokenInfo) => {
const config = readConfig()
const baseURL = getBaseURL(config)
const resp = await fetch(`${baseURL}/bots/${botId}/chat/stream?session_id=${encodeURIComponent(sessionId)}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token.access_token}`,
},
body: JSON.stringify({ query }),
}).catch(() => null)
if (!resp || !resp.ok || !resp.body) return false
const stream = resp.body
const reader = stream.getReader()
const decoder = new TextDecoder()
let buffer = ''
let printed = false
while (true) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
let idx
while ((idx = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, idx).trim()
buffer = buffer.slice(idx + 1)
if (!line.startsWith('data:')) continue
const payload = line.slice(5).trim()
if (!payload || payload === '[DONE]') continue
const text = extractTextFromEvent(payload)
if (text) {
process.stdout.write(text)
printed = true
}
}
}
if (printed) {
process.stdout.write('\n')
}
return true
}
const extractTextFromMessage = (message: unknown) => {
if (typeof message === 'string') return message
if (message && typeof message === 'object') {
const value = message as { text?: unknown; parts?: unknown[] }
if (typeof value.text === 'string') return value.text
if (Array.isArray(value.parts)) {
const lines = value.parts
.map((part) => {
if (!part || typeof part !== 'object') return ''
const typed = part as { text?: unknown; url?: unknown; emoji?: unknown }
if (typeof typed.text === 'string' && typed.text.trim()) return typed.text
if (typeof typed.url === 'string' && typed.url.trim()) return typed.url
if (typeof typed.emoji === 'string' && typed.emoji.trim()) return typed.emoji
return ''
})
.filter(Boolean)
if (lines.length) return lines.join('\n')
}
}
return null
}
const extractTextFromEvent = (payload: string) => {
try {
const event = JSON.parse(payload)
if (typeof event === 'string') return event
if (typeof event?.text === 'string') return event.text
const messageText = extractTextFromMessage(event?.message)
if (messageText) return messageText
if (typeof event?.delta === 'string') return event.delta
if (typeof event?.delta?.content === 'string') return event.delta.content
if (typeof event?.content === 'string') return event.content
if (typeof event?.data === 'string') return event.data
if (typeof event?.data?.text === 'string') return event.data.text
if (typeof event?.data?.delta?.content === 'string') return event.data.delta.content
const nestedMessageText = extractTextFromMessage(event?.data?.message)
if (nestedMessageText) return nestedMessageText
return null
} catch {
return payload
}
}
export const registerBotCommands = (program: Command) => {
const bot = program.command('bot').description('Bot management')
@@ -297,7 +211,8 @@ export const registerBotCommands = (program: Command) => {
await ensureModelsReady()
const token = ensureAuth()
const botId = await resolveBotId(token, id)
const sessionId = String(opts.session || `cli:${randomUUID()}`)
const config = readConfig()
const sessionId = String(opts.session || config.session_id)
const rl = readline.createInterface({ input, output })
console.log(chalk.green(`Chatting with ${chalk.bold(botId)} (session ${sessionId}). Type \`exit\` to quit.`))
while (true) {
+6 -87
View File
@@ -6,12 +6,12 @@ import ora from 'ora'
import { table } from 'table'
import readline from 'node:readline/promises'
import { stdin as input, stdout as output } from 'node:process'
import { randomUUID } from 'node:crypto'
import packageJson from '../../package.json'
import { apiRequest } from '../core/api'
import { registerBotCommands } from './bot'
import { registerChannelCommands } from './channel'
import { streamChat } from './stream'
import {
readConfig,
writeConfig,
@@ -710,7 +710,8 @@ program
await ensureModelsReady()
const token = ensureAuth()
const botId = await resolveBotId(token, program.opts().bot)
const sessionId = `cli:${randomUUID()}`
const config = readConfig()
const sessionId = config.session_id
const rl = readline.createInterface({ input, output })
console.log(chalk.green(`Chatting with ${chalk.bold(botId)}. Type \`exit\` to quit.`))
@@ -756,93 +757,11 @@ program
program.parseAsync(process.argv)
const streamChat = async (query: string, botId: string, sessionId: string, token: TokenInfo) => {
const config = readConfig()
const baseURL = getBaseURL(config)
const resp = await fetch(`${baseURL}/bots/${botId}/chat/stream?session_id=${sessionId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token.access_token}`,
},
body: JSON.stringify({ query }),
}).catch(() => null)
if (!resp || !resp.ok || !resp.body) return false
const stream = resp.body
const reader = stream.getReader()
const decoder = new TextDecoder()
let buffer = ''
let printed = false
while (true) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
let idx
while ((idx = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, idx).trim()
buffer = buffer.slice(idx + 1)
if (!line.startsWith('data:')) continue
const payload = line.slice(5).trim()
if (!payload || payload === '[DONE]') continue
const text = extractTextFromEvent(payload)
if (text) {
process.stdout.write(text)
printed = true
}
}
}
if (printed) {
process.stdout.write('\n')
}
return true
}
const extractTextFromMessage = (message: unknown) => {
if (typeof message === 'string') return message
if (message && typeof message === 'object') {
const value = message as { text?: unknown; parts?: unknown[] }
if (typeof value.text === 'string') return value.text
if (Array.isArray(value.parts)) {
const lines = value.parts
.map((part) => {
if (!part || typeof part !== 'object') return ''
const typed = part as { text?: unknown; url?: unknown; emoji?: unknown }
if (typeof typed.text === 'string' && typed.text.trim()) return typed.text
if (typeof typed.url === 'string' && typed.url.trim()) return typed.url
if (typeof typed.emoji === 'string' && typed.emoji.trim()) return typed.emoji
return ''
})
.filter(Boolean)
if (lines.length) return lines.join('\n')
}
}
return null
}
const extractTextFromEvent = (payload: string) => {
try {
const event = JSON.parse(payload)
if (typeof event === 'string') return event
if (typeof event?.text === 'string') return event.text
const messageText = extractTextFromMessage(event?.message)
if (messageText) return messageText
if (typeof event?.delta === 'string') return event.delta
if (typeof event?.delta?.content === 'string') return event.delta.content
if (typeof event?.content === 'string') return event.content
if (typeof event?.data === 'string') return event.data
if (typeof event?.data?.text === 'string') return event.data.text
if (typeof event?.data?.delta?.content === 'string') return event.data.delta.content
const nestedMessageText = extractTextFromMessage(event?.data?.message)
if (nestedMessageText) return nestedMessageText
return null
} catch {
return payload
}
}
// streamChat is imported from ./stream
const runTui = async (botId: string, token: TokenInfo) => {
const sessionId = `cli:${randomUUID()}`
const config = readConfig()
const sessionId = config.session_id
const rl = readline.createInterface({ input, output })
console.log(chalk.green(`TUI session (line mode) with ${chalk.bold(botId)}. Type \`exit\` to quit.`))
+388
View File
@@ -0,0 +1,388 @@
import chalk from 'chalk'
import { readConfig, getBaseURL, TokenInfo } from '../utils/store'
// ---------------------------------------------------------------------------
// Tool display configuration
// ---------------------------------------------------------------------------
type ToolDisplayMode = 'inline' | 'expanded'
interface ToolDisplayConfig {
mode: ToolDisplayMode
/** For expanded mode: which parameter to show as detail content */
expandParam?: string
/** Label shown in the display header */
label?: string
}
/**
* Tools listed here will be displayed in "expanded" mode with a box showing
* the specified parameter content. Everything else defaults to single-line.
* exec uses a custom single-line format instead of the box.
*/
const TOOL_DISPLAY: Record<string, ToolDisplayConfig> = {
exec: { mode: 'expanded', label: 'exec' },
write: { mode: 'expanded', expandParam: 'content', label: 'write' },
edit: { mode: 'expanded', expandParam: 'patch', label: 'edit' },
}
const getToolDisplay = (toolName: string): ToolDisplayConfig => {
return TOOL_DISPLAY[toolName] ?? { mode: 'inline' }
}
// ---------------------------------------------------------------------------
// Tool call formatting helpers
// ---------------------------------------------------------------------------
const BOX_WIDTH = 60
// ---------------------------------------------------------------------------
// exec-specific helpers
// ---------------------------------------------------------------------------
/** Extract the actual shell command from exec input like { command: "bash", args: ["-lc", "echo hi"] } */
const extractExecCommand = (toolInput: unknown): string => {
if (!toolInput || typeof toolInput !== 'object') return ''
const input = toolInput as Record<string, unknown>
const command = typeof input.command === 'string' ? input.command : ''
const args = Array.isArray(input.args) ? input.args.map(String) : []
// If shell + -c/-lc flag, extract the actual script
if (/^(bash|sh|zsh)$/.test(command) && args.length >= 2) {
const flag = args[0]
if (flag === '-c' || flag === '-lc') {
return args.slice(1).join(' ')
}
}
return [command, ...args].filter(Boolean).join(' ')
}
const formatExecCall = (toolInput: unknown) => {
const cmd = extractExecCommand(toolInput)
return chalk.dim(' ▶ ') + chalk.white('$ ') + chalk.bold.white(cmd)
}
/** Try to unwrap MCP content-block results into a plain object */
const unwrapToolResult = (result: unknown): Record<string, unknown> | null => {
if (!result) return null
// Helper to extract from MCP content blocks array
const extractFromContentBlocks = (arr: unknown[]): Record<string, unknown> | null => {
for (const block of arr) {
if (block && typeof block === 'object') {
const b = block as Record<string, unknown>
if (b.type === 'text' && typeof b.text === 'string') {
try { return JSON.parse(b.text) } catch { /* ignore */ }
}
}
}
return null
}
// MCP content array: [{ type: "text", text: "{...}" }]
if (Array.isArray(result)) {
return extractFromContentBlocks(result)
}
// Object - might be MCP wrapper { content: [...] } or direct result
if (typeof result === 'object') {
const obj = result as Record<string, unknown>
// MCP wrapper: { content: [{ type: "text", text: "{...}" }], isError: ... }
if (Array.isArray(obj.content)) {
const extracted = extractFromContentBlocks(obj.content)
if (extracted) return extracted
}
// Direct object with known result fields
return obj
}
// JSON string
if (typeof result === 'string') {
try { return JSON.parse(result) } catch { /* ignore */ }
}
return null
}
const formatExecResult = (result: unknown) => {
const r = unwrapToolResult(result)
if (!r) return chalk.dim(' ╰─ done')
const exitCode = typeof r.exit_code === 'number' ? r.exit_code : (r.ok ? 0 : 1)
const ok = exitCode === 0
const stdout = typeof r.stdout === 'string' ? r.stdout.trim() : ''
const stderr = typeof r.stderr === 'string' ? r.stderr.trim() : ''
const lines: string[] = []
lines.push(chalk.dim(' ╰─ ') + (ok ? chalk.green(`✓ exit ${exitCode}`) : chalk.red(`✗ exit ${exitCode}`)))
const output = ok ? stdout : (stderr || stdout)
if (output) {
const outputLines = output.split('\n')
const maxLines = 8
const shown = outputLines.slice(0, maxLines)
for (const ol of shown) {
const truncated = ol.length > 72 ? ol.slice(0, 69) + '...' : ol
lines.push(chalk.dim(' ') + (ok ? chalk.white(truncated) : chalk.yellow(truncated)))
}
if (outputLines.length > maxLines) {
lines.push(chalk.dim(` ... (${outputLines.length - maxLines} more lines)`))
}
}
return lines.join('\n')
}
// ---------------------------------------------------------------------------
// Generic tool formatting
// ---------------------------------------------------------------------------
const formatToolCallInline = (toolName: string, toolInput: unknown) => {
let params = ''
if (toolInput && typeof toolInput === 'object') {
const entries = Object.entries(toolInput as Record<string, unknown>)
params = entries
.map(([k, v]) => {
const s = typeof v === 'string' ? v : JSON.stringify(v)
const short = s.length > 40 ? s.slice(0, 37) + '...' : s
return `${k}=${short}`
})
.join(', ')
}
return chalk.dim(`${toolName}`) + (params ? chalk.dim(`(${params})`) : '')
}
const formatToolCallExpanded = (config: ToolDisplayConfig, toolName: string, toolInput: unknown) => {
const label = config.label ?? toolName
const inputObj = (toolInput && typeof toolInput === 'object' ? toolInput : {}) as Record<string, unknown>
const summaryParts: string[] = []
for (const [k, v] of Object.entries(inputObj)) {
if (k === config.expandParam) continue
const s = typeof v === 'string' ? v : JSON.stringify(v)
summaryParts.push(`${k}: ${s.length > 50 ? s.slice(0, 47) + '...' : s}`)
}
const summary = summaryParts.length ? ' ' + summaryParts.join(', ') : ''
let detail = ''
if (config.expandParam && config.expandParam in inputObj) {
const raw = inputObj[config.expandParam]
if (typeof raw === 'string') {
detail = raw
} else if (Array.isArray(raw)) {
detail = raw.join(' ')
} else {
detail = JSON.stringify(raw, null, 2)
}
}
const topBorder = '┌' + '─'.repeat(BOX_WIDTH - 2) + '┐'
const botBorder = '└' + '─'.repeat(BOX_WIDTH - 2) + '┘'
const lines: string[] = []
lines.push(chalk.cyan(topBorder))
lines.push(chalk.cyan('│ ') + chalk.bold.white(label) + chalk.gray(summary))
if (detail) {
lines.push(chalk.cyan('│ ') + chalk.dim('─'.repeat(BOX_WIDTH - 4)))
const detailLines = detail.split('\n')
const maxLines = 20
const shown = detailLines.slice(0, maxLines)
for (const dl of shown) {
const truncated = dl.length > BOX_WIDTH - 4 ? dl.slice(0, BOX_WIDTH - 7) + '...' : dl
lines.push(chalk.cyan('│ ') + chalk.white(truncated))
}
if (detailLines.length > maxLines) {
lines.push(chalk.cyan('│ ') + chalk.dim(`... (${detailLines.length - maxLines} more lines)`))
}
}
lines.push(chalk.cyan(botBorder))
return lines.join('\n')
}
const formatToolResult = (toolName: string, result: unknown) => {
// exec has its own result formatter
if (toolName === 'exec') {
return formatExecResult(result)
}
const config = getToolDisplay(toolName)
if (config.mode === 'expanded') {
const r = unwrapToolResult(result)
if (r) {
if ('ok' in r) {
return chalk.dim(` ╰─ `) + (r.ok ? chalk.green('✓ ok') : chalk.red('✗ failed'))
}
}
return chalk.dim(` ╰─ done`)
}
return null
}
// ---------------------------------------------------------------------------
// Text extraction helpers (fallback for unknown event formats)
// ---------------------------------------------------------------------------
const extractTextFromMessage = (message: unknown) => {
if (typeof message === 'string') return message
if (message && typeof message === 'object') {
const value = message as { text?: unknown; parts?: unknown[] }
if (typeof value.text === 'string') return value.text
if (Array.isArray(value.parts)) {
const lines = value.parts
.map((part) => {
if (!part || typeof part !== 'object') return ''
const typed = part as { text?: unknown; url?: unknown; emoji?: unknown }
if (typeof typed.text === 'string' && typed.text.trim()) return typed.text
if (typeof typed.url === 'string' && typed.url.trim()) return typed.url
if (typeof typed.emoji === 'string' && typed.emoji.trim()) return typed.emoji
return ''
})
.filter(Boolean)
if (lines.length) return lines.join('\n')
}
}
return null
}
const extractTextFromEvent = (payload: string) => {
try {
const event = JSON.parse(payload)
if (typeof event === 'string') return event
if (typeof event?.text === 'string') return event.text
const messageText = extractTextFromMessage(event?.message)
if (messageText) return messageText
if (typeof event?.delta === 'string') return event.delta
if (typeof event?.delta?.content === 'string') return event.delta.content
if (typeof event?.content === 'string') return event.content
if (typeof event?.data === 'string') return event.data
if (typeof event?.data?.text === 'string') return event.data.text
if (typeof event?.data?.delta?.content === 'string') return event.data.delta.content
const nestedMessageText = extractTextFromMessage(event?.data?.message)
if (nestedMessageText) return nestedMessageText
return null
} catch {
return payload
}
}
// ---------------------------------------------------------------------------
// Stream chat
// ---------------------------------------------------------------------------
export const streamChat = async (query: string, botId: string, sessionId: string, token: TokenInfo) => {
const config = readConfig()
const baseURL = getBaseURL(config)
const resp = await fetch(`${baseURL}/bots/${botId}/chat/stream?session_id=${encodeURIComponent(sessionId)}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token.access_token}`,
},
body: JSON.stringify({ query }),
}).catch(() => null)
if (!resp || !resp.ok || !resp.body) return false
const stream = resp.body
const reader = stream.getReader()
const decoder = new TextDecoder()
let buffer = ''
let printedText = false
while (true) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
let idx
while ((idx = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, idx).trim()
buffer = buffer.slice(idx + 1)
if (!line.startsWith('data:')) continue
const payload = line.slice(5).trim()
if (!payload || payload === '[DONE]') continue
let event: Record<string, unknown>
try {
const parsed = JSON.parse(payload)
if (typeof parsed === 'string') {
process.stdout.write(parsed)
printedText = true
continue
}
event = parsed
} catch {
process.stdout.write(payload)
printedText = true
continue
}
const eventType = event.type as string | undefined
switch (eventType) {
case 'text_start':
break
case 'text_delta':
if (typeof event.delta === 'string') {
process.stdout.write(event.delta)
printedText = true
}
break
case 'text_end':
if (printedText) {
process.stdout.write('\n')
printedText = false
}
break
case 'tool_call_start': {
if (printedText) {
process.stdout.write('\n')
printedText = false
}
const toolName = event.toolName as string
const toolInput = event.input
if (toolName === 'exec') {
console.log(formatExecCall(toolInput))
} else {
const displayConfig = getToolDisplay(toolName)
if (displayConfig.mode === 'expanded') {
console.log(formatToolCallExpanded(displayConfig, toolName, toolInput))
} else {
console.log(formatToolCallInline(toolName, toolInput))
}
}
break
}
case 'tool_call_end': {
const toolName = event.toolName as string
const result = event.result
const resultLine = formatToolResult(toolName, result)
if (resultLine) {
console.log(resultLine)
}
break
}
case 'reasoning_start':
case 'reasoning_delta':
case 'reasoning_end':
case 'agent_start':
case 'agent_end':
break
default: {
const text = extractTextFromEvent(payload)
if (text) {
process.stdout.write(text)
printedText = true
}
break
}
}
}
}
if (printedText) {
process.stdout.write('\n')
}
return true
}
+17 -5
View File
@@ -1,10 +1,12 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { randomUUID } from 'node:crypto'
export type CliConfig = {
host: string
port: number
session_id: string
}
export type TokenInfo = {
@@ -18,6 +20,7 @@ export type TokenInfo = {
const defaultConfig: CliConfig = {
host: '127.0.0.1',
port: 8080,
session_id: '',
}
const memohDir = () => join(homedir(), '.memoh')
@@ -46,24 +49,33 @@ const parseTomlConfig = (raw: string): CliConfig => {
} else if (key === 'port') {
const parsed = Number.parseInt(value, 10)
if (!Number.isNaN(parsed)) result.port = parsed
} else if (key === 'session_id') {
result.session_id = value
}
}
return result
}
const serializeTomlConfig = (config: CliConfig) => {
return `host = "${config.host}"\nport = ${config.port}\n`
return `host = "${config.host}"\nport = ${config.port}\nsession_id = "${config.session_id}"\n`
}
export const readConfig = (): CliConfig => {
ensureStore()
const path = configPath()
let config: CliConfig
if (!existsSync(path)) {
writeFileSync(path, serializeTomlConfig(defaultConfig), 'utf-8')
return { ...defaultConfig }
config = { ...defaultConfig }
} else {
const raw = readFileSync(path, 'utf-8')
config = parseTomlConfig(raw)
}
const raw = readFileSync(path, 'utf-8')
return parseTomlConfig(raw)
// Auto-generate session_id on first run
if (!config.session_id) {
config.session_id = `cli:${randomUUID()}`
writeFileSync(path, serializeTomlConfig(config), 'utf-8')
}
return config
}
export const writeConfig = (config: CliConfig) => {