How to Build a Programmatic Agent Using `handleSteps` Generators in FreeBuff
FreeBuff agents use generator functions in the handleSteps property to execute step-by-step workflows, yielding tool calls and log chunks that the runtime processes sequentially until completion or step limit.
The FreeBuff SDK enables fully programmable agents through a template-based system. An agent definition exports a handleSteps generator that the SDK serializes, transmits to the runtime, and executes step-by-step. Each yielded value becomes an instruction—whether a tool invocation, progress log, or control signal—that drives the agent's behavior programmatically.
Understanding the handleSteps Execution Flow
Building a programmatic agent using handleSteps generators involves seven distinct phases, each handled by specific components in the FreeBuff source code.
Phase 1: Define the Agent Template
The agent creator exports an AgentDefinition object containing the handleSteps generator function. This definition resides in the agents package and establishes the agent's identity and behavior.
// agents/types/agent-definition.ts
export interface AgentDefinition {
name: string;
description: string;
handleSteps?: () => Generator<HandleStepsYieldValue, any, any>;
}
Phase 2: Serialize for Transport
When agents are loaded, the SDK converts the generator function to a string for serialization while preserving a live reference for local execution. In sdk/src/agents/load-agents.ts, the loader performs:
agentDefinition.handleSteps = fn.toString();
const handleStepsFn = fn; // Live reference preserved
This dual representation enables both remote runtime execution and local development workflows.
Phase 3: Initialize the Session
The client creates a run session with step-limit protection. In sdk/src/client.ts, the run method seeds stepsRemaining from maxAgentSteps (defaulting to 200):
const runOptions = {
maxAgentSteps: options.maxAgentSteps ?? 200,
// ... session configuration
};
Phase 4: Execute Programmatic Steps
The runtime deserializes and drives the generator. The core driver in packages/agent-runtime/src/run-programmatic-step.ts uses globalEval to reconstruct the generator function, then repeatedly invokes next() until exhaustion or step limit:
const generator = globalEval(agentDefinition.handleSteps)();
let result = generator.next();
while (!result.done && stepsRemaining > 0) {
const yieldedValue = result.value;
// Process yield...
result = generator.next(toolResult);
stepsRemaining--;
}
Phase 5: Validate and Route Yields
Each yielded value passes through HandleStepsYieldValueSchema validation. Tool requests route to the Tool Executor; results feed back into the generator via next():
// packages/agent-runtime/src/util/parse-tool-calls-from-text.ts
const validated = HandleStepsYieldValueSchema.parse(yieldedValue);
if (validated.tool) {
const toolResult = await toolExecutor.execute(validated.tool, validated.args);
result = generator.next(toolResult);
}
Phase 6: Stream Progress Logs
When the generator yields handleStepsLogChunk, the runtime forwards it to the client's callback for UI rendering:
yield { handleStepsLogChunk: 'Processing file...' };
// Runtime delivers to: options.handleStepsLogChunk?.(msg)
Phase 7: Return Final Output
Generator completion propagates the return value as the agent's final result, terminating the run session.
Creating a handleSteps Generator Agent
Follow this implementation pattern to build your own programmatic agent using handleSteps generators.
Step 1: Structure Your Generator Function
The handleSteps property must be a generator function (function*) yielding objects matching the HandleStepsYieldValueSchema. Valid yield types include:
- Tool calls:
{ tool: string, args: object } - Log chunks:
{ handleStepsLogChunk: string } - Error signals:
{ error: string }
function* workflowGenerator() {
// Emit progress visible to users
yield { handleStepsLogChunk: 'Initializing analysis...' };
// Execute a tool and capture the result
const fileContent = yield {
tool: 'readFile',
args: { path: 'src/config.ts' }
};
// Use the result in subsequent logic
yield { handleStepsLogChunk: `Read ${fileContent.length} bytes` };
// Return final output
return { success: true, data: fileContent };
}
Step 2: Export the Complete Definition
Create your agent file in the agents/ directory with full type safety:
// agents/src/my-analyzer.ts
import type { AgentDefinition } from '@codebuff/common/types/agent-template';
export const fileAnalyzer: AgentDefinition = {
name: 'file-analyzer',
description: 'Reads and analyzes source files programmatically',
handleSteps: function* () {
// Log: operation start
yield { handleStepsLogChunk: '🔍 Starting file analysis' };
// Tool: read package.json for context
const packageJson = yield {
tool: 'readFile',
args: { path: 'package.json' }
};
// Log: intermediate progress
const pkg = JSON.parse(packageJson);
yield {
handleStepsLogChunk: `📦 Project: ${pkg.name}@${pkg.version}`
};
// Tool: list source files
const sourceFiles = yield {
tool: 'listDirectory',
args: { path: 'src', recursive: false }
};
// Log: completion status
yield {
handleStepsLogChunk: `✅ Found ${sourceFiles.length} files in src/`
};
// Return structured result
return {
project: pkg.name,
filesAnalyzed: sourceFiles.length,
fileList: sourceFiles
};
}
};
Step 3: Register and Export
Ensure your agent is discoverable by adding it to the package index:
// agents/src/index.ts
export { fileAnalyzer } from './my-analyzer';
Step 4: Execute with the SDK Client
Invoke your agent through the runtime using the client.run API:
// examples/run-analyzer.ts
import { client } from '@codebuff/sdk';
import { fileAnalyzer } from '@codebuff/agents';
async function analyzeProject() {
const result = await client.run({
agents: [fileAnalyzer],
maxAgentSteps: 15, // Prevent runaway execution
handleStepsLogChunk: (msg: string) => {
console.log(`[${new Date().toISOString()}] ${msg}`);
}
});
console.log('Analysis complete:', result);
return result;
}
analyzeProject().catch(console.error);
Working with Tool Results and State
The handleSteps generator maintains state across yields. Tool results passed through generator.next() become available for subsequent logic:
handleSteps: function* () {
// First tool call
const userQuery = yield { tool: 'getUserInput', args: {} };
// Use result in second tool call
const searchResults = yield {
tool: 'webSearch',
args: { query: userQuery }
};
// Chain results into final processing
const summary = yield {
tool: 'summarize',
args: { text: searchResults.join('\n') }
};
return { answer: summary };
}
This synchronous-looking code executes asynchronously, with the runtime managing all promise resolution and error handling between steps.
Advanced handleSteps Patterns
Nested Agent Delegation
Spawn sub-agents within a generator using loopAgentSteps, as implemented in the built-in researcher agents:
handleSteps: function* () {
const researchTask = yield {
tool: 'spawnAgent',
args: {
agentName: 'deep-researcher',
inputs: { topic: 'quantum computing' }
}
};
// Process sub-agent output
yield {
handleStepsLogChunk: `Research produced ${researchTask.pages} pages`
};
return { synthesized: researchTask.findings };
}
Note that spawnAgent sets fromHandleSteps: false internally to prevent infinite recursion cycles.
Error Recovery Strategies
Tool failures yield error objects that generators can handle gracefully:
handleSteps: function* () {
yield { handleStepsLogChunk: 'Attempting file read' };
const result = yield {
tool: 'readFile',
args: { path: 'maybe-missing.txt' }
};
// Check for error structure
if (result && typeof result === 'object' && 'error' in result) {
yield { handleStepsLogChunk: `Fallback: ${result.error}` };
// Attempt recovery
const fallback = yield {
tool: 'readFile',
args: { path: 'backup.txt' }
};
return { data: fallback, source: 'backup' };
}
return { data: result, source: 'primary' };
}
Step Budget Management
Monitor remaining steps for long-running workflows:
handleSteps: function* () {
const maxIterations = 5;
for (let i = 0; i < maxIterations; i++) {
yield { handleStepsLogChunk: `Iteration ${i + 1}/${maxIterations}` };
const progress = yield { tool: 'processBatch', args: { batchId: i } };
if (progress.complete) {
yield { handleStepsLogChunk: 'Early termination: goal achieved' };
break;
}
}
return { iterations: i + 1 };
}
The runtime enforces the global maxAgentSteps ceiling regardless of loop structures.
Key Source Files Reference
| File | Purpose |
|---|---|
packages/agent-runtime/src/run-programmatic-step.ts |
Core driver: deserializes generators, iterates yields, manages step counting |
sdk/src/agents/load-agents.ts |
Serializes handleSteps functions for runtime transport |
sdk/src/client.ts |
Public API: client.run with maxAgentSteps and logging options |
agents/types/agent-definition.ts |
TypeScript interfaces for AgentDefinition and yield schemas |
packages/agent-runtime/src/tools/tool-executor.ts |
Executes tool calls yielded from generators |
packages/agent-runtime/src/util/parse-tool-calls-from-text.ts |
Validates and parses HandleStepsYieldValueSchema |
Summary
- FreeBuff programmatic agents center on the
handleStepsgenerator pattern defined inAgentDefinition - Generator functions (
function*) yield tool calls, log chunks, and control signals that the runtime processes sequentially - Serialization in
load-agents.tsenables both local development and remote runtime execution - Execution in
run-programmatic-step.tshandles deserialization, validation, tool execution, and step limiting - State management flows through
generator.next()calls, with tool results feeding back into subsequent yields - Safety defaults include 200-step limits via
maxAgentStepsand schema validation on all yields
Frequently Asked Questions
What yield values are valid in a handleSteps generator?
Valid yields must conform to HandleStepsYieldValueSchema. Common patterns include { tool: 'name', args: {} } for tool execution, { handleStepsLogChunk: 'message' } for progress streaming, and { error: 'description' } for error signaling. The runtime validates each yield and routes it appropriately.
How does the runtime handle generator state across async tool calls?
The runtime maintains the generator instance in run-programmatic-step.ts, calling next() with resolved tool results. This creates the illusion of synchronous execution while the runtime manages underlying promises. Generator-local variables persist across yields, enabling stateful workflows.
Can I use handleSteps with TypeScript async generators?
No—handleSteps specifically expects synchronous generators (function*, not async function*). The asynchronous handling occurs in the runtime layer, not within the generator itself. Attempting to use async function* will fail serialization in load-agents.ts and break the execution model.
What happens when maxAgentSteps is exceeded?
The runtime forcibly terminates generator execution. In run-programmatic-step.ts, the loop condition checks stepsRemaining > 0 before each next() call. Upon exhaustion, the run ends with an incomplete status and partial results. Always set conservative limits and monitor handleStepsLogChunk for progress visibility.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →