This commit is contained in:
sindriii 2026-09-12 23:54:03 -07:00 committed by GitHub
commit f9cd6dfa7e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 345 additions and 14 deletions

View file

@ -5,7 +5,7 @@ https://docs.aws.amazon.com/bedrock/latest/APIReference/API_agentcore_InvokeAgen
"""
import json
from collections.abc import AsyncGenerator
from collections.abc import AsyncGenerator, Mapping
from typing import TYPE_CHECKING, Any, Final, Optional, Union, cast
from urllib.parse import quote
@ -229,10 +229,19 @@ class AmazonAgentCoreConfig(BaseConfig, BaseAWSLLM):
OpenAI-shaped multimodal blocks. This is opt-in because an AgentCore agent
must be explicitly written to read ``payload["content"]``; by default the
payload stays byte-identical to the legacy ``{"prompt": "..."}`` shape.
- ``tools`` is added ONLY when the ``forward_tools`` litellm param is truthy
AND the caller declared a non-empty OpenAI ``tools`` list, so a schemaless
agent can see what the caller offered this turn. Same opt-in reasoning as
``content``. AgentCore reports no supported OpenAI params, so ``tools`` is
dropped (or rejected) before this method runs unless the caller also sets
``allowed_openai_params=["tools"]``: that keeps
``get_supported_openai_params()`` honest about what AgentCore natively
supports and makes forwarding a deliberate two-step opt-in. One-way signal
only, since AgentCore responses carry no tool-call channel.
Returns:
dict: Payload dict containing the prompt and (optionally) the OpenAI
content list.
dict: Payload dict containing the prompt and, optionally, the OpenAI
content list and tool declarations.
"""
verbose_logger.debug("AgentCore transform_request - optional_params keys: %s", list(optional_params.keys()))
@ -246,7 +255,7 @@ class AmazonAgentCoreConfig(BaseConfig, BaseAWSLLM):
# list verbatim under "content" so an attachment-aware agent can read the raw
# blocks (image_url, file, etc.). Default off keeps the payload byte-identical
# to the legacy {"prompt": "..."} shape for agents that only read the prompt.
if self._should_forward_multimodal_content(optional_params, litellm_params):
if self._is_flag_enabled(optional_params, litellm_params, "forward_multimodal_content"):
last_content: Final = messages[-1].get("content")
if isinstance(last_content, list) and any(
isinstance(block, dict) and block.get("type") not in (None, "text") for block in last_content
@ -255,6 +264,14 @@ class AmazonAgentCoreConfig(BaseConfig, BaseAWSLLM):
# not deep, to avoid cloning large base64 media on the request path.
payload["content"] = list(last_content)
if self._is_flag_enabled(optional_params, litellm_params, "forward_tools"):
tools: Final = optional_params.get("tools")
if isinstance(tools, list) and tools:
# Immutable shallow copy: the payload can never alias the caller's list,
# the nested dicts stay shared to avoid a deep clone, and json.dumps
# serializes a tuple to the same JSON array a list would.
payload["tools"] = tuple(tools)
# Get or generate session ID - this goes in the header
runtime_session_id: Final = self._get_runtime_session_id(optional_params)
headers["X-Amzn-Bedrock-AgentCore-Runtime-Session-Id"] = runtime_session_id
@ -271,19 +288,25 @@ class AmazonAgentCoreConfig(BaseConfig, BaseAWSLLM):
return payload
@staticmethod
def _should_forward_multimodal_content(optional_params: dict, litellm_params: dict) -> bool:
"""Whether to forward raw OpenAI content blocks under ``payload["content"]``.
def _is_flag_enabled(
optional_params: Mapping[str, object],
litellm_params: Mapping[str, object],
key: str,
) -> bool:
"""Whether the ``key`` payload-forwarding flag is on, ``optional_params`` first.
Opt-in via the ``forward_multimodal_content`` litellm param (default ``False``)
because AgentCore agents must be explicitly written to read the field. The
value may arrive as a bool or a config/env string ("true", "1", ...). Checks
``optional_params`` first (where other AgentCore params land), then
``litellm_params``.
These flags are opt-in (default ``False``) because AgentCore agents must be
explicitly written to read the field they add, and the value may arrive as a
bool or as a config/env string ("true", "1", ...).
Both a deployment-level and a per-request flag reach this method through
``optional_params``: ``get_litellm_params`` only forwards an allowlist of keys
and these are not on it, so anything unrecognized is routed to the provider
params instead. ``litellm_params`` is the fallback for direct
``transform_request`` callers.
"""
for source in (optional_params, litellm_params):
if not isinstance(source, dict):
continue
value = source.get("forward_multimodal_content")
value = source.get(key)
if value is None:
continue
if isinstance(value, str):

View file

@ -641,3 +641,311 @@ class TestAgentCoreMultimodalContent:
payload = config.transform_request(messages=messages, **kwargs)
assert payload["content"] == content
assert payload["content"] is not content
AGENTCORE_TEST_MODEL = (
"agentcore/arn:aws:bedrock-agentcore:us-west-2:111111111111:runtime/test_agent"
)
WEB_SEARCH_TOOL = {
"type": "function",
"function": {
"name": "web_search",
"description": "Search the web",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
},
},
}
class TestAgentCoreForwardTools:
"""Tests for transform_request forwarding the caller's OpenAI tool declarations.
AgentCore Runtime is schemaless on the agent side the agent author's
@app.entrypoint handler parses whatever JSON arrives. An agent that implements
a capability itself (its own search, its own retrieval) has no way today to
learn whether the caller offered that capability for this turn: AgentCore
declares no supported OpenAI params, so ``tools`` never reaches the transform.
When the ``forward_tools`` litellm param is set, the OpenAI tools list is
forwarded verbatim under a "tools" field. This is opt-in: an agent must be
written to read payload["tools"]. Without the flag, the payload is
byte-identical to the legacy {"prompt": "..."} shape.
This is a one-way signal AgentCore responses carry no tool-call channel, so
none of this implies function-calling support.
"""
@pytest.fixture
def config(self):
return AmazonAgentCoreConfig()
@pytest.fixture
def messages(self):
return [{"role": "user", "content": "what happened today"}]
@pytest.fixture
def base_kwargs(self):
"""Default kwargs — forwarding is OFF (no opt-in flag)."""
return {
"model": "bedrock/agentcore/arn:aws:bedrock-agentcore:us-west-2:111111111111:runtime/test_agent",
"optional_params": {},
"litellm_params": {},
"headers": {},
}
def test_tools_not_forwarded_by_default(self, config, messages, base_kwargs):
"""Default (no opt-in flag): tools are NOT forwarded — backward compat."""
base_kwargs["optional_params"] = {"tools": [WEB_SEARCH_TOOL]}
payload = config.transform_request(messages=messages, **base_kwargs)
assert payload == {"prompt": "what happened today"}
assert "tools" not in payload
def test_tools_forwarded_when_opted_in(self, config, messages, base_kwargs):
"""Flag on + tools present → forwarded verbatim."""
tools = [WEB_SEARCH_TOOL]
base_kwargs["optional_params"] = {"tools": tools, "forward_tools": True}
payload = config.transform_request(messages=messages, **base_kwargs)
assert payload["tools"] == tuple(tools)
assert payload["prompt"] == "what happened today"
def test_multiple_tools_forwarded_verbatim(self, config, messages, base_kwargs):
"""The list is not filtered or reshaped — the agent decides what it wants."""
calculator = {"type": "function", "function": {"name": "calculator"}}
tools = [WEB_SEARCH_TOOL, calculator]
base_kwargs["optional_params"] = {"tools": tools, "forward_tools": True}
payload = config.transform_request(messages=messages, **base_kwargs)
assert payload["tools"] == tuple(tools)
def test_no_tools_key_when_opted_in_without_tools(
self, config, messages, base_kwargs
):
"""Flag on but the caller declared nothing → no "tools" field."""
base_kwargs["optional_params"] = {"forward_tools": True}
payload = config.transform_request(messages=messages, **base_kwargs)
assert payload == {"prompt": "what happened today"}
def test_empty_tools_list_not_forwarded(self, config, messages, base_kwargs):
"""Clients send tools=[] when a tool toggle is off — treat it as absent."""
base_kwargs["optional_params"] = {"tools": [], "forward_tools": True}
payload = config.transform_request(messages=messages, **base_kwargs)
assert payload == {"prompt": "what happened today"}
assert "tools" not in payload
def test_non_list_tools_not_forwarded(self, config, messages, base_kwargs):
"""A malformed tools value must not reach the payload."""
base_kwargs["optional_params"] = {
"tools": {"name": "web_search"},
"forward_tools": True,
}
payload = config.transform_request(messages=messages, **base_kwargs)
assert "tools" not in payload
@pytest.mark.parametrize("flag", ["true", "True", "1", "yes", "on", " TRUE "])
def test_truthy_string_flags(self, config, messages, base_kwargs, flag):
"""Config/env values arrive as strings."""
base_kwargs["optional_params"] = {
"tools": [WEB_SEARCH_TOOL],
"forward_tools": flag,
}
payload = config.transform_request(messages=messages, **base_kwargs)
assert payload["tools"] == (WEB_SEARCH_TOOL,)
@pytest.mark.parametrize("flag", ["false", "0", "no", "off", ""])
def test_falsy_string_flags(self, config, messages, base_kwargs, flag):
base_kwargs["optional_params"] = {
"tools": [WEB_SEARCH_TOOL],
"forward_tools": flag,
}
payload = config.transform_request(messages=messages, **base_kwargs)
assert "tools" not in payload
def test_flag_read_from_litellm_params(self, config, messages, base_kwargs):
"""litellm_params is the fallback source for direct transform_request callers.
Deployment-level and per-request flags both arrive in optional_params (see
TestAgentCoreForwardToolsEndToEnd), so this branch only serves callers that
build litellm_params themselves.
"""
base_kwargs["optional_params"] = {"tools": [WEB_SEARCH_TOOL]}
base_kwargs["litellm_params"] = {"forward_tools": True}
payload = config.transform_request(messages=messages, **base_kwargs)
assert payload["tools"] == (WEB_SEARCH_TOOL,)
def test_optional_params_wins_over_litellm_params(
self, config, messages, base_kwargs
):
"""optional_params is read first, so it wins over the litellm_params fallback."""
base_kwargs["optional_params"] = {
"tools": [WEB_SEARCH_TOOL],
"forward_tools": False,
}
base_kwargs["litellm_params"] = {"forward_tools": True}
payload = config.transform_request(messages=messages, **base_kwargs)
assert "tools" not in payload
def test_payload_does_not_alias_optional_params(
self, config, messages, base_kwargs
):
"""The payload holds an immutable copy, so it cannot alias the caller's list.
The forwarded value serializes to the same JSON array either way, so the wire
format is asserted here too rather than just the in-memory type.
"""
tools = [WEB_SEARCH_TOOL]
base_kwargs["optional_params"] = {"tools": tools, "forward_tools": True}
payload = config.transform_request(messages=messages, **base_kwargs)
assert isinstance(payload["tools"], tuple)
with pytest.raises(AttributeError):
payload["tools"].append({"type": "function", "function": {"name": "extra"}})
assert tools == [WEB_SEARCH_TOOL]
assert json.loads(json.dumps(payload))["tools"] == [WEB_SEARCH_TOOL]
def test_tools_and_multimodal_content_are_independent(self, config, base_kwargs):
"""Both opt-ins together → both keys present, neither disturbs the other."""
content = [
{"type": "text", "text": "what is this"},
{
"type": "image_url",
"image_url": {"url": "data:image/png;base64,aGVsbG8="},
},
]
tools = [WEB_SEARCH_TOOL]
base_kwargs["optional_params"] = {
"tools": tools,
"forward_tools": True,
"forward_multimodal_content": True,
}
payload = config.transform_request(
messages=[{"role": "user", "content": content}], **base_kwargs
)
assert payload["tools"] == tuple(tools)
assert payload["content"] == content
assert payload["prompt"] == "what is this"
def test_session_header_still_set_when_forwarding(
self, config, messages, base_kwargs
):
"""Forwarding must not disturb the existing header contract."""
base_kwargs["optional_params"] = {
"tools": [WEB_SEARCH_TOOL],
"forward_tools": True,
"runtimeSessionId": "session-abc",
}
config.transform_request(messages=messages, **base_kwargs)
assert (
base_kwargs["headers"]["X-Amzn-Bedrock-AgentCore-Runtime-Session-Id"]
== "session-abc"
)
class TestAgentCoreForwardToolsEndToEnd:
"""``tools`` only reaches transform_request via allowed_openai_params.
AgentCore reports no supported OpenAI params, so get_optional_params drops
``tools`` (or raises without drop_params). ``allowed_openai_params=["tools"]``
is the documented escape hatch, and is what makes forwarding reachable.
"""
def test_tools_dropped_without_allowed_openai_params(self):
optional_params = litellm.utils.get_optional_params(
model=AGENTCORE_TEST_MODEL,
custom_llm_provider="bedrock",
tools=[WEB_SEARCH_TOOL],
drop_params=True,
)
assert "tools" not in optional_params
def test_allowed_openai_params_carries_tools_to_the_transform(self):
optional_params = litellm.utils.get_optional_params(
model=AGENTCORE_TEST_MODEL,
custom_llm_provider="bedrock",
tools=[WEB_SEARCH_TOOL],
allowed_openai_params=["tools"],
)
assert optional_params["tools"] == [WEB_SEARCH_TOOL]
payload = AmazonAgentCoreConfig().transform_request(
model=AGENTCORE_TEST_MODEL,
messages=[{"role": "user", "content": "hi"}],
optional_params=optional_params,
litellm_params={"forward_tools": True},
headers={},
)
assert payload["tools"] == (WEB_SEARCH_TOOL,)
def test_flag_reaches_the_transform_via_optional_params(self):
"""The flag itself is not an OpenAI param, so it lands in optional_params.
get_litellm_params only forwards an allowlist of keys, which excludes
forward_tools, so this is the path every real caller takes: both the flag and
the tools list arrive in optional_params and litellm_params stays empty.
"""
optional_params = litellm.utils.get_optional_params(
model=AGENTCORE_TEST_MODEL,
custom_llm_provider="bedrock",
tools=[WEB_SEARCH_TOOL],
allowed_openai_params=["tools"],
forward_tools=True,
)
assert optional_params["forward_tools"] is True
payload = AmazonAgentCoreConfig().transform_request(
model=AGENTCORE_TEST_MODEL,
messages=[{"role": "user", "content": "hi"}],
optional_params=optional_params,
litellm_params={},
headers={},
)
assert payload["tools"] == (WEB_SEARCH_TOOL,)
def test_allowed_openai_params_alone_does_not_forward(self):
"""Both opt-ins are required; the escape hatch alone changes nothing."""
optional_params = litellm.utils.get_optional_params(
model=AGENTCORE_TEST_MODEL,
custom_llm_provider="bedrock",
tools=[WEB_SEARCH_TOOL],
allowed_openai_params=["tools"],
)
payload = AmazonAgentCoreConfig().transform_request(
model=AGENTCORE_TEST_MODEL,
messages=[{"role": "user", "content": "hi"}],
optional_params=optional_params,
litellm_params={},
headers={},
)
assert payload == {"prompt": "hi"}