diff --git a/tests/tools/test_delegate.py b/tests/tools/test_delegate.py index 0fe6e0028eb6c..4e886606ce856 100644 --- a/tests/tools/test_delegate.py +++ b/tests/tools/test_delegate.py @@ -3157,3 +3157,101 @@ def test_child_gets_no_fallback_when_parent_chain_empty(self): if __name__ == "__main__": unittest.main() + + +class TestPerCallCredentialOverride(unittest.TestCase): + """Tests for _resolve_per_call_credentials and the per-call override path.""" + + def test_resolve_model_only_no_provider(self): + """When provider is None, _resolve_per_call_credentials returns model-only + with None providers/credentials so _build_child_agent inherits parent.""" + from tools.delegate_tool import _resolve_per_call_credentials + + result = _resolve_per_call_credentials(model="deepseek-v4-flash") + self.assertEqual(result["model"], "deepseek-v4-flash") + self.assertIsNone(result["provider"]) + self.assertIsNone(result["base_url"]) + self.assertIsNone(result["api_key"]) + self.assertIsNone(result["api_mode"]) + + def test_resolve_model_with_provider_no_profile(self): + """When provider is set but profile is None, the function attempts + to resolve from the default profile directories and returns whatever + it finds (or None values if nothing is configured).""" + from tools.delegate_tool import _resolve_per_call_credentials + + result = _resolve_per_call_credentials( + model="deepseek-v4-flash", + provider="opencode-go", + ) + self.assertEqual(result["model"], "deepseek-v4-flash") + self.assertEqual(result["provider"], "opencode-go") + # base_url/api_key/api_mode may be None if not configured — that's fine + + def test_resolve_model_with_provider_explicitly(self): + """Passing both model and provider should resolve the provider.""" + from tools.delegate_tool import _resolve_per_call_credentials + + result = _resolve_per_call_credentials( + model="mimo-v2-5-pro", + provider="opencode-go", + profile="default", + ) + self.assertEqual(result["model"], "mimo-v2-5-pro") + self.assertEqual(result["provider"], "opencode-go") + + def test_resolve_returns_dict_compatible_with_delegation_creds(self): + """The return shape must have all keys that _build_child_agent expects.""" + from tools.delegate_tool import _resolve_per_call_credentials + + result = _resolve_per_call_credentials( + model="test-model", + provider="test-provider", + ) + required_keys = { + "model", "provider", "base_url", "api_key", "api_mode", + "request_overrides", "max_output_tokens", "command", "args", + } + self.assertEqual(set(result.keys()), required_keys) + + def test_resolve_nonexistent_profile_falls_back(self): + """When the named profile doesn't exist, _resolve_per_call_credentials + should still work — returning None values from the default profile + rather than crashing.""" + from tools.delegate_tool import _resolve_per_call_credentials + + result = _resolve_per_call_credentials( + model="test-model", + provider="opencode-go", + profile="nonexistent-profile-that-will-never-exist-12345", + ) + self.assertEqual(result["model"], "test-model") + self.assertEqual(result["provider"], "opencode-go") + # base_url/api_key/api_mode will be None since the profile doesn't + # have a config.yaml / .env — graceful degradation, not a crash. + + def test_empty_model_is_not_an_override(self): + """An empty string for model should not trigger the override path. + The guard ``if model:`` in delegate_task() handles this — empty string + is falsy, so the existing delegation config path runs unchanged.""" + from tools.delegate_tool import _resolve_per_call_credentials + + result = _resolve_per_call_credentials(model="") + self.assertEqual(result["model"], "") + + def test_delegate_task_accepts_model_param(self): + """delegate_task must accept 'model' in its signature.""" + import inspect + from tools.delegate_tool import delegate_task + + sig = inspect.signature(delegate_task) + self.assertIn("model", sig.parameters) + self.assertIn("provider", sig.parameters) + self.assertIn("profile", sig.parameters) + + def test_DELEGATE_TASK_SCHEMA_has_model_fields(self): + """The static schema must advertise model/provider/profile.""" + schema_props = DELEGATE_TASK_SCHEMA["parameters"]["properties"] + self.assertIn("model", schema_props) + self.assertIn("provider", schema_props) + self.assertIn("profile", schema_props) diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index c8f6fa5293a9a..4cf61877093ad 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -2374,6 +2374,9 @@ def delegate_task( role: Optional[str] = None, background: Optional[bool] = None, parent_agent=None, + model: Optional[str] = None, + provider: Optional[str] = None, + profile: Optional[str] = None, ) -> str: """ Spawn one or more child agents to handle delegated tasks. @@ -2454,6 +2457,19 @@ def delegate_task( except ValueError as exc: return tool_error(str(exc)) + # Per-call model/provider/profile override — applied before task building + # so all children in a batch inherit the same override. When model is set, + # resolves credentials from the specified profile's .env and config.yaml, + # bypassing the delegation config block entirely. + # provider defaults to None so _build_child_agent inherits from the parent. + if model: + per_call_creds = _resolve_per_call_credentials( + model=model, + provider=provider, + profile=profile, + ) + creds = per_call_creds + # Normalize to task list max_children = _get_max_concurrent_children() recovered_tasks, tasks_error = _recover_tasks_from_json_string(tasks) @@ -3166,6 +3182,138 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict: } +# --------------------------------------------------------------------------- +# Per-call credential resolution (model/provider/profile overrides) +# --------------------------------------------------------------------------- +def _resolve_per_call_credentials( + model: str, + provider: Optional[str] = None, + profile: Optional[str] = None, +) -> dict: + """Resolve credentials for a per-call model/provider override. + + Reads the specified profile's ``.env`` and ``config.yaml`` to find the + API key, base URL, and API mode for the given provider. Falls back to + the current session's environment variables when profile-specific files + don't exist. + + When provider is None, returns only the model (no credential override) + so _build_child_agent inherits the parent's provider and credentials. + When profile is None, defaults to the active profile's directories. + + When called from within a running Hermes session without explicit + credentials, returns the model/provider as-is so the child inherits + the parent's credentials. + + Returns a dict: {model, provider, base_url, api_key, api_mode} + Compatible with the shape returned by _resolve_delegation_credentials + so the caller can swap the result in. + + Examples: + >>> _resolve_per_call_credentials(model="deepseek-v4-flash") + {...} # model-only override, inherits parent's provider + + >>> _resolve_per_call_credentials( + ... model="mimo-v2-5-pro", + ... provider="opencode-go", + ... profile="coder", + ... ) + {...} # full override: model, provider, and profile credentials + """ + from pathlib import Path + + hermes_home = Path.home() / ".hermes" + + # Determine profile directory + if profile and profile != "default": + profile_dir = hermes_home / "profiles" / profile + else: + profile_dir = hermes_home + + # If no provider is specified, return the model override only — the child + # inherits the parent's provider, credentials, and config. + if not provider: + return { + "model": model, + "provider": None, + "base_url": None, + "api_key": None, + "api_mode": None, + "request_overrides": None, + "max_output_tokens": None, + "command": None, + "args": None, + } + + # Map provider names to expected env var names + provider_key_map = { + "opencode-go": "OPENCODE_GO_API_KEY", + "opencode-zen": "OPENCODE_ZEN_API_KEY", + "openrouter": "OPENROUTER_API_KEY", + "deepseek": "DEEPSEEK_API_KEY", + "anthropic": "ANTHROPIC_API_KEY", + "openai": "OPENAI_API_KEY", + "minimax": "MINIMAX_API_KEY", + "kimi": "KIMI_API_KEY", + "xiaomi": "XIAOMI_API_KEY", + "dashscope": "DASHSCOPE_API_KEY", + "github": "GITHUB_API_KEY", + "google": "GOOGLE_API_KEY", + "mistral": "MISTRAL_API_KEY", + "groq": "GROQ_API_KEY", + "together": "TOGETHER_API_KEY", + } + + api_key_var = provider_key_map.get(provider, f"{provider.upper()}_API_KEY") # provider is non-None here (early return above) + + # Read env from .env files (profile first, then global) + env_vars = {} + for env_path in [profile_dir / ".env", hermes_home / ".env"]: + if env_path.exists(): + try: + with open(env_path) as f: + for line in f: + line = line.strip() + if "=" in line and not line.startswith("#"): + key, _, val = line.partition("=") + env_vars[key.strip()] = val.strip().strip("'\"") + except OSError: + pass + + api_key = env_vars.get(api_key_var, "") or os.environ.get(api_key_var, "") + + # Read config for base_url and api_mode + base_url = "" + api_mode = "" + config_path = profile_dir / "config.yaml" + if config_path.exists(): + try: + import yaml + + with open(config_path) as f: + cfg = yaml.safe_load(f) or {} + + providers_cfg = cfg.get("providers", {}) or {} + provider_cfg = providers_cfg.get(provider, {}) or {} + if isinstance(provider_cfg, dict): + base_url = provider_cfg.get("base_url", "") or "" + api_mode = provider_cfg.get("api_mode", "") or "" + except Exception: + pass + + return { + "model": model, + "provider": provider, + "base_url": base_url or None, + "api_key": api_key or None, + "api_mode": api_mode or None, + "request_overrides": None, + "max_output_tokens": None, + "command": None, + "args": None, + } + + def _load_config() -> dict: """Load delegation config from the active Hermes config. @@ -3461,6 +3609,35 @@ def _build_dynamic_schema_overrides() -> dict: "compatibility." ), }, + "model": { + "type": "string", + "description": ( + "Optional model override for this subagent. " + "When set, the subagent uses this model instead of inheriting " + "the parent's model or the delegation config. " + "Examples: deepseek-v4-flash, mimo-v2-5-pro, qwen-3.7-plus, minimax-m3. " + "When used without 'provider', the child inherits the parent's " + "provider and credentials (model-only override)." + ), + }, + "provider": { + "type": "string", + "description": ( + "Optional provider override for this subagent. " + "When set together with 'model', specifies which " + "provider serves the model. Examples: opencode-go, " + "openrouter, deepseek, anthropic." + ), + }, + "profile": { + "type": "string", + "description": ( + "Optional Hermes profile used for credential resolution. " + "When set, the subagent reads API keys and config from the " + "specified profile's .env and config.yaml. " + "Examples: default, coder, dr-k." + ), + }, }, "required": [], }, @@ -3521,6 +3698,9 @@ def _strip_model_hidden_task_fields(tasks: Any) -> Any: role=args.get("role"), background=_model_background_value(args, kw.get("parent_agent")), parent_agent=kw.get("parent_agent"), + model=args.get("model"), + provider=args.get("provider"), + profile=args.get("profile"), ), check_fn=check_delegate_requirements, emoji="🔀",