feat: add from af cli flag (#195)

This commit is contained in:
Ari Webb
2025-12-14 14:27:31 -08:00
committed by GitHub
parent d77e92be48
commit 82518f135b
5 changed files with 155 additions and 11 deletions

55
src/agent/import.ts Normal file
View File

@@ -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<ImportAgentResult> {
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 };
}

View File

@@ -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",

View File

@@ -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:

View File

@@ -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(

View File

@@ -46,6 +46,7 @@ OPTIONS
Default: text
--skills <path> Custom path to skills directory (default: .skills in current directory)
--sleeptime Enable sleeptime memory management (only for new agents)
--from-af <path> 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