Files
tai/tai_pkg/agent.py
T
retoor 8df5cf7a45 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.
2026-10-10 19:25:02 +02:00

452 lines
21 KiB
Python

"""Main agent loop, context accounting, and turn resume."""
import collections
import json
import os
import sys
import time
import uuid
from tai_pkg import render
from tai_pkg.chat import ChatClient
from tai_pkg.consts import Consts
from tai_pkg.errors import BackendError
from tai_pkg.errors import Denied
from tai_pkg.render import Ansi
from tai_pkg.render import Render
from tai_pkg.skills import Skills
from tai_pkg.text import Text
from tai_pkg.tools import Tools
from tai_pkg.toolschema import ToolRegistry
__all__ = ["Agent"]
class Agent:
def __init__(self, config, store, persist=True, quiet=False, depth=0, auto=False, yolo=False):
self.config = config
self.auto = auto
self.yolo = yolo
self.store = store
self.chat = ChatClient(config)
if not persist or depth > 0:
self.chat.fixed_key = "worker/%s" % uuid.uuid4().hex
self.tools = Tools(self)
self.profile = ""
self.bot = "main"
self.system_message = ""
self.messages = []
self.skills = Skills.discover_skills(config.home, os.getcwd())
self.env = "home"
self.persist = persist
self.quiet = quiet
self.depth = depth
self.deadline = None
self.timed_out = False
self.runner_override = None
self.user_stepped_in = False
self._turn_open = False
self._turn_goal = ""
self._turn_steps = 0
self._turn_tools = 0
self.switch_profile(config.profile, silent=True)
def apply_system(self):
content = self.system_message + Skills.skill_catalog(self.skills) + ToolRegistry.tool_catalog()
if self.auto:
content += "\n\n" + Consts.AUTONOMOUS_NOTE
self.messages[0] = {"role": "system", "content": content}
def sync_chat_scope(self):
self.chat.scope = (self.profile, self.bot)
def reset_history(self):
self.messages = [{"role": "system", "content": self.system_message}]
self.apply_system()
def switch_profile(self, name, silent=False):
if self.messages and self.persist:
self.store.save_session(self.profile, self.messages, self.bot)
self.profile = name
self.bot = "main"
self.store.profile = name
self.tools.read_files.clear()
self.tools.secret_grants.clear()
loaded = self.store.load_system(name)
if loaded is None:
loaded = Consts.DEFAULT_SYSTEM
self.store.save_system(name, loaded)
if Consts.LEGACY_PASSWORD_NOTE in loaded:
loaded = loaded.replace(Consts.LEGACY_PASSWORD_NOTE, Consts.VAULT_MEMORY_NOTE)
self.store.save_system(name, loaded)
self.system_message = loaded
restored = self.store.load_session(name) if self.persist else []
self.messages = [{"role": "system", "content": self.system_message}] + restored
self.apply_system()
self.sync_chat_scope()
if not silent:
print(Render.paint("profile: %s (%d chars knowledge, %d restored messages)" % (name, len(self.system_message), len(restored)), Ansi.GREEN))
def update_system(self, text):
self.system_message = text
if self.bot == "main":
self.store.save_system(self.profile, text)
else:
self.store.save_bot_system(self.profile, self.bot, text)
self.apply_system()
self.store.log_event(self.profile, "system", "remember", "system message updated (%d chars)" % len(text))
def switch_bot(self, name, silent=False):
target = self.store.resolve_bot(self.profile, name)
if target is None:
return "unknown bot @%s (available: %s)" % (name, ", ".join(item["name"] for item in self.store.list_bots(self.profile)))
if self.messages and self.persist:
self.store.save_session(self.profile, self.messages, self.bot)
self.bot = target
loaded = self.store.load_bot_system(self.profile, target)
if loaded is None:
loaded = Consts.DEFAULT_SYSTEM
if target == "main":
self.store.save_system(self.profile, loaded)
else:
self.store.save_bot_system(self.profile, target, loaded)
self.system_message = loaded
restored = self.store.load_session(self.profile, target) if self.persist else []
self.messages = [{"role": "system", "content": self.system_message}] + restored
self.apply_system()
self.sync_chat_scope()
if not silent:
print(Render.paint("bot: %s (%d restored messages)" % (target, len(restored)), Ansi.GREEN))
return "switched to bot '%s'" % target
def scrub_secret_from_history(self):
for item in self.messages:
content = item.get("content")
if isinstance(content, str) and content:
item["content"] = self.store.redact(content, self.profile)
for recorded in item.get("tool_calls") or []:
args = (recorded.get("function") or {}).get("arguments")
if isinstance(args, str) and args:
recorded["function"]["arguments"] = self.store.redact(args, self.profile)
self.chat.reset_muse()
def ask_approval(self, command, guidance=True):
if self.yolo or self.auto:
return True
if not self.persist:
return self.config.auto_approve
if not sys.stdin.isatty():
return False
print(Render.paint("run: %s" % command, Ansi.YELLOW))
try:
answer = input(Render.paint("allow? [y]once [Y]always [n]o ", Ansi.YELLOW)).strip()
except (EOFError, KeyboardInterrupt):
return False
self.user_stepped_in = True
if answer == "Y" or answer.lower() in ("yolo", "always"):
self.yolo = True
self.config.auto_approve = True
print(Render.paint("yolo mode on, approvals off for this session", Ansi.YELLOW))
return True
if answer.lower() in ("y", "yes"):
return True
if not guidance or self.auto:
return False
try:
typed = input(Render.paint("denied. What should I do instead? (empty aborts this turn) ", Ansi.YELLOW)).strip()
except (EOFError, KeyboardInterrupt):
typed = ""
raise Denied(typed)
def show_call(self, call):
if self.quiet:
return
print(Render.paint("┌─ %s" % call["name"], Ansi.MAGENTA))
preview_args = Text.tool_args_text(call["arguments"])
if call["name"] == "store_secret":
try:
masked = json.loads(preview_args or "{}")
masked["value"] = "[hidden]"
preview_args = json.dumps(masked)
except ValueError:
preview_args = "[hidden]"
preview = self.store.redact(preview_args, self.profile)[:400].replace("\n", " ")
print(Render.paint("│ %s" % preview, Ansi.DIM))
def show_result(self, result, elapsed_ms):
if self.quiet:
return
self.show_diff()
print(Render.paint("└─ %d chars · %dms" % (len(result), elapsed_ms), Ansi.DIM))
def show_diff(self):
preview = getattr(self.tools, "diff_preview", None)
if preview is None:
return
self.tools.diff_preview = None
rendered = Render.render_diff(preview["path"], preview["old"], preview["new"], colors=Render.color_enabled())
if rendered:
print(rendered)
def checkpoint(self):
if not self.persist:
return
self.store.save_session(self.profile, self.messages, self.bot)
if self._turn_open:
self.store.save_turn_state(self.profile, self.bot, {"open": True, "goal": self._turn_goal, "steps": self._turn_steps, "tools": self._turn_tools, "updated": Text.now_iso()})
def close_turn(self):
if not self.persist:
self._turn_open = False
return
self.store.save_session(self.profile, self.messages, self.bot)
self._turn_open = False
if self._turn_goal:
self.store.save_turn_state(self.profile, self.bot, {"open": False, "goal": self._turn_goal, "steps": self._turn_steps, "tools": self._turn_tools, "updated": Text.now_iso()})
def run_turn(self, text, capture=False, max_steps=Consts.MAX_STEPS, _routed=False):
if not _routed:
mentioned = Consts.BOT_MENTION_RE.match(text or "")
if mentioned:
target = self.store.resolve_bot(self.profile, mentioned.group(1))
if target is None:
return "unknown bot @%s (available: %s)" % (mentioned.group(1), ", ".join(item["name"] for item in self.store.list_bots(self.profile)))
if target != self.bot:
return self.run_mention(target, mentioned.group(2), text, capture, max_steps)
text = mentioned.group(2)
self.messages.append({"role": "user", "content": text})
self.store.log_event(self.profile, "user", "message", self.store.redact(text, self.profile))
self._turn_open = True
self._turn_goal = text[:500]
self._turn_steps = 0
self._turn_tools = 0
self.checkpoint()
started = time.time()
backends = []
pieces = []
last_text = ""
sink = pieces.append
loud = not capture and not self.quiet
self.tools.live = loud
seen_actions = collections.deque(maxlen=400)
seen_results = collections.deque(maxlen=400)
stall = 0
last_pair = None
run_len = 0
nudged = False
for _step in range(Consts.TOTAL_STEP_CAP):
if self.deadline is not None and time.time() > self.deadline:
self.timed_out = True
last_text = (last_text + "\n[time limit reached]").strip()
break
payload = ToolRegistry.select_tools(Agent.conversation_text(self.messages))
tokens = Agent.conversation_tokens(self.messages, payload)
if tokens > Consts.CONTEXT_CAP * Consts.COMPACT_RATIO:
if loud:
print(Render.paint("compacting context (%d tokens)..." % tokens, Ansi.DIM))
self.messages, _changed = Agent.compact_messages(self.messages, self.chat)
self.apply_system()
spinner = None
if loud:
print(Render.paint("tai", Ansi.BOLD, Ansi.CYAN))
spinner = render.Spinner("thinking")
spinner.start()
try:
reply = self.chat.complete(self.messages, payload, stream_sink=sink, deadline=self.deadline)
except BackendError as exc:
if loud:
print(Render.paint("backend error: %s" % exc, Ansi.RED))
self.messages.pop()
self.checkpoint()
return last_text or "backend error: %s" % exc
finally:
if spinner is not None:
spinner.stop()
reply["content"] = self.store.redact(reply["content"] or "", self.profile)
if loud and reply["content"].strip():
print(Render.render_markdown(reply["content"]))
if reply["backend"] not in backends:
backends.append(reply["backend"])
if loud and (len(backends) > 1 or reply["backend"] != Consts.MUSE_LABEL):
print(Render.paint("(via %s)" % reply["backend"], Ansi.DIM))
if loud and reply["reasoning"] and not reply["content"] and not reply["tool_calls"]:
print(Render.paint(reply["reasoning"][:2000], Ansi.GRAY))
history_calls = [{"id": call["id"], "type": "function", "function": {"name": call["name"], "arguments": call["arguments"]}} for call in reply["tool_calls"]]
self.messages.append({"role": "assistant", "content": reply["content"] or None, "tool_calls": history_calls or None})
if not reply["tool_calls"]:
if reply["content"]:
last_text = reply["content"]
elif reply["reasoning"]:
last_text = reply["reasoning"]
if last_text:
self.store.log_event(self.profile, "assistant", "message", last_text)
break
aborted = False
step_pairs = []
for pos, call in enumerate(reply["tool_calls"]):
self.show_call(call)
call_started = time.time()
try:
result = self.tools.dispatch(call["name"], call["arguments"])
except Denied as denied:
result = "denied by user"
self.show_result(result, (time.time() - call_started) * 1000)
self.messages.append({"role": "tool", "tool_call_id": call["id"], "content": result})
self.store.log_event(self.profile, "tool", call["name"], result)
step_pairs.append((Text.action_signature(call["name"], call["arguments"]), Text.result_digest(result)))
for skipped in reply["tool_calls"][pos + 1:]:
self.messages.append({"role": "tool", "tool_call_id": skipped["id"], "content": "skipped: stopped after denial"})
if denied.guidance:
self.messages.append({"role": "user", "content": denied.guidance})
self.store.log_event(self.profile, "user", "guidance", self.store.redact(denied.guidance, self.profile))
else:
aborted = True
last_text = "stopped by user"
break
self.show_result(result, (time.time() - call_started) * 1000)
self.messages.append({"role": "tool", "tool_call_id": call["id"], "content": result})
self.store.log_event(self.profile, "tool", call["name"], result)
step_pairs.append((Text.action_signature(call["name"], call["arguments"]), Text.result_digest(result)))
if call["name"] == "store_secret":
self.scrub_secret_from_history()
if aborted:
break
productive = self.user_stepped_in
self.user_stepped_in = False
for pair in step_pairs:
if pair[0] not in seen_actions or pair[1] not in seen_results:
productive = True
seen_actions.append(pair[0])
seen_results.append(pair[1])
if pair == last_pair:
run_len += 1
else:
last_pair = pair
run_len = 1
nudged = False
self._turn_steps += 1
self._turn_tools += len(step_pairs)
self.checkpoint()
if productive:
stall = 0
else:
stall += 1
if run_len == Consts.LOOP_NUDGE_AT and not nudged:
nudged = True
nudge = "loop warning: you called %s %d times in a row with identical results. Change approach, try different arguments, or stop instead of repeating it." % (last_pair[0][:200], run_len)
self.messages.append({"role": "user", "content": nudge})
self.store.log_event(self.profile, "user", "guidance", nudge)
if loud:
print(Render.paint(nudge, Ansi.YELLOW))
if run_len >= Consts.LOOP_STOP_AT:
last_text = "stuck in a loop: %s repeated %d times with identical results" % (last_pair[0][:200], run_len)
if loud:
print(Render.paint(last_text, Ansi.YELLOW))
self.store.log_event(self.profile, "system", "stall", last_text)
break
if stall >= max_steps:
last_text = "no progress for %d steps: actions and results keep repeating. Say 'continue' to resume with fresh direction." % max_steps
if loud:
print(Render.paint(last_text, Ansi.YELLOW))
self.store.log_event(self.profile, "system", "stall", last_text)
break
else:
if loud:
print(Render.paint("step budget exhausted (%d total steps)" % Consts.TOTAL_STEP_CAP, Ansi.YELLOW))
self.close_turn()
total = time.time() - started
if loud:
print(Render.paint("tokens≈%d · %s · %.1fs" % (Agent.conversation_tokens(self.messages), "+".join(backends), total), Ansi.DIM))
return last_text
def run_mention(self, target, rest, original, capture=False, max_steps=Consts.MAX_STEPS):
if self.persist:
self.store.save_session(self.profile, self.messages, self.bot)
other = self.store.load_session(self.profile, target)
else:
other = []
system = self.store.load_bot_system(self.profile, target) or Consts.DEFAULT_SYSTEM
saved_messages, saved_system, saved_bot = self.messages, self.system_message, self.bot
self.messages = [{"role": "system", "content": system}] + other
self.system_message = system
self.bot = target
self.apply_system()
self.sync_chat_scope()
try:
answer = self.run_turn(rest, capture=capture, max_steps=max_steps, _routed=True)
finally:
if self.persist:
self.store.save_session(self.profile, self.messages, target)
self.messages, self.system_message, self.bot = saved_messages, saved_system, saved_bot
self.apply_system()
self.sync_chat_scope()
self.store.log_event(self.profile, "user", "message", self.store.redact(original, self.profile))
self.messages.append({"role": "user", "content": original})
self.messages.append({"role": "assistant", "content": answer, "reasoning": "", "tool_calls": [], "backend": "mention"})
if self.persist:
self.store.save_session(self.profile, self.messages, self.bot)
return answer
@staticmethod
def conversation_text(messages):
parts = []
for item in messages:
content = item.get("content")
if item.get("role") in ("user", "assistant", "tool") and isinstance(content, str) and content:
parts.append(content)
return "\n".join(parts)[-6000:]
@staticmethod
def conversation_tokens(messages, tools=None):
total = 0
for item in messages:
total += Text.estimate_tokens(item.get("content"))
for call in item.get("tool_calls") or []:
total += Text.estimate_tokens(json.dumps(call))
if tools:
total += Text.estimate_tokens(json.dumps(tools))
return total
@staticmethod
def compact_messages(messages, chat, keep=Consts.KEEP_TURNS):
if len(messages) <= keep + 1:
return messages, False
system = messages[0] if messages[0].get("role") == "system" else {"role": "system", "content": Consts.DEFAULT_SYSTEM}
recent = messages[-keep:]
while recent and recent[0].get("role") != "user":
recent = recent[1:]
if not recent:
recent = messages[-keep:]
middle = messages[1:len(messages) - len(recent)]
if not middle:
return messages, False
digest = []
for item in middle:
role = item.get("role")
if role == "assistant" and item.get("tool_calls"):
names = ", ".join(call.get("function", {}).get("name", "?") for call in item["tool_calls"])
digest.append("assistant tool calls: " + names)
digest.append("%s: %s" % (role, (item.get("content") or "")[:1500]))
request = [
{"role": "system", "content": Consts.COMPACT_SYSTEM},
{"role": "user", "content": "Compress this history:\n" + "\n".join(digest)},
]
summary = chat.complete(request, ephemeral=True)["content"].strip()
rebuilt = [
system,
{"role": "user", "content": "Previous session summary:\n" + summary},
{"role": "assistant", "content": "Understood. Continuing with summarized context."},
] + recent
return rebuilt, True
@staticmethod
def resume_turn(agent):
state = agent.store.load_turn_state(agent.profile, agent.bot)
if not state.get("open"):
print("nothing to resume: last turn for %s/%s completed normally" % (agent.profile, agent.bot))
return ""
goal = state.get("goal") or ""
print("resuming interrupted turn for %s/%s: %d steps, %d tool calls, goal: %s" % (agent.profile, agent.bot, state.get("steps", 0), state.get("tools", 0), goal[:200]))
message = "Continue where you left off%s. Do not repeat steps already completed above; carry on with the next step." % (": the interrupted request was: %s" % goal if goal else "")
return agent.run_turn(message)