feat(automation): sidechat auto-provisioning, response harvester, followup sweeper, and super CLI
This commit is contained in:
+156
-26
@@ -97,7 +97,7 @@ def load_job(job_name):
|
||||
sys.exit(1)
|
||||
print(f"Warning: YAML not supported (no PyYAML). Convert {job_file} to JSON.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
with open(job_file) as f:
|
||||
return json.load(f)
|
||||
|
||||
@@ -109,25 +109,114 @@ def render_prompt(template, variables):
|
||||
result = result.replace(f"{{{key}}}", str(value))
|
||||
return result
|
||||
|
||||
def send_dm(agent, target, message, dry_run=False):
|
||||
"""Send DM via dm.py"""
|
||||
# ---- follow-up tracking (DM follow-up system integration) ----------------
|
||||
# Jobs opt in via a "followup" block in the job JSON:
|
||||
#
|
||||
# "followup": {
|
||||
# "expect_reply": true, # required: enables tracking
|
||||
# "timeout": "1h", # duration ("30s","15m","2h","1d") or seconds
|
||||
# # int; default "1h" (3600s)
|
||||
# "nudges": 2, # 0..10, default 2
|
||||
# "escalate": "opm", # identity string, default "opm"
|
||||
# "route": "646-pip-coord" # optional route_id
|
||||
# }
|
||||
#
|
||||
# The dispatcher translates this into dm.py --tag flags using the canonical
|
||||
# vocabulary (box-threads/DEPLOY-DECISIONS.md). dm.py strips the tags from
|
||||
# delivered text and creates a dm_followup request-store record after
|
||||
# SENT+VERIFIED. Jobs without a followup block behave exactly as today.
|
||||
#
|
||||
# LIMITATIONS (v1):
|
||||
# - The heartbeat job NEVER gets follow-ups (loopback health check).
|
||||
# Hardcoded guard below; a followup block on heartbeat is ignored loudly.
|
||||
# - Sidechat sends (muse-chat-api.py direct path) do not go through dm.py,
|
||||
# so --tag flags cannot attach. v2 needs a record-creation path that does
|
||||
# not send (e.g. POST /api/box/followups, or a bl->VM queue; bl cannot
|
||||
# currently SSH to the VM). The dispatcher logs a warning when a
|
||||
# sidechat-targeted job has followup enabled.
|
||||
|
||||
HEARTBEAT_JOB_NAME = "heartbeat"
|
||||
|
||||
|
||||
def parse_followup_duration(value):
|
||||
"""Parse a followup timeout into seconds. Accepts int (seconds) or
|
||||
strings like '30s', '15m', '2h', '1d'. Returns int seconds.
|
||||
Raises ValueError on bad input."""
|
||||
if isinstance(value, int) and not isinstance(value, bool):
|
||||
s = value
|
||||
elif isinstance(value, str):
|
||||
m = re.fullmatch(r"(\d+)\s*([smhd])?", value.strip().lower())
|
||||
if not m:
|
||||
raise ValueError("bad duration %r" % (value,))
|
||||
n = int(m.group(1))
|
||||
unit = m.group(2) or "s"
|
||||
s = n * {"s": 1, "m": 60, "h": 3600, "d": 86400}[unit]
|
||||
else:
|
||||
raise ValueError("timeout must be int seconds or duration string")
|
||||
if not 60 <= s <= 604800:
|
||||
raise ValueError("timeout must be 60..604800s (1m..7d), got %d" % s)
|
||||
return s
|
||||
|
||||
|
||||
def build_followup_tags(followup):
|
||||
"""Translate a job's followup block into dm.py --tag arguments.
|
||||
Returns a flat list like ['--tag', 'reply:timeout=3600', ...].
|
||||
Returns [] if followup is falsy or expect_reply is not true.
|
||||
Raises ValueError on invalid config (caller logs a warning and sends
|
||||
the DM untagged -- the job itself must never fail over this)."""
|
||||
if not followup or not followup.get("expect_reply"):
|
||||
return []
|
||||
args = []
|
||||
# Bare trigger. dm.py's parse_tags splits each --tag on '='; an empty
|
||||
# value means "present". If the deployed dm.py requires a non-empty
|
||||
# value for this key, use 'reply:expected=true' instead.
|
||||
args += ["--tag", "reply:expected="]
|
||||
if "timeout" in followup:
|
||||
s = parse_followup_duration(followup["timeout"])
|
||||
args += ["--tag", "reply:timeout=%d" % s]
|
||||
if "nudges" in followup:
|
||||
n = followup["nudges"]
|
||||
if not isinstance(n, int) or isinstance(n, bool) or not 0 <= n <= 10:
|
||||
raise ValueError("nudges must be int 0..10")
|
||||
args += ["--tag", "reply:nudges=%d" % n]
|
||||
if "escalate" in followup:
|
||||
e = followup["escalate"]
|
||||
if not isinstance(e, str) or not re.fullmatch(r"[a-z0-9_-]{1,64}", e):
|
||||
raise ValueError("escalate must be an identity string")
|
||||
args += ["--tag", "reply:escalate=%s" % e]
|
||||
if "route" in followup:
|
||||
r = followup["route"]
|
||||
if not isinstance(r, str) or not re.fullmatch(r"[a-z0-9_-]{1,64}", r):
|
||||
raise ValueError("route must be a route_id string")
|
||||
args += ["--tag", "route:%s" % r]
|
||||
# 'thread' is intentionally not settable from job JSON; it names a
|
||||
# specific existing thread and is filled by the dispatcher when known.
|
||||
return args
|
||||
|
||||
|
||||
def send_dm(agent, target, message, dry_run=False, followup_tags=None):
|
||||
"""Send DM via dm.py. followup_tags: flat ['--tag', 'k=v', ...] list
|
||||
from build_followup_tags(), or None."""
|
||||
if dry_run:
|
||||
print(f"[DRY RUN] Would send to {agent} ({target}):")
|
||||
if followup_tags:
|
||||
print(f"[DRY RUN] With follow-up tags: {' '.join(followup_tags)}")
|
||||
print(message[:200] + "..." if len(message) > 200 else message)
|
||||
return "dry-run-id"
|
||||
|
||||
|
||||
# Rate limit
|
||||
if HAS_RATE_LIMITER:
|
||||
rate_limit_wait(agent)
|
||||
|
||||
cmd = [str(DM_PY), "send", "--agent", "opm", "--to", agent,
|
||||
"--target", target, message]
|
||||
|
||||
cmd = ([str(DM_PY), "send", "--agent", "opm", "--to", agent,
|
||||
"--target", target]
|
||||
+ (followup_tags or []) + [message])
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
||||
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f"DM send failed: {result.stderr}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
# Extract message ID from output (format: SENT [id])
|
||||
# dm.py prints the ID on success
|
||||
output = result.stdout.strip()
|
||||
@@ -140,7 +229,7 @@ def create_sidechat(sender_agent, dry_run=False):
|
||||
if dry_run:
|
||||
print(f"[DRY RUN] Would create sidechat for {sender_agent}")
|
||||
return True
|
||||
|
||||
|
||||
cmd = [NETVM_EXEC, sender_agent, "--", "python3", str(CHAT_API),
|
||||
"--account", sender_agent, "sidechat", "create"]
|
||||
try:
|
||||
@@ -166,10 +255,10 @@ def send_to_current_chat(sender_agent, message, dry_run=False):
|
||||
if dry_run:
|
||||
print(f"[DRY RUN] Would send to current chat: {message[:100]}...")
|
||||
return "dry-run-id"
|
||||
|
||||
|
||||
if HAS_RATE_LIMITER:
|
||||
rate_limit_wait(sender_agent)
|
||||
|
||||
|
||||
cmd = [NETVM_EXEC, sender_agent, "--", "python3", str(CHAT_API),
|
||||
"--account", sender_agent, "send", message]
|
||||
try:
|
||||
@@ -186,43 +275,68 @@ def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(f"Usage: {sys.argv[0]} <job_name> [--dry-run]", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
job_name = sys.argv[1]
|
||||
dry_run = "--dry-run" in sys.argv
|
||||
|
||||
|
||||
# Load job
|
||||
job = load_job(job_name)
|
||||
|
||||
|
||||
# Generate job_id
|
||||
job_id = f"{job_name}-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
# Variables for template
|
||||
variables = {
|
||||
"job_id": job_id,
|
||||
"job_name": job_name,
|
||||
"date": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
|
||||
"datetime": datetime.now(timezone.utc).isoformat(),
|
||||
"prev_job_id": os.environ.get("CHAIN_PREV_JOB_ID", ""),
|
||||
"prev_result": os.environ.get("CHAIN_PREV_RESULT", ""),
|
||||
}
|
||||
|
||||
|
||||
# Follow-up tracking (opt-in via job JSON "followup" block; see helpers).
|
||||
# The heartbeat job is a loopback health check and must never be tracked.
|
||||
followup_cfg = job.get("followup")
|
||||
followup_tags = []
|
||||
if followup_cfg:
|
||||
if job_name == HEARTBEAT_JOB_NAME:
|
||||
print(f"Warning: job '{job_name}' must not use follow-up "
|
||||
f"tracking (loopback); ignoring followup block",
|
||||
file=sys.stderr)
|
||||
log_event("job_followup_skipped",
|
||||
{"job_id": job_id, "reason": "heartbeat_loopback"})
|
||||
else:
|
||||
try:
|
||||
followup_tags = build_followup_tags(followup_cfg)
|
||||
if followup_tags:
|
||||
log_event("job_followup_armed",
|
||||
{"job_id": job_id, "tags": followup_tags})
|
||||
except ValueError as e:
|
||||
print(f"Warning: invalid followup block: {e}; "
|
||||
f"sending untagged", file=sys.stderr)
|
||||
log_event("job_followup_invalid",
|
||||
{"job_id": job_id, "error": str(e)})
|
||||
|
||||
# Render prompt
|
||||
prompt_template = job.get("prompt_template", "")
|
||||
if not prompt_template:
|
||||
print(f"Error: Job '{job_name}' has no prompt_template", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
rendered = render_prompt(prompt_template, variables)
|
||||
|
||||
|
||||
# Format as JOB DM
|
||||
dm_message = f"[JOB {job_id}] {rendered}"
|
||||
|
||||
|
||||
# Get target
|
||||
agent = job.get("agent", "muse")
|
||||
sidechat_cfg = job.get("sidechat", {})
|
||||
sidechat_url = None
|
||||
|
||||
|
||||
use_sidechat = sidechat_cfg.get("create", False)
|
||||
sidechat_created = False
|
||||
|
||||
|
||||
if use_sidechat:
|
||||
reuse_key = sidechat_cfg.get("reuse_key")
|
||||
name_tmpl = sidechat_cfg.get("name_template", "job-{job_name}-{date}")
|
||||
@@ -262,7 +376,12 @@ def main():
|
||||
capture_uuid = False
|
||||
else:
|
||||
target = "main"
|
||||
|
||||
# dm_target override: job JSON can specify a dm.py --target
|
||||
# (sidechat name/UUID) for tracked sends to a thread.
|
||||
_dt = job.get("dm_target")
|
||||
if _dt and isinstance(_dt, str) and _dt.strip():
|
||||
target = _dt.strip()
|
||||
|
||||
# Log job_sent
|
||||
log_event("job_sent", {
|
||||
"job_id": job_id,
|
||||
@@ -271,7 +390,7 @@ def main():
|
||||
"target": target,
|
||||
"dry_run": dry_run,
|
||||
})
|
||||
|
||||
|
||||
# Send DM: to sidechat via direct API, or to main via dm.py
|
||||
if use_sidechat and sidechat_created:
|
||||
msg_id = send_to_current_chat("opm", dm_message, dry_run=dry_run)
|
||||
@@ -297,12 +416,23 @@ def main():
|
||||
log_event("job_sidechat_mapped", {"reuse_key": reuse_key, "thread_uuid": thread_uuid})
|
||||
print(f"Mapped reuse_key {reuse_key} -> {thread_uuid}", file=sys.stderr)
|
||||
break
|
||||
if followup_tags and not dry_run:
|
||||
# v1 limitation: sidechat sends bypass dm.py, so --tag flags
|
||||
# cannot attach and no dm_followup record is created. The
|
||||
# job is still dispatched; tracking is skipped loudly.
|
||||
print(f"Warning: follow-up tracking not supported for "
|
||||
f"sidechat sends (v1); job {job_id} dispatched "
|
||||
f"without tracking", file=sys.stderr)
|
||||
log_event("job_followup_skipped",
|
||||
{"job_id": job_id,
|
||||
"reason": "sidechat_path_v1"})
|
||||
# Skip the dm.py dispatch block below
|
||||
import sys as _sys2
|
||||
_sys2.exit(0)
|
||||
else:
|
||||
msg_id = send_dm(agent, target, dm_message, dry_run=dry_run)
|
||||
|
||||
msg_id = send_dm(agent, target, dm_message,
|
||||
dry_run=dry_run, followup_tags=followup_tags)
|
||||
|
||||
if msg_id and not dry_run:
|
||||
print(f"Dispatched job {job_id} to {agent} (DM: {msg_id})")
|
||||
log_event("job_dispatched", {
|
||||
|
||||
Reference in New Issue
Block a user