Context & Configuration
Part 1: Foundation - Lesson 4
Passing state, callbacks, and metadata through Runnable chains
Overview
You've learned Runnables (Lesson 1), Messages (Lesson 2), and LLM Wrappers (Lesson 3). Now we tackle a critical question: How do we pass configuration, callbacks, and state through complex chains without cluttering our code?
The answer is RunnableConfig - a powerful pattern that threads context through every step of a pipeline, enabling logging, debugging, authentication, and more without changing your core logic.
Why Does This Matter?
The Problem: Configuration Chaos
Without a proper context system:
// Bad: Configuration everywhere
async function complexPipeline(input, temperature, callbacks, debug, userId) {
const result1 = await step1(input, temperature, debug);
callbacks.onStep('step1', result1);
const result2 = await step2(result1, userId, debug);
callbacks.onStep('step2', result2);
const result3 = await step3(result2, temperature, callbacks, debug);
callbacks.onStep('step3', result3);
return result3;
}
// Every function needs to know about every configuration option!
Problems:
- Every function signature becomes huge
- Adding new config requires changing every function
- Hard to add features like logging or metrics
- Impossible to intercept at specific points
- Can't pass user context through chains
The Solution: RunnableConfig
With RunnableConfig:
// Good: Config flows automatically
const config = {
temperature: 0.7,
callbacks: [loggingCallback, metricsCallback],
metadata: { userId: 'user_123', sessionId: 'sess_456' },
tags: ['production', 'api-v2']
};
const result = await pipeline.invoke(input, config);
// Every Runnable in the pipeline receives config automatically
// No need to pass it manually at each step!
Much cleaner! And infinitely extensible.
Learning Objectives
By the end of this lesson, you will:
- β Understand the RunnableConfig pattern
- β Implement a callback system for monitoring
- β Add metadata and tags for tracking
- β Build configurable Runnables
- β Create custom callbacks for logging and metrics
- β Debug chains with visibility into each step
- β Understand how LangChain's callbacks work
Core Concepts
What is RunnableConfig?
RunnableConfig is an object that flows through your entire pipeline, carrying:
- Callbacks - Functions called at specific points (logging, metrics, debugging)
- Metadata - Arbitrary data (user IDs, session info, request context)
- Tags - Labels for filtering and organization
- Recursion Limit - Prevent infinite loops
- Runtime Configuration - Override default settings (temperature, max tokens)
π Where This Lives in the Framework:
Looking back at our framework structure from the main README, RunnableConfig is part of the Core module:
// Core module (what we're building now)
export {
Runnable, // β Lesson 1
RunnableSequence, // β Lesson 1
BaseMessage, // β Lesson 2
HumanMessage, // β Lesson 2
AIMessage, // β Lesson 2
SystemMessage, // β Lesson 2
RunnableConfig // β THIS LESSON (Lesson 4)
} from './core/index.js';
RunnableConfig isn't a separate feature you add later - it's foundational infrastructure built into the Core module that every other part of the framework depends on. The callback system we're about to build is how this config becomes useful for observability.
The Flow
User calls: runnable.invoke(input, config)
β
βββββββββββββββββ΄ββββββββββββββ
β β
Config passed to every step β
β β
βββββββββββΌββββββββββ¬βββββββββββ¬βββββββββΌβββββ
β β β β β β
Step1 Step2 Step3 Step4 Step5 ...
β β β β β
βββββββββββ΄ββββββββββ΄βββββββββββ΄βββββββββ
β
All use same config
All trigger callbacks
All have access to metadata
Key Benefits
- Separation of concerns: Logic separate from monitoring
- Composability: Add features without changing code
- Observability: See what's happening at every step
- Flexibility: Runtime configuration override
- Extensibility: Easy to add new capabilities
Implementation Deep Dive
Step 1: The RunnableConfig Object
Location: src/core/context.js
/**
* RunnableConfig - Configuration passed through chains
*/
export class RunnableConfig {
constructor(options = {}) {
// Callbacks for monitoring
this.callbacks = options.callbacks || [];
// Metadata (arbitrary data)
this.metadata = options.metadata || {};
// Tags for filtering/organization
this.tags = options.tags || [];
// Recursion limit (prevent infinite loops)
this.recursionLimit = options.recursionLimit ?? 25;
// Runtime overrides for generation parameters
this.configurable = options.configurable || {};
}
/**
* Merge with another config (child inherits from parent)
*/
merge(other) {
return new RunnableConfig({
callbacks: [...this.callbacks, ...(other.callbacks || [])],
metadata: { ...this.metadata, ...(other.metadata || {}) },
tags: [...this.tags, ...(other.tags || [])],
recursionLimit: other.recursionLimit ?? this.recursionLimit,
configurable: { ...this.configurable, ...(other.configurable || {}) }
});
}
/**
* Create a child config with additional settings
*/
child(options = {}) {
return this.merge(new RunnableConfig(options));
}
}
Why this design?
- Immutable merging (doesn't modify original)
- Child configs inherit parent settings
- Easy to add new fields without breaking existing code
Step 2: The Callback System
π How Callbacks Relate to the Framework:
Callbacks are the mechanism that makes RunnableConfig useful. They're not a separate module - they're the "hooks" that get triggered as your Runnables execute. Think of them as event listeners built into the Core module.
In the framework structure, callbacks support observability:
- They live in Core (used by every module)
- Later modules like Agents and Chains use them for tracing
- The Utils module's
CallbackManager(which we'll see) is a helper for managing them
Here's the base callback class:
Location: src/utils/callbacks.js
/**
* BaseCallback - Abstract callback handler
*/
export class BaseCallback {
/**
* Called when a Runnable starts
*/
async onStart(runnable, input, config) {
// Override in subclass
}
/**
* Called when a Runnable completes successfully
*/
async onEnd(runnable, output, config) {
// Override in subclass
}
/**
* Called when a Runnable errors
*/
async onError(runnable, error, config) {
// Override in subclass
}
/**
* Called for LLM token streaming
*/
async onLLMNewToken(token, config) {
// Override in subclass
}
/**
* Called when a chain step completes
*/
async onChainStep(stepName, output, config) {
// Override in subclass
}
}
Callback Lifecycle:
invoke() called
β
onStart() β Before execution
β
[execution] β Your _call() method runs
β
onEnd() β After success
or
onError() β After failure
Key insight: Callbacks are optional observers - your code works fine without them, but they let you see what's happening.
Step 3: CallbackManager
Manages multiple callbacks and ensures they all get called:
Location: src/utils/callback-manager.js
/**
* CallbackManager - Manages multiple callbacks
*/
export class CallbackManager {
constructor(callbacks = []) {
this.callbacks = callbacks;
}
/**
* Add a callback
*/
add(callback) {
this.callbacks.push(callback);
}
/**
* Call onStart for all callbacks
*/
async handleStart(runnable, input, config) {
await Promise.all(
this.callbacks.map(cb =>
this._safeCall(() => cb.onStart(runnable, input, config))
)
);
}
/**
* Call onEnd for all callbacks
*/
async handleEnd(runnable, output, config) {
await Promise.all(
this.callbacks.map(cb =>
this._safeCall(() => cb.onEnd(runnable, output, config))
)
);
}
/**
* Call onError for all callbacks
*/
async handleError(runnable, error, config) {
await Promise.all(
this.callbacks.map(cb =>
this._safeCall(() => cb.onError(runnable, error, config))
)
);
}
/**
* Call onLLMNewToken for all callbacks
*/
async handleLLMNewToken(token, config) {
await Promise.all(
this.callbacks.map(cb =>
this._safeCall(() => cb.onLLMNewToken(token, config))
)
);
}
/**
* Call onChainStep for all callbacks
*/
async handleChainStep(stepName, output, config) {
await Promise.all(
this.callbacks.map(cb =>
this._safeCall(() => cb.onChainStep(stepName, output, config))
)
);
}
/**
* Safely call a callback (don't let one callback crash others)
*/
async _safeCall(fn) {
try {
await fn();
} catch (error) {
console.error('Callback error:', error);
// Don't throw - callbacks shouldn't break the pipeline
}
}
}
Key insight: Callbacks can fail without breaking the pipeline.
Step 4: Integrating with Runnable
Update the Runnable base class to use config:
Location: src/core/runnable.js
export class Runnable {
constructor() {
this.name = this.constructor.name;
}
/**
* Execute with config support
*/
async invoke(input, config = {}) {
// Normalize config to RunnableConfig instance
const runnableConfig = config instanceof RunnableConfig
? config
: new RunnableConfig(config);
// Create callback manager
const callbackManager = new CallbackManager(runnableConfig.callbacks);
try {
// Notify callbacks: starting
await callbackManager.handleStart(this, input, runnableConfig);
// Execute the runnable
const output = await this._call(input, runnableConfig);
// Notify callbacks: success
await callbackManager.handleEnd(this, output, runnableConfig);
return output;
} catch (error) {
// Notify callbacks: error
await callbackManager.handleError(this, error, runnableConfig);
throw error;
}
}
async _call(input, config) {
throw new Error(
`${this.name} must implement _call() method`
);
}
// ... stream(), batch(), pipe() methods remain the same ...
}
Now every Runnable automatically:
- β Receives config
- β Triggers callbacks
- β Handles errors properly
- β Passes config to nested Runnables
Step 5: Useful Built-in Callbacks
/**
* ConsoleCallback - Logs to console
*/
export class ConsoleCallback extends Callbacks {
constructor(options = {}) {
super();
this.verbose = options.verbose ?? true;
this.colors = options.colors ?? true;
}
async onStart(runnable, input, config) {
if (this.verbose) {
console.log(`\nβΆ Starting: ${runnable._name}`);
console.log(` Input:`, this._format(input));
}
}
async onEnd(runnable, output, config) {
if (this.verbose) {
console.log(`β Completed: ${runnable._name}`);
console.log(` Output:`, this._format(output));
}
}
async onError(runnable, error, config) {
console.error(`β Error in ${runnable._name}:`, error.message);
}
async onLLMNewToken(token, config) {
process.stdout.write(token);
}
_format(value) {
if (typeof value === 'string') {
return value.length > 100 ? value.substring(0, 97) + '...' : value;
}
return JSON.stringify(value, null, 2);
}
}
/**
* MetricsCallback - Tracks timing and counts
*/
export class MetricsCallback extends Callbacks {
constructor() {
super();
this.metrics = {
calls: {},
totalTime: {},
errors: {}
};
this.startTimes = new Map();
}
async onStart(runnable, input, config) {
const name = runnable._name;
this.startTimes.set(name, Date.now());
this.metrics.calls[name] = (this.metrics.calls[name] || 0) + 1;
}
async onEnd(runnable, output, config) {
const name = runnable._name;
const startTime = this.startTimes.get(name);
if (startTime) {
const duration = Date.now() - startTime;
this.metrics.totalTime[name] = (this.metrics.totalTime[name] || 0) + duration;
this.startTimes.delete(name);
}
}
async onError(runnable, error, config) {
const name = runnable._name;
this.metrics.errors[name] = (this.metrics.errors[name] || 0) + 1;
}
getReport() {
const report = [];
for (const [name, calls] of Object.entries(this.metrics.calls)) {
const totalTime = this.metrics.totalTime[name] || 0;
const avgTime = calls > 0 ? (totalTime / calls).toFixed(2) : 0;
const errors = this.metrics.errors[name] || 0;
report.push({
runnable: name,
calls,
avgTime: `${avgTime}ms`,
totalTime: `${totalTime}ms`,
errors
});
}
return report;
}
reset() {
this.metrics = {calls: {}, totalTime: {}, errors: {}};
this.startTimes.clear();
}
}
/**
* FileCallback - Logs to file
*/
export class FileCallback extends Callbacks {
constructor(filename) {
super();
this.filename = filename;
this.logs = [];
}
async onStart(runnable, input, config) {
this.logs.push({
timestamp: new Date().toISOString(),
event: 'start',
runnable: runnable._name,
input: this._serialize(input)
});
}
async onEnd(runnable, output, config) {
this.logs.push({
timestamp: new Date().toISOString(),
event: 'end',
runnable: runnable._name,
output: this._serialize(output)
});
}
async onError(runnable, error, config) {
this.logs.push({
timestamp: new Date().toISOString(),
event: 'error',
runnable: runnable._name,
error: error.message
});
}
async flush() {
const fs = await import('fs/promises');
await fs.writeFile(
this.filename,
JSON.stringify(this.logs, null, 2),
'utf-8'
);
this.logs = [];
}
_serialize(value) {
if (typeof value === 'string') return value;
if (value?.content) return value.content; // Message
return JSON.stringify(value);
}
}
Complete Implementation
Here's the full context system:
/**
* Context & Configuration System
*
* @module core/context
*/
/**
* RunnableConfig - Configuration passed through chains
*/
export class RunnableConfig {
constructor(options = {}) {
this.callbacks = options.callbacks || [];
this.metadata = options.metadata || {};
this.tags = options.tags || [];
this.recursionLimit = options.recursionLimit ?? 25;
this.configurable = options.configurable || {};
}
merge(other) {
return new RunnableConfig({
callbacks: [...this.callbacks, ...(other.callbacks || [])],
metadata: { ...this.metadata, ...(other.metadata || {}) },
tags: [...this.tags, ...(other.tags || [])],
recursionLimit: other.recursionLimit ?? this.recursionLimit,
configurable: { ...this.configurable, ...(other.configurable || {}) }
});
}
child(options = {}) {
return this.merge(new RunnableConfig(options));
}
}
/**
* BaseCallback - Base class for callbacks
*/
export class BaseCallback {
async onStart(runnable, input, config) {}
async onEnd(runnable, output, config) {}
async onError(runnable, error, config) {}
async onLLMNewToken(token, config) {}
async onChainStep(stepName, output, config) {}
}
/**
* CallbackManager - Manages multiple callbacks
*/
export class CallbackManager {
constructor(callbacks = []) {
this.callbacks = callbacks;
}
add(callback) {
this.callbacks.push(callback);
}
async handleStart(runnable, input, config) {
await Promise.all(
this.callbacks.map(cb =>
this._safeCall(() => cb.onStart(runnable, input, config))
)
);
}
async handleEnd(runnable, output, config) {
await Promise.all(
this.callbacks.map(cb =>
this._safeCall(() => cb.onEnd(runnable, output, config))
)
);
}
async handleError(runnable, error, config) {
await Promise.all(
this.callbacks.map(cb =>
this._safeCall(() => cb.onError(runnable, error, config))
)
);
}
async handleLLMNewToken(token, config) {
await Promise.all(
this.callbacks.map(cb =>
this._safeCall(() => cb.onLLMNewToken(token, config))
)
);
}
async handleChainStep(stepName, output, config) {
await Promise.all(
this.callbacks.map(cb =>
this._safeCall(() => cb.onChainStep(stepName, output, config))
)
);
}
async _safeCall(fn) {
try {
await fn();
} catch (error) {
console.error('Callback error:', error);
}
}
}
/**
* ConsoleCallback - Logs to console with colors
*/
export class ConsoleCallback extends BaseCallback {
constructor(options = {}) {
super();
this.verbose = options.verbose ?? true;
}
async onStart(runnable, input, config) {
if (this.verbose) {
console.log(`\nβΆ Starting: ${runnable._name}`);
console.log(` Input:`, this._format(input));
if (config.metadata && Object.keys(config.metadata).length > 0) {
console.log(` Metadata:`, config.metadata);
}
}
}
async onEnd(runnable, output, config) {
if (this.verbose) {
console.log(`β Completed: ${runnable._name}`);
console.log(` Output:`, this._format(output));
}
}
async onError(runnable, error, config) {
console.error(`β Error in ${runnable._name}:`, error.message);
}
async onLLMNewToken(token, config) {
process.stdout.write(token);
}
_format(value) {
if (typeof value === 'string') {
return value.length > 100 ? value.substring(0, 97) + '...' : value;
}
if (value?.content) {
return value.content.substring(0, 100);
}
return JSON.stringify(value, null, 2);
}
}
/**
* MetricsCallback - Tracks performance metrics
*/
export class MetricsCallback extends BaseCallback {
constructor() {
super();
this.metrics = {
calls: {},
totalTime: {},
errors: {}
};
this.startTimes = new Map();
}
async onStart(runnable, input, config) {
const key = `${runnable._name}_${Date.now()}_${Math.random()}`;
this.startTimes.set(key, { name: runnable._name, time: Date.now() });
const name = runnable._name;
this.metrics.calls[name] = (this.metrics.calls[name] || 0) + 1;
}
async onEnd(runnable, output, config) {
const name = runnable._name;
// Find the most recent start time for this runnable
let startTime = null;
for (const [key, value] of this.startTimes.entries()) {
if (value.name === name) {
startTime = value.time;
this.startTimes.delete(key);
break;
}
}
if (startTime) {
const duration = Date.now() - startTime;
this.metrics.totalTime[name] = (this.metrics.totalTime[name] || 0) + duration;
}
}
async onError(runnable, error, config) {
const name = runnable._name;
this.metrics.errors[name] = (this.metrics.errors[name] || 0) + 1;
}
getReport() {
const report = [];
for (const [name, calls] of Object.entries(this.metrics.calls)) {
const totalTime = this.metrics.totalTime[name] || 0;
const avgTime = calls > 0 ? (totalTime / calls).toFixed(2) : 0;
const errors = this.metrics.errors[name] || 0;
report.push({
runnable: name,
calls,
avgTime: `${avgTime}ms`,
totalTime: `${totalTime}ms`,
errors,
successRate: calls > 0 ? `${((calls - errors) / calls * 100).toFixed(1)}%` : '0%'
});
}
return report;
}
printReport() {
console.log('\nπ Performance Report:');
console.log('β'.repeat(80));
console.table(this.getReport());
}
reset() {
this.metrics = { calls: {}, totalTime: {}, errors: {} };
this.startTimes.clear();
}
}
export default {
RunnableConfig,
BaseCallback,
CallbackManager,
ConsoleCallback,
MetricsCallback
};
Real-World Examples
Example 1: Basic Logging
import { ConsoleCallback } from './context.js';
const logger = new ConsoleCallback({ verbose: true });
const config = {
callbacks: [logger]
};
// Every step will log
const result = await chain.invoke(input, config);
Output:
βΆ Starting: PromptTemplate
Input: "Translate to Spanish: Hello"
β Completed: PromptTemplate
Output: "Translate the following to Spanish: Hello"
βΆ Starting: LlamaCppLLM
Input: "Translate the following to Spanish: Hello"
β Completed: LlamaCppLLM
Output: AIMessage("Hola")
Example 2: Performance Monitoring
import { MetricsCallback } from './context.js';
const metrics = new MetricsCallback();
const config = {
callbacks: [metrics]
};
// Run multiple times
for (let i = 0; i < 10; i++) {
await chain.invoke(input, config);
}
// Get performance report
metrics.printReport();
Output:
π Performance Report:
βββββββββββ¬ββββββββββββββββββ¬ββββββββ¬βββββββββββ¬ββββββββββββ¬βββββββββ¬βββββββββββββββ
β (index) β runnable β calls β avgTime β totalTime β errors β successRate β
βββββββββββΌββββββββββββββββββΌββββββββΌβββββββββββΌββββββββββββΌβββββββββΌβββββββββββββββ€
β 0 β 'PromptTemplate'β 10 β '5.20ms' β '52ms' β 0 β '100.0%' β
β 1 β 'LlamaCppLLM' β 10 β '243.5ms'β '2435ms' β 0 β '100.0%' β
βββββββββββ΄ββββββββββββββββββ΄ββββββββ΄βββββββββββ΄ββββββββββββ΄βββββββββ΄βββββββββββββββ
Example 3: Metadata Tracking
const config = {
metadata: {
userId: 'user_123',
sessionId: 'sess_456',
requestId: 'req_789'
},
tags: ['production', 'api-v2']
};
await agent.invoke(input, config);
// Every callback receives this metadata
// Useful for logging, debugging, billing
Example 4: Multiple Callbacks
const logger = new ConsoleCallback();
const metrics = new MetricsCallback();
const fileLogger = new FileCallback('./logs/agent.json');
const config = {
callbacks: [logger, metrics, fileLogger]
};
await chain.invoke(input, config);
// All three callbacks are triggered
await fileLogger.flush(); // Save to file
metrics.printReport(); // Show metrics
Example 5: Runtime Configuration Override
const llm = new LlamaCppLLM({
modelPath: './model.gguf',
temperature: 0.7 // default
});
// Override at runtime
const result1 = await llm.invoke(input, {
configurable: { temperature: 0.2 } // more deterministic
});
const result2 = await llm.invoke(input, {
configurable: { temperature: 1.2 } // more creative
});
Example 6: Custom Callback for API Logging
class APILoggerCallback extends Callbacks {
constructor(apiKey) {
super();
this.apiKey = apiKey;
}
async onEnd(runnable, output, config) {
// Send to logging API
await fetch('https://api.yourservice.com/logs', {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
runnable: runnable._name,
output: this._serialize(output),
metadata: config.metadata,
timestamp: new Date().toISOString()
})
});
}
_serialize(output) {
if (output?.content) return output.content;
return String(output);
}
}
// Usage
const apiLogger = new APILoggerCallback(process.env.API_KEY);
const config = {callbacks: [apiLogger]};
await chain.invoke(input, config);
Advanced Patterns
Pattern 1: Conditional Callbacks
class ConditionalCallback extends Callbacks {
constructor(condition, callback) {
super();
this.condition = condition;
this.callback = callback;
}
async onEnd(runnable, output, config) {
if (this.condition(runnable, output, config)) {
await this.callback.onEnd(runnable, output, config);
}
}
}
// Only log slow operations
const slowLogger = new ConditionalCallback(
(runnable, output, config) => {
// Check if operation took > 1 second
return config.executionTime > 1000;
},
new ConsoleCallback()
);
Pattern 2: Callback Composition
class CompositeCallback extends Callbacks {
constructor(callbacks) {
super();
this.callbacks = callbacks;
}
async onStart(runnable, input, config) {
for (const cb of this.callbacks) {
await cb.onStart(runnable, input, config);
}
}
async onEnd(runnable, output, config) {
for (const cb of this.callbacks) {
await cb.onEnd(runnable, output, config);
}
}
async onError(runnable, error, config) {
for (const cb of this.callbacks) {
await cb.onError(runnable, error, config);
}
}
}
// Combine multiple callbacks
const composite = new CompositeCallback([
new ConsoleCallback(),
new MetricsCallback(),
new FileCallback('./logs.json')
]);
Pattern 3: Filtered Logging
class FilteredCallback extends Callbacks {
constructor(filter, callback) {
super();
this.filter = filter;
this.callback = callback;
}
async onStart(runnable, input, config) {
if (this.filter(runnable._name, 'start')) {
await this.callback.onStart(runnable, input, config);
}
}
async onEnd(runnable, output, config) {
if (this.filter(runnable._name, 'end')) {
await this.callback.onEnd(runnable, output, config);
}
}
}
// Only log LLM calls
const llmOnly = new FilteredCallback(
(name, event) => name.includes('LLM'),
new ConsoleCallback()
);
Pattern 4: Callback with State
class StatefulCallback extends Callbacks {
constructor() {
super();
this.state = {
callCount: 0,
totalTokens: 0,
errors: []
};
}
async onEnd(runnable, output, config) {
this.state.callCount++;
if (output?.additionalKwargs?.usage) {
this.state.totalTokens += output.additionalKwargs.usage.totalTokens;
}
}
async onError(runnable, error, config) {
this.state.errors.push({
runnable: runnable._name,
error: error.message,
timestamp: Date.now()
});
}
getState() {
return {...this.state};
}
}
Common Use Cases
Use Case 1: Debug Mode
const debugConfig = {
callbacks: [new ConsoleCallback({ verbose: true })],
tags: ['debug']
};
// See everything that happens
await agent.invoke(query, debugConfig);
Use Case 2: Production Monitoring
const productionConfig = {
callbacks: [
new MetricsCallback(),
new APILoggerCallback(API_KEY)
],
metadata: {
environment: 'production',
version: '1.2.3'
},
tags: ['production']
};
Use Case 3: A/B Testing
const configA = {
metadata: { variant: 'A' },
configurable: { temperature: 0.7 }
};
const configB = {
metadata: { variant: 'B' },
configurable: { temperature: 0.9 }
};
// Track which performs better
Use Case 4: User Context
const userConfig = {
metadata: {
userId: req.userId,
sessionId: req.sessionId,
plan: req.user.plan // 'free', 'pro', 'enterprise'
}
};
// Different behavior based on user plan
if (userConfig.metadata.plan === 'free') {
userConfig.configurable = { maxTokens: 100 };
} else if (userConfig.metadata.plan === 'pro') {
userConfig.configurable = { maxTokens: 500 };
}
Best Practices
β DO:
// Use config for cross-cutting concerns
const config = {
callbacks: [logger, metrics],
metadata: { userId, sessionId }
};
// Let config flow automatically
await chain.invoke(input, config);
// Create child configs for nested calls
const childConfig = config.child({ tags: ['nested'] });
// Handle callback errors gracefully (already done in CallbackManager)
β DON'T:
// Don't pass config manually at each step
const result1 = await step1(input, config);
const result2 = await step2(result1, config); // Unnecessary
// Don't mutate config
config.metadata.foo = 'bar'; // Bad! Create new config instead
// Don't put business logic in callbacks
// Callbacks are for observability, not logic
Debugging Tips
Tip 1: Add Timestamps
class TimestampCallback extends Callbacks {
async onStart(runnable, input, config) {
console.log(`[${new Date().toISOString()}] ${runnable._name} started`);
}
}
Tip 2: Stack Traces in Callbacks
class DebugCallback extends Callbacks {
async onError(runnable, error, config) {
console.error('Full stack trace:');
console.error(error.stack);
console.error('Config:', config);
}
}
Tip 3: Callback Filtering
// Only show LLM operations
const config = {
callbacks: [
new FilteredCallback(
name => name.includes('LLM'),
new ConsoleCallback()
)
]
};
Exercises
Practice building with context and callbacks:
Exercise 13: Build a Simple Logging Callback
Understand the basic callback lifecycle.
Starter code: exercises/13-simple-logger.js
Exercise 14: Build a Metrics Tracker with Metadata
Learn to use config metadata and track metrics.
Starter code: exercises/14-metrics-metadata.js
Exercise 15: Config Merging and Child Configs
Understand how configs inherit and merge.
Starter code: exercises/15-retry-callback.js
Exercise 16: Runtime Configuration Override
Learn to override LLM settings at runtime.
Starter code: exercises/16-runtime-config.js
Summary
Congratulations! You've mastered the context and configuration system.
Key Takeaways
- RunnableConfig flows automatically: No need to pass manually at each step
- Callbacks enable observability: See what's happening without changing code
- Metadata carries context: User info, session data, request IDs
- Tags enable filtering: Organize and filter operations
- Callbacks don't break pipelines: Errors in callbacks are caught
- Configuration is composable: Child configs inherit from parents
- Runtime overrides are powerful: Change behavior without changing code
What You Built
A context system that:
- β Flows config through chains automatically
- β Supports multiple callbacks
- β Tracks metrics and performance
- β Enables runtime configuration
- β Provides observability
- β Handles errors gracefully
- β Is infinitely extensible
Foundation Complete! π
You've completed Part 1 (Foundation). You now understand:
- Runnable - The composability pattern
- Messages - Structured conversation data
- LLM Wrapper - Integrating real models
- Context - Passing state and observability
These four concepts are the foundation of every agent framework.
What's Next
In Part 2: Composition, you'll learn:
- Prompt templates
- Output parsers
- LLM chains
- The pipe operator
- Memory systems
β‘οΈ Continue to Part 2: Composition
Additional Resources
- LangChain Callbacks
- OpenTelemetry - Industry standard for observability
- Structured Logging
Questions & Discussion
Q: Why not just use console.log everywhere?
A: Callbacks are:
- Composable (turn on/off easily)
- Non-invasive (don't clutter code)
- Centralized (one place to change logging)
- Production-ready (can send to monitoring services)
Q: What's the performance overhead of callbacks?
A: Minimal if implemented correctly. The CallbackManager calls them in parallel and catches errors, so one slow callback doesn't slow everything down.
Q: Can I modify the input/output in a callback?
A: You can, but you shouldn't. Callbacks are for observation, not transformation. Transformations belong in the Runnable logic.
Q: How do I pass authentication through chains?
A: Use metadata:
const config = {
metadata: {
authToken: req.headers.authorization
}
};
Built with β€οΈ for learners who want to understand AI agents deeply
β Previous: LLM Wrapper | Tutorial Index | Next: Prompts β