Skip to content

server: tail_tools are callable without entering the rendered head - #71

Closed
joelteply wants to merge 3 commits into
fable/ram-pagesfrom
fable/tail-tools
Closed

joelteply wants to merge 3 commits into
fable/ram-pagesfrom
fable/tail-tools

Conversation

@joelteply

Copy link
Copy Markdown

Continuum goal 10 (card ecd5dff3): a mind's head holds a learned working set of tool schemas, and the rest load into the tail so the cached prefix survives.

The gap: common_chat_params_init_qwen3_coder (Qwen3.5 uses the same path) builds the parser and the lazy grammar from inputs.tools alone. Once <tool_call> triggers, sampling is constrained to those names, so a verb declared later in the conversation cannot even be emitted.

The change: a request field tail_tools.

  • common_chat_templates_apply renders the prompt from tools only.
  • It builds format, parser, grammar, triggers and stops over tools + tail_tools, skipping names already in tools.
  • oaicompat_chat_params_parse reads tail_tools, and sets parse_tool_calls when either list is non-empty.

Test: test-chat test_tail_tools asserts:

  • the prompt and generation prompt are byte-identical with and without tail_tools;
  • the tail verb's description is not in the prompt, and its name is in the grammar;
  • <tool_call><function=python>... parses as a python call with its arguments;
  • the head-only parser does not produce that call;
  • the same holds through oaicompat_chat_params_parse.

Disabling the tail branch makes the test fail (checked).

Measured

  • 2026-10-24, M5, CPU build of 6cae10f on top of 971962d: [chat] All tests passed!; llama-server builds.
  • Live receipt owed: a resident act calling a tail-loaded verb on the M5. Owner Fable, with continuum slice 2. The live change stays off until the core side lands.

Stacked on #70 (fable/ram-pages).

🤖 Generated with Claude Code

https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo

A request may carry `tail_tools` beside `tools`: the chat template renders only `tools`
(so the prompt bytes, and the KV cached for them, are unchanged), while the format,
parser, lazy grammar, triggers and stops are built over tools + tail_tools. A client that
declares a verb later in the conversation gets a real tool call to it on the next turn,
instead of a grammar that cannot even emit its name. Continuum goal 10 (card ecd5dff3):
a mind's head holds a learned working set and loads the rest into the tail.

test-chat: test_tail_tools (Qwen3.5 template) asserts the prompt and generation prompt are
byte-identical with and without tail_tools, the tail verb is in the grammar and parses as a
tool call with its arguments, the head-only parser does not produce it, and the same through
oaicompat_chat_params_parse. Disabling the tail branch fails the test (checked).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
joelteply added a commit to CambrianTech/continuum that referenced this pull request Oct 24, 2026
…s lands (goal 10, slice 2) (#5362)

* A request carries tail_tools: verbs callable natively without entering the head (goal 10 slice 2, card ecd5dff3)

TextGenerationRequest gains `tail_tools`: verbs she loaded into the tail this epoch. The
OpenAI body sends them as the fork's `tail_tools` field (CambrianTech/llama.cpp#71), which
feeds llama-server's tool-call parser and lazy grammar but never the template's head, so the
cached prefix stays byte-identical. Sent only to a llama.cpp-family gateway (the typed
`llamacpp_sampling_extensions` capability, as `repeat_penalty`); cloud providers that
reject unknown fields never see it. The wire-truth probe reports `tail_tools_n`.

Nothing sets it yet (every builder passes None): the faculty's working set fills it in the
next commit, and the live change stays off until the fork half lands.

Tests: request_body `tail_tools_reach_only_a_llamacpp_family_server_and_never_join_the_head`;
continuum-engine request_body 3/3; check continuum-core --tests --bins, continuum-mind
--tests (test-fixtures), continuum-engine --tests, continuum-genome --tests: clean
(metal, 2026-10-24, serving down). TS binding regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo

* Her learned tool head: noted from every call, adopted only at a turnover, kept in her store (goal 10 slice 2, card ecd5dff3)

cognition::tool_head owns which schemas her head holds for an epoch:
- every executed call is noted from working memory's receipt seam (the one place every act
  lands), and a commands/help(name=X) call loads X into the tail until the epoch ends;
- `next` computes the head the next epoch would hold (the slice-1 online blend over her
  epochs, the current one counted as ended, the finder always held) for the budget to
  reserve; `turn_over` learns from the epoch that ended, returns its miss/waste receipt
  and adopts the reserved head. Epochs are numbered here, monotonic across restarts;
- `ToolHeadRecord` (collection `tool_heads`, one row per persona) keeps the state through
  the ORM (`ToolHeadStore::{load, keep}`), no SQL outside it.

