Skip to content

server: DSML calls stream from the invoke name, not the first parameter's closer - #2201

Merged
ashhart merged 3 commits into
ashhart:mainfrom
devopsfortunato:task/Tensorfold-Contribut+t-mw2jjnq5-sdj9lw
Nov 6, 2026
Merged

ashhart merged 3 commits into
ashhart:mainfrom
devopsfortunato:task/Tensorfold-Contribut+t-mw2jjnq5-sdj9lw

Conversation

@devopsfortunato

@devopsfortunato devopsfortunato commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

What this changes

Per your note, this is now a small change to main's tool_dsml_stream.zig instead of a second streamer. The invoke's header goes out at its name rather than once its first parameter is whole, so a call whose first parameter is the long one — a write_file whose only parameter is content — streams while that value is written instead of staying silent until the parameter closes. Dsml.held and decide(), which buffered the arguments until the first parameter closed, are deleted; send() writes straight to the deltas; cut() no longer needs its !d.header check, because a value can only be open after its header is out. Everything else is main's design: the same spellings, the same rejected fallback to the buffered containment, the same cut replay and repeated-key behaviour, and the joined arguments still equal the whole reply's byte for byte.

The window you asked about. Before, a call's header was withheld until the first parameter closed, so a malformation inside that parameter — a bad string= flag, junk in its key, a non-JSON typed value, a closer where a parameter belongs — streamed nothing at all. Now the window starts at the end of the invoke name: any such malformation after the name leaves a streamed call while the whole reply keeps the block as text, carrying the arguments written up to the break. A client gains the long first parameter as live output; it loses the guarantee that a call that fails to parse was never announced to it. Both sides are pinned by tests:

  • earlyBreaks — four block shapes that break before the invoke name is whole (an unoffered name, text before the invoke, a name whose quote is not followed by >, a block with no invoke) still send nothing at any byte, and a later legitimate call still streams.
  • lateBreaks — ten shapes that break after the name (bad or missing flags, non-JSON typed values, an unclosed block, a block closer with the invoke open, another spelling's closers, junk between parameters) now stream the header while tool_parse.parse keeps the whole block as text; a new test pins that.
  • A cut inside the invoke name makes no call, while a cut inside the first parameter now returns the call it began ({"path":"notes).
  • The new test for your case: a single-invoke block whose first parameter carries 2,000 bytes, fed three bytes at a time in all three spellings — one header, argument deltas before that parameter's closer, joined equal to the whole reply's arguments.
  • The one-call (parallel_tool_calls: false) policy still withholds a call whose arguments never closed; the many-call mode shows it.

Checks on this head

  • Head commit: a1902b1c6
  • Lean checker, python3 tools/zig/lean_check.py, on the base 0311c9280 and on this head: 425 and 425, 0 new findings.
  • Tests run, with the command and pass, fail and skip counts:
    • zig build test-server-cpu --summary all: 5/5 steps succeeded; 1057/1058 tests passed (1 skipped), including the new long-first-parameter test, the split break-before/after-name tests, and the renamed cut and single-stream tests whose streamed arguments are now pinned to exact strings rather than substrings.
    • zig build test-golden --summary all: 322/335 and 55/55 equal to server goldens, 0 unexpected differences; no fixture is in the diff.
    • zig fmt --check on all seven touched files: clean. git diff --check: clean. Files ≤600 lines (largest 349).
  • CI: green on a1902b1c6 — zig 16m5s, zig-windows 17m31s, zig-macos-dev-tools 1m20s, release-paths 3m17s, fmt 14s and both benchmark-clients jobs pass (No GPU run 42014715450 and Zig format run 42014715362, both conclusion success).
  • Not run, and why: zig-macos-dev-tools, zig-windows and release-paths need those toolchains; no GPU or checkpoint check applies, since this change reaches no kernel, weight or arithmetic path.

Receipt

n/a: the change reaches the wire, not inference. It moves the point at which a call's header delta leaves, not which tokens are produced: no token, logit, kernel, weight or arithmetic path is touched, a reply without a DSML call is byte-identical, and the tests pin that the joined streamed arguments equal the whole reply's.

@devopsfortunato
devopsfortunato force-pushed the task/Tensorfold-Contribut+t-mw2jjnq5-sdj9lw branch 2 times, most recently from d19bc8a to c9e09dc Compare November 5, 2026 01:09
@devopsfortunato

devopsfortunato commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor Author

Superseded — the note below describes the first branch, not this pull request. That branch was a second DSML streamer beside main's; #2191 landed main's own, so the branch was reworked (see the 09:24 comment) into a small change to main's tool_dsml_stream.zig. The "open item" below no longer applies: tool_dsml_cut.zig and closeCall/dsmlClose are not part of this change at all.


Review status on this head, for whoever reviews it.

An independent review (GLM-5.3) ran twice. Round 1 raised six issues, all fixed before the first push here: typed-ness followed the JSON schema instead of the DSML string= flag; a orelse over an error union in cutCalls; two new tests that named a parameter where a tool name belongs; closeCall tried DSML before the XML/GLM envelope; a byte-level whitespace helper at the cut; a write-only struct field. Round 2 checked the streaming state machine itself (the byte-for-byte join to the whole reply, the resume arithmetic, the buffered containment fallback and the cut replay) and found it sound, but asked for one more change that is not in this head.

Open, not in this revision: tool_dsml_cut.cutCalls's envelope-ownership guard counts only <tool_call> (zig/src/server/tool_dsml_cut.zig:22), so a DSML block that sits inside an open <|tool_call> or <NAME:tool_call> envelope could still be turned into a call the whole reply keeps as text; closeCall → dsmlClose has the same blind spot for an end token, since openCall reads only the <tool_call> family. The fix both reviews point at is one ownership check shared with tool_parse.envelopes, plus two mixed-family tests (whole and cut). Say the word and I will add it as a revision; I did not bundle it here because it widens the change past the streaming behavior this pull request is about.

Everything else in the description is measured on this head: the reporter's probe reports 676 argument deltas with the first argument byte at 105 (it reports 1 delta at byte 2249 on main), test-server-cpu 1028/1029 with 1 skipped and 0 failed, test-golden 322/335 plus 55/55 with 0 unexpected differences, zig fmt --check clean, and the lean checker at its 425 baseline with 0 new findings.

@ashhart

ashhart commented Nov 5, 2026

Copy link
Copy Markdown
Owner

Thanks @devopsfortunato for the careful work here, especially pinning the joined pieces against the whole reply at every byte boundary in all three spellings. DSML streaming for #1975 landed on main in the meantime, in #2191, so this branch now adds a second streamer beside the one that's there and no longer applies. The one case main doesn't stream is a call whose first parameter is the long one: main sends that call when the parameter closes, and yours would stream it from the name. If you'd like to keep that, could you rebase it as a small change to main's tool_dsml_stream.zig, with a test for that case, and say what it does to the window where the stream has sent a call that the whole reply keeps as text? Otherwise we'll close this with thanks.

@devopsfortunato
devopsfortunato force-pushed the task/Tensorfold-Contribut+t-mw2jjnq5-sdj9lw branch 2 times, most recently from 9e10bbc to 47cff9e Compare November 5, 2026 09:23
@devopsfortunato devopsfortunato changed the title server: DSML tool calls stream their arguments as they are written (#1975) server: DSML calls stream from the invoke name, not the first parameter's closer Nov 5, 2026
@devopsfortunato

Copy link
Copy Markdown
Contributor Author

Reworked as you asked: the branch is now a small change to main's tool_dsml_stream.zig (head 47cff9ec1, 7 files, +149/−65, most of it tests) instead of a second streamer. The invoke's header goes out at its name rather than once its first parameter is whole, so a call whose first parameter is the long one — a write_file with only content — streams while that value is written. Dsml.held and decide() are deleted, send() writes straight to the deltas, and cut() drops its header check because a value can only be open after its header is out.

What it does to the window. Before, the header was withheld until the first parameter closed, so a malformation inside that parameter — a bad string= flag, junk in its key, a non-JSON typed value, a closer where a parameter belongs — streamed nothing. Now the window starts at the end of the invoke name: any such malformation after the name leaves a streamed call the whole reply keeps as text, carrying the arguments written up to the break. A client gains the long first parameter as live output and loses the guarantee that a call which fails to parse was never announced.

Both sides are pinned: four shapes that break before the name still send nothing at any byte (and a later legitimate call still streams), ten that break after it stream the header while tool_parse.parse keeps the block as text, a cut inside the name makes no call while a cut inside the first parameter returns the call it began, and the one-call policy still withholds a call whose arguments never closed. Your case has its own test: a 2,000-byte first parameter fed three bytes at a time in all three spellings — one header, argument deltas before that parameter's closer, joined equal to the whole reply's arguments byte for byte.

Checks on this head: test-server-cpu 1043/1044 (1 skipped, 0 failed), test-golden 322/335 and 55/55 with 0 unexpected differences and no fixture touched, zig fmt --check clean, lean checker 425 = base 425 with 0 new. CI is running on 47cff9ec1.

@devopsfortunato
devopsfortunato force-pushed the task/Tensorfold-Contribut+t-mw2jjnq5-sdj9lw branch 2 times, most recently from aae35a3 to 94f7f0a Compare November 5, 2026 10:41
@devopsfortunato
devopsfortunato force-pushed the task/Tensorfold-Contribut+t-mw2jjnq5-sdj9lw branch from 94f7f0a to a1902b1 Compare November 5, 2026 10:55
@devopsfortunato

Copy link
Copy Markdown
Contributor Author

CI is green on the final head a1902b1c6: No GPU run 42014715450 succeeded — zig 16m5s, zig-windows 17m31s, zig-macos-dev-tools 1m20s, release-paths 3m17s, benchmark-clients Ubuntu 14s and Windows 27s — and the Zig format run 42014715362 passed. The last rebase onto main 0311c9280 resolved only the CHANGELOG.md slot; the change still applies cleanly and is mergeable.

@ashhart

ashhart commented Nov 5, 2026

Copy link
Copy Markdown
Owner

Thanks @devopsfortunato. As a small change to main's streamer, with both sides of the window pinned, this is what we hoped for. Please add a line to its CHANGELOG entry saying DSML now streams from the invoke name, unlike GLM and XML, and that a call malformed after its name may stream before the reply keeps it as text. Then it's queued for after the 1.2.3 cut.

@devopsfortunato

Copy link
Copy Markdown
Contributor Author

Added, as its own changelog: commit on this head (21ffabebd):

One note on the "unlike GLM and XML" wording, in case you meant something narrower: I read it as the invoke tag being DSML's own, because on main XML already sends its header at <function=NAME> and GLM's goes out when the first <arg_key> opens, so both do stream before their first value. If you meant the window instead, say the word and I will reword that clause.

This commit touches only CHANGELOG.md; no production file changed. The branch still differs from main only in the Unreleased slot, so it stays conflicted on that one file until you rebase it at landing. CI is running on the new head and I will add the result here.

@devopsfortunato

Copy link
Copy Markdown
Contributor Author

CI note for the record: GitHub created no workflow run for 21ffabebd — actions/runs?head_sha=21ffabebd… returns 0 runs after the push and again after a close/reopen, while other heads in the repository are starting runs normally, so this looks like the repository's own workflow scheduling/throttle rather than a failure of the commit. gh pr checks reports no checks on the branch.

The commit changes one CHANGELOG.md line and nothing else: git diff --check and zig fmt --check build.zig zig are clean, and the code it describes is the head you already reviewed and that passed No GPU run 42014715450 (a1902b1c6: zig 16m5s, zig-windows 17m31s, zig-macos-dev-tools, release-paths, both benchmark-clients). Nothing in the tree beyond that one line differs from the green head, so a re-run at landing will exercise the same code.

@ashhart

ashhart commented Nov 5, 2026

Copy link
Copy Markdown
Owner

Thanks @devopsfortunato. The missing run has a simple cause: the branch conflicts with main in CHANGELOG.md, and GitHub doesn't start pull-request runs while a branch can't merge. Nothing is needed from you. We'll resolve that line when we prepare it, CI runs then, and it's in our queue for after the 1.2.3 cut.

…er's closer (ashhart#1975)

A call whose first parameter is the long one was still silent on a streamed
reply: the DSML streamer held a call's header until its first parameter was
whole, so a write whose content comes first waited for that content. The
header now goes out at the invoke name and the first parameter's key and
value stream while they are written; the held-arguments buffer and decide()
that deferred them are gone, and cut() no longer needs its header check
because a value can only be open after its header is out.

The window where the stream has sent a call the whole reply keeps as text now
starts at the end of the invoke name instead of at the first parameter's
closer: a malformation inside the first parameter leaves a streamed call while
the whole reply keeps the block as text. Both sides stay pinned: four block
shapes that break before the name send nothing at any byte, ten that break
after it stream the header and keep the reply's text, a cut inside the name
makes no call while a cut inside the first parameter returns the call it
began, and a 2,000-byte first parameter fed three bytes at a time streams its
arguments before its closer in all three spellings and joins to the whole
reply byte for byte.
@ashhart
ashhart force-pushed the task/Tensorfold-Contribut+t-mw2jjnq5-sdj9lw branch from 21ffabe to dddf984 Compare November 6, 2026 02:11
@ashhart
ashhart merged commit f3b740f into ashhart:main Nov 6, 2026
5 of 7 checks passed
@ashhart

ashhart commented Nov 6, 2026

Copy link
Copy Markdown
Owner

Thank you @devopsfortunato. This is merged into main with your commits under your own name, so you're listed as a TensorFold contributor. It ships in the next release, credited to you in the changelog and the release notes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants