#!/usr/bin/env python """glm_agent.py -- autonomous GLM (z.ai) agent loop with guarded shell execution. Delegates a long grind to GLM so it runs unattended instead of burning Claude context. GLM proposes one shell command at a time; this harness executes it in the repo, feeds the output back, and repeats until GLM reports DONE or the wall clock runs out. python tools/glm_agent.py --task-file --minutes 120 \ [--model glm-4.6] [--log ] [--probe] Protocol (plain text, not tool-calling -- more robust across providers). GLM must answer with exactly one of: COMMAND: DONE: Safety (it runs unattended): - cwd is pinned to the animation repo; commands run through bash. - DENY list blocks destructive/irreversible/network-push actions outright. - Per-command timeout, output truncation, and a hard wall-clock budget. - Everything is logged to --log for review. Key: Z_AI_GLM_API_KEY in tinqs-docs/.env (never printed, never committed). NOTE: thinking must be DISABLED for this endpoint or content comes back empty. """ import argparse import json import os import re import subprocess import sys import time import urllib.error import urllib.request REPO = r"C:\Users\Jeremy\tinqs\animation" ENV_PATH = r"C:\Users\Jeremy\tinqs\tinqs-docs\.env" ENDPOINT = "https://api.z.ai/api/paas/v4/chat/completions" CMD_TIMEOUT_S = 900 # 15 min: a Blender stage can be slow MAX_OUT_CHARS = 6000 # truncate tool output fed back to the model MAX_STEPS = 2000 # effectively unlimited; the wall clock is the real budget LOG_PATH = None # set in main(); used by the crash handler # Blocked outright -- irreversible, or reaches outside this machine/task. DENY = [ r"\brm\s+-[rf]", r"\brmdir\b", r"\bdel\s+/", r"Remove-Item", r"\bgit\s+(push|commit|reset\s+--hard|clean|checkout\s+--|rebase|merge)", r"\btinqs\s+(push|pull)", r"\bformat\b", r"\bshutdown\b", r"\breboot\b", r"\bmkfs", r"\bdd\s+if=", r":\(\)\{", r"\bchmod\s+777\b", r"\bcurl\b[^|]*\|\s*(ba)?sh", r"\bwget\b[^|]*\|\s*(ba)?sh", r"\bpip\s+install", r"\bnpm\s+(install|i)\b", r">\s*/dev/sd", r"\.env\b", r"\bZ_AI_GLM_API_KEY\b", ] # Windows consoles/redirects default to cp1252; model replies contain unicode # (≈, —, box drawing). Without this the whole run dies on a stray character. for _s in (sys.stdout, sys.stderr): try: _s.reconfigure(encoding="utf-8", errors="replace") except Exception: pass def load_key(): with open(ENV_PATH, "r", encoding="utf-8") as f: for line in f: if line.strip().startswith("Z_AI_GLM_API_KEY="): return line.split("=", 1)[1].strip() raise SystemExit("Z_AI_GLM_API_KEY not found in " + ENV_PATH) def chat(key, model, messages, timeout=180): """One completion. Thinking disabled -- required or content is empty.""" body = json.dumps({ "model": model, "messages": messages, "thinking": {"type": "disabled"}, "temperature": 0.2, "max_tokens": 1500, }).encode("utf-8") req = urllib.request.Request( ENDPOINT, data=body, headers={"Authorization": "Bearer " + key, "Content-Type": "application/json"}) with urllib.request.urlopen(req, timeout=timeout) as r: data = json.loads(r.read().decode("utf-8")) return data["choices"][0]["message"]["content"] def denied(cmd): for pat in DENY: if re.search(pat, cmd, re.IGNORECASE): return pat return None # Absolute path: when this harness is launched detached (Start-Process / Task Scheduler) the # child does not inherit a Git-Bash PATH, subprocess can't resolve "bash", and every command # fails with FileNotFoundError — the agent then reports itself blocked and gives up. BASH = r"C:\Program Files\Git\bin\bash.exe" if not os.path.exists(BASH): BASH = "bash" def run_cmd(cmd): try: p = subprocess.run([BASH, "-lc", cmd], cwd=REPO, capture_output=True, text=True, timeout=CMD_TIMEOUT_S) out = (p.stdout or "") + (("\n[stderr]\n" + p.stderr) if p.stderr else "") out = out.strip() or "(no output)" if len(out) > MAX_OUT_CHARS: out = out[:MAX_OUT_CHARS] + "\n...[truncated]" return f"exit={p.returncode}\n{out}" except subprocess.TimeoutExpired: return f"exit=TIMEOUT after {CMD_TIMEOUT_S}s" except Exception as exc: return f"exit=HARNESS_ERROR {exc!r}" SYSTEM = """You are an autonomous build agent working inside a git repo on Windows (Git Bash). You act by emitting exactly ONE of these, and NOTHING else -- no markdown fences, no commentary: COMMAND: DONE: Rules: - ONE command per turn. Wait for its output before the next. - Commands run with cwd = the animation repo root. Use relative paths. - Prefer small, verifiable steps. Inspect before you change. - To write files, use a heredoc on one line via printf/echo, or python -c. - Never: git commit/push, rm -rf, install packages, touch .env or secrets. - If a command fails, diagnose from its output and adapt. Do not repeat a failing command unchanged. - If you are blocked and cannot proceed, emit DONE: with a clear explanation of the blocker and what you tried. - Budget your steps; you have a wall-clock limit. Report DONE before you run out if the goal is met.""" def main(): ap = argparse.ArgumentParser() ap.add_argument("--task-file") ap.add_argument("--minutes", type=float, default=120.0) ap.add_argument("--model", default="glm-4.6") ap.add_argument("--log", default="glm_agent_run.log") ap.add_argument("--probe", action="store_true", help="connectivity/model check, then exit") a = ap.parse_args() key = load_key() if a.probe: for m in ("glm-4.6", "glm-4.5", "glm-5.2", "glm-4.5-air"): try: r = chat(key, m, [{"role": "user", "content": "Reply with exactly: OK"}], timeout=60) print(f"{m:12s} -> {r.strip()[:60]!r}") except urllib.error.HTTPError as e: print(f"{m:12s} -> HTTP {e.code}") except Exception as e: print(f"{m:12s} -> {type(e).__name__}") return 0 global LOG_PATH LOG_PATH = a.log with open(a.task_file, "r", encoding="utf-8") as f: task = f.read() log = open(a.log, "a", encoding="utf-8", errors="replace", buffering=1) def emit(s): # never let a logging problem kill a long unattended run try: print(s, flush=True) except Exception: pass try: log.write(s + "\n") except Exception: pass emit(f"\n===== GLM agent start {time.strftime('%Y-%m-%d %H:%M:%S')} " f"model={a.model} budget={a.minutes}min =====") messages = [{"role": "system", "content": SYSTEM}, {"role": "user", "content": task}] deadline = time.time() + a.minutes * 60 api_fails = 0 for step in range(1, MAX_STEPS + 1): left = deadline - time.time() if left <= 0: emit(f"\n!! wall-clock budget exhausted at step {step}") break try: reply = chat(key, a.model, messages).strip() api_fails = 0 except Exception as exc: # Exponential backoff, capped at 10 min. A fixed 20 s retry on HTTP 429 hammers # the rate limiter and can keep the key blocked indefinitely — one run spun for # an hour straight without completing a single step that way. api_fails += 1 wait = min(20 * (2 ** min(api_fails - 1, 5)), 600) emit(f"[api error #{api_fails}] {exc!r} -- backing off {wait}s") time.sleep(wait) continue emit(f"\n--- step {step} ({left/60:.0f} min left) ---\n{reply}") messages.append({"role": "assistant", "content": reply}) if reply.upper().startswith("DONE"): emit("\n===== agent reported DONE =====") break m = re.search(r"COMMAND:\s*(.+)", reply, re.DOTALL) if not m: messages.append({"role": "user", "content": "Malformed. Reply with exactly 'COMMAND: ' or 'DONE: '."}) continue cmd = m.group(1).strip().splitlines()[0].strip().strip("`") bad = denied(cmd) if bad: emit(f"[DENIED by guard: {bad}]") messages.append({"role": "user", "content": f"BLOCKED by safety guard (pattern {bad}). " "That action is not permitted. Choose another approach."}) continue out = run_cmd(cmd) emit(f"[output]\n{out}") messages.append({"role": "user", "content": out}) # keep context bounded: drop oldest exchanges, keep system+task if len(messages) > 40: messages = messages[:2] + messages[-30:] emit(f"\n===== GLM agent end {time.strftime('%Y-%m-%d %H:%M:%S')} =====") log.close() return 0 if __name__ == "__main__": # A crash in an unattended run must be visible in the run log, not just on # a stdout nobody is watching (this bit us once: UnicodeEncodeError killed # a run silently and it looked like the agent had merely gone quiet). try: sys.exit(main()) except SystemExit: raise except BaseException: import traceback as _tb try: with open(LOG_PATH or "glm_agent_run.log", "a", encoding="utf-8", errors="replace") as _f: _f.write("\n===== AGENT CRASHED =====\n" + _tb.format_exc() + "\n") except Exception: pass raise