Daydreams Logo

Actions

Define capabilities and interactions for your Daydreams agent.

Actions are the primary way Daydreams agents perform tasks, interact with external systems (like APIs or databases), and modify their own state. They are essentially functions that the agent's underlying Large Language Model (LLM) can choose to call based on its reasoning and the current situation.

Think of actions as the tools or capabilities you give your agent.

Defining an Action

You define actions using the action helper function from @daydreamsai/core. Here's the essential structure:

import {
  action,
  type ActionCallContext,
  type AnyAgent,
} from "@daydreamsai/core";
import { z } from "zod";
 
// Define the structure for the action's arguments using Zod
const searchSchema = z.object({
  query: z.string().describe("The specific search term or question"),
  limit: z
    .number()
    .optional()
    .default(10)
    .describe("Maximum number of results to return"),
});
 
// Define the action itself
export const searchDatabaseAction = action({
  // Required: A unique name used by the LLM to call this action.
  // Use clear, descriptive names (e.g., verbNoun).
  name: "searchDatabase",
 
  // Required: A clear description explaining what the action does.
  // This is crucial for the LLM to understand *when* to use this action.
  description:
    "Searches the company knowledge base for records matching the query.",
 
  // Optional (but highly recommended): A Zod schema defining the expected arguments.
  // The framework automatically validates arguments provided by the LLM against this schema.
  // Use `.describe()` on fields to provide hints to the LLM.
  schema: searchSchema,
 
  // Required: The function that executes the action's logic.
  // It receives validated arguments, context information, and the agent instance.
  async handler(args, ctx, agent) {
    // 'args': The validated arguments object, matching the 'schema'.
    // Type is automatically inferred: { query: string; limit: number }
    const { query, limit } = args;
 
    // 'ctx': The ActionCallContext, providing access to memory, signals, etc.
    // 'agent': The main agent instance, for accessing services, logger, etc.
 
    agent.logger.info(
      "searchDatabase",
      `Searching for: "${query}" (limit: ${limit})`
    );
 
    try {
      // --- Perform the action's logic ---
      // Example: Use a service resolved from the agent's container
      // const db = agent.container.resolve<MyDatabaseClient>('database');
      // const results = await db.search(query, { limit });
 
      // Simulate an async database call
      await new Promise((resolve) => setTimeout(resolve, 100));
      const results = [
        { id: "doc1", title: `Result for ${query}` },
        { id: "doc2", title: `Another result for ${query}` },
      ].slice(0, limit);
      // --- End of action logic ---
 
      // Return a structured result. This is logged and becomes available
      // to the LLM in subsequent reasoning steps.
      return {
        success: true,
        count: results.length,
        results: results,
        message: `Found ${results.length} results for "${query}".`,
      };
    } catch (error) {
      agent.logger.error("searchDatabase", "Search failed", { query, error });
      // Return an error structure if the action fails
      return {
        success: false,
        error:
          error instanceof Error ? error.message : "Unknown database error",
        message: `Failed to search database for "${query}".`,
      };
    }
  },
});
 
// Type definition for the ActionCallContext if needed elsewhere
type SearchDbContext = ActionCallContext<typeof searchSchema>;

Working with Action Arguments (args)

The handler function receives the action's arguments in the first parameter (args). These arguments have already been:

  1. Generated by the LLM based on your action's schema.
  2. Parsed and validated by the framework against your schema.

You can directly use the properties defined in your schema within the handler, with type safety if using TypeScript.

Managing State (ctx.memory)

Actions often need to read or modify the persistent state associated with the current context instance (e.g., the specific chat session or project the agent is working on). You access this state via ctx.memory.

import {
  action,
  type ActionCallContext,
  type AnyAgent,
} from "@daydreamsai/core";
import { z } from "zod";
 
// Define the expected structure of the context's memory
interface TaskListMemory {
  tasks?: { id: string; title: string; status: "pending" | "completed" }[];
}
 
// Assume myContext is defined elsewhere and uses TaskListMemory
 
