From 3c96030488914eeea8a67dfc4d50944b79cc4c3b Mon Sep 17 00:00:00 2001 From: shivam Date: Sat, 8 Aug 2026 02:20:35 +0000 Subject: [PATCH 1/4] fix(advisor): resolve the advisor sub-call through the proxy router Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../messages/interceptors/advisor.py | 79 +++++++- .../messages/test_advisor_orchestration.py | 190 ++++++++++++++++++ 2 files changed, 264 insertions(+), 5 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/interceptors/advisor.py b/litellm/llms/anthropic/experimental_pass_through/messages/interceptors/advisor.py index dfae7b4f4cf..805f5625ad9 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/interceptors/advisor.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/interceptors/advisor.py @@ -16,7 +16,7 @@ How it works: import uuid from collections.abc import AsyncIterator -from typing import Any, Final +from typing import TYPE_CHECKING, Any, Final, cast import litellm import litellm.constants as _c @@ -28,6 +28,9 @@ from litellm.types.llms.anthropic_messages.anthropic_response import ( AnthropicMessagesResponse, ) +if TYPE_CHECKING: + from litellm.router import Router + ADVISOR_MAX_USES: Final[int] = _c.ADVISOR_MAX_USES ADVISOR_NATIVE_PROVIDERS: Final[frozenset] = _c.ADVISOR_NATIVE_PROVIDERS ADVISOR_TOOL_DESCRIPTION: Final[str] = _c.ADVISOR_TOOL_DESCRIPTION @@ -138,13 +141,10 @@ class AdvisorOrchestrationHandler(MessagesInterceptor): # --- Advisor sub-call (always non-streaming, no tools) --- try: - advisor_response: AnthropicMessagesResponse = await _call_messages_handler( + advisor_response: AnthropicMessagesResponse = await _call_advisor( model=advisor_model, messages=advisor_messages, - tools=None, - stream=False, max_tokens=max_tokens, - custom_llm_provider=None, # let litellm resolve from model name metadata={ **metadata_base, "advisor_sub_call": True, @@ -357,6 +357,75 @@ def _inject_max_uses_error( ] +def _resolve_advisor_router(advisor_model: str) -> "Router | None": + """Return the proxy router when it can resolve ``advisor_model``. + + The advisor sub-call must honor the proxy's ``model_list`` (and its + fallbacks / credentials) exactly like a direct call to that model group + would. Without this, provider resolution falls back to the bare model + name, which for a ``claude-*`` advisor model means the public Anthropic + API, bypassing the configured deployment entirely. + + Returns ``None`` for SDK callers (no proxy router) and for advisor models + the router doesn't know about, so those keep resolving through + ``litellm.anthropic_messages()`` provider inference. + """ + try: + from litellm.proxy.proxy_server import llm_router + except (ImportError, ModuleNotFoundError): + return None + if llm_router is None: + return None + if llm_router.get_model_list(model_name=advisor_model): + return llm_router + if llm_router.model_group_alias and advisor_model in llm_router.model_group_alias: + return llm_router + if llm_router.pattern_router.route(advisor_model) is not None: + return llm_router + return None + + +async def _call_advisor( + *, + model: str, + messages: list[dict], + max_tokens: int, + metadata: dict, + api_key: str | None, + api_base: str | None, +) -> AnthropicMessagesResponse: + """Run the advisor sub-call, through the proxy router when it applies. + + A caller-supplied ``api_key`` / ``api_base`` override is an explicit + request to bypass the configured deployment, so it keeps the direct + SDK-level path. + """ + router: Final = None if (api_key or api_base) else _resolve_advisor_router(model) + response: Final = ( + await router.aanthropic_messages( + model=model, + messages=messages, + tools=None, + stream=False, + max_tokens=max_tokens, + metadata=metadata, + ) + if router is not None + else await _call_messages_handler( + model=model, + messages=messages, + tools=None, + stream=False, + max_tokens=max_tokens, + custom_llm_provider=None, + metadata=metadata, + api_key=api_key, + api_base=api_base, + ) + ) + return cast(AnthropicMessagesResponse, response) # cast-ok: both /messages entry points are untyped + + async def _call_messages_handler( model: str, messages: list[dict], diff --git a/tests/test_litellm/llms/anthropic/messages/test_advisor_orchestration.py b/tests/test_litellm/llms/anthropic/messages/test_advisor_orchestration.py index 3d35e93167f..bcb1843f408 100644 --- a/tests/test_litellm/llms/anthropic/messages/test_advisor_orchestration.py +++ b/tests/test_litellm/llms/anthropic/messages/test_advisor_orchestration.py @@ -1041,3 +1041,193 @@ async def test_executor_failure_is_not_tagged(): ) assert is_advisor_orchestration_failure(exc_info.value) is False + + +# --------------------------------------------------------------------------- +# 15. The advisor sub-call resolves through the proxy router when the advisor +# model is configured in model_list, instead of dialing the public +# Anthropic API (regression for LIT-5307). +# --------------------------------------------------------------------------- + + +def _router_with_advisor_deployment(recorder, advisor_model="claude-opus-4-8"): + """Build a Router whose only deployment is the advisor model on Foundry. + + The recorder replaces ``litellm.anthropic_messages`` before construction + because Router binds it at init time, so the returned Router exercises the + real deployment-resolution path and records what it dispatched. + """ + import litellm + from litellm.router import Router + + with patch("litellm.anthropic_messages", new=recorder): + return Router( + model_list=[ + { + "model_name": advisor_model, + "litellm_params": { + "model": f"azure_ai/{advisor_model}", + "api_base": "http://127.0.0.1:1/foundry", + "api_key": "fake-foundry-key", + }, + } + ], + num_retries=0, + ) + + +@pytest.mark.asyncio +async def test_advisor_sub_call_routes_through_proxy_router(): + import litellm.proxy.proxy_server as proxy_server + from litellm.llms.anthropic.experimental_pass_through.messages.interceptors.advisor import ( + AdvisorOrchestrationHandler, + ) + + router_calls = [] + + async def recorder(**kwargs): + router_calls.append(kwargs) + return _make_text_response("Use trial division.", model="claude-opus-4-8") + + router = _router_with_advisor_deployment(recorder) + + call_count = 0 + + async def mock_call(model, messages, tools, stream, max_tokens, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + return _make_advisor_tool_use_response() + return _make_text_response("Final answer.") + + with ( + patch( + "litellm.llms.anthropic.experimental_pass_through.messages.interceptors.advisor._call_messages_handler", + side_effect=mock_call, + ), + patch.object(proxy_server, "llm_router", router), + ): + h = AdvisorOrchestrationHandler() + result = await h.handle( + model="executor-model", + messages=MESSAGES, + tools=[{**ADVISOR_TOOL, "model": "claude-opus-4-8"}], + stream=False, + max_tokens=512, + custom_llm_provider="azure_ai", + ) + + assert call_count == 2 + assert len(router_calls) == 1 + assert router_calls[0]["model"] == "azure_ai/claude-opus-4-8" + assert router_calls[0]["api_base"] == "http://127.0.0.1:1/foundry" + assert router_calls[0]["api_key"] == "fake-foundry-key" + assert "Final answer." in result["content"][0]["text"] + + +@pytest.mark.asyncio +async def test_advisor_sub_call_bypasses_router_for_unconfigured_model(): + """An advisor model the router doesn't know about keeps the SDK-level path.""" + import litellm.proxy.proxy_server as proxy_server + from litellm.llms.anthropic.experimental_pass_through.messages.interceptors.advisor import ( + AdvisorOrchestrationHandler, + ) + + router_calls = [] + + async def recorder(**kwargs): + router_calls.append(kwargs) + return _make_text_response("should not be used") + + router = _router_with_advisor_deployment(recorder, advisor_model="some-other-model") + + call_count = 0 + + async def mock_call(model, messages, tools, stream, max_tokens, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + return _make_advisor_tool_use_response() + if tools is None: + return _make_text_response("Advice.", model="claude-opus-4-8") + return _make_text_response("Final answer.") + + with ( + patch( + "litellm.llms.anthropic.experimental_pass_through.messages.interceptors.advisor._call_messages_handler", + side_effect=mock_call, + ), + patch.object(proxy_server, "llm_router", router), + ): + h = AdvisorOrchestrationHandler() + await h.handle( + model="executor-model", + messages=MESSAGES, + tools=[{**ADVISOR_TOOL, "model": "claude-opus-4-8"}], + stream=False, + max_tokens=512, + custom_llm_provider="azure_ai", + ) + + assert router_calls == [] + assert call_count == 3 + + +@pytest.mark.asyncio +async def test_advisor_sub_call_client_override_bypasses_router(): + """A caller-supplied api_key/api_base override must not be re-routed.""" + import litellm + import litellm.proxy.proxy_server as proxy_server + from litellm.llms.anthropic.experimental_pass_through.messages.interceptors.advisor import ( + AdvisorOrchestrationHandler, + ) + + router_calls = [] + + async def recorder(**kwargs): + router_calls.append(kwargs) + return _make_text_response("should not be used") + + router = _router_with_advisor_deployment(recorder) + + sub_calls = [] + + async def mock_call(model, messages, tools, stream, max_tokens, **kwargs): + sub_calls.append({"model": model, "tools": tools, **kwargs}) + if len(sub_calls) == 1: + return _make_advisor_tool_use_response() + if tools is None: + return _make_text_response("Advice.", model="claude-opus-4-8") + return _make_text_response("Final answer.") + + advisor_tool = { + **ADVISOR_TOOL, + "model": "claude-opus-4-8", + "api_key": "client-key", + "api_base": "https://client.example.com", + } + + with ( + patch( + "litellm.llms.anthropic.experimental_pass_through.messages.interceptors.advisor._call_messages_handler", + side_effect=mock_call, + ), + patch.object(proxy_server, "llm_router", router), + patch.dict(proxy_server.general_settings, {"allow_client_side_credentials": True}), + patch.object(litellm, "user_url_validation", False), + ): + h = AdvisorOrchestrationHandler() + await h.handle( + model="executor-model", + messages=MESSAGES, + tools=[advisor_tool], + stream=False, + max_tokens=512, + custom_llm_provider="azure_ai", + ) + + assert router_calls == [] + advisor_sub_calls = [c for c in sub_calls if c["tools"] is None] + assert len(advisor_sub_calls) == 1 + assert advisor_sub_calls[0]["api_key"] == "client-key" + assert advisor_sub_calls[0]["api_base"] == "https://client.example.com" From 45317d58641c0ebcb1a84868d79fbcc19fca7b02 Mon Sep 17 00:00:00 2001 From: shivam Date: Sat, 8 Aug 2026 02:48:08 +0000 Subject: [PATCH 2/4] refactor(advisor): resolve advisor router once per request Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../messages/interceptors/advisor.py | 83 +++++++------------ 1 file changed, 30 insertions(+), 53 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/interceptors/advisor.py b/litellm/llms/anthropic/experimental_pass_through/messages/interceptors/advisor.py index 805f5625ad9..adf96db61c2 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/interceptors/advisor.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/interceptors/advisor.py @@ -16,7 +16,7 @@ How it works: import uuid from collections.abc import AsyncIterator -from typing import TYPE_CHECKING, Any, Final, cast +from typing import TYPE_CHECKING, Any, Final import litellm import litellm.constants as _c @@ -100,6 +100,14 @@ class AdvisorOrchestrationHandler(MessagesInterceptor): parent_request_id: Final[str] = str(kwargs.pop("litellm_call_id", None) or uuid.uuid4()) metadata_base: Final[dict] = dict(kwargs.pop("metadata", None) or {}) + advisor_metadata: Final = { + **metadata_base, + "advisor_sub_call": True, + "parent_request_id": parent_request_id, + } + advisor_router: Final = ( + None if (advisor_api_key or advisor_api_base) else _resolve_advisor_router(advisor_model) + ) iteration = 0 while True: @@ -141,17 +149,27 @@ class AdvisorOrchestrationHandler(MessagesInterceptor): # --- Advisor sub-call (always non-streaming, no tools) --- try: - advisor_response: AnthropicMessagesResponse = await _call_advisor( - model=advisor_model, - messages=advisor_messages, - max_tokens=max_tokens, - metadata={ - **metadata_base, - "advisor_sub_call": True, - "parent_request_id": parent_request_id, - }, - api_key=advisor_api_key, - api_base=advisor_api_base, + advisor_response: AnthropicMessagesResponse = ( + await advisor_router.aanthropic_messages( + model=advisor_model, + messages=advisor_messages, + tools=None, + stream=False, + max_tokens=max_tokens, + metadata=advisor_metadata, + ) + if advisor_router is not None + else await _call_messages_handler( + model=advisor_model, + messages=advisor_messages, + tools=None, + stream=False, + max_tokens=max_tokens, + custom_llm_provider=None, + metadata=advisor_metadata, + api_key=advisor_api_key, + api_base=advisor_api_base, + ) ) except Exception as advisor_sub_call_exception: mark_advisor_orchestration_failure(advisor_sub_call_exception) @@ -385,47 +403,6 @@ def _resolve_advisor_router(advisor_model: str) -> "Router | None": return None -async def _call_advisor( - *, - model: str, - messages: list[dict], - max_tokens: int, - metadata: dict, - api_key: str | None, - api_base: str | None, -) -> AnthropicMessagesResponse: - """Run the advisor sub-call, through the proxy router when it applies. - - A caller-supplied ``api_key`` / ``api_base`` override is an explicit - request to bypass the configured deployment, so it keeps the direct - SDK-level path. - """ - router: Final = None if (api_key or api_base) else _resolve_advisor_router(model) - response: Final = ( - await router.aanthropic_messages( - model=model, - messages=messages, - tools=None, - stream=False, - max_tokens=max_tokens, - metadata=metadata, - ) - if router is not None - else await _call_messages_handler( - model=model, - messages=messages, - tools=None, - stream=False, - max_tokens=max_tokens, - custom_llm_provider=None, - metadata=metadata, - api_key=api_key, - api_base=api_base, - ) - ) - return cast(AnthropicMessagesResponse, response) # cast-ok: both /messages entry points are untyped - - async def _call_messages_handler( model: str, messages: list[dict], From 93f08ed938ed91ab84e28fc71b417497cc60d271 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:36:29 -0700 Subject: [PATCH 3/4] refactor(advisor): collapse router resolution to is_recognized_model plus wildcard check --- .../messages/interceptors/advisor.py | 20 ++---- .../messages/test_advisor_orchestration.py | 67 ++++++++++++++++++- 2 files changed, 70 insertions(+), 17 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/interceptors/advisor.py b/litellm/llms/anthropic/experimental_pass_through/messages/interceptors/advisor.py index adf96db61c2..6f732db1548 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/interceptors/advisor.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/interceptors/advisor.py @@ -376,17 +376,11 @@ def _inject_max_uses_error( def _resolve_advisor_router(advisor_model: str) -> "Router | None": - """Return the proxy router when it can resolve ``advisor_model``. + """Return the proxy router when it serves ``advisor_model`` directly or via a wildcard. - The advisor sub-call must honor the proxy's ``model_list`` (and its - fallbacks / credentials) exactly like a direct call to that model group - would. Without this, provider resolution falls back to the bare model - name, which for a ``claude-*`` advisor model means the public Anthropic - API, bypassing the configured deployment entirely. - - Returns ``None`` for SDK callers (no proxy router) and for advisor models - the router doesn't know about, so those keep resolving through - ``litellm.anthropic_messages()`` provider inference. + Returns ``None`` for SDK callers (no proxy router) and for advisor models the router + doesn't know about, so those keep resolving through ``litellm.anthropic_messages()`` + provider inference. """ try: from litellm.proxy.proxy_server import llm_router @@ -394,11 +388,7 @@ def _resolve_advisor_router(advisor_model: str) -> "Router | None": return None if llm_router is None: return None - if llm_router.get_model_list(model_name=advisor_model): - return llm_router - if llm_router.model_group_alias and advisor_model in llm_router.model_group_alias: - return llm_router - if llm_router.pattern_router.route(advisor_model) is not None: + if llm_router.is_recognized_model(advisor_model) or llm_router.pattern_router.route(advisor_model): return llm_router return None diff --git a/tests/test_litellm/llms/anthropic/messages/test_advisor_orchestration.py b/tests/test_litellm/llms/anthropic/messages/test_advisor_orchestration.py index bcb1843f408..019eb4355c2 100644 --- a/tests/test_litellm/llms/anthropic/messages/test_advisor_orchestration.py +++ b/tests/test_litellm/llms/anthropic/messages/test_advisor_orchestration.py @@ -1050,7 +1050,9 @@ async def test_executor_failure_is_not_tagged(): # --------------------------------------------------------------------------- -def _router_with_advisor_deployment(recorder, advisor_model="claude-opus-4-8"): +def _router_with_advisor_deployment( + recorder, advisor_model="claude-opus-4-8", deployment_model=None, model_group_alias=None +): """Build a Router whose only deployment is the advisor model on Foundry. The recorder replaces ``litellm.anthropic_messages`` before construction @@ -1066,12 +1068,13 @@ def _router_with_advisor_deployment(recorder, advisor_model="claude-opus-4-8"): { "model_name": advisor_model, "litellm_params": { - "model": f"azure_ai/{advisor_model}", + "model": deployment_model or f"azure_ai/{advisor_model}", "api_base": "http://127.0.0.1:1/foundry", "api_key": "fake-foundry-key", }, } ], + model_group_alias=model_group_alias, num_retries=0, ) @@ -1125,6 +1128,66 @@ async def test_advisor_sub_call_routes_through_proxy_router(): assert "Final answer." in result["content"][0]["text"] +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("router_kwargs", "advisor_model"), + [ + pytest.param({"model_group_alias": {"advisor": "claude-opus-4-8"}}, "advisor", id="model_group_alias"), + pytest.param( + {"advisor_model": "azure_ai/*", "deployment_model": "azure_ai/*"}, + "azure_ai/claude-opus-4-8", + id="wildcard", + ), + ], +) +async def test_advisor_sub_call_routes_through_router_for_alias_and_wildcard(router_kwargs, advisor_model): + """Alias and wildcard advisor models resolve through the router like exact model_list matches.""" + import litellm.proxy.proxy_server as proxy_server + from litellm.llms.anthropic.experimental_pass_through.messages.interceptors.advisor import ( + AdvisorOrchestrationHandler, + ) + + router_calls = [] + + async def recorder(**kwargs): + router_calls.append(kwargs) + return _make_text_response("Use trial division.", model="claude-opus-4-8") + + router = _router_with_advisor_deployment(recorder, **router_kwargs) + + call_count = 0 + + async def mock_call(model, messages, tools, stream, max_tokens, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + return _make_advisor_tool_use_response() + return _make_text_response("Final answer.") + + with ( + patch( + "litellm.llms.anthropic.experimental_pass_through.messages.interceptors.advisor._call_messages_handler", + side_effect=mock_call, + ), + patch.object(proxy_server, "llm_router", router), + ): + h = AdvisorOrchestrationHandler() + await h.handle( + model="executor-model", + messages=MESSAGES, + tools=[{**ADVISOR_TOOL, "model": advisor_model}], + stream=False, + max_tokens=512, + custom_llm_provider="azure_ai", + ) + + assert call_count == 2 + assert len(router_calls) == 1 + assert router_calls[0]["model"] == "azure_ai/claude-opus-4-8" + assert router_calls[0]["api_base"] == "http://127.0.0.1:1/foundry" + assert router_calls[0]["api_key"] == "fake-foundry-key" + + @pytest.mark.asyncio async def test_advisor_sub_call_bypasses_router_for_unconfigured_model(): """An advisor model the router doesn't know about keeps the SDK-level path.""" From 4e8efa041de3b4c0d4493c17a8fc20e7de05ca52 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:51:01 -0700 Subject: [PATCH 4/4] fix(advisor): exclude in-sequence system rows from the advisor sub-call context --- .../messages/interceptors/advisor.py | 7 ++- .../messages/test_advisor_orchestration.py | 53 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/interceptors/advisor.py b/litellm/llms/anthropic/experimental_pass_through/messages/interceptors/advisor.py index 6f732db1548..701211049db 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/interceptors/advisor.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/interceptors/advisor.py @@ -302,6 +302,11 @@ def _build_advisor_context( tool_use blocks are excluded because Anthropic requires tool_use to be immediately followed by tool_result — not the advisor question. + + In-sequence system rows (e.g. Claude Code SessionStart hook output) are + excluded: they are executor-directed, and a trailing one becomes invalid + once the question turn is appended after it (a system row must precede an + assistant message or end the array). """ question: Final = (advisor_use_block.get("input") or {}).get("question") or ( "Please provide guidance on the current task." @@ -313,7 +318,7 @@ def _build_advisor_context( for block in raw_content if isinstance(block, dict) and block.get("type") == "text" ] - result: Final = list(messages) + result: Final = [m for m in messages if m.get("role") != "system"] if executor_text_blocks: result.append({"role": "assistant", "content": executor_text_blocks}) result.append({"role": "user", "content": question}) diff --git a/tests/test_litellm/llms/anthropic/messages/test_advisor_orchestration.py b/tests/test_litellm/llms/anthropic/messages/test_advisor_orchestration.py index 019eb4355c2..da5b5ac3867 100644 --- a/tests/test_litellm/llms/anthropic/messages/test_advisor_orchestration.py +++ b/tests/test_litellm/llms/anthropic/messages/test_advisor_orchestration.py @@ -1294,3 +1294,56 @@ async def test_advisor_sub_call_client_override_bypasses_router(): assert len(advisor_sub_calls) == 1 assert advisor_sub_calls[0]["api_key"] == "client-key" assert advisor_sub_calls[0]["api_base"] == "https://client.example.com" + + +# --------------------------------------------------------------------------- +# 16. In-sequence system rows (e.g. Claude Code SessionStart hook output) are +# excluded from the advisor sub-call context but kept for the executor: a +# trailing system row followed by the appended question turn is rejected +# upstream ("role 'system' must precede an 'assistant' message or end the +# array"). +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_advisor_context_excludes_in_sequence_system_rows(): + from litellm.llms.anthropic.experimental_pass_through.messages.interceptors.advisor import ( + AdvisorOrchestrationHandler, + ) + + messages_with_system_row = [ + *MESSAGES, + {"role": "system", "content": "SessionStart hook output: prefer functional style."}, + ] + + sub_calls = [] + + async def mock_call(model, messages, tools, stream, max_tokens, **kwargs): + sub_calls.append({"messages": messages, "tools": tools}) + if len(sub_calls) == 1: + return _make_advisor_tool_use_response() + if tools is None: + return _make_text_response("Advice.", model="claude-opus-4-6") + return _make_text_response("Final answer.") + + with patch( + "litellm.llms.anthropic.experimental_pass_through.messages.interceptors.advisor._call_messages_handler", + side_effect=mock_call, + ): + h = AdvisorOrchestrationHandler() + await h.handle( + model="openai/gpt-4o-mini", + messages=messages_with_system_row, + tools=[ADVISOR_TOOL], + stream=False, + max_tokens=512, + custom_llm_provider="openai", + ) + + assert len(sub_calls) == 3 + advisor_messages = sub_calls[1]["messages"] + assert sub_calls[1]["tools"] is None + assert [m["role"] for m in advisor_messages if m["role"] == "system"] == [] + assert advisor_messages[-1]["role"] == "user" + executor_roles = [m["role"] for m in sub_calls[0]["messages"]] + assert "system" in executor_roles