diff --git a/README.md b/README.md index 8e89c02..769547e 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,42 @@ Commands work with `tinyclaw` (if CLI installed) or `./tinyclaw.sh` (direct scri | `reset` | Reset all conversations | `tinyclaw reset` | | `channels reset ` | Reset channel authentication | `tinyclaw channels reset whatsapp` | +### OpenAI (Codex CLI) Provider + +If you select the `openai` provider, TinyClaw uses the Codex CLI. + +Environment variables: + +```bash +export OPENAI_API_KEY="..." +``` + +Note: model availability can depend on how the Codex CLI is authenticated. If you see errors about a model being unsupported with a ChatGPT account, switch to a supported Codex model (e.g. `gpt-5.3-codex`) or authenticate with an API-key style credential appropriate for your endpoint. + +### Cerebras (Qwen/LLama) Provider (Optional) + +Codex CLI currently calls the OpenAI **Responses** API (`/v1/responses`). Some OpenAI-compatible providers (including Cerebras) only expose **Chat Completions** (`/v1/chat/completions`), so TinyClaw includes an optional `cerebras` provider that talks to Cerebras directly. + +Environment variables: + +```bash +export CEREBRAS_API_KEY="..." +export TINYCLAW_CEREBRAS_BASE_URL="https://api.cerebras.ai/v1" # optional (defaults to Cerebras) +``` + +In `settings.json`, set the agent provider/model: + +```json +{ + "agents": { + "assistant": { + "provider": "cerebras", + "model": "qwen-3-32b" + } + } +} +``` + ### Pairing Commands Use sender pairing to control who can message your agents. @@ -206,6 +242,12 @@ Pairing behavior: tinyclaw update ``` +**Non-interactive update (CI/automation):** + +```bash +TINYCLAW_UPDATE_YES=1 tinyclaw update +``` + This will: 1. Check for latest release diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 156c393..3aded6f 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -182,10 +182,10 @@ claude --dangerously-skip-permissions \ **OpenAI (Codex):** ```bash cd "$agent_working_directory" # e.g., ~/tinyclaw-workspace/coder/ -codex exec resume --last \ +codex --ask-for-approval never exec --ephemeral \ --model gpt-5.3-codex \ --skip-git-repo-check \ - --dangerously-bypass-approvals-and-sandbox \ + --sandbox danger-full-access \ --json \ "User message here" ``` diff --git a/docs/QUEUE.md b/docs/QUEUE.md index 0a31ad9..7491415 100644 --- a/docs/QUEUE.md +++ b/docs/QUEUE.md @@ -133,10 +133,10 @@ claude --dangerously-skip-permissions \ **Codex (OpenAI):** ```bash cd ~/workspace/coder/ -codex exec resume --last \ +codex --ask-for-approval never exec --ephemeral \ --model gpt-5.3-codex \ --skip-git-repo-check \ - --dangerously-bypass-approvals-and-sandbox \ + --sandbox danger-full-access \ --json "fix the authentication bug" ``` diff --git a/lib/daemon.sh b/lib/daemon.sh index 7b3c430..53118f0 100644 --- a/lib/daemon.sh +++ b/lib/daemon.sh @@ -15,26 +15,38 @@ start_daemon() { if [ ! -d "$SCRIPT_DIR/node_modules" ]; then echo -e "${YELLOW}Installing Node.js dependencies...${NC}" cd "$SCRIPT_DIR" - PUPPETEER_SKIP_DOWNLOAD=true npm install + if command -v bun >/dev/null 2>&1; then + # bun is typically much faster than npm. Avoid writing lockfiles in user installs. + PUPPETEER_SKIP_DOWNLOAD=true bun install --no-save + else + PUPPETEER_SKIP_DOWNLOAD=true npm install + fi fi - # Build TypeScript if any src file is newer than its dist counterpart + # Build TypeScript if any src file is newer than its dist counterpart. + # Check recursively so changes under src/lib/* also trigger rebuilds. local needs_build=false if [ ! -d "$SCRIPT_DIR/dist" ]; then needs_build=true else - for ts_file in "$SCRIPT_DIR"/src/*.ts; do - local js_file="$SCRIPT_DIR/dist/$(basename "${ts_file%.ts}.js")" + while IFS= read -r -d '' ts_file; do + local rel="${ts_file#"$SCRIPT_DIR/src/"}" + local js_rel="${rel%.ts}.js" + local js_file="$SCRIPT_DIR/dist/$js_rel" if [ ! -f "$js_file" ] || [ "$ts_file" -nt "$js_file" ]; then needs_build=true break fi - done + done < <(find "$SCRIPT_DIR/src" -type f -name '*.ts' -print0) fi if [ "$needs_build" = true ]; then echo -e "${YELLOW}Building TypeScript...${NC}" cd "$SCRIPT_DIR" - npm run build + if command -v bun >/dev/null 2>&1; then + bun run build + else + npm run build + fi fi # Load settings or run setup wizard @@ -104,6 +116,14 @@ start_daemon() { tmux new-session -d -s "$TMUX_SESSION" -n "tinyclaw" -c "$SCRIPT_DIR" + # Ensure critical env vars are present inside tmux panes. + # tmux servers can outlive the calling shell, so relying on inheritance is brittle. + for k in CEREBRAS_API_KEY OPENAI_API_KEY TINYCLAW_CEREBRAS_BASE_URL; do + if [ -n "${!k:-}" ]; then + tmux set-environment -t "$TMUX_SESSION" "$k" "${!k}" + fi + done + # Create remaining panes (pane 0 already exists) for ((i=1; i "$SETTINGS_FILE" </dev/null 2>&1; then + PUPPETEER_SKIP_DOWNLOAD=true bun install --no-save --silent +else + PUPPETEER_SKIP_DOWNLOAD=true npm install --silent +fi echo -e "${GREEN}✓ Dependencies installed${NC}" echo "" # Step 3: Build TypeScript echo -e "${BLUE}[3/5] Building TypeScript...${NC}" -npm run build --silent +if command -v bun >/dev/null 2>&1; then + bun run build +else + npm run build --silent +fi echo -e "${GREEN}✓ Build complete${NC}" echo "" @@ -66,7 +74,12 @@ echo "" echo -e "${BLUE}[4/5] Creating bundle...${NC}" # Keep runtime bundle lean: remove development-only dependencies after build. -npm prune --omit=dev --silent +if command -v bun >/dev/null 2>&1; then + rm -rf node_modules + PUPPETEER_SKIP_DOWNLOAD=true bun install --production --no-save --silent +else + npm prune --omit=dev --silent +fi mkdir -p "$BUNDLE_DIR" diff --git a/scripts/remote-install.sh b/scripts/remote-install.sh index 784aaa6..c5f0318 100755 --- a/scripts/remote-install.sh +++ b/scripts/remote-install.sh @@ -41,8 +41,8 @@ if ! command_exists node; then MISSING_DEPS+=("node") fi -if ! command_exists npm; then - MISSING_DEPS+=("npm") +if ! command_exists bun && ! command_exists npm; then + MISSING_DEPS+=("bun or npm") fi if ! command_exists tmux; then @@ -60,7 +60,9 @@ if [ ${#MISSING_DEPS[@]} -ne 0 ]; then done echo "" echo "Install instructions:" - echo " - Node.js/npm: https://nodejs.org/" + echo " - Node.js: https://nodejs.org/" + echo " - bun (recommended): https://bun.sh/" + echo " - npm: included with Node.js" echo " - tmux: sudo apt install tmux (or brew install tmux)" echo " - Claude Code: https://claude.com/claude-code" echo "" @@ -152,14 +154,26 @@ if [ "$USE_BUNDLE" = false ]; then echo -e "${BLUE}[5/6] Installing dependencies...${NC}" cd "$INSTALL_DIR" - echo "Running npm install (this may take a few minutes)..." - PUPPETEER_SKIP_DOWNLOAD=true npm install --silent + if command_exists bun; then + echo "Running bun install..." + PUPPETEER_SKIP_DOWNLOAD=true bun install --no-save --silent - echo "Building TypeScript..." - npm run build --silent + echo "Building TypeScript..." + bun run build - echo "Pruning development dependencies..." - npm prune --omit=dev --silent + echo "Installing production-only dependencies..." + rm -rf node_modules + PUPPETEER_SKIP_DOWNLOAD=true bun install --production --no-save --silent + else + echo "Running npm install (this may take a few minutes)..." + PUPPETEER_SKIP_DOWNLOAD=true npm install --silent + + echo "Building TypeScript..." + npm run build --silent + + echo "Pruning development dependencies..." + npm prune --omit=dev --silent + fi echo -e "${GREEN}✓ Dependencies installed${NC}" echo "" diff --git a/src/lib/cerebras.ts b/src/lib/cerebras.ts new file mode 100644 index 0000000..72b94a5 --- /dev/null +++ b/src/lib/cerebras.ts @@ -0,0 +1,246 @@ +import fs from 'fs'; +import path from 'path'; +import { log } from './logging'; + +type Role = 'system' | 'user' | 'assistant'; +type Msg = { role: Role; content: string }; + +const DEFAULT_BASE_URL = 'https://api.cerebras.ai/v1'; +const HISTORY_FILE = '.tinyclaw/cerebras_history.jsonl'; +const META_FILE = '.tinyclaw/cerebras_meta.json'; +const MAX_HISTORY_MESSAGES = 20; +const SYSTEM_PROMPT_FILE = path.join('.claude', 'CLAUDE.md'); + +function getHistoryPath(agentDir: string): string { + return path.join(agentDir, HISTORY_FILE); +} + +function getMetaPath(agentDir: string): string { + return path.join(agentDir, META_FILE); +} + +function readMeta(agentDir: string): { model?: string } { + const p = getMetaPath(agentDir); + try { + const raw = fs.readFileSync(p, 'utf8'); + const j = JSON.parse(raw); + if (j && typeof j.model === 'string') return { model: j.model }; + } catch { + // ignore + } + return {}; +} + +function writeMeta(agentDir: string, meta: { model: string }): void { + const p = getMetaPath(agentDir); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, JSON.stringify({ ...meta, updatedAt: Date.now() }, null, 2)); +} + +function readSystemPrompt(agentDir: string): string { + const p = path.join(agentDir, SYSTEM_PROMPT_FILE); + try { + const raw = fs.readFileSync(p, 'utf8').trim(); + if (raw) return raw; + } catch { + // ignore + } + + // Keep this short and stable: we primarily want to prevent incorrect self-identification. + return [ + 'You are TinyClaw, a multi-agent assistant running inside a local daemon.', + 'Do not claim to be Codex or a GPT-5 model.', + 'If asked what model/provider you are using, answer that you are the TinyClaw assistant running via the configured provider/model.', + ].join('\n'); +} + +function readHistory(agentDir: string): Msg[] { + const p = getHistoryPath(agentDir); + try { + const raw = fs.readFileSync(p, 'utf8'); + const lines = raw.split('\n').map(l => l.trim()).filter(Boolean); + const msgs: Msg[] = []; + for (const line of lines) { + try { + const j = JSON.parse(line); + if (j && (j.role === 'user' || j.role === 'assistant' || j.role === 'system') && typeof j.content === 'string') { + msgs.push({ role: j.role, content: j.content }); + } + } catch { + // ignore + } + } + return msgs.slice(-MAX_HISTORY_MESSAGES); + } catch { + return []; + } +} + +function inferModelFromHistory(history: Msg[]): string | undefined { + // Best-effort: detect self-reported model strings in recent assistant messages + // to avoid getting "stuck" after switching models. + for (let i = history.length - 1; i >= 0; i--) { + const m = history[i]; + if (m.role !== 'assistant') continue; + const txt = m.content || ''; + const match = + txt.match(/\bprovider\s*=\s*cerebras\b[\s\S]{0,80}\bmodel\s*=\s*([a-z0-9._-]+)/i) || + txt.match(/\bruntime model:\s*([a-z0-9._-]+)/i) || + txt.match(/\bmodel\s*=\s*([a-z0-9._-]+)\b/i); + if (match && match[1]) return match[1].toLowerCase(); + } + return undefined; +} + +function appendHistory(agentDir: string, msg: Msg): void { + const p = getHistoryPath(agentDir); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.appendFileSync(p, JSON.stringify(msg) + '\n'); +} + +export function resetCerebrasHistory(agentDir: string): void { + const p = getHistoryPath(agentDir); + try { + fs.unlinkSync(p); + } catch { + // ignore + } +} + +function getBaseUrl(): string { + // Keep Cerebras isolated from OpenAI/Codex env vars to avoid accidental cross-contamination. + return (process.env.TINYCLAW_CEREBRAS_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, ''); +} + +function getApiKey(): string { + // Do not fall back to OPENAI_API_KEY: it's for Codex/OpenAI, not Cerebras. + return process.env.CEREBRAS_API_KEY || ''; +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function isRetryable(status: number, message: string): boolean { + if ([408, 409, 425, 429, 500, 502, 503, 504].includes(status)) return true; + const m = message.toLowerCase(); + return m.includes('high traffic') || m.includes('rate limit') || m.includes('timeout') || m.includes('temporar'); +} + +export async function cerebrasChatCompletion(opts: { + agentDir: string; + model: string; + userMessage: string; +}): Promise { + const baseUrl = getBaseUrl(); + const apiKey = getApiKey(); + if (!apiKey) { + throw new Error('Missing Cerebras API key (set CEREBRAS_API_KEY).'); + } + + const url = `${baseUrl}/chat/completions`; + + // If the configured model changed, reset history to avoid model "stickiness". + // This is intentionally conservative: we'd rather lose a bit of conversational context + // than keep returning stale self-identification. + const initialHistory = readHistory(opts.agentDir); + const inferred = inferModelFromHistory(initialHistory); + const prevMeta = readMeta(opts.agentDir); + if ((prevMeta.model && prevMeta.model !== opts.model) || (inferred && inferred !== opts.model)) { + resetCerebrasHistory(opts.agentDir); + } + writeMeta(opts.agentDir, { model: opts.model }); + + const history = readHistory(opts.agentDir); + const baseSystem = readSystemPrompt(opts.agentDir); + const system = [ + `Runtime provider: cerebras`, + `Runtime model: ${opts.model}`, + `If asked what provider/model you're using, answer exactly: provider=cerebras model=${opts.model}.`, + '', + baseSystem, + ].join('\n'); + const messages: Msg[] = [{ role: 'system', content: system }, ...history, { role: 'user', content: opts.userMessage }]; + + // Persist user message before sending so history is consistent even if we crash mid-call. + appendHistory(opts.agentDir, { role: 'user', content: opts.userMessage }); + + let lastErrMsg = 'Unknown Cerebras error'; + let lastStatus = 0; + for (let attempt = 0; attempt < 3; attempt++) { + const attemptStart = Date.now(); + let res: Response; + try { + res = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: opts.model, + messages, + }), + }); + } catch (e) { + lastStatus = 0; + lastErrMsg = (e as Error).message || 'Network error'; + if (attempt < 2) { + await sleep(250 * Math.pow(2, attempt)); + continue; + } + break; + } + + lastStatus = res.status; + const text = await res.text(); + let json: any; + try { + json = JSON.parse(text); + } catch { + lastErrMsg = `Cerebras HTTP ${res.status}: ${text.slice(0, 200)}`; + if (attempt < 2 && isRetryable(res.status, lastErrMsg)) { + await sleep(250 * Math.pow(2, attempt)); + continue; + } + if (isRetryable(res.status, lastErrMsg)) break; + throw new Error(lastErrMsg); + } + + if (!res.ok) { + const msg = json?.error?.message || json?.message || `Cerebras HTTP ${res.status}`; + lastErrMsg = msg; + if (attempt < 2 && isRetryable(res.status, msg)) { + const ra = Number(res.headers.get('retry-after') || ''); + const delay = Number.isFinite(ra) && ra > 0 ? ra * 1000 : 250 * Math.pow(2, attempt); + log('WARN', `Cerebras retrying (model=${opts.model}, status=${res.status}, attempt=${attempt + 1}/3, delayMs=${delay}, ms=${Date.now() - attemptStart}): ${msg}`); + await sleep(delay); + continue; + } + if (isRetryable(res.status, msg)) break; + throw new Error(msg); + } + + const content: string | undefined = json?.choices?.[0]?.message?.content; + if (!content) { + lastErrMsg = 'Cerebras returned no message content.'; + if (attempt < 2) { + await sleep(250 * Math.pow(2, attempt)); + continue; + } + break; + } + + const usage = json?.usage || {}; + const completionTokens = Number(usage.completion_tokens || usage.completionTokens || 0) || 0; + const totalTokens = Number(usage.total_tokens || usage.totalTokens || 0) || 0; + const ms = Date.now() - attemptStart; + const tps = completionTokens > 0 && ms > 0 ? Math.round((completionTokens / ms) * 1000) : 0; + log('INFO', `Cerebras ok (model=${opts.model}, status=${res.status}, attempt=${attempt + 1}/3, ms=${ms}, completionTokens=${completionTokens || 'n/a'}, totalTokens=${totalTokens || 'n/a'}, tps=${tps || 'n/a'})`); + + appendHistory(opts.agentDir, { role: 'assistant', content }); + return content; + } + + throw new Error(`Cerebras temporarily unavailable${lastStatus ? ` (HTTP ${lastStatus})` : ''}: ${lastErrMsg}`); +} diff --git a/src/lib/invoke.ts b/src/lib/invoke.ts index 471eaf2..5f52134 100644 --- a/src/lib/invoke.ts +++ b/src/lib/invoke.ts @@ -5,6 +5,7 @@ import { AgentConfig, TeamConfig } from './types'; import { SCRIPT_DIR, resolveClaudeModel, resolveCodexModel } from './config'; import { log } from './logging'; import { ensureAgentDirectory, updateAgentTeammates } from './agent-setup'; +import { cerebrasChatCompletion, resetCerebrasHistory } from './cerebras'; export async function runCommand(command: string, args: string[], cwd?: string): Promise { return new Promise((resolve, reject) => { @@ -43,6 +44,41 @@ export async function runCommand(command: string, args: string[], cwd?: string): }); } +async function runCommandWithExitCode( + command: string, + args: string[], + cwd?: string +): Promise<{ stdout: string; stderr: string; code: number | null }> { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: cwd || SCRIPT_DIR, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + }); + + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + + child.on('error', (error) => { + reject(error); + }); + + child.on('close', (code) => { + resolve({ stdout, stderr, code }); + }); + }); +} + /** * Invoke a single agent with a message. Contains all Claude/Codex invocation logic. * Returns the raw response text. @@ -76,30 +112,60 @@ export async function invokeAgent( const provider = agent.provider || 'anthropic'; - if (provider === 'openai') { - log('INFO', `Using Codex CLI (agent: ${agentId})`); + if (provider === 'cerebras') { + log('INFO', `Using Cerebras provider (agent: ${agentId})`); - const shouldResume = !shouldReset; + if (shouldReset) { + resetCerebrasHistory(agentDir); + } + + // Best-effort fallback: if user config requests an inaccessible model, fall back to qwen-3-32b. + const preferredModel = agent.model || 'qwen-3-32b'; + try { + return await cerebrasChatCompletion({ + agentDir, + model: preferredModel, + userMessage: message, + }); + } catch (e) { + const msg = (e as Error).message || ''; + const isModelNotFound = /does not exist|do not have access|model_not_found/i.test(msg); + if (!isModelNotFound || preferredModel === 'qwen-3-32b') { + throw e; + } + log('WARN', `Cerebras model "${preferredModel}" unavailable; falling back to qwen-3-32b (agent: ${agentId})`); + return await cerebrasChatCompletion({ + agentDir, + model: 'qwen-3-32b', + userMessage: message, + }); + } + } else if (provider === 'openai') { + log('INFO', `Using Codex CLI (agent: ${agentId})`); if (shouldReset) { log('INFO', `🔄 Resetting Codex conversation for agent: ${agentId}`); } const modelId = resolveCodexModel(agent.model); - const codexArgs = ['exec']; - if (shouldResume) { - codexArgs.push('resume', '--last'); - } + // Avoid `codex exec resume --last`: a corrupted local rollout state can make resume fail + // and break the bot. Stateless `--ephemeral` is more reliable for chat bridges. + const codexArgs = ['--ask-for-approval', 'never', 'exec', '--ephemeral']; if (modelId) { codexArgs.push('--model', modelId); } - codexArgs.push('--skip-git-repo-check', '--dangerously-bypass-approvals-and-sandbox', '--json', message); + codexArgs.push('--skip-git-repo-check', '--sandbox', 'danger-full-access', '--json', message); - const codexOutput = await runCommand('codex', codexArgs, workingDir); + const { stdout: codexStdout, stderr: codexStderr, code } = await runCommandWithExitCode('codex', codexArgs, workingDir); + if (codexStderr.trim()) { + const isRolloutWarn = /state db missing rollout path|codex_core::rollout::list/i.test(codexStderr); + const level = isRolloutWarn ? 'WARN' : (code === 0 ? 'WARN' : 'ERROR'); + log(level as any, `Codex stderr (agent: ${agentId}): ${codexStderr.trim()}`); + } // Parse JSONL output and extract final agent_message let response = ''; - const lines = codexOutput.trim().split('\n'); + const lines = codexStdout.trim().split('\n').filter(Boolean); for (const line of lines) { try { const json = JSON.parse(line); @@ -111,7 +177,11 @@ export async function invokeAgent( } } - return response || 'Sorry, I could not generate a response from Codex.'; + if (response) return response; + if (code && code !== 0) { + throw new Error(codexStderr.trim() || `Codex exited with code ${code}`); + } + return 'Sorry, I could not generate a response from Codex.'; } else { // Default to Claude (Anthropic) log('INFO', `Using Claude provider (agent: ${agentId})`); diff --git a/src/lib/types.ts b/src/lib/types.ts index f0b2e40..dae1c3c 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,6 +1,6 @@ export interface AgentConfig { name: string; - provider: string; // 'anthropic' or 'openai' + provider: string; // 'anthropic' | 'openai' | 'cerebras' (others allowed) model: string; // e.g. 'sonnet', 'opus', 'gpt-5.3-codex' working_directory: string; } @@ -77,6 +77,7 @@ export interface Conversation { export interface ResponseData { channel: string; sender: string; + senderId?: string; message: string; originalMessage: string; timestamp: number; diff --git a/src/queue-processor.ts b/src/queue-processor.ts index e89b989..57588dc 100644 --- a/src/queue-processor.ts +++ b/src/queue-processor.ts @@ -231,6 +231,7 @@ async function processMessage(messageFile: string): Promise { const messageData: MessageData = JSON.parse(fs.readFileSync(processingFile, 'utf8')); const { channel, sender, message: rawMessage, timestamp, messageId } = messageData; const isInternal = !!messageData.conversationId; + const startedAt = Date.now(); log('INFO', `Processing [${isInternal ? 'internal' : channel}] ${isInternal ? `@${messageData.fromAgent}→@${messageData.agent}` : `from ${sender}`}: ${rawMessage.substring(0, 50)}...`); if (!isInternal) { @@ -279,6 +280,7 @@ async function processMessage(messageFile: string): Promise { fs.writeFileSync(responseFile, JSON.stringify(responseData, null, 2)); fs.unlinkSync(processingFile); log('INFO', `✓ Easter egg sent to ${sender}`); + log('INFO', `Handled message ${messageId} (channel=${channel}, agent=error, ms=${Date.now() - startedAt})`); return; } @@ -343,13 +345,21 @@ async function processMessage(messageFile: string): Promise { // Invoke agent emitEvent('chain_step_start', { agentId, agentName: agent.name, fromAgent: messageData.fromAgent || null }); let response: string; + const invokeStartedAt = Date.now(); try { response = await invokeAgent(agent, agentId, message, workspacePath, shouldReset, agents, teams); } catch (error) { const provider = agent.provider || 'anthropic'; - log('ERROR', `${provider === 'openai' ? 'Codex' : 'Claude'} error (agent: ${agentId}): ${(error as Error).message}`); - response = "Sorry, I encountered an error processing your request. Please check the queue logs."; + const providerLabel = provider === 'openai' ? 'Codex' : provider === 'cerebras' ? 'Cerebras' : 'Claude'; + log('ERROR', `${providerLabel} error (agent: ${agentId}): ${(error as Error).message}`); + const msg = ((error as Error).message || '').toLowerCase(); + if (provider === 'cerebras' && (msg.includes('high traffic') || msg.includes('rate limit') || msg.includes('temporarily unavailable'))) { + response = "Cerebras is experiencing high traffic right now. Please try again in ~30 seconds."; + } else { + response = "Sorry, I encountered an error processing your request. Please check the queue logs."; + } } + log('INFO', `Agent invoke done (agent=${agentId}, provider=${agent.provider}/${agent.model}, ms=${Date.now() - invokeStartedAt})`); emitEvent('chain_step_done', { agentId, agentName: agent.name, responseLength: response.length, responseText: response }); @@ -371,6 +381,7 @@ async function processMessage(messageFile: string): Promise { const responseData: ResponseData = { channel, sender, + senderId: messageData.senderId, message: responseMessage, originalMessage: rawMessage, timestamp: Date.now(), @@ -389,6 +400,7 @@ async function processMessage(messageFile: string): Promise { emitEvent('response_ready', { channel, sender, agentId, responseLength: finalResponse.length, responseText: finalResponse, messageId }); fs.unlinkSync(processingFile); + log('INFO', `Handled message ${messageId} (channel=${channel}, agent=${agentId}, provider=${agent.provider}/${agent.model}, ms=${Date.now() - startedAt})`); return; } diff --git a/tinyclaw.sh b/tinyclaw.sh index 8b17467..d647644 100755 --- a/tinyclaw.sh +++ b/tinyclaw.sh @@ -24,6 +24,11 @@ fi mkdir -p "$LOG_DIR" +# Provider env vars +# Keep Cerebras and OpenAI/Codex environment variables separate to avoid breaking tool-capable CLIs. +# - Cerebras: set CEREBRAS_API_KEY and optional TINYCLAW_CEREBRAS_BASE_URL +# - OpenAI/Codex: set OPENAI_API_KEY (Codex CLI manages its own config/state) + # Source library files source "$SCRIPT_DIR/lib/common.sh" source "$SCRIPT_DIR/lib/daemon.sh" @@ -89,11 +94,15 @@ case "${1:-}" in provider) if [ -z "$2" ]; then if [ -f "$SETTINGS_FILE" ]; then - CURRENT_PROVIDER=$(jq -r '.models.provider // "anthropic"' "$SETTINGS_FILE" 2>/dev/null) - if [ "$CURRENT_PROVIDER" = "openai" ]; then - CURRENT_MODEL=$(jq -r '.models.openai.model // empty' "$SETTINGS_FILE" 2>/dev/null) - else - CURRENT_MODEL=$(jq -r '.models.anthropic.model // empty' "$SETTINGS_FILE" 2>/dev/null) + # Prefer per-agent config if present (newer multi-agent config format). + CURRENT_PROVIDER=$(jq -r '.agents.assistant.provider // .models.provider // "anthropic"' "$SETTINGS_FILE" 2>/dev/null) + CURRENT_MODEL=$(jq -r '.agents.assistant.model // empty' "$SETTINGS_FILE" 2>/dev/null) + if [ -z "$CURRENT_MODEL" ]; then + if [ "$CURRENT_PROVIDER" = "openai" ]; then + CURRENT_MODEL=$(jq -r '.models.openai.model // empty' "$SETTINGS_FILE" 2>/dev/null) + else + CURRENT_MODEL=$(jq -r '.models.anthropic.model // empty' "$SETTINGS_FILE" 2>/dev/null) + fi fi echo -e "${BLUE}Current provider: ${GREEN}$CURRENT_PROVIDER${NC}" if [ -n "$CURRENT_MODEL" ]; then @@ -122,11 +131,11 @@ case "${1:-}" in tmp_file="$SETTINGS_FILE.tmp" if [ -n "$MODEL_ARG" ]; then # Set both provider and model - jq ".models.provider = \"anthropic\" | .models.anthropic.model = \"$MODEL_ARG\"" "$SETTINGS_FILE" > "$tmp_file" && mv "$tmp_file" "$SETTINGS_FILE" + jq ".models.provider = \"anthropic\" | .models.anthropic.model = \"$MODEL_ARG\" | (if .agents.assistant? then .agents.assistant.provider = \"anthropic\" | .agents.assistant.model = \"$MODEL_ARG\" else . end)" "$SETTINGS_FILE" > "$tmp_file" && mv "$tmp_file" "$SETTINGS_FILE" echo -e "${GREEN}✓ Switched to Anthropic provider with model: $MODEL_ARG${NC}" else # Set provider only - jq ".models.provider = \"anthropic\"" "$SETTINGS_FILE" > "$tmp_file" && mv "$tmp_file" "$SETTINGS_FILE" + jq ".models.provider = \"anthropic\" | (if .agents.assistant? then .agents.assistant.provider = \"anthropic\" else . end)" "$SETTINGS_FILE" > "$tmp_file" && mv "$tmp_file" "$SETTINGS_FILE" echo -e "${GREEN}✓ Switched to Anthropic provider${NC}" echo "" echo "Use 'tinyclaw model {sonnet|opus}' to set the model." @@ -142,29 +151,50 @@ case "${1:-}" in tmp_file="$SETTINGS_FILE.tmp" if [ -n "$MODEL_ARG" ]; then # Set both provider and model (supports any model name) - jq ".models.provider = \"openai\" | .models.openai.model = \"$MODEL_ARG\"" "$SETTINGS_FILE" > "$tmp_file" && mv "$tmp_file" "$SETTINGS_FILE" + jq ".models.provider = \"openai\" | .models.openai.model = \"$MODEL_ARG\" | (if .agents.assistant? then .agents.assistant.provider = \"openai\" | .agents.assistant.model = \"$MODEL_ARG\" else . end)" "$SETTINGS_FILE" > "$tmp_file" && mv "$tmp_file" "$SETTINGS_FILE" echo -e "${GREEN}✓ Switched to OpenAI/Codex provider with model: $MODEL_ARG${NC}" echo "" echo "Note: Make sure you have the 'codex' CLI installed and authenticated." else # Set provider only - jq ".models.provider = \"openai\"" "$SETTINGS_FILE" > "$tmp_file" && mv "$tmp_file" "$SETTINGS_FILE" + jq ".models.provider = \"openai\" | (if .agents.assistant? then .agents.assistant.provider = \"openai\" else . end)" "$SETTINGS_FILE" > "$tmp_file" && mv "$tmp_file" "$SETTINGS_FILE" echo -e "${GREEN}✓ Switched to OpenAI/Codex provider${NC}" echo "" echo "Use 'tinyclaw model {gpt-5.3-codex|gpt-5.2}' to set the model." echo "Note: Make sure you have the 'codex' CLI installed and authenticated." fi ;; + cerebras) + if [ ! -f "$SETTINGS_FILE" ]; then + echo -e "${RED}No settings file found. Run setup first.${NC}" + exit 1 + fi + + # Switch to Cerebras provider (native Chat Completions client) + tmp_file="$SETTINGS_FILE.tmp" + if [ -n "$MODEL_ARG" ]; then + jq ".models.provider = \"cerebras\" | (if .agents.assistant? then .agents.assistant.provider = \"cerebras\" | .agents.assistant.model = \"$MODEL_ARG\" else . end)" "$SETTINGS_FILE" > "$tmp_file" && mv "$tmp_file" "$SETTINGS_FILE" + echo -e "${GREEN}✓ Switched to Cerebras provider with model: $MODEL_ARG${NC}" + else + jq ".models.provider = \"cerebras\" | (if .agents.assistant? then .agents.assistant.provider = \"cerebras\" else . end)" "$SETTINGS_FILE" > "$tmp_file" && mv "$tmp_file" "$SETTINGS_FILE" + echo -e "${GREEN}✓ Switched to Cerebras provider${NC}" + echo "" + echo "Tip: set a model with e.g. 'tinyclaw provider cerebras --model qwen-3-32b'" + echo "Note: some Cerebras models may require allowlist access." + fi + ;; *) - echo "Usage: $0 provider {anthropic|openai} [--model MODEL_NAME]" + echo "Usage: $0 provider {anthropic|openai|cerebras} [--model MODEL_NAME]" echo "" echo "Examples:" echo " $0 provider # Show current provider and model" echo " $0 provider anthropic # Switch to Anthropic" echo " $0 provider openai # Switch to OpenAI" + echo " $0 provider cerebras # Switch to Cerebras" echo " $0 provider anthropic --model sonnet # Switch to Anthropic with Sonnet" echo " $0 provider openai --model gpt-5.3-codex # Switch to OpenAI with GPT-5.3 Codex" echo " $0 provider openai --model gpt-4o # Switch to OpenAI with custom model" + echo " $0 provider cerebras --model qwen-3-32b # Switch to Cerebras with Qwen" exit 1 ;; esac @@ -173,11 +203,14 @@ case "${1:-}" in model) if [ -z "$2" ]; then if [ -f "$SETTINGS_FILE" ]; then - CURRENT_PROVIDER=$(jq -r '.models.provider // "anthropic"' "$SETTINGS_FILE" 2>/dev/null) - if [ "$CURRENT_PROVIDER" = "openai" ]; then - CURRENT_MODEL=$(jq -r '.models.openai.model // empty' "$SETTINGS_FILE" 2>/dev/null) - else - CURRENT_MODEL=$(jq -r '.models.anthropic.model // empty' "$SETTINGS_FILE" 2>/dev/null) + CURRENT_PROVIDER=$(jq -r '.agents.assistant.provider // .models.provider // "anthropic"' "$SETTINGS_FILE" 2>/dev/null) + CURRENT_MODEL=$(jq -r '.agents.assistant.model // empty' "$SETTINGS_FILE" 2>/dev/null) + if [ -z "$CURRENT_MODEL" ]; then + if [ "$CURRENT_PROVIDER" = "openai" ]; then + CURRENT_MODEL=$(jq -r '.models.openai.model // empty' "$SETTINGS_FILE" 2>/dev/null) + else + CURRENT_MODEL=$(jq -r '.models.anthropic.model // empty' "$SETTINGS_FILE" 2>/dev/null) + fi fi if [ -n "$CURRENT_MODEL" ]; then echo -e "${BLUE}Current provider: ${GREEN}$CURRENT_PROVIDER${NC}" @@ -191,53 +224,47 @@ case "${1:-}" in exit 1 fi else - case "$2" in - sonnet|opus) - if [ ! -f "$SETTINGS_FILE" ]; then - echo -e "${RED}No settings file found. Run setup first.${NC}" - exit 1 - fi + if [ ! -f "$SETTINGS_FILE" ]; then + echo -e "${RED}No settings file found. Run setup first.${NC}" + exit 1 + fi - # Update model using jq - tmp_file="$SETTINGS_FILE.tmp" - jq ".models.anthropic.model = \"$2\"" "$SETTINGS_FILE" > "$tmp_file" && mv "$tmp_file" "$SETTINGS_FILE" + CURRENT_PROVIDER=$(jq -r '.agents.assistant.provider // .models.provider // "anthropic"' "$SETTINGS_FILE" 2>/dev/null) + MODEL_NAME="$2" - echo -e "${GREEN}✓ Model switched to: $2${NC}" - echo "" - echo "Note: This affects the queue processor. Changes take effect on next message." - ;; - gpt-5.2|gpt-5.3-codex) - if [ ! -f "$SETTINGS_FILE" ]; then - echo -e "${RED}No settings file found. Run setup first.${NC}" + if [ "$CURRENT_PROVIDER" = "anthropic" ]; then + case "$MODEL_NAME" in + sonnet|opus) + tmp_file="$SETTINGS_FILE.tmp" + jq ".models.anthropic.model = \"$MODEL_NAME\" | (if .agents.assistant? then .agents.assistant.model = \"$MODEL_NAME\" else . end)" "$SETTINGS_FILE" > "$tmp_file" && mv "$tmp_file" "$SETTINGS_FILE" + echo -e "${GREEN}✓ Model switched to: $MODEL_NAME${NC}" + echo "" + echo "Note: This affects the queue processor. Changes take effect on next message." + ;; + *) + echo "Usage: $0 model {sonnet|opus}" + echo "" + echo "Anthropic models:" + echo " sonnet # Claude Sonnet (fast)" + echo " opus # Claude Opus (smartest)" exit 1 - fi - - # Update model using jq - tmp_file="$SETTINGS_FILE.tmp" - jq ".models.openai.model = \"$2\"" "$SETTINGS_FILE" > "$tmp_file" && mv "$tmp_file" "$SETTINGS_FILE" + ;; + esac + else + # For OpenAI and Cerebras, accept any model id (provider-specific). + tmp_file="$SETTINGS_FILE.tmp" + if [ "$CURRENT_PROVIDER" = "openai" ]; then + jq ".models.openai.model = \"$MODEL_NAME\" | (if .agents.assistant? then .agents.assistant.model = \"$MODEL_NAME\" else . end)" "$SETTINGS_FILE" > "$tmp_file" && mv "$tmp_file" "$SETTINGS_FILE" + elif [ "$CURRENT_PROVIDER" = "cerebras" ]; then + jq ".models.cerebras.model = \"$MODEL_NAME\" | (if .agents.assistant? then .agents.assistant.model = \"$MODEL_NAME\" else . end)" "$SETTINGS_FILE" > "$tmp_file" && mv "$tmp_file" "$SETTINGS_FILE" + else + jq "(if .agents.assistant? then .agents.assistant.model = \"$MODEL_NAME\" else . end)" "$SETTINGS_FILE" > "$tmp_file" && mv "$tmp_file" "$SETTINGS_FILE" + fi - echo -e "${GREEN}✓ Model switched to: $2${NC}" - echo "" - echo "Note: This affects the queue processor. Changes take effect on next message." - ;; - *) - echo "Usage: $0 model {sonnet|opus|gpt-5.2|gpt-5.3-codex}" - echo "" - echo "Anthropic models:" - echo " sonnet # Claude Sonnet (fast)" - echo " opus # Claude Opus (smartest)" - echo "" - echo "OpenAI models:" - echo " gpt-5.3-codex # GPT-5.3 Codex" - echo " gpt-5.2 # GPT-5.2" - echo "" - echo "Examples:" - echo " $0 model # Show current model" - echo " $0 model sonnet # Switch to Claude Sonnet" - echo " $0 model gpt-5.3-codex # Switch to GPT-5.3 Codex" - exit 1 - ;; - esac + echo -e "${GREEN}✓ Model switched to: $MODEL_NAME${NC}" + echo "" + echo "Note: This affects the queue processor. Changes take effect on next message." + fi fi ;; agent) @@ -320,7 +347,11 @@ case "${1:-}" in if [ ! -f "$SCRIPT_DIR/dist/visualizer/team-visualizer.js" ] || \ [ "$SCRIPT_DIR/src/visualizer/team-visualizer.tsx" -nt "$SCRIPT_DIR/dist/visualizer/team-visualizer.js" ]; then echo -e "${BLUE}Building team visualizer...${NC}" - cd "$SCRIPT_DIR" && npm run build:visualizer 2>/dev/null + if command -v bun >/dev/null 2>&1; then + cd "$SCRIPT_DIR" && bun run build:visualizer 2>/dev/null + else + cd "$SCRIPT_DIR" && npm run build:visualizer 2>/dev/null + fi if [ $? -ne 0 ]; then echo -e "${RED}Failed to build visualizer.${NC}" exit 1