"""Command-line entry point for the Open Agent API integration.""" from __future__ import annotations import argparse import json import os import platform import sys import time from typing import Any, Dict, Optional, Sequence import httpx from agent_integration.client import ( OpenAgentAPIClient, OpenAgentAPIError, OpenAgentEvent, OpenAgentProtocolError, OpenAgentTransportError, ) from agent_integration.config import AgentConfig, AgentConfigurationError from agent_integration.events import AgentStreamNormalizer from agent_integration.service import AgentResponseError, OpenAgentService, SessionNotFoundError from agent_integration.sessions import SessionStoreError, SQLiteSessionStore RUN_ACTIVE_STATUSES = {"pending", "running", "cancelling"} NETWORK_COMMANDS = {"ensure-session", "chat", "send", "get-run", "cancel-run"} def parse_metadata(value: str) -> Dict[str, Any]: try: parsed = json.loads(value) except json.JSONDecodeError as exc: raise argparse.ArgumentTypeError(f"metadata must be valid JSON: {exc}") from exc if not isinstance(parsed, dict): raise argparse.ArgumentTypeError("metadata must be a JSON object") return parsed def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Connect this project to the DeerFlow Open Agent API.") parser.add_argument("--base-url", help="Override DEERFLOW_BASE_URL (the API key remains env-only).") parser.add_argument("--auth-mode", choices=["bearer", "x-api-key"]) parser.add_argument("--session-db", help="Override DEERFLOW_SESSION_DB.") parser.add_argument("--timeout", type=float, help="Overall request timeout in seconds.") parser.add_argument("--connect-timeout", type=float, help="Connection timeout in seconds.") subparsers = parser.add_subparsers(dest="command", required=True) subparsers.add_parser("doctor", help="Validate local configuration without making a network call.") ensure_parser = subparsers.add_parser("ensure-session", help="Create or reuse an Agent session.") _add_conversation_args(ensure_parser, include_message=False) chat_parser = subparsers.add_parser("chat", help="Stream one message as SSE-derived output.") _add_conversation_args(chat_parser, include_message=True) chat_parser.add_argument( "--output", choices=["jsonl", "text", "raw-jsonl"], default="jsonl", help=( "jsonl emits filtered public events; text emits visible answer text; " "raw-jsonl exposes unfiltered server internals for diagnostics only." ), ) send_parser = subparsers.add_parser("send", help="Submit a non-streaming run.") _add_conversation_args(send_parser, include_message=True) send_parser.add_argument("--poll", action="store_true") send_parser.add_argument("--poll-interval", type=float, default=2.0) send_parser.add_argument("--poll-timeout", type=float, default=120.0) get_parser = subparsers.add_parser("get-run", help="Fetch a run by local conversation.") get_parser.add_argument("--conversation-id", required=True) get_parser.add_argument("--run-id", required=True) cancel_parser = subparsers.add_parser("cancel-run", help="Cancel a run by local conversation.") cancel_parser.add_argument("--conversation-id", required=True) cancel_parser.add_argument("--run-id", required=True) show_parser = subparsers.add_parser("show-session", help="Show the local session mapping only.") show_parser.add_argument("--conversation-id", required=True) forget_parser = subparsers.add_parser("forget-session", help="Delete a stale local session mapping.") forget_parser.add_argument("--conversation-id", required=True) return parser def _add_conversation_args(parser: argparse.ArgumentParser, *, include_message: bool) -> None: parser.add_argument("--conversation-id", required=True, help="Stable opaque upstream conversation ID.") parser.add_argument("--external-subject-id", help="Stable opaque user/customer ID.") parser.add_argument("--metadata", type=parse_metadata, default={}) if include_message: parser.add_argument("--message", required=True) parser.add_argument( "--message-id", help="Stable upstream message ID; pass the same value when safely retrying.", ) def _config_for_args(args: argparse.Namespace, *, require_api_key: bool) -> AgentConfig: env = dict(os.environ) overrides = { "DEERFLOW_BASE_URL": args.base_url, "DEERFLOW_AUTH_MODE": args.auth_mode, "DEERFLOW_SESSION_DB": args.session_db, "DEERFLOW_TIMEOUT_SECONDS": args.timeout, "DEERFLOW_CONNECT_TIMEOUT_SECONDS": args.connect_timeout, } for key, value in overrides.items(): if value is not None: env[key] = str(value) return AgentConfig.from_env(env, require_api_key=require_api_key) def main(argv: Optional[Sequence[str]] = None) -> int: parser = build_parser() args = parser.parse_args(argv) try: config = _config_for_args(args, require_api_key=args.command in NETWORK_COMMANDS) if args.command == "doctor": report = config.redacted_summary() report.update( { "status": "ready" if config.api_key_configured else "missing_api_key", "python_version": platform.python_version(), "httpx_version": httpx.__version__, "network_checked": False, } ) _print_json(report) return 0 if config.api_key_configured else 2 with SQLiteSessionStore(config.session_db) as store: if args.command == "show-session": record = store.get(args.conversation_id) if record is None: raise SessionNotFoundError("no local Agent session mapping was found") _print_json(record.to_dict()) return 0 if args.command == "forget-session": _print_json( { "conversation_id": args.conversation_id, "deleted": store.delete(args.conversation_id), } ) return 0 if not config.api_key_configured or not config.api_key: raise AgentConfigurationError("DEERFLOW_OPEN_API_KEY is required") with OpenAgentAPIClient( base_url=config.base_url, api_key=config.api_key, auth_mode=config.auth_mode, # type: ignore[arg-type] timeout=config.timeout_seconds, connect_timeout=config.connect_timeout_seconds, ) as client: service = OpenAgentService(client, store) return _run_network_command(service, args) except AgentConfigurationError as exc: _print_error("configuration_error", str(exc)) return 2 except (ValueError, SessionNotFoundError, AgentResponseError, SessionStoreError) as exc: _print_error("input_or_state_error", str(exc)) return 2 except OpenAgentAPIError as exc: _print_error( "api_error", str(exc), status_code=exc.status_code, request_id=exc.request_id, retryable=exc.retryable, active_run_conflict=exc.active_run_conflict, ) return 1 except (OpenAgentTransportError, OpenAgentProtocolError) as exc: _print_error("transport_or_protocol_error", str(exc)) return 1 except TimeoutError as exc: _print_error("poll_timeout", str(exc)) return 1 except KeyboardInterrupt: _print_error("interrupted", "operation interrupted by user") return 130 def _run_network_command(service: OpenAgentService, args: argparse.Namespace) -> int: if args.command == "ensure-session": record = service.ensure_session( args.conversation_id, external_subject_id=args.external_subject_id, metadata=args.metadata, ) _print_json(record.to_dict()) return 0 if args.command == "chat": wrote_text = False normalizer = AgentStreamNormalizer() for raw_event in service.stream_message( args.conversation_id, args.message, message_id=args.message_id, external_subject_id=args.external_subject_id, metadata=args.metadata, ): public_events = normalizer.feed(raw_event) if args.output == "raw-jsonl": print(json.dumps(raw_event.to_dict(), ensure_ascii=False), flush=True) continue for public_event in public_events: wrote_text = _emit_public_event(public_event, args.output, wrote_text) for public_event in normalizer.finish(): if args.output != "raw-jsonl": wrote_text = _emit_public_event(public_event, args.output, wrote_text) if args.output == "text" and wrote_text: print() return 1 if normalizer.fatal_error else 0 if args.command == "send": run = service.send_message( args.conversation_id, args.message, message_id=args.message_id, external_subject_id=args.external_subject_id, metadata=args.metadata, ) _print_json(run) if args.poll: run_id = run.get("run_id") if not isinstance(run_id, str) or not run_id: raise AgentResponseError("send response is missing run_id") _poll_run(service, args.conversation_id, run_id, args.poll_interval, args.poll_timeout) return 0 if args.command == "get-run": _print_json(service.get_run(args.conversation_id, args.run_id)) return 0 if args.command == "cancel-run": _print_json(service.cancel_run(args.conversation_id, args.run_id)) return 0 raise ValueError(f"unsupported command: {args.command}") def _poll_run( service: OpenAgentService, conversation_id: str, run_id: str, poll_interval: float, poll_timeout: float, ) -> None: if poll_interval <= 0 or poll_timeout <= 0: raise ValueError("poll interval and timeout must be greater than zero") deadline = time.monotonic() + poll_timeout while True: status = service.get_run(conversation_id, run_id) _print_json(status) if str(status.get("status")) not in RUN_ACTIVE_STATUSES: if status.get("status") == "success" and not status.get("final_content"): _print_warning( "run_result_unavailable", "run succeeded but final_content is empty; use streaming or a configured webhook for the answer", run_id=run_id, ) return if time.monotonic() >= deadline: raise TimeoutError(f"run {run_id} did not finish within {poll_timeout} seconds") time.sleep(poll_interval) def _emit_public_event(event: OpenAgentEvent, output: str, wrote_text: bool) -> bool: if output == "jsonl": print(json.dumps(event.to_dict(), ensure_ascii=False), flush=True) return wrote_text if event.event in {"run.warning", "run.error"}: print(json.dumps(event.to_dict(), ensure_ascii=False), file=sys.stderr, flush=True) return wrote_text if event.event == "message.delta" and isinstance(event.data, dict): content = event.data.get("content") if isinstance(content, str) and content: print(content, end="", flush=True) return True if event.event == "message.completed" and isinstance(event.data, dict): content = event.data.get("content") if not event.data.get("streamed") and isinstance(content, str) and content: print(content, end="", flush=True) return True return wrote_text def _print_json(value: Any) -> None: print(json.dumps(value, ensure_ascii=False, indent=2)) def _print_error(kind: str, message: str, **extra: Any) -> None: payload: Dict[str, Any] = {"error": kind, "message": message} payload.update({key: value for key, value in extra.items() if value is not None}) print(json.dumps(payload, ensure_ascii=False), file=sys.stderr) def _print_warning(kind: str, message: str, **extra: Any) -> None: payload: Dict[str, Any] = {"warning": kind, "message": message} payload.update({key: value for key, value in extra.items() if value is not None}) print(json.dumps(payload, ensure_ascii=False), file=sys.stderr) if __name__ == "__main__": raise SystemExit(main())