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:
@@ -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,
|
||||
|
||||
28
src/index.ts
28
src/index.ts
@@ -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;
|
||||
|
||||
85
src/tests/tools/toolset-client-tool-rule-cleanup.test.ts
Normal file
85
src/tests/tools/toolset-client-tool-rule-cleanup.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,7 @@ const mockGetClient = mock(() =>
|
||||
|
||||
mock.module("../../agent/client", () => ({
|
||||
getClient: mockGetClient,
|
||||
getServerUrl: () => "http://localhost:8283",
|
||||
}));
|
||||
|
||||
const { detachMemoryTools } = await import("../../tools/toolset");
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user