Skip to main content

Creating Agents

Build custom agents that do exactly what you need. This guide covers the full agent development lifecycle. Already read Your First Agent? This goes deeper.

Agent Structure

agents/
 my-agent/
     agent.yaml          # Agent definition
     agent.test.ts       # Tests
     README.md           # Documentation (optional)

Complete Agent Example

# agents/company-enricher/agent.yaml
agent: company-enricher
description: Enriches company data from multiple sources with fallbacks

inputs:
  company_name:
    type: string
    required: true
    description: Company name to enrich
  include_news:
    type: boolean
    default: false
    description: Whether to include recent news

cache:
  ttl: 86400  # Cache entire agent for 24 hours
  key: enrich-${input.company_name}

operations:
  # Step 1: Search for company
  - name: search
    operation: http
    config:
      url: https://api.duckduckgo.com/?q=${input.company_name}+official+website&format=json
      method: GET
    cache:
      ttl: 86400
      key: search-${input.company_name}
    retry:
      maxAttempts: 3
      backoff: exponential
      initialDelay: 1000

  # Step 2: Scrape website
  - name: scrape-primary
    operation: http
    config:
      url: ${search.output.AbstractURL}
      method: GET
      timeout: 10000
    retry:
      maxAttempts: 2

  # Step 3: Fallback scrape if primary fails
  - name: scrape-fallback
    condition: ${scrape-primary.failed}
    operation: http
    config:
      url: https://www.crunchbase.com/organization/${input.company_name}
      method: GET

  # Step 4: Extract with AI
  - name: extract
    operation: think
    config:
      provider: openai
      model: gpt-4o-mini
      temperature: 0.3
      maxTokens: 500
      prompt: |
        Extract company info from this HTML:
        ${scrape-primary.output?.body || scrape-fallback.output?.body}

        Return JSON with:
        {
          "name": "Company name",
          "description": "Brief description (1-2 sentences)",
          "industry": "Primary industry",
          "founded": "Year founded if available"
        }
    cache:
      ttl: 3600
      key: extract-${input.company_name}

  # Step 5: Optionally fetch news
  - name: fetch-news
    condition: ${input.include_news && extract.executed}
    operation: http
    config:
      url: https://newsapi.org/v2/everything?q=${input.company_name}&sortBy=publishedAt&pageSize=5
      headers:
        Authorization: Bearer ${env.NEWS_API_KEY}
    cache:
      ttl: 3600

  # Step 6: Store in cache
  - name: cache-result
    condition: ${extract.executed}
    operation: storage
    config:
      type: kv
      action: put
      key: company-${input.company_name}
      value:
        company_data: ${extract.output}
        news: ${fetch-news.output?.body?.articles}
        cached_at: ${Date.now()}
      expirationTtl: 86400

outputs:
  company_data: ${extract.output}
  news: ${fetch-news.output?.body?.articles}
  source_url: ${search.output.AbstractURL}
  from_cache: ${__cache_hit}

Caching Strategies

Agent-Level Caching

Cache the entire agent execution:
agent: expensive-agent

cache:
  ttl: 3600
  key: expensive-${input.id}

operations:
  # All operations run if cache miss
  # Skip all if cache hit

Operation-Level Caching

Cache specific operations:
operations:
  - name: expensive-scrape
    operation: http
    config:
      url: ${input.url}
    cache:
      ttl: 86400  # 24 hours
      key: scrape-${input.url}

  - name: ai-analysis
    operation: think
    config:
      prompt: Analyze: ${expensive-scrape.output}
    cache:
      ttl: 3600  # 1 hour
      key: analyze-${input.url}

Dynamic Cache Keys

operations:
  - name: cached-op
    operation: think
    config:
      prompt: ${input.text}
    cache:
      ttl: ${input.cache_duration || 3600}
      key: ${input.cache_key || `default-${input.text}`}

Retry Logic

Basic Retry

operations:
  - name: flaky-api
    operation: http
    config:
      url: https://api.example.com
    retry:
      maxAttempts: 3
      backoff: exponential
      initialDelay: 1000

Advanced Retry

operations:
  - name: smart-retry
    operation: http
    config:
      url: https://api.example.com
    retry:
      maxAttempts: 5
      backoff: exponential  # 1s, 2s, 4s, 8s, 16s
      initialDelay: 1000
      maxDelay: 30000       # Cap at 30s
      retryOn: [500, 502, 503, 504]  # Only retry these codes
      timeout: 10000        # Timeout per attempt

Conditional Retry

operations:
  - name: try-operation
    operation: http
    config:
      url: https://api.example.com
    retry:
      maxAttempts: 2

  - name: check-retry-count
    operation: code
    config:
      code: |
        return {
          should_retry: ${try-operation.retry_count} < 3,
          count: ${try-operation.retry_count}
        };

Error Handling

Fallback Pattern

operations:
  - name: primary
    operation: http
    config:
      url: https://primary-api.com
    retry:
      maxAttempts: 2

  - name: secondary
    condition: ${primary.failed}
    operation: http
    config:
      url: https://backup-api.com

  - name: cached
    condition: ${primary.failed && secondary.failed}
    operation: storage
    config:
      type: kv
      action: get
      key: fallback-data

outputs:
  data: ${primary.output || secondary.output || cached.output}
  source: ${primary.executed ? 'primary' : secondary.executed ? 'secondary' : 'cache'}

Error Logging

operations:
  - name: risky-operation
    operation: http
    config:
      url: https://api.example.com

  - name: log-error
    condition: ${risky-operation.failed}
    operation: storage
    config:
      type: d1
      query: |
        INSERT INTO error_log (agent, operation, error, timestamp)
        VALUES (?, ?, ?, ?)
      params:
        - company-enricher
        - risky-operation
        - ${risky-operation.error}
        - ${Date.now()}

