From 35f4cab0dc3c6e1bf72aba97d1d9223be716d065 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Mon, 9 Feb 2026 21:23:07 -0800 Subject: [PATCH] feat: add log-level system to suppress noisy fallback debug output Replace console.debug/log/warn calls in model router, retry, and daemon startup with a structured logger that respects a configurable log_level. Default level is 'info', suppressing verbose fallback debug messages in the TUI while keeping them available via config when needed. - Add src/logger.ts with debug/info/warn/error/silent levels - Wire log_level into config schema (default: 'info') - Initialize log level in both daemon and TUI startup paths - Convert all console.debug in router.ts and retry.ts to logger.debug - Convert console.log/warn in daemon/models.ts to logger.info/warn --- config/default.yaml | 4 ++++ src/cli/tui.ts | 3 +++ src/config/schema.ts | 4 ++++ src/daemon/index.ts | 4 ++++ src/daemon/models.ts | 11 ++++++----- src/logger.ts | 44 ++++++++++++++++++++++++++++++++++++++++++++ src/models/retry.ts | 4 +++- src/models/router.ts | 21 +++++++++++---------- 8 files changed, 79 insertions(+), 16 deletions(-) create mode 100644 src/logger.ts diff --git a/config/default.yaml b/config/default.yaml index c28e9d8..71e9f37 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -1,6 +1,10 @@ # Flynn Configuration # Copy to ~/.config/flynn/config.yaml and customize +# Log verbosity: debug | info | warn | error | silent (default: info) +# Set to 'debug' to see model fallback details. +# log_level: info + telegram: bot_token: ${FLYNN_TELEGRAM_TOKEN} allowed_chat_ids: [] # Add your Telegram chat ID diff --git a/src/cli/tui.ts b/src/cli/tui.ts index 3b0a5c9..3ccc228 100644 --- a/src/cli/tui.ts +++ b/src/cli/tui.ts @@ -4,6 +4,7 @@ import { loadConfigSafe, getConfigPath } from './shared.js'; import { existsSync, mkdirSync, readFileSync } from 'fs'; import { resolve } from 'path'; import { homedir } from 'os'; +import { setLogLevel } from '../logger.js'; // ANSI color codes for tool status display const toolColors = { @@ -43,6 +44,8 @@ export function registerTuiCommand(program: Command): void { // Dynamic imports to keep CLI startup fast const { SessionStore, SessionManager } = await import('../session/index.js'); + + setLogLevel(config.log_level); const { MinimalTui, startFullscreenTui } = await import('../frontends/tui/index.js'); const { NativeAgent } = await import('../backends/index.js'); const { ToolRegistry, ToolExecutor, allBuiltinTools, createWebSearchTools, createProcessTools, ProcessManager } = await import('../tools/index.js'); diff --git a/src/config/schema.ts b/src/config/schema.ts index f3764dd..7ff80ad 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -345,7 +345,10 @@ const sessionsSchema = z.object({ ttl: z.string().default('30d'), }).default({}); +const logLevelSchema = z.enum(['debug', 'info', 'warn', 'error', 'silent']).default('info'); + export const configSchema = z.object({ + log_level: logLevelSchema, telegram: telegramSchema, discord: discordSchema, slack: slackSchema, @@ -406,3 +409,4 @@ export type HeartbeatCheck = z.infer; export type EmbeddingConfig = z.infer; export type EmbeddingProvider = z.infer; export type PairingCodeConfig = z.infer; +export type LogLevel = z.infer; diff --git a/src/daemon/index.ts b/src/daemon/index.ts index 85d9479..ec66012 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -9,6 +9,7 @@ import type { AudioTranscriptionConfig } from '../models/media.js'; import type { ToolRegistry, ToolExecutor, BrowserManager } from '../tools/index.js'; import type { AgentConfigRegistry, AgentRouter } from '../agents/index.js'; import type { SandboxManager } from '../sandbox/index.js'; +import { setLogLevel } from '../logger.js'; // ── Daemon Modules ── import { Lifecycle } from './lifecycle.js'; @@ -51,6 +52,9 @@ export interface DaemonContext { } export async function startDaemon(config: Config): Promise { + // ── Log level ── + setLogLevel(config.log_level); + const lifecycle = new Lifecycle(); // ── Data & Sessions ── diff --git a/src/daemon/models.ts b/src/daemon/models.ts index a266659..fb23af3 100644 --- a/src/daemon/models.ts +++ b/src/daemon/models.ts @@ -1,6 +1,7 @@ import type { Config, ModelConfig } from '../config/index.js'; import { AnthropicClient, OpenAIClient, OllamaClient, LlamaCppClient, GeminiClient, BedrockClient, GitHubModelsClient, ModelRouter, DEFAULT_RETRY_CONFIG } from '../models/index.js'; import type { ModelClient, RetryConfig, ModelTier } from '../models/index.js'; +import { logger } from '../logger.js'; /** * Create a ModelClient from a provider config entry. @@ -166,7 +167,7 @@ export function createModelRouter(config: Config): ModelRouter { // Named provider from local_providers map fallbackChain.push(createClientFromConfig(models.local_providers[providerName])); } else { - console.warn(`Fallback chain entry "${providerName}" not found — skipping`); + logger.warn(`Fallback chain entry "${providerName}" not found — skipping`); } } @@ -211,13 +212,13 @@ export function createModelRouter(config: Config): ModelRouter { if (tierFallbacks.size > 0) { const tierNames = Array.from(tierFallbacks.keys()).join(', '); - console.log(`Per-tier fallbacks configured for: ${tierNames}`); + logger.info(`Per-tier fallbacks configured for: ${tierNames}`); } if (autoFallbackTiers.length > 0) { - console.log(`Auto same-model fallback (via GitHub Models) for: ${autoFallbackTiers.join(', ')}`); + logger.info(`Auto same-model fallback (via GitHub Models) for: ${autoFallbackTiers.join(', ')}`); } - console.log(`Model router: default=${models.default.provider}/${models.default.model}, ` + + logger.info(`Model router: default=${models.default.provider}/${models.default.model}, ` + `fallback=[${models.fallback_chain.join(', ')}]`); // Build retry config if enabled @@ -230,7 +231,7 @@ export function createModelRouter(config: Config): ModelRouter { } : undefined; if (retryConfig) { - console.log(`Retry policy: max_retries=${retryConfig.maxRetries}, initial_delay=${retryConfig.initialDelayMs}ms`); + logger.info(`Retry policy: max_retries=${retryConfig.maxRetries}, initial_delay=${retryConfig.initialDelayMs}ms`); } return new ModelRouter({ diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..1fa1424 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,44 @@ +/** Simple log-level utility. + * + * Default level is `info`, which suppresses `debug` output. + * Set to `debug` (via config or `setLevel()`) to see all messages. + */ + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'silent'; + +const LEVELS: Record = { + debug: 0, + info: 1, + warn: 2, + error: 3, + silent: 4, +}; + +let currentLevel: LogLevel = 'info'; + +export function setLogLevel(level: LogLevel): void { + currentLevel = level; +} + +export function getLogLevel(): LogLevel { + return currentLevel; +} + +function shouldLog(level: LogLevel): boolean { + return LEVELS[level] >= LEVELS[currentLevel]; +} + +export const logger = { + debug(...args: unknown[]): void { + if (shouldLog('debug')) console.debug(...args); + }, + info(...args: unknown[]): void { + if (shouldLog('info')) console.log(...args); + }, + warn(...args: unknown[]): void { + if (shouldLog('warn')) console.warn(...args); + }, + error(...args: unknown[]): void { + if (shouldLog('error')) console.error(...args); + }, +}; diff --git a/src/models/retry.ts b/src/models/retry.ts index 40bfa07..5fc2fbc 100644 --- a/src/models/retry.ts +++ b/src/models/retry.ts @@ -1,3 +1,5 @@ +import { logger } from '../logger.js'; + export interface RetryConfig { /** Maximum number of retry attempts (default: 3). Does not count the initial attempt. */ maxRetries: number; @@ -60,7 +62,7 @@ export async function withRetry( const delay = Math.min(baseDelay, config.maxDelayMs); const jitter = delay * (0.5 + Math.random() * 0.5); // 50-100% of delay - console.debug( + logger.debug( `[retry] ${label ?? 'operation'} attempt ${attempt + 1}/${config.maxRetries} failed: ${lastError.message}. Retrying in ${Math.round(jitter)}ms...`, ); diff --git a/src/models/router.ts b/src/models/router.ts index fbd6f64..9f80cea 100644 --- a/src/models/router.ts +++ b/src/models/router.ts @@ -1,6 +1,7 @@ import type { ChatRequest, ChatResponse, ChatStreamEvent, ModelClient } from './types.js'; import { withRetry } from './retry.js'; import type { RetryConfig } from './retry.js'; +import { logger } from '../logger.js'; export type ModelTier = 'fast' | 'default' | 'complex' | 'local'; @@ -76,7 +77,7 @@ export class ModelRouter implements ModelClient { return await primaryClient.chat(request); } catch (error) { errors.push(error instanceof Error ? error : new Error(String(error))); - console.debug(`Primary model failed: ${errors[0].message}`); + logger.debug(`Primary model failed: ${errors[0].message}`); } // Try tier-specific fallbacks first @@ -84,12 +85,12 @@ export class ModelRouter implements ModelClient { for (let i = 0; i < tierFallbackList.length; i++) { try { const reason = `Primary model failed (${errors[0].message}), using tier fallback #${i + 1}`; - console.debug(reason); + logger.debug(reason); const response = await tierFallbackList[i].chat(request); return { ...response, fallback: true, fallbackReason: reason }; } catch (error) { errors.push(error instanceof Error ? error : new Error(String(error))); - console.debug(`Tier fallback #${i + 1} failed: ${errors[errors.length - 1].message}`); + logger.debug(`Tier fallback #${i + 1} failed: ${errors[errors.length - 1].message}`); } } @@ -98,12 +99,12 @@ export class ModelRouter implements ModelClient { const fallbackClient = this.fallbackChain[i]; try { const reason = `Primary model failed (${errors[0].message}), using global fallback #${i + 1}`; - console.debug(reason); + logger.debug(reason); const response = await fallbackClient.chat(request); return { ...response, fallback: true, fallbackReason: reason }; } catch (error) { errors.push(error instanceof Error ? error : new Error(String(error))); - console.debug(`Global fallback #${i + 1} failed: ${errors[errors.length - 1].message}`); + logger.debug(`Global fallback #${i + 1} failed: ${errors[errors.length - 1].message}`); } } @@ -121,7 +122,7 @@ export class ModelRouter implements ModelClient { if (event.type === 'error') { hasError = true; primaryError = event.error?.message ?? 'Unknown error'; - console.debug(`Primary stream failed: ${primaryError}`); + logger.debug(`Primary stream failed: ${primaryError}`); break; } yield event; @@ -139,14 +140,14 @@ export class ModelRouter implements ModelClient { if (!fallbackClient.chatStream) continue; const reason = `Primary model failed (${primaryError}), using tier fallback #${i + 1}`; - console.debug(reason); + logger.debug(reason); yield { type: 'fallback_warning', fallbackReason: reason }; let hasError = false; for await (const event of fallbackClient.chatStream(request)) { if (event.type === 'error') { hasError = true; - console.debug(`Tier fallback stream #${i + 1} failed: ${event.error?.message}`); + logger.debug(`Tier fallback stream #${i + 1} failed: ${event.error?.message}`); break; } yield event; @@ -161,14 +162,14 @@ export class ModelRouter implements ModelClient { if (!fallbackClient.chatStream) continue; const reason = `Primary model failed (${primaryError}), using global fallback #${i + 1}`; - console.debug(reason); + logger.debug(reason); yield { type: 'fallback_warning', fallbackReason: reason }; let hasError = false; for await (const event of fallbackClient.chatStream(request)) { if (event.type === 'error') { hasError = true; - console.debug(`Global fallback stream #${i + 1} failed: ${event.error?.message}`); + logger.debug(`Global fallback stream #${i + 1} failed: ${event.error?.message}`); break; } yield event;