Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/core/tools/tool-idempotent-replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
70 changes: 70 additions & 0 deletions test/mcp-read-freshness.spec.mjs
Original file line number Diff line number Diff line change
@@ -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));
}
});
29 changes: 29 additions & 0 deletions test/tool-idempotent-replay.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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' }),
Expand Down
Loading