Skip to content

fix(core): keep deferred-tools listing out of the cached system prompt - #4781

Closed
qqqys wants to merge 11 commits into
QwenLM:mainfrom
qqqys:fix/deferred-tools-prompt-cache
Closed

fix(core): keep deferred-tools listing out of the cached system prompt#4781
qqqys wants to merge 11 commits into
QwenLM:mainfrom
qqqys:fix/deferred-tools-prompt-cache

Conversation

@qqqys

@qqqys qqqys commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

The "Deferred Tools" listing — the set of MCP tools, which are always deferred and reachable only when the model calls ToolSearch — used to be embedded in the cached system prompt. This PR moves that listing out of the system prompt and into a per-turn <system-reminder> injected into the message tail, rebuilt from the live tool registry on each user/cron turn. The system-prompt prefix therefore stays byte-stable for the whole conversation, while deferred tools are still advertised to the model and remain visible across turns because the reminder is recorded into chat history. It also splits the now-misnamed resolveDeferredToolsForSystemPrompt into a reveal side-effect (revealDeferredToolsWhenUnreachable) and a pure getter (getDeferredToolsForReminder), and drops the now-unused deferredTools parameter from getCoreSystemPrompt / getCustomSystemPrompt.

Why it's needed

Embedding the deferred-tools listing in the system prompt meant every change to the deferred set rewrote the entire system instruction via setSystemInstruction() — which happens whenever MCP progressive discovery completes after startChat(), or the model reveals a tool via ToolSearch. Rewriting the system instruction changes the cached prefix, so the prompt cache is invalidated for the rest of the conversation (Anthropic: the explicit cache_control system block; Gemini: implicit prefix caching). Non-interactive --prompt runs are hit hardest, since that's where progressive MCP discovery fires. Keeping the listing out of the prefix preserves the cache hit across discovery and reveals. Fixes #4777.

Visibility is preserved. The prior design kept deferred tools in the system prompt specifically so non-interactive --prompt runs wouldn't lose sight of progressively-discovered MCP tools; that guarantee still holds here — the reminder is rebuilt from live registry state every user turn and persists in chat history, so late-discovered MCP tools surface and stay visible on subsequent (including tool-result) turns. When ToolSearch is unavailable (e.g. --exclude-tools tool_search), deferred tools are eagerly revealed into the declaration list instead, exactly as before.

Trade-off: because the reminder rides in the message tail, it accumulates in conversation history (one copy per user turn while tools remain unrevealed), shrinking as tools get revealed. This matches the existing per-turn reminders and is far outweighed by preserving the system-prefix cache hit for typical session lengths.

Reviewer Test Plan

How to verify

Type-check and unit tests:

  • cd packages/core && npx tsc --noEmit
  • npx vitest run src/core/client.test.ts src/core/prompts.test.ts → 212 passing, including a new deferred-tools reminder (per-turn) suite that covers: reminder built from the live filtered summary, omits already-revealed tools, suppressed when every tool is already revealed, suppressed when ToolSearch is absent, and NOT injected on tool-result turns — plus direct unit tests for getDeferredToolsSystemReminder.

End-to-end (a real deferred MCP tool is still reachable):

  • Configure an MCP server exposing one tool, then run a non-interactive prompt asking the model to tool_search for it and call it.
  • Observed: the model calls ToolSearch, reveals the MCP tool, and invokes it successfully — confirmed both by the model's reply carrying a value only the tool could return and by the MCP server logging the tools/call.

Evidence (Before & After)

Non–user-visible (prompt-cache behavior + internal refactor), so no screenshots. The behavioral evidence is the end-to-end run above: the deferred MCP tool is revealed and called exactly as before; the only change is that the system-prompt prefix no longer mutates on discovery/reveal, so the prompt cache is no longer invalidated.

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

The "Deferred Tools" listing (MCP tools, always deferred and reachable only via ToolSearch) was baked into the cached system prompt. Every late MCP discovery or ToolSearch reveal rewrote the whole system instruction via setSystemInstruction(), mutating the cached prefix and dropping the prompt-cache hit for the rest of the conversation — for both Anthropic (explicit cache_control on the system block) and Gemini (implicit prefix caching). Non-interactive --prompt runs are hit hardest.

