Skip to content

Commit d2a78d2

Browse files
committed
2.1: tool_batch — batch tool calls in one round trip
Phase 2.1 — the agent can now invoke multiple TDPilot tools in a single tool_use block. Saves LLM round-trip latency on independent read-style chains ("get info + get errors + get capabilities") that otherwise pay N model→server→model cycles for the trivial chained-read case. The reviewer's strongest single recommendation; cheap to implement once the dispatcher had the ``_raw_dispatcher`` field landed for ``recipe_replay``. Sub-calls execute SEQUENTIALLY on the cook thread because TD's Python API isn't thread-safe — ``ThreadPoolExecutor.submit`` wouldn't help, the per-tool latency is unchanged. The win is strictly LLM round trips (eliminates ``tool_use → tool_result → think → next tool_use`` chains). New module: td_component/tdpilot_api_batch.py - handle_tool_batch(body) — dispatches body["calls"] (max 8) via the raw dispatcher resolved through COMP → extension → runtime, same pattern as handle_recipe_replay. - Soft failure: a sub-call returning {"error": ...} or raising captures the failure in results[i] but does NOT abort the batch — the rest still run. - Recursion guard: a sub-call to "tool_batch" itself is rejected per-row with a "Nested tool_batch is not allowed" error. - Validation: empty calls list, oversize batch, missing tool name, non-dict call entries, non-dict args — all surface structured errors. Outside-TD invocation (no parent()) returns a clean "could not access dispatcher" error. Wiring: - tdpilot_api_schema_defs.py: tool_batch schema entry (description names the round-trip win, calls items: tool + args, maxItems=8). - tdpilot_api_schema_map.py: TOOL_TO_HANDLER → "handle_tool_batch". - tdpilot_api_extension.py: tdpilot_api_batch added to _ensure_module_path's name list AND to the handler-modules loop in build_runtime so the dispatcher can find ``handle_tool_batch``. - build_tdpilot_api_tox.py: tdpilot_api_batch.py listed in _SOURCE_FILES so the build script bakes it into the .tox. Tool count: 88 → 89. Schema/handler parity verified (``test_tool_batch_present_in_schemas_and_handlers``). Tests: 13 in tests/test_tdpilot_api_batch.py covering happy path, elapsed_ms recording, soft-failure-with-error-dict, soft-failure-on- exception, validation (empty / missing / oversize / nested / malformed entries), no-dispatcher cleanup, and the parity check. Pytest 1013 passing (up from 1000). Lints + format clean. Five td_component sources changed — standalone tdpilot_API.tox needs rebuilding before the agent can invoke tool_batch.
1 parent 100a966 commit d2a78d2

6 files changed

Lines changed: 469 additions & 0 deletions

File tree

td_component/build_tdpilot_api_tox.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ def _load_legacy_module():
199199
("tdpilot_api_official_docs", "textDAT", "td_component/tdpilot_api_official_docs.py"),
200200
("tdpilot_api_td2025", "textDAT", "td_component/tdpilot_api_td2025.py"),
201201
("tdpilot_api_introspect", "textDAT", "td_component/tdpilot_api_introspect.py"),
202+
("tdpilot_api_batch", "textDAT", "td_component/tdpilot_api_batch.py"),
202203
("tdpilot_api_chat_html", "textDAT", "td_component/tdpilot_api_chat.html"),
203204
("tdpilot_api_web_callbacks", "textDAT", "td_component/tdpilot_api_web_callbacks.py"),
204205
("mcp_webserver_callbacks", "textDAT", "td_component/mcp_webserver_callbacks.py"),

