Compare commits

...

3 Commits

Author SHA1 Message Date
c4c6063c6e test: expect conversational dave presence
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 47s
Build / test-and-image (pull_request) Successful in 2m32s
2026-07-11 18:33:07 +02:00
429114f272 fix: keep dave chat responsive on llm failures
Some checks failed
Codex Template Compliance / template-compliance (pull_request) Successful in 52s
Build / test-and-image (pull_request) Failing after 1m31s
2026-07-11 18:27:12 +02:00
74579375b3 Merge pull request 'fix: make DAVE Telegram alerts conversational' (#87)
All checks were successful
Codex Template Compliance / template-compliance (push) Successful in 27s
Release Dry Run / release-dry-run (push) Successful in 45s
Build / test-and-image (push) Successful in 1m18s
Shortens DAVE Telegram delivery and removes report-style fallback/tool traces.
2026-07-11 09:06:12 +00:00
8 changed files with 196 additions and 21 deletions

View File

@@ -5,7 +5,8 @@ Last updated: 2026-07-11
## Current DAVE Telegram Style Fix ## 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`. - 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. - 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: - Code changes in progress:
- `TelegramAlerter.sendConversation(...)` now defaults to 420-character chat chunks and can split long paragraphs by sentence. - `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. - 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. - 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. - 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 ## Source Degradation Handling

View File

@@ -125,7 +125,7 @@ export class DavePresence {
const evidence = result.evidence?.length const evidence = result.evidence?.length
? `${german ? 'Belege' : 'Evidence'}:\n${result.evidence.slice(0, 2).map(item => `- ${String(item).slice(0, 120)}`).join('\n')}` ? `${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' const sent = typeof this.alerter.sendConversation === 'function'
? await this.alerter.sendConversation(messages, { maxChunkChars: 380 }) ? await this.alerter.sendConversation(messages, { maxChunkChars: 380 })
: await this.alerter.sendMessage(messages.join('\n\n'), { parseMode: null }); : await this.alerter.sendMessage(messages.join('\n\n'), { parseMode: null });

View File

@@ -86,13 +86,25 @@ export class TerminalAgent {
].join('\n\n'); ].join('\n\n');
for (let step = 0; step < this.maxSteps; step++) { for (let step = 0; step < this.maxSteps; step++) {
const response = await this.provider.complete(this._systemPrompt(mode, responseLanguage), working, { let response;
try {
response = await this.provider.complete(this._systemPrompt(mode, responseLanguage), working, {
maxTokens: this.maxTokens, maxTokens: this.maxTokens,
timeout: this.timeoutMs, 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); const decision = parseDecision(response?.text);
if (!decision) { 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)) { if (looksLikeProtocolPayload(answer)) {
working += '\n\nPROTOCOL ERROR: The previous tool syntax was malformed. Return the documented JSON tool_call or final object only.'; working += '\n\nPROTOCOL ERROR: The previous tool syntax was malformed. Return the documented JSON tool_call or final object only.';
continue; continue;
@@ -146,10 +158,18 @@ export class TerminalAgent {
} }
for (let attempt = 0; attempt < 2; attempt++) { 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.`, { 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, maxTokens: this.maxTokens,
timeout: this.timeoutMs, 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); const decision = parseDecision(response?.text);
if (decision?.type === 'final') { if (decision?.type === 'final') {
const result = finalResult(decision, trace); 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.'; : '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) { function finalResult(decision, trace) {
return { return {
answer: String(decision.answer || '').trim() || 'No conclusion was produced.', answer: String(decision.answer || '').trim() || 'No conclusion was produced.',

View File

@@ -611,10 +611,14 @@ export class TelegramAlerter {
}); });
} catch (err) { } catch (err) {
console.error('[Telegram] AI chat error:', err.message); 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, chatId: replyChatId,
replyToMessageId: msg.message_id, replyToMessageId: msg.message_id,
parseMode: null, parseMode: null,
maxChunkChars: 360,
}); });
} finally { } finally {
stopTyping(); 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 signals = Array.isArray(evaluation.signals) ? evaluation.signals.map(compactLine).filter(Boolean).slice(0, 2) : [];
const messages = [ const messages = [
`[DAVE // ${tierLabel}]\n${headline}`, german ? `Kurzer Hinweis: ${headline}` : `${tierLabel}: ${headline}`,
]; ];
if (reason && reason !== headline) { if (reason && reason !== headline) {

View File

@@ -943,11 +943,8 @@ async function runProactiveAgent(data, delta) {
} }
const german = profile?.language === 'de'; 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 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 = [ const messages = [
`[DAVE // ${priority}]\n${result.answer}`, result.answer,
evidence, evidence,
].filter(Boolean); ].filter(Boolean);
const sent = await telegramAlerter.sendConversation(messages, { maxChunkChars: 380 }); const sent = await telegramAlerter.sendConversation(messages, { maxChunkChars: 380 });

View File

@@ -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 now = new Date('2026-07-06T12:00:00Z');
const outcome = await presence.tick(now); const outcome = await presence.tick(now);
assert.deepEqual(outcome, { sent: true, reason: 'sent' }); 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(messages[0], /Belege:/);
assert.match(calls[0].prompt, /dynamic presence evaluation, not a fixed scheduled briefing/i); assert.match(calls[0].prompt, /dynamic presence evaluation, not a fixed scheduled briefing/i);
assert.equal(calls[0].options.mode, 'presence'); assert.equal(calls[0].options.mode, 'presence');

View File

@@ -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); 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 () => { test('Telegram conversation delivery strips visible markdown and splits large AI replies', async () => {
const alerter = new TelegramAlerter({ botToken: 'test-token', chatId: '42' }); const alerter = new TelegramAlerter({ botToken: 'test-token', chatId: '42' });
const requests = []; 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); const texts = requests.filter(request => request.url.includes('/sendMessage')).map(request => request.body.text);
assert.ok(texts.length >= 2); assert.ok(texts.length >= 2);
assert.match(texts[0], /^\[DAVE \/\/ HINWEIS\]/); assert.match(texts[0], /^Kurzer Hinweis:/);
assert.doesNotMatch(texts.join('\n'), /CRUCIX ROUTINE|Confidence:|Direction:|Cross-correlation:|Werkzeuge|Tools used/); 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}`); for (const text of texts) assert.ok(text.length <= 380, `alert chunk too long: ${text.length}`);
}); });

View File

@@ -132,6 +132,52 @@ test('repeated tool calls during finalization fail closed without leaking protoc
assert.equal(result.notify, false); 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 () => { test('agent propagates preferred German language through tool and finalization prompts', async () => {
const prompts = []; const prompts = [];
let call = 0; let call = 0;