feat: clothing lane, character sources, and DCC bridges
Bulk import of the working lanes that were living untracked on the PC. Content: - characters/ Lena/male body lanes, bakes, texture work, run logs - clothing/ garment pipeline, configs, gates, contract docs - garments/ MD-authored garment sources (.zprj/.zpac) - UAL-Lib/ Universal Animation Library 2 source (.blend/.fbx/.glb) - tools/ blender_bridge, iclone_bridge, md_bridge, tailor, glm_agent - docs/, plans/, dev/, .agents/plans/ Repo hygiene: - .gitattributes: LFS now covers .blend, .zprj, .zpac, .obj, .npy and the Reallusion .iAvatar/.ccAvatar/.ccRestore containers. Without this the ~3.8 GB in this commit would land as raw blobs. .png/.jpg are left out on purpose — ~250 are already tracked raw and converting them would rewrite every one without shrinking history. - .gitignore: exclude /accurig/ (~1 GB AccuRig program files, redistributable from Reallusion, nothing authored here) and /dev/null/ (git-lfs hook copies dropped by a `>/dev/null` redirect on Windows). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
#!/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 <brief.md> --minutes 120 \
|
||||
[--model glm-4.6] [--log <path>] [--probe]
|
||||
|
||||
Protocol (plain text, not tool-calling -- more robust across providers). GLM
|
||||
must answer with exactly one of:
|
||||
|
||||
COMMAND: <single-line shell command>
|
||||
DONE: <summary of what was accomplished>
|
||||
|
||||
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: <one single-line shell command>
|
||||
DONE: <what you accomplished, and anything left unfinished>
|
||||
|
||||
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: <cmd>' or 'DONE: <summary>'."})
|
||||
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
|
||||
Reference in New Issue
Block a user