feat: display tool calls and reasoning in channel output (#302)

This commit is contained in:
Cameron
2026-02-23 09:15:43 -08:00
committed by GitHub
parent a06641b08d
commit 0544102fe3
5 changed files with 101 additions and 2 deletions

View File

@@ -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:

View File

@@ -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.)

View File

@@ -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<string, unknown> | 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<string, unknown> } | null = null;
let retryInfo: { attempt: number; maxAttempts: number; reason: string } | null = null;
let reasoningBuffer = '';
const msgTypeCounts: Record<string, number> = {};
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.

View File

@@ -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;

View File

@@ -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: {