td_component/tdpilot_api_batch.py

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
"""TDPilot API — ``tool_batch`` handler (Phase 2.1).
2+
3+
Run multiple TDPilot tool calls in one round trip. The win is
4+
LLM-round-trip latency: instead of issuing N tool_use blocks and
5+
paying N model→server→model cycles, the agent issues one
6+
``tool_batch`` and gets all N results back together.
7+
8+
Sub-calls execute SEQUENTIALLY on the cook thread because TD's
9+
Python API isn't thread-safe — ``ThreadPoolExecutor.submit``
10+
doesn't help here, the per-tool latency is unchanged. What we save
11+
is the chain of ``tool_use → tool_result → next-think → next
12+
tool_use`` that the model would otherwise traverse.
13+
14+
Failure handling: a sub-call that returns ``{"error": ...}`` does
15+
NOT abort the batch. Each result is reported in
16+
``results[i].error`` and the rest still run. This matches the way
17+
``recipe_replay`` was always *supposed* to behave when the agent
18+
runs heterogeneous read-only lookups.
19+
20+
Hard caps:
21+
max 8 sub-calls (matches the schema's maxItems).
22+
nested ``tool_batch`` is rejected per sub-call (cheap recursion
23+
guard so a confused agent can't fork-bomb the dispatcher).
24+
"""
25+
26+
from __future__ import annotations
27+
28+
import time
29+
from typing import Any
30+
31+
MAX_BATCH_SIZE = 8
32+
33+
34+
def _resolve_raw_dispatcher() -> Any:
35+
"""Walk COMP → extension → runtime to find the cook-thread-bypass
36+
dispatcher (same pattern as ``handle_recipe_replay``).
37+
38+
Returns ``None`` if any step fails — caller surfaces a clear error.
39+
"""
40+
try:
41+
comp = parent() # type: ignore[name-defined]
42+
except NameError:
43+
return None
44+
if comp is None:
45+
return None
46+
ext_dat = comp.op("tdpilot_api_extension")
47+
if ext_dat is None:
48+
return None
49+
try:
50+
ext = ext_dat.module.get_extension(comp)
51+
return ext._runtime._raw_dispatcher
52+
except Exception:
53+
return None
54+
55+
56+
def handle_tool_batch(body: dict) -> dict:
57+
"""Dispatch a list of tool calls and return all results.
58+
59+
Body schema:
60+
{"calls": [{"tool": str, "args": dict}, ...]}
61+
62+
Returns:
63+
{"ok": True,
64+
"count": int,
65+
"results": [
66+
{"tool": str,
67+
"ok": bool,
68+
"result": dict | None,
69+
"error": str | None,
70+
"elapsed_ms": int},
71+
...
72+
]}
73+
74+
On invalid body shape returns ``{"error": str}`` instead.
75+
"""
76+
if not isinstance(body, dict):
77+
return {"error": "tool_batch body must be a dict"}
78+
79+
calls = body.get("calls")
80+
if not isinstance(calls, list) or not calls:
81+
return {"error": "tool_batch requires non-empty 'calls' list"}
82+
83+
if len(calls) > MAX_BATCH_SIZE:
84+
return {"error": (f"tool_batch capped at {MAX_BATCH_SIZE} sub-calls (received {len(calls)})")}
85+
86+
raw_dispatcher = _resolve_raw_dispatcher()
87+
if raw_dispatcher is None:
88+
return {"error": "tool_batch could not access the runtime dispatcher"}
89+
90+
results: list[dict] = []
91+
for i, call in enumerate(calls):
92+
if not isinstance(call, dict):
93+
results.append(
94+
{
95+
"tool": None,
96+
"ok": False,
97+
"result": None,
98+
"error": f"call[{i}] is not an object",
99+
"elapsed_ms": 0,
100+
}
101+
)
102+
continue
103+
tool_name = call.get("tool")
104+
if not isinstance(tool_name, str) or not tool_name.strip():
105+
results.append(
106+
{
107+
"tool": tool_name,
108+
"ok": False,
109+
"result": None,
110+
"error": f"call[{i}].tool is missing or not a string",
111+
"elapsed_ms": 0,
112+
}
113+
)
114+
continue
115+
if tool_name == "tool_batch":
116+
results.append(
117+
{
118+
"tool": tool_name,
119+
"ok": False,
120+
"result": None,
121+
"error": "Nested tool_batch is not allowed",
122+
"elapsed_ms": 0,
123+
}
124+
)
125+
continue
126+
127+
tool_args = call.get("args") or {}
128+
if not isinstance(tool_args, dict):
129+
results.append(
130+
{
131+
"tool": tool_name,
132+
"ok": False,
133+
"result": None,
134+
"error": f"call[{i}].args must be an object",
135+
"elapsed_ms": 0,
136+
}
137+
)
138+
continue
139+
140+
t_start = time.monotonic()
141+
try:
142+
result = raw_dispatcher(tool_name, tool_args)
143+
except Exception as exc: # noqa: BLE001 — surface as per-call error
144+
elapsed_ms = int((time.monotonic() - t_start) * 1000)
145+
results.append(
146+
{
147+
"tool": tool_name,
148+
"ok": False,
149+
"result": None,
150+
"error": f"{type(exc).__name__}: {exc}",
151+
"elapsed_ms": elapsed_ms,
152+
}
153+
)
154+
continue
155+
elapsed_ms = int((time.monotonic() - t_start) * 1000)
156+
157+
is_error = isinstance(result, dict) and "error" in result
158+
results.append(
159+
{
160+
"tool": tool_name,
161+
"ok": not is_error,
162+
"result": result if not is_error else None,
163+
"error": (result.get("error") if is_error else None),
164+
"elapsed_ms": elapsed_ms,
165+
}
166+
)
167+
168+
return {
169+
"ok": True,
170+
"count": len(results),
171+
"results": results,
172+
}

