feat(main-loop): digest response protocol — actionable digests, reply verbs, closure metrics
Session: sidechat/main-loop-protocol
This commit is contained in:
+71
-14
@@ -4,8 +4,9 @@ response-harvester.py — Fleet agent readback and response harvesting daemon.
|
||||
|
||||
Monitors Chromebox agents (muse, pip, 646, opm), harvests incoming messages from
|
||||
Main Chat and registered sidechats, maintains persistent watermarks, appends to
|
||||
chat-history.jsonl, resolves pending follow-ups, and records [RESULT] completions
|
||||
in job-log.jsonl.
|
||||
chat-history.jsonl, resolves pending follow-ups (verb-aware: [ACK|CLAIM] ->
|
||||
acknowledged, [RESULT|DECLINE|NO-ACTION] -> resolved, outcome recorded), and
|
||||
records [RESULT] completions in job-log.jsonl.
|
||||
|
||||
Features:
|
||||
- Direct CDP over host veth interfaces (fast, no sudo needed).
|
||||
@@ -83,6 +84,20 @@ def iter_result_markers(text):
|
||||
yield m.group(1).strip(), m.group(2).strip()
|
||||
|
||||
|
||||
# Verb markers for the digest response protocol:
|
||||
# [ACK|CLAIM|RESULT|DECLINE|NO-ACTION <job_id>].
|
||||
# ACK/CLAIM acknowledge a digest (nudge-suppressed, NOT closed);
|
||||
# RESULT/DECLINE/NO-ACTION close the digest. Every verb match records
|
||||
# outcome=<verb> on the followup record.
|
||||
VERB_RE = re.compile(r"\[(ACK|CLAIM|RESULT|DECLINE|NO-ACTION)\s+([A-Za-z0-9_-]+)\]")
|
||||
|
||||
|
||||
def iter_verb_markers(text):
|
||||
"""Yield (verb, job_id) for every [VERB <job_id>] marker in text."""
|
||||
for m in VERB_RE.finditer(text or ""):
|
||||
yield m.group(1), m.group(2).strip()
|
||||
|
||||
|
||||
|
||||
def utcnow():
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
@@ -366,7 +381,8 @@ def harvest_main_feed(cdp, agent, watermarks, followups, dry_run=False):
|
||||
|
||||
if author == "assistant":
|
||||
markers = list(iter_result_markers(text))
|
||||
if markers:
|
||||
verbs = list(iter_verb_markers(text))
|
||||
if markers or verbs:
|
||||
for job_id, result_text in markers:
|
||||
is_fail = is_fail_result(result_text)
|
||||
job_results += 1
|
||||
@@ -385,7 +401,12 @@ def harvest_main_feed(cdp, agent, watermarks, followups, dry_run=False):
|
||||
append_jsonl(JOB_LOG, job_record)
|
||||
trigger_chain_next(job_id, result_text, success=not is_fail)
|
||||
clear_matching_followups(followups, agent, "main", mid, text,
|
||||
dry_run, job_id=job_id)
|
||||
dry_run, job_id=job_id, verb="RESULT")
|
||||
for verb, job_id in verbs:
|
||||
if verb == "RESULT":
|
||||
continue # resolved via the result-marker path above
|
||||
clear_matching_followups(followups, agent, "main", mid, text,
|
||||
dry_run, job_id=job_id, verb=verb)
|
||||
else:
|
||||
clear_matching_followups(followups, agent, "main", mid, text, dry_run)
|
||||
|
||||
@@ -487,7 +508,8 @@ def harvest_agent_thread(cdp, agent, thread_info, watermarks, followups, dry_run
|
||||
|
||||
if author == "assistant":
|
||||
markers = list(iter_result_markers(text))
|
||||
if markers:
|
||||
verbs = list(iter_verb_markers(text))
|
||||
if markers or verbs:
|
||||
for job_id, result_text in markers:
|
||||
is_fail = is_fail_result(result_text)
|
||||
job_results += 1
|
||||
@@ -507,7 +529,12 @@ def harvest_agent_thread(cdp, agent, thread_info, watermarks, followups, dry_run
|
||||
# Trigger pipeline chaining or next job if configured
|
||||
trigger_chain_next(job_id, result_text, success=not is_fail)
|
||||
clear_matching_followups(followups, agent, thread_id, mid, text,
|
||||
dry_run, job_id=job_id)
|
||||
dry_run, job_id=job_id, verb="RESULT")
|
||||
for verb, job_id in verbs:
|
||||
if verb == "RESULT":
|
||||
continue # resolved via the result-marker path above
|
||||
clear_matching_followups(followups, agent, thread_id, mid, text,
|
||||
dry_run, job_id=job_id, verb=verb)
|
||||
else:
|
||||
clear_matching_followups(followups, agent, thread_id, mid, text, dry_run)
|
||||
|
||||
@@ -611,7 +638,7 @@ def trigger_chain_next(job_id, result_text, success=True):
|
||||
|
||||
|
||||
def clear_matching_followups(followups, agent, thread_id, mid, text, dry_run=False,
|
||||
job_id=None):
|
||||
job_id=None, verb=None):
|
||||
"""Resolve follow-up records if an assistant message is detected in the thread.
|
||||
|
||||
Matches on thread identity (thread_uuid or target='main') OR on job_id
|
||||
@@ -619,13 +646,27 @@ def clear_matching_followups(followups, agent, thread_id, mid, text, dry_run=Fal
|
||||
thread_uuid or target, fixing ghost followups with null thread_uuid.
|
||||
Also matches a main-chat reply when the sweeper recorded
|
||||
final_nudge_target='main' (final nudge routed to main chat).
|
||||
|
||||
When verb is given ([ACK|CLAIM|RESULT|DECLINE|NO-ACTION <job_id>]),
|
||||
matching is job_id-scoped for non-RESULT verbs (a verb marker names the
|
||||
digest it answers, so it must not touch unrelated pending followups that
|
||||
merely share the thread); RESULT keeps the historical thread-or-job
|
||||
matching. Every verb match records outcome=<verb>. ACK/CLAIM set status
|
||||
'acknowledged' (nudge-suppressed, NOT closed) instead of 'resolved', and
|
||||
also match already-'acknowledged' records so an ACK -> RESULT lifecycle
|
||||
closes correctly.
|
||||
"""
|
||||
if not followups:
|
||||
return
|
||||
|
||||
modified = False
|
||||
for f_id, f_rec in followups.items():
|
||||
if f_rec.get("status") != "pending":
|
||||
# Verb replies can follow an ACK (ACK -> RESULT lifecycle), so verbs
|
||||
# also match 'acknowledged' records; plain replies only match pending.
|
||||
if verb:
|
||||
if f_rec.get("status") not in ("pending", "acknowledged"):
|
||||
continue
|
||||
elif f_rec.get("status") != "pending":
|
||||
continue
|
||||
if f_rec.get("recipient") != agent:
|
||||
continue
|
||||
@@ -642,17 +683,33 @@ def clear_matching_followups(followups, agent, thread_id, mid, text, dry_run=Fal
|
||||
# sidechat.
|
||||
match_thread = True
|
||||
|
||||
# Match by job_id (from [RESULT <job_id>]) -- works regardless of
|
||||
# thread_uuid or target. This is an ADDITIONAL path, not a replacement.
|
||||
# Match by job_id (from [RESULT <job_id>] or [VERB <job_id>]) --
|
||||
# works regardless of thread_uuid or target. This is an ADDITIONAL
|
||||
# path, not a replacement.
|
||||
match_job = False
|
||||
if job_id and f_rec.get("job_id") and f_rec.get("job_id") == job_id:
|
||||
match_job = True
|
||||
|
||||
# Non-RESULT verbs are job-scoped: they must not acknowledge/resolve
|
||||
# unrelated pending followups that merely share the thread. RESULT
|
||||
# keeps the historical thread-or-job matching.
|
||||
if verb and verb != "RESULT" and not match_job:
|
||||
continue
|
||||
|
||||
if match_thread or match_job:
|
||||
f_rec["status"] = "resolved"
|
||||
f_rec["resolved_at"] = utcnow()
|
||||
f_rec["resolved_by_mid"] = mid
|
||||
f_rec["resolved_snippet"] = text[:150]
|
||||
if verb:
|
||||
f_rec["outcome"] = verb
|
||||
if verb in ("ACK", "CLAIM"):
|
||||
# Acknowledged: sweeper nudges stop (status != pending), but
|
||||
# the digest is NOT closed until a closing verb arrives.
|
||||
f_rec["status"] = "acknowledged"
|
||||
f_rec["acknowledged_at"] = utcnow()
|
||||
f_rec["acknowledged_by_mid"] = mid
|
||||
else:
|
||||
f_rec["status"] = "resolved"
|
||||
f_rec["resolved_at"] = utcnow()
|
||||
f_rec["resolved_by_mid"] = mid
|
||||
f_rec["resolved_snippet"] = text[:150]
|
||||
modified = True
|
||||
|
||||
if modified and not dry_run:
|
||||
|
||||
Reference in New Issue
Block a user