This commit is contained in:
AlirezaKebriaei 2026-07-09 11:53:47 +02:00 • committed by GitHub
commit 9395bdfc54
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1241 additions and 38 deletions

View file

@ -74,3 +74,75 @@ The proxy then normalizes the client-facing response to the agent's pinned `prot
- `A2ACompletionBridgeTransformation` - Static methods for message format conversion
- `A2ACompletionBridgeHandler` - Static methods for handling requests (streaming/non-streaming)
## Agentic loop: MCP tools and agent-to-agent delegation
A plain bridge agent maps one A2A `message/send` to one `litellm.acompletion`, so
it can answer from the model alone but cannot use tools or other agents.
`agentic_loop.run_agentic_loop` turns that single call into a loop: the model is
offered the agent's tools, any tool or agent calls it makes are executed, their
results are fed back, and the loop repeats until the model stops asking for tools
and returns prose.
Two kinds of tool are offered to the model:
- MCP tools, when the agent has `enable_mcp_tools` set. The agent only ever sees
the servers its key is authorised for; entitlement is enforced downstream by
`user_api_key_auth`, not by the loop.
- One function tool per other agent the caller may reach, when the agent has
`enable_agent_calls` set. The tool name carries the target agent id, and a call
to it is dispatched as a real A2A `message/send` to that agent via
`asend_message`, so a delegated agent behaves exactly as if invoked directly.
Delegation is limited to one level so a sub-agent cannot recurse into further
hops.
### The empty-answer bug this fixes
`LiteLLM_Proxy_MCP_Handler._execute_tool_calls` returns each result as
`{tool_call_id, result, name}`, which is not a valid chat message. Feeding those
dicts straight back into the conversation left the model unable to see the
result, so it re-issued the same tool call on every iteration until the loop hit
its cap and returned an empty final answer. `_mcp_results_to_tool_messages`
reshapes each result into the `{role: "tool", tool_call_id, content}` form the
chat API expects, so the model sees the result, stops re-calling the tool, and
answers. The regression is covered in
`tests/test_litellm/a2a_protocol/test_agentic_loop.py`.
### Configuring an agent
Set these on the agent's `litellm_params` (via `POST /v1/agents`, the agent edit
API, or the dashboard agent create / Settings form):
- `prompt_id`: a stored prompt whose persona the agent answers in. A prompt that
carries a model overrides the agent's model; a prompt with no model leaves the
agent's own model in place. Clear the model in the prompt editor (or omit the
`model:` line in the dotprompt) to have the agent use its own model.
- `enable_mcp_tools`: allow the agent to call its entitled MCP tools.
- `enable_agent_calls`: allow the agent to delegate to other agents it can reach.
The agent's `model` may be provider-prefixed (`gemini/gemini-flash-latest`) or a
bare proxy model-list alias (`gemini-flash`); the endpoint resolves the alias
against the model list and derives the provider so the bridge can route without
an explicit URL.
### Invoking an agent
```bash
curl -sS -X POST "http://localhost:4000/a2a/$AGENT_ID" \
-H "Authorization: Bearer $LITELLM_KEY" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": "1",
"method": "message/send",
"params": {
"message": {
"role": "user",
"parts": [{ "kind": "text", "text": "your question here" }],
"messageId": "m-1"
}
}
}'
```
The answer comes back in `result.parts[].text`.

View file

@ -0,0 +1,214 @@
"""Agentic tool loop for completion-bridge A2A agents.
Drives the model in a loop (model -> tool/agent calls -> execute -> feed results
back -> repeat) until it produces a final answer. An agent can call MCP tools
and delegate to other A2A agents; the inter-agent hop is a real A2A message/send
supplied by the proxy layer via ``call_agent``.
"""
import json
from typing import (
TYPE_CHECKING,
Any,
Awaitable,
Callable,
Iterable,
Mapping,
Optional,
cast,
)
import litellm
from litellm._logging import verbose_logger
if TYPE_CHECKING:
from litellm.proxy._types import UserAPIKeyAuth
AGENT_TOOL_PREFIX = "agent__"
DEFAULT_MAX_ITERATIONS = 8
def _mcp_results_to_tool_messages(
tool_results: Iterable[Mapping[str, object]],
) -> tuple[dict[str, object], ...]:
"""``_execute_tool_calls`` returns ``{tool_call_id, result, name}``, but the
chat API needs ``{role: "tool", tool_call_id, content}``; without this
conversion the model never sees the result and re-calls the tool until the
loop is exhausted, yielding an empty final answer."""
return tuple(
{
"role": "tool",
"tool_call_id": result.get("tool_call_id"),
"content": str(result.get("result", "")),
}
for result in tool_results
)
def _build_agent_tools(callable_agents: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""One function tool per callable agent. The model picks an agent by name;
the tool name carries the target agent id so we can route the A2A hop."""
tools: list[dict[str, Any]] = []
for agent in callable_agents:
agent_id = agent.get("agent_id") or agent.get("id")
if not agent_id:
continue
name = agent.get("name") or agent_id
description = (
agent.get("description")
or f"Delegate a self-contained task to the '{name}' specialist agent and use its reply."
)
tools.append(
{
"type": "function",
"function": {
"name": f"{AGENT_TOOL_PREFIX}{agent_id}",
"description": description,
"parameters": {
"type": "object",
"properties": {
"message": {
"type": "string",
"description": "The complete instruction and all facts the specialist needs; it has no prior context.",
}
},
"required": ["message"],
},
},
}
)
return tools
async def _load_mcp_tools(
user_api_key_auth: Optional["UserAPIKeyAuth"],
litellm_trace_id: Optional[str],
):
"""Returns (openai_tools, tool_server_map) for the agent's allowed MCP servers."""
from litellm.responses.mcp.litellm_proxy_mcp_handler import (
LiteLLM_Proxy_MCP_Handler,
)
mcp_tools_spec = [
{"type": "mcp", "server_url": "litellm_proxy", "require_approval": "never"}
]
(
deduplicated,
tool_server_map,
) = await LiteLLM_Proxy_MCP_Handler._process_mcp_tools_without_openai_transform(
user_api_key_auth=user_api_key_auth,
mcp_tools_with_litellm_proxy=mcp_tools_spec,
litellm_trace_id=litellm_trace_id,
)
openai_tools = LiteLLM_Proxy_MCP_Handler._transform_mcp_tools_to_openai(
deduplicated, target_format="chat"
)
return openai_tools, tool_server_map
async def run_agentic_loop(
*,
model: str,
messages: list[dict[str, Any]],
completion_kwargs: dict[str, Any],
callable_agents: list[dict[str, Any]],
call_agent: Callable[[str, str], Awaitable[str]],
user_api_key_auth: Optional["UserAPIKeyAuth"] = None,
enable_mcp_tools: bool = False,
max_iterations: int = DEFAULT_MAX_ITERATIONS,
):
"""
Drive the model until it stops requesting tools/agents.
- Agent (``agent__<id>``) tool calls -> real A2A hop via ``call_agent``.
- MCP tool calls -> executed against the proxy MCP manager (auth-scoped).
Returns the final ModelResponse.
"""
agent_tools = _build_agent_tools(callable_agents)
mcp_openai_tools: list[dict[str, Any]] = []
tool_server_map: dict[str, str] = {}
if enable_mcp_tools:
mcp_openai_tools, tool_server_map = await _load_mcp_tools(
user_api_key_auth=user_api_key_auth,
litellm_trace_id=completion_kwargs.get("litellm_trace_id"),
)
all_tools = (mcp_openai_tools + agent_tools) or None
messages = list(messages)
response = None
for _ in range(max_iterations):
response = await litellm.acompletion(
model=model,
messages=messages,
tools=all_tools,
stream=False,
**completion_kwargs,
)
message = response.choices[0].message
tool_calls = getattr(message, "tool_calls", None) or []
if not tool_calls:
break
messages.append(
cast(
"dict[str, Any]",
message.model_dump() if hasattr(message, "model_dump") else message,
)
)
agent_calls = [
tc
for tc in tool_calls
if (tc.function.name or "").startswith(AGENT_TOOL_PREFIX)
]
mcp_calls = [tc for tc in tool_calls if tc not in agent_calls]
if mcp_calls:
from litellm.responses.mcp.litellm_proxy_mcp_handler import (
LiteLLM_Proxy_MCP_Handler,
)
tool_results = await LiteLLM_Proxy_MCP_Handler._execute_tool_calls(
tool_server_map=tool_server_map,
tool_calls=mcp_calls,
user_api_key_auth=user_api_key_auth,
)
messages.extend(_mcp_results_to_tool_messages(tool_results))
for tool_call in agent_calls:
target_agent_id = (tool_call.function.name or "")[len(AGENT_TOOL_PREFIX) :]
try:
arguments = json.loads(tool_call.function.arguments or "{}")
except json.JSONDecodeError:
arguments = {}
sub_message = arguments.get("message", "")
try:
reply = await call_agent(target_agent_id, sub_message)
except Exception as e:
verbose_logger.warning(
f"A2A sub-agent call to '{target_agent_id}' failed: {e}"
)
reply = f"Error calling agent '{target_agent_id}': {e}"
messages.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": reply,
}
)
# If the loop ended on a tool-calling turn (no prose), make one tool-free
# call so the agent summarises instead of returning an empty message.
final_message = response.choices[0].message if response is not None else None
final_text = getattr(final_message, "content", None) if final_message else None
if not (final_text or "").strip():
response = await litellm.acompletion(
model=model,
messages=messages,
stream=False,
**completion_kwargs,
)
return response

