mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
252 lines
8.6 KiB
Python
252 lines
8.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Productized CLI for Hermes Codex Bridge."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
if str(REPO_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(REPO_ROOT))
|
|
|
|
try:
|
|
from .validator import (
|
|
SMOKE_SENTINEL,
|
|
TERMINAL_STATUSES,
|
|
ValidationError,
|
|
parse_json_object,
|
|
validate_approval_policy,
|
|
validate_bridge_output,
|
|
validate_interrupt_input,
|
|
validate_respond_input,
|
|
validate_sandbox,
|
|
validate_smoke_test_result,
|
|
validate_start_input,
|
|
validate_status_input,
|
|
validate_steer_input,
|
|
validate_notify_completed_output,
|
|
validate_notify_target,
|
|
)
|
|
except ImportError:
|
|
from validator import ( # type: ignore
|
|
SMOKE_SENTINEL,
|
|
TERMINAL_STATUSES,
|
|
ValidationError,
|
|
parse_json_object,
|
|
validate_approval_policy,
|
|
validate_bridge_output,
|
|
validate_interrupt_input,
|
|
validate_respond_input,
|
|
validate_sandbox,
|
|
validate_smoke_test_result,
|
|
validate_start_input,
|
|
validate_status_input,
|
|
validate_steer_input,
|
|
validate_notify_completed_output,
|
|
validate_notify_target,
|
|
)
|
|
|
|
from tools.codex_bridge_tool import DEFAULT_APPROVAL_POLICY, DEFAULT_SANDBOX, codex_bridge
|
|
|
|
|
|
def emit(data: dict[str, Any]) -> None:
|
|
print(json.dumps(data, ensure_ascii=False, sort_keys=True))
|
|
|
|
|
|
def call_bridge(action: str, **kwargs: Any) -> dict[str, Any]:
|
|
raw = codex_bridge(action=action, **kwargs)
|
|
try:
|
|
data = json.loads(raw)
|
|
except json.JSONDecodeError as exc:
|
|
raise ValidationError(f"codex_bridge returned invalid JSON for {action}: {exc.msg}") from exc
|
|
validate_bridge_output(action, data)
|
|
return data
|
|
|
|
|
|
def _prompt_from_args(args: argparse.Namespace) -> str:
|
|
prompt = args.prompt
|
|
if prompt is None and args.prompt_text:
|
|
prompt = " ".join(args.prompt_text)
|
|
return prompt or ""
|
|
|
|
|
|
def cmd_start(args: argparse.Namespace) -> dict[str, Any]:
|
|
prompt = _prompt_from_args(args)
|
|
validate_start_input(prompt, args.cwd, args.sandbox, args.approval_policy)
|
|
notify_target = validate_notify_target(args.notify_target)
|
|
return call_bridge(
|
|
"start",
|
|
prompt=prompt,
|
|
cwd=args.cwd,
|
|
model=args.model,
|
|
sandbox=args.sandbox,
|
|
approval_policy=args.approval_policy,
|
|
codex_home=args.codex_home,
|
|
notify_target=notify_target,
|
|
)
|
|
|
|
|
|
def cmd_status(args: argparse.Namespace) -> dict[str, Any]:
|
|
validate_status_input(args.task_id)
|
|
return call_bridge("status", task_id=args.task_id)
|
|
|
|
|
|
def cmd_list(args: argparse.Namespace) -> dict[str, Any]:
|
|
return call_bridge("list", limit=args.limit)
|
|
|
|
|
|
def cmd_notify_completed(args: argparse.Namespace) -> dict[str, Any]:
|
|
data = call_bridge("notify_completed", limit=args.limit, dry_run=args.dry_run)
|
|
validate_notify_completed_output(data)
|
|
return data
|
|
|
|
|
|
def cmd_steer(args: argparse.Namespace) -> dict[str, Any]:
|
|
validate_steer_input(args.task_id, args.instruction)
|
|
return call_bridge("steer", task_id=args.task_id, instruction=args.instruction)
|
|
|
|
|
|
def cmd_interrupt(args: argparse.Namespace) -> dict[str, Any]:
|
|
validate_interrupt_input(args.task_id)
|
|
return call_bridge("interrupt", task_id=args.task_id)
|
|
|
|
|
|
def cmd_respond(args: argparse.Namespace) -> dict[str, Any]:
|
|
answers = parse_json_object(args.answers, field_name="answers")
|
|
validate_respond_input(args.task_id, args.request_id, args.decision, answers)
|
|
return call_bridge(
|
|
"respond",
|
|
task_id=args.task_id,
|
|
instruction=args.request_id,
|
|
decision=args.decision,
|
|
answers=answers,
|
|
)
|
|
|
|
|
|
def _smoke_prompt(wait_seconds: int) -> str:
|
|
return (
|
|
f"Wait {wait_seconds} seconds asynchronously, then reply exactly {SMOKE_SENTINEL}. "
|
|
"Do not modify files."
|
|
)
|
|
|
|
|
|
def cmd_smoke_test(args: argparse.Namespace) -> dict[str, Any]:
|
|
validate_start_input(_smoke_prompt(args.wait), args.cwd, args.sandbox, args.approval_policy)
|
|
notify_target = validate_notify_target(args.notify_target)
|
|
started = call_bridge(
|
|
"start",
|
|
prompt=_smoke_prompt(args.wait),
|
|
cwd=args.cwd,
|
|
model=args.model,
|
|
sandbox=args.sandbox,
|
|
approval_policy=args.approval_policy,
|
|
codex_home=args.codex_home,
|
|
notify_target=notify_target,
|
|
)
|
|
task_id = started["task"]["hermes_task_id"]
|
|
deadline = time.monotonic() + args.timeout
|
|
last_status: dict[str, Any] | None = None
|
|
while time.monotonic() < deadline:
|
|
time.sleep(args.poll_interval)
|
|
last_status = call_bridge("status", task_id=task_id)
|
|
task = last_status.get("task") or {}
|
|
if task.get("status") in TERMINAL_STATUSES:
|
|
validate_smoke_test_result(last_status)
|
|
return {
|
|
"success": True,
|
|
"task_id": task_id,
|
|
"status": task.get("status"),
|
|
"start": started,
|
|
"final_status": last_status,
|
|
}
|
|
return {
|
|
"success": False,
|
|
"error": f"smoke-test timed out after {args.timeout} seconds.",
|
|
"task_id": task_id,
|
|
"start": started,
|
|
"last_status": last_status,
|
|
}
|
|
|
|
|
|
def add_common_start_options(parser: argparse.ArgumentParser) -> None:
|
|
parser.add_argument("--cwd", default=str(Path.cwd()), help="Working directory for Codex.")
|
|
parser.add_argument("--model", default=None, help="Optional Codex model override.")
|
|
parser.add_argument("--sandbox", default=DEFAULT_SANDBOX, type=validate_sandbox)
|
|
parser.add_argument("--approval-policy", default=DEFAULT_APPROVAL_POLICY, type=validate_approval_policy)
|
|
parser.add_argument("--codex-home", default=None, help="Optional CODEX_HOME override.")
|
|
parser.add_argument(
|
|
"--notify-target",
|
|
default=None,
|
|
help="Optional completion notification target, e.g. local or feishu:<chat_id>.",
|
|
)
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(description="Hermes Codex Bridge skill CLI")
|
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
|
|
start = subparsers.add_parser("start", help="Start a Codex task.")
|
|
start.add_argument("--prompt", help="Task prompt.")
|
|
start.add_argument("prompt_text", nargs="*", help="Task prompt as positional text.")
|
|
add_common_start_options(start)
|
|
start.set_defaults(func=cmd_start)
|
|
|
|
status = subparsers.add_parser("status", help="Show task status.")
|
|
status.add_argument("task_id")
|
|
status.set_defaults(func=cmd_status)
|
|
|
|
list_parser = subparsers.add_parser("list", help="List recent Codex Bridge tasks.")
|
|
list_parser.add_argument("--limit", type=int, default=10)
|
|
list_parser.set_defaults(func=cmd_list)
|
|
|
|
notify = subparsers.add_parser("notify-completed", help="One-shot poll and notify completed tasks.")
|
|
notify.add_argument("--limit", type=int, default=10)
|
|
notify.add_argument("--dry-run", action="store_true", help="Preview notifications without sending or marking.")
|
|
notify.set_defaults(func=cmd_notify_completed)
|
|
|
|
steer = subparsers.add_parser("steer", help="Steer an active Codex turn.")
|
|
steer.add_argument("task_id")
|
|
steer.add_argument("--instruction", required=True)
|
|
steer.set_defaults(func=cmd_steer)
|
|
|
|
interrupt = subparsers.add_parser("interrupt", help="Interrupt an active Codex turn.")
|
|
interrupt.add_argument("task_id")
|
|
interrupt.set_defaults(func=cmd_interrupt)
|
|
|
|
respond = subparsers.add_parser("respond", help="Respond to a pending Codex request.")
|
|
respond.add_argument("task_id")
|
|
respond.add_argument("--request-id", required=True)
|
|
respond.add_argument("--decision", default="decline")
|
|
respond.add_argument("--answers", default=None, help="JSON object for user-input answers.")
|
|
respond.set_defaults(func=cmd_respond)
|
|
|
|
smoke = subparsers.add_parser("smoke-test", help="Run an async Codex Bridge smoke test.")
|
|
smoke.add_argument("--wait", type=int, default=10)
|
|
smoke.add_argument("--timeout", type=int, default=60)
|
|
smoke.add_argument("--poll-interval", type=float, default=2.0)
|
|
add_common_start_options(smoke)
|
|
smoke.set_defaults(func=cmd_smoke_test)
|
|
|
|
return parser
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = build_parser()
|
|
try:
|
|
args = parser.parse_args(argv)
|
|
result = args.func(args)
|
|
emit(result)
|
|
return 0 if result.get("success") is True else 1
|
|
except ValidationError as exc:
|
|
emit({"success": False, "error": str(exc)})
|
|
return 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|