mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-10-21 14:21:48 +00:00
fix(core): close the round-2 review findings on the deferred-tool rules PR
- session-switch.ts: re-initialize the client in the opentui /resume rollback, mirroring the other three rollback routes, so the restored session's reviewed-schema evidence is rebuilt (R2-14). - tool-registry.ts: syncReviewedDeclarations keeps a reviewed tool armed when its tool_search block is present but mid-JSON truncated — the name is recovered from the block head and the in-memory review carries over; blocks absent from history entirely are still forgotten (qqqys R2-2/R2-4). - extract.ts: back the pending-window cut off to a real turn boundary so a model functionCall is never split from its functionResponse (R2-12). - extractionAgentPlanner.ts: curate the pending window through extractCuratedHistory (dropping degraded placeholder turns), and stamp a historical window with windowAsOf so the prompt drops the recency wording and the anchor-to-today date claim (R2-5). Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
parent
778610a092
commit
ffd9df8928
10 changed files with 415 additions and 19 deletions
|
|
@ -249,6 +249,30 @@ describe('handleResumeSession', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('re-initializes the client when rolling back after the core swap', async () => {
|
||||
// The forward initialize succeeds; the failure lands at the transcript
|
||||
// reset (after the core swap, before the UI commit). The rollback's
|
||||
// startNewSession clears the reviewed-schema evidence, so the client
|
||||
// must be re-initialized against the restored session — otherwise the
|
||||
// next hidden deferred call is refused even though its tool_search
|
||||
// block is still in context. Removing the rollback's initialize() reds
|
||||
// this: the count stays at the forward call alone.
|
||||
const { config, calls } = createFakeConfig();
|
||||
const host = createFakeHost(config);
|
||||
vi.mocked(host.resetTranscript).mockImplementation(() => {
|
||||
throw new Error('transcript boom');
|
||||
});
|
||||
|
||||
await handleResumeSession(host, 'target-session');
|
||||
|
||||
expect(calls.startNewSession.map(([id]) => id)).toEqual([
|
||||
'target-session',
|
||||
'old-session',
|
||||
]);
|
||||
expect(calls.clientInitialize).toBe(2);
|
||||
expect(calls.swapAbort).toBe(1);
|
||||
});
|
||||
|
||||
it('blocks the switch while background work is running', async () => {
|
||||
const { config, calls } = createFakeConfig();
|
||||
const registry = config.getBackgroundTaskRegistry() as unknown as {
|
||||
|
|
|
|||
|
|
@ -207,6 +207,17 @@ export async function handleResumeSession(
|
|||
resetBackgroundStateForSessionSwitch(config);
|
||||
config.startNewSession(oldSessionId, undefined);
|
||||
await config.loadPausedBackgroundAgents(oldSessionId).catch(() => {});
|
||||
// Re-hydrate the client against the restored session, mirroring the
|
||||
// other three rollback routes (useResumeCommand, useBranchCommand,
|
||||
// the TUI session-switch): startNewSession cleared the reviewed-schema
|
||||
// evidence, and without a fresh startChat the chat keeps serving the
|
||||
// abandoned session's replayed history, so the next hidden deferred
|
||||
// call is refused as un-reviewed even though its tool_search block is
|
||||
// still in context. Must run BEFORE abortTelemetrySwap below — the
|
||||
// re-initialize replays the old session's history on top of the
|
||||
// abandoned session's replay, and restore overwrites rather than
|
||||
// subtracts, so the final state is exactly pre-swap (#9833).
|
||||
await config.getGeminiClient()?.initialize?.();
|
||||
} catch (rollbackErr) {
|
||||
config
|
||||
.getDebugLogger()
|
||||
|
|
|
|||
|
|
@ -1595,7 +1595,9 @@ function validateHistory(history: Content[]) {
|
|||
* filters or recitation). Extracting valid turns from the history
|
||||
* ensures that subsequent requests could be accepted by the model.
|
||||
*/
|
||||
function extractCuratedHistory(comprehensiveHistory: Content[]): Content[] {
|
||||
export function extractCuratedHistory(
|
||||
comprehensiveHistory: Content[],
|
||||
): Content[] {
|
||||
if (comprehensiveHistory === undefined || comprehensiveHistory.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -259,22 +259,113 @@ describe('auto-memory extraction', () => {
|
|||
|
||||
const first = await runAutoMemoryExtract({ ...params, history });
|
||||
const boundary = prefix.length + CACHE_SAFE_HISTORY_TAIL_ENTRIES;
|
||||
// The naive boundary lands on the open functionCall at i=38 (the skipped
|
||||
// fact shifted the alternating pairs by one), so the cut retreats one
|
||||
// entry: the window ends before the open call and the cursor stops
|
||||
// there, keeping the call and its response together for the next run.
|
||||
const aligned = boundary - 1;
|
||||
// The aligned window does not reach the end of history, so the run is
|
||||
// stamped as a historical segment for the planner prompt.
|
||||
expect(runAutoMemoryExtractionByAgent).toHaveBeenLastCalledWith(
|
||||
mockConfig,
|
||||
projectRoot,
|
||||
history.slice(prefix.length, boundary),
|
||||
history.slice(prefix.length, aligned),
|
||||
{ windowAsOf: expect.any(String) },
|
||||
);
|
||||
expect(first.cursor.processedOffset).toBe(boundary);
|
||||
expect(first.cursor.processedOffset).toBe(aligned);
|
||||
const persisted = JSON.parse(
|
||||
await fs.readFile(getAutoMemoryExtractCursorPath(projectRoot), 'utf-8'),
|
||||
);
|
||||
expect(persisted.processedOffset).toBe(boundary);
|
||||
expect(persisted.processedOffset).toBe(aligned);
|
||||
|
||||
const second = await runAutoMemoryExtract({ ...params, history });
|
||||
expect(runAutoMemoryExtractionByAgent).toHaveBeenLastCalledWith(
|
||||
mockConfig,
|
||||
projectRoot,
|
||||
history.slice(boundary),
|
||||
history.slice(aligned),
|
||||
undefined,
|
||||
);
|
||||
expect(second.cursor.processedOffset).toBe(history.length);
|
||||
});
|
||||
|
||||
it('backs the pending-window cut off a model functionCall so the response is never orphaned', async () => {
|
||||
// A plain-index cut can land between a model functionCall and its
|
||||
// functionResponse: the trailing repair would fabricate a response
|
||||
// reusing the real call's id, and the next window would open on the
|
||||
// orphaned true output, which no run ever sees. The cut must retreat to
|
||||
// the turn boundary: the window ends before the open call, the cursor
|
||||
// stops there, and the next run's window starts on it.
|
||||
vi.mocked(runAutoMemoryExtractionByAgent).mockResolvedValue({
|
||||
touchedTopics: [],
|
||||
touchedProjectScope: false,
|
||||
touchedUserScope: false,
|
||||
hasToolActivity: true,
|
||||
});
|
||||
const params = {
|
||||
projectRoot,
|
||||
sessionId: 'session-1',
|
||||
config: mockConfig,
|
||||
preserveUnprocessedHistory: true,
|
||||
};
|
||||
const prefix: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'Already processed.' }] },
|
||||
];
|
||||
await runAutoMemoryExtract({ ...params, history: prefix });
|
||||
|
||||
// Entry at the naive cut (startOffset + CACHE_SAFE_HISTORY_TAIL_ENTRIES)
|
||||
// is a model functionCall; its response follows it.
|
||||
const boundary = prefix.length + CACHE_SAFE_HISTORY_TAIL_ENTRIES;
|
||||
const history: Content[] = [
|
||||
...prefix,
|
||||
{ role: 'user', parts: [{ text: 'Pending user fact.' }] },
|
||||
...Array.from(
|
||||
{ length: CACHE_SAFE_HISTORY_TAIL_ENTRIES - 2 },
|
||||
(_, i): Content => ({
|
||||
role: 'model',
|
||||
parts: [{ text: `Filler turn ${i}.` }],
|
||||
}),
|
||||
),
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ functionCall: { id: 'call-open', name: 'read_file', args: {} } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'call-open',
|
||||
name: 'read_file',
|
||||
response: { output: 'the real file bytes' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'user', parts: [{ text: 'A durable fact in the remainder.' }] },
|
||||
];
|
||||
expect(history[boundary - 1]?.parts?.[0]).toHaveProperty('functionCall');
|
||||
|
||||
const first = await runAutoMemoryExtract({ ...params, history });
|
||||
// The cut retreated one entry: the window ends before the open call.
|
||||
// That window is a historical segment, so the planner gets windowAsOf.
|
||||
expect(runAutoMemoryExtractionByAgent).toHaveBeenLastCalledWith(
|
||||
mockConfig,
|
||||
projectRoot,
|
||||
history.slice(prefix.length, boundary - 1),
|
||||
{ windowAsOf: expect.any(String) },
|
||||
);
|
||||
expect(first.cursor.processedOffset).toBe(boundary - 1);
|
||||
|
||||
const second = await runAutoMemoryExtract({ ...params, history });
|
||||
// The next window opens on the call, so call and response are extracted
|
||||
// together — and it reaches the end of history, so no segment stamp.
|
||||
expect(runAutoMemoryExtractionByAgent).toHaveBeenLastCalledWith(
|
||||
mockConfig,
|
||||
projectRoot,
|
||||
history.slice(boundary - 1),
|
||||
undefined,
|
||||
);
|
||||
expect(second.cursor.processedOffset).toBe(history.length);
|
||||
});
|
||||
|
|
@ -332,10 +423,13 @@ describe('auto-memory extraction', () => {
|
|||
);
|
||||
|
||||
const first = await runAutoMemoryExtract({ ...params, history });
|
||||
// The first window does not reach the end of history, so it is stamped
|
||||
// as a historical segment for the planner prompt.
|
||||
expect(runAutoMemoryExtractionByAgent).toHaveBeenLastCalledWith(
|
||||
mockConfig,
|
||||
projectRoot,
|
||||
history.slice(0, CACHE_SAFE_HISTORY_TAIL_ENTRIES),
|
||||
{ windowAsOf: expect.any(String) },
|
||||
);
|
||||
// No genuine progress, but the windowed arm must still advance: holding
|
||||
// the cursor at startOffset recomputes a byte-identical slice next turn
|
||||
|
|
|
|||
|
|
@ -75,6 +75,38 @@ function getSessionMismatchResult(
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cut-safety predicates for the pending-window boundary. A cut is unsafe when
|
||||
* the last included entry is a model entry carrying a functionCall (its
|
||||
* response falls outside the window and the trailing repair would fabricate
|
||||
* one reusing the real call's id), or when the next entry is a user entry
|
||||
* that is all functionResponse parts (the next window would open on an
|
||||
* orphaned response whose call sits in already-processed history, and no
|
||||
* extraction run would ever see it — the cursor only advances).
|
||||
*/
|
||||
function endsOnOpenCall(
|
||||
history: readonly Content[],
|
||||
endOffset: number,
|
||||
): boolean {
|
||||
const last = history[endOffset - 1];
|
||||
return (
|
||||
last?.role === 'model' &&
|
||||
(last.parts ?? []).some((part) => part.functionCall)
|
||||
);
|
||||
}
|
||||
|
||||
function opensOnOrphanedResponse(
|
||||
history: readonly Content[],
|
||||
endOffset: number,
|
||||
): boolean {
|
||||
const next = history[endOffset];
|
||||
return (
|
||||
next?.role === 'user' &&
|
||||
(next.parts ?? []).length > 0 &&
|
||||
(next.parts ?? []).every((part) => part.functionResponse)
|
||||
);
|
||||
}
|
||||
|
||||
async function readExtractCursor(
|
||||
projectRoot: string,
|
||||
): Promise<AutoMemoryExtractCursor> {
|
||||
|
|
@ -182,12 +214,30 @@ export async function runAutoMemoryExtract(params: {
|
|||
// With turn-skipping enabled, a large ending turn can evict skipped facts
|
||||
// from the usual tail. Process the oldest pending window instead, and never
|
||||
// mark its unseen remainder as processed.
|
||||
const endOffset = params.preserveUnprocessedHistory
|
||||
let endOffset = params.preserveUnprocessedHistory
|
||||
? Math.min(
|
||||
params.history.length,
|
||||
startOffset + CACHE_SAFE_HISTORY_TAIL_ENTRIES,
|
||||
)
|
||||
: params.history.length;
|
||||
// A plain index can split a model functionCall from its functionResponse:
|
||||
// the trailing repair would fabricate a response reusing the real call's
|
||||
// id, and the next window would open on the orphaned true output, which no
|
||||
// extraction run ever sees (the cursor only advances). Back the cut off to
|
||||
// a real turn boundary — but only while the window does not already reach
|
||||
// the end of history (a session ending on an open call must still be able
|
||||
// to arm the no-op cooldown at processedOffset === history.length), and
|
||||
// never past startOffset (a zero-length window still writes the cursor).
|
||||
if (params.preserveUnprocessedHistory) {
|
||||
while (
|
||||
endOffset < params.history.length &&
|
||||
endOffset > startOffset &&
|
||||
(endsOnOpenCall(params.history, endOffset) ||
|
||||
opensOnOrphanedResponse(params.history, endOffset))
|
||||
) {
|
||||
endOffset--;
|
||||
}
|
||||
}
|
||||
const pendingHistory = params.history.slice(startOffset, endOffset);
|
||||
|
||||
// Skip if there are no new, non-empty user messages in the unprocessed
|
||||
|
|
@ -218,6 +268,14 @@ export async function runAutoMemoryExtract(params: {
|
|||
params.config,
|
||||
params.projectRoot,
|
||||
params.preserveUnprocessedHistory ? pendingHistory : undefined,
|
||||
// A pending window that does not reach the end of history is a
|
||||
// historical segment: the planner drops the recency wording and the
|
||||
// anchor-to-today date claim, which would otherwise convert the
|
||||
// segment's relative dates against the drain day. The previous cursor's
|
||||
// write time is the nearest proxy for the segment's own date.
|
||||
endOffset < params.history.length
|
||||
? { windowAsOf: currentCursor.updatedAt }
|
||||
: undefined,
|
||||
);
|
||||
|
||||
if (agentResult.touchedTopics.length > 0) {
|
||||
|
|
|
|||
|
|
@ -87,10 +87,13 @@ describe('auto-memory extraction with agent planner', () => {
|
|||
});
|
||||
|
||||
expect(result.touchedTopics).toEqual(['user']);
|
||||
// The cached-tail path passes no window stamp: history stays undefined
|
||||
// and the planner keeps the current-tail prompt wording.
|
||||
expect(runAutoMemoryExtractionByAgent).toHaveBeenCalledWith(
|
||||
mockConfig,
|
||||
projectRoot,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
|
||||
const docs = await scanAutoMemoryTopicDocuments(projectRoot);
|
||||
|
|
|
|||
|
|
@ -171,6 +171,56 @@ describe('runAutoMemoryExtractionByAgent', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('curates degraded placeholder turns out of the pending window', async () => {
|
||||
// The window comes from raw history; the cached tail was curated. Without
|
||||
// the same curation the extractor is told the assistant said
|
||||
// "(request timeout)" and may write a durable memory from it.
|
||||
vi.mocked(runForkedAgent).mockResolvedValue({
|
||||
status: 'completed',
|
||||
finalText: '',
|
||||
filesTouched: [],
|
||||
filesWritten: [],
|
||||
});
|
||||
const pending: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'Remember: I prefer pnpm.' }] },
|
||||
{ role: 'model', parts: [{ text: '(request timeout)' }] },
|
||||
{ role: 'model', parts: [{ text: 'Noted.' }] },
|
||||
];
|
||||
|
||||
await runAutoMemoryExtractionByAgent(mockConfig, '/tmp', pending);
|
||||
|
||||
const extraHistory =
|
||||
vi.mocked(runForkedAgent).mock.calls[0]?.[0].extraHistory;
|
||||
expect(JSON.stringify(extraHistory)).not.toContain('(request timeout)');
|
||||
expect(JSON.stringify(extraHistory)).toContain('Noted.');
|
||||
});
|
||||
|
||||
it('drops the recency and today-anchoring claims for a historical window', async () => {
|
||||
// While the cursor is behind, the window is a historical segment: the
|
||||
// prompt must not claim it is the recent tail nor anchor its relative
|
||||
// dates to today (nothing on this path carries the segment's own date).
|
||||
vi.mocked(runForkedAgent).mockResolvedValue({
|
||||
status: 'completed',
|
||||
finalText: '',
|
||||
filesTouched: [],
|
||||
filesWritten: [],
|
||||
});
|
||||
const pending: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'We hit this yesterday.' }] },
|
||||
{ role: 'model', parts: [{ text: 'Noted.' }] },
|
||||
];
|
||||
|
||||
await runAutoMemoryExtractionByAgent(mockConfig, '/tmp', pending, {
|
||||
windowAsOf: '2026-09-28T10:00:00.000Z',
|
||||
});
|
||||
|
||||
const prompt = vi.mocked(runForkedAgent).mock.calls[0]?.[0].taskPrompt;
|
||||
expect(prompt).not.toContain("Today's date is");
|
||||
expect(prompt).not.toContain('recent conversation history');
|
||||
expect(prompt).toContain('earlier part of the session');
|
||||
expect(prompt).toContain('2026-09-28T10:00:00.000Z');
|
||||
});
|
||||
|
||||
it('strips runtime reminders and hidden reasoning from inherited history', async () => {
|
||||
vi.mocked(getCacheSafeParams).mockReturnValue({
|
||||
generationConfig: {},
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type { Config } from '../config/config.js';
|
|||
import { createDebugLogger } from '../utils/debugLogger.js';
|
||||
import { runForkedAgent, getCacheSafeParams } from '../agents/forkedAgent.js';
|
||||
import { slimCompactionInput } from '../services/compactionInputSlimming.js';
|
||||
import { extractCuratedHistory } from '../core/llm-chat.js';
|
||||
import { buildFunctionResponseParts } from '../tools/agent/fork-subagent.js';
|
||||
import type { Content } from '@google/genai';
|
||||
import {
|
||||
|
|
@ -43,7 +44,7 @@ const debugLogger = createDebugLogger('AUTO_MEMORY_EXTRACTION_AGENT');
|
|||
const EXTRACTION_AGENT_SYSTEM_PROMPT = [
|
||||
'You are now acting as the managed memory extraction subagent for an AI coding assistant.',
|
||||
'',
|
||||
'The recent conversation history is already in your context. Analyze only that recent conversation and use it to update persistent managed memory.',
|
||||
'The conversation history is already in your context. Analyze only that conversation and use it to update persistent managed memory.',
|
||||
'',
|
||||
'Rules:',
|
||||
'- Read existing memory files first to avoid creating duplicates.',
|
||||
|
|
@ -188,25 +189,35 @@ function buildTaskPrompt(
|
|||
userMemoryRoot: string,
|
||||
topicSummaries: string,
|
||||
keywordVocabularySnapshot: string,
|
||||
windowAsOf?: string,
|
||||
): string {
|
||||
return [
|
||||
'Managed memory has TWO directories. Choose which one to write each memory into using the per-type `<scope>` guidance in your system instructions:',
|
||||
`- USER memory (cross-project, durable knowledge about who the user is): \`${userMemoryRoot}\``,
|
||||
`- PROJECT memory (this project only): \`${projectMemoryRoot}\``,
|
||||
'',
|
||||
'Scan the recent conversation history in your context and update durable managed memory in whichever directory each memory belongs.',
|
||||
windowAsOf
|
||||
? 'Scan the conversation history segment in your context and update durable managed memory in whichever directory each memory belongs.'
|
||||
: 'Scan the recent conversation history in your context and update durable managed memory in whichever directory each memory belongs.',
|
||||
'',
|
||||
// Inherited history is scrubbed of `<system-reminder>` blocks above, and
|
||||
// those are the only place a date reaches the model (the startup prelude
|
||||
// and the per-turn refresh). Passing extraHistory also suppresses the
|
||||
// fork's own env bootstrap, so the date has to be stated here or the
|
||||
// "convert relative dates to absolute ones" instruction is unanswerable.
|
||||
`Today's date is ${formatDateForContext()} — use it to turn any relative date in the history into an absolute one before saving.`,
|
||||
// A historical segment (the pending window while the cursor is behind)
|
||||
// must NOT be anchored to today: nothing on this path carries the
|
||||
// window's own date, and `windowAsOf` is the previous cursor's write
|
||||
// time — only a proxy, so the instruction drops the conversion claim
|
||||
// rather than re-anchoring to it.
|
||||
windowAsOf
|
||||
? `This segment is from an earlier part of the session (last processed around ${windowAsOf}) — do not convert its relative dates against today; keep them relative or omit them.`
|
||||
: `Today's date is ${formatDateForContext()} — use it to turn any relative date in the history into an absolute one before saving.`,
|
||||
'',
|
||||
'Available tools in this run: `read_file`, `grep_search`, `glob`, and `write_file`/`edit` for paths inside EITHER managed memory directory above.',
|
||||
'- Do not use any other tools.',
|
||||
'- You have a limited turn budget. `edit` requires a prior `read_file` of the same file, so the efficient strategy is: first issue all reads in parallel for every file you might update; then issue all `write_file`/`edit` calls in parallel. Do not interleave reads and writes across multiple turns.',
|
||||
'- You MUST only use content from the recent conversation history in your context plus the current managed memory files.',
|
||||
'- You MUST only use content from the conversation history in your context plus the current managed memory files.',
|
||||
'- Do not inspect repository code, git history, or unrelated files.',
|
||||
`- Treat files under the top-level \`${AUTO_MEMORY_PINNED_DIRNAME}/\` directory in either managed memory root as protected read-only records. You may read them to avoid duplicates, but never modify, overwrite, rename, merge into, or delete them, and do not intentionally remove their valid entries from \`${AUTO_MEMORY_INDEX_FILENAME}\`.`,
|
||||
'- Prefer updating an existing writable memory file over creating a duplicate. Check both directories for an existing entry before creating a new one.',
|
||||
|
|
@ -300,6 +311,14 @@ export async function runAutoMemoryExtractionByAgent(
|
|||
config: Config,
|
||||
projectRoot: string,
|
||||
history?: Content[],
|
||||
opts?: {
|
||||
/**
|
||||
* Set when `history` is the pending window and the cursor is behind: the
|
||||
* previous cursor's write time, the nearest proxy for the window's own
|
||||
* date. Drops the recency wording and the anchor-to-today date claim.
|
||||
*/
|
||||
windowAsOf?: string;
|
||||
},
|
||||
): Promise<AutoMemoryExtractionExecutionResult> {
|
||||
const cacheSafe = getCacheSafeParams(config.getSessionId());
|
||||
if (!cacheSafe) {
|
||||
|
|
@ -308,9 +327,14 @@ export async function runAutoMemoryExtractionByAgent(
|
|||
'extraction must run after a completed main turn.',
|
||||
);
|
||||
}
|
||||
// The pending window comes from raw history: curate it the way the cached
|
||||
// tail is curated, or degraded placeholder turns (e.g. "(request
|
||||
// timeout)") reach the extractor as something the assistant said.
|
||||
const inputHistory = history
|
||||
? slimCompactionInput(history, config.getEffectiveInputModalities())
|
||||
.slimmedHistory
|
||||
? slimCompactionInput(
|
||||
extractCuratedHistory(history),
|
||||
config.getEffectiveInputModalities(),
|
||||
).slimmedHistory
|
||||
: cacheSafe.history;
|
||||
const extraHistory = buildAgentHistory(inputHistory);
|
||||
|
||||
|
|
@ -338,6 +362,7 @@ export async function runAutoMemoryExtractionByAgent(
|
|||
userMemoryRoot,
|
||||
topicSummaries,
|
||||
keywordVocabularySnapshot,
|
||||
opts?.windowAsOf,
|
||||
),
|
||||
systemPrompt: EXTRACTION_AGENT_SYSTEM_PROMPT,
|
||||
maxTurns: config.getMemoryAgentMaxTurns() ?? 5,
|
||||
|
|
|
|||
|
|
@ -1036,15 +1036,36 @@ export class ToolRegistry {
|
|||
this.reviewedDeferredDeclarations.clear();
|
||||
}
|
||||
|
||||
/** Rebuilds reviews from schemas actually retained in the primary history. */
|
||||
/**
|
||||
* Rebuilds reviews from schemas actually retained in the primary history.
|
||||
*
|
||||
* Replacement is the contract for history-replacement events: when a
|
||||
* compaction or truncation removes the tool_search block entirely, the
|
||||
* model can no longer see the schema, so the review must be forgotten.
|
||||
* The one exception is a block that is still present but unreadable —
|
||||
* model-facing truncation can cut a tool_search response mid-JSON, which
|
||||
* fails the parse without meaning the review never happened: the model
|
||||
* did receive the full schema when the response arrived. Such a name is
|
||||
* recovered from the block's intact head and its in-memory review carries
|
||||
* over; a name absent from every tool_search block in history is still
|
||||
* forgotten.
|
||||
*/
|
||||
syncReviewedDeclarations(history: readonly Content[]): void {
|
||||
const reviewed = new Map<string, string>();
|
||||
const derived = new Map<string, string>();
|
||||
// Names mentioned by a tool_search block, recoverable from the block's
|
||||
// head even when truncation cut the JSON tail.
|
||||
const mentioned = new Set<string>();
|
||||
for (const entry of history) {
|
||||
for (const part of entry.parts ?? []) {
|
||||
const response = part.functionResponse;
|
||||
if (response?.name !== ToolNames.TOOL_SEARCH) continue;
|
||||
const output = response.response?.['output'];
|
||||
if (typeof output !== 'string') continue;
|
||||
for (const mention of output.matchAll(
|
||||
/<function>\s*\{\s*"name"\s*:\s*"([^"]+)"/gs,
|
||||
)) {
|
||||
mentioned.add(mention[1]!);
|
||||
}
|
||||
for (const match of output.matchAll(/<function>(.*?)<\/function>/gs)) {
|
||||
try {
|
||||
const { name, parametersJsonSchema, serverName } = JSON.parse(
|
||||
|
|
@ -1054,10 +1075,10 @@ export class ToolRegistry {
|
|||
const suffix = `\u0000${name}\u0000${JSON.stringify(parametersJsonSchema)}`;
|
||||
const previous = this.reviewedDeferredDeclarations.get(name);
|
||||
if (typeof serverName === 'string') {
|
||||
reviewed.set(name, `${serverName}${suffix}`);
|
||||
derived.set(name, `${serverName}${suffix}`);
|
||||
} else if (previous?.endsWith(suffix)) {
|
||||
// Old transcripts did not serialize the MCP server identity.
|
||||
reviewed.set(name, previous);
|
||||
derived.set(name, previous);
|
||||
} else {
|
||||
// Legacy block without server provenance, and no in-memory
|
||||
// review to re-adopt (fresh process, or a cleared map).
|
||||
|
|
@ -1076,10 +1097,10 @@ export class ToolRegistry {
|
|||
// changed schema must keep refusing.
|
||||
const liveFingerprint = deferredDeclarationFingerprint(live);
|
||||
if (liveFingerprint === `${live.serverName}${suffix}`) {
|
||||
reviewed.set(name, liveFingerprint);
|
||||
derived.set(name, liveFingerprint);
|
||||
}
|
||||
} else {
|
||||
reviewed.set(name, suffix);
|
||||
derived.set(name, suffix);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -1088,7 +1109,16 @@ export class ToolRegistry {
|
|||
}
|
||||
}
|
||||
}
|
||||
this.reviewedDeferredDeclarations = reviewed;
|
||||
for (const name of mentioned) {
|
||||
if (derived.has(name)) continue;
|
||||
// Present but unreadable (truncated mid-block): keep the review the
|
||||
// model genuinely received rather than disarming the tool.
|
||||
const existing = this.reviewedDeferredDeclarations.get(name);
|
||||
if (existing !== undefined) {
|
||||
derived.set(name, existing);
|
||||
}
|
||||
}
|
||||
this.reviewedDeferredDeclarations = derived;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -832,6 +832,105 @@ describe('ToolSearchTool', () => {
|
|||
).resolves.toMatchObject({ tool: mcpTool });
|
||||
});
|
||||
|
||||
it('keeps a reviewed tool armed when its tool_search block is truncated mid-JSON', async () => {
|
||||
// Model-facing truncation can cut a tool_search response inside a
|
||||
// <function> block: the entry stays in history but the JSON no longer
|
||||
// parses. The review did happen — the model received the full schema
|
||||
// when the response arrived — so the tool must stay callable. Red when
|
||||
// the sync goes back to deriving only from parseable blocks.
|
||||
const hidden = new MockTool({
|
||||
name: 'cron_list',
|
||||
shouldDefer: true,
|
||||
// A real schema so the serialized block carries parametersJsonSchema
|
||||
// for the truncation cut to land inside the JSON payload.
|
||||
params: { type: 'object', properties: { target: { type: 'string' } } },
|
||||
});
|
||||
registry.registerTool(hidden);
|
||||
const history = await searchHistory(hidden.name);
|
||||
registry.syncReviewedDeclarations(history);
|
||||
expect(registry.getReviewedDeclaration(hidden.name)).toBe(
|
||||
deferredDeclarationFingerprint(hidden),
|
||||
);
|
||||
|
||||
// Cut the response output inside the JSON payload: the <function> head
|
||||
// (and the tool's name) survives, the schema tail is gone.
|
||||
const truncated = history.map((entry) => ({
|
||||
...entry,
|
||||
parts: (entry.parts ?? []).map((part) => {
|
||||
if (!part.functionResponse) return part;
|
||||
const output = part.functionResponse.response?.['output'];
|
||||
if (typeof output !== 'string') return part;
|
||||
const cut = output.indexOf('"parametersJsonSchema"');
|
||||
return {
|
||||
...part,
|
||||
functionResponse: {
|
||||
...part.functionResponse,
|
||||
response: { output: output.slice(0, cut) },
|
||||
},
|
||||
};
|
||||
}),
|
||||
}));
|
||||
registry.syncReviewedDeclarations(truncated);
|
||||
|
||||
expect(registry.getReviewedDeclaration(hidden.name)).toBe(
|
||||
deferredDeclarationFingerprint(hidden),
|
||||
);
|
||||
await expect(
|
||||
resolveDeferredToolCall(registry, {
|
||||
name: hidden.name,
|
||||
arguments: {},
|
||||
}),
|
||||
).resolves.toMatchObject({ tool: hidden });
|
||||
});
|
||||
|
||||
it('still forgets a review when its tool_search block leaves the history entirely', async () => {
|
||||
// The carry-over is for present-but-unreadable evidence only: an empty
|
||||
// retained window means the model can no longer see the schema at all,
|
||||
// so the review is forgotten (the truncate/orphan/clear pins' contract).
|
||||
const hidden = new MockTool({ name: 'cron_list', shouldDefer: true });
|
||||
registry.registerTool(hidden);
|
||||
registry.syncReviewedDeclarations(await searchHistory(hidden.name));
|
||||
expect(registry.getReviewedDeclaration(hidden.name)).toBeDefined();
|
||||
|
||||
registry.syncReviewedDeclarations([]);
|
||||
|
||||
expect(registry.getReviewedDeclaration(hidden.name)).toBeUndefined();
|
||||
await expect(
|
||||
resolveDeferredToolCall(registry, {
|
||||
name: hidden.name,
|
||||
arguments: {},
|
||||
}),
|
||||
).resolves.toMatchObject({ errorType: ToolErrorType.INVALID_TOOL_PARAMS });
|
||||
});
|
||||
|
||||
it('overwrites a review when a fresher tool_search block re-derives one', async () => {
|
||||
// The carry-over is not accumulation of stale entries: a new review of
|
||||
// the same name re-derives and replaces the old fingerprint.
|
||||
const hidden = new MockTool({
|
||||
name: 'cron_list',
|
||||
shouldDefer: true,
|
||||
});
|
||||
registry.registerTool(hidden);
|
||||
registry.recordReviewedDeclaration(hidden);
|
||||
const stale = registry.getReviewedDeclaration(hidden.name);
|
||||
|
||||
const changed = new MockTool({
|
||||
name: 'cron_list',
|
||||
shouldDefer: true,
|
||||
description: 'a changed schema',
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: { extra: { type: 'string' } },
|
||||
},
|
||||
});
|
||||
registry.registerTool(changed);
|
||||
registry.syncReviewedDeclarations(await searchHistory(changed.name));
|
||||
|
||||
const fresh = registry.getReviewedDeclaration(changed.name);
|
||||
expect(fresh).toBe(deferredDeclarationFingerprint(changed));
|
||||
expect(fresh).not.toBe(stale);
|
||||
});
|
||||
|
||||
it('leaves a legacy serverName-less block unreviewed while its tool is not registered', () => {
|
||||
// Progressive MCP discovery registers tools after a resumed chat's first
|
||||
// sync. Recording the bare suffix here would refuse every later call as
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue