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:
Colin Francis
2026-06-18 14:26:25 -07:00
committed by GitHub
parent f99f712597
commit 014a9033af
7 changed files with 78 additions and 40 deletions
@@ -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"
@@ -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>
```
@@ -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>
```
@@ -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")