Move the listing into a per-turn <system-reminder> injected into the message tail, rebuilt from live registry state each UserQuery/Cron turn. The system prefix stays byte-stable across discovery/reveals; deferred tools still surface and remain visible across turns via chat history.

Also split the (now misnamed) resolveDeferredToolsForSystemPrompt into revealDeferredToolsWhenUnreachable() (reveal side-effect) and getDeferredToolsForReminder() (pure), and drop the now-unused deferredTools param from getCoreSystemPrompt/getCustomSystemPrompt.

Closes QwenLM#4777

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

📋 Review Summary

This PR addresses prompt cache invalidation issues (#4777) by moving the deferred-tools listing from the cached system prompt into a per-turn <system-reminder> injected into the message tail. The change preserves prompt cache hits across MCP tool discovery and tool reveal events while maintaining tool visibility. The implementation is well-tested with comprehensive unit tests covering edge cases.

🔍 General Feedback

  • The PR correctly identifies the root cause: embedding deferred-tools in the system prompt caused cache invalidation on every MCP discovery/reveal event
  • Good separation of concerns: splits resolveDeferredToolsForSystemPrompt into a side-effect function (revealDeferredToolsWhenUnreachable) and a pure getter (getDeferredToolsForReminder)
  • Test coverage is thorough, including the new "deferred-tools reminder (per-turn)" suite
  • Comments are detailed and explain the "why" behind design decisions effectively
  • The trade-off (reminder accumulates per-turn vs. cache hit preservation) is well-documented

🎯 Specific Feedback

🟡 High

  • File: client.ts:507 - The setTools() method no longer calls setSystemInstruction(), but the test at line 1044 ('rebuilds systemInstruction so newly-registered MCP tools land in the prompt') appears to test the OLD behavior based on the test comment. The test description and assertions should be updated to match the new behavior where setTools() only updates chat.tools without rewriting the system instruction. The test currently expects setSystemInstructionSpy to be called, which contradicts the new design.

  • File: client.test.ts:997-1160 - The entire 'setTools — system instruction refresh' describe block appears to test the old behavior where setTools() rebuilds the system instruction with deferred tools. These tests need to be updated to reflect the new architecture where:

    • setTools() calls revealDeferredToolsWhenUnreachable() but does NOT call setSystemInstruction()
    • Deferred tools surface via per-turn reminder, not system instruction
    • Tests should verify getDeferredToolsForReminder() is used for metrics instead of checking deferredTools argument to getCoreSystemPrompt

🟢 Medium

  • File: client.ts:680-687 - The revealDeferredToolsWhenUnreachable() method has a comment stating it's "Idempotent" in the JSDoc, but the implementation loops through all deferred tools and calls revealDeferredTool() for each. If revealDeferredTool() is not itself idempotent, this could cause issues. Consider adding a guard or documenting that revealDeferredTool() handles repeated calls safely.

  • File: client.ts:1610-1615 - The deferred-tools reminder injection checks both deferredToolsForReminder && deferredToolsForReminder.length > 0. Since getDeferredToolsForReminder() returns undefined when ToolSearch is unavailable (line 703), the && check is redundant. Consider simplifying to just the length check or documenting why both checks are present.

  • File: prompts.ts:197 - The getDeferredToolsSystemReminder() function calls buildDeferredToolsSection(deferredTools) and then checks if (!section) return ''. However, buildDeferredToolsSection already returns '' for empty arrays (line 132). This means an empty array will result in calling buildDeferredToolsSection unnecessarily. Consider checking deferredTools.length === 0 first for early return.

🔵 Low

  • File: client.ts:514 - Comment says "Deferred tools are no longer threaded into the system instruction" — consider using more precise terminology like "Deferred tools listing is no longer embedded in" instead of "threaded into" for clarity.

  • File: client.test.ts:1015-1023 - The lastDeferredArg() helper function references the old function signature (userMemory, model, appendInstruction, deferredTools). Since the deferredTools parameter has been removed from getCoreSystemPrompt and getCustomSystemPrompt, this helper and its usage in tests should be removed or updated to test the new getDeferredToolsForReminder() method instead.

  • File: prompts.ts:82 and prompts.ts:188 - The deferredTools parameter is still present in the function signatures of getCustomSystemPrompt and getCoreSystemPrompt in the checked-out version, but the PR description says these parameters are dropped. Ensure these unused parameters are removed to match the stated goals.

✅ Highlights

  • Excellent JSDoc documentation explaining the cache preservation mechanism and trade-offs (client.ts:677-695)
  • Strong test coverage for edge cases: ToolSearch unavailable, all tools already revealed, ToolResult turns excluded
  • Good security consideration: JSON-encoding tool names/descriptions to prevent markdown injection via MCP tool metadata
  • Clean refactoring: separating side-effect (revealDeferredToolsWhenUnreachable) from pure computation (getDeferredToolsForReminder)
  • The per-turn reminder gating (UserQuery/Cron only, not ToolResult) shows thoughtful consideration of message structure integrity

// ToolSearch is unavailable (those tools are revealed into the
// declaration list by startChat / setTools), in which case no reminder
// is needed. The tool registry is already warm by this point.
const deferredToolsForReminder = this.getDeferredToolsForReminder();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] On SendMessageType.Retry turns, the deferred-tools reminder is not injected. stripOrphanedUserEntriesFromHistory() (line 1213) pops the prior user entry (which contained the reminder), and the gate at line 1597-1599 only covers UserQuery | Cron — so Retry turns lose deferred-tools visibility for one turn.

