Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions tests/tools/test_delegate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
180 changes: 180 additions & 0 deletions tools/delegate_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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": [],
},
Expand Down Expand Up @@ -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="🔀",
Expand Down