Writing Ensembles
Ensembles orchestrate agents. This guide covers patterns, anti-patterns, and best practices for building production workflows.Ensemble Design Principles
1. Single Responsibility
Each ensemble should accomplish one clear task:Copy
# Good: Clear purpose
ensemble: process-invoice
description: Process customer invoice and send confirmation
# Bad: Too broad
ensemble: handle-everything
description: Does various things
2. Composability
Design agents to be reusable across ensembles:Copy
# Good: Reusable agents
ensemble: customer-onboarding
agents:
- name: validate-email
agent: email-validator # Reusable
- name: create-account
agent: account-creator # Reusable
# Good: Another ensemble reusing same agents
ensemble: newsletter-signup
agents:
- name: validate-email
agent: email-validator # Same agent!
3. Explicit Dependencies
Make data flow obvious:Copy
# Good: Clear dependencies
agents:
- name: fetch
agent: fetcher
- name: process
operation: code
config:
code: return { processed: ${fetch.output} }; # Clear dependency
# Bad: Hidden dependencies
agents:
- name: step1
agent: agent1
- name: step2
agent: agent2 # What does it depend on?
Common Patterns
Sequential Pipeline
Process data through multiple stages:Copy
ensemble: data-pipeline
agents:
- name: fetch
agent: fetcher
inputs:
url: ${input.url}
- name: validate
agent: validator
inputs:
data: ${fetch.output}
- name: transform
agent: transformer
inputs:
data: ${fetch.output}
- name: store
operation: storage
config:
type: d1
query: INSERT INTO data (json) VALUES (?)
params: [${transform.output}]
output:
success: ${store.executed}
Parallel Fan-Out/Fan-In
Process multiple items in parallel:Copy
ensemble: multi-source-aggregator
agents:
# Fan-out: Parallel processing
- name: fetch-api-1
agent: fetcher
inputs:
url: https://api1.com
- name: fetch-api-2
agent: fetcher
inputs:
url: https://api2.com
- name: fetch-api-3
agent: fetcher
inputs:
url: https://api3.com
# Fan-in: Aggregate results
- name: aggregate
operation: code
config:
code: |
return {
results: [
${fetch-api-1.output},
${fetch-api-2.output},
${fetch-api-3.output}
]
};
output:
all_data: ${aggregate.output.results}
Conditional Branching
Route based on conditions:Copy
ensemble: smart-routing
agents:
- name: classify
operation: think
config:
provider: openai
model: gpt-4o-mini
prompt: |
Classify this request: "${input.text}"
Return: urgent, normal, or low
- name: urgent-handler
condition: ${classify.output === 'urgent'}
agent: urgent-processor
- name: normal-handler
condition: ${classify.output === 'normal'}
agent: normal-processor
- name: low-handler
condition: ${classify.output === 'low'}
agent: low-processor
output:
result: ${urgent-handler.output || normal-handler.output || low-handler.output}
priority: ${classify.output}
Error Recovery
Handle failures gracefully:Copy
ensemble: resilient-workflow
agents:
- name: try-primary
agent: fetcher
inputs:
url: ${input.primary_url}
retry:
maxAttempts: 2
- name: try-secondary
condition: ${try-primary.failed}
agent: fetcher
inputs:
url: ${input.secondary_url}
- name: use-cache
condition: ${try-primary.failed && try-secondary.failed}
operation: storage
config:
type: kv
action: get
key: cached-data
output:
data: ${try-primary.output || try-secondary.output || use-cache.output.value}
source: ${try-primary.executed ? 'primary' : try-secondary.executed ? 'secondary' : 'cache'}
Event-Driven
Trigger actions based on events:Copy
ensemble: event-processor
agents:
- name: parse-event
operation: code
config:
code: return JSON.parse("${input.event}");
- name: handle-user-created
condition: ${parse-event.output.type === 'user.created'}
agent: user-onboarding
- name: handle-payment-received
condition: ${parse-event.output.type === 'payment.received'}
agent: payment-processor
- name: handle-order-placed
condition: ${parse-event.output.type === 'order.placed'}
agent: order-fulfillment
output:
handled: ${handle-user-created.executed || handle-payment-received.executed || handle-order-placed.executed}
event_type: ${parse-event.output.type}
Anti-Patterns
1. God Ensemble
Copy
# Bad: Does too much
ensemble: everything
description: Handles all business logic
agents:
- name: step1...
- name: step2...
# ... 50 more agents
# Good: Break into focused ensembles
ensemble: process-order
ensemble: fulfill-order
ensemble: notify-customer
2. Tight Coupling
Copy
# Bad: Agents tightly coupled
agents:
- name: step1
agent: custom-step1
- name: step2
agent: custom-step2-only-for-step1 # Only works with step1
# Good: Loosely coupled
agents:
- name: fetch
agent: fetcher # Generic
- name: process
agent: processor # Generic
3. Hidden State
Copy
# Bad: Implicit state management
agents:
- name: step1
agent: agent1 # Sets global state internally
# Good: Explicit state
state:
schema:
shared_data: object
agents:
- name: step1
operation: code
config:
code: return { data: "value" };
state:
set:
shared_data: ${step1.output.data}
4. Deep Nesting
Copy
# Bad: Deeply nested conditionals
agents:
- name: check1
condition: ${input.a}
operation: code
# ...
- name: check2
condition: ${check1.executed && input.b}
operation: code
# ...
- name: check3
condition: ${check2.executed && input.c}
operation: code
# ...
# Good: Flatten with early returns
agents:
- name: validate
operation: code
config:
code: |
if (!${input.a}) return { valid: false };
if (!${input.b}) return { valid: false };
if (!${input.c}) return { valid: false };
return { valid: true };
- name: process
condition: ${validate.output.valid}
operation: code
# ...
Performance Optimization
Minimize Sequential Dependencies
Copy
# Bad: Sequential (slow)
agents:
- name: step1
agent: fetcher
- name: step2
operation: code
config:
code: return { a: ${step1.output} };
- name: step3
operation: code
config:
code: return { b: ${step2.output} };
# Good: Parallel where possible
agents:
- name: step1
agent: fetcher
- name: step2
agent: fetcher
- name: combine
operation: code
config:
code: return { a: ${step1.output}, b: ${step2.output} };
Cache Expensive Operations
Copy
agents:
- name: expensive-scrape
agent: scraper
inputs:
url: ${input.url}
cache:
ttl: 86400 # Cache for 24 hours
- name: expensive-ai
operation: think
config:
provider: openai
model: gpt-4o
prompt: ${expensive-scrape.output}
cache:
ttl: 3600 # Cache for 1 hour
Early Termination
Copy
agents:
# Quick validation first
- name: quick-check
operation: code
config:
code: return { valid: ${input.data}.length > 0 };
# Only do expensive work if valid
- name: expensive-process
condition: ${quick-check.output.valid}
agent: expensive-agent
Testing Ensembles
Copy
// ensembles/process-invoice.test.ts
import { describe, it, expect } from 'vitest';
import { TestConductor } from '@ensemble-edge/conductor/testing';
describe('process-invoice ensemble', () => {
it('should process valid invoice', async () => {
const conductor = await TestConductor.create();
await conductor.loadProject('./');
const result = await conductor.execute('process-invoice', {
invoice: {
id: 'INV-001',
amount: 100,
customer_email: 'test@example.com'
}
});
expect(result).toBeSuccessful();
expect(result.output.processed).toBe(true);
expect(result.output.confirmation_sent).toBe(true);
});
it('should reject invalid invoice', async () => {
const conductor = await TestConductor.create();
await conductor.loadProject('./');
const result = await conductor.execute('process-invoice', {
invoice: {
id: 'INV-002',
amount: -100 // Invalid
}
});
expect(result).toBeSuccessful();
expect(result.output.processed).toBe(false);
expect(result.output.errors).toBeDefined();
});
it('should use fallback on primary failure', async () => {
const conductor = await TestConductor.create();
await conductor.loadProject('./');
// Mock primary agent failure
conductor.mockAgent('primary-processor', {
failed: true,
error: 'Service unavailable'
});
const result = await conductor.execute('process-invoice', {
invoice: { id: 'INV-003', amount: 100 }
});
expect(result).toBeSuccessful();
expect(result.agents['fallback-processor'].executed).toBe(true);
});
it('should execute operations in parallel', async () => {
const conductor = await TestConductor.create();
await conductor.loadProject('./');
const result = await conductor.execute('parallel-ensemble', {
urls: ['url1', 'url2', 'url3']
});
const startTimes = [
result.agents['fetch-1'].startTime,
result.agents['fetch-2'].startTime,
result.agents['fetch-3'].startTime
];
const timeSpread = Math.max(...startTimes) - Math.min(...startTimes);
expect(timeSpread).toBeLessThan(100); // Started within 100ms
});
});
Documentation
Document your ensembles:Copy
ensemble: process-invoice
description: |
Process customer invoice through validation, payment processing,
and confirmation email.
Requirements:
- Valid invoice with amount > 0
- Customer email in database
- Payment gateway available
Returns:
- processed: true if successful
- confirmation_sent: true if email sent
- errors: array of error messages if failed
inputs:
invoice:
type: object
required: true
properties:
id: string
amount: number
customer_email: string
agents:
# ... agents ...
output:
processed: ${process.output.success}
confirmation_sent: ${send-email.executed}
errors: ${validate.output.errors}
Best Practices
- Single Responsibility - One ensemble, one task
- Composable Agents - Reusable across ensembles
- Explicit Dependencies - Make data flow clear
- Parallel by Default - Only add dependencies when needed
- Handle Failures - Always have fallbacks
- Cache Strategically - Cache expensive operations
- Test Thoroughly - Unit and integration tests
- Document Well - Clear descriptions and examples
- Monitor Performance - Track execution times
- Version with Edgit - Track changes over time

