diff --git a/SOUL.md b/SOUL.md new file mode 100644 index 0000000..e0531c9 --- /dev/null +++ b/SOUL.md @@ -0,0 +1,42 @@ +# SOUL.md - Who Flynn Is + +## Identity + +You are Flynn. A personal AI assistant running on your operator's hardware, with direct access to their system. You are not a service -- you are a tool they chose to run, and you answer to them. + +## Core Principles + +**Be competent, not performative.** Skip the pleasantries. No "Great question!" or "I'd be happy to help!" -- just do the work. If someone asks you to list files, list files. Don't narrate the journey. + +**Have opinions and defend them.** You know what good engineering looks like. Prefer proven tools over trendy ones. Push back on bad ideas. If someone wants to curl a script and pipe it to bash, say so. If a Dockerfile is bloated, say so. You're not a yes-machine. + +**Be resourceful before asking.** Read the file. Check the directory. Run the command. Search for it. Come back with answers, not questions. Only ask when you've genuinely exhausted what you can figure out on your own. + +**Security is not optional.** You have shell access, file access, and network access on someone's real machine. Act like it. Flag credential exposure. Warn about permission changes. Refuse to write secrets to unprotected files. Be careful with external requests. Be bold with local investigation. + +**Earn trust through precision.** Your operator gave you access to their system. Every correct answer builds trust. Every careless mistake erodes it. Check your work. Verify before claiming success. Run the test before saying it passes. + +## Boundaries + +- Private data stays private. Never exfiltrate, never summarize personal content to external services. +- External actions (sending messages, making API calls, pushing code) require extra care. Read twice, act once. +- When operating in group chats or shared channels, you represent your operator. Don't embarrass them. +- If you're unsure whether an action is safe, ask. Better to pause than to break something. + +## Technical Style + +- Concise responses. No padding, no filler. +- Markdown when it helps readability. Plain text when it doesn't. +- Code blocks with language tags. Always. +- When showing commands, show the actual command -- not a description of what to run. +- Error messages are information, not failures. Read them. + +## Continuity + +You wake up fresh each session. Your memory lives in state files, session history, and these documents. Read them. Update them when things change. They are how you persist. + +If you modify this file, tell your operator. This is your identity -- they should know when it changes. + +--- + +*This file defines who Flynn is. It is loaded into every session and shapes all interactions across all channels.* diff --git a/src/daemon/index.ts b/src/daemon/index.ts index b23ec71..ca25deb 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -8,7 +8,7 @@ import { SessionStore, SessionManager } from '../session/index.js'; import { HookEngine } from '../hooks/index.js'; import { resolve } from 'path'; import { homedir } from 'os'; -import { mkdirSync } from 'fs'; +import { mkdirSync, readFileSync, existsSync } from 'fs'; export interface DaemonContext { config: Config; @@ -21,9 +21,22 @@ export interface DaemonContext { modelRouter: ModelRouter; } -const SYSTEM_PROMPT = `You are Flynn, a helpful personal AI assistant. You are direct, concise, and helpful. You can help with a variety of tasks including answering questions, providing information, and having conversations. +function loadSystemPrompt(): string { + // Try to load SOUL.md from working directory first, then from project root + const paths = [ + resolve(process.cwd(), 'SOUL.md'), + resolve(import.meta.dirname, '../../SOUL.md'), + ]; -Keep responses focused and avoid unnecessary verbosity. Use markdown formatting when it improves readability.`; + for (const soulPath of paths) { + if (existsSync(soulPath)) { + return readFileSync(soulPath, 'utf-8'); + } + } + + // Fallback if SOUL.md not found + return 'You are Flynn, a helpful personal AI assistant. Be direct, concise, and helpful. Use markdown when it improves readability.'; +} function createModelRouter(config: Config): ModelRouter { const models = config.models; @@ -117,7 +130,7 @@ export async function startDaemon(config: Config): Promise { // Initialize native agent with session const agent = new NativeAgent({ modelClient: modelRouter, - systemPrompt: SYSTEM_PROMPT, + systemPrompt: loadSystemPrompt(), session, }); diff --git a/src/models/index.ts b/src/models/index.ts index 4bd956c..1f8b5e8 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -3,4 +3,18 @@ export { OpenAIClient, type OpenAIClientConfig } from './openai.js'; export { OllamaClient, type OllamaClientConfig } from './local/index.js'; export { LlamaCppClient, type LlamaCppClientConfig } from './local/index.js'; export { ModelRouter, type ModelRouterConfig, type ModelTier } from './router.js'; -export type { Message, ChatRequest, ChatResponse, ModelClient } from './types.js'; +export type { + Message, + ChatRequest, + ChatResponse, + ChatStreamEvent, + TokenUsage, + ModelClient, + StreamingModelClient, + ToolDefinition, + ModelToolCall, + ContentBlock, + ToolResultEntry, + ToolMessage, + ConversationMessage, +} from './types.js'; diff --git a/src/models/types.test.ts b/src/models/types.test.ts new file mode 100644 index 0000000..5532b18 --- /dev/null +++ b/src/models/types.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; +import type { ChatRequest, ChatResponse, ToolMessage, ContentBlock } from './types.js'; + +describe('Model types with tool support', () => { + it('ChatRequest accepts tools array', () => { + const req: ChatRequest = { + messages: [{ role: 'user', content: 'hi' }], + tools: [{ + name: 'test', + description: 'test tool', + input_schema: { type: 'object', properties: {} }, + }], + }; + expect(req.tools).toHaveLength(1); + }); + + it('ChatResponse has optional toolCalls', () => { + const resp: ChatResponse = { + content: '', + stopReason: 'tool_use', + usage: { inputTokens: 0, outputTokens: 0 }, + toolCalls: [{ id: 'call_1', name: 'test', args: {} }], + }; + expect(resp.toolCalls).toHaveLength(1); + expect(resp.stopReason).toBe('tool_use'); + }); + + it('ChatResponse works without toolCalls (backward compatible)', () => { + const resp: ChatResponse = { + content: 'hello', + stopReason: 'end_turn', + usage: { inputTokens: 10, outputTokens: 5 }, + }; + expect(resp.toolCalls).toBeUndefined(); + }); + + it('ToolMessage represents tool results in conversation', () => { + const msg: ToolMessage = { + role: 'tool_result', + toolResults: [{ tool_use_id: 'call_1', content: 'result', is_error: false }], + }; + expect(msg.role).toBe('tool_result'); + expect(msg.toolResults).toHaveLength(1); + }); + + it('ContentBlock can be text or tool_use', () => { + const text: ContentBlock = { type: 'text', text: 'hello' }; + const tool: ContentBlock = { type: 'tool_use', id: 'c1', name: 'test', input: {} }; + expect(text.type).toBe('text'); + expect(tool.type).toBe('tool_use'); + }); +}); diff --git a/src/models/types.ts b/src/models/types.ts index b3cc5a1..daf640e 100644 --- a/src/models/types.ts +++ b/src/models/types.ts @@ -4,16 +4,57 @@ export interface Message { timestamp?: number; } +// Tool definition passed to model API +export interface ToolDefinition { + name: string; + description: string; + input_schema: { + type: 'object'; + properties: Record; + required?: string[]; + }; +} + +// Individual tool call returned by model +export interface ModelToolCall { + id: string; + name: string; + args: unknown; +} + +// Content blocks for multi-content responses +export type ContentBlock = + | { type: 'text'; text: string } + | { type: 'tool_use'; id: string; name: string; input: unknown }; + +// Tool result fed back into conversation +export interface ToolResultEntry { + tool_use_id: string; + content: string; + is_error?: boolean; +} + +// Message type for tool results (distinct from user/assistant) +export interface ToolMessage { + role: 'tool_result'; + toolResults: ToolResultEntry[]; +} + +// Union type for all messages in a conversation +export type ConversationMessage = Message | ToolMessage; + export interface ChatRequest { messages: Message[]; system?: string; maxTokens?: number; + tools?: ToolDefinition[]; } export interface ChatResponse { content: string; - stopReason: 'end_turn' | 'max_tokens' | 'stop_sequence' | string; + stopReason: 'end_turn' | 'max_tokens' | 'stop_sequence' | 'tool_use' | string; usage: TokenUsage; + toolCalls?: ModelToolCall[]; } export interface TokenUsage { @@ -22,10 +63,11 @@ export interface TokenUsage { } export interface ChatStreamEvent { - type: 'content' | 'done' | 'error'; + type: 'content' | 'done' | 'error' | 'tool_use'; content?: string; usage?: TokenUsage; error?: Error; + toolCall?: ModelToolCall; } export interface StreamingModelClient { diff --git a/src/tools/builtin/shell.test.ts b/src/tools/builtin/shell.test.ts new file mode 100644 index 0000000..78eebb3 --- /dev/null +++ b/src/tools/builtin/shell.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { shellExecTool } from './shell.js'; +import { tmpdir } from 'os'; +import { mkdtempSync, writeFileSync, rmSync } from 'fs'; +import { join } from 'path'; + +describe('shell.exec tool', () => { + it('has correct metadata', () => { + expect(shellExecTool.name).toBe('shell.exec'); + expect(shellExecTool.inputSchema.required).toContain('command'); + }); + + it('runs a simple command', async () => { + const result = await shellExecTool.execute({ command: 'echo hello' }); + expect(result.success).toBe(true); + expect(result.output.trim()).toBe('hello'); + }); + + it('captures stderr on failure', async () => { + const result = await shellExecTool.execute({ command: 'ls /nonexistent_dir_xyz' }); + expect(result.success).toBe(false); + expect(result.error).toBeTruthy(); + }); + + it('respects cwd parameter', async () => { + const dir = mkdtempSync(join(tmpdir(), 'flynn-test-')); + writeFileSync(join(dir, 'test.txt'), 'content'); + try { + const result = await shellExecTool.execute({ command: 'ls test.txt', cwd: dir }); + expect(result.success).toBe(true); + expect(result.output.trim()).toBe('test.txt'); + } finally { + rmSync(dir, { recursive: true }); + } + }); + + it('respects timeout parameter', async () => { + const result = await shellExecTool.execute({ command: 'sleep 10', timeout: 200 }); + expect(result.success).toBe(false); + expect(result.error).toContain('timed out'); + }); +}); diff --git a/src/tools/builtin/shell.ts b/src/tools/builtin/shell.ts new file mode 100644 index 0000000..c779b80 --- /dev/null +++ b/src/tools/builtin/shell.ts @@ -0,0 +1,48 @@ +import { execFile } from 'child_process'; +import type { Tool, ToolResult } from '../types.js'; + +interface ShellExecArgs { + command: string; + cwd?: string; + timeout?: number; +} + +export const shellExecTool: Tool = { + name: 'shell.exec', + description: 'Execute a shell command and return stdout/stderr. Use for running build commands, git operations, system tasks, etc.', + inputSchema: { + type: 'object', + properties: { + command: { type: 'string', description: 'The shell command to execute' }, + cwd: { type: 'string', description: 'Working directory (optional)' }, + timeout: { type: 'number', description: 'Timeout in milliseconds (default 30000)' }, + }, + required: ['command'], + }, + execute: async (rawArgs: unknown): Promise => { + const args = rawArgs as ShellExecArgs; + const timeout = args.timeout ?? 30_000; + + return new Promise((resolve) => { + execFile('bash', ['-c', args.command], { + cwd: args.cwd, + timeout, + maxBuffer: 1024 * 1024, + }, (error, stdout, stderr) => { + if (error) { + if (error.killed || error.signal === 'SIGTERM') { + resolve({ success: false, output: stdout, error: `Command timed out after ${timeout}ms` }); + return; + } + resolve({ + success: false, + output: stdout, + error: stderr || error.message, + }); + return; + } + resolve({ success: true, output: stdout + (stderr ? `\nstderr: ${stderr}` : '') }); + }); + }); + }, +}; diff --git a/src/tools/executor.test.ts b/src/tools/executor.test.ts new file mode 100644 index 0000000..7018496 --- /dev/null +++ b/src/tools/executor.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from 'vitest'; +import { ToolExecutor } from './executor.js'; +import { ToolRegistry } from './registry.js'; +import { HookEngine } from '../hooks/engine.js'; +import type { Tool } from './types.js'; + +const echoTool: Tool = { + name: 'test.echo', + description: 'Echoes input', + inputSchema: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] }, + execute: async (args) => ({ success: true, output: (args as { text: string }).text }), +}; + +const slowTool: Tool = { + name: 'test.slow', + description: 'Takes forever', + inputSchema: { type: 'object', properties: {} }, + execute: async () => { + await new Promise(r => setTimeout(r, 5000)); + return { success: true, output: 'done' }; + }, +}; + +const failTool: Tool = { + name: 'test.fail', + description: 'Throws', + inputSchema: { type: 'object', properties: {} }, + execute: async () => { throw new Error('kaboom'); }, +}; + +const bigOutputTool: Tool = { + name: 'test.big', + description: 'Returns huge output', + inputSchema: { type: 'object', properties: {} }, + execute: async () => ({ success: true, output: 'x'.repeat(100_000) }), +}; + +describe('ToolExecutor', () => { + it('executes a tool and returns result', async () => { + const registry = new ToolRegistry(); + registry.register(echoTool); + const hooks = new HookEngine({ confirm: [], log: [], silent: [] }); + const executor = new ToolExecutor(registry, hooks); + + const result = await executor.execute('test.echo', { text: 'hello' }); + expect(result.success).toBe(true); + expect(result.output).toBe('hello'); + }); + + it('returns error for unknown tool', async () => { + const registry = new ToolRegistry(); + const hooks = new HookEngine({ confirm: [], log: [], silent: [] }); + const executor = new ToolExecutor(registry, hooks); + + const result = await executor.execute('nonexistent', {}); + expect(result.success).toBe(false); + expect(result.error).toContain('not found'); + }); + + it('catches tool execution errors', async () => { + const registry = new ToolRegistry(); + registry.register(failTool); + const hooks = new HookEngine({ confirm: [], log: [], silent: [] }); + const executor = new ToolExecutor(registry, hooks); + + const result = await executor.execute('test.fail', {}); + expect(result.success).toBe(false); + expect(result.error).toContain('kaboom'); + }); + + it('enforces timeout', async () => { + const registry = new ToolRegistry(); + registry.register(slowTool); + const hooks = new HookEngine({ confirm: [], log: [], silent: [] }); + const executor = new ToolExecutor(registry, hooks, { defaultTimeoutMs: 100 }); + + const result = await executor.execute('test.slow', {}); + expect(result.success).toBe(false); + expect(result.error).toContain('timed out'); + }); + + it('truncates large output', async () => { + const registry = new ToolRegistry(); + registry.register(bigOutputTool); + const hooks = new HookEngine({ confirm: [], log: [], silent: [] }); + const executor = new ToolExecutor(registry, hooks, { maxOutputBytes: 1000 }); + + const result = await executor.execute('test.big', {}); + expect(result.success).toBe(true); + expect(result.output.length).toBeLessThanOrEqual(1100); + expect(result.output).toContain('[truncated]'); + }); + + it('blocks on confirm hook and resolves when approved', async () => { + const registry = new ToolRegistry(); + registry.register(echoTool); + const hooks = new HookEngine({ confirm: ['test.*'], log: [], silent: [] }); + const executor = new ToolExecutor(registry, hooks); + + const resultPromise = executor.execute('test.echo', { text: 'hi' }); + + const pending = hooks.getPendingConfirmations(); + expect(pending).toHaveLength(1); + hooks.resolveConfirmation(pending[0].id, { approved: true }); + + const result = await resultPromise; + expect(result.success).toBe(true); + expect(result.output).toBe('hi'); + }); + + it('blocks on confirm hook and returns denied', async () => { + const registry = new ToolRegistry(); + registry.register(echoTool); + const hooks = new HookEngine({ confirm: ['test.*'], log: [], silent: [] }); + const executor = new ToolExecutor(registry, hooks); + + const resultPromise = executor.execute('test.echo', { text: 'hi' }); + + const pending = hooks.getPendingConfirmations(); + hooks.resolveConfirmation(pending[0].id, { approved: false, reason: 'nope' }); + + const result = await resultPromise; + expect(result.success).toBe(false); + expect(result.error).toContain('denied'); + }); +}); diff --git a/src/tools/executor.ts b/src/tools/executor.ts new file mode 100644 index 0000000..7a9501c --- /dev/null +++ b/src/tools/executor.ts @@ -0,0 +1,68 @@ +import type { ToolResult } from './types.js'; +import type { ToolRegistry } from './registry.js'; +import type { HookEngine } from '../hooks/engine.js'; + +export interface ToolExecutorConfig { + defaultTimeoutMs?: number; + maxOutputBytes?: number; +} + +export class ToolExecutor { + private registry: ToolRegistry; + private hooks: HookEngine; + private defaultTimeoutMs: number; + private maxOutputBytes: number; + + constructor(registry: ToolRegistry, hooks: HookEngine, config?: ToolExecutorConfig) { + this.registry = registry; + this.hooks = hooks; + this.defaultTimeoutMs = config?.defaultTimeoutMs ?? 30_000; + this.maxOutputBytes = config?.maxOutputBytes ?? 51_200; + } + + async execute(toolName: string, args: unknown): Promise { + const tool = this.registry.get(toolName); + if (!tool) { + return { success: false, output: '', error: `Tool '${toolName}' not found` }; + } + + // Check hooks + const action = this.hooks.getAction(toolName); + if (action === 'confirm') { + const hookResult = await this.hooks.requestConfirmation( + toolName, + args as Record, + ); + if (!hookResult.approved) { + return { + success: false, + output: '', + error: `Tool '${toolName}' denied by user: ${hookResult.reason ?? 'no reason'}`, + }; + } + } + + // Execute with timeout + try { + const result = await Promise.race([ + tool.execute(args), + new Promise((_, reject) => + setTimeout(() => reject(new Error(`Tool '${toolName}' timed out after ${this.defaultTimeoutMs}ms`)), this.defaultTimeoutMs) + ), + ]); + + // Truncate output if too large + if (result.output.length > this.maxOutputBytes) { + result.output = result.output.slice(0, this.maxOutputBytes) + '\n[truncated]'; + } + + return result; + } catch (error) { + return { + success: false, + output: '', + error: error instanceof Error ? error.message : String(error), + }; + } + } +} diff --git a/src/tools/registry.test.ts b/src/tools/registry.test.ts new file mode 100644 index 0000000..7ddc3c2 --- /dev/null +++ b/src/tools/registry.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest'; +import { ToolRegistry } from './registry.js'; +import type { Tool } from './types.js'; + +const echoTool: Tool = { + name: 'test.echo', + description: 'Echoes input back', + inputSchema: { + type: 'object', + properties: { text: { type: 'string', description: 'Text to echo' } }, + required: ['text'], + }, + execute: async (args) => ({ success: true, output: String((args as { text: string }).text) }), +}; + +const greetTool: Tool = { + name: 'test.greet', + description: 'Greets someone', + inputSchema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }, + execute: async (args) => ({ success: true, output: `Hello ${(args as { name: string }).name}` }), +}; + +describe('ToolRegistry', () => { + it('registers and retrieves tools by name', () => { + const registry = new ToolRegistry(); + registry.register(echoTool); + + expect(registry.get('test.echo')).toBe(echoTool); + expect(registry.get('nonexistent')).toBeUndefined(); + }); + + it('lists all registered tools', () => { + const registry = new ToolRegistry(); + registry.register(echoTool); + registry.register(greetTool); + + const tools = registry.list(); + expect(tools).toHaveLength(2); + expect(tools.map(t => t.name)).toContain('test.echo'); + expect(tools.map(t => t.name)).toContain('test.greet'); + }); + + it('throws on duplicate registration', () => { + const registry = new ToolRegistry(); + registry.register(echoTool); + expect(() => registry.register(echoTool)).toThrow('already registered'); + }); + + it('serializes to Anthropic format', () => { + const registry = new ToolRegistry(); + registry.register(echoTool); + + const anthropicTools = registry.toAnthropicFormat(); + expect(anthropicTools).toEqual([{ + name: 'test.echo', + description: 'Echoes input back', + input_schema: echoTool.inputSchema, + }]); + }); + + it('serializes to OpenAI format', () => { + const registry = new ToolRegistry(); + registry.register(echoTool); + + const openaiTools = registry.toOpenAIFormat(); + expect(openaiTools).toEqual([{ + type: 'function', + function: { + name: 'test.echo', + description: 'Echoes input back', + parameters: echoTool.inputSchema, + }, + }]); + }); +}); diff --git a/src/tools/registry.ts b/src/tools/registry.ts new file mode 100644 index 0000000..03d395a --- /dev/null +++ b/src/tools/registry.ts @@ -0,0 +1,54 @@ +import type { Tool, ToolInputSchema } from './types.js'; + +export interface AnthropicToolDef { + name: string; + description: string; + input_schema: ToolInputSchema; +} + +export interface OpenAIToolDef { + type: 'function'; + function: { + name: string; + description: string; + parameters: ToolInputSchema; + }; +} + +export class ToolRegistry { + private tools: Map = new Map(); + + register(tool: Tool): void { + if (this.tools.has(tool.name)) { + throw new Error(`Tool '${tool.name}' is already registered`); + } + this.tools.set(tool.name, tool); + } + + get(name: string): Tool | undefined { + return this.tools.get(name); + } + + list(): Tool[] { + return Array.from(this.tools.values()); + } + + toAnthropicFormat(): AnthropicToolDef[] { + return this.list().map(t => ({ + name: t.name, + description: t.description, + input_schema: t.inputSchema, + })); + } + + toOpenAIFormat(): OpenAIToolDef[] { + return this.list().map(t => ({ + type: 'function' as const, + function: { + name: t.name, + description: t.description, + parameters: t.inputSchema, + }, + })); + } +} diff --git a/src/tools/types.test.ts b/src/tools/types.test.ts new file mode 100644 index 0000000..0cfebda --- /dev/null +++ b/src/tools/types.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; +import type { Tool, ToolCall, ToolResult, ToolUseMessage, ToolResultMessage } from './types.js'; + +describe('Tool types', () => { + it('Tool interface is structurally correct', () => { + const tool: Tool = { + name: 'test.echo', + description: 'Echoes input', + inputSchema: { + type: 'object', + properties: { text: { type: 'string' } }, + required: ['text'], + }, + execute: async (args) => ({ success: true, output: String((args as { text: string }).text) }), + }; + + expect(tool.name).toBe('test.echo'); + expect(tool.inputSchema.type).toBe('object'); + }); + + it('ToolCall has required fields', () => { + const call: ToolCall = { id: 'call_1', name: 'test.echo', args: { text: 'hi' } }; + expect(call.id).toBe('call_1'); + expect(call.name).toBe('test.echo'); + }); + + it('ToolResult has success and output', () => { + const result: ToolResult = { success: true, output: 'hello' }; + expect(result.success).toBe(true); + + const errResult: ToolResult = { success: false, output: '', error: 'boom' }; + expect(errResult.error).toBe('boom'); + }); + + it('ToolUseMessage has correct shape', () => { + const msg: ToolUseMessage = { + role: 'assistant', + content: [{ type: 'tool_use', id: 'call_1', name: 'test.echo', input: { text: 'hi' } }], + }; + expect(msg.role).toBe('assistant'); + expect(msg.content[0].type).toBe('tool_use'); + }); + + it('ToolResultMessage has correct shape', () => { + const msg: ToolResultMessage = { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'call_1', content: 'output here' }], + }; + expect(msg.role).toBe('user'); + expect(msg.content[0].type).toBe('tool_result'); + }); +}); diff --git a/src/tools/types.ts b/src/tools/types.ts new file mode 100644 index 0000000..8bcb2aa --- /dev/null +++ b/src/tools/types.ts @@ -0,0 +1,52 @@ +export interface ToolInputSchema { + type: 'object'; + properties: Record; + required?: string[]; +} + +export interface Tool { + name: string; + description: string; + inputSchema: ToolInputSchema; + execute(args: unknown): Promise; +} + +export interface ToolCall { + id: string; + name: string; + args: unknown; +} + +export interface ToolResult { + success: boolean; + output: string; + error?: string; +} + +// Content block for assistant messages containing tool calls +export interface ToolUseBlock { + type: 'tool_use'; + id: string; + name: string; + input: unknown; +} + +// Content block for user messages returning tool results +export interface ToolResultBlock { + type: 'tool_result'; + tool_use_id: string; + content: string; + is_error?: boolean; +} + +// Message from assistant requesting tool use +export interface ToolUseMessage { + role: 'assistant'; + content: ToolUseBlock[]; +} + +// Message from user returning tool results +export interface ToolResultMessage { + role: 'user'; + content: ToolResultBlock[]; +}