fix(startup): clear Letta Code tool rules on boot (#1405)

Co-authored-by: Jin Peng <jinjpeng@gmail.com>
Co-authored-by: Letta Code <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-03-16 12:53:17 -07:00
committed by GitHub
parent 380c7e1369
commit 6c7a2efdbb
5 changed files with 188 additions and 2 deletions

View File

@@ -101,6 +101,7 @@ import {
registerExternalTools,
setExternalToolExecutor,
} from "./tools/manager";
import { clearPersistedClientToolRules } from "./tools/toolset";
import type {
AutoApprovalMessage,
BootstrapSessionStateRequest,
@@ -119,7 +120,7 @@ import type {
StreamEvent,
SystemInitMessage,
} from "./types/protocol";
import { debugLog } from "./utils/debug";
import { debugLog, debugWarn } from "./utils/debug";
import {
markMilestone,
measureSinceMilestone,
@@ -1042,6 +1043,31 @@ export async function handleHeadlessCommand(
}
}
const startupAgentId = agent.id;
void clearPersistedClientToolRules(startupAgentId)
.then((cleanup) => {
if (cleanup) {
const count = cleanup.removedToolNames.length;
const names = cleanup.removedToolNames.join(", ");
debugLog(
"headless startup",
`Cleared ${count} persisted client tool rule${count === 1 ? "" : "s"} for ${startupAgentId}${count > 0 ? `: ${names}` : ""}`,
);
return;
}
debugLog(
"headless startup",
`No persisted client tool rules to clear for ${startupAgentId}`,
);
})
.catch((error) => {
debugWarn(
"headless startup",
`Failed to clear persisted client tool rules for ${startupAgentId}: ${error instanceof Error ? error.message : String(error)}`,
);
});
try {
effectiveReflectionSettings = await applyReflectionOverrides(
agent.id,

View File

@@ -48,7 +48,8 @@ import { settingsManager } from "./settings-manager";
import { startStartupAutoUpdateCheck } from "./startup-auto-update";
import { telemetry } from "./telemetry";
import { loadTools } from "./tools/manager";
import { debugLog } from "./utils/debug";
import { clearPersistedClientToolRules } from "./tools/toolset";
import { debugLog, debugWarn } from "./utils/debug";
import { markMilestone } from "./utils/timing";
// Stable empty array constants to prevent new references on every render
@@ -1793,6 +1794,31 @@ async function main(): Promise<void> {
}
}
const startupAgentId = agent.id;
void clearPersistedClientToolRules(startupAgentId)
.then((cleanup) => {
if (cleanup) {
const count = cleanup.removedToolNames.length;
const names = cleanup.removedToolNames.join(", ");
debugLog(
"startup",
`Cleared ${count} persisted client tool rule${count === 1 ? "" : "s"} for ${startupAgentId}${count > 0 ? `: ${names}` : ""}`,
);
return;
}
debugLog(
"startup",
`No persisted client tool rules to clear for ${startupAgentId}`,
);
})
.catch((error) => {
debugWarn(
"startup",
`Failed to clear persisted client tool rules for ${startupAgentId}: ${error instanceof Error ? error.message : String(error)}`,
);
});
// Handle conversation: either resume existing or create new
// Using definite assignment assertion - all branches below either set this or exit/throw
let conversationIdToUse!: string;

View File

@@ -0,0 +1,85 @@
import { beforeEach, describe, expect, mock, test } from "bun:test";
const retrieveMock = mock((_agentId: string, _opts?: Record<string, unknown>) =>
Promise.resolve({
tags: ["origin:letta-code"],
tool_rules: [
{ type: "requires_approval", tool_name: "Bash" },
{ type: "requires_approval", tool_name: "Task" },
{ type: "requires_approval", tool_name: "web_search" },
{ type: "continue", tool_name: "fetch_webpage" },
],
}),
);
const updateMock = mock((_agentId: string, _payload: Record<string, unknown>) =>
Promise.resolve({}),
);
const mockGetClient = mock(() =>
Promise.resolve({
agents: {
retrieve: retrieveMock,
update: updateMock,
},
}),
);
mock.module("../../agent/client", () => ({
getClient: mockGetClient,
getServerUrl: () => "http://localhost:8283",
}));
const { clearPersistedClientToolRules, shouldClearPersistedToolRules } =
await import("../../tools/toolset");
describe("client tool rule cleanup", () => {
beforeEach(() => {
retrieveMock.mockClear();
updateMock.mockClear();
mockGetClient.mockClear();
});
test("marks Letta Code agents with any persisted tool rules for cleanup", () => {
expect(
shouldClearPersistedToolRules({
tags: ["origin:letta-code"],
tool_rules: [{ type: "requires_approval", tool_name: "Bash" }],
}),
).toBe(true);
});
test("clears all tool rules for Letta Code agents on startup", async () => {
const result = await clearPersistedClientToolRules("agent-123");
expect(result).toEqual({
removedToolNames: ["Bash", "Task", "web_search", "fetch_webpage"],
});
expect(updateMock).toHaveBeenCalledTimes(1);
expect(updateMock.mock.calls[0]?.[1]).toEqual({
tool_rules: [],
});
});
test("skips update when there are no persisted tool rules", async () => {
retrieveMock.mockResolvedValueOnce({
tags: ["origin:letta-code"],
tool_rules: [],
});
const result = await clearPersistedClientToolRules("agent-123");
expect(result).toBeNull();
expect(updateMock).not.toHaveBeenCalled();
});
test("skips update for non-Letta Code agents", async () => {
retrieveMock.mockResolvedValueOnce({
tags: ["some-other-tag"],
tool_rules: [{ type: "requires_approval", tool_name: "web_search" }],
});
const result = await clearPersistedClientToolRules("agent-123");
expect(result).toBeNull();
expect(updateMock).not.toHaveBeenCalled();
});
});

View File

@@ -35,6 +35,7 @@ const mockGetClient = mock(() =>
mock.module("../../agent/client", () => ({
getClient: mockGetClient,
getServerUrl: () => "http://localhost:8283",
}));
const { detachMemoryTools } = await import("../../tools/toolset");

View File

@@ -1,3 +1,4 @@
import type { AgentState } from "@letta-ai/letta-client/resources/agents/agents";
import { getClient } from "../agent/client";
import { resolveModel } from "../agent/model";
import { toolFilter } from "./filter";
@@ -219,6 +220,53 @@ export async function reattachMemoryTool(
}
}
type PersistedToolRule = NonNullable<AgentState["tool_rules"]>[number];
interface AgentWithToolsAndRules {
tags?: string[] | null;
tool_rules?: PersistedToolRule[];
}
export function shouldClearPersistedToolRules(
agent: AgentWithToolsAndRules,
): boolean {
return (
agent.tags?.includes("origin:letta-code") === true &&
(agent.tool_rules?.length ?? 0) > 0
);
}
export async function clearPersistedClientToolRules(
agentId: string,
): Promise<{ removedToolNames: string[] } | null> {
const client = await getClient();
try {
const agentWithTools = (await client.agents.retrieve(agentId, {
include: ["agent.tools"],
})) as AgentWithToolsAndRules;
if (!shouldClearPersistedToolRules(agentWithTools)) {
return null;
}
const existingRules = agentWithTools.tool_rules || [];
await client.agents.update(agentId, {
tool_rules: [],
});
return {
removedToolNames: existingRules
.map((rule) => rule.tool_name)
.filter((name): name is string => typeof name === "string"),
};
} catch (err) {
console.warn(
`Warning: Failed to clear persisted client tool rules: ${err instanceof Error ? err.message : String(err)}`,
);
return null;
}
}
/**
* Force switch to a specific toolset regardless of model.
*