Repository navigation
fix(core): keep an XML tool call quoted in a parameter value inert - #13515
Conversation
A write_file whose content documented this dialect with an unfenced example call dispatched the quoted example and dropped the write. The outer block is rejected correctly - its lazily matched body ends at the quoted block's closer, so the parameters region holds a call opener the parameters did not consume - but the rescan added for truncated blocks then restarted just after the outer open tag, where the nested complete call matched on its own merits and ran. Text-level, "a following call the truncated block swallowed" and "a call a value quotes" have the same geometry; they differ only in whether a value owns the region, which the flat lazy PARAMETER_PATTERN does not model because a nested element consumes its enclosing value's own terminator. Pair parameter open/close tags by depth and skip any call matched inside a span a closed value owns. An open tag that is never closed owns nothing, so the borrowed-closer rescan still reaches a call a truncated block swallowed, and a value merely mentioning the tag shape still keeps its enclosing call recoverable. Fixes #13492 Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-issue-patrol/jmuwmsy7g1j
|
|
|
Thanks for the PR! Template looks good ✓ — every section filled in, the Chinese translation is complete rather than abbreviated, and the before/after carries real output. Problem: observed, not theoretical. #13492 has a runnable reproduction, measured output at three separate commits ( Direction: aligned. "Markup a parameter value quotes is that value's data" is the invariant the pre-hardening base already had, so this restores known-good semantics instead of inventing new ones. The deeper fix — re-deriving block extent from value-scoped spans so the enclosing Size: core path ( Approach: the scope feels right, and it beats the obvious cheaper fix. Just pushing the rescan past the whole rejected block would also make both new tests pass, but it would still dispatch the second example when one value quotes two calls; depth-paired spans cover that, because both quoted calls fall inside the span the closed value owns. That variant is the thing that separates this approach from the naive one, and it is the one case the new tests don't pin — worth adding, raised as a suggestion in the review comment rather than a blocker. No unrelated changes and no drive-by refactor: the helper is module-private and the exported surface is untouched. Risk: no elevated risk signals — neither changed file matches the revert-correlated path list. One process point rather than a code one: the description says Moving on to code review. 🔍 中文说明感谢贡献! 模板完整 ✓ —— 各小节都填了,中文说明是完整翻译而不是摘要,before/after 也给了真实输出。 问题:是已观测到的 bug,不是理论性加固。#13492 提供了可运行的复现、在三个不同 commit( 方向:对齐。「参数值引用的标记就是该值的数据」正是加固前基线本来就有的不变量,所以这是在恢复已验证过的语义,而不是发明新语义。更彻底的修法——用按值定界的区间重新推导块范围,让外层 规模:触及核心路径( 方案:范围合理,而且优于那个看起来更省事的修法。仅仅把重扫位置推到整个被拒块之后,也能让两个新用例通过,但当一个值里引用了两个调用时,第二个示例仍会被派发;按深度配对区间能覆盖这种情况,因为两个被引用的调用都落在「已闭合值所拥有的区间」里。这个变体正是本方案与朴素修法的分水岭,也是新用例唯一没有钉住的一种——建议补上,我在评审意见里作为 suggestion 提出,不作为阻塞项。没有无关改动,也没有顺手重构:新增辅助函数是模块内私有,导出面未变。 风险:无升级风险信号——两个改动文件都不匹配与回滚相关的路径清单。有一点属于流程而非代码:描述里写的是 进入代码审查 🔍 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
Code reviewI wrote my own plan before opening the diff: pair parameter open/close tags by depth, then treat any tool-call match starting inside a closed value as that value's data. That is what this does, so the approach matches my independent proposal. The cheaper variant I also considered — pushing the rescan past the whole rejected block instead of past its open tag — is worth naming, because it shows why the extra machinery earns its place: it makes both new tests pass, but when one value quotes two example calls the lazy outer match still ends at the first inner closer, so the second example gets dispatched. Depth-paired spans put both quoted calls inside the span the closed No correctness blockers found. The load-bearing question is whether the new skip can ever suppress a call that should run, and the stack discipline answers it: a close tag always pops the most recent open, and a well-formed call's own parameter open tag always sits after its opener. So the span a real call's own parameter creates can never start before that call's opener — a span can only start earlier when the opener is genuinely nested inside a parameter element, which is data by definition. An open tag that never closes owns nothing, which is what keeps the two witnesses the issue names intact. I hand-traced those against the new code rather than taking the description's word: Two smaller things I checked because they are easy to get wrong here. The For the core-path confidence bar, the consumer set is complete and small: the only production caller is Suggestions, neither blocking:
Conventions are clean: module-private helper, kebab-case file, no TestingEvidence this comment carries: a static read of the diff and of the surrounding module in a clean worktree at the reviewed commit, plus the PR's own CI results read through the API. I did not build or run anything from this PR — triage never executes PR-derived code — so nothing below is my own test run. The decisive check for this change is Final CI results for
One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。 Sandboxed verification would settle the one thing CI cannot: 中文说明代码审查我在看 diff 之前先写了自己的方案:按深度把 parameter 的开闭标签配对,凡是起点落在已闭合值内部的 tool-call 匹配都当作该值的数据。本 PR 正是这么做的,所以方案与我的独立提议一致。我也考虑过一个更省事的变体——把重扫位置推到整个被拒块之后而不是开标签之后——值得点出来,因为它正好说明这套机制不是多余的:那个变体也能让两个新用例通过,但当一个值里引用了两个示例调用时,外层惰性匹配仍然停在第一个内层闭合标签处,于是第二个示例会被派发出去。按深度配对的区间让两个被引用的调用都落在已闭合 未发现正确性阻塞项。关键问题是这个跳过会不会误伤本该执行的调用,栈的配对规则回答了它:闭合标签总是弹出最近的那个开标签,而一个格式完整的调用,它自己的 parameter 开标签必然位于自身开标签之后。所以真实调用自己那个 parameter 所产生的区间,起点不可能早于该调用的开标签——只有当开标签真的嵌套在某个 parameter 元素内部时,区间起点才会更早,而那种情况按定义就是数据。从未闭合的开标签不拥有任何区间,这正是 issue 点名的两个见证用例不受影响的原因。这些我是照着新代码手工推演的,没有直接采信描述: 另外两处容易写错、我专门看了。跳过时的 关于核心路径要求的确定性,使用方集合是完整且很小的:唯一的生产调用方是 两条建议,都不阻塞:
约定方面干净:辅助函数模块内私有、文件名 kebab-case、无 测试本条意见携带的证据:在干净 worktree 里对所审 commit 的 diff 及其周边模块做静态阅读,加上通过 API 读到的该 PR 自身 CI 结果。我没有构建或运行本 PR 的任何内容——triage 从不执行 PR 派生的代码——所以下面没有一项是我自己跑出来的测试结果。 对这个改动起决定作用的是 (上方表格中的检查名与结论为 GitHub 元数据,未在此重复。) 有一件事 CI 无法证明,可以用沙箱验证补上: — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
|
Confidence: 4/5 — the fix is right and narrowly scoped; the two things keeping it off 5 are a missing test for the case that justifies the approach, and a closing keyword that will shut an issue this only half-fixes. Stepping back: this restores an invariant the module used to have, rather than adding a new rule. "Markup a value quotes is that value's data" is what the pre-hardening base did, the rescan broke it, and this puts it back with the minimum machinery that can actually express the distinction. My own cheaper idea would have passed the new tests and still shipped a hole, so the approach exceeds what I proposed — that is the main reason I am comfortable here. The failure mode also points the right way. Everything this change can newly do is leave text inert and visible; nothing it does can make a call run that would not have. In a module whose whole job is deciding whether model prose is an instruction, that asymmetry matters more than completeness, and it is why the deferred block-extent rework is a reasonable thing to defer rather than an excuse. My one real reservation is about scope, not correctness. After this change the reproduction in #13492 recovers nothing, where the pre-hardening base at least recovered the On the reflection questions I would otherwise skip: the problem is verified, not accepted on framing — I read the reproduction in #13492 and traced it through the base source by hand before opening the diff, and the trace reproduces the reported output. This is not volume wearing me down either; the same author has #13437 merged and #13492 filed in this area, and I judged this one on its reproduction and named regressing commit, not on the narrative around it. Six months from now the helper's docstring is the thing that saves whoever touches this next, and it explains the flat-versus-paired distinction instead of restating the loop. What I am not confident about, stated plainly: my "no regression" claim rests on hand-tracing seven of the 66 tests in the file plus a unit suite that had not finished when I fetched it. That is decent static evidence and it is not a test result. So the approval is deferred until CI lands green on 中文说明置信度:4/5 —— 修复方向正确、范围收得很紧;没给到 5 分的两点是:缺一个钉住「本方案存在理由」的用例,以及关闭关键字会把一个只修了一半的 issue 关掉。 退一步看:这是在恢复该模块本来就有的不变量,而不是新增规则。「值所引用的标记就是该值的数据」正是加固前基线的行为,重扫破坏了它,而本 PR 用能真正表达这个区别的最小机制把它恢复了。我自己那个更省事的想法能让新用例通过、却仍会留下漏洞,所以本方案优于我的提议——这也是我在这里感到踏实的主要原因。 失效方向也是对的。这个改动能新造成的后果,只有让文本失效且可见;它不会让任何原本不会执行的调用被执行。在一个职责就是判断「模型散文是不是指令」的模块里,这种不对称比完备性更重要,也正因如此,把「按值定界的块范围」重构推迟下去是合理的取舍,而不是托词。 我唯一实质性的保留是关于范围,而不是正确性。改动之后,#13492 的复现场景什么也恢复不出来;而加固前的基线至少还能恢复出那个 关于我平时容易跳过的几个自省问题:问题是验证过的,不是采信了 PR 的叙述——我在打开 diff 之前先读了 #13492 的复现,并对着基线源码手工推演过,推演结果与报告的输出一致。也不是被数量磨软了:同一作者在这个领域有已合并的 #13437 和已提交的 #13492,而我评判本 PR 依据的是它的复现和被点名的回归提交,不是它周边的叙事。六个月后能救下一个动这块代码的人的,是那个辅助函数的文档注释——它解释的是扁平匹配与深度配对的差别,而不是复述循环。 我不自信的地方也直说:我「无回归」的判断,依据是手工推演了文件里 66 个用例中的 7 个,加上一份我抓取时尚未跑完的单元测试。这算是不错的静态证据,但它不是测试结果。所以批准被推迟到 CI 在 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
yiliang114
left a comment
There was a problem hiding this comment.
Reviewed the full diff (base 4ce35fc…head 76513d8) and the complete module at head. No blocking findings; correct as far as I can verify. Evidence below.
How it works. The fix is purely additive: closedParameterSpans pairs parameter tags by depth, and any TOOL_CALL_PATTERN match whose opener starts inside a closed value span is skipped before the guards run. Every remaining branch is base code, so the change can only suppress dispatches, never add one — I confirmed by tracing both added branches and by the red/green runs below.
Verification I ran myself (in a worktree at the head commit, npm run build first):
packages/corefallback suite: 82/82 green at head, including the untouched borrowed-closers witness.- Red-first reproduced: head test file against the base source yields exactly the two new tests failing and the 80 pre-existing ones passing.
eslint(changed files) andtsc --noEmit(packages/core): clean.- Boundary probes beyond the added tests (run once, then deleted):
<function=name>/<parameter=name>dialect parity; a real sibling call after a closed value still dispatches; an unclosed outer parameter owns nothing (the truncated-block rescan path stays intact); an opener at the exact span-end dispatches; a nested parameter pair inside a value does not reopen it. All 5 pass.
Consumers. Sole production consumer is llm-chat.ts (containsXmlToolCalls gates tryRecoverXmlToolCalls); the fix strictly reduces recovered calls in quoted-markup text, which is the intended security improvement there — no consumer relies on getting more calls. containsXmlToolCalls is unchanged, so the gate/recovery flow is coherent: unsupported matches now leave the text as prose, exactly as the new first test asserts via remainingText.
Tests. Both added tests pass the value gate: the first reproduces the #13492 incident (fails pre-fix, passes post-fix), the second pins that the skip is scoped to the owning value — a broad skip would fail it. The first also asserts the tryRecoverXmlToolCalls contract (remainingText preserved).
Two non-blocking notes: (1) CI was still mid-run on the head commit when I reviewed (ubuntu Test and Lint & Static in progress; macOS/Windows Test skipped by matrix), so merge should wait for green rather than my review. (2) Per the repo's two-tier rule for packages/core this is a small-scope core change (52 production lines); my confidence is high from the evidence above, but maintainer sign-off is still required — treating this comment as the requested gate evaluation, not an approval.
chiga0
left a comment
There was a problem hiding this comment.
Tier: Standard. Pure additive change (92 lines, 0 deleted) to the XML tool-call fallback parser. No public API changes.
Scope. xml-tool-call-fallback.ts (new PARAMETER_TAG_PATTERN regex, closedParameterSpans() helper, guard in recoverableToolCallBlocks) and its test file. Sole production consumer is llm-chat.ts, which is unchanged. Not reviewed: macOS/Windows runtime behavior (PR flags both as untested; unit-only scope).
Findings
No blocking findings.
What was checked
PARAMETER_TAG_PATTERN regex — /<\/?parameter(?=[\s=>])(?:[^>"']|"[^"]*"|'[^']*')*>/g. The (?=[\s=>]) lookahead correctly prevents matches on longer tag names like <parameterize>. Quoted attribute values are handled by the alternation. lastIndex = 0 is reset at closedParameterSpans entry, preventing stale state between calls. Self-closing <parameter/> is not matched (the lookahead expects \s, =, or >); self-closing parameters are not part of this dialect, so this is correct.
closedParameterSpans depth logic — The stack-based pairing is correct: open tags push their start position; close tags pop and record a [start, end) span where end = tagMatch.index + tagMatch[0].length (position just past the > of the close tag). Unmatched open tags stay on the stack and contribute no span, which preserves the existing borrowed-closer rescan path: an outer block whose parameter is never closed does not suppress a swallowed sibling call.
Span check boundary — matchStart >= start && matchStart < end uses an exclusive end, correctly excluding a call that starts immediately after </parameter>. Traced the geometry for both added tests: the inner run_shell_command opener falls inside the content value span in both; the read_file opener in the second test falls after the span end and dispatches normally.
Rescan interaction — When the outer write_file block is rejected and TOOL_CALL_PATTERN.lastIndex is set to resumeAt, the next exec() finds the inner run_shell_command block. Its matchStart is inside the content value span — continue fires. Since exec() already advanced lastIndex past that match, the next iteration searches from after the inner block's end, finds no further <invoke> before the outer block's real close, and returns zero dispatched calls. The sibling read_file in the second test starts outside all value spans and is dispatched correctly.
Tests — Both added tests are effective. The first reproduces the #13492 incident precisely (fails against base, passes at head). The second pins that the skip is scoped to the owning value — a broad guard would fail it. Deleting the valueSpans.some(...) guard makes both red. The five pre-existing borrowed-closer witnesses are untouched and still pass.
Consumer check — llm-chat.ts is the sole production consumer. The fix strictly reduces recovered calls for quoted-markup text. No consumer depends on getting more calls back from the fallback; the change is a security improvement in the safe direction.
Cross-check — yiliang114 reviewed at prior head 76513d8d52b0 (COMMENTED, no approval): no issues flagged, five boundary probes confirmed. My analysis confirms the same conclusions. No misses found.
No blocking findings. Approval blockers: none.
Reviewed with AI assistance.
qqqys
left a comment
There was a problem hiding this comment.
Critical-only scan at head 9cb4847d9e0677f69559238d144877670e4bc5a9 — complete. Verdict: APPROVE.
No Critical found, and there is no historical blocking issue to clear: the PR has zero review threads, no prior review findings from any reviewer, and the one existing review entry is the author's own self-assessment, explicitly graded "no blocking findings" and explicitly not offered as an approval.
The defect being closed is a real execute-from-prose path, not a cosmetic one
Before this change, a write_file whose content documented this dialect with an unfenced example call had the example dispatched. The first new test reproduces the reported incident, and the value that ran was run_shell_command with command: "rm -rf /tmp/x". Text a model emits as documentation executed as a tool call, while the write the user actually asked for stayed behind as prose. That is the worst available direction for a fallback whose entire job is deciding which model-authored markup becomes an action.
The mechanism the body describes checks out against the source. The outer block's lazily matched body ends at the quoted block's closer, so its parameter region holds a call opener the parameters did not consume and the borrowed-closer guard rejects the block — correctly, by its own rule. The truncated-block rescan then restarted just after the outer open tag, where the nested complete call matched on its own merits. Flat lazy PARAMETER_PATTERN cannot distinguish that from a genuinely swallowed following call, because a nested element consumes its enclosing value's own terminator. Pairing by depth supplies the missing fact: whether a value owns the region.
The change is monotonic in the safe direction, which bounds its blast radius
closedParameterSpans feeds exactly one new branch, and that branch only ever continues. It cannot admit a match the guards would have rejected, cannot alter parameterRanges, computeExampleRanges or positionInsideFence, and cannot change what remainingText retains beyond leaving more of it. So the only failure mode available to this diff is a false negative — a real call left as prose — never a false dispatch. I confirmed this by reading both added branches rather than inferring it from their shape.
That makes the false-negative surface the thing worth probing, and it is narrow:
- Single ordinary call: the invoke's
matchStartprecedes its own parameter span, somatchStart >= startis false and it is recovered. - Sibling calls: the second starts after the first value's close and before its own parameter open. Pinned by the second new test.
- A value documenting a stray parameter close tag: that close pops the value's own open, so the value's span ends early and the enclosing call's
matchStartstill precedes it. The enclosing call is recovered, and a later real call falls outside the shortened span. - A value documenting a stray parameter open tag: never popped, so it owns nothing. This is precisely the truncated-block case, and leaving it owning nothing is what keeps the existing rescan witness intact.
The residual false negative needs an unmatched open before a real call together with an unmatched close after it, and it degrades to this file's behaviour on main — no recovery. That is the safe side of the trade.
Loop safety, regex state and cost
- The bare
continuecannot stall the scan.TOOL_CALL_PATTERN.execon a/gregex advanceslastIndexpast the match on success, and a match here necessarily spans literal open and close tags, so it is never zero-length. This contrasts deliberately with the reject branch, which setslastIndex = resumeAtin order to rescan; skipping whole is the right choice here, since rescanning into a value is what dispatched the quoted call in the first place. - Module-level regex state is reset.
PARAMETER_TAG_PATTERN.lastIndex = 0at the top ofclosedParameterSpans, matching how the file already treatsTOOL_CALL_PATTERNandPARAMETER_PATTERNat every entry point. No re-entrancy or cross-call leakage. - The quoted-run admission is consistent with the file's existing tag scanning.
(?:[^>"']|"[^"]*"|'[^']*')*mirrors thetagspattern incomputeExampleRanges, so a>inside an attribute value cannot end a tag early — the same hazardopenTagEndalready closes for the rescan position. - No new stall hazard.
closedParameterSpansis one linear scan. The per-matchvalueSpans.some(...)is numeric comparison only, while the same loop already callspositionInsideFenceper block, which slices and splits the entire prefix. The addition is asymptotically no worse than code already in the loop and considerably cheaper per unit.
Both tests carry their weight
The first asserts extractXmlToolCalls returns an empty array and that tryRecoverXmlToolCalls reports recovered: false with remainingText equal to the whole input — so it pins the recovery contract, not just the extraction. The second asserts exactly one recovered call, read_file with args: { p: 'b.ts' }. That single assertion does three jobs at once: the quoted run_shell_command is not recovered, the outer write_file is still rejected by the borrowed-closer guard, and a real sibling outside the owning value survives. A blanket skip would fail it, which is what makes it a genuine scope control rather than a restatement of the first test.
Dependency and CI
This PR builds on #13437, which merged at f3dd8cf8; the patch context sits inside that PR's openTagEnd, so the base is merged code rather than an open sibling. The delta between the head the author self-reviewed and this head is four commits of merges from main touching unrelated packages — neither of this PR's two files changed, so its own contribution is byte-identical to what that review covered.
Lint & Static, Integration Tests (no-AK, No Sandbox) and both Desktop Shell legs pass at this head. Test (ubuntu-latest, Node 22.x) was still pending at review time; under this channel's rules a pending Test lane is not a gate and I did not poll it, so my verdict rests on the source read above rather than on that lane. It is the one check worth seeing land before merge.
Both changed files are under packages/core/src/core/, so require_code_owner_review applies. This approval is submitted as qqqys.
qwen-code-review-bot
left a comment
There was a problem hiding this comment.
APPROVE at 9cb4847d. Gates: 0 review threads (nothing unresolved), this is the first approval on the PR, mergeable=MERGEABLE, and CI is 20 pass / 0 fail / 53 skipped. Two lanes — Test (ubuntu-latest, Node 22.x) and Hosted process fault gates / MySQL 8.4 / Java 21 — have been stuck IN_PROGRESS since 2026-10-06T13:53Z, three days, and three review-pr automation lanes are cancelled. That is runner infrastructure, not a code signal, and this PR touches no Java. Rather than cite the stuck lane I replaced it: I ran the suite myself against this exact head and got 82/82 passing, which reproduces the body's "After" evidence independently.
I reviewed this file in #13437, so I read the change against the two guards that landed there — the quote-aware openTagEnd and the nested-opener test that replaced the borrowed-closer arithmetic. This is the third member of that family, and it is the one that closes a hole the second guard opened.
The defect was real, and I measured it rather than accepting the description
I built a differential harness in an isolated scratch directory over the head source and a variant with only the valueSpans skip removed, so each difference is attributable to this change:
| case | without the skip | at head |
|---|---|---|
A — write_file whose content quotes a whole run_shell_command call |
run_shell_command |
(none) |
B — a real read_file sibling following A's block |
run_shell_command,read_file |
read_file |
| C — unclosed opener, then a well-formed call, then a stray closer | read_file |
(none) |
| D — same as C with no stray closer | read_file |
read_file |
E — content containing don't |
write_file |
write_file |
| F — unbalanced quote inside the tag itself | read_file |
read_file |
Row A is the point of the PR and it is worse than "a call was misattributed": before this change the fallback dispatched run_shell_command with command: "rm -rf /tmp/x" taken from a value that was documenting syntax. Row B shows the skip is scoped to the span that owns the markup rather than to the rest of the turn, which is the property that makes it safe to add at all. Rows E and F are the controls I cared about most, because an unbalanced quote in a value is ordinary prose and a common apostrophe must not disturb pairing — neither does, since PARAMETER_TAG_PATTERN's quoted-run alternation only has to survive the tag, not the value.
C is a regression I can reproduce, and the obvious fix makes things worse
Pairing is positional: openStarts.pop() matches the most recent unmatched opener, and a closer carries no name, so nothing can validate the pair. When openers and closers do not balance, a stray closer pops an opener belonging to an earlier, never-closed element and the span it creates reaches across everything in between — including a well-formed call. Measured input, an unclosed content, then an intact read_file, then one extra closer:
<invoke name="write_file">
<parameter name="content">unclosed value
</invoke>
<invoke name="read_file"><parameter name="p">b.ts</parameter></invoke>
</parameter>
Head returns (none); without the skip it returns read_file. So a legitimate call is silently dropped and left as prose, with no error to the model — the same failure class #13437's nested-opener rewrite existed to eliminate.
Two things keep this non-blocking. The polarity is safe: it drops a call rather than executing one, in input that is already malformed enough that the fallback is running at all. And the intuitive hardening does not work — I tried clearing the stack at each </invoke> or </function> so a pending opener cannot reach across a completed call, and that breaks A: the quoted call carries its own </invoke> inside the value, so the reset pops content, content then owns nothing, and rm -rf /tmp/x is dispatched again. Worth recording so nobody spends a round on it. What I would ask for instead is cheap: a test pinning the current unbalanced-closer behaviour, so a future change to the pairing cannot widen the span silently. The docstring's claim that an unclosed opener "owns nothing" is true only while no stray closer arrives later, and D versus C is exactly that boundary.
A verified negative: the outer write_file is not cheaply recoverable, and trying corrupts the write
Case A now yields no calls at all, so the write the model actually asked for still does not land — #13492's symptom was "dropped the write and ran the example", and this fixes the dangerous half. Since closedParameterSpans already computes which region a value owns, the tempting follow-up is to reuse it to exempt quoted regions from the nested-opener guard and recover the outer call. I tested that instead of proposing it: neutralising only that guard does recover write_file, but with a truncated content —
"content": "Usage:\n<invoke name=\"run_shell_command\"><parameter name=\"command\">rm -rf /tmp/x"
The value's own </parameter></invoke>\n tail is gone, because TOOL_CALL_PATTERN is lazy so match[0] ends at the quoted call's closer. That would be a silent corrupted write reporting success — strictly worse than the inert outcome this PR produces. So the guard's rejection is load-bearing here, the ownership information does not substitute for depth-aware block matching, and leaving the rescan as it was is the right call. Recovering the outer call needs </invoke> pairing at the block level, which is its own change; if #13492's reporter needs the write to land, that is the issue to file.
Scope of my read
Both diffs in full, plus recoverableToolCallBlocks, closedParameterSpans, PARAMETER_TAG_PATTERN and the guard at head. I did not review computeExampleRanges or positionInsideFence beyond confirming the skip happens before them and that valueSpans is derived from raw text like parameterRanges is, so a rejected block's data stays masked. Execution was in an isolated scratch tree; the repository checkout it borrowed node_modules from was not modified.
Vote effect
Both files are under packages/core/src/core/, so require_code_owner_review applies: one of wenshao, tanzhenxin, LaZzyMan, doudouOUC or qqqys must approve. This approval satisfies required_approving_review_count: 1 but not the code-owner requirement, so reviewDecision stays REVIEW_REQUIRED and mergeStateStatus stays BLOCKED until one of them submits.
|
Correcting three factual errors in my approval above. None of them touch the findings — the A–F differential measurements, regression C and the verified negative about exempting the nested-opener guard all stand as measured. I wrote that approval while working from a repository state I had fetched at the start of the review, and did not re-fetch before posting. The clock and the review list disagree with what I published:
The practical effect is that this PR was already mergeable on review state before I voted, and my review contributed findings rather than a gate. What I would still ask for is the one cheap item from the approval: a test pinning the unbalanced-closer behaviour in case C, so a future change to the positional |
The rewritten "does not dispatch a call quoted inside a parameter value" fixture no longer exercises the valueSpans skip: the new close-tag rescan moves the scan past the quoted call, so dropping the guard left all 83 parser tests green. Add the smallest known discriminating input - with it, removing the guard fails exactly this one test. Co-authored-by: Qwen-Coder <[email protected]>
What this PR does
Stops the XML tool-call fallback from dispatching a call that a parameter value merely quotes. Parameter open and close tags are now paired by depth, and any tool-call match starting inside a span that a closed parameter value owns is treated as that value's data and skipped. The rejected-block rescan is left as it was, so a truncated block that borrowed a following call's closers still has that following call recovered.
Why it's needed
A
write_filewhosecontentdocumented this dialect with an unfenced example call dropped the write and ran the example instead: the quoted shell command was dispatched as a real call. Text a model emits as documentation must not execute. Fenced blocks and explicit example wrappers were already skipped, so the gap was specifically an unfenced quote inside a parameter value — which is exactly what "here is the syntax" prose looks like.Mechanically: the outer block's lazily matched body ends at the quoted block's closer, so its parameters region holds a call opener the parameters did not consume, and the guard rejects the block — correctly, by its own rule. The rescan added for truncated blocks then restarted just after the outer block's open tag, where the nested complete call matched on its own merits and was returned. At the text level "a following call the truncated block swallowed" and "a call a value quotes" have the same geometry; they differ only in whether a value owns the region, which the flat lazy
PARAMETER_PATTERNcannot express because a nested element consumes its enclosing value's own terminator.Reviewer Test Plan
How to verify
Two tests are added to the existing
borrowed closers, lexer cost and rejected-block maskinggroup inpackages/core/src/core/xml-tool-call-fallback.test.ts:does not dispatch a call quoted inside a parameter value— the issue's exact input. Before: one recovered call,run_shell_commandwithcommand: "rm -rf /tmp/x". After: no calls at all, andtryRecoverXmlToolCallsreportsrecovered: falsewith the whole block left inremainingTextas prose.still dispatches a real call that follows a value quoting one— pins that the skip is scoped to the value that owns the quoted markup, so a sibling call outside it is still recovered.The witness the issue calls out,
does not dispatch a truncated block that borrows the next call closers, is untouched and still passes: there the swallowing parameter is never closed, so it owns no span and the rescan still reaches the swallowed call.recovers a value that mentions the parameter syntax literallyalso still passes, for the same reason — an open tag that is never closed owns nothing.Evidence (Before & After)
Before — the reproduction run against the unmodified source on this branch's base:
After — same suite, rebased onto
4ce35fc9c3:The only consumer of this module is
packages/core/src/core/llm-chat.ts, so its suite was run as well:Tests 553 passed (553).npx tsc --noEmit -p tsconfig.jsoninsidepackages/corereports no errors, and eslint and prettier both report nothing on the two changed files.Tested on
Environment
Unit tests only (vitest). No runtime, sandbox or network needed.
Risk & Scope
write_filein the reproduction is not recovered. It stays visible as prose instead of being replaced by a phantom execution, which is the safe direction, but it is not the pre-hardening behaviour the issue quotes as a comparison. The issue states that rejection is "correctly, by its own guard"; recovering the block would need a value-scoped block-extent rework (re-deriving where the block ends, making the guard and the argument extraction depth-aware, and adding a fallback for values that mention an open tag they never close). That rework also changes the fate of the block pinned bydoes not let a rejected block parameter swallow a later valid call, so it is deliberately not bundled here.PARAMETER_PATTERN,TOOL_CALL_PATTERNand both exported functions keep their signatures, and the new helper is module-private.Linked Issues
Refs #13492
Deliberately not
Fixes. This PR closes the execution half of the report: markup that a parameter value merely quotes is no longer dispatched as a real call. The other half the issue reports - the enclosingwrite_filebeing dropped - is unchanged: that block is still rejected by its own guard and now stays visible as prose instead of being replaced by a phantom execution. So #13492 should stay open. Recovering the block is the value-scoped block-extent rework described under Risk & Scope; it trades against an existing pinned witness, so it wants its own issue rather than riding along here.中文说明
这个 PR 做了什么
让 XML tool-call 兜底解析不再把「参数值里引用的示例调用」当成真调用派发。现在会按深度把 parameter 的开标签与闭标签配对,凡是起点落在「某个已闭合参数值所拥有的区间」内的 tool-call 匹配,都视为该值的数据而跳过。被拒块的重扫(rescan)逻辑保持原样,所以「截断块借用了后一个调用的闭合标签」那种情况,后一个调用仍然能被恢复出来。
为什么需要
一个
content里用未加围栏的示例调用来说明本方言的write_file,会导致这次写入被丢掉、而示例反而被执行:被引用的 shell 命令被当成真调用派发出去了。模型作为文档输出的文本不应该被执行。围栏代码块和显式 example 包裹此前已经会跳过,所以缺口恰恰是「参数值里未加围栏的引用」——而这正是「语法长这样」这类说明文字的典型形态。从机制上看:外层块的惰性匹配体会停在被引用块的闭合标签处,于是它的参数区里出现了一个「参数并未消费掉」的调用开标签,守卫据此拒绝该块——按它自己的规则,这个拒绝是对的。而为截断块加上的重扫随后把扫描位置重置到外层块开标签之后,在那里那个完整的嵌套调用凭自身条件被匹配并返回。在文本层面,「截断块吞掉的后一个调用」与「某个值引用的调用」几何形状相同,区别只在于那段区域是否归某个值所有;而扁平且惰性的
PARAMETER_PATTERN表达不出这个区别,因为嵌套元素会消费掉外层值自己的终止符。评审测试计划
如何验证
在
packages/core/src/core/xml-tool-call-fallback.test.ts已有的borrowed closers, lexer cost and rejected-block masking分组里新增两个用例:does not dispatch a call quoted inside a parameter value—— issue 里的原始输入。修复前:恢复出一个调用,即run_shell_command,command: "rm -rf /tmp/x"。修复后:一个调用都不恢复,tryRecoverXmlToolCalls返回recovered: false,整块内容作为普通文本留在remainingText里。still dispatches a real call that follows a value quoting one—— 钉住「跳过只作用于拥有该引用标记的那个值」:值外面的同级调用仍会被恢复。issue 点名的那个既有见证用例
does not dispatch a truncated block that borrows the next call closers未被改动且仍然通过:那种情况下吞掉后一个调用的 parameter 从未闭合,因此不拥有任何区间,重扫仍能到达被吞掉的调用。recovers a value that mentions the parameter syntax literally同样仍然通过,原因一致——从未闭合的开标签不拥有任何东西。证据(修复前与修复后)
修复前——在本分支基线上、源码未改动时跑复现:
修复后——同一套用例,已 rebase 到
4ce35fc9c3:该模块唯一的使用方是
packages/core/src/core/llm-chat.ts,因此它的用例也一并跑了:Tests 553 passed (553)。在packages/core下执行npx tsc --noEmit -p tsconfig.json无报错;eslint 与 prettier 对这两个改动文件也都没有意见。已测试平台
环境
仅单元测试(vitest),不需要运行时、沙箱或网络。
风险与范围
write_file没有被恢复。它会以普通文本的形式可见地留下,而不是被一次幽灵执行替换掉——这个方向是安全的,但它不等于 issue 用作对照的加固前行为。issue 明确说该拒绝是「按它自己的守卫,正确地」发生的;要恢复这个块需要一次「按值定界的块范围」重构(重新推导块的结束位置、让守卫与参数提取都具备深度感知,并为「值里提到一个从未闭合的开标签」补一个兜底)。这次重构还会改变does not let a rejected block parameter swallow a later valid call所钉住的那个块的命运,因此刻意没有打包进本 PR。PARAMETER_PATTERN、TOOL_CALL_PATTERN与两个导出函数的签名都保持原样,新增的辅助函数是模块内私有。关联 Issue
Refs #13492
故意不写
Fixes。本 PR 关闭的是报告里的「执行」那半边:参数值只是引用的标记不再被当成真调用派发。issue 同时报告的另一半边——外层write_file被丢弃——没有变:那个块仍被它自己的守卫拒绝,现在以普通文本的形式可见地留下,而不是被一次幽灵执行替换掉。所以 #13492 应当保持 open。要恢复这个块属于「风险与范围」里描述的按值定界块范围重构;它与一个既有见证用例存在取舍冲突,应当单开 issue,而不是搭在本 PR 里。