mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-26 06:01:49 +00:00
feat(codex-bridge): notify origin on async completion
This commit is contained in:
parent
f27b32d6b0
commit
da25c6e163
8 changed files with 614 additions and 7 deletions
|
|
@ -30,6 +30,8 @@ try:
|
|||
validate_start_input,
|
||||
validate_status_input,
|
||||
validate_steer_input,
|
||||
validate_notify_completed_output,
|
||||
validate_notify_target,
|
||||
)
|
||||
except ImportError:
|
||||
from validator import ( # type: ignore
|
||||
|
|
@ -46,6 +48,8 @@ except ImportError:
|
|||
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
|
||||
|
|
@ -75,6 +79,7 @@ def _prompt_from_args(args: argparse.Namespace) -> str:
|
|||
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,
|
||||
|
|
@ -83,6 +88,7 @@ def cmd_start(args: argparse.Namespace) -> dict[str, Any]:
|
|||
sandbox=args.sandbox,
|
||||
approval_policy=args.approval_policy,
|
||||
codex_home=args.codex_home,
|
||||
notify_target=notify_target,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -95,6 +101,12 @@ 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)
|
||||
|
|
@ -126,6 +138,7 @@ def _smoke_prompt(wait_seconds: int) -> str:
|
|||
|
||||
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),
|
||||
|
|
@ -134,6 +147,7 @@ def cmd_smoke_test(args: argparse.Namespace) -> dict[str, Any]:
|
|||
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
|
||||
|
|
@ -166,6 +180,11 @@ def add_common_start_options(parser: argparse.ArgumentParser) -> None:
|
|||
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:
|
||||
|
|
@ -186,6 +205,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ ALLOWED_SANDBOXES = {"read-only", "workspace-write"}
|
|||
ALLOWED_APPROVAL_POLICIES = {"untrusted", "on-request"}
|
||||
ALLOWED_DECISIONS = {"accept", "acceptForSession", "decline", "cancel"}
|
||||
TERMINAL_STATUSES = {"completed", "failed", "cancelled"}
|
||||
NOTIFICATION_STATUSES = {"sent", "failed", "no_target", "dry_run", "pending"}
|
||||
SMOKE_SENTINEL = "CODEX_ASYNC_OK"
|
||||
|
||||
|
||||
|
|
@ -56,6 +57,15 @@ def validate_start_input(prompt: str, cwd: str, sandbox: str, approval_policy: s
|
|||
validate_approval_policy(approval_policy)
|
||||
|
||||
|
||||
def validate_notify_target(target: str | None) -> str | None:
|
||||
if target is None:
|
||||
return None
|
||||
normalized = target.strip()
|
||||
if not normalized:
|
||||
raise ValidationError("notify_target must be non-empty when provided.")
|
||||
return normalized
|
||||
|
||||
|
||||
def validate_task_id(action: str, task_id: str | None) -> None:
|
||||
if not task_id or not str(task_id).strip():
|
||||
raise ValidationError(f"{action} requires task_id.")
|
||||
|
|
@ -123,10 +133,30 @@ def validate_bridge_output(action: str, data: Mapping[str, Any]) -> None:
|
|||
if action == "start":
|
||||
validate_start_output(data)
|
||||
return
|
||||
if action == "notify_completed":
|
||||
validate_notify_completed_output(data)
|
||||
return
|
||||
if "success" in data and data.get("success") is not True:
|
||||
raise ValidationError(str(data.get("error") or f"{action} failed."))
|
||||
|
||||
|
||||
def validate_notify_completed_output(data: Mapping[str, Any]) -> None:
|
||||
if data.get("success") is not True:
|
||||
raise ValidationError("notify_completed output must have success=true.")
|
||||
notifications = data.get("notifications")
|
||||
if not isinstance(notifications, list):
|
||||
raise ValidationError("notify_completed output must include notifications list.")
|
||||
for item in notifications:
|
||||
if not isinstance(item, Mapping):
|
||||
raise ValidationError("notify_completed notifications must be objects.")
|
||||
if not item.get("task_id"):
|
||||
raise ValidationError("notify_completed notification missing task_id.")
|
||||
status = item.get("notification_status")
|
||||
if status not in NOTIFICATION_STATUSES:
|
||||
allowed = ", ".join(sorted(NOTIFICATION_STATUSES))
|
||||
raise ValidationError(f"notification_status must be one of: {allowed}.")
|
||||
|
||||
|
||||
def contains_text(value: Any, needle: str) -> bool:
|
||||
if isinstance(value, str):
|
||||
return needle in value
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue