diff --git a/src/agent/import.ts b/src/agent/import.ts new file mode 100644 index 0000000..ec09c07 --- /dev/null +++ b/src/agent/import.ts @@ -0,0 +1,55 @@ +/** + * Import an agent from an AgentFile (.af) template + */ +import { createReadStream } from "node:fs"; +import { resolve } from "node:path"; +import type { AgentState } from "@letta-ai/letta-client/resources/agents/agents"; +import { getClient } from "./client"; +import { getModelUpdateArgs } from "./model"; +import { linkToolsToAgent, updateAgentLLMConfig } from "./modify"; + +export interface ImportAgentOptions { + filePath: string; + modelOverride?: string; + stripMessages?: boolean; +} + +export interface ImportAgentResult { + agent: AgentState; +} + +export async function importAgentFromFile( + options: ImportAgentOptions, +): Promise { + const client = await getClient(); + const resolvedPath = resolve(options.filePath); + + // Create a file stream for the API (compatible with Node.js and Bun) + const file = createReadStream(resolvedPath); + + // Import the agent via API + const importResponse = await client.agents.importFile({ + file: file, + strip_messages: options.stripMessages ?? true, + override_existing_tools: false, + }); + + if (!importResponse.agent_ids || importResponse.agent_ids.length === 0) { + throw new Error("Import failed: no agent IDs returned"); + } + + const agentId = importResponse.agent_ids[0] as string; + let agent = await client.agents.retrieve(agentId); + + // Override model if specified + if (options.modelOverride) { + const updateArgs = getModelUpdateArgs(options.modelOverride); + await updateAgentLLMConfig(agentId, options.modelOverride, updateArgs); + agent = await client.agents.retrieve(agentId); + } + + // Link Letta Code tools to the imported agent + await linkToolsToAgent(agentId); + + return { agent }; +} diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 597e950..a3799a2 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -1,6 +1,6 @@ // src/cli/App.tsx -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { APIUserAbortError } from "@letta-ai/letta-client/core/error"; import type { AgentState, @@ -239,6 +239,7 @@ export default function App({ | "upserting" | "linking" | "unlinking" + | "importing" | "initializing" | "checking" | "ready"; @@ -1995,7 +1996,7 @@ export default function App({ const client = await getClient(); const fileContent = await client.agents.exportFile(agentId); const fileName = `${agentId}.af`; - await Bun.write(fileName, JSON.stringify(fileContent, null, 2)); + writeFileSync(fileName, JSON.stringify(fileContent, null, 2)); buffersRef.current.byId.set(cmdId, { kind: "command", diff --git a/src/cli/components/WelcomeScreen.tsx b/src/cli/components/WelcomeScreen.tsx index 48af605..97df7df 100644 --- a/src/cli/components/WelcomeScreen.tsx +++ b/src/cli/components/WelcomeScreen.tsx @@ -12,6 +12,7 @@ type LoadingState = | "upserting" | "linking" | "unlinking" + | "importing" | "initializing" | "checking" | "ready"; @@ -189,6 +190,8 @@ function getStatusMessage( return "Attaching Letta Code tools..."; case "unlinking": return "Removing Letta Code tools..."; + case "importing": + return "Importing agent from template..."; case "checking": return "Checking for pending approvals..."; default: diff --git a/src/headless.ts b/src/headless.ts index af88de6..6f25552 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -66,6 +66,7 @@ export async function handleHeadlessCommand( sleeptime: { type: "boolean" }, "init-blocks": { type: "string" }, "base-tools": { type: "string" }, + "from-af": { type: "string" }, }, strict: false, allowPositionals: true, @@ -108,6 +109,23 @@ export async function handleHeadlessCommand( const initBlocksRaw = values["init-blocks"] as string | undefined; const baseToolsRaw = values["base-tools"] as string | undefined; const sleeptimeFlag = (values.sleeptime as boolean | undefined) ?? undefined; + const fromAfFile = values["from-af"] as string | undefined; + + // Validate --from-af flag + if (fromAfFile) { + if (specifiedAgentId) { + console.error("Error: --from-af cannot be used with --agent"); + process.exit(1); + } + if (shouldContinue) { + console.error("Error: --from-af cannot be used with --continue"); + process.exit(1); + } + if (forceNew) { + console.error("Error: --from-af cannot be used with --new"); + process.exit(1); + } + } if (initBlocksRaw && !forceNew) { console.error( @@ -149,8 +167,19 @@ export async function handleHeadlessCommand( } } - // Priority 1: Try to use --agent specified ID - if (specifiedAgentId) { + // Priority 1: Import from AgentFile template + if (fromAfFile) { + const { importAgentFromFile } = await import("./agent/import"); + const result = await importAgentFromFile({ + filePath: fromAfFile, + modelOverride: model, + stripMessages: true, + }); + agent = result.agent; + } + + // Priority 2: Try to use --agent specified ID + if (!agent && specifiedAgentId) { try { agent = await client.agents.retrieve(specifiedAgentId); } catch (_error) { @@ -158,7 +187,7 @@ export async function handleHeadlessCommand( } } - // Priority 2: Check if --new flag was passed (skip all resume logic) + // Priority 3: Check if --new flag was passed (skip all resume logic) if (!agent && forceNew) { const updateArgs = getModelUpdateArgs(model); const result = await createAgent( @@ -177,7 +206,7 @@ export async function handleHeadlessCommand( agent = result.agent; } - // Priority 3: Try to resume from project settings (.letta/settings.local.json) + // Priority 4: Try to resume from project settings (.letta/settings.local.json) if (!agent) { await settingsManager.loadLocalProjectSettings(); const localProjectSettings = settingsManager.getLocalProjectSettings(); @@ -192,7 +221,7 @@ export async function handleHeadlessCommand( } } - // Priority 4: Try to reuse global lastAgent if --continue flag is passed + // Priority 5: Try to reuse global lastAgent if --continue flag is passed if (!agent && shouldContinue && settings.lastAgent) { try { agent = await client.agents.retrieve(settings.lastAgent); @@ -203,7 +232,7 @@ export async function handleHeadlessCommand( } } - // Priority 5: Create a new agent + // Priority 6: Create a new agent if (!agent) { const updateArgs = getModelUpdateArgs(model); const result = await createAgent( diff --git a/src/index.ts b/src/index.ts index a351bcd..3f94118 100755 --- a/src/index.ts +++ b/src/index.ts @@ -46,6 +46,7 @@ OPTIONS Default: text --skills Custom path to skills directory (default: .skills in current directory) --sleeptime Enable sleeptime memory management (only for new agents) + --from-af Create agent from an AgentFile (.af) template BEHAVIOR @@ -144,6 +145,7 @@ async function main() { link: { type: "boolean" }, unlink: { type: "boolean" }, sleeptime: { type: "boolean" }, + "from-af": { type: "string" }, }, strict: true, allowPositionals: true, @@ -200,6 +202,7 @@ async function main() { const specifiedToolset = (values.toolset as string | undefined) ?? undefined; const skillsDirectory = (values.skills as string | undefined) ?? undefined; const sleeptimeFlag = (values.sleeptime as boolean | undefined) ?? undefined; + const fromAfFile = values["from-af"] as string | undefined; const isHeadless = values.prompt || values.run || !process.stdin.isTTY; // --init-blocks only makes sense when creating a brand new agent @@ -270,6 +273,30 @@ async function main() { } } + // Validate --from-af flag + if (fromAfFile) { + if (specifiedAgentId) { + console.error("Error: --from-af cannot be used with --agent"); + process.exit(1); + } + if (shouldContinue) { + console.error("Error: --from-af cannot be used with --continue"); + process.exit(1); + } + if (forceNew) { + console.error("Error: --from-af cannot be used with --new"); + process.exit(1); + } + // Verify file exists + const { resolve } = await import("node:path"); + const { existsSync } = await import("node:fs"); + const resolvedPath = resolve(fromAfFile); + if (!existsSync(resolvedPath)) { + console.error(`Error: AgentFile not found: ${resolvedPath}`); + process.exit(1); + } + } + // Check if API key is configured const apiKey = process.env.LETTA_API_KEY || settings.env?.LETTA_API_KEY; const baseURL = @@ -434,6 +461,7 @@ async function main() { system, toolset, skillsDirectory, + fromAfFile, }: { continueSession: boolean; forceNew: boolean; @@ -445,12 +473,14 @@ async function main() { system?: string; toolset?: "codex" | "default" | "gemini"; skillsDirectory?: string; + fromAfFile?: string; }) { const [loadingState, setLoadingState] = useState< | "assembling" | "upserting" | "linking" | "unlinking" + | "importing" | "initializing" | "checking" | "ready" @@ -569,8 +599,25 @@ async function main() { let agent: AgentState | null = null; - // Priority 1: Try to use --agent specified ID - if (agentIdArg) { + // Priority 1: Import from AgentFile template + if (fromAfFile) { + setLoadingState("importing"); + const { importAgentFromFile } = await import("./agent/import"); + const result = await importAgentFromFile({ + filePath: fromAfFile, + modelOverride: model, + stripMessages: true, + }); + agent = result.agent; + setAgentProvenance({ + isNew: true, + freshBlocks: true, + blocks: [], + }); + } + + // Priority 2: Try to use --agent specified ID + if (!agent && agentIdArg) { try { agent = await client.agents.retrieve(agentIdArg); // console.log(`Using agent ${agentIdArg}...`); @@ -731,7 +778,15 @@ async function main() { } init(); - }, [continueSession, forceNew, freshBlocks, agentIdArg, model, system]); + }, [ + continueSession, + forceNew, + freshBlocks, + agentIdArg, + model, + system, + fromAfFile, + ]); if (!agentId) { return React.createElement(App, { @@ -771,6 +826,7 @@ async function main() { system: specifiedSystem, toolset: specifiedToolset as "codex" | "default" | "gemini" | undefined, skillsDirectory: skillsDirectory, + fromAfFile: fromAfFile, }), { exitOnCtrlC: false, // We handle CTRL-C manually with double-press guard