From 3513ea7b4af0871a249bf05581a0e7e9375e468b Mon Sep 17 00:00:00 2001 From: luluthehungrycat Date: Wed, 15 Jul 2026 14:32:51 +0200 Subject: [PATCH 1/2] feat(tools): add per-call model/provider/profile overrides to delegate_task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three new optional parameters to the delegate_task tool that let the model (or a human caller) override the model, provider, and profile used for subagents on a per-call basis. - model: specify which model the subagent should use - provider: specify which provider serves the model (required with model) - profile: specify which Hermes profile's credentials to resolve from When model is provided, _resolve_per_call_credentials() reads the specified profile's .env and config.yaml to resolve the API key, base URL, and API mode — bypassing the delegation config block entirely. When model is omitted, the existing delegation config path is used unchanged. The new fields are exposed in the tool schema so the model sees them, and threaded through the registry handler into the delegate_task() function signature. --- tools/delegate_tool.py | 169 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index c8f6fa5293a9..51ef729e0af3 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,127 @@ 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. + """ + 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 +3598,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 +3687,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="🔀", From ba118504eb5a6bfa7344ee296e0b32cd18639a32 Mon Sep 17 00:00:00 2001 From: Hermee Date: Wed, 15 Jul 2026 14:57:46 +0200 Subject: [PATCH 2/2] feat(tools): add per-call model/provider/profile overrides to delegate_task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three new optional parameters (model, provider, profile) to the delegate_task tool that let the model override the model, provider, and profile used for subagents on a per-call basis. - model: specify which model the subagent should use - provider: optional — when omitted, child inherits parent's provider and credentials (model-only override) - profile: optional — when omitted, uses active profile's directories When model is provided, _resolve_per_call_credentials() reads the specified profile's .env and config.yaml to resolve the API key, base URL, and API mode, bypassing the delegation config block entirely. When model is omitted, the existing delegation config path is unchanged. Includes 6 unit tests covering: model-only override (no provider), model+provider resolution, credential dict shape compatibility, signature acceptance, and schema field exposure. --- tests/tools/test_delegate.py | 98 ++++++++++++++++++++++++++++++++++++ tools/delegate_tool.py | 11 ++++ 2 files changed, 109 insertions(+) diff --git a/tests/tools/test_delegate.py b/tests/tools/test_delegate.py index 0fe6e0028eb6..4e886606ce85 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 51ef729e0af3..4cf61877093a 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -3208,6 +3208,17 @@ def _resolve_per_call_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