From edfa13e78505ec05e8348bf6155cfc4e147d7257 Mon Sep 17 00:00:00 2001 From: dal Date: Fri, 3 Oct 2025 08:09:09 -0600 Subject: [PATCH] starting to piece together ui for tasks --- apps/cli/src/commands/main.tsx | 130 +++++--------- apps/cli/src/components/execute-message.tsx | 120 +++++++++++++ apps/cli/src/components/typed-message.tsx | 67 ++++--- .../services/analytics-engineer-handler.ts | 165 ++++++++++-------- .../analytics-engineer-agent.ts | 12 +- .../analytics-engineer-agent/tool-events.ts | 53 ++++++ packages/ai/src/index.ts | 33 ++++ .../idle-tool/idle-tool.ts | 21 ++- .../file-tools/bash-tool/bash-tool-execute.ts | 20 ++- .../tools/file-tools/bash-tool/bash-tool.ts | 1 + .../file-tools/grep-tool/grep-tool.test.ts | 33 +++- .../tools/file-tools/grep-tool/grep-tool.ts | 1 + .../file-tools/ls-tool/ls-tool-execute.ts | 45 ++++- .../src/tools/file-tools/ls-tool/ls-tool.ts | 1 + 14 files changed, 503 insertions(+), 199 deletions(-) create mode 100644 apps/cli/src/components/execute-message.tsx create mode 100644 packages/ai/src/agents/analytics-engineer-agent/tool-events.ts diff --git a/apps/cli/src/commands/main.tsx b/apps/cli/src/commands/main.tsx index 80bc8b428..a7cfd864e 100644 --- a/apps/cli/src/commands/main.tsx +++ b/apps/cli/src/commands/main.tsx @@ -10,34 +10,20 @@ import { ChatVersionTagline, VimStatus, } from '../components/chat-layout'; -import { Diff } from '../components/diff'; import { SettingsForm } from '../components/settings-form'; -import { type MessageType, TypedMessage } from '../components/typed-message'; +import { AgentMessageComponent } from '../components/typed-message'; +import type { DocsAgentMessage } from '../services/analytics-engineer-handler'; import { getSetting } from '../utils/settings'; import type { SlashCommand } from '../utils/slash-commands'; import type { VimMode } from '../utils/vim-mode'; type AppMode = 'Planning' | 'Auto-accept' | 'None'; -interface Message { - id: number; - type: 'user' | 'assistant'; - content: string; - messageType?: MessageType; - metadata?: string; - diffLines?: Array<{ - lineNumber: number; - content: string; - type: 'add' | 'remove' | 'context'; - }>; - fileName?: string; -} - export function Main() { const { exit } = useApp(); const [input, setInput] = useState(''); const [history, setHistory] = useState([]); - const [messages, setMessages] = useState([]); + const [messages, setMessages] = useState([]); const historyCounter = useRef(0); const messageCounter = useRef(0); const [vimEnabled, setVimEnabled] = useState(() => getSetting('vimMode')); @@ -66,7 +52,43 @@ export function Main() { } }); - const getMockResponse = (userInput: string): Message[] => { + const handleSubmit = useCallback(async () => { + const trimmed = input.trim(); + if (!trimmed) { + setInput(''); + return; + } + + const userMessage: DocsAgentMessage = { + id: ++messageCounter.current, + message: { + kind: 'user', + content: trimmed, + }, + }; + + setMessages((prev) => [...prev, userMessage]); + setInput(''); + + // Import and run the docs agent + const { runDocsAgent } = await import('../services/analytics-engineer-handler'); + + // Run agent - callbacks will handle message display + await runDocsAgent({ + userMessage: trimmed, + onMessage: (agentMessage) => { + // Assign unique ID to each message + const messageWithId = { + id: ++messageCounter.current, + message: agentMessage.message, + }; + setMessages((prev) => [...prev, messageWithId]); + }, + }); + }, [input]); + + // REMOVED: Old getMockResponse function + const _oldGetMockResponse = (userInput: string) => { const responses: Message[] = []; if (userInput.toLowerCase().includes('plan')) { @@ -225,47 +247,9 @@ export function Main() { }); } - return responses; + return []; // Old mock code removed }; - const handleSubmit = useCallback(async () => { - const trimmed = input.trim(); - if (!trimmed) { - setInput(''); - return; - } - - messageCounter.current += 1; - const userMessage: Message = { - id: messageCounter.current, - type: 'user', - content: trimmed, - }; - - setMessages((prev) => [...prev, userMessage]); - setInput(''); - - // Import and run the docs agent - const { runDocsAgent } = await import('../services/analytics-engineer-handler'); - - await runDocsAgent({ - userMessage: trimmed, - onMessage: (agentMessage) => { - messageCounter.current += 1; - setMessages((prev) => [ - ...prev, - { - id: messageCounter.current, - type: agentMessage.type, - content: agentMessage.content, - messageType: agentMessage.messageType, - metadata: agentMessage.metadata, - }, - ]); - }, - }); - }, [input]); - const handleCommandExecute = useCallback( (command: SlashCommand) => { switch (command.action) { @@ -309,35 +293,9 @@ export function Main() { // Memoize message list to prevent re-renders from cursor blinking const messageList = useMemo(() => { - return messages.map((message) => { - if (message.type === 'user') { - return ( - - - ❯{' '} - - {message.content} - - ); - } else if (message.messageType) { - return ( - - - {message.diffLines && } - - ); - } else { - return ( - - {message.content} - - ); - } - }); + return messages.map((msg) => ( + + )); }, [messages]); return ( diff --git a/apps/cli/src/components/execute-message.tsx b/apps/cli/src/components/execute-message.tsx new file mode 100644 index 000000000..b4c9d5339 --- /dev/null +++ b/apps/cli/src/components/execute-message.tsx @@ -0,0 +1,120 @@ +import { Box, Text, useInput } from 'ink'; +import React, { useState } from 'react'; +import type { AgentMessage } from '../services/analytics-engineer-handler'; + +interface ExecuteMessageProps { + message: Extract; +} + +/** + * Component for displaying bash, grep, and ls command execution + * Shows EXECUTE badge, command description, and output logs + * Supports expansion with Ctrl+O to show full output + */ +export function ExecuteMessage({ message }: ExecuteMessageProps) { + const [isExpanded, setIsExpanded] = useState(false); + + // Handle Ctrl+O to toggle expansion + useInput((input, key) => { + if (key.ctrl && input === 'o') { + setIsExpanded((prev) => !prev); + } + }); + + const { args, result } = message; + + // Get command description and output based on tool type + let description = ''; + let output = ''; + let exitCode: number | undefined; + let success = true; + + if (message.kind === 'bash') { + description = args.description || args.command; + if (result) { + output = result.stdout || result.stderr || ''; + exitCode = result.exitCode; + success = result.success; + } + } else if (message.kind === 'grep') { + description = `Search for "${args.pattern}"${args.glob ? ` in ${args.glob}` : ''}`; + if (result) { + output = result.matches + .map((m) => `${m.path}:${m.lineNum}: ${m.lineText}`) + .join('\n'); + success = result.totalMatches > 0; + } + } else if (message.kind === 'ls') { + description = `List directory ${args.path || '.'}`; + if (result) { + output = result.output; + success = result.success; + } + } + + // Split output into lines for display + const outputLines = output.split('\n').filter(Boolean); + + // Show last 5 lines when not expanded, all lines when expanded + const displayLines = isExpanded ? outputLines : outputLines.slice(-5); + + return ( + + {/* EXECUTE badge with actual command in parentheses */} + + + EXECUTE + + ({args.command}) + + + {/* Output lines - always show with indentation */} + {outputLines.length > 0 && ( + + {displayLines.map((line, idx) => ( + + {line} + + ))} + + )} + + {/* Exit code/status line with indentation */} + {message.kind === 'bash' && exitCode !== undefined && ( + + + ↳ Exit code: {exitCode}. Output: {outputLines.length} lines. + + + )} + + {message.kind === 'grep' && result && ( + + 0 ? 'green' : 'yellow'}> + ↳ Found {result.totalMatches} match{result.totalMatches !== 1 ? 'es' : ''} + {result.truncated ? ' (truncated)' : ''} + + + )} + + {message.kind === 'ls' && result && ( + + + ↳ Listed {result.count} file{result.count !== 1 ? 's' : ''} + {result.truncated ? ' (truncated)' : ''} + {result.errorMessage ? `: ${result.errorMessage}` : ''} + + + )} + + {/* Expansion hint if output is long */} + {outputLines.length > 5 && ( + + + {isExpanded ? '(Press Ctrl+O to collapse)' : `... (${outputLines.length - 5} more lines, press Ctrl+O to expand)`} + + + )} + + ); +} diff --git a/apps/cli/src/components/typed-message.tsx b/apps/cli/src/components/typed-message.tsx index d15607312..f7871195a 100644 --- a/apps/cli/src/components/typed-message.tsx +++ b/apps/cli/src/components/typed-message.tsx @@ -1,35 +1,48 @@ import { Box, Text } from 'ink'; import React from 'react'; +import type { AgentMessage } from '../services/analytics-engineer-handler'; +import { ExecuteMessage } from './execute-message'; -export type MessageType = 'PLAN' | 'EXECUTE' | 'WRITE' | 'EDIT'; - -interface TypedMessageProps { - type: MessageType; - content: string; - metadata?: string; +interface AgentMessageComponentProps { + message: AgentMessage; } -const typeStyles: Record = { - PLAN: { bg: '#fb923c', fg: '#000000', label: 'PLAN' }, - EXECUTE: { bg: '#fb923c', fg: '#000000', label: 'EXECUTE' }, - WRITE: { bg: '#3b82f6', fg: '#ffffff', label: 'WRITE' }, - EDIT: { bg: '#22c55e', fg: '#ffffff', label: 'EDIT' }, -}; +export function AgentMessageComponent({ message }: AgentMessageComponentProps) { + switch (message.kind) { + case 'user': + return ( + + + ❯{' '} + + {message.content} + + ); -export function TypedMessage({ type, content, metadata }: TypedMessageProps) { - const style = typeStyles[type]; + case 'text-delta': + return ( + + {message.content} + + ); - return ( - - - - {` ${style.label} `} - - {metadata && {metadata}} - - - {content} - - - ); + case 'idle': + // For idle tool, just show the final response as plain text + return ( + + + {message.args?.final_response || 'Task completed'} + + + ); + + case 'bash': + case 'grep': + case 'ls': + // For execute commands, use the ExecuteMessage component + return ; + + default: + return null; + } } diff --git a/apps/cli/src/services/analytics-engineer-handler.ts b/apps/cli/src/services/analytics-engineer-handler.ts index f86a45894..0f49778f5 100644 --- a/apps/cli/src/services/analytics-engineer-handler.ts +++ b/apps/cli/src/services/analytics-engineer-handler.ts @@ -1,15 +1,45 @@ import { randomUUID } from 'node:crypto'; import { createProxyModel } from '@buster/ai/llm/providers/proxy-model'; -import type { ModelMessage } from '@buster/ai'; +import type { + BashToolInput, + BashToolOutput, + GrepToolInput, + GrepToolOutput, + IdleInput, + LsToolInput, + LsToolOutput, + ModelMessage, + ToolEvent, +} from '@buster/ai'; import { getProxyConfig } from '../utils/ai-proxy'; import { createAnalyticsEngineerAgent } from '@buster/ai/agents/analytics-engineer-agent/analytics-engineer-agent'; +// Discriminated union for all possible message types - uses tool types directly +export type AgentMessage = + | { kind: 'user'; content: string } + | { kind: 'text-delta'; content: string } + | { kind: 'idle'; args: IdleInput } + | { + kind: 'bash'; + event: 'start' | 'complete'; + args: BashToolInput; + result?: BashToolOutput; + } + | { + kind: 'grep'; + event: 'start' | 'complete'; + args: GrepToolInput; + result?: GrepToolOutput; + } + | { + kind: 'ls'; + event: 'start' | 'complete'; + args: LsToolInput; + result?: LsToolOutput; + }; + export interface DocsAgentMessage { - id: number; - type: 'user' | 'assistant'; - content: string; - messageType?: 'PLAN' | 'EDIT' | 'EXECUTE' | 'WRITE' | 'WEB_SEARCH'; - metadata?: string; + message: AgentMessage; } export interface RunDocsAgentParams { @@ -21,11 +51,9 @@ export interface RunDocsAgentParams { * Runs the docs agent in the CLI without sandbox * The agent runs locally but uses the proxy model to route LLM calls through the server */ -export async function runDocsAgent(params: RunDocsAgentParams): Promise { +export async function runDocsAgent(params: RunDocsAgentParams) { const { userMessage, onMessage } = params; - let messageId = 1; - // Get proxy configuration const proxyConfig = await getProxyConfig(); @@ -36,16 +64,65 @@ export async function runDocsAgent(params: RunDocsAgentParams): Promise { modelId: 'anthropic/claude-4-sonnet-20250514', }); - // Create the docs agent with proxy model + // Create the docs agent with proxy model and typed event callback // Tools are handled locally, only model calls go through proxy const analyticsEngineerAgent = createAnalyticsEngineerAgent({ - folder_structure: 'CLI mode - limited file access', + folder_structure: process.cwd(), // Use current working directory for CLI mode userId: 'cli-user', chatId: randomUUID(), dataSourceId: '', organizationId: 'cli', messageId: randomUUID(), model: proxyModel, + // Handle typed tool events - TypeScript knows exact shape based on discriminants + onToolEvent: (event: ToolEvent) => { + // Type narrowing: TypeScript knows event.args and event.result types! + if (event.tool === 'idleTool' && event.event === 'complete') { + // event.args is IdleInput, event.result is IdleOutput - fully typed! + onMessage({ + message: { + kind: 'idle', + args: event.args, // Type-safe: IdleInput + }, + }); + } + + // Handle bash tool events - only show complete to avoid duplicates + if (event.tool === 'bashTool' && event.event === 'complete') { + onMessage({ + message: { + kind: 'bash', + event: 'complete', + args: event.args, + result: event.result, // Type-safe: BashToolOutput + }, + }); + } + + // Handle grep tool events - only show complete to avoid duplicates + if (event.tool === 'grepTool' && event.event === 'complete') { + onMessage({ + message: { + kind: 'grep', + event: 'complete', + args: event.args, + result: event.result, // Type-safe: GrepToolOutput + }, + }); + } + + // Handle ls tool events - only show complete to avoid duplicates + if (event.tool === 'lsTool' && event.event === 'complete') { + onMessage({ + message: { + kind: 'ls', + event: 'complete', + args: event.args, + result: event.result, // Type-safe: LsToolOutput + }, + }); + } + }, }); const messages: ModelMessage[] = [ @@ -55,65 +132,13 @@ export async function runDocsAgent(params: RunDocsAgentParams): Promise { }, ]; - try { - // Execute the docs agent - const result = await analyticsEngineerAgent.stream({ messages }); + // Start the stream - this triggers the agent to run + const stream = await analyticsEngineerAgent.stream({ messages }); - // Stream the response - for await (const part of result.fullStream) { - // Handle different stream part types - if (part.type === 'text-delta') { - onMessage({ - id: messageId++, - type: 'assistant', - content: part.delta, - }); - } else if (part.type === 'tool-call') { - // Map tool calls to message types - let messageType: DocsAgentMessage['messageType']; - let content = ''; - const metadata = ''; - - switch (part.toolName) { - case 'sequentialThinking': - messageType = 'PLAN'; - content = 'Planning next steps...'; - break; - case 'bashExecute': - messageType = 'EXECUTE'; - content = 'Executing command...'; - break; - case 'webSearch': - messageType = 'WEB_SEARCH'; - content = 'Searching the web...'; - break; - case 'grepSearch': - messageType = 'EXECUTE'; - content = 'Searching files...'; - break; - case 'idleTool': - messageType = 'EXECUTE'; - content = 'Entering idle state...'; - break; - default: - content = `Using tool: ${part.toolName}`; - } - - onMessage({ - id: messageId++, - type: 'assistant', - content, - messageType, - metadata, - }); - } - // Ignore other stream part types (start, finish, etc.) - } - } catch (error) { - onMessage({ - id: messageId++, - type: 'assistant', - content: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`, - }); + // Consume the stream to trigger tool execution + // Tools will call callbacks directly when they execute + for await (const _part of stream.fullStream) { + // Stream parts are consumed but tools handle their own display via callbacks + // In the future we could handle text-delta here if needed } } diff --git a/packages/ai/src/agents/analytics-engineer-agent/analytics-engineer-agent.ts b/packages/ai/src/agents/analytics-engineer-agent/analytics-engineer-agent.ts index bdab079b9..5fa6d66ac 100644 --- a/packages/ai/src/agents/analytics-engineer-agent/analytics-engineer-agent.ts +++ b/packages/ai/src/agents/analytics-engineer-agent/analytics-engineer-agent.ts @@ -12,6 +12,7 @@ import { createGrepTool } from '../../tools/file-tools/grep-tool/grep-tool'; import { createReadFileTool } from '../../tools/file-tools/read-file-tool/read-file-tool'; import { type AgentContext, repairToolCall } from '../../utils/tool-call-repair'; import { getDocsAgentSystemPrompt as getAnalyticsEngineerAgentSystemPrompt } from './get-analytics-engineer-agent-system-prompt'; +import type { ToolEventCallback } from './tool-events'; export const ANALYST_ENGINEER_AGENT_NAME = 'analyticsEngineerAgent'; @@ -42,7 +43,9 @@ const AnalyticsEngineerAgentStreamOptionsSchema = z.object({ messages: z.array(z.custom()).describe('The messages to send to the docs agent'), }); -export type AnalyticsEngineerAgentOptions = z.infer; +export type AnalyticsEngineerAgentOptions = z.infer & { + onToolEvent?: ToolEventCallback; +}; export type AnalyticsEngineerAgentStreamOptions = z.infer; // Extended type for passing to tools (includes sandbox) @@ -55,7 +58,9 @@ export function createAnalyticsEngineerAgent(analyticsEngineerAgentOptions: Anal providerOptions: DEFAULT_ANTHROPIC_OPTIONS, } as ModelMessage; - const idleTool = createIdleTool(); + const idleTool = createIdleTool({ + onToolEvent: analyticsEngineerAgentOptions.onToolEvent, + }); const writeFileTool = createWriteFileTool({ messageId: analyticsEngineerAgentOptions.messageId, projectDirectory: analyticsEngineerAgentOptions.folder_structure, @@ -63,6 +68,7 @@ export function createAnalyticsEngineerAgent(analyticsEngineerAgentOptions: Anal const grepTool = createGrepTool({ messageId: analyticsEngineerAgentOptions.messageId, projectDirectory: analyticsEngineerAgentOptions.folder_structure, + onToolEvent: analyticsEngineerAgentOptions.onToolEvent, }); const readFileTool = createReadFileTool({ messageId: analyticsEngineerAgentOptions.messageId, @@ -71,6 +77,7 @@ export function createAnalyticsEngineerAgent(analyticsEngineerAgentOptions: Anal const bashTool = createBashTool({ messageId: analyticsEngineerAgentOptions.messageId, projectDirectory: analyticsEngineerAgentOptions.folder_structure, + onToolEvent: analyticsEngineerAgentOptions.onToolEvent, }); const editFileTool = createEditFileTool({ messageId: analyticsEngineerAgentOptions.messageId, @@ -83,6 +90,7 @@ export function createAnalyticsEngineerAgent(analyticsEngineerAgentOptions: Anal const lsTool = createLsTool({ messageId: analyticsEngineerAgentOptions.messageId, projectDirectory: analyticsEngineerAgentOptions.folder_structure, + onToolEvent: analyticsEngineerAgentOptions.onToolEvent, }); // Create planning tools with simple context diff --git a/packages/ai/src/agents/analytics-engineer-agent/tool-events.ts b/packages/ai/src/agents/analytics-engineer-agent/tool-events.ts new file mode 100644 index 000000000..99334fc07 --- /dev/null +++ b/packages/ai/src/agents/analytics-engineer-agent/tool-events.ts @@ -0,0 +1,53 @@ +import type { IdleInput, IdleOutput } from '../../tools/communication-tools/idle-tool/idle-tool'; +import type { BashToolInput, BashToolOutput } from '../../tools/file-tools/bash-tool/bash-tool'; +import type { + EditFileToolInput, + EditFileToolOutput, +} from '../../tools/file-tools/edit-file-tool/edit-file-tool'; +import type { GrepToolInput, GrepToolOutput } from '../../tools/file-tools/grep-tool/grep-tool'; +import type { LsToolInput, LsToolOutput } from '../../tools/file-tools/ls-tool/ls-tool'; +import type { + ReadFileToolInput, + ReadFileToolOutput, +} from '../../tools/file-tools/read-file-tool/read-file-tool'; +import type { + WriteFileToolInput, + WriteFileToolOutput, +} from '../../tools/file-tools/write-file-tool/write-file-tool'; + +/** + * Discriminated union of all possible tool events in the analytics engineer agent + * This provides full type safety from tools -> CLI display + */ +export type ToolEvent = + // Idle tool events + | { tool: 'idleTool'; event: 'start'; args: IdleInput } + | { tool: 'idleTool'; event: 'complete'; result: IdleOutput; args: IdleInput } + // Bash tool events + | { tool: 'bashTool'; event: 'start'; args: BashToolInput } + | { tool: 'bashTool'; event: 'complete'; result: BashToolOutput; args: BashToolInput } + // Grep tool events + | { tool: 'grepTool'; event: 'start'; args: GrepToolInput } + | { tool: 'grepTool'; event: 'complete'; result: GrepToolOutput; args: GrepToolInput } + // Read file tool events + | { tool: 'readFileTool'; event: 'start'; args: ReadFileToolInput } + | { tool: 'readFileTool'; event: 'complete'; result: ReadFileToolOutput; args: ReadFileToolInput } + // Write file tool events + | { tool: 'writeFileTool'; event: 'start'; args: WriteFileToolInput } + | { + tool: 'writeFileTool'; + event: 'complete'; + result: WriteFileToolOutput; + args: WriteFileToolInput; + } + // Edit file tool events + | { tool: 'editFileTool'; event: 'start'; args: EditFileToolInput } + | { tool: 'editFileTool'; event: 'complete'; result: EditFileToolOutput; args: EditFileToolInput } + // Ls tool events + | { tool: 'lsTool'; event: 'start'; args: LsToolInput } + | { tool: 'lsTool'; event: 'complete'; result: LsToolOutput; args: LsToolInput }; + +/** + * Callback type for tool events - single typed callback for all tools + */ +export type ToolEventCallback = (event: ToolEvent) => void; diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 884078f64..5d0dd99e6 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -4,3 +4,36 @@ export * from './workflows'; export * from './utils'; export * from './embeddings'; export * from './tasks'; + +// Export tool types for CLI usage +export type { + IdleInput, + IdleOutput, +} from './tools/communication-tools/idle-tool/idle-tool'; +export type { + BashToolInput, + BashToolOutput, +} from './tools/file-tools/bash-tool/bash-tool'; +export type { + GrepToolInput, + GrepToolOutput, +} from './tools/file-tools/grep-tool/grep-tool'; +export type { + ReadFileToolInput, + ReadFileToolOutput, +} from './tools/file-tools/read-file-tool/read-file-tool'; +export type { + WriteFileToolInput, + WriteFileToolOutput, +} from './tools/file-tools/write-file-tool/write-file-tool'; +export type { + EditFileToolInput, + EditFileToolOutput, +} from './tools/file-tools/edit-file-tool/edit-file-tool'; +export type { + LsToolInput, + LsToolOutput, +} from './tools/file-tools/ls-tool/ls-tool'; + +// Export typed tool events for type-safe tool callbacks +export type { ToolEvent, ToolEventCallback } from './agents/analytics-engineer-agent/tool-events'; diff --git a/packages/ai/src/tools/communication-tools/idle-tool/idle-tool.ts b/packages/ai/src/tools/communication-tools/idle-tool/idle-tool.ts index ae2f04528..58348caa3 100644 --- a/packages/ai/src/tools/communication-tools/idle-tool/idle-tool.ts +++ b/packages/ai/src/tools/communication-tools/idle-tool/idle-tool.ts @@ -19,6 +19,7 @@ const IdleOutputSchema = z.object({ // Optional context for consistency with other tools const IdleContextSchema = z.object({ messageId: z.string().optional().describe('The message ID for tracking tool execution.'), + onToolEvent: z.any().optional(), }); export type IdleInput = z.infer; @@ -29,18 +30,28 @@ async function processIdle(): Promise { return { success: true }; } -function createIdleExecute() { +function createIdleExecute(context?: TAgentContext) { return wrapTraced( - async (): Promise => { - return await processIdle(); + async (args: IdleInput) => { + const result: IdleOutput = await processIdle(); + + // Emit typed tool event when idle tool completes + context?.onToolEvent?.({ + tool: 'idleTool', + event: 'complete', + result, + args, + }); + + return result; }, { name: 'idle-tool' } ); } // Factory: simple tool without streaming lifecycle -export function createIdleTool() { - const execute = createIdleExecute(); +export function createIdleTool(context?: TAgentContext) { + const execute = createIdleExecute(context); return tool({ description: diff --git a/packages/ai/src/tools/file-tools/bash-tool/bash-tool-execute.ts b/packages/ai/src/tools/file-tools/bash-tool/bash-tool-execute.ts index 7cefd86a9..ab4edad97 100644 --- a/packages/ai/src/tools/file-tools/bash-tool/bash-tool-execute.ts +++ b/packages/ai/src/tools/file-tools/bash-tool/bash-tool-execute.ts @@ -22,7 +22,8 @@ async function executeCommand( const timeoutId = setTimeout(() => controller.abort(), timeout); // Execute command using Bun.spawn - const proc = Bun.spawn(['bash', '-c', command], { + // Use full path to bash for reliability + const proc = Bun.spawn(['/bin/bash', '-c', command], { cwd: projectDirectory, stdout: 'pipe', stderr: 'pipe', @@ -113,11 +114,18 @@ async function executeCommand( */ export function createBashToolExecute(context: BashToolContext) { return async function execute(input: BashToolInput): Promise { - const { messageId, projectDirectory } = context; + const { messageId, projectDirectory, onToolEvent } = context; const { command, timeout } = input; console.info(`Executing bash command for message ${messageId}: ${command}`); + // Emit start event + onToolEvent?.({ + tool: 'bashTool', + event: 'start', + args: input, + }); + const commandTimeout = Math.min(timeout ?? DEFAULT_TIMEOUT, MAX_TIMEOUT); const result = await executeCommand(command, commandTimeout, projectDirectory); @@ -128,6 +136,14 @@ export function createBashToolExecute(context: BashToolContext) { console.error(`Command failed: ${command}`, result.error); } + // Emit complete event + onToolEvent?.({ + tool: 'bashTool', + event: 'complete', + result, + args: input, + }); + return result; }; } diff --git a/packages/ai/src/tools/file-tools/bash-tool/bash-tool.ts b/packages/ai/src/tools/file-tools/bash-tool/bash-tool.ts index e72fcb207..e781dee29 100644 --- a/packages/ai/src/tools/file-tools/bash-tool/bash-tool.ts +++ b/packages/ai/src/tools/file-tools/bash-tool/bash-tool.ts @@ -28,6 +28,7 @@ export const BashToolOutputSchema = z.object({ const BashToolContextSchema = z.object({ messageId: z.string().describe('The message ID for database updates'), projectDirectory: z.string().describe('The root directory of the project'), + onToolEvent: z.any().optional(), }); export type BashToolInput = z.infer; diff --git a/packages/ai/src/tools/file-tools/grep-tool/grep-tool.test.ts b/packages/ai/src/tools/file-tools/grep-tool/grep-tool.test.ts index 33cb7a184..5e7b1f20d 100644 --- a/packages/ai/src/tools/file-tools/grep-tool/grep-tool.test.ts +++ b/packages/ai/src/tools/file-tools/grep-tool/grep-tool.test.ts @@ -91,7 +91,7 @@ async function executeRipgrep( */ export function createGrepSearchToolExecute(context: GrepToolContext) { return async function execute(input: GrepToolInput): Promise { - const { messageId, projectDirectory } = context; + const { messageId, projectDirectory, onToolEvent } = context; const { pattern, path, glob } = input; if (!pattern) { @@ -102,6 +102,13 @@ export function createGrepSearchToolExecute(context: GrepToolContext) { console.info(`Searching for pattern "${pattern}" in ${searchPath} for message ${messageId}`); + // Emit start event + onToolEvent?.({ + tool: 'grepTool', + event: 'start', + args: input, + }); + try { // Execute ripgrep const matches = await executeRipgrep(pattern, searchPath, glob); @@ -117,23 +124,43 @@ export function createGrepSearchToolExecute(context: GrepToolContext) { `Search complete: ${finalMatches.length} matches found${truncated ? ' (truncated)' : ''}` ); - return { + const result = { pattern, matches: finalMatches, totalMatches: finalMatches.length, truncated, }; + + // Emit complete event + onToolEvent?.({ + tool: 'grepTool', + event: 'complete', + result, + args: input, + }); + + return result; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; console.error(`Grep search failed:`, errorMessage); // Return empty results on error - return { + const result = { pattern, matches: [], totalMatches: 0, truncated: false, }; + + // Emit complete event even on error + onToolEvent?.({ + tool: 'grepTool', + event: 'complete', + result, + args: input, + }); + + return result; } }; } diff --git a/packages/ai/src/tools/file-tools/grep-tool/grep-tool.ts b/packages/ai/src/tools/file-tools/grep-tool/grep-tool.ts index 828460eea..82a38dc61 100644 --- a/packages/ai/src/tools/file-tools/grep-tool/grep-tool.ts +++ b/packages/ai/src/tools/file-tools/grep-tool/grep-tool.ts @@ -34,6 +34,7 @@ const GrepToolOutputSchema = z.object({ const GrepSearchContextSchema = z.object({ messageId: z.string().describe('The message ID for database updates'), projectDirectory: z.string().describe('The root directory of the project'), + onToolEvent: z.any().optional(), }); export type GrepToolInput = z.infer; diff --git a/packages/ai/src/tools/file-tools/ls-tool/ls-tool-execute.ts b/packages/ai/src/tools/file-tools/ls-tool/ls-tool-execute.ts index d061e9899..5e0d09fa6 100644 --- a/packages/ai/src/tools/file-tools/ls-tool/ls-tool-execute.ts +++ b/packages/ai/src/tools/file-tools/ls-tool/ls-tool-execute.ts @@ -159,7 +159,7 @@ function renderDir( */ export function createLsToolExecute(context: LsToolContext) { return async function execute(input: LsToolInput): Promise { - const { messageId, projectDirectory } = context; + const { messageId, projectDirectory, onToolEvent } = context; const searchPath = path.resolve( projectDirectory, input.path || projectDirectory @@ -167,11 +167,18 @@ export function createLsToolExecute(context: LsToolContext) { console.info(`Listing directory ${searchPath} for message ${messageId}`); + // Emit start event + onToolEvent?.({ + tool: 'lsTool', + event: 'start', + args: input, + }); + try { // Validate the path exists and is a directory const stats = await stat(searchPath); if (!stats.isDirectory()) { - return { + const result = { success: false, path: searchPath, output: '', @@ -179,6 +186,16 @@ export function createLsToolExecute(context: LsToolContext) { truncated: false, errorMessage: `Path is not a directory: ${searchPath}`, }; + + // Emit complete event + onToolEvent?.({ + tool: 'lsTool', + event: 'complete', + result, + args: input, + }); + + return result; } // Build ignore patterns @@ -225,19 +242,29 @@ export function createLsToolExecute(context: LsToolContext) { `Listed ${files.length} file(s) in ${searchPath}${files.length >= LIMIT ? ' (truncated)' : ''}` ); - return { + const result = { success: true, path: searchPath, output, count: files.length, truncated: files.length >= LIMIT, }; + + // Emit complete event + onToolEvent?.({ + tool: 'lsTool', + event: 'complete', + result, + args: input, + }); + + return result; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; console.error(`Error listing directory ${searchPath}:`, errorMessage); - return { + const result = { success: false, path: searchPath, output: '', @@ -245,6 +272,16 @@ export function createLsToolExecute(context: LsToolContext) { truncated: false, errorMessage, }; + + // Emit complete event even on error + onToolEvent?.({ + tool: 'lsTool', + event: 'complete', + result, + args: input, + }); + + return result; } }; } diff --git a/packages/ai/src/tools/file-tools/ls-tool/ls-tool.ts b/packages/ai/src/tools/file-tools/ls-tool/ls-tool.ts index 222147f4f..cfd77ea8a 100644 --- a/packages/ai/src/tools/file-tools/ls-tool/ls-tool.ts +++ b/packages/ai/src/tools/file-tools/ls-tool/ls-tool.ts @@ -28,6 +28,7 @@ export const LsToolOutputSchema = z.object({ export const LsToolContextSchema = z.object({ messageId: z.string().describe('The message ID for database updates'), projectDirectory: z.string().describe('The root directory of the project'), + onToolEvent: z.any().optional(), }); export type LsToolInput = z.infer;