86f0082ffc
Session: sidechat/main-loop-protocol
214 lines
9.2 KiB
Markdown
214 lines
9.2 KiB
Markdown
# 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).*
|