From e6d2fbe51ad2e82140eb10ebb8e1dd1323f98629 Mon Sep 17 00:00:00 2001 From: Ian de Marcellus <50731233+ian-de-marcellus@users.noreply.github.com> Date: Thu, 24 Sep 2026 03:52:58 +0200 Subject: [PATCH] =?UTF-8?q?feat(inference):=20opt-in=20failureNotices=20?= =?UTF-8?q?=E2=80=94=20tell=20the=20room=20when=20a=20turn=20produced=20no?= =?UTF-8?q?thing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chronicle marker already tells the resident its turn failed; people in the room saw only silence and couldn't tell a dead turn from a quiet one (Fable: 'silence should mark itself'). With AgentConfig.failureNotices, a short, clearly automatic notice goes to the agent's speaking room on the first failure of a streak and every 5th after (so a stuck loop can't flood it), for both exhausted inference errors and refusals. Default off. Co-Authored-By: Claude Opus 5.5 (1M context) --- changelog.d/feat-failure-notices.added.md | 4 +++ src/agent.ts | 3 +++ src/framework.ts | 26 ++++++++++++++++++++ src/types/agent.ts | 7 ++++++ test/inference-failure-observability.test.ts | 21 ++++++++++++++++ 5 files changed, 61 insertions(+) create mode 100644 changelog.d/feat-failure-notices.added.md diff --git a/changelog.d/feat-failure-notices.added.md b/changelog.d/feat-failure-notices.added.md new file mode 100644 index 00000000..65b0608f --- /dev/null +++ b/changelog.d/feat-failure-notices.added.md @@ -0,0 +1,4 @@ +- Opt-in `failureNotices`: when a turn fails outright (provider error, + refusal, over-budget), post a short automatic notice to the agent's locus on + the first failure of a streak and every 5th after, so people can tell a + dead turn from a quiet one. diff --git a/src/agent.ts b/src/agent.ts index 0855aafd..797ffac5 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -84,6 +84,8 @@ export class Agent { readonly refusalHandling: AgentConfig['refusalHandling']; /** Prose delivery mode (see AgentConfig.proseRouting). Default 'locus'. */ readonly proseRouting: 'locus' | 'explicit' | 'hybrid' | 'disabled'; + /** Post a room notice when a turn fails (see AgentConfig.failureNotices). */ + readonly failureNotices: boolean; /** Exact whole-response known-tool wrapper containment (default off). */ readonly toolWrapperProseGuard: boolean; /** Prompt-cache TTL forwarded to the provider (see AgentConfig.cacheTtl). */ @@ -144,6 +146,7 @@ export class Agent { this.thinking = config.thinking; this.refusalHandling = config.refusalHandling; this.proseRouting = config.proseRouting ?? 'locus'; + this.failureNotices = config.failureNotices === true; this.toolWrapperProseGuard = config.toolWrapperProseGuard ?? false; this.cacheTtl = config.cacheTtl ?? '1h'; this.promptCaching = config.promptCaching ?? true; diff --git a/src/framework.ts b/src/framework.ts index 87f9c2a4..d4f926b3 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -11531,6 +11531,7 @@ export class AgentFramework { const streak = (this.refusalStreak.get(agentName) ?? 0) + 1; this.refusalStreak.set(agentName, streak); + this.postFailureNotice(agentName, streak, `the model declined to respond (safety category: ${category})`); this.logFailure({ agent: agentName, @@ -11591,6 +11592,30 @@ export class AgentFramework { this.opsAlert(kind, agentName, message, { data }); } + /** + * AgentConfig.failureNotices: tell the ROOM when a turn produced nothing. + * The chronicle marker already tells the resident; without this, people + * see only silence and can't tell a dead turn from a quiet one. Posted on + * the first failure of a streak and every 5th after, so a stuck loop can't + * flood the channel. Best-effort: a notice that can't be delivered is + * dropped (the stderr/failures.log records remain). + */ + private postFailureNotice(agentName: string, streak: number, what: string): void { + const agent = this.agents.get(agentName); + if (!agent?.failureNotices || !this.channelRegistry) return; + if (streak !== 1 && streak % 5 !== 0) return; + const locus = this.channelRegistry.resolveLocus(agentName) ?? null; + if (!locus) return; + const oneLine = what.replace(/\s+/g, ' ').trim(); + const reason = oneLine.length > 160 ? `${oneLine.slice(0, 157)}…` : oneLine; + const text = + `⚠️ [automatic notice] ${agentName}'s reply failed to generate` + + `${streak > 1 ? ` (${streak} in a row)` : ''}: ${reason}. ` + + 'Nothing was lost from the conversation; it will see this and your messages on its next turn.'; + void this.channelRegistry.routeSpeech(agentName, text, locus) + .catch((err) => console.error(`[inference-failed] failure notice not delivered for ${agentName}:`, err)); + } + private noteInferenceExhausted( agentName: string, reason: string, @@ -11603,6 +11628,7 @@ export class AgentFramework { // (1) Durable stderr line — works in headless/daemon mode with no client. console.error(`[inference-failed] agent=${agentName} consecutive=${streak}: ${reason}`); + this.postFailureNotice(agentName, streak, reason); // (1b) Machine-greppable durable record, independent of journald/unit log // redirects: logs/failures.log under the host's working directory. This is diff --git a/src/types/agent.ts b/src/types/agent.ts index b5cd7471..62659ad9 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -185,6 +185,13 @@ export interface AgentConfig { */ proseRouting?: 'locus' | 'explicit' | 'hybrid' | 'disabled'; + /** + * Post a short automatic notice to the agent's speaking room when a turn + * fails outright (model call error, refusal, over-budget) — first failure + * of a streak and every 5th after. Default false. + */ + failureNotices?: boolean; + /** * Fail-closed containment for a text response whose entire visible prose is * an invocation-shaped wrapper naming a tool registered on that exact turn. diff --git a/test/inference-failure-observability.test.ts b/test/inference-failure-observability.test.ts index d589610e..657e165f 100644 --- a/test/inference-failure-observability.test.ts +++ b/test/inference-failure-observability.test.ts @@ -83,3 +83,24 @@ test('per-agent isolation: one agent failing does not flag another', () => { assert.equal(fw.consecutiveInferenceFailures.get('cairn'), 2); assert.equal(fw.consecutiveInferenceFailures.get('lena') ?? 0, 0); }); + +test('failureNotices: room notice on failure 1 and every 5th; off by default', async () => { + const { fw, restore } = makeHarness(); + const posted: Array<{ text: string; locus: string | null }> = []; + fw.channelRegistry = { + resolveLocus: () => 'discord:g:salon', + routeSpeech: async (_agent: string, text: string, locus: string | null) => { posted.push({ text, locus }); return { delivered: true }; }, + }; + const addMessage = () => {}; + fw.agents.set('cairn', { failureNotices: true, getContextManager: () => ({ addMessage }) }); + fw.agents.set('quiet', { failureNotices: false, getContextManager: () => ({ addMessage }) }); + try { + for (let i = 0; i < 5; i++) fw.noteInferenceExhausted('cairn', '400 image/webp label but PNG bytes'); + fw.noteInferenceExhausted('quiet', 'boom'); + } finally { restore(); } + await new Promise((r) => setImmediate(r)); + assert.equal(posted.length, 2, 'failure 1 and failure 5 only'); + assert.equal(posted[0].locus, 'discord:g:salon'); + assert.match(posted[0].text, /^⚠️ \[automatic notice\] cairn's reply failed to generate: 400 image\/webp/); + assert.match(posted[1].text, /\(5 in a row\)/); +});