diff --git a/python/packages/typesafe/agent_framework_typesafe/_chat_client.py b/python/packages/typesafe/agent_framework_typesafe/_chat_client.py index d7ed9c2e1c9..5522e393b8c 100644 --- a/python/packages/typesafe/agent_framework_typesafe/_chat_client.py +++ b/python/packages/typesafe/agent_framework_typesafe/_chat_client.py @@ -413,10 +413,18 @@ def _serialize_function_result(content: Content) -> dict[str, Any]: f"TypeSafe function results support text items only; received unsupported {item.type!r} content." ) items.append({"type": "text", "text": item.text or ""}) + if items: + result = "\n".join(item["text"] for item in items) + elif isinstance(content.result, str): + result = content.result + else: + raise ChatClientInvalidRequestException( + "TypeSafe function results require canonical text items or a string result." + ) return { "type": "function_result", "call_id": content.call_id, - "result": content.result, + "result": result, **({"items": items} if items else {}), } @@ -454,8 +462,15 @@ def _get_current_turn_function_result_texts(messages: Sequence[Message]) -> list if message.role == "user": break for content in reversed(message.contents): - if content.type == "function_result" and isinstance(content.result, str): - results.append(RawTypeSafeChatClient._unwrap_function_result_text(content.result)) + if content.type != "function_result": + continue + if content.items: + result = "\n".join(item.text or "" for item in content.items if item.type == "text") + elif isinstance(content.result, str): + result = content.result + else: + continue + results.append(RawTypeSafeChatClient._unwrap_function_result_text(result)) results.reverse() return results diff --git a/python/packages/typesafe/agent_framework_typesafe/_tool_calls.py b/python/packages/typesafe/agent_framework_typesafe/_tool_calls.py index 07fe68197b2..32ab33d4a71 100644 --- a/python/packages/typesafe/agent_framework_typesafe/_tool_calls.py +++ b/python/packages/typesafe/agent_framework_typesafe/_tool_calls.py @@ -146,11 +146,25 @@ def compile_tool_call_plan( compiled: list[_CompiledTool] = [] unsupported: dict[str, str] = {} + compiled_question_count = 0 for index, tool in enumerate(selected_tools): try: - compiled.append(_compile_tool(tool, index, list((previous_calls or {}).get(tool.name, ())))) + compiled_tool = _compile_tool(tool, index, list((previous_calls or {}).get(tool.name, ()))) except _UnsupportedToolSchema as exc: unsupported[tool.name] = str(exc) + continue + compiled.append(compiled_tool) + if len(compiled) > MAX_ROUTABLE_TOOLS: + raise ChatClientInvalidRequestException( + f"TypeSafe supports at most {MAX_ROUTABLE_TOOLS} routable tools per request. " + "Use tool_choice.allowed_tools to narrow MCP or local tools." + ) + compiled_question_count += len(compiled_tool.questions) + if compiled_question_count > MAX_INTERNAL_QUESTIONS: + raise ChatClientInvalidRequestException( + f"TypeSafe tool schemas generated more than {MAX_INTERNAL_QUESTIONS} internal questions. " + "Narrow the available tools." + ) if unsupported: for name, reason in unsupported.items(): @@ -164,11 +178,6 @@ def compile_tool_call_plan( if not compiled: return None - if len(compiled) > MAX_ROUTABLE_TOOLS: - raise ChatClientInvalidRequestException( - f"TypeSafe supports at most {MAX_ROUTABLE_TOOLS} routable tools per request. " - "Use tool_choice.allowed_tools to narrow MCP or local tools." - ) required_tool = compiled[0] if mode == "required" and len(compiled) == 1 else None route_question_id: str | None = None diff --git a/python/packages/typesafe/tests/typesafe/test_chat_client.py b/python/packages/typesafe/tests/typesafe/test_chat_client.py index 6c5da0d3ca5..e40d10a3d10 100644 --- a/python/packages/typesafe/tests/typesafe/test_chat_client.py +++ b/python/packages/typesafe/tests/typesafe/test_chat_client.py @@ -341,6 +341,53 @@ def test_text_function_result_items_are_serialized() -> None: } +def test_function_result_uses_canonical_items_over_stale_result() -> None: + result = Content( + "function_result", + call_id="call-1", + result="stale", + items=[Content.from_text("canonical")], + ) + + state = RawTypeSafeChatClient._build_state( # pyright: ignore[reportPrivateUsage] + [Message("user", ["run"]), Message("tool", [result])], + instructions=None, + ) + + assert state["messages"][1]["contents"][0] == { + "type": "function_result", + "call_id": "call-1", + "result": "canonical", + "items": [{"type": "text", "text": "canonical"}], + } + + +def test_terminal_text_uses_canonical_function_result_items() -> None: + result = Content( + "function_result", + call_id="call-1", + result="stale", + items=[Content.from_text("canonical")], + ) + + text = RawTypeSafeChatClient._build_terminal_text( # pyright: ignore[reportPrivateUsage] + [Message("user", ["run"]), Message("tool", [result])], + make_response(), + ) + + assert text == "canonical" + + +def test_function_result_without_canonical_text_is_rejected() -> None: + result = Content("function_result", call_id="call-1", result={"value": 1}) + + with pytest.raises(ChatClientInvalidRequestException, match="canonical text items or a string result"): + RawTypeSafeChatClient._build_state( # pyright: ignore[reportPrivateUsage] + [Message("user", ["run"]), Message("tool", [result])], + instructions=None, + ) + + def test_simple_mcp_result_wrapper_is_unwrapped() -> None: result = RawTypeSafeChatClient._get_current_turn_function_result_texts( # pyright: ignore[reportPrivateUsage] [ diff --git a/python/packages/typesafe/tests/typesafe/test_tool_calls.py b/python/packages/typesafe/tests/typesafe/test_tool_calls.py index 4f4b1563982..f00e4368767 100644 --- a/python/packages/typesafe/tests/typesafe/test_tool_calls.py +++ b/python/packages/typesafe/tests/typesafe/test_tool_calls.py @@ -164,6 +164,27 @@ def test_routable_tool_limit_is_enforced() -> None: compile_tool_call_plan(tools, tool_mode=None, user_question_ids=set()) +def test_routable_tool_limit_stops_compiling_later_tools() -> None: + class CountingFunctionTool(FunctionTool): + compiled_count = 0 + + def parameters(self) -> dict[str, Any]: + type(self).compiled_count += 1 + if type(self).compiled_count > MAX_ROUTABLE_TOOLS + 1: + raise AssertionError("tool compilation continued after the routable limit") + return super().parameters() + + tools = [ + CountingFunctionTool(name=f"tool_{index}", func=lambda: None, input_model={}) + for index in range(MAX_ROUTABLE_TOOLS + 2) + ] + + with pytest.raises(ChatClientInvalidRequestException, match="at most"): + compile_tool_call_plan(tools, tool_mode=None, user_question_ids=set()) + + assert CountingFunctionTool.compiled_count == MAX_ROUTABLE_TOOLS + 1 + + def test_tool_property_limit_is_enforced_before_compilation() -> None: properties = {f"field_{index}": {"type": "boolean"} for index in range(MAX_TOOL_PROPERTIES + 1)}