Skip to content

Commit c7b90c3

Browse files
committed
Adjust tools resolution
1 parent f265811 commit c7b90c3

6 files changed

Lines changed: 400 additions & 175 deletions

File tree

docs/responses.md

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -280,9 +280,9 @@ Optional. **Tool selection strategy** that controls whether and how the model us
280280

281281
**Specific tool objects (object with `type`):**
282282

283-
- `allowed_tools`: Restrict to a list of tool definitions; `mode` is `"auto"` or `"required"`, `tools` is a list of tool objects (same shapes as in [tools](#tools)).
284-
- `file_search`: Force the model to use file search.
285-
- `web_search`: Force the model to use web search (optionally with a variant such as `web_search_preview`).
283+
- `allowed_tools`: Restrict to a list of tool definitions; `mode` is `"auto"` or `"required"`, `tools` is a list of key-valued filters for tools configured by `tools` attribute.
284+
- `file_search`: Force the model to use file-only search.
285+
- `web_search`: Force the model to use only web search.
286286
- `function`: Force a specific function; `name` (required) is the function name.
287287
- `mcp`: Force a tool on an MCP server; `server_label` (required), `name` (optional) tool name.
288288
- `custom`: Force a custom tool; `name` (required).
@@ -297,16 +297,25 @@ Simple modes (string): use one of `"auto"`, `"required"`, or `"none"`.
297297
{ "tool_choice": "none" }
298298
```
299299

300-
Restrict to specific tools with `allowed_tools` (mode `"auto"` or `"required"`, plus `tools` array):
300+
Restrict tool usage to a specific subset using `allowed_tools`. You can control behavior with the `mode` field (`"auto"` or `"required"`) and explicitly list permitted tools in the `tools` array.
301+
302+
The `tools` array acts as a **key-value filter**: each object specifies matching criteria (such as `type`, `server_label`, or `name`), and only tools that satisfy all provided attributes are allowed.
303+
304+
The example below limits tool usage to:
305+
- the `file_search` tool
306+
- a specific MCP tools (`tool_1` and `tool_2`) available on `server_1` (for multiple `name`s act as union)
307+
308+
If the `name` field is omitted for an MCP tool, the filter applies to all tools available on the specified server.
301309

302310
```json
303311
{
304312
"tool_choice": {
305313
"type": "allowed_tools",
306314
"mode": "required",
307315
"tools": [
308-
{ "type": "file_search", "vector_store_ids": ["vs_123"] },
309-
{ "type": "web_search" }
316+
{ "type": "file_search"},
317+
{ "type": "mcp", "server_label": "server_1", "name": "tool_1" },
318+
{ "type": "mcp", "server_label": "server_1", "name": "tool_2" }
310319
]
311320
}
312321
}
@@ -396,8 +405,8 @@ The following response attributes are inherited directly from the LLS OpenAPI sp
396405
| `temperature` | float | Temperature parameter used for generation |
397406
| `text` | object | Text response configuration object used |
398407
| `top_p` | float | Top-p sampling used |
399-
| `tools` | array[object] | Tools available during generation |
400-
| `tool_choice` | string or object | Tool selection used |
408+
| `tools` | array[object] | Internally resolved tools available during generation |
409+
| `tool_choice` | string | Internally resolved tool choice mode |
401410
| `truncation` | string | Truncation strategy applied (`"auto"` or `"disabled"`) |
402411
| `usage` | object | Token usage (input_tokens, output_tokens, total_tokens) |
403412
| `instructions` | string | System instructions used |
@@ -517,6 +526,8 @@ Vector store IDs are configured within the `tools` as `file_search` tools rather
517526

518527
**Vector store IDs:** Accepts **LCORE format** in requests and also outputs it in responses; LCORE translates to/from Llama Stack format internally.
519528

529+
The response includes `tools` and `tool_choice` fields that reflect the internally resolved configuration. More specifically, the final set of tools and selection constraints after internal resolution and filtering.
530+
520531
### LCORE-Specific Extensions
521532

522533
The API introduces extensions that are not part of the OpenResponses specification:

src/app/endpoints/responses.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -234,15 +234,14 @@ async def responses_endpoint_handler(
234234
request.headers,
235235
)
236236

237-
#Build RAG context from Inline RAG sources
237+
# Build RAG context from Inline RAG sources
238238
inline_rag_context = await build_rag_context(
239239
client,
240240
moderation_result.decision,
241241
input_text,
242242
vector_store_ids,
243243
responses_request.solr,
244244
)
245-
246245
if moderation_result.decision == "passed":
247246
responses_request.input = append_inline_rag_context_to_responses_input(
248247
responses_request.input, inline_rag_context.context_text
@@ -663,7 +662,6 @@ async def handle_non_streaming_response(
663662
)
664663
else:
665664
try:
666-
print("API Params: ", api_params.model_dump(exclude_none=True))
667665
api_response = cast(
668666
OpenAIResponseObject,
669667
await client.responses.create(

src/app/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,5 +229,5 @@ async def send_wrapper(message: Message) -> None:
229229
# RestApiMetricsMiddleware (registered last) is outermost. This ensures metrics
230230
# always observe a status code — including 500s synthesised by the exception
231231
# middleware — rather than seeing a raw exception with no response.
232-
#app.add_middleware(GlobalExceptionMiddleware)
232+
app.add_middleware(GlobalExceptionMiddleware)
233233
app.add_middleware(RestApiMetricsMiddleware)

src/utils/responses.py

Lines changed: 145 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -420,16 +420,12 @@ def extract_vector_store_ids_from_tools(
420420
return vector_store_ids
421421

422422

423-
def _tool_matches_allowed_entry(tool: InputTool, entry: dict[str, str]) -> bool:
423+
def tool_matches_allowed_entry(tool: InputTool, entry: dict[str, str]) -> bool:
424424
"""Return True if the tool satisfies every key in the allowlist entry.
425425
426-
``OpenAIResponseInputToolChoiceAllowedTools.tools`` entries use string keys
427-
and values (e.g. ``type``, ``server_label``, ``name``); each must match the
428-
corresponding attribute on the tool.
429-
430426
Parameters:
431427
tool: A configured input tool.
432-
entry: One allowlist entry from ``allowed_tools.tools``.
428+
entry: One allowlist entry from allowed_tools.tools.
433429
434430
Returns:
435431
True if all entry keys match the tool.
@@ -445,28 +441,139 @@ def _tool_matches_allowed_entry(tool: InputTool, entry: dict[str, str]) -> bool:
445441
return True
446442

447443

448-
def filter_tools_by_allowed_entries(
449-
tools: list[InputTool],
444+
def group_mcp_tools_by_server(
445+
entries: list[dict[str, str]],
446+
) -> dict[str, Optional[list[str]]]:
447+
"""Group MCP tool filters by server_label.
448+
449+
Rules:
450+
- Non-MCP entries are ignored.
451+
- Entries without server_label are ignored.
452+
- If any entry for a server has no "name", that server is unrestricted (None).
453+
- Otherwise, collect unique tool names in first-seen order.
454+
455+
Returns:
456+
Dict mapping:
457+
server_label -> None (unrestricted) OR list of allowed tool names
458+
"""
459+
unrestricted_servers: set[str] = set()
460+
server_to_names: dict[str, list[str]] = {}
461+
for entry in entries:
462+
if entry.get("type") != "mcp":
463+
continue
464+
server = entry.get("server_label")
465+
if not server:
466+
continue
467+
# Unrestricted entry (no "name")
468+
if "name" not in entry:
469+
unrestricted_servers.add(server)
470+
continue
471+
# Skip collecting names if already unrestricted
472+
if server in unrestricted_servers:
473+
continue
474+
name = entry["name"]
475+
if server not in server_to_names:
476+
server_to_names[server] = []
477+
478+
if name not in server_to_names[server]:
479+
server_to_names[server].append(name)
480+
481+
# Build final result
482+
result: dict[str, Optional[list[str]]] = {}
483+
for server in unrestricted_servers:
484+
result[server] = None
485+
486+
for server, names in server_to_names.items():
487+
if server not in unrestricted_servers:
488+
result[server] = names
489+
490+
return result
491+
492+
493+
def mcp_strip_name_from_allowlist_entries(
450494
allowed_entries: list[dict[str, str]],
451-
) -> list[InputTool]:
452-
"""Keep tools that match at least one allowlist entry.
495+
) -> list[dict[str, str]]:
496+
"""Return a copy of entries where 'name' is removed only for MCP entries."""
497+
result: list[dict[str, str]] = []
498+
for entry in allowed_entries:
499+
new_entry = entry.copy()
500+
if new_entry.get("type") == "mcp":
501+
new_entry.pop("name", None)
453502

454-
If ``allowed_entries`` is empty, no tools are kept (strict allowlist).
503+
result.append(new_entry)
504+
505+
return result
455506

456-
Parameters:
457-
tools: Tools to filter (typically after translation / preparation).
458-
allowed_entries: Entries from ``OpenAIResponseInputToolChoiceAllowedTools.tools``.
507+
508+
def mcp_project_allowed_tools_to_names(
509+
tool: InputToolMCP, names: list[str]
510+
) -> list[str] | None:
511+
"""Intersect narrowed names with what the MCP tool already permits.
459512
460513
Returns:
461-
A sublist of ``tools`` matching the allowlist.
514+
List of permitted tool names, or None if the intersection is empty.
515+
"""
516+
if not names:
517+
return None
518+
name_set = set(names)
519+
allowed = tool.allowed_tools
520+
if allowed is None:
521+
permitted = name_set
522+
elif isinstance(allowed, list):
523+
permitted = name_set & set(allowed)
524+
else:
525+
if allowed.tool_names is None:
526+
permitted = name_set
527+
else:
528+
permitted = name_set & set(allowed.tool_names)
529+
530+
if not permitted:
531+
return None
532+
533+
return list(permitted)
534+
535+
536+
def filter_tools_by_allowed_entries(
537+
tools: list[InputTool],
538+
allowed_entries: list[dict[str, str]],
539+
) -> list[InputTool]:
540+
"""Filter tools based on allowlist entries.
541+
542+
- Keeps tools matching at least one entry.
543+
- Applies MCP name narrowing when applicable.
462544
"""
463545
if not allowed_entries:
464546
return []
465-
return [
466-
t
467-
for t in tools
468-
if any(_tool_matches_allowed_entry(t, e) for e in allowed_entries)
469-
]
547+
548+
mcp_names_by_server = group_mcp_tools_by_server(allowed_entries)
549+
sanitized_entries = mcp_strip_name_from_allowlist_entries(allowed_entries)
550+
filtered: list[InputTool] = []
551+
for tool in tools:
552+
# Skip tools not matching any allowlist entry
553+
if not any(tool_matches_allowed_entry(tool, e) for e in sanitized_entries):
554+
continue
555+
# Non-MCP tools pass through and are handled separately
556+
if tool.type != "mcp":
557+
filtered.append(tool)
558+
continue
559+
560+
mcp_tool = cast(InputToolMCP, tool)
561+
server = mcp_tool.server_label
562+
563+
narrowed_names = mcp_names_by_server.get(server)
564+
# No filters specified for this MCP server
565+
if narrowed_names is None:
566+
filtered.append(tool)
567+
continue
568+
569+
# Apply intersection
570+
permitted = mcp_project_allowed_tools_to_names(mcp_tool, narrowed_names)
571+
if permitted is None:
572+
continue
573+
574+
filtered.append(mcp_tool.model_copy(update={"allowed_tools": permitted}))
575+
576+
return filtered
470577

471578

472579
def resolve_vector_store_ids(
@@ -1382,46 +1489,41 @@ async def resolve_tool_choice(
13821489
mcp_headers: Optional[McpHeaders] = None,
13831490
request_headers: Optional[Mapping[str, str]] = None,
13841491
) -> tuple[Optional[list[InputTool]], Optional[ToolChoice], Optional[list[str]]]:
1385-
"""Resolve tools and tool_choice for the Responses API.
1492+
"""Resolve tools and tool choice for the Responses API.
13861493
1387-
If ``tool_choice`` is ``none``, always returns ``(None, None, None)`` — no
1388-
tools are sent to Llama Stack, even when the request included explicit
1389-
``tools`` (e.g. file_search).
1494+
When tool choice disables tools, always return Nones so Llama Stack
1495+
sees no tools, even if the request listed tools.
13901496
1391-
If ``tool_choice`` is ``allowed_tools``, it is rewritten for downstream
1392-
services: tools are filtered to those matching the allowlist entries, and
1393-
``tool_choice`` becomes ``auto`` or ``required`` per the allowlist ``mode``.
1497+
Allowed-tools mode: filter tools to the allowlist and narrow tool choice to
1498+
auto or required from the allowlist mode.
13941499
1395-
If the request includes tools and tool_choice is not ``none``, uses them
1396-
(after allowlist filtering) and derives vector_store_ids from the prepared
1397-
tools; otherwise loads tools via prepare_tools (using all configured vector
1398-
stores), then applies allowlist filtering when present. When no tools end
1399-
up configured, tool_choice is cleared to None.
1500+
Otherwise: use request tools (with filtering) and derive vector store IDs, or
1501+
load tools via prepare_tools, then filter. Clear tool choice when no tools
1502+
remain.
14001503
14011504
Args:
1402-
tools: Tools from the request, or None to use LCORE-configured tools.
1403-
tool_choice: Requested tool choice (e.g. auto, required, none) or None.
1404-
token: User token for MCP/auth.
1405-
mcp_headers: Optional MCP headers to propagate.
1406-
request_headers: Optional request headers for tool resolution.
1505+
tools: Request tools, or None for LCORE-configured tools.
1506+
tool_choice: Requested strategy, or None.
1507+
token: User token for MCP and auth.
1508+
mcp_headers: Optional MCP headers.
1509+
request_headers: Optional headers for tool resolution.
14071510
14081511
Returns:
1409-
A tuple of (prepared_tools, prepared_tool_choice, vector_store_ids):
1410-
prepared_tools is the list of tools to use, or None if none configured;
1411-
prepared_tool_choice is the resolved tool choice, or None when there
1412-
are no tools; vector_store_ids is extracted from tools (in user-facing format)
1413-
when provided, otherwise None (also None when tool_choice is ``none``).
1512+
Prepared tools, resolved tool choice, and vector store IDs (user-facing),
1513+
each possibly None.
14141514
"""
1515+
# If tool_choice is "none", no tools are allowed
14151516
if isinstance(tool_choice, ToolChoiceMode) and tool_choice == ToolChoiceMode.none:
14161517
return None, None, None
14171518

1519+
# Extract the allowed filters if specified and overwrite tool choice mode
14181520
allowed_filters: Optional[list[dict[str, str]]] = None
14191521
if isinstance(tool_choice, AllowedTools):
14201522
allowed_filters = tool_choice.tools
14211523
tool_choice = ToolChoiceMode(tool_choice.mode)
14221524

14231525
prepared_tools: Optional[list[InputTool]] = None
1424-
if tools: # explicitly specified in request
1526+
if tools is not None: # explicitly specified in request
14251527
byok_rags = configuration.configuration.byok_rag
14261528
prepared_tools = translate_tools_vector_store_ids(tools, byok_rags)
14271529
if allowed_filters is not None:

tests/e2e/features/steps/llm_query_response.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,9 @@ def responses_output_should_include_item_type(context: Context, item_type: str)
5757

5858

5959
@then('The responses output should not include an item with type "{item_type}"')
60-
def responses_output_should_not_include_item_type(context: Context, item_type: str) -> None:
60+
def responses_output_should_not_include_item_type(
61+
context: Context, item_type: str
62+
) -> None:
6163
"""Assert no ``output`` item has the given ``type``."""
6264
assert context.response is not None, "Request needs to be performed first"
6365
response_json = cast(dict[str, Any], context.response.json())

0 commit comments

Comments
 (0)