Skip to content

Commit 39929d9

Browse files
committed
Adjust tools resolution
1 parent 409cdc2 commit 39929d9

4 files changed

Lines changed: 840 additions & 40 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 & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,6 @@
6666
validate_model_provider_override,
6767
)
6868
from utils.quota import check_tokens_available, get_available_quotas
69-
from utils.tool_formatter import translate_vector_store_ids_to_user_facing
7069
from utils.responses import (
7170
build_tool_call_summary,
7271
build_turn_summary,
@@ -87,6 +86,7 @@
8786
from utils.suid import (
8887
normalize_conversation_id,
8988
)
89+
from utils.tool_formatter import translate_vector_store_ids_to_user_facing
9090
from utils.types import (
9191
RAGContext,
9292
ResponseInput,

src/utils/responses.py

Lines changed: 224 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@
2626
from llama_stack_api.openai_responses import (
2727
OpenAIResponseInputToolChoice as ToolChoice,
2828
)
29+
from llama_stack_api.openai_responses import (
30+
OpenAIResponseInputToolChoiceAllowedTools as AllowedTools,
31+
)
2932
from llama_stack_api.openai_responses import (
3033
OpenAIResponseInputToolChoiceMode as ToolChoiceMode,
3134
)
@@ -417,6 +420,182 @@ def extract_vector_store_ids_from_tools(
417420
return vector_store_ids
418421

419422

423+
def tool_matches_allowed_entry(tool: InputTool, entry: dict[str, str]) -> bool:
424+
"""Check whether a tool matches every field on one allowlist row.
425+
426+
Parameters:
427+
tool: Configured input tool.
428+
entry: Single row from allowed_tools.tools (field names match tool attributes).
429+
430+
Returns:
431+
True if each entry key exists on the tool, the attribute is not None, and
432+
the value matches (including string coercion).
433+
"""
434+
for key, value in entry.items():
435+
if not hasattr(tool, key):
436+
return False
437+
attr = getattr(tool, key)
438+
if attr is None:
439+
return False
440+
if attr != value and str(attr) != value:
441+
return False
442+
return True
443+
444+
445+
def group_mcp_tools_by_server(
446+
entries: list[dict[str, str]],
447+
) -> dict[str, Optional[list[str]]]:
448+
"""Group MCP tool filters by server_label.
449+
450+
Ignores non-mcp rows and rows without server_label. If any mcp row for a
451+
server has no name field, that server is unrestricted. Otherwise unique
452+
names are kept in first-seen order.
453+
454+
Parameters:
455+
entries: Raw allowlist rows (typically allowed_tools.tools).
456+
457+
Returns:
458+
Mapping from server_label to None (no name restriction) or to the list
459+
of allowed tool names on that server.
460+
"""
461+
unrestricted_servers: set[str] = set()
462+
server_to_names: dict[str, list[str]] = {}
463+
for entry in entries:
464+
if entry.get("type") != "mcp":
465+
continue
466+
server = entry.get("server_label")
467+
if not server:
468+
continue
469+
# Unrestricted entry (no "name")
470+
if "name" not in entry:
471+
unrestricted_servers.add(server)
472+
continue
473+
# Skip collecting names if already unrestricted
474+
if server in unrestricted_servers:
475+
continue
476+
name = entry["name"]
477+
if server not in server_to_names:
478+
server_to_names[server] = []
479+
480+
if name not in server_to_names[server]:
481+
server_to_names[server].append(name)
482+
483+
# Build final result
484+
result: dict[str, Optional[list[str]]] = {}
485+
for server in unrestricted_servers:
486+
result[server] = None
487+
488+
for server, names in server_to_names.items():
489+
if server not in unrestricted_servers:
490+
result[server] = names
491+
492+
return result
493+
494+
495+
def mcp_strip_name_from_allowlist_entries(
496+
allowed_entries: list[dict[str, str]],
497+
) -> list[dict[str, str]]:
498+
"""Copy allowlist rows and remove the name field from mcp rows only.
499+
500+
Parameters:
501+
allowed_entries: Original allowed_tools.tools rows.
502+
503+
Returns:
504+
Shallow-copied rows; name is dropped only when type is mcp.
505+
"""
506+
result: list[dict[str, str]] = []
507+
for entry in allowed_entries:
508+
new_entry = entry.copy()
509+
if new_entry.get("type") == "mcp":
510+
new_entry.pop("name", None)
511+
512+
result.append(new_entry)
513+
514+
return result
515+
516+
517+
def mcp_project_allowed_tools_to_names(
518+
tool: InputToolMCP, names: list[str]
519+
) -> list[str] | None:
520+
"""Intersect allowlist tool names with the MCP tool allowed_tools constraint.
521+
522+
Parameters:
523+
tool: MCP tool; allowed_tools may be unset, a list of names, or a filter.
524+
names: Names from grouped allowlist rows for this server_label.
525+
526+
Returns:
527+
List of names in the intersection, or None if names is empty or the
528+
intersection is empty.
529+
"""
530+
if not names:
531+
return None
532+
name_set = set(names)
533+
allowed = tool.allowed_tools
534+
if allowed is None:
535+
permitted = name_set
536+
elif isinstance(allowed, list):
537+
permitted = name_set & set(allowed)
538+
else:
539+
if allowed.tool_names is None:
540+
permitted = name_set
541+
else:
542+
permitted = name_set & set(allowed.tool_names)
543+
544+
if not permitted:
545+
return None
546+
547+
return list(permitted)
548+
549+
550+
def filter_tools_by_allowed_entries(
551+
tools: list[InputTool],
552+
allowed_entries: list[dict[str, str]],
553+
) -> list[InputTool]:
554+
"""Drop tools that match no allowlist row; narrow MCP allowed_tools when needed.
555+
556+
Parameters:
557+
tools: Candidate tools (e.g. after BYOK translation or prepare_tools).
558+
allowed_entries: Rows from allowed_tools.tools.
559+
560+
Returns:
561+
Sublist of tools matching at least one sanitized row. MCP tools may be
562+
copied with a tighter allowed_tools list when the allowlist names tools
563+
per server. Empty allowlist yields an empty list.
564+
"""
565+
if not allowed_entries:
566+
return []
567+
568+
mcp_names_by_server = group_mcp_tools_by_server(allowed_entries)
569+
sanitized_entries = mcp_strip_name_from_allowlist_entries(allowed_entries)
570+
filtered: list[InputTool] = []
571+
for tool in tools:
572+
# Skip tools not matching any allowlist entry
573+
if not any(tool_matches_allowed_entry(tool, e) for e in sanitized_entries):
574+
continue
575+
# Non-MCP tools pass through and are handled separately
576+
if tool.type != "mcp":
577+
filtered.append(tool)
578+
continue
579+
580+
mcp_tool = cast(InputToolMCP, tool)
581+
server = mcp_tool.server_label
582+
583+
narrowed_names = mcp_names_by_server.get(server)
584+
# No filters specified for this MCP server
585+
if narrowed_names is None:
586+
filtered.append(tool)
587+
continue
588+
589+
# Apply intersection
590+
permitted = mcp_project_allowed_tools_to_names(mcp_tool, narrowed_names)
591+
if permitted is None:
592+
continue
593+
594+
filtered.append(mcp_tool.model_copy(update={"allowed_tools": permitted}))
595+
596+
return filtered
597+
598+
420599
def resolve_vector_store_ids(
421600
vector_store_ids: list[str], byok_rags: list[ByokRag]
422601
) -> list[str]:
@@ -1330,54 +1509,69 @@ async def resolve_tool_choice(
13301509
mcp_headers: Optional[McpHeaders] = None,
13311510
request_headers: Optional[Mapping[str, str]] = None,
13321511
) -> tuple[Optional[list[InputTool]], Optional[ToolChoice], Optional[list[str]]]:
1333-
"""Resolve tools and tool_choice for the Responses API.
1512+
"""Resolve tools and tool choice for the Responses API.
1513+
1514+
When tool choice disables tools, always return Nones so Llama Stack
1515+
sees no tools, even if the request listed tools.
1516+
1517+
Allowed-tools mode: filter tools to the allowlist and narrow tool choice to
1518+
auto or required from the allowlist mode.
13341519
1335-
If the request includes tools, uses them as-is and derives vector_store_ids
1336-
from tool configs; otherwise loads tools via prepare_tools (using all
1337-
configured vector stores) and honors tool_choice "none" via the no_tools
1338-
flag. When no tools end up configured, tool_choice is cleared to None.
1520+
Otherwise: use request tools (with filtering) and derive vector store IDs, or
1521+
load tools via prepare_tools, then filter. Clear tool choice when no tools
1522+
remain.
13391523
13401524
Args:
1341-
tools: Tools from the request, or None to use LCORE-configured tools.
1342-
tool_choice: Requested tool choice (e.g. auto, required, none) or None.
1343-
token: User token for MCP/auth.
1344-
mcp_headers: Optional MCP headers to propagate.
1345-
request_headers: Optional request headers for tool resolution.
1525+
tools: Request tools, or None for LCORE-configured tools.
1526+
tool_choice: Requested strategy, or None.
1527+
token: User token for MCP and auth.
1528+
mcp_headers: Optional MCP headers.
1529+
request_headers: Optional headers for tool resolution.
13461530
13471531
Returns:
1348-
A tuple of (prepared_tools, prepared_tool_choice, vector_store_ids):
1349-
prepared_tools is the list of tools to use, or None if none configured;
1350-
prepared_tool_choice is the resolved tool choice, or None when there
1351-
are no tools; vector_store_ids is extracted from tools (in user-facing format)
1352-
when provided, otherwise None.
1532+
Prepared tools, resolved tool choice, and vector store IDs (user-facing),
1533+
each possibly None.
13531534
"""
1535+
# If tool_choice is "none", no tools are allowed
1536+
if isinstance(tool_choice, ToolChoiceMode) and tool_choice == ToolChoiceMode.none:
1537+
return None, None, None
1538+
1539+
# Extract the allowed filters if specified and overwrite tool choice mode
1540+
allowed_filters: Optional[list[dict[str, str]]] = None
1541+
if isinstance(tool_choice, AllowedTools):
1542+
allowed_filters = tool_choice.tools
1543+
tool_choice = ToolChoiceMode(tool_choice.mode)
1544+
13541545
prepared_tools: Optional[list[InputTool]] = None
1355-
client = AsyncLlamaStackClientHolder().get_client()
1356-
if tools: # explicitly specified in request
1357-
# Per-request override of vector stores (user-facing rag_ids)
1358-
vector_store_ids = extract_vector_store_ids_from_tools(tools)
1359-
# Translate user-facing rag_ids to llama-stack vector_store_ids in each file_search tool
1546+
if tools is not None: # explicitly specified in request
13601547
byok_rags = configuration.configuration.byok_rag
13611548
prepared_tools = translate_tools_vector_store_ids(tools, byok_rags)
1549+
if allowed_filters is not None:
1550+
prepared_tools = filter_tools_by_allowed_entries(
1551+
prepared_tools, allowed_filters
1552+
)
1553+
if not prepared_tools:
1554+
return None, None, None
1555+
vector_store_ids_list = extract_vector_store_ids_from_tools(prepared_tools)
1556+
vector_store_ids = vector_store_ids_list if vector_store_ids_list else None
13621557
prepared_tool_choice = tool_choice or ToolChoiceMode.auto
13631558
else:
1364-
# Vector stores were not overwritten in request, use all configured vector stores
13651559
vector_store_ids = None
1366-
# Get all tools configured in LCORE (returns None or non-empty list)
1367-
no_tools = (
1368-
isinstance(tool_choice, ToolChoiceMode)
1369-
and tool_choice == ToolChoiceMode.none
1370-
)
1371-
# Vector stores are prepared in llama-stack format
1560+
client = AsyncLlamaStackClientHolder().get_client()
13721561
prepared_tools = await prepare_tools(
13731562
client=client,
1374-
vector_store_ids=vector_store_ids, # allow all configured vector stores
1375-
no_tools=no_tools,
1563+
vector_store_ids=vector_store_ids,
1564+
no_tools=False,
13761565
token=token,
13771566
mcp_headers=mcp_headers,
13781567
request_headers=request_headers,
13791568
)
1380-
# If there are no tools, tool_choice cannot be set at all - LLS implicit behavior
1569+
if allowed_filters is not None and prepared_tools:
1570+
prepared_tools = filter_tools_by_allowed_entries(
1571+
prepared_tools, allowed_filters
1572+
)
1573+
if not prepared_tools:
1574+
prepared_tools = None
13811575
prepared_tool_choice = tool_choice if prepared_tools else None
13821576

13831577
return prepared_tools, prepared_tool_choice, vector_store_ids

0 commit comments

Comments
 (0)