View file

@ -33,11 +33,73 @@ _AGENT_ONLY_PARAMS = frozenset(
"agent_name",
"agent_id",
"agent_card_params",
"enable_mcp_tools",
"user_api_key_auth",
"callable_agents",
"call_agent",
A2A_USER_API_KEY_HASH_PARAM,
}
)
def _mcp_tools_for_agent(litellm_params: dict[str, Any]) -> Optional[list]:
"""Expose the proxy MCP tool for an MCP-entitled agent. Which servers the
agent can actually reach is enforced downstream by ``user_api_key_auth``."""
if not litellm_params.get("enable_mcp_tools"):
return None
return [{"type": "mcp", "server_url": "litellm_proxy", "require_approval": "never"}]
async def _run_completion(
completion_params: dict[str, Any],
litellm_params: dict[str, Any],
):
"""Run the model call, routing to the agentic loop when the agent can call
other agents, to ``acompletion_with_mcp`` when it only has MCP tools, or a
plain completion otherwise."""
callable_agents = litellm_params.get("callable_agents")
call_agent = litellm_params.get("call_agent")
if callable_agents and call_agent is not None:
from litellm.a2a_protocol.litellm_completion_bridge.agentic_loop import (
run_agentic_loop,
)
loop_kwargs = dict(completion_params)
model = loop_kwargs.pop("model")
messages = loop_kwargs.pop("messages")
loop_kwargs.pop("stream", None)
# The loop owns the tool list (MCP tools + agent-as-tools), so drop any
# tools/tool_choice carried in from the agent's litellm_params or they
# collide with the explicit tools= the loop passes.
loop_kwargs.pop("tools", None)
loop_kwargs.pop("tool_choice", None)
return await run_agentic_loop(
model=model,
messages=messages,
completion_kwargs=loop_kwargs,
callable_agents=callable_agents,
call_agent=call_agent,
user_api_key_auth=litellm_params.get("user_api_key_auth"),
enable_mcp_tools=bool(litellm_params.get("enable_mcp_tools")),
)
mcp_tools = _mcp_tools_for_agent(litellm_params)
if mcp_tools is None:
return await litellm.acompletion(**completion_params)
from litellm.responses.mcp.chat_completions_handler import acompletion_with_mcp
# Same collision guard: acompletion_with_mcp passes its own tools= explicitly.
mcp_kwargs = {
k: v for k, v in completion_params.items() if k not in ("tools", "tool_choice")
}
return await acompletion_with_mcp(
tools=mcp_tools,
user_api_key_auth=litellm_params.get("user_api_key_auth"),
**mcp_kwargs,
)
class A2ACompletionBridgeHandler:
"""
Static methods for handling A2A requests via LiteLLM completion.
@ -46,13 +108,13 @@ class A2ACompletionBridgeHandler:
@staticmethod
async def handle_non_streaming(
request_id: str,
params: Dict[str, Any],
litellm_params: Dict[str, Any],
params: dict[str, Any],
litellm_params: dict[str, Any],
api_base: Optional[str] = None,
agent_extra_headers: Optional[Dict[str, str]] = None,
agent_extra_headers: Optional[dict[str, str]] = None,
*,
_skip_a2a_provider_routing: bool = False,
) -> Dict[str, Any]:
) -> dict[str, Any]:
"""
Handle non-streaming A2A request via litellm.acompletion.
@ -133,8 +195,8 @@ class A2ACompletionBridgeHandler:
static_headers=completion_params.get("extra_headers"),
)
# Call litellm.acompletion
response = await litellm.acompletion(**completion_params)
# Call the model (auto-executing MCP tools in a loop if the agent has any)
response = await _run_completion(completion_params, litellm_params)
# Transform response to A2A format
a2a_response = A2ACompletionBridgeTransformation.openai_response_to_a2a_response(
@ -264,24 +326,31 @@ class A2ACompletionBridgeHandler:
)
yield working_event
# Call litellm.acompletion with streaming
response = await litellm.acompletion(**completion_params)
# Call the model with streaming (auto-executing MCP tools if the agent has any)
response = await _run_completion(completion_params, litellm_params)
# 3. Accumulate content and emit artifact update
# 3. Accumulate content and emit artifact update. The agentic loop / MCP
# tool execution resolve to a single non-streamed response; only a plain
# completion yields an async stream, so handle both shapes.
accumulated_text = ""
chunk_count = 0
async for chunk in response: # type: ignore[union-attr]
chunk_count += 1
if hasattr(response, "__aiter__"):
async for chunk in response: # type: ignore[union-attr]
chunk_count += 1
# Extract delta content
content = ""
if chunk is not None and hasattr(chunk, "choices") and chunk.choices:
choice = chunk.choices[0]
if hasattr(choice, "delta") and choice.delta:
content = choice.delta.content or ""
content = ""
if chunk is not None and hasattr(chunk, "choices") and chunk.choices:
choice = chunk.choices[0]
if hasattr(choice, "delta") and choice.delta:
content = choice.delta.content or ""
if content:
accumulated_text += content
if content:
accumulated_text += content
else:
chunk_count = 1
choices = getattr(response, "choices", None)
if choices:
accumulated_text = getattr(choices[0].message, "content", None) or ""
# Emit artifact update with accumulated content
if accumulated_text:

