Compare commits

...
Author SHA1 Message Date
Robin Newhouse 13e196bf79 fix(sdk): skip exact repeated run commands 2026-06-12 23:32:58 -07:00
2 changed files with 320 additions and 3 deletions
@@ -7,6 +7,7 @@ import {
import {
createBashTool,
createDefaultTools,
createEditorTool,
createReadFilesTool,
createSearchTool,
createSkillsTool,
@@ -700,6 +701,170 @@ describe("default run_commands tool", () => {
]);
});
it("skips exact repeated commands in the same session", async () => {
const execute = vi.fn(
async (command: string | { command: string }) =>
`ran:${typeof command === "string" ? command : command.command}`,
);
const tool = createBashTool(execute);
const context = {
sessionId: "session-repeated-command",
agentId: "agent-1",
conversationId: "conv-1",
iteration: 1,
};
await tool.execute({ commands: ["python3 --version"] }, context);
const result = await tool.execute(
{ commands: ["python3 --version"] },
{ ...context, iteration: 2 },
);
expect(result).toEqual([
expect.objectContaining({
query: "python3 --version",
success: true,
}),
]);
expect(execute).toHaveBeenCalledTimes(1);
expect(result[0]?.result).toContain("Skipped exact repeated command");
expect(result[0]?.result).not.toContain("ran:python3 --version");
});
it("allows an exact command rerun after a successful file edit", async () => {
const executeBash = vi.fn(
async (command: string | { command: string }) =>
`ran:${typeof command === "string" ? command : command.command}`,
);
const executeEdit = vi.fn(async () => "patched");
const bashTool = createBashTool(executeBash);
const editorTool = createEditorTool(executeEdit);
const context = {
sessionId: "session-repeat-after-edit",
agentId: "agent-1",
conversationId: "conv-1",
iteration: 1,
};
await bashTool.execute({ commands: ["npm test"] }, context);
await editorTool.execute(
{
path: "/tmp/example.ts",
old_text: "before",
new_text: "after",
},
{ ...context, iteration: 2 },
);
const result = await bashTool.execute(
{ commands: ["npm test"] },
{ ...context, iteration: 3 },
);
expect(executeBash).toHaveBeenCalledTimes(2);
expect(result).toEqual([
expect.objectContaining({
query: "npm test",
result: expect.stringContaining("ran:npm test"),
success: true,
}),
]);
});
it("allows an exact command rerun after a successful shell mutation", async () => {
const execute = vi.fn(
async (command: string | { command: string }) =>
`ran:${typeof command === "string" ? command : command.command}`,
);
const tool = createBashTool(execute);
const context = {
sessionId: "session-repeat-after-shell-mutation",
agentId: "agent-1",
conversationId: "conv-1",
iteration: 1,
};
await tool.execute({ commands: ["npm test"] }, context);
await tool.execute(
{ commands: ["sed -i 's/before/after/' src/example.ts"] },
{ ...context, iteration: 2 },
);
const result = await tool.execute(
{ commands: ["npm test"] },
{ ...context, iteration: 3 },
);
expect(execute).toHaveBeenCalledTimes(3);
expect(result).toEqual([
expect.objectContaining({
query: "npm test",
result: expect.stringContaining("ran:npm test"),
success: true,
}),
]);
});
it("does not treat stderr fd duplication as a workspace mutation", async () => {
const execute = vi.fn(
async (command: string | { command: string }) =>
`ran:${typeof command === "string" ? command : command.command}`,
);
const tool = createBashTool(execute);
const context = {
sessionId: "session-stderr-redirection-is-not-mutation",
agentId: "agent-1",
conversationId: "conv-1",
iteration: 1,
};
await tool.execute({ commands: ["pmars -b warrior.red 2>&1"] }, context);
await tool.execute(
{ commands: ["other-check 2>&1"] },
{ ...context, iteration: 2 },
);
const result = await tool.execute(
{ commands: ["pmars -b warrior.red 2>&1"] },
{ ...context, iteration: 3 },
);
expect(execute).toHaveBeenCalledTimes(2);
expect(result).toEqual([
expect.objectContaining({
query: "pmars -b warrior.red 2>&1",
success: true,
}),
]);
expect(result[0]?.result).toContain("Skipped exact repeated command");
});
it("skips an exact repeated shell mutation command", async () => {
const execute = vi.fn(
async (command: string | { command: string }) =>
`ran:${typeof command === "string" ? command : command.command}`,
);
const tool = createBashTool(execute);
const context = {
sessionId: "session-repeated-shell-mutation",
agentId: "agent-1",
conversationId: "conv-1",
iteration: 1,
};
await tool.execute({ commands: ["rm -f /app/povray-2.2.tgz"] }, context);
const result = await tool.execute(
{ commands: ["rm -f /app/povray-2.2.tgz"] },
{ ...context, iteration: 2 },
);
expect(execute).toHaveBeenCalledTimes(1);
expect(result).toEqual([
expect.objectContaining({
query: "rm -f /app/povray-2.2.tgz",
success: true,
}),
]);
expect(result[0]?.result).toContain("Skipped exact repeated command");
});
it("truncates long command echoes in tool results without affecting execution", async () => {
const execute = vi.fn(
async (command: string | { command: string }) =>
@@ -110,6 +110,142 @@ function captureRunCommandsTimeoutFromContext(
});
}
const MAX_TOOL_HISTORY_SCOPES = 200;
const workspaceMutationRevisionByScope = new Map<string, number>();
const previousRunCommandResultsByScope = new Map<
string,
Map<
string,
{
count: number;
revision: number;
query: string;
success: boolean;
error?: string;
}
>
>();
type PreviousRunCommandResult = {
count: number;
revision: number;
query: string;
success: boolean;
error?: string;
};
function getToolHistoryScopeKey(context: AgentToolContext): string | undefined {
if (context.runId) {
return `run:${context.runId}`;
}
if (context.sessionId) {
return `session:${context.sessionId}`;
}
return undefined;
}
function getScopeMutationRevision(scope: string | undefined): number {
if (!scope) {
return 0;
}
return workspaceMutationRevisionByScope.get(scope) ?? 0;
}
function bumpScopeMutationRevisionForScope(scope: string | undefined): void {
if (!scope) {
return;
}
workspaceMutationRevisionByScope.set(
scope,
getScopeMutationRevision(scope) + 1,
);
}
function bumpScopeMutationRevision(context: AgentToolContext): void {
bumpScopeMutationRevisionForScope(getToolHistoryScopeKey(context));
}
function getScopedCommandResultMap(
scope: string | undefined,
): Map<string, PreviousRunCommandResult> | undefined {
if (!scope) {
return undefined;
}
if (
!previousRunCommandResultsByScope.has(scope) &&
previousRunCommandResultsByScope.size >= MAX_TOOL_HISTORY_SCOPES
) {
const oldestScope = previousRunCommandResultsByScope.keys().next().value;
if (oldestScope) {
previousRunCommandResultsByScope.delete(oldestScope);
workspaceMutationRevisionByScope.delete(oldestScope);
}
}
let commands = previousRunCommandResultsByScope.get(scope);
if (!commands) {
commands = new Map();
previousRunCommandResultsByScope.set(scope, commands);
}
return commands;
}
function getRepeatedCommandSkip(
scope: string | undefined,
command: string,
): ToolOperationResult | undefined {
const commandResults = getScopedCommandResultMap(scope);
const previous = commandResults?.get(command);
if (!previous || previous.revision !== getScopeMutationRevision(scope)) {
return undefined;
}
previous.count += 1;
const skippedResult: ToolOperationResult = {
query: previous.query,
result:
`Tool guidance: Skipped exact repeated command (already run ${previous.count} times since the last file edit). ` +
"Reuse the previous result already in the conversation, edit files before rerunning tests, or run a different targeted command.",
success: previous.success,
};
if (!previous.success && previous.error) {
skippedResult.error = previous.error;
}
return skippedResult;
}
function rememberCommandResult(
scope: string | undefined,
command: string,
result: ToolOperationResult,
): void {
const commandResults = getScopedCommandResultMap(scope);
if (!commandResults) {
return;
}
commandResults.set(command, {
count: 1,
revision: getScopeMutationRevision(scope),
query: result.query,
success: result.success,
error: result.error,
});
}
function commandLikelyMutatesWorkspace(command: string): boolean {
return (
/(?:^|[;&|({]\s*)(?:rm|mv|cp|mkdir|touch|chmod|chown|ln|install)\b/.test(
command,
) ||
/(?:^|[;&|({]\s*)(?:git\s+(?:apply|checkout|switch|restore|clean|reset))\b/.test(
command,
) ||
/(?:^|[;&|({]\s*)(?:sed\s+-i|perl\s+-pi)\b/.test(command) ||
/(?:^|[;&|({]\s*)(?:(?:npm|pnpm|yarn|bun)\s+install)\b/.test(command) ||
/(?:^|[;&|({]\s*)(?:tar\s+[\s\S]*\b-x|unzip)\b/.test(command) ||
/(?:^|[;&|({]\s*)(?:curl[\s\S]*\s-o\s|wget[\s\S]*\s-O\s)/.test(command) ||
/(?:^|[\s;|&({])(?:\d*)>>?\s*(?!&\d\b|\/dev\/null\b)\S/.test(command)
);
}
// =============================================================================
// AgentTool Factory Functions
// =============================================================================
@@ -298,6 +434,7 @@ export function createBashTool(
maxRetries: 0,
execute: async (input, context) => {
const validate = validateWithZod(RunCommandsInputUnionSchema, input);
const scope = getToolHistoryScopeKey(context);
let commands: string[];
if (typeof validate === "string") {
commands = [validate];
@@ -317,17 +454,26 @@ export function createBashTool(
commands.map(async (command: string): Promise<ToolOperationResult> => {
const startedAt = Date.now();
const query = formatRunCommandQueryPreview(command);
const skippedRepeat = getRepeatedCommandSkip(scope, command);
if (skippedRepeat) {
return skippedRepeat;
}
try {
const output = await withTimeout(
executor(command, cwd, context),
timeoutMs,
`Command timed out after ${timeoutMs}ms`,
);
return {
const result = {
query,
result: output,
success: true,
};
if (commandLikelyMutatesWorkspace(command)) {
bumpScopeMutationRevisionForScope(scope);
}
rememberCommandResult(scope, command, result);
return result;
} catch (error) {
if (error instanceof TimeoutError) {
captureRunCommandsTimeoutFromContext(context, {
@@ -338,20 +484,24 @@ export function createBashTool(
});
}
if (error instanceof CommandExitError) {
return {
const result = {
query,
result: error.output,
error: error.message,
success: false,
};
rememberCommandResult(scope, command, result);
return result;
}
const msg = formatError(error);
return {
const result = {
query,
result: "",
error: `Command failed: ${msg}`,
success: false,
};
rememberCommandResult(scope, command, result);
return result;
}
}),
);
@@ -559,6 +709,7 @@ export function createApplyPatchTool(
`apply_patch timed out after ${timeoutMs}ms`,
);
bumpScopeMutationRevision(context);
return {
query: "apply_patch",
result,
@@ -622,6 +773,7 @@ export function createEditorTool(
`Editor operation timed out after ${timeoutMs}ms`,
);
bumpScopeMutationRevision(context);
return {
query: `${operation}:${validatedInput.path}`,
result,