155 lines
6.1 KiB
Python
155 lines
6.1 KiB
Python
from __future__ import annotations
|
||
|
||
import argparse
|
||
import asyncio
|
||
import json
|
||
import sys
|
||
from typing import Any, Iterable
|
||
from uuid import uuid4
|
||
|
||
ORIGINAL_STDOUT = sys.stdout
|
||
|
||
|
||
def event_stdout():
|
||
if sys.stdout.__class__.__name__ == "_DiscardStream":
|
||
return ORIGINAL_STDOUT
|
||
return sys.stdout
|
||
|
||
|
||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||
parser = argparse.ArgumentParser(description="Run the ViMax agent loop.")
|
||
parser.add_argument("--session", default="", help="Existing session id to activate before the run starts.")
|
||
parser.add_argument("--new-session", action="store_true", help="Create and activate a new empty session before the run starts.")
|
||
parser.add_argument("--jsonl", action="store_true", help="Print one JSON event per line.")
|
||
parser.add_argument("--once", default="", help="Run a single prompt and exit. If omitted and stdin is not a TTY, stdin is consumed as one prompt.")
|
||
parser.add_argument("--stdin-repl", action="store_true", help=argparse.SUPPRESS)
|
||
return parser.parse_args(argv)
|
||
|
||
|
||
def load_runtime():
|
||
from agent_runtime import build_runtime
|
||
|
||
return build_runtime(".")
|
||
|
||
|
||
def load_session_index():
|
||
from agent_runtime.session_index import SessionIndex
|
||
|
||
return SessionIndex(".")
|
||
|
||
|
||
def print_event(event: dict[str, Any], *, jsonl: bool) -> None:
|
||
out = event_stdout()
|
||
if jsonl:
|
||
print(json.dumps(event, ensure_ascii=False, default=str), file=out, flush=True)
|
||
return
|
||
event_type = event.get("type")
|
||
if event_type == "turn":
|
||
print(f"· turn: {event.get('turn_id', '')}", file=out, flush=True)
|
||
elif event_type == "token":
|
||
print(event.get("delta", ""), end="", file=out, flush=True)
|
||
elif event_type == "tool_start":
|
||
tool = event.get("tool", {})
|
||
print(f"\n· tool: {tool.get('name')} started", file=out, flush=True)
|
||
elif event_type == "tool_progress":
|
||
progress = event.get("progress", {})
|
||
tool = event.get("tool", {})
|
||
print(f"· tool: {tool.get('name')} {progress.get('stage', 'running')}: {progress.get('message', '')}", file=out, flush=True)
|
||
elif event_type == "tool_result":
|
||
result = event["tool_result"]
|
||
status = "done" if result.get("ok") else "error"
|
||
print(f"· tool: {result.get('name')} {status}", file=out, flush=True)
|
||
elif event_type == "terminal":
|
||
stream = event.get("stream", "stdout")
|
||
print(f"· terminal[{stream}]: {event.get('line', '')}", file=out, flush=True)
|
||
elif event_type == "status":
|
||
print(f"· status: {event.get('phase')}: {event.get('message', '')}", file=out, flush=True)
|
||
elif event_type == "session":
|
||
session = (event.get("session") or {}).get("session") or {}
|
||
if session:
|
||
print(f"· session: {session.get('session_id')} {session.get('stage', '')}", file=out, flush=True)
|
||
elif event_type == "done":
|
||
print("", file=out, flush=True)
|
||
elif event_type == "error":
|
||
print(f"\nerror: {event.get('message', '')}", file=out, flush=True)
|
||
|
||
|
||
def prompt_inputs(args: argparse.Namespace) -> Iterable[str]:
|
||
if args.once:
|
||
yield args.once
|
||
return
|
||
if args.stdin_repl:
|
||
for line in sys.stdin:
|
||
user_input = line.strip()
|
||
if user_input:
|
||
yield user_input
|
||
return
|
||
if not sys.stdin.isatty():
|
||
payload = sys.stdin.read().strip()
|
||
if payload:
|
||
yield payload
|
||
return
|
||
while True:
|
||
try:
|
||
user_input = input("› " if not args.jsonl else "")
|
||
except EOFError:
|
||
break
|
||
if user_input.strip():
|
||
yield user_input.strip()
|
||
|
||
|
||
async def amain(argv: list[str] | None = None) -> int:
|
||
args = parse_args(argv)
|
||
if args.session and args.new_session:
|
||
print("error: --session and --new-session cannot be used together", file=sys.stderr)
|
||
return 2
|
||
if args.session or args.new_session:
|
||
try:
|
||
session_index = load_session_index()
|
||
if args.new_session:
|
||
session_index.create()
|
||
else:
|
||
session_index.set_active(args.session)
|
||
except KeyError:
|
||
print(f"error: unknown session id: {args.session}", file=sys.stderr)
|
||
return 2
|
||
except ValueError as exc:
|
||
print(f"error: invalid session id: {exc}", file=sys.stderr)
|
||
return 2
|
||
runtime = load_runtime()
|
||
interactive = sys.stdin.isatty() and not args.once
|
||
if interactive and not args.jsonl:
|
||
print("ViMax agent ready. Ctrl+C to exit.")
|
||
for user_input in prompt_inputs(args):
|
||
if user_input.strip() == "/compact":
|
||
turn_id = f"turn-{uuid4().hex[:12]}"
|
||
print_event({"type": "turn", "turn_id": turn_id, "turn": {"id": turn_id}}, jsonl=args.jsonl)
|
||
print_event({"type": "status", "turn_id": turn_id, "phase": "compact", "message": "Compacting context"}, jsonl=args.jsonl)
|
||
message = await runtime.compact_history(reason="manual")
|
||
print_event({"type": "token", "turn_id": turn_id, "delta": message}, jsonl=args.jsonl)
|
||
print_event({"type": "done", "turn_id": turn_id, "assistant": message, "tool_results": []}, jsonl=args.jsonl)
|
||
print_event({"type": "session", "turn_id": turn_id, "session": runtime.session_index.snapshot()}, jsonl=args.jsonl)
|
||
continue
|
||
try:
|
||
async for event in runtime.stream_events(user_input):
|
||
print_event(event, jsonl=args.jsonl)
|
||
except Exception as exc:
|
||
# Keep the REPL alive: one failed turn must not kill the process
|
||
# (and with it the TUI driving us over stdio).
|
||
turn_id = f"turn-{uuid4().hex[:12]}"
|
||
print_event({"type": "error", "turn_id": turn_id, "message": f"turn failed: {exc}"}, jsonl=args.jsonl)
|
||
print_event({"type": "done", "turn_id": turn_id, "assistant": "", "tool_results": []}, jsonl=args.jsonl)
|
||
return 0
|
||
|
||
|
||
def main() -> None:
|
||
try:
|
||
raise SystemExit(asyncio.run(amain()))
|
||
except KeyboardInterrupt:
|
||
print("", file=sys.stderr)
|
||
raise SystemExit(130)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|