View file

@ -11,8 +11,10 @@ The A2A SDK can point to LiteLLM's URL and invoke agents registered with LiteLLM
"""
import json
from collections.abc import Awaitable, Callable
from copy import deepcopy
from typing import TYPE_CHECKING, Any, AsyncGenerator, Dict, List
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, AsyncGenerator, Dict, List, Optional, cast
from urllib.parse import urlparse
from fastapi import APIRouter, Depends, HTTPException, Request, Response
@ -570,6 +572,159 @@ async def get_agent_card(
raise HTTPException(status_code=500, detail=str(e))
def _resolve_model_and_provider(
litellm_params: dict[str, Any],
model_list: Optional[list[dict[str, Any]]],
) -> tuple[dict[str, Any], Optional[str]]:
"""A completion-bridge agent needs a provider-prefixed model and a
custom_llm_provider. A UI-created agent may carry a bare proxy model-list
alias (e.g. "gemini-flash") with no provider; resolve the alias against the
model list, then derive the provider from the "provider/model" prefix so the
bridge can route without a URL."""
custom_llm_provider = litellm_params.get("custom_llm_provider")
if custom_llm_provider:
return litellm_params, custom_llm_provider
agent_model = litellm_params.get("model")
if isinstance(agent_model, str) and "/" not in agent_model:
for deployment in model_list or []:
if deployment.get("model_name") == agent_model:
resolved = (deployment.get("litellm_params") or {}).get("model")
if isinstance(resolved, str):
agent_model = resolved
break
litellm_params = {**litellm_params, "model": agent_model}
if isinstance(agent_model, str) and "/" in agent_model:
custom_llm_provider = agent_model.split("/", 1)[0]
litellm_params = {**litellm_params, "custom_llm_provider": custom_llm_provider}
return litellm_params, custom_llm_provider
def _scope_auth_to_agent(
user_api_key_auth: Optional[UserAPIKeyAuth], agent_id: str
) -> Optional[UserAPIKeyAuth]:
"""Bind the caller's auth to ``agent_id`` so the MCP permission handler applies
the agent's own object_permission (caller perms intersected with the agent's
allowlist). Without ``agent_id`` set only the caller's key/team perms gate MCP,
which would let an agent reach tools its own permissions exclude. Returns a
copy so the shared caller auth object is never mutated."""
if user_api_key_auth is not None and hasattr(user_api_key_auth, "model_copy"):
return user_api_key_auth.model_copy(update={"agent_id": agent_id})
return user_api_key_auth
async def _source_agent_allowed_targets(
source_auth: Optional[UserAPIKeyAuth],
) -> set | None:
"""Agent ids the source agent itself may call, from its own
object_permission.agents plus agents resolved from its access groups. None
means the source agent has no agent restriction. Delegation targets are
intersected with this (not only the caller's perms), so a prompt-injected
delegation cannot exceed what the source agent was granted."""
if source_auth is None:
return None
from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
MCPRequestHandler,
)
from litellm.proxy.agent_endpoints.auth.agent_permission_handler import (
AgentRequestHandler,
)
obj_perm = await MCPRequestHandler._get_agent_object_permission(
user_api_key_auth=source_auth
)
if obj_perm is None:
return None
allowed = set(getattr(obj_perm, "agents", None) or [])
allowed |= set(
await AgentRequestHandler._get_agents_from_access_groups(
getattr(obj_perm, "agent_access_groups", None) or []
)
)
return allowed or None
@dataclass(frozen=True, slots=True)
class _DelegationContext:
allowed_agent_ids: set
get_agent: Callable[[str], Any]
user_api_key_auth: Optional[UserAPIKeyAuth]
send_message: Callable[..., Awaitable[Any]]
async def _delegate_to_agent(
*,
target_id: str,
message: str,
context: _DelegationContext,
) -> str:
"""Dispatch a sub-agent call and return its text reply.
The target id comes from a model tool call, so it is attacker-influenceable
via prompt injection, and ``send_message`` bypasses the endpoint's auth gate.
The auth-filtered ``allowed_agent_ids`` whitelist is therefore re-enforced
here so a caller can never reach an agent it was not granted."""
if target_id not in context.allowed_agent_ids:
return f"Agent '{target_id}' is not available."
target = context.get_agent(target_id)
if target is None:
return f"Agent '{target_id}' not found."
# The target's proxy-side guardrails are enforced by the endpoint request
# pipeline, which this direct dispatch bypasses. Rather than return output a
# guardrail would have blocked on a direct call, refuse to delegate to a
# guarded agent (it can still be invoked directly).
if (target.litellm_params or {}).get("guardrails"):
return f"Agent '{target_id}' cannot be called indirectly."
from uuid import uuid4
from a2a.types import Message, MessageSendParams, SendMessageRequest
target_card = target.agent_card_params or {}
# Strip delegation flags so a sub-agent cannot recurse into further
# agent-to-agent calls (one level of delegation).
target_params = {
k: v
for k, v in (target.litellm_params or {}).items()
if k not in ("enable_agent_calls", "callable_agents", "call_agent")
}
target_params["user_api_key_auth"] = _scope_auth_to_agent(
context.user_api_key_auth, target.agent_id
)
sub_request = SendMessageRequest(
id=uuid4().hex,
params=MessageSendParams(
message=cast(
Message,
{
"role": "user",
"parts": [{"kind": "text", "text": message}],
"messageId": uuid4().hex,
},
)
),
)
sub_response = await context.send_message(
request=sub_request,
api_base=target_card.get("url"),
litellm_params=target_params,
agent_id=target.agent_id,
)
dumped = (
sub_response.model_dump()
if hasattr(sub_response, "model_dump")
else sub_response
)
parts = ((dumped or {}).get("result") or {}).get("parts") or []
return "\n".join(p.get("text", "") for p in parts if p.get("kind") == "text")
@router.post(
"/a2a/{agent_id}",
tags=["[beta] A2A Agents"],
@ -669,7 +824,12 @@ async def invoke_agent_a2a(
# Get litellm_params (may include custom_llm_provider for completion bridge)
litellm_params = agent.litellm_params or {}
custom_llm_provider = litellm_params.get("custom_llm_provider")
from litellm.proxy.proxy_server import llm_router
litellm_params, custom_llm_provider = _resolve_model_and_provider(
litellm_params,
llm_router.model_list if llm_router is not None else None,
)
# Hand the authenticated key hash to the completion bridge so provider
# configs can scope provider-side session state per key (e.g. LangFlow
@ -684,6 +844,74 @@ async def invoke_agent_a2a(
A2A_USER_API_KEY_HASH_PARAM: user_api_key_dict.api_key,
}
# MCP tools and/or agent-to-agent delegation both run inside the completion
# bridge; hand the caller's auth bound to this agent so MCP tools are scoped
# to the caller's perms intersected with the agent's own allowlist.
if custom_llm_provider and (
litellm_params.get("enable_mcp_tools")
or litellm_params.get("enable_agent_calls")
):
litellm_params = {
**litellm_params,
"user_api_key_auth": _scope_auth_to_agent(
user_api_key_dict, agent.agent_id
),
}
# Agents-as-tools: expose the other agents this caller may reach, and
# dispatch each call as a real A2A message/send to the target agent.
if custom_llm_provider and litellm_params.get("enable_agent_calls"):
from litellm.a2a_protocol import asend_message
from litellm.proxy.agent_endpoints.agent_registry import (
global_agent_registry,
)
source_allowed_targets = await _source_agent_allowed_targets(
_scope_auth_to_agent(user_api_key_dict, agent.agent_id)
)
callable_agents: list[dict[str, Any]] = []
for other in global_agent_registry.get_agent_list():
if other.agent_id == agent.agent_id:
continue
if not await AgentRequestHandler.is_agent_allowed(
agent_id=other.agent_id, user_api_key_auth=user_api_key_dict
):
continue
if (
source_allowed_targets is not None
and other.agent_id not in source_allowed_targets
):
continue
other_card = other.agent_card_params or {}
callable_agents.append(
{
"agent_id": other.agent_id,
"name": other_card.get("name", other.agent_id),
"description": other_card.get("description", ""),
}
)
allowed_agent_ids = {a["agent_id"] for a in callable_agents}
async def _call_agent(target_id: str, message: str) -> str:
return await _delegate_to_agent(
target_id=target_id,
message=message,
context=_DelegationContext(
allowed_agent_ids=allowed_agent_ids,
get_agent=_get_agent,
user_api_key_auth=user_api_key_dict,
send_message=asend_message,
),
)
litellm_params = {
**litellm_params,
"callable_agents": callable_agents,
"call_agent": _call_agent,
}
# URL is required unless using completion bridge with a provider that derives endpoint from model
# (e.g., bedrock/agentcore derives endpoint from ARN in model string)
if not agent_url and not custom_llm_provider:

View file

@ -0,0 +1,312 @@
"""Tests for the completion-bridge agentic loop and its completion routing.
Covers the helpers the loop is built on (turning MCP tool results into chat tool
messages, exposing other A2A agents as function tools), the loop driver itself
(MCP tool execution, agent delegation, and the non-empty-answer guarantee), and
the routing in ``_run_completion``.
"""
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from litellm.a2a_protocol.litellm_completion_bridge import handler
from litellm.a2a_protocol.litellm_completion_bridge.agentic_loop import (
AGENT_TOOL_PREFIX,
_build_agent_tools,
_mcp_results_to_tool_messages,
run_agentic_loop,
)
def _tool_call(call_id: str, name: str, arguments: str = "{}"):
return SimpleNamespace(
id=call_id, function=SimpleNamespace(name=name, arguments=arguments)
)
def _response(content=None, tool_calls=None):
message = SimpleNamespace(
content=content,
tool_calls=tool_calls,
model_dump=lambda: {"role": "assistant", "content": content},
)
return SimpleNamespace(choices=[SimpleNamespace(message=message)])
def test_mcp_results_become_tool_role_messages():
"""Regression: ``_execute_tool_calls`` returns ``{tool_call_id, result,
name}``, which is not a valid chat message. Feeding that back verbatim left
the model blind to the result, so it re-called the tool until the loop was
exhausted and produced an empty answer. The result must be reshaped into a
``tool`` role message whose ``content`` holds the result text."""
raw = [{"tool_call_id": "call_1", "result": "sunny, 21C", "name": "get_weather"}]
messages = _mcp_results_to_tool_messages(raw)
assert messages == (
{"role": "tool", "tool_call_id": "call_1", "content": "sunny, 21C"},
)
# The original keys must not survive: leaking "result"/"name" or dropping
# "role" is exactly the bug.
assert "result" not in messages[0]
assert "name" not in messages[0]
def test_mcp_results_coerce_non_string_results_and_preserve_order():
raw = [
{"tool_call_id": "a", "result": {"city": "Paris", "temp_c": 21}},
{"tool_call_id": "b", "result": None},
]
messages = _mcp_results_to_tool_messages(raw)
assert [m["tool_call_id"] for m in messages] == ["a", "b"]
assert all(m["role"] == "tool" for m in messages)
assert isinstance(messages[0]["content"], str)
assert "Paris" in messages[0]["content"]
def test_build_agent_tools_one_function_tool_per_agent():
tools = _build_agent_tools(
[
{
"agent_id": "translator",
"name": "Translator",
"description": "Translates text",
},
{"agent_id": "summariser"},
]
)
assert len(tools) == 2
first = tools[0]["function"]
assert first["name"] == f"{AGENT_TOOL_PREFIX}translator"
assert first["description"] == "Translates text"
# The model must supply a self-contained message for the sub-agent.
assert first["parameters"]["required"] == ["message"]
assert first["parameters"]["properties"]["message"]["type"] == "string"
# An agent without its own description still gets a usable default.
assert "summariser" in tools[1]["function"]["description"]
def test_build_agent_tools_skips_agents_without_id():
tools = _build_agent_tools([{"name": "no id here"}, {"id": "kept"}])
names = [t["function"]["name"] for t in tools]
assert names == [f"{AGENT_TOOL_PREFIX}kept"]
@pytest.mark.asyncio
async def test_loop_delegates_to_agent_then_answers():
"""An agent tool call routes through call_agent, the reply is fed back as a
tool message, and the loop returns the model's final prose."""
call_agent = AsyncMock(return_value="bonjour")
with patch("litellm.acompletion", new_callable=AsyncMock) as acompletion:
acompletion.side_effect = [
_response(
tool_calls=[
_tool_call(
"c1",
f"{AGENT_TOOL_PREFIX}translator",
json.dumps({"message": "say hi in french"}),
)
]
),
_response(content="done"),
]
result = await run_agentic_loop(
model="gemini/gemini-flash-latest",
messages=[{"role": "user", "content": "hi"}],
completion_kwargs={},
callable_agents=[{"agent_id": "translator", "name": "Translator"}],
call_agent=call_agent,
)
assert result.choices[0].message.content == "done"
call_agent.assert_awaited_once_with("translator", "say hi in french")
fed_back = acompletion.await_args_list[1].kwargs["messages"]
assert {"role": "tool", "tool_call_id": "c1", "content": "bonjour"} in fed_back
@pytest.mark.asyncio
async def test_loop_executes_mcp_tool_and_feeds_result_back():
"""Regression at the loop level: an MCP tool result is reshaped to a tool
message so the model sees it and answers instead of re-calling the tool."""
handler_path = (
"litellm.responses.mcp.litellm_proxy_mcp_handler.LiteLLM_Proxy_MCP_Handler"
)
with (
patch("litellm.acompletion", new_callable=AsyncMock) as acompletion,
patch(
"litellm.a2a_protocol.litellm_completion_bridge.agentic_loop._load_mcp_tools",
new_callable=AsyncMock,
) as load_mcp,
patch(
f"{handler_path}._execute_tool_calls", new_callable=AsyncMock
) as execute_tool_calls,
):
acompletion.side_effect = [
_response(tool_calls=[_tool_call("t1", "get_weather")]),
_response(content="It is sunny"),
]
load_mcp.return_value = (
[{"type": "function", "function": {"name": "get_weather"}}],
{"get_weather": "server-1"},
)
execute_tool_calls.return_value = [
{"tool_call_id": "t1", "result": "sunny", "name": "get_weather"}
]
result = await run_agentic_loop(
model="gemini/gemini-flash-latest",
messages=[{"role": "user", "content": "weather?"}],
completion_kwargs={},
callable_agents=[],
call_agent=AsyncMock(),
enable_mcp_tools=True,
)
assert result.choices[0].message.content == "It is sunny"
fed_back = acompletion.await_args_list[1].kwargs["messages"]
assert {"role": "tool", "tool_call_id": "t1", "content": "sunny"} in fed_back
@pytest.mark.asyncio
async def test_loop_makes_tool_free_final_call_when_last_turn_has_no_prose():
"""If the loop is exhausted on a tool-calling turn, one tool-free call is made
so the agent answers instead of returning an empty message."""
with patch("litellm.acompletion", new_callable=AsyncMock) as acompletion:
acompletion.side_effect = [
_response(tool_calls=[_tool_call("a1", f"{AGENT_TOOL_PREFIX}t")]),
_response(content="final summary"),
]
result = await run_agentic_loop(
model="m",
messages=[{"role": "user", "content": "x"}],
completion_kwargs={},
callable_agents=[{"agent_id": "t"}],
call_agent=AsyncMock(return_value="r"),
max_iterations=1,
)
assert result.choices[0].message.content == "final summary"
assert acompletion.await_count == 2
# the final call is tool-free
assert "tools" not in acompletion.await_args_list[1].kwargs
@pytest.mark.asyncio
async def test_loop_surfaces_sub_agent_failure_without_crashing():
"""If a delegated agent call raises, the loop records the error as the tool
reply and still returns a final answer instead of propagating the exception."""
call_agent = AsyncMock(side_effect=RuntimeError("boom"))
with patch("litellm.acompletion", new_callable=AsyncMock) as acompletion:
acompletion.side_effect = [
_response(tool_calls=[_tool_call("c1", f"{AGENT_TOOL_PREFIX}flaky")]),
_response(content="handled"),
]
result = await run_agentic_loop(
model="m",
messages=[{"role": "user", "content": "x"}],
completion_kwargs={},
callable_agents=[{"agent_id": "flaky"}],
call_agent=call_agent,
)
assert result.choices[0].message.content == "handled"
fed_back = acompletion.await_args_list[1].kwargs["messages"]
assert any(
m.get("role") == "tool"
and "Error calling agent 'flaky'" in str(m.get("content"))
for m in fed_back
)
@pytest.mark.asyncio
async def test_run_completion_routes_to_agentic_loop_when_agent_can_delegate():
with patch(
"litellm.a2a_protocol.litellm_completion_bridge.agentic_loop.run_agentic_loop",
new_callable=AsyncMock,
) as loop:
loop.return_value = "LOOP"
result = await handler._run_completion(
{"model": "m", "messages": [{"role": "user", "content": "x"}]},
{"callable_agents": [{"agent_id": "a"}], "call_agent": AsyncMock()},
)
assert result == "LOOP"
loop.assert_awaited_once()
@pytest.mark.asyncio
async def test_run_completion_routes_to_mcp_when_only_tools_enabled():
with patch(
"litellm.responses.mcp.chat_completions_handler.acompletion_with_mcp",
new_callable=AsyncMock,
) as acompletion_with_mcp:
acompletion_with_mcp.return_value = "MCP"
result = await handler._run_completion(
{"model": "m", "messages": []}, {"enable_mcp_tools": True}
)
assert result == "MCP"
acompletion_with_mcp.assert_awaited_once()
@pytest.mark.asyncio
async def test_run_completion_plain_when_no_tools_or_agents():
with patch("litellm.acompletion", new_callable=AsyncMock) as acompletion:
acompletion.return_value = "PLAIN"
result = await handler._run_completion({"model": "m", "messages": []}, {})
assert result == "PLAIN"
acompletion.assert_awaited_once()
@pytest.mark.asyncio
async def test_run_completion_strips_agent_tools_before_agentic_loop():
"""Regression: an agent carrying `tools`/`tool_choice` in its litellm_params
must not reach the loop's completion_kwargs, or they collide with the explicit
tools= the loop passes (TypeError: got multiple values for 'tools')."""
with patch(
"litellm.a2a_protocol.litellm_completion_bridge.agentic_loop.run_agentic_loop",
new_callable=AsyncMock,
) as loop:
loop.return_value = "LOOP"
await handler._run_completion(
{
"model": "m",
"messages": [],
"tools": [{"type": "function", "function": {"name": "x"}}],
"tool_choice": "auto",
},
{"callable_agents": [{"agent_id": "a"}], "call_agent": AsyncMock()},
)
completion_kwargs = loop.await_args.kwargs["completion_kwargs"]
assert "tools" not in completion_kwargs
assert "tool_choice" not in completion_kwargs
@pytest.mark.asyncio
async def test_run_completion_strips_agent_tools_on_mcp_path():
"""Same collision guard on the MCP path: acompletion_with_mcp passes tools=
explicitly, so a tools key from litellm_params must be dropped (else the call
raises before the request is even made)."""
with patch(
"litellm.responses.mcp.chat_completions_handler.acompletion_with_mcp",
new_callable=AsyncMock,
) as acompletion_with_mcp:
acompletion_with_mcp.return_value = "MCP"
result = await handler._run_completion(
{
"model": "m",
"messages": [],
"tools": [{"type": "function", "function": {"name": "x"}}],
},
{"enable_mcp_tools": True},
)
assert result == "MCP"

View file

@ -1916,6 +1916,205 @@ async def test_caller_identity_headers_cannot_be_spoofed_via_forwarded_headers()
), "authenticated team id must not be overridden by forwarded client headers"
def test_resolve_model_and_provider_resolves_bare_alias():
from litellm.proxy.agent_endpoints.a2a_endpoints import _resolve_model_and_provider
model_list = [
{
"model_name": "gemini-flash",
"litellm_params": {"model": "gemini/gemini-flash-latest"},
}
]
params, provider = _resolve_model_and_provider(
{"model": "gemini-flash"}, model_list
)
assert params["model"] == "gemini/gemini-flash-latest"
assert provider == "gemini"
def test_resolve_model_and_provider_keeps_explicit_provider():
from litellm.proxy.agent_endpoints.a2a_endpoints import _resolve_model_and_provider
params, provider = _resolve_model_and_provider(
{"model": "anything", "custom_llm_provider": "openai"}, None
)
assert provider == "openai"
assert params["model"] == "anything"
def test_resolve_model_and_provider_derives_provider_from_prefixed_model():
from litellm.proxy.agent_endpoints.a2a_endpoints import _resolve_model_and_provider
params, provider = _resolve_model_and_provider(
{"model": "anthropic/claude-x"}, None
)
assert provider == "anthropic"
def test_resolve_model_and_provider_leaves_unknown_alias_unresolved():
from litellm.proxy.agent_endpoints.a2a_endpoints import _resolve_model_and_provider
params, provider = _resolve_model_and_provider({"model": "mystery"}, [])
assert provider is None
assert params["model"] == "mystery"
@pytest.mark.asyncio
async def test_delegate_to_agent_rejects_target_outside_whitelist():
"""Security regression: the target id is model-supplied (prompt-injectable)
and send_message bypasses the endpoint auth gate, so an agent id outside the
auth-filtered whitelist must be refused before any dispatch or registry
lookup."""
from litellm.proxy.agent_endpoints.a2a_endpoints import (
_DelegationContext,
_delegate_to_agent,
)
send_message = AsyncMock()
get_agent = MagicMock()
reply = await _delegate_to_agent(
target_id="evil-agent",
message="hi",
context=_DelegationContext(
allowed_agent_ids={"allowed-agent"},
get_agent=get_agent,
user_api_key_auth=None,
send_message=send_message,
),
)
assert "not available" in reply
send_message.assert_not_awaited()
get_agent.assert_not_called()
@pytest.mark.asyncio
async def test_delegate_to_agent_does_not_dispatch_when_target_missing():
from litellm.proxy.agent_endpoints.a2a_endpoints import (
_DelegationContext,
_delegate_to_agent,
)
send_message = AsyncMock()
reply = await _delegate_to_agent(
target_id="known",
message="hi",
context=_DelegationContext(
allowed_agent_ids={"known"},
get_agent=lambda _id: None,
user_api_key_auth=None,
send_message=send_message,
),
)
assert "not found" in reply
send_message.assert_not_awaited()
def test_scope_auth_to_agent_binds_agent_id_without_mutating_caller():
"""The agent's MCP object_permission is only enforced when agent_id is set on
the auth, so the bridge must receive a copy bound to the agent; the shared
caller auth must not be mutated."""
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.agent_endpoints.a2a_endpoints import _scope_auth_to_agent
caller = UserAPIKeyAuth(api_key="sk-x", user_id="u")
scoped = _scope_auth_to_agent(caller, "agent-123")
assert scoped.agent_id == "agent-123"
assert caller.agent_id != "agent-123"
@pytest.mark.asyncio
async def test_delegate_to_agent_refuses_target_with_guardrails():
"""A target with proxy-side guardrails must not be reachable via delegation:
this path bypasses the guardrail/post-call enforcement a direct call runs, so
it is refused rather than returning output a guardrail would have blocked."""
from types import SimpleNamespace
from litellm.proxy.agent_endpoints.a2a_endpoints import (
_DelegationContext,
_delegate_to_agent,
)
send_message = AsyncMock()
guarded = SimpleNamespace(
agent_id="guarded",
agent_card_params={"url": "http://x"},
litellm_params={"guardrails": ["pii-mask"]},
)
reply = await _delegate_to_agent(
target_id="guarded",
message="hi",
context=_DelegationContext(
allowed_agent_ids={"guarded"},
get_agent=lambda _id: guarded,
user_api_key_auth=None,
send_message=send_message,
),
)
assert "cannot be called indirectly" in reply
send_message.assert_not_awaited()
_MCP_OBJ_PERM = (
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp."
"MCPRequestHandler._get_agent_object_permission"
)
_ACCESS_GROUPS = (
"litellm.proxy.agent_endpoints.auth.agent_permission_handler."
"AgentRequestHandler._get_agents_from_access_groups"
)
@pytest.mark.asyncio
async def test_source_agent_targets_unrestricted_without_object_permission():
from litellm.proxy.agent_endpoints.a2a_endpoints import (
_source_agent_allowed_targets,
)
with patch(_MCP_OBJ_PERM, new_callable=AsyncMock, return_value=None):
assert await _source_agent_allowed_targets(MagicMock()) is None
@pytest.mark.asyncio
async def test_source_agent_targets_intersect_agents_and_access_groups():
"""Regression: delegation must be limited to the source agent's own allowed
agents (object_permission.agents + access-group agents), not just the caller's."""
from types import SimpleNamespace
from litellm.proxy.agent_endpoints.a2a_endpoints import (
_source_agent_allowed_targets,
)
obj_perm = SimpleNamespace(agents=["a", "b"], agent_access_groups=["grp"])
with (
patch(_MCP_OBJ_PERM, new_callable=AsyncMock, return_value=obj_perm),
patch(_ACCESS_GROUPS, new_callable=AsyncMock, return_value=["c"]),
):
assert await _source_agent_allowed_targets(MagicMock()) == {"a", "b", "c"}
@pytest.mark.asyncio
async def test_source_agent_targets_empty_permission_is_unrestricted():
from types import SimpleNamespace
from litellm.proxy.agent_endpoints.a2a_endpoints import (
_source_agent_allowed_targets,
)
obj_perm = SimpleNamespace(agents=[], agent_access_groups=[])
with (
patch(_MCP_OBJ_PERM, new_callable=AsyncMock, return_value=obj_perm),
patch(_ACCESS_GROUPS, new_callable=AsyncMock, return_value=[]),
):
assert await _source_agent_allowed_targets(MagicMock()) is None
def _agent(protocol_version):
agent = MagicMock()
agent.agent_card_params = (

View file

@ -28,7 +28,14 @@ const ModelConfigCard: React.FC<ModelConfigCardProps> = ({
return (
<div className="flex items-center gap-3">
<div className="w-[300px]">
<ModelSelector accessToken={accessToken || ""} value={model} onChange={onModelChange} showLabel={false} />
<ModelSelector
accessToken={accessToken || ""}
value={model}
onChange={onModelChange}
showLabel={false}
allowClear={true}
placeholder="No model (use calling agent's model)"
/>
</div>
<button

View file

@ -200,6 +200,22 @@ describe("convertToDotPrompt", () => {
expect(result).toContain("Assistant: Hi there");
expect(result).toContain("User: How are you?");
});
it("omits the model line when no model is set so the calling agent's model is used", () => {
const prompt: PromptType = {
name: "test",
model: "",
config: {},
tools: [],
developerMessage: "",
messages: [{ role: "user", content: "Hello" }],
};
const result = convertToDotPrompt(prompt);
expect(result).not.toContain("model:");
expect(result.startsWith("---")).toBe(true);
expect(result).toContain("User: Hello");
});
});
describe("parseExistingPrompt", () => {
@ -227,6 +243,27 @@ User: Hello world`,
expect(result.messages).toEqual([{ role: "user", content: "Hello world" }]);
});
it("returns an empty model when the frontmatter has none so the agent model is used", () => {
const apiResponse = {
prompt_spec: {
litellm_params: {
dotprompt_content: `---
input:
schema:
output:
format: text
---
User: Hello world`,
},
prompt_id: "no-model-prompt",
},
};
const result = parseExistingPrompt(apiResponse);
expect(result.model).toBe("");
});
it("should parse with config parameters", () => {
const apiResponse = {
prompt_spec: {

View file

@ -23,7 +23,9 @@ export const extractVariables = (prompt: PromptType): string[] => {
export const convertToDotPrompt = (prompt: PromptType): string => {
const variables = extractVariables(prompt);
let result = `---\nmodel: ${prompt.model}\n`;
// No model means "use the calling agent's / request model": omit it from the
// frontmatter so prompt management does not override the caller's model.
let result = prompt.model ? `---\nmodel: ${prompt.model}\n` : `---\n`;
// Add temperature if set
if (prompt.config.temperature !== undefined) {
@ -237,7 +239,7 @@ export const parseExistingPrompt = (apiResponse: any): PromptType => {
return {
name: baseName,
model: parsedFrontmatter.model || "gpt-4o",
model: parsedFrontmatter.model || "",
config: parsedFrontmatter.config,
tools: parsedFrontmatter.tools,
developerMessage: parsedBody.developerMessage,

View file

@ -9,6 +9,7 @@ import {
createAgentCall,
getAgentCreateMetadata,
getAgentsList,
getPromptsList,
keyCreateForAgentCall,
keyListCall,
keyUpdateCall,
@ -60,6 +61,7 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
const [loadingModels, setLoadingModels] = useState(false);
const [availableAgents, setAvailableAgents] = useState<{ agent_id: string; agent_name: string }[]>([]);
const [loadingAgents, setLoadingAgents] = useState(false);
const [promptOptions, setPromptOptions] = useState<string[]>([]);
// Step 4: results
const [createdAgentName, setCreatedAgentName] = useState<string>("");
@ -95,6 +97,13 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
fetchMetadata();
}, []);
useEffect(() => {
if (!accessToken) return;
getPromptsList(accessToken)
.then((res) => setPromptOptions(res.prompts.map((p) => p.prompt_id)))
.catch((error) => console.error("Error fetching prompts:", error));
}, [accessToken]);
// Fetch existing keys when Agent Management step becomes active (step 3)
useEffect(() => {
if (currentStep === 3 && accessToken && existingKeys.length === 0) {
@ -748,10 +757,10 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
</Form.Item>
</div>
) : agentType === "a2a" ? (
<AgentFormFields showAgentName={true} />
<AgentFormFields showAgentName={true} promptOptions={promptOptions} />
) : selectedAgentTypeInfo?.use_a2a_form_fields ? (
<>
<AgentFormFields showAgentName={true} />
<AgentFormFields showAgentName={true} promptOptions={promptOptions} />
{selectedAgentTypeInfo.credential_fields.length > 0 && (
<div className="mt-4 p-4 border border-gray-200 rounded-lg">
<h4 className="text-sm font-medium text-gray-700 mb-3">

View file

@ -152,6 +152,25 @@ export const AGENT_FORM_CONFIG: {
label: "Make Public",
type: "switch",
},
{
name: "prompt_id",
label: "Prompt",
type: "select",
placeholder: "Select a stored prompt to apply (optional)",
tooltip: "The agent answers in this prompt's persona. Create prompts under Experimental > Prompts.",
},
{
name: "enable_mcp_tools",
label: "Enable MCP Tools",
type: "switch",
tooltip: "Let the agent call the MCP tools it is entitled to, executing them in a loop.",
},
{
name: "enable_agent_calls",
label: "Enable Agent-to-Agent Calls",
type: "switch",
tooltip: "Let the agent delegate to other agents it can access (agents-as-tools over A2A).",
},
],
},
cost: {
@ -281,6 +300,9 @@ export const buildAgentDataFromForm = (values: any, existingAgent?: any) => {
if (values.model) params.model = values.model;
if (values.make_public !== undefined) params.make_public = values.make_public;
if (values.prompt_id) params.prompt_id = values.prompt_id;
if (values.enable_mcp_tools !== undefined) params.enable_mcp_tools = values.enable_mcp_tools;
if (values.enable_agent_calls !== undefined) params.enable_agent_calls = values.enable_agent_calls;
if (values.cost_per_query) params.cost_per_query = parseFloat(values.cost_per_query);
if (values.input_cost_per_token) params.input_cost_per_token = parseFloat(values.input_cost_per_token);
if (values.output_cost_per_token) params.output_cost_per_token = parseFloat(values.output_cost_per_token);
@ -339,6 +361,9 @@ export const parseAgentForForm = (agent: any) => {
documentationUrl: agent.agent_card_params?.documentationUrl,
supportsAuthenticatedExtendedCard: agent.agent_card_params?.supportsAuthenticatedExtendedCard,
model: agent.litellm_params?.model,
prompt_id: agent.litellm_params?.prompt_id,
enable_mcp_tools: agent.litellm_params?.enable_mcp_tools,
enable_agent_calls: agent.litellm_params?.enable_agent_calls,
make_public: agent.litellm_params?.make_public,
cost_per_query: agent.litellm_params?.cost_per_query,
input_cost_per_token: agent.litellm_params?.input_cost_per_token,

View file

@ -11,13 +11,18 @@ const { Panel } = Collapse;
interface AgentFormFieldsProps {
showAgentName?: boolean;
visiblePanels?: string[];
promptOptions?: string[];
}
/**
* Reusable form fields component for agent forms
* Uses shared configuration from agent_config.ts
*/
const AgentFormFields: React.FC<AgentFormFieldsProps> = ({ showAgentName = true, visiblePanels }) => {
const AgentFormFields: React.FC<AgentFormFieldsProps> = ({
showAgentName = true,
visiblePanels,
promptOptions = [],
}) => {
const shouldShow = (key: string) => !visiblePanels || visiblePanels.includes(key);
return (
<>
@ -188,8 +193,21 @@ const AgentFormFields: React.FC<AgentFormFieldsProps> = ({ showAgentName = true,
label={field.label}
name={field.name}
valuePropName={field.type === "switch" ? "checked" : undefined}
tooltip={field.tooltip}
>
{field.type === "switch" ? <Switch /> : <Input placeholder={field.placeholder} />}
{field.type === "switch" ? (
<Switch />
) : field.type === "select" ? (
<Select
showSearch
allowClear
placeholder={field.placeholder}
options={promptOptions.map((id) => ({ label: id, value: id }))}
optionFilterProp="label"
/>
) : (
<Input placeholder={field.placeholder} />
)}
</Form.Item>
))}
</Panel>

View file

@ -3,7 +3,7 @@ import { Card, Title, Text, Button as TremorButton, Tab, TabGroup, TabList, TabP
import { Form, Input, InputNumber, Button as AntButton, Spin, Descriptions, Divider } from "antd";
import MessageManager from "@/components/molecules/message_manager";
import { ArrowLeftIcon } from "@heroicons/react/outline";
import { getAgentInfo, patchAgentCall, getAgentCreateMetadata, AgentCreateInfo } from "../networking";
import { getAgentInfo, patchAgentCall, getAgentCreateMetadata, AgentCreateInfo, getPromptsList } from "../networking";
import { Agent } from "./types";
import { KeyResponse } from "../key_team_helpers/key_list";
import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
@ -35,6 +35,7 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
const [form] = Form.useForm();
const [agentTypeMetadata, setAgentTypeMetadata] = useState<AgentCreateInfo[]>([]);
const [detectedAgentType, setDetectedAgentType] = useState<string>("a2a");
const [promptOptions, setPromptOptions] = useState<string[]>([]);
const [appliedDiscoveredSelection, setAppliedDiscoveredSelection] = useState<DiscoveredAgentCardSelection | null>(
null,
);
@ -51,6 +52,13 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
fetchMetadata();
}, []);
useEffect(() => {
if (!accessToken) return;
getPromptsList(accessToken)
.then((res) => setPromptOptions(res.prompts.map((p) => p.prompt_id)))
.catch((error) => console.error("Error fetching prompts:", error));
}, [accessToken]);
useEffect(() => {
fetchAgentInfo();
}, [agentId, accessToken]);
@ -375,11 +383,11 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
</Form.Item>
{detectedAgentType === "a2a" ? (
<AgentFormFields showAgentName={true} />
<AgentFormFields showAgentName={true} promptOptions={promptOptions} />
) : selectedAgentTypeInfo ? (
<DynamicAgentFormFields agentTypeInfo={selectedAgentTypeInfo} />
) : (
<AgentFormFields showAgentName={true} />
<AgentFormFields showAgentName={true} promptOptions={promptOptions} />
)}
{discoveryRequest && (

View file

@ -14,6 +14,7 @@ interface ModelSelectorProps {
className?: string;
showLabel?: boolean;
labelText?: string;
allowClear?: boolean;
}
const ModelSelector: React.FC<ModelSelectorProps> = ({
@ -26,6 +27,7 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({
className,
showLabel = true,
labelText = "Select Model",
allowClear = false,
}) => {
const [selectedModel, setSelectedModel] = useState<string | undefined>(value);
const [showCustomModelInput, setShowCustomModelInput] = useState<boolean>(false);
@ -54,16 +56,16 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({
loadModels();
}, [accessToken]);
const onModelChange = (value: string) => {
const onModelChange = (value?: string) => {
if (value === "custom") {
setShowCustomModelInput(true);
setSelectedModel(undefined);
} else {
setShowCustomModelInput(false);
setSelectedModel(value);
if (onChange) {
onChange(value);
}
return;
}
setShowCustomModelInput(false);
setSelectedModel(value);
if (onChange) {
onChange(value ?? "");
}
};
@ -102,6 +104,7 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({
]}
style={{ width: "100%", ...style }}
showSearch={true}
allowClear={allowClear}
className={`rounded-md ${className || ""}`}
disabled={disabled}
/>