feat(autonomy): add followup.create primitive and tool-continuation directives to enforce silence-kills-work
This commit is contained in:
+44
-9
@@ -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
@@ -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
|
||||
;;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user