diff --git a/.env.example b/.env.example index 6dfcbdcc612f..78d0dcf9fbae 100644 --- a/.env.example +++ b/.env.example @@ -67,12 +67,12 @@ # ============================================================================= # MiniMax provides access to MiniMax models (global endpoint) # Get your key at: https://www.minimax.io -# MINIMAX_API_KEY= -# MINIMAX_BASE_URL=https://api.minimax.io/v1 # Override default base URL +# MINIMAX_API_KEY=*** +# MINIMAX_BASE_URL=https://api.minimax.io/anthropic # Anthropic-compatible endpoint (required for prompt caching) # MiniMax China endpoint (for users in mainland China) -# MINIMAX_CN_API_KEY= -# MINIMAX_CN_BASE_URL=https://api.minimaxi.com/v1 # Override default base URL +# MINIMAX_CN_API_KEY=*** +# MINIMAX_CN_BASE_URL=https://api.minimaxi.com/anthropic # Anthropic-compatible endpoint (required for prompt caching) # ============================================================================= # LLM PROVIDER (OpenCode Zen) diff --git a/.gitignore b/.gitignore index 6ae86265a60c..06e6e9e9f2a2 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,7 @@ mini-swe-agent/ result website/static/api/skills-index.json models-dev-upstream/ + +# Local project memory and session artifacts (never commit) +.codex +hermes_conversation_*.json diff --git a/AGENTS_SETUP.md b/AGENTS_SETUP.md new file mode 100644 index 000000000000..b503b18d6a64 --- /dev/null +++ b/AGENTS_SETUP.md @@ -0,0 +1,102 @@ +# Agent Setup Guide + +How to set up and run the multi-agent Kanban coding roster on your machine. + +## Prerequisites + +- Hermes Agent installed and working (`hermes chat -q "hello"`) +- API keys for your preferred providers in `~/.hermes/.env` +- Git access to this repo + +## Quick Start + +```bash +# 1. Pull latest +cd ~/Projects/hermes-agent +git pull origin main + +# 2. Sync deploy target (if using gateway) +cd ~/.hermes/hermes-agent +git pull local-project main + +# 3. Create profiles (one-time) +hermes profile create riqui +hermes profile create miki +hermes profile create maxi + +# 4. Copy configs from repo +cp ~/Projects/hermes-agent/profiles/riqui/config.yaml ~/.hermes/profiles/riqui/ +cp ~/Projects/hermes-agent/profiles/miki/config.yaml ~/.hermes/profiles/miki/ +cp ~/Projects/hermes-agent/profiles/maxi/config.yaml ~/.hermes/profiles/maxi/ + +# 5. ADAPT PROVIDERS TO YOUR STACK (IMPORTANT) +# Edit each profile's config.yaml: +# - model.provider: your provider (openrouter, anthropic, nous, etc.) +# - model.default: your model name +# - model.base_url: your provider's endpoint (if needed) +# - model.api_key or symlink .env +$EDITOR ~/.hermes/profiles/riqui/config.yaml +$EDITOR ~/.hermes/profiles/miki/config.yaml +$EDITOR ~/.hermes/profiles/maxi/config.yaml + +# 6. Copy SOUL.md files +cp ~/Projects/hermes-agent/profiles/riqui/SOUL.md ~/.hermes/profiles/riqui/ +cp ~/Projects/hermes-agent/profiles/miki/SOUL.md ~/.hermes/profiles/miki/ +cp ~/Projects/hermes-agent/profiles/maxi/SOUL.md ~/.hermes/profiles/maxi/ + +# 7. Symlink .env and agent-memory +ln -sf ~/.hermes/.env ~/.hermes/profiles/riqui/.env +ln -sf ~/.hermes/.env ~/.hermes/profiles/miki/.env +ln -sf ~/.hermes/.env ~/.hermes/profiles/maxi/.env +ln -sf ~/.hermes/agent-memory ~/.hermes/profiles/riqui/agent-memory +ln -sf ~/.hermes/agent-memory ~/.hermes/profiles/miki/agent-memory +ln -sf ~/.hermes/agent-memory ~/.hermes/profiles/maxi/agent-memory + +# 8. Test each profile +hermes -p riqui chat -q "hello" --quiet +hermes -p miki chat -q "hello" --quiet +hermes -p maxi chat -q "hello" --quiet # ⚠ known issue: maxi needs api_mode fix +``` + +## Profile Reference + +| Profile | Purpose | Key config | Status | +|---------|---------|-----------|--------| +| riqui | Fast surgical coding | max_turns=30, reasoning=minimal | ✓ Working | +| miki | Deep-thinking coding (Kimi) | max_turns=30, reasoning=high | ✓ Working | +| maxi | Deep-thinking coding (MiniMax) | max_turns=30, reasoning=high, Anthropic endpoint | ⚠ API mode bug | + +## Provider Adaptation + +The profiles assume our stack (DeepSeek, Kimi OAuth, MiniMax API key). To use different providers: + +### Using OpenRouter +```yaml +model: + default: openai/gpt-5.4 # or anthropic/claude-sonnet-4-6, etc. + provider: openrouter +``` + +### Using Anthropic Direct +```yaml +model: + default: claude-sonnet-4-6-20250514 + provider: anthropic +``` + +### Using Nous Portal +```yaml +model: + default: anthropic/claude-sonnet-4-6 + provider: nous +``` + +The `agent.max_turns` and `agent.reasoning_effort` settings are provider-agnostic. + +## Kanban Worker Rules (CRITICAL) + +- All coding profiles MUST have `max_turns >= 25` and `reasoning_effort >= minimal` +- Lower values cause protocol violations (exhausted iterations before kanban_complete) +- Kanban dispatcher spawns `hermes -p --skills kanban-worker chat -q "work kanban task "` +- Workers MUST end with `kanban_complete()` or `kanban_block()` — text-only exit is a violation +- Dispatcher auto-blocks after 1 protocol violation (effective_limit=1) diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000000..318bea90dc55 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,53 @@ +# CHANGELOG — nicoechaniz/hermes-agent fork + +Team-facing summary of changes to our fork. For upstream changes between syncs, +see `~/wiki/projects/hermes-agent/notes/upstream-changes-review.md`. + +--- + +## 2026-05-11 + +### Upstream sync (138 commits behind → caught up) + +- `nousmain` reset to upstream/main (`8e2eb4b51`) +- `main` merged with upstream (2 conflicts resolved: Kimi OAuth headers + ProviderProfile fallback) +- `feat/daemoncraft` merged into `main` (5 commits: embodied heartbeat, @name! interrupt, compressor fix, embodied_plan, kanban-review) +- Deployed to `~/.hermes/hermes-agent`, gateway restarted + +### Kimi K2.6 context window bug — fixed + +**Problem:** Hermes rejected `kimi-k2.6` with "context window of 32,768 tokens, below minimum 64,000". Real context is 262,144 (256K). + +**Root cause:** Two issues in the context-length resolution chain (`agent/model_metadata.py`, `agent/models_dev.py`): +1. `PROVIDER_TO_MODELS_DEV` was missing `"kimi"` and `"moonshot"` entries (only had `kimi-coding`/`kimi-coding-cn`) +2. OpenRouter metadata (community-maintained, incorrect for kimi-k2.6) was consulted BEFORE the project's own curated `DEFAULT_CONTEXT_LENGTHS` + +**Fix (3 changes in 2 files):** +1. Added `"kimi"` → `"kimi-for-coding"` and `"moonshot"` → `"kimi-for-coding"` to `PROVIDER_TO_MODELS_DEV` +2. Gated OpenRouter fallback behind `not effective_provider` — known providers skip third-party metadata and go straight to curated defaults +3. Added explicit `DEFAULT_CONTEXT_LENGTHS` entries for `kimi-k2.6`, `kimi-k2.5`, `kimi-k2`, `k2p6`, `k2p5` (all 262,144) + +**Result:** `provider: kimi` with `kimi-k2.6` now resolves to 262,144. No config workaround needed. + +**PR upstream:** https://github.com/NousResearch/hermes-agent/pull/23950 +**Cherry-picked to:** `feat/kimi-oauth-clean` + +### Kanban cleanup + +- hermes-agent board had ~2,778 synthetic test tasks from kanban development +- DB backed up to `kanban.db.backup-20260511-145448`, then deleted +- Fresh empty board auto-created on next CLI access + +### Branches + +| Branch | Status | Notes | +|--------|--------|-------| +| `nousmain` | Clean | Tracks upstream/main exactly | +| `main` | Integration | upstream + all our features | +| `feat/daemoncraft` | Active | Consolidated DC work (needs rebase onto new nousmain) | +| `feat/kimi-oauth-clean` | Active | Kimi OAuth + context-length fix | +| `fix/kimi-context-length-resolution` | Merged to main | Kimi context window fix | + +### Pending + +- `feat/daemoncraft` is on the old base — needs rebase onto new `nousmain` on next sync cycle diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 693826920cbf..978eebb44fc2 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1311,7 +1311,8 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]: return GeminiNativeClient(api_key=api_key, base_url=base_url), model extra = {} if base_url_host_matches(base_url, "api.kimi.com"): - extra["default_headers"] = {"User-Agent": "claude-code/0.1.0"} + from hermes_cli.auth import kimi_coding_default_headers + extra["default_headers"] = kimi_coding_default_headers() elif base_url_host_matches(base_url, "api.githubcopilot.com"): from hermes_cli.models import copilot_default_headers @@ -1346,7 +1347,8 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]: return GeminiNativeClient(api_key=api_key, base_url=base_url), model extra = {} if base_url_host_matches(base_url, "api.kimi.com"): - extra["default_headers"] = {"User-Agent": "claude-code/0.1.0"} + from hermes_cli.auth import kimi_coding_default_headers + extra["default_headers"] = kimi_coding_default_headers() elif base_url_host_matches(base_url, "api.githubcopilot.com"): from hermes_cli.models import copilot_default_headers @@ -2372,6 +2374,17 @@ def _refresh_provider_credentials(provider: str) -> bool: return False _evict_cached_clients(normalized) return True + if normalized in ("kimi-coding", "kimi-coding-cn"): + from hermes_cli.auth import resolve_kimi_coding_runtime_credentials + + creds = resolve_kimi_coding_runtime_credentials( + force_refresh=True, + allow_api_key_fallback=True, + ) + if not str(creds.get("api_key", "") or "").strip(): + return False + _evict_cached_clients(normalized) + return True except Exception as exc: logger.debug("Auxiliary provider credential refresh failed for %s: %s", normalized, exc) return False @@ -2586,7 +2599,8 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False): is_agent_turn=True, is_vision=is_vision ) elif base_url_host_matches(sync_base_url, "api.kimi.com"): - async_kwargs["default_headers"] = {"User-Agent": "claude-code/0.1.0"} + from hermes_cli.auth import kimi_coding_default_headers + async_kwargs["default_headers"] = kimi_coding_default_headers() else: # Fall back to profile.default_headers for providers that declare # client-level headers on their ProviderProfile (e.g. attribution @@ -2822,7 +2836,8 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", if _dq: extra["default_query"] = _dq if base_url_host_matches(custom_base, "api.kimi.com"): - extra["default_headers"] = {"User-Agent": "claude-code/0.1.0"} + from hermes_cli.auth import kimi_coding_default_headers + extra["default_headers"] = kimi_coding_default_headers() elif base_url_host_matches(custom_base, "api.githubcopilot.com"): from hermes_cli.copilot_auth import copilot_request_headers extra["default_headers"] = copilot_request_headers( @@ -3019,7 +3034,8 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", # Provider-specific headers headers = {} if base_url_host_matches(base_url, "api.kimi.com"): - headers["User-Agent"] = "claude-code/0.1.0" + from hermes_cli.auth import kimi_coding_default_headers + headers.update(kimi_coding_default_headers()) elif base_url_host_matches(base_url, "api.githubcopilot.com"): from hermes_cli.copilot_auth import copilot_request_headers diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 885b0ca7895c..7878990b568b 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -75,6 +75,83 @@ _IMAGE_CHAR_EQUIVALENT = _IMAGE_TOKEN_ESTIMATE * _CHARS_PER_TOKEN _SUMMARY_FAILURE_COOLDOWN_SECONDS = 600 +# Default summarizer preamble (used when config does not override). +_DEFAULT_SUMMARIZER_PREAMBLE = ( + "You are a summarization agent creating a context checkpoint. " + "Your output will be injected as reference material for a DIFFERENT " + "assistant that continues the conversation. " + "Do NOT respond to any questions or requests in the conversation — " + "only output the structured summary. " + "Do NOT include any preamble, greeting, or prefix. " + "Write the summary in the same language the user was using in the " + "conversation — do not translate or switch to English. " + "NEVER include API keys, tokens, passwords, secrets, credentials, " + "or connection strings in the summary — replace any that appear " + "with [REDACTED]. Note that the user had credentials present, but " + "do not preserve their values." +) + +# Default structured template sections (used when config does not override). +# The placeholder {summary_budget} is replaced with the computed token budget. +_DEFAULT_TEMPLATE_SECTIONS = """## Active Task +[THE SINGLE MOST IMPORTANT FIELD. Copy the user's most recent request or +task assignment verbatim — the exact words they used. If multiple tasks +were requested and only some are done, list only the ones NOT yet completed. +The next assistant must pick up exactly here. Example: +"User asked: 'Now refactor the auth module to use JWT instead of sessions'" +If no outstanding task exists, write "None."] + +## Goal +[What the user is trying to accomplish overall] + +## Constraints & Preferences +[User preferences, coding style, constraints, important decisions] + +## Completed Actions +[Numbered list of concrete actions taken — include tool used, target, and outcome. +Format each as: N. ACTION target — outcome [tool: name] +Example: +1. READ config.py:45 — found `==` should be `!=` [tool: read_file] +2. PATCH config.py:45 — changed `==` to `!=` [tool: patch] +3. TEST `pytest tests/` — 3/50 failed: test_parse, test_validate, test_edge [tool: terminal] +Be specific with file paths, commands, line numbers, and results.] + +## Active State +[Current working state — include: +- Working directory and branch (if applicable) +- Modified/created files with brief note on each +- Test status (X/Y passing) +- Any running processes or servers +- Environment details that matter] + +## In Progress +[Work currently underway — what was being done when compaction fired] + +## Blocked +[Any blockers, errors, or issues not yet resolved. Include exact error messages.] + +## Key Decisions +[Important technical decisions and WHY they were made] + +## Resolved Questions +[Questions the user asked that were ALREADY answered — include the answer so the next assistant does not re-answer them] + +## Pending User Asks +[Questions or requests from the user that have NOT yet been answered or fulfilled. If none, write "None."] + +## Relevant Files +[Files read, modified, or created — with brief note on each] + +## Remaining Work +[What remains to be done — framed as context, not instructions] + +## Critical Context +[Any specific values, error messages, configuration details, or data that would be lost without explicit preservation. NEVER include API keys, tokens, passwords, or credentials — write [REDACTED] instead.] + +Target ~{summary_budget} tokens. Be CONCRETE — include file paths, command outputs, error messages, line numbers, and specific values. Avoid vague descriptions like "made some changes" — say exactly what changed. + +Write only the summary body. Do not include any preamble or prefix.""" + def _content_length_for_budget(raw_content: Any) -> int: """Return the effective char-length of a message's content for token budgeting. @@ -415,6 +492,8 @@ def __init__( config_context_length: int | None = None, provider: str = "", api_mode: str = "", + summary_preamble: str = None, + summary_template: str = None, ): self.model = model self.base_url = base_url @@ -425,6 +504,8 @@ def __init__( self.protect_first_n = protect_first_n self.protect_last_n = protect_last_n self.summary_target_ratio = max(0.10, min(summary_target_ratio, 0.80)) + self.summary_preamble = summary_preamble + self.summary_template = summary_template self.quiet_mode = quiet_mode self.context_length = get_model_context_length( @@ -896,9 +977,18 @@ def _generate_summary(self, turns_to_summarize: List[Dict[str, Any]], focus_topi Write only the summary body. Do not include any preamble or prefix.""" + # Honour caller overrides (set via summary_preamble / summary_template + # kwargs at construction); fall back to the defaults defined above. + preamble = self.summary_preamble or _summarizer_preamble + template = self.summary_template or _template_sections + # Custom templates may include {summary_budget} as a placeholder. The + # default template is already an f-string and has no placeholders left. + if self.summary_template and "{summary_budget}" in template: + template = template.replace("{summary_budget}", str(summary_budget)) + if self._previous_summary: # Iterative update: preserve existing info, add new progress - prompt = f"""{_summarizer_preamble} + prompt = f"""{preamble} You are updating a context compaction summary. A previous compaction produced the summary below. New conversation turns have occurred since then and need to be incorporated. @@ -910,10 +1000,10 @@ def _generate_summary(self, turns_to_summarize: List[Dict[str, Any]], focus_topi Update the summary using this exact structure. PRESERVE all existing information that is still relevant. ADD new completed actions to the numbered list (continue numbering). Move items from "In Progress" to "Completed Actions" when done. Move answered questions to "Resolved Questions". Update "Active State" to reflect current state. Remove information only if it is clearly obsolete. CRITICAL: Update "## Active Task" to reflect the user's most recent unfulfilled request — this is the most important field for task continuity. -{_template_sections}""" +{template}""" else: # First compaction: summarize from scratch - prompt = f"""{_summarizer_preamble} + prompt = f"""{preamble} Create a structured checkpoint summary for the conversation after earlier turns are compacted. The summary should preserve enough detail for continuity without re-reading the original turns. @@ -922,7 +1012,7 @@ def _generate_summary(self, turns_to_summarize: List[Dict[str, Any]], focus_topi Use this exact structure: -{_template_sections}""" +{template}""" # Inject focus topic guidance when the user provides one via /compress . # This goes at the end of the prompt so it takes precedence. @@ -1345,6 +1435,9 @@ def has_content_to_compress(self, messages: List[Dict[str, Any]]) -> bool: the protected head/tail. """ compress_start = self._align_boundary_forward(messages, self.protect_first_n) + # Always preserve the system prompt as a literal message. + if compress_start == 0 and messages and messages[0].get("role") == "system": + compress_start = 1 compress_end = self._find_tail_cut_by_tokens(messages, compress_start) return compress_start < compress_end @@ -1402,6 +1495,11 @@ def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, f # Phase 2: Determine boundaries compress_start = self.protect_first_n compress_start = self._align_boundary_forward(messages, compress_start) + # Always preserve the system prompt as a literal message even when + # protect_first_n is 0. The user wants early conversation turns + # summarized, not the instructions that define agent identity. + if compress_start == 0 and messages and messages[0].get("role") == "system": + compress_start = 1 # Use token-budget tail protection instead of fixed message count compress_end = self._find_tail_cut_by_tokens(messages, compress_start) diff --git a/agent/model_metadata.py b/agent/model_metadata.py index cdca9ae5b2f6..ce0cd44cc49f 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -217,6 +217,11 @@ def _strip_provider_prefix(model: str) -> str: "grok": 131072, # catch-all (grok-beta, unknown grok-*) # Kimi "kimi": 262144, + "kimi-k2.6": 262144, + "kimi-k2.5": 262144, + "kimi-k2": 262144, + "k2p6": 262144, + "k2p5": 262144, # Tencent — Hy3 Preview (Hunyuan) with 256K context window. # OpenRouter live metadata reports 262144 (256 × 1024); align the # static fallback so cache and offline both agree (issue #22268). @@ -1467,10 +1472,16 @@ def get_model_context_length( if ctx: return ctx - # 6. OpenRouter live API metadata (provider-unaware fallback) - metadata = fetch_model_metadata() - if model in metadata: - return metadata[model].get("context_length", DEFAULT_FALLBACK_CONTEXT) + # 6. OpenRouter live API metadata — provider-unaware fallback. + # Only consulted when the provider is unknown (no effective_provider), + # because OpenRouter data is community-maintained and can be incorrect + # for models that belong to known providers with curated defaults. + if not effective_provider: + metadata = fetch_model_metadata() + if model in metadata: + return metadata[model].get("context_length", DEFAULT_FALLBACK_CONTEXT) + + # 7. (reserved) # 8. Hardcoded defaults (fuzzy match — longest key first for specificity) # Only check `default_model in model` (is the key a substring of the input). diff --git a/agent/models_dev.py b/agent/models_dev.py index fbb3153829ba..d3517a6a0d96 100644 --- a/agent/models_dev.py +++ b/agent/models_dev.py @@ -145,7 +145,9 @@ class ProviderInfo: "openai": "openai", "openai-codex": "openai", "zai": "zai", + "kimi": "kimi-for-coding", "kimi-coding": "kimi-for-coding", + "moonshot": "kimi-for-coding", "stepfun": "stepfun", "kimi-coding-cn": "kimi-for-coding", "minimax": "minimax", diff --git a/agent/transports/chat_completions.py b/agent/transports/chat_completions.py index 9b0dc32e5cc0..022049eecddd 100644 --- a/agent/transports/chat_completions.py +++ b/agent/transports/chat_completions.py @@ -10,8 +10,11 @@ """ import copy +import logging from typing import Any, Dict, List, Optional +logger = logging.getLogger(__name__) + from agent.lmstudio_reasoning import resolve_lmstudio_effort from agent.moonshot_schema import is_moonshot_model, sanitize_moonshot_tools from agent.prompt_builder import DEVELOPER_ROLE_MODELS @@ -388,6 +391,39 @@ def build_kwargs( if overrides: api_kwargs.update(overrides) + # Tool choice override for proactive/agentic turns + _tool_choice = params.get("tool_choice") + if _tool_choice: + # Kimi: tool_choice='required' is rejected when thinking is enabled. + # Fall back to letting the model decide — the system prompt still + # instructs it to use tools. + if is_kimi and _tool_choice == "required" and not _kimi_thinking_off: + logger.warning( + "[chat_completions] Skipping tool_choice='required' for Kimi " + "because thinking is enabled (incompatible)." + ) + else: + # Generic: disable thinking/reasoning for this turn when forcing + # tool_choice="required", since several providers reject the + # combination. Kimi is handled above (it cannot disable thinking). + if _tool_choice == "required": + _stripped_any = False + if "reasoning_effort" in api_kwargs: + api_kwargs.pop("reasoning_effort") + _stripped_any = True + if "extra_body" in api_kwargs: + _eb = api_kwargs["extra_body"] + if isinstance(_eb, dict) and "thinking_config" in _eb: + _eb.pop("thinking_config") + _stripped_any = True + if isinstance(_eb, dict) and not _eb: + api_kwargs.pop("extra_body") + if _stripped_any: + logger.info( + "[chat_completions] Disabled thinking for tool_choice='required' turn." + ) + api_kwargs["tool_choice"] = _tool_choice + return api_kwargs def _build_kwargs_from_profile(self, profile, model, sanitized, tools, params): diff --git a/cli.py b/cli.py index 10eb5212d019..9cdc9e91f031 100644 --- a/cli.py +++ b/cli.py @@ -359,6 +359,12 @@ def load_cli_config() -> Dict[str, Any]: "skin": "default", }, + "tui": { + "input_max_lines": 8, + "collapse_large_pastes": True, + "history_nav_requires_empty_input": False, + "show_full_input": False, + }, "clarify": { "timeout": 120, # Seconds to wait for a clarify answer before auto-proceeding }, @@ -2289,12 +2295,10 @@ def __init__( # "queue" (Enter queues for next turn), or "steer" (Enter injects # mid-run via /steer, arriving after the next tool call). _bim = str(CLI_CONFIG["display"].get("busy_input_mode", "interrupt")).strip().lower() - if _bim == "queue": - self.busy_input_mode = "queue" - elif _bim == "steer": - self.busy_input_mode = "steer" - else: - self.busy_input_mode = "interrupt" + self.busy_input_mode = _bim if _bim in ("queue", "steer") else "interrupt" + # ctrl_c_priority: "interrupt_agent" (default) or "clear_input" + _ccp = CLI_CONFIG["display"].get("ctrl_c_priority", "interrupt_agent") + self.ctrl_c_priority = "clear_input" if str(_ccp).strip().lower() == "clear_input" else "interrupt_agent" self.verbose = verbose if verbose is not None else (self.tool_progress_mode == "verbose") @@ -3374,9 +3378,13 @@ def _expand_ref(match): def _print_user_message_preview(self, user_input: str) -> None: """Render a user message using the normal chat scrollback style.""" + _show_full_input = bool(CLI_CONFIG.get("tui", {}).get("show_full_input", False)) ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]") text = str(user_input or "") - if "\n" in text: + if _show_full_input: + ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]") + ChatConsole().print(f"[bold {_accent_hex()}]●[/] [bold]{_escape(text)}[/]") + elif "\n" in text: ChatConsole().print(self._format_submitted_user_message_preview(text)) else: ChatConsole().print(f"[bold {_accent_hex()}]●[/] [bold]{_escape(text)}[/]") @@ -10617,9 +10625,24 @@ def run_agent(): all_parts.append(extra) except queue.Empty: break - combined = "\n".join(all_parts) + + # Normalize multimodal payloads: (text, images) tuples come from + # interrupt messages that contain pasted/attached images. + # Split text and images so we can combine text with join while + # preserving image attachments. + texts = [] + images = [] + for part in all_parts: + if isinstance(part, tuple): + texts.append(str(part[0]) if part else "") + if len(part) > 1: + images.extend(part[1]) + else: + texts.append(str(part)) + + combined = ("\n".join(texts), images) if images else "\n".join(texts) n = len(all_parts) - preview = combined[:50] + ("..." if len(combined) > 50 else "") + preview = "\n".join(texts)[:50] + ("..." if len("\n".join(texts)) > 50 else "") if n > 1: print(f"\n⚡ Sending {n} messages after interrupt: '{preview}'") else: @@ -11478,9 +11501,13 @@ def handler(event): lambda: not self._clarify_state and not self._approval_state and not self._slash_confirm_state and not self._sudo_state and not self._secret_state and not self._model_picker_state ) + _history_nav_requires_empty = bool(CLI_CONFIG.get("tui", {}).get("history_nav_requires_empty_input", False)) + @kb.add('up', filter=_normal_input) def history_up(event): """Up arrow: browse history when on first line, else move cursor up.""" + if _history_nav_requires_empty and event.app.current_buffer.text: + return event.app.current_buffer.auto_up(count=event.arg) @kb.add('down', filter=_normal_input) @@ -11576,13 +11603,21 @@ def handle_ctrl_c(event): event.app.invalidate() return + # When the user prefers "clear_input", Ctrl+C behaves like bash: + # clear the buffer first; only interrupt the agent when the buffer is empty. + if self.ctrl_c_priority == "clear_input" and (event.app.current_buffer.text or self._attached_images): + event.app.current_buffer.reset() + self._attached_images.clear() + event.app.invalidate() + return + if self._agent_running and self.agent: if now - self._last_ctrl_c_time < 2.0: print("\n⚡ Force exiting...") self._should_exit = True event.app.exit() return - + self._last_ctrl_c_time = now print("\n⚡ Interrupting agent... (press Ctrl+C again to force exit)") self.agent.interrupt() @@ -11846,6 +11881,9 @@ def _start_recording(): event.app.invalidate() from prompt_toolkit.keys import Keys + _input_max_lines = int(CLI_CONFIG.get("tui", {}).get("input_max_lines", 8)) + _collapse_large_pastes = bool(CLI_CONFIG.get("tui", {}).get("collapse_large_pastes", True)) + @kb.add(Keys.BracketedPaste, eager=True) def handle_paste(event): """Handle terminal paste — detect clipboard images. @@ -11881,7 +11919,7 @@ def handle_paste(event): pasted_text = _sanitize_surrogates(pasted_text) line_count = pasted_text.count('\n') buf = event.current_buffer - if line_count >= 5 and not buf.text.strip().startswith('/'): + if _collapse_large_pastes and line_count >= 5 and not buf.text.strip().startswith('/'): _paste_counter[0] += 1 paste_dir = _hermes_home / "pastes" paste_dir.mkdir(parents=True, exist_ok=True) @@ -11953,11 +11991,12 @@ def get_prompt(): command_filter=cli_ref._command_available, ) input_area = TextArea( - height=Dimension(min=1, max=8, preferred=1), + height=Dimension(min=1, max=_input_max_lines, preferred=1), prompt=get_prompt, style='class:input-area', multiline=True, wrap_lines=True, + scrollbar=True, read_only=Condition(lambda: bool(cli_ref._command_running)), history=FileHistory(str(self._history_file)), completer=_completer, @@ -11973,6 +12012,26 @@ def get_prompt(): # EEXIST. The suffix keeps markdown highlighting without that bug. input_area.buffer.tempfile_suffix = '.md' + # Guard history navigation so Up/Down only browse history when the input is empty. + if _history_nav_requires_empty: + _orig_auto_up = input_area.buffer.auto_up + _orig_auto_down = input_area.buffer.auto_down + + def _auto_up_guard(count=1, go_to_start_of_line_if_history_changes=False): + if input_area.buffer.text: + input_area.buffer.cursor_up(count) + else: + _orig_auto_up(count, go_to_start_of_line_if_history_changes) + + def _auto_down_guard(count=1, go_to_start_of_line_if_history_changes=False): + if input_area.buffer.text: + input_area.buffer.cursor_down(count) + else: + _orig_auto_down(count, go_to_start_of_line_if_history_changes) + + input_area.buffer.auto_up = _auto_up_guard + input_area.buffer.auto_down = _auto_down_guard + # Dynamic height: accounts for both explicit newlines AND visual # wrapping of long lines so the input area always fits its content. def _input_height(): @@ -11997,7 +12056,7 @@ def _input_height(): visual_lines += 1 else: visual_lines += max(1, -(-line_width // available_width)) # ceil division - return min(max(visual_lines, 1), 8) + return min(max(visual_lines, 1), _input_max_lines) except Exception: return 1 @@ -12048,7 +12107,7 @@ def _on_text_changed(buf): newlines_added = line_count - _prev_newline_count[0] _prev_newline_count[0] = line_count is_paste = chars_added > 1 or newlines_added >= 4 - if line_count >= 5 and is_paste and not text.startswith('/'): + if _collapse_large_pastes and line_count >= 5 and is_paste and not text.startswith('/'): _paste_counter[0] += 1 paste_dir = _hermes_home / "pastes" paste_dir.mkdir(parents=True, exist_ok=True) @@ -12822,10 +12881,19 @@ def process_loop(): # Expand paste references back to full content _paste_ref_re = re.compile(r'\[Pasted text #\d+: \d+ lines \u2192 (.+?)\]') paste_refs = list(_paste_ref_re.finditer(user_input)) if isinstance(user_input, str) else [] + _show_full_input = bool(CLI_CONFIG.get("tui", {}).get("show_full_input", False)) + _user_bar = f"[{_accent_hex()}]{'─' * 40}[/]" + print() + ChatConsole().print(_user_bar) if paste_refs: user_input = self._expand_paste_references(user_input) print() - self._print_user_message_preview(user_input) + _show_full_input = bool(CLI_CONFIG.get("tui", {}).get("show_full_input", False)) + if _show_full_input: + ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]") + ChatConsole().print(f"[bold {_accent_hex()}]●[/] [bold]{_escape(user_input)}[/]") + else: + self._print_user_message_preview(user_input) # Show image attachment count if submit_images: diff --git a/gateway/config.py b/gateway/config.py index 89393f9117e6..cf309d8439f2 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -109,6 +109,7 @@ class Platform(Enum): BLUEBUBBLES = "bluebubbles" QQBOT = "qqbot" YUANBAO = "yuanbao" + DAEMONCRAFT = "daemoncraft" @classmethod def _missing_(cls, value): """Accept unknown platform names only for known plugin adapters. diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 8e1f83c9b2a0..7a2a0d58c492 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -960,6 +960,10 @@ class MessageEvent: # completion notifications) that must bypass user authorization checks. internal: bool = False + # Tool choice override for proactive/agentic turns (e.g. wake-up events). + # When set to "required", the agent MUST respond with a tool call. + tool_choice: Optional[str] = None + # Timestamps timestamp: datetime = field(default_factory=datetime.now) diff --git a/gateway/platforms/daemoncraft.py b/gateway/platforms/daemoncraft.py new file mode 100644 index 000000000000..7a463e115a4c --- /dev/null +++ b/gateway/platforms/daemoncraft.py @@ -0,0 +1,1203 @@ +""" +DaemonCraft platform adapter for Hermes Gateway. + +Routes Minecraft chat (player whispers + world broadcasts) through the +Hermes AIAgent, while the agent_loop.py handles embodiment (movement, +quest engine, sensors). + +The adapter consumes the Bot API WebSocket and HTTP endpoints: + - WS /ws : inbound chat events (array snapshot) + - POST /chat/send : outbound text + - POST /tts/play : outbound TTS relay to dashboards + - GET /agent/log : recent loop turns for context injection +""" + +import asyncio +import datetime as _dt +import json +import logging +import os +import random +import time +import uuid +from pathlib import Path +from typing import Any, Dict, Optional, Set + +import aiohttp +from aiohttp import WSMsgType + +from gateway.config import Platform, PlatformConfig + +# --------------------------------------------------------------------------- +# CycleDetector — ported from daemoncraft agents/safety.py (stdlib-only) +# --------------------------------------------------------------------------- +import hashlib +import json as _json +from collections import deque +from dataclasses import dataclass, field +from typing import Deque + + +def _cd_canonicalize(args) -> str: + try: + if isinstance(args, str): + try: + args = _json.loads(args) + except Exception: + return args + return _json.dumps(args, sort_keys=True, default=str) + except Exception: + return repr(args) + + +def _cd_signature(name: str, args) -> str: + payload = f"{name}|{_cd_canonicalize(args)}".encode("utf-8") + return hashlib.sha256(payload).hexdigest()[:16] + + +@dataclass +class _CycleResult: + triggered: bool + sig: Optional[str] + count: int + window: int + action: str + + +@dataclass +class CycleDetector: + """Ring-buffer cycle detector for repeated tool-call patterns.""" + n: int = 4 + window: int = 6 + action: str = "warn" + _buf: Deque[str] = field(default_factory=deque) + _last_triggered_sig: Optional[str] = None + + def __post_init__(self) -> None: + self._buf = deque(maxlen=max(self.window, self.n)) + + def record(self, name: str, args) -> _CycleResult: + sig = _cd_signature(name, args) + self._buf.append(sig) + return self._evaluate() + + def _evaluate(self) -> _CycleResult: + if len(self._buf) < self.n: + return _CycleResult(False, None, 0, len(self._buf), self.action) + counts: Dict[str, int] = {} + for s in self._buf: + counts[s] = counts.get(s, 0) + 1 + top_sig, top_count = max(counts.items(), key=lambda kv: kv[1]) + if top_count >= self.n: + if top_sig == self._last_triggered_sig: + return _CycleResult(False, top_sig, top_count, len(self._buf), self.action) + self._last_triggered_sig = top_sig + return _CycleResult(True, top_sig, top_count, len(self._buf), self.action) + if self._last_triggered_sig and self._last_triggered_sig != top_sig: + self._last_triggered_sig = None + return _CycleResult(False, top_sig, top_count, len(self._buf), self.action) + + def reset(self) -> None: + self._buf.clear() + self._last_triggered_sig = None +from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType, SendResult +from gateway.session import SessionSource, build_session_key + +logger = logging.getLogger(__name__) + +META_NO_CLAMP = "_no_clamp" # Set in metadata to bypass gateway-side char clamping (used by TTS transcripts) + + +class DaemonCraftAdapter(BasePlatformAdapter): + """Gateway adapter for DaemonCraft (Minecraft bot API).""" + + def __init__(self, config: PlatformConfig): + super().__init__(config, Platform.DAEMONCRAFT) + self._bot_api_url: str = (config.extra or {}).get("bot_api_url", "") + self._bot_username: str = (config.extra or {}).get("bot_username", "") + self._profile: str = (config.extra or {}).get("profile", "") + self._allowed_users: Set[str] = set() + self._session: Optional[aiohttp.ClientSession] = None + self._ws_task: Optional[asyncio.Task] = None + self._last_seen_timestamp: int = 0 + self._shutdown_event = asyncio.Event() + self._world_names: Set[str] = set() # Track broadcast worlds for send() routing + self._ws_retry_count: int = 0 + self._voice_mode_default: str = "all" # DaemonCraft defaults to TTS for all replies + self._last_tts_time: float = 0.0 + self._tts_queue: list[dict] = [] # Dedup buffer for rapid-fire messages + self._cycle_detector: Optional[CycleDetector] = None + + # Plan tracking for heartbeat-driven progress evaluation and GC + self._plan_goal: Optional[str] = None + self._plan_tasks_snapshot: list = [] + self._plan_created_at: float = 0.0 + self._plan_last_progress_at: float = 0.0 + self._plan_gc_timeout: int = (config.extra or {}).get("plan_gc_timeout_seconds", 300) + self._turn_counter: int = 0 # Sequential turn counter for agent logs + + # Load allowlist by UUID (preferred) or username fallback. + raw_allow = os.getenv("DAEMONCRAFT_ALLOWED_USERS", "").strip() + if raw_allow: + self._allowed_users = {u.strip().lower() for u in raw_allow.split(",") if u.strip()} + + # Force group sessions per world (broadcasts must share context) + if config.extra is None: + config.extra = {} + config.extra.setdefault("group_sessions_per_user", False) + + def _group_chat_id(self, world: str = "world") -> str: + """Return a chat_id scoped to this bot so each bot has its own session.""" + return f"{world}:{self._bot_username}" + + def _is_group_chat_id(self, chat_id: str) -> bool: + """Check whether a chat_id is one of our group chat ids.""" + return chat_id in self._world_names or any( + chat_id.startswith(w + ":") for w in self._world_names + ) + + # ------------------------------------------------------------------ + # Connection lifecycle + # ------------------------------------------------------------------ + + async def connect(self) -> bool: + if not self._bot_api_url: + logger.error("[DaemonCraft] bot_api_url missing in platform config extra") + return False + if not self._bot_username: + logger.error("[DaemonCraft] bot_username missing in platform config extra") + return False + + self._last_seen_timestamp = int(time.time() * 1000) + self._shutdown_event.clear() + self._session = aiohttp.ClientSession() + + n = int(os.getenv("MC_CYCLE_N", "0")) + window = int(os.getenv("MC_CYCLE_WINDOW", "20")) + action = os.getenv("MC_CYCLE_ACTION", "warn") + if n > 0: + self._cycle_detector = CycleDetector(n=n, window=window, action=action) + logger.info("[DaemonCraft] CycleDetector enabled: n=%d window=%d action=%s", n, window, action) + self._ws_task = asyncio.create_task(self._ws_loop()) + self._mark_connected() + logger.info("[DaemonCraft] Connected to %s as %s", self._bot_api_url, self._bot_username) + return True + + async def disconnect(self) -> None: + self._shutdown_event.set() + if self._ws_task: + self._ws_task.cancel() + try: + await self._ws_task + except asyncio.CancelledError: + pass + self._ws_task = None + if self._session: + await self._session.close() + self._session = None + self._mark_disconnected() + logger.info("[DaemonCraft] Disconnected") + + async def handle_message(self, event: MessageEvent) -> None: + """Handle a chat message, injecting heartbeat context if relevant. + + Sets the bot_api_url context variable so that any tools (today: + embodied_plan; previously: minecraft/altercraft) dispatched for + this message target the correct bot server. + """ + from tools.bot_api_url_ctx import set_bot_api_url, reset_bot_api_url + token = set_bot_api_url(self._bot_api_url) + try: + await super().handle_message(event) + finally: + reset_bot_api_url(token) + + # ------------------------------------------------------------------ + # WebSocket listener + # ------------------------------------------------------------------ + + async def _ws_loop(self) -> None: + ws_url = self._bot_api_url.replace("http://", "ws://").replace("https://", "wss://") + "/ws" + while not self._shutdown_event.is_set(): + try: + async with self._session.ws_connect(ws_url) as ws: + self._ws_retry_count = 0 + logger.info("[DaemonCraft] WebSocket connected") + while not self._shutdown_event.is_set(): + msg = await ws.receive(timeout=30) + if msg.type == WSMsgType.TEXT: + await self._on_ws_message(msg.data) + elif msg.type in (WSMsgType.CLOSED, WSMsgType.ERROR): + break + except asyncio.CancelledError: + raise + except Exception as e: + self._ws_retry_count += 1 + delay = min(2 ** self._ws_retry_count, 30) + jitter = random.random() # 0–1s uniform jitter + sleep_time = delay + jitter + logger.warning("[DaemonCraft] WebSocket error: %s — reconnecting in %.1fs", e, sleep_time) + await asyncio.sleep(sleep_time) + + async def _on_ws_message(self, data: str) -> None: + try: + payload = json.loads(data) + except json.JSONDecodeError: + return + + msg_type = payload.get("type") + if msg_type == "chat": + messages = payload.get("data", []) + if not isinstance(messages, list): + return + await self._handle_chat_batch(messages) + elif msg_type == "quest_event": + data = payload.get("data", {}) + await self._handle_quest_event(data) + elif msg_type == "blueprint_updated": + data = payload.get("data", {}) + await self._handle_blueprint_updated(data) + elif msg_type == "heartbeat_context": + data = payload.get("data", {}) + await self._handle_heartbeat_context(data) + elif msg_type == "action_result": + await self._handle_action_result(payload) + elif msg_type == "interrupt": + # Loop-to-gateway interrupt acknowledgment — no action needed + pass + elif msg_type == "status": + pass + else: + logger.debug("[DaemonCraft] Unknown WS message type: %s", msg_type) + + async def _handle_chat_batch(self, messages: list) -> None: + """Process a batch of chat messages with bot filtering and @mention classification. + + - Bot messages without @mention are silently dropped. + - Human @mentions are treated as urgent (interrupts loop + immediate response). + - All other human messages are queued normally. + """ + new_messages = [m for m in messages if m.get("time", 0) > self._last_seen_timestamp] + if not new_messages: + return + + for entry in new_messages: + self._last_seen_timestamp = max(self._last_seen_timestamp, entry.get("time", 0)) + + # Dynamically discover all known bots from cast configs. + # This is a live hook — no need to update .env files when bots change. + def _discover_known_bots() -> set[str]: + import yaml + from pathlib import Path as _Path + bots = set() + casts_dir = _Path.home() / "Projects" / "DaemonCraft" / "agents" / "casts" + try: + for cf in sorted(casts_dir.glob("*.yaml")): + cfg = yaml.safe_load(cf.read_text()) or {} + for a in cfg.get("agents", []): + name = a.get("name", "") + if name: + bots.add(name.strip().lower()) + except Exception: + pass + # Also check env override + override = os.getenv("MC_KNOWN_BOTS", "") + if override: + for u in override.split(","): + u = u.strip().lower() + if u: + bots.add(u) + return bots + + known_bots = _discover_known_bots() + + urgent_msgs = [] + accepted_msgs = [] + import re + + # Build two regexes: + # 1. @username! — URGENT interrupt (exclamation forces immediate response) + # 2. @username — normal steer (queued, doesn't interrupt) + urgent_re = re.compile(rf"\b@{re.escape(self._bot_username.lower())}!", re.IGNORECASE) + mention_re = re.compile(rf"\b@{re.escape(self._bot_username.lower())}\b", re.IGNORECASE) + + for entry in new_messages: + from_user = entry.get("from", "").lower() + msg_text = entry.get("message", "") + is_bot = from_user in known_bots + mentions_bot = bool(mention_re.search(msg_text)) + is_urgent = bool(urgent_re.search(msg_text)) + + if is_bot and not mentions_bot: + continue # Silently drop bot spam + + accepted_msgs.append(entry) + + # Only @username! (with exclamation) is urgent interrupt. + # @username without ! is steer — queued, doesn't abort current turn. + if is_urgent and not is_bot: + urgent_msgs.append(entry) + + # Interrupt the loop for urgent human @mentions before generating response + if urgent_msgs: + senders = ", ".join({m.get("from", "Player") for m in urgent_msgs}) + logger.info("[DaemonCraft] Urgent @mention from %s — interrupting loop", senders) + await self._interrupt_agent("urgent_mention") + elif accepted_msgs: + senders = ", ".join({m.get("from", "Player") for m in accepted_msgs}) + logger.info("[DaemonCraft] Chat from %s queued", senders) + + # Process all accepted messages through the gateway + for entry in accepted_msgs: + await self._handle_chat_entry(entry) + + async def _interrupt_agent(self, reason: str) -> None: + """POST /agent/interrupt to abort the loop's in-progress LLM turn.""" + try: + async with self._session.post( + f"{self._bot_api_url}/agent/interrupt", + json={"reason": reason}, + ) as resp: + if resp.status >= 400: + body = await resp.text() + logger.warning("[DaemonCraft] /agent/interrupt failed: %s %s", resp.status, body) + else: + logger.debug("[DaemonCraft] /agent/interrupt sent (%s)", reason) + except Exception as e: + logger.warning("[DaemonCraft] /agent/interrupt exception: %s", e) + + async def _handle_quest_event(self, data: dict) -> None: + """Process a quest_event from the QuestEngine. + + Builds a narrative message and injects it into the gateway so the + AIAgent can respond to the player (narrate phase changes, etc.). + """ + message = data.get("message", "A quest event occurred.") + event_type = data.get("event_type", "quest_event") + from_phase = data.get("from_phase") + to_phase = data.get("to_phase") + + # Build a natural-language description for the gateway AIAgent + lines = [f"[Quest Event] {message}"] + if from_phase and to_phase: + lines.append(f"Phase transition: {from_phase} → {to_phase}") + elif event_type: + lines.append(f"Event type: {event_type}") + event_text = "\n".join(lines) + + logger.info("[DaemonCraft] Quest event: %s", event_text.replace("\n", " | ")) + + # Route to the world broadcast session (group chat) + source = self.build_source( + chat_id=self._group_chat_id(), + chat_name="world", + chat_type="group", + user_id="quest_engine", + user_name="QuestEngine", + thread_id="world", + ) + source.profile = self._profile + + event = MessageEvent( + text=event_text, + message_type=MessageType.TEXT, + source=source, + raw_message=data, + ) + await self.handle_message(event) + + async def _handle_blueprint_updated(self, data: dict) -> None: + """Process a blueprint_updated event from the dashboard. + + Notifies the gateway AIAgent that a blueprint was modified so it + can reload or acknowledge the change. + """ + name = data.get("name", "unknown") + saved_at = data.get("saved_at", 0) + + event_text = ( + f"[Blueprint Updated] The blueprint '{name}' was edited via the dashboard " + f"at {time.strftime('%H:%M:%S', time.localtime(saved_at / 1000))}. " + f"Use mc_story(action='load_blueprint', name='{name}') to reload the latest version." + ) + + logger.info("[DaemonCraft] Blueprint updated: %s", name) + + source = self.build_source( + chat_id=self._group_chat_id(), + chat_name="world", + chat_type="group", + user_id="dashboard", + user_name="Dashboard", + thread_id="world", + ) + source.profile = self._profile + + event = MessageEvent( + text=event_text, + message_type=MessageType.TEXT, + source=source, + raw_message=data, + ) + await self.handle_message(event) + + async def _handle_action_result(self, payload: dict) -> None: + """Forward action_result events to transform_tool_result hooks.""" + import json as _json + result_str = _json.dumps(payload) + await self.invoke_hook("transform_tool_result", tool_name="mc_action_result", result=result_str) + + async def _handle_heartbeat_context(self, data: dict) -> None: + """Process heartbeat_context with two-level event architecture. + + - Context-only updates: inject synthetic mc_perceive tool result silently + into the session_store. No LLM turn is forced. + - Wake-up events: inject synthetic tool result + force an agent turn with + tool_choice="required". The agent MUST react with a tool call (or mc_no_op). + - Active plans: every heartbeat while a plan is active forces a wake_up so + the agent evaluates progress against the plan. + """ + plan = data.get("plan") or {} + await self._update_plan_tracking(plan) + + # Run plan garbage collection before classification + gc_reason = await self._maybe_gc_plan() + if gc_reason: + logger.info("[DaemonCraft] Plan GC: %s", gc_reason) + # Inject cancellation as a system event + await self._inject_synthetic_perceive({ + "type": "plan_cancelled", + "reason": gc_reason, + "timestamp": int(time.time() * 1000), + }) + # Force wake_up with the cancellation message + source = self.build_source( + chat_id=self._group_chat_id(), + chat_name="world", + chat_type="group", + user_id="system", + user_name="System", + thread_id="world", + ) + source.profile = self._profile + event = MessageEvent( + text=f"[System: {gc_reason} — set a new plan or continue with immediate actions.]", + message_type=MessageType.TEXT, + source=source, + raw_message={"gc_reason": gc_reason}, + internal=True, + tool_choice="required", + ) + await self.handle_message(event) + return + + event_type = self._classify_heartbeat_event(data) + logger.info("[DaemonCraft] Heartbeat classified as: %s", event_type) + + # Inject world state from the body (Gemma-Andy) instead of raw bot data + await self._inject_embodied_world_state(data) + + if event_type == "context": + logger.debug("[DaemonCraft] Context-only heartbeat injected silently") + return + + # Cycle guard — skip wake-up if loop is repeating embodied_plan calls + if await self._check_cycle("embodied_plan", {}): + return + + # Wake-up event: force an agent turn with tool_choice=required + plan_goal = self._plan_goal + if plan_goal and event_type == "wake_up": + prompt_text = ( + f"[System: Evaluate progress on plan '{plan_goal}'. " + f"Current tasks: {len(self._plan_tasks_snapshot)}. " + f"Use mc_plan(action='get_plan') to review, mc_plan(action='update_task') to mark progress, " + f"or other tools to advance the active task.]" + ) + else: + prompt_text = "[System: React to the perceptual update above using available tools.]" + + source = self.build_source( + chat_id=self._group_chat_id(), + chat_name="world", + chat_type="group", + user_id="system", + user_name="System", + thread_id="world", + ) + source.profile = self._profile + + event = MessageEvent( + text=prompt_text, + message_type=MessageType.TEXT, + source=source, + raw_message=data, + internal=True, + tool_choice="required", + ) + await self.handle_message(event) + + async def _update_plan_tracking(self, plan: dict) -> None: + """Update internal plan snapshot and detect progress.""" + goal = plan.get("goal") + tasks = plan.get("tasks", []) + + if not goal: + # No active plan + self._plan_goal = None + self._plan_tasks_snapshot = [] + self._plan_created_at = 0.0 + self._plan_last_progress_at = 0.0 + return + + # Detect if this is a new plan + if goal != self._plan_goal: + self._plan_goal = goal + self._plan_tasks_snapshot = [dict(t) for t in tasks] + self._plan_created_at = time.time() + self._plan_last_progress_at = time.time() + logger.info("[DaemonCraft] New plan tracked: %s (%d tasks)", goal, len(tasks)) + return + + # Detect progress: compare task statuses + progress_made = False + if len(tasks) == len(self._plan_tasks_snapshot): + for old, new in zip(self._plan_tasks_snapshot, tasks): + if old.get("status") != new.get("status"): + progress_made = True + break + elif len(tasks) != len(self._plan_tasks_snapshot): + progress_made = True + + if progress_made: + self._plan_last_progress_at = time.time() + self._plan_tasks_snapshot = [dict(t) for t in tasks] + logger.debug("[DaemonCraft] Plan progress detected: %s", goal) + + async def _maybe_gc_plan(self) -> Optional[str]: + """Garbage-collect stale plans. Returns cancellation reason or None.""" + if not self._plan_goal: + return None + + now = time.time() + age = now - self._plan_created_at + since_progress = now - self._plan_last_progress_at + + # GC if plan is older than timeout AND no progress in timeout period + if age > self._plan_gc_timeout and since_progress > self._plan_gc_timeout: + reason = ( + f"Plan '{self._plan_goal}' cancelled after {int(age)}s " + f"with no progress for {int(since_progress)}s" + ) + # Clear plan on bot server + try: + async with self._session.post( + f"{self._bot_api_url}/plan/update", + json={"action": "clear_goal"}, + ) as resp: + if resp.status < 400: + logger.info("[DaemonCraft] Plan cleared on bot server") + except Exception as e: + logger.warning("[DaemonCraft] Failed to clear plan on bot server: %s", e) + + # Reset local tracking + self._plan_goal = None + self._plan_tasks_snapshot = [] + self._plan_created_at = 0.0 + self._plan_last_progress_at = 0.0 + return reason + + return None + + def _classify_heartbeat_event(self, data: dict) -> str: + """Classify heartbeat as 'context' or 'wake_up'. + + Wake-up triggers: + - Bot is stuck on a movement task (task_stuck in status) + - Active plan exists (agent must evaluate progress every heartbeat) + - Health decreased from previous known value + - Nearby hostile entities (zombie, skeleton, creeper, spider) + - Explicit damage events in events list + """ + status = data.get("status") or {} + nearby = data.get("nearby") or {} + events = data.get("events") or [] + plan = data.get("plan") or {} + + # Stuck on movement task — force wake_up so agent can react + task_stuck = status.get("task_stuck") + if task_stuck: + events.append(f"Stuck: {task_stuck}") + return "wake_up" + + # Active plan — force wake_up so agent evaluates progress + if plan.get("goal"): + events.append(f"Plan progress check: {plan['goal']}") + return "wake_up" + + # Damage / health drop + current_health = status.get("health") + if current_health is not None and hasattr(self, "_last_health"): + if current_health < self._last_health: + logger.info("[DaemonCraft] Wake-up reason: health dropped %s -> %s", self._last_health, current_health) + self._last_health = current_health + return "wake_up" + if current_health is not None: + self._last_health = current_health + + # Explicit damage events + for ev in events: + ev_str = str(ev).lower() + if any(k in ev_str for k in ("damage", "hurt", "attack", "hit", "died", "killed")): + logger.info("[DaemonCraft] Wake-up reason: damage event '%s'", ev_str[:80]) + return "wake_up" + + # Nearby hostile mobs + hostile = {"zombie", "skeleton", "creeper", "spider", "enderman", "witch", "husk", "drowned", "phantom"} + for ent in nearby.get("entities", [])[:12]: + name = str(ent.get("name", ent) if isinstance(ent, dict) else ent).lower() + if any(h in name for h in hostile): + logger.info("[DaemonCraft] Wake-up reason: hostile entity '%s'", name) + return "wake_up" + + # Bot stuck — critical, needs immediate reaction + task = status.get("task") + if task and task.get("status") == "stuck": + logger.info("[DaemonCraft] Wake-up reason: bot stuck (%s)", task.get("error", "unknown")[:60]) + return "wake_up" + + return "context" + + async def _inject_synthetic_perceive(self, data: dict) -> None: + """Inject a fake assistant tool_call + tool result into the world session.""" + if not self._session_store: + logger.debug("[DaemonCraft] No session_store available, skipping synthetic injection") + return + + session_id = self._get_world_session_id() + if not session_id: + logger.debug("[DaemonCraft] No world session found, skipping synthetic injection") + return + + tool_call_id = f"hb_{uuid.uuid4().hex[:12]}" + + # Build a concise JSON payload for the tool result + payload = json.dumps(data, ensure_ascii=False, default=str) + # Truncate if too large to avoid flooding context window + if len(payload) > 4000: + payload = payload[:4000] + "\n...[truncated]" + + assistant_msg = { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": tool_call_id, + "type": "function", + "function": {"name": "mc_perceive", "arguments": "{}"}, + } + ], + } + tool_msg = { + "role": "tool", + "tool_call_id": tool_call_id, + "content": payload, + } + + self._session_store.append_to_transcript(session_id, assistant_msg) + + # Run transform_tool_result hooks so plugins (e.g. altercraft scene-graph) + # can consume synthetic mc_perceive on the same path as real tool results. + try: + from hermes_cli.plugins import invoke_hook + for hook_result in invoke_hook( + "transform_tool_result", + tool_name="mc_perceive", + args={}, + result=payload, + task_id="", + session_id=session_id, + tool_call_id=tool_call_id, + duration_ms=0, + ): + if isinstance(hook_result, str): + payload = hook_result + tool_msg["content"] = payload + break + except Exception as _hook_exc: + logger.debug("[DaemonCraft] transform_tool_result hook error: %s", _hook_exc) + + self._session_store.append_to_transcript(session_id, tool_msg) + logger.info("[DaemonCraft] Synthetic mc_perceive injected into session %s", session_id) + + async def _inject_embodied_world_state(self, data: dict) -> None: + """Query the body (Gemma-Andy via embodied service) for world state. + + Instead of injecting raw bot data as synthetic mc_perceive, we ask the + body to scan the world and inject its processed response. This keeps + the architecture pure: Steve only knows the world through his body. + """ + if not self._session_store: + logger.debug("[DaemonCraft] No session_store, skipping embodied injection") + return + + session_id = self._get_world_session_id() + if not session_id: + logger.debug("[DaemonCraft] No world session, skipping embodied injection") + return + + embodied_url = os.environ.get("EMBODIED_SERVICE_URL", "http://localhost:7790") + intent = ( + "Scan the area. Report concisely: your position, the 5 most common " + "nearby blocks with counts, any entities (players, mobs) with distances, " + "inventory highlights (tools, key materials), and any hazards. " + "Keep the report under 600 characters." + ) + + tool_call_id = f"hb_{uuid.uuid4().hex[:12]}" + payload = None + ok = False + exc_info = None + + try: + async with aiohttp.ClientSession() as session: + async with session.post( + f"{embodied_url}/intent", + json={"intent": intent, "autonomy_level": 1, "deadline_seconds": 15}, + timeout=aiohttp.ClientTimeout(total=20), + ) as resp: + if resp.status == 200: + body = await resp.json() + ok = body.get("ok", False) + if ok and body.get("execution_results"): + payload = json.dumps(body, ensure_ascii=False, default=str) + elif body.get("plan", {}).get("body_plan"): + payload = json.dumps(body["plan"], ensure_ascii=False, default=str) + except Exception as exc: + logger.warning("[DaemonCraft] Embodied world-state query failed: %s", exc) + exc_info = str(exc) + + if not payload: + payload = json.dumps({ + "_note": "Body unresponsive — do not act as if it is responding. Wait for the next heartbeat.", + "error": exc_info or "embodied service unavailable", + }) + + if len(payload) > 4000: + payload = payload[:4000] + "\n...[truncated]" + + assistant_msg = { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": tool_call_id, + "type": "function", + "function": {"name": "embodied_plan", "arguments": json.dumps({"intent": intent})}, + } + ], + } + tool_msg = { + "role": "tool", + "tool_call_id": tool_call_id, + "content": payload, + } + + self._session_store.append_to_transcript(session_id, assistant_msg) + self._session_store.append_to_transcript(session_id, tool_msg) + logger.info( + "[DaemonCraft] Embodied world-state injected (body ok=%s, %d chars) into session %s", + ok, len(payload), session_id, + ) + + def _get_world_session_id(self) -> Optional[str]: + """Resolve the session_id for the world broadcast session.""" + if not self._session_store: + return None + source = SessionSource( + platform=Platform.DAEMONCRAFT, + chat_id=self._group_chat_id(), + chat_type="group", + user_id=self._bot_username, + thread_id="world", + ) + session_key = build_session_key( + source, + group_sessions_per_user=False, + thread_sessions_per_user=False, + ) + entries = getattr(self._session_store, "_entries", {}) + entry = entries.get(session_key) + if entry: + return entry.session_id + return None + + async def _handle_chat_entry(self, entry: dict) -> None: + from_ = entry.get("from", "") + if not from_: + return + if from_.lower() == self._bot_username.lower(): + return # Ignore self-echo + + # Authorization by UUID (preferred) or username fallback + sender_uuid = entry.get("uuid") + if self._allowed_users: + allowed = False + if sender_uuid and sender_uuid.lower() in self._allowed_users: + allowed = True + if from_.lower() in self._allowed_users: + allowed = True + if not allowed: + logger.debug("[DaemonCraft] Ignored message from unauthorized user: %s", from_) + return + + text = entry.get("message", "") + if not text: + return + + is_whisper = entry.get("whisper", False) + is_private = entry.get("private", False) + world = entry.get("world", "world") + + # Session mapping + if is_whisper or is_private: + # 1:1 session + chat_id = from_ + chat_type = "dm" + thread_id = None + else: + # Group session per world, scoped to this bot + chat_id = self._group_chat_id(world) + chat_type = "group" + thread_id = world + self._world_names.add(world) + + source = self.build_source( + chat_id=chat_id, + chat_name=chat_id, + chat_type=chat_type, + user_id=sender_uuid or from_, + user_name=from_, + thread_id=thread_id, + ) + source.profile = self._profile + + event = MessageEvent( + text=text, + message_type=MessageType.TEXT, + source=source, + raw_message=entry, + ) + + await self.handle_message(event) + + # ------------------------------------------------------------------ + # Cycle detection + # ------------------------------------------------------------------ + + async def _check_cycle(self, tool_name: str, args: dict) -> bool: + """Check tool-call cycle. Returns True if cycle detected and action is 'interrupt'.""" + if self._cycle_detector is None: + return False + result = self._cycle_detector.record(tool_name, args) + if result.triggered: + if result.action == "interrupt": + logger.warning( + "[DaemonCraft] Cycle detected for '%s' (%d/%d) — interrupting agent", + tool_name, result.count, result.window, + ) + await self._interrupt_agent("cycle_detected") + return True + else: + logger.warning( + "[DaemonCraft] Cycle detected for '%s' (%d/%d) — action=%s", + tool_name, result.count, result.window, result.action, + ) + return False + + # ------------------------------------------------------------------ + # Dashboard feed (DC-123) + # ------------------------------------------------------------------ + + async def on_processing_complete(self, event, outcome) -> None: + """POST the last assistant turn to /agent/log so the dashboard Bot Mind panel populates. + + Before DC-112 the agent_loop posted turns directly. After DC-112 cognition + moved to the gateway but no one wired the log relay. This hook restores + visibility without touching the loop. + """ + if not self._bot_api_url or not self._session: + return + try: + session_id = self._get_world_session_id() + if not session_id or not self._session_store: + return + transcript = self._session_store.load_transcript(session_id) + # Find the last assistant message in the transcript + last_assistant = None + tool_calls = [] + for msg in reversed(transcript): + role = msg.get("role", "") + if role == "assistant" and last_assistant is None: + content = msg.get("content", "") + if isinstance(content, list): + # Extract text and tool_use blocks + text_parts = [b.get("text", "") for b in content if b.get("type") == "text"] + tool_calls = [ + {"name": b.get("name"), "input": b.get("input")} + for b in content if b.get("type") == "tool_use" + ] + last_assistant = "\n".join(text_parts).strip() + else: + last_assistant = str(content) + break + + if last_assistant is None and not tool_calls: + return + + await self._session.post( + f"{self._bot_api_url}/agent/log", + json={ + "turn": len(transcript), + "time": int(time.time() * 1000), + "prompt": "", # omit — transcript is large; response + tools is what the panel needs + "response": last_assistant or "", + "tool_calls": tool_calls, + "error": None, + }, + ) + except Exception as e: + logger.debug("[DaemonCraft] on_processing_complete /agent/log post failed: %s", e) + + # DC-132 — emit a turn metric (best-effort; never raises). + # Latency: time since the last user/perceive message in the transcript, + # if we can find one. tokens_in/out: not yet exposed by AIAgent at this + # hook, so we emit zero placeholders rather than fabricate values. + try: + self._emit_metric( + "turn", + tokens_in=0, + tokens_out=0, + latency_ms=None, + tool_call_count=len(tool_calls), + ) + for tc in tool_calls: + self._emit_metric("tool", tool=tc.get("name") or "?", ok=True) + except Exception: + pass + + # ------------------------------------------------------------------ + # DC-132 — JSONL metrics (mirrors agents/agent_loop.py emitter in daemoncraft) + # ------------------------------------------------------------------ + + def _emit_metric(self, kind: str, **fields) -> None: + """Append a JSON line to ~/.hermes/metrics//.jsonl. + + Schema is documented in scripts/agent-metrics-report.py in the + daemoncraft repo. This is the gateway counterpart to the heartbeat + emitter in agent_loop.py — together they cover the four families + the report script aggregates. + + Cast comes from DAEMONCRAFT_METRICS_CAST env var; falls back to the + bot username so events still group sensibly if the operator hasn't + set it. No env var → emitter still fires under the username. + """ + try: + cast = os.getenv("DAEMONCRAFT_METRICS_CAST", "").strip() or self._bot_username or "daemoncraft" + metrics_root = Path(os.getenv("DAEMONCRAFT_METRICS_DIR", str(Path.home() / ".hermes" / "metrics"))) + now = _dt.datetime.utcnow() + cast_dir = metrics_root / cast + cast_dir.mkdir(parents=True, exist_ok=True) + path = cast_dir / f"{now.date().isoformat()}.jsonl" + record = { + "ts": now.isoformat(timespec="seconds") + "Z", + "cast": cast, + "agent": self._bot_username or "?", + "kind": kind, + **fields, + } + # Single os.write() with O_APPEND — POSIX-atomic for writes + # under PIPE_BUF (typically 4 KB on Linux). Prevents truncated + # lines under concurrent writers / mid-write process kill. + line = (json.dumps(record, separators=(",", ":")) + "\n").encode("utf-8") + fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644) + try: + os.write(fd, line) + finally: + os.close(fd) + except Exception: + pass + + # ------------------------------------------------------------------ + # Outbound + # ------------------------------------------------------------------ + + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + # Log agent turn to bot server for dashboard display (skip heartbeat/context-only turns) + if content and content.strip() and content != "None": + await self._post_agent_log(content, metadata) + + # _world_names is populated lazily from inbound broadcasts. If the gateway + # initiates an outbound broadcast before any inbound from that world, this + # will default to DM (whisper). For now the agent only replies to inbound. + is_group = self._is_group_chat_id(chat_id) + payload: dict[str, Any] = {"message": content} + + if is_group: + payload["target"] = "broadcast" + else: + payload["target"] = chat_id + + try: + async with self._session.post( + f"{self._bot_api_url}/chat/send", + json=payload, + ) as resp: + if resp.status >= 400: + body = await resp.text() + logger.warning("[DaemonCraft] /chat/send failed: %s %s", resp.status, body) + return SendResult(success=False, error=f"HTTP {resp.status}: {body}") + except Exception as e: + logger.warning("[DaemonCraft] /chat/send exception: %s", e) + return SendResult(success=False, error=str(e), retryable=True) + + # DC-123: relay TTS to dashboard after successful outbound message. + system_tts_skip = {"steer", "gateway shutting down", "synthetic mc_perceive", "heartbeat", "mc_perceive"} + is_system_msg = any(skip in content.lower() for skip in system_tts_skip) + if (content and content.strip() not in ("PASS", "") + and not is_system_msg + and not (metadata or {}).get("suppress_tts")): + asyncio.create_task(self._generate_and_relay_tts(content, chat_id)) + + return SendResult(success=True) + + async def _post_agent_log(self, content: str, metadata: Optional[Dict[str, Any]] = None) -> None: + """Post agent turn to bot server /agent/log for dashboard display.""" + try: + self._turn_counter += 1 + tool_calls = [] + if metadata and "tool_calls" in metadata: + tool_calls = metadata["tool_calls"] + payload = { + "turn": self._turn_counter, + "time": int(time.time() * 1000), + "prompt": getattr(self, "_last_prompt", ""), + "response": content, + "tool_calls": tool_calls, + "error": None, + } + async with self._session.post( + f"{self._bot_api_url}/agent/log", + json=payload, + ) as resp: + if resp.status >= 400: + body = await resp.text() + logger.debug("[DaemonCraft] /agent/log failed: %s %s", resp.status, body) + except Exception as e: + logger.debug("[DaemonCraft] /agent/log exception: %s", e) + + async def _generate_and_relay_tts(self, text: str, chat_id: str) -> None: + """Generate TTS for outbound text and relay audio to the dashboard. + + DC-123 fix: before DC-112 agent_loop called TTS explicitly. After DC-112 + the gateway owns cognition but the TTS relay was never wired. This method + closes that gap — it is called as a fire-and-forget task from send(). + """ + try: + from tools.tts_tool import text_to_speech_tool, check_tts_requirements + if not check_tts_requirements(): + return + import re as _re, json as _json + # Strip Minecraft formatting codes and markdown before synthesis. + clean = _re.sub(r'§[0-9a-fklmnor]', '', text) + clean = _re.sub(r'[*_`#\[\]()]', '', clean).strip() + if not clean: + return + # Edge-TTS stutter fix: prepend zero-width space to prevent first-word repetition. + clean = "\u200b" + clean + tts_result = await asyncio.to_thread(text_to_speech_tool, text=clean[:4000]) + tts_data = _json.loads(tts_result) + audio_path = tts_data.get("file_path") + if audio_path and os.path.exists(audio_path): + await self._copy_and_relay_tts(audio_path, chat_id) + try: + os.remove(audio_path) + except OSError: + pass + except Exception as e: + logger.debug("[DaemonCraft] TTS generation failed: %s", e) + + async def _copy_and_relay_tts(self, audio_path: str, chat_id: str) -> SendResult: + """Copy audio to shared TTS cache and POST /tts/play to dashboards.""" + try: + import shutil + + tts_dir = "/tmp/daemoncraft-tts" + os.makedirs(tts_dir, exist_ok=True) + filename = os.path.basename(audio_path) + dest = os.path.join(tts_dir, filename) + shutil.copy2(audio_path, dest) + + # Build public URL — bot API serves /tts/audio/:filename + audio_url = f"{self._bot_api_url}/tts/audio/{filename}" + + async with self._session.post( + f"{self._bot_api_url}/tts/play", + json={"audio_url": audio_url, "chat_id": chat_id}, + ) as resp: + if resp.status >= 400: + body = await resp.text() + logger.warning("[DaemonCraft] /tts/play failed: %s %s", resp.status, body) + return SendResult(success=False, error=f"HTTP {resp.status}: {body}") + return SendResult(success=True) + except Exception as e: + logger.warning("[DaemonCraft] /tts/play exception: %s", e) + return SendResult(success=False, error=str(e), retryable=True) + + async def play_tts(self, chat_id: str, audio_path: str, **kwargs) -> SendResult: + """Relay TTS audio to dashboards and send transcript to Minecraft chat.""" + result = await self._copy_and_relay_tts(audio_path, chat_id) + if not result.success: + return result + + # Also send the full text to Minecraft chat so players can read it + text = kwargs.get("text", "[Voice message]") + return await self.send(chat_id, text) + + async def send_typing(self, chat_id: str, metadata=None) -> None: + # Minecraft has no typing indicator — no-op + pass + + async def send_voice( + self, + chat_id: str, + audio_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + **kwargs, + ) -> SendResult: + """Relay TTS audio to dashboards via the bot API.""" + return await self._copy_and_relay_tts(audio_path, chat_id) + + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: + chat_type = "group" if self._is_group_chat_id(chat_id) else "dm" + return {"name": chat_id, "type": chat_type, "chat_id": chat_id} + + +# ------------------------------------------------------------------ +# Requirements check +# ------------------------------------------------------------------ + +def check_daemoncraft_requirements() -> bool: + """DaemonCraft only needs aiohttp (already a core dep).""" + try: + import aiohttp # noqa: F401 + return True + except ImportError: + return False diff --git a/gateway/run.py b/gateway/run.py index 6c652e7cf103..19a702e08f38 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -42,6 +42,7 @@ from pathlib import Path from datetime import datetime from typing import Dict, Optional, Any, List, Union +import copy # account_usage imports the OpenAI SDK chain (~230 ms). Only needed by # /usage; we still import it at module top in the gateway because test @@ -671,6 +672,60 @@ def _reload_runtime_env_preserving_config_authority() -> None: _AGENT_PENDING_SENTINEL = object() +def _load_profile_config(profile_name: str) -> tuple[dict, Path]: + """Load config and .env from a Hermes profile directory. + + Returns (config_dict, profile_dir). Config is empty dict if the file + doesn't exist. .env vars are injected into os.environ only when the + key is not already present (global ~/.hermes/.env takes precedence). + """ + from hermes_cli.profiles import get_profile_dir, profile_exists + + if not profile_exists(profile_name): + logger.warning("Profile '%s' does not exist — falling back to global config", profile_name) + return {}, Path() + + profile_dir = get_profile_dir(profile_name) + config_path = profile_dir / "config.yaml" + config: dict = {} + if config_path.exists(): + import yaml + try: + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + except Exception as exc: + logger.warning("Failed to load profile config for '%s': %s", profile_name, exc) + + # Load profile .env so credentials (MINIMAX_API_KEY, etc.) are available. + env_path = profile_dir / ".env" + if env_path.exists(): + for line in env_path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" in line: + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + # Strip inline comments + in_quote = None + comment_idx = None + for i, ch in enumerate(value): + if ch in ('"', "'"): + if in_quote == ch: + in_quote = None + elif in_quote is None: + in_quote = ch + elif ch == "#" and in_quote is None and i > 0 and value[i - 1] == " ": + comment_idx = i - 1 + break + if comment_idx is not None: + value = value[:comment_idx].rstrip() + if key and key not in os.environ: + os.environ[key] = value + + return config, profile_dir + + def _resolve_runtime_agent_kwargs() -> dict: """Resolve provider credentials for gateway-created AIAgent instances. @@ -1802,6 +1857,29 @@ def _resolve_session_agent_runtime( resolved_session_key = None model = _resolve_gateway_model(user_config) + + # If the source carries a Hermes profile, load its config and credentials + # so the agent uses the profile's model/provider/system_prompt instead of + # the gateway global defaults. + profile_config = {} + profile_name = getattr(source, "profile", None) if source else None + if profile_name: + profile_config, _ = _load_profile_config(profile_name) + if profile_config: + logger.debug( + "Loaded profile '%s' for session %s", + profile_name, resolved_session_key or "", + ) + + # Merge profile config on top of global user_config for model resolution. + # Profile takes precedence for model/providers; global stays for display/tools. + merged_config = copy.deepcopy(user_config) if user_config else {} + if profile_config: + from hermes_cli.config import _deep_merge + merged_config = _deep_merge(merged_config, profile_config) + # Re-resolve model with profile config in scope + model = _resolve_gateway_model(merged_config) + override = self._session_model_overrides.get(resolved_session_key) if resolved_session_key else None if override: override_model = override.get("model", model) @@ -1840,11 +1918,97 @@ def _resolve_session_agent_runtime( runtime_model, ) model = runtime_model + + # Profile-scoped config: if the message source carries a profile, + # load that profile's config.yaml and .env so the agent uses the + # correct model, provider and credentials. + _profile = getattr(source, "profile", None) if source else None + if _profile: + try: + _profile_home = Path.home() / ".hermes" / "profiles" / _profile + _profile_cfg_path = _profile_home / "config.yaml" + if _profile_cfg_path.exists(): + import yaml as _yaml + with open(_profile_cfg_path, encoding="utf-8") as _f: + _profile_cfg = _yaml.safe_load(_f) or {} + _profile_model = _profile_cfg.get("model", {}) + _profile_default = _profile_model.get("default", model) + _profile_provider = _profile_model.get("provider", runtime_kwargs.get("provider")) + _profile_providers = _profile_cfg.get("providers", {}) + _provider_cfg = _profile_providers.get(_profile_provider, {}) + _profile_base_url = _provider_cfg.get("base_url", runtime_kwargs.get("base_url")) + + # Read API key from profile .env + _profile_env_path = _profile_home / ".env" + _profile_api_key = None + if _profile_env_path.exists(): + _prov_upper = (_profile_provider or "").upper().replace("-", "_").replace("_OAUTH", "") + _key_name = f"{_prov_upper}_API_KEY" + with open(_profile_env_path, encoding="utf-8") as _ef: + for line in _ef: + if line.startswith(f"{_key_name}="): + _profile_api_key = line.split("=", 1)[1].strip() + break + + model = _profile_default or model + runtime_kwargs = { + "api_key": _profile_api_key or runtime_kwargs.get("api_key"), + "base_url": _profile_base_url or runtime_kwargs.get("base_url"), + "provider": _profile_provider or runtime_kwargs.get("provider"), + "api_mode": runtime_kwargs.get("api_mode"), + "command": runtime_kwargs.get("command"), + "args": list(runtime_kwargs.get("args") or []), + "credential_pool": runtime_kwargs.get("credential_pool"), + } + logger.info("Profile '%s' loaded: model=%s provider=%s", _profile, model, _profile_provider) + except Exception as _profile_exc: + logger.warning("Failed to load profile '%s' config: %s", _profile, _profile_exc) if override and resolved_session_key: model, runtime_kwargs = self._apply_session_model_override( resolved_session_key, model, runtime_kwargs ) + # When a profile is configured, re-resolve runtime_kwargs so that + # profile-level provider credentials (api_key, base_url, etc.) are used. + if profile_config and profile_name: + from hermes_cli.runtime_provider import resolve_runtime_provider + from hermes_cli.auth import AuthError + try: + _requested = (profile_config.get("model") or {}).get("provider") + if not _requested and isinstance(profile_config.get("providers"), dict): + # Pick the first provider block that has a 'provider' key + for _pname, _pval in profile_config["providers"].items(): + if isinstance(_pval, dict) and _pval.get("provider"): + _requested = _pval["provider"] + break + + # Extract explicit credentials from profile config (providers..api_key / base_url) + _provider_cfg = {} + if isinstance(profile_config.get("providers"), dict): + if _requested and _requested in profile_config["providers"]: + _provider_cfg = profile_config["providers"][_requested] + else: + # Fallback: use the first provider block + _provider_cfg = next(iter(profile_config["providers"].values()), {}) + + profile_runtime = resolve_runtime_provider( + requested=_requested, + explicit_api_key=_provider_cfg.get("api_key"), + explicit_base_url=_provider_cfg.get("base_url"), + ) + if profile_runtime: + runtime_kwargs.update(profile_runtime) + logger.debug( + "Applied runtime from profile '%s': provider=%s base_url=%s", + profile_name, + profile_runtime.get("provider"), + profile_runtime.get("base_url"), + ) + except AuthError as auth_exc: + logger.warning("Profile '%s' auth failed: %s", profile_name, auth_exc) + except Exception as exc: + logger.debug("Could not resolve runtime for profile '%s': %s", profile_name, exc) + # When the config has no model.default but a provider was resolved # (e.g. user ran `hermes auth add openai-codex` without `hermes model`), # fall back to the provider's first catalog model so the API call @@ -5324,6 +5488,13 @@ def _create_adapter( return None return YuanbaoAdapter(config) + elif platform == Platform.DAEMONCRAFT: + from gateway.platforms.daemoncraft import DaemonCraftAdapter, check_daemoncraft_requirements + if not check_daemoncraft_requirements(): + logger.warning("DaemonCraft: aiohttp not installed") + return None + return DaemonCraftAdapter(config) + return None def _is_user_authorized(self, source: SessionSource) -> bool: """ @@ -5366,6 +5537,7 @@ def _is_user_authorized(self, source: SessionSource) -> bool: Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOWED_USERS", Platform.QQBOT: "QQ_ALLOWED_USERS", Platform.YUANBAO: "YUANBAO_ALLOWED_USERS", + Platform.DAEMONCRAFT: "DAEMONCRAFT_ALLOWED_USERS", } platform_group_user_env_map = { Platform.TELEGRAM: "TELEGRAM_GROUP_ALLOWED_USERS", @@ -5392,6 +5564,7 @@ def _is_user_authorized(self, source: SessionSource) -> bool: Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOW_ALL_USERS", Platform.QQBOT: "QQ_ALLOW_ALL_USERS", Platform.YUANBAO: "YUANBAO_ALLOW_ALL_USERS", + Platform.DAEMONCRAFT: "DAEMONCRAFT_ALLOW_ALL_USERS", } # Bots admitted by {PLATFORM}_ALLOW_BOTS bypass the human allowlist (#4466). platform_allow_bots_map = { @@ -7423,7 +7596,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g # One-time prompt if no home channel is set for this platform # Skip for webhooks - they deliver directly to configured targets (github_comment, etc.) - if not history and source.platform and source.platform != Platform.LOCAL and source.platform != Platform.WEBHOOK: + if not history and source.platform and source.platform != Platform.LOCAL and source.platform != Platform.WEBHOOK and source.platform != Platform.DAEMONCRAFT: platform_name = source.platform.value env_key = _home_target_env_var(platform_name) if not os.getenv(env_key): @@ -7507,6 +7680,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g run_generation=run_generation, event_message_id=self._reply_anchor_for_event(event), channel_prompt=event.channel_prompt, + tool_choice=getattr(event, "tool_choice", None), ) # Stop persistent typing indicator now that the agent is done @@ -14100,6 +14274,7 @@ async def _run_agent( _interrupt_depth: int = 0, event_message_id: Optional[str] = None, channel_prompt: Optional[str] = None, + tool_choice: Optional[str] = None, ) -> Dict[str, Any]: """ Run the agent with the given message and context. @@ -14653,8 +14828,26 @@ def run_sync(): # (concurrency-safe). Keep os.environ as fallback for CLI/cron. os.environ["HERMES_SESSION_KEY"] = session_key or "" - # Read from env var or use default (same as CLI) - max_iterations = int(os.getenv("HERMES_MAX_ITERATIONS", "90")) + # DC-134: per-profile max_iterations / turn_timeout (DaemonCraft etc.) + # Load from active profile config so gateway-wide defaults are not + # forced on every platform. + _profile_name = getattr(source, "profile", None) or "" + _profile_max_turns = None + _profile_turn_timeout = None + if _profile_name: + try: + import yaml as _yaml + _profile_cfg_path = Path.home() / ".hermes" / "profiles" / _profile_name / "config.yaml" + if _profile_cfg_path.exists(): + _profile_cfg = _yaml.safe_load(_profile_cfg_path.read_text()) or {} + _agent_cfg = _profile_cfg.get("agent", {}) + _profile_max_turns = _agent_cfg.get("max_turns") + _profile_turn_timeout = _agent_cfg.get("turn_timeout_seconds") + except Exception: + pass + + max_iterations = int(_profile_max_turns or os.getenv("HERMES_MAX_ITERATIONS", "90")) + turn_timeout_seconds = int(_profile_turn_timeout or os.getenv("HERMES_TURN_TIMEOUT_SECONDS", "0") or 0) or None # Map platform enum to the platform hint key the agent understands. # Platform.LOCAL ("local") maps to "cli"; others pass through as-is. @@ -14666,7 +14859,36 @@ def run_sync(): event_channel_prompt = (channel_prompt or "").strip() if event_channel_prompt: combined_ephemeral = (combined_ephemeral + "\n\n" + event_channel_prompt).strip() - if self._ephemeral_system_prompt: + + # If a Hermes profile is specified for this source, load its system + # prompt (from SOUL.md or agent.system_prompt) instead of the global + # gateway ephemeral prompt. + _profile_name = getattr(source, "profile", None) + _profile_system_prompt = "" + if _profile_name: + _profile_config, _profile_dir = _load_profile_config(_profile_name) + # 1. SOUL.md / AGENTS.md in the profile directory + if _profile_dir and _profile_dir.exists(): + for fname in ("SOUL.md", "AGENTS.md", ".cursorrules"): + fpath = _profile_dir / fname + if fpath.exists(): + _profile_system_prompt += "\n\n" + fpath.read_text(encoding="utf-8") + # 2. agent.system_prompt from profile config.yaml + _cfg_prompt = (_profile_config.get("agent") or {}).get("system_prompt", "") + if _cfg_prompt: + _profile_system_prompt += "\n\n" + str(_cfg_prompt) + _profile_system_prompt = _profile_system_prompt.strip() + if _profile_system_prompt: + logger.debug("Loaded system prompt from profile '%s' (%d chars)", _profile_name, len(_profile_system_prompt)) + + if _profile_system_prompt: + # Profile overrides the global ephemeral system prompt, but keeps + # session context and per-channel context. + combined_ephemeral = (context_prompt or "").strip() + if event_channel_prompt: + combined_ephemeral = (combined_ephemeral + "\n\n" + event_channel_prompt).strip() + combined_ephemeral = (combined_ephemeral + "\n\n" + _profile_system_prompt).strip() + elif self._ephemeral_system_prompt: combined_ephemeral = (combined_ephemeral + "\n\n" + self._ephemeral_system_prompt).strip() # Re-read .env and config for fresh credentials (gateway is long-lived, @@ -14836,10 +15058,16 @@ def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None: if agent is None: # Config changed or first message — create fresh agent + # If a Hermes profile is active, skip loading global context files + # (SOUL.md, AGENTS.md, MEMORY.md) so the profile's system prompt + # is the only identity injected. + _profile_name = getattr(source, "profile", None) + _skip_context = bool(_profile_name) agent = AIAgent( model=turn_route["model"], **turn_route["runtime"], max_iterations=max_iterations, + turn_timeout_seconds=turn_timeout_seconds, quiet_mode=True, verbose_logging=False, enabled_toolsets=enabled_toolsets, @@ -14866,6 +15094,8 @@ def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None: gateway_session_key=session_key, session_db=self._session_db, fallback_model=self._fallback_model, + skip_context_files=_skip_context, + skip_memory=_skip_context, ) if _cache_lock and _cache is not None: with _cache_lock: @@ -14875,6 +15105,11 @@ def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None: # Per-message state — callbacks and reasoning config change every # turn and must not be baked into the cached agent constructor. + # If a profile is active, override the cached system prompt so the + # agent does not load the global SOUL.md from SQLite session storage. + _profile_name = getattr(source, "profile", None) + if _profile_name and combined_ephemeral: + agent._cached_system_prompt = combined_ephemeral agent.tool_progress_callback = progress_callback if tool_progress_enabled else None agent.step_callback = _step_callback_sync if _hooks_ref.loaded_hooks else None agent.stream_delta_callback = _stream_delta_cb @@ -15205,7 +15440,12 @@ def _approval_notify_sync(approval_data: dict) -> None: else: _run_message = message - result = agent.run_conversation(_run_message, conversation_history=agent_history, task_id=session_id) + result = agent.run_conversation( + _run_message, + conversation_history=agent_history, + task_id=session_id, + tool_choice=tool_choice, + ) finally: unregister_gateway_notify(_approval_session_key) reset_current_session_key(_approval_session_token) diff --git a/gateway/session.py b/gateway/session.py index c145625ae44e..8ab1286037f9 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -91,6 +91,7 @@ class SessionSource: guild_id: Optional[str] = None # Discord guild / Slack workspace / Matrix server scope parent_chat_id: Optional[str] = None # Parent channel when chat_id refers to a thread message_id: Optional[str] = None # ID of the triggering message (for pin/reply/react) + profile: Optional[str] = None # Hermes profile to use for this session @property def description(self) -> str: @@ -134,6 +135,8 @@ def to_dict(self) -> Dict[str, Any]: d["parent_chat_id"] = self.parent_chat_id if self.message_id: d["message_id"] = self.message_id + if self.profile: + d["profile"] = self.profile return d @classmethod @@ -152,6 +155,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "SessionSource": guild_id=data.get("guild_id"), parent_chat_id=data.get("parent_chat_id"), message_id=data.get("message_id"), + profile=data.get("profile"), ) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 42e2f720874b..4c83f20dff02 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -481,13 +481,9 @@ def get_anthropic_key() -> str: # on api.kimi.com/coding. Legacy keys from platform.moonshot.ai work on # api.moonshot.ai/v1 (the old default). Auto-detect when user hasn't set # KIMI_BASE_URL explicitly. -# -# Note: the base URL intentionally has NO /v1 suffix. The /coding endpoint -# speaks the Anthropic Messages protocol, and the anthropic SDK appends -# "/v1/messages" internally — so "/coding" + SDK suffix → "/coding/v1/messages" -# (the correct target). Using "/coding/v1" here would produce -# "/coding/v1/v1/messages" (a 404). -KIMI_CODE_BASE_URL = "https://api.kimi.com/coding" +KIMI_CODE_BASE_URL = "https://api.kimi.com/coding/v1" +KIMI_CODE_CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098" +KIMI_CODE_OAUTH_HOST = "https://auth.kimi.com" def _resolve_kimi_base_url(api_key: str, default_url: str, env_override: str) -> str: @@ -506,6 +502,324 @@ def _resolve_kimi_base_url(api_key: str, default_url: str, env_override: str) -> return default_url +# ============================================================================= +# Kimi CLI OAuth (read credentials installed by `kimi login`) +# ============================================================================= + +def _kimi_cli_credentials_path(): + return Path.home() / ".kimi" / "credentials" / "kimi-code.json" + + +def _kimi_cli_device_id_path() -> Path: + return Path.home() / ".kimi" / "device_id" + + +def _kimi_cli_version() -> str: + """Return installed kimi-cli version, or a sensible default.""" + try: + import shutil + kimi_bin = shutil.which("kimi") + if kimi_bin: + import subprocess + result = subprocess.run( + [kimi_bin, "--version"], + capture_output=True, text=True, timeout=5, + ) + for part in result.stdout.strip().split(): + part = part.strip().rstrip(",") + if part and part[0].isdigit(): + return part + except Exception: + pass + return "1.37.0" + + +def _read_kimi_cli_credentials() -> Dict[str, Any]: + """Read OAuth credentials from the installed Kimi CLI.""" + cred_path = _kimi_cli_credentials_path() + if not cred_path.exists(): + raise AuthError( + "Kimi CLI credentials not found. Run 'kimi login' first.", + provider="kimi-coding", + code="kimi_auth_missing", + ) + try: + data = json.loads(cred_path.read_text(encoding="utf-8")) + except Exception as exc: + raise AuthError( + f"Failed to read Kimi CLI credentials from {cred_path}: {exc}", + provider="kimi-coding", + code="kimi_auth_read_failed", + ) from exc + if not isinstance(data, dict): + raise AuthError( + f"Invalid Kimi CLI credentials in {cred_path}.", + provider="kimi-coding", + code="kimi_auth_invalid", + ) + return data + + +def _save_kimi_cli_credentials(tokens: Dict[str, Any]) -> Path: + cred_path = _kimi_cli_credentials_path() + cred_path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = cred_path.with_suffix(".tmp") + tmp_path.write_text(json.dumps(tokens, indent=2, sort_keys=True) + "\n", encoding="utf-8") + os.chmod(tmp_path, stat.S_IRUSR | stat.S_IWUSR) + tmp_path.replace(cred_path) + return cred_path + + +def _refresh_kimi_cli_credentials( + tokens: Dict[str, Any], + *, + base_url: str, + force_refresh: bool = False, + timeout_seconds: float = 20.0, +) -> Dict[str, Any]: + """Refresh Kimi CLI OAuth credentials and persist the updated token file.""" + refresh_token = str(tokens.get("refresh_token", "") or "").strip() + access_token = str(tokens.get("access_token", "") or "").strip() + if not refresh_token: + if access_token and not force_refresh and not _kimi_oauth_token_is_expired(tokens.get("expires_at")): + return { + "provider": "kimi-coding", + "api_key": access_token, + "base_url": base_url, + "source": "kimi-cli-oauth", + "auth_file": str(_kimi_cli_credentials_path()), + } + raise AuthError( + "Kimi CLI OAuth credentials are missing a refresh_token. Run `kimi login` to re-authenticate.", + provider="kimi-coding", + code="kimi_oauth_missing_refresh_token", + relogin_required=True, + ) + + if access_token and not force_refresh and not _kimi_oauth_token_is_expired(tokens.get("expires_at")): + return { + "provider": "kimi-coding", + "api_key": access_token, + "base_url": base_url, + "source": "kimi-cli-oauth", + "auth_file": str(_kimi_cli_credentials_path()), + } + + timeout = httpx.Timeout(max(5.0, float(timeout_seconds))) + with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}) as client: + response = client.post( + f"{KIMI_CODE_OAUTH_HOST.rstrip('/')}/api/oauth/token", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + data={ + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": KIMI_CODE_CLIENT_ID, + }, + ) + + if response.status_code != 200: + code = "kimi_oauth_refresh_failed" + message = f"Kimi token refresh failed with status {response.status_code}." + relogin_required = False + try: + err = response.json() + if isinstance(err, dict): + err_code = err.get("error") + if isinstance(err_code, str) and err_code.strip(): + code = err_code.strip() + err_desc = err.get("error_description") or err.get("message") + if isinstance(err_desc, str) and err_desc.strip(): + message = f"Kimi token refresh failed: {err_desc.strip()}" + except Exception: + pass + if code in {"invalid_grant", "invalid_token", "invalid_request"}: + relogin_required = True + if response.status_code in (401, 403): + relogin_required = True + raise AuthError( + message, + provider="kimi-coding", + code=code, + relogin_required=relogin_required, + ) + + try: + refresh_payload = response.json() + except Exception as exc: + raise AuthError( + "Kimi token refresh returned invalid JSON.", + provider="kimi-coding", + code="kimi_oauth_refresh_invalid_json", + relogin_required=True, + ) from exc + + if not isinstance(refresh_payload, dict): + raise AuthError( + "Kimi token refresh returned an invalid payload.", + provider="kimi-coding", + code="kimi_oauth_refresh_invalid_payload", + relogin_required=True, + ) + + refreshed_access = refresh_payload.get("access_token") + if not isinstance(refreshed_access, str) or not refreshed_access.strip(): + raise AuthError( + "Kimi token refresh response was missing access_token.", + provider="kimi-coding", + code="kimi_oauth_refresh_missing_access_token", + relogin_required=True, + ) + + next_refresh = str(refresh_payload.get("refresh_token", refresh_token) or refresh_token).strip() + expires_in_raw = refresh_payload.get("expires_in") + try: + expires_in = float(expires_in_raw) + except Exception: + expires_in = None + + updated = dict(tokens) + updated["access_token"] = refreshed_access.strip() + updated["refresh_token"] = next_refresh + if expires_in is not None and expires_in > 0: + updated["expires_at"] = time.time() + expires_in + updated["expires_in"] = expires_in + else: + updated["expires_at"] = tokens.get("expires_at", time.time() + 3600) + updated["expires_in"] = tokens.get("expires_in", 3600) + scope = refresh_payload.get("scope") + if isinstance(scope, str) and scope.strip(): + updated["scope"] = scope.strip() + token_type = refresh_payload.get("token_type") + if isinstance(token_type, str) and token_type.strip(): + updated["token_type"] = token_type.strip() + _save_kimi_cli_credentials(updated) + + return { + "provider": "kimi-coding", + "api_key": updated["access_token"], + "base_url": base_url, + "source": "kimi-cli-oauth-refresh", + "auth_file": str(_kimi_cli_credentials_path()), + } + + +def _kimi_oauth_token_is_expired(expires_at: Any, skew_seconds: int = 300) -> bool: + try: + exp = float(expires_at) + except Exception: + return True + return exp <= (time.time() + max(0, skew_seconds)) + + +def kimi_coding_default_headers() -> Dict[str, str]: + """Return the X-Msh-* headers that Kimi's coding API now requires.""" + import platform + import socket + + device_id = "" + device_path = _kimi_cli_device_id_path() + if device_path.exists(): + try: + device_id = device_path.read_text(encoding="utf-8").strip() + except Exception: + pass + + version = _kimi_cli_version() + + headers: Dict[str, str] = { + "User-Agent": f"KimiCLI/{version}", + "X-Msh-Platform": "kimi_cli", + "X-Msh-Version": version, + "X-Msh-Device-Name": platform.node() or socket.gethostname(), + "X-Msh-Device-Model": platform.machine(), + "X-Msh-Os-Version": platform.version(), + } + if device_id: + headers["X-Msh-Device-Id"] = device_id + return headers + + +def resolve_kimi_coding_runtime_credentials( + *, + prefer_cli_oauth: bool = True, + force_refresh: bool = False, + allow_api_key_fallback: bool = True, +) -> Dict[str, Any]: + """Resolve credentials for kimi-coding, preferring Kimi CLI OAuth.""" + base_url = os.getenv("KIMI_BASE_URL", "").strip().rstrip("/") + if not base_url: + base_url = KIMI_CODE_BASE_URL + + if prefer_cli_oauth: + try: + creds = _read_kimi_cli_credentials() + access_token = str(creds.get("access_token", "") or "").strip() + refresh_token = str(creds.get("refresh_token", "") or "").strip() + token_expired = _kimi_oauth_token_is_expired(creds.get("expires_at")) + + if access_token and not force_refresh and not token_expired: + return { + "provider": "kimi-coding", + "api_key": access_token, + "base_url": base_url, + "source": "kimi-cli-oauth", + "auth_file": str(_kimi_cli_credentials_path()), + } + + if refresh_token: + return _refresh_kimi_cli_credentials( + creds, + base_url=base_url, + force_refresh=force_refresh or token_expired or not access_token, + ) + + if access_token and not force_refresh: + return { + "provider": "kimi-coding", + "api_key": access_token, + "base_url": base_url, + "source": "kimi-cli-oauth", + "auth_file": str(_kimi_cli_credentials_path()), + } + + raise AuthError( + "Kimi CLI OAuth credentials are not usable. Run 'kimi login' to refresh them.", + provider="kimi-coding", + code="kimi_oauth_credentials_unusable", + relogin_required=True, + ) + except AuthError: + if not allow_api_key_fallback: + raise + logger.debug("Kimi CLI OAuth unavailable, falling back to API key.") + except Exception as exc: + if not allow_api_key_fallback: + raise AuthError( + f"Kimi CLI OAuth read failed: {exc}", + provider="kimi-coding", + code="kimi_oauth_read_failed", + relogin_required=True, + ) from exc + logger.debug("Kimi CLI OAuth read failed: %s", exc) + + api_key = os.getenv("KIMI_API_KEY", "").strip() + if api_key: + if not base_url: + base_url = _resolve_kimi_base_url(api_key, KIMI_CODE_BASE_URL, "") + return { + "provider": "kimi-coding", + "api_key": api_key, + "base_url": base_url, + "source": "env", + } + + raise AuthError( + "No Kimi credentials found. Either run 'kimi login' (OAuth) or " + "set the KIMI_API_KEY environment variable.", + provider="kimi-coding", + code="kimi_no_credentials", + ) + _PLACEHOLDER_SECRET_VALUES = { "*", @@ -4092,6 +4406,20 @@ def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]: if provider_id in ("kimi-coding", "kimi-coding-cn"): base_url = _resolve_kimi_base_url(api_key, pconfig.inference_base_url, env_url) + # Prefer the Kimi CLI OAuth session only when no explicit KIMI_API_KEY + # (or stored API key) is available. Explicit API keys must remain + # deterministic for tests and for users who intentionally choose them. + if not api_key and ("api.kimi.com" in base_url or not env_url): + try: + oauth_creds = resolve_kimi_coding_runtime_credentials() + return { + "provider": provider_id, + "api_key": oauth_creds["api_key"], + "base_url": str(oauth_creds.get("base_url") or base_url).rstrip("/"), + "source": oauth_creds.get("source", "kimi-cli-oauth"), + } + except AuthError: + logger.debug("Kimi CLI OAuth unavailable, using API key fallback.") elif provider_id == "zai": base_url = _resolve_zai_base_url(api_key, pconfig.inference_base_url, env_url) elif env_url: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index feeb10892f2f..3c39b83e73a7 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -717,8 +717,13 @@ def _ensure_hermes_home_managed(home: Path): "enabled": True, "threshold": 0.50, # compress when context usage exceeds this ratio "target_ratio": 0.20, # fraction of threshold to preserve as recent tail + "protect_first_n": 3, # messages from start to keep uncompressed (0 = only summary + tail) "protect_last_n": 20, # minimum recent messages to keep uncompressed "hygiene_hard_message_limit": 400, # gateway session-hygiene force-compress threshold by message count + "prompt": { + "preamble": "", # optional custom summarizer preamble (empty = default) + "template": "", # optional custom summary template (empty = default) + }, }, # Anthropic prompt caching (Claude via OpenRouter or native Anthropic API). @@ -894,6 +899,7 @@ def _ensure_hermes_home_managed(home: Path): "personality": "kawaii", "resume_display": "full", "busy_input_mode": "interrupt", # interrupt | queue | steer + "ctrl_c_priority": "interrupt_agent", # "interrupt_agent" | "clear_input" # When true, `hermes --tui` auto-resumes the most recent human- # facing session on launch instead of forging a fresh one. # Mirrors `hermes -c` muscle memory. Default off so existing @@ -4713,6 +4719,7 @@ def show_config(): if enabled: print(f" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%") print(f" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved") + print(f" Protect first: {compression.get('protect_first_n', 3)} messages") print(f" Protect last: {compression.get('protect_last_n', 20)} messages") _aux_comp = config.get('auxiliary', {}).get('compression', {}) _sm = _aux_comp.get('model', '') or '(auto)' diff --git a/hermes_cli/kanban_review.py b/hermes_cli/kanban_review.py new file mode 100644 index 000000000000..4d10a496040f --- /dev/null +++ b/hermes_cli/kanban_review.py @@ -0,0 +1,351 @@ +"""Kanban ship-review graph creation. + +Provides ``create_review_graph()`` — a helper that builds a durable +5-card review graph for a git change: + + 1. Parent review card (base..head change summary) + 2. Code-quality reviewer ┐ + 3. Security reviewer │ parallel + 4. Test-coverage reviewer ┘ + 5. Synthesis card ← gated on 2-4 + +All cards use deterministic idempotency keys so repeated invocations are +idempotent. By default every card is created in ``triage`` so nothing +dispatches until the operator explicitly promotes them. + +The CLI surface lives in ``hermes_cli/kanban.py`` under +``hermes kanban review create …``. +""" + +from __future__ import annotations + +import hashlib +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Optional + +from hermes_cli import kanban_db as kb + + +# --------------------------------------------------------------------------- +# Typed spec +# --------------------------------------------------------------------------- + +@dataclass +class ReviewGraphSpec: + """Parameters that fully describe a ship-review graph.""" + + repo_path: str + base: str + head: str + title: str + assignee: Optional[str] = None + ready: bool = False + idempotency_prefix: Optional[str] = None + skills: list[str] = field(default_factory=list) + body: Optional[str] = None + + +# --------------------------------------------------------------------------- +# Idempotency helpers +# --------------------------------------------------------------------------- + +def _repo_hash(repo_path: str) -> str: + """Stable 16-char hex hash of the resolved repo path.""" + abs_path = str(Path(repo_path).resolve()) + return hashlib.sha256(abs_path.encode()).hexdigest()[:16] + + +def _review_base_key(base: str, head: str, repo_path: str) -> str: + """Deterministic base key for a given review target. + + Derives from repo realpath hash + base + head so the key is stable + across board switches and path aliasing. + """ + return f"ship-review:{_repo_hash(repo_path)}:{base}:{head}" + + +def _card_key(base_key: str, role: str) -> str: + return f"{base_key}:{role}" + + +# --------------------------------------------------------------------------- +# Template helpers +# --------------------------------------------------------------------------- + +_ROLE_FOCUS = { + "code-quality": ( + "readability, maintainability, naming, complexity, DRY violations, " + "and architectural consistency" + ), + "security": ( + "injection vectors, unsafe evals, hardcoded secrets, input validation, " + "auth/authz gaps, and dependency risks" + ), + "test-coverage": ( + "missing tests for new logic, edge cases, regression tests, " + "test readability, and CI pass status" + ), +} + +_ROLE_CHECKLIST: dict[str, list[str]] = { + "code-quality": [ + "Readability and naming conventions", + "DRY violations and duplicated logic", + "Complexity and function length", + "Architectural consistency with existing patterns", + "Type safety and static analysis concerns", + "Documentation completeness", + ], + "security": [ + "Injection vectors (SQL, command, path, eval)", + "Hardcoded secrets or credentials", + "Input validation and sanitization", + "Authentication/authorization gaps", + "Unsafe deserialization or eval usage", + "Dependency risks (untrusted sources, version pinning)", + "Privilege escalation paths", + ], + "test-coverage": [ + "New logic has accompanying tests", + "Edge cases are covered", + "Regression tests for bug fixes", + "Test readability and naming", + "CI pass status and flaky test checks", + "Integration / E2E coverage for user-facing changes", + ], +} + + +def _reviewer_body(role: str, base: str, head: str, ws_path: str) -> str: + """Return a hardened reviewer task body for *role*.""" + focus = _ROLE_FOCUS.get(role, "general code review") + checklist_lines = "\n".join(f"- [ ] {item}" for item in _ROLE_CHECKLIST.get(role, [])) + + return ( + f"Review the {role} of `{base}` → `{head}` in `{ws_path}`.\n\n" + f"Diff to review:\n" + f" git diff {base}...{head} --stat\n" + f" git diff {base}...{head}\n\n" + f"**REVIEW-ONLY v1** — Do NOT modify source code. " + f"Report findings as structured metadata only.\n\n" + f"Severity labels:\n" + f"- **Critical** — Merge blocker; must be fixed before ship.\n" + f"- **Important** — Significant concern; strongly recommend fixing.\n" + f"- **Optional/Nit** — Minor improvement; ship at discretion.\n\n" + f"kanban_complete / kanban_block contract:\n" + f'- Call kanban_complete(summary=..., metadata={{"findings": [...]}})\n' + f'- Call kanban_block(reason=...) if you are blocked ' + f"(missing context, cannot access files)\n" + f"- Each finding must include: severity, file, line (if applicable), " + f"issue description.\n\n" + f"Focus on: {focus}.\n\n" + f"Checklist:\n{checklist_lines}" + ) + + +def _synthesis_body(base: str, head: str, ws_path: str) -> str: + """Return a hardened synthesis task body.""" + return ( + f"Synthesize findings from the three reviewers for `{base}` → `{head}` " + f"in `{ws_path}`.\n\n" + f"Inputs: parent card + three completed reviewer cards " + f"(code-quality, security, test-coverage).\n\n" + f"Required output structure:\n" + f"- **GO/NO-GO decision** with explicit rationale.\n" + f"- **Blockers**: list of Critical findings that must be resolved before ship.\n" + f"- **Recommended fixes**: ordered by priority (Critical first, then Important).\n" + f"- **Acknowledged risks**: Important/Optional findings accepted as-is with justification.\n" + f"- **Rollback plan**: steps to revert this change if issues surface in production.\n" + f"- **Evidence reviewed**: list of files/evidence examined " + f"(diff stat, key changed files).\n\n" + f"Default rule: **NO-GO** if any Critical finding exists unless the user " + f"explicitly accepts the risk in writing.\n\n" + f"kanban_complete / kanban_block contract:\n" + f'- Call kanban_complete(summary=..., metadata={{"ship_decision": "GO|NO-GO", ' + f'"blockers": [...], "recommended_fixes": [...], ' + f'"acknowledged_risks": [...], "rollback_plan": "...", ' + f'"evidence_reviewed": [...]}})\n' + f'- Call kanban_block(reason=...) if you are blocked ' + f"(missing reviewer output, incomplete context)." + ) + + +# --------------------------------------------------------------------------- +# Graph creation +# --------------------------------------------------------------------------- + +def create_review_graph( + *, + title: str, + base: str, + head: str, + repo_path: str, + board: Optional[str] = None, + assignee: Optional[str] = None, + ready: bool = False, + body: Optional[str] = None, + skills: Optional[list[str]] = None, +) -> dict[str, Any]: + """Create (or return existing) ship-review graph. + + Parameters + ---------- + title: + Human title for the parent review card (e.g. "Review PR #42"). + base: + Git base ref (e.g. ``nousmain``). + head: + Git head ref (e.g. ``feat/auth``). + repo_path: + Absolute path to the repository root. Used as ``dir:`` workspace. + board: + Board slug. Defaults to ``kanban_db.get_current_board()``. + assignee: + Profile name for **all** cards. ``None`` leaves them unassigned. + ready: + When ``False`` (default) every card is created in ``triage``. + When ``True`` the parent + reviewer cards are created in ``ready`` + and the synthesis card in ``todo`` (it will auto-promote once its + parents complete). + body: + Optional extra context appended to the parent review card body. + skills: + Optional list of skills to attach to every card. + + Returns + ------- + dict with ``parent_id``, ``reviewer_ids``, ``synthesis_id``, and + ``created`` (bool — ``False`` when every id already existed). + """ + board = board or kb.get_current_board() + base_key = _review_base_key(base, head, repo_path) + ws_kind, ws_path = "dir", str(Path(repo_path).resolve()) + default_status = "ready" if ready else "triage" + + # Parent card body + parent_body_parts = [ + f"Ship review for `{base}` → `{head}`.", + f"Repository: {ws_path}", + "", + "**REVIEW-ONLY v1** — Do NOT modify source code. " + "Report findings as structured metadata only.", + "", + "Reviewers:", + "- code-quality", + "- security", + "- test-coverage", + "", + "Synthesis card will aggregate findings once all reviewers finish.", + ] + if body: + parent_body_parts.extend(["", "Context:", body]) + parent_body = "\n".join(parent_body_parts) + + # Reviewer templates + reviewers = [ + ("code-quality", f"[REVIEW] Code quality — {title}"), + ("security", f"[REVIEW] Security — {title}"), + ("test-coverage", f"[REVIEW] Test coverage — {title}"), + ] + + created_any = False + reviewer_ids: list[str] = [] + + with kb.connect(board=board) as conn: + # --- Parent review card --- + parent_key = _card_key(base_key, "parent") + existing_parent = conn.execute( + "SELECT id FROM tasks WHERE idempotency_key = ? AND status != 'archived'", + (parent_key,), + ).fetchone() + if existing_parent: + parent_id = existing_parent["id"] + else: + parent_id = kb.create_task( + conn, + title=title, + body=parent_body, + assignee=assignee, + created_by=_profile_author(), + workspace_kind=ws_kind, + workspace_path=ws_path, + triage=not ready, + idempotency_key=parent_key, + skills=skills, + ) + created_any = True + + # --- Reviewer cards (parallel) --- + # Note: reviewers are NOT linked to the parent card because + # kanban_db treats every parent link as a blocking dependency. + # The parent is an organisational umbrella; only the synthesis + # card is gated on the reviewers. + for role, rtitle in reviewers: + rkey = _card_key(base_key, role) + existing = conn.execute( + "SELECT id FROM tasks WHERE idempotency_key = ? AND status != 'archived'", + (rkey,), + ).fetchone() + if existing: + rid = existing["id"] + else: + rid = kb.create_task( + conn, + title=rtitle, + body=_reviewer_body(role, base, head, ws_path), + assignee=assignee, + created_by=_profile_author(), + workspace_kind=ws_kind, + workspace_path=ws_path, + triage=not ready, + idempotency_key=rkey, + skills=skills, + ) + created_any = True + reviewer_ids.append(rid) + + # --- Synthesis card (gated on all reviewers) --- + synthesis_body = _synthesis_body(base, head, ws_path) + synth_key = _card_key(base_key, "synthesis") + existing_synth = conn.execute( + "SELECT id FROM tasks WHERE idempotency_key = ? AND status != 'archived'", + (synth_key,), + ).fetchone() + if existing_synth: + synthesis_id = existing_synth["id"] + else: + synthesis_id = kb.create_task( + conn, + title=f"[SYNTHESIS] {title}", + body=synthesis_body, + assignee=assignee, + created_by=_profile_author(), + workspace_kind=ws_kind, + workspace_path=ws_path, + triage=not ready, + idempotency_key=synth_key, + parents=tuple(reviewer_ids), + skills=skills, + ) + created_any = True + + return { + "parent_id": parent_id, + "reviewer_ids": reviewer_ids, + "synthesis_id": synthesis_id, + "created": created_any, + } + + +def _profile_author() -> str: + for env in ("HERMES_PROFILE_NAME", "HERMES_PROFILE"): + v = os.environ.get(env) + if v: + return v + try: + from hermes_cli.profiles import get_active_profile_name + return get_active_profile_name() or "user" + except Exception: + return "user" diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 2bf679b14aeb..e6dcd25da1a6 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -4264,6 +4264,8 @@ def _model_flow_kimi(config, current_model=""): _prompt_model_selection, _save_model_choice, deactivate_provider, + resolve_kimi_coding_runtime_credentials, + AuthError, ) from hermes_cli.config import ( get_env_value, @@ -4277,21 +4279,48 @@ def _model_flow_kimi(config, current_model=""): key_env = pconfig.api_key_env_vars[0] if pconfig.api_key_env_vars else "" base_url_env = pconfig.base_url_env_var or "" - # Step 1: Check / prompt for API key + # Step 1: Check for credentials — prefer OAuth, then env API key, then prompt existing_key = "" for ev in pconfig.api_key_env_vars: existing_key = get_env_value(ev) or os.getenv(ev, "") if existing_key: break - existing_key, abort = _prompt_api_key( - pconfig, existing_key, provider_id=provider_id - ) - if abort: - return + oauth_available = False + if not existing_key: + try: + oauth_creds = resolve_kimi_coding_runtime_credentials() + if oauth_creds.get("source") in {"kimi-cli-oauth", "kimi-cli-oauth-refresh"}: + oauth_available = True + print(f" {pconfig.name} OAuth: {oauth_creds['auth_file']} ✓") + print() + except AuthError: + pass + + if not existing_key and not oauth_available: + print(f"No {pconfig.name} API key configured.") + if key_env: + try: + import getpass - # Step 2: Auto-detect endpoint from key prefix - is_coding_plan = existing_key.startswith("sk-kimi-") + new_key = getpass.getpass(f"{key_env} (or Enter to cancel): ").strip() + except (KeyboardInterrupt, EOFError): + print() + return + if not new_key: + print("Cancelled.") + return + save_env_value(key_env, new_key) + existing_key = new_key + print("API key saved.") + print() + elif existing_key: + print(f" {pconfig.name} API key: {existing_key[:8]}... ✓") + print() + + + # Step 2: Auto-detect endpoint from key prefix or OAuth + is_coding_plan = oauth_available or existing_key.startswith("sk-kimi-") if is_coding_plan: effective_base = KIMI_CODE_BASE_URL print(f" Detected Kimi Coding Plan key → {effective_base}") @@ -4305,11 +4334,11 @@ def _model_flow_kimi(config, current_model=""): # Step 3: Model selection — show appropriate models for the endpoint if is_coding_plan: - # Coding Plan models (kimi-k2.6 first) + # Coding Plan models model_list = [ "kimi-k2.6", - "kimi-k2.5", "kimi-for-coding", + "kimi-k2.5", "kimi-k2-thinking", "kimi-k2-thinking-turbo", ] diff --git a/hermes_cli/models.py b/hermes_cli/models.py index cf693ae28b44..bcbef04e5189 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -3541,8 +3541,6 @@ def validate_requested_model( ), } - # No catalog available — accept with a warning, matching the comment's - # stated intent ("Accept and persist, but warn"). return { "accepted": True, "persist": True, diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index f766a50ebf95..2b63d22ad8bc 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -490,12 +490,9 @@ def determine_api_mode(provider: str, base_url: str = "") -> str: """ pdef = get_provider(provider) if pdef is not None: - # Even for known providers, check URL heuristics for special endpoints - # (e.g. kimi /coding endpoint needs anthropic_messages even on 'custom') + # Even for known providers, check URL heuristics for special endpoints. if base_url: url_lower = base_url.rstrip("/").lower() - if "api.kimi.com/coding" in url_lower: - return "anthropic_messages" if url_lower.endswith("/anthropic") or "api.anthropic.com" in url_lower: return "anthropic_messages" if "api.openai.com" in url_lower: @@ -512,8 +509,6 @@ def determine_api_mode(provider: str, base_url: str = "") -> str: hostname = base_url_hostname(base_url) if url_lower.endswith("/anthropic") or hostname == "api.anthropic.com": return "anthropic_messages" - if hostname == "api.kimi.com" and "/coding" in url_lower: - return "anthropic_messages" if hostname == "api.openai.com": return "codex_responses" if hostname.startswith("bedrock-runtime.") and base_url_host_matches(base_url, "amazonaws.com"): diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index fe996d1e3999..9606c3688b7c 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -23,6 +23,7 @@ resolve_codex_runtime_credentials, resolve_qwen_runtime_credentials, resolve_gemini_oauth_runtime_credentials, + resolve_kimi_coding_runtime_credentials, resolve_api_key_provider_credentials, resolve_external_process_provider_credentials, has_usable_secret, @@ -64,14 +65,10 @@ def _detect_api_mode_for_url(base_url: str) -> Optional[str]: - Direct api.openai.com endpoints need the Responses API for GPT-5.x tool calls with reasoning (chat/completions returns 400). - - Third-party Anthropic-compatible gateways (MiniMax, Zhipu GLM, - LiteLLM proxies, etc.) conventionally expose the native Anthropic - protocol under a ``/anthropic`` suffix — treat those as - ``anthropic_messages`` transport instead of the default - ``chat_completions``. - - Kimi Code's ``api.kimi.com/coding`` endpoint also speaks the - Anthropic Messages protocol (the /coding route accepts Claude - Code's native request shape). + - Third-party Anthropic-compatible gateways (MiniMax, LiteLLM proxies, + etc.) conventionally expose the native Anthropic protocol under a + ``/anthropic`` suffix — treat those as ``anthropic_messages`` + transport instead of the default ``chat_completions``. """ normalized = (base_url or "").strip().lower().rstrip("/") hostname = base_url_hostname(base_url) @@ -81,8 +78,6 @@ def _detect_api_mode_for_url(base_url: str) -> Optional[str]: return "codex_responses" if normalized.endswith("/anthropic"): return "anthropic_messages" - if hostname == "api.kimi.com" and "/coding" in normalized: - return "anthropic_messages" return None @@ -1284,15 +1279,24 @@ def resolve_runtime_provider( # API-key providers (z.ai/GLM, Kimi, MiniMax, MiniMax-CN) pconfig = PROVIDER_REGISTRY.get(provider) if pconfig and pconfig.auth_type == "api_key": + cfg_provider = str(model_cfg.get("provider") or "").strip().lower() + cfg_base_url = "" + if cfg_provider == provider: + cfg_base_url = (model_cfg.get("base_url") or "").strip().rstrip("/") creds = resolve_api_key_provider_credentials(provider) + if ( + provider in ("kimi-coding", "kimi-coding-cn") + and not str(creds.get("api_key", "")).strip() + and "api.kimi.com" in cfg_base_url + ): + try: + creds = resolve_kimi_coding_runtime_credentials() + except AuthError: + pass # Honour model.base_url from config.yaml when the configured provider # matches this provider — mirrors the Anthropic path above. Without # this, users who set model.base_url to e.g. api.minimaxi.com/anthropic # (China endpoint) still get the hardcoded api.minimax.io default (#6039). - cfg_provider = str(model_cfg.get("provider") or "").strip().lower() - cfg_base_url = "" - if cfg_provider == provider: - cfg_base_url = (model_cfg.get("base_url") or "").strip().rstrip("/") base_url = cfg_base_url or creds.get("base_url", "").rstrip("/") api_mode = "chat_completions" if provider == "copilot": diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 96b3d4e3be5e..1d347cea0354 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -70,6 +70,7 @@ ("delegation", "👥 Task Delegation", "delegate_task"), ("cronjob", "⏰ Cron Jobs", "create/list/update/pause/resume/run, with optional attached skills"), ("messaging", "📨 Cross-Platform Messaging", "send_message"), + ("minecraft", "⛏️ Minecraft", "perceive, navigate, build, craft, combat, manage, screenshot, command, story"), ("rl", "🧪 RL Training", "Tinker-Atropos training tools"), ("homeassistant", "🏠 Home Assistant", "smart home device control"), ("spotify", "🎵 Spotify", "playback, search, playlists, library"), diff --git a/model_tools.py b/model_tools.py index 253cf02fe8d2..457734742be4 100644 --- a/model_tools.py +++ b/model_tools.py @@ -780,12 +780,14 @@ def handle_function_call( result = registry.dispatch( function_name, function_args, task_id=task_id, + session_id=session_id, enabled_tools=sandbox_enabled, ) else: result = registry.dispatch( function_name, function_args, task_id=task_id, + session_id=session_id, user_task=user_task, ) duration_ms = int((time.monotonic() - _dispatch_start) * 1000) diff --git a/run_agent.py b/run_agent.py index 8908562b38cc..055922fff533 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1114,6 +1114,8 @@ def __init__( checkpoint_max_total_size_mb: int = 500, checkpoint_max_file_size_mb: int = 10, pass_session_id: bool = False, + turn_timeout_seconds: int = None, + agent_identity: str = None, ): """ Initialize the AI Agent. @@ -1167,6 +1169,8 @@ def __init__( self.model = model self.max_iterations = max_iterations + self.turn_timeout_seconds = turn_timeout_seconds + self.agent_identity = agent_identity # Shared iteration budget — parent creates, children inherit. # Consumed by every LLM turn across parent + all subagents. self.iteration_budget = iteration_budget or IterationBudget(max_iterations) @@ -1224,6 +1228,17 @@ def __init__( # use a URL convention ending in /anthropic. Auto-detect these so the # Anthropic Messages API adapter is used instead of chat completions. self.api_mode = "anthropic_messages" + elif self.provider in ("minimax", "minimax-cn"): + # MiniMax serves its own models through an Anthropic-compatible endpoint. + # Default to anthropic_messages so prompt caching and other Anthropic + # features work out of the box. Mirrors the runtime_provider.py logic. + self.api_mode = "anthropic_messages" + if not self.base_url: + self.base_url = ( + "https://api.minimax.io/anthropic" + if self.provider == "minimax" + else "https://api.minimaxi.com/anthropic" + ) elif self.provider == "bedrock" or ( self._base_url_hostname.startswith("bedrock-runtime.") and base_url_host_matches(self._base_url_lower, "amazonaws.com") @@ -1602,9 +1617,8 @@ def __init__( client_kwargs["default_headers"] = copilot_default_headers() elif base_url_host_matches(effective_base, "api.kimi.com"): - client_kwargs["default_headers"] = { - "User-Agent": "claude-code/0.1.0", - } + from hermes_cli.auth import kimi_coding_default_headers + client_kwargs["default_headers"] = kimi_coding_default_headers() elif base_url_host_matches(effective_base, "portal.qwen.ai"): client_kwargs["default_headers"] = _qwen_portal_headers() elif base_url_host_matches(effective_base, "chatgpt.com"): @@ -2033,8 +2047,21 @@ def __init__( pass compression_enabled = str(_compression_cfg.get("enabled", True)).lower() in ("true", "1", "yes") compression_target_ratio = float(_compression_cfg.get("target_ratio", 0.20)) + compression_protect_first = int(_compression_cfg.get("protect_first_n", 3)) compression_protect_last = int(_compression_cfg.get("protect_last_n", 20)) + # Optional custom compression prompt overrides + _prompt_cfg = _compression_cfg.get("prompt", {}) + if not isinstance(_prompt_cfg, dict): + _prompt_cfg = {} + compression_summary_preamble = _prompt_cfg.get("preamble") or None + compression_summary_template = _prompt_cfg.get("template") or None + # Strip whitespace so empty strings in YAML are treated as "not set" + if compression_summary_preamble and not compression_summary_preamble.strip(): + compression_summary_preamble = None + if compression_summary_template and not compression_summary_template.strip(): + compression_summary_template = None + # Read optional explicit context_length override for the auxiliary # compression model. Custom endpoints often cannot report this via # /models, so the startup feasibility check needs the config hint. @@ -2234,7 +2261,7 @@ def __init__( self.context_compressor = ContextCompressor( model=self.model, threshold_percent=compression_threshold, - protect_first_n=3, + protect_first_n=compression_protect_first, protect_last_n=compression_protect_last, summary_target_ratio=compression_target_ratio, summary_model_override=None, @@ -2244,6 +2271,8 @@ def __init__( config_context_length=_config_context_length, provider=self.provider, api_mode=self.api_mode, + summary_preamble=compression_summary_preamble, + summary_template=compression_summary_template, ) self.compression_enabled = compression_enabled @@ -3392,11 +3421,13 @@ def _anthropic_prompt_cache_policy( OpenAI-wire proxies expect the looser layout). Third-party providers using the native Anthropic transport - (``api_mode == 'anthropic_messages'`` + Claude-named model) get - caching with the native layout so they benefit from the same - cost reduction as direct Anthropic callers, provided their - gateway implements the Anthropic cache_control contract - (MiniMax, Zhipu GLM, LiteLLM's Anthropic proxy mode all do). + (``api_mode == 'anthropic_messages'``) get caching with the + native layout when the model is Claude-named or the provider + is a known Anthropic-compatible gateway that documents + ``cache_control`` support for its own models (MiniMax). + LiteLLM proxies and Zhipu GLM also implement the contract + but are only enabled for Claude-named models until they + document cache support for their own model families. Qwen / Alibaba-family models on OpenCode, OpenCode Go, and direct Alibaba (DashScope) also honour Anthropic-style ``cache_control`` @@ -4302,6 +4333,37 @@ def _apply_persist_user_message_override(self, messages: List[Dict]) -> None: if isinstance(msg, dict) and msg.get("role") == "user": msg["content"] = override + def _sanitize_unanswered_tool_calls(self, messages: List[Dict], error_msg: str = "Agent interrupted by user") -> None: + """Append synthetic error results for any unanswered tool_calls. + + OpenAI/Anthropic require a `role="tool"` message for every + `tool_call_id` emitted by the assistant. If an interrupt or error + fires before all tools finish, this prevents the next API call from + failing with a "missing tool response" error. + """ + for idx in range(len(messages) - 1, -1, -1): + msg = messages[idx] + if not isinstance(msg, dict): + break + if msg.get("role") == "tool": + continue + if msg.get("role") == "assistant" and msg.get("tool_calls"): + answered_ids = { + m["tool_call_id"] + for m in messages[idx + 1:] + if isinstance(m, dict) and m.get("role") == "tool" + } + for tc in msg["tool_calls"]: + if not tc or not isinstance(tc, dict): + continue + if tc["id"] not in answered_ids: + messages.append({ + "role": "tool", + "tool_call_id": tc["id"], + "content": f"Error executing tool: {error_msg}", + }) + break + def _persist_session(self, messages: List[Dict], conversation_history: List[Dict] = None): """Save session state to both JSON log and SQLite on any exit path. @@ -4309,6 +4371,8 @@ def _persist_session(self, messages: List[Dict], conversation_history: List[Dict """ self._drop_trailing_empty_response_scaffolding(messages) self._apply_persist_user_message_override(messages) + # Guarantee API-valid transcript: every tool_call must have a matching tool result. + self._sanitize_unanswered_tool_calls(messages) self._session_messages = messages self._save_session_log(messages) self._flush_messages_to_session_db(messages, conversation_history) @@ -6175,6 +6239,10 @@ def _invalidate_system_prompt(self): if self._memory_store: self._memory_store.load_from_disk() + def _responses_tools(self, tools: Optional[List[Dict[str, Any]]] = None) -> Optional[List[Dict[str, Any]]]: + """Convert chat-completions tool schemas to Responses function-tool schemas.""" + return _codex_responses_tools(tools if tools is not None else self.tools) + @staticmethod def _deterministic_call_id(fn_name: str, arguments: str, index: int = 0) -> str: """Generate a deterministic call_id from tool call content. @@ -6198,6 +6266,33 @@ def _derive_responses_function_call_id( """Build a valid Responses `function_call.id` (must start with `fc_`).""" return _codex_derive_responses_function_call_id(call_id, response_item_id) + def _chat_messages_to_responses_input(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Convert internal chat-style messages to Responses input items.""" + return _codex_chat_messages_to_responses_input(messages) + + def _preflight_codex_input_items(self, raw_items: Any) -> List[Dict[str, Any]]: + return _codex_preflight_codex_input_items(raw_items) + + def _preflight_codex_api_kwargs( + self, + api_kwargs: Any, + *, + allow_stream: bool = False, + ) -> Dict[str, Any]: + return _codex_preflight_codex_api_kwargs(api_kwargs, allow_stream=allow_stream) + + def _extract_responses_message_text(self, item: Any) -> str: + """Extract assistant text from a Responses message output item.""" + return _codex_extract_responses_message_text(item) + + def _extract_responses_reasoning_text(self, item: Any) -> str: + """Extract a compact reasoning text from a Responses reasoning item.""" + return _codex_extract_responses_reasoning_text(item) + + def _normalize_codex_response(self, response: Any) -> tuple[Any, str]: + """Normalize a Responses API object to an assistant_message-like object.""" + return _codex_normalize_codex_response(response) + def _thread_identity(self) -> str: thread = threading.current_thread() return f"{thread.name}:{thread.ident}" @@ -6915,6 +7010,41 @@ def _try_refresh_copilot_client_credentials(self) -> bool: logger.info("Copilot credentials refreshed from %s", token_source) return True + def _try_refresh_kimi_client_credentials(self, *, force: bool = True) -> bool: + if self.provider not in {"kimi-coding", "kimi-coding-cn"} and not base_url_host_matches(self.base_url, "api.kimi.com"): + return False + + try: + from hermes_cli.auth import resolve_kimi_coding_runtime_credentials, kimi_coding_default_headers + + creds = resolve_kimi_coding_runtime_credentials( + force_refresh=force, + allow_api_key_fallback=False, + ) + except Exception as exc: + logger.debug("Kimi credential refresh failed: %s", exc) + return False + + api_key = creds.get("api_key") + base_url = creds.get("base_url") + source = str(creds.get("source") or "") + if source not in {"kimi-cli-oauth", "kimi-cli-oauth-refresh"}: + return False + if not isinstance(api_key, str) or not api_key.strip(): + return False + if not isinstance(base_url, str) or not base_url.strip(): + return False + + self.api_key = api_key.strip() + self.base_url = base_url.strip().rstrip("/") + self._client_kwargs["api_key"] = self.api_key + self._client_kwargs["base_url"] = self.base_url + self._client_kwargs["default_headers"] = kimi_coding_default_headers() + + if not self._replace_primary_openai_client(reason="kimi_credential_refresh"): + return False + return True + def _try_refresh_anthropic_client_credentials(self) -> bool: if self.api_mode != "anthropic_messages" or not hasattr(self, "_anthropic_api_key"): return False @@ -6980,7 +7110,8 @@ def _apply_client_headers_for_base_url(self, base_url: str) -> None: self._client_kwargs["default_headers"] = copilot_default_headers() elif base_url_host_matches(base_url, "api.kimi.com"): - self._client_kwargs["default_headers"] = {"User-Agent": "claude-code/0.1.0"} + from hermes_cli.auth import kimi_coding_default_headers + self._client_kwargs["default_headers"] = kimi_coding_default_headers() elif base_url_host_matches(base_url, "portal.qwen.ai"): self._client_kwargs["default_headers"] = _qwen_portal_headers() elif base_url_host_matches(base_url, "chatgpt.com"): @@ -9423,6 +9554,7 @@ def _build_api_kwargs(self, api_messages: list) -> dict: lmstudio_reasoning_options=self._lmstudio_reasoning_options_cached() if _is_lmstudio else None, anthropic_max_output=_ant_max, provider_name=self.provider, + tool_choice=getattr(self, "_tool_choice", None), ) def _supports_reasoning_extra_body(self) -> bool: @@ -11424,6 +11556,7 @@ def run_conversation( task_id: str = None, stream_callback: Optional[callable] = None, persist_user_message: Optional[str] = None, + tool_choice: str = None, ) -> Dict[str, Any]: """ Run a complete conversation with tool calling until completion. @@ -11502,6 +11635,7 @@ def run_conversation( # state registry. Set BEFORE any tool dispatch so snapshots taken at # child-launch time see the parent's real id, not None. self._current_task_id = effective_task_id + self._tool_choice = tool_choice # Reset retry counters and iteration budget at the start of each turn # so subagent usage from a previous turn doesn't eat into the next one. @@ -11841,6 +11975,7 @@ def run_conversation( except Exception: pass + _turn_start_time = time.time() while (api_call_count < self.max_iterations and self.iteration_budget.remaining > 0) or self._budget_grace_call: # Reset per-turn checkpoint dedup so each iteration can take one snapshot self._checkpoint_mgr.new_turn() @@ -11852,6 +11987,16 @@ def run_conversation( if not self.quiet_mode: self._safe_print("\n⚡ Breaking out of tool loop due to interrupt...") break + + # Check turn wall-clock timeout (DaemonCraft / long-turn guard) + if self.turn_timeout_seconds: + elapsed = time.time() - _turn_start_time + if elapsed > self.turn_timeout_seconds: + interrupted = True + _turn_exit_reason = "turn_timeout" + if not self.quiet_mode: + self._safe_print(f"\n⏰ Turn timed out after {elapsed:.1f}s (limit: {self.turn_timeout_seconds}s). Stopping...") + break api_call_count += 1 self._api_call_count = api_call_count @@ -12161,6 +12306,7 @@ def run_conversation( anthropic_auth_retry_attempted=False nous_auth_retry_attempted=False copilot_auth_retry_attempted=False + kimi_auth_retry_attempted=False thinking_sig_retry_attempted = False image_shrink_retry_attempted = False oauth_1m_beta_retry_attempted = False @@ -12228,7 +12374,6 @@ def run_conversation( _sanitize_structure_non_ascii(api_kwargs) if self.api_mode == "codex_responses": api_kwargs = self._get_transport().preflight_kwargs(api_kwargs, allow_stream=False) - try: from hermes_cli.plugins import invoke_hook as _invoke_hook _invoke_hook( @@ -13292,6 +13437,18 @@ def _stop_spinner(): if self._try_refresh_copilot_client_credentials(): self._vprint(f"{self.log_prefix}🔐 Copilot credentials refreshed after 401. Retrying request...") continue + if ( + status_code == 401 + and not kimi_auth_retry_attempted + and ( + self.provider in {"kimi-coding", "kimi-coding-cn"} + or base_url_host_matches(self.base_url, "api.kimi.com") + ) + ): + kimi_auth_retry_attempted = True + if self._try_refresh_kimi_client_credentials(force=True): + print(f"{self.log_prefix}🔐 Kimi OAuth refreshed after 401. Retrying request...") + continue if ( self.api_mode == "anthropic_messages" and status_code == 401 @@ -14099,8 +14256,7 @@ def _stop_spinner(): _normalize_kwargs["strip_tool_prefix"] = self._is_anthropic_oauth normalized = _transport.normalize_response(response, **_normalize_kwargs) assistant_message = normalized - finish_reason = normalized.finish_reason - + finish_reason = normalized.finish_reason # Normalize content to string — some OpenAI-compatible servers # (llama-server, etc.) return content as a dict or list instead # of a plain string, which crashes downstream .strip() calls. @@ -14500,7 +14656,19 @@ def _stop_spinner(): except Exception: pass - self._execute_tool_calls(assistant_message, messages, effective_task_id, api_call_count) + execute_tool_calls = self._execute_tool_calls + try: + import inspect as _inspect + _execute_params = _inspect.signature(execute_tool_calls).parameters + _accepts_api_call_count = len(_execute_params) >= 4 or any( + p.kind == p.VAR_POSITIONAL for p in _execute_params.values() + ) + except Exception: + _accepts_api_call_count = True + if _accepts_api_call_count: + execute_tool_calls(assistant_message, messages, effective_task_id, api_call_count) + else: + execute_tool_calls(assistant_message, messages, effective_task_id) if self._tool_guardrail_halt_decision is not None: decision = self._tool_guardrail_halt_decision @@ -14950,6 +15118,7 @@ def _stop_spinner(): messages.append(err_msg) break + # Non-tool errors don't need a synthetic message injected. # The error is already printed to the user (line above), and # the retry loop continues. Injecting a fake user/assistant diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index a38f60b7edc5..1680676e7125 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -1678,6 +1678,20 @@ async def test_async_call_llm_refreshes_anthropic_on_401_for_non_vision(self): assert fresh_client.chat.completions.create.await_count == 1 +class TestKimiCodingDefaultHeaders: + """kimi_coding_default_headers produces the full X-Msh-* header set.""" + + def test_headers_include_required_fields(self): + from hermes_cli.auth import kimi_coding_default_headers + headers = kimi_coding_default_headers() + assert headers["User-Agent"].startswith("KimiCLI/") + assert headers["X-Msh-Platform"] == "kimi_cli" + assert "X-Msh-Version" in headers + assert "X-Msh-Device-Name" in headers + assert "X-Msh-Device-Model" in headers + assert "X-Msh-Os-Version" in headers + + class TestAuxiliaryPoolRotationRetry: def test_call_llm_rotates_explicit_codex_pool_on_429(self): rate_err = Exception("usage limit reached") diff --git a/tests/agent/test_compress_focus.py b/tests/agent/test_compress_focus.py index 8b5b1d35da3b..b48e5789bc1f 100644 --- a/tests/agent/test_compress_focus.py +++ b/tests/agent/test_compress_focus.py @@ -27,6 +27,8 @@ def _make_compressor(): compressor.summary_model = None compressor.model = "test-model" compressor.provider = "test" + compressor.summary_preamble = None + compressor.summary_template = None compressor.base_url = "http://localhost" compressor.api_key = "test-key" compressor.api_mode = "chat_completions" diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index 97a7c7b3d0ff..3d987cd0a21d 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -1756,3 +1756,135 @@ def test_pass3_emits_valid_json_for_downstream_provider(self): parsed = _json.loads(shrunk) assert parsed["path"] == "~/.hermes/skills/shopping/browser-setup-notes.md" assert parsed["content"].endswith("...[truncated]") + + +class TestProtectFirstNZero: + """ protect_first_n=0 means no literal head messages survive compression. + + The system prompt (if present) is included in the summarised middle region + and the summary itself becomes the first message after compression. + """ + + def test_protect_first_n_zero_drops_all_head_messages(self): + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "Summary of everything" + + with patch("agent.context_compressor.get_model_context_length", return_value=100000): + c = ContextCompressor( + model="test", + threshold_percent=0.50, + protect_first_n=0, + protect_last_n=2, + quiet_mode=True, + ) + + messages = [ + {"role": "system", "content": "You are a test assistant."}, + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi"}, + {"role": "user", "content": "do something"}, + {"role": "assistant", "content": "ok"}, + {"role": "user", "content": "next"}, + {"role": "assistant", "content": "done"}, + ] + + with patch("agent.context_compressor.call_llm", return_value=mock_response): + result = c.compress(messages) + + # System prompt should survive even with protect_first_n=0 + assert result[0]["role"] == "system" + assert "You are a test assistant" in result[0]["content"] + # The summary comes next + assert result[1]["role"] in ("user", "assistant") + assert "Summary of everything" in result[1]["content"] + # Tail should still be present + assert result[-1]["content"] == "done" + assert result[-2]["content"] == "next" + + def test_protect_first_n_zero_with_no_system_prompt(self): + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "Summary" + + with patch("agent.context_compressor.get_model_context_length", return_value=100000): + c = ContextCompressor( + model="test", + threshold_percent=0.50, + protect_first_n=0, + protect_last_n=2, + quiet_mode=True, + ) + + messages = [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi"}, + {"role": "user", "content": "do something"}, + {"role": "assistant", "content": "ok"}, + {"role": "user", "content": "next"}, + {"role": "assistant", "content": "done"}, + ] + + with patch("agent.context_compressor.call_llm", return_value=mock_response): + result = c.compress(messages) + + assert "Summary" in result[0]["content"] + assert len(result) < len(messages) + + +class TestCustomPromptOverrides: + """Custom preamble and template passed via constructor are used in place of defaults.""" + + def test_custom_preamble_and_template_used(self): + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "Custom summary result" + + with patch("agent.context_compressor.get_model_context_length", return_value=100000): + c = ContextCompressor( + model="test", + quiet_mode=True, + summary_preamble="CUSTOM PREAMBLE", + summary_template="CUSTOM TEMPLATE with budget {summary_budget}", + ) + + messages = [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi"}, + ] + + with patch("agent.context_compressor.call_llm", return_value=mock_response) as mock_call: + c._generate_summary(messages) + + kwargs = mock_call.call_args.kwargs + prompt = kwargs["messages"][0]["content"] + assert "CUSTOM PREAMBLE" in prompt + assert "CUSTOM TEMPLATE with budget" in prompt + # Make sure the budget placeholder was replaced with a number + assert "{summary_budget}" not in prompt + + def test_empty_custom_prompt_falls_back_to_default(self): + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "Default summary" + + with patch("agent.context_compressor.get_model_context_length", return_value=100000): + c = ContextCompressor( + model="test", + quiet_mode=True, + summary_preamble=None, + summary_template=None, + ) + + messages = [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi"}, + ] + + with patch("agent.context_compressor.call_llm", return_value=mock_response) as mock_call: + c._generate_summary(messages) + + kwargs = mock_call.call_args.kwargs + prompt = kwargs["messages"][0]["content"] + assert "summarization agent creating a context checkpoint" in prompt + assert "## Active Task" in prompt diff --git a/tests/agent/test_minimax_provider.py b/tests/agent/test_minimax_provider.py index 2e7f134e4d4d..677b1ac1ae09 100644 --- a/tests/agent/test_minimax_provider.py +++ b/tests/agent/test_minimax_provider.py @@ -2,6 +2,8 @@ from unittest.mock import patch +from run_agent import AIAgent + class TestMinimaxContextLengths: """Verify context length entries match official docs (204,800 for all models). @@ -333,7 +335,6 @@ def test_switch_to_minimax_does_not_resolve_anthropic_token(self): from unittest.mock import patch, MagicMock with patch("run_agent.AIAgent.__init__", return_value=None): - from run_agent import AIAgent agent = AIAgent.__new__(AIAgent) agent.provider = "anthropic" agent.model = "claude-sonnet-4" @@ -364,3 +365,98 @@ def test_switch_to_minimax_does_not_resolve_anthropic_token(self): # The key passed to build_anthropic_client should be the MiniMax key build_args = mock_build.call_args assert build_args[0][0] == "mm-key-123" + + +class TestMinimaxAgentInitDefaults: + """Verify AIAgent.__init__ defaults MiniMax to anthropic_messages + correct base_url.""" + + def test_minimax_defaults_to_anthropic_messages_and_global_url(self): + with ( + patch("run_agent.get_tool_definitions", return_value=[]), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + ): + agent = AIAgent( + api_key="minimax-key-1234", + provider="minimax", + model="MiniMax-M2.7", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + assert agent.api_mode == "anthropic_messages" + assert agent.base_url == "https://api.minimax.io/anthropic" + assert agent.provider == "minimax" + + def test_minimax_cn_defaults_to_anthropic_messages_and_china_url(self): + with ( + patch("run_agent.get_tool_definitions", return_value=[]), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + ): + agent = AIAgent( + api_key="minimax-cn-key-5678", + provider="minimax-cn", + model="MiniMax-M2.7", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + assert agent.api_mode == "anthropic_messages" + assert agent.base_url == "https://api.minimaxi.com/anthropic" + assert agent.provider == "minimax-cn" + + def test_minimax_explicit_base_url_not_overwritten(self): + with ( + patch("run_agent.get_tool_definitions", return_value=[]), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + ): + agent = AIAgent( + api_key="minimax-key-1234", + provider="minimax", + base_url="https://custom.minimax.example.com/anthropic", + model="MiniMax-M2.7", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + assert agent.api_mode == "anthropic_messages" + assert agent.base_url == "https://custom.minimax.example.com/anthropic" + + def test_minimax_explicit_api_mode_chat_completions_allowed(self): + with ( + patch("run_agent.get_tool_definitions", return_value=[]), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + ): + agent = AIAgent( + api_key="minimax-key-1234", + provider="minimax", + api_mode="chat_completions", + base_url="https://api.minimax.io/v1", + model="MiniMax-M2.7", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + assert agent.api_mode == "chat_completions" + # explicit base_url should be preserved + assert agent.base_url == "https://api.minimax.io/v1" + + def test_minimax_prompt_caching_enabled_by_default(self): + with ( + patch("run_agent.get_tool_definitions", return_value=[]), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + ): + agent = AIAgent( + api_key="minimax-key-1234", + provider="minimax", + model="MiniMax-M2.7", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + assert agent._use_prompt_caching is True + assert agent._use_native_cache_layout is True diff --git a/tests/gateway/test_daemoncraft_cycle_detector.py b/tests/gateway/test_daemoncraft_cycle_detector.py new file mode 100644 index 000000000000..102de43745d7 --- /dev/null +++ b/tests/gateway/test_daemoncraft_cycle_detector.py @@ -0,0 +1,171 @@ +"""Unit tests for CycleDetector ported into gateway/platforms/daemoncraft.py.""" +from __future__ import annotations + +import os +import sys +import types +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +# gateway/platforms/__init__.py eagerly imports yuanbao (httpx) and daemoncraft +# itself needs aiohttp. Stub missing optional deps before import. +def _stub_module(name: str, **attrs): + if name not in sys.modules: + mod = types.ModuleType(name) + for k, v in attrs.items(): + setattr(mod, k, v) + sys.modules[name] = mod + +_stub_module("httpx") +_stub_module("aiohttp", WSMsgType=MagicMock(), ClientSession=MagicMock) + +# Import the standalone class directly — no server needed +from gateway.platforms.daemoncraft import CycleDetector + + +# --------------------------------------------------------------------------- +# CycleDetector unit tests +# --------------------------------------------------------------------------- + +class TestCycleDetectorUnit: + def test_no_cycle_below_threshold(self): + cd = CycleDetector(n=3, window=10, action="warn") + for _ in range(2): + r = cd.record("tool_a", {}) + assert r.triggered is False + + def test_cycle_on_nth_identical_call(self): + cd = CycleDetector(n=3, window=10, action="warn") + r = None + for _ in range(3): + r = cd.record("tool_a", {}) + assert r.triggered is True + assert r.count >= 3 + + def test_no_double_trigger_on_n_plus_one(self): + cd = CycleDetector(n=3, window=10, action="warn") + for _ in range(3): + cd.record("tool_a", {}) + # 4th call: same sig, already triggered — should suppress + r = cd.record("tool_a", {}) + assert r.triggered is False + + def test_different_tool_names_no_cycle(self): + cd = CycleDetector(n=3, window=10, action="warn") + results = [] + for i in range(6): + results.append(cd.record(f"tool_{i}", {})) + assert not any(r.triggered for r in results) + + def test_different_args_no_cycle(self): + cd = CycleDetector(n=3, window=10, action="warn") + results = [] + for i in range(6): + results.append(cd.record("tool_a", {"x": i})) + assert not any(r.triggered for r in results) + + def test_cycle_clears_after_different_sig_dominates(self): + """After suppression, a NEW dominant sig should trigger fresh.""" + # Use small window=3 so tool_b can fully dominate and evict tool_a + cd = CycleDetector(n=3, window=3, action="warn") + # Trigger first cycle for tool_a + for _ in range(3): + cd.record("tool_a", {}) + # Flood with tool_b — fills the window, clears _last_triggered_sig + for _ in range(3): + cd.record("tool_b", {}) + # Now tool_a again — should trigger fresh (suppression was cleared) + r = None + for _ in range(3): + r = cd.record("tool_a", {}) + assert r.triggered is True + + +# --------------------------------------------------------------------------- +# DaemonCraftAdapter._check_cycle integration tests +# --------------------------------------------------------------------------- + +def _make_adapter(): + """Build a minimal DaemonCraftAdapter with all external deps mocked.""" + from gateway.platforms.daemoncraft import DaemonCraftAdapter + from gateway.config import PlatformConfig + + cfg = PlatformConfig( + enabled=True, + extra={ + "bot_api_url": "http://localhost:9999", + "bot_username": "TestBot", + "profile": "test", + }, + ) + adapter = DaemonCraftAdapter(cfg) + # Patch _interrupt_agent so tests don't need a real HTTP session + adapter._interrupt_agent = AsyncMock() + return adapter + + +class TestCheckCycleMethod: + @pytest.mark.anyio + async def test_returns_false_when_no_detector(self): + adapter = _make_adapter() + assert adapter._cycle_detector is None + result = await adapter._check_cycle("mc_perceive", {}) + assert result is False + + @pytest.mark.anyio + async def test_returns_false_for_non_cycling_calls(self): + adapter = _make_adapter() + adapter._cycle_detector = CycleDetector(n=3, window=10, action="warn") + result = await adapter._check_cycle("mc_perceive", {}) + assert result is False + + @pytest.mark.anyio + async def test_warn_action_returns_false_on_cycle(self): + """Cycle detected with action='warn' should log but NOT interrupt.""" + adapter = _make_adapter() + adapter._cycle_detector = CycleDetector(n=3, window=10, action="warn") + for _ in range(2): + await adapter._check_cycle("mc_perceive", {}) + result = await adapter._check_cycle("mc_perceive", {}) + # warn = no interrupt + assert result is False + adapter._interrupt_agent.assert_not_called() + + @pytest.mark.anyio + async def test_interrupt_action_returns_true_and_calls_interrupt(self): + """Cycle with action='interrupt' should call _interrupt_agent and return True.""" + adapter = _make_adapter() + adapter._cycle_detector = CycleDetector(n=3, window=10, action="interrupt") + for _ in range(2): + await adapter._check_cycle("mc_perceive", {}) + result = await adapter._check_cycle("mc_perceive", {}) + assert result is True + adapter._interrupt_agent.assert_called_once_with("cycle_detected") + + +class TestAdapterCycleDetectorInit: + @pytest.mark.anyio + async def test_no_detector_when_mc_cycle_n_zero(self, monkeypatch): + monkeypatch.delenv("MC_CYCLE_N", raising=False) + adapter = _make_adapter() + # Patch connect internals so no actual socket is opened + with patch("gateway.platforms.daemoncraft.aiohttp.ClientSession", return_value=MagicMock()), \ + patch("asyncio.create_task", return_value=MagicMock()): + await adapter.connect() + assert adapter._cycle_detector is None + + @pytest.mark.anyio + async def test_detector_created_when_mc_cycle_n_set(self, monkeypatch): + monkeypatch.setenv("MC_CYCLE_N", "3") + monkeypatch.setenv("MC_CYCLE_WINDOW", "10") + monkeypatch.setenv("MC_CYCLE_ACTION", "warn") + adapter = _make_adapter() + with patch("gateway.platforms.daemoncraft.aiohttp.ClientSession", return_value=MagicMock()), \ + patch("asyncio.create_task", return_value=MagicMock()): + await adapter.connect() + assert adapter._cycle_detector is not None + assert adapter._cycle_detector.n == 3 + assert adapter._cycle_detector.window == 10 + assert adapter._cycle_detector.action == "warn" diff --git a/tests/gateway/test_daemoncraft_patches.py b/tests/gateway/test_daemoncraft_patches.py new file mode 100644 index 000000000000..6f7b51382c2d --- /dev/null +++ b/tests/gateway/test_daemoncraft_patches.py @@ -0,0 +1,197 @@ +"""Tests for CycleDetector and _inject_synthetic_perceive hook in daemoncraft.py.""" +from __future__ import annotations + +import sys +import types +from unittest.mock import AsyncMock, MagicMock, call, patch + +import pytest + +# --------------------------------------------------------------------------- +# Stub heavy optional deps before importing daemoncraft +# --------------------------------------------------------------------------- + +def _stub_module(name: str, **attrs): + if name not in sys.modules: + mod = types.ModuleType(name) + for k, v in attrs.items(): + setattr(mod, k, v) + sys.modules[name] = mod + +_stub_module("httpx") +_stub_module("aiohttp", WSMsgType=MagicMock(), ClientSession=MagicMock) + +from gateway.platforms.daemoncraft import CycleDetector # noqa: E402 + + +# =========================================================================== +# CycleDetector tests +# =========================================================================== + +class TestCycleDetector: + """5 focused tests for CycleDetector behaviour.""" + + def test_no_trigger_below_threshold(self): + """N-1 identical calls must NOT trigger.""" + cd = CycleDetector(n=4, window=20, action="warn") + results = [cd.record("tool_x", {"k": "v"}) for _ in range(3)] + assert not any(r.triggered for r in results) + + def test_trigger_at_nth_identical_call(self): + """The Nth identical call must trigger.""" + cd = CycleDetector(n=3, window=10, action="warn") + r = None + for _ in range(3): + r = cd.record("loop_tool", {}) + assert r.triggered is True + assert r.count >= 3 + + def test_reset_after_action_no_double_trigger(self): + """After triggering, subsequent calls with the same sig should NOT re-trigger.""" + cd = CycleDetector(n=3, window=10, action="warn") + for _ in range(3): + cd.record("loop_tool", {}) + # 4th and 5th same-sig calls — suppressed + r4 = cd.record("loop_tool", {}) + r5 = cd.record("loop_tool", {}) + assert r4.triggered is False + assert r5.triggered is False + + def test_window_size_evicts_old_entries(self): + """Once the ring buffer (size=window) is filled with other sigs, old counts are gone.""" + # window=3: buffer holds at most 3 entries + cd = CycleDetector(n=3, window=3, action="warn") + # Two calls of "old_tool" — not yet triggering + cd.record("old_tool", {}) + cd.record("old_tool", {}) + # Fill buffer with 3 different sigs, evicting "old_tool" entries + cd.record("tool_b", {}) + cd.record("tool_c", {}) + cd.record("tool_d", {}) + # Now one more "old_tool" — only 1 in window, should not trigger + r = cd.record("old_tool", {}) + assert r.triggered is False + + def test_different_sigs_do_not_trigger(self): + """Calls with different args must not be counted together.""" + cd = CycleDetector(n=3, window=10, action="warn") + results = [cd.record("tool_a", {"n": i}) for i in range(6)] + assert not any(r.triggered for r in results) + + +# =========================================================================== +# _inject_synthetic_perceive hook tests +# =========================================================================== + +def _make_adapter(): + from gateway.platforms.daemoncraft import DaemonCraftAdapter + from gateway.config import PlatformConfig + + cfg = PlatformConfig( + enabled=True, + extra={ + "bot_api_url": "http://localhost:9999", + "bot_username": "TestBot", + "profile": "test", + }, + ) + adapter = DaemonCraftAdapter(cfg) + return adapter + + +def _wire_adapter(adapter, *, session_id="world-session-1", hook_results=()): + """Attach a mock session_store and stub invoke_hook.""" + store = MagicMock() + store.append_to_transcript = MagicMock() + adapter._session_store = store + + # Stub _get_world_session_id + adapter._get_world_session_id = MagicMock(return_value=session_id) + return store + + +class TestSyntheticPerceiveHook: + """3 tests covering the transform_tool_result hook path.""" + + @pytest.mark.anyio + async def test_hook_called_before_transcript_append(self): + """invoke_hook must be called; tool_msg append comes after it.""" + adapter = _make_adapter() + store = _wire_adapter(adapter) + call_order = [] + + def fake_invoke_hook(event, **kwargs): + call_order.append("hook") + return iter([]) # no replacement + + # Capture append_to_transcript calls in order + original_append = store.append_to_transcript + def recording_append(sid, msg): + call_order.append(("append", msg["role"])) + store.append_to_transcript.side_effect = recording_append + + with patch("gateway.platforms.daemoncraft.invoke_hook", fake_invoke_hook, create=True), \ + patch.dict(sys.modules, {"hermes_cli.plugins": types.SimpleNamespace(invoke_hook=fake_invoke_hook)}): + # Patch the local import inside _inject_synthetic_perceive + import importlib + import gateway.platforms.daemoncraft as dc_mod + with patch.object(dc_mod, "_inject_synthetic_perceive_hook_module", None, create=True): + # We patch the from-import by monkeypatching the module namespace + pass + + # Direct patch: replace hermes_cli.plugins in sys.modules + fake_plugins = types.ModuleType("hermes_cli.plugins") + fake_plugins.invoke_hook = fake_invoke_hook + sys.modules["hermes_cli.plugins"] = fake_plugins + sys.modules.setdefault("hermes_cli", types.ModuleType("hermes_cli")) + + await adapter._inject_synthetic_perceive({"x": 1}) + + # assistant append should come first, then hook, then tool append + assert ("append", "assistant") in call_order + assert ("append", "tool") in call_order + assert call_order.index(("append", "assistant")) < call_order.index("hook") + assert call_order.index("hook") < call_order.index(("append", "tool")) + + @pytest.mark.anyio + async def test_hook_receives_mc_perceive_tool_name(self): + """invoke_hook must be called with tool_name='mc_perceive'.""" + adapter = _make_adapter() + _wire_adapter(adapter) + + received_kwargs: dict = {} + + def fake_invoke_hook(event, **kwargs): + received_kwargs.update({"event": event, **kwargs}) + return iter([]) + + fake_plugins = types.ModuleType("hermes_cli.plugins") + fake_plugins.invoke_hook = fake_invoke_hook + sys.modules["hermes_cli.plugins"] = fake_plugins + sys.modules.setdefault("hermes_cli", types.ModuleType("hermes_cli")) + + await adapter._inject_synthetic_perceive({"obs": "block"}) + + assert received_kwargs.get("event") == "transform_tool_result" + assert received_kwargs.get("tool_name") == "mc_perceive" + + @pytest.mark.anyio + async def test_transcript_appended_even_if_hook_raises(self): + """If invoke_hook raises, transcript append must still happen.""" + adapter = _make_adapter() + store = _wire_adapter(adapter) + + def exploding_hook(event, **kwargs): + raise RuntimeError("hook boom") + + fake_plugins = types.ModuleType("hermes_cli.plugins") + fake_plugins.invoke_hook = exploding_hook + sys.modules["hermes_cli.plugins"] = fake_plugins + sys.modules.setdefault("hermes_cli", types.ModuleType("hermes_cli")) + + await adapter._inject_synthetic_perceive({"obs": "fire"}) + + # Both assistant_msg and tool_msg must have been appended + assert store.append_to_transcript.call_count == 2 + roles = [c.args[1]["role"] for c in store.append_to_transcript.call_args_list] + assert roles == ["assistant", "tool"] diff --git a/tests/hermes_cli/test_api_key_providers.py b/tests/hermes_cli/test_api_key_providers.py index 291b8b70d464..9211f692825a 100644 --- a/tests/hermes_cli/test_api_key_providers.py +++ b/tests/hermes_cli/test_api_key_providers.py @@ -1,6 +1,7 @@ """Tests for API-key provider support (z.ai/GLM, Kimi, MiniMax, AI Gateway).""" import os +from pathlib import Path import pytest @@ -18,6 +19,7 @@ STEPFUN_STEP_PLAN_INTL_BASE_URL, STEPFUN_STEP_PLAN_CN_BASE_URL, _resolve_kimi_base_url, + resolve_kimi_coding_runtime_credentials, ) from hermes_cli.copilot_auth import _try_gh_cli_token @@ -498,6 +500,22 @@ def test_resolve_kimi_with_key(self, monkeypatch): assert creds["api_key"] == "kimi-secret-key" assert creds["base_url"] == "https://api.moonshot.ai/v1" + def test_resolve_kimi_prefers_cli_oauth_without_api_key(self, monkeypatch): + monkeypatch.setattr( + "hermes_cli.auth.resolve_kimi_coding_runtime_credentials", + lambda: { + "provider": "kimi-coding", + "api_key": "oauth-token", + "base_url": KIMI_CODE_BASE_URL, + "source": "kimi-cli-oauth", + }, + ) + creds = resolve_api_key_provider_credentials("kimi-coding") + assert creds["provider"] == "kimi-coding" + assert creds["api_key"] == "oauth-token" + assert creds["base_url"] == KIMI_CODE_BASE_URL + assert creds["source"] == "kimi-cli-oauth" + def test_resolve_stepfun_with_key(self, monkeypatch): monkeypatch.setenv("STEPFUN_API_KEY", "stepfun-secret-key") creds = resolve_api_key_provider_credentials("stepfun") @@ -1025,6 +1043,66 @@ def test_no_key_skips_probe(self, monkeypatch): assert creds["api_key"] == "" +class TestKimiCliOAuthRefresh: + def test_force_refresh_uses_refresh_token_and_persists_updated_file(self, monkeypatch): + from hermes_cli import auth as auth_mod + + saved = {} + + def _fake_read(): + return { + "access_token": "old-access", + "refresh_token": "old-refresh", + "expires_at": 1, + "scope": "kimi-code", + "token_type": "Bearer", + } + + class _DummyResponse: + status_code = 200 + + @staticmethod + def json(): + return { + "access_token": "new-access", + "refresh_token": "new-refresh", + "expires_in": 7200, + "scope": "kimi-code", + "token_type": "Bearer", + } + + class _DummyClient: + def __init__(self, *args, **kwargs): + self.calls = [] + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def post(self, url, headers=None, data=None): + self.calls.append((url, headers, data)) + return _DummyResponse() + + def _fake_save(tokens): + saved.update(tokens) + return Path("/tmp/kimi-code.json") + + monkeypatch.setattr(auth_mod, "_read_kimi_cli_credentials", _fake_read) + monkeypatch.setattr(auth_mod, "_save_kimi_cli_credentials", _fake_save) + monkeypatch.setattr(auth_mod.httpx, "Client", _DummyClient) + + creds = resolve_kimi_coding_runtime_credentials(force_refresh=True, allow_api_key_fallback=False) + + assert creds["source"] == "kimi-cli-oauth-refresh" + assert creds["api_key"] == "new-access" + assert creds["base_url"] == "https://api.kimi.com/coding/v1" + assert saved["access_token"] == "new-access" + assert saved["refresh_token"] == "new-refresh" + assert saved["scope"] == "kimi-code" + + # ============================================================================= # Kimi / Moonshot model list isolation tests # ============================================================================= diff --git a/tests/hermes_cli/test_detect_api_mode_for_url.py b/tests/hermes_cli/test_detect_api_mode_for_url.py index f758570ea582..53d38c18fdd3 100644 --- a/tests/hermes_cli/test_detect_api_mode_for_url.py +++ b/tests/hermes_cli/test_detect_api_mode_for_url.py @@ -66,6 +66,9 @@ def test_anthropic_in_middle_of_path_does_not_match(self): class TestDefaultCase: + def test_kimi_coding_returns_none(self): + assert _detect_api_mode_for_url("https://api.kimi.com/coding/v1") is None + def test_generic_url_returns_none(self): assert _detect_api_mode_for_url("https://api.together.xyz/v1") is None diff --git a/tests/hermes_cli/test_determine_api_mode_hostname.py b/tests/hermes_cli/test_determine_api_mode_hostname.py index 8b6cd042ce57..ed57f5618bdc 100644 --- a/tests/hermes_cli/test_determine_api_mode_hostname.py +++ b/tests/hermes_cli/test_determine_api_mode_hostname.py @@ -41,3 +41,8 @@ def test_anthropic_path_suffix_still_wins(self): # proxies) expose the Anthropic protocol under a ``/anthropic`` suffix. # That convention must still resolve to anthropic_messages. assert determine_api_mode("", "https://api.minimax.io/anthropic") == "anthropic_messages" + + +class TestKimiCodingRouting: + def test_kimi_coding_stays_chat_completions(self): + assert determine_api_mode("kimi-coding", "https://api.kimi.com/coding/v1") == "chat_completions" diff --git a/tests/hermes_cli/test_kanban_review.py b/tests/hermes_cli/test_kanban_review.py new file mode 100644 index 000000000000..d3765c5ddc91 --- /dev/null +++ b/tests/hermes_cli/test_kanban_review.py @@ -0,0 +1,672 @@ +"""Tests for the ship-review graph creation helper and CLI.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from hermes_cli import kanban_db as kb +from hermes_cli import kanban_review as kr + + +@pytest.fixture +def kanban_home(tmp_path, monkeypatch): + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + kb.init_db() + return home + + +@pytest.fixture +def fake_repo(tmp_path): + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".git").mkdir() + return str(repo) + + +# --------------------------------------------------------------------------- +# ReviewGraphSpec +# --------------------------------------------------------------------------- + +def test_review_graph_spec_fields(): + spec = kr.ReviewGraphSpec( + repo_path="/tmp/repo", + base="nousmain", + head="feat/auth", + title="Review PR #42", + assignee="miki", + ready=True, + idempotency_prefix="prefix", + skills=["github-code-review"], + body="Extra context", + ) + assert spec.repo_path == "/tmp/repo" + assert spec.base == "nousmain" + assert spec.head == "feat/auth" + assert spec.title == "Review PR #42" + assert spec.assignee == "miki" + assert spec.ready is True + assert spec.idempotency_prefix == "prefix" + assert spec.skills == ["github-code-review"] + assert spec.body == "Extra context" + + +def test_review_graph_spec_defaults(): + spec = kr.ReviewGraphSpec(repo_path="/tmp/repo", base="main", head="feat/x", title="T") + assert spec.assignee is None + assert spec.ready is False + assert spec.idempotency_prefix is None + assert spec.skills == [] + assert spec.body is None + + +# --------------------------------------------------------------------------- +# Core helper tests +# --------------------------------------------------------------------------- + +def test_create_review_graph_smoke(kanban_home, fake_repo): + result = kr.create_review_graph( + title="Review PR #42", + base="nousmain", + head="feat/auth", + repo_path=fake_repo, + ) + assert result["created"] is True + assert result["parent_id"].startswith("t_") + assert len(result["reviewer_ids"]) == 3 + assert result["synthesis_id"].startswith("t_") + + # Verify synthesis is gated on reviewers + with kb.connect() as conn: + for rid in result["reviewer_ids"]: + # Reviewers are parallel — they have no parents + assert kb.parent_ids(conn, rid) == [] + synth_parents = kb.parent_ids(conn, result["synthesis_id"]) + assert set(synth_parents) == set(result["reviewer_ids"]) + + +def test_create_review_graph_idempotent(kanban_home, fake_repo): + r1 = kr.create_review_graph( + title="Review PR #42", + base="nousmain", + head="feat/auth", + repo_path=fake_repo, + ) + r2 = kr.create_review_graph( + title="Review PR #42", + base="nousmain", + head="feat/auth", + repo_path=fake_repo, + ) + assert r1["parent_id"] == r2["parent_id"] + assert r1["reviewer_ids"] == r2["reviewer_ids"] + assert r1["synthesis_id"] == r2["synthesis_id"] + assert r2["created"] is False + + +def test_create_review_graph_idempotent_different_base_or_head(kanban_home, fake_repo): + """Changing base or head creates a new graph.""" + r1 = kr.create_review_graph( + title="Review PR #42", + base="nousmain", + head="feat/auth", + repo_path=fake_repo, + ) + r2 = kr.create_review_graph( + title="Review PR #42", + base="nousmain", + head="feat/other", + repo_path=fake_repo, + ) + r3 = kr.create_review_graph( + title="Review PR #42", + base="main", + head="feat/auth", + repo_path=fake_repo, + ) + assert r1["parent_id"] != r2["parent_id"] + assert r1["parent_id"] != r3["parent_id"] + assert r2["parent_id"] != r3["parent_id"] + + +def test_create_review_graph_triage_by_default(kanban_home, fake_repo): + result = kr.create_review_graph( + title="Review PR #42", + base="nousmain", + head="feat/auth", + repo_path=fake_repo, + ) + with kb.connect() as conn: + for tid in [result["parent_id"], *result["reviewer_ids"], result["synthesis_id"]]: + task = kb.get_task(conn, tid) + assert task.status == "triage" + + +def test_create_review_graph_ready_mode(kanban_home, fake_repo): + result = kr.create_review_graph( + title="Review PR #42", + base="nousmain", + head="feat/auth", + repo_path=fake_repo, + ready=True, + ) + with kb.connect() as conn: + parent = kb.get_task(conn, result["parent_id"]) + assert parent.status == "ready" + for rid in result["reviewer_ids"]: + task = kb.get_task(conn, rid) + assert task.status == "ready" + # Synthesis starts as todo because its parents (reviewers) are not done. + synth = kb.get_task(conn, result["synthesis_id"]) + assert synth.status == "todo" + + +def test_create_review_graph_assignee_and_skills(kanban_home, fake_repo): + result = kr.create_review_graph( + title="Review PR #42", + base="nousmain", + head="feat/auth", + repo_path=fake_repo, + assignee="miki", + skills=["github-code-review"], + ) + with kb.connect() as conn: + for tid in [result["parent_id"], *result["reviewer_ids"], result["synthesis_id"]]: + task = kb.get_task(conn, tid) + assert task.assignee == "miki" + assert task.skills == ["github-code-review"] + + +def test_create_review_graph_workspace_is_dir(kanban_home, fake_repo): + result = kr.create_review_graph( + title="Review PR #42", + base="nousmain", + head="feat/auth", + repo_path=fake_repo, + ) + with kb.connect() as conn: + for tid in [result["parent_id"], *result["reviewer_ids"], result["synthesis_id"]]: + task = kb.get_task(conn, tid) + assert task.workspace_kind == "dir" + assert task.workspace_path == str(Path(fake_repo).resolve()) + + +def test_create_review_graph_body_appended(kanban_home, fake_repo): + result = kr.create_review_graph( + title="Review PR #42", + base="nousmain", + head="feat/auth", + repo_path=fake_repo, + body="Extra context here", + ) + with kb.connect() as conn: + parent = kb.get_task(conn, result["parent_id"]) + assert "Extra context here" in parent.body + assert "nousmain" in parent.body + assert "feat/auth" in parent.body + + +def test_create_review_graph_parent_body_has_review_only_contract(kanban_home, fake_repo): + result = kr.create_review_graph( + title="Review PR #42", + base="nousmain", + head="feat/auth", + repo_path=fake_repo, + ) + with kb.connect() as conn: + parent = kb.get_task(conn, result["parent_id"]) + assert "REVIEW-ONLY v1" in parent.body + assert "Do NOT modify source code" in parent.body + + +def test_create_review_graph_reviewer_bodies_have_review_only_contract(kanban_home, fake_repo): + result = kr.create_review_graph( + title="Review PR #42", + base="nousmain", + head="feat/auth", + repo_path=fake_repo, + ) + with kb.connect() as conn: + for rid in result["reviewer_ids"]: + task = kb.get_task(conn, rid) + assert "REVIEW-ONLY v1" in task.body + assert "Do NOT modify source code" in task.body + + +def test_create_review_graph_base_head_in_synthesis_body(kanban_home, fake_repo): + result = kr.create_review_graph( + title="Review PR #42", + base="nousmain", + head="feat/auth", + repo_path=fake_repo, + ) + with kb.connect() as conn: + synth = kb.get_task(conn, result["synthesis_id"]) + assert "nousmain" in synth.body + assert "feat/auth" in synth.body + + +# --------------------------------------------------------------------------- +# CLI integration tests +# --------------------------------------------------------------------------- + +def test_cli_review_create_json(kanban_home, fake_repo): + from hermes_cli import kanban as kc + + out = kc.run_slash( + f"review create 'Review PR #42' --base nousmain --head feat/auth --repo {fake_repo} --json" + ) + payload = json.loads(out) + assert payload["parent_id"].startswith("t_") + assert len(payload["reviewer_ids"]) == 3 + assert payload["synthesis_id"].startswith("t_") + assert payload["created"] is True + + +def test_cli_review_create_human_output(kanban_home, fake_repo): + from hermes_cli import kanban as kc + + out = kc.run_slash( + f"review create 'Review PR #42' --base nousmain --head feat/auth --repo {fake_repo}" + ) + assert "Created review graph" in out + assert "parent:" in out + assert "reviewer 1:" in out + assert "reviewer 2:" in out + assert "reviewer 3:" in out + assert "synthesis:" in out + + +def test_cli_review_create_idempotent_human_output(kanban_home, fake_repo): + from hermes_cli import kanban as kc + + kc.run_slash( + f"review create 'Review PR #42' --base nousmain --head feat/auth --repo {fake_repo}" + ) + out = kc.run_slash( + f"review create 'Review PR #42' --base nousmain --head feat/auth --repo {fake_repo}" + ) + assert "Found existing review graph" in out + assert "all cards already existed" in out + + +def test_cli_review_create_missing_repo(kanban_home): + from hermes_cli import kanban as kc + + out = kc.run_slash( + "review create 'Review PR #42' --base nousmain --head feat/auth --repo /nonexistent/path" + ) + assert "is not a directory" in out + + +def test_cli_review_create_ready_flag(kanban_home, fake_repo): + from hermes_cli import kanban as kc + + out = kc.run_slash( + f"review create 'Review PR #42' --base nousmain --head feat/auth --repo {fake_repo} --ready --json" + ) + payload = json.loads(out) + with kb.connect() as conn: + parent = kb.get_task(conn, payload["parent_id"]) + assert parent.status == "ready" + + +def test_cli_review_create_with_skills(kanban_home, fake_repo): + from hermes_cli import kanban as kc + + out = kc.run_slash( + f"review create 'Review PR #42' --base nousmain --head feat/auth --repo {fake_repo} " + f"--skill github-code-review --skill security-scan --json" + ) + payload = json.loads(out) + with kb.connect() as conn: + task = kb.get_task(conn, payload["parent_id"]) + assert "github-code-review" in task.skills + assert "security-scan" in task.skills + + +def test_cli_review_create_base_head_required(kanban_home, fake_repo): + """Missing --base or --head should produce a usage error.""" + from hermes_cli import kanban as kc + + out = kc.run_slash( + f"review create 'Review PR #42' --repo {fake_repo}" + ) + assert "usage error" in out.lower() + + +# --------------------------------------------------------------------------- +# Hardened template contract tests +# --------------------------------------------------------------------------- + +def test_reviewer_bodies_contain_exact_diff_command(kanban_home, fake_repo): + result = kr.create_review_graph( + title="Review PR #42", + base="nousmain", + head="feat/auth", + repo_path=fake_repo, + ) + with kb.connect() as conn: + for rid in result["reviewer_ids"]: + task = kb.get_task(conn, rid) + assert "git diff nousmain...feat/auth --stat" in task.body + assert "git diff nousmain...feat/auth\n" in task.body + + +def test_reviewer_bodies_contain_severity_labels(kanban_home, fake_repo): + result = kr.create_review_graph( + title="Review PR #42", + base="nousmain", + head="feat/auth", + repo_path=fake_repo, + ) + with kb.connect() as conn: + for rid in result["reviewer_ids"]: + task = kb.get_task(conn, rid) + assert "**Critical**" in task.body + assert "**Important**" in task.body + assert "**Optional/Nit**" in task.body + + +def test_reviewer_bodies_contain_kanban_contract(kanban_home, fake_repo): + result = kr.create_review_graph( + title="Review PR #42", + base="nousmain", + head="feat/auth", + repo_path=fake_repo, + ) + with kb.connect() as conn: + for rid in result["reviewer_ids"]: + task = kb.get_task(conn, rid) + assert "kanban_complete" in task.body + assert "kanban_block" in task.body + assert '"findings":' in task.body + + +def test_reviewer_bodies_contain_role_specific_checklists(kanban_home, fake_repo): + result = kr.create_review_graph( + title="Review PR #42", + base="nousmain", + head="feat/auth", + repo_path=fake_repo, + ) + expected = { + "code-quality": [ + "Readability and naming conventions", + "DRY violations", + "Complexity and function length", + "Architectural consistency", + "Type safety", + "Documentation completeness", + ], + "security": [ + "Injection vectors", + "Hardcoded secrets", + "Input validation", + "Authentication/authorization gaps", + "Unsafe deserialization", + "Dependency risks", + "Privilege escalation", + ], + "test-coverage": [ + "New logic has accompanying tests", + "Edge cases are covered", + "Regression tests", + "Test readability", + "CI pass status", + "Integration / E2E coverage", + ], + } + with kb.connect() as conn: + roles = ["code-quality", "security", "test-coverage"] + for rid, role in zip(result["reviewer_ids"], roles): + task = kb.get_task(conn, rid) + for snippet in expected[role]: + assert snippet in task.body, f"{role} body missing: {snippet}" + + +def test_synthesis_body_contains_go_no_go(kanban_home, fake_repo): + result = kr.create_review_graph( + title="Review PR #42", + base="nousmain", + head="feat/auth", + repo_path=fake_repo, + ) + with kb.connect() as conn: + synth = kb.get_task(conn, result["synthesis_id"]) + assert "GO/NO-GO decision" in synth.body + + +def test_synthesis_body_contains_all_required_sections(kanban_home, fake_repo): + result = kr.create_review_graph( + title="Review PR #42", + base="nousmain", + head="feat/auth", + repo_path=fake_repo, + ) + with kb.connect() as conn: + synth = kb.get_task(conn, result["synthesis_id"]) + assert "Blockers" in synth.body + assert "Recommended fixes" in synth.body + assert "Acknowledged risks" in synth.body + assert "Rollback plan" in synth.body + assert "Evidence reviewed" in synth.body + + +def test_synthesis_body_contains_default_no_go_on_critical(kanban_home, fake_repo): + result = kr.create_review_graph( + title="Review PR #42", + base="nousmain", + head="feat/auth", + repo_path=fake_repo, + ) + with kb.connect() as conn: + synth = kb.get_task(conn, result["synthesis_id"]) + assert "NO-GO" in synth.body + assert "Critical finding exists" in synth.body + + +def test_synthesis_body_contains_kanban_contract(kanban_home, fake_repo): + result = kr.create_review_graph( + title="Review PR #42", + base="nousmain", + head="feat/auth", + repo_path=fake_repo, + ) + with kb.connect() as conn: + synth = kb.get_task(conn, result["synthesis_id"]) + assert "kanban_complete" in synth.body + assert "kanban_block" in synth.body + assert '"ship_decision":' in synth.body + assert '"blockers":' in synth.body + assert '"recommended_fixes":' in synth.body + assert '"acknowledged_risks":' in synth.body + assert '"rollback_plan":' in synth.body + assert '"evidence_reviewed":' in synth.body + + +def test_generated_bodies_do_not_reference_skills(kanban_home, fake_repo): + """Template bodies must not instruct workers to load skills that may be + missing from the Miki profile. + """ + result = kr.create_review_graph( + title="Review PR #42", + base="nousmain", + head="feat/auth", + repo_path=fake_repo, + ) + with kb.connect() as conn: + for tid in result["reviewer_ids"] + [result["synthesis_id"]]: + task = kb.get_task(conn, tid) + # Reject explicit skill-loading instructions (case-insensitive) + lower = task.body.lower() + assert "load the `" not in lower + assert "use the `" not in lower + assert "skill `" not in lower + + +# --------------------------------------------------------------------------- +# Synthesis promotion tests +# --------------------------------------------------------------------------- + +def test_reviewer_completion_promotes_synthesis(kanban_home, fake_repo): + """When all three reviewers are marked done, complete_task's internal + recompute_ready promotes the synthesis card from todo to ready.""" + result = kr.create_review_graph( + title="Review PR #42", + base="nousmain", + head="feat/auth", + repo_path=fake_repo, + ready=True, + ) + with kb.connect() as conn: + synth = kb.get_task(conn, result["synthesis_id"]) + assert synth.status == "todo" + + for rid in result["reviewer_ids"]: + kb.complete_task(conn, rid, summary="review done") + + # complete_task calls recompute_ready internally; the third completion + # promotes the synthesis automatically. + synth = kb.get_task(conn, result["synthesis_id"]) + assert synth.status == "ready" + + +def test_synthesis_stays_todo_until_all_reviewers_done(kanban_home, fake_repo): + """If only two of three reviewers are done, synthesis stays in todo.""" + result = kr.create_review_graph( + title="Review PR #42", + base="nousmain", + head="feat/auth", + repo_path=fake_repo, + ready=True, + ) + with kb.connect() as conn: + for rid in result["reviewer_ids"][:2]: + kb.complete_task(conn, rid, summary="review done") + + synth = kb.get_task(conn, result["synthesis_id"]) + assert synth.status == "todo" + + +# --------------------------------------------------------------------------- +# Self-contained body tests +# --------------------------------------------------------------------------- + +def test_self_contained_reviewer_bodies(kanban_home, fake_repo): + """Reviewer bodies must contain everything the worker needs without + relying on conversation history or external context. + """ + result = kr.create_review_graph( + title="Review PR #42", + base="nousmain", + head="feat/auth", + repo_path=fake_repo, + ) + ws_path = str(Path(fake_repo).resolve()) + with kb.connect() as conn: + for rid in result["reviewer_ids"]: + task = kb.get_task(conn, rid) + body = task.body + assert "nousmain" in body + assert "feat/auth" in body + assert ws_path in body + assert "git diff" in body + assert "REVIEW-ONLY" in body + assert "kanban_complete" in body + assert "kanban_block" in body + assert "**Critical**" in body + assert "Checklist:" in body + + +def test_self_contained_synthesis_body(kanban_home, fake_repo): + """Synthesis body must contain everything the worker needs without + relying on conversation history or external context. + """ + result = kr.create_review_graph( + title="Review PR #42", + base="nousmain", + head="feat/auth", + repo_path=fake_repo, + ) + ws_path = str(Path(fake_repo).resolve()) + with kb.connect() as conn: + synth = kb.get_task(conn, result["synthesis_id"]) + body = synth.body + assert "nousmain" in body + assert "feat/auth" in body + assert ws_path in body + assert "code-quality" in body + assert "security" in body + assert "test-coverage" in body + assert "GO/NO-GO" in body + assert "kanban_complete" in body + assert "kanban_block" in body + + +def test_self_contained_parent_body(kanban_home, fake_repo): + """Parent body must contain everything the worker needs without + relying on conversation history or external context. + """ + result = kr.create_review_graph( + title="Review PR #42", + base="nousmain", + head="feat/auth", + repo_path=fake_repo, + ) + ws_path = str(Path(fake_repo).resolve()) + with kb.connect() as conn: + parent = kb.get_task(conn, result["parent_id"]) + body = parent.body + assert "nousmain" in body + assert "feat/auth" in body + assert ws_path in body + assert "REVIEW-ONLY" in body + assert "code-quality" in body + assert "security" in body + assert "test-coverage" in body + assert "Synthesis card will aggregate" in body + + +# --------------------------------------------------------------------------- +# JSON CLI output structure +# --------------------------------------------------------------------------- + +def test_cli_review_create_json_structure(kanban_home, fake_repo): + """JSON output must contain exact keys with correct types.""" + from hermes_cli import kanban as kc + + out = kc.run_slash( + f"review create 'Review PR #42' --base nousmain --head feat/auth --repo {fake_repo} --json" + ) + payload = json.loads(out) + assert set(payload.keys()) == {"parent_id", "reviewer_ids", "synthesis_id", "created"} + assert isinstance(payload["parent_id"], str) + assert isinstance(payload["reviewer_ids"], list) + assert len(payload["reviewer_ids"]) == 3 + for rid in payload["reviewer_ids"]: + assert isinstance(rid, str) + assert rid.startswith("t_") + assert isinstance(payload["synthesis_id"], str) + assert payload["synthesis_id"].startswith("t_") + assert isinstance(payload["created"], bool) + + +def test_cli_review_create_json_idempotent_returns_false(kanban_home, fake_repo): + """Second invocation with same params must return created=False.""" + from hermes_cli import kanban as kc + + kc.run_slash( + f"review create 'Review PR #42' --base nousmain --head feat/auth --repo {fake_repo} --json" + ) + out = kc.run_slash( + f"review create 'Review PR #42' --base nousmain --head feat/auth --repo {fake_repo} --json" + ) + payload = json.loads(out) + assert payload["created"] is False + diff --git a/tests/hermes_cli/test_model_validation.py b/tests/hermes_cli/test_model_validation.py index 03c0fcca3d47..184db618b0a2 100644 --- a/tests/hermes_cli/test_model_validation.py +++ b/tests/hermes_cli/test_model_validation.py @@ -555,7 +555,6 @@ def test_dissimilar_model_shows_suggestions_not_autocorrect(self): assert result.get("corrected_model") is None assert "not found" in result["message"] - # -- validate — API unreachable — soft-accept via catalog or warning -------- class TestValidateApiFallback: @@ -830,3 +829,4 @@ def test_probe_user_agent_sent_without_api_key(self): assert ua and ua.startswith("hermes-cli/") # No Authorization was set, but UA must still be present. assert req.get_header("Authorization") is None + diff --git a/tests/hermes_cli/test_runtime_provider_resolution.py b/tests/hermes_cli/test_runtime_provider_resolution.py index d17b1a41e3a8..d903eea45144 100644 --- a/tests/hermes_cli/test_runtime_provider_resolution.py +++ b/tests/hermes_cli/test_runtime_provider_resolution.py @@ -240,6 +240,37 @@ def test_resolve_runtime_provider_ai_gateway(monkeypatch): assert resolved["requested_provider"] == "ai-gateway" +def test_resolve_runtime_provider_kimi_uses_oauth_chat_mode(monkeypatch): + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "kimi-coding") + monkeypatch.setattr( + rp, + "_get_model_config", + lambda: { + "provider": "kimi-coding", + "base_url": "https://api.kimi.com/coding/v1", + "default": "kimi-k2.6", + }, + ) + monkeypatch.setattr( + rp, + "resolve_api_key_provider_credentials", + lambda provider: { + "provider": provider, + "api_key": "***", + "base_url": "https://api.kimi.com/coding/v1", + "source": "kimi-cli-oauth", + }, + ) + + resolved = rp.resolve_runtime_provider(requested="kimi-coding") + + assert resolved["provider"] == "kimi-coding" + assert resolved["api_mode"] == "chat_completions" + assert resolved["base_url"] == "https://api.kimi.com/coding/v1" + assert resolved["api_key"] == "oauth-token" + assert resolved["source"] == "kimi-cli-oauth" + + def test_resolve_runtime_provider_lmstudio_uses_token_when_present(monkeypatch): monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "lmstudio") monkeypatch.setattr( @@ -261,7 +292,7 @@ def test_resolve_runtime_provider_lmstudio_uses_token_when_present(monkeypatch): "resolve_api_key_provider_credentials", lambda provider: { "provider": "lmstudio", - "api_key": "lm-token", + "api_key": "***", "base_url": "http://127.0.0.1:1234/v1", "source": "LM_API_KEY", }, diff --git a/tests/profiles/__init__.py b/tests/profiles/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/profiles/test_daemoncraft_base_rules.py b/tests/profiles/test_daemoncraft_base_rules.py new file mode 100644 index 000000000000..d64c32a9093b --- /dev/null +++ b/tests/profiles/test_daemoncraft_base_rules.py @@ -0,0 +1,74 @@ +"""Pin the five intent-composition rules to the daemoncraft-base profile. + +These rules are validated in primitives_lab experiments 001-007 (vault +pages lessons-001-003-primitives-baseline.md and +lessons-004-007-primitives-second-round.md). Removing them from the +profile silently degrades cloud LLM intent-compose quality, so this +test exists as a regression gate. + +The test reads the live profile config at ~/.hermes/profiles/. If the +profile isn't installed (e.g., on CI without the user's home dir), +the test skips rather than fails. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + + +PROFILE_CONFIG = Path.home() / ".hermes/profiles/daemoncraft-base/config.yaml" + + +@pytest.fixture +def profile_config() -> dict: + if not PROFILE_CONFIG.exists(): + pytest.skip(f"daemoncraft-base profile not installed at {PROFILE_CONFIG}") + return yaml.safe_load(PROFILE_CONFIG.read_text()) + + +def test_agent_system_prompt_is_present(profile_config): + sp = profile_config.get("agent", {}).get("system_prompt", "") + assert sp, "agent.system_prompt is missing or empty" + + +def test_all_five_intent_rules_present(profile_config): + sp = profile_config["agent"]["system_prompt"] + for n in (1, 2, 3, 4, 5): + assert f"RULE {n}" in sp, f"RULE {n} marker missing from system_prompt" + + +def test_rule_text_carries_concrete_examples(profile_config): + """If an engineer trims the rules to bullets, the model loses the + examples that make the rules actionable. Pin the load-bearing strings.""" + sp = profile_config["agent"]["system_prompt"] + expected_phrases = [ + "English imperative", # rule 1 keyword + "explicit non-bot coordinates", # rule 2 keyword + "numbered stages", # rule 3 keyword + "delegate conditionals", # rule 4 keyword + "explicit username", # rule 5 keyword + "(5, 65, 37)", # concrete-coords example in rule 2 + "Step 1:", # numbered-stages example in rule 3 + ] + for phrase in expected_phrases: + assert phrase in sp, f"missing key phrase: {phrase!r}" + + +def test_recovery_section_documents_auto_retry(profile_config): + """The system prompt must tell the cloud LLM not to micro-manage retries. + Updated for Autonomía Corporal: the autonomous loop in agent_loop.py + owns retry/verify/escalate; Hermes writes Plans, doesn't retry.""" + sp = profile_config["agent"]["system_prompt"] + assert "loop owns" in sp.lower() or "happens automatically" in sp, \ + "system_prompt must inform cloud LLM that recovery is loop-owned" + + +def test_recovery_section_references_autonomous_loop_terms(profile_config): + """Phase 2 of the integration: rules now reference Plan/Step/VerifySpec + so Steve composes plans for the autonomous loop, not free-form intents.""" + sp = profile_config["agent"]["system_prompt"] + expected_terms = ["VerifySpec", "Plan", "Step", "agent_loop", "body_session"] + missing = [t for t in expected_terms if t not in sp] + assert not missing, f"system_prompt missing autonomous-loop terms: {missing}" diff --git a/tests/run_agent/test_anthropic_prompt_cache_policy.py b/tests/run_agent/test_anthropic_prompt_cache_policy.py index b8a380a62e7f..8cd90c91c642 100644 --- a/tests/run_agent/test_anthropic_prompt_cache_policy.py +++ b/tests/run_agent/test_anthropic_prompt_cache_policy.py @@ -89,74 +89,73 @@ def test_minimax_claude_via_anthropic_messages(self): assert should is True, "Third-party Anthropic gateway with Claude must cache" assert native is True, "Third-party Anthropic gateway uses native cache_control layout" - def test_third_party_anthropic_non_claude_unknown_provider_does_not_cache(self): - # A provider exposing e.g. GLM via anthropic_messages transport from - # a host we don't recognize — we don't know whether it supports - # cache_control, so stay conservative. - agent = _make_agent( - provider="custom", - base_url="https://some-unknown-gateway.example.com/anthropic", - api_mode="anthropic_messages", - model="glm-4.5", - ) - assert agent._anthropic_prompt_cache_policy() == (False, False) - - -class TestMiniMaxAnthropicWire: - """MiniMax's own model family on its Anthropic-compatible endpoint. - - MiniMax documents cache_control support on ``/anthropic`` (0.1× read - pricing, 5-minute TTL). Issue #17332: the blanket ``is_claude`` gate on - the third-party-gateway branch left MiniMax-M2.7 etc. paying full input - cost every turn. Allowlist MiniMax explicitly via provider id or host. - """ - - def test_minimax_m27_on_provider_minimax_caches_native_layout(self): + def test_minimax_own_model_caches_with_native_layout(self): agent = _make_agent( provider="minimax", base_url="https://api.minimax.io/anthropic", api_mode="anthropic_messages", - model="minimax-m2.7", + model="MiniMax-M2.7", ) - assert agent._anthropic_prompt_cache_policy() == (True, True) + should, native = agent._anthropic_prompt_cache_policy() + assert should is True + assert native is True - def test_minimax_m25_on_provider_minimax_cn_caches_native_layout(self): + def test_minimax_cn_own_model_caches_with_native_layout(self): agent = _make_agent( provider="minimax-cn", base_url="https://api.minimaxi.com/anthropic", api_mode="anthropic_messages", - model="minimax-m2.5", + model="MiniMax-M2.7", ) - assert agent._anthropic_prompt_cache_policy() == (True, True) + should, native = agent._anthropic_prompt_cache_policy() + assert should is True + assert native is True - def test_custom_provider_pointed_at_minimax_host_caches(self): - # User wires a custom provider manually at MiniMax's Anthropic URL; - # host match alone should be sufficient to enable caching. + def test_minimax_custom_endpoint_by_url_caches(self): + # Custom provider config pointing at MiniMax's Anthropic endpoint. agent = _make_agent( provider="custom", base_url="https://api.minimax.io/anthropic", api_mode="anthropic_messages", - model="minimax-m2.7", + model="MiniMax-M2.7", ) - assert agent._anthropic_prompt_cache_policy() == (True, True) + should, native = agent._anthropic_prompt_cache_policy() + assert should is True + assert native is True - def test_minimax_host_china_endpoint_caches(self): + def test_minimax_own_model_with_empty_base_url_caches_by_provider_name(self): + # When AIAgent.__init__ is fixed to default base_url for minimax, + # this tests the policy directly even before base_url is set. agent = _make_agent( - provider="custom", - base_url="https://api.minimaxi.com/anthropic", + provider="minimax", + base_url="", api_mode="anthropic_messages", - model="minimax-m2.1", + model="MiniMax-M2.7", ) - assert agent._anthropic_prompt_cache_policy() == (True, True) + should, native = agent._anthropic_prompt_cache_policy() + assert should is True + assert native is True - def test_minimax_provider_on_openai_wire_does_not_cache(self): - # chat_completions transport — MiniMax's cache_control support is - # documented only for the /anthropic endpoint. Stay off. + def test_minimax_cn_own_model_with_empty_base_url_caches_by_provider_name(self): agent = _make_agent( - provider="minimax", - base_url="https://api.minimax.io/v1", - api_mode="chat_completions", - model="minimax-m2.7", + provider="minimax-cn", + base_url="", + api_mode="anthropic_messages", + model="MiniMax-M2.7", + ) + should, native = agent._anthropic_prompt_cache_policy() + assert should is True + assert native is True + + def test_third_party_without_claude_name_does_not_cache(self): + # A generic provider exposing e.g. GLM via anthropic_messages transport + # — we don't know whether it supports cache_control for its own models, + # so stay conservative. + agent = _make_agent( + provider="custom", + base_url="https://api.glm.ai/anthropic", + api_mode="anthropic_messages", + model="glm-5", ) assert agent._anthropic_prompt_cache_policy() == (False, False) diff --git a/tests/tools/test_embodied_plan_tool.py b/tests/tools/test_embodied_plan_tool.py new file mode 100644 index 000000000000..6cb7896c6256 --- /dev/null +++ b/tests/tools/test_embodied_plan_tool.py @@ -0,0 +1,279 @@ +"""Tests for tools.embodied_plan_tool.""" +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +import pytest + +import httpx + + +def test_tool_registered(): + """Importing the module should register the tool in the global registry.""" + from tools.registry import registry + import tools.embodied_plan_tool # noqa: F401 + + tool = registry.get_entry("embodied_plan") + assert tool is not None + assert tool.toolset == "embodiment" + assert tool.schema["function"]["name"] == "embodied_plan" + + +def test_handler_rejects_missing_intent(): + from tools.embodied_plan_tool import _handler + + out = _handler({}) + payload = json.loads(out) + assert payload["ok"] is False + assert payload["error"]["error_type"] == "missing_intent" + + +def test_handler_rejects_non_string_intent(): + from tools.embodied_plan_tool import _handler + + out = _handler({"intent": 42}) + payload = json.loads(out) + assert payload["ok"] is False + assert payload["error"]["error_type"] == "missing_intent" + + +def test_handler_posts_intent_to_service(): + """Standard happy path — handler posts to /intent and returns the + service's response verbatim.""" + from tools.embodied_plan_tool import _handler + + fake_response = MagicMock() + fake_response.json.return_value = { + "ok": True, + "context_id": "abc-123", + "plan": { + "body_plan": ["scan", "mine"], + "checks": ["time=day"], + "tool_calls": [{"name": "scan_nearby", "arguments": {"radius": 16}}], + "failure_policy": "ask the player", + "operational_risk": "low", + }, + "execution_results": [{"tool": "scan_nearby", "ok": True, "data": {}}], + "elapsed_seconds": 1.2, + } + fake_response.status_code = 200 + + captured = {} + def fake_post(url, json=None, timeout=None): + captured["url"] = url + captured["body"] = json + captured["timeout"] = timeout + return fake_response + + with patch("tools.embodied_plan_tool.httpx.post", side_effect=fake_post): + out = _handler({ + "intent": "Help the player gather wood before night.", + "autonomy_level": 2, + "allowed_tools": ["scan_nearby", "mine_block"], + }) + + assert captured["url"].endswith("/intent") + assert captured["body"]["intent"] == "Help the player gather wood before night." + assert captured["body"]["autonomy_level"] == 2 + assert captured["body"]["allowed_tools"] == ["scan_nearby", "mine_block"] + payload = json.loads(out) + assert payload["ok"] is True + assert payload["plan"]["operational_risk"] == "low" + + +def test_handler_omits_none_optional_fields(): + """Optional fields that are None must NOT be in the request body — the + service treats absence as 'use default', not as 'use None'.""" + from tools.embodied_plan_tool import _handler + + captured = {} + def fake_post(url, json=None, timeout=None): + captured["body"] = json + resp = MagicMock() + resp.json.return_value = {"ok": True} + resp.status_code = 200 + return resp + + with patch("tools.embodied_plan_tool.httpx.post", side_effect=fake_post): + _handler({ + "intent": "Do a thing.", + "previous_error": None, # explicitly None — should not be forwarded + }) + + assert "intent" in captured["body"] + assert "previous_error" not in captured["body"] + + +def test_handler_handles_timeout(): + from tools.embodied_plan_tool import _handler + + with patch("tools.embodied_plan_tool.httpx.post", + side_effect=httpx.TimeoutException("request timed out")): + out = _handler({"intent": "test"}) + payload = json.loads(out) + assert payload["ok"] is False + assert payload["error"]["error_type"] == "embodied_service_timeout" + + +def test_handler_handles_connection_error(): + from tools.embodied_plan_tool import _handler + + with patch("tools.embodied_plan_tool.httpx.post", + side_effect=httpx.ConnectError("connection refused")): + out = _handler({"intent": "test"}) + payload = json.loads(out) + assert payload["ok"] is False + assert payload["error"]["error_type"] == "embodied_service_unreachable" + + +def test_handler_handles_non_json_response(): + from tools.embodied_plan_tool import _handler + + fake_response = MagicMock() + fake_response.json.side_effect = json.JSONDecodeError("bad", "", 0) + fake_response.status_code = 502 + fake_response.text = "bad gateway" + + with patch("tools.embodied_plan_tool.httpx.post", return_value=fake_response): + out = _handler({"intent": "test"}) + payload = json.loads(out) + assert payload["ok"] is False + assert payload["error"]["error_type"] == "embodied_service_bad_response" + + +def test_check_service_available_validates_url(): + from tools.embodied_plan_tool import _check_service_available + + assert _check_service_available() is True # default http://localhost:7790 + + +def test_service_url_respects_env(monkeypatch): + monkeypatch.setenv("EMBODIED_SERVICE_URL", "http://10.10.20.5:7790") + from tools.embodied_plan_tool import _service_url + + assert _service_url() == "http://10.10.20.5:7790" + + +# --------------------------------------------------------------------------- +# Narrative replan composition (workaround for `previous_error` regression +# verified by primitives_lab experiment 007). +# --------------------------------------------------------------------------- + + +def test_compose_replan_intent_includes_tool_and_error_type(): + from tools.embodied_plan_tool import _compose_replan_intent + + out = _compose_replan_intent( + "build a wall with 4 oak_planks at the player", + {"tool": "place_block", "error_type": "missing_material", + "details": "no oak_log; have oak_planks(40)"}, + ) + assert "place_block" in out + assert "missing_material" in out + assert "no oak_log" in out + # Original intent appears in past-tense framing + assert "tried to build a wall with 4 oak_planks at the player" in out + + +def test_compose_replan_intent_uses_past_tense_framing(): + """The original (failing) intent must appear in past-tense framing — the + model has a strong last-instruction bias and we need the recovery + directive to be the active imperative, not the failing instruction.""" + from tools.embodied_plan_tool import _compose_replan_intent + + out = _compose_replan_intent( + "build a wall with 4 oak_log blocks", + {"tool": "place_block", "error_type": "missing_material", + "details": "no oak_log in inventory; have oak_planks(40)."}, + ) + # Original intent is wrapped in "We tried to ...", not stated as a goal + assert "tried to build a wall with 4 oak_log blocks" in out + # The closing sentence must be the recovery directive + sentences = [s.strip() for s in out.split(". ") if s.strip()] + last = sentences[-1].rstrip(".") + assert "oak_log" not in last, f"failing material in trailing directive: {last!r}" + assert any(kw in last.lower() for kw in ("compose", "plan", "re-emit", "avoid")) + + +def test_compose_replan_intent_handles_missing_fields(): + from tools.embodied_plan_tool import _compose_replan_intent + + out = _compose_replan_intent("retry", {}) + assert "the previous action" in out # fallback + assert "failed" in out + assert "retry" in out + + +def test_handler_prepends_narrative_when_previous_error_present(): + """When previous_error is set, the handler must rewrite the intent on the + wire to embed the failure narrative — Gemma-Andy ignores the structured + field but honors in-intent narration (validated by 007 at n=10).""" + from tools.embodied_plan_tool import _handler + + captured = {} + def fake_post(url, json=None, timeout=None): + captured["body"] = json + resp = MagicMock() + resp.json.return_value = {"ok": True} + resp.status_code = 200 + return resp + + with patch("tools.embodied_plan_tool.httpx.post", side_effect=fake_post): + _handler({ + "intent": "Place 4 blocks at the player.", + "previous_error": { + "tool": "place_block", + "error_type": "missing_material", + "details": "no oak_log in inventory; have oak_planks(40).", + }, + }) + + sent_intent = captured["body"]["intent"] + # Original intent appears, wrapped in past-tense framing + assert "tried to Place 4 blocks at the player" in sent_intent + # Failure narrative embedded + assert "place_block" in sent_intent + assert "missing_material" in sent_intent + assert "no oak_log" in sent_intent + # Structured field dropped — see handler comment for rationale + # (avoids daemoncraft `recovery_naive_retry` false-positive) + assert "previous_error" not in captured["body"] + + +def test_handler_does_not_modify_intent_when_no_previous_error(): + from tools.embodied_plan_tool import _handler + + captured = {} + def fake_post(url, json=None, timeout=None): + captured["body"] = json + resp = MagicMock() + resp.json.return_value = {"ok": True} + resp.status_code = 200 + return resp + + with patch("tools.embodied_plan_tool.httpx.post", side_effect=fake_post): + _handler({"intent": "Toss 2 oak_planks to the player."}) + + assert captured["body"]["intent"] == "Toss 2 oak_planks to the player." + assert "previous_error" not in captured["body"] + + +def test_handler_skips_narrative_when_previous_error_is_empty_dict(): + """Defensive: an empty dict shouldn't trigger the narrative prepend.""" + from tools.embodied_plan_tool import _handler + + captured = {} + def fake_post(url, json=None, timeout=None): + captured["body"] = json + resp = MagicMock() + resp.json.return_value = {"ok": True} + resp.status_code = 200 + return resp + + with patch("tools.embodied_plan_tool.httpx.post", side_effect=fake_post): + _handler({"intent": "Do a thing.", "previous_error": {}}) + + assert captured["body"]["intent"] == "Do a thing." + # Empty dict is treated as "no error info" — neither prepended nor forwarded. + assert "previous_error" not in captured["body"] or captured["body"].get("previous_error") == {} diff --git a/tools/bot_api_url_ctx.py b/tools/bot_api_url_ctx.py new file mode 100644 index 000000000000..df66038adf8f --- /dev/null +++ b/tools/bot_api_url_ctx.py @@ -0,0 +1,53 @@ +"""Session-scoped bot API URL routing. + +The DaemonCraft (and previously AlterCraft) gateway adapter receives chat +messages from a Minecraft world over WebSocket, and needs the tool layer +to dispatch HTTP back to the *same* bot that sent the message — not just +to whatever the process-wide env var says. This contextvar is the +mechanism: the gateway sets it inside `handle_message`, tools read it +through `get_bot_api_url`, and the gateway resets it on exit. + +This module replaces the same-named contextvar that lived inside +`tools/minecraft_tools.py` (retired 2026-05-09 along with the rest of +the mc_*/altercraft_* toolset stack — see legacy/altercraft-toolsets +branch). Keeping the contextvar in a neutral module decouples the +gateway from any specific toolset implementation. + +Today only the embodied service path (POST → embodied service → bot) +needs this. Future tools that hit a Mineflayer bot directly should +import from here rather than reintroducing a per-toolset contextvar. +""" +from __future__ import annotations + +import contextvars +import os +from typing import Optional + + +_bot_api_url_ctx: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar( + "bot_api_url", default=None +) + + +def get_bot_api_url() -> str: + """Resolve the active bot HTTP API URL for the current call context. + + Priority: + 1. Context variable (set by the gateway adapter for the lifetime + of one inbound message) + 2. ``MC_API_URL`` environment variable (CLI / legacy fallback) + 3. Default ``http://localhost:3001`` + """ + url = _bot_api_url_ctx.get() + if url: + return url + return os.getenv("MC_API_URL", "http://localhost:3001") + + +def set_bot_api_url(url: str) -> contextvars.Token: + """Set the contextvar and return the token. Caller MUST `reset` it.""" + return _bot_api_url_ctx.set(url) + + +def reset_bot_api_url(token: contextvars.Token) -> None: + _bot_api_url_ctx.reset(token) diff --git a/tools/embodied_plan_tool.py b/tools/embodied_plan_tool.py new file mode 100644 index 000000000000..f348c855f18d --- /dev/null +++ b/tools/embodied_plan_tool.py @@ -0,0 +1,377 @@ +#!/usr/bin/env python3 +"""embodied_plan — single-tool body orchestration delegate. + +The Hermes-side counterpart to the DaemonCraft embodied service v1. + +Hermes' cloud LLM (Kimi/MiniMax/etc.) calls this **one tool** when it +needs the body to do something. The embodied service handles: + + 1. Reading world_state from bot/server.js + 2. Filtering allowed_tools by executor_supported + 3. Composing a canonical Gemma-Andy v2 payload + 4. Calling Ollama (gemma-andy:e4b-v2-2-3-q8_0) + 5. Parsing the response (with strip + bracket fallback) + 6. Dispatching each tool_call to bot/server.js + 7. Returning the assembled {plan, execution_results} + +Hermes never has to know about the granular Mineflayer mc_* tools — that +is Gemma-Andy's job. Path B canonical per team architectural decision +2026-05-08 (see vault/concepts/gemma-andy-embodied-service.md and +vault/epics/E002-body-protocol-wireup.md). + +Environment: + EMBODIED_SERVICE_URL Base URL of the embodied service + (default: http://localhost:7790) + EMBODIED_PLAN_TIMEOUT Per-request timeout in seconds + (default: 60 — Ollama + dispatch can be slow) +""" + +from __future__ import annotations + +import json +import logging +import os +from typing import Any + +import httpx + +from tools.registry import registry + +logger = logging.getLogger(__name__) + + +def _service_url() -> str: + return os.environ.get("EMBODIED_SERVICE_URL", "http://localhost:7790").rstrip("/") + + +def _timeout() -> float: + try: + return float(os.environ.get("EMBODIED_PLAN_TIMEOUT", "60")) + except ValueError: + return 60.0 + + +# --------------------------------------------------------------------------- +# Tool schema +# --------------------------------------------------------------------------- + +EMBODIED_PLAN_SCHEMA = { + "type": "function", + "function": { + "name": "embodied_plan", + "description": ( + "Delegate a body task in Minecraft to the embodied service " + "(Gemma-Andy via Ollama). Use this when the user wants the " + "agent's Minecraft character to DO something — gather, build, " + "fight, navigate, craft, etc. The service handles world-state " + "perception, tool selection, and execution against bot/server.js. " + "You only describe the high-level intent in natural language. " + "DO NOT use granular mc_* tools when this tool is available — " + "this one collapses what would be 5-15 LLM rounds into a single " + "delegation backed by a fine-tuned local model.\n\n" + "USE WHEN:\n" + "- The user asks the bot to do something physical in Minecraft\n" + "- A multi-step body task (gather → craft → place)\n" + "- A movement / navigation request\n" + "- A combat / defensive action\n\n" + "NOT FOR:\n" + "- Conversation, narrative, education (handle yourself)\n" + "- Reading/explaining game state to the user (handle yourself)\n" + "- Tasks outside body orchestration (writing code, web research, etc.)\n\n" + "INTENT COMPOSITION RULES — these are validated by primitives_lab\n" + "experiments 001-007 against gemma-andy:e4b-v2-2-3-q8_0. Following\n" + "them is the difference between a task succeeding and the body\n" + "model emitting an empty plan or the wrong tool:\n\n" + "1. ENGLISH IMPERATIVE ALWAYS. Compose the intent in English\n" + " imperative form regardless of the user's surface language.\n" + " Spanish conversational ('dame X', 'seguime') makes the model\n" + " pick the wrong tool semantics. The model is a body, not a\n" + " conversation partner.\n\n" + "2. PLACEMENT INTENTS NEED EXPLICIT NON-BOT COORDINATES. When the\n" + " intent is to place block(s), supply each (x, y, z) explicitly\n" + " and ENSURE no coordinate equals the bot's current position.\n" + " Phrases like 'stack upward', 'build a wall here', 'place at\n" + " your spot' make the body model pick the bot's own [x, y, z],\n" + " which fails with bot_action_failed (can't place a block in\n" + " the space the bot occupies). Read bot_position from the\n" + " most recent world snapshot, then compose target coords that\n" + " are adjacent. Example for a 4-block vertical wall starting\n" + " one block in front of the player: 'Place 4 oak_planks at\n" + " coordinates (X, Y, Z), (X, Y+1, Z), (X, Y+2, Z), (X, Y+3, Z)'\n" + " with the actual integer values substituted in.\n\n" + "3. MULTI-STEP INTENTS NEED NUMBERED STAGES. The body model only\n" + " produces a true gather→craft→place plan when the intent\n" + " enumerates stages: 'Step 1: scan for X. Step 2: mine N X.\n" + " Step 3: craft into Y. Step 4: place at .' Free-form\n" + " prose ('build a wall using wood from nearby trees, then...')\n" + " collapses to empty plans. If you need >2 stages, enumerate.\n\n" + "4. DON'T DELEGATE CONDITIONALS. The body model does not honor\n" + " if/then/else against world_state. NEVER write 'if you have X\n" + " then Y else Z'. Read world state first (call this tool with\n" + " a get_inventory-style intent OR consult prior tool results),\n" + " decide the branch yourself, then issue an unconditional\n" + " imperative.\n\n" + "5. PLAYER-AS-TARGET INTENTS NEED EXPLICIT USERNAME. 'Toss N X to\n" + " the player named ' / 'Follow the player named\n" + " ' / 'Stand next to player ' all hit 100%\n" + " success. Pronoun forms ('come to me', 'follow me') do not." + ), + "parameters": { + "type": "object", + "properties": { + "intent": { + "type": "string", + "description": ( + "Natural-language description of what the bot should do. " + "Compose in English imperative form (rule 1 in the parent " + "tool description). Be CONCRETE — include 'what', 'where' " + "(exact coordinates when placement or movement is " + "involved), and 'why' when relevant.\n\n" + "Good examples:\n" + "- 'Mine 4 oak_log from the tree at (17, 68, 30), then " + " return to coordinates (5, 65, 38).'\n" + "- 'Place 4 oak_planks at coordinates (5, 65, 37), " + " (5, 66, 37), (5, 67, 37), (5, 68, 37) — a vertical " + " pillar starting one block east of the player.'\n" + "- 'Toss 16 oak_planks to the player named Fede3043.'\n" + "- 'Follow the player named Fede3043 wherever they go.'\n\n" + "Bad examples that the body model misinterprets:\n" + "- 'Build a tall wall' → no coords, model picks bot's own\n" + " position and fails with bot_action_failed.\n" + "- 'Stack oak_planks upward until materials run out' → " + " same problem; the model has no implicit notion of an\n" + " adjacent build face.\n" + "- 'Dame 2 oak_planks' (Spanish 'give') → model crafts\n" + " instead of tossing.\n" + "- 'If you have planks then build, otherwise gather' → " + " model emits one branch regardless of inventory.\n\n" + "Ambiguous intents are okay only when the ambiguity is " + "about the user's preference (not about geometry or " + "available materials). The embodied service responds " + "with an ask_clarification tool_call which surfaces a " + "question to ask the user." + ), + }, + "autonomy_level": { + "type": "integer", + "description": ( + "Guardian autonomy. 0=observer / 1=assistant / " + "2=supervised builder (DEFAULT, safe for kids+adults) / " + "3=autonomous companion / 4=advanced operator (risky)." + ), + "default": 2, + }, + "allowed_tools": { + "type": "array", + "items": {"type": "string"}, + "description": ( + "Optional override of the tool subset Gemma-Andy may use. " + "Names must be canonical v2 tool names. When omitted, the " + "service uses its default safe set. The service further " + "filters by executor_supported, so passing tools the bot " + "server doesn't implement is harmless — they're dropped." + ), + }, + "guardian_constraints": { + "type": "object", + "description": ( + "Optional override of the safety constraints. Recognized " + "fields include no_tnt, no_protected_zone_edit, " + "protected_zone_owner, plus any no_ bool flags. " + "Defaults are sane (no_tnt=true, no_protected_zone_edit=true)." + ), + }, + "previous_error": { + "type": "object", + "description": ( + "Optional. Pass when the previous embodied_plan call's " + "execution_results contained a failure and you want " + "Gemma-Andy to compose a recovery plan. Shape: " + "{tool: , error_type: 'stuck'|'no_path'|'tool_timeout'|" + "'hazard_detected'|'missing_material'|'other', " + "details: }.\n\n" + "Implementation note: Gemma-Andy v2-2-3 currently ignores " + "the structured previous_error field 100% of the time " + "(primitives_lab experiment 003+007). The Hermes-side " + "handler works around this by prepending a narrative " + "reformulation of previous_error to the intent text, " + "which the model honors 100% of the time (experiment " + "007 in_intent_directive at n=10). The structured field " + "is still forwarded for forward-compat with a future " + "model retrain." + ), + }, + "deadline_seconds": { + "type": "integer", + "description": ( + "Wall-clock budget for the WHOLE call (compose + Ollama + " + "dispatch). Default 30. Set higher for long execution " + "sequences." + ), + "default": 30, + }, + }, + "required": ["intent"], + }, + }, +} + + +# --------------------------------------------------------------------------- +# Replan composition +# --------------------------------------------------------------------------- + + +def _compose_replan_intent(intent: str, prev_err: dict[str, Any]) -> str: + """Embed `previous_error` as a recovery directive trailing the intent. + + Why: `gemma-andy:e4b-v2-2-3-q8_0` ignores the structured `previous_error` + field 100% of the time (verified by primitives_lab experiment 003 + 007, + n=10 each). The same failure context embedded in the intent text shifts + the plan 100% of the time (experiment 007 `in_intent_directive`). + + Composition shape — the order is load-bearing. Put the original intent + first, then the failure narration, then the recovery directive **last**. + The model has a strong last-instruction bias; in our first iteration the + narrative was prepended and the original (uncorrected) intent landed at + the tail — Andy re-emitted the failing tool. Trailing recovery shifts + the plan as designed in experiment 007. + + This is a Hermes-side workaround — once Andy is retrained to honor the + structured field, the rewrite can be removed without breaking anything. + """ + tool = prev_err.get("tool") or "the previous action" + error_type = prev_err.get("error_type") or "failed" + details = (prev_err.get("details") or "").strip() + # Past-tense framing: the original (failing) intent never appears as a + # live imperative — only as something we *tried* and that failed. This + # matches experiment 007's winning `in_intent_narrative` shape (9/10). + # The closing directive is what the model picks up as the active task. + parts = [ + f"We tried to {intent}, but {tool} failed with error_type={error_type}.", + ] + if details: + parts.append(details) + parts.append( + "Compose a new plan that achieves the same outcome using the " + "actually-available state. Do not re-emit the failing action." + ) + return " ".join(parts) + + +# --------------------------------------------------------------------------- +# Handler +# --------------------------------------------------------------------------- + + +def _handler(args: dict[str, Any], **_kw: Any) -> str: + intent = (args or {}).get("intent", "") + if not intent or not isinstance(intent, str): + return json.dumps({ + "ok": False, + "error": {"error_type": "missing_intent", + "details": "embodied_plan requires a non-empty 'intent' string"}, + }) + + body: dict[str, Any] = {"intent": intent} + for k in ( + "autonomy_level", + "allowed_tools", + "guardian_constraints", + "previous_error", + "deadline_seconds", + ): + if k in args and args[k] is not None: + body[k] = args[k] + + # Multi-bot: include bot_api_url so the embodied service dispatches + # to the correct bot/server.js instance. Read from the agent's own + # environment (each DaemonCraft agent has BOT_API_URL in its systemd + # unit pointing to its own Mineflayer bot). Also accept explicit + # override from tool args (for future per-call routing). + bot_api_url = args.get("bot_api_url") or os.environ.get("BOT_API_URL") + if bot_api_url: + body["bot_api_url"] = bot_api_url + + prev_err = body.get("previous_error") + if isinstance(prev_err, dict) and prev_err: + body["intent"] = _compose_replan_intent(intent, prev_err) + # Once we've embedded the failure narrative into the intent, forwarding + # the structured field is redundant — and worse, the daemoncraft + # `recovery_naive_retry` mitigation compares only tool names, so it + # flags `place_block` re-emission as a regression even when the model + # correctly swapped the block argument. Drop the structured field to + # avoid that false positive. Re-enable forwarding once Andy is + # retrained to honor previous_error directly (then this rewrite path + # can be retired entirely). + body.pop("previous_error", None) + + url = f"{_service_url()}/intent" + timeout = _timeout() + + try: + resp = httpx.post(url, json=body, timeout=timeout) + except httpx.TimeoutException as exc: + logger.warning("embodied_plan timed out after %.1fs: %s", timeout, exc) + return json.dumps({ + "ok": False, + "error": { + "error_type": "embodied_service_timeout", + "details": f"timed out after {timeout}s waiting for {url}", + }, + }) + except httpx.RequestError as exc: + logger.warning("embodied_plan request failed: %s", exc) + return json.dumps({ + "ok": False, + "error": { + "error_type": "embodied_service_unreachable", + "details": f"{type(exc).__name__}: {exc}", + }, + }) + + try: + result = resp.json() + except json.JSONDecodeError as exc: + return json.dumps({ + "ok": False, + "error": { + "error_type": "embodied_service_bad_response", + "details": f"non-JSON body (status {resp.status_code}): {resp.text[:200]}", + }, + }) + + # Pass the service response through verbatim. Hermes' AIAgent gets + # the full {ok, plan, execution_results, ...} envelope so the LLM + # can decide whether to retry with previous_error, reword the + # request, or surface ask_clarification questions to the user. + return json.dumps(result) + + +def _check_service_available() -> bool: + """Light availability check — does NOT call /health (would block tool + discovery on a slow service). The check_fn is invoked at toolset + enumeration time; an unreachable service still lets the tool register + and produce a clean error at call time. We just verify the URL parses.""" + try: + url = _service_url() + return url.startswith("http://") or url.startswith("https://") + except Exception: + return False + + +# --------------------------------------------------------------------------- +# Registration +# --------------------------------------------------------------------------- + +# AST check in tools/registry.py only recognizes `registry.register(...)` +# at module scope, not inside loops or conditionals. +registry.register( + name="embodied_plan", + toolset="embodiment", + schema=EMBODIED_PLAN_SCHEMA, + handler=_handler, + check_fn=_check_service_available, + emoji="🤖", + description=EMBODIED_PLAN_SCHEMA["function"]["description"], +) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 9b9ceb6830e0..9dd074178c7d 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -102,6 +102,7 @@ export interface UiState { compact: boolean detailsMode: DetailsMode detailsModeCommandOverride: boolean + historyNavRequiresEmptyInput: boolean info: null | SessionInfo inlineDiffs: boolean mouseTracking: boolean diff --git a/ui-tui/src/app/uiStore.ts b/ui-tui/src/app/uiStore.ts index ea592700b774..b0dcd8b1bc0b 100644 --- a/ui-tui/src/app/uiStore.ts +++ b/ui-tui/src/app/uiStore.ts @@ -13,6 +13,7 @@ const buildUiState = (): UiState => ({ compact: false, detailsMode: 'collapsed', detailsModeCommandOverride: false, + historyNavRequiresEmptyInput: false, indicatorStyle: DEFAULT_INDICATOR_STYLE, info: null, inlineDiffs: true, diff --git a/ui-tui/src/app/useConfigSync.ts b/ui-tui/src/app/useConfigSync.ts index b0e590ee2c2c..ed85f1b4feba 100644 --- a/ui-tui/src/app/useConfigSync.ts +++ b/ui-tui/src/app/useConfigSync.ts @@ -123,6 +123,7 @@ export const applyDisplay = ( setVoiceRecordKey?: (v: ParsedVoiceRecordKey) => void ) => { const d = cfg?.config?.display ?? {} + const t = cfg?.config?.tui ?? {} setBell(!!d.bell_on_complete) // Only push the voice record key when the RPC actually returned a @@ -140,6 +141,7 @@ export const applyDisplay = ( compact: !!d.tui_compact, detailsMode: resolveDetailsMode(d), detailsModeCommandOverride: false, + historyNavRequiresEmptyInput: !!t.history_nav_requires_empty_input, indicatorStyle: normalizeIndicatorStyle(d.tui_status_indicator), inlineDiffs: d.inline_diffs !== false, mouseTracking: normalizeMouseTracking(d), diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index ce25af70edde..7b1c6fa255ca 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -382,6 +382,10 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { !cState.input || (cursor !== null && cState.input.lastIndexOf('\n', Math.max(0, cursor - 1)) < 0) if (noLineAbove) { + if (getUiState().historyNavRequiresEmptyInput && cState.input) { + return + } + cycleQueue(1) || cycleHistory(-1) return @@ -393,7 +397,17 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { const cursor = inputSel && inputSel.start === inputSel.end ? inputSel.start : null const noLineBelow = !cState.input || (cursor !== null && cState.input.indexOf('\n', cursor) < 0) - if (noLineBelow || cState.historyIdx !== null) { + if (cState.historyIdx !== null) { + cycleQueue(-1) || cycleHistory(1) + + return + } + + if (noLineBelow) { + if (getUiState().historyNavRequiresEmptyInput && cState.input) { + return + } + cycleQueue(-1) || cycleHistory(1) return diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 8c5cb18b23d8..0a3a916e37c7 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -81,8 +81,12 @@ export interface ConfigVoiceConfig { record_key?: unknown } +export interface ConfigTuiConfig { + history_nav_requires_empty_input?: boolean +} + export interface ConfigFullResponse { - config?: { display?: ConfigDisplayConfig; voice?: ConfigVoiceConfig } + config?: { display?: ConfigDisplayConfig; voice?: ConfigVoiceConfig; tui?: ConfigTuiConfig } } export interface ConfigMtimeResponse { diff --git a/website/docs/developer-guide/context-compression-and-caching.md b/website/docs/developer-guide/context-compression-and-caching.md index 5c6268bbce78..da37937eda38 100644 --- a/website/docs/developer-guide/context-compression-and-caching.md +++ b/website/docs/developer-guide/context-compression-and-caching.md @@ -83,7 +83,11 @@ compression: enabled: true # Enable/disable compression (default: true) threshold: 0.50 # Fraction of context window (default: 0.50 = 50%) target_ratio: 0.20 # How much of threshold to keep as tail (default: 0.20) + protect_first_n: 3 # Messages from start to keep uncompressed (default: 3) protect_last_n: 20 # Minimum protected tail messages (default: 20) + prompt: + preamble: "" # Optional custom summarizer preamble (empty = default) + template: "" # Optional custom summary template (empty = default) # Summarization model/provider configured under auxiliary: auxiliary: @@ -99,8 +103,10 @@ auxiliary: |-----------|---------|-------|-------------| | `threshold` | `0.50` | 0.0-1.0 | Compression triggers when prompt tokens ≥ `threshold × context_length` | | `target_ratio` | `0.20` | 0.10-0.80 | Controls tail protection token budget: `threshold_tokens × target_ratio` | +| `protect_first_n` | `3` | ≥0 | Messages from start to keep uncompressed. 0 = summarize everything into summary + tail. | | `protect_last_n` | `20` | ≥1 | Minimum number of recent messages always preserved | -| `protect_first_n` | `3` | (hardcoded) | System prompt + first exchange always preserved | +| `prompt.preamble` | `""` | string | Optional override for the summarizer preamble (empty = default) | +| `prompt.template` | `""` | string | Optional override for summary template (empty = default). Use `{summary_budget}` placeholder. | ### Computed Values (for a 200K context model at defaults) @@ -129,14 +135,14 @@ outputs (file contents, terminal output, search results). ### Phase 2: Determine Boundaries ``` -┌─────────────────────────────────────────────────────────────┐ +┌──────────────────────────────────────────────────┐ │ Message list │ -│ │ -│ [0..2] ← protect_first_n (system + first exchange) │ -│ [3..N] ← middle turns → SUMMARIZED │ -│ [N..end] ← tail (by token budget OR protect_last_n) │ -│ │ -└─────────────────────────────────────────────────────────────┘ +│ │ +│ [0..first_n-1] ← protect_first_n (system + first exchange)│ +│ [first_n..N] ← middle turns → SUMMARIZED │ +│ [N..end] ← tail (by token budget OR protect_last_n) │ +│ │ +└──────────────────────────────────────────────────┘ ``` Tail protection is **token-budget based**: walks backward from the end, diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 9d7208883b79..6907f6f68779 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -568,7 +568,11 @@ compression: enabled: true threshold: 0.50 target_ratio: 0.20 # fraction of threshold to preserve as recent tail + protect_first_n: 3 # messages from start to keep (0 = summarize everything) protect_last_n: 20 # minimum recent messages to keep uncompressed + prompt: + preamble: "" # optional custom summarizer preamble + template: "" # optional custom summary template ``` :::info Legacy migration diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index ed94dfb0ed73..16d41a3e749a 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -604,8 +604,12 @@ compression: enabled: true # Toggle compression on/off threshold: 0.50 # Compress at this % of context limit target_ratio: 0.20 # Fraction of threshold to preserve as recent tail + protect_first_n: 3 # Messages from start to keep (0 = summarize everything) protect_last_n: 20 # Min recent messages to keep uncompressed hygiene_hard_message_limit: 400 # Gateway safety valve — see below + prompt: + preamble: "" # Optional custom summarizer preamble + template: "" # Optional custom summary template # The summarization model/provider is configured under auxiliary: auxiliary: diff --git a/website/docs/user-guide/features/kanban-ship-review.md b/website/docs/user-guide/features/kanban-ship-review.md new file mode 100644 index 000000000000..5890b259bd8a --- /dev/null +++ b/website/docs/user-guide/features/kanban-ship-review.md @@ -0,0 +1,145 @@ +--- +sidebar_position: 13 +title: "Ship Review (kanban review)" +description: "Create durable review graphs for code changes with safe triage, ready dispatch, and REVIEW-ONLY contracts" +--- + +# Ship Review — Kanban Review Graphs + +`hermes kanban review create` builds a durable 5-card review graph for any git change. It replaces ad-hoc "hey can someone review this?" messages with a structured, tracked, and replayable workflow. + +## The graph shape + +``` +Parent review card (organisational umbrella) +├─ [REVIEW] Code quality ─┐ +├─ [REVIEW] Security │ parallel reviewers +└─ [REVIEW] Test coverage ─┘ + │ + ▼ +[SYNTHESIS] GO/NO-GO decision ← gated on all three reviewers +``` + +1. **Parent card** — holds the base..head context and the REVIEW-ONLY contract. +2. **Three reviewers** — run in parallel, each with a role-specific checklist. +3. **Synthesis** — auto-promotes to `ready` once all reviewers finish. It reads their handoffs and produces a GO/NO-GO decision. + +## Safe triage by default + +By default every card is created in `triage`: + +```bash +hermes kanban review create "Review PR #42" \ + --base nousmain \ + --head feat/auth \ + --repo /home/me/Projects/myapp \ + --assignee miki +``` + +Nothing dispatches until a human explicitly promotes cards. This is the safe pattern for reviews that need scheduling or human triage. + +## Ready dispatch + +If you want the reviewers to start immediately: + +```bash +hermes kanban review create "Review PR #42" \ + --base nousmain \ + --head feat/auth \ + --repo /home/me/Projects/myapp \ + --assignee miki \ + --ready +``` + +With `--ready`: +- Parent + reviewer cards start in `ready` (dispatcher picks them up on next tick). +- Synthesis card starts in `todo` because its parents (the reviewers) are not yet `done`. +- As each reviewer completes, `kanban_db` auto-runs `recompute_ready`. +- When the third reviewer finishes, the synthesis auto-promotes from `todo` → `ready`. + +## Local Miki example + +A concrete invocation on the Hermes repo itself, using `--json` for scripting: + +```bash +hermes kanban review create "Ship kanban review orchestration" \ + --base nousmain \ + --head feat/kanban-ship-review-orchestration \ + --repo /home/nicolas/Projects/hermes-agent \ + --assignee miki \ + --triage \ + --json +``` + +Output: +```json +{ + "parent_id": "t_a1b2c3d4", + "reviewer_ids": ["t_e5f6g7h8", "t_i9j0k1l2", "t_m3n4o5p6"], + "synthesis_id": "t_q7r8s9t0", + "created": true +} +``` + +Rerun the same command and you get the **same IDs** — the graph is idempotent by `sha256(repo realpath) + base + head + role`. + +## Review-only limitation + +Every generated body contains a **REVIEW-ONLY v1** contract: + +> Do NOT modify source code. Report findings as structured metadata only. + +This is intentional. Reviewer workers are scoped to read, analyse, and report. They do not patch, commit, or push. If a reviewer finds a bug, it records the finding in `kanban_complete(metadata={"findings": [...]})` and the synthesis task decides whether to spawn a separate remediation task. + +The contract exists because: +- **Auditability** — a review that silently fixes its own findings is indistinguishable from a no-op. +- **Separation of concerns** — reviewers judge; other agents (or humans) remediate. +- **Safety** — a reviewer with write access could introduce new issues while fixing old ones, especially when running autonomously. + +## JSON CLI output + +Pass `--json` to get machine-readable output: + +```bash +hermes kanban review create "Review PR #42" \ + --base main --head feat/x --repo . --json +``` + +Keys: +- `parent_id` — the organisational umbrella card +- `reviewer_ids` — list of 3 reviewer task ids +- `synthesis_id` — the synthesis task id +- `created` — `true` if new cards were created, `false` if all existed already + +## Idempotency + +The graph is keyed by the **resolved repo path** + **base** + **head** + **role**. Changing any of `base`, `head`, or the absolute repo path creates a new graph. Moving the repo directory (e.g., symlinks that resolve differently) also creates a new graph — use stable absolute paths in automation. + +## Skills + +Attach skills to every card with `--skill` (repeatable): + +```bash +hermes kanban review create "Review auth PR" \ + --base main --head feat/auth --repo . \ + --assignee reviewer \ + --skill github-code-review \ + --skill security-pr-audit +``` + +These are force-loaded into the worker alongside the built-in `kanban-worker` skill. + +## Body templates + +Each role gets a hardened body with: +- Exact `git diff` commands to run +- Severity labels (**Critical**, **Important**, **Optional/Nit**) +- Role-specific checklist (code-quality, security, test-coverage) +- `kanban_complete` / `kanban_block` contract with expected metadata shape + +The synthesis body expects: +- GO/NO-GO decision with rationale +- Blockers, recommended fixes, acknowledged risks +- Rollback plan and evidence reviewed + +Bodies are self-contained — a worker can execute the review without conversation history or external context.