diff --git a/lettabot.example.yaml b/lettabot.example.yaml index 647a671..b1dbbe9 100644 --- a/lettabot.example.yaml +++ b/lettabot.example.yaml @@ -74,6 +74,10 @@ features: intervalMin: 30 # skipRecentUserMin: 5 # Skip auto-heartbeats for N minutes after user message (0 disables) # memfs: true # Enable memory filesystem (git-backed context repository). Syncs memory blocks to local files. + # display: + # showToolCalls: false # Show tool invocations in chat (e.g. "Using tool: Read (file_path: ...)") + # showReasoning: false # Show agent reasoning/thinking in chat + # reasoningMaxChars: 0 # Truncate reasoning to N chars (0 = no limit, default) # Attachment handling (defaults to 20MB if omitted) # attachments: diff --git a/src/config/types.ts b/src/config/types.ts index 9e73b7e..06a2210 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -25,6 +25,18 @@ export function serverModeLabel(mode?: ServerMode): string { return canonicalizeServerMode(mode); } +/** + * Display configuration for tool calls and reasoning in channel output. + */ +export interface DisplayConfig { + /** Show tool invocations in channel output (default: false) */ + showToolCalls?: boolean; + /** Show agent reasoning/thinking in channel output (default: false) */ + showReasoning?: boolean; + /** Truncate reasoning to N characters (default: 0 = no limit) */ + reasoningMaxChars?: number; +} + /** * Configuration for a single agent in multi-agent mode. * Each agent has its own name, channels, and features. @@ -65,6 +77,7 @@ export interface AgentConfig { }; memfs?: boolean; // Enable memory filesystem (git-backed context repository) for SDK sessions maxToolCalls?: number; + display?: DisplayConfig; }; /** Polling config */ polling?: PollingYamlConfig; @@ -138,6 +151,7 @@ export interface LettaBotConfig { inlineImages?: boolean; // Send images directly to the LLM (default: true). Set false to only send file paths. memfs?: boolean; // Enable memory filesystem (git-backed context repository) for SDK sessions maxToolCalls?: number; // Abort if agent calls this many tools in one turn (default: 100) + display?: DisplayConfig; // Show tool calls / reasoning in channel output }; // Polling - system-level background checks (Gmail, etc.) diff --git a/src/core/bot.ts b/src/core/bot.ts index 1c4b096..bf18464 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -211,6 +211,50 @@ export class LettaBot implements AgentSession { return `${this.config.displayName}: ${text}`; } + /** + * Format a tool call for channel display. + * Shows tool name + abbreviated key parameters. + */ + private formatToolCallDisplay(streamMsg: StreamMsg): string { + const name = streamMsg.toolName || 'unknown'; + const params = this.abbreviateToolInput(streamMsg); + return params ? `> **Tool:** ${name} (${params})` : `> **Tool:** ${name}`; + } + + /** + * Extract a brief parameter summary from a tool call's input. + */ + private abbreviateToolInput(streamMsg: StreamMsg): string { + const input = streamMsg.toolInput as Record | undefined; + if (!input || typeof input !== 'object') return ''; + const entries = Object.entries(input).slice(0, 2); + return entries + .map(([k, v]) => { + let str: string; + try { + str = typeof v === 'string' ? v : (JSON.stringify(v) ?? String(v)); + } catch { + str = String(v); + } + const truncated = str.length > 80 ? str.slice(0, 77) + '...' : str; + return `${k}: ${truncated}`; + }) + .join(', '); + } + + /** + * Format reasoning text for channel display, respecting truncation config. + */ + private formatReasoningDisplay(text: string): string { + const maxChars = this.config.display?.reasoningMaxChars ?? 0; + const truncated = maxChars > 0 && text.length > maxChars + ? text.slice(0, maxChars) + '...' + : text; + // Prefix every line with "> " so the whole block renders as a blockquote + const lines = truncated.split('\n').map(line => `> ${line}`); + return `> **Thinking**\n${lines.join('\n')}`; + } + // ========================================================================= // Session options (shared by processMessage and sendToAgent) // ========================================================================= @@ -1064,6 +1108,7 @@ export class LettaBot implements AgentSession { let sawNonAssistantSinceLastUuid = false; let lastErrorDetail: { message: string; stopReason: string; apiError?: Record } | null = null; let retryInfo: { attempt: number; maxAttempts: number; reason: string } | null = null; + let reasoningBuffer = ''; const msgTypeCounts: Record = {}; const finalizeMessage = async () => { @@ -1122,6 +1167,20 @@ export class LettaBot implements AgentSession { if (lastMsgType && lastMsgType !== streamMsg.type && response.trim() && streamMsg.type !== 'result') { await finalizeMessage(); } + + // Flush reasoning buffer when type changes away from reasoning + if (lastMsgType === 'reasoning' && streamMsg.type !== 'reasoning' && reasoningBuffer.trim()) { + if (this.config.display?.showReasoning && !suppressDelivery) { + try { + const text = this.formatReasoningDisplay(reasoningBuffer); + await adapter.sendMessage({ chatId: msg.chatId, text, threadId: msg.threadId }); + sentAnyMessage = true; + } catch (err) { + console.warn('[Bot] Failed to send reasoning display:', err instanceof Error ? err.message : err); + } + } + reasoningBuffer = ''; + } // Tool loop detection const maxToolCalls = this.config.maxToolCalls ?? 100; @@ -1137,14 +1196,30 @@ export class LettaBot implements AgentSession { this.syncTodoToolCall(streamMsg); console.log(`[Stream] >>> TOOL CALL: ${streamMsg.toolName || 'unknown'} (id: ${streamMsg.toolCallId?.slice(0, 12) || '?'})`); sawNonAssistantSinceLastUuid = true; + // Display tool call in channel if configured + if (this.config.display?.showToolCalls && !suppressDelivery) { + try { + const text = this.formatToolCallDisplay(streamMsg); + await adapter.sendMessage({ chatId: msg.chatId, text, threadId: msg.threadId }); + sentAnyMessage = true; + } catch (err) { + console.warn('[Bot] Failed to send tool call display:', err instanceof Error ? err.message : err); + } + } } else if (streamMsg.type === 'tool_result') { console.log(`[Stream] <<< TOOL RESULT: error=${streamMsg.isError}, len=${(streamMsg as any).content?.length || 0}`); sawNonAssistantSinceLastUuid = true; } else if (streamMsg.type === 'assistant' && lastMsgType !== 'assistant') { console.log(`[Bot] Generating response...`); - } else if (streamMsg.type === 'reasoning' && lastMsgType !== 'reasoning') { - console.log(`[Bot] Reasoning...`); + } else if (streamMsg.type === 'reasoning') { + if (lastMsgType !== 'reasoning') { + console.log(`[Bot] Reasoning...`); + } sawNonAssistantSinceLastUuid = true; + // Accumulate reasoning content for display + if (this.config.display?.showReasoning) { + reasoningBuffer += streamMsg.content || ''; + } } else if (streamMsg.type === 'error') { // SDK now surfaces error detail that was previously dropped. // Store for use in the user-facing error message. diff --git a/src/core/types.ts b/src/core/types.ts index 4f35ad7..0d18eb0 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -128,6 +128,11 @@ export interface BotConfig { // Display displayName?: string; // Prefix outbound messages (e.g. "💜 Signo") + display?: { + showToolCalls?: boolean; // Show tool invocations in channel output + showReasoning?: boolean; // Show agent reasoning/thinking in channel output + reasoningMaxChars?: number; // Truncate reasoning to N chars (default: 0 = no limit) + }; // Skills skills?: SkillsConfig; diff --git a/src/main.ts b/src/main.ts index 7230618..936c3a1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -531,6 +531,7 @@ async function main() { displayName: agentConfig.displayName, maxToolCalls: agentConfig.features?.maxToolCalls, memfs: resolvedMemfs, + display: agentConfig.features?.display, conversationMode: agentConfig.conversations?.mode || 'shared', heartbeatConversation: agentConfig.conversations?.heartbeat || 'last-active', skills: {