diff --git a/src/core/tools/tool-idempotent-replay.ts b/src/core/tools/tool-idempotent-replay.ts index e03ed86c..7988f40d 100644 --- a/src/core/tools/tool-idempotent-replay.ts +++ b/src/core/tools/tool-idempotent-replay.ts @@ -37,6 +37,9 @@ export function findReplayableToolResultContent( sideEffectClass?: ToolSideEffectClass ): string | null { if (isToolAssumedMutating(toolName, sideEffectClass)) return null; + // Readonly describes side effects, not freshness. External observations and + // MCP search catalogs may change without a local write; ask the server again. + if (toolName.startsWith('mcp__')) return null; const want = stableSerializeToolInput(input); const wantedPathKey = toolPathKey(toolName, input); diff --git a/test/mcp-read-freshness.spec.mjs b/test/mcp-read-freshness.spec.mjs new file mode 100644 index 00000000..e67eb505 --- /dev/null +++ b/test/mcp-read-freshness.spec.mjs @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import test from 'node:test'; +import { MossAgent } from '../dist/core/agent/moss-agent.js'; +import { InMemorySessionStore } from '../dist/core/session/session.js'; + +test('two Agent turns execute the same readonly MCP query and observe its changed backend state', async () => { + let requests = 0; + let completions = 0; + const observed = []; + const server = createServer((_req, response) => { + response.setHeader('content-type', 'application/json'); + response.end(JSON.stringify({ status: ++requests === 1 ? 'running' : 'completed' })); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const url = `http://127.0.0.1:${server.address().port}/status`; + const name = 'mcp__fixture__training_status'; + const agent = new MossAgent({ + workspaceDir: process.cwd(), + llmProvider: { + id: 'freshness-fixture', + capabilities: { streaming: false }, + async complete() { + return ++completions % 2 + ? { + stopReason: 'tool_use', + content: [ + { + type: 'tool_use', + id: `read-${completions}`, + name, + input: { runId: 'selected-run' }, + }, + ], + } + : { stopReason: 'end_turn', content: [{ type: 'text', text: 'checked' }] }; + }, + }, + model: 'fixture', + sessionStore: new InMemorySessionStore(), + domainPrompt: false, + includeAgentBehaviorPrompt: false, + enableSteering: false, + enableCompaction: false, + enableFollowUpGuard: false, + maxAgentTurns: 4, + hooks: { onBeforeToolExec: async () => ({ approved: true }) }, + }); + agent.tools.register({ + name, + description: 'Read current status from the owned fixture', + inputSchema: { type: 'object', properties: { runId: { type: 'string' } } }, + metadata: { sideEffectClass: 'readonly' }, + async execute() { + const result = await (await fetch(url)).json(); + observed.push(result.status); + return JSON.stringify(result); + }, + }); + try { + await agent.chat('fixture-session', 'Check current status'); + await agent.chat('fixture-session', 'Check the same status again'); + assert.equal(requests, 2); + assert.deepEqual(observed, ['running', 'completed']); + } finally { + await agent.close(); + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + } +}); diff --git a/test/tool-idempotent-replay.spec.mjs b/test/tool-idempotent-replay.spec.mjs index 83d30f04..5952d8e5 100644 --- a/test/tool-idempotent-replay.spec.mjs +++ b/test/tool-idempotent-replay.spec.mjs @@ -22,6 +22,35 @@ function toolResult(id, content) { }; } +test('never replays external MCP observations, even when declared readonly', () => { + for (const name of [ + 'mcp__duck-lab__rdk_training_status', + 'mcp__another-server__health', + 'mcp__fixture__search', + ]) { + const input = { runId: 'same-run' }; + const messages = [assistantToolUse('query-1', name, input), toolResult('query-1', 'running')]; + assert.equal(findReplayableToolResultContent(messages, name, input, 32, 'readonly'), null); + } +}); + +test('retains stable local read reuse and never replays mutating tool results', () => { + const input = { path: 'example.txt' }; + const messages = [ + assistantToolUse('read-1', 'read_file', input), + toolResult('read-1', 'file contents'), + ]; + assert.equal( + findReplayableToolResultContent(messages, 'read_file', input, 32, 'readonly'), + 'file contents' + ); + assert.equal( + findReplayableToolResultContent(messages, 'read_file', input, 32, 'local_write'), + null + ); + assert.equal(findReplayableToolResultContent(messages, 'read_file', input), null); +}); + test('does not replay a file read after the same path was mutated', () => { const messages = [ assistantToolUse('read-1', 'read_file', { path: 'src/example.ts' }),