Add /link and /unlink commands for managing agent tools (#59)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
47
examples/send-image-simple.ts
Normal file
47
examples/send-image-simple.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* Simplest possible example: Send an image to a Letta agent with streaming
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { sendMessageStream } from "../src/agent/message";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const agentId = "agent-YOUR-AGENT-ID"; // Replace with your agent ID
|
||||||
|
const imagePath = "./screenshot.png"; // Replace with your image path
|
||||||
|
|
||||||
|
// 1. Read and encode image
|
||||||
|
const imageData = readFileSync(imagePath).toString("base64");
|
||||||
|
|
||||||
|
// 2. Send message with streaming
|
||||||
|
const stream = await sendMessageStream(agentId, [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "What's in this image?",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "image",
|
||||||
|
source: {
|
||||||
|
type: "base64",
|
||||||
|
mediaType: "image/png",
|
||||||
|
data: imageData,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 3. Print streaming response
|
||||||
|
console.log("Agent response:");
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
if (chunk.messageType === "assistant_message") {
|
||||||
|
process.stdout.write(chunk.content || "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
81
examples/send-image.ts
Normal file
81
examples/send-image.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* Minimal example: Send an image to a Letta agent
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* bun examples/send-image.ts <agent-id> <image-path>
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* bun examples/send-image.ts agent-123abc screenshot.png
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { getClient } from "../src/agent/client";
|
||||||
|
import { sendMessageStream } from "../src/agent/message";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const agentId = process.argv[2];
|
||||||
|
const imagePath = process.argv[3];
|
||||||
|
|
||||||
|
if (!agentId || !imagePath) {
|
||||||
|
console.error("Usage: bun send-image.ts <agent-id> <image-path>");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Read image file and convert to base64
|
||||||
|
const imageBuffer = readFileSync(imagePath);
|
||||||
|
const base64Data = imageBuffer.toString("base64");
|
||||||
|
|
||||||
|
// Determine media type from file extension
|
||||||
|
const ext = imagePath.toLowerCase();
|
||||||
|
const mediaType = ext.endsWith(".png")
|
||||||
|
? "image/png"
|
||||||
|
: ext.endsWith(".jpg") || ext.endsWith(".jpeg")
|
||||||
|
? "image/jpeg"
|
||||||
|
: ext.endsWith(".gif")
|
||||||
|
? "image/gif"
|
||||||
|
: ext.endsWith(".webp")
|
||||||
|
? "image/webp"
|
||||||
|
: "image/jpeg"; // default
|
||||||
|
|
||||||
|
console.log(`Sending ${imagePath} to agent ${agentId}...`);
|
||||||
|
|
||||||
|
// Step 2: Create message content with text and image
|
||||||
|
const messageContent = [
|
||||||
|
{ type: "text" as const, text: "What do you see in this image?" },
|
||||||
|
{
|
||||||
|
type: "image" as const,
|
||||||
|
source: {
|
||||||
|
type: "base64" as const,
|
||||||
|
mediaType,
|
||||||
|
data: base64Data,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Step 3: Send message to agent
|
||||||
|
const stream = await sendMessageStream(agentId, [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: messageContent,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Step 4: Process the streaming response
|
||||||
|
console.log("\nAgent response:");
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
if (chunk.messageType === "assistant_message") {
|
||||||
|
process.stdout.write(chunk.content || "");
|
||||||
|
} else if (chunk.messageType === "reasoning_message") {
|
||||||
|
// Optionally show internal monologue
|
||||||
|
// console.log(`[thinking] ${chunk.reasoning}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("Error:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
// Utilities for modifying agent configuration
|
// Utilities for modifying agent configuration
|
||||||
|
|
||||||
import type { LlmConfig } from "@letta-ai/letta-client/resources/models/models";
|
import type { LlmConfig } from "@letta-ai/letta-client/resources/models/models";
|
||||||
|
import { getToolNames } from "../tools/manager";
|
||||||
import { getClient } from "./client";
|
import { getClient } from "./client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,3 +45,149 @@ export async function updateAgentLLMConfig(
|
|||||||
|
|
||||||
return finalConfig;
|
return finalConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LinkResult {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
addedCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnlinkResult {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
removedCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach all Letta Code tools to an agent.
|
||||||
|
*
|
||||||
|
* @param agentId - The agent ID
|
||||||
|
* @returns Result with success status and message
|
||||||
|
*/
|
||||||
|
export async function linkToolsToAgent(agentId: string): Promise<LinkResult> {
|
||||||
|
try {
|
||||||
|
const client = await getClient();
|
||||||
|
|
||||||
|
// Get ALL agent tools from agent state
|
||||||
|
const agent = await client.agents.retrieve(agentId);
|
||||||
|
const currentTools = agent.tools || [];
|
||||||
|
const currentToolIds = currentTools
|
||||||
|
.map((t) => t.id)
|
||||||
|
.filter((id): id is string => typeof id === "string");
|
||||||
|
const currentToolNames = new Set(
|
||||||
|
currentTools
|
||||||
|
.map((t) => t.name)
|
||||||
|
.filter((name): name is string => typeof name === "string"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get Letta Code tool names
|
||||||
|
const lettaCodeToolNames = getToolNames();
|
||||||
|
|
||||||
|
// Find tools to add (tools that aren't already attached)
|
||||||
|
const toolsToAdd = lettaCodeToolNames.filter(
|
||||||
|
(name) => !currentToolNames.has(name),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (toolsToAdd.length === 0) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "All Letta Code tools already attached",
|
||||||
|
addedCount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up tool IDs from global tool list
|
||||||
|
const toolsToAddIds: string[] = [];
|
||||||
|
for (const toolName of toolsToAdd) {
|
||||||
|
const tools = await client.tools.list({ name: toolName });
|
||||||
|
const tool = tools[0];
|
||||||
|
if (tool?.id) {
|
||||||
|
toolsToAddIds.push(tool.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine current tools with new tools
|
||||||
|
const newToolIds = [...currentToolIds, ...toolsToAddIds];
|
||||||
|
|
||||||
|
// Get current tool_rules and add requires_approval rules for new tools
|
||||||
|
const currentToolRules = agent.tool_rules || [];
|
||||||
|
const newToolRules = [
|
||||||
|
...currentToolRules,
|
||||||
|
...toolsToAdd.map((toolName) => ({
|
||||||
|
tool_name: toolName,
|
||||||
|
type: "requires_approval" as const,
|
||||||
|
prompt_template: null,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
await client.agents.modify(agentId, {
|
||||||
|
tool_ids: newToolIds,
|
||||||
|
tool_rules: newToolRules,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Attached ${toolsToAddIds.length} Letta Code tool(s) to agent`,
|
||||||
|
addedCount: toolsToAddIds.length,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove all Letta Code tools from an agent.
|
||||||
|
*
|
||||||
|
* @param agentId - The agent ID
|
||||||
|
* @returns Result with success status and message
|
||||||
|
*/
|
||||||
|
export async function unlinkToolsFromAgent(
|
||||||
|
agentId: string,
|
||||||
|
): Promise<UnlinkResult> {
|
||||||
|
try {
|
||||||
|
const client = await getClient();
|
||||||
|
|
||||||
|
// Get ALL agent tools from agent state (not tools.list which may be incomplete)
|
||||||
|
const agent = await client.agents.retrieve(agentId);
|
||||||
|
const allTools = agent.tools || [];
|
||||||
|
const lettaCodeToolNames = new Set(getToolNames());
|
||||||
|
|
||||||
|
// Filter out Letta Code tools, keep everything else
|
||||||
|
const remainingTools = allTools.filter(
|
||||||
|
(t) => t.name && !lettaCodeToolNames.has(t.name),
|
||||||
|
);
|
||||||
|
const removedCount = allTools.length - remainingTools.length;
|
||||||
|
|
||||||
|
// Extract IDs from remaining tools (filter out any undefined IDs)
|
||||||
|
const remainingToolIds = remainingTools
|
||||||
|
.map((t) => t.id)
|
||||||
|
.filter((id): id is string => typeof id === "string");
|
||||||
|
|
||||||
|
// Remove approval rules for Letta Code tools being unlinked
|
||||||
|
const currentToolRules = agent.tool_rules || [];
|
||||||
|
const remainingToolRules = currentToolRules.filter(
|
||||||
|
(rule) =>
|
||||||
|
rule.type !== "requires_approval" ||
|
||||||
|
!lettaCodeToolNames.has(rule.tool_name),
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.agents.modify(agentId, {
|
||||||
|
tool_ids: remainingToolIds,
|
||||||
|
tool_rules: remainingToolRules,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Removed ${removedCount} Letta Code tool(s) from agent`,
|
||||||
|
removedCount,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { getResumeData } from "../agent/check-approval";
|
import { getResumeData } from "../agent/check-approval";
|
||||||
import { getClient } from "../agent/client";
|
import { getClient } from "../agent/client";
|
||||||
import { sendMessageStream } from "../agent/message";
|
import { sendMessageStream } from "../agent/message";
|
||||||
|
import { linkToolsToAgent, unlinkToolsFromAgent } from "../agent/modify";
|
||||||
import { SessionStats } from "../agent/stats";
|
import { SessionStats } from "../agent/stats";
|
||||||
import type { ApprovalContext } from "../permissions/analyzer";
|
import type { ApprovalContext } from "../permissions/analyzer";
|
||||||
import { permissionMode } from "../permissions/mode";
|
import { permissionMode } from "../permissions/mode";
|
||||||
@@ -111,6 +112,8 @@ export default function App({
|
|||||||
loadingState?:
|
loadingState?:
|
||||||
| "assembling"
|
| "assembling"
|
||||||
| "upserting"
|
| "upserting"
|
||||||
|
| "linking"
|
||||||
|
| "unlinking"
|
||||||
| "initializing"
|
| "initializing"
|
||||||
| "checking"
|
| "checking"
|
||||||
| "ready";
|
| "ready";
|
||||||
@@ -837,6 +840,92 @@ export default function App({
|
|||||||
return { submitted: true };
|
return { submitted: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Special handling for /link command - attach Letta Code tools
|
||||||
|
if (msg.trim() === "/link") {
|
||||||
|
const cmdId = uid("cmd");
|
||||||
|
buffersRef.current.byId.set(cmdId, {
|
||||||
|
kind: "command",
|
||||||
|
id: cmdId,
|
||||||
|
input: msg,
|
||||||
|
output: "Attaching Letta Code tools to agent...",
|
||||||
|
phase: "running",
|
||||||
|
});
|
||||||
|
buffersRef.current.order.push(cmdId);
|
||||||
|
refreshDerived();
|
||||||
|
|
||||||
|
setCommandRunning(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await linkToolsToAgent(agentId);
|
||||||
|
|
||||||
|
buffersRef.current.byId.set(cmdId, {
|
||||||
|
kind: "command",
|
||||||
|
id: cmdId,
|
||||||
|
input: msg,
|
||||||
|
output: result.message,
|
||||||
|
phase: "finished",
|
||||||
|
success: result.success,
|
||||||
|
});
|
||||||
|
refreshDerived();
|
||||||
|
} catch (error) {
|
||||||
|
buffersRef.current.byId.set(cmdId, {
|
||||||
|
kind: "command",
|
||||||
|
id: cmdId,
|
||||||
|
input: msg,
|
||||||
|
output: `Failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
phase: "finished",
|
||||||
|
success: false,
|
||||||
|
});
|
||||||
|
refreshDerived();
|
||||||
|
} finally {
|
||||||
|
setCommandRunning(false);
|
||||||
|
}
|
||||||
|
return { submitted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special handling for /unlink command - remove Letta Code tools
|
||||||
|
if (msg.trim() === "/unlink") {
|
||||||
|
const cmdId = uid("cmd");
|
||||||
|
buffersRef.current.byId.set(cmdId, {
|
||||||
|
kind: "command",
|
||||||
|
id: cmdId,
|
||||||
|
input: msg,
|
||||||
|
output: "Removing Letta Code tools from agent...",
|
||||||
|
phase: "running",
|
||||||
|
});
|
||||||
|
buffersRef.current.order.push(cmdId);
|
||||||
|
refreshDerived();
|
||||||
|
|
||||||
|
setCommandRunning(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await unlinkToolsFromAgent(agentId);
|
||||||
|
|
||||||
|
buffersRef.current.byId.set(cmdId, {
|
||||||
|
kind: "command",
|
||||||
|
id: cmdId,
|
||||||
|
input: msg,
|
||||||
|
output: result.message,
|
||||||
|
phase: "finished",
|
||||||
|
success: result.success,
|
||||||
|
});
|
||||||
|
refreshDerived();
|
||||||
|
} catch (error) {
|
||||||
|
buffersRef.current.byId.set(cmdId, {
|
||||||
|
kind: "command",
|
||||||
|
id: cmdId,
|
||||||
|
input: msg,
|
||||||
|
output: `Failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
phase: "finished",
|
||||||
|
success: false,
|
||||||
|
});
|
||||||
|
refreshDerived();
|
||||||
|
} finally {
|
||||||
|
setCommandRunning(false);
|
||||||
|
}
|
||||||
|
return { submitted: true };
|
||||||
|
}
|
||||||
|
|
||||||
// Immediately add command to transcript with "running" phase
|
// Immediately add command to transcript with "running" phase
|
||||||
const cmdId = uid("cmd");
|
const cmdId = uid("cmd");
|
||||||
buffersRef.current.byId.set(cmdId, {
|
buffersRef.current.byId.set(cmdId, {
|
||||||
|
|||||||
@@ -50,6 +50,20 @@ export const commands: Record<string, Command> = {
|
|||||||
return "Clearing credentials...";
|
return "Clearing credentials...";
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"/link": {
|
||||||
|
desc: "Attach Letta Code tools to current agent",
|
||||||
|
handler: () => {
|
||||||
|
// Handled specially in App.tsx to access agent ID and client
|
||||||
|
return "Attaching tools...";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/unlink": {
|
||||||
|
desc: "Remove Letta Code tools from current agent",
|
||||||
|
handler: () => {
|
||||||
|
// Handled specially in App.tsx to access agent ID and client
|
||||||
|
return "Removing tools...";
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -34,9 +34,10 @@ export function CommandPreview({
|
|||||||
paddingX={1}
|
paddingX={1}
|
||||||
>
|
>
|
||||||
{commandList.map((item) => (
|
{commandList.map((item) => (
|
||||||
<Box key={item.cmd} justifyContent="space-between" width={40}>
|
<Box key={item.cmd}>
|
||||||
<Text>{item.cmd}</Text>
|
<Text>
|
||||||
<Text dimColor>{item.desc}</Text>
|
{item.cmd.padEnd(15)} <Text dimColor>{item.desc}</Text>
|
||||||
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
{showAgentUrl && (
|
{showAgentUrl && (
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { colors } from "./colors";
|
|||||||
type LoadingState =
|
type LoadingState =
|
||||||
| "assembling"
|
| "assembling"
|
||||||
| "upserting"
|
| "upserting"
|
||||||
|
| "linking"
|
||||||
|
| "unlinking"
|
||||||
| "initializing"
|
| "initializing"
|
||||||
| "checking"
|
| "checking"
|
||||||
| "ready";
|
| "ready";
|
||||||
@@ -82,6 +84,12 @@ export function WelcomeScreen({
|
|||||||
if (loadingState === "upserting") {
|
if (loadingState === "upserting") {
|
||||||
return "Upserting tools...";
|
return "Upserting tools...";
|
||||||
}
|
}
|
||||||
|
if (loadingState === "linking") {
|
||||||
|
return "Attaching Letta Code tools...";
|
||||||
|
}
|
||||||
|
if (loadingState === "unlinking") {
|
||||||
|
return "Removing Letta Code tools...";
|
||||||
|
}
|
||||||
if (loadingState === "checking") {
|
if (loadingState === "checking") {
|
||||||
return "Checking for pending approvals...";
|
return "Checking for pending approvals...";
|
||||||
}
|
}
|
||||||
|
|||||||
47
src/index.ts
47
src/index.ts
@@ -91,6 +91,8 @@ async function main() {
|
|||||||
"permission-mode": { type: "string" },
|
"permission-mode": { type: "string" },
|
||||||
yolo: { type: "boolean" },
|
yolo: { type: "boolean" },
|
||||||
"output-format": { type: "string" },
|
"output-format": { type: "string" },
|
||||||
|
link: { type: "boolean" },
|
||||||
|
unlink: { type: "boolean" },
|
||||||
},
|
},
|
||||||
strict: true,
|
strict: true,
|
||||||
allowPositionals: true,
|
allowPositionals: true,
|
||||||
@@ -233,6 +235,21 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle --link and --unlink flags (modify tools before starting session)
|
||||||
|
const shouldLink = values.link as boolean | undefined;
|
||||||
|
const shouldUnlink = values.unlink as boolean | undefined;
|
||||||
|
|
||||||
|
// Validate --link/--unlink flags require --agent
|
||||||
|
if (shouldLink || shouldUnlink) {
|
||||||
|
if (!specifiedAgentId) {
|
||||||
|
console.error(
|
||||||
|
`Error: --${shouldLink ? "link" : "unlink"} requires --agent <id>`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
// Implementation is in InteractiveSession init()
|
||||||
|
}
|
||||||
|
|
||||||
if (isHeadless) {
|
if (isHeadless) {
|
||||||
// For headless mode, load tools synchronously
|
// For headless mode, load tools synchronously
|
||||||
await loadTools();
|
await loadTools();
|
||||||
@@ -263,7 +280,13 @@ async function main() {
|
|||||||
model?: string;
|
model?: string;
|
||||||
}) {
|
}) {
|
||||||
const [loadingState, setLoadingState] = useState<
|
const [loadingState, setLoadingState] = useState<
|
||||||
"assembling" | "upserting" | "initializing" | "checking" | "ready"
|
| "assembling"
|
||||||
|
| "upserting"
|
||||||
|
| "linking"
|
||||||
|
| "unlinking"
|
||||||
|
| "initializing"
|
||||||
|
| "checking"
|
||||||
|
| "ready"
|
||||||
>("assembling");
|
>("assembling");
|
||||||
const [agentId, setAgentId] = useState<string | null>(null);
|
const [agentId, setAgentId] = useState<string | null>(null);
|
||||||
const [agentState, setAgentState] = useState<AgentState | null>(null);
|
const [agentState, setAgentState] = useState<AgentState | null>(null);
|
||||||
@@ -279,6 +302,28 @@ async function main() {
|
|||||||
const client = await getClient();
|
const client = await getClient();
|
||||||
await upsertToolsToServer(client);
|
await upsertToolsToServer(client);
|
||||||
|
|
||||||
|
// Handle --link/--unlink after upserting tools
|
||||||
|
if (shouldLink || shouldUnlink) {
|
||||||
|
if (!agentIdArg) {
|
||||||
|
console.error("Error: --link/--unlink requires --agent <id>");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingState(shouldLink ? "linking" : "unlinking");
|
||||||
|
const { linkToolsToAgent, unlinkToolsFromAgent } = await import(
|
||||||
|
"./agent/modify"
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = shouldLink
|
||||||
|
? await linkToolsToAgent(agentIdArg)
|
||||||
|
: await unlinkToolsFromAgent(agentIdArg);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
console.error(`✗ ${result.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setLoadingState("initializing");
|
setLoadingState("initializing");
|
||||||
const { createAgent } = await import("./agent/create");
|
const { createAgent } = await import("./agent/create");
|
||||||
const { getModelUpdateArgs } = await import("./agent/model");
|
const { getModelUpdateArgs } = await import("./agent/model");
|
||||||
|
|||||||
205
src/tests/agent/link-unlink.test.ts
Normal file
205
src/tests/agent/link-unlink.test.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import { Letta } from "@letta-ai/letta-client";
|
||||||
|
import { linkToolsToAgent, unlinkToolsFromAgent } from "../../agent/modify";
|
||||||
|
import { settingsManager } from "../../settings-manager";
|
||||||
|
import { getToolNames, loadTools } from "../../tools/manager";
|
||||||
|
|
||||||
|
// Skip these integration tests if LETTA_API_KEY is not set
|
||||||
|
const shouldSkip = !process.env.LETTA_API_KEY;
|
||||||
|
const describeOrSkip = shouldSkip ? describe.skip : describe;
|
||||||
|
|
||||||
|
describeOrSkip("Link/Unlink Tools", () => {
|
||||||
|
let client: Letta;
|
||||||
|
let testAgentId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Initialize settings and load tools
|
||||||
|
await settingsManager.initialize();
|
||||||
|
await loadTools();
|
||||||
|
|
||||||
|
// Create a test agent
|
||||||
|
const apiKey = process.env.LETTA_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error("LETTA_API_KEY required for tests");
|
||||||
|
}
|
||||||
|
|
||||||
|
client = new Letta({ apiKey });
|
||||||
|
|
||||||
|
const agent = await client.agents.create({
|
||||||
|
model: "openai/gpt-4o-mini",
|
||||||
|
embedding: "openai/text-embedding-3-small",
|
||||||
|
memory_blocks: [
|
||||||
|
{ label: "human", value: "Test user" },
|
||||||
|
{ label: "persona", value: "Test agent" },
|
||||||
|
],
|
||||||
|
tools: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
testAgentId = agent.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Cleanup: delete test agent
|
||||||
|
if (testAgentId) {
|
||||||
|
try {
|
||||||
|
await client.agents.delete(testAgentId);
|
||||||
|
} catch (_error) {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("linkToolsToAgent attaches all Letta Code tools", async () => {
|
||||||
|
// Reset: ensure tools are not already attached
|
||||||
|
await unlinkToolsFromAgent(testAgentId);
|
||||||
|
|
||||||
|
const result = await linkToolsToAgent(testAgentId);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.addedCount).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Verify tools were attached
|
||||||
|
const agent = await client.agents.retrieve(testAgentId);
|
||||||
|
const toolNames = agent.tools?.map((t) => t.name) || [];
|
||||||
|
const lettaCodeTools = getToolNames();
|
||||||
|
|
||||||
|
for (const toolName of lettaCodeTools) {
|
||||||
|
expect(toolNames).toContain(toolName);
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
test("linkToolsToAgent adds approval rules for all tools", async () => {
|
||||||
|
// First unlink to reset
|
||||||
|
await unlinkToolsFromAgent(testAgentId);
|
||||||
|
|
||||||
|
// Link tools
|
||||||
|
await linkToolsToAgent(testAgentId);
|
||||||
|
|
||||||
|
// Verify approval rules were added
|
||||||
|
const agent = await client.agents.retrieve(testAgentId);
|
||||||
|
const approvalRules = agent.tool_rules?.filter(
|
||||||
|
(rule) => rule.type === "requires_approval",
|
||||||
|
);
|
||||||
|
|
||||||
|
const lettaCodeTools = getToolNames();
|
||||||
|
expect(approvalRules?.length).toBe(lettaCodeTools.length);
|
||||||
|
|
||||||
|
// Check all Letta Code tools have approval rules
|
||||||
|
const rulesToolNames = approvalRules?.map((r) => r.tool_name) || [];
|
||||||
|
for (const toolName of lettaCodeTools) {
|
||||||
|
expect(rulesToolNames).toContain(toolName);
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
test("linkToolsToAgent returns success when tools already attached", async () => {
|
||||||
|
// Reset and link once
|
||||||
|
await unlinkToolsFromAgent(testAgentId);
|
||||||
|
await linkToolsToAgent(testAgentId);
|
||||||
|
|
||||||
|
// Link again
|
||||||
|
const result = await linkToolsToAgent(testAgentId);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.addedCount).toBe(0);
|
||||||
|
expect(result.message).toContain("already attached");
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
test("unlinkToolsFromAgent removes all Letta Code tools", async () => {
|
||||||
|
// First link tools
|
||||||
|
await linkToolsToAgent(testAgentId);
|
||||||
|
|
||||||
|
// Then unlink
|
||||||
|
const result = await unlinkToolsFromAgent(testAgentId);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.removedCount).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Verify tools were removed
|
||||||
|
const agent = await client.agents.retrieve(testAgentId);
|
||||||
|
const toolNames = agent.tools?.map((t) => t.name) || [];
|
||||||
|
const lettaCodeTools = getToolNames();
|
||||||
|
|
||||||
|
for (const toolName of lettaCodeTools) {
|
||||||
|
expect(toolNames).not.toContain(toolName);
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
test("unlinkToolsFromAgent removes approval rules", async () => {
|
||||||
|
// First link tools
|
||||||
|
await linkToolsToAgent(testAgentId);
|
||||||
|
|
||||||
|
// Then unlink
|
||||||
|
await unlinkToolsFromAgent(testAgentId);
|
||||||
|
|
||||||
|
// Verify approval rules were removed
|
||||||
|
const agent = await client.agents.retrieve(testAgentId);
|
||||||
|
const approvalRules = agent.tool_rules?.filter(
|
||||||
|
(rule) => rule.type === "requires_approval",
|
||||||
|
);
|
||||||
|
|
||||||
|
const lettaCodeTools = new Set(getToolNames());
|
||||||
|
const remainingApprovalRules = approvalRules?.filter((r) =>
|
||||||
|
lettaCodeTools.has(r.tool_name),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(remainingApprovalRules?.length || 0).toBe(0);
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
test("unlinkToolsFromAgent preserves non-Letta-Code tools", async () => {
|
||||||
|
// Link Letta Code tools
|
||||||
|
await linkToolsToAgent(testAgentId);
|
||||||
|
|
||||||
|
// Attach memory tool
|
||||||
|
const memoryTools = await client.tools.list({ name: "memory" });
|
||||||
|
const memoryTool = memoryTools[0];
|
||||||
|
if (memoryTool?.id) {
|
||||||
|
await client.agents.tools.attach(memoryTool.id, {
|
||||||
|
agent_id: testAgentId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlink Letta Code tools
|
||||||
|
await unlinkToolsFromAgent(testAgentId);
|
||||||
|
|
||||||
|
// Verify memory tool is still there
|
||||||
|
const agent = await client.agents.retrieve(testAgentId);
|
||||||
|
const toolNames = agent.tools?.map((t) => t.name) || [];
|
||||||
|
|
||||||
|
expect(toolNames).toContain("memory");
|
||||||
|
|
||||||
|
// Verify Letta Code tools are gone
|
||||||
|
const lettaCodeTools = getToolNames();
|
||||||
|
for (const toolName of lettaCodeTools) {
|
||||||
|
expect(toolNames).not.toContain(toolName);
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
test("unlinkToolsFromAgent preserves non-approval tool_rules", async () => {
|
||||||
|
// Link tools
|
||||||
|
await linkToolsToAgent(testAgentId);
|
||||||
|
|
||||||
|
// Add a continue_loop rule manually
|
||||||
|
const agent = await client.agents.retrieve(testAgentId);
|
||||||
|
const newToolRules = [
|
||||||
|
...(agent.tool_rules || []),
|
||||||
|
{
|
||||||
|
tool_name: "memory",
|
||||||
|
type: "continue_loop" as const,
|
||||||
|
prompt_template: "Test rule",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await client.agents.modify(testAgentId, { tool_rules: newToolRules });
|
||||||
|
|
||||||
|
// Unlink Letta Code tools
|
||||||
|
await unlinkToolsFromAgent(testAgentId);
|
||||||
|
|
||||||
|
// Verify continue_loop rule is still there
|
||||||
|
const updatedAgent = await client.agents.retrieve(testAgentId);
|
||||||
|
const continueLoopRules = updatedAgent.tool_rules?.filter(
|
||||||
|
(r) => r.type === "continue_loop" && r.tool_name === "memory",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(continueLoopRules?.length).toBe(1);
|
||||||
|
}, 30000);
|
||||||
|
});
|
||||||
60
test-image-send.ts
Normal file
60
test-image-send.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { createAgent } from "./src/agent/create";
|
||||||
|
import { sendMessageStream } from "./src/agent/message";
|
||||||
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// Create a simple test image (1x1 red PNG)
|
||||||
|
const testImageBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==";
|
||||||
|
const testImagePath = "/tmp/test.png";
|
||||||
|
writeFileSync(testImagePath, Buffer.from(testImageBase64, "base64"));
|
||||||
|
console.log("Created test image at", testImagePath);
|
||||||
|
|
||||||
|
// Create agent
|
||||||
|
console.log("\nCreating test agent...");
|
||||||
|
const agent = await createAgent("image-test-agent");
|
||||||
|
console.log("Agent created:", agent.id);
|
||||||
|
|
||||||
|
// Read image
|
||||||
|
const imageData = readFileSync(testImagePath).toString("base64");
|
||||||
|
|
||||||
|
// Send message with image
|
||||||
|
console.log("\nSending image to agent...");
|
||||||
|
const stream = await sendMessageStream(agent.id, [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "What do you see in this image?",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "image",
|
||||||
|
source: {
|
||||||
|
type: "base64",
|
||||||
|
media_type: "image/png",
|
||||||
|
data: imageData,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Print response
|
||||||
|
console.log("\nAgent response:");
|
||||||
|
let fullResponse = "";
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
if (chunk.message_type === "assistant_message" && chunk.content) {
|
||||||
|
fullResponse += chunk.content;
|
||||||
|
process.stdout.write(chunk.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!fullResponse) {
|
||||||
|
console.log("(no assistant message received)");
|
||||||
|
}
|
||||||
|
console.log("\n\n✅ Done!");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("❌ Error:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user