diff --git a/agent/agent_init.py b/agent/agent_init.py index e20755c50919..4a1b7d588b09 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -289,8 +289,11 @@ def init_agent( agent.provider = provider_name or "" agent.acp_command = acp_command or command agent.acp_args = list(acp_args or args or []) - if api_mode in {"chat_completions", "codex_responses", "anthropic_messages", "bedrock_converse", "codex_app_server"}: + if api_mode in {"chat_completions", "codex_responses", "anthropic_messages", "bedrock_converse", "codex_app_server", "claude_cli"}: agent.api_mode = api_mode + elif agent.provider in {"claude-cli", "claude_cli"}: + agent.api_mode = "claude_cli" + agent.provider = "claude-cli" elif agent.provider == "openai-codex": agent.api_mode = "codex_responses" elif agent.provider in {"xai", "xai-oauth"}: @@ -687,6 +690,19 @@ def init_agent( if not agent.quiet_mode: _gr_label = " + Guardrails" if agent._bedrock_guardrail_config else "" print(f"🤖 AI Agent initialized with model: {agent.model} (AWS Bedrock, {agent._bedrock_region}{_gr_label})") + elif agent.api_mode == "claude_cli": + # Claude CLI owns auth and subscription selection. Hermes should + # not require Anthropic API/OAuth credentials or an OpenAI client. + agent.api_key = api_key or "" + if base_url: + agent.base_url = base_url + agent.client = None + agent._client_kwargs = { + "api_key": agent.api_key, + "base_url": agent.base_url, + } + if not agent.quiet_mode: + print(f"🤖 AI Agent initialized with model: {agent.model} (Claude CLI)") else: if api_key and base_url: # Explicit credentials from CLI/gateway — construct directly. diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index eed3cfa956b1..cadf1dd44aea 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -640,6 +640,18 @@ def run_conversation( effective_task_id=effective_task_id, should_review_memory=_should_review_memory, ) + if agent.api_mode == "claude_cli": + return agent._run_claude_cli_turn( + user_message=user_message, + original_user_message=original_user_message, + messages=messages, + effective_task_id=effective_task_id, + current_turn_user_idx=current_turn_user_idx, + active_system_prompt=active_system_prompt, + ext_prefetch_cache=_ext_prefetch_cache, + plugin_user_context=_plugin_user_context, + should_review_memory=_should_review_memory, + ) while (api_call_count < agent.max_iterations and agent.iteration_budget.remaining > 0) or agent._budget_grace_call: # Reset per-turn checkpoint dedup so each iteration can take one snapshot diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 9c36d205ac5b..984ea18a93b3 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -222,14 +222,13 @@ def _strip_yaml_frontmatter(content: str) -> str: "(`{changed_files: [...], tests_run: N, decisions: [...]}`). Downstream " "workers read both via their own `kanban_show`. Never put secrets / " "tokens / raw PII in either field — run rows are durable forever. " - "Exception: if your output is a code change that needs human review " - "before counting as merged/done (most coding tasks), drop the " - "structured metadata (changed_files / tests_run / diff_path) into a " - "`kanban_comment` first, then end with " - "`kanban_block(reason=\"review-required: \")` so a " - "reviewer can approve+unblock or request changes. Reviewing-then-" - "completing is more honest than auto-completing work that still needs " - "eyes on it.\n" + "If your output is a code change that needs review, do not block the " + "implementation task. Put changed_files / tests_run / diff_path in " + "`metadata`, create a review child with " + "`kanban_create(title=..., assignee=, " + "parents=[your-task-id])` if one does not already exist, then call " + "`kanban_complete(...)`. The review child is the gate; blocking your " + "own task for review can deadlock the dependency graph.\n" "6. **If follow-up work appears, create it; don't do it.** Use " "`kanban_create(title=..., assignee=, parents=[your-task-id])` " "to spawn a child task for the appropriate specialist profile instead of " diff --git a/agent/transports/claude_cli_session.py b/agent/transports/claude_cli_session.py new file mode 100644 index 000000000000..380f701d7c7d --- /dev/null +++ b/agent/transports/claude_cli_session.py @@ -0,0 +1,280 @@ +"""Whole-turn adapter for Claude Code CLI. + +This runtime intentionally bypasses Hermes' provider auth and API transports. +It shells out to ``claude -p`` so paid Claude plans authenticated in Claude +Code stay the source of truth. +""" + +from __future__ import annotations + +import json +import os +import shlex +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Mapping, Optional + + +@dataclass +class ClaudeCliTurnResult: + final_text: str = "" + error: Optional[str] = None + interrupted: bool = False + returncode: int = 0 + + +class ClaudeCliSession: + """Run one Hermes turn through the local ``claude`` CLI.""" + + def __init__( + self, + *, + cwd: Optional[str] = None, + claude_bin: Optional[str] = None, + model: Optional[str] = None, + timeout_seconds: Optional[float] = None, + extra_args: Optional[list[str]] = None, + env: Optional[Mapping[str, str]] = None, + ) -> None: + self._cwd = cwd or os.getcwd() + self._claude_bin = ( + claude_bin or os.getenv("HERMES_CLAUDE_CLI_BIN") or "claude" + ) + self._model = (model or os.getenv("HERMES_CLAUDE_CLI_MODEL") or "").strip() + self._timeout_seconds = timeout_seconds or float( + os.getenv("HERMES_CLAUDE_CLI_TIMEOUT_SECONDS", "900") + ) + env_args = shlex.split(os.getenv("HERMES_CLAUDE_CLI_EXTRA_ARGS", "")) + self._extra_args = list(extra_args or []) + env_args + self._env = dict(env) if env is not None else None + + def run_turn( + self, + *, + messages: list[dict[str, Any]], + user_input: str, + ) -> ClaudeCliTurnResult: + prompt = render_claude_cli_prompt(messages=messages, user_input=user_input) + cmd = [ + self._claude_bin, + "-p", + prompt, + "--no-session-persistence", + "--output-format", + "text", + ] + if self._model: + cmd.extend(["--model", self._model]) + cmd.extend(_kanban_worker_hermes_tools_args()) + cmd.extend(self._extra_args) + + try: + completed = subprocess.run( + cmd, + cwd=self._cwd, + env=self._env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=self._timeout_seconds, + ) + except FileNotFoundError: + return ClaudeCliTurnResult( + error=( + f"Claude CLI not found: {self._claude_bin}. " + "Install Claude Code or set HERMES_CLAUDE_CLI_BIN." + ), + returncode=127, + ) + except subprocess.TimeoutExpired as exc: + return ClaudeCliTurnResult( + error=f"Claude CLI timed out after {self._timeout_seconds:g}s.", + final_text=( + (exc.stdout or "").strip() + if isinstance(exc.stdout, str) + else "" + ), + interrupted=True, + returncode=124, + ) + + stdout = (completed.stdout or "").strip() + stderr = (completed.stderr or "").strip() + if completed.returncode != 0: + return ClaudeCliTurnResult( + final_text=stdout, + error=stderr or stdout or f"Claude CLI exited with {completed.returncode}.", + returncode=completed.returncode, + ) + return ClaudeCliTurnResult(final_text=stdout, returncode=completed.returncode) + + +def render_claude_cli_prompt(*, messages: list[dict[str, Any]], user_input: str) -> str: + if not messages: + return str(user_input or "") + + lines = [ + "You are being invoked by Hermes through Claude Code CLI.", + "Continue the transcript below and answer the final user message.", + "", + "", + ] + for msg in messages: + role = str(msg.get("role") or "message").upper() + content = _content_to_text(msg.get("content")) + tool_calls = msg.get("tool_calls") + if tool_calls: + content = (content + "\n" if content else "") + _json_line( + "tool_calls", tool_calls + ) + tool_call_id = msg.get("tool_call_id") + if tool_call_id: + content = f"[tool_call_id={tool_call_id}]\n{content}".strip() + lines.append(f"{role}:\n{content}".rstrip()) + lines.append("") + lines.append("") + return "\n".join(lines).strip() + + +_KANBAN_HERMES_TOOL_NAMES: tuple[str, ...] = ( + "kanban_show", + "kanban_comment", + "kanban_block", + "kanban_complete", + "kanban_heartbeat", + "kanban_create", + "kanban_list", + "kanban_unblock", + "kanban_link", + "skill_view", + "skills_list", +) + +_CLAUDE_CODE_WORKER_TOOLS: tuple[str, ...] = ( + "Read", + "Write", + "Edit", + "MultiEdit", + "Glob", + "Grep", + "LS", + "Bash", + "TodoWrite", +) + +_KANBAN_CLI_SYSTEM_PROMPT = ( + "You are running inside Hermes' Claude CLI bridge as a Kanban worker. " + "Hermes tools are available as MCP tools from the hermes-tools server " + "(for example mcp__hermes-tools__kanban_show and " + "mcp__hermes-tools__kanban_block). Use those MCP tools for Kanban " + "lifecycle operations and end every assigned task with kanban_complete " + "or kanban_block; do not finish with prose only." +) + + +def _kanban_worker_hermes_tools_args() -> list[str]: + """Attach Hermes' MCP tool bridge for dispatcher-spawned Claude workers. + + The plain Claude CLI runtime otherwise has only Claude Code's tools. A + Kanban worker needs Hermes' own kanban_* tools so the dispatcher can observe + block/complete transitions instead of seeing a clean prose-only exit. + """ + if not os.getenv("HERMES_KANBAN_TASK"): + return [] + + mcp_config = { + "mcpServers": { + "hermes-tools": { + "command": sys.executable, + "args": ["-m", "agent.transports.hermes_tools_mcp_server"], + "env": _hermes_tools_mcp_env(), + } + } + } + allowed_tools = list(_CLAUDE_CODE_WORKER_TOOLS) + allowed_tools.extend( + f"mcp__hermes-tools__{name}" for name in _KANBAN_HERMES_TOOL_NAMES + ) + return [ + "--mcp-config", + json.dumps(mcp_config, separators=(",", ":")), + "--strict-mcp-config", + "--allowedTools", + ",".join(allowed_tools), + "--append-system-prompt", + _KANBAN_CLI_SYSTEM_PROMPT, + ] + + +def _hermes_tools_mcp_env() -> dict[str, str]: + repo_root = str(Path(__file__).resolve().parents[2]) + existing_pythonpath = os.getenv("PYTHONPATH", "") + pythonpath = ( + repo_root + if not existing_pythonpath + else f"{repo_root}{os.pathsep}{existing_pythonpath}" + ) + + env: dict[str, str] = { + "PYTHONPATH": pythonpath, + "HERMES_QUIET": "1", + "HERMES_REDACT_SECRETS": "true", + } + + for key in ( + "HOME", + "PATH", + "LANG", + "LC_ALL", + "TZ", + "HERMES_HOME", + "HERMES_PROFILE", + "HERMES_KANBAN_TASK", + "HERMES_KANBAN_RUN_ID", + "HERMES_KANBAN_CLAIM_LOCK", + "HERMES_KANBAN_WORKSPACE", + "HERMES_KANBAN_DB", + "HERMES_KANBAN_WORKSPACES_ROOT", + "HERMES_KANBAN_BOARD", + "HERMES_TENANT", + ): + value = os.getenv(key) + if value: + env[key] = value + return env + + +def _content_to_text(content: Any) -> str: + if content is None: + return "" + if isinstance(content, str): + return content + if isinstance(content, list): + parts: list[str] = [] + for item in content: + if isinstance(item, dict): + if item.get("type") == "text": + text = item.get("text") + if text: + parts.append(str(text)) + elif item.get("type") == "image_url": + parts.append("[image omitted]") + else: + parts.append(_json_dumps(item)) + else: + parts.append(str(item)) + return "\n".join(parts) + return _json_dumps(content) + + +def _json_line(label: str, value: Any) -> str: + return f"[{label}: {_json_dumps(value)}]" + + +def _json_dumps(value: Any) -> str: + try: + return json.dumps(value, ensure_ascii=False, sort_keys=True) + except TypeError: + return str(value) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 81d852ebfa49..ab75067bc0a0 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -4001,6 +4001,13 @@ class DispatchResult: Reasons: ``"blocker_auth"`` (quota/auth error — also auto-blocked), ``"recent_success"`` (completed run within guard window), ``"active_pr"`` (GitHub PR URL in a recent comment).""" + profile_unhealthy: list[tuple[str, str]] = field(default_factory=list) + """Tasks skipped because the assignee profile's configured runtime + is currently unusable, as ``(task_id, reason)`` pairs. + + The task remains in its original column and is not claimed, so it can + run automatically on a later tick after the operator fixes the profile + runtime (for example by running ``claude auth login --claudeai``).""" # Bounded registry of recently-reaped worker child exits, populated by the @@ -4990,6 +4997,203 @@ def has_spawnable_review(conn: sqlite3.Connection) -> bool: return False +_CLAUDE_CLI_PROVIDER_ALIASES = frozenset({ + "claude-cli", + "claude_cli", + "claude-code", + "claude-code-cli", + "anthropic-cli", +}) +_CLAUDE_CLI_RUNTIME_ALIASES = frozenset({"cli", "claude_cli", "claude-cli"}) +_CLAUDE_CLI_LOGIN_MESSAGE = ( + "Claude CLI is not logged in; run `claude auth login --claudeai`." +) + + +def _config_uses_claude_cli_runtime(config: Any) -> bool: + """Return True if a Hermes config routes model calls through Claude CLI.""" + if not isinstance(config, dict): + return False + model_cfg = config.get("model") + if not isinstance(model_cfg, dict): + return False + provider = str(model_cfg.get("provider") or "").strip().lower() + if provider in _CLAUDE_CLI_PROVIDER_ALIASES: + return True + runtime = str(model_cfg.get("claude_runtime") or "").strip().lower() + return provider == "anthropic" and runtime in _CLAUDE_CLI_RUNTIME_ALIASES + + +def _profile_config_uses_claude_cli_runtime(profile: str) -> bool: + """Best-effort check for whether ``profile`` is configured for Claude CLI.""" + try: + from hermes_cli.profiles import normalize_profile_name, resolve_profile_env + + profile_home = Path(resolve_profile_env(normalize_profile_name(profile))) + except Exception: + return False + + config_path = profile_home / "config.yaml" + if not config_path.is_file(): + return False + try: + import yaml + + with open(config_path, "r", encoding="utf-8") as f: + config = yaml.safe_load(f) or {} + except Exception: + return False + return _config_uses_claude_cli_runtime(config) + + +def _parse_claude_auth_status_json(output: str) -> Optional[dict[str, Any]]: + if not output.strip(): + return None + try: + parsed = json.loads(output) + except Exception: + return None + return parsed if isinstance(parsed, dict) else None + + +def _short_cli_output(output: str, limit: int = 500) -> str: + compact = " ".join(line.strip() for line in output.splitlines() if line.strip()) + if len(compact) <= limit: + return compact + return compact[: limit - 1].rstrip() + "…" + + +def _claude_cli_smoke_preflight(claude_bin: str) -> Optional[str]: + """Run a tiny Claude CLI inference to catch stale subscription tokens.""" + raw_enabled = os.getenv("HERMES_CLAUDE_CLI_PREFLIGHT_SMOKE", "1").strip().lower() + if raw_enabled in {"0", "false", "no", "off"}: + return None + raw_timeout = os.getenv("HERMES_CLAUDE_CLI_PREFLIGHT_TIMEOUT_SECONDS", "30") + try: + timeout = max(1, int(raw_timeout)) + except ValueError: + timeout = 30 + try: + result = subprocess.run( + [ + claude_bin, + "-p", + "Reply exactly OK.", + "--no-session-persistence", + "--output-format", + "text", + ], + capture_output=True, + text=True, + timeout=timeout, + ) + except FileNotFoundError: + return ( + f"Claude CLI not found: {claude_bin}. " + "Install Claude Code or set HERMES_CLAUDE_CLI_BIN." + ) + except subprocess.TimeoutExpired: + return f"Claude CLI inference smoke test timed out after {timeout}s." + except Exception as exc: + return f"Claude CLI inference smoke test failed: {exc}" + + output = "\n".join( + part for part in ((result.stdout or "").strip(), (result.stderr or "").strip()) + if part + ).strip() + if result.returncode == 0: + return None + lower = output.lower() + if ( + "not logged" in lower + or "log in" in lower + or "login required" in lower + or "invalid authentication credentials" in lower + or "failed to authenticate" in lower + ): + return _CLAUDE_CLI_LOGIN_MESSAGE + detail = _short_cli_output(output) or f"claude exited {result.returncode}" + return f"Claude CLI inference smoke test failed: {detail}" + + +def _claude_cli_auth_preflight() -> Optional[str]: + """Return an operator-facing reason if the local Claude CLI is unusable.""" + claude_bin = os.getenv("HERMES_CLAUDE_CLI_BIN", "").strip() or "claude" + try: + result = subprocess.run( + [claude_bin, "auth", "status"], + capture_output=True, + text=True, + timeout=5, + ) + except FileNotFoundError: + return ( + f"Claude CLI not found: {claude_bin}. " + "Install Claude Code or set HERMES_CLAUDE_CLI_BIN." + ) + except subprocess.TimeoutExpired: + return "Claude CLI auth check timed out after 5s." + except Exception as exc: + return f"Claude CLI auth check failed: {exc}" + + stdout = (result.stdout or "").strip() + stderr = (result.stderr or "").strip() + combined = "\n".join(part for part in (stdout, stderr) if part).strip() + + parsed = ( + _parse_claude_auth_status_json(stdout) + or _parse_claude_auth_status_json(combined) + ) + if parsed is not None: + if bool(parsed.get("loggedIn") or parsed.get("logged_in")): + return _claude_cli_smoke_preflight(claude_bin) + if "loggedIn" in parsed or "logged_in" in parsed: + return _CLAUDE_CLI_LOGIN_MESSAGE + detail = _short_cli_output( + str(parsed.get("error") or parsed.get("message") or combined) + ) + if detail and detail != "{}": + lower_detail = detail.lower() + if "not logged" in lower_detail or "login" in lower_detail: + return _CLAUDE_CLI_LOGIN_MESSAGE + return f"Claude CLI auth check failed: {detail}" + return _CLAUDE_CLI_LOGIN_MESSAGE + + lower = combined.lower() + if ( + "not logged" in lower + or "log in" in lower + or "login required" in lower + or "invalid authentication credentials" in lower + or "failed to authenticate" in lower + ): + return _CLAUDE_CLI_LOGIN_MESSAGE + if result.returncode != 0: + detail = _short_cli_output(combined) or f"claude exited {result.returncode}" + return f"Claude CLI auth check failed: {detail}" + return _claude_cli_smoke_preflight(claude_bin) + + +def _kanban_profile_runtime_preflight( + assignee: str, + cache: dict[str, Optional[str]], +) -> Optional[str]: + """Return a runtime health problem for ``assignee``, cached per tick.""" + try: + from hermes_cli.profiles import normalize_profile_name + + profile = normalize_profile_name(assignee) + except Exception: + profile = str(assignee or "").strip().lower() + if profile in cache: + return cache[profile] + if not _profile_config_uses_claude_cli_runtime(profile): + cache[profile] = None + return None + cache[profile] = _claude_cli_auth_preflight() + return cache[profile] + + def dispatch_once( conn: sqlite3.Connection, *, @@ -5115,6 +5319,7 @@ def dispatch_once( if max_spawn is None or max_spawn > remaining: max_spawn = remaining spawned = 0 + profile_preflight_cache: dict[str, Optional[str]] = {} for row in ready_rows: if max_spawn is not None and running_count + spawned >= max_spawn: break @@ -5165,6 +5370,23 @@ def dispatch_once( {"reason": guard_reason}, ) continue + preflight_reason = _kanban_profile_runtime_preflight( + row["assignee"], profile_preflight_cache + ) + if preflight_reason is not None: + result.profile_unhealthy.append((row["id"], preflight_reason)) + if not dry_run: + with write_txn(conn): + _append_event( + conn, + row["id"], + "spawn_preflight_failed", + { + "assignee": row["assignee"], + "reason": preflight_reason, + }, + ) + continue if dry_run: result.spawned.append((row["id"], row["assignee"], "")) continue @@ -5244,6 +5466,23 @@ def dispatch_once( if profile_exists is not None and not profile_exists(row["assignee"]): result.skipped_nonspawnable.append(row["id"]) continue + preflight_reason = _kanban_profile_runtime_preflight( + row["assignee"], profile_preflight_cache + ) + if preflight_reason is not None: + result.profile_unhealthy.append((row["id"], preflight_reason)) + if not dry_run: + with write_txn(conn): + _append_event( + conn, + row["id"], + "spawn_preflight_failed", + { + "assignee": row["assignee"], + "reason": preflight_reason, + }, + ) + continue if dry_run: result.spawned.append((row["id"], row["assignee"], "")) continue @@ -5492,6 +5731,28 @@ def _resolve_hermes_argv() -> list[str]: return _module_hermes_argv() +def _worker_skill_available(hermes_home: Optional[str], skill_name: str) -> bool: + """True if ``skill_name`` resolves for the home the worker will use.""" + skill_name = (skill_name or "").strip() + if not skill_name: + return False + from pathlib import Path as _Path + + base = _Path(hermes_home) if hermes_home else (_Path.home() / ".hermes") + skills_root = base / "skills" + if not skills_root.is_dir(): + return False + if (skills_root / skill_name / "SKILL.md").is_file(): + return True + try: + for skill_md in skills_root.rglob(f"{skill_name}/SKILL.md"): + if skill_md.is_file(): + return True + except OSError: + pass + return False + + def _kanban_worker_skill_available(hermes_home: Optional[str]) -> bool: """True if the bundled ``kanban-worker`` skill resolves for the home the spawned worker will run under. @@ -5506,25 +5767,7 @@ def _kanban_worker_skill_available(hermes_home: Optional[str]) -> bool: the kanban lifecycle contract is still injected via ``KANBAN_GUIDANCE``, so omitting the flag only drops the supplementary pattern library. """ - from pathlib import Path as _Path - - # An unset HERMES_HOME means the worker falls back to the default root - # home (``~/.hermes``), which ships the bundled skill. - base = _Path(hermes_home) if hermes_home else (_Path.home() / ".hermes") - skills_root = base / "skills" - if not skills_root.is_dir(): - return False - # Canonical bundled location first (cheap), then a bounded scan for - # profiles that have it nested elsewhere. - if (skills_root / "devops" / "kanban-worker" / "SKILL.md").is_file(): - return True - try: - for skill_md in skills_root.rglob("kanban-worker/SKILL.md"): - if skill_md.is_file(): - return True - except OSError: - pass - return False + return _worker_skill_available(hermes_home, "kanban-worker") def _worker_terminal_timeout_env( @@ -5679,7 +5922,11 @@ def _default_spawn( # if a task author asks for it explicitly. if task.skills: for sk in task.skills: - if sk and sk != "kanban-worker": + if ( + sk + and sk != "kanban-worker" + and _worker_skill_available(env.get("HERMES_HOME"), sk) + ): cmd.extend(["--skills", sk]) if task.model_override: cmd.extend(["-m", task.model_override]) diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index c40316e02ccf..d6a7aba3e0f5 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -248,6 +248,10 @@ def _copilot_runtime_api_mode(model_cfg: Dict[str, Any], api_key: str) -> str: # `model.openai_runtime == "codex_app_server"` AND provider in # {"openai", "openai-codex"}. Default is unchanged. "codex_app_server", + # Optional opt-in: hand the entire turn to the local Claude Code CLI. + # This uses the user's Claude CLI login/subscription instead of Hermes' + # Anthropic API/OAuth credential path. + "claude_cli", } @@ -286,6 +290,48 @@ def _maybe_apply_codex_app_server_runtime( return api_mode +_CLAUDE_CLI_PROVIDER_ALIASES = { + "claude-cli", + "claude_cli", + "claude-code", + "claude-code-cli", + "anthropic-cli", +} + + +def _claude_cli_runtime_requested( + requested_provider: str, + model_cfg: Optional[Dict[str, Any]], +) -> bool: + requested_norm = (requested_provider or "").strip().lower() + if requested_norm in _CLAUDE_CLI_PROVIDER_ALIASES: + return True + runtime = str((model_cfg or {}).get("claude_runtime") or "").strip().lower() + return requested_norm == "anthropic" and runtime in {"cli", "claude_cli", "claude-cli"} + + +def _resolve_claude_cli_runtime( + *, + requested_provider: str, + model_cfg: Optional[Dict[str, Any]], +) -> Dict[str, Any]: + model_cfg = model_cfg or {} + command = ( + str(model_cfg.get("claude_cli_bin") or "").strip() + or os.getenv("HERMES_CLAUDE_CLI_BIN", "").strip() + or "claude" + ) + return { + "provider": "claude-cli", + "api_mode": "claude_cli", + "base_url": "claude-cli://local", + "api_key": "", + "command": command, + "source": "claude-cli", + "requested_provider": requested_provider, + } + + def _resolve_runtime_from_pool_entry( *, provider: str, @@ -1215,6 +1261,13 @@ def resolve_runtime_provider( behavior (api_mode derived from config). """ requested_provider = resolve_requested_provider(requested) + model_cfg = _get_model_config() + + if _claude_cli_runtime_requested(requested_provider, model_cfg): + return _resolve_claude_cli_runtime( + requested_provider=requested_provider, + model_cfg=model_cfg, + ) # Azure Anthropic short-circuit: when explicitly targeting an Azure endpoint # with provider="anthropic", bypass _resolve_named_custom_runtime (which would @@ -1244,7 +1297,7 @@ def resolve_runtime_provider( if requested_provider == "azure-foundry": azure_runtime = _resolve_azure_foundry_runtime( requested_provider=requested_provider, - model_cfg=_get_model_config(), + model_cfg=model_cfg, explicit_api_key=explicit_api_key, explicit_base_url=explicit_base_url, target_model=target_model, @@ -1265,7 +1318,6 @@ def resolve_runtime_provider( explicit_api_key=explicit_api_key, explicit_base_url=explicit_base_url, ) - model_cfg = _get_model_config() explicit_runtime = _resolve_explicit_runtime( provider=provider, requested_provider=requested_provider, diff --git a/hermes_cli/status.py b/hermes_cli/status.py index 5629da03fe38..18e4fa032f09 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -8,6 +8,7 @@ import sys import subprocess # noqa: F401 — re-exported for tests that monkeypatch status.subprocess to guard against regressions import importlib.util +import json from pathlib import Path PROJECT_ROOT = Path(__file__).parent.parent.resolve() @@ -70,9 +71,77 @@ def _configured_model_label(config: dict) -> str: return model or "(not set)" +def _claude_cli_runtime_enabled(config: dict) -> bool: + """Return True when Claude turns are routed through Claude Code CLI.""" + model_cfg = config.get("model") + if not isinstance(model_cfg, dict): + return False + provider = str(model_cfg.get("provider") or "").strip().lower() + if provider in {"claude-cli", "claude_cli", "claude-code", "claude-code-cli", "anthropic-cli"}: + return True + runtime = str(model_cfg.get("claude_runtime") or "").strip().lower() + return provider == "anthropic" and runtime in {"cli", "claude_cli", "claude-cli"} + + +def _get_claude_cli_auth_status() -> dict: + try: + result = subprocess.run( + ["claude", "auth", "status"], + capture_output=True, + text=True, + timeout=5, + ) + except Exception as exc: + return {"logged_in": False, "error": str(exc)} + + output = ((result.stdout or "") + "\n" + (result.stderr or "")).strip() + if result.returncode != 0: + return {"logged_in": False, "error": output or f"claude exited {result.returncode}"} + + try: + parsed = json.loads(output) + except Exception: + parsed = None + if isinstance(parsed, dict): + return { + "logged_in": bool(parsed.get("loggedIn") or parsed.get("logged_in")), + "auth_method": parsed.get("authMethod") or parsed.get("auth_method") or "", + "subscription": parsed.get("subscriptionType") or parsed.get("subscription") or "", + } + + lower = output.lower() + logged_in = "not logged" not in lower and ( + "logged in" in lower or "authenticated" in lower or "login successful" in lower + ) + subscription = "" + auth_method = "" + for line in output.splitlines(): + key, sep, value = line.partition(":") + if not sep: + continue + key_lower = key.strip().lower() + if "subscription" in key_lower or key_lower == "plan": + subscription = value.strip() + elif key_lower in {"authmethod", "auth method", "method"}: + auth_method = value.strip() + return { + "logged_in": logged_in, + "auth_method": auth_method, + "subscription": subscription, + "raw": output, + } + + def _effective_provider_label() -> str: """Return the provider label matching current CLI runtime resolution.""" + try: + if _claude_cli_runtime_enabled(load_config()): + return "Claude CLI" + except Exception: + pass requested = resolve_requested_provider() + if requested in {"claude-cli", "claude_cli", "claude-code", "claude-code-cli", "anthropic-cli"}: + return "Claude CLI" try: effective = resolve_provider(requested) except AuthError: @@ -230,6 +299,25 @@ def _resolve_env(env_ref) -> str: if codex_status.get("error") and not codex_logged_in: print(f" Error: {codex_status.get('error')}") + if _claude_cli_runtime_enabled(config): + claude_status = _get_claude_cli_auth_status() + claude_logged_in = bool(claude_status.get("logged_in")) + claude_label = ( + "logged in via Claude CLI" + if claude_logged_in + else "not logged in (run: claude auth login --claudeai)" + ) + print( + f" {'Claude CLI':<12} {check_mark(claude_logged_in)} " + f"{claude_label}" + ) + if claude_status.get("subscription"): + print(f" Plan: {claude_status.get('subscription')}") + if claude_status.get("auth_method"): + print(f" Auth: {claude_status.get('auth_method')}") + if claude_status.get("error") and not claude_logged_in: + print(f" Error: {claude_status.get('error')}") + qwen_logged_in = bool(qwen_status.get("logged_in")) print( f" {'Qwen OAuth':<12} {check_mark(qwen_logged_in)} " diff --git a/run_agent.py b/run_agent.py index b364127c2780..928de94665dd 100644 --- a/run_agent.py +++ b/run_agent.py @@ -4077,6 +4077,255 @@ def chat(self, message: str, stream_callback: Optional[callable] = None) -> str: result = self.run_conversation(message, stream_callback=stream_callback) return result["final_response"] + def _kanban_task_needs_terminal_transition(self, task_id: str) -> bool: + """Return True when this worker's Kanban task is still in-flight. + + External runtimes such as Claude CLI own their own tool loop. If they + return final text without calling Hermes' kanban_complete / + kanban_block MCP tools, the dispatcher later sees a clean process exit + and records a protocol violation. This helper lets Hermes check + whether a terminal transition is still missing before it performs a + final safety block. + """ + try: + from hermes_cli import kanban_db as kb + + conn = kb.connect() + try: + task = kb.get_task(conn, task_id) + finally: + conn.close() + return bool(task and task.status == "running") + except Exception: + logger.debug( + "could not inspect kanban task %s before external-runtime " + "fallback", + task_id, + exc_info=True, + ) + return False + + def _maybe_block_kanban_external_runtime_prose( + self, + *, + final_text: str, + effective_task_id: str, + runtime_name: str, + ) -> bool: + """Last-resort safety net for external runtimes in Kanban workers. + + The preferred path is always a real kanban_complete / kanban_block tool + call from the model. This only fires when an external runtime returned + prose and the task is still running, which would otherwise become a + clean-exit protocol violation in the dispatcher. + """ + task_id = os.environ.get("HERMES_KANBAN_TASK") + if not task_id or not (final_text or "").strip(): + return False + if not self._kanban_task_needs_terminal_transition(task_id): + return False + + excerpt = re.sub(r"\s+", " ", (final_text or "").strip()) + if len(excerpt) > 900: + excerpt = excerpt[:897].rstrip() + "..." + reason = ( + f"external-runtime-prose: {runtime_name} returned final text " + "without calling kanban_complete or kanban_block. Hermes blocked " + f"the task to avoid a dispatcher protocol violation. Last response: " + f"{excerpt}" + ) + try: + tool_result = handle_function_call( + "kanban_block", + {"task_id": task_id, "reason": reason}, + task_id=effective_task_id, + ) + try: + parsed = json.loads(tool_result) if isinstance(tool_result, str) else {} + except Exception: + parsed = {} + if isinstance(parsed, dict) and parsed.get("error"): + logger.warning( + "external-runtime fallback kanban_block failed for %s: %s", + task_id, + parsed.get("error"), + ) + return False + logger.info( + "external-runtime fallback called kanban_block for %s via %s", + task_id, + runtime_name, + ) + return True + except Exception: + logger.warning( + "external-runtime fallback failed to call kanban_block for %s", + task_id, + exc_info=True, + ) + return False + + def _build_claude_cli_messages( + self, + *, + messages: List[Dict[str, Any]], + current_turn_user_idx: int, + active_system_prompt: str, + ext_prefetch_cache: str = "", + plugin_user_context: str = "", + ) -> List[Dict[str, Any]]: + """Build a transcript for Claude CLI with the same one-turn context + injections the API transports receive.""" + api_messages: List[Dict[str, Any]] = [] + for idx, msg in enumerate(messages): + api_msg = msg.copy() + if idx == current_turn_user_idx and msg.get("role") == "user": + injections = [] + if ext_prefetch_cache: + fenced = build_memory_context_block(ext_prefetch_cache) + if fenced: + injections.append(fenced) + if plugin_user_context: + injections.append(plugin_user_context) + if injections: + base = api_msg.get("content", "") + if isinstance(base, str): + api_msg["content"] = base + "\n\n" + "\n\n".join(injections) + + self._copy_reasoning_content_for_api(msg, api_msg) + api_msg.pop("reasoning", None) + api_msg.pop("finish_reason", None) + api_msg.pop("_thinking_prefill", None) + if self._should_sanitize_tool_calls(): + self._sanitize_tool_calls_for_strict_api(api_msg) + api_messages.append(api_msg) + + effective_system = active_system_prompt or "" + if self.ephemeral_system_prompt: + effective_system = ( + effective_system + "\n\n" + self.ephemeral_system_prompt + ).strip() + if effective_system: + api_messages = [{"role": "system", "content": effective_system}] + api_messages + + if self.prefill_messages: + sys_offset = 1 if ( + api_messages and api_messages[0].get("role") == "system" + ) else 0 + for idx, pfm in enumerate(self.prefill_messages): + api_messages.insert(sys_offset + idx, pfm.copy()) + + api_messages = self._sanitize_api_messages(api_messages) + api_messages = self._drop_thinking_only_and_merge_users(api_messages) + for am in api_messages: + if isinstance(am.get("content"), str): + am["content"] = am["content"].strip() + return api_messages + + def _run_claude_cli_turn( + self, + *, + user_message: str, + original_user_message: Any, + messages: List[Dict[str, Any]], + effective_task_id: str, + current_turn_user_idx: int, + active_system_prompt: str, + ext_prefetch_cache: str = "", + plugin_user_context: str = "", + should_review_memory: bool = False, + ) -> Dict[str, Any]: + """Claude CLI runtime path. Hands a rendered transcript to + ``claude -p`` and stores the final text as a normal assistant turn.""" + from agent.transports.claude_cli_session import ClaudeCliSession + + if not hasattr(self, "_claude_cli_session") or self._claude_cli_session is None: + cwd = getattr(self, "session_cwd", None) or os.getcwd() + self._claude_cli_session = ClaudeCliSession( + cwd=cwd, + claude_bin=self.acp_command or None, + model=self.model, + ) + + api_messages = self._build_claude_cli_messages( + messages=messages, + current_turn_user_idx=current_turn_user_idx, + active_system_prompt=active_system_prompt, + ext_prefetch_cache=ext_prefetch_cache, + plugin_user_context=plugin_user_context, + ) + + try: + turn = self._claude_cli_session.run_turn( + messages=api_messages, + user_input=user_message, + ) + except Exception as exc: + logger.exception("claude cli turn failed") + return { + "final_response": f"Claude CLI turn failed: {exc}", + "messages": messages, + "api_calls": 0, + "completed": False, + "partial": True, + "error": str(exc), + } + + blocked_by_fallback = self._maybe_block_kanban_external_runtime_prose( + final_text=turn.final_text, + effective_task_id=effective_task_id, + runtime_name="claude_cli", + ) + + if turn.final_text and not turn.error: + messages.append({"role": "assistant", "content": turn.final_text}) + + if ( + turn.final_text + and not turn.interrupted + and turn.error is None + and not blocked_by_fallback + ): + try: + self._sync_external_memory_for_turn( + original_user_message=original_user_message, + final_response=turn.final_text, + interrupted=False, + ) + except Exception: + logger.debug("external memory sync raised", exc_info=True) + + if ( + turn.final_text + and not turn.interrupted + and turn.error is None + and not blocked_by_fallback + and should_review_memory + ): + try: + self._spawn_background_review( + messages_snapshot=list(messages), + review_memory=should_review_memory, + review_skills=False, + ) + except Exception: + logger.debug("background review spawn raised", exc_info=True) + + return { + "final_response": turn.final_text + or (f"Claude CLI turn failed: {turn.error}" if turn.error else ""), + "messages": messages, + "api_calls": 1, + "completed": ( + not turn.interrupted and turn.error is None and not blocked_by_fallback + ), + "partial": turn.interrupted or turn.error is not None or blocked_by_fallback, + "error": turn.error or ( + "external-runtime-prose fallback blocked Kanban task" + if blocked_by_fallback else None + ), + } + def _run_codex_app_server_turn( self, *, diff --git a/tests/agent/transports/test_claude_cli_session.py b/tests/agent/transports/test_claude_cli_session.py new file mode 100644 index 000000000000..2685e7bd25f9 --- /dev/null +++ b/tests/agent/transports/test_claude_cli_session.py @@ -0,0 +1,56 @@ +"""Tests for the Claude Code CLI turn adapter.""" + +from __future__ import annotations + +import json +import subprocess + +from agent.transports.claude_cli_session import ClaudeCliSession + + +class _Completed: + returncode = 0 + stdout = "OK\n" + stderr = "" + + +def test_kanban_worker_invocation_wires_hermes_mcp_tools(monkeypatch, tmp_path): + seen: dict[str, object] = {} + + def fake_run(cmd, **kwargs): + seen["cmd"] = cmd + seen["kwargs"] = kwargs + return _Completed() + + monkeypatch.setenv("HERMES_HOME", "/tmp/hermes-profile") + monkeypatch.setenv("HERMES_PROFILE", "coding-agent") + monkeypatch.setenv("HERMES_KANBAN_TASK", "t_12345678") + monkeypatch.setenv("HERMES_KANBAN_BOARD", "ai-stack") + monkeypatch.setenv("HERMES_KANBAN_DB", "/tmp/kanban.db") + monkeypatch.setenv("HERMES_KANBAN_WORKSPACE", str(tmp_path)) + monkeypatch.setattr(subprocess, "run", fake_run) + + session = ClaudeCliSession( + cwd=str(tmp_path), + claude_bin="claude", + model="sonnet", + ) + + result = session.run_turn(messages=[], user_input="work kanban task") + + assert result.final_text == "OK" + cmd = seen["cmd"] + assert isinstance(cmd, list) + assert "--mcp-config" in cmd + mcp_config = json.loads(cmd[cmd.index("--mcp-config") + 1]) + server = mcp_config["mcpServers"]["hermes-tools"] + assert server["args"] == ["-m", "agent.transports.hermes_tools_mcp_server"] + assert server["env"]["HERMES_KANBAN_TASK"] == "t_12345678" + assert server["env"]["HERMES_KANBAN_BOARD"] == "ai-stack" + assert server["env"]["HERMES_PROFILE"] == "coding-agent" + + assert "--allowedTools" in cmd + allowed_tools = cmd[cmd.index("--allowedTools") + 1] + assert "mcp__hermes-tools__kanban_show" in allowed_tools + assert "mcp__hermes-tools__kanban_complete" in allowed_tools + assert "mcp__hermes-tools__kanban_block" in allowed_tools diff --git a/tests/hermes_cli/test_kanban_core_functionality.py b/tests/hermes_cli/test_kanban_core_functionality.py index a97ddbbe15b5..20d723e97378 100644 --- a/tests/hermes_cli/test_kanban_core_functionality.py +++ b/tests/hermes_cli/test_kanban_core_functionality.py @@ -2976,6 +2976,9 @@ def test_default_spawn_appends_per_task_skills(kanban_home, monkeypatch): """Dispatcher argv must carry one `--skills X` pair per task skill, in addition to the built-in kanban-worker.""" monkeypatch.setattr(kb, "_kanban_worker_skill_available", lambda _h: True) + monkeypatch.setattr( + kb, "_worker_skill_available", lambda _h, _name: True, raising=False + ) captured = {} class FakeProc: @@ -3023,9 +3026,56 @@ def fake_popen(cmd, **kwargs): ) +def test_default_spawn_skips_unavailable_per_task_skills(kanban_home, monkeypatch): + """Missing force-loaded skills must not crash dispatcher-spawned workers.""" + monkeypatch.setattr(kb, "_kanban_worker_skill_available", lambda _h: True) + monkeypatch.setattr( + kb, + "_worker_skill_available", + lambda _h, name: name != "sdlc-review", + raising=False, + ) + captured = {} + + class FakeProc: + pid = 43 + + def fake_popen(cmd, **kwargs): + captured["cmd"] = cmd + return FakeProc() + + monkeypatch.setattr("subprocess.Popen", fake_popen) + + conn = kb.connect() + try: + tid = kb.create_task( + conn, + title="review worker", + assignee="reviewer", + skills=["sdlc-review", "github-code-review"], + ) + task = kb.get_task(conn, tid) + workspace = kb.resolve_workspace(task) + kb._default_spawn(task, str(workspace)) + finally: + conn.close() + + cmd = captured["cmd"] + skill_names = [ + cmd[i + 1] + for i, tok in enumerate(cmd) + if tok == "--skills" and i + 1 < len(cmd) + ] + assert "sdlc-review" not in skill_names + assert "github-code-review" in skill_names + + def test_default_spawn_dedupes_kanban_worker_from_task_skills(kanban_home, monkeypatch): """If a task explicitly lists 'kanban-worker', we don't double-pass it.""" monkeypatch.setattr(kb, "_kanban_worker_skill_available", lambda _h: True) + monkeypatch.setattr( + kb, "_worker_skill_available", lambda _h, _name: True, raising=False + ) captured = {} class FakeProc: diff --git a/tests/hermes_cli/test_kanban_db.py b/tests/hermes_cli/test_kanban_db.py index 25ef4e9f865f..c320cdd14c58 100644 --- a/tests/hermes_cli/test_kanban_db.py +++ b/tests/hermes_cli/test_kanban_db.py @@ -1323,6 +1323,147 @@ def fake_spawn(task, workspace): assert kb.get_task(conn, t).status == "ready" +def test_dispatch_preflight_defers_unhealthy_profile_before_claim( + kanban_home, all_assignees_spawnable, monkeypatch +): + """dispatch_once leaves a task retryable when its profile runtime is unhealthy.""" + reason = "Claude CLI is not logged in; run `claude auth login --claudeai`." + spawned_ids = [] + + def fake_spawn(task, workspace): + spawned_ids.append(task.id) + + monkeypatch.setattr( + kb, + "_kanban_profile_runtime_preflight", + lambda assignee, cache: reason, + raising=False, + ) + + with kb.connect() as conn: + t = kb.create_task(conn, title="cli-auth", assignee="coding-agent") + res = kb.dispatch_once(conn, spawn_fn=fake_spawn) + task = kb.get_task(conn, t) + events = kb.list_events(conn, t) + + assert task is not None + assert task.status == "ready" + assert task.claim_lock is None + assert task.current_run_id is None + assert spawned_ids == [] + assert res.spawned == [] + assert res.profile_unhealthy == [(t, reason)] + + event = next(e for e in events if e.kind == "spawn_preflight_failed") + assert isinstance(event.payload, dict) + assert event.payload == {"assignee": "coding-agent", "reason": reason} + + +def test_dispatch_review_preflight_defers_unhealthy_profile_before_claim( + kanban_home, all_assignees_spawnable, monkeypatch +): + """Review tasks get the same profile-runtime preflight as ready tasks.""" + reason = "Claude CLI auth check failed: expired token" + monkeypatch.setattr( + kb, + "_kanban_profile_runtime_preflight", + lambda assignee, cache: reason, + raising=False, + ) + + with kb.connect() as conn: + t = kb.create_task(conn, title="review-cli-auth", assignee="code-review-agent") + conn.execute("UPDATE tasks SET status = 'review' WHERE id = ?", (t,)) + res = kb.dispatch_once(conn, spawn_fn=lambda task, ws: None) + task = kb.get_task(conn, t) + events = kb.list_events(conn, t) + + assert task is not None + assert task.status == "review" + assert task.claim_lock is None + assert res.spawned == [] + assert res.profile_unhealthy == [(t, reason)] + assert any(e.kind == "spawn_preflight_failed" for e in events) + + +def test_config_uses_claude_cli_runtime_aliases(): + assert kb._config_uses_claude_cli_runtime( + {"model": {"provider": "claude-cli"}} + ) + assert kb._config_uses_claude_cli_runtime( + {"model": {"provider": "anthropic", "claude_runtime": "cli"}} + ) + assert not kb._config_uses_claude_cli_runtime( + {"model": {"provider": "openai-codex"}} + ) + assert not kb._config_uses_claude_cli_runtime({"model": "claude-sonnet-4"}) + + +def test_claude_cli_auth_preflight_parses_logged_out_json(monkeypatch): + class Result: + def __init__(self, returncode, stdout="", stderr=""): + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + monkeypatch.setattr( + kb.subprocess, + "run", + lambda *args, **kwargs: Result( + 1, '{"loggedIn": false, "authMethod": "none"}' + ), + ) + + assert kb._claude_cli_auth_preflight() == ( + "Claude CLI is not logged in; run `claude auth login --claudeai`." + ) + + +def test_claude_cli_auth_preflight_smoke_catches_invalid_credentials(monkeypatch): + class Result: + def __init__(self, returncode, stdout="", stderr=""): + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + calls = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + if cmd[:3] == ["claude", "auth", "status"]: + return Result(0, '{"loggedIn": true, "authMethod": "claude.ai"}') + return Result( + 1, + "", + "Failed to authenticate. API Error: 401 Invalid authentication credentials", + ) + + monkeypatch.setattr(kb.subprocess, "run", fake_run) + + assert kb._claude_cli_auth_preflight() == ( + "Claude CLI is not logged in; run `claude auth login --claudeai`." + ) + assert len(calls) == 2 + assert calls[1][:2] == ["claude", "-p"] + + +def test_claude_cli_auth_preflight_smoke_passes_when_inference_works(monkeypatch): + class Result: + def __init__(self, returncode, stdout="", stderr=""): + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + def fake_run(cmd, **kwargs): + if cmd[:3] == ["claude", "auth", "status"]: + return Result(0, '{"loggedIn": true, "authMethod": "claude.ai"}') + return Result(0, "OK") + + monkeypatch.setattr(kb.subprocess, "run", fake_run) + + assert kb._claude_cli_auth_preflight() is None + + def test_dispatch_respawn_guard_skips_recent_success( kanban_home, all_assignees_spawnable ): @@ -3187,4 +3328,3 @@ def test_maybe_emit_scratch_tip_skips_non_scratch_workspaces(kanban_home, caplog "SELECT kind FROM task_events WHERE task_id = ?", (tid,), ).fetchall() assert "tip_scratch_workspace" not in [e["kind"] for e in events] - diff --git a/tests/hermes_cli/test_runtime_provider_resolution.py b/tests/hermes_cli/test_runtime_provider_resolution.py index 394216c9171c..9efe4a099316 100644 --- a/tests/hermes_cli/test_runtime_provider_resolution.py +++ b/tests/hermes_cli/test_runtime_provider_resolution.py @@ -150,6 +150,30 @@ def test_resolve_runtime_provider_codex(monkeypatch): assert resolved["requested_provider"] == "openai-codex" +def test_resolve_runtime_provider_claude_cli_skips_anthropic_oauth(monkeypatch): + def _unexpected_provider_resolution(*args, **kwargs): + raise AssertionError("claude-cli should not resolve through provider auth") + + monkeypatch.setattr(rp, "resolve_provider", _unexpected_provider_resolution) + monkeypatch.setattr( + rp, + "_get_model_config", + lambda: { + "provider": "claude-cli", + "default": "claude-sonnet-4-6", + }, + ) + + resolved = rp.resolve_runtime_provider(requested="claude-cli") + + assert resolved["provider"] == "claude-cli" + assert resolved["api_mode"] == "claude_cli" + assert resolved["base_url"] == "claude-cli://local" + assert resolved["api_key"] == "" + assert resolved["source"] == "claude-cli" + assert resolved["requested_provider"] == "claude-cli" + + def test_resolve_runtime_provider_qwen_oauth(monkeypatch): monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "qwen-oauth") monkeypatch.setattr( diff --git a/tests/hermes_cli/test_status.py b/tests/hermes_cli/test_status.py index 3cee9ab10ba7..9d0964f420db 100644 --- a/tests/hermes_cli/test_status.py +++ b/tests/hermes_cli/test_status.py @@ -83,6 +83,47 @@ def test_show_status_reports_nous_auth_error(monkeypatch, capsys, tmp_path): assert "Key exp:" in output +def test_show_status_explains_claude_cli_runtime(monkeypatch, capsys, tmp_path): + from hermes_cli import status as status_mod + import hermes_cli.auth as auth_mod + import hermes_cli.gateway as gateway_mod + + monkeypatch.setattr(status_mod, "get_env_path", lambda: tmp_path / ".env", raising=False) + monkeypatch.setattr(status_mod, "get_hermes_home", lambda: tmp_path, raising=False) + monkeypatch.setattr( + status_mod, + "load_config", + lambda: { + "model": { + "provider": "claude-cli", + "default": "claude-sonnet-4-6", + } + }, + raising=False, + ) + monkeypatch.setattr(status_mod, "resolve_requested_provider", lambda requested=None: "claude-cli", raising=False) + monkeypatch.setattr(auth_mod, "get_nous_auth_status", lambda: {}, raising=False) + monkeypatch.setattr(auth_mod, "get_codex_auth_status", lambda: {}, raising=False) + monkeypatch.setattr(auth_mod, "get_qwen_auth_status", lambda: {}, raising=False) + monkeypatch.setattr(auth_mod, "get_minimax_oauth_auth_status", lambda: {}, raising=False) + monkeypatch.setattr(gateway_mod, "find_gateway_pids", lambda exclude_pids=None: [], raising=False) + + class _ClaudeStatus: + returncode = 0 + stdout = '{"loggedIn": true, "authMethod": "claude.ai", "subscriptionType": "max"}' + stderr = "" + + monkeypatch.setattr(status_mod.subprocess, "run", lambda *a, **k: _ClaudeStatus()) + + status_mod.show_status(SimpleNamespace(all=False, deep=False)) + + output = capsys.readouterr().out + assert "Provider: Claude CLI" in output + assert "Claude CLI ✓ logged in via Claude CLI" in output + assert "Plan: max" in output + assert "Auth: claude.ai" in output + + def test_show_status_reports_vercel_backend_contract(monkeypatch, capsys, tmp_path): from hermes_cli import status as status_mod import hermes_cli.auth as auth_mod diff --git a/tests/run_agent/test_claude_cli_integration.py b/tests/run_agent/test_claude_cli_integration.py new file mode 100644 index 000000000000..5adc85a0d334 --- /dev/null +++ b/tests/run_agent/test_claude_cli_integration.py @@ -0,0 +1,95 @@ +"""Integration test for the claude_cli runtime path through AIAgent.""" + +from __future__ import annotations + +from unittest.mock import patch + +import run_agent +from agent.transports.claude_cli_session import ClaudeCliSession, ClaudeCliTurnResult + + +def _make_claude_cli_agent(): + return run_agent.AIAgent( + provider="claude-cli", + api_mode="claude_cli", + model="sonnet", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + +def test_claude_cli_mode_does_not_require_provider_api_key(monkeypatch): + def _unexpected_openai_client(**kwargs): + raise AssertionError("claude_cli should not initialize an OpenAI client") + + monkeypatch.setattr(run_agent, "OpenAI", _unexpected_openai_client) + + agent = _make_claude_cli_agent() + + assert agent.api_mode == "claude_cli" + assert agent.provider == "claude-cli" + assert agent.api_key == "" + assert agent.client is None + + +def test_run_conversation_returns_claude_cli_shape(monkeypatch): + seen = {} + + def fake_run_turn(self, *, messages, user_input): + seen["messages"] = messages + seen["user_input"] = user_input + return ClaudeCliTurnResult(final_text=f"cli: {user_input}") + + monkeypatch.setattr(ClaudeCliSession, "run_turn", fake_run_turn) + + agent = _make_claude_cli_agent() + with patch.object(agent, "_spawn_background_review", return_value=None): + result = agent.run_conversation("hello from max") + + assert result["final_response"] == "cli: hello from max" + assert result["completed"] is True + assert result["partial"] is False + assert result["error"] is None + assert result["api_calls"] == 1 + assert result["messages"][-1] == { + "role": "assistant", + "content": "cli: hello from max", + } + assert seen["user_input"] == "hello from max" + assert any(m.get("role") == "system" for m in seen["messages"]) + assert any(m.get("role") == "user" and m.get("content") == "hello from max" for m in seen["messages"]) + + +def test_kanban_worker_text_fallback_blocks_task(monkeypatch): + """If Claude CLI returns prose without a terminal Kanban MCP call, + Hermes should block the task itself rather than creating a dispatcher + protocol violation.""" + monkeypatch.setenv("HERMES_KANBAN_TASK", "t_test_task_123") + monkeypatch.setattr( + run_agent.AIAgent, + "_kanban_task_needs_terminal_transition", + lambda self, task_id: True, + raising=False, + ) + + def fake_run_turn(self, *, messages, user_input): + return ClaudeCliTurnResult(final_text="I could not write files.") + + monkeypatch.setattr(ClaudeCliSession, "run_turn", fake_run_turn) + + agent = _make_claude_cli_agent() + with ( + patch.object(agent, "_spawn_background_review", return_value=None), + patch("run_agent.handle_function_call", return_value='{"ok": true}') as hfc, + ): + result = agent.run_conversation("work kanban task t_test_task_123") + + assert result["completed"] is False + kanban_block_calls = [ + c for c in hfc.call_args_list if c.args and c.args[0] == "kanban_block" + ] + assert len(kanban_block_calls) == 1 + args = kanban_block_calls[0].args[1] + assert args["task_id"] == "t_test_task_123" + assert "external-runtime-prose" in args["reason"] diff --git a/tests/tools/test_kanban_tools.py b/tests/tools/test_kanban_tools.py index 80b08377ab51..7a326b48d3cb 100644 --- a/tests/tools/test_kanban_tools.py +++ b/tests/tools/test_kanban_tools.py @@ -616,6 +616,26 @@ def test_block_rejects_empty_reason(worker_env): assert json.loads(out).get("error") +def test_block_rejects_review_required_handoff(worker_env): + """Review gates should complete/create review work, not sticky-block.""" + from hermes_cli import kanban_db as kb + from tools import kanban_tools as kt + + out = kt._handle_block({ + "reason": "review-required: implementation finished; needs eyes", + }) + err = json.loads(out).get("error", "") + assert "kanban_complete" in err + assert "review child" in err + + conn = kb.connect() + try: + task = kb.get_task(conn, worker_env) + assert task.status == "running" + finally: + conn.close() + + def test_heartbeat_happy_path(worker_env): from tools import kanban_tools as kt out = kt._handle_heartbeat({"note": "progress"}) @@ -1142,6 +1162,8 @@ def test_kanban_guidance_in_worker_prompt(monkeypatch, tmp_path): assert "kanban_complete" in prompt assert "kanban_block" in prompt assert "kanban_create" in prompt + assert 'kanban_block(reason="review-required' not in prompt + assert "review child" in prompt # Anti-shell guidance assert "Do not shell out" in prompt or "tools — they work" in prompt diff --git a/tools/kanban_tools.py b/tools/kanban_tools.py index 29b5618e6815..09d1c06cf704 100644 --- a/tools/kanban_tools.py +++ b/tools/kanban_tools.py @@ -511,6 +511,11 @@ def _handle_complete(args: dict, **kw) -> str: return tool_error(f"kanban_complete: {e}") +def _looks_like_review_required_handoff(reason: object) -> bool: + text = str(reason or "").strip().lower() + return text.startswith("review-required:") or text.startswith("review required:") + + def _handle_block(args: dict, **kw) -> str: """Transition the task to blocked with a reason a human will read.""" tid = _default_task_id(args.get("task_id")) @@ -524,6 +529,15 @@ def _handle_block(args: dict, **kw) -> str: reason = args.get("reason") if not reason or not str(reason).strip(): return tool_error("reason is required — explain what input you need") + if _looks_like_review_required_handoff(reason): + return tool_error( + "review handoffs must not use kanban_block. Add any review " + "handoff details with kanban_comment if needed, create a review " + "child with kanban_create(..., parents=[your-task-id]) if one " + "does not already exist, then call kanban_complete with summary " + "and metadata. The review child is the gate; blocking this task " + "for review can deadlock its dependents." + ) board = args.get("board") try: kb, conn = _connect(board=board) @@ -965,7 +979,8 @@ def _board_schema_prop() -> dict[str, str]: "to proceed. ``reason`` will be shown to the human on the " "board and included in context when someone unblocks you. " "Use for genuine blockers only — don't block on things you can " - "resolve yourself." + "resolve yourself. Do not use this for code-review handoffs; " + "create/route a review child and complete this task instead." ), "parameters": { "type": "object", diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 87c4f849b406..85f5afb78c7e 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -548,6 +548,10 @@ Advanced per-platform knobs for throttling the outbound message batcher. Most us | `HERMES_HUMAN_DELAY_MAX_MS` | Custom delay range maximum (ms) | | `HERMES_QUIET` | Suppress non-essential output (`true`/`false`) | | `CODEX_HOME` | When [Codex app-server runtime](../user-guide/features/codex-app-server-runtime) is enabled, override the directory Codex CLI reads its config + auth from (default: `~/.codex`). Hermes' migration writes the managed block to `/config.toml`. | +| `HERMES_CLAUDE_CLI_BIN` | Override the Claude Code CLI executable used by the optional [Claude CLI runtime](../user-guide/features/claude-cli-runtime) (default: `claude`). | +| `HERMES_CLAUDE_CLI_MODEL` | Fallback Claude CLI model when Hermes did not pass a model name. Prefer `model.default` in `config.yaml` for normal use. | +| `HERMES_CLAUDE_CLI_TIMEOUT_SECONDS` | Timeout for one Claude CLI turn in seconds (default: `900`). | +| `HERMES_CLAUDE_CLI_EXTRA_ARGS` | Extra shell-style arguments appended to the `claude -p` invocation used by the Claude CLI runtime. | | `HERMES_KANBAN_TASK` | Set by the kanban dispatcher when spawning a worker (task UUID). Workers and the spawned `hermes-tools` MCP subprocess inherit it so kanban tools gate correctly. Don't set manually. | | `HERMES_API_TIMEOUT` | LLM API call timeout in seconds (default: `1800`) | | `HERMES_API_CALL_STALE_TIMEOUT` | Non-streaming stale-call timeout in seconds (default: `300`). Auto-disabled for local providers when left unset. Also configurable via `providers..stale_timeout_seconds` or `providers..models..stale_timeout_seconds` in `config.yaml`. | diff --git a/website/docs/user-guide/features/claude-cli-runtime.md b/website/docs/user-guide/features/claude-cli-runtime.md new file mode 100644 index 000000000000..2d586413a042 --- /dev/null +++ b/website/docs/user-guide/features/claude-cli-runtime.md @@ -0,0 +1,118 @@ +--- +title: Claude CLI Runtime (optional) +sidebar_label: Claude CLI Runtime +--- + +# Claude CLI Runtime + +Hermes can optionally hand a whole turn to the local Claude Code CLI instead of using Hermes' Anthropic API/OAuth transport. This is useful when you already use Claude Code locally and want Hermes gateway, Kanban, and cron flows to run through the same `claude` login and subscription. + +This runtime is **opt-in only**. Default Hermes behavior is unchanged unless you configure it. + +## When To Use It + +Use the Claude CLI runtime when: + +- You want Hermes to use Claude Code's local authentication instead of an Anthropic API key. +- You want to run Kanban workers through `claude -p` while keeping Hermes' task lifecycle. +- You prefer Claude Code's local model selection and subscription behavior for a specific Hermes profile. + +Use the default Hermes runtime when: + +- You need Hermes' native streaming/tool loop for fine-grained tool events. +- You depend on provider-level Anthropic features that are not exposed through `claude -p`. +- You need exact API credentials or routing through Hermes credential pools. + +## Configuration + +The direct form is: + +```yaml +# ~/.hermes/config.yaml +model: + provider: claude-cli + default: sonnet +``` + +You can also keep `provider: anthropic` and ask Hermes to use the CLI transport: + +```yaml +model: + provider: anthropic + default: sonnet + claude_runtime: cli +``` + +Hermes resolves both forms to: + +- `provider: claude-cli` +- `api_mode: claude_cli` +- no Hermes Anthropic API key required +- one `claude -p` process per turn + +To use a non-default executable: + +```yaml +model: + provider: claude-cli + default: sonnet + claude_cli_bin: /opt/homebrew/bin/claude +``` + +The equivalent environment override is: + +```bash +HERMES_CLAUDE_CLI_BIN=/opt/homebrew/bin/claude +``` + +## Runtime Options + +| Setting | Description | +|---------|-------------| +| `model.default` | Model name passed to `claude -p --model`. | +| `model.claude_runtime: cli` | Enables the CLI runtime while keeping `provider: anthropic`. | +| `model.claude_cli_bin` | CLI executable path. | +| `HERMES_CLAUDE_CLI_BIN` | Environment fallback for the executable path. | +| `HERMES_CLAUDE_CLI_MODEL` | Fallback model when Hermes did not pass one. Prefer `model.default` for normal profiles. | +| `HERMES_CLAUDE_CLI_TIMEOUT_SECONDS` | Per-turn timeout, default `900`. | +| `HERMES_CLAUDE_CLI_EXTRA_ARGS` | Extra shell-style arguments appended to the `claude -p` command. | + +## Status And Auth + +`hermes status` shows `Claude CLI` when this runtime is active. Hermes checks `claude auth status` and reports the CLI auth method and subscription when the local CLI exposes them. + +Hermes does not require `ANTHROPIC_API_KEY`, Anthropic OAuth, or Hermes credential-pool entries for this runtime. The Claude Code CLI owns authentication. + +## Kanban Workers + +When a Hermes Kanban worker runs through Claude CLI, Hermes attaches a small MCP bridge named `hermes-tools` to the `claude -p` process. That bridge exposes the Kanban lifecycle tools the dispatcher needs: + +- `kanban_show` +- `kanban_comment` +- `kanban_block` +- `kanban_complete` +- `kanban_heartbeat` +- related list/link/unblock helpers + +The worker prompt tells Claude to finish every assigned task with `kanban_complete` or `kanban_block`. If the CLI returns final prose while the task is still `running`, Hermes has a last-resort guard that calls `kanban_block` with an `external-runtime-prose:` reason so the dispatcher does not record a clean-exit protocol violation. + +## Verification + +After upgrading Hermes or Claude Code CLI, run: + +```bash +./scripts/run_tests.sh \ + tests/agent/transports/test_claude_cli_session.py \ + tests/run_agent/test_claude_cli_integration.py \ + tests/hermes_cli/test_runtime_provider_resolution.py::test_resolve_runtime_provider_claude_cli_skips_anthropic_oauth \ + tests/hermes_cli/test_status.py::test_show_status_explains_claude_cli_runtime \ + -q +``` + +Then do a live smoke test from a Claude-CLI-backed profile: + +```bash +HERMES_PROFILE=claude-cli-smoke hermes -z 'Reply exactly OK.' +``` + +Expected result: `OK`. If Hermes asks for Anthropic credentials, runtime resolution is not selecting `api_mode: claude_cli`. diff --git a/website/sidebars.ts b/website/sidebars.ts index b0cd3a470fd7..2c6fb10237d0 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -78,6 +78,7 @@ const sidebars: SidebarsConfig = { 'user-guide/features/delegation', 'user-guide/features/kanban', 'user-guide/features/codex-app-server-runtime', + 'user-guide/features/claude-cli-runtime', 'user-guide/features/kanban-tutorial', 'user-guide/features/kanban-worker-lanes', 'user-guide/features/goals',