Anatomy of an agent

Create a more complex agent with dynamic prompts, tools, structured data, and dependency injection.

Goal

Our goal for this agent is to demonstrate how to automate customer support for a bank (albeit within a limited context). The agent interacts with customers, analyzes their queries, and generates meaningful responses. It showcases how an agent can integrate with a database to retrieve customer-specific information, such as their name and account balance. It also demonstrates how an agent can return a structured response.

Bank agent flow

Define the agent

The first step is to define the agent. In AXAR, an agent is a subclass of Axar's built-in Agent class.

When defining an agent, we need to specify:

  • The LLM the agent should use: This is done using the @model decorator.

  • A high-level prompt or instruction for the agent: This is specified using the class-level @systemPrompt decorator.

  • Its input and output format: These are defined using the @input and @output annotations (along with generics provided in the class declaration).

Adding @inputand @output decorators might seem redundant, but it is necessary because TypeScript does not retain generics information at runtime.

We also want to provide the agent with access to the customerId and a database connection. This is achieved using simple dependency injection through the agent's constructor.

With that our SupportAgent now look like this:

// Specify the AI model used by the agent (e.g., OpenAI GPT-4 mini version).
@model('openai:gpt-4o-mini')
// Provide a system-level prompt to guide the agent's behavior and tone.
@systemPrompt(`
  You are a support agent in our bank. 
  Give the customer support and judge the risk level of their query.
  Reply using the customer's name.
`)
// Define the expected output format of the agent.
@output(SupportResponse)
export class SupportAgent extends Agent<string, SupportResponse> {
  // Initialize with a customer ID and a DB connection to fetch any customer-specific data.
  constructor(
    private customerId: number,
    private db: DatabaseConn,
  ) {
    super();
  }
  // More to be implemented...
}

We have omitted the implementation of DatabaseConn for brevity.

Dynamic prompts

Now that the agent has access to the customerId and the database, we want it to dynamically insert customer-specific context at runtime. To achieve this, we use the @systemPrompt decorator with an instance method inside the agent class.

The purpose of this method is to retrieve the customer's name from the database and include it as part of the agent's context. This way, whenever the agent processes a customer query, it already has the relevant context available.

Here’s how it looks:

// ...
export class SupportAgent extends Agent<string, SupportResponse> {
  // ...

  // Provide additional context for the agent about the customer's details.
  @systemPrompt()
  async getCustomerContext(): Promise<string> {
    // Fetch the customer's name from the database and provide it as context.
    const name = await this.db.customerName(this.customerId);
    return `The customer's name is '${name}'`;
  }
}

Define schema

As you may have noticed, we have yet to define the agent's response schemaSupportResponse. For this support agent, we want the response to include:

  • A support advice: A string containing human-readable advice for the customer.

  • Risk level: A number between 0 and 1 representing the risk level of the customer query.

  • Card blocking: A boolean indicating whether blocking the customer’s card is necessary.

  • Customer’s emotional state: An optional field using an enum (Happy, Sad, or Neutral) to represent the customer’s emotional state.

Since this schema is for an LLM, we want to provide not just the structure of the response but also additional metadata such as descriptions and validation rules. In AXAR, we use annotations to define schemas with this metadata.

Here’s how we define the SupportResponse schema:

@schema()
export class SupportResponse {
  @property('Human-readable advice to give to the customer.')
  support_advice!: string;
  @property("Whether to block customer's card.")
  block_card!: boolean;
  @property('Risk level of query')
  @min(0)
  @max(1)
  risk!: number;
  @property("Customer's emotional state")
  @optional()
  status?: 'Happy' | 'Sad' | 'Neutral';
}

Define tool

What if an agent needs to perform an action or gather additional data based on a request? This is where tools become valuable. Tools allow an agent to access specific functionality, which they can invoke only when needed. In AXAR, tools are defined as instance methods and annotated with @tool.

For this agent, we define a tool that takes a parameter of type ToolParams (containing the customer's name and whether to include pending transactions) and returns the customer's bank balance.

Here’s how it looks:

@schema()
class ToolParams {
  @property("Customer's name")
  customerName!: string;

  @property('Whether to include pending transactions')
  @optional()
  includePending?: boolean;
}

// ...
export class SupportAgent extends Agent<string, SupportResponse> {
  // ...

  // Define a tool (function) accessible to the agent for retrieving the customer's balance.
  @tool("Get customer's current balance")
  async customerBalance(params: ToolParams): Promise<number> {
    // Fetch the customer's balance, optionally including pending transactions.
    return this.db.customerBalance(
      this.customerId,
      params.customerName,
      params.includePending ?? true,
    );
  }
}

As you can see, we have also defined the schema for ToolParams using the @schema annotation. This provides the agent with a clear understanding of the structure of the parameters required by the tool.

Wrapping up

This guide covered how to build a functional, context-aware agent using AXAR AI. With dynamic prompts, schema definitions, and tools, you can create agents tailored to specific tasks and workflows. From here, you can explore more advanced use cases or refine the approach as needed for your applications.

Last updated