Graceful Degradation

operations:
  - name: enhanced-analysis
    operation: think
    config:
      provider: openai
      model: gpt-4o
      prompt: ${input.text}

  - name: basic-analysis
    condition: ${enhanced-analysis.failed}
    operation: think
    config:
      provider: cloudflare
      model: '@cf/meta/llama-3.1-8b-instruct'
      prompt: ${input.text}

outputs:
  analysis: ${enhanced-analysis.output || basic-analysis.output}
  quality: ${enhanced-analysis.executed ? 'enhanced' : 'basic'}

Testing Agents

Unit Tests

// agents/company-enricher/agent.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { TestConductor } from '@ensemble-edge/conductor/testing';

describe('company-enricher agent', () => {
  let conductor: TestConductor;

  beforeEach(async () => {
    conductor = await TestConductor.create();
    await conductor.loadProject('./');
  });

  it('should enrich company data', async () => {
    const result = await conductor.executeAgent('company-enricher', {
      company_name: 'Anthropic',
      include_news: false
    });

    expect(result).toBeSuccessful();
    expect(result.output.company_data).toHaveProperty('name');
    expect(result.output.company_data).toHaveProperty('description');
    expect(result.output.company_data).toHaveProperty('industry');
  });

  it('should include news when requested', async () => {
    const result = await conductor.executeAgent('company-enricher', {
      company_name: 'Anthropic',
      include_news: true
    });

    expect(result.output.news).toBeDefined();
    expect(Array.isArray(result.output.news)).toBe(true);
  });

  it('should use cache on second call', async () => {
    // First call
    const result1 = await conductor.executeAgent('company-enricher', {
      company_name: 'Anthropic'
    });

    // Second call
    const result2 = await conductor.executeAgent('company-enricher', {
      company_name: 'Anthropic'
    });

    expect(result1.output.from_cache).toBe(false);
    expect(result2.output.from_cache).toBe(true);
  });

  it('should handle scraping failures with fallback', async () => {
    // Mock primary scrape failure
    conductor.mockOperation('scrape-primary', {
      failed: true,
      error: 'Timeout'
    });

    const result = await conductor.executeAgent('company-enricher', {
      company_name: 'Anthropic'
    });

    expect(result).toBeSuccessful();
    expect(result.operations['scrape-fallback'].executed).toBe(true);
  });

  it('should retry failed operations', async () => {
    conductor.mockOperation('search', {
      failed: true,
      retry_count: 2
    });

    const result = await conductor.executeAgent('company-enricher', {
      company_name: 'Anthropic'
    });

    expect(result.operations.search.retry_count).toBeGreaterThan(0);
  });
});

Integration Tests

describe('company-enricher integration', () => {
  it('should work end-to-end with real APIs', async () => {
    const conductor = await TestConductor.create({
      env: {
        NEWS_API_KEY: process.env.NEWS_API_KEY
      }
    });
    await conductor.loadProject('./');

    const result = await conductor.executeAgent('company-enricher', {
      company_name: 'Anthropic',
      include_news: true
    });

    expect(result).toBeSuccessful();
    expect(result.output.company_data.name).toContain('Anthropic');
    expect(result.output.news.length).toBeGreaterThan(0);
    expect(result.output.source_url).toMatch(/https?:\/\/.+/);
  });
});

Mock Data

describe('company-enricher with mocks', () => {
  it('should handle mocked responses', async () => {
    const conductor = await TestConductor.create();
    await conductor.loadProject('./');

    // Mock search result
    conductor.mockOperation('search', {
      output: {
        AbstractURL: 'https://anthropic.com'
      }
    });

    // Mock scrape result
    conductor.mockOperation('scrape-primary', {
      output: {
        body: '<html><body>Anthropic is an AI safety company...</body></html>'
      }
    });

    const result = await conductor.executeAgent('company-enricher', {
      company_name: 'Anthropic'
    });

    expect(result).toBeSuccessful();
  });
});

Performance Optimization

Parallel Operations

operations:
  # These run in parallel (no dependencies)
  - name: fetch-a
    operation: http
    config:
      url: https://api-a.com

  - name: fetch-b
    operation: http
    config:
      url: https://api-b.com

  - name: fetch-c
    operation: http
    config:
      url: https://api-c.com

  # This waits for all 3
  - name: merge
    operation: code
    config:
      code: |
        return {
          a: ${fetch-a.output},
          b: ${fetch-b.output},
          c: ${fetch-c.output}
        };

Minimize Dependencies

# Bad: Sequential
operations:
  - name: step1
    operation: http
    config:
      url: https://api.com/step1

  - name: step2
    operation: http
    config:
      url: https://api.com/step2
      data: ${step1.output}  # Creates dependency

# Good: Parallel
operations:
  - name: step1
    operation: http
    config:
      url: https://api.com/step1

  - name: step2
    operation: http
    config:
      url: https://api.com/step2

  - name: combine
    operation: code
    config:
      code: return { ...${step1.output}, ...${step2.output} };

Early Termination

operations:
  # Quick check first
  - name: quick-check
    operation: code
    config:
      code: return { valid: ${input.data}.length > 0 };

  # Only run expensive ops if valid
  - name: expensive-analysis
    condition: ${quick-check.output.valid}
    operation: think
    config:
      provider: openai
      model: gpt-4o
      prompt: ${input.data}

Best Practices

  1. Single Responsibility - One agent, one job
  2. Cache Aggressively - Cache expensive operations
  3. Handle Failures - Always have fallbacks
  4. Use Retry Logic - For transient failures
  5. Test Thoroughly - Unit and integration tests
  6. Document Inputs/Outputs - Clear schemas
  7. Monitor Performance - Track execution times
  8. Version with Edgit - Track changes over time

Next Steps