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.
123 lines
5.5 KiB
Python
123 lines
5.5 KiB
Python
"""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))
|