Something went wrong. Try again.
A small demo repository demonstrating how to use the Letta Agent SDK in Tangled CI.
Something went wrong. Try again.
6.2 kB · 203 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204/** * Teaching demo: run one Agent SDK turn from Tangled CI. * * This process runs on the Tangled runner. The agent's sandbox tools run in a * Letta-managed Cloud sandbox. report_pipeline runs here, in the runner * process, and is the pipeline verdict. */
import { execFileSync } from "node:child_process";import { createTranscriptAccumulator, LettaAgentClient, type AnyAgentTool, type SDKResultMessage, type TranscriptToolCallRow,} from "@letta-ai/letta-agent-sdk/client";import { parsePipelineReport, PIPELINE_TOOL_NAME, shellQuote, validateGitSha, validatePipelineKind, validatePipelineReport, validateRepositoryUrl, validateSandboxEvidence,} from "./ci-contract.ts";
function requiredEnv(name: string): string { const value = process.env[name]; if (!value) { throw new Error(`${name} is required`); } return value;}
function git(...args: string[]): string { return execFileSync("git", args, { encoding: "utf8" }).trim();}
function createPipelineTool(): AnyAgentTool { return { name: PIPELINE_TOOL_NAME, label: "Report pipeline", description: "Record the pipeline verdict after inspecting the sandbox. Call this instead of writing a text receipt. success: true passes the check; success: false fails it.", parameters: { type: "object", additionalProperties: false, properties: { success: { type: "boolean", description: "Whether the pipeline check passed.", }, summary: { type: "string", description: "Short reason. Required when success is false.", }, }, required: ["success"], }, execute: async (_toolCallId, args) => { const report = parsePipelineReport(args); if (!report.success) { throw new Error(report.summary ?? "Pipeline reported success: false"); } return { content: [{ type: "text", text: "pipeline: success" }], }; }, };}
async function main(): Promise<void> { const apiKey = requiredEnv("LETTA_API_KEY"); const agentId = requiredEnv("LETTA_AGENT_ID"); const repoUrl = validateRepositoryUrl(requiredEnv("TANGLED_REPO_URL")); const pipelineKind = validatePipelineKind(process.env.TANGLED_PIPELINE_KIND ?? "local"); const model = process.env.LETTA_MODEL?.trim();
const runnerSha = validateGitSha(git("rev-parse", "HEAD"), "runner checkout SHA"); const triggerSha = process.env.TANGLED_SHA ?? process.env.TANGLED_COMMIT_SHA; if (triggerSha && validateGitSha(triggerSha, "Tangled trigger SHA") !== runnerSha) { throw new Error( `Tangled trigger SHA does not match the runner checkout: ${triggerSha} != ${runnerSha}`, ); }
const expectedFiles = git("ls-tree", "--name-only", "HEAD") .split(/\r?\n/) .filter(Boolean) .sort();
const client = new LettaAgentClient({ backend: "cloud", apiKey, sandbox: { ttlMinutes: 10, readyTimeoutMs: 120_000, terminateOnClose: true, }, });
console.log(`agent: ${agentId}`); console.log(`model: ${model || "(agent default)"}`);
const checkoutCommands = [ "rm -rf repo", "git init repo", `git -C repo remote add origin ${shellQuote(repoUrl)}`, `git -C repo fetch --depth 1 origin ${shellQuote(runnerSha)}`, "git -C repo checkout --detach FETCH_HEAD", ].join(" && ");
const message = [ "This is a CI run.", `Pipeline kind: ${pipelineKind}`, `Expected commit: ${runnerSha}`, `Public clone URL: ${repoUrl}`, "", "You are in a Letta Cloud sandbox, not on the Tangled runner.", "The runner already cloned this repo; you cannot see that checkout.", "Inspect the repository with tools. Do not read or execute files from it.", "", "Suggested sandbox commands:", `1. ${checkoutCommands}`, "2. git -C repo rev-parse HEAD", "3. git -C repo ls-tree --name-only HEAD | sort", "", `Then call ${PIPELINE_TOOL_NAME} exactly once:`, "- success: true if the sandbox checkout matches the expected commit and tracked names", "- success: false with a short summary if it does not", "", "The pipeline verdict is that tool call. Do not write a text receipt.", ].join("\n");
const session = client.createSession(agentId, { permissionMode: "unrestricted", tools: [createPipelineTool()], ...(model ? { model } : {}), }); const transcript = createTranscriptAccumulator(); let result: SDKResultMessage | undefined;
try { await session.send(message); for await (const event of session.stream()) { transcript.apply(event); if (event.type === "result") result = event; } } finally { session.close(); }
if (!result) { throw new Error("Agent stream ended without a terminal result"); }
console.log(`conversation: ${result.conversationId ?? "(none)"}`); console.log(`duration_ms: ${result.durationMs}`); if (result.totalCostUsd !== undefined) { console.log(`cost_usd: ${result.totalCostUsd}`); }
if (!result.success) { console.error(result.error ?? result.errorDetail ?? "agent turn failed"); process.exit(1); }
const transcriptRows = transcript.rows(); const toolRows = transcriptRows.filter((row): row is TranscriptToolCallRow => row.kind === "tool_call"); const calls = toolRows.map((row) => ({ toolCallId: row.toolCallId, toolName: row.toolName, toolInput: row.toolInput, })); const results = toolRows.flatMap((row) => row.result ? [ { toolCallId: row.toolCallId, content: row.result.content, isError: row.result.isError, }, ] : [], ); const expectedCheckout = { sha: runnerSha, files: expectedFiles, };
const toolReceiptCount = validateSandboxEvidence(calls, results, expectedCheckout); validatePipelineReport(calls, results); console.log(`tool_receipts: ${toolReceiptCount} successful`); console.log("pipeline: success"); console.log(`checkout: ${runnerSha}`); console.log(`files: ${expectedFiles.join(", ")}`);}
main().catch((error: unknown) => { console.error(error instanceof Error ? error.message : error); process.exit(1);});