export const addTaskAction = action({
  name: "addTask",
  description: "Adds a new task to the current project's task list.",
  schema: z.object({
    title: z.string().describe("The title of the new task"),
  }),
  handler(args, ctx: ActionCallContext<any, any, TaskListMemory>, agent) {
    // Access the persistent memory for THIS context instance.
    const contextMemory = ctx.memory;
 
    // Initialize the tasks array if it doesn't exist
    if (!Array.isArray(contextMemory.tasks)) {
      contextMemory.tasks = [];
    }
 
    const newTask = {
      id: agent.utils.randomUUIDv7(), // Generate a unique ID
      title: args.title,
      status: "pending" as const,
    };
 
    // --- Modify the context's state ---
    contextMemory.tasks.push(newTask);
    // --- State modification ends ---
 
    // The changes to ctx.memory are automatically saved
    // by the framework at the end of the run cycle.
 
    agent.logger.info("addTask", `Added task: ${newTask.title}`);
 
    return {
      success: true,
      taskId: newTask.id,
      message: `Task "${newTask.title}" added successfully.`,
    };
  },
});

Important Memory Scopes in ctx:

  • ctx.memory: (Most commonly used) Persistent memory for the current context instance. Use this for state related to the specific chat, project, game, etc.
  • ctx.actionMemory: (Less common) Persistent memory tied to the action definition itself, across all contexts. Configure via the memory option in action(). Useful for action-specific counters, rate limits, etc.
  • ctx.agentMemory: Persistent memory for the main agent context (if one was defined globally). Use for global agent settings or state.

Always choose the memory scope that matches where your state needs to live.

Associating Actions with Contexts

While you can define actions globally when calling createDreams, it's often more organized and efficient to associate actions directly with the Context they primarily relate to. This makes the action available only when that specific context is active and ensures the action handler has direct, typed access to that context's memory.

There are two ways to associate actions with a context definition:

1. Using the actions property:

import { context, action, type ActionCallContext } from "@daydreamsai/core";
import { z } from "zod";
 
interface MyContextMemory {
  value: number;
}
 
const myContext = context<MyContextMemory, { id: string }>({
  type: "myContext",
  schema: z.object({ id: string() }),
  create: () => ({ value: 0 }),
  // ... other context properties
 
  // Define actions directly within the context
  actions: [
    action({
      name: "incrementValue",
      description: "Increments the context's value.",
      schema: z.object({ amount: z.number().optional().default(1) }),
      // `ctx.memory` is automatically typed as MyContextMemory here
      handler(args, ctx, agent) {
        ctx.memory.value += args.amount;
        return { success: true, newValue: ctx.memory.value };
      },
    }),
  ],
});

2. Using the .setActions() method (often better for typing):

import { context, action, type ActionCallContext } from "@daydreamsai/core";
import { z } from "zod";
 
interface MyContextMemory {
  value: number;
}
 
const myContextDefinition = context<MyContextMemory, { id: string }>({
  type: "myContext",
  schema: z.object({ id: string() }),
  create: () => ({ value: 0 }),
  // ... other context properties
});
 
const incrementAction = action({
  name: "incrementValue",
  description: "Increments the context's value.",
  schema: z.object({ amount: z.number().optional().default(1) }),
  // Handler defined separately or inline
  handler(
    args,
    ctx: ActionCallContext<any, typeof myContextDefinition>,
    agent
  ) {
    // Type hint might be needed here sometimes, but .setActions() helps inference
    ctx.memory.value += args.amount;
    return { success: true, newValue: ctx.memory.value };
  },
});
 
// Associate actions using the chained method
const myContextWithActions = myContextDefinition.setActions([
  incrementAction,
  // ... other actions specific to myContext
]);

Benefits of Context-Specific Actions:

  • Scoped Availability: The incrementValue action will only appear in the LLM's available actions when an instance of myContext is active in the agent's run.
  • Typed State Access: Within the handler, ctx.memory is correctly typed according to the context's memory interface (MyContextMemory in this example).
  • Organization: Keeps related logic bundled together.

Note: This same pattern applies to defining context-specific Inputs and Outputs using .setInputs({...}) and .setOutputs({...}).

