Skip to content

Commit 9ef5d2e

Browse files
committed
Adjust tools resolution
1 parent 509987f commit 9ef5d2e

4 files changed

Lines changed: 859 additions & 54 deletions

File tree

docs/responses.md

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,7 @@ JSON schema with optional name, description, and strict mode:
268268

269269
#### `tool_choice`
270270

271-
Optional. **Tool selection strategy** that controls whether and how the model uses tools.
271+
Optional. **Tool selection strategy** that controls whether and how the model uses tools. Does not affect inline RAG selection.
272272

273273
**What it does:** When tools are supplied by `tools` attribute, `tool_choice` decides if the model may call them, must call at least one, or must not use any. You can pass a **simple mode string** or a **specific tool-config object** to force a particular tool (e.g. always use file search or a given function). Omitted or `null` behaves like `"auto"`. Typical use: disable tools for a plain-Q&A turn (`"none"`), force RAG-only (`file_search`), or constrain to a subset of tools (`allowed_tools`).
274274

@@ -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 or object | Internally resolved tool selection used |
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,10 @@ 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+
531+
`tool_choice` only constrains how the model may use tools (including RAG via the `file_search` tool). It does **not** change inline RAG: vector store IDs you list under `tools` for file search are still used to build inline RAG context even if you set `tool_choice` to `none` or use an allowlist that omits `file_search`.
532+
520533
### LCORE-Specific Extensions
521534

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

src/app/endpoints/responses.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -222,11 +222,14 @@ async def responses_endpoint_handler(
222222
responses_request.shield_ids,
223223
)
224224

225-
(
226-
responses_request.tools,
227-
responses_request.tool_choice,
228-
vector_store_ids,
229-
) = await resolve_tool_choice(
225+
# Extract vector store IDs for Inline RAG context before resolving tool choice.
226+
vector_store_ids: Optional[list[str]] = (
227+
extract_vector_store_ids_from_tools(responses_request.tools)
228+
if responses_request.tools is not None
229+
else None
230+
)
231+
232+
responses_request.tools, responses_request.tool_choice = await resolve_tool_choice(
230233
responses_request.tools,
231234
responses_request.tool_choice,
232235
auth[1],

src/utils/responses.py

Lines changed: 228 additions & 39 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,185 @@ 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: Optional[list[InputTool]],
552+
allowed_entries: list[dict[str, str]],
553+
) -> Optional[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 tools is None:
566+
return None
567+
568+
if not allowed_entries:
569+
return []
570+
571+
mcp_names_by_server = group_mcp_tools_by_server(allowed_entries)
572+
sanitized_entries = mcp_strip_name_from_allowlist_entries(allowed_entries)
573+
filtered: list[InputTool] = []
574+
for tool in tools:
575+
# Skip tools not matching any allowlist entry
576+
if not any(tool_matches_allowed_entry(tool, e) for e in sanitized_entries):
577+
continue
578+
# Non-MCP tools pass through and are handled separately
579+
if tool.type != "mcp":
580+
filtered.append(tool)
581+
continue
582+
583+
mcp_tool = cast(InputToolMCP, tool)
584+
server = mcp_tool.server_label
585+
586+
narrowed_names = mcp_names_by_server.get(server)
587+
# No filters specified for this MCP server
588+
if narrowed_names is None:
589+
filtered.append(tool)
590+
continue
591+
592+
# Apply intersection
593+
permitted = mcp_project_allowed_tools_to_names(mcp_tool, narrowed_names)
594+
if permitted is None:
595+
continue
596+
597+
filtered.append(mcp_tool.model_copy(update={"allowed_tools": permitted}))
598+
599+
return filtered
600+
601+
420602
def resolve_vector_store_ids(
421603
vector_store_ids: list[str], byok_rags: list[ByokRag]
422604
) -> list[str]:
@@ -1329,55 +1511,62 @@ async def resolve_tool_choice(
13291511
token: str,
13301512
mcp_headers: Optional[McpHeaders] = None,
13311513
request_headers: Optional[Mapping[str, str]] = None,
1332-
) -> tuple[Optional[list[InputTool]], Optional[ToolChoice], Optional[list[str]]]:
1333-
"""Resolve tools and tool_choice for the Responses API.
1514+
) -> tuple[Optional[list[InputTool]], Optional[ToolChoice]]:
1515+
"""Resolve tools and tool choice for the Responses API.
1516+
1517+
When tool choice is mode none, returns (None, None) so Llama Stack sees no
1518+
tools, even if the request listed tools.
1519+
1520+
When tools is omitted, load tools from LCORE configuration via prepare_tools.
1521+
When tools are present, translate vector store IDs using BYOK configuration.
13341522
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.
1523+
When filters are present, apply them to prepared tools and overwrite tool choice mode.
1524+
1525+
If no tools remain after filtering, both prepared tools and tool choice are cleared.
13391526
13401527
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.
1528+
tools: Request tools, or None for LCORE-configured tools.
1529+
tool_choice: Requested strategy, or None.
1530+
token: User token for MCP and auth.
1531+
mcp_headers: Optional MCP headers.
1532+
request_headers: Optional headers for tool resolution.
13461533
13471534
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.
1535+
Prepared tools and resolved tool choice, each possibly None.
13531536
"""
1354-
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
1360-
byok_rags = configuration.configuration.byok_rag
1361-
prepared_tools = translate_tools_vector_store_ids(tools, byok_rags)
1362-
prepared_tool_choice = tool_choice or ToolChoiceMode.auto
1363-
else:
1364-
# Vector stores were not overwritten in request, use all configured vector stores
1365-
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
1537+
# If tool_choice mode is "none", tools are explicitly disallowed
1538+
if isinstance(tool_choice, ToolChoiceMode) and tool_choice == ToolChoiceMode.none:
1539+
return None, None
1540+
1541+
if tools is None:
1542+
# Register all tools configured in LCORE configuration
1543+
client = AsyncLlamaStackClientHolder().get_client()
13721544
prepared_tools = await prepare_tools(
13731545
client=client,
1374-
vector_store_ids=vector_store_ids, # allow all configured vector stores
1375-
no_tools=no_tools,
1546+
vector_store_ids=None, # allow all vector stores configured
1547+
no_tools=False,
13761548
token=token,
13771549
mcp_headers=mcp_headers,
13781550
request_headers=request_headers,
13791551
)
1380-
# If there are no tools, tool_choice cannot be set at all - LLS implicit behavior
1381-
prepared_tool_choice = tool_choice if prepared_tools else None
1552+
else:
1553+
# Pass tools explicitly configured for this request
1554+
byok_rags = configuration.configuration.byok_rag
1555+
prepared_tools = translate_tools_vector_store_ids(tools, byok_rags)
1556+
1557+
if isinstance(tool_choice, AllowedTools):
1558+
# Apply filters to tools if specified and overwrite tool choice mode
1559+
prepared_tool_choice = ToolChoiceMode(tool_choice.mode)
1560+
prepared_tools = filter_tools_by_allowed_entries(
1561+
prepared_tools, tool_choice.tools
1562+
)
1563+
else:
1564+
# Use request tool choice mode or default to auto
1565+
prepared_tool_choice = tool_choice or ToolChoiceMode.auto
1566+
1567+
# Clear tools and tool choice if no tools remain for consistency with Responses API
1568+
if not prepared_tools:
1569+
prepared_tools = None
1570+
prepared_tool_choice = None
13821571

1383-
return prepared_tools, prepared_tool_choice, vector_store_ids
1572+
return prepared_tools, prepared_tool_choice

0 commit comments

Comments
 (0)