td_component/tdpilot_api_extension.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ def _ensure_module_path(self) -> None:
111111
"tdpilot_api_official_docs",
112112
"tdpilot_api_td2025",
113113
"tdpilot_api_introspect",
114+
"tdpilot_api_batch",
114115
"mcp_webserver_callbacks",
115116
):
116117
child = self.owner.op(name)
@@ -205,6 +206,7 @@ def _build_runtime(self) -> None:
205206
("tdpilot_api_official_docs", "official_docs"),
206207
("tdpilot_api_td2025", "td2025_native"),
207208
("tdpilot_api_introspect", "introspect"),
209+
("tdpilot_api_batch", "tool_batch"),
208210
):
209211
dat = self.owner.op(mod_name)
210212
if dat is not None:

td_component/tdpilot_api_schema_defs.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1764,6 +1764,53 @@
17641764
),
17651765
"input_schema": {"type": "object", "properties": {}, "additionalProperties": False},
17661766
},
1767+
{
1768+
"name": "tool_batch",
1769+
"description": (
1770+
"Run multiple TDPilot tool calls in one round trip. Saves "
1771+
"LLM round-trip cost when you need several independent "
1772+
"lookups (e.g. info + errors + capabilities) — submit them "
1773+
"all here instead of issuing N separate tool_use blocks. "
1774+
"Each sub-call's result is returned in `results[i]` with "
1775+
"the same shape as a normal tool result. A failed sub-call "
1776+
"does NOT abort the batch — the failure is reported in "
1777+
"results[i].error and the rest still run. Max 8 sub-calls "
1778+
"per batch; nested tool_batch is rejected. Sub-calls "
1779+
"execute serially on the cook thread (TD's API isn't "
1780+
"thread-safe), so the win is round-trip latency, not "
1781+
"per-tool latency."
1782+
),
1783+
"input_schema": {
1784+
"type": "object",
1785+
"properties": {
1786+
"calls": {
1787+
"type": "array",
1788+
"minItems": 1,
1789+
"maxItems": 8,
1790+
"description": "List of tool calls to dispatch.",
1791+
"items": {
1792+
"type": "object",
1793+
"properties": {
1794+
"tool": {
1795+
"type": "string",
1796+
"description": (
1797+
"Name of an existing TDPilot tool (must be "
1798+
"in TOOL_SCHEMAS — tool_batch itself is rejected)."
1799+
),
1800+
},
1801+
"args": {
1802+
"type": "object",
1803+
"description": "Arguments dict for that tool. May be empty.",
1804+
},
1805+
},
1806+
"required": ["tool"],
1807+
},
1808+
},
1809+
},
1810+
"required": ["calls"],
1811+
"additionalProperties": False,
1812+
},
1813+
},
17671814
]
17681815

17691816

td_component/tdpilot_api_schema_map.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,4 +200,6 @@ def _adapt_get_errors(d: dict) -> dict:
200200
"td_get_server_metrics": ("handle_get_server_metrics", _id),
201201
"td_describe_surface": ("handle_describe_surface", _id),
202202
"td_get_capabilities": ("handle_get_capabilities", _id),
203+
# ---- Tool batch (Phase 2.1 — handler in tdpilot_api_batch.py) ----
204+
"tool_batch": ("handle_tool_batch", _id),
203205
}

0 commit comments

Comments
 (0)