Split monolithic tai.py into tai_pkg package, keep tai-orig.py as original

tai.py (8643 lines) is now a thin facade over tai_pkg/ (33 feature modules, one class or feature per file). tai-orig.py preserves the pre-split single-file version byte for byte (sha256 verified against snapshot); import tai and ./tai.py behavior unchanged.

Equivalence proof, all run on this tree immediately pre-commit: differential battery tai-orig.py vs facade 3718 checks 0 failures (surface, pure fuzz, tool dispatch, store/audit, release ops, fake-HOME install roundtrip, CLI runs); test_tai.py 261 OK; test_seal.py 16 OK; --version/--help byte-identical; dir(tai) zero removals.

Fix included: opencode wire-protocol schema echo leaked into replies; opencode_parse now scans with JSONDecoder.raw_decode capped at 200 scans (13 OpencodeParseTests). Test count 261 = 237 HEAD + 8 pre-existing uncommitted ToolArgumentsTests (now committed with this change, disclosed) + 13 OpencodeParseTests + 3 EmbeddedStringsTests. Patch-target-only test migration (24 hunks), zero assertion changes. tai_pkg clean-regenerated after mid-verification tamper was found and reverted.
This commit is contained in:
2026-10-10 19:25:02 +02:00
parent da51e44359
commit 8df5cf7a45
39 changed files with 19175 additions and 8582 deletions
+122
View File
@@ -0,0 +1,122 @@
"""Opencode CLI backend, prompts, and response parsing."""
import json
import os
import random
import re
import shutil
import subprocess
import tempfile
from tai_pkg.consts import Consts
from tai_pkg.errors import BackendError
from tai_pkg.muse import Muse
from tai_pkg.text import Text
__all__ = ["Opencode", "_OPENCODE_ID_ALPHABET", "_OPENCODE_SESSION"]
class Opencode:
"""Opencode."""
@staticmethod
def opencode_binary():
return os.environ.get("OPENCODE_BIN") or shutil.which("opencode")
@staticmethod
def opencode_prompt(messages, tools):
schema = json.dumps(Muse.muse_output_schema())
rules = (
"You are the model behind another agent. The transcript above is the whole conversation; lines starting with TOOL <id> are results of tool calls that already ran. "
"Do not use any of your own tools and do not repeat a tool call whose result is already shown. "
"If the results answer the user, put the final answer in content with an empty tool_calls list; otherwise request the next tool calls from TOOLS. "
"Reply with one JSON object only, no code fence, matching this schema: "
)
return Muse.muse_prompt(messages, tools) + "\n\n" + rules + schema
@staticmethod
def opencode_parse(text):
text = (text or "").strip()
fenced = re.match(r"^```(?:json)?\s*(.*?)\s*```$", text, re.S)
if fenced:
text = fenced.group(1)
# Scan every "{" last-first and decode one value at each spot: free
# models often echo the prompt schema or tool JSON before the real
# answer, and a first-"{"-to-last-"}" slice spans both into garbage.
# Last-first means the model's own trailing answer wins over echoes.
decoder = json.JSONDecoder()
starts = [pos for pos, char in enumerate(text) if char == "{"]
for pos in reversed(starts[-Consts.OPENCODE_PARSE_MAX_SCANS:]):
try:
answer, _end = decoder.raw_decode(text, pos)
except ValueError:
continue
if isinstance(answer, dict) and ("content" in answer or "tool_calls" in answer):
return answer
return {"content": text, "tool_calls": []}
@staticmethod
def opencode_parse_events(stdout_text, returncode, stderr_text):
"""Shared by opencode_run() (local CLI) and box_opencode_run() (same CLI,
executed inside the sandbox box) -- the JSON-lines event stream is
identical either way, only how the process gets launched differs."""
texts = []
for line in stdout_text.splitlines():
try:
event = json.loads(line)
except ValueError:
continue
if not isinstance(event, dict):
continue
if event.get("type") == "error":
data = (event.get("error") or {}).get("data") or {}
raise BackendError("opencode: %s" % (data.get("message") or json.dumps(event.get("error"))[:300]), data.get("statusCode") or 0)
part = event.get("part") or {}
if event.get("type") == "text" and part.get("text"):
texts.append(part["text"])
if not texts:
raise BackendError("opencode returned no text (exit %d): %s" % (returncode, (stderr_text or stdout_text)[-300:]))
return "".join(texts)
@staticmethod
def opencode_run(model, prompt, timeout):
binary = Opencode.opencode_binary()
if not binary:
raise BackendError("opencode CLI not found (OPENCODE_BIN or PATH)")
argv = [binary, "run", "--pure", "--agent", Consts.OPENCODE_AGENT, "--format", "json", "-m", "opencode/" + model]
with tempfile.TemporaryDirectory(prefix="tai-opencode-") as folder:
try:
done = subprocess.run(argv, input=prompt, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, cwd=folder, timeout=timeout)
except subprocess.TimeoutExpired:
raise BackendError("opencode run timed out after %ds" % timeout)
except OSError as exc:
raise BackendError("opencode run failed: %s" % Text.short_error(exc))
return Opencode.opencode_parse_events(done.stdout, done.returncode, done.stderr)
@staticmethod
def opencode_free_models():
binary = Opencode.opencode_binary()
if not binary:
return None
try:
done = subprocess.run([binary, "models", "opencode"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=Consts.MODEL_LIST_TIMEOUT * 4)
except (OSError, subprocess.TimeoutExpired):
return None
models = [line.strip().split("/", 1)[1] for line in done.stdout.splitlines() if line.strip().startswith("opencode/")]
return [item for item in models if Text.is_free_model(item)] or None
@staticmethod
def opencode_headers():
return {
"User-Agent": os.environ.get("OPENCODE_CLIENT_USER_AGENT") or Consts.OPENCODE_USER_AGENT,
"x-opencode-client": os.environ.get("OPENCODE_CLIENT_NAME") or Consts.OPENCODE_CLIENT_NAME,
"x-opencode-project": "global",
"x-opencode-session": _OPENCODE_SESSION,
"x-opencode-request": "msg_" + "".join(random.choice(_OPENCODE_ID_ALPHABET) for _ in range(26)),
}
_OPENCODE_ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
_OPENCODE_SESSION = "ses_" + "".join(random.choice(_OPENCODE_ID_ALPHABET) for _ in range(26))