diff --git a/src/skills/builtin/messaging-agents/SKILL.md b/src/skills/builtin/messaging-agents/SKILL.md new file mode 100644 index 0000000..3445103 --- /dev/null +++ b/src/skills/builtin/messaging-agents/SKILL.md @@ -0,0 +1,125 @@ +--- +name: messaging-agents +description: Send messages to other agents on your server. Use when you need to communicate with, query, or delegate tasks to another agent. +--- + +# Messaging Agents + +This skill enables you to send messages to other agents on the same Letta server using the thread-safe conversations API. + +## When to Use This Skill + +- You need to ask another agent a question +- You want to query an agent that has specialized knowledge +- You need information that another agent has in their memory +- You want to coordinate with another agent on a task + +## What the Target Agent Can and Cannot Do + +**The target agent CANNOT:** +- Access your local environment (read/write files in your codebase) +- Execute shell commands on your machine +- Use your tools (Bash, Read, Write, Edit, etc.) + +**The target agent CAN:** +- Use their own tools (whatever they have configured) +- Access their own memory blocks +- Make API calls if they have web/API tools +- Search the web if they have web search tools +- Respond with information from their knowledge/memory + +**Important:** This skill is for *communication* with other agents, not *delegation* of local work. The target agent runs in their own environment and cannot interact with your codebase. + +## Finding an Agent to Message + +If you don't have a specific agent ID, use these skills to find one: + +### By Name or Tags +Load the `finding-agents` skill to search for agents: +```bash +npx tsx /scripts/find-agents.ts --query "agent-name" +npx tsx /scripts/find-agents.ts --tags "origin:letta-code" +``` + +### By Topic They Discussed +Load the `searching-messages` skill to find which agent worked on something: +```bash +npx tsx /scripts/search-messages.ts --query "topic" --all-agents +``` +Results include `agent_id` for each matching message. + +## Script Usage + +### Starting a New Conversation + +```bash +npx tsx /scripts/start-conversation.ts --agent-id --message "" +``` + +**Arguments:** +| Arg | Required | Description | +|-----|----------|-------------| +| `--agent-id ` | Yes | Target agent ID to message | +| `--message ` | Yes | Message to send | +| `--timeout ` | No | Max wait time in ms (default: 120000) | + +**Example:** +```bash +npx tsx /scripts/start-conversation.ts \ + --agent-id agent-abc123 \ + --message "What do you know about the authentication system?" +``` + +**Response:** +```json +{ + "conversation_id": "conversation-xyz789", + "response": "The authentication system uses JWT tokens...", + "agent_id": "agent-abc123", + "agent_name": "BackendExpert" +} +``` + +### Continuing a Conversation + +```bash +npx tsx /scripts/continue-conversation.ts --conversation-id --message "" +``` + +**Arguments:** +| Arg | Required | Description | +|-----|----------|-------------| +| `--conversation-id ` | Yes | Existing conversation ID | +| `--message ` | Yes | Follow-up message to send | +| `--timeout ` | No | Max wait time in ms (default: 120000) | + +**Example:** +```bash +npx tsx /scripts/continue-conversation.ts \ + --conversation-id conversation-xyz789 \ + --message "Can you explain more about the token refresh flow?" +``` + +## Understanding the Response + +- Scripts return only the **final assistant message** (not tool calls or reasoning) +- The target agent may use tools, think, and reason - but you only see their final response +- To see the full conversation transcript (including tool calls), use the `searching-messages` skill with `--agent-id` targeting the other agent + +## How It Works + +When you send a message, the target agent receives it with a system reminder: +``` + +This message is from "YourAgentName" (agent ID: agent-xxx), an agent currently running inside the Letta Code CLI (docs.letta.com/letta-code). +The sender will only see the final message you generate (not tool calls or reasoning). +If you need to share detailed information, include it in your response text. + +``` + +This helps the target agent understand the context and format their response appropriately. + +## Related Skills + +- **finding-agents**: Find agents by name, tags, or fuzzy search +- **searching-messages**: Search past messages across agents, or view full conversation transcripts diff --git a/src/skills/builtin/messaging-agents/scripts/continue-conversation.ts b/src/skills/builtin/messaging-agents/scripts/continue-conversation.ts new file mode 100644 index 0000000..60e19d1 --- /dev/null +++ b/src/skills/builtin/messaging-agents/scripts/continue-conversation.ts @@ -0,0 +1,222 @@ +#!/usr/bin/env npx tsx +/** + * Continue Conversation - Send a follow-up message to an existing conversation + * + * This script is standalone and can be run outside the CLI process. + * It reads auth from LETTA_API_KEY env var or ~/.letta/settings.json. + * It reads sender agent ID from LETTA_AGENT_ID env var. + * + * Usage: + * npx tsx continue-conversation.ts --conversation-id --message "" + * + * Options: + * --conversation-id Existing conversation ID (required) + * --message Message to send (required) + * --timeout Max wait time in ms (default: 120000) + * + * Output: + * JSON with conversation_id, response, agent_id, agent_name + */ + +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +// Use createRequire for @letta-ai/letta-client so NODE_PATH is respected +// (ES module imports don't respect NODE_PATH, but require does) +const require = createRequire(import.meta.url); +const Letta = require("@letta-ai/letta-client") + .default as typeof import("@letta-ai/letta-client").default; +type LettaClient = InstanceType; + +interface ContinueConversationOptions { + conversationId: string; + message: string; + timeout?: number; +} + +interface ContinueConversationResult { + conversation_id: string; + response: string; + agent_id: string; + agent_name: string; +} + +/** + * Get API key from env var or settings file + */ +function getApiKey(): string { + if (process.env.LETTA_API_KEY) { + return process.env.LETTA_API_KEY; + } + + const settingsPath = join(homedir(), ".letta", "settings.json"); + try { + const settings = JSON.parse(readFileSync(settingsPath, "utf-8")); + if (settings.env?.LETTA_API_KEY) { + return settings.env.LETTA_API_KEY; + } + } catch { + // Settings file doesn't exist or is invalid + } + + throw new Error( + "No LETTA_API_KEY found. Set the env var or run the Letta CLI to authenticate.", + ); +} + +/** + * Get the sender agent ID from env var + */ +function getSenderAgentId(): string { + if (process.env.LETTA_AGENT_ID) { + return process.env.LETTA_AGENT_ID; + } + throw new Error( + "No LETTA_AGENT_ID found. This script should be run from within a Letta Code session.", + ); +} + +/** + * Create a Letta client with auth from env/settings + */ +function createClient(): LettaClient { + return new Letta({ apiKey: getApiKey() }); +} + +/** + * Build the system reminder prefix for the message + */ +function buildSystemReminder( + senderAgentName: string, + senderAgentId: string, +): string { + return ` +This message is from "${senderAgentName}" (agent ID: ${senderAgentId}), an agent currently running inside the Letta Code CLI (docs.letta.com/letta-code). +The sender will only see the final message you generate (not tool calls or reasoning). +If you need to share detailed information, include it in your response text. + + +`; +} + +/** + * Continue an existing conversation by sending a follow-up message + * @param client - Letta client instance + * @param options - Options including conversation ID and message + * @returns Conversation result with response and metadata + */ +export async function continueConversation( + client: LettaClient, + options: ContinueConversationOptions, +): Promise { + const { conversationId, message } = options; + + // 1. Fetch conversation to get agent_id and validate it exists + const conversation = await client.conversations.retrieve(conversationId); + + // 2. Fetch target agent to get name + const targetAgent = await client.agents.retrieve(conversation.agent_id); + + // 3. Fetch sender agent to get name for system reminder + const senderAgentId = getSenderAgentId(); + const senderAgent = await client.agents.retrieve(senderAgentId); + + // 4. Build message with system reminder prefix + const systemReminder = buildSystemReminder(senderAgent.name, senderAgentId); + const fullMessage = systemReminder + message; + + // 5. Send message and consume the stream + // Note: conversations.messages.create always returns a Stream + const stream = await client.conversations.messages.create(conversationId, { + input: fullMessage, + }); + + // 6. Consume stream and extract final assistant message + let finalResponse = ""; + for await (const chunk of stream) { + if (chunk.message_type === "assistant_message") { + // Content can be string or array of content parts + const content = chunk.content; + if (typeof content === "string") { + finalResponse += content; + } else if (Array.isArray(content)) { + for (const part of content) { + if ( + typeof part === "object" && + part !== null && + "type" in part && + part.type === "text" && + "text" in part + ) { + finalResponse += (part as { text: string }).text; + } + } + } + } + } + + return { + conversation_id: conversationId, + response: finalResponse, + agent_id: targetAgent.id, + agent_name: targetAgent.name, + }; +} + +function parseArgs(args: string[]): ContinueConversationOptions { + const conversationIdIndex = args.indexOf("--conversation-id"); + if (conversationIdIndex === -1 || conversationIdIndex + 1 >= args.length) { + throw new Error( + "Missing required argument: --conversation-id ", + ); + } + const conversationId = args[conversationIdIndex + 1] as string; + + const messageIndex = args.indexOf("--message"); + if (messageIndex === -1 || messageIndex + 1 >= args.length) { + throw new Error("Missing required argument: --message "); + } + const message = args[messageIndex + 1] as string; + + const options: ContinueConversationOptions = { conversationId, message }; + + const timeoutIndex = args.indexOf("--timeout"); + if (timeoutIndex !== -1 && timeoutIndex + 1 < args.length) { + const timeout = Number.parseInt(args[timeoutIndex + 1] as string, 10); + if (!Number.isNaN(timeout)) { + options.timeout = timeout; + } + } + + return options; +} + +// CLI entry point - check if this file is being run directly +const isMainModule = import.meta.url === `file://${process.argv[1]}`; +if (isMainModule) { + (async () => { + try { + const options = parseArgs(process.argv.slice(2)); + const client = createClient(); + const result = await continueConversation(client, options); + console.log(JSON.stringify(result, null, 2)); + process.exit(0); + } catch (error) { + console.error( + "Error:", + error instanceof Error ? error.message : String(error), + ); + console.error(` +Usage: npx tsx continue-conversation.ts --conversation-id --message "" + +Options: + --conversation-id Existing conversation ID (required) + --message Message to send (required) + --timeout Max wait time in ms (default: 120000) +`); + process.exit(1); + } + })(); +} diff --git a/src/skills/builtin/messaging-agents/scripts/start-conversation.ts b/src/skills/builtin/messaging-agents/scripts/start-conversation.ts new file mode 100644 index 0000000..eb6b6ba --- /dev/null +++ b/src/skills/builtin/messaging-agents/scripts/start-conversation.ts @@ -0,0 +1,225 @@ +#!/usr/bin/env npx tsx +/** + * Start Conversation - Start a new conversation with an agent and send a message + * + * This script is standalone and can be run outside the CLI process. + * It reads auth from LETTA_API_KEY env var or ~/.letta/settings.json. + * It reads sender agent ID from LETTA_AGENT_ID env var. + * + * Usage: + * npx tsx start-conversation.ts --agent-id --message "" + * + * Options: + * --agent-id Target agent ID to message (required) + * --message Message to send (required) + * --timeout Max wait time in ms (default: 120000) + * + * Output: + * JSON with conversation_id, response, agent_id, agent_name + */ + +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +// Use createRequire for @letta-ai/letta-client so NODE_PATH is respected +// (ES module imports don't respect NODE_PATH, but require does) +const require = createRequire(import.meta.url); +const Letta = require("@letta-ai/letta-client") + .default as typeof import("@letta-ai/letta-client").default; +type LettaClient = InstanceType; + +interface StartConversationOptions { + agentId: string; + message: string; + timeout?: number; +} + +interface StartConversationResult { + conversation_id: string; + response: string; + agent_id: string; + agent_name: string; +} + +/** + * Get API key from env var or settings file + */ +function getApiKey(): string { + if (process.env.LETTA_API_KEY) { + return process.env.LETTA_API_KEY; + } + + const settingsPath = join(homedir(), ".letta", "settings.json"); + try { + const settings = JSON.parse(readFileSync(settingsPath, "utf-8")); + if (settings.env?.LETTA_API_KEY) { + return settings.env.LETTA_API_KEY; + } + } catch { + // Settings file doesn't exist or is invalid + } + + throw new Error( + "No LETTA_API_KEY found. Set the env var or run the Letta CLI to authenticate.", + ); +} + +/** + * Get the sender agent ID from env var + */ +function getSenderAgentId(): string { + if (process.env.LETTA_AGENT_ID) { + return process.env.LETTA_AGENT_ID; + } + throw new Error( + "No LETTA_AGENT_ID found. This script should be run from within a Letta Code session.", + ); +} + +/** + * Create a Letta client with auth from env/settings + */ +function createClient(): LettaClient { + return new Letta({ apiKey: getApiKey() }); +} + +/** + * Build the system reminder prefix for the message + */ +function buildSystemReminder( + senderAgentName: string, + senderAgentId: string, +): string { + return ` +This message is from "${senderAgentName}" (agent ID: ${senderAgentId}), an agent currently running inside the Letta Code CLI (docs.letta.com/letta-code). +The sender will only see the final message you generate (not tool calls or reasoning). +If you need to share detailed information, include it in your response text. + + +`; +} + +/** + * Start a new conversation with an agent and send a message + * @param client - Letta client instance + * @param options - Options including target agent ID and message + * @returns Conversation result with response and metadata + */ +export async function startConversation( + client: LettaClient, + options: StartConversationOptions, +): Promise { + const { agentId, message } = options; + + // 1. Fetch target agent to validate existence and get name + const targetAgent = await client.agents.retrieve(agentId); + + // 2. Fetch sender agent to get name for system reminder + const senderAgentId = getSenderAgentId(); + const senderAgent = await client.agents.retrieve(senderAgentId); + + // 3. Create new conversation + const conversation = await client.conversations.create({ + agent_id: agentId, + }); + + // 4. Build message with system reminder prefix + const systemReminder = buildSystemReminder(senderAgent.name, senderAgentId); + const fullMessage = systemReminder + message; + + // 5. Send message and consume the stream + // Note: conversations.messages.create always returns a Stream + const stream = await client.conversations.messages.create(conversation.id, { + input: fullMessage, + }); + + // 6. Consume stream and extract final assistant message + let finalResponse = ""; + for await (const chunk of stream) { + if (process.env.DEBUG) { + console.error("Chunk:", JSON.stringify(chunk, null, 2)); + } + if (chunk.message_type === "assistant_message") { + // Content can be string or array of content parts + const content = chunk.content; + if (typeof content === "string") { + finalResponse += content; + } else if (Array.isArray(content)) { + for (const part of content) { + if ( + typeof part === "object" && + part !== null && + "type" in part && + part.type === "text" && + "text" in part + ) { + finalResponse += (part as { text: string }).text; + } + } + } + } + } + + return { + conversation_id: conversation.id, + response: finalResponse, + agent_id: targetAgent.id, + agent_name: targetAgent.name, + }; +} + +function parseArgs(args: string[]): StartConversationOptions { + const agentIdIndex = args.indexOf("--agent-id"); + if (agentIdIndex === -1 || agentIdIndex + 1 >= args.length) { + throw new Error("Missing required argument: --agent-id "); + } + const agentId = args[agentIdIndex + 1] as string; + + const messageIndex = args.indexOf("--message"); + if (messageIndex === -1 || messageIndex + 1 >= args.length) { + throw new Error("Missing required argument: --message "); + } + const message = args[messageIndex + 1] as string; + + const options: StartConversationOptions = { agentId, message }; + + const timeoutIndex = args.indexOf("--timeout"); + if (timeoutIndex !== -1 && timeoutIndex + 1 < args.length) { + const timeout = Number.parseInt(args[timeoutIndex + 1] as string, 10); + if (!Number.isNaN(timeout)) { + options.timeout = timeout; + } + } + + return options; +} + +// CLI entry point - check if this file is being run directly +const isMainModule = import.meta.url === `file://${process.argv[1]}`; +if (isMainModule) { + (async () => { + try { + const options = parseArgs(process.argv.slice(2)); + const client = createClient(); + const result = await startConversation(client, options); + console.log(JSON.stringify(result, null, 2)); + process.exit(0); + } catch (error) { + console.error( + "Error:", + error instanceof Error ? error.message : String(error), + ); + console.error(` +Usage: npx tsx start-conversation.ts --agent-id --message "" + +Options: + --agent-id Target agent ID to message (required) + --message Message to send (required) + --timeout Max wait time in ms (default: 120000) +`); + process.exit(1); + } + })(); +} diff --git a/src/tests/skills/messaging-agents-scripts.test.ts b/src/tests/skills/messaging-agents-scripts.test.ts new file mode 100644 index 0000000..d29a14e --- /dev/null +++ b/src/tests/skills/messaging-agents-scripts.test.ts @@ -0,0 +1,293 @@ +/** + * Tests for the bundled messaging-agents scripts + */ + +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import type Letta from "@letta-ai/letta-client"; +import { continueConversation } from "../../skills/builtin/messaging-agents/scripts/continue-conversation"; +import { startConversation } from "../../skills/builtin/messaging-agents/scripts/start-conversation"; + +// Mock agent data +const mockSenderAgent = { + id: "agent-sender-123", + name: "SenderAgent", +}; + +const mockTargetAgent = { + id: "agent-target-456", + name: "TargetAgent", +}; + +const mockConversation = { + id: "conversation-789", + agent_id: mockTargetAgent.id, +}; + +// Helper to create a mock async iterator for streaming +function createMockStream( + chunks: Array<{ message_type: string; content?: string }>, +) { + return { + [Symbol.asyncIterator]: async function* () { + for (const chunk of chunks) { + yield chunk; + } + }, + }; +} + +describe("start-conversation", () => { + const originalEnv = process.env.LETTA_AGENT_ID; + + beforeEach(() => { + process.env.LETTA_AGENT_ID = mockSenderAgent.id; + }); + + afterEach(() => { + if (originalEnv) { + process.env.LETTA_AGENT_ID = originalEnv; + } else { + delete process.env.LETTA_AGENT_ID; + } + }); + + test("creates conversation and sends message with system reminder", async () => { + const mockRetrieve = mock((id: string) => { + if (id === mockSenderAgent.id) return Promise.resolve(mockSenderAgent); + if (id === mockTargetAgent.id) return Promise.resolve(mockTargetAgent); + throw new Error(`Unknown agent: ${id}`); + }); + + const mockCreate = mock(() => Promise.resolve(mockConversation)); + + const mockMessageCreate = mock(() => + Promise.resolve( + createMockStream([ + { message_type: "reasoning_message", content: "thinking..." }, + { message_type: "assistant_message", content: "Hello there!" }, + ]), + ), + ); + + const mockClient = { + agents: { + retrieve: mockRetrieve, + }, + conversations: { + create: mockCreate, + messages: { + create: mockMessageCreate, + }, + }, + } as unknown as Letta; + + const result = await startConversation(mockClient, { + agentId: mockTargetAgent.id, + message: "Hello!", + }); + + // Check that target agent was fetched + expect(mockRetrieve).toHaveBeenCalledWith(mockTargetAgent.id); + // Check that sender agent was fetched + expect(mockRetrieve).toHaveBeenCalledWith(mockSenderAgent.id); + + // Check conversation was created + expect(mockCreate).toHaveBeenCalledWith({ + agent_id: mockTargetAgent.id, + }); + + // Check message was sent with system reminder + expect(mockMessageCreate).toHaveBeenCalledWith(mockConversation.id, { + input: expect.stringContaining(""), + }); + expect(mockMessageCreate).toHaveBeenCalledWith(mockConversation.id, { + input: expect.stringContaining("Hello!"), + }); + expect(mockMessageCreate).toHaveBeenCalledWith(mockConversation.id, { + input: expect.stringContaining(mockSenderAgent.name), + }); + + // Check result + expect(result.conversation_id).toBe(mockConversation.id); + expect(result.response).toBe("Hello there!"); + expect(result.agent_id).toBe(mockTargetAgent.id); + expect(result.agent_name).toBe(mockTargetAgent.name); + }); + + test("throws error when target agent not found", async () => { + const mockClient = { + agents: { + retrieve: mock(() => Promise.reject(new Error("Agent not found"))), + }, + conversations: { + create: mock(), + messages: { create: mock() }, + }, + } as unknown as Letta; + + await expect( + startConversation(mockClient, { + agentId: "nonexistent", + message: "Hello!", + }), + ).rejects.toThrow("Agent not found"); + }); + + test("throws error when LETTA_AGENT_ID not set", async () => { + delete process.env.LETTA_AGENT_ID; + + const mockClient = { + agents: { + retrieve: mock(() => Promise.resolve(mockTargetAgent)), + }, + conversations: { + create: mock(), + messages: { create: mock() }, + }, + } as unknown as Letta; + + await expect( + startConversation(mockClient, { + agentId: mockTargetAgent.id, + message: "Hello!", + }), + ).rejects.toThrow("LETTA_AGENT_ID"); + }); +}); + +describe("continue-conversation", () => { + const originalEnv = process.env.LETTA_AGENT_ID; + + beforeEach(() => { + process.env.LETTA_AGENT_ID = mockSenderAgent.id; + }); + + afterEach(() => { + if (originalEnv) { + process.env.LETTA_AGENT_ID = originalEnv; + } else { + delete process.env.LETTA_AGENT_ID; + } + }); + + test("continues existing conversation with system reminder", async () => { + const mockAgentRetrieve = mock((id: string) => { + if (id === mockSenderAgent.id) return Promise.resolve(mockSenderAgent); + if (id === mockTargetAgent.id) return Promise.resolve(mockTargetAgent); + throw new Error(`Unknown agent: ${id}`); + }); + + const mockConversationRetrieve = mock(() => + Promise.resolve(mockConversation), + ); + + const mockMessageCreate = mock(() => + Promise.resolve( + createMockStream([ + { message_type: "assistant_message", content: "Follow-up response!" }, + ]), + ), + ); + + const mockClient = { + agents: { + retrieve: mockAgentRetrieve, + }, + conversations: { + retrieve: mockConversationRetrieve, + messages: { + create: mockMessageCreate, + }, + }, + } as unknown as Letta; + + const result = await continueConversation(mockClient, { + conversationId: mockConversation.id, + message: "Follow-up question", + }); + + // Check conversation was fetched + expect(mockConversationRetrieve).toHaveBeenCalledWith(mockConversation.id); + + // Check target agent was fetched + expect(mockAgentRetrieve).toHaveBeenCalledWith(mockTargetAgent.id); + + // Check sender agent was fetched + expect(mockAgentRetrieve).toHaveBeenCalledWith(mockSenderAgent.id); + + // Check message was sent with system reminder + expect(mockMessageCreate).toHaveBeenCalledWith(mockConversation.id, { + input: expect.stringContaining(""), + }); + expect(mockMessageCreate).toHaveBeenCalledWith(mockConversation.id, { + input: expect.stringContaining("Follow-up question"), + }); + + // Check result + expect(result.conversation_id).toBe(mockConversation.id); + expect(result.response).toBe("Follow-up response!"); + expect(result.agent_id).toBe(mockTargetAgent.id); + expect(result.agent_name).toBe(mockTargetAgent.name); + }); + + test("throws error when conversation not found", async () => { + const mockClient = { + agents: { + retrieve: mock(), + }, + conversations: { + retrieve: mock(() => + Promise.reject(new Error("Conversation not found")), + ), + messages: { create: mock() }, + }, + } as unknown as Letta; + + await expect( + continueConversation(mockClient, { + conversationId: "nonexistent", + message: "Hello!", + }), + ).rejects.toThrow("Conversation not found"); + }); + + test("handles empty response from agent", async () => { + const mockAgentRetrieve = mock((id: string) => { + if (id === mockSenderAgent.id) return Promise.resolve(mockSenderAgent); + if (id === mockTargetAgent.id) return Promise.resolve(mockTargetAgent); + throw new Error(`Unknown agent: ${id}`); + }); + + const mockConversationRetrieve = mock(() => + Promise.resolve(mockConversation), + ); + + const mockMessageCreate = mock(() => + Promise.resolve( + createMockStream([ + { message_type: "reasoning_message", content: "thinking..." }, + // No assistant message - agent didn't respond with text + ]), + ), + ); + + const mockClient = { + agents: { + retrieve: mockAgentRetrieve, + }, + conversations: { + retrieve: mockConversationRetrieve, + messages: { + create: mockMessageCreate, + }, + }, + } as unknown as Letta; + + const result = await continueConversation(mockClient, { + conversationId: mockConversation.id, + message: "Hello?", + }); + + expect(result.response).toBe(""); + }); +});