Skip to content

design server tools

sinrimin edited this page Oct 20, 2026 · 1 revision

Server tools — append injection + per-wire presets (#56)

Issue #56, shipped 2026-10-15. Research basis: research-provider-model-params.md (§3.1 upstream history, §4.2 the replace defect, §5 the candidate design this implements). Everything here is about the platform assistant and the provider REGISTRY form; no client-agent writer changed.

The defect being fixed

buildGatewayModel (#51) mapped serverTools to samplingParams.tools. On the openai-responses wire pi-ai builds the converted client tools first (params.tools) and runs Object.assign(params, samplingParams) LAST ("so … sampling parameters override named request fields") — a shallow merge that REPLACED the tools array. The assistant sends its AgentTools every turn (M2 MCP bridge + read_skill), so any registry entry with serverTools set stripped them all. The #51 S0 spike verified the seam existed, not the merge semantics.

Upstream (checked 2026-10-15): pi proposals #9556/#9560 (Model.serverTools, append-verbatim on BOTH openai-responses and anthropic-messages wires), #7704 (builtinTools), #8661 (reserved-name translation) were all auto-closed by the repo's contribution gate and never reopened; no released pi-ai (1.1.0 / 1.2.0) or master carries server-side tool support. Their APPEND semantics are the reference we implement ourselves.

Wire semantics — append via the fetch seam

server/src/assistant/provider.ts:

  • appendServerTools(body, serverTools, kind) — pure: for a chat-shaped body (has input or messages) appends the server-tool entries to body.tools (creating the array when absent); anything else returns untouched.
  • serverToolsFetch(serverTools, kind, inner) — wraps a fetch; when init.body is JSON and chat-shaped, rewrites it via appendServerTools before the inner fetch; non-JSON / non-chat pass through byte-identical.
  • streamFn injects the wrapper ONLY for kinds openai-responses and anthropic, wrapping a caller-provided fetch when present. Both pi-ai adapters hand options.fetch to their OpenAI/Anthropic SDK client — one seam every wire crosses. The samplingParams.tools mapping is GONE; topP/extraBody still ride samplingParams (OpenAI wires only).

Per kind:

  • openai-responses: bare { type } entries verbatim.
  • anthropic (2026-10-16 follow-up, Bailian's anthropic-compatible doc — the rig's actual route): a web_search* type also carries name: "web_search" (Bailian's setup shape; official Anthropic discriminates on type either way), AND the body gains a LEADING system text block x-anthropic-billing-header: cc_entrypoint=cli; — the marker Claude Code itself sends; Bailian's gateway silently SKIPS web search without it ("需在 system 中传入客户端标识…否则联网搜索不生效"). String system prompts are normalized to blocks. On official Anthropic both additions are inert. Wire-level compat only — no prompt/product surface, registry value shape untouched.

Value shape unchanged: serverTools: string[], WYSIWYG wire values. The anthropic preset IS the dated type (web_search_20250305) — no alias map, no request-build translation. openai-chat has no tools[] surface → not injected, warned.

Presets (shared, Bailian-baselined 2026-10)

SERVER_TOOL_PRESETS / EXTRA_BODY_PRESETS / EXTRA_BODY_BOOLEAN_KEYS in packages/shared (re-exported by the SDK):

api kind serverTools presets extraBody key presets
openai-responses web_search, web_extractor, code_interpreter enable_thinking
anthropic web_search_20250305 —
openai-chat — (field hidden; search rides extended params) enable_search, search_options, enable_text_image_mixed, enable_thinking

Open lists by design (the catalog evolves server-side); typed custom values in the combobox are the gateway-compat escape hatch (future dated aliases, vendor-specific names). On openai-chat the extraBody field is the PRIMARY carrier for Bailian-style search, not merely an escape hatch.

enable_thinking on the responses wire was doubted and then LIVE-VERIFIED (#58, 2026-10-16): Bailian's official Responses parameter table documents it top-level, and the endpoint accepts and honors it — qwen3.8-flash, non-streaming: enable_thinking:true → output items ["reasoning", "message"], false → ["message"] only. The same doc marks it deprecated in favor of reasoning.effort ("enable_thinking 后续将不再支持") — when Bailian drops it, drop the preset; reasoning effort rides pi's first-class reasoningEffort, not extraBody. (The Responses table also lists MORE built-in tools than our presets — web_search_image, image_search, file_search, mcp — presets stay at the curated baseline; typed values cover the rest.)

providerModelConfigWarnings: serverToolsIrrelevant now fires for api === 'openai-chat' ONLY (both other wires carry server tools); extraBodyIrrelevant unchanged (anthropic).

Web — one device, two fields

New kit device TagCombobox (components/kit/tag-combobox.tsx): a creatable multi-select — chosen values render as removable × tags, the input filters the preset list, a typed value is offered as a custom row ("Use “{value}”"), Enter commits the typed value verbatim, Backspace on an empty input drops the last tag. Two consumers (serverTools, extraBody keys) earn it its kit place.

Provider form (pages/LlmProviders.tsx):

  • #58-2 hide-and-clear: serverTools renders ONLY on openai-responses and anthropic (the kinds with a tools[] surface); extraBody ONLY on the OpenAI-compatible kinds. Switching the api kind WIPES the now-hidden field from every dialog row — a saved payload never carries values the wire cannot use (the shared warnings stay as the REST backstop; #56's "values survive a kind switch" behavior is superseded).
  • extraBody keys via TagCombobox, then a per-key value editor — a Switch for EXTRA_BODY_BOOLEAN_KEYS, a JSON Input otherwise. Invalid/empty JSON drops that key at the save boundary, flagged inline (per-row invalid note), never saved.
  • #58-1: the dialog's fetch-candidates no longer mass-adds rows — the model-id input is a combobox offering the fetched list (see design-provider-model-registry.md §3.1).

Verification

  • shared 118 / server 343 / cli 315 / web 107 green; ui-snap 52/52; web build clean.
  • New tests: server/test/assistant-server-tools.test.ts pins append-not-replace, untouched non-chat bodies, wrapper composition; web tag-combobox.test.tsx pins presets/custom/remove/filter/keyboard; shared warning + preset-table tests.
  • agent-browser on the docker rig (deployed web verified by md5 + marker): per-kind preset lists (anthropic web_search_20250305; openai-chat extraBody keys + hint + the narrowed warning; openai-responses web_search/web_extractor/code_interpreter), custom row, boolean toggle, api-switch tag retention, save → server payload exact (serverTools: ["web_search_20250305","web_search"], extraBody: { "enable_thinking": false }), edit round-trip.

Residuals

  • Bailian anthropic-wire CoT flattening (2026-10-16, wire-isolated via direct endpoint matrix): the precise trigger is STREAMING + the cc_entrypoint billing marker — with both, Bailian's Claude-Code-mode pipeline returns DeepSeek reasoning as inline TEXT blocks (no thinking_delta at all); non-streaming separates thinking under every shape tested. The marker cannot be dropped: without it web search silently does not execute (verified — no server_tool_use blocks). name and mixed client+server tools are NOT triggers (S5/S7 clean). So on the anthropic wire it is a genuine either/or: search (marker required) or separated CoT. Bailian's Responses wire gives BOTH: streaming + web_search executes real searches (web_search_call.* events) AND keeps reasoning separated (response.reasoning_text.delta). The workaround for CoT-sensitive routes: a provider row with api=openai-responses on …/compatible-mode/v1 instead of the anthropic-compatible endpoint. (Product-side isolation first showed "serverTools on → CoT in body, off → thinking blocks"; the raw-wire matrix pinned the actual switch.)
  • Per-entry extra fields (max_uses) and the reserved-name translation (#8661) remain out of scope — MCP bridge tools are <server>__<tool>- prefixed, so only custom tool names can collide.
  • The anthropic temperature/thinking gate warning (research §4.1) is a separate follow-up, untouched here.
  • Bailian's doc lists supported web-search models (Qwen3.x, glm-5.2, kimi-k3, deepseek-v4-pro/v4-flash-0731) WITHOUT deepseek-v4.1-flash — the rig verified v4.1-flash DOES execute search (2026-10-16); the doc list lags.
  • Found while verifying: the assistant hub's live flag clears ONLY on an explicit assistant:session.close — an abruptly closed tab leaves the session live (and thus undeletable, #55's 409 guard) until a server restart; no socket-disconnect eviction exists. Filed as its own issue (#57).

Rig verification (2026-10-16, tui-verify account)

The rig's real route: Bailian's anthropic-compatible endpoint (token-plan…/apps/anthropic), model deepseek-v4.1-flash, serverTools: ["web_search_20250305"], assistant hikari. First attempt failed (the server container had not been redeployed with #56 — anthropic wire injected nothing; the model answered "no web tool"). After the redeploy + the name/billing-marker follow-up: a current-events question returned live facts with cited URLs (multi-round search visible in the reasoning), 19s / ↑23k ↓1.1k tokens. Test session deleted; the account's pre-existing session untouched.

Clone this wiki locally