feat(main-loop): digest response protocol — actionable digests, reply verbs, closure metrics
Session: sidechat/main-loop-protocol
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
# Digest Protocol Runbook
|
||||
|
||||
How the main loop sends digests and how agents close them.
|
||||
|
||||
**Status:** spec (2026-10-04). Implemented in `self_main_loop.py` + `response-harvester.py`;
|
||||
no new machinery — the protocol wires digests into the existing `dm.py --expect-reply`
|
||||
/ `followups.json` / harvester response path.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why this protocol exists
|
||||
|
||||
Main-loop digests (`[main-loop] N new in <agent> main chat: ...`) were being sent
|
||||
with no response contract: no stable ID, no declared timeout, no nudge/escalation
|
||||
policy, and no way to distinguish "seen and closed" from "ignored". Across 43
|
||||
digests there were zero RESULT replies and zero measurable outcomes — not because
|
||||
agents ignored them, but because nothing defined how to answer.
|
||||
|
||||
This protocol closes that wiring gap without inventing a new system:
|
||||
- digest composer → `dm.py send --expect-reply` (followup record, timeout/nudges
|
||||
declared at send time)
|
||||
- agent replies in-band with a verb: `[ACK|CLAIM|RESULT|DECLINE|NO-ACTION <id>]`
|
||||
- `response-harvester.py` matches by `job_id` and records the outcome in
|
||||
`followups.json`
|
||||
- the sweeper nudges/escalates only unresolved followups, per the declared policy
|
||||
|
||||
## 2. Digest classes
|
||||
|
||||
Not every digest deserves a reply demand (notification fatigue; and the standing
|
||||
rule that the human only sees real decisions and escalations). The composer
|
||||
already marks `[?]` (question) and `[!]` (operator-needed / urgent).
|
||||
|
||||
| Class | Marker | Response required? | Followup record? | Measured by |
|
||||
|---|---|---|---|---|
|
||||
| **ACTIONABLE** | carries `[?]` or `[!]` | **yes** | yes | delivered → acked → closed |
|
||||
| **INFORMATIONAL** | neither | no | no | delivered only |
|
||||
|
||||
Informational digests are sent exactly as today. They are **excluded from every
|
||||
reply-rate metric** — an informational digest is measured as *delivered*, full stop.
|
||||
Judging informational FYI traffic by answer rate is structurally misleading.
|
||||
|
||||
## 3. Digest ID
|
||||
|
||||
Every ACTIONABLE digest gets a stable ID:
|
||||
|
||||
```
|
||||
ml-<agent>-<YYYYMMDD-HHMMSS>
|
||||
```
|
||||
|
||||
Example: `ml-646-20261004-213000`.
|
||||
|
||||
The ID is embedded visibly in the digest as `[JOB ml-...]`. This marker survives
|
||||
`extract_trailing_tags` (only known canonical keys are stripped) and is picked up
|
||||
by `dm_send`'s job_id regex (`\[JOB\s+([A-Za-z0-9_-]+)\]`) anywhere in the message,
|
||||
so it lands in `tags["job_id"]` → the followup record. **No new dm.py flags are
|
||||
needed for the ID.**
|
||||
|
||||
Replies match on this ID, so attribution games with `[from:X]` can never break
|
||||
resolution.
|
||||
|
||||
## 4. Reply verbs
|
||||
|
||||
Printed in the contract footer of every ACTIONABLE digest (Section 7).
|
||||
|
||||
| Verb | Syntax | Meaning | Followup effect |
|
||||
|---|---|---|---|
|
||||
| `ACK` | `[ACK <id>]` | Seen, noted | status `acknowledged` — stops nudges, **not closed** |
|
||||
| `CLAIM` | `[CLAIM <id>]` | I'm handling it | status `acknowledged` — stops nudges, **not closed** |
|
||||
| `RESULT` | `[RESULT <id>] <result text>` | Done, result attached | status `resolved`, outcome `RESULT` — **closed** |
|
||||
| `DECLINE` | `[DECLINE <id>]` | Won't act (reason optional) | status `resolved`, outcome `DECLINE` — **closed** |
|
||||
| `NO-ACTION` | `[NO-ACTION <id>]` | Reviewed, nothing needed | status `resolved`, outcome `NO-ACTION` — **closed** |
|
||||
|
||||
Semantics:
|
||||
- `ACK` vs `CLAIM`: both acknowledge. `CLAIM` signals ownership; `ACK` is just
|
||||
"seen". Use `CLAIM` when you're picking up the work so the human knows who's on it.
|
||||
- `NO-ACTION` is a real answer, not a dodge. It means "I reviewed this and there
|
||||
is genuinely nothing to do." It closes the digest so it stops being counted as open.
|
||||
- `DECLINE` should carry a reason when one exists (`[DECLINE ml-646-...] blocked:
|
||||
chromebox flapping`), but a bare DECLINE is still a valid close.
|
||||
- `RESULT` closes only when the result text is present. An empty RESULT is a malformed
|
||||
reply — the harvester treats it as unrecognized.
|
||||
|
||||
Nudge interaction: `ACK`/`CLAIM` suppress further nudges but leave the followup open
|
||||
until a closing verb arrives. This matches the real workflow — "I'm on it" shouldn't
|
||||
trigger nags, but the loop isn't closed until there's a result.
|
||||
|
||||
## 5. Timeout policy
|
||||
|
||||
Declared at send time (standing rule: policy is part of the send, not decided later).
|
||||
|
||||
| Digest class | Timeout | Nudges |
|
||||
|---|---|---|
|
||||
| normal (marked `[?]`) | 60 min | 2 |
|
||||
| urgent (marked `[!]`) | 30 min | 2 |
|
||||
|
||||
Implemented as `--reply-timeout {3600|1800} --reply-nudges 2` on the `dm.py send`
|
||||
invocation for ACTIONABLE digests. The timeout and nudge count are stored in the
|
||||
followup record; the sweeper honors them.
|
||||
|
||||
## 6. Nudge / escalation policy
|
||||
|
||||
- The sweeper nudges only followups with `status == "pending"`. Anything
|
||||
`acknowledged` or `resolved` is never re-nudged (ACKed digests getting nagged
|
||||
trains agents to ignore the protocol — verify this when wiring the sweeper).
|
||||
- After the declared nudges are exhausted and the timeout passes, the followup is
|
||||
**escalated**, not retried forever:
|
||||
- **Phase 1:** the sweeper's existing escalation path (DM to the escalate target
|
||||
declared at send time via `--reply-escalate`).
|
||||
- **Phase 2:** route to the registered `main-loop brain` sidechat for triage.
|
||||
The brain is registered in `job-sidechats.json` as `main-loop brain` and is
|
||||
name-resolvable, so no hardcoded UUIDs are needed.
|
||||
- `stale` = still `pending` past timeout + nudges → escalated.
|
||||
|
||||
Digest routing must use sidechat **names** (`646 tasks`, `main-loop brain`) via the
|
||||
existing name resolution — never hardcoded thread UUIDs (see the stale-UUID lesson
|
||||
in AGENTS.md).
|
||||
|
||||
Note: the harvester only monitors threads registered in `job-sidechats.json`.
|
||||
`opm/heartbeat` is not registered (digests there resolve via fuzzy search), so
|
||||
digest replies there would never resolve. **Route opm's digests to the registered
|
||||
`main-loop brain` sidechat instead of `heartbeat`** — cleaner separation
|
||||
(brain = triage workspace, heartbeat = heartbeats) and zero new registrations.
|
||||
|
||||
## 7. In-band contract footer
|
||||
|
||||
The digest budget is 600 chars, so the footer is compact. Actionable digests trim
|
||||
message previews from 5 to 3 to fit it:
|
||||
|
||||
```
|
||||
[main-loop] [JOB ml-646-20261004-213000] 3 new in 646 main chat — reply needed:
|
||||
- human: <preview> [?]
|
||||
Reply: [ACK id] seen | [CLAIM id] mine | [RESULT id] done | [DECLINE id] | [NO-ACTION id]
|
||||
```
|
||||
|
||||
The footer is the primary contract. This doc is the reference.
|
||||
|
||||
## 8. Example exchange
|
||||
|
||||
```
|
||||
# opm → 646 tasks (ACTIONABLE, --expect-reply --reply-timeout 3600 --reply-nudges 2)
|
||||
|
||||
[from:opm] [id:a6579a8d] [main-loop] [JOB ml-646-20261004-213000] 3 new in 646 main chat — reply needed:
|
||||
- human: should we archive the stale pipe-7d2896 thread? [?]
|
||||
- opm: propose yes, it's superseded by the brain channel [?]
|
||||
Reply: [ACK id] seen | [CLAIM id] mine | [RESULT id] done | [DECLINE id] | [NO-ACTION id]
|
||||
|
||||
# 646 picks it up
|
||||
|
||||
[from:646] [CLAIM ml-646-20261004-213000] reviewing now
|
||||
|
||||
→ followup status: acknowledged (nudges suppressed, loop still open)
|
||||
|
||||
# 646 finishes
|
||||
|
||||
[from:646] [RESULT ml-646-20261004-213000] archived pipe-7d2896 via muse-cli; brain channel is canonical
|
||||
|
||||
→ followup status: resolved, outcome: RESULT (loop closed)
|
||||
```
|
||||
|
||||
A shorter close:
|
||||
|
||||
```
|
||||
# pip sees an informational-with-a-question, reviews, nothing to do
|
||||
|
||||
[from:pip] [NO-ACTION ml-pip-20261004-214500] reviewed — nothing needed, timer stagger already handles it
|
||||
|
||||
→ followup status: resolved, outcome: NO-ACTION (loop closed)
|
||||
```
|
||||
|
||||
## 9. Metrics
|
||||
|
||||
Source of truth: `followups.json` (`outcome` field, written by the harvester).
|
||||
|
||||
| Metric | Definition |
|
||||
|---|---|
|
||||
| **delivered** | `dm.py` reported `verified:true` with placement confirmed for the digest message (see dm-log) |
|
||||
| **acked** | outcome ∈ {`ACK`, `CLAIM`} |
|
||||
| **closed** | outcome ∈ {`RESULT`, `DECLINE`, `NO-ACTION`} |
|
||||
| **stale** | still `pending` past timeout + nudges → escalated |
|
||||
| **closure_rate** | `closed / actionable_delivered` |
|
||||
|
||||
Rules:
|
||||
- Informational digests are excluded from every rate. They are measured as
|
||||
delivered, full stop.
|
||||
- Actionable digests are measured as closed. A digest that is ACKed but never
|
||||
resolved counts as open until a closing verb arrives.
|
||||
- `delivered` requires the placement confirmation, not just the `verified:true`
|
||||
flag (see the AGENTS.md placement-blindness lesson).
|
||||
|
||||
Reporting: `self_main_loop.py` `status` exposes `digest_health()` counts
|
||||
(delivered/acked/closed/stale), surfaced through the existing Box
|
||||
`main-loop/status` API. No new endpoint needed in phase 1.
|
||||
|
||||
## 10. Implementation notes
|
||||
|
||||
- `dm.py` — **no changes.** `--expect-reply`, job_id extraction from `[JOB …]`,
|
||||
followup registration, and placement verification already exist and are tested.
|
||||
- `response-harvester.py` — extend the reply regex to verb-aware matching:
|
||||
`\[(ACK|CLAIM|RESULT|DECLINE|NO-ACTION)\s+([A-Za-z0-9_-]+)\]`; on match, resolve
|
||||
by job_id and record `outcome`. `ACK`/`CLAIM` → `acknowledged`;
|
||||
`RESULT`/`DECLINE`/`NO-ACTION` → `resolved` + outcome.
|
||||
- `self_main_loop.py` — `compose_digest` classifies actionable vs informational;
|
||||
actionable embeds `[JOB ml-…]` + footer; `send_prompt` passes
|
||||
`--expect-reply --reply-timeout {3600|1800} --reply-nudges 2` for actionable only.
|
||||
- Sweeper — verify it nudges only `status == "pending"` (no code change expected).
|
||||
|
||||
All changes follow review-then-commit. No new daemons, no new state files beyond
|
||||
the existing `followups.json`.
|
||||
|
||||
---
|
||||
|
||||
*Companion docs: `DM-SPEC.md` (DM format), `THREAD-BOOKKEEPING.md` (pin/archive),
|
||||
`WARP-EGRESS-FIX.md` (partition handling).*
|
||||
Reference in New Issue
Block a user