In the old design, deferred tools lived in the cached systemInstruction (part of generationConfig, not conversation history), so they survived the orphan-strip. Now they don't.

This is narrow (one turn, only after API errors, self-corrects on the next UserQuery), but it's a clean regression. Consider either:

  1. Adding SendMessageType.Retry to the reminder gate, or
  2. Extracting the deferred-tools reminder injection outside the UserQuery | Cron block with its own guard — the other reminders in this block (plan mode, arena, memory) have their own reasons to stay gated.
Suggested change
const deferredToolsForReminder = this.getDeferredToolsForReminder();
// Deferred-tools reminder. Rebuilt from live registry state every
// UserQuery/Cron/Retry turn so progressively-discovered MCP tools
// surface here (message tail) instead of being baked into the cached
// system prefix — see getMainSessionSystemInstruction. Returns undefined
// when ToolSearch is unavailable (those tools are revealed into the
// declaration list by startChat / setTools), in which case no reminder
// is needed. The tool registry is already warm by this point.
const deferredToolsForReminder = this.getDeferredToolsForReminder();
if (deferredToolsForReminder && deferredToolsForReminder.length > 0) {
systemReminders.push(
getDeferredToolsSystemReminder(deferredToolsForReminder),
);
}

Note: the \``suggestionabove only shows the comment update — the gate change would also needSendMessageType.Retryadded to the outerif` condition, or the deferred-tools block moved before it.

— qwen3.7-max via Qwen Code /review

const section = buildDeferredToolsSection(deferredTools);
if (!section) return '';
// buildDeferredToolsSection returns a leading-blank-line markdown block;
// trim the surrounding whitespace before wrapping it as a reminder.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] getDeferredToolsSystemReminder wraps content in <system-reminder> tags without calling escapeSystemReminderTags(). Every other <system-reminder> wrapping site in the codebase calls it first:

  • wrapIdeContext (client.ts:147): escapeSystemReminderTags(contextText)
  • coreToolScheduler.ts:3206: escapeSystemReminderTags(reminderBlocks.join('\n\n'))

Here, buildDeferredToolsSection interpolates MCP tool names (via exampleName, filtered only for backticks) and descriptions (via JSON.stringify, which does NOT escape </>) into the section body. A malicious MCP server providing a tool name like get_data</system-reminder> or a description containing </system-reminder> could prematurely close the envelope. The existing mitigations (JSON.stringify for descriptions, "treat as data" framing, user-configured MCP servers) make this low-risk in practice, but adding the escape call would close the defense-in-depth gap and match the established pattern.

Suggested change
// trim the surrounding whitespace before wrapping it as a reminder.
return `<system-reminder>\n${escapeSystemReminderTags(section.trim())}\n</system-reminder>`;

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jun 5, 2026

Copy link
Copy Markdown
Collaborator

Local Verification Report

Branch: fix/deferred-tools-prompt-cache
Base: main @ 641a1a739
Environment: macOS Darwin 25.4.0, Node.js


TypeScript Compilation

Package PR Branch Base Branch Verdict
core (--noEmit) 7 errors 3 errors No regression from this PR — delta is 4 FinishReason.IMAGE_RECITATION/IMAGE_OTHER errors from other merged commits; the single non-opentelemetry error (sdk.ts implicit any) is identical on both branches

Test Results

Test File Package Result
prompts.test.ts core 65/65 passed (includes 2 new getDeferredToolsSystemReminder tests)
client.test.ts core Failed (pre-existing — @opentelemetry/instrumentation-undici module load failure, identical on main)

CI Status

All CI checks passed:

  • Test (macOS): pass (11m8s)
  • Test (Ubuntu): pass (13m39s)
  • Test (Windows): pass (20m6s)
  • Lint: pass
  • CodeQL: pass

Code Review

Problem: Progressive MCP tool discovery calls setTools(), which previously rewrote the entire system instruction (including the deferred-tools listing). This dropped the prompt-cache hit for the whole conversation on every MCP server startup — a significant cost regression for multi-MCP setups.

Solution: Move the deferred-tools listing from the cached system-prompt prefix to a per-turn <system-reminder> in the message tail.

Changes across 4 files:

  1. prompts.ts (+25/-10)

    • New getDeferredToolsSystemReminder(): wraps buildDeferredToolsSection() output in <system-reminder> tags for per-turn injection
    • Removes deferredTools parameter from getCoreSystemPrompt() and getCustomSystemPrompt() — deferred tools no longer baked into the cached prefix
    • Clean separation: the system instruction is now cache-stable
  2. client.ts (+76/-69)

    • setTools(): no longer calls setSystemInstruction() — only updates chat.tools declarations. This is the core fix: progressive MCP discovery no longer busts the prompt cache
    • Removes the setTools()applySessionStartContext() re-apply step (no longer needed since setTools() doesn't rewrite the prefix)
    • New revealDeferredToolsWhenUnreachable(): extracted from the old resolveDeferredToolsForSystemPrompt(), handles the ToolSearch-absent case (eager reveal)
    • New getDeferredToolsForReminder(): pure function returning unrevealed deferred tools for the per-turn reminder
    • sendMessageStream(): injects getDeferredToolsSystemReminder() into the message tail on UserQuery, Cron, and Retry turns (NOT on ToolResult — correct, as that would break functionCall/functionResponse pairing)
    • refreshSystemInstruction(): simplified — no longer warms tools or resolves deferred tools (those concerns are now per-turn)
    • startChat(): uses revealDeferredToolsWhenUnreachable() instead of resolveDeferredToolsForSystemPrompt()
  3. client.test.ts (+207/-105)

    • Updated setTools tests: verify setSystemInstruction is NOT called (was previously the opposite assertion)
    • New deferred-tools reminder (per-turn) describe block with 7 tests:
      • Injects reminder on UserQuery and Retry turns
      • Omits already-revealed tools from reminder
      • Skips reminder when ToolSearch unavailable (tools already revealed)
      • Skips reminder when all deferred tools already revealed
      • Does NOT inject on ToolResult turns (preserves functionCall/Response pairing)
    • Updated SessionStart additionalContext test: verifies setTools() leaves system instruction untouched (before === after)
  4. prompts.test.ts (+23/-0)

    • 2 new tests for getDeferredToolsSystemReminder: verifies <system-reminder> wrapping and empty-input handling

Key design observations:

  • The per-turn reminder approach is correct: deferred tools change infrequently (only on MCP discovery) but the system instruction is cache-critical for every turn
  • Retry turn type correctly added to the reminder gate (previously only UserQuery/Cron)
  • ToolResult correctly excluded — prepending text before functionResponse parts would break the Gemini API contract
  • The length > 0 guard prevents empty <system-reminder> wrappers when all tools are already revealed

Verdict: Ready to merge — Clean architectural improvement that preserves prompt-cache stability during progressive MCP discovery. prompts.test.ts 65/65 pass. client.test.ts failure is pre-existing on main. CI all green.

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No review findings. Downgraded from Approve to Comment: CI failing: CodeQL. — qwen3.7-max via Qwen Code /review

wenshao
wenshao previously approved these changes Jun 6, 2026
@tanzhenxin tanzhenxin added the type/bug Something isn't working as expected label Jun 8, 2026

@LaZzyMan LaZzyMan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cache-stability goal and the refactor look solid, and moving the deferred-tools listing into an escaped per-turn <system-reminder> (linear escapeSystemReminderTags) is a nice hardening. One issue blocks though.

Adding Retry to the reminder-injection block can split functionCall/functionResponse

This change adds SendMessageType.Retry to the branch that prepends the system-reminders block to the front of requestToSend, but unlike the IDE-context block above it, this branch has no hasPendingToolCall guard:

if (
  messageType === SendMessageType.UserQuery ||
  messageType === SendMessageType.Cron ||
  messageType === SendMessageType.Retry   // new
) {
  const systemReminders = [];
  // deferred-tools / plan-mode / arena / memory.unshift
  requestToSend = [...systemReminders, ...requestToSend];  // prepend
}

A retry can replay tool-result (functionResponse) parts:

  1. retryLastPrompt() submits lastPromptRef.current with SendMessageType.Retry.
  2. lastPromptRef.current is set on every submit, including ToolResult continuations — so after a failed tool-result continuation it holds the functionResponse parts.
  3. Retry requires lastPromptErroredRef.current === true, which is exactly the failed-tool-continuation case.
  4. In sendMessageStream, request is then the functionResponse parts, and the new branch prepends the reminders → [reminderText, ...functionResponseParts].

This is the ordering this file deliberately avoids elsewhere. The ToolResult memory path appends rather than prepends, with the rationale:

Putting the memory text after the functionResponse parts keeps the call/response pairing intact under native Gemini; the OpenAI converter then emits the text as a separate user message after the tool messages.

So a reminder placed before functionResponse parts produces assistant(tool_calls) → user(text) → tool(results) on the OpenAI-compatible path (the default for Qwen), where the tool messages no longer immediately follow the tool_calls → API error. This fires whenever the reminder is non-empty (plan mode active, unrevealed deferred/MCP tools, arena, or a pending managed-memory prefetch) on a retry of a failed tool continuation — an error-recovery path where a second, more confusing failure is especially bad. main doesn't have this (the branch was UserQuery || Cron only), so it's a regression.

The new injects the reminder on a Retry turn test only covers a text retry ([{ text: 'Hi' }]), so it misses this branch.

Suggested fix — reuse the existing guard so the behavior matches the IDE-context block:

if (
  (messageType === SendMessageType.UserQuery ||
   messageType === SendMessageType.Cron ||
   messageType === SendMessageType.Retry) &&
  !hasPendingToolCall
) {

On a normal UserQuery hasPendingToolCall is already false (no behavior change); on a tool-result retry it's true, so the prepend is skipped. Visibility is preserved — the deferred-tools reminder re-injects on the next UserQuery turn, and the previous turn's reminder is still in history. A functionResponse-parts retry test would lock this down.

Everything else looks good to me.

@tanzhenxin

Copy link
Copy Markdown
Collaborator

Heads-up, @qqqys#4053 ("Move startup context into system reminders") just merged to main (543d612c), and it overlaps substantially with this PR, so I want to lay out where that leaves things.

#4053 shares this PR's core goal — keeping the deferred-tools listing out of the cached system prompt so MCP progressive discovery / ToolSearch reveals don't bust the provider prefix cache — and as a result, both of your main changes here are now already on main:

  • Deferred-tools relocation: main no longer carries the deferred-tools section in the system instruction (prompts.ts); Move startup context into system reminders #4053 moves it into a <system-reminder> prelude at history[0] (extended via an append-only queue drained on the next user/cron turn). This PR does the same relocation, just via a fresh per-turn reminder injected into the message tail instead.
  • The ReDoS fix: the catastrophically-backtracking escapeSystemReminderTags regex is already replaced on main with a linear scanner (xml.ts) — the same approach you took here. Genuinely good catch on that; it was a real vulnerability reachable via malicious MCP tool metadata.

Two consequences:

  1. This branch now conflicts with main — the two changes touch the same regions of client.ts, prompts.ts, and xml.ts.
  2. The open review item here — adding SendMessageType.Retry to the prepend-injection without a !hasPendingToolCall guard, which can split functionCall/functionResponse on a tool-result retry — is something Move startup context into system reminders #4053's persistent-prelude design sidesteps structurally: its reminder lives in history and is never re-injected on tool-result turns.

So with #4053 landed, the substance of this PR is largely already on main via a different mechanism. I'll leave it to you to decide how to proceed — close it as superseded, or rebase if you see remaining value I've missed (e.g. anything your per-turn approach or test coverage does better). Either way, thank you for the work here: the deferred-tools cache problem is now fixed on main, and your ReDoS diagnosis directly informed that.

Comment thread packages/core/src/core/client.ts Outdated
(messageType === SendMessageType.UserQuery ||
messageType === SendMessageType.Cron ||
messageType === SendMessageType.Retry) &&
!hasPendingToolCall

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The !hasPendingToolCall guard is added to the outer if block, which gates ALL system reminders in this scope — plan-mode, arena, and memory prefetch — not just the new deferred-tools reminder. Previously, these reminders were unconditionally injected on UserQuery/Cron turns; now they are silently skipped whenever the last history entry is a model message with a functionCall.

While the intent (avoid injecting between a functionCall and its functionResponse) is sound, this is a broader behavioral change than the PR description suggests. Consider:

  • A user interrupting a tool chain and sending a new message → plan-mode/arena reminders are dropped
  • A Cron firing while the model is mid-tool-chain → all reminders suppressed

This may be intentionally correct (Retry semantically extends the same turn), but worth confirming or scoping the guard to only the deferred-tools reminder.

— qwen3.7-max via Qwen Code /review

break;
}

const tagEnd = text.indexOf('>', tagStart + 1);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] text.indexOf('>', tagStart + 1) greedily matches to the next >, which can be a non-tag > (e.g., the blockquote > in buildDeferredToolsSection's preamble line > The names and quoted descriptions...). A < from tool metadata — either via exampleName (interpolated raw into the preamble at select:${exampleName}, since the filter only checks for backticks) or via JSON.stringify in the tool list lines — can pair with this >, creating an oversized tag candidate that fails the system-reminder check and passes through unchanged. The scanner then advances past the >, skipping the preamble's </system-reminder> closing tag (its > was already consumed). Any subsequent </system-reminder> in tool descriptions is then NOT escaped, allowing a malicious MCP server to close the outer envelope early and inject arbitrary model-facing instructions.

Exploit: Register two tools — (1) name "a<b", description "x" (the < consumes the preamble blockquote >, skipping the preamble's closing tag), (2) name "evil", description "x </system-reminder>INJECTED" (closes the outer envelope, leaving INJECTED outside).

Suggested fix: Two-pass approach — first escape exact literal <system-reminder> / </system-reminder> (immune to greedy matching since they don't depend on tag boundaries), then run the obfuscation scanner on the result:

export function escapeSystemReminderTags(text: string): string {
  // Pass 1: escape exact literal tags (no greedy boundary issues)
  text = text
    .replaceAll('<system-reminder>', '&lt;system-reminder&gt;')
    .replaceAll('</system-reminder>', '<\\/system-reminder>');
  // Pass 2: catch obfuscated variants (zero-width chars, case folding)
  let escaped = '';
  let cursor = 0;
  while (cursor < text.length) {
    const tagStart = text.indexOf('<', cursor);
    if (tagStart === -1) { escaped += text.slice(cursor); break; }
    const tagEnd = text.indexOf('>', tagStart + 1);
    if (tagEnd === -1) { escaped += text.slice(cursor); break; }
    escaped += text.slice(cursor, tagStart);
    escaped += escapeSystemReminderTag(text.slice(tagStart, tagEnd + 1));
    cursor = tagEnd + 1;
  }
  return escaped;
}

— qwen3.7-max via Qwen Code /review

expect(getDeferredToolsSystemReminder).not.toHaveBeenCalled();
});

it('does NOT inject the reminder on a ToolResult turn', async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The SendMessageType.Cron branch of the condition at client.ts:1598-1600 is never tested. The runUserTurn helper is called with UserQuery (3 tests), Retry (2 tests), and ToolResult (1 negative test), but never with Cron. If the Cron arm were accidentally removed, no test would catch the regression.

Suggested change
it('does NOT inject the reminder on a ToolResult turn', async () => {
it('injects the reminder on a Cron turn', async () => {
const reg = getRegistryMock();
reg.getTool.mockImplementation((n: string) =>
n === 'tool_search' ? ({} as never) : null,
);
reg.getDeferredToolSummary.mockReturnValue([
{ name: 'mcp__server__alpha', description: 'a' },
]);
reg.isDeferredToolRevealed.mockReturnValue(false);
vi.mocked(getDeferredToolsSystemReminder).mockReturnValue(
'<system-reminder>DEFERRED</system-reminder>',
);
await runUserTurn(SendMessageType.Cron);
expect(getDeferredToolsSystemReminder).toHaveBeenCalledWith([
{ name: 'mcp__server__alpha', description: 'a' },
]);
});
it('does NOT inject the reminder on a ToolResult turn', async () => {

— qwen3.7-max via Qwen Code /review

);
});

it('handles many angle brackets without regex backtracking', () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] getSystemReminderTagKind has a branch at xml.ts:95 for <system-reminder attr="val"/> (whitespace after tag name + self-closing /) that is never independently tested. Existing tests cover <system-reminder/> (self-closing without attributes) and <system-reminder data-source="file"> (opening with attributes), but not the combination.

Suggested change
it('handles many angle brackets without regex backtracking', () => {
it('handles many angle brackets without regex backtracking', () => {
const input = '<'.repeat(5000);
expect(escapeSystemReminderTags(input)).toBe(input);
});
it('escapes self-closing system-reminder tags with attributes', () => {
const input = '<system-reminder data-x="y"/>';
expect(escapeSystemReminderTags(input)).toBe(
'&lt;system-reminder data-x=&quot;y&quot;/&gt;',
);
});

— qwen3.7-max via Qwen Code /review

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review did not complete successfully: Qwen review timed out after 55 minutes. See workflow logs.

this.chat.setSystemInstruction(
this.getMainSessionSystemInstruction(deferredTools),
);
this.chat.setSystemInstruction(this.getMainSessionSystemInstruction());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] refreshSystemInstruction() is still declared async after the warmAll() removal in this PR, but the body no longer contains any await. The function is purely synchronous now — setSystemInstruction() and applySessionStartContext() are both sync calls.

Dropping async avoids an unnecessary Promise wrapper and signals to callers that no I/O is involved (useful when investigating race conditions).

Suggested change
this.chat.setSystemInstruction(this.getMainSessionSystemInstruction());
refreshSystemInstruction(): void {

Callers that await the result still work (await on a non-Promise is a no-op), so this can be done without coordinating call sites.

— qwen3.7-max via Qwen Code /review

// reminder is needed. The tool registry is already warm by this point.
const deferredToolsForReminder = this.getDeferredToolsForReminder();
if (deferredToolsForReminder && deferredToolsForReminder.length > 0) {
systemReminders.push(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] No debug logging in the deferred-tools per-turn reminder injection path. When MCP tools silently become invisible to the model (e.g., ToolSearch excluded by a deny rule, progressive discovery races with the first user turn), there's no log trail showing whether the reminder was built, pushed, or skipped — and with how many tools.

The deferred-tools listing moved from the cached system instruction (inspectable once at startup) to a per-turn message-tail injection with no observability. Adding a debugLogger.debug() call here would make "model doesn't see MCP tool X" reports traceable at 3 AM.

Suggested change
systemReminders.push(
debugLogger.debug('deferred-tools reminder: %d tools', deferredToolsForReminder.length);
systemReminders.push(
getDeferredToolsSystemReminder(deferredToolsForReminder),
);

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

Independent local verification — ✅ code & tests pass at branch HEAD, ⚠️ needs rebase before merge

Reproduced this PR locally in an isolated git worktree on a real npm ci install (not a symlinked node_modules), driving the long steps through tmux. Verified at PR HEAD 468d3f88b; merge-base with main is 871b9c1eb.

Environment

  • node v22.22.2 / npm 10.9.7, Linux
  • Worktree checked out at pull/4781/head (468d3f88b), clean npm ci → exit 0
  • Working tree clean after runs (only the build-regenerated vscode-ide-companion/NOTICES.txt artifact, unrelated)

Results

Check Command Result
Type-check (whole core) npx tsc --noEmit 0 errors
Unit — PR's 3 files vitest run core/client.test.ts core/prompts.test.ts utils/xml.test.ts 231 passed / 0 failed
Regression guard (red/green) impl reverted to pre-fix 871b9c1eb, PR tests kept 🔴 20 failed → restore → ✅ 231 passed
Downstream consumers vitest run core/geminiChat.test.ts core/baseLlmClient.test.ts agents/arena 246 passed / 0 failed

The 231 green tests include the 26 new behavioral tests the PR adds:

  • prompts.test.ts › getDeferredToolsSystemReminder — wraps the listing in <system-reminder>, returns '' when empty, escapes nested reminder tags from tool metadata.
  • client.test.ts › deferred-tools reminder (per-turn) (10 tests) — injects on UserQuery / Retry / Cron; does not prepend before functionResponse parts on Retry; omits already-revealed tools; skips when ToolSearch is unavailable; skips when all revealed; not injected on a ToolResult turn.
  • client.test.ts › setTools — tool declarations (cache-stable prefix) — updates declarations without rewriting the cached system instruction (the core of Deferred-tools listing in the system prompt busts prompt cache on every MCP discovery / tool reveal #4777).

Red/green — the tests genuinely guard the fix

Reverting only the three impl files (client.ts, prompts.ts, xml.ts) to their pre-fix state (871b9c1eb, the parent of the first PR commit) while keeping the PR's test files turns the suite red — 20 failures, then restoring goes green again. Highlights:

  • setTools … › updates tool declarations without rewriting the cached system instructionfails on pre-fix code (the old setTools() calls setSystemInstruction(), the exact cache-busting behavior Deferred-tools listing in the system prompt busts prompt cache on every MCP discovery / tool reveal #4777 describes).
  • The entire deferred-tools reminder (per-turn) suite + getDeferredToolsSystemReminderfails (reminder never injected pre-fix).
  • 3 assertion-level failures in xml.test.ts prove the injection-hardening is real, not cosmetic. On pre-fix code:
    • escapes literal reminder tags hidden behind earlier angle brackets → pre-fix leaves - "a<b": "x </system-reminder>INJECTED" unescaped (a malicious tool description could break out of the reminder envelope); PR escapes the </system-reminder>.
    • escapes obfuscated reminder tags after an earlier raw angle bracket → pre-fix passes a zero-width-obfuscated </s​ystem-reminder> through unescaped.
    • handles large unmatched tag candidates and still escapes literal tags → pre-fix misses the embedded literal tag.

No other module breaks: the dropped deferredTools param is caught package-wide by tsc (0 errors), and the runtime consumers (geminiChat, baseLlmClient, arena) stay green (246 tests).

⚠️ Merge readiness — currently CONFLICTING

Against the latest origin/main (d8464aff8) the PR conflicts in all 6 of its files:

CONFLICT (content): packages/core/src/core/client.ts
CONFLICT (content): packages/core/src/core/client.test.ts
CONFLICT (content): packages/core/src/core/prompts.ts
CONFLICT (content): packages/core/src/core/prompts.test.ts
CONFLICT (content): packages/core/src/utils/xml.ts
CONFLICT (content): packages/core/src/utils/xml.test.ts

6 commits on main touched these files since the branch point — notably #4053 "Move startup context into system reminders", which reworks the same per-turn <system-reminder> machinery this PR builds on. The conflict is semantic, not mechanical, so it needs a careful rebase (especially in client.ts sendMessageStream and prompts.ts) and a re-run of the suites above afterward.

Scope notes (for honesty)

  • This verifies the mechanism — the unit test asserts setTools() no longer rewrites the system instruction, and the package-wide tsc + downstream suites confirm nothing regresses. I did not measure live prompt-cache hit-rates against a real Anthropic/Gemini endpoint; that part rests on the code + the cache-stable-prefix test.
  • The live-model MCP reveal path (model calls ToolSearch → reveals → invokes an MCP tool) was not re-run with real credentials; it's covered by the author's reported e2e and by the client.test.ts integration tests that exercise the real sendMessageStream path with a mocked content generator.

Verdict

Code is correct and well-guarded at branch HEAD: type-clean, 231 green, a convincing red/green that ties each new test to the behavior it protects, and no downstream regressions. The only blocker is the merge conflict — rebase onto current main, re-resolve the reminder/escaping overlap with #4053, and re-run core tests before merging.

Verified locally in a dedicated worktree (npm ci, tmux); commands and counts above are reproducible.

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review did not complete successfully: Qwen review timed out after 55 minutes. See workflow logs.

@qqqys qqqys closed this Jun 9, 2026

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found. LGTM! ✅ — qwen3.7-max via Qwen Code /review

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

Labels

type/bug Something isn't working as expected

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Deferred-tools listing in the system prompt busts prompt cache on every MCP discovery / tool reveal

5 participants