Files
tai/tai_pkg/muse.py
T

343 lines
12 KiB
Python
Raw Normal View History

"""Muse CLI backend and persistent session map."""
import glob
import hashlib
import json
import os
import shutil
import subprocess
import threading
import time
from tai_pkg.clirun import Cli
from tai_pkg.compat import fcntl
from tai_pkg.consts import Consts
from tai_pkg.errors import BackendError
from tai_pkg.release import Release
from tai_pkg.text import Text
__all__ = ["Muse", "_MUSE_THREAD_LOCKS", "_MUSE_THREAD_GUARD"]
class Muse:
"""Muse."""
@staticmethod
def muse_binary():
return os.environ.get("MUSE_BIN") or shutil.which("muse")
@staticmethod
def muse_output_schema():
call = {"type": "object", "additionalProperties": False, "properties": {"name": {"type": "string"}, "arguments": {"type": "string"}}, "required": ["name", "arguments"]}
return {"type": "object", "additionalProperties": False, "properties": {"content": {"type": "string"}, "tool_calls": {"type": "array", "items": call}}, "required": ["content", "tool_calls"]}
@staticmethod
def muse_prompt(messages, tools):
parts = []
for message in messages or []:
role = (message.get("role") or "user").upper()
body = message.get("content") or ""
if isinstance(body, list):
body = " ".join(str(part.get("text") or "") for part in body if isinstance(part, dict))
if message.get("tool_call_id"):
parts.append("%s %s:\n%s" % (role, message["tool_call_id"], body))
else:
parts.append("%s:\n%s" % (role, body))
for call in message.get("tool_calls") or []:
func = call.get("function") or {}
parts.append("TOOL CALL %s:\n%s %s" % (call.get("id") or "", func.get("name") or "", func.get("arguments") or ""))
if tools:
parts.append("TOOLS:\n%s" % json.dumps(tools))
parts.append("Answer with content text plus tool_calls for every tool to invoke now. Empty tool_calls ends the turn.")
return "\n\n".join(part for part in parts if part.strip())
@staticmethod
def muse_argv(config, schema_path, prompt_path, session_id=None):
argv = [Muse.muse_binary(), "exec", "--json", "--max-model-steps", str(Consts.MUSE_MAX_STEPS), "--disable-web-tools", "--disable-reminders", "--allow-workspace-switch", "--output-schema", schema_path, "--prompt-file", prompt_path]
if session_id is None:
argv.append("--no-session-log")
else:
argv.extend(["--session-id", session_id])
if config.explicit_model:
argv.extend(["--model", config.explicit_model])
return argv
@staticmethod
def muse_write_file(path, text):
with open(path, "w", encoding="utf-8") as handle:
handle.write(text)
handle.flush()
os.fsync(handle.fileno())
@staticmethod
def muse_message_hash(message):
blob = json.dumps(message, sort_keys=True, separators=(",", ":"), default=str)
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
@staticmethod
def muse_map_path(home):
return os.path.join(home, Consts.MUSE_SESSIONS_FILE)
@staticmethod
def muse_load_map(home):
path = Muse.muse_map_path(home)
try:
with open(path, "r", encoding="utf-8") as handle:
data = json.load(handle)
except FileNotFoundError:
return {}
except (OSError, ValueError):
Release.quarantine(path)
return {}
if not isinstance(data, dict):
Release.quarantine(path)
return {}
now = time.time()
pruned = False
for key in list(data):
entry = data[key]
if not isinstance(entry, dict) or not isinstance(entry.get("id"), str) or not isinstance(entry.get("hashes"), list):
del data[key]
pruned = True
elif key.startswith("worker/"):
try:
stamp = Text._fromisoformat(entry.get("updated") or "").timestamp()
except ValueError:
stamp = 0
if now - stamp > Consts.MUSE_WORKER_TTL:
del data[key]
pruned = True
if pruned:
Muse.muse_save_map(home, data)
return data
@staticmethod
def muse_save_map(home, data):
Muse.muse_write_file(Muse.muse_map_path(home), json.dumps(data, sort_keys=True) + "\n")
@staticmethod
def muse_acquire(home, name):
with _MUSE_THREAD_GUARD:
thread_lock = _MUSE_THREAD_LOCKS.setdefault(name, threading.Lock())
thread_lock.acquire()
handle = None
if fcntl is not None:
try:
folder = os.path.join(home, Consts.MUSE_LOCKS_DIR)
os.makedirs(folder, exist_ok=True)
handle = open(os.path.join(folder, hashlib.sha256(name.encode("utf-8")).hexdigest() + ".lock"), "w")
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
except OSError:
if handle is not None:
try:
handle.close()
except OSError:
pass
handle = None
def release():
try:
if handle is not None:
if fcntl is not None:
try:
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
except OSError:
pass
try:
handle.close()
except OSError:
pass
finally:
thread_lock.release()
return release
@staticmethod
def muse_transact(home, func):
release = Muse.muse_acquire(home, "map")
try:
data = Muse.muse_load_map(home)
result = func(data)
Muse.muse_save_map(home, data)
return result
finally:
release()
@staticmethod
def muse_session_root():
base = os.environ.get("XDG_DATA_HOME") or os.path.join(os.path.expanduser("~"), ".local", "share")
return os.path.join(base, "muse", "sessions")
@staticmethod
def muse_session_alive(session_id):
try:
pattern = os.path.join(Muse.muse_session_root(), "*", "*", "*", session_id, "session.jsonl")
return bool(glob.glob(pattern))
except (OSError, ValueError):
return True
@staticmethod
def muse_session_split(messages, hashes):
if len(hashes) > len(messages):
return None
for pos, digest in enumerate(hashes):
if Muse.muse_message_hash(messages[pos]) != digest:
return None
return list(messages[len(hashes):])
@staticmethod
def muse_valid_answer(text):
try:
data = json.loads(text)
except ValueError:
return None
if not isinstance(data, dict):
return None
if not isinstance(data.get("content"), str) or not isinstance(data.get("tool_calls"), list):
return None
for call in data["tool_calls"]:
if not isinstance(call, dict) or not isinstance(call.get("name"), str) or not isinstance(call.get("arguments"), str):
return None
return data
@staticmethod
def muse_gate(func):
def gated(ok):
func(ok)
gated.armed = threading.Event()
return gated
@staticmethod
def muse_reap(proc, stderr_done, on_reap):
def target():
ok = False
try:
try:
for _line in proc.stdout:
pass
except (OSError, ValueError):
pass
try:
proc.stdout.close()
except (OSError, ValueError):
pass
try:
ok = proc.wait(timeout=Consts.MUSE_REAP_GRACE) == 0
except subprocess.TimeoutExpired:
try:
proc.kill()
except OSError:
pass
try:
proc.wait(timeout=10)
except (OSError, subprocess.SubprocessError):
pass
try:
stderr_done.wait(timeout=10)
except (OSError, RuntimeError):
pass
finally:
if on_reap is not None:
try:
on_reap(ok)
except Exception:
pass
thread = threading.Thread(target=target, daemon=True)
thread.start()
return thread
@staticmethod
def muse_run(argv, cwd, stream_sink, timeout, on_reap=None):
try:
proc = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, cwd=cwd, bufsize=1)
except OSError as exc:
raise BackendError("muse exec failed: %s" % Text.short_error(exc))
gate = getattr(on_reap, "armed", None)
if gate is not None:
gate.set()
stderr_parts = []
stderr_done = threading.Event()
def collect_stderr():
try:
for chunk in proc.stderr:
stderr_parts.append(chunk)
except (OSError, ValueError):
pass
finally:
stderr_done.set()
threading.Thread(target=collect_stderr, daemon=True).start()
finished = threading.Event()
expired = threading.Event()
def watchdog():
if not finished.wait(timeout):
expired.set()
try:
proc.kill()
except OSError:
pass
threading.Thread(target=watchdog, daemon=True).start()
answer = None
terminal = None
buffer = []
try:
for line in proc.stdout:
text = line.strip()
if not text.startswith("{"):
continue
try:
event = json.loads(text)
except ValueError:
continue
kind = event.get("payload_type") or ""
payload = event.get("payload") or {}
if kind == "run.output.delta" and payload.get("text"):
piece = payload["text"]
if stream_sink is not None:
stream_sink(piece)
buffer.append(piece)
if answer is None:
answer = Muse.muse_valid_answer("".join(buffer))
if answer is not None:
break
elif kind.startswith("run.terminal."):
terminal = payload
break
finally:
finished.set()
Muse.muse_reap(proc, stderr_done, on_reap)
if terminal is not None:
if terminal.get("terminal") != "completed":
raise BackendError("muse %s: %s" % (terminal.get("terminal") or "failed", (terminal.get("reason") or "unknown")[:300]))
parsed = Muse.muse_valid_answer(terminal.get("text") or "")
if parsed is None:
raise BackendError("muse returned invalid structured answer")
return parsed
if answer is not None:
return answer
if expired.is_set():
raise BackendError("muse timed out after %ds" % timeout)
try:
code = proc.returncode
except (OSError, ValueError):
code = "?"
raise BackendError("muse exit %s: %s" % (code, "".join(stderr_parts).strip()[-300:] or "no terminal event"))
@staticmethod
def muse_health():
binary = Muse.muse_binary()
if not binary:
return "missing: muse CLI not found"
return Cli._cli_version_only_health(binary, "muse")
_MUSE_THREAD_LOCKS = {}
_MUSE_THREAD_GUARD = threading.Lock()