From 1d809ec778244921cda072eb3744f36e28b3c1b4 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Sat, 18 Oct 2025 22:07:10 -0400 Subject: [PATCH] Add plot agent generate command --- .changeset/bold-humans-throw.md | 5 + sdk/cli/commands/deploy.ts | 183 ++++++------ sdk/cli/commands/generate.ts | 305 +++++++++++++++++++ sdk/cli/index.ts | 23 +- sdk/package.json | 4 + sdk/src/agents-guide.ts | 512 ++++++++++++++++++++++++++++++++ sdk/src/tools/agent.ts | 86 +++++- 7 files changed, 1012 insertions(+), 106 deletions(-) create mode 100644 .changeset/bold-humans-throw.md create mode 100644 sdk/cli/commands/generate.ts create mode 100644 sdk/src/agents-guide.ts diff --git a/.changeset/bold-humans-throw.md b/.changeset/bold-humans-throw.md new file mode 100644 index 0000000..e0e436c --- /dev/null +++ b/.changeset/bold-humans-throw.md @@ -0,0 +1,5 @@ +--- +"@plotday/sdk": minor +--- + +Add plot agent generate command diff --git a/sdk/cli/commands/deploy.ts b/sdk/cli/commands/deploy.ts index 1b31beb..499cb22 100644 --- a/sdk/cli/commands/deploy.ts +++ b/sdk/cli/commands/deploy.ts @@ -9,13 +9,13 @@ import { bundleAgent } from "../utils/bundle"; interface DeployOptions { dir: string; - spec?: string; id?: string; deployToken?: string; apiUrl: string; name?: string; description?: string; environment?: string; + dryRun?: boolean; } interface PackageJson { @@ -32,40 +32,19 @@ interface PackageJson { env?: Record; } +interface AgentSource { + dependencies: Record; + files: Record; +} + export async function deployCommand(options: DeployOptions) { const agentPath = path.resolve(process.cwd(), options.dir); - // Read spec file if provided - let specContent: string | undefined; - if (options.spec) { - const specPath = path.resolve(process.cwd(), options.spec); - if (!fs.existsSync(specPath)) { - out.error(`Spec file not found: ${options.spec}`); - process.exit(1); - } - try { - specContent = fs.readFileSync(specPath, "utf-8"); - } catch (error) { - out.error("Failed to read spec file", String(error)); - process.exit(1); - } - } - - // Verify we're in an agent directory by checking for package.json - // Package.json is required when deploying source code, optional for spec + // Check for package.json const packageJsonPath = path.join(agentPath, "package.json"); let packageJson: PackageJson | undefined; - if (!options.spec) { - // Source code deployment requires package.json - if (!fs.existsSync(packageJsonPath)) { - out.error( - "package.json not found. Are you in an agent directory?", - "Run this command from your agent's root directory, or use --spec to deploy from a spec file" - ); - process.exit(1); - } - + if (fs.existsSync(packageJsonPath)) { // Read and validate package.json try { const packageJsonContent = fs.readFileSync(packageJsonPath, "utf-8"); @@ -74,13 +53,41 @@ export async function deployCommand(options: DeployOptions) { out.error("Failed to parse package.json", String(error)); process.exit(1); } - } else if (fs.existsSync(packageJsonPath)) { - // Optional: read package.json for defaults when using spec - try { - const packageJsonContent = fs.readFileSync(packageJsonPath, "utf-8"); - packageJson = JSON.parse(packageJsonContent); - } catch { - // Ignore errors, package.json is optional for spec deployments + } else { + // No package.json - check for plot-agent.md as fallback + const specPath = path.join(agentPath, "plot-agent.md"); + if (fs.existsSync(specPath)) { + out.info( + "No package.json found, but plot-agent.md exists", + ["Generating agent from spec first..."] + ); + + // Import and run generate command + const { generateCommand } = await import("./generate"); + await generateCommand({ + dir: options.dir, + id: options.id, + deployToken: options.deployToken, + apiUrl: options.apiUrl, + }); + + // Re-read the generated package.json + try { + const packageJsonContent = fs.readFileSync(packageJsonPath, "utf-8"); + packageJson = JSON.parse(packageJsonContent); + } catch (error) { + out.error("Failed to read generated package.json", String(error)); + process.exit(1); + } + + out.blank(); + out.progress("Continuing with deployment..."); + } else { + out.error( + "Neither package.json nor plot-agent.md found", + "Run 'plot agent create' to create a new agent, or create a plot-agent.md spec file" + ); + process.exit(1); } } @@ -91,26 +98,8 @@ export async function deployCommand(options: DeployOptions) { const environment = options.environment || "personal"; - // For spec deployments without package.json, require CLI options - if (options.spec && !packageJson) { - if (!options.id) { - out.error( - "Agent ID is required when deploying spec without package.json", - "Provide --id flag" - ); - process.exit(1); - } - if (!options.name) { - out.error( - "Agent name is required when deploying spec without package.json", - "Provide --name flag" - ); - process.exit(1); - } - } - - // Validate required fields for source code deployments - if (!options.spec && !agentName) { + // Validate required fields + if (!agentName) { out.error( "package.json is missing displayName", 'Add "displayName": "Your Agent Name" to package.json' @@ -200,55 +189,47 @@ export async function deployCommand(options: DeployOptions) { } } - // Prepare request body based on spec or source code + // Build the agent let requestBody: { - module?: string; - spec?: string; + module: string; name: string; description?: string; environment: string; + dryRun?: boolean; }; try { - if (options.spec && specContent) { - // Deploying from spec - out.progress(`Deploying ${deploymentName} from spec...`); - - requestBody = { - spec: specContent, - name: deploymentName!, - description: deploymentDescription, - environment: environment, - }; - } else { - // Deploying from source code - build the agent - out.progress(`Building ${deploymentName}...`); + out.progress( + options.dryRun + ? `Validating ${deploymentName}...` + : `Building ${deploymentName}...` + ); - const result = await bundleAgent(agentPath, { - minify: false, - sourcemap: true, - }); + const result = await bundleAgent(agentPath, { + minify: false, + sourcemap: true, + }); - const moduleContent = result.code; + const moduleContent = result.code; - if (result.warnings.length > 0) { - out.warning("Build completed with warnings"); - for (const warning of result.warnings.slice(0, 5)) { - console.warn(` ${warning}`); - } - if (result.warnings.length > 5) { - console.warn(` ... and ${result.warnings.length - 5} more warnings`); - } + if (result.warnings.length > 0) { + out.warning("Build completed with warnings"); + for (const warning of result.warnings.slice(0, 5)) { + console.warn(` ${warning}`); + } + if (result.warnings.length > 5) { + console.warn(` ... and ${result.warnings.length - 5} more warnings`); } - - requestBody = { - module: moduleContent, - name: deploymentName!, - description: deploymentDescription, - environment: environment, - }; } + requestBody = { + module: moduleContent, + name: deploymentName!, + description: deploymentDescription, + environment: environment, + dryRun: options.dryRun, + }; + // Validate all required deployment fields if (!deploymentName) { out.error( @@ -291,6 +272,24 @@ export async function deployCommand(options: DeployOptions) { const result = (await response.json()) as any; + // Handle dryRun response + if (options.dryRun) { + if (result.errors && result.errors.length > 0) { + out.error("Validation failed"); + for (const error of result.errors) { + console.error(` ${error}`); + } + process.exit(1); + } else { + out.success("Validation passed - agent is ready to deploy"); + out.info( + "Run without --dry-run to deploy", + [`plot agent deploy`] + ); + } + return; + } + // Show dependencies from API response const dependencies = result.dependencies; if (dependencies && dependencies.length > 0) { diff --git a/sdk/cli/commands/generate.ts b/sdk/cli/commands/generate.ts new file mode 100644 index 0000000..eb85a89 --- /dev/null +++ b/sdk/cli/commands/generate.ts @@ -0,0 +1,305 @@ +import * as dotenv from "dotenv"; +import * as fs from "fs"; +import * as path from "path"; +import prompts from "prompts"; + +import * as out from "../utils/output"; +import { getGlobalTokenPath } from "../utils/token"; + +interface GenerateOptions { + dir: string; + spec?: string; + id?: string; + deployToken?: string; + apiUrl: string; +} + +interface AgentSource { + dependencies: Record; + files: Record; +} + +/** + * Check if files exist and prompt to overwrite + */ +async function promptOverwrite( + files: string[] +): Promise<{ overwrite: boolean; skip: Set }> { + const existingFiles = files.filter((file) => fs.existsSync(file)); + + if (existingFiles.length === 0) { + return { overwrite: true, skip: new Set() }; + } + + out.warning( + `${existingFiles.length} file(s) already exist`, + existingFiles.map((file) => path.basename(file)) + ); + + const response = await prompts({ + type: "select", + name: "action", + message: "What would you like to do?", + choices: [ + { title: "Overwrite all", value: "overwrite" }, + { title: "Skip existing files", value: "skip" }, + { title: "Cancel generation", value: "cancel" }, + ], + }); + + if (response.action === "cancel") { + out.plain("\nGeneration cancelled."); + process.exit(0); + } + + if (response.action === "skip") { + return { overwrite: false, skip: new Set(existingFiles) }; + } + + return { overwrite: true, skip: new Set() }; +} + +export async function generateCommand(options: GenerateOptions) { + const agentPath = path.resolve(process.cwd(), options.dir); + + // Determine spec file path (default to plot-agent.md) + const specPath = options.spec + ? path.resolve(process.cwd(), options.spec) + : path.join(agentPath, "plot-agent.md"); + + // Check if spec file exists + if (!fs.existsSync(specPath)) { + out.error( + `Spec file not found: ${path.relative(process.cwd(), specPath)}`, + 'Create a plot-agent.md file describing your agent, or use --spec to specify a different file' + ); + process.exit(1); + } + + // Read spec content + let specContent: string; + try { + specContent = fs.readFileSync(specPath, "utf-8"); + } catch (error) { + out.error("Failed to read spec file", String(error)); + process.exit(1); + } + + // Try to read package.json for agent ID + const packageJsonPath = path.join(agentPath, "package.json"); + let agentId = options.id; + + if (!agentId && fs.existsSync(packageJsonPath)) { + try { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")); + agentId = packageJson.plotAgentId; + } catch { + // Ignore errors + } + } + + // Require agent ID + if (!agentId) { + out.error( + "Agent ID is required", + "Run 'plot agent create' to generate one, or provide --id flag" + ); + process.exit(1); + } + + // Load DEPLOY_TOKEN from multiple sources + let deployToken = options.deployToken; + const envPath = path.join(agentPath, ".env"); + + if (!deployToken) { + deployToken = process.env.PLOT_DEPLOY_TOKEN; + } + + if (!deployToken && fs.existsSync(envPath)) { + const envConfig = dotenv.parse(fs.readFileSync(envPath)); + deployToken = envConfig.DEPLOY_TOKEN; + } + + if (!deployToken) { + const globalTokenPath = getGlobalTokenPath(); + if (fs.existsSync(globalTokenPath)) { + try { + deployToken = fs.readFileSync(globalTokenPath, "utf-8").trim(); + } catch (error) { + console.warn( + `Warning: Failed to read global token file: ${globalTokenPath}` + ); + } + } + } + + // Prompt for token if not found + if (!deployToken) { + out.info("Authentication required", [ + "Run 'plot login' for easiest setup", + "Or provide token via --deploy-token, PLOT_DEPLOY_TOKEN env var, or DEPLOY_TOKEN in .env", + ]); + + const response = await prompts({ + type: "password", + name: "token", + message: "Enter your deployment token:", + validate: (value: string) => value.length > 0 || "Token is required", + }); + + if (!response.token) { + out.plain("\nGeneration cancelled."); + process.exit(0); + } + + deployToken = response.token; + + // Save token to .env file for future use + const envContent = fs.existsSync(envPath) + ? fs.readFileSync(envPath, "utf-8") + : ""; + + if (!envContent.includes("DEPLOY_TOKEN=")) { + const newEnvContent = + envContent + + (envContent.endsWith("\n") || !envContent ? "" : "\n") + + `DEPLOY_TOKEN=${deployToken}\n`; + fs.writeFileSync(envPath, newEnvContent); + out.success("Token saved to .env file"); + } + } + + // Call generate API + try { + out.progress("Generating agent from spec..."); + + const response = await fetch(`${options.apiUrl}/v1/agent/${agentId}/generate`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${deployToken}`, + }, + body: JSON.stringify({ spec: specContent }), + }); + + if (!response.ok) { + const errorText = await response.text(); + out.error( + `Generation failed: ${response.status} ${response.statusText}`, + errorText + ); + process.exit(1); + } + + const source = (await response.json()) as AgentSource; + + // Create agent directory if it doesn't exist + if (!fs.existsSync(agentPath)) { + fs.mkdirSync(agentPath, { recursive: true }); + } + + // Prepare list of files that will be created + const packageJsonPath = path.join(agentPath, "package.json"); + const tsconfigPath = path.join(agentPath, "tsconfig.json"); + const readmePath = path.join(agentPath, "README.md"); + const agentsMdPath = path.join(agentPath, "AGENTS.md"); + const claudeMdPath = path.join(agentPath, "CLAUDE.md"); + + const srcPath = path.join(agentPath, "src"); + const sourceFiles = Object.keys(source.files).map((filename) => + path.join(srcPath, filename) + ); + + const allFiles = [ + packageJsonPath, + tsconfigPath, + readmePath, + agentsMdPath, + claudeMdPath, + ...sourceFiles, + ]; + + // Check for existing files and prompt to overwrite + const { skip } = await promptOverwrite(allFiles); + + // Helper to write file if not skipped + const writeFile = (filePath: string, content: string) => { + if (!skip.has(filePath)) { + fs.writeFileSync(filePath, content); + out.success(`Created ${path.relative(agentPath, filePath)}`); + } else { + out.info(`Skipped ${path.relative(agentPath, filePath)}`); + } + }; + + // Write package.json + const packageJson = { + name: agentId, + version: "1.0.0", + plotAgentId: agentId, + dependencies: source.dependencies, + }; + writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2) + "\n"); + + // Write tsconfig.json + const tsconfigContent = { + extends: "@plotday/sdk/tsconfig.base.json", + include: ["src/*.ts"], + }; + writeFile(tsconfigPath, JSON.stringify(tsconfigContent, null, 2) + "\n"); + + // Load and write template files + const templatesPath = path.join(__dirname, "..", "..", "bin", "templates"); + + // Write AGENTS.md (no template processing needed) + const agentsMdTemplate = fs.readFileSync( + path.join(templatesPath, "AGENTS.template.md"), + "utf-8" + ); + writeFile(agentsMdPath, agentsMdTemplate); + + // Write CLAUDE.md (no template processing needed) + const claudeMdTemplate = fs.readFileSync( + path.join(templatesPath, "CLAUDE.template.md"), + "utf-8" + ); + writeFile(claudeMdPath, claudeMdTemplate); + + // Write README.md with template replacements + let readmeTemplate = fs.readFileSync( + path.join(templatesPath, "README.template.md"), + "utf-8" + ); + // Replace template variables + const displayName = packageJson.name + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); + readmeTemplate = readmeTemplate.replace(/\{\{displayName\}\}/g, displayName); + readmeTemplate = readmeTemplate.replace(/\{\{packageManager\}\}/g, "pnpm"); + writeFile(readmePath, readmeTemplate); + + // Create src directory + if (!fs.existsSync(srcPath)) { + fs.mkdirSync(srcPath, { recursive: true }); + } + + // Write source files + for (const [filename, content] of Object.entries(source.files)) { + const filePath = path.join(srcPath, filename); + writeFile(filePath, content); + } + + out.blank(); + out.success("Agent generated successfully!"); + + out.nextSteps([ + "Review the generated code in src/", + "Run 'plot agent lint' to check for errors", + "Run 'plot agent deploy' to deploy your agent", + ]); + } catch (error) { + out.error("Generation failed", String(error)); + process.exit(1); + } +} diff --git a/sdk/cli/index.ts b/sdk/cli/index.ts index f6e98f4..e56c026 100644 --- a/sdk/cli/index.ts +++ b/sdk/cli/index.ts @@ -6,6 +6,7 @@ import { join } from "path"; import { buildCommand } from "./commands/build"; import { createCommand } from "./commands/create"; import { deployCommand } from "./commands/deploy"; +import { generateCommand } from "./commands/generate"; import { lintCommand } from "./commands/lint"; import { loginCommand } from "./commands/login"; import { priorityCreateCommand } from "./commands/priority-create"; @@ -69,15 +70,33 @@ agent .option("-d, --dir ", "Agent directory to build", process.cwd()) .action(buildCommand); +agent + .command("generate") + .description("Generate agent code from a spec file") + .option("-d, --dir ", "Agent directory to generate in", process.cwd()) + .option("--spec ", "Spec file to generate from (defaults to plot-agent.md)") + .option("--id ", "Agent ID") + .option("--deploy-token ", "Authentication token") + .action(function (this: Command) { + const opts = this.optsWithGlobals() as { + dir: string; + spec?: string; + id?: string; + deployToken?: string; + apiUrl: string; + }; + return generateCommand(opts); + }); + agent .command("deploy") .description("Bundle and deploy the agent") .option("-d, --dir ", "Agent directory to deploy", process.cwd()) - .option("--spec ", "Spec file to deploy (markdown)") .option("--id ", "Agent ID for deployment") .option("--deploy-token ", "Authentication token for deployment") .option("--name ", "Agent name") .option("--description ", "Agent description") + .option("--dry-run", "Validate without deploying") .option( "-e, --environment ", "Deployment environment (personal, private, review)", @@ -86,13 +105,13 @@ agent .action(function (this: Command) { const opts = this.optsWithGlobals() as { dir: string; - spec?: string; id?: string; deployToken?: string; apiUrl: string; name?: string; description?: string; environment?: string; + dryRun?: boolean; }; return deployCommand(opts); }); diff --git a/sdk/package.json b/sdk/package.json index 1110d59..a0932a1 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -62,6 +62,10 @@ "types": "./dist/common/calendar.d.ts", "default": "./dist/common/calendar.js" }, + "./agents-guide": { + "types": "./dist/agents-guide.d.ts", + "default": "./dist/agents-guide.js" + }, "./tsconfig.base.json": "./tsconfig.base.json" }, "files": [ diff --git a/sdk/src/agents-guide.ts b/sdk/src/agents-guide.ts new file mode 100644 index 0000000..22fdf08 --- /dev/null +++ b/sdk/src/agents-guide.ts @@ -0,0 +1,512 @@ +/** + * Agent Implementation Guide + * + * This guide is used by AI systems to generate Plot agents. + * Single source of truth - manually synced from bin/templates/AGENTS.template.md + * + * To update: Copy content from AGENTS.template.md to this constant. + */ + +export const AGENTS_GUIDE = `# Agent Implementation Guide for LLMs + +This document provides context for AI assistants generating or modifying agent code. + +## Architecture Overview + +Plot agents are TypeScript classes that extend the \`Agent\` base class. Agents interact with external services and Plot's core functionality through a tool-based architecture. + +### Runtime Environment + +**Critical**: All agent and tool functions are executed in a sandboxed, ephemeral environment with limited resources: + +- **Memory is temporary**: Anything stored in memory (e.g. as a variable in the agent/tool object) is lost after the function completes. Use the Store tool instead. Only use memory for temporary caching. +- **Limited CPU time**: Each execution has limited CPU time (typically 10 seconds) and memory (128MB) +- **Use the Run tool**: Queue separate chunks of work with \`run.now(functionName, context)\` +- **Break long operations**: Split large operations into smaller batches that can be processed independently +- **Store intermediate state**: Use the Store tool to persist state between batches +- **Examples**: Syncing large datasets, processing many API calls, or performing batch operations + +## Agent Structure Pattern + +\`\`\`typescript +import { + type Activity, + Agent, + type Priority, + type Tools, +} from "@plotday/sdk"; +import { Plot } from "@plotday/sdk/tools/plot"; + +export default class MyAgent extends Agent { + private plot: Plot; + + constructor(protected tools: Tools) { + super(tools); + this.plot = tools.get(Plot); + // Store, Run, and Callback methods are available directly via this + } + + async activate(priority: Pick) { + // Called when agent is enabled for a priority + // Common actions: request auth, create setup activities + } + + async activity(activity: Activity) { + // Called when an activity is routed to this agent + // Common actions: process external events, update activities + } +} +\`\`\` + +## Tool System + +### Accessing Tools + +All tools are accessed through the \`tools\` parameter in the constructor: + +\`\`\`typescript +constructor(protected tools: Tools) { + super(); + this.toolName = tools.get(ToolClass); +} +\`\`\` + +All \`tools.get()\` calls must occur in the constructor as they are used for dependency analysis. + +### Built-in Tools (Always Available) + +#### Plot Tool + +Core functionality for managing activities: + +\`\`\`typescript +import { Plot } from "@plotday/sdk/tools/plot"; + +// Create activity +await this.plot.createActivity({ + type: ActivityType.Task, + title: "Task title", + start: new Date(), + end: null, + links: [], // Optional activity links + parent: { id: "parent-activity-id" }, // Optional parent +}); + +// Update activity +await this.plot.updateActivity(activityId, { + title: "New title", + completed: true, +}); + +// Delete activity +await this.plot.deleteActivity(activityId); + +// Add contacts +await this.plot.addContacts(contacts); +\`\`\` + +#### Store Tool + +Persistent key-value storage (available directly via \`this\`): + +\`\`\`typescript +// Set value (no import needed) +await this.set("key", value); + +// Get value +const value = await this.get("key"); + +// Clear value +await this.clear("key"); + +// Clear all values +await this.clearAll(); +\`\`\` + +**Critical**: Never use instance variables for state. They are lost after function execution. Always use Store methods. + +#### Run Tool + +Queue separate chunks of work (available directly via \`this\`): + +\`\`\`typescript +// Create callback and queue execution (no import needed) +const callback = await this.callback("functionName", { context: "data" }); +await this.run(callback); + +// The function must exist on the agent class +async functionName(args: any, context: { context: string }) { + // Process batch and queue next if needed + if (hasMore) { + const nextCallback = await this.callback("functionName", { context: "next" }); + await this.run(nextCallback); + } +} +\`\`\` + +#### Callback Tool + +Create persistent function references (for webhooks, auth callbacks - available directly via \`this\`): + +\`\`\`typescript +// Create callback (no import needed) +const token = await this.callback("onAuthComplete", { + provider: "google", +}); + +// Pass token to external service or store it +await this.set("webhook_token", token); + +// When callback is invoked, your function is called +async onAuthComplete(authResult: any, context?: any) { + // Handle callback + const provider = context?.provider; +} + +// Clean up +await this.deleteCallback(token); +await this.deleteAllCallbacks(); // Delete all for this agent +\`\`\` + +### External Tools (Add to package.json) + +Add tool dependencies to \`package.json\`: + +\`\`\`json +{ + "dependencies": { + "@plotday/sdk": "workspace:^", + "@plotday/tool-google-calendar": "workspace:^" + } +} +\`\`\` + +#### Common External Tools + +- \`@plotday/tool-google-calendar\`: Google Calendar integration +- \`@plotday/tool-outlook-calendar\`: Outlook Calendar integration +- \`@plotday/tool-google-contacts\`: Google Contacts integration + +## Lifecycle Methods + +### activate(priority: Pick) + +Called when the agent is enabled for a priority. Common patterns: + +**Request Authentication:** + +\`\`\`typescript +async activate(_priority: Pick) { + const callback = await this.callback.create("onAuthComplete", { provider: "google" }); + const authLink = await this.externalTool.requestAuth(callback); + + await this.plot.createActivity({ + type: ActivityType.Task, + title: "Connect your account", + start: new Date(), + links: [authLink], + }); +} +\`\`\` + +**Store Parent Activity for Later:** + +\`\`\`typescript +const activity = await this.plot.createActivity({ + type: ActivityType.Task, + title: "Setup", + start: new Date(), +}); + +await this.set("setup_activity_id", activity.id); +\`\`\` + +### activity(activity: Activity) + +Called when an activity is routed to the agent. Common patterns: + +**Create Activities from External Events:** + +\`\`\`typescript +async activity(activity: Activity) { + await this.plot.createActivity(activity); +} +\`\`\` + +**Update Based on User Action:** + +\`\`\`typescript +async activity(activity: Activity) { + if (activity.completed) { + await this.handleCompletion(activity); + } +} +\`\`\` + +## Activity Links + +Activity links enable user interaction: + +\`\`\`typescript +import { type ActivityLink, ActivityLinkType } from "@plotday/sdk"; + +// URL link +const urlLink: ActivityLink = { + title: "Open website", + type: ActivityLinkType.url, + url: "https://example.com", +}; + +// Callback link (uses Callback tool) +const token = await this.callback.create("onLinkClicked", { data: "context" }); +const callbackLink: ActivityLink = { + title: "Click me", + type: ActivityLinkType.callback, + token: token, +}; + +// Add to activity +await this.plot.createActivity({ + type: ActivityType.Task, + title: "Task with links", + links: [urlLink, callbackLink], +}); +\`\`\` + +## Authentication Pattern + +Common pattern for OAuth authentication: + +\`\`\`typescript +async activate(_priority: Pick) { + // Create callback for auth completion + const callback = await this.callback.create("onAuthComplete", { + provider: "google", + }); + + // Request auth link from tool + const authLink = await this.googleTool.requestAuth(callback); + + // Create activity with auth link + const activity = await this.plot.createActivity({ + type: ActivityType.Task, + title: "Connect Google account", + start: new Date(), + links: [authLink], + }); + + // Store for later use + await this.store.set("auth_activity_id", activity.id); +} + +async onAuthComplete(authResult: { authToken: string }, context?: any) { + const provider = context?.provider; + + // Store auth token + await this.store.set(\`\${provider}_auth\`, authResult.authToken); + + // Continue setup flow + await this.setupSyncOptions(authResult.authToken); +} +\`\`\` + +## Sync Pattern + +Pattern for syncing external data with callbacks: + +\`\`\`typescript +async startSync(calendarId: string): Promise { + const authToken = await this.store.get("auth_token"); + + // Create callback for event handling + const callback = await this.callback.create("handleEvent", { + calendarId, + }); + + await this.calendarTool.startSync(authToken, calendarId, callback); +} + +async handleEvent(activity: Activity, context?: any): Promise { + // Process incoming event from external service + await this.plot.createActivity(activity); +} + +async stopSync(calendarId: string): Promise { + const authToken = await this.store.get("auth_token"); + await this.calendarTool.stopSync(authToken, calendarId); +} +\`\`\` + +## Calendar Selection Pattern + +Pattern for letting users select from multiple calendars/accounts: + +\`\`\`typescript +private async createCalendarSelectionActivity( + provider: string, + calendars: Calendar[], + authToken: string +): Promise { + const links: ActivityLink[] = []; + + for (const calendar of calendars) { + const token = await this.callback.create("onCalendarSelected", { + provider, + calendarId: calendar.id, + calendarName: calendar.name, + authToken, + }); + + links.push({ + title: \`📅 \${calendar.name}\${calendar.primary ? " (Primary)" : ""}\`, + type: ActivityLinkType.callback, + token: token, + }); + } + + await this.plot.createActivity({ + type: ActivityType.Task, + title: "Which calendars would you like to connect?", + start: new Date(), + links, + }); +} + +async onCalendarSelected(link: ActivityLink, context: any): Promise { + // Start sync for selected calendar + const callback = await this.callback.create("handleEvent", { + provider: context.provider, + calendarId: context.calendarId, + }); + + await this.tool.startSync(context.authToken, context.calendarId, callback); +} +\`\`\` + +## Batch Processing Pattern + +**Important**: Because agents run in an ephemeral environment with limited execution time (~10 seconds), you must break long operations into batches. Each batch runs independently in a new execution context. + +### Key Principles + +1. **Store state between batches**: Use the Store tool to persist progress +2. **Queue next batch**: Use the Run tool to schedule the next chunk +3. **Clean up when done**: Delete stored state after completion +4. **Handle failures**: Store enough state to resume if a batch fails + +### Example Implementation + +\`\`\`typescript +async startSync(resourceId: string): Promise { + // Initialize state in Store (persists between executions) + await this.set(\`sync_state_\${resourceId}\`, { + nextPageToken: null, + batchNumber: 1, + itemsProcessed: 0, + }); + + // Queue first batch using run method + const callback = await this.callback("syncBatch", { resourceId }); + await this.run(callback); +} + +async syncBatch(args: any, context: { resourceId: string }): Promise { + // Load state from Store (set by previous execution) + const state = await this.get(\`sync_state_\${context.resourceId}\`); + + // Process one batch (keep under time limit) + const result = await this.fetchBatch(state.nextPageToken); + + // Process results + for (const item of result.items) { + await this.plot.createActivity(item); + } + + if (result.nextPageToken) { + // Update state in Store for next batch + await this.set(\`sync_state_\${context.resourceId}\`, { + nextPageToken: result.nextPageToken, + batchNumber: state.batchNumber + 1, + itemsProcessed: state.itemsProcessed + result.items.length, + }); + + // Queue next batch (runs in new execution context) + const nextCallback = await this.callback("syncBatch", context); + await this.run(nextCallback); + } else { + // Cleanup when complete + await this.clear(\`sync_state_\${context.resourceId}\`); + + // Optionally notify user of completion + await this.plot.createActivity({ + type: ActivityType.Note, + note: \`Sync complete: \${state.itemsProcessed + result.items.length} items processed\`, + }); + } +} +\`\`\` + +## Error Handling + +Always handle errors gracefully and communicate them to users: + +\`\`\`typescript +try { + await this.externalOperation(); +} catch (error) { + console.error("Operation failed:", error); + + await this.plot.createActivity({ + type: ActivityType.Note, + note: \`Failed to complete operation: \${error.message}\`, + }); +} +\`\`\` + +## Common Pitfalls + +1. **Don't use instance variables for state** - Anything stored in memory is lost after function execution. Always use the Store tool for data that needs to persist. +2. **Don't forget timeout limits** - Each execution has ~10 seconds. Break long operations into batches with the Run tool. +3. **Don't assume execution order** - Batches may run on different workers. Store all necessary state between executions. +4. **Always use Callback tool for persistent references** - Direct function references don't survive worker restarts. +5. **Store auth tokens** - Don't re-request authentication unnecessarily. +6. **Clean up callbacks and stored state** - Delete callbacks and Store entries when no longer needed. +7. **Handle missing auth gracefully** - Check for stored auth before operations. +8. **Batch size matters** - Process enough items per batch to be efficient, but few enough to stay under time limits. + +## Type Patterns + +### Entity Types + +Follow Plot's entity type patterns: + +\`\`\`typescript +export type Activity = { + id: string; // Required + type: ActivityType; // Required + title: string | null; // Nullable (not optional) + note: string | null; // Nullable (not optional) + start: Date | string | null; // Nullable (not optional) +}; + +export type NewActivity = { + type: Activity["type"]; // Only type is required +} & Partial>; +\`\`\` + +This pattern distinguishes between: + +- Omitted fields (\`undefined\` in Partial types) +- Explicitly set to null (clearing a value) +- Set to a value + +## Testing + +Before deploying, verify: + +1. Linting passes: \`{{packageManager}} lint\` +2. All dependencies are in package.json +3. Authentication flow works end-to-end +4. Batch operations handle pagination correctly +5. Error cases are handled gracefully +`; diff --git a/sdk/src/tools/agent.ts b/sdk/src/tools/agent.ts index 798800a..5ea2faf 100644 --- a/sdk/src/tools/agent.ts +++ b/sdk/src/tools/agent.ts @@ -1,5 +1,23 @@ import { type Callback, ITool } from ".."; +/** + * Agent source code structure containing dependencies and source files. + */ +export interface AgentSource { + /** + * Package dependencies with version specifiers + * @example { "@plotday/sdk": "workspace:^", "@plotday/tool-google-calendar": "^1.0.0" } + */ + dependencies: Record; + + /** + * Source files with their content + * Must include "index.ts" as the entry point + * @example { "index.ts": "export default class MyAgent extends Agent {...}" } + */ + files: Record; +} + /** * Represents a log entry from an agent execution. */ @@ -48,22 +66,53 @@ export abstract class AgentManager extends ITool { */ abstract create(): Promise; + /** + * Generates agent source code from a specification using AI. + * + * This method uses Claude AI to generate TypeScript source code and dependencies + * from a markdown specification. The generated source is validated by attempting + * to build it, with iterative error correction (up to 3 attempts). + * + * @param spec - Markdown specification describing the agent functionality + * @returns Promise resolving to agent source (dependencies and files) + * @throws When generation fails after maximum attempts + * + * @example + * ```typescript + * const source = await agent.generate(` + * # Calendar Sync Agent + * + * This agent syncs Google Calendar events to Plot activities. + * + * ## Features + * - Authenticate with Google + * - Sync calendar events + * - Create activities from events + * `); + * + * // source.dependencies: { "@plotday/sdk": "workspace:^", ... } + * // source.files: { "index.ts": "export default class..." } + * ``` + */ + abstract generate(_spec: string): Promise; + /** * Deploys an agent programmatically. * * This method provides the same functionality as the plot agent deploy CLI * command, but can be called from within an agent. Accepts either: * - A pre-bundled module (JavaScript code) - * - A spec (markdown text describing the functionality) - not yet implemented + * - A source object (dependencies + files) which is built in a sandbox * * @param options - Deployment configuration * @param options.agentId - Agent ID for deployment - * @param options.module - Pre-bundled agent module code (mutually exclusive with spec) - * @param options.spec - Markdown text describing agent functionality (mutually exclusive with module, not yet implemented) + * @param options.module - Pre-bundled agent module code (mutually exclusive with source) + * @param options.source - Agent source code with dependencies (mutually exclusive with module) * @param options.environment - Target environment (defaults to "personal") * @param options.name - Optional agent name (required for first deploy) * @param options.description - Optional agent description (required for first deploy) - * @returns Promise resolving to deployment result with version + * @param options.dryRun - If true, validates without deploying (returns errors if any) + * @returns Promise resolving to deployment result with version and optional errors * @throws When deployment fails or user lacks access * * @example @@ -78,36 +127,49 @@ export abstract class AgentManager extends ITool { * }); * console.log(`Deployed version ${result.version}`); * - * // Deploy with a spec (not yet implemented, will throw error) + * // Deploy with source + * const source = await agent.generate(spec); * const result = await agent.deploy({ * agentId: 'abc-123-...', - * spec: '# My Agent\n\nDoes something cool', + * source, * environment: 'personal', * name: 'My Agent', * }); + * + * // Validate with dryRun + * const result = await agent.deploy({ + * agentId: 'abc-123-...', + * source, + * dryRun: true, + * }); + * if (result.errors?.length) { + * console.error('Build errors:', result.errors); + * } * ``` */ abstract deploy( - _options: ( + _options: | { agentId: string; module: string; - spec?: never; + source?: never; environment?: "personal" | "private" | "review"; - name: string; + name?: string; description?: string; + dryRun?: boolean; } | { agentId: string; - spec: string; + source: AgentSource; module?: never; environment?: "personal" | "private" | "review"; - name: string; + name?: string; description?: string; + dryRun?: boolean; } - ) ): Promise<{ version: string; + errors?: string[]; }>; /**