diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index a96cde5..5697fc3 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -5,7 +5,8 @@ Last updated: 2026-07-11 ## Current DAVE Telegram Style Fix - Operator feedback from 2026-07-11 screenshots: DAVE still sent large Telegram report blocks, legacy `CRUCIX ROUTINE` fallback alerts, visible report labels such as `Confidence`, and tool/audit traces such as `Werkzeuge: get_security_profile`. -- Active branch: `codex/fix-dave-telegram-human-style`. +- Follow-up feedback from 2026-07-11 18:18: DAVE sometimes stopped answering with `The agent returned no usable response` / `AI chat failed`, and visible labels like `[DAVE // HINWEIS]` still felt like system blocks. +- Current follow-up branch: `codex/fix-dave-agent-timeout-fallback`. - Intent: DAVE should write like a human Telegram contact: short messages, one thought per message, no visible Markdown/report sections, and no automatic tool traces in normal chat. - Code changes in progress: - `TelegramAlerter.sendConversation(...)` now defaults to 420-character chat chunks and can split long paragraphs by sentence. @@ -13,7 +14,10 @@ Last updated: 2026-07-11 - Normal Telegram AI answers no longer append tool trace suffixes; `/trace` remains the explicit audit command. - Proactive DAVE and dynamic presence prompts request short human-style Telegram messages; evidence is capped and tool names are hidden from visible chat. - DAVE persona and terminal-agent protocol now require short natural chat answers for proactive/presence responses. -- Tests added in `test/telegram-chat.test.mjs` to verify short default chunks and conversational fallback alerts without `CRUCIX ROUTINE`, `Confidence:`, `Direction:`, or visible tool traces. + - Follow-up removes visible `[DAVE // ...]` headers from proactive/presence/fallback Telegram messages. + - Follow-up catches empty model responses and LLM timeout/fetch failures inside the terminal agent, returning a short local-sweep fallback instead of `no usable response` or `AI chat failed`. + - The fallback tells the operator which terminal surfaces DAVE can control from chat: status, sources, briefings, markets, evidence/web search, memory, scenarios, and confirmed sweeps/alerts. +- Tests added in `test/telegram-chat.test.mjs` and `test/terminal-agent.test.mjs` to verify short default chunks, conversational fallback alerts without `CRUCIX ROUTINE`, no visible DAVE system labels, no tool traces, empty-response repair, and timeout fallback to the local sweep. ## Source Degradation Handling diff --git a/lib/agent/dave-presence.mjs b/lib/agent/dave-presence.mjs index 63a9adb..f977fb1 100644 --- a/lib/agent/dave-presence.mjs +++ b/lib/agent/dave-presence.mjs @@ -125,7 +125,7 @@ export class DavePresence { const evidence = result.evidence?.length ? `${german ? 'Belege' : 'Evidence'}:\n${result.evidence.slice(0, 2).map(item => `- ${String(item).slice(0, 120)}`).join('\n')}` : ''; - const messages = [`[DAVE // ${german ? 'AKTIV' : 'ACTIVE'}]\n${result.answer}`, evidence].filter(Boolean); + const messages = [result.answer, evidence].filter(Boolean); const sent = typeof this.alerter.sendConversation === 'function' ? await this.alerter.sendConversation(messages, { maxChunkChars: 380 }) : await this.alerter.sendMessage(messages.join('\n\n'), { parseMode: null }); diff --git a/lib/agent/terminal-agent.mjs b/lib/agent/terminal-agent.mjs index 981db16..64ddfd2 100644 --- a/lib/agent/terminal-agent.mjs +++ b/lib/agent/terminal-agent.mjs @@ -86,13 +86,25 @@ export class TerminalAgent { ].join('\n\n'); for (let step = 0; step < this.maxSteps; step++) { - const response = await this.provider.complete(this._systemPrompt(mode, responseLanguage), working, { - maxTokens: this.maxTokens, - timeout: this.timeoutMs, - }); + let response; + try { + response = await this.provider.complete(this._systemPrompt(mode, responseLanguage), working, { + maxTokens: this.maxTokens, + timeout: this.timeoutMs, + }); + } catch (error) { + trace.push({ tool: 'llm', status: 'failed', durationMs: 0, rationale: short(error.message || error) }); + const result = localFallbackResult(input, context, responseLanguage, trace, error); + this.lastTraceByChat.set(key, trace); + return result; + } const decision = parseDecision(response?.text); if (!decision) { - const answer = String(response?.text || '').trim() || 'The agent returned no usable response.'; + const answer = String(response?.text || '').trim(); + if (!answer) { + working += '\n\nPROTOCOL ERROR: The model returned an empty response. Return the documented JSON final object, or call one allowlisted tool if needed.'; + continue; + } if (looksLikeProtocolPayload(answer)) { working += '\n\nPROTOCOL ERROR: The previous tool syntax was malformed. Return the documented JSON tool_call or final object only.'; continue; @@ -146,10 +158,18 @@ export class TerminalAgent { } for (let attempt = 0; attempt < 2; attempt++) { - const response = await this.provider.complete(this._finalPrompt(mode, responseLanguage), `${working}\n\nFINALIZATION ATTEMPT ${attempt + 1}: Synthesize the answer from the evidence above.`, { - maxTokens: this.maxTokens, - timeout: this.timeoutMs, - }); + let response; + try { + response = await this.provider.complete(this._finalPrompt(mode, responseLanguage), `${working}\n\nFINALIZATION ATTEMPT ${attempt + 1}: Synthesize the answer from the evidence above.`, { + maxTokens: this.maxTokens, + timeout: this.timeoutMs, + }); + } catch (error) { + trace.push({ tool: 'llm_final', status: 'failed', durationMs: 0, rationale: short(error.message || error) }); + const result = localFallbackResult(input, context, responseLanguage, trace, error); + this.lastTraceByChat.set(key, trace); + return result; + } const decision = parseDecision(response?.text); if (decision?.type === 'final') { const result = finalResult(decision, trace); @@ -351,6 +371,74 @@ function finalizationFailureMessage(input) { : 'I could not reliably synthesize the retrieved evidence into an answer. Internal tool data was not exposed. Please retry the question or cautiously increase TELEGRAM_AGENT_MAX_STEPS.'; } +function localFallbackResult(input, context, preferredLanguage, trace, error) { + const german = preferredLanguage === 'de' || looksGerman(input); + const snapshot = parseSnapshot(context); + const reason = classifyFailure(error); + const parts = []; + + parts.push(german + ? `Ich bekomme gerade keine stabile Modellantwort (${reason}). Ich nutze deshalb den letzten lokalen Sweep statt abzubrechen.` + : `I am not getting a stable model response right now (${reason}), so I am falling back to the last local sweep instead of going silent.`); + + const news = Array.isArray(snapshot?.news) ? snapshot.news.map(item => item.title || item.headline).filter(Boolean).slice(0, 2) : []; + const urgent = Array.isArray(snapshot?.urgentOsint) ? snapshot.urgentOsint.filter(Boolean).slice(0, 1) : []; + const degraded = Array.isArray(snapshot?.degradedSources) ? snapshot.degradedSources.map(item => item.name).filter(Boolean).slice(0, 4) : []; + const ideas = Array.isArray(snapshot?.ideas) ? snapshot.ideas.map(item => item.title || item.ticker).filter(Boolean).slice(0, 2) : []; + + if (news.length || urgent.length) { + parts.push(german + ? `Im letzten Sweep fallen mir auf: ${[...urgent, ...news].join(' | ')}.` + : `From the last sweep, the main items I see are: ${[...urgent, ...news].join(' | ')}.`); + } + + if (ideas.length) { + parts.push(german + ? `Marktseitig liegen diese Ideen im Snapshot: ${ideas.join(' | ')}.` + : `Market ideas in the snapshot: ${ideas.join(' | ')}.`); + } + + if (degraded.length) { + parts.push(german + ? `Einschraenkung: ${degraded.join(', ')} liefern gerade nicht sauber.` + : `Caveat: ${degraded.join(', ')} are not clean right now.`); + } + + parts.push(german + ? 'Steuern kann ich per Chat: Status, Quellen, Briefing, Maerkte, Evidenz/Websuche, Memory, Szenarien und nach deiner Bestaetigung Sweep oder Alerts.' + : 'From chat I can control status, sources, briefings, markets, evidence/web search, memory, scenarios, and after your confirmation sweeps or alerts.'); + + return { + answer: parts.filter(Boolean).join('\n\n'), + confidence: 'low', + evidence: [], + notify: false, + priority: 'routine', + trace, + degraded: 'llm_fallback', + }; +} + +function parseSnapshot(context) { + try { + const parsed = JSON.parse(String(context || '{}')); + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } +} + +function classifyFailure(error) { + const text = String(error?.message || error || '').toLowerCase(); + if (text.includes('timeout') || text.includes('aborted')) return 'Timeout'; + if (text.includes('fetch failed')) return 'Netzwerkfehler'; + return 'LLM-Fehler'; +} + +function looksGerman(input) { + return /\b(wie|was|warum|angriff|russland|gefahr|bitte|ist|sind|kann|koennte|könnte|auch|andere|welt|passiert|pruef|prüf)\b/i.test(String(input || '')); +} + function finalResult(decision, trace) { return { answer: String(decision.answer || '').trim() || 'No conclusion was produced.', diff --git a/lib/alerts/telegram.mjs b/lib/alerts/telegram.mjs index c0b475e..a0abf0c 100644 --- a/lib/alerts/telegram.mjs +++ b/lib/alerts/telegram.mjs @@ -611,10 +611,14 @@ export class TelegramAlerter { }); } catch (err) { console.error('[Telegram] AI chat error:', err.message); - await this.sendMessage('AI chat failed. Please try again or use /status to check the LLM configuration.', { + await this.sendConversation([ + 'Dave bekommt gerade keine stabile Antwort vom Modell.', + 'Ich bleibe erreichbar. Frag kurz nach Status, Quellen, Briefing, Maerkten, Websuche oder starte einen Sweep nach Bestaetigung.', + ], { chatId: replyChatId, replyToMessageId: msg.message_id, parseMode: null, + maxChunkChars: 360, }); } finally { stopTyping(); @@ -978,7 +982,7 @@ Respond with ONLY valid JSON: const signals = Array.isArray(evaluation.signals) ? evaluation.signals.map(compactLine).filter(Boolean).slice(0, 2) : []; const messages = [ - `[DAVE // ${tierLabel}]\n${headline}`, + german ? `Kurzer Hinweis: ${headline}` : `${tierLabel}: ${headline}`, ]; if (reason && reason !== headline) { diff --git a/server.mjs b/server.mjs index a3b537a..78fe0ef 100644 --- a/server.mjs +++ b/server.mjs @@ -943,11 +943,8 @@ async function runProactiveAgent(data, delta) { } const german = profile?.language === 'de'; const evidence = result.evidence?.length ? `${german ? 'Belege' : 'Evidence'}:\n${result.evidence.slice(0, 2).map(item => `- ${String(item).slice(0, 120)}`).join('\n')}` : ''; - const priority = german - ? ({ routine: 'HINWEIS', priority: 'PRIORITÄT', flash: 'SOFORT' }[result.priority] || 'HINWEIS') - : String(result.priority || 'routine').toUpperCase(); const messages = [ - `[DAVE // ${priority}]\n${result.answer}`, + result.answer, evidence, ].filter(Boolean); const sent = await telegramAlerter.sendConversation(messages, { maxChunkChars: 380 }); diff --git a/test/dave-presence.test.mjs b/test/dave-presence.test.mjs index bf1e772..e2204cc 100644 --- a/test/dave-presence.test.mjs +++ b/test/dave-presence.test.mjs @@ -49,7 +49,8 @@ test('dynamic presence sends a grounded message and persists a variable next eva const now = new Date('2026-07-06T12:00:00Z'); const outcome = await presence.tick(now); assert.deepEqual(outcome, { sent: true, reason: 'sent' }); - assert.match(messages[0], /^\[DAVE \/\/ AKTIV\]/); + assert.match(messages[0], /^Die Lage ist ruhig/); + assert.doesNotMatch(messages[0], /\[DAVE \/\/ AKTIV\]/); assert.match(messages[0], /Belege:/); assert.match(calls[0].prompt, /dynamic presence evaluation, not a fixed scheduled briefing/i); assert.equal(calls[0].options.mode, 'presence'); diff --git a/test/telegram-chat.test.mjs b/test/telegram-chat.test.mjs index b85f9ae..6c6a6d9 100644 --- a/test/telegram-chat.test.mjs +++ b/test/telegram-chat.test.mjs @@ -117,6 +117,41 @@ test('Telegram transport routes authorized free text as plain-text AI reply', as assert.equal(sent.body.reply_to_message_id, 11); }); +test('Telegram AI handler failures return short DAVE fallback messages', async () => { + const alerter = new TelegramAlerter({ botToken: 'test-token', chatId: '42' }); + const requests = []; + alerter.onMessage(async () => { + throw new Error('The operation was aborted due to timeout'); + }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url, options = {}) => { + requests.push({ url, body: options.body ? JSON.parse(options.body) : null }); + if (url.includes('/getUpdates')) { + return { + ok: true, + json: async () => ({ + ok: true, + result: [{ update_id: 1, message: { message_id: 10, text: 'Noch da?', chat: { id: 42 } } }], + }), + }; + } + return { ok: true, json: async () => ({ ok: true, result: { message_id: requests.length } }) }; + }; + + try { + await alerter._pollUpdates(); + } finally { + globalThis.fetch = originalFetch; + } + + const sent = requests.filter(request => request.url.includes('/sendMessage')); + assert.equal(sent.length, 2); + assert.match(sent[0].body.text, /Dave bekommt gerade keine stabile Antwort/); + assert.doesNotMatch(sent.map(item => item.body.text).join('\n'), /AI chat failed|Please try again/i); + assert.equal(sent[0].body.reply_to_message_id, 10); +}); + test('Telegram conversation delivery strips visible markdown and splits large AI replies', async () => { const alerter = new TelegramAlerter({ botToken: 'test-token', chatId: '42' }); const requests = []; @@ -209,8 +244,8 @@ test('Telegram tiered fallback alert is sent as conversational DAVE messages', a const texts = requests.filter(request => request.url.includes('/sendMessage')).map(request => request.body.text); assert.ok(texts.length >= 2); - assert.match(texts[0], /^\[DAVE \/\/ HINWEIS\]/); - assert.doesNotMatch(texts.join('\n'), /CRUCIX ROUTINE|Confidence:|Direction:|Cross-correlation:|Werkzeuge|Tools used/); + assert.match(texts[0], /^Kurzer Hinweis:/); + assert.doesNotMatch(texts.join('\n'), /\[DAVE \/\/|CRUCIX ROUTINE|Confidence:|Direction:|Cross-correlation:|Werkzeuge|Tools used/); for (const text of texts) assert.ok(text.length <= 380, `alert chunk too long: ${text.length}`); }); diff --git a/test/terminal-agent.test.mjs b/test/terminal-agent.test.mjs index 9521790..b0c5d05 100644 --- a/test/terminal-agent.test.mjs +++ b/test/terminal-agent.test.mjs @@ -132,6 +132,52 @@ test('repeated tool calls during finalization fail closed without leaking protoc assert.equal(result.notify, false); }); +test('empty model responses are repaired instead of shown to the operator', async () => { + let call = 0; + const agent = new TerminalAgent({ + provider: { + isConfigured: true, + async complete() { + call++; + return call === 1 + ? { text: '' } + : { text: JSON.stringify({ type: 'final', answer: 'Ich pruefe den letzten Sweep weiter.', confidence: 'low', evidence: [], notify: false, priority: 'routine' }) }; + }, + }, + registry: new TerminalToolRegistry([]), + }); + + const result = await agent.run('Ist noch woanders was passiert?', { preferredLanguage: 'de' }); + assert.equal(result.answer, 'Ich pruefe den letzten Sweep weiter.'); + assert.doesNotMatch(result.answer, /no usable response/i); +}); + +test('provider timeout falls back to the local sweep snapshot', async () => { + const agent = new TerminalAgent({ + provider: { + isConfigured: true, + async complete() { + throw new Error('The operation was aborted due to timeout'); + }, + }, + registry: new TerminalToolRegistry([]), + }); + const context = JSON.stringify({ + news: [{ title: 'Regional escalation report' }], + urgentOsint: ['OSINT: power outage near border'], + degradedSources: [{ name: 'GDELT' }, { name: 'ACLED' }], + ideas: [{ title: 'Gold hedge' }], + }); + + const result = await agent.run('Auch auf andere Teile der Welt', { preferredLanguage: 'de', context }); + assert.match(result.answer, /keine stabile Modellantwort/); + assert.match(result.answer, /letzten lokalen Sweep/); + assert.match(result.answer, /OSINT: power outage near border/); + assert.match(result.answer, /Steuern kann ich per Chat/); + assert.equal(result.trace[0].tool, 'llm'); + assert.equal(result.trace[0].status, 'failed'); +}); + test('agent propagates preferred German language through tool and finalization prompts', async () => { const prompts = []; let call = 0;