You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Tool-call pairing: DSH checks it at three moments — and the failure still cannot say which moment said no
#7670
This is the first of a short series on runtime invariants, and it starts with the runtime we maintain plugins for rather than with a survey of others: a coordinate is only useful if you can put your own feet on it. Everything below was read out of published packages, at the versions named, without running anything.
The contract, in the provider's own words
When a runtime hands tool calls and their results to a model provider, the provider judges the shape. For the OpenAI-compatible wire the rule is stated by the provider, and its error is explicit:
An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'. (insufficient tool messages following tool_calls message)
Around that sit the requirements that make it exact: every call's id answered by a message carrying that same id, the full assistant message sent back with its calls, and one answering message per call. None of this is negotiable per framework — it is the wire.
So the useful question is not whether a runtime honours pairing. It is where the runtime checks it, and whether you can tell which check said no.
DSH checks at three moments
Moment
What runs
What it actually asks
write (dsh-session@0.1.7-alpha.2)
Session.append:1326 → validateSessionEventData, then validateNext:1346 → planSurfaceEvent:532 (defined :422)
May this event join the surface, and in what shape?SURFACE_EVENT_TYPES:154-160 names the five message-producing types, tool/result among them; an event without a surfaceOp returns early at :438
assertReleasedV4Relationships:1027 → assertV4LifecycleRelationships:858 → closeTools:576–:577, reached at each step/end (:754, :764); called from dsh-session-persistence-jsonl:1072
Does this step/turn close with every advertised call answered? — the message is … leaves unresolved tool call <id>, and the refusal is non-destructive
send (dsh-llm-deepseek@0.1.7-alpha.2)
the request builder, :1430–:1441
Is the wire shape I am about to send legal? — four assertions: duplicate call id :1433, result with no matching call :1436, calls needing immediate results :1437, history ending on an unresolved call :1440
Two observations that we think are worth having in the record.
First, the three moments check three different predicates about one invariant. Write time asks about surface membership and shape; load time asks about step and turn relationships; send time asks about adjacency on the wire and about the call set being stable — and it also normalises order, lifting tool_result blocks to the front of their message. A runtime can pass one and fail another, and none of them is redundant.
Second, the send-time group is new. In dsh-llm-deepseek@0.1.5-rc.2 the same path serialised tool results as standalone {role: "tool"} messages (:134–:158, with tool_calls written only when there were calls, :123) and we could not find an unresolved-call assertion anywhere in that package. The 0.1.7 line carries both the assertions and a different message shape — results ride on a user message as tool_result blocks with tool_use_id (:1417). Numbers below the package level do not carry across that change, which is worth saying out loud before anyone quotes a line number.
Where it still hurts: the refusal cannot name itself
Here is the part we would ask about. Every one of those four send-time assertions raises the same code, INVALID_REQUEST. And so does the provider: :1500 maps status === 400 || status === 413 || type === "invalid_request_error" onto the same code. Within this one package, INVALID_REQUEST already covers at least:
Cause
Where
upload exceeds the byte limit
:249
file expiry outside its range
:250
image over the per-image limit
:590
request image missing
:1363
request file id missing
:1365
duplicate tool call id
:1433
tool result with no matching call
:1436
tool calls without immediate results
:1437
history ending on an unresolved call
:1440
the provider rejecting the request
:1500
Ten causes, one code — and the last row is the one that matters most, because it is not the runtime's judgement at all. From the outside, "DSH refused to build this request" and "the provider refused the request DSH built" arrive as the same thing, and they have different fixes. We have seen the provider-side form in the wild as a 400 quoting the rule at the top of this post; a reader cannot tell from the code which side spoke, and neither can a bug report.
A second consequence is specific to how DSH works rather than to the code we read: the assembled request is not a durable artifact. So when the provider is the one that refused, there is nothing left on disk to inspect — while the log at that moment is very likely balanced, because the load-time check already passed it. The surviving evidence points at the wrong layer.
A check you can run on a session
If a session produces a pairing failure, the log itself answers the first question. The three id positions live in different places, which is worth knowing before comparing:
the assistant message's tool-call blocks carry the call id (data.message.content[], blocks of type tool-call);
a tool/call event carries data.callId;
a tool/result event carries data.message.source.callId (and the same id inside its block).
Collect those three sets and compare them by id, not by count. A log where they disagree is already broken, and no assembly step can fix it — the load-time check will refuse it. Where all three agree, the log is clean and the failure you are looking at came from the send side, from either the runtime's own assertions or the provider's.
If a repair is needed, we would rather it stayed visible
A log with an unanswered call can be "fixed" by writing a synthetic result. The project's own record takes a position on that form, in an adjacent case:
A future "recover partial crashed work" feature should be designed as an explicit user-facing recovery view, not as synthetic events silently inserted into the canonical transcript.
— rejected/simplification/2026-06-20-truncate-interrupted-turns.md
We read that as a general criterion: a repair should be visible and reversible, and it should not rewrite the record of what happened.
The ask
Small, and we think cheap: when the send-side assertions refuse, let the failure name itself.
say which of the four conditions fired, and which call id it is about — the identifiers are in scope at every one of those lines;
keep "the harness refused" distinguishable from "the provider refused" — the mapping at :1500 is the join point, and one extra discriminator there would separate ten causes into the two groups that have different remedies.
We are plugin authors, not maintainers, and this is not a demand for a design or a timeline. We also do not think the coverage is the problem here: three moments checked is more than we expected to find, and it is the reason the remaining gap is narrow enough to state in a paragraph. If we have misread any of these lines, we would rather be corrected than quoted.
Authorship note: reproduced and measured by me against my own store; root-cause tracing and drafting assisted by AI; verification and publication by me. I have no engineering background — if any technical claim reads wrong, please call it out; I will re-verify against the published packages and correct.
An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'. (insufficient tool messages following tool_calls message)
围绕它的一组要求让它变得精确:每个调用的 id 都要被携带同一 id 的消息应答;完整的 assistant 消息要连同调用一起回带;每个调用一条应答。这些不因框架而异 —— 它就是线上。
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
This is the first of a short series on runtime invariants, and it starts with the runtime we maintain plugins for rather than with a survey of others: a coordinate is only useful if you can put your own feet on it. Everything below was read out of published packages, at the versions named, without running anything.
The contract, in the provider's own words
When a runtime hands tool calls and their results to a model provider, the provider judges the shape. For the OpenAI-compatible wire the rule is stated by the provider, and its error is explicit:
Around that sit the requirements that make it exact: every call's id answered by a message carrying that same id, the full assistant message sent back with its calls, and one answering message per call. None of this is negotiable per framework — it is the wire.
So the useful question is not whether a runtime honours pairing. It is where the runtime checks it, and whether you can tell which check said no.
DSH checks at three moments
dsh-session@0.1.7-alpha.2)Session.append:1326→validateSessionEventData, thenvalidateNext:1346→planSurfaceEvent:532(defined:422)SURFACE_EVENT_TYPES:154-160names the five message-producing types,tool/resultamong them; an event without asurfaceOpreturns early at:438dsh-session-format-v3-to-v4@0.1.7-alpha.2)assertReleasedV4Relationships:1027→assertV4LifecycleRelationships:858→closeTools:576–:577, reached at eachstep/end(:754,:764); called fromdsh-session-persistence-jsonl:1072… leaves unresolved tool call <id>, and the refusal is non-destructivedsh-llm-deepseek@0.1.7-alpha.2):1430–:1441:1433, result with no matching call:1436, calls needing immediate results:1437, history ending on an unresolved call:1440Two observations that we think are worth having in the record.
First, the three moments check three different predicates about one invariant. Write time asks about surface membership and shape; load time asks about step and turn relationships; send time asks about adjacency on the wire and about the call set being stable — and it also normalises order, lifting
tool_resultblocks to the front of their message. A runtime can pass one and fail another, and none of them is redundant.Second, the send-time group is new. In
dsh-llm-deepseek@0.1.5-rc.2the same path serialised tool results as standalone{role: "tool"}messages (:134–:158, withtool_callswritten only when there were calls,:123) and we could not find an unresolved-call assertion anywhere in that package. The0.1.7line carries both the assertions and a different message shape — results ride on ausermessage astool_resultblocks withtool_use_id(:1417). Numbers below the package level do not carry across that change, which is worth saying out loud before anyone quotes a line number.Where it still hurts: the refusal cannot name itself
Here is the part we would ask about. Every one of those four send-time assertions raises the same code,
INVALID_REQUEST. And so does the provider::1500mapsstatus === 400 || status === 413 || type === "invalid_request_error"onto the same code. Within this one package,INVALID_REQUESTalready covers at least::249:250:590:1363:1365:1433:1436:1437:1440:1500Ten causes, one code — and the last row is the one that matters most, because it is not the runtime's judgement at all. From the outside, "DSH refused to build this request" and "the provider refused the request DSH built" arrive as the same thing, and they have different fixes. We have seen the provider-side form in the wild as a
400quoting the rule at the top of this post; a reader cannot tell from the code which side spoke, and neither can a bug report.A second consequence is specific to how DSH works rather than to the code we read: the assembled request is not a durable artifact. So when the provider is the one that refused, there is nothing left on disk to inspect — while the log at that moment is very likely balanced, because the load-time check already passed it. The surviving evidence points at the wrong layer.
A check you can run on a session
If a session produces a pairing failure, the log itself answers the first question. The three id positions live in different places, which is worth knowing before comparing:
id(data.message.content[], blocks of typetool-call);tool/callevent carriesdata.callId;tool/resultevent carriesdata.message.source.callId(and the same id inside its block).Collect those three sets and compare them by id, not by count. A log where they disagree is already broken, and no assembly step can fix it — the load-time check will refuse it. Where all three agree, the log is clean and the failure you are looking at came from the send side, from either the runtime's own assertions or the provider's.
If a repair is needed, we would rather it stayed visible
A log with an unanswered call can be "fixed" by writing a synthetic result. The project's own record takes a position on that form, in an adjacent case:
We read that as a general criterion: a repair should be visible and reversible, and it should not rewrite the record of what happened.
The ask
Small, and we think cheap: when the send-side assertions refuse, let the failure name itself.
:1500is the join point, and one extra discriminator there would separate ten causes into the two groups that have different remedies.We are plugin authors, not maintainers, and this is not a demand for a design or a timeline. We also do not think the coverage is the problem here: three moments checked is more than we expected to find, and it is the reason the remaining gap is narrow enough to state in a paragraph. If we have misread any of these lines, we would rather be corrected than quoted.
Authorship note: reproduced and measured by me against my own store; root-cause tracing and drafting assisted by AI; verification and publication by me. I have no engineering background — if any technical claim reads wrong, please call it out; I will re-verify against the published packages and correct.
Reported by the OfferKuai Team — Founder: Zhaofeng (Yaming). Website: https://www.offerkuai.com/ | Contact: contact@offerkuai.com
中文版
这是一组关于"运行时不变式"的短系列的第 1 篇。它从我们维护插件的那个运行时讲起,而不是从横向综述讲起 —— 坐标只有能踩在自己脚下才有用。以下内容全部读自已发布包,版本逐处标注,没有运行任何东西。
契约,用供应商自己的话
当运行时把工具调用与它们的结果交给模型供应商时,判形状的是供应商。OpenAI 兼容线上这条规则由供应商写明,报错也毫不含糊:
围绕它的一组要求让它变得精确:每个调用的 id 都要被携带同一 id 的消息应答;完整的 assistant 消息要连同调用一起回带;每个调用一条应答。这些不因框架而异 —— 它就是线上。
所以有用的问题不是"运行时尊不尊重配对",而是:它在哪儿校验,以及你能不能看出是哪一处说了不。
DSH 在三个时刻校验
dsh-session@0.1.7-alpha.2)Session.append:1326→validateSessionEventData,再validateNext:1346→planSurfaceEvent:532(定义在:422)SURFACE_EVENT_TYPES:154-160列出五个产出消息的类型,tool/result在列;没有surfaceOp的事件在:438提前返回dsh-session-format-v3-to-v4@0.1.7-alpha.2)assertReleasedV4Relationships:1027→assertV4LifecycleRelationships:858→closeTools:576–:577,在每个step/end处触达(:754、:764);调用点在dsh-session-persistence-jsonl:1072… leaves unresolved tool call <id>,且拒收是非破坏性的dsh-llm-deepseek@0.1.7-alpha.2):1430–:1441:1433、结果找不到对应调用:1436、调用必须有立即的结果:1437、历史以未解决的调用结尾:1440两点我们认为值得记录。
第一,三个时刻查的是同一条不变式的三个不同判据。 写入时问的是表面成员资格与形状;加载时问的是步骤与轮次的关系;发送时问的是线上的相邻关系与调用集合是否稳定 —— 它还会顺带规整顺序,把
tool_result提到它所在消息内容的最前。一个运行时可以通过其中一处而失败于另一处,而三处都不冗余。第二,发送侧这一组是新增的。 在
dsh-llm-deepseek@0.1.5-rc.2里,同一条路径把工具结果序列化成独立的{role:"tool"}消息(:134–:158,tool_calls只在有调用时才写出,:123),而且我们在那个包里找不到任何"未解决调用"类断言。0.1.7这一线既带上了这组断言,也换了消息形状 —— 结果改挂在user消息的tool_result块上、用tool_use_id(:1417)。包以下的行号不跨这次变化沿用,这句话值得在有人引行号之前先说清。仍然疼的地方:拒绝说不了自己是谁
这一处是我们想请教的部分。上面那四条发送时断言抛的是同一个码:
INVALID_REQUEST。而供应商也是同一个码 ——:1500把status === 400 || status === 413 || type === "invalid_request_error"映射到它。仅仅在这一个包里,INVALID_REQUEST已经覆盖至少这些::249:250:590:1363:1365:1433:1436:1437:1440:1500十种成因,一个码 —— 而最后一行最要命,因为它根本不是运行时的判断。从外面看,"DSH 拒绝组装这个请求"与"供应商拒绝了 DSH 组装出的请求"以同一个东西抵达,而它们的修法不同。我们在野外见过后者的形态:一个
400,引用本文开头那条规则;读者无法从这个码看出是哪一边在说话,写 bug 报告的人也看不出。第二个后果跟 DSH 的工作方式有关,而不是跟代码有关:组装后的请求不是持久产物。所以当拒绝来自供应商时,磁盘上没有任何东西可以查 —— 而那一刻的日志很可能是平衡的,因为加载期那道校验已经放它过去了。留下的证据指向了错误的一层。
一个可以在会话上跑的检查
如果某个会话出现配对失败,日志本身就能回答第一个问题。三个 id 位置在三个不同的地方,比之前值得先知道:
id(data.message.content[]中tool-call类型的块);tool/call事件带data.callId;tool/result事件带data.message.source.callId(块内也有同一 id)。把这三个集合收起来,按 id 比,不要按条数比。三者不一致的日志已经坏了,任何组装都救不回 —— 加载期那道校验会拒收它。三者一致的,日志是干净的,你看到的失败来自发送侧:要么是运行时自己的断言,要么是供应商。
如果要修,我们希望它保持可见
一个含未应答调用的日志,可以用写入一条合成结果来"修好"。项目自己的记录在一个相邻案例上表达过立场:
我们把它读成一条通用判据:修复应当可见、可回退,而且不应改写"发生了什么"的记录。
请求
很小,我们认为也很便宜:当发送侧那些断言拒绝时,让这个失败说出自己是谁。
:1500那处映射正是汇合点,在那里多一个判别位,就能把十种成因分成两组、各对应各自不同的修法。我们是插件作者、不是维护者;这不是对某个设计的诉求,也不要时间表。我们也不认为覆盖面是问题:三个时刻都校验,比我们预期找到的多 —— 这也正是剩下那个缺口窄到一段话就能说清的原因。若我们把哪一行读错了,我们更愿意被纠正,而不是被引用。
声明:本次实测与统计由我针对自己的数据完成;根因追查与文稿撰写由 AI 辅助;核验与发布由我本人负责。我没有工程背景 —— 若任何技术表述有误,请直接指出,我会对照已发布包重新核实并更正。
本报告由 OfferKuai(Offer快)团队提交 —— 创始人:Zhaofeng(Yaming)。官网:https://www.offerkuai.com/ | 联系:contact@offerkuai.com
All reactions