From 2506e688d343eaf1f62b0be5f614a6568e52f943 Mon Sep 17 00:00:00 2001 From: Gleb Panov <5999832+gutleib@users.noreply.github.com> Date: Thu, 28 May 2026 23:46:48 +0300 Subject: [PATCH 01/14] feat(delegate): add _resolve_profile() for delegation.profiles config lookup --- tests/tools/test_delegate_profiles.py | 97 +++++++++++++++++++++++++++ tools/delegate_tool.py | 15 +++++ 2 files changed, 112 insertions(+) create mode 100644 tests/tools/test_delegate_profiles.py diff --git a/tests/tools/test_delegate_profiles.py b/tests/tools/test_delegate_profiles.py new file mode 100644 index 0000000000000..6d8682561b9ff --- /dev/null +++ b/tests/tools/test_delegate_profiles.py @@ -0,0 +1,97 @@ +"""Tests for delegate_task profile support.""" +import json +import threading +from unittest.mock import MagicMock, patch + +import pytest + +from tools.delegate_tool import ( + _resolve_profile, + _build_child_system_prompt, + delegate_task, + DELEGATE_TASK_SCHEMA, +) + + +def _make_mock_parent(depth=0): + parent = MagicMock() + parent.base_url = "https://openrouter.ai/api/v1" + parent.api_key = "sk-test" + parent.provider = "openrouter" + parent.api_mode = "chat_completions" + parent.model = "anthropic/claude-sonnet-4" + parent.platform = "cli" + parent.providers_allowed = None + parent.providers_ignored = None + parent.providers_order = None + parent.provider_sort = None + parent._session_db = None + parent._delegate_depth = depth + parent._active_children = [] + parent._active_children_lock = threading.Lock() + parent._print_fn = None + parent.tool_progress_callback = None + parent.thinking_callback = None + parent.enabled_toolsets = ["terminal", "file", "web"] + return parent + + +_PROFILE_CFG = { + "profiles": { + "coder": { + "nickname": "👷 Coder", + "summary": "Writes code with TDD", + "model": "deepseek-v4-flash", + "provider": "deepseek", + "toolsets": ["terminal", "file"], + "system_prompt": "You are a coder.", + "constraints": "- Tests before code", + }, + "critic": { + "nickname": "🔍 Critic", + "summary": "Code review", + "model": "claude-sonnet-4-20250514", + "provider": "custom", + "base_url": "https://api.anthropic.com/v1", + "api_mode": "anthropic_messages", + "proxy": "http://localhost:8119", + "toolsets": ["file"], + "system_prompt": "You are a reviewer.", + "constraints": "- No code writing", + }, + "copilot-runner": { + "nickname": "🤖 Copilot", + "summary": "Runs via Copilot ACP", + "acp_command": "copilot", + "acp_args": ["--model", "claude-sonnet-4-5"], + "toolsets": ["terminal", "file"], + "system_prompt": "You are a copilot agent.", + }, + } +} + + +class TestResolveProfile: + @patch("tools.delegate_tool._load_config", return_value=_PROFILE_CFG) + def test_known_profile_returns_dict(self, _mock_cfg): + result = _resolve_profile("coder") + assert result["model"] == "deepseek-v4-flash" + assert result["provider"] == "deepseek" + + @patch("tools.delegate_tool._load_config", return_value=_PROFILE_CFG) + def test_unknown_profile_raises_valueerror(self, _mock_cfg): + with pytest.raises(ValueError, match="Unknown profile 'typo'"): + _resolve_profile("typo") + + @patch("tools.delegate_tool._load_config", return_value=_PROFILE_CFG) + def test_error_message_lists_available_profiles(self, _mock_cfg): + with pytest.raises(ValueError) as exc_info: + _resolve_profile("typo") + msg = str(exc_info.value) + assert "coder" in msg + assert "critic" in msg + + @patch("tools.delegate_tool._load_config", return_value={}) + def test_no_profiles_section_raises_with_none_configured(self, _mock_cfg): + with pytest.raises(ValueError, match="none configured"): + _resolve_profile("coder") diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 86dcd0715cc9c..b82958a040b1a 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -2481,6 +2481,21 @@ def _load_config() -> dict: return {} +def _resolve_profile(name: str) -> dict: + """Return the named profile dict from delegation.profiles config. + + Raises ValueError with available profile names if not found. + """ + profiles = _load_config().get("profiles", {}) + if name not in profiles: + available = sorted(profiles.keys()) + raise ValueError( + f"Unknown profile '{name}'. " + f"Available: {available if available else '(none configured)'}" + ) + return profiles[name] + + # --------------------------------------------------------------------------- # OpenAI Function-Calling Schema # --------------------------------------------------------------------------- From eefde5c8feaa49b8fc3a0dae16d767e9ac64070c Mon Sep 17 00:00:00 2001 From: Gleb Panov <5999832+gutleib@users.noreply.github.com> Date: Thu, 28 May 2026 23:51:05 +0300 Subject: [PATCH 02/14] chore(test): remove unused json import from test_delegate_profiles --- tests/tools/test_delegate_profiles.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/tools/test_delegate_profiles.py b/tests/tools/test_delegate_profiles.py index 6d8682561b9ff..79e4e12e1c513 100644 --- a/tests/tools/test_delegate_profiles.py +++ b/tests/tools/test_delegate_profiles.py @@ -1,5 +1,4 @@ """Tests for delegate_task profile support.""" -import json import threading from unittest.mock import MagicMock, patch From 8d1bd214dad89dc0125f301f3b19986c727a0295 Mon Sep 17 00:00:00 2001 From: Gleb Panov <5999832+gutleib@users.noreply.github.com> Date: Thu, 28 May 2026 23:53:19 +0300 Subject: [PATCH 03/14] feat(delegate): profile_system_prompt replaces first line, profile_constraints added before task instruction --- tests/tools/test_delegate_profiles.py | 49 +++++++++++++++++++++++++++ tools/delegate_tool.py | 5 +++ 2 files changed, 54 insertions(+) diff --git a/tests/tools/test_delegate_profiles.py b/tests/tools/test_delegate_profiles.py index 79e4e12e1c513..4dca5ca53dd83 100644 --- a/tests/tools/test_delegate_profiles.py +++ b/tests/tools/test_delegate_profiles.py @@ -94,3 +94,52 @@ def test_error_message_lists_available_profiles(self, _mock_cfg): def test_no_profiles_section_raises_with_none_configured(self, _mock_cfg): with pytest.raises(ValueError, match="none configured"): _resolve_profile("coder") + + +class TestBuildChildSystemPrompt: + def test_default_prompt_starts_with_focused_subagent(self): + result = _build_child_system_prompt("Do something") + assert result.startswith("You are a focused subagent") + + def test_profile_system_prompt_replaces_first_line(self): + result = _build_child_system_prompt( + "Do something", + profile_system_prompt="You are a coder.", + ) + assert result.startswith("You are a coder.") + assert "focused subagent" not in result + + def test_profile_system_prompt_does_not_duplicate(self): + result = _build_child_system_prompt( + "Do something", + profile_system_prompt="You are a coder.", + ) + assert result.count("You are a coder.") == 1 + + def test_profile_constraints_appear_before_complete_instruction(self): + result = _build_child_system_prompt( + "Do something", + profile_constraints="- Tests before code\n- No side effects", + ) + constraints_pos = result.index("CONSTRAINTS:") + complete_pos = result.index("Complete this task") + assert constraints_pos < complete_pos + + def test_profile_constraints_section_header(self): + result = _build_child_system_prompt( + "Do something", + profile_constraints="- No side effects", + ) + assert "CONSTRAINTS:\n- No side effects" in result + + def test_no_constraints_no_section(self): + result = _build_child_system_prompt("Do something") + assert "CONSTRAINTS:" not in result + + def test_goal_always_present(self): + result = _build_child_system_prompt( + "Build login", + profile_system_prompt="You are a coder.", + profile_constraints="- TDD only", + ) + assert "YOUR TASK:\nBuild login" in result diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index b82958a040b1a..64c4b4b5cb7f2 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -574,6 +574,8 @@ def _build_child_system_prompt( role: str = "leaf", max_spawn_depth: int = 2, child_depth: int = 1, + profile_system_prompt: Optional[str] = None, + profile_constraints: Optional[str] = None, ) -> str: """Build a focused system prompt for a child agent. @@ -584,6 +586,7 @@ def _build_child_system_prompt( the LLM doesn't confabulate nesting capabilities that don't exist. """ parts = [ + profile_system_prompt.strip() if profile_system_prompt else "You are a focused subagent working on a specific delegated task.", "", f"YOUR TASK:\n{goal}", @@ -596,6 +599,8 @@ def _build_child_system_prompt( f"{workspace_path}\n" "Use this exact path for local repository/workdir operations unless the task explicitly says otherwise." ) + if profile_constraints and profile_constraints.strip(): + parts.append(f"\nCONSTRAINTS:\n{profile_constraints.strip()}") parts.append( "\nComplete this task using the tools available to you. " "When finished, provide a clear, concise summary of:\n" From 22927c4a72604adffc298301b6ad0a22ee2a65d4 Mon Sep 17 00:00:00 2001 From: Gleb Panov <5999832+gutleib@users.noreply.github.com> Date: Thu, 28 May 2026 23:56:53 +0300 Subject: [PATCH 04/14] fix(delegate): guard whitespace-only profile_system_prompt to prevent empty persona line --- tests/tools/test_delegate_profiles.py | 4 ++++ tools/delegate_tool.py | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/tools/test_delegate_profiles.py b/tests/tools/test_delegate_profiles.py index 4dca5ca53dd83..e2a90c7ed9be6 100644 --- a/tests/tools/test_delegate_profiles.py +++ b/tests/tools/test_delegate_profiles.py @@ -143,3 +143,7 @@ def test_goal_always_present(self): profile_constraints="- TDD only", ) assert "YOUR TASK:\nBuild login" in result + + def test_whitespace_only_profile_system_prompt_falls_back_to_default(self): + result = _build_child_system_prompt("Do something", profile_system_prompt=" ") + assert result.startswith("You are a focused subagent") diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 64c4b4b5cb7f2..acf9bf54c2ad6 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -586,8 +586,8 @@ def _build_child_system_prompt( the LLM doesn't confabulate nesting capabilities that don't exist. """ parts = [ - profile_system_prompt.strip() if profile_system_prompt else - "You are a focused subagent working on a specific delegated task.", + profile_system_prompt.strip() if (profile_system_prompt and profile_system_prompt.strip()) + else "You are a focused subagent working on a specific delegated task.", "", f"YOUR TASK:\n{goal}", ] From 5f91f03bd6bf74ec2a723a80443b984fcfcd0d4d Mon Sep 17 00:00:00 2001 From: Gleb Panov <5999832+gutleib@users.noreply.github.com> Date: Thu, 28 May 2026 23:59:32 +0300 Subject: [PATCH 05/14] feat(delegate): add override_proxy v1 stub to _build_child_agent; proxy=None stub to _build_keepalive_http_client --- run_agent.py | 5 ++- tests/tools/test_delegate_profiles.py | 56 +++++++++++++++++++++++++++ tools/delegate_tool.py | 17 ++++++++ 3 files changed, 77 insertions(+), 1 deletion(-) diff --git a/run_agent.py b/run_agent.py index 55df748a5a4b0..507a8152ac6fd 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2720,7 +2720,10 @@ def _is_openai_client_closed(client: Any) -> bool: return False @staticmethod - def _build_keepalive_http_client(base_url: str = "") -> Any: + def _build_keepalive_http_client(base_url: str = "", proxy: Optional[str] = None) -> Any: + # proxy param is a v2 stub. In v2, replace _get_proxy_for_base_url(base_url) + # with: proxy or _get_proxy_for_base_url(base_url) + # In v1 it is always None — existing env-based logic is unchanged. try: import httpx as _httpx import socket as _socket diff --git a/tests/tools/test_delegate_profiles.py b/tests/tools/test_delegate_profiles.py index e2a90c7ed9be6..46925af3ab3d9 100644 --- a/tests/tools/test_delegate_profiles.py +++ b/tests/tools/test_delegate_profiles.py @@ -147,3 +147,59 @@ def test_goal_always_present(self): def test_whitespace_only_profile_system_prompt_falls_back_to_default(self): result = _build_child_system_prompt("Do something", profile_system_prompt=" ") assert result.startswith("You are a focused subagent") + + +class TestBuildChildAgentProxyStub: + """override_proxy is accepted and logs a warning in v1.""" + + @patch("tools.delegate_tool._load_config", return_value={"max_iterations": 10}) + @patch("tools.delegate_tool.logger") + def test_override_proxy_warns_in_v1(self, mock_logger, _mock_cfg): + from tools.delegate_tool import _build_child_agent + parent = _make_mock_parent() + + with patch("run_agent.AIAgent") as MockAgent: + mock_child = MagicMock() + MockAgent.return_value = mock_child + _build_child_agent( + task_index=0, + goal="test", + context=None, + toolsets=["file"], + model="gpt-4o", + max_iterations=10, + task_count=1, + parent_agent=parent, + override_proxy="http://localhost:8119", + ) + mock_logger.warning.assert_called() + warning_text = " ".join( + str(a) for call in mock_logger.warning.call_args_list for a in call[0] + ) + assert "proxy" in warning_text.lower() + assert "v1" in warning_text.lower() + + @patch("tools.delegate_tool._load_config", return_value={"max_iterations": 10}) + def test_override_proxy_none_no_proxy_warning(self, _mock_cfg): + from tools.delegate_tool import _build_child_agent + parent = _make_mock_parent() + + with patch("run_agent.AIAgent") as MockAgent, \ + patch("tools.delegate_tool.logger") as mock_logger: + MockAgent.return_value = MagicMock() + _build_child_agent( + task_index=0, + goal="test", + context=None, + toolsets=["file"], + model="gpt-4o", + max_iterations=10, + task_count=1, + parent_agent=parent, + override_proxy=None, + ) + proxy_warnings = [ + call for call in mock_logger.warning.call_args_list + if "proxy" in str(call).lower() and "v1" in str(call).lower() + ] + assert len(proxy_warnings) == 0 diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index acf9bf54c2ad6..cb3a4911bfef9 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -893,6 +893,13 @@ def _build_child_agent( # 'leaf' (default) cannot; 'orchestrator' retains the delegation # toolset subject to depth/kill-switch bounds applied below. role: str = "leaf", + # v1 stub: stored for future async-proxy path (v2). + # When non-None, a warning is emitted and the proxy is NOT applied. + override_proxy: Optional[str] = None, + # Profile-supplied system prompt and constraints forwarded to + # _build_child_system_prompt. + profile_system_prompt: Optional[str] = None, + profile_constraints: Optional[str] = None, ): """ Build a child AIAgent on the main thread (thread-safe construction). @@ -906,6 +913,14 @@ def _build_child_agent( from run_agent import AIAgent import uuid as _uuid + # ── Proxy warning (v1 stub) ──────────────────────────────────────── + if override_proxy: + logger.warning( + "_build_child_agent: proxy='%s' not applied in v1 — " + "per-child proxy requires v2 async httpx.AsyncClient path", + override_proxy, + ) + # ── Role resolution ───────────────────────────────────────────────── # Honor the caller's role only when BOTH the kill switch and the # child's depth allow it. This is the single point where role @@ -980,6 +995,8 @@ def _build_child_agent( role=effective_role, max_spawn_depth=max_spawn, child_depth=child_depth, + profile_system_prompt=profile_system_prompt, + profile_constraints=profile_constraints, ) # Extract parent's API key so subagents inherit auth (e.g. Nous Portal). parent_api_key = getattr(parent_agent, "api_key", None) From c31905abebcbb560455b866d0d8964b8a39f7c59 Mon Sep 17 00:00:00 2001 From: Gleb Panov <5999832+gutleib@users.noreply.github.com> Date: Fri, 29 May 2026 00:12:14 +0300 Subject: [PATCH 06/14] =?UTF-8?q?feat(delegate):=20profile=20param=20?= =?UTF-8?q?=E2=80=94=20ACP/API=20routing,=20proxy=20stub=20pass-through,?= =?UTF-8?q?=20toolset=20priority,=20early=20batch=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/tools/test_delegate_profiles.py | 176 ++++++++++++++++++++++++++ tools/delegate_tool.py | 89 ++++++++++--- 2 files changed, 250 insertions(+), 15 deletions(-) diff --git a/tests/tools/test_delegate_profiles.py b/tests/tools/test_delegate_profiles.py index 46925af3ab3d9..e3d3d601cd075 100644 --- a/tests/tools/test_delegate_profiles.py +++ b/tests/tools/test_delegate_profiles.py @@ -1,4 +1,5 @@ """Tests for delegate_task profile support.""" +import json import threading from unittest.mock import MagicMock, patch @@ -203,3 +204,178 @@ def test_override_proxy_none_no_proxy_warning(self, _mock_cfg): if "proxy" in str(call).lower() and "v1" in str(call).lower() ] assert len(proxy_warnings) == 0 + + +def _mock_child(): + child = MagicMock() + child.run_conversation.return_value = { + "final_response": "done", "completed": True, "api_calls": 1 + } + return child + + +class TestDelegateTaskProfileRouting: + + @patch("tools.delegate_tool._load_config", return_value={"max_iterations": 10, **_PROFILE_CFG}) + @patch("tools.delegate_tool._resolve_delegation_credentials") + def test_profile_none_uses_default_creds(self, mock_creds, _mock_cfg): + """No profile → _resolve_delegation_credentials called with cfg dict.""" + mock_creds.return_value = { + "model": "gpt-4o", "provider": "openai", + "base_url": "https://api.openai.com/v1", + "api_key": "sk-test", "api_mode": "chat_completions", + } + parent = _make_mock_parent() + with patch("run_agent.AIAgent") as MockAgent: + MockAgent.return_value = _mock_child() + delegate_task(goal="Do work", parent_agent=parent) + mock_creds.assert_called_once() + + @patch("tools.delegate_tool._load_config", return_value={"max_iterations": 10, **_PROFILE_CFG}) + @patch("tools.delegate_tool._resolve_delegation_credentials") + def test_api_profile_passes_profile_cfg_to_creds(self, mock_creds, _mock_cfg): + """API profile → _resolve_delegation_credentials called with profile dict.""" + mock_creds.return_value = { + "model": "deepseek-v4-flash", "provider": "deepseek", + "base_url": "https://api.deepseek.com/v1", + "api_key": "sk-ds", "api_mode": "chat_completions", + } + parent = _make_mock_parent() + with patch("run_agent.AIAgent") as MockAgent: + MockAgent.return_value = _mock_child() + delegate_task(goal="Write code", profile="coder", parent_agent=parent) + + call_cfg = mock_creds.call_args[0][0] + assert call_cfg.get("model") == "deepseek-v4-flash" + assert call_cfg.get("provider") == "deepseek" + + @patch("tools.delegate_tool._load_config", return_value={"max_iterations": 10, **_PROFILE_CFG}) + @patch("tools.delegate_tool._resolve_delegation_credentials") + def test_acp_profile_uses_acp_command_not_api_creds(self, mock_creds, _mock_cfg): + """ACP profile → acp_command passed to AIAgent, no API creds lookup.""" + mock_creds.return_value = { + "model": None, "provider": None, + "base_url": None, "api_key": None, "api_mode": None, + } + parent = _make_mock_parent() + with patch("run_agent.AIAgent") as MockAgent: + MockAgent.return_value = _mock_child() + delegate_task(goal="Run via copilot", profile="copilot-runner", parent_agent=parent) + + _, kwargs = MockAgent.call_args + assert kwargs.get("acp_command") == "copilot" + assert kwargs.get("acp_args") == ["--model", "claude-sonnet-4-5"] + + @patch("tools.delegate_tool._load_config", return_value={"max_iterations": 10, **_PROFILE_CFG}) + def test_unknown_profile_returns_error_before_spawn(self, _mock_cfg): + parent = _make_mock_parent() + with patch("run_agent.AIAgent") as MockAgent: + result = delegate_task(goal="Work", profile="nonexistent", parent_agent=parent) + MockAgent.assert_not_called() + assert "nonexistent" in result + + @patch("tools.delegate_tool._load_config", return_value={"max_iterations": 10, **_PROFILE_CFG}) + def test_unknown_profile_in_batch_blocks_all_spawns(self, _mock_cfg): + """Invalid profile in one batch item → no children spawned at all.""" + parent = _make_mock_parent() + with patch("run_agent.AIAgent") as MockAgent: + result = delegate_task(tasks=[ + {"goal": "Task A", "profile": "coder"}, + {"goal": "Task B", "profile": "typo_profile"}, + ], parent_agent=parent) + MockAgent.assert_not_called() + assert "typo_profile" in result + + @patch("tools.delegate_tool._load_config", return_value={"max_iterations": 10, **_PROFILE_CFG}) + @patch("tools.delegate_tool._resolve_delegation_credentials") + def test_explicit_toolsets_beat_profile_toolsets(self, mock_creds, _mock_cfg): + """toolsets kwarg takes priority over profile.toolsets.""" + mock_creds.return_value = { + "model": "deepseek-v4-flash", "provider": "deepseek", + "base_url": "https://api.deepseek.com/v1", + "api_key": "sk-ds", "api_mode": "chat_completions", + } + parent = _make_mock_parent() + parent.enabled_toolsets = ["terminal", "file", "web"] + + with patch("run_agent.AIAgent") as MockAgent: + MockAgent.return_value = _mock_child() + delegate_task( + goal="Work", + profile="coder", # profile.toolsets = ["terminal", "file"] + toolsets=["web"], # explicit → should win + parent_agent=parent, + ) + _, kwargs = MockAgent.call_args + assert "web" in kwargs.get("enabled_toolsets", []) + + @patch("tools.delegate_tool._load_config", return_value={"max_iterations": 10, **_PROFILE_CFG}) + @patch("tools.delegate_tool._resolve_delegation_credentials") + def test_profile_toolsets_used_when_no_explicit(self, mock_creds, _mock_cfg): + """When no explicit toolsets, profile.toolsets are used (intersected with parent).""" + mock_creds.return_value = { + "model": "deepseek-v4-flash", "provider": "deepseek", + "base_url": "https://api.deepseek.com/v1", + "api_key": "sk-ds", "api_mode": "chat_completions", + } + parent = _make_mock_parent() + parent.enabled_toolsets = ["terminal", "file", "web"] + + with patch("run_agent.AIAgent") as MockAgent: + MockAgent.return_value = _mock_child() + delegate_task(goal="Work", profile="coder", parent_agent=parent) + + _, kwargs = MockAgent.call_args + enabled = kwargs.get("enabled_toolsets", []) + assert "terminal" in enabled + assert "file" in enabled + assert "web" not in enabled # coder profile doesn't include web + + @patch("tools.delegate_tool._load_config", return_value={"max_iterations": 10, **_PROFILE_CFG}) + @patch("tools.delegate_tool._resolve_delegation_credentials") + def test_proxy_in_profile_emits_warning(self, mock_creds, _mock_cfg): + """critic profile has proxy field — logger.warning must be called.""" + mock_creds.return_value = { + "model": "claude-sonnet-4-20250514", "provider": "custom", + "base_url": "https://api.anthropic.com/v1", + "api_key": "sk-ant", "api_mode": "anthropic_messages", + } + parent = _make_mock_parent() + parent.enabled_toolsets = ["terminal", "file", "web"] + + with patch("run_agent.AIAgent") as MockAgent, \ + patch("tools.delegate_tool.logger") as mock_logger: + MockAgent.return_value = _mock_child() + delegate_task(goal="Review code", profile="critic", parent_agent=parent) + + all_warnings = " ".join(str(c) for c in mock_logger.warning.call_args_list) + assert "proxy" in all_warnings.lower() + + @patch("tools.delegate_tool._load_config", return_value={"max_iterations": 10, **_PROFILE_CFG}) + @patch("tools.delegate_tool._resolve_delegation_credentials") + def test_batch_different_profiles_each_uses_own_cfg(self, mock_creds, _mock_cfg): + """Batch with two profiles → credentials resolved per-task with correct cfg.""" + call_cfgs = [] + + def capture_creds(cfg, parent): + call_cfgs.append(cfg) + return { + "model": cfg.get("model"), "provider": cfg.get("provider"), + "base_url": cfg.get("base_url", "https://example.com"), + "api_key": "sk-test", "api_mode": "chat_completions", + } + + mock_creds.side_effect = capture_creds + parent = _make_mock_parent() + parent.enabled_toolsets = ["terminal", "file", "web"] + + with patch("run_agent.AIAgent") as MockAgent: + MockAgent.return_value = _mock_child() + delegate_task(tasks=[ + {"goal": "Write code", "profile": "coder"}, + {"goal": "Review", "profile": "critic"}, + ], parent_agent=parent) + + models = [c.get("model") for c in call_cfgs] + assert "deepseek-v4-flash" in models + assert "claude-sonnet-4-20250514" in models diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index cb3a4911bfef9..c25bced0c87cc 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -1946,6 +1946,7 @@ def delegate_task( acp_command: Optional[str] = None, acp_args: Optional[List[str]] = None, role: Optional[str] = None, + profile: Optional[str] = None, parent_agent=None, ) -> str: """ @@ -2056,6 +2057,22 @@ def delegate_task( if not task.get("goal", "").strip(): return tool_error(f"Task {i} is missing a 'goal'.") + # Resolve and cache all profiles referenced in this call. + # Validate ALL profiles before spawning any children — a typo in one + # batch item must not leave other children dangling. + _profiles_cache: Dict[str, dict] = {} + _profile_names_to_check: set = set() + if profile: + _profile_names_to_check.add(profile) + for _t in task_list: + if _t.get("profile"): + _profile_names_to_check.add(_t["profile"]) + for _pname in _profile_names_to_check: + try: + _profiles_cache[_pname] = _resolve_profile(_pname) + except ValueError as exc: + return tool_error(str(exc)) + overall_start = time.monotonic() results = [] @@ -2076,31 +2093,72 @@ def delegate_task( children = [] try: for i, t in enumerate(task_list): - task_acp_args = t.get("acp_args") if "acp_args" in t else None # Per-task role beats top-level; normalise again so unknown # per-task values warn and degrade to leaf uniformly. effective_role = _normalize_role(t.get("role") or top_role) + + # Resolve per-task profile (per-task beats top-level) + _task_profile_name = t.get("profile") or profile + _profile_cfg = _profiles_cache.get(_task_profile_name, {}) if _task_profile_name else {} + + # ACP mode vs API mode — mutually exclusive. + # When profile defines acp_command, use ACP transport (no direct API creds). + if _profile_cfg.get("acp_command"): + _task_creds = { + "model": None, "provider": None, + "base_url": None, "api_key": None, "api_mode": None, + } + _task_acp_command = _profile_cfg["acp_command"] + _task_acp_args = ( + t.get("acp_args") if "acp_args" in t + else _profile_cfg.get("acp_args") + ) + else: + # API mode: resolve credentials from profile dict (if set) or + # fall back to default delegation config. + _cred_src = _profile_cfg if _profile_cfg else cfg + try: + _task_creds = _resolve_delegation_credentials(_cred_src, parent_agent) if _profile_cfg else creds + except ValueError as exc: + return tool_error(str(exc)) + _task_acp_args_explicit = t.get("acp_args") if "acp_args" in t else None + _task_acp_command = ( + t.get("acp_command") or acp_command or _task_creds.get("command") + ) + _task_acp_args = ( + _task_acp_args_explicit + if _task_acp_args_explicit is not None + else (acp_args if acp_args is not None else _task_creds.get("args")) + ) + + # Toolsets: explicit call arg > profile default > top-level toolsets arg + _task_toolsets = ( + t.get("toolsets") + or _profile_cfg.get("toolsets") + or toolsets + ) + + # Proxy from profile (v1: warning logged inside _build_child_agent, not applied) + _task_proxy = _profile_cfg.get("proxy") if _profile_cfg else None + child = _build_child_agent( task_index=i, goal=t["goal"], context=t.get("context"), - toolsets=t.get("toolsets") or toolsets, - model=creds["model"], + toolsets=_task_toolsets, + model=_task_creds["model"], max_iterations=effective_max_iter, task_count=n_tasks, parent_agent=parent_agent, - override_provider=creds["provider"], - override_base_url=creds["base_url"], - override_api_key=creds["api_key"], - override_api_mode=creds["api_mode"], - override_acp_command=t.get("acp_command") - or acp_command - or creds.get("command"), - override_acp_args=( - task_acp_args - if task_acp_args is not None - else (acp_args if acp_args is not None else creds.get("args")) - ), + override_provider=_task_creds["provider"], + override_base_url=_task_creds["base_url"], + override_api_key=_task_creds["api_key"], + override_api_mode=_task_creds["api_mode"], + override_acp_command=_task_acp_command, + override_acp_args=_task_acp_args, + override_proxy=_task_proxy, + profile_system_prompt=_profile_cfg.get("system_prompt"), + profile_constraints=_profile_cfg.get("constraints"), role=effective_role, ) # Override with correct parent tool names (before child construction mutated global) @@ -2830,6 +2888,7 @@ def _build_dynamic_schema_overrides() -> dict: acp_command=args.get("acp_command"), acp_args=args.get("acp_args"), role=args.get("role"), + profile=args.get("profile"), parent_agent=kw.get("parent_agent"), ), check_fn=check_delegate_requirements, From 85f969abe0d231f8aa316ba0c4b5d8e7e410a518 Mon Sep 17 00:00:00 2001 From: Gleb Panov <5999832+gutleib@users.noreply.github.com> Date: Fri, 29 May 2026 00:19:39 +0300 Subject: [PATCH 07/14] fix(delegate): forward profile in _dispatch_delegate_task; fix toolsets [] bug; cache per-profile creds --- run_agent.py | 1 + tools/delegate_tool.py | 32 ++++++++++++++++++++++++-------- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/run_agent.py b/run_agent.py index 507a8152ac6fd..772b09e83cbe3 100644 --- a/run_agent.py +++ b/run_agent.py @@ -4283,6 +4283,7 @@ def _dispatch_delegate_task(self, function_args: dict) -> str: acp_command=function_args.get("acp_command"), acp_args=function_args.get("acp_args"), role=function_args.get("role"), + profile=function_args.get("profile"), parent_agent=self, ) diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index c25bced0c87cc..eeb8a539289e2 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -2073,6 +2073,11 @@ def delegate_task( except ValueError as exc: return tool_error(str(exc)) + # Cache resolved credentials per profile name so _resolve_delegation_credentials + # is called once per unique profile, not once per task (avoids repeated + # provider lookups / env reads in large batches). + _creds_cache: Dict[str, dict] = {} + overall_start = time.monotonic() results = [] @@ -2116,11 +2121,19 @@ def delegate_task( else: # API mode: resolve credentials from profile dict (if set) or # fall back to default delegation config. - _cred_src = _profile_cfg if _profile_cfg else cfg - try: - _task_creds = _resolve_delegation_credentials(_cred_src, parent_agent) if _profile_cfg else creds - except ValueError as exc: - return tool_error(str(exc)) + # Use creds cache so N tasks sharing one profile don't trigger + # N provider lookups (env reads, token refreshes, etc.). + if _task_profile_name and _task_profile_name in _profiles_cache: + if _task_profile_name not in _creds_cache: + try: + _creds_cache[_task_profile_name] = _resolve_delegation_credentials( + _profile_cfg, parent_agent + ) + except ValueError as exc: + return tool_error(str(exc)) + _task_creds = _creds_cache[_task_profile_name] + else: + _task_creds = creds _task_acp_args_explicit = t.get("acp_args") if "acp_args" in t else None _task_acp_command = ( t.get("acp_command") or acp_command or _task_creds.get("command") @@ -2132,10 +2145,13 @@ def delegate_task( ) # Toolsets: explicit call arg > profile default > top-level toolsets arg + # Toolset priority: explicit per-task > profile default > top-level arg. + # Use `is not None` (not `or`) so an explicit empty list [] is respected + # as "no toolsets" rather than falling through to the profile default. + _t_toolsets = t.get("toolsets") _task_toolsets = ( - t.get("toolsets") - or _profile_cfg.get("toolsets") - or toolsets + _t_toolsets if _t_toolsets is not None + else (_profile_cfg.get("toolsets") or toolsets) ) # Proxy from profile (v1: warning logged inside _build_child_agent, not applied) From e03b84a1bf0b0bda6ba8790d9f469bb79553022f Mon Sep 17 00:00:00 2001 From: Gleb Panov <5999832+gutleib@users.noreply.github.com> Date: Fri, 29 May 2026 00:20:51 +0300 Subject: [PATCH 08/14] feat(delegate): add profile field to DELEGATE_TASK_SCHEMA (root + tasks.items) Adds static 'profile' field to the schema at both root level and per-task level. Both fields remain optional (not in required array). Description mentions config.yaml delegation.profiles and that this will be overridden with dynamic values in get_definitions(). --- tests/tools/test_delegate_profiles.py | 20 ++++++++++++++++++++ tools/delegate_tool.py | 15 +++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/tests/tools/test_delegate_profiles.py b/tests/tools/test_delegate_profiles.py index e3d3d601cd075..5f79ca25d7067 100644 --- a/tests/tools/test_delegate_profiles.py +++ b/tests/tools/test_delegate_profiles.py @@ -379,3 +379,23 @@ def capture_creds(cfg, parent): models = [c.get("model") for c in call_cfgs] assert "deepseek-v4-flash" in models assert "claude-sonnet-4-20250514" in models + + +class TestDelegateTaskSchema: + def test_profile_in_root_properties(self): + props = DELEGATE_TASK_SCHEMA["parameters"]["properties"] + assert "profile" in props + assert props["profile"]["type"] == "string" + + def test_profile_in_tasks_items_properties(self): + tasks_items = DELEGATE_TASK_SCHEMA["parameters"]["properties"]["tasks"]["items"] + assert "profile" in tasks_items["properties"] + assert tasks_items["properties"]["profile"]["type"] == "string" + + def test_profile_description_mentions_config(self): + desc = DELEGATE_TASK_SCHEMA["parameters"]["properties"]["profile"]["description"] + assert "delegation.profiles" in desc + + def test_per_task_profile_description_present(self): + items_props = DELEGATE_TASK_SCHEMA["parameters"]["properties"]["tasks"]["items"]["properties"] + assert items_props["profile"]["description"] diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index eeb8a539289e2..32a9e26151494 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -2847,6 +2847,12 @@ def _build_dynamic_schema_overrides() -> dict: "enum": ["leaf", "orchestrator"], "description": "Per-task role override. See top-level 'role' for semantics.", }, + "profile": { + "type": "string", + "description": ( + "Per-task profile override. See top-level 'profile' for semantics." + ), + }, }, "required": ["goal"], }, @@ -2882,6 +2888,15 @@ def _build_dynamic_schema_overrides() -> dict: "Leave empty unless acp_command is explicitly provided." ), }, + "profile": { + "type": "string", + "description": ( + "Named delegation profile from config.yaml (delegation.profiles). " + "Sets model, provider, toolsets, system_prompt, and constraints for the subagent. " + "Available profiles are shown at runtime via the dynamic schema. " + "Omit to use default delegation settings." + ), + }, }, "required": [], }, From cbfb2368a3974b17235232c7964ceb9503db7e1f Mon Sep 17 00:00:00 2001 From: Gleb Panov <5999832+gutleib@users.noreply.github.com> Date: Fri, 29 May 2026 00:23:50 +0300 Subject: [PATCH 09/14] feat(delegate): _build_dynamic_schema_overrides injects profile enum from config --- tests/tools/test_delegate_profiles.py | 41 +++++++++++++++++++++++++++ tools/delegate_tool.py | 33 +++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/tests/tools/test_delegate_profiles.py b/tests/tools/test_delegate_profiles.py index 5f79ca25d7067..39125bc417e00 100644 --- a/tests/tools/test_delegate_profiles.py +++ b/tests/tools/test_delegate_profiles.py @@ -399,3 +399,44 @@ def test_profile_description_mentions_config(self): def test_per_task_profile_description_present(self): items_props = DELEGATE_TASK_SCHEMA["parameters"]["properties"]["tasks"]["items"]["properties"] assert items_props["profile"]["description"] + + +class TestDynamicSchemaProfileEnum: + @patch("tools.delegate_tool._load_config", return_value=_PROFILE_CFG) + def test_profile_enum_populated_when_profiles_configured(self, _mock_cfg): + from tools.delegate_tool import _build_dynamic_schema_overrides + overrides = _build_dynamic_schema_overrides() + profile_prop = overrides["parameters"]["properties"].get("profile") + assert profile_prop is not None + assert set(profile_prop["enum"]) == {"coder", "critic", "copilot-runner"} + + @patch("tools.delegate_tool._load_config", return_value=_PROFILE_CFG) + def test_profile_description_lists_summaries(self, _mock_cfg): + from tools.delegate_tool import _build_dynamic_schema_overrides + overrides = _build_dynamic_schema_overrides() + desc = overrides["parameters"]["properties"]["profile"]["description"] + assert "Writes code with TDD" in desc + assert "Code review" in desc + + @patch("tools.delegate_tool._load_config", return_value={"max_iterations": 10}) + def test_no_profiles_configured_no_enum(self, _mock_cfg): + from tools.delegate_tool import _build_dynamic_schema_overrides + overrides = _build_dynamic_schema_overrides() + profile_prop = overrides["parameters"]["properties"].get("profile", {}) + assert "enum" not in profile_prop + + @patch("tools.delegate_tool._load_config", return_value=_PROFILE_CFG) + def test_tasks_items_profile_also_gets_enum(self, _mock_cfg): + from tools.delegate_tool import _build_dynamic_schema_overrides + overrides = _build_dynamic_schema_overrides() + tasks_items = overrides["parameters"]["properties"]["tasks"]["items"] + profile_prop = tasks_items["properties"].get("profile", {}) + assert set(profile_prop.get("enum", [])) == {"coder", "critic", "copilot-runner"} + + @patch("tools.delegate_tool._load_config", return_value=_PROFILE_CFG) + def test_static_schema_not_mutated(self, _mock_cfg): + from tools.delegate_tool import _build_dynamic_schema_overrides, DELEGATE_TASK_SCHEMA + before = "enum" in DELEGATE_TASK_SCHEMA["parameters"]["properties"].get("profile", {}) + _build_dynamic_schema_overrides() + after = "enum" in DELEGATE_TASK_SCHEMA["parameters"]["properties"].get("profile", {}) + assert before == after # static schema unchanged diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 32a9e26151494..e59550debc3f8 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -2762,6 +2762,39 @@ def _build_dynamic_schema_overrides() -> dict: } overrides_params["properties"]["tasks"]["description"] = _build_tasks_param_description() overrides_params["properties"]["role"]["description"] = _build_role_param_description() + + # Inject profile enum so the orchestrator sees actual profile names, not a + # free-form string field — prevents hallucinated profile names. + cfg = _load_config() + profiles = cfg.get("profiles", {}) + if profiles: + profile_lines = "\n".join( + f" - **{k}**: {v.get('summary', k)}" for k, v in profiles.items() + ) + profile_prop = { + **overrides_params["properties"].get("profile", {}), + "type": "string", + "enum": list(profiles.keys()), + "description": ( + f"Named delegation profile from config.yaml. Available:\n{profile_lines}" + ), + } + overrides_params["properties"]["profile"] = profile_prop + + # Also update tasks.items without mutating the static tasks dict. + tasks_prop = dict(overrides_params["properties"]["tasks"]) + tasks_items = dict(tasks_prop.get("items", {})) + tasks_items_props = dict(tasks_items.get("properties", {})) + tasks_items_props["profile"] = { + **tasks_items_props.get("profile", {}), + "type": "string", + "enum": list(profiles.keys()), + "description": "Per-task profile override. See top-level 'profile' for semantics.", + } + tasks_items["properties"] = tasks_items_props + tasks_prop["items"] = tasks_items + overrides_params["properties"]["tasks"] = tasks_prop + return { "description": _build_top_level_description(), "parameters": overrides_params, From 74138dd30081d86af0c2397850013118dd95692a Mon Sep 17 00:00:00 2001 From: Gleb Panov <5999832+gutleib@users.noreply.github.com> Date: Fri, 29 May 2026 00:35:57 +0300 Subject: [PATCH 10/14] fix(delegate): lazy default-creds, toolsets [] key-check, ACP acp_args priority - Move _resolve_delegation_credentials to lazy evaluation so ACP-only calls (no API credentials needed) no longer fail when delegation.provider is absent or misconfigured - Use key-presence check ("toolsets" in _profile_cfg) instead of `or` so profile.toolsets=[] is passed through rather than silently falling back to the top-level toolsets arg - Honor top-level acp_args as fallback in ACP profile branch (per-task > top-level > profile), matching existing API-mode precedence semantics - Add assert_not_called() to ACP test (verifies lazy-creds fix holds) - Add tests: ACP-only batch skips provider lookup, profile toolsets [] key-presence, top-level acp_args fallback in ACP mode --- tests/tools/test_delegate_profiles.py | 86 +++++++++++++++++++++++++-- tools/delegate_tool.py | 37 ++++++------ 2 files changed, 101 insertions(+), 22 deletions(-) diff --git a/tests/tools/test_delegate_profiles.py b/tests/tools/test_delegate_profiles.py index 39125bc417e00..d92375aa72884 100644 --- a/tests/tools/test_delegate_profiles.py +++ b/tests/tools/test_delegate_profiles.py @@ -252,11 +252,7 @@ def test_api_profile_passes_profile_cfg_to_creds(self, mock_creds, _mock_cfg): @patch("tools.delegate_tool._load_config", return_value={"max_iterations": 10, **_PROFILE_CFG}) @patch("tools.delegate_tool._resolve_delegation_credentials") def test_acp_profile_uses_acp_command_not_api_creds(self, mock_creds, _mock_cfg): - """ACP profile → acp_command passed to AIAgent, no API creds lookup.""" - mock_creds.return_value = { - "model": None, "provider": None, - "base_url": None, "api_key": None, "api_mode": None, - } + """ACP profile → acp_command passed to AIAgent, _resolve_delegation_credentials NOT called.""" parent = _make_mock_parent() with patch("run_agent.AIAgent") as MockAgent: MockAgent.return_value = _mock_child() @@ -265,6 +261,24 @@ def test_acp_profile_uses_acp_command_not_api_creds(self, mock_creds, _mock_cfg) _, kwargs = MockAgent.call_args assert kwargs.get("acp_command") == "copilot" assert kwargs.get("acp_args") == ["--model", "claude-sonnet-4-5"] + mock_creds.assert_not_called() + + @patch("tools.delegate_tool._load_config", return_value={"max_iterations": 10, **_PROFILE_CFG}) + @patch("tools.delegate_tool._resolve_delegation_credentials") + def test_acp_profile_top_level_acp_args_used_as_fallback(self, mock_creds, _mock_cfg): + """Top-level acp_args are used when no per-task override; profile.acp_args is lowest priority.""" + parent = _make_mock_parent() + with patch("run_agent.AIAgent") as MockAgent: + MockAgent.return_value = _mock_child() + delegate_task( + goal="Run via copilot", + profile="copilot-runner", + acp_args=["--model", "override-model"], # top-level beats profile.acp_args + parent_agent=parent, + ) + + _, kwargs = MockAgent.call_args + assert kwargs.get("acp_args") == ["--model", "override-model"] @patch("tools.delegate_tool._load_config", return_value={"max_iterations": 10, **_PROFILE_CFG}) def test_unknown_profile_returns_error_before_spawn(self, _mock_cfg): @@ -380,6 +394,68 @@ def capture_creds(cfg, parent): assert "deepseek-v4-flash" in models assert "claude-sonnet-4-20250514" in models + @patch("tools.delegate_tool._load_config", return_value={"max_iterations": 10, **_PROFILE_CFG}) + @patch("tools.delegate_tool._resolve_delegation_credentials") + def test_acp_only_call_does_not_need_delegation_provider(self, mock_creds, _mock_cfg): + """ACP-only batch never calls _resolve_delegation_credentials even if delegation.provider is absent.""" + # If this test passes with assert_not_called(), the lazy-creds fix is working. + parent = _make_mock_parent() + with patch("run_agent.AIAgent") as MockAgent: + MockAgent.return_value = _mock_child() + delegate_task(tasks=[ + {"goal": "Task A", "profile": "copilot-runner"}, + {"goal": "Task B", "profile": "copilot-runner"}, + ], parent_agent=parent) + mock_creds.assert_not_called() + + @patch("tools.delegate_tool._resolve_delegation_credentials") + def test_profile_toolsets_empty_list_not_replaced_by_default(self, mock_creds): + """Profile with toolsets:[] is passed to _build_child_agent (not skipped for top-level). + When no explicit top-level toolsets are given, profile [] wins — _build_child_agent + then inherits from parent (its convention for falsy toolsets), so child gets parent's tools. + If we'd incorrectly fallen through profile [] to a non-None default, the child would + have gotten different tools.""" + _profile_with_empty_toolsets = { + "max_iterations": 10, + "profiles": { + "restricted": { + "model": "gpt-4o", + "provider": "openai", + "toolsets": [], # profile explicitly declares empty toolsets + } + } + } + mock_creds.return_value = { + "model": "gpt-4o", "provider": "openai", + "base_url": "https://api.openai.com/v1", + "api_key": "sk-test", "api_mode": "chat_completions", + } + parent = _make_mock_parent() + parent.enabled_toolsets = ["terminal", "file", "web"] + + captured_toolsets = [] + + with patch("tools.delegate_tool._load_config", return_value=_profile_with_empty_toolsets), \ + patch("tools.delegate_tool._build_child_agent", wraps=None) as mock_build: + def capture(*args, **kwargs): + captured_toolsets.append(kwargs.get("toolsets")) + child = _mock_child() + return child + mock_build.side_effect = capture + + delegate_task( + goal="Work", + profile="restricted", + # No top-level toolsets — profile should supply [] + parent_agent=parent, + ) + + # _build_child_agent received toolsets=[] from profile, not any other value + assert len(captured_toolsets) == 1 + assert captured_toolsets[0] == [], ( + f"Expected profile toolsets=[] to be passed, got {captured_toolsets[0]!r}" + ) + class TestDelegateTaskSchema: def test_profile_in_root_properties(self): diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index e59550debc3f8..b93d1d1b2e539 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -2010,16 +2010,6 @@ def delegate_task( ) effective_max_iter = default_max_iter - # Resolve delegation credentials (provider:model pair). - # When delegation.provider is configured, this resolves the full credential - # bundle (base_url, api_key, api_mode) via the same runtime provider system - # used by CLI/gateway startup. When unconfigured, returns None values so - # children inherit from the parent. - try: - creds = _resolve_delegation_credentials(cfg, parent_agent) - except ValueError as exc: - return tool_error(str(exc)) - # Normalize to task list max_children = _get_max_concurrent_children() recovered_tasks, tasks_error = _recover_tasks_from_json_string(tasks) @@ -2073,6 +2063,12 @@ def delegate_task( except ValueError as exc: return tool_error(str(exc)) + # Lazy default credentials: resolved only when a task needs the default API + # path (no profile, or profile without acp_command). ACP-only calls never + # trigger provider resolution — users without delegation.provider configured + # can still use ACP profiles without an API-key error. + _default_creds: Optional[dict] = None + # Cache resolved credentials per profile name so _resolve_delegation_credentials # is called once per unique profile, not once per task (avoids repeated # provider lookups / env reads in large batches). @@ -2114,9 +2110,10 @@ def delegate_task( "base_url": None, "api_key": None, "api_mode": None, } _task_acp_command = _profile_cfg["acp_command"] + # Priority: per-task > top-level call arg > profile default _task_acp_args = ( t.get("acp_args") if "acp_args" in t - else _profile_cfg.get("acp_args") + else (acp_args if acp_args is not None else _profile_cfg.get("acp_args")) ) else: # API mode: resolve credentials from profile dict (if set) or @@ -2133,7 +2130,14 @@ def delegate_task( return tool_error(str(exc)) _task_creds = _creds_cache[_task_profile_name] else: - _task_creds = creds + # Default path (no profile) — resolve default credentials lazily + # so ACP-only callers never trigger provider credential lookup. + if _default_creds is None: + try: + _default_creds = _resolve_delegation_credentials(cfg, parent_agent) + except ValueError as exc: + return tool_error(str(exc)) + _task_creds = _default_creds _task_acp_args_explicit = t.get("acp_args") if "acp_args" in t else None _task_acp_command = ( t.get("acp_command") or acp_command or _task_creds.get("command") @@ -2144,14 +2148,13 @@ def delegate_task( else (acp_args if acp_args is not None else _task_creds.get("args")) ) - # Toolsets: explicit call arg > profile default > top-level toolsets arg - # Toolset priority: explicit per-task > profile default > top-level arg. - # Use `is not None` (not `or`) so an explicit empty list [] is respected - # as "no toolsets" rather than falling through to the profile default. + # Toolsets: explicit per-task > profile default > top-level arg. + # Use key-presence checks (not `or`) so an explicit empty list [] is + # respected at every level rather than falling through to the next tier. _t_toolsets = t.get("toolsets") _task_toolsets = ( _t_toolsets if _t_toolsets is not None - else (_profile_cfg.get("toolsets") or toolsets) + else (_profile_cfg["toolsets"] if "toolsets" in _profile_cfg else toolsets) ) # Proxy from profile (v1: warning logged inside _build_child_agent, not applied) From 4799fbaa2e076a859447ce1c7bd334dd6c1104be Mon Sep 17 00:00:00 2001 From: Gleb Panov <5999832+gutleib@users.noreply.github.com> Date: Fri, 29 May 2026 16:32:35 +0300 Subject: [PATCH 11/14] docs(delegate): add delegation.profiles example to cli-config.yaml.example Shows API mode, ACP mode, proxy/toolsets/constraints usage in commented example block under the existing delegation: section. --- cli-config.yaml.example | 53 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 355b6bb756947..72144b9bab2a5 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -885,6 +885,59 @@ delegation: # # Resolves full credentials (base_url, api_key) automatically. # # Supported: openrouter, nous, zai, kimi-coding, minimax + # --------------------------------------------------------------------------- + # Named delegation profiles (delegation.profiles) + # --------------------------------------------------------------------------- + # Each profile defines a specialised subagent role with its own model, + # provider, toolsets, system_prompt, and optional constraints. + # Usage: delegate_task(goal="...", profile="coder") + # delegate_task(tasks=[{"goal": "...", "profile": "critic"}]) + # + # Two routing modes — mutually exclusive per profile: + # API mode: model + provider (+ optional base_url / api_key / api_mode) + # ACP mode: acp_command (+ optional acp_args) — runs via terminal wrapper + # + # Toolset priority: explicit call arg > profile.toolsets > inherited from parent + # Proxy field is accepted but NOT applied in v1 (warning emitted); v2 will use it. + # + # profiles: + # coder: + # nickname: "👷 Coder" # shown in tool description (informational) + # summary: "Writes code, TDD" # shown to LLM orchestrator in the schema enum + # model: deepseek-v4-flash + # provider: deepseek + # toolsets: [terminal, file] + # system_prompt: | + # You are a focused developer. Follow TDD strictly. + # constraints: | + # - No changes outside the assigned task scope + # - Tests before implementation + # + # critic: + # nickname: "🔍 Critic" + # summary: "Code review, bug hunting" + # model: claude-sonnet-4-20250514 + # provider: custom + # base_url: https://api.anthropic.com/v1 + # api_mode: anthropic_messages + # # proxy: http://localhost:8119 # v1: logged as warning, not applied + # toolsets: [file] + # system_prompt: | + # You are a code reviewer. Check for logic errors, security issues, + # and spec conformance. + # constraints: | + # - Do not write code + # - Return JSON: {passed, issues, summary} + # + # copilot-runner: + # nickname: "🤖 Copilot" + # summary: "Runs via Copilot CLI (ACP mode)" + # acp_command: copilot + # acp_args: ["--model", "claude-sonnet-4-5"] + # toolsets: [terminal, file] + # system_prompt: | + # You are a developer agent running via Copilot CLI. + # ============================================================================= # Honcho Integration (Cross-Session User Modeling) # ============================================================================= From ee99c6a950eb80b65fe27cf4520f4b4a4e77ce61 Mon Sep 17 00:00:00 2001 From: Gleb Panov <5999832+gutleib@users.noreply.github.com> Date: Fri, 29 May 2026 20:30:22 +0300 Subject: [PATCH 12/14] chore: gitignore .hermes/ user data directory Prevents accidental staging of ~/.hermes config, sessions, and keys when developing from a checkout that sits alongside a local Hermes install. --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 80984656b09cd..0ff54705f87b3 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,8 @@ __pycache__/model_tools.cpython-310.pyc __pycache__/web_tools.cpython-310.pyc logs/ data/ +# Local development artifacts (plans, drafts, design notes) +.hermes/ .pytest_cache/ test_durations.json .pytest-cache/ From b0f1ebdb1bf071fcecab64e8d7d6b18419ea119a Mon Sep 17 00:00:00 2001 From: Gleb Panov <5999832+gutleib@users.noreply.github.com> Date: Fri, 29 May 2026 22:03:11 +0300 Subject: [PATCH 13/14] feat(delegate): per-profile max_iterations override Profiles can now declare their own iteration budget via max_iterations, overriding the global delegation.max_iterations for that subagent only. Falls back to the global value when the field is absent. --- cli-config.yaml.example | 1 + tests/tools/test_delegate_profiles.py | 57 +++++++++++++++++++++++++++ tools/delegate_tool.py | 8 +++- 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 72144b9bab2a5..ec784ad6e5c9a 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -907,6 +907,7 @@ delegation: # model: deepseek-v4-flash # provider: deepseek # toolsets: [terminal, file] + # max_iterations: 80 # override delegation.max_iterations for this profile (default: inherited) # system_prompt: | # You are a focused developer. Follow TDD strictly. # constraints: | diff --git a/tests/tools/test_delegate_profiles.py b/tests/tools/test_delegate_profiles.py index d92375aa72884..d79d9235956bb 100644 --- a/tests/tools/test_delegate_profiles.py +++ b/tests/tools/test_delegate_profiles.py @@ -456,6 +456,63 @@ def capture(*args, **kwargs): f"Expected profile toolsets=[] to be passed, got {captured_toolsets[0]!r}" ) + @patch("tools.delegate_tool._resolve_delegation_credentials") + def test_profile_max_iterations_overrides_global(self, mock_creds): + """Profile with max_iterations overrides delegation.max_iterations.""" + _cfg = { + "max_iterations": 10, + "profiles": { + "heavy": { + "model": "gpt-4o", + "provider": "openai", + "max_iterations": 25, + } + }, + } + mock_creds.return_value = { + "model": "gpt-4o", "provider": "openai", + "base_url": "https://api.openai.com/v1", + "api_key": "sk-test", "api_mode": "chat_completions", + } + captured = [] + with patch("tools.delegate_tool._load_config", return_value=_cfg), \ + patch("tools.delegate_tool._build_child_agent", wraps=None) as mock_build: + def capture(*args, **kwargs): + captured.append(kwargs.get("max_iterations")) + return _mock_child() + mock_build.side_effect = capture + delegate_task(goal="Work", profile="heavy", parent_agent=_make_mock_parent()) + + assert captured == [25], f"Expected max_iterations=25 from profile, got {captured}" + + @patch("tools.delegate_tool._resolve_delegation_credentials") + def test_profile_without_max_iterations_uses_global(self, mock_creds): + """Profile without max_iterations falls back to delegation.max_iterations.""" + _cfg = { + "max_iterations": 10, + "profiles": { + "light": { + "model": "gpt-4o-mini", + "provider": "openai", + } + }, + } + mock_creds.return_value = { + "model": "gpt-4o-mini", "provider": "openai", + "base_url": "https://api.openai.com/v1", + "api_key": "sk-test", "api_mode": "chat_completions", + } + captured = [] + with patch("tools.delegate_tool._load_config", return_value=_cfg), \ + patch("tools.delegate_tool._build_child_agent", wraps=None) as mock_build: + def capture(*args, **kwargs): + captured.append(kwargs.get("max_iterations")) + return _mock_child() + mock_build.side_effect = capture + delegate_task(goal="Work", profile="light", parent_agent=_make_mock_parent()) + + assert captured == [10], f"Expected global max_iterations=10, got {captured}" + class TestDelegateTaskSchema: def test_profile_in_root_properties(self): diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index b93d1d1b2e539..25f62ac023b0d 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -2160,13 +2160,19 @@ def delegate_task( # Proxy from profile (v1: warning logged inside _build_child_agent, not applied) _task_proxy = _profile_cfg.get("proxy") if _profile_cfg else None + # max_iterations: profile override > global delegation.max_iterations + _task_max_iter = ( + _profile_cfg.get("max_iterations", effective_max_iter) + if _profile_cfg else effective_max_iter + ) + child = _build_child_agent( task_index=i, goal=t["goal"], context=t.get("context"), toolsets=_task_toolsets, model=_task_creds["model"], - max_iterations=effective_max_iter, + max_iterations=_task_max_iter, task_count=n_tasks, parent_agent=parent_agent, override_provider=_task_creds["provider"], From 9a96fdc9af8fbb39e959a96ac7a5f9f3ce5cd40d Mon Sep 17 00:00:00 2001 From: Gleb Panov <5999832+gutleib@users.noreply.github.com> Date: Fri, 29 May 2026 22:17:41 +0300 Subject: [PATCH 14/14] chore: add gutleib to AUTHOR_MAP --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 6c255108ccb0f..92276b3a0eec9 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -45,6 +45,7 @@ # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "5999832+gutleib@users.noreply.github.com": "gutleib", "9592417+adam91holt@users.noreply.github.com": "adam91holt", "kchuang1015@users.noreply.github.com": "kchuang1015", "45688690+fujinice@users.noreply.github.com": "fujinice",