diff --git a/bin/job-dispatch.py b/bin/job-dispatch.py index 41679cc..2c6201f 100755 --- a/bin/job-dispatch.py +++ b/bin/job-dispatch.py @@ -528,28 +528,10 @@ def main(): "step_n": step_n, }) - # Determine thread url / context for the runtime envelope - target_clean = str(target) - thread_url = f"https://box.muse-dev.online/thread/{target_clean}" if re.fullmatch(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", target_clean.lower()) else f"https://box.muse-dev.online/sidechat/{target_clean}" - - # Build active tool calling envelope for https://box.muse-dev.online/ - tool_envelope = ( - f"\n\n--- NetVM Runtime & Tools ({agent} on {target_clean}) ---\n" - f"Thread Console: {thread_url}\n" - f"Available Tools via [TOOL ] or curl to https://box.muse-dev.online/exec:\n" - f" • Status: [TOOL health.check {{}}]\n" - f" • Service: [TOOL service.status {{\"unit\": \"board.service\"}}]\n" - f" • Follow-up: [TOOL followup.create {{\"in_m\": 10, \"prompt\": \"check progress\"}}]\n" - f" • Swarm: [TOOL swarm.spawn {{\"count\": 2, \"task\": \"parallel work\"}}]\n" - f"Direct Curl (Agent Bearer Auth):\n" - f" curl -sk -X POST https://box.muse-dev.online/exec -H \"Authorization: Bearer \" " - f"-H \"Content-Type: application/json\" -d '{{\"op\":\"\",\"args\":{{...}}}}'\n\n" - f"When finished, end your response with:\n" - f"[RESULT {job_id}] : " - ) - - if "[RESULT" not in rendered: - rendered = rendered.rstrip() + tool_envelope + # Work-first envelope: executable swarm.spawn/followup.create at TOP and BOTTOM + # (see bin/prompt_envelope.py). Always applied, even if the template has its own [RESULT. + import prompt_envelope + rendered = prompt_envelope.wrap(job_name, job_id, agent, target, rendered) # Format as JOB DM dm_message = f"[JOB {job_id}] {rendered}" diff --git a/bin/prompt_envelope.py b/bin/prompt_envelope.py new file mode 100644 index 0000000..2ac1d70 --- /dev/null +++ b/bin/prompt_envelope.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""prompt_envelope.py — wraps every dispatched job prompt so agents DO work. + +Why: agents treated job DMs as informational and replied with prose; swarms and +timers never got created. The envelope puts an executable subagent-spawn tool +call at the TOP (before the task) and repeats it at the BOTTOM (after the task), +with a work-first mandate and explicit tool/curl surface in between. + +Profiles tune the spawn recipe per job family; add new ones to PROFILES. +""" +import json +import re + +BOX_EXEC = "https://box.muse-dev.online/exec" +UUID_RE = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") + +# profile -> (spawn count, followup minutes, spawn task hint) +PROFILES = { + "pulse": (2, 30, "execute your scope's standing work: set timers, verify them, report per-slot verdicts"), + "health": (2, 60, "run the prescribed health gates read-only and report per-gate verdicts"), + "swarm": (2, 20, "sweep swarms and sidechats read-only and report stale or unarchived items"), + "work": (2, 15, "split the task above into two independent halves and complete each"), +} + + +def pick_profile(job_name): + n = job_name or "" + if n.startswith("autonomy-pulse"): + return "pulse" + if "health" in n: + return "health" + if "swarm" in n: + return "swarm" + return "work" + + +def _tool(op, args): + # no ']' inside the JSON: the harvester's [TOOL ...] regex stops at the first one + return "[TOOL %s %s]" % (op, json.dumps(args, separators=(", ", ": "))) + + +def spawn_call(job_id, job_name, profile): + count, _, hint = PROFILES[profile] + # no brackets in the task text: the harvester's [TOOL ...] regex stops at the first ']' + task = "Subagent for job %s (%s): %s." % (job_id, job_name, hint) + return _tool("swarm.spawn", {"count": count, "task": task[:900], "label": (job_name or "job")[:60]}) + + +def followup_call(job_id, job_name, profile, agent): + _, mins, _ = PROFILES[profile] + return _tool("followup.create", { + "agent": agent, "in_m": mins, + "prompt": "Continue job %s (%s): check swarm results with swarm.list then act or close." % (job_id, job_name)}) + + +def thread_url(target): + t = str(target) + return "https://box.muse-dev.online/%s/%s" % ("thread" if UUID_RE.fullmatch(t.lower()) else "sidechat", t) + + +def wrap(job_name, job_id, agent, target, rendered): + """Return the full DM body (without the [JOB id] prefix).""" + profile = pick_profile(job_name) + spawn = spawn_call(job_id, job_name, profile) + follow = followup_call(job_id, job_name, profile, agent) + top = ( + "WORK ORDER - ACTION REQUIRED, NOT INFORMATIONAL.\n" + "Your FIRST output must be tool calls, not prose. Start now:\n" + " 1) %s\n" + " 2) %s\n" + "Each tool call returns a result message; keep going until the task is done.\n" + "---- TASK ----\n" % (spawn, follow) + ) + has_result = "[RESULT" in rendered + bottom = ( + "\n---- END TASK ----\n" + "REMINDER (importance: critical): work is only real if a tool call ran. " + "If you have not yet spawned subagents or set a follow-up timer, do it now:\n" + " %s\n" + " %s\n" + "Other tools: [TOOL health.check {}] [TOOL swarm.list {}] [TOOL swarm.status {\"swarm_id\": \"\"}] " + "[TOOL swarm.results {\"swarm_id\": \"\"}] [TOOL quality.check {}]\n" + "Direct: curl -sk -X POST %s -H \"Authorization: Bearer \" " + "-H \"Content-Type: application/json\" -d '{\"op\":\"\",\"args\":{...}}'\n" + "Thread: %s\n" % (spawn, follow, BOX_EXEC, thread_url(target)) + ) + if not has_result: + bottom += "Close with: [RESULT %s] : \n" % job_id + else: + bottom += "Close using the [RESULT ...] line specified in the task, summarising tool outputs.\n" + return top + rendered.strip() + "\n" + bottom diff --git a/bin/response-harvester.py b/bin/response-harvester.py index f0e0f08..527237f 100755 --- a/bin/response-harvester.py +++ b/bin/response-harvester.py @@ -163,7 +163,16 @@ def parse_tool_calls(text): except Exception: pass - return calls + # De-duplicate identical calls within one message (envelope repeats the spawn + # call at top and bottom; an agent echoing both must not run it twice). + seen, unique = set(), [] + for op, args in calls: + key = (op, json.dumps(args, sort_keys=True, default=str)) + if key in seen: + continue + seen.add(key) + unique.append((op, args)) + return unique def execute_agent_tool(agent, op, args, timeout=30): @@ -1065,12 +1074,19 @@ def maybe_nudge_untagged_sidechat(agent, thread_id, thread_name, mid, text, dry_ # 1st turn: Strict rejection and tool invocation prompt if n_count == 0: thread_url = f"https://box.muse-dev.online/thread/{thread_id}" + try: + import prompt_envelope + _spawn = prompt_envelope.spawn_call( + matching_job_id, matching_job_id, prompt_envelope.pick_profile(matching_job_id)) + except Exception: + _spawn = '[TOOL swarm.spawn {"count": 2, "task": "continue the job work"}]' nudge_msg = ( + f"{_spawn}\n" f"[STRICT ENFORCEMENT: Conversational commentary is rejected. Work requires active execution.]\n" f"Thread Console: {thread_url}\n" - f"You must emit an executable tool call: [TOOL ] or curl command against https://box.muse-dev.online/exec\n" - f"Example: [TOOL service.status {{\"unit\": \"board.service\"}}] or [TOOL followup.create {{\"in_m\": 10, \"prompt\": \"continue\"}}]\n" - f"When all operations are finished, close strictly with [RESULT {matching_job_id}] ." + f"Emit executable tool calls now: [TOOL ] or curl against https://box.muse-dev.online/exec\n" + f"When all operations are finished, close strictly with [RESULT {matching_job_id}] .\n" + f"{_spawn}" ) try: import muse_hybrid