#!/usr/bin/env python3 """Interactive terminal client for ControlAI.""" from __future__ import annotations import argparse import sys import time from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parent if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) from controlai_agent.agent import ControlAgent from controlai_agent.engine import DEFAULT_ADAPTER, DEFAULT_MODEL, LocalEngine DIM, RESET, BOLD = "\033[2m", "\033[0m", "\033[1m" def converse(agent: ControlAgent, question: str, history: list[dict], verbose: bool) -> str: started = time.time() first_text: float | None = None answer = "" in_thought = False for event in agent.stream(question, history): kind = event["type"] if kind == "thinking": if not in_thought: print(f"{DIM}thinking… ", end="", flush=True) in_thought = True if verbose: print(f"{DIM}{event['text']}{RESET}", end="", flush=True) elif kind == "text": if in_thought: print(RESET, flush=True) in_thought = False if first_text is None: first_text = time.time() - started print(event["text"], end="", flush=True) elif kind == "tool_start": args = str(event["arguments"]) print(f"\n{DIM}→ {event['tool']}({args[:100]}{'…' if len(args) > 100 else ''}){RESET}", flush=True) elif kind == "tool_end": print(f"{DIM} {event['status']}{RESET}", flush=True) elif kind == "plot": print(f"\n{DIM}[plot saved: outputs/plots/{Path(event['url']).name}]{RESET}", flush=True) elif kind == "done": answer = event["answer"] if event["sources"]: print(f"\n\n{DIM}Sources: {'; '.join(dict.fromkeys(event['sources']))}{RESET}") stats = event["stats"] print( f"\n{DIM}{time.time() - started:.1f}s total · first token {first_text or 0:.2f}s · " f"{stats['cached_tokens']}/{stats['prompt_tokens']} prompt tokens cached · " f"{stats['decode_tps']} tok/s{RESET}" ) return answer def main() -> int: parser = argparse.ArgumentParser(description="ControlAI -- offline control engineering agent") parser.add_argument("prompt", nargs="*", help="ask one question and exit") parser.add_argument("--model", default=DEFAULT_MODEL, help="MLX model id or local path") parser.add_argument("--adapter", default=DEFAULT_ADAPTER, help="optional LoRA adapter path") parser.add_argument( "--thinking", choices=("off", "auto", "on"), default=None, help="reasoning before answering (default: auto -- on for conceptual questions only)", ) parser.add_argument("--think-budget", type=int, default=None, help="max tokens spent reasoning") parser.add_argument("--verbose", action="store_true", help="print reasoning and full tool output") args = parser.parse_args() print(f"Loading {args.model}…") engine = LocalEngine(model_id=args.model, adapter_path=args.adapter) kwargs = {} if args.thinking: kwargs["thinking"] = args.thinking if args.think_budget: kwargs["think_budget"] = args.think_budget agent = ControlAgent(engine=engine, **kwargs) print(f"Ready in {engine.load_seconds:.1f}s.\n") if args.prompt: converse(agent, " ".join(args.prompt), [], args.verbose) return 0 print(f"{BOLD}ControlAI{RESET} — control engineering assistant. Ctrl-D or 'exit' to quit.") history: list[dict] = [] while True: try: question = input(f"\n{BOLD}you ›{RESET} ").strip() except (KeyboardInterrupt, EOFError): print("\nBye.") return 0 if not question: continue if question.lower() in ("exit", "quit", "q"): return 0 if question.lower() in ("clear", "reset"): history.clear() agent.engine.reset_cache() print(f"{DIM}history cleared{RESET}") continue print() answer = converse(agent, question, history, args.verbose) history.append({"role": "user", "content": question}) history.append({"role": "assistant", "content": answer}) if __name__ == "__main__": raise SystemExit(main())