mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 01:35:28 -04:00
fix(quickjs): ptc tools in tools namespace are rendered without prepended tools. in system prompt and task as ptc duplicated task global (#4075)
### Summary Our system prompt for the code interpreter has a tools namespace section that shows call signatures for PTC tools. The existing logic rendered those tools without tools. prepended. This caused the agent to confuse the task global with the tools-namespaced (PTC) task variant, which uses `subagent_type` instead of `subagentType`. Additionally, since we already expose task as a global we should disallow it from being provided as a ptc tool. This PR prepends tools. to rendered PTC tool signatures to differentiate PTC tools from globals and throws a value error if task is passed in ptc. ### Tests - Unit tests to validate expected behavior - Ran an e2e test in LangSmith to validate behavior
This commit is contained in:
@@ -455,7 +455,7 @@ def _render_signature(
|
||||
) -> str:
|
||||
return_clause = f"Promise<{return_type}>"
|
||||
default_signature = (
|
||||
f"async function {fn_name}(input: Record<string, unknown>): {return_clause}"
|
||||
f"tools.{fn_name}(input: Record<string, unknown>): {return_clause}"
|
||||
)
|
||||
if not schema or not isinstance(schema.get("properties"), dict):
|
||||
return default_signature
|
||||
@@ -471,7 +471,7 @@ def _render_signature(
|
||||
body = "\n".join(fields) if fields else ""
|
||||
if not body:
|
||||
return default_signature
|
||||
return f"async function {fn_name}(input: {{\n{body}\n}}): {return_clause}"
|
||||
return f"tools.{fn_name}(input: {{\n{body}\n}}): {return_clause}"
|
||||
|
||||
|
||||
# Return types come from the tool's underlying function annotation. We feed
|
||||
|
||||
@@ -34,6 +34,16 @@ if TYPE_CHECKING:
|
||||
|
||||
PTCOption = list[str | BaseTool]
|
||||
|
||||
_RESERVED_SUBAGENT_TASK_NAME = "task"
|
||||
|
||||
_TASK_IN_PTC_MSG = (
|
||||
"The subagent `task` tool cannot be exposed via `ptc`. It is always "
|
||||
"available as the top-level `task()` global inside the REPL (with "
|
||||
"`subagentType` and `responseSchema` support); exposing it through the "
|
||||
"`tools.*` namespace would create a second, conflicting dispatch path "
|
||||
'that drops `responseSchema`. Remove "task" from `ptc`.'
|
||||
)
|
||||
|
||||
|
||||
def filter_tools_for_ptc(
|
||||
tools: Sequence[BaseTool],
|
||||
@@ -58,6 +68,11 @@ def filter_tools_for_ptc(
|
||||
are included first, then name-matched agent tools are appended.
|
||||
Duplicate tool names are deduplicated.
|
||||
|
||||
The subagent ``task`` tool is reserved and may not appear in ``config``
|
||||
(by name or instance) — it is always available as the ``task()`` global,
|
||||
so a ``tools.task`` PTC variant would be a conflicting, degraded duplicate.
|
||||
A ``"task"`` entry raises ``ValueError``.
|
||||
|
||||
Warning:
|
||||
PTC tool calls execute through the REPL bridge and currently do
|
||||
not respect `interrupt_on` / HITL approval hooks for each
|
||||
@@ -68,10 +83,14 @@ def filter_tools_for_ptc(
|
||||
allow_names: set[str] = set()
|
||||
for entry in config:
|
||||
if isinstance(entry, BaseTool):
|
||||
if entry.name == _RESERVED_SUBAGENT_TASK_NAME:
|
||||
raise ValueError(_TASK_IN_PTC_MSG)
|
||||
if entry.name != self_tool_name:
|
||||
explicit_tools.append(entry)
|
||||
continue
|
||||
if isinstance(entry, str):
|
||||
if entry == _RESERVED_SUBAGENT_TASK_NAME:
|
||||
raise ValueError(_TASK_IN_PTC_MSG)
|
||||
allow_names.add(entry)
|
||||
continue
|
||||
msg = "ptc list entries must be str or BaseTool"
|
||||
|
||||
+5
-5
@@ -376,27 +376,27 @@ console.log({ city, normalized });
|
||||
|
||||
```typescript
|
||||
/** Find users with the given name. */
|
||||
async function findUsersByName(input: {
|
||||
tools.findUsersByName(input: {
|
||||
name: string;
|
||||
}): Promise<unknown[]>
|
||||
|
||||
/** Get the location id for a user. */
|
||||
async function getUserLocation(input: {
|
||||
tools.getUserLocation(input: {
|
||||
user_id: number;
|
||||
}): Promise<number>
|
||||
|
||||
/** Get the city for a location. */
|
||||
async function getCityForLocation(input: {
|
||||
tools.getCityForLocation(input: {
|
||||
location_id: number;
|
||||
}): Promise<string>
|
||||
|
||||
/** Normalize a user name for matching. */
|
||||
async function normalizeName(input: {
|
||||
tools.normalizeName(input: {
|
||||
name: string;
|
||||
}): Promise<string>
|
||||
|
||||
/** Fetch the current weather for a city. */
|
||||
async function fetchWeather(input: {
|
||||
tools.fetchWeather(input: {
|
||||
city: string;
|
||||
}): Promise<string>
|
||||
```
|
||||
|
||||
+5
-5
@@ -376,27 +376,27 @@ console.log({ city, normalized });
|
||||
|
||||
```typescript
|
||||
/** Find users with the given name. */
|
||||
async function findUsersByName(input: {
|
||||
tools.findUsersByName(input: {
|
||||
name: string;
|
||||
}): Promise<unknown[]>
|
||||
|
||||
/** Get the location id for a user. */
|
||||
async function getUserLocation(input: {
|
||||
tools.getUserLocation(input: {
|
||||
user_id: number;
|
||||
}): Promise<number>
|
||||
|
||||
/** Get the city for a location. */
|
||||
async function getCityForLocation(input: {
|
||||
tools.getCityForLocation(input: {
|
||||
location_id: number;
|
||||
}): Promise<string>
|
||||
|
||||
/** Normalize a user name for matching. */
|
||||
async function normalizeName(input: {
|
||||
tools.normalizeName(input: {
|
||||
name: string;
|
||||
}): Promise<string>
|
||||
|
||||
/** Fetch the current weather for a city. */
|
||||
async function fetchWeather(input: {
|
||||
tools.fetchWeather(input: {
|
||||
city: string;
|
||||
}): Promise<string>
|
||||
```
|
||||
|
||||
+5
-5
@@ -376,27 +376,27 @@ console.log({ city, normalized });
|
||||
|
||||
```typescript
|
||||
/** Find users with the given name. */
|
||||
async function findUsersByName(input: {
|
||||
tools.findUsersByName(input: {
|
||||
name: string;
|
||||
}): Promise<unknown[]>
|
||||
|
||||
/** Get the location id for a user. */
|
||||
async function getUserLocation(input: {
|
||||
tools.getUserLocation(input: {
|
||||
user_id: number;
|
||||
}): Promise<number>
|
||||
|
||||
/** Get the city for a location. */
|
||||
async function getCityForLocation(input: {
|
||||
tools.getCityForLocation(input: {
|
||||
location_id: number;
|
||||
}): Promise<string>
|
||||
|
||||
/** Normalize a user name for matching. */
|
||||
async function normalizeName(input: {
|
||||
tools.normalizeName(input: {
|
||||
name: string;
|
||||
}): Promise<string>
|
||||
|
||||
/** Fetch the current weather for a city. */
|
||||
async function fetchWeather(input: {
|
||||
tools.fetchWeather(input: {
|
||||
city: string;
|
||||
}): Promise<string>
|
||||
```
|
||||
|
||||
@@ -252,6 +252,28 @@ def test_filter_list_include() -> None:
|
||||
assert [t.name for t in out] == ["a", "c"]
|
||||
|
||||
|
||||
def test_filter_rejects_task_by_name() -> None:
|
||||
task = _echo_tool("task")
|
||||
with pytest.raises(ValueError, match="task` tool cannot be exposed"):
|
||||
filter_tools_for_ptc([task], ["task"], self_tool_name="eval")
|
||||
|
||||
|
||||
def test_filter_rejects_task_by_instance() -> None:
|
||||
task = _echo_tool("task")
|
||||
with pytest.raises(ValueError, match="task` tool cannot be exposed"):
|
||||
filter_tools_for_ptc([], [task], self_tool_name="eval")
|
||||
|
||||
|
||||
def test_filter_allows_non_task_tools() -> None:
|
||||
# Sanity: the reservation is specific to the name "task".
|
||||
out = filter_tools_for_ptc(
|
||||
[_echo_tool("tasks"), _echo_tool("subtask")],
|
||||
["tasks", "subtask"],
|
||||
self_tool_name="eval",
|
||||
)
|
||||
assert [t.name for t in out] == ["tasks", "subtask"]
|
||||
|
||||
|
||||
def test_filter_list_of_tools_uses_them_directly() -> None:
|
||||
"""`list[BaseTool]` ignores agent tools and uses the supplied list."""
|
||||
greet = _greet_tool()
|
||||
@@ -314,7 +336,7 @@ def test_render_ptc_prompt_uses_signatures() -> None:
|
||||
prompt = render_ptc_prompt([_greet_tool()])
|
||||
assert "`tools` namespace" in prompt
|
||||
assert "globalThis.tools" in prompt
|
||||
assert "async function greet(input:" in prompt
|
||||
assert "tools.greet(input:" in prompt
|
||||
# Fields come through
|
||||
assert "name: string" in prompt
|
||||
assert "times?: number" in prompt
|
||||
@@ -583,7 +605,7 @@ def test_middleware_ptc_list_includes_prompt_block() -> None:
|
||||
req = SimpleNamespace(tools=[_greet_tool(), _echo_tool("eval")])
|
||||
prompt = mw._prepare_for_call(req)
|
||||
# Greet included
|
||||
assert "async function greet(" in prompt
|
||||
assert "tools.greet(" in prompt
|
||||
# The REPL's own tool never appears
|
||||
assert "tools.eval(" not in prompt
|
||||
|
||||
@@ -595,7 +617,7 @@ def test_middleware_ptc_list_of_tools_exposes_without_agent_tools() -> None:
|
||||
mw = CodeInterpreterMiddleware(ptc=[_greet_tool()])
|
||||
req = SimpleNamespace(tools=[])
|
||||
prompt = mw._prepare_for_call(req)
|
||||
assert "async function greet(" in prompt
|
||||
assert "tools.greet(" in prompt
|
||||
|
||||
|
||||
async def test_ptc_install_and_eval_resolve_to_same_repl() -> None:
|
||||
|
||||
@@ -9,8 +9,6 @@ from collections.abc import (
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from deepagents import create_deep_agent
|
||||
from deepagents.middleware.subagents import SubAgentMiddleware
|
||||
from langchain.agents import create_agent
|
||||
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
|
||||
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
||||
from langchain_core.runnables import RunnableLambda
|
||||
@@ -105,8 +103,12 @@ async def test_quickjs_async_ptc_runs_tools_on_outer_loop() -> None:
|
||||
_assert_result_contains(tool_message.content, outer_loop_id)
|
||||
|
||||
|
||||
async def test_quickjs_async_ptc_task_subagent_loop_affinity_e2e() -> None:
|
||||
"""E2E: PTC `task` runs compiled subagents on the caller loop."""
|
||||
async def test_quickjs_async_task_global_subagent_loop_affinity_e2e() -> None:
|
||||
"""E2E: the top-level `task()` global runs compiled subagents on the caller loop.
|
||||
|
||||
`task` is reserved and cannot be exposed via `ptc` (it is always the global),
|
||||
so the loop-affinity guarantee is exercised through the global dispatch path.
|
||||
"""
|
||||
outer_loop_id = id(asyncio.get_running_loop())
|
||||
owner_loop = asyncio.get_running_loop()
|
||||
subagent_loop_ids: list[int] = []
|
||||
@@ -126,33 +128,28 @@ async def test_quickjs_async_ptc_task_subagent_loop_affinity_e2e() -> None:
|
||||
return {"messages": [AIMessage(content="subagent-ok")]}
|
||||
|
||||
subagent_runnable = RunnableLambda(_subagent_sync, afunc=_subagent_async)
|
||||
agent = create_agent(
|
||||
agent = create_deep_agent(
|
||||
model=_FakeChatModel(
|
||||
messages=_script(
|
||||
"await tools.task({"
|
||||
"description: 'say hi', subagent_type: 'researcher'"
|
||||
"})",
|
||||
"await task({description: 'say hi', subagentType: 'researcher'})",
|
||||
final_message="done",
|
||||
)
|
||||
),
|
||||
subagents=[
|
||||
{
|
||||
"name": "researcher",
|
||||
"description": "returns one short answer",
|
||||
"runnable": subagent_runnable,
|
||||
}
|
||||
],
|
||||
middleware=[
|
||||
SubAgentMiddleware(
|
||||
backend=None,
|
||||
subagents=[
|
||||
{
|
||||
"name": "researcher",
|
||||
"description": "returns one short answer",
|
||||
"runnable": subagent_runnable,
|
||||
}
|
||||
],
|
||||
),
|
||||
CodeInterpreterMiddleware(ptc=["task"]),
|
||||
CodeInterpreterMiddleware(),
|
||||
],
|
||||
)
|
||||
|
||||
result = await agent.ainvoke(
|
||||
{"messages": [HumanMessage(content="Use eval and call task for researcher")]},
|
||||
config={"configurable": {"thread_id": "ptc-task-loop-affinity"}},
|
||||
config={"configurable": {"thread_id": "task-global-loop-affinity"}},
|
||||
)
|
||||
tool_message = _eval_tool_message(result)
|
||||
_assert_result_contains(tool_message.content, "subagent-ok")
|
||||
|
||||
Reference in New Issue
Block a user