Logistic is serde so its weights and running scale persist. Nothing reads the head into a
request yet (next commit, behind the provider's tail_tools capability).

Tests: tool_head 4/4 (adopt only at turnover, found verbs loaded for the epoch, the
ended epoch's misses and waste, survives a restart through the ORM); tool-working-set
12/12. 2026-10-24, serving down.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo

* The learned head reaches her request at a turnover, in shadow until the server takes tail_tools (goal 10 slice 2, card ecd5dff3)

- ToolSurface::vocabulary prices every native verb as the surface sends it, shared with her
  tool head whenever the surface is rebuilt or her working memory attaches.
- select_tool_surface computes and RESERVES the head the next epoch would hold, priced by the
  measured act cost (prefill_rate::measured_rate_for, decode_knee::rate_for, her p90 act
  need; request overhead unmeasured and left out; any unmeasured term keeps her surface
  static). On a server that takes tail_tools it sends her held head (canonical order) and
  charges the budget the larger of it and the next one; her loaded verbs ride the request's
  tail_tools. Elsewhere the surface is unchanged and the head runs in shadow.
- Every view start (a turnover, or a fresh start) adopts the reserved head, learns from the
  ended epoch, saves her state, and emits delib.tool_head.epoch: calls, misses, waste and
  head tokens, `sent` saying whether the head was on the wire or a shadow measurement.
- The provider capability `tail_tools` (typed, like llamacpp_sampling_extensions) gates the
  body field and AIProviderAdapter::accepts_tail_tools; llama-server declares it false until
  the fork pin carries CambrianTech/llama.cpp#71, so the live change is off.
- AdmissionState opens her `tool_heads` store beside her engrams; a resident's registration
  attaches it (a measurement fork never writes).

Tests (2026-10-24, serving down, cargo-target-tws): continuum-mind lib 2374 passed (was
2369: +4 tool_head, +1 learned surface); continuum-engine lib 1214 passed; continuum-core
deliberation 18 and surface/aliases/registry/bindings 731 passed; check continuum-core
--tests --bins clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo

* tool_head tests: a split test file declares #![cfg(test)] so the scans read it as test code

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo

* commands/help finds a verb by what she wants to do; a found verb loads under its canonical name (goal 10 slice 2, card ecd5dff3)

- commands/help gains `query`: the authorized commands whose name and description best match
  it, ranked through the same relevance seam the learned head reads
  (tool_working_set::relevance), at most SUGGESTION_WINDOW of them. That window is now one
  kernel constant shared with did_you_mean (it was a literal take(6) there).
  `help(name=X)` is what loads X: her tool head reads the finder's input, resolved to the
  canonical command name (tool_dialect::resolve_wire_name).
- The agentic-surface ceiling moves 17298 -> 17335 for the `query` param itself; its text
  was cut to one line first (17427 before), stated at the pin.

Tests: commands::help 8/8 incl. a query ranking code/github/pr-create first over the live
registry; tool_head 5/5; core surface/registry/aliases/bindings/deliberation 67/67.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@joelteply

Copy link
Copy Markdown
Author

Review of exact 6cae10f: the Jinja head/callable split preserves rendered prompt bytes and derives grammar/parser from the union; duplicate names keep the head definition. The new regression covers the Qwen3.5 non-thinking head+tail route, including server parsing.

One request-boundary issue to fix: server-common.cpp:1155-1163 still defines has_tools from tools alone. A request with only tail_tools on a non-Jinja server bypasses the existing requires-Jinja rejection, then sets parse_tool_calls=true at1314 while common_chat_templates_apply ignores tail_tools in its legacy branch. The API silently accepts a capability it cannot supply. Validate nonempty tail_tools at the same boundary and add a tail-only/non-Jinja rejection regression. Check the later has_tools streaming guard too so tail-only requests obey the same format restrictions.

Before advertising this capability on the IntelMac Qwen2.5 lane, add/execute its template case (including empty head, tool_choice none and streaming) rather than extrapolating the Qwen3.5 test. This is a rollout gate; no claim of measured speedup yet. Source reviewed only; tests not independently executed by me.

… for Qwen2.5, empty head, tool_choice none and streaming

Codex's review of #71: has_tools read only `tools`, so a request whose verbs were all in
`tail_tools` passed the requires-Jinja rejection on a non-Jinja server (the legacy renderer then
dropped the tail while parse_tool_calls was set) and skipped the tools + stream format guard.
has_tools now counts the tail, and common_chat_templates_apply refuses a tail on the legacy path
instead of dropping it.

test_tail_tools now covers, per template family (Qwen2.5 JSON <tool_call>, Qwen3.5 XML
<tool_call>) and for a populated and an empty head: prompt bytes equal the head-only render, the
tail verb's call parses through streamed deltas with the lazy grammar accepting it, and with
tool_choice "none" the tail adds nothing to the grammar and the verb does not parse as a call.
Server boundary: tail-only on non-Jinja is refused, tail-only + stream + logprobs is refused,
tool_choice "none" sets no parse_tool_calls, empty head + tail renders the no-tools prompt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
@joelteply

Copy link
Copy Markdown
Author

Response to @codex's review of 6cae10f. New head: 0e96a02.

(1) The request boundary ignored a tail-only request. Fixed in server-common.cpp: has_tools now counts a non-empty tail_tools, which covers both places you named: the requires-Jinja rejection and the later tools + stream (logprobs) guard. common_chat_templates_apply also throws invalid_argument on a tail with use_jinja=false, so the library refuses a tail it cannot make callable instead of dropping it.
Regressions in test_tail_tools:

  • tail-only request on a non-Jinja server (Qwen2.5 template): refused. Fails on 6cae10f (Expected: 1 Actual: 0).
  • tail-only + stream + logprobs on a Jinja server: refused. Fails when only that guard is reverted to the head-only check (verified separately, then restored).
  • common_chat_templates_apply with use_jinja=false and a tail: throws.

(2) Coverage before the capability is enabled on any node. A loop over two template families, Qwen-Qwen2.5-7B-Instruct.jinja (JSON in <tool_call>; enrico's qwen2.5-coder-1.5b) and Qwen3.5-4B.jinja (XML parameters), each with a populated head (special_function) and an empty head, with python in the tail. Each case checks:

  • the prompt and generation_prompt are byte-identical to the head-only render, and the tail description is absent from the prompt;
  • the tail verb parses as a call, and does not parse as one without the tail;
  • streaming: test_peg_parser parses every prefix of the call, checks that the accumulated deltas equal each partial message and the final call, and checks that the lazy grammar accepts the output;
  • tool_choice "none": the prompt and grammar match head-only with "none", python is absent from the grammar, and the verb does not parse as a call.

At the server level: with tool_choice: "none" and a tail, no parse_tool_calls. With an empty head and a tail, the prompt matches the no-tools request and parse_tool_calls is true.

The four family cases already passed on 6cae10f. That run stopped at the first server-boundary assertion, so only the boundary checks were new failures. The family cases are coverage for the rollout gate, not a fix.

Result (CPU build, -DGGML_METAL=OFF, Release, on 0e96a02, 2026-10-26): test-chat → [chat] All tests passed!. llama-server builds. This is source and unit coverage only: there is no live-node or speed claim, and the IntelMac Qwen2.5 lane still needs its own read before the capability is advertised there.

@joelteply

Copy link
Copy Markdown
Author

Re-review at exact 0e96a02: source PASS on my findings. The shared has_tools predicate now includes tail tools for both non-Jinja and streaming/logprobs rejection; the library also refuses unsupported legacy tails. Added cases cover Qwen2.5 and Qwen3.5, populated/empty heads, parser streaming via test_peg_parser, prompt equality and tool_choice none. No remaining blocker found in this diff. Owner reports CPU Release test-chat/server validation; I have not independently executed it. Keep provider capability gated to the actually adopted supported engine/template and require the live tail-verb receipt before rollout claims.

@joelteply

Copy link
Copy Markdown
Author

Superseded by #75: the same tail-tools change restacked on the deployed engine pin e7e1077 (base fable/page-checkpoint-subset). The merge of fable/ram-pages at 5ad86c8 pulled #70's unreviewed page-store, durable-move and Windows-adapter commits into this head, so the review no longer covered it. #75 cherry-picks only 6cae10f and 0e96a02. Its tail-tools diff is unchanged from 0e96a02, which Codex reviewed and passed (the added and removed lines match exactly). The diff against the pin is common/chat.cpp, common/chat.h, tools/server/server-common.cpp and tests/test-chat.cpp only. test-chat passes locally on #75's head ca53e26.

@joelteply joelteply closed this Oct 26, 2026
joelteply added a commit that referenced this pull request Oct 26, 2026
…estacked on the engine pin) (#75)

* server: tail_tools are callable without entering the rendered head

A request may carry `tail_tools` beside `tools`: the chat template renders only `tools`
(so the prompt bytes, and the KV cached for them, are unchanged), while the format,
parser, lazy grammar, triggers and stops are built over tools + tail_tools. A client that
declares a verb later in the conversation gets a real tool call to it on the next turn,
instead of a grammar that cannot even emit its name. Continuum goal 10 (card ecd5dff3):
a mind's head holds a learned working set and loads the rest into the tail.

test-chat: test_tail_tools (Qwen3.5 template) asserts the prompt and generation prompt are
byte-identical with and without tail_tools, the tail verb is in the grammar and parses as a
tool call with its arguments, the head-only parser does not produce it, and the same through
oaicompat_chat_params_parse. Disabling the tail branch fails the test (checked).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo

* server: a tail-only request obeys every tools boundary; tail coverage for Qwen2.5, empty head, tool_choice none and streaming

Codex's review of #71: has_tools read only `tools`, so a request whose verbs were all in
`tail_tools` passed the requires-Jinja rejection on a non-Jinja server (the legacy renderer then
dropped the tail while parse_tool_calls was set) and skipped the tools + stream format guard.
has_tools now counts the tail, and common_chat_templates_apply refuses a tail on the legacy path
instead of dropping it.

test_tail_tools now covers, per template family (Qwen2.5 JSON <tool_call>, Qwen3.5 XML
<tool_call>) and for a populated and an empty head: prompt bytes equal the head-only render, the
tail verb's call parses through streamed deltas with the lazy grammar accepting it, and with
tool_choice "none" the tail adds nothing to the grammar and the verb does not parse as a call.
Server boundary: tail-only on non-Jinja is refused, tail-only + stream + logprobs is refused,
tool_choice "none" sets no parse_tool_calls, empty head + tail renders the no-tools prompt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant