MCP servers, over streamable HTTP
A server is a row with a URL; its tools are discovered by a button and cached, then offered beside the built-in ones. Written by hand rather than taken from the reference SDK, because that SDK's transport does its own connecting -- and the one thing that must not be bypassed is check_url on every hop. Owning the transport is the point; the framing beside it is the small part. Sessions are per call: initialize, initialized, the call, a best-effort DELETE. Caching one wants an owner, a TTL, eviction, a lock and a shutdown hook, and the server may expire it under all of that anyway -- ToolContext is a session-free snapshot precisely so nothing in a tool holds live state. A server's names and descriptions reach the model as instructions and are bounded before they do; what it returns is escaped preformatted text, never markdown. Tools are namespaced per server, so two servers exposing "search" do not collide and neither shadows a built-in. Also: a round's calls now run together under a semaphore, results indexed so each tool turn stays paired with its call, and generation.status names what is running -- a remote tool is latency-bound, and a silent pause is what a hang looks like. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -338,13 +338,19 @@ async def _run(generation: Generation) -> None:
|
||||
*payload["messages"],
|
||||
tools_service.assistant_turn(calls, "".join(round_text)),
|
||||
]
|
||||
for call in calls:
|
||||
outcome = await tools_service.run_tool(
|
||||
tool_context, call["name"], call["arguments"]
|
||||
)
|
||||
generation.tool_events.append(outcome.event)
|
||||
|
||||
generation.status = _tool_status(calls)
|
||||
generation.touch()
|
||||
try:
|
||||
outcomes = await _run_calls(tool_context, calls)
|
||||
finally:
|
||||
generation.status = ""
|
||||
generation.touch()
|
||||
|
||||
for call, outcome in zip(calls, outcomes, strict=True):
|
||||
generation.tool_events.append(outcome.event)
|
||||
messages.append(tools_service.tool_turn(call, outcome.content))
|
||||
generation.touch()
|
||||
|
||||
payload = {**payload, "messages": messages}
|
||||
|
||||
@@ -465,6 +471,49 @@ async def _maybe_compact(generation: Generation) -> None:
|
||||
generation.touch()
|
||||
|
||||
|
||||
# How many of a round's tool calls may be in flight at once. A bound rather
|
||||
# than none: a model that asks for eight would otherwise open eight sockets and
|
||||
# eight database sessions at the same moment.
|
||||
MAX_PARALLEL_TOOLS = 4
|
||||
|
||||
|
||||
def _tool_status(calls: list[dict]) -> str:
|
||||
"""What to show while tools run.
|
||||
|
||||
A remote tool -- an HTTP endpoint, an MCP server -- can take seconds with
|
||||
nothing streaming, and a silent pause is exactly what a hang looks like.
|
||||
"""
|
||||
if len(calls) == 1:
|
||||
return f"Running {calls[0]['name']}…"
|
||||
return f"Running {len(calls)} tools…"
|
||||
|
||||
|
||||
async def _run_calls(context, calls: list[dict]) -> list:
|
||||
"""Run one round's calls together, results in call order.
|
||||
|
||||
Sequential was right when every tool was a local database read. A remote one
|
||||
is latency-bound, and three two-second calls in a row are six seconds of a
|
||||
reply looking hung -- while the model has already been told it may ask for
|
||||
several at once.
|
||||
|
||||
The results are indexed rather than appended as they finish, because each
|
||||
tool turn has to line up with the assistant turn's `tool_calls`: an endpoint
|
||||
matching on `tool_call_id` would otherwise pair the right id with the wrong
|
||||
content the moment two calls came back out of order.
|
||||
|
||||
Safe to run together because `run_tool` never raises, so no failure cancels
|
||||
its siblings, and each runner opens its own `session_scope()` against a
|
||||
database in WAL mode with a busy timeout.
|
||||
"""
|
||||
limit = asyncio.Semaphore(MAX_PARALLEL_TOOLS)
|
||||
|
||||
async def one(call: dict):
|
||||
async with limit:
|
||||
return await tools_service.run_tool(context, call["name"], call["arguments"])
|
||||
|
||||
return list(await asyncio.gather(*(one(call) for call in calls)))
|
||||
|
||||
|
||||
def _pending_text(db, message: Message) -> str:
|
||||
"""The user turn this reply is answering, for the size estimate."""
|
||||
previous = db.scalars(
|
||||
|
||||
Reference in New Issue
Block a user