Skip to content

Commit c667323

Browse files
authored
Merge pull request #53 from mattmezza/feat/reply-decision
feat: reply-decision gate to stop infinite loops in group chats (#36)
2 parents f64ed0b + de04cb5 commit c667323

12 files changed

Lines changed: 564 additions & 10 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ A self-hosted personal AI agent that runs in a single Docker container. MPA acts
99
- **Calendar** — CalDAV integration (Google Calendar, iCloud, etc.)
1010
- **Contacts** — CardDAV providers via the built-in contacts CLI
1111
- **Personae** — Swappable agent identities (own character, skill/tool scope, voice). Bind one per chat — and, on Telegram, per forum topic — so several run concurrently, each with its own isolated context. Give a persona its own bot token and it becomes a separate Telegram contact (bot-per-persona)
12+
- **Reply decision** — In shared/group chats the agent decides per message whether to reply at all, staying quiet for messages aimed at another bot or caught in a bot-to-bot reaction loop, with a hard rate cap that guarantees runaway loops end (off by default)
1213
- **Memory** — Two-tier system: permanent long-term facts and expiring short-term context, both extracted automatically from conversations
1314
- **Scheduled tasks** — Cron-based jobs for morning briefings, email checks, contact sync, and custom tasks
1415
- **Subagents** — Delegate a scoped subtask to a sub-loop under a chosen persona, on demand or scheduled. Runs sync (result returned in-turn) or in the background; a finished background batch is distilled by a summary inference into a one-line chat notification + a concise context digest (raw output never reaches the user or the agent's context). The agent sizes each run (steps / token budget / thinking effort) and defaults the persona to its own; scope is a subset of the caller's (inherit-never-widen). Monitor and cancel from Telegram (`/jobs`) or the admin UI

api/admin.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1058,6 +1058,15 @@ async def partial_llm() -> HTMLResponse:
10581058
tr_model = await config_store.get("task_reflection.model") or "deepseek-v4-flash"
10591059
gd_thinking_level = await config_store.get("goal_decomposition.thinking_level") or ""
10601060
tr_thinking_level = await config_store.get("task_reflection.thinking_level") or ""
1061+
rd_enabled = await config_store.get("reply_decision.enabled")
1062+
rd_enabled = rd_enabled if rd_enabled is not None else "false"
1063+
rd_provider = await config_store.get("reply_decision.provider") or "deepseek"
1064+
rd_model = await config_store.get("reply_decision.model") or "deepseek-v4-flash"
1065+
rd_thinking_level = await config_store.get("reply_decision.thinking_level") or ""
1066+
rd_group_only = await config_store.get("reply_decision.group_only")
1067+
rd_group_only = rd_group_only if rd_group_only is not None else "true"
1068+
rd_max_replies = await config_store.get("reply_decision.max_replies_per_window") or "6"
1069+
rd_window_seconds = await config_store.get("reply_decision.window_seconds") or "120"
10611070
compaction_provider = await config_store.get("compaction.provider") or "deepseek"
10621071
compaction_model = await config_store.get("compaction.model") or "deepseek-v4-flash"
10631072
compaction_thinking_level = await config_store.get("compaction.thinking_level") or ""
@@ -1104,6 +1113,13 @@ async def partial_llm() -> HTMLResponse:
11041113
tr_provider=tr_provider,
11051114
tr_model=tr_model,
11061115
tr_thinking_level=tr_thinking_level,
1116+
rd_enabled=rd_enabled,
1117+
rd_provider=rd_provider,
1118+
rd_model=rd_model,
1119+
rd_thinking_level=rd_thinking_level,
1120+
rd_group_only=rd_group_only,
1121+
rd_max_replies=rd_max_replies,
1122+
rd_window_seconds=rd_window_seconds,
11071123
compaction_provider=compaction_provider,
11081124
compaction_model=compaction_model,
11091125
compaction_thinking_level=compaction_thinking_level,

api/templates/base.html

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@
8282
}
8383
window.restartAgentNow = restartAgentNow;
8484

85-
function llmTab(provider, apiKey, model, openaiKey, openaiBaseUrl, googleKey, googleBaseUrl, grokKey, grokBaseUrl, deepseekKey, deepseekBaseUrl, extractionProvider, extractionModel, consolidationProvider, consolidationModel, gdEnabled, gdProvider, gdModel, trEnabled, trProvider, trModel, promptToolUsageOverride, promptHistoryOverride, defaultToolUsage, defaultHistoryHandling, promptCaptureEnabled, compactionProvider, compactionModel, thinkingLevel, extractionThinkingLevel, consolidationThinkingLevel, gdThinkingLevel, trThinkingLevel, compactionThinkingLevel, visionEnabled, visionProvider, visionModel) {
85+
function llmTab(provider, apiKey, model, openaiKey, openaiBaseUrl, googleKey, googleBaseUrl, grokKey, grokBaseUrl, deepseekKey, deepseekBaseUrl, extractionProvider, extractionModel, consolidationProvider, consolidationModel, gdEnabled, gdProvider, gdModel, trEnabled, trProvider, trModel, promptToolUsageOverride, promptHistoryOverride, defaultToolUsage, defaultHistoryHandling, promptCaptureEnabled, compactionProvider, compactionModel, thinkingLevel, extractionThinkingLevel, consolidationThinkingLevel, gdThinkingLevel, trThinkingLevel, compactionThinkingLevel, visionEnabled, visionProvider, visionModel, rdEnabled, rdProvider, rdModel, rdThinkingLevel, rdGroupOnly, rdMaxReplies, rdWindowSeconds) {
8686
const currentProvider = provider || 'anthropic';
8787
return {
8888
providerOptions: [
@@ -146,6 +146,15 @@
146146
gdResultOk: false,
147147
trResult: '',
148148
trResultOk: false,
149+
rdEnabled: rdEnabled === 'true' || rdEnabled === true,
150+
rdProvider: rdProvider || 'deepseek',
151+
rdModel: rdModel || 'deepseek-v4-flash',
152+
rdThinkingLevel: rdThinkingLevel || '',
153+
rdGroupOnly: rdGroupOnly === 'true' || rdGroupOnly === true,
154+
rdMaxReplies: rdMaxReplies || '6',
155+
rdWindowSeconds: rdWindowSeconds || '120',
156+
rdResult: '',
157+
rdResultOk: false,
149158
compactionProvider: compactionProvider || 'anthropic',
150159
compactionModel: compactionModel || 'claude-haiku-4-5',
151160
compactionResult: '',
@@ -403,6 +412,12 @@
403412
if (list.length) { this.gdModel = list[0].value; }
404413
return;
405414
}
415+
if (kind === 'rd') {
416+
if (this.rdModel) return;
417+
const list = this.modelOptions(this.rdProvider);
418+
if (list.length) { this.rdModel = list[0].value; }
419+
return;
420+
}
406421
if (this.trModel) return;
407422
const list = this.modelOptions(this.trProvider);
408423
if (list.length) { this.trModel = list[0].value; }

api/templates/partials/llm.html

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,14 @@
6363
{{ compaction_thinking_level|default('', true)|tojson|forceescape }},
6464
{{ vision_enabled|default('false', true)|tojson|forceescape }},
6565
{{ vision_provider|default('anthropic', true)|tojson|forceescape }},
66-
{{ vision_model|default('claude-haiku-4-5', true)|tojson|forceescape }}
66+
{{ vision_model|default('claude-haiku-4-5', true)|tojson|forceescape }},
67+
{{ rd_enabled|default('false', true)|tojson|forceescape }},
68+
{{ rd_provider|default('deepseek', true)|tojson|forceescape }},
69+
{{ rd_model|default('deepseek-v4-flash', true)|tojson|forceescape }},
70+
{{ rd_thinking_level|default('', true)|tojson|forceescape }},
71+
{{ rd_group_only|default('true', true)|tojson|forceescape }},
72+
{{ rd_max_replies|default('6', true)|tojson|forceescape }},
73+
{{ rd_window_seconds|default('120', true)|tojson|forceescape }}
6774
)">
6875
<div class="card">
6976
<h2 class="text-base mb-1">System Prompt Controls</h2>
@@ -480,6 +487,89 @@ <h2 class="text-base mb-1">Task Reflection</h2>
480487
</template>
481488
</div>
482489

490+
<div class="card">
491+
<h2 class="text-base mb-1">Reply Decision</h2>
492+
<p class="text-muted text-xs mb-4">In shared/group chats with several bots and people, decide per message whether to reply at all — staying quiet for messages aimed at another bot, caught in a bot-to-bot reaction loop, or that the agent can't usefully add to. Off by default; uses a cheap/fast model for the yes/no call.</p>
493+
494+
<form class="space-y-4" @submit.prevent>
495+
<div class="flex items-center gap-2">
496+
<label class="label mb-0">Enabled</label>
497+
<input type="checkbox" x-model="rdEnabled" class="rounded border-border">
498+
</div>
499+
500+
<div>
501+
<label class="label">Provider</label>
502+
<select class="input-sm" style="max-width:500px" x-model="rdProvider" @change="resetBackgroundModel('rd')" x-effect="$el.value = rdProvider" :disabled="!rdEnabled">
503+
<template x-for="item in providerOptions" :key="'rd-' + item.value">
504+
<option :value="item.value" :selected="item.value === rdProvider" x-text="item.label"></option>
505+
</template>
506+
</select>
507+
</div>
508+
<div>
509+
<label class="label">Model</label>
510+
<input type="text" class="input-sm" style="max-width:500px" x-model="rdModel" :disabled="!rdEnabled"
511+
list="dl-model-rd" placeholder="Type or pick a model">
512+
<datalist id="dl-model-rd">
513+
<template x-for="item in modelOptions(rdProvider)" :key="'rd-m-' + item.value">
514+
<option :value="item.value"></option>
515+
</template>
516+
</datalist>
517+
<p class="text-muted text-xs mt-1">Model used to decide whether a group-chat message warrants a reply.</p>
518+
</div>
519+
{{ think('rdThinkingLevel', 'rdProvider', 'rdModel', 'dl-think-rd', true) }}
520+
521+
<div class="flex items-center gap-2">
522+
<label class="label mb-0">Group chats only</label>
523+
<input type="checkbox" x-model="rdGroupOnly" class="rounded border-border" :disabled="!rdEnabled">
524+
<span class="text-muted text-xs">When on, 1:1 chats always get a reply; only group chats are gated.</span>
525+
</div>
526+
<div class="flex flex-wrap gap-4">
527+
<div>
528+
<label class="label">Max replies per window</label>
529+
<input type="number" min="1" class="input-sm" style="max-width:160px" x-model="rdMaxReplies" :disabled="!rdEnabled">
530+
</div>
531+
<div>
532+
<label class="label">Window (seconds)</label>
533+
<input type="number" min="1" class="input-sm" style="max-width:160px" x-model="rdWindowSeconds" :disabled="!rdEnabled">
534+
</div>
535+
</div>
536+
<p class="text-muted text-xs">Hard backstop: never send more than this many auto-replies into one chat per rolling window — guarantees a runaway loop terminates even if the model keeps voting to reply.</p>
537+
</form>
538+
539+
<div class="flex items-center gap-2 mt-4">
540+
<button class="btn-primary btn-sm"
541+
@click="
542+
fetch('/config', {
543+
method: 'PATCH',
544+
headers: {
545+
'Content-Type': 'application/json',
546+
'Authorization': 'Bearer ' + (localStorage.getItem('admin_api_key') || '')
547+
},
548+
body: JSON.stringify({values: {
549+
'reply_decision.enabled': rdEnabled ? 'true' : 'false',
550+
'reply_decision.provider': rdProvider,
551+
'reply_decision.model': rdModel,
552+
'reply_decision.thinking_level': rdThinkingLevel,
553+
'reply_decision.group_only': rdGroupOnly ? 'true' : 'false',
554+
'reply_decision.max_replies_per_window': String(rdMaxReplies),
555+
'reply_decision.window_seconds': String(rdWindowSeconds)
556+
}})
557+
})
558+
.then(r => { rdResultOk = r.ok; return r.json(); })
559+
.then(d => {
560+
rdResult = rdResultOk ? 'Reply decision settings saved' : (d.detail || 'Error');
561+
if (rdResultOk && window.showToast) { window.showToast('Reply decision settings saved'); }
562+
})
563+
.catch(e => { rdResultOk = false; rdResult = e.message; })
564+
">
565+
Save reply decision
566+
</button>
567+
</div>
568+
<template x-if="rdResult">
569+
<div :class="rdResultOk ? 'alert-success' : 'alert-error'" class="mt-2" x-text="rdResult"></div>
570+
</template>
571+
</div>
572+
483573
<div class="card">
484574
<h2 class="text-base mb-1">History Compaction</h2>
485575
<p class="text-muted text-xs mb-4">Model used to summarize older conversation turns when a session's context grows large. Enable compaction and set the trigger threshold in the <strong>History</strong> tab.</p>

channels/whatsapp.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,10 @@ async def handle_webhook(self, payload: dict) -> dict:
125125
user_id=sender,
126126
chat_id=chat_id,
127127
)
128-
await self.send(sender, response.text)
128+
# Empty text means "no reply" (e.g. the reply-decision gate, #36) — stay
129+
# silent rather than sending a blank WhatsApp message.
130+
if response.text:
131+
await self.send(sender, response.text)
129132
if getattr(response, "system_notice", None):
130133
await self.send(sender, response.system_notice)
131134
return {"ok": True}

config.yml.example

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,3 +150,16 @@ subagent_summary:
150150
provider: "deepseek"
151151
model: "deepseek-v4-flash" # fast + cheap is ideal for this distillation
152152
thinking_level: "" # "" (off) | "low" | "medium" | "high"
153+
154+
# Reply decision — in shared/group chats with several bots and people, decide
155+
# per message whether to reply at all. Stays quiet for messages aimed at another
156+
# bot, caught in a bot-to-bot reaction loop, or that the agent can't usefully add
157+
# to. Off by default (1:1 chats always reply, and it costs one extra LLM call).
158+
# Enable it for group chats that mix multiple bots.
159+
reply_decision:
160+
enabled: false
161+
provider: "deepseek"
162+
model: "deepseek-v4-flash" # fast + cheap model for the yes/no reply decision
163+
group_only: true # only gate group chats; DMs always get a reply
164+
max_replies_per_window: 6 # hard backstop: cap auto-replies per chat per window
165+
window_seconds: 120 # rolling window for the cap — guarantees loops end

0 commit comments

Comments
 (0)