Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
end_span_once,
end_unfinished_spans,
is_content_blocks,
is_function_tool,
parse_template,
report_usage,
set_input_content_attributes,
Expand Down Expand Up @@ -230,14 +231,18 @@ def _forwarded_parameters(config: AiConfigRep) -> dict[str, Any]:
)


def _build_tools(config_tools: dict[str, Any]) -> list[dict[str, Any]]:
def _build_tools(
config_tools: dict[str, Any], tool_handlers: Mapping[str, Any]
) -> list[dict[str, Any]]:
return [
{
"name": name,
"description": tool.get("description", ""),
"input_schema": tool.get("parameters", {}),
}
for name, tool in config_tools.items()
# A missing handler still reaches the model; a NativeTool never does.
if name not in tool_handlers or is_function_tool(tool_handlers[name], name)
]


Expand Down Expand Up @@ -380,7 +385,7 @@ async def _run_tool_loop(
# Not filtered to the tools that have a registered handler, unlike the TypeScript SDK. That
# difference predates this span work and changes what the model is offered, not what the span
# reports, so it stays as it is: the catalog recorded below is the catalog actually sent.
tools = _build_tools(config.get("tools") or {})
tools = _build_tools(config.get("tools") or {}, tool_handlers or {})
extra_params = _forwarded_parameters(config)
max_tokens = extra_params.pop("max_tokens", 1024)
conversation = list(messages)
Expand Down Expand Up @@ -720,7 +725,7 @@ async def _stream_gen(
config, user_input, variables, include_output_format=False, history=history
)

tools = _build_tools(config.get("tools") or {})
tools = _build_tools(config.get("tools") or {}, tool_handlers or {})
tool_definitions = to_tool_definitions(tools)
extra_params = _forwarded_parameters(config)
max_tokens = extra_params.pop("max_tokens", 1024)
Expand Down
54 changes: 54 additions & 0 deletions packages/claude-messages/tests/test_native_tools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""A NativeTool is never sent to the model as a function tool."""

from __future__ import annotations

import logging
from typing import Any

import pytest

from launchdarkly_ai_server import NATIVE_TOOL_KEY, NativeTool

# The warn-once set is process-wide, so each test uses its own key.
PREFIX = "native-web-claude-messages"


def _config_tools(key: str) -> dict[str, Any]:
return {
"search": {"description": "d", "parameters": {"type": "object"}},
key: {"description": "d", "parameters": {"type": "object"}},
}


def _stub(native: NativeTool) -> Any:
"""The tracking stub ``wrap_tool_handlers`` makes from a NativeTool."""

def stub() -> None:
return None

setattr(stub, NATIVE_TOOL_KEY, native)
return stub


def _assert_skipped(
key: str, build: Any, names: Any, caplog: pytest.LogCaptureFixture
) -> None:
native = NativeTool("web_search")
tools = _config_tools(key)
with caplog.at_level(logging.WARNING):
first = build(tools, {"search": lambda *a, **k: "x", key: _stub(native)})
second = build(tools, {"search": lambda *a, **k: "x", key: native})
assert names(first) == ["search"]
assert names(second) == ["search"]
assert sum(f'"{key}"' in r.getMessage() for r in caplog.records) == 1


def test_handler_never_sends_a_native_tool(caplog: pytest.LogCaptureFixture) -> None:
from launchdarkly_ai_claude_messages.handler import _build_tools

_assert_skipped(
PREFIX + "handler",
lambda c, h: _build_tools(c, h),
lambda r: [t["name"] for t in r],
caplog,
)
1 change: 1 addition & 0 deletions packages/client/api-report/launchdarkly_ai_server.txt
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ InputTokenDetails [class, @dataclass]
uncached: int = 0
inspect_config(config_key: str, context: dict[str, Any]) -> dict[str, Any] [async]
is_content_blocks(content: MessageContent) -> bool
is_function_tool(handler: Any, name: str) -> TypeGuard[Callable[..., Any]]
Judge [class, @dataclass(frozen=True)]
__init__(key: str, threshold: float | None = DEFAULT_JUDGE_THRESHOLD, pass_rate_threshold: float | None = None, ground_truth_context: str | None = None)
__post_init__() -> None
Expand Down
2 changes: 2 additions & 0 deletions packages/client/src/launchdarkly_ai_server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@
create_run_usage,
end_span_once,
end_unfinished_spans,
is_function_tool,
lang_chain_span_usage,
make_track_data,
model_stamps_from_meta,
Expand Down Expand Up @@ -215,6 +216,7 @@
"create_run_usage",
"end_span_once",
"end_unfinished_spans",
"is_function_tool",
"lang_chain_content_text",
"lang_chain_finish_reasons",
"lang_chain_span_messages",
Expand Down
31 changes: 29 additions & 2 deletions packages/client/src/launchdarkly_ai_server/utils.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,28 @@
from __future__ import annotations

import json
import logging
import re
from collections.abc import Mapping
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from typing import Any, Literal
from typing import Any, Literal, TypeGuard

from .sdk_usage import report_usage
from .types import (
NATIVE_TOOL_KEY,
AiConfigRep,
GraphNode,
InputTokenDetails,
NativeTool,
ProviderHandler,
UsageDict,
VariationMeta,
_HandlerFn,
_StreamFn,
)

logger = logging.getLogger(__name__)


def create_handler(
provides_for: tuple[str, Literal["agent", "messages"]],
Expand Down Expand Up @@ -896,3 +901,25 @@ def parse_json_with_possible_fences(raw_text: str) -> Any | None:
pass

return None


_warned_native_tools: set[str] = set()


def is_function_tool(handler: Any, name: str) -> TypeGuard[Callable[..., Any]]:
"""
Whether a handler may send ``handler`` to the model as a function tool. A ``NativeTool``,
or the tracking stub ``wrap_tool_handlers`` makes from one, is a provider built-in, never a
function tool: a handler with no built-in for it skips it, warning once per tool key.
"""
if isinstance(handler, NativeTool) or isinstance(
getattr(handler, NATIVE_TOOL_KEY, None), NativeTool
):
if name not in _warned_native_tools:
_warned_native_tools.add(name)
logger.warning(
'Skipping native tool "%s": this handler has no built-in tool for it.',
name,
)
return False
return callable(handler)
1 change: 1 addition & 0 deletions packages/client/tests/test_public_surface.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
"init_client",
"inspect_config",
"is_content_blocks",
"is_function_tool",
"lang_chain_content_text",
"lang_chain_finish_reasons",
"lang_chain_span_messages",
Expand Down
32 changes: 32 additions & 0 deletions packages/client/tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -434,3 +434,35 @@ def test_never_reads_custom(self) -> None:
result = model_parameters(config)
assert "secret" not in result
assert "custom" not in result


# ---------------------------------------------------------------------------
# is_function_tool
# ---------------------------------------------------------------------------


def test_is_function_tool_accepts_a_callable_and_rejects_a_missing_handler() -> None:
from launchdarkly_ai_server import is_function_tool

assert is_function_tool(lambda: 1, "fn") is True
assert is_function_tool(None, "missing") is False


def test_is_function_tool_rejects_a_native_tool_and_its_stub_warning_once_per_key(
caplog: pytest.LogCaptureFixture,
) -> None:
from launchdarkly_ai_server import NativeTool, is_function_tool, wrap_tool_handlers

native = NativeTool("WebSearch")
stub = wrap_tool_handlers({"utils-web": native}, {"kind": "user", "key": "u"}, {})[
"utils-web"
]
assert callable(stub)
with caplog.at_level("WARNING"):
assert is_function_tool(stub, "utils-web") is False
assert is_function_tool(native, "utils-web") is False
assert is_function_tool(native, "utils-other") is False
assert [r.getMessage() for r in caplog.records] == [
'Skipping native tool "utils-web": this handler has no built-in tool for it.',
'Skipping native tool "utils-other": this handler has no built-in tool for it.',
]
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
create_run_usage,
end_span_once,
end_unfinished_spans,
is_function_tool,
lang_chain_content_text,
lang_chain_span_messages,
lang_chain_span_usage,
Expand Down Expand Up @@ -400,11 +401,14 @@ def _build_agent_tools(

result = []
for name, tool_cfg in config_tools.items():
# A missing handler still reaches the model; a NativeTool never does.
if name in tool_handlers and not is_function_tool(tool_handlers[name], name):
continue
schema = tool_cfg.get("parameters") or {}

async def _handler(_name: str = name, **kwargs: Any) -> str:
fn = tool_handlers.get(_name)
# A NativeTool reaches here unwrapped on the offline evaluations path.
# The builder skips a NativeTool, so this guards only a handler that is not callable.
if not callable(fn):
raise ValueError(f'No handler registered for tool "{_name}"')
# Handlers may be sync or async. Graph ``__handoff_*`` tools stay sync so
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
NativeTool,
compose_history,
get_client,
is_function_tool,
make_track_data,
parse_template,
report_usage,
Expand Down Expand Up @@ -163,6 +164,8 @@ def _build_node_tools(

result = []
for name, tool_cfg in node.config["tools"].items():
if name in tool_handlers and not is_function_tool(tool_handlers[name], name):
continue
schema = tool_cfg.get("parameters") or {}

async def _handler(_name: str = name, **kwargs: Any) -> str:
Expand Down
21 changes: 13 additions & 8 deletions packages/langchain-agents/tests/test_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -431,16 +431,21 @@ def _capture_tool(name: Any, fn: Any = None, **kw: Any) -> Any:
await captured_fns[0](key="val")

@pytest.mark.asyncio
async def test_native_tool_is_not_called(self) -> None:
# Offline evaluations pass a NativeTool through unwrapped; it is not callable.
async def test_native_tool_is_not_registered(self) -> None:
# Offline evaluations pass a NativeTool through unwrapped; it is never a function tool.
from launchdarkly_ai_server import NativeTool

run = _built_tool(
{"web-search": {"description": "d", "parameters": {}}},
{"web-search": NativeTool("WebSearch")},
)
with pytest.raises(ValueError, match="No handler"):
await run(query="q")
mocks = _make_langchain_mock()
mocks["langchain_core.tools"].tool = MagicMock()
with _patch_lc(mocks):
from launchdarkly_ai_langchain_agents.handler import _build_agent_tools

tools = _build_agent_tools(
{"web-search": {"description": "d", "parameters": {}}},
{"web-search": NativeTool("WebSearch")},
)
assert tools == []
mocks["langchain_core.tools"].tool.assert_not_called()

@pytest.mark.asyncio
async def test_tool_handler_throws_propagates(self) -> None:
Expand Down
68 changes: 68 additions & 0 deletions packages/langchain-agents/tests/test_native_tools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""A NativeTool is never sent to the model as a function tool."""

from __future__ import annotations

import logging
from types import SimpleNamespace
from typing import Any

import pytest

from launchdarkly_ai_server import NATIVE_TOOL_KEY, NativeTool

# The warn-once set is process-wide, so each test uses its own key.
PREFIX = "native-web-langchain-agents"


def _config_tools(key: str) -> dict[str, Any]:
return {
"search": {"description": "d", "parameters": {"type": "object"}},
key: {"description": "d", "parameters": {"type": "object"}},
}


def _stub(native: NativeTool) -> Any:
"""The tracking stub ``wrap_tool_handlers`` makes from a NativeTool."""

def stub() -> None:
return None

setattr(stub, NATIVE_TOOL_KEY, native)
return stub


def _assert_skipped(
key: str, build: Any, names: Any, caplog: pytest.LogCaptureFixture
) -> None:
native = NativeTool("web_search")
tools = _config_tools(key)
with caplog.at_level(logging.WARNING):
first = build(tools, {"search": lambda *a, **k: "x", key: _stub(native)})
second = build(tools, {"search": lambda *a, **k: "x", key: native})
assert names(first) == ["search"]
assert names(second) == ["search"]
assert sum(f'"{key}"' in r.getMessage() for r in caplog.records) == 1


def test_handler_never_sends_a_native_tool(caplog: pytest.LogCaptureFixture) -> None:
from launchdarkly_ai_langchain_agents.handler import _build_agent_tools

_assert_skipped(
PREFIX + "handler",
lambda c, h: _build_agent_tools(c, h),
lambda r: [t.name for t in r],
caplog,
)


def test_native_graph_never_sends_a_native_tool(
caplog: pytest.LogCaptureFixture,
) -> None:
from launchdarkly_ai_langchain_agents.native_graph import _build_node_tools

_assert_skipped(
PREFIX + "native_graph",
lambda c, h: _build_node_tools(SimpleNamespace(config={"tools": c}), h),
lambda r: [t.name for t in r],
caplog,
)
Loading
Loading