Interacting with External Systems

Actions are the natural place to interact with external APIs, databases, or other services. Remember to use async/await for any I/O operations.

import { action } from "@daydreamsai/core";
import { z } from "zod";
 
export const sendNotificationAction = action({
  name: "sendNotification",
  description: "Sends a notification to a user via an external service.",
  schema: z.object({
    userId: z.string().describe("The ID of the user to notify"),
    message: z.string().describe("The notification message"),
  }),
  async handler(args, ctx, agent) {
    try {
      // Example: Get a service client from the DI container
      // const notificationService = agent.container.resolve<NotificationService>('notify');
      // await notificationService.send(args.userId, args.message);
 
      // Simulate async API call
      agent.logger.debug(
        "sendNotification",
        `Simulating notification to ${args.userId}`
      );
      await new Promise((resolve) => setTimeout(resolve, 150));
 
      return { success: true, message: `Notification sent to ${args.userId}.` };
    } catch (error) {
      agent.logger.error("sendNotification", "Failed to send notification", {
        args,
        error,
      });
      return {
        success: false,
        error: "Service unavailable",
        message: "Could not send notification.",
      };
    }
  },
});

Important Considerations for Handlers

When writing action handlers, keep these points in mind:

  1. Asynchronous Execution: Handlers often perform I/O. Always use async/await for promises. The framework runs handlers concurrently up to a configured limit, so they might not execute instantly if the agent is busy.

  2. Cancellation (ctx.abortSignal): For actions that might run for a long time (e.g., complex calculations, polling), you must check for cancellation requests. The ctx.abortSignal signals if the overall agent run has been cancelled.

    async handler(args, ctx, agent) {
      for (let i = 0; i < 100; i++) {
        // Check for cancellation before long-running work
        if (ctx.abortSignal?.aborted) {
           agent.logger.warn("longAction", "Action cancelled by signal");
           throw new Error("Action cancelled"); // Or return an appropriate state
        }
        // Or use throwIfAborted() which throws if cancelled
        // ctx.abortSignal?.throwIfAborted();
     
        await performStep(i); // Represents potentially long work
      }
      // ...
    }
  3. Retries (retry option): You can configure actions to automatically retry on failure using the retry option in the action definition (e.g., retry: 3). If you use retries, try to make your handler idempotent – running it multiple times with the same args should produce the same final state without unintended side effects (e.g., don't create duplicate records).

  4. Error Handling: Use try...catch blocks within your handler to catch errors from external calls or internal logic. Return a structured error response so the agent (or logs) can understand what went wrong. You can also use the advanced onError hook in the action definition for centralized error handling logic.

How the LLM Chooses Actions

The LLM doesn't directly execute your code. Instead:

  1. The framework presents the name and description of all available actions to the LLM in its prompt.
  2. Based on its instructions and the current context, the LLM decides which action to use.
  3. It formulates the arguments for the action based on the schema you provided (including any .describe() hints).
  4. The framework intercepts the LLM's request, validates the arguments against the schema, and then securely executes your handler function with the validated args.

Therefore, clear names and detailed descriptions are vital for the LLM to use your actions correctly.

Best Practices

  • Clear Naming & Descriptions: Make them unambiguous for the LLM.
  • Precise Schemas: Use Zod effectively, adding .describe() to clarify arguments for the LLM.
  • Structured Returns: Return objects from handlers with clear success/error status and relevant data.
  • Use async/await: Essential for any I/O.
  • Handle Errors: Use try...catch and return meaningful error information.
  • Check for Cancellation: Implement ctx.abortSignal checks in long-running handlers.
  • Consider Idempotency: Especially if using the retry option.
  • Choose the Right Memory Scope: Use ctx.memory for context instance state unless you specifically need action (ctx.actionMemory) or global (ctx.agentMemory) state.
  • Keep Actions Focused: Aim for single responsibility per action.
  • Use agent.logger: Log important steps and errors for debugging.

Actions are the core mechanism for adding custom capabilities and stateful interactions to your Daydreams agents.

On this page