repositories
loading repo index
repositories
loading repo index
repository
loading code, commits, and activity
Mirrored from https://github.com/yingqi-z20/Agent-libOS
stars
latest
clone command
git clone gitlawb://did:key:z6MkqRzA...RfoM/yingqi-z20-Agen...git clone gitlawb://did:key:z6MkqRzA.../yingqi-z20-Agen...d98dd2c9IPC1d ago| #1 | from __future__ import annotations |
| #2 | |
| #3 | import argparse |
| #4 | import asyncio |
| #5 | import json |
| #6 | import sys |
| #7 | import threading |
| #8 | from collections import Counter |
| #9 | from pathlib import Path |
| #10 | from typing import Any |
| #11 | |
| #12 | from agent_libos.capability.manager import CapabilityManager |
| #13 | from agent_libos.config import DEFAULT_CONFIG |
| #14 | from agent_libos.models import ( |
| #15 | CapabilityRight, |
| #16 | ForkMode, |
| #17 | MemoryViewSpec, |
| #18 | ObjectHandle, |
| #19 | ObjectMetadata, |
| #20 | ObjectType, |
| #21 | ProcessMessage, |
| #22 | ProcessMessageKind, |
| #23 | ProcessStatus, |
| #24 | ToolCallResult, |
| #25 | ViewMode, |
| #26 | ) |
| #27 | from agent_libos.runtime.runtime import Runtime |
| #28 | |
| #29 | _RUNTIME_DEFAULTS = DEFAULT_CONFIG.runtime |
| #30 | |
| #31 | DEMO_PATCH_PREVIEW_PATH = "agent_outputs/demo_patch_preview.txt" |
| #32 | DEMO_PATCH_PREVIEW_CONTENT = "change add() expected value\n" |
| #33 | _TERMINAL_PROCESS_STATUSES = {ProcessStatus.EXITED, ProcessStatus.FAILED, ProcessStatus.KILLED} |
| #34 | |
| #35 | |
| #36 | def main(argv: list[str] | None = None) -> None: |
| #37 | parser = argparse.ArgumentParser(prog="agent-libos") |
| #38 | parser.add_argument( |
| #39 | "--db", |
| #40 | default=_RUNTIME_DEFAULTS.local_store_target, |
| #41 | help=f"SQLite DB path, or '{_RUNTIME_DEFAULTS.local_store_target}' for in-memory", |
| #42 | ) |
| #43 | sub = parser.add_subparsers(dest="command", required=True) |
| #44 | sub.add_parser("init", help="Initialize a runtime database") |
| #45 | sub.add_parser("demo", help="Run the coding-agent MVP demo") |
| #46 | sub.add_parser("audit", help="Print audit trace") |
| #47 | llm_calls_parser = sub.add_parser("llm-calls", help="Print persisted LLM call records") |
| #48 | llm_calls_parser.add_argument("--pid", help="Filter by process id.") |
| #49 | llm_calls_parser.add_argument("--limit", type=int, help="Maximum number of records to print.") |
| #50 | sub.add_parser("processes", help="Print process table") |
| #51 | sub.add_parser("tools", help="Print registered tools") |
| #52 | spawn_parser = sub.add_parser("spawn", help="Spawn a process") |
| #53 | spawn_parser.add_argument("--image", default=_RUNTIME_DEFAULTS.default_image_id) |
| #54 | spawn_parser.add_argument("--goal", required=True) |
| #55 | cd_parser = sub.add_parser("cd", help="Set an AgentProcess working directory") |
| #56 | cd_parser.add_argument("pid") |
| #57 | cd_parser.add_argument("path") |
| #58 | exec_parser = sub.add_parser("exec", help="Exec an AgentProcess into another image") |
| #59 | exec_parser.add_argument("image", help="Target AgentImage id, or a .yaml/.yml AgentImage manifest to load first.") |
| #60 | exec_parser.add_argument("goal", help="Replacement process goal.") |
| #61 | exec_parser.add_argument("--pid", required=True, help="Process id to exec.") |
| #62 | exec_parser.add_argument("--replace-image", action="store_true", help="Allow a YAML image manifest to replace an existing image id.") |
| #63 | exec_parser.add_argument("--args-json", default="{}", help="JSON object recorded as structured exec args.") |
| #64 | exec_parser.add_argument( |
| #65 | "--preserve-memory", |
| #66 | action=argparse.BooleanOptionalAction, |
| #67 | default=True, |
| #68 | help="Keep the current MemoryView across exec. Use --no-preserve-memory to replace it with the new goal only.", |
| #69 | ) |
| #70 | exec_parser.add_argument( |
| #71 | "--preserve-capabilities", |
| #72 | action="store_true", |
| #73 | help="Keep existing external capabilities. Exec never grants target image required_capabilities automatically.", |
| #74 | ) |
| #75 | run_group = exec_parser.add_mutually_exclusive_group() |
| #76 | run_group.add_argument("--run", dest="run", action="store_true", default=False, help="Run the scheduler after exec.") |
| #77 | run_group.add_argument("--no-run", dest="run", action="store_false", help="Only apply exec; do not run the scheduler.") |
| #78 | exec_parser.add_argument("--max-quanta", type=int, default=_RUNTIME_DEFAULTS.run_until_idle_max_quanta) |
| #79 | exit_parser = sub.add_parser("exit", help="Exit an AgentProcess") |
| #80 | exit_parser.add_argument("pid") |
| #81 | exit_parser.add_argument("--message", help="Optional process status message.") |
| #82 | exit_parser.add_argument("--payload", help="Optional JSON final-result payload. Non-JSON text is wrapped as content.") |
| #83 | exit_parser.add_argument("--result-oid", help="Existing object id to use as process result.") |
| #84 | exit_parser.add_argument("--failed", action="store_true", help="Mark the process as failed instead of exited.") |
| #85 | llm_once_parser = sub.add_parser("llm-once", help="Run one LLM quantum for a process") |
| #86 | llm_once_parser.add_argument("pid") |
| #87 | run_parser = sub.add_parser("run", help="Run runnable processes with the LLM scheduler") |
| #88 | run_parser.add_argument("--max-quanta", type=int, default=_RUNTIME_DEFAULTS.run_until_idle_max_quanta) |
| #89 | run_parser.add_argument("--interactive", action="store_true", help="Read human input while running and post it as process messages.") |
| #90 | run_parser.add_argument("--pid", help="Default target process for interactive human messages.") |
| #91 | run_parser.add_argument("--human", default=_RUNTIME_DEFAULTS.default_human, help="Human actor name for interactive messages.") |
| #92 | run_parser.add_argument("--message-channel", default="human", help="Process-message channel for interactive human input.") |
| #93 | message_parser = sub.add_parser("message", help="Send a human process message") |
| #94 | _add_message_parser_args(message_parser) |
| #95 | interrupt_parser = sub.add_parser("interrupt", help="Send a human interrupt process message") |
| #96 | _add_message_parser_args(interrupt_parser, include_kind=False) |
| #97 | sub.add_parser("human", help="Process pending human messages in terminal order") |
| #98 | grant_tool_parser = sub.add_parser("grant-tool", help="Deprecated: process tools are fixed by AgentImage at creation") |
| #99 | grant_tool_parser.add_argument("pid") |
| #100 | grant_tool_parser.add_argument("tool") |
| #101 | args = parser.parse_args(argv) |
| #102 | |
| #103 | runtime = Runtime.open(args.db) |
| #104 | try: |
| #105 | if args.command == "init": |
| #106 | print(f"initialized {args.db}") |
| #107 | elif args.command == "demo": |
| #108 | print(json.dumps(run_demo(runtime), indent=2, ensure_ascii=False)) |
| #109 | elif args.command == "audit": |
| #110 | _print_json([record.__dict__ for record in runtime.audit.trace()]) |
| #111 | elif args.command == "llm-calls": |
| #112 | _print_json([record.__dict__ for record in runtime.store.list_llm_calls(pid=args.pid, limit=args.limit)]) |
| #113 | elif args.command == "processes": |
| #114 | _print_json([process.__dict__ for process in runtime.process.list()]) |
| #115 | elif args.command == "tools": |
| #116 | _print_json(runtime.tools.list()) |
| #117 | elif args.command == "spawn": |
| #118 | pid = runtime.process.spawn(image=args.image, goal=args.goal) |
| #119 | _print_json({"pid": pid, "image": args.image, "goal": args.goal}) |
| #120 | elif args.command == "cd": |
| #121 | _print_json(_run_cd_command(runtime, args)) |
| #122 | elif args.command == "exec": |
| #123 | _print_json(asyncio.run(_run_exec_command(runtime, args))) |
| #124 | elif args.command == "exit": |
| #125 | _print_json(_run_exit_command(runtime, args)) |
| #126 | elif args.command == "llm-once": |
| #127 | _print_json(asyncio.run(runtime.arun_process_once(args.pid))) |
| #128 | elif args.command == "run": |
| #129 | if args.interactive: |
| #130 | _print_json(asyncio.run(_run_interactive_command(runtime, args))) |
| #131 | else: |
| #132 | _print_json(asyncio.run(runtime.arun_until_idle(max_quanta=args.max_quanta))) |
| #133 | elif args.command == "message": |
| #134 | _print_json(asyncio.run(_run_message_command(runtime, args))) |
| #135 | elif args.command == "interrupt": |
| #136 | _print_json(asyncio.run(_run_message_command(runtime, args, fixed_kind=ProcessMessageKind.INTERRUPT))) |
| #137 | elif args.command == "grant-tool": |
| #138 | raise SystemExit("tool execute grants are disabled; configure tools in the AgentImage before spawning") |
| #139 | elif args.command == "human": |
| #140 | _print_json([request.__dict__ for request in runtime.human.drain_terminal_queue()]) |
| #141 | finally: |
| #142 | runtime.close() |
| #143 | |
| #144 | |
| #145 | def _run_cd_command(runtime: Runtime, args: argparse.Namespace) -> dict[str, Any]: |
| #146 | before = runtime.process.working_directory(args.pid) |
| #147 | process = runtime.set_process_working_directory(args.pid, args.path) |
| #148 | return { |
| #149 | "pid": process.pid, |
| #150 | "previous_working_directory": before, |
| #151 | "working_directory": process.working_directory, |
| #152 | } |
| #153 | |
| #154 | |
| #155 | async def _run_exec_command(runtime: Runtime, args: argparse.Namespace) -> dict[str, Any]: |
| #156 | loaded_image = ( |
| #157 | _load_cli_image_from_yaml(runtime, args.image, replace=args.replace_image) |
| #158 | if _is_yaml_image_arg(args.image) |
| #159 | else None |
| #160 | ) |
| #161 | target_image = loaded_image["image_id"] if loaded_image is not None else args.image |
| #162 | exec_args = _parse_json_mapping(args.args_json, "--args-json") |
| #163 | old_image = runtime.process.get(args.pid).image_id |
| #164 | process = runtime.exec_process( |
| #165 | args.pid, |
| #166 | target_image, |
| #167 | args=exec_args, |
| #168 | goal=args.goal, |
| #169 | preserve_memory=args.preserve_memory, |
| #170 | preserve_capabilities=args.preserve_capabilities, |
| #171 | ) |
| #172 | results: list[Any] = [] |
| #173 | if args.run: |
| #174 | results = await runtime.arun_until_idle(max_quanta=args.max_quanta) |
| #175 | process = runtime.process.get(args.pid) |
| #176 | return { |
| #177 | "pid": args.pid, |
| #178 | "goal": args.goal, |
| #179 | "image_arg": args.image, |
| #180 | "loaded_image": loaded_image, |
| #181 | "exec": { |
| #182 | "old_image": old_image, |
| #183 | "new_image": process.image_id, |
| #184 | "preserve_memory": args.preserve_memory, |
| #185 | "preserve_capabilities": args.preserve_capabilities, |
| #186 | "args": exec_args, |
| #187 | }, |
| #188 | "process": _process_cli_summary(process), |
| #189 | "ran": args.run, |
| #190 | "results": results, |
| #191 | } |
| #192 | |
| #193 | |
| #194 | def _run_exit_command(runtime: Runtime, args: argparse.Namespace) -> dict[str, Any]: |
| #195 | if args.payload is not None and args.result_oid is not None: |
| #196 | raise SystemExit("exit accepts at most one of --payload or --result-oid") |
| #197 | result_handle: ObjectHandle | None = None |
| #198 | if args.result_oid is not None: |
| #199 | result_handle = runtime.capability.handle_for_object( |
| #200 | args.pid, |
| #201 | args.result_oid, |
| #202 | {"read", "materialize", "link", "diff"}, |
| #203 | issued_by="cli.exit", |
| #204 | ) |
| #205 | elif args.payload is not None: |
| #206 | result_handle = runtime.memory.create_object( |
| #207 | pid=args.pid, |
| #208 | object_type=ObjectType.SUMMARY, |
| #209 | payload=_parse_json_value(args.payload), |
| #210 | metadata=ObjectMetadata(title="CLI process final result", tags=["final", "cli"]), |
| #211 | ) |
| #212 | runtime.process.exit(args.pid, result=result_handle, failed=args.failed, message=args.message) |
| #213 | process = runtime.process.get(args.pid) |
| #214 | return { |
| #215 | "pid": process.pid, |
| #216 | "status": process.status.value, |
| #217 | "message": args.message, |
| #218 | "result_oid": result_handle.oid if result_handle is not None else None, |
| #219 | } |
| #220 | |
| #221 | |
| #222 | async def _run_message_command( |
| #223 | runtime: Runtime, |
| #224 | args: argparse.Namespace, |
| #225 | *, |
| #226 | fixed_kind: ProcessMessageKind | None = None, |
| #227 | ) -> dict[str, Any]: |
| #228 | kind = fixed_kind or (ProcessMessageKind.INTERRUPT if getattr(args, "interrupt", False) else ProcessMessageKind(args.kind)) |
| #229 | payload = _parse_json_mapping(args.payload_json, "--payload-json") |
| #230 | payload.setdefault("source", "cli.message") |
| #231 | message = runtime.human.send_process_message( |
| #232 | args.pid, |
| #233 | args.body, |
| #234 | kind=kind, |
| #235 | human=args.human, |
| #236 | channel=args.channel, |
| #237 | correlation_id=args.correlation_id, |
| #238 | reply_to=args.reply_to, |
| #239 | subject=args.subject, |
| #240 | payload=payload, |
| #241 | ) |
| #242 | results: list[Any] = [] |
| #243 | if args.run: |
| #244 | results = await runtime.arun_until_idle(max_quanta=args.max_quanta, human=args.human) |
| #245 | return { |
| #246 | "message": _message_cli_summary(message), |
| #247 | "ran": args.run, |
| #248 | "results": results, |
| #249 | } |
| #250 | |
| #251 | |
| #252 | async def _run_interactive_command(runtime: Runtime, args: argparse.Namespace) -> dict[str, Any]: |
| #253 | target_pid = args.pid or _single_active_process_pid(runtime) |
| #254 | if target_pid is None: |
| #255 | raise SystemExit("run --interactive needs --pid when there is not exactly one active process") |
| #256 | _redirect_human_output_to_stderr(runtime) |
| #257 | queue: asyncio.Queue[str | None] = asyncio.Queue() |
| #258 | stop = threading.Event() |
| #259 | _start_interactive_input_thread(asyncio.get_running_loop(), queue, stop) |
| #260 | _print_interactive_help(target_pid) |
| #261 | |
| #262 | results: list[Any] = [] |
| #263 | posted: list[dict[str, Any]] = [] |
| #264 | state = {"pid": target_pid, "shown_request_id": ""} |
| #265 | remaining = int(args.max_quanta) |
| #266 | selected_human = args.human or _RUNTIME_DEFAULTS.default_human |
| #267 | try: |
| #268 | while remaining > 0: |
| #269 | command = _drain_interactive_queue(runtime, queue, state, selected_human, args.message_channel, posted) |
| #270 | if command in {"exit", "eof"}: |
| #271 | break |
| #272 | |
| #273 | batch = await runtime.scheduler.arun_until_idle(runtime.arun_process_once, max_quanta=remaining) |
| #274 | results.extend(batch) |
| #275 | remaining -= len(batch) |
| #276 | |
| #277 | processed = _process_interactive_terminal_outputs(runtime, selected_human) |
| #278 | if processed: |
| #279 | runtime.audit.record( |
| #280 | actor="runtime", |
| #281 | action="runtime.human_queue_drained", |
| #282 | target=f"human:{selected_human}", |
| #283 | decision={"request_ids": [request.request_id for request in processed]}, |
| #284 | ) |
| #285 | |
| #286 | command = _drain_interactive_queue(runtime, queue, state, selected_human, args.message_channel, posted) |
| #287 | if command in {"exit", "eof"}: |
| #288 | break |
| #289 | |
| #290 | target = runtime.process.get(state["pid"]) |
| #291 | if target.status in _TERMINAL_PROCESS_STATUSES: |
| #292 | break |
| #293 | _show_pending_interactive_human_request(runtime, selected_human, state) |
| #294 | if batch or processed: |
| #295 | await asyncio.sleep(0) |
| #296 | continue |
| #297 | try: |
| #298 | line = await asyncio.wait_for(queue.get(), timeout=runtime.scheduler.poll_interval_s) |
| #299 | except asyncio.TimeoutError: |
| #300 | continue |
| #301 | command = _handle_interactive_line(runtime, line, state, selected_human, args.message_channel, posted) |
| #302 | if command in {"exit", "eof"}: |
| #303 | break |
| #304 | finally: |
| #305 | stop.set() |
| #306 | return { |
| #307 | "interactive": True, |
| #308 | "target_pid": state["pid"], |
| #309 | "posted_messages": posted, |
| #310 | "results": results, |
| #311 | "remaining_quanta": remaining, |
| #312 | "process": _process_cli_summary(runtime.process.get(state["pid"])), |
| #313 | } |
| #314 | |
| #315 | |
| #316 | def _load_cli_image_from_yaml(runtime: Runtime, value: str, *, replace: bool) -> dict[str, Any]: |
| #317 | path = Path(value).expanduser() |
| #318 | if not path.is_absolute(): |
| #319 | path = Path.cwd() / path |
| #320 | path = path.resolve() |
| #321 | if not path.exists(): |
| #322 | raise SystemExit(f"image YAML does not exist: {path}") |
| #323 | if not path.is_file(): |
| #324 | raise SystemExit(f"image YAML is not a file: {path}") |
| #325 | result = runtime.image_registry.register_from_yaml_text( |
| #326 | path.read_text(encoding="utf-8"), |
| #327 | actor="cli", |
| #328 | replace=replace, |
| #329 | require_capability=False, |
| #330 | source=str(path), |
| #331 | ) |
| #332 | return { |
| #333 | "image_id": result.image.image_id, |
| #334 | "name": result.image.name, |
| #335 | "version": result.image.version, |
| #336 | "replaced": result.replaced, |
| #337 | "source": result.source, |
| #338 | } |
| #339 | |
| #340 | |
| #341 | def _is_yaml_image_arg(value: str) -> bool: |
| #342 | return Path(value).suffix.lower() in {".yaml", ".yml"} |
| #343 | |
| #344 | |
| #345 | def _parse_json_mapping(value: str, label: str) -> dict[str, Any]: |
| #346 | try: |
| #347 | decoded = json.loads(value) |
| #348 | except json.JSONDecodeError as exc: |
| #349 | raise SystemExit(f"{label} must be valid JSON") from exc |
| #350 | if not isinstance(decoded, dict): |
| #351 | raise SystemExit(f"{label} must be a JSON object") |
| #352 | return decoded |
| #353 | |
| #354 | |
| #355 | def _parse_json_value(value: str) -> Any: |
| #356 | try: |
| #357 | return json.loads(value) |
| #358 | except json.JSONDecodeError: |
| #359 | return {"content": value} |
| #360 | |
| #361 | |
| #362 | def _add_message_parser_args(parser: argparse.ArgumentParser, *, include_kind: bool = True) -> None: |
| #363 | parser.add_argument("pid", help="Target process id.") |
| #364 | parser.add_argument("body", help="Message body. Quote it to include spaces.") |
| #365 | if include_kind: |
| #366 | parser.add_argument("--kind", choices=[kind.value for kind in ProcessMessageKind], default=ProcessMessageKind.NORMAL.value) |
| #367 | parser.add_argument("--interrupt", action="store_true", help="Shortcut for --kind interrupt.") |
| #368 | parser.add_argument("--human", default=_RUNTIME_DEFAULTS.default_human, help="Human actor name.") |
| #369 | parser.add_argument("--channel", default="human", help="Process-message channel.") |
| #370 | parser.add_argument("--subject", help="Short message subject.") |
| #371 | parser.add_argument("--correlation-id", help="Optional conversation/request correlation id.") |
| #372 | parser.add_argument("--reply-to", help="Optional message id this message replies to.") |
| #373 | parser.add_argument("--payload-json", default="{}", help="Structured JSON object to include in the message payload.") |
| #374 | parser.add_argument("--run", action="store_true", help="Run the scheduler after posting the message.") |
| #375 | parser.add_argument("--max-quanta", type=int, default=_RUNTIME_DEFAULTS.run_until_idle_max_quanta) |
| #376 | |
| #377 | |
| #378 | def _message_cli_summary(message: ProcessMessage) -> dict[str, Any]: |
| #379 | return { |
| #380 | "message_id": message.message_id, |
| #381 | "sender": message.sender, |
| #382 | "recipient_pid": message.recipient_pid, |
| #383 | "kind": message.kind.value, |
| #384 | "channel": message.channel, |
| #385 | "correlation_id": message.correlation_id, |
| #386 | "reply_to": message.reply_to, |
| #387 | "subject": message.subject, |
| #388 | "body": message.body, |
| #389 | "payload": message.payload, |
| #390 | "status": message.status.value, |
| #391 | "created_at": message.created_at, |
| #392 | } |
| #393 | |
| #394 | |
| #395 | def _single_active_process_pid(runtime: Runtime) -> str | None: |
| #396 | active = [process.pid for process in runtime.process.list() if process.status not in _TERMINAL_PROCESS_STATUSES] |
| #397 | return active[0] if len(active) == 1 else None |
| #398 | |
| #399 | |
| #400 | def _redirect_human_output_to_stderr(runtime: Runtime) -> None: |
| #401 | provider = runtime.substrate.human |
| #402 | if hasattr(provider, "output_sink"): |
| #403 | provider.output_sink = lambda message: print(message, file=sys.stderr, flush=True) |
| #404 | |
| #405 | |
| #406 | def _start_interactive_input_thread( |
| #407 | loop: asyncio.AbstractEventLoop, |
| #408 | queue: asyncio.Queue[str | None], |
| #409 | stop: threading.Event, |
| #410 | ) -> None: |
| #411 | def worker() -> None: |
| #412 | while not stop.is_set(): |
| #413 | print("agent-libos> ", end="", file=sys.stderr, flush=True) |
| #414 | line = sys.stdin.readline() |
| #415 | if line == "": |
| #416 | _enqueue_interactive_line(loop, queue, None) |
| #417 | return |
| #418 | _enqueue_interactive_line(loop, queue, line.rstrip("\r\n")) |
| #419 | |
| #420 | threading.Thread(target=worker, name="agent-libos-cli-input", daemon=True).start() |
| #421 | |
| #422 | |
| #423 | def _enqueue_interactive_line( |
| #424 | loop: asyncio.AbstractEventLoop, |
| #425 | queue: asyncio.Queue[str | None], |
| #426 | line: str | None, |
| #427 | ) -> None: |
| #428 | try: |
| #429 | loop.call_soon_threadsafe(queue.put_nowait, line) |
| #430 | except RuntimeError: |
| #431 | pass |
| #432 | |
| #433 | |
| #434 | def _print_interactive_help(target_pid: str) -> None: |
| #435 | print( |
| #436 | ( |
| #437 | f"Interactive human input target: {target_pid}\n" |
| #438 | "Plain text sends a normal message. Commands: /interrupt <text>, /message <text>, " |
| #439 | "/pid <pid>, /help, /exit" |
| #440 | ), |
| #441 | file=sys.stderr, |
| #442 | flush=True, |
| #443 | ) |
| #444 | |
| #445 | |
| #446 | def _drain_interactive_queue( |
| #447 | runtime: Runtime, |
| #448 | queue: asyncio.Queue[str | None], |
| #449 | state: dict[str, str], |
| #450 | human: str, |
| #451 | channel: str, |
| #452 | posted: list[dict[str, Any]], |
| #453 | ) -> str | None: |
| #454 | command: str | None = None |
| #455 | while True: |
| #456 | try: |
| #457 | line = queue.get_nowait() |
| #458 | except asyncio.QueueEmpty: |
| #459 | return command |
| #460 | command = _handle_interactive_line(runtime, line, state, human, channel, posted) |
| #461 | if command in {"exit", "eof"}: |
| #462 | return command |
| #463 | |
| #464 | |
| #465 | def _handle_interactive_line( |
| #466 | runtime: Runtime, |
| #467 | line: str | None, |
| #468 | state: dict[str, str], |
| #469 | human: str, |
| #470 | channel: str, |
| #471 | posted: list[dict[str, Any]], |
| #472 | ) -> str | None: |
| #473 | if line is None: |
| #474 | return "eof" |
| #475 | if _handle_interactive_human_response(runtime, line, human): |
| #476 | return None |
| #477 | parsed = _parse_interactive_line(line) |
| #478 | command = parsed.get("command") |
| #479 | if command is None: |
| #480 | return None |
| #481 | if command == "exit": |
| #482 | return "exit" |
| #483 | if command == "help": |
| #484 | _print_interactive_help(state["pid"]) |
| #485 | return None |
| #486 | if command == "pid": |
| #487 | pid = str(parsed["pid"]) |
| #488 | runtime.process.get(pid) |
| #489 | state["pid"] = pid |
| #490 | print(f"Target process: {pid}", file=sys.stderr, flush=True) |
| #491 | return None |
| #492 | if command == "message": |
| #493 | kind = ProcessMessageKind(str(parsed["kind"])) |
| #494 | message = runtime.human.send_process_message( |
| #495 | state["pid"], |
| #496 | str(parsed["body"]), |
| #497 | kind=kind, |
| #498 | human=human, |
| #499 | channel=channel, |
| #500 | payload={"source": "cli.interactive"}, |
| #501 | ) |
| #502 | summary = _message_cli_summary(message) |
| #503 | posted.append(summary) |
| #504 | print(f"Sent {message.kind.value} message {message.message_id} -> {message.recipient_pid}", file=sys.stderr, flush=True) |
| #505 | return None |
| #506 | return None |
| #507 | |
| #508 | |
| #509 | def _parse_interactive_line(line: str) -> dict[str, Any]: |
| #510 | stripped = line.strip() |
| #511 | if not stripped: |
| #512 | return {} |
| #513 | if not stripped.startswith("/"): |
| #514 | return {"command": "message", "kind": ProcessMessageKind.NORMAL.value, "body": stripped} |
| #515 | command, _, rest = stripped[1:].partition(" ") |
| #516 | command = command.lower() |
| #517 | body = rest.strip() |
| #518 | if command in {"exit", "quit", "q"}: |
| #519 | return {"command": "exit"} |
| #520 | if command in {"help", "h", "?"}: |
| #521 | return {"command": "help"} |
| #522 | if command in {"pid", "target"}: |
| #523 | if not body: |
| #524 | raise SystemExit("/pid requires a process id") |
| #525 | return {"command": "pid", "pid": body} |
| #526 | if command in {"interrupt", "i"}: |
| #527 | return { |
| #528 | "command": "message", |
| #529 | "kind": ProcessMessageKind.INTERRUPT.value, |
| #530 | "body": body or "Human requested attention.", |
| #531 | } |
| #532 | if command in {"message", "m"}: |
| #533 | return {"command": "message", "kind": ProcessMessageKind.NORMAL.value, "body": body} |
| #534 | print(f"Unknown interactive command: /{command}. Type /help for commands.", file=sys.stderr, flush=True) |
| #535 | return {} |
| #536 | |
| #537 | |
| #538 | def _process_interactive_terminal_outputs(runtime: Runtime, human: str) -> list[Any]: |
| #539 | processed: list[Any] = [] |
| #540 | while True: |
| #541 | pending = runtime.human.pending(human=human) |
| #542 | if not pending or pending[0].payload.get("type") != "output": |
| #543 | return processed |
| #544 | processed.append(runtime.human.process_next_terminal(human=human)) |
| #545 | |
| #546 | |
| #547 | def _show_pending_interactive_human_request(runtime: Runtime, human: str, state: dict[str, str]) -> None: |
| #548 | request = _first_interactive_input_request(runtime, human) |
| #549 | if request is None: |
| #550 | state["shown_request_id"] = "" |
| #551 | return |
| #552 | if state.get("shown_request_id") == request.request_id: |
| #553 | return |
| #554 | state["shown_request_id"] = request.request_id |
| #555 | question = str(request.payload.get("question") or request.payload) |
| #556 | request_type = str(request.payload.get("type") or "approval") |
| #557 | if request_type == "permission_request": |
| #558 | suffix = "Reply a=always allow, d=always deny, e=ask each time." |
| #559 | elif request_type == "question": |
| #560 | suffix = "Reply with the answer text." |
| #561 | else: |
| #562 | suffix = "Reply y/yes to approve, n/no to reject." |
| #563 | print(f"\nHuman request {request.request_id}: {question}\n{suffix}", file=sys.stderr, flush=True) |
| #564 | |
| #565 | |
| #566 | def _handle_interactive_human_response(runtime: Runtime, line: str, human: str) -> bool: |
| #567 | stripped = line.strip() |
| #568 | if not stripped or stripped.startswith(("/message", "/m", "/interrupt", "/i", "/pid", "/target", "/help", "/exit", "/quit")): |
| #569 | return False |
| #570 | request = _first_interactive_input_request(runtime, human) |
| #571 | if request is None: |
| #572 | return False |
| #573 | response = _interactive_response_text(stripped) |
| #574 | if response is None: |
| #575 | return False |
| #576 | request_type = request.payload.get("type") |
| #577 | if request_type == "question": |
| #578 | runtime.human.approve( |
| #579 | request.request_id, |
| #580 | {"approved": True, "answer": response, "source": "interactive_cli"}, |
| #581 | responder=f"human:{human}", |
| #582 | ) |
| #583 | print(f"Answered human request {request.request_id}", file=sys.stderr, flush=True) |
| #584 | return True |
| #585 | if request_type == "permission_request": |
| #586 | policy = _interactive_permission_policy(response) |
| #587 | decision = {"policy": policy, "source": "interactive_cli"} |
| #588 | if policy == CapabilityManager.ALWAYS_DENY: |
| #589 | runtime.human.reject(request.request_id, {"approved": False, **decision}, responder=f"human:{human}") |
| #590 | else: |
| #591 | runtime.human.approve(request.request_id, {"approved": True, **decision}, responder=f"human:{human}") |
| #592 | print(f"Resolved permission request {request.request_id} with policy={policy}", file=sys.stderr, flush=True) |
| #593 | return True |
| #594 | approved = response.lower() in {"y", "yes", "approve", "approved", "a", "allow"} |
| #595 | if approved: |
| #596 | runtime.human.approve( |
| #597 | request.request_id, |
| #598 | {"approved": True, "source": "interactive_cli"}, |
| #599 | responder=f"human:{human}", |
| #600 | ) |
| #601 | else: |
| #602 | runtime.human.reject( |
| #603 | request.request_id, |
| #604 | {"approved": False, "source": "interactive_cli"}, |
| #605 | responder=f"human:{human}", |
| #606 | ) |
| #607 | print(f"{'Approved' if approved else 'Rejected'} human request {request.request_id}", file=sys.stderr, flush=True) |
| #608 | return True |
| #609 | |
| #610 | |
| #611 | def _interactive_response_text(stripped: str) -> str | None: |
| #612 | if not stripped.startswith("/"): |
| #613 | return stripped |
| #614 | command, _, rest = stripped[1:].partition(" ") |
| #615 | command = command.lower() |
| #616 | if command in {"answer", "reply"}: |
| #617 | return rest |
| #618 | if command in {"approve", "yes", "y"}: |
| #619 | return "yes" |
| #620 | if command in {"reject", "deny", "no", "n"}: |
| #621 | return "no" |
| #622 | if command in {"allow", "always-allow", "always_allow"}: |
| #623 | return "always_allow" |
| #624 | if command in {"ask", "ask-each-time", "ask_each_time"}: |
| #625 | return "ask_each_time" |
| #626 | return None |
| #627 | |
| #628 | |
| #629 | def _interactive_permission_policy(answer: str) -> str: |
| #630 | normalized = answer.strip().lower() |
| #631 | return { |
| #632 | "a": CapabilityManager.ALWAYS_ALLOW, |
| #633 | "allow": CapabilityManager.ALWAYS_ALLOW, |
| #634 | "always_allow": CapabilityManager.ALWAYS_ALLOW, |
| #635 | "yes": CapabilityManager.ALWAYS_ALLOW, |
| #636 | "y": CapabilityManager.ALWAYS_ALLOW, |
| #637 | "e": CapabilityManager.ASK_EACH_TIME, |
| #638 | "ask": CapabilityManager.ASK_EACH_TIME, |
| #639 | "each": CapabilityManager.ASK_EACH_TIME, |
| #640 | "ask_each_time": CapabilityManager.ASK_EACH_TIME, |
| #641 | "d": CapabilityManager.ALWAYS_DENY, |
| #642 | "deny": CapabilityManager.ALWAYS_DENY, |
| #643 | "always_deny": CapabilityManager.ALWAYS_DENY, |
| #644 | "no": CapabilityManager.ALWAYS_DENY, |
| #645 | "n": CapabilityManager.ALWAYS_DENY, |
| #646 | }.get(normalized, CapabilityManager.ALWAYS_DENY) |
| #647 | |
| #648 | |
| #649 | def _first_interactive_input_request(runtime: Runtime, human: str) -> Any | None: |
| #650 | for request in runtime.human.pending(human=human): |
| #651 | if request.payload.get("type") != "output": |
| #652 | return request |
| #653 | return None |
| #654 | |
| #655 | |
| #656 | def _process_cli_summary(process: Any) -> dict[str, Any]: |
| #657 | return { |
| #658 | "pid": process.pid, |
| #659 | "image": process.image_id, |
| #660 | "status": process.status.value, |
| #661 | "goal_oid": process.goal_oid, |
| #662 | "working_directory": process.working_directory, |
| #663 | "active_tools": sorted(process.tool_table), |
| #664 | } |
| #665 | |
| #666 | |
| #667 | def run_demo(runtime: Runtime) -> dict[str, Any]: |
| #668 | tool_sequence: list[dict[str, Any]] = [] |
| #669 | root = runtime.process.spawn( |
| #670 | image=_RUNTIME_DEFAULTS.coding_image_id, |
| #671 | goal={"text": "Fix failing tests in this repository"}, |
| #672 | ) |
| #673 | log = """ |
| #674 | =========================== FAILURES =========================== |
| #675 | FAILED tests/test_math.py::test_add - AssertionError: assert 5 == 4 |
| #676 | E AssertionError: assert 5 == 4 |
| #677 | ==================== 1 failed, 3 passed in 0.12s ==================== |
| #678 | """.strip() |
| #679 | log_handle = runtime.memory.create_object( |
| #680 | pid=root, |
| #681 | object_type=ObjectType.ERROR_TRACE, |
| #682 | payload={"log": log}, |
| #683 | metadata=ObjectMetadata(title="pytest failure log", tags=["pytest", "failure"]), |
| #684 | ) |
| #685 | root_proc = runtime.process.get(root) |
| #686 | assert root_proc.memory_view is not None |
| #687 | root_proc.memory_view.roots.append(log_handle) |
| #688 | runtime.store.update_process(root_proc) |
| #689 | |
| #690 | worker = runtime.process.fork( |
| #691 | parent=root, |
| #692 | goal={"text": "Analyze the pytest failure log"}, |
| #693 | memory_view=MemoryViewSpec(roots=[log_handle], mode=ViewMode.READ_ONLY), |
| #694 | mode=ForkMode.WORKER, |
| #695 | ) |
| #696 | parse_tool = runtime.tools.resolve("parse_pytest_log") |
| #697 | parsed = runtime.tools.call(worker, parse_tool, {"log": log}) |
| #698 | tool_sequence.append(_tool_call_summary("parse_pytest_log", worker, parsed)) |
| #699 | runtime.process.exit(worker, parsed.result_handle) |
| #700 | worker_result = runtime.process.wait(root, worker) |
| #701 | if worker_result.result is not None: |
| #702 | root_proc = runtime.process.get(root) |
| #703 | if root_proc.memory_view is not None: |
| #704 | root_proc.memory_view.roots.append(worker_result.result) |
| #705 | runtime.store.update_process(root_proc) |
| #706 | |
| #707 | jit_source = """ |
| #708 | export function run(args, libos) { |
| #709 | const log = String(args.log ?? ""); |
| #710 | const names = []; |
| #711 | for (const rawLine of log.split("\\n")) { |
| #712 | const line = rawLine.trim(); |
| #713 | if (line.startsWith("FAILED ")) { |
| #714 | names.push(line.split(/\\s+/)[1]); |
| #715 | } |
| #716 | } |
| #717 | return { tests: names, count: names.length }; |
| #718 | } |
| #719 | """.strip() |
| #720 | candidate = runtime.tools.propose( |
| #721 | root, |
| #722 | { |
| #723 | "name": "extract_failed_tests", |
| #724 | "description": "Extract failed pytest node ids.", |
| #725 | "input_schema": {"type": "object", "properties": {"log": {"type": "string"}}}, |
| #726 | "output_schema": {"type": "object"}, |
| #727 | }, |
| #728 | source_code=jit_source, |
| #729 | tests=[{"args": {"log": log}, "expected": {"tests": ["tests/test_math.py::test_add"], "count": 1}}], |
| #730 | ) |
| #731 | validation = runtime.tools.validate(candidate) |
| #732 | jit_tool = runtime.tools.register(root, candidate) if validation.ok else None |
| #733 | jit_result = runtime.tools.call(root, jit_tool, {"log": log}) if jit_tool is not None else None |
| #734 | if jit_result is not None: |
| #735 | tool_sequence.append(_tool_call_summary("extract_failed_tests", root, jit_result)) |
| #736 | |
| #737 | checkpoint = runtime.checkpoint.checkpoint(root, "before high-risk patch application") |
| #738 | write_args = { |
| #739 | "path": DEMO_PATCH_PREVIEW_PATH, |
| #740 | "content": DEMO_PATCH_PREVIEW_CONTENT, |
| #741 | "overwrite": True, |
| #742 | } |
| #743 | denied_without_filesystem = runtime.tools.call(root, "write_text_file", write_args) |
| #744 | tool_sequence.append(_tool_call_summary("write_text_file", root, denied_without_filesystem)) |
| #745 | if denied_without_filesystem.ok: |
| #746 | raise RuntimeError("demo expected write_text_file to fail before filesystem write capability was granted") |
| #747 | |
| #748 | filesystem_resource = runtime.filesystem.resource_for(DEMO_PATCH_PREVIEW_PATH) |
| #749 | approval_request = runtime.human.query( |
| #750 | pid=root, |
| #751 | human=_RUNTIME_DEFAULTS.default_human, |
| #752 | request={ |
| #753 | "type": "approval", |
| #754 | "question": f"Grant workspace write capability for {DEMO_PATCH_PREVIEW_PATH}?", |
| #755 | "requested_capability": { |
| #756 | "subject": root, |
| #757 | "resource": filesystem_resource, |
| #758 | "rights": [CapabilityRight.WRITE.value], |
| #759 | }, |
| #760 | "context": {"path": DEMO_PATCH_PREVIEW_PATH, "tool": "write_text_file"}, |
| #761 | }, |
| #762 | blocking=True, |
| #763 | ) |
| #764 | runtime.human.approve(approval_request, {"approved": True, "reason": "demo filesystem write approval"}) |
| #765 | approved_call = runtime.tools.call(root, "write_text_file", write_args) |
| #766 | tool_sequence.append(_tool_call_summary("write_text_file", root, approved_call)) |
| #767 | target = runtime.workspace_root / DEMO_PATCH_PREVIEW_PATH |
| #768 | target_exists = target.exists() |
| #769 | target_content_matches = target_exists and target.read_text(encoding="utf-8") == DEMO_PATCH_PREVIEW_CONTENT |
| #770 | if not approved_call.ok or not target_content_matches: |
| #771 | raise RuntimeError("demo write_text_file contract failed") |
| #772 | |
| #773 | audit_records = runtime.audit.trace() |
| #774 | audit_summary = dict(Counter(record.action for record in audit_records)) |
| #775 | report_payload = { |
| #776 | "summary": ( |
| #777 | "Analyzed a failing pytest log, extracted the failed test, checkpointed before the write, " |
| #778 | "verified filesystem denial before external-resource authorization, requested human approval for workspace write, " |
| #779 | "and wrote a patch preview file." |
| #780 | ), |
| #781 | "problem": { |
| #782 | "failed_test": "tests/test_math.py::test_add", |
| #783 | "assertion": "AssertionError: assert 5 == 4", |
| #784 | "source": "synthetic pytest failure log", |
| #785 | }, |
| #786 | "evidence": [ |
| #787 | {"kind": "pytest_log", "oid": log_handle.oid, "title": "pytest failure log"}, |
| #788 | { |
| #789 | "kind": "worker_parse_result", |
| #790 | "oid": worker_result.result.oid if worker_result.result else None, |
| #791 | "payload": parsed.payload, |
| #792 | }, |
| #793 | { |
| #794 | "kind": "jit_extract_result", |
| #795 | "payload": jit_result.payload if jit_result else None, |
| #796 | "validation_ok": validation.ok, |
| #797 | "validation_errors": validation.errors, |
| #798 | }, |
| #799 | {"kind": "filesystem_denial", "payload": denied_without_filesystem.payload, "error": denied_without_filesystem.error}, |
| #800 | ], |
| #801 | "tool_sequence": tool_sequence, |
| #802 | "authorization": { |
| #803 | "filesystem_write_approval_request": approval_request, |
| #804 | "filesystem_write_resource": filesystem_resource, |
| #805 | "filesystem_write_granted_by": _RUNTIME_DEFAULTS.default_human_actor, |
| #806 | "filesystem_write_denied_before_grant": { |
| #807 | "ok": denied_without_filesystem.ok, |
| #808 | "error": denied_without_filesystem.error, |
| #809 | }, |
| #810 | }, |
| #811 | "external_side_effects": [ |
| #812 | { |
| #813 | "adapter": "filesystem", |
| #814 | "action": "write_text", |
| #815 | "path": DEMO_PATCH_PREVIEW_PATH, |
| #816 | "bytes_written": approved_call.payload.get("bytes_written") if isinstance(approved_call.payload, dict) else None, |
| #817 | "audit_action": "primitive.filesystem.write_text", |
| #818 | } |
| #819 | ], |
| #820 | "checkpoint": checkpoint, |
| #821 | "write_result": _tool_call_summary("write_text_file", root, approved_call), |
| #822 | "target_file": { |
| #823 | "path": DEMO_PATCH_PREVIEW_PATH, |
| #824 | "exists": target_exists, |
| #825 | "content_matches": target_content_matches, |
| #826 | }, |
| #827 | "audit_summary": audit_summary, |
| #828 | "limits": "This demo writes a patch preview only; it is not a production automatic repair system.", |
| #829 | "next_steps": [ |
| #830 | "Review the patch preview.", |
| #831 | "Add real repository tests for the suspected math assertion.", |
| #832 | "Implement policy decisions for side-effect tools before expanding external adapters.", |
| #833 | ], |
| #834 | } |
| #835 | report_handle = runtime.memory.create_object( |
| #836 | pid=root, |
| #837 | object_type=ObjectType.SUMMARY, |
| #838 | payload=report_payload, |
| #839 | metadata=ObjectMetadata(title="coding-agent demo final report", tags=["demo", "report"]), |
| #840 | ) |
| #841 | runtime.process.exit(root, report_handle) |
| #842 | final_audit_records = runtime.audit.trace() |
| #843 | final_audit_summary = dict(Counter(record.action for record in final_audit_records)) |
| #844 | return { |
| #845 | "root": root, |
| #846 | "worker": worker, |
| #847 | "worker_result_oid": worker_result.result.oid if worker_result.result else None, |
| #848 | "jit_candidate": candidate, |
| #849 | "jit_validation_ok": validation.ok, |
| #850 | "jit_validation_errors": validation.errors, |
| #851 | "jit_result": jit_result.payload if jit_result else None, |
| #852 | "approval_request": approval_request, |
| #853 | "filesystem_write_denial": _tool_call_summary("write_text_file", root, denied_without_filesystem), |
| #854 | "checkpoint": checkpoint, |
| #855 | "tool_sequence": tool_sequence, |
| #856 | "write_result": _tool_call_summary("write_text_file", root, approved_call), |
| #857 | "write_path": DEMO_PATCH_PREVIEW_PATH, |
| #858 | "target_file_exists": target_exists, |
| #859 | "target_file_content_matches": target_content_matches, |
| #860 | "final_report_oid": report_handle.oid, |
| #861 | "final_report": report_payload, |
| #862 | "audit_records": len(final_audit_records), |
| #863 | "audit_summary": final_audit_summary, |
| #864 | } |
| #865 | |
| #866 | |
| #867 | def _tool_call_summary(tool: str, pid: str, result: ToolCallResult) -> dict[str, Any]: |
| #868 | return { |
| #869 | "pid": pid, |
| #870 | "tool": tool, |
| #871 | "ok": result.ok, |
| #872 | "tool_id": result.tool_id, |
| #873 | "call_id": result.call_id, |
| #874 | "result_oid": result.result_handle.oid if result.result_handle else None, |
| #875 | "payload": result.payload, |
| #876 | "error": result.error, |
| #877 | } |
| #878 | |
| #879 | |
| #880 | def _print_json(value: Any) -> None: |
| #881 | print(json.dumps(value, indent=2, ensure_ascii=False, default=str)) |
| #882 | |
| #883 | |
| #884 | if __name__ == "__main__": |
| #885 | main() |
| #886 |