feat(autonomy): autonomous swarm worker orchestration, nudge triggers, and recursive loop

This commit is contained in:
operator
2026-10-05 04:32:06 +00:00
parent b0454289ae
commit b88209d7a1
4 changed files with 1636 additions and 4 deletions
+140 -2
View File
@@ -276,9 +276,14 @@ def make_digest_id(agent):
# In-band response-contract footer for ACTIONABLE digests. The verbs are matched
# by response-harvester.py to resolve followups: ACK/CLAIM acknowledge (nudge
# suppression), RESULT/DECLINE/NO-ACTION close. ~94 chars, well under budget.
# suppression), RESULT/DECLINE/NO-ACTION close. The closing line enforces the
# recursive box->agent->box discipline: post the RESULT back in this thread
# (box records it and dispatches the next chained step); never DM the next
# agent directly. ~166 chars, within the 600-char digest budget.
CONTRACT_FOOTER = ("Reply: [ACK id] seen | [CLAIM id] mine | "
"[RESULT id] done | [DECLINE id] | [NO-ACTION id]")
"[RESULT id] done | [DECLINE id] | [NO-ACTION id]. "
"Report back here. Box dispatches the next step; "
"do not DM the next agent directly.")
def send_prompt(sender, agent, sidechat, digest):
@@ -552,6 +557,63 @@ def check_subagents(agent, cfg, threads_meta):
return alerts
# ---------------------------------------------------------------------------
# Brain workspace — the main loop operates its thinking in the "main-loop
# brain" sidechat (opm's account). See bin/brain.py.
# User directive 2026-10-04: "we need main loop to operate its brains in
# side chat". Safety: brain posts carry [BRAIN], never [JOB]; never
# --expect-reply; own messages skipped on read; !loop only from authorized
# senders. All enforced in brain.py.
# ---------------------------------------------------------------------------
_BRAIN_LAST_IDS = {} # (agent, sidechat_name) -> last message_id seen
def read_brain_messages(agent, sidechat_name, since_ts):
"""read_fn adapter for brain.BrainWorkspace.
Resolves sidechat_name -> UUID via dm (never hardcoded; UUIDs rotate),
reads via the same muse_hybrid primitive the loop uses, returns
[{"sender", "text", "ts"}]. Tracks last message_id per (agent, name);
first run anchors at newest with no backfill (same policy as
new_messages for main chat).
"""
try:
import dm
uuid = dm.resolve_sidechat_target(sidechat_name, agent)
except Exception:
return []
if not uuid:
return []
try:
import muse_hybrid
msgs, err = muse_hybrid.get_history(agent, thread_id=uuid, limit=10)
except Exception:
return []
if err or not msgs:
return []
key = (agent, sidechat_name)
last_id = _BRAIN_LAST_IDS.get(key)
ids = [m.get("message_id") or "msg-%s" % m.get("seq") for m in msgs]
if last_id is None:
# First run: anchor at newest, no backfill (old !loop commands
# must not fire on deploy).
_BRAIN_LAST_IDS[key] = ids[-1] if ids else None
return []
if last_id in ids:
new_msgs = msgs[ids.index(last_id) + 1:]
else:
# Watermark fell out of the read window: treat all as new.
# Safe: brain intake skips own messages and only authorized
# !loop senders can act.
new_msgs = msgs
_BRAIN_LAST_IDS[key] = ids[-1] if ids else last_id
now = time.time()
return [{"sender": m.get("role", "unknown"),
"text": m.get("text", ""),
"ts": now} for m in new_msgs]
def do_check(only_agent=None):
st = load_state()
cfg = get_config(st)
@@ -564,6 +626,23 @@ def do_check(only_agent=None):
results = {}
prompted = 0
errors = 0
quiet_held = 0
# Brain workspace: the loop thinks in the sidechat, takes !loop
# instructions there. Non-fatal: a brain failure must never break
# the tick.
brain = None
try:
from brain import BrainWorkspace
brain = BrainWorkspace(STATE_FILE, read_fn=read_brain_messages)
intake = brain.intake()
if intake.get("commands"):
log("brain: %d commands, %d acks, %d ignored-senders" % (
intake["commands"], intake.get("acks", 0),
intake.get("ignored_senders", 0)))
except Exception as e:
log("brain init/intake failed (non-fatal): %r" % e)
brain = None
import muse_hybrid
@@ -581,6 +660,10 @@ def do_check(only_agent=None):
if not enabled.get(agent, True):
results[agent] = {"ok": True, "new": 0, "disabled": True}
continue
if brain is not None and brain.ignored(agent):
log("%s: skipped (brain !loop ignore active)" % agent)
results[agent] = {"ok": True, "new": 0, "ignored": True}
continue
wm = agents_state.get(agent) or {}
# 1. Main chat check
@@ -640,6 +723,18 @@ def do_check(only_agent=None):
errors += 1
continue
# Brain quiet mode: hold actionable escalations. Reads continue,
# digests are composed, but nothing escalates to the agent — the
# thinking note in the brain carries the activity instead.
if brain is not None and brain.quiet():
is_act, _urg = classify_digest(digest)
if is_act:
log("%s: quiet mode - digest held (not escalated)" % agent)
results[agent] = {"ok": True, "new": total_new,
"quiet_held": True}
quiet_held += 1
continue
sent, detail, digest_id, actionable = send_prompt(cfg["sender"], agent, sidechat, digest)
if sent:
prompted += 1
@@ -653,6 +748,49 @@ def do_check(only_agent=None):
results[agent] = {"ok": False, "error": detail, "new": total_new}
errors += 1
# Brain: post the tick's thinking to the sidechat workspace, then
# persist brain state. Non-fatal on failure.
if brain is not None:
try:
seen = {}
escalated = []
for a in agents:
r = results.get(a, {})
seen[a] = (r.get("new", 0), 0, 0)
if r.get("prompted"):
wm_a = agents_state.get(a) or {}
did = wm_a.get("last_digest_id")
if did and wm_a.get("last_digest_actionable"):
escalated.append(did)
closure_rate = None
health = None
try:
health = digest_health()
closure_rate = (health or {}).get("closure_rate")
except Exception:
pass
wm_epochs = {}
for a in agents:
ts_s = (agents_state.get(a) or {}).get("last_ts")
try:
if ts_s:
dt = datetime.fromisoformat(
ts_s.replace("Z", "+00:00"))
wm_epochs[a] = dt.timestamp()
except Exception:
pass
brain.post_thinking(
{"seen": seen, "escalated": escalated,
"skipped_info": quiet_held, "errors": errors,
"closure_rate": closure_rate},
cfg={a: bool(enabled.get(a, True)) for a in agents},
watermarks=wm_epochs,
health=health,
)
brain.save()
except Exception as e:
log("brain post_thinking failed (non-fatal): %r" % e)
# Save under the state lock with a fresh reload: an enable/disable may
# have landed during the slow chat reads; preserve its config changes
# and only update the keys this run owns (watermarks, last_run/result).