feat(autonomy): add followup.create primitive and tool-continuation directives to enforce silence-kills-work

This commit is contained in:
operator
2026-10-05 05:24:45 +00:00
parent 03374795d8
commit c90e096192
5 changed files with 172 additions and 16 deletions
+44 -9
View File
@@ -197,9 +197,10 @@ def cron_to_oncalendar(schedule):
# M H DOM * * -> *-*-DOM HH:MM:00 (monthly)
if m.isdigit() and h.isdigit() and dom.isdigit() and is_star(mon) and is_star(dow):
return f"*-*-{int(dom):02d} {int(h):02d}:{int(m):02d}:00"
# M H * * * with stepped hour: M */n * * * -> 0/n:MM
if m.isdigit() and h.startswith("*/") and h[2:].isdigit() and all(is_star(f) for f in (dom, mon, dow)):
return f"0/{h[2:]}:{int(m):02d}"
# M,M... * * * * -> *:M1,M2...
if all(part.isdigit() for part in m.split(",")) and all(is_star(f) for f in (h, dom, mon, dow)):
formatted_m = ",".join(f"{int(part):02d}" for part in m.split(","))
return f"*:{formatted_m}"
return None
@@ -885,9 +886,12 @@ def _valid_sidechat_name(name):
ord(c) < 32 or ord(c) == 127 for c in name)
def act_notify(agent, message, sidechat=None, allow_main_chat=False):
def act_notify(agent, message, sidechat=None, allow_main_chat=False, sender=None):
if agent not in VALID_AGENTS:
fail("BAD_NAME", f"agent must be one of {sorted(VALID_AGENTS)}")
if sender is not None and sender not in VALID_AGENTS:
fail("BAD_NAME", f"sender must be one of {sorted(VALID_AGENTS)}")
sender_agent = sender if sender else "opm"
if not message or len(message) > 1000:
fail("INVALID_JOB", "message must be 1\u20131000 chars")
# Sidechat-first policy: default to the agent's sidechat, never main.
@@ -902,7 +906,7 @@ def act_notify(agent, message, sidechat=None, allow_main_chat=False):
fail("BAD_NAME", "sidechat name must be 1-64 chars, no control characters")
extra = []
r = run([sys.executable, str(DM_PY), "send",
"--agent", "opm", "--to", agent, "--target", target,
"--agent", sender_agent, "--to", agent, "--target", target,
*extra, message],
timeout=120)
if r.returncode != 0:
@@ -2642,6 +2646,30 @@ def main(argv):
if len(rest) != 1:
fail("BAD_NAME", f"usage: {action} <name>")
act_timer_control(rest[0], action[len("timer-"):])
elif action == "timer":
if not rest:
act_timer_list()
else:
sub = rest[0]
if sub in ("list",):
act_timer_list()
elif sub in ("status",):
if len(rest) != 2:
fail("BAD_NAME", "usage: timer status <name>")
act_timer_status(rest[1])
elif sub in ("create",):
if len(rest) != 2:
fail("BAD_NAME", "usage: timer create <name>")
act_timer_create(rest[1])
elif sub in ("delete",):
keep = "--keep-job" in rest[2:]
act_timer_delete(rest[1], keep_job=keep)
elif sub in ("start", "stop", "enable", "disable"):
if len(rest) != 2:
fail("BAD_NAME", f"usage: timer {sub} <name>")
act_timer_control(rest[1], sub)
else:
fail("BAD_NAME", "usage: timer list|status|create|delete|start|stop|enable|disable [...]")
elif action == "job-list":
act_job_list()
elif action == "job-get":
@@ -2809,22 +2837,29 @@ def main(argv):
fail("BAD_NAME", f"usage: {action} --agent <agent> --thread <id> [--title <title>]")
act_thread(op, agent, thread, title)
elif action == "notify":
# notify <agent> <message> [--sidechat <name>] [--allow-main-chat]
# notify <agent> <message> [--sidechat <name>] [--allow-main-chat] [--sender <agent>]
args = list(rest)
allow_main = False
sidechat = None
sender = None
if "--allow-main-chat" in args:
allow_main = True
args.remove("--allow-main-chat")
if "--sidechat" in args:
i = args.index("--sidechat")
if i + 1 >= len(args):
fail("BAD_NAME", "usage: notify <agent> <message> [--sidechat <name>] [--allow-main-chat]")
fail("BAD_NAME", "usage: notify <agent> <message> [--sidechat <name>] [--allow-main-chat] [--sender <agent>]")
sidechat = args[i + 1]
del args[i:i + 2]
if "--sender" in args:
i = args.index("--sender")
if i + 1 >= len(args):
fail("BAD_NAME", "usage: notify <agent> <message> [--sidechat <name>] [--allow-main-chat] [--sender <agent>]")
sender = args[i + 1]
del args[i:i + 2]
if len(args) != 2:
fail("BAD_NAME", "usage: notify <agent> <message> [--sidechat <name>] [--allow-main-chat]")
act_notify(args[0], args[1], sidechat=sidechat, allow_main_chat=allow_main)
fail("BAD_NAME", "usage: notify <agent> <message> [--sidechat <name>] [--allow-main-chat] [--sender <agent>]")
act_notify(args[0], args[1], sidechat=sidechat, allow_main_chat=allow_main, sender=sender)
elif action == "policy":
if not rest:
act_policy()
+24 -1
View File
@@ -296,8 +296,31 @@ case "$cmd" in
args=$(python3 -c "import json, sys; print(json.dumps({'name': sys.argv[1]}))" "$name")
call_exec "cron.status" "$args"
;;
in|delay)
min="${1:?usage: box timer in <minutes> <prompt>}"
shift
prompt="${*:?usage: box timer in <minutes> <prompt>}"
args=$(python3 -c "import json, sys; print(json.dumps({'agent': sys.argv[1], 'in_m': float(sys.argv[2]), 'prompt': sys.argv[3]}))" "$AGENT" "$min" "$prompt")
call_exec "followup.create" "$args"
;;
*)
echo "Usage: box timer create|start|status <name>"
echo "Usage: box timer create|start|status <name> OR box timer in <minutes> <prompt>"
;;
esac
;;
followup)
sub="${1:-create}"
shift || true
case "$sub" in
create|set|schedule)
min="${1:?usage: box followup create <minutes> <prompt>}"
shift
prompt="${*:?usage: box followup create <minutes> <prompt>}"
args=$(python3 -c "import json, sys; print(json.dumps({'agent': sys.argv[1], 'in_m': float(sys.argv[2]), 'prompt': sys.argv[3]}))" "$AGENT" "$min" "$prompt")
call_exec "followup.create" "$args"
;;
*)
echo "Usage: box followup create <minutes> <prompt>"
;;
esac
;;
+40
View File
@@ -130,6 +130,44 @@ def op_service_restart(unit):
"error": r.stderr.strip() if r.returncode != 0 else None
}
def op_followup_schedule(args):
agent = args.get("agent")
if agent not in ("muse", "pip", "646", "opm", "dev", "def"):
return {"ok": False, "error": f"Invalid agent: {agent}"}
sender = args.get("sender") or agent
if sender not in ("muse", "pip", "646", "opm", "dev", "def"):
sender = agent
try:
in_m = float(args.get("in_m", 1))
except (TypeError, ValueError):
return {"ok": False, "error": "in_m must be a number"}
sec = max(5, int(in_m * 60))
prompt = args.get("prompt", "")
if not prompt or not isinstance(prompt, str):
return {"ok": False, "error": "prompt must be a non-empty string"}
if len(prompt) > 1000:
return {"ok": False, "error": "prompt exceeds 1000 characters"}
sidechat = args.get("thread") or args.get("sidechat")
cmd = [
"systemd-run", "--user", f"--on-active={sec}s",
sys.executable, str(REPO_ROOT / "bin" / "box-ctl.py"),
"notify", agent, prompt, "--sender", sender
]
if sidechat and isinstance(sidechat, str) and len(sidechat) <= 64:
cmd.extend(["--sidechat", sidechat])
r = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
if r.returncode != 0:
return {"ok": False, "error": r.stderr.strip() or r.stdout.strip()}
return {
"ok": True,
"agent": agent,
"in_seconds": sec,
"sidechat": sidechat,
"timer_info": (r.stderr or r.stdout).strip()
}
def main():
if len(sys.argv) < 2:
print(json.dumps({"ok": False, "error": "missing operation"}))
@@ -153,6 +191,8 @@ def main():
res = op_service_status(args.get("unit", args.get("name", "")))
elif op == "service.restart":
res = op_service_restart(args.get("unit", args.get("name", "")))
elif op in ("followup.create", "followup.schedule"):
res = op_followup_schedule(args)
else:
res = {"ok": False, "error": f"unknown operation: {op}"}
except Exception as e:
+40 -1
View File
@@ -733,6 +733,40 @@ def _service_restart_build(a):
return [sys.executable, os.path.join(BIN_DIR, 'box-sys-op.py'), 'service.restart']
def _followup_create_validate(raw):
if not isinstance(raw, dict):
raise OpError('args must be an object')
allowed = {'agent', 'in_m', 'prompt', 'thread', 'sidechat', 'sender'}
for k in raw:
if k not in allowed:
raise OpError(f'unknown arg: {k}')
agent = _agent(raw.get('agent', 'opm'))
sender = raw.get('sender')
if sender:
sender = _agent(sender)
try:
in_m = float(raw.get('in_m', 1))
except (TypeError, ValueError):
raise OpError('in_m must be a number')
if in_m < 0.05 or in_m > 1440:
raise OpError('in_m must be between 0.05 and 1440 minutes')
prompt = _clean_message(raw.get('prompt'))
sidechat = raw.get('thread') or raw.get('sidechat')
if sidechat and not TARGET_RE.fullmatch(str(sidechat)):
raise OpError('thread/sidechat must match safe identifier')
return {
'agent': agent,
'sender': sender or agent,
'in_m': in_m,
'prompt': prompt,
'thread': str(sidechat) if sidechat else None
}
def _followup_create_build(a):
return [sys.executable, os.path.join(BIN_DIR, 'box-sys-op.py'), 'followup.create']
def _swarm_spawn_validate(raw):
if not isinstance(raw, dict):
raise OpError('args must be an object')
@@ -924,6 +958,11 @@ OPS = {
'timeout': 30, 'side_effecting': False,
'desc': 'List all subagent swarms and their counts',
},
'followup.create': {
'validate': _followup_create_validate, 'build': _followup_create_build,
'timeout': 30, 'side_effecting': True,
'desc': 'Schedule a delayed autonomous self-followup reminder via transient systemd timer',
},
'exec.ping': {
'validate': _health_validate,
'build': lambda a: ['/bin/echo', 'PONG'],
@@ -1114,7 +1153,7 @@ class Handler(BaseHTTPRequestHandler):
).hexdigest()[:16]})
t0 = time.monotonic()
try:
stdin_input = json.dumps(clean) if op.startswith(('files.', 'web.', 'service.')) else None
stdin_input = json.dumps(clean) if op.startswith(('files.', 'web.', 'service.', 'followup.')) else None
p = subprocess.run(argv, input=stdin_input, capture_output=True, text=True,
timeout=spec['timeout'], cwd=WORK_DIR)
rc = p.returncode
+24 -5
View File
@@ -274,10 +274,23 @@ def format_tool_result_for_chat(op, raw_output):
st = "ACTIVE" if data.get("active") else "INACTIVE"
return f"Service `{data.get('service')}` is {st}.\nStatus: {data.get('status_line')}"
if op == "service.restart" and isinstance(data, dict):
if op in ("followup.create", "followup.schedule") and isinstance(data, dict):
if not data.get("ok"):
return f"Service restart failed for `{data.get('service')}`: {data.get('error')}"
return f"Service `{data.get('service')}` restarted successfully."
return f"Follow-up scheduling failed: {data.get('error')}"
sec = data.get("in_seconds", 60)
ag = data.get("agent", "")
return f"Follow-up timer scheduled for {ag} in {sec}s ({round(sec/60, 1)}m)."
if op == "swarm.spawn" and isinstance(data, dict):
if not data.get("ok"):
return f"Swarm spawn failed: {data.get('error')}"
return f"Swarm `{data.get('swarm_id')}` spawned with {data.get('count')} worker slots (status: {data.get('status')})."
if op == "swarm.status" and isinstance(data, dict):
if not data.get("ok"):
return f"Swarm status check failed: {data.get('error')}"
sw = data.get("swarm", {})
return f"Swarm `{sw.get('swarm_id')}`: {sw.get('status')} ({sw.get('done', 0)}/{sw.get('count', 0)} slots completed)."
# General fallback: compact JSON capped to 400 chars
s = json.dumps(data)
@@ -647,9 +660,15 @@ def process_messages(raw_messages, agent, thread_id, thread_name, last_wm, follo
import muse_hybrid
if t_ok:
clean_msg = format_tool_result_for_chat(op, t_res)
resp_text = f"Tool result (`{op}`):\n{clean_msg}"
resp_text = (
f"Tool result (`{op}`):\n{clean_msg}\n\n"
f"[Directive: Review result and take next action. When complete, close with [RESULT <job_id>] <summary>]"
)
else:
resp_text = f"Tool error (`{op}`): {t_res}"
resp_text = (
f"Tool error (`{op}`): {t_res}\n\n"
f"[Directive: Review error and retry or proceed. When complete, close with [RESULT <job_id>] <summary>]"
)
muse_hybrid.send_message(agent, resp_text, thread_id=thread_id, wait=0)
except Exception as te:
sys.stderr.write(f"warning: failed to post tool response back to thread: {te}\n")