ExecutionContext Patterns & Best Practices
Added in: 0.6.0
Complete guide to using ExecutionContext effectively in production applications.
Overviewβ
ExecutionContext is the unified context system in Spice 0.6.0 that replaces the dual AgentContext + NodeContext.metadata approach. This guide covers practical patterns and best practices.
Core Patternsβ
Pattern 1: Multi-Tenant Graph Executionβ
Execute graphs with tenant isolation:
import io.github.noailabs.spice.ExecutionContext
import io.github.noailabs.spice.graph.dsl.graph
import io.github.noailabs.spice.graph.runner.DefaultGraphRunner
suspend fun processOrder(orderId: String, tenantId: String, userId: String) {
val graph = graph("order-processor") {
agent("validator", validatorAgent)
agent("processor", processorAgent)
output("result")
}
val runner = DefaultGraphRunner()
// Initialize with execution context
val result = runner.run(
graph,
mapOf(
"input" to orderId,
"metadata" to mapOf(
"tenantId" to tenantId,
"userId" to userId,
"correlationId" to UUID.randomUUID().toString(),
"timestamp" to Instant.now().toString()
)
)
).getOrThrow()
return result.result
}
Key Points:
- β Context flows through entire graph automatically
- β
Every node can access
ctx.context.tenantId - β No manual context passing needed
- β
Type-safe accessors (
tenantId,userId,correlationId)
Pattern 2: Coroutine-Level Contextβ
Use coroutine context for cross-cutting concerns:
import io.github.noailabs.spice.dsl.withAgentContext
import kotlinx.coroutines.withContext
// Set context once for entire scope
suspend fun handleRequest(request: Request) {
withAgentContext(
"tenantId" to request.tenantId,
"userId" to request.userId,
"requestId" to UUID.randomUUID().toString()
) {
// All nested operations inherit context
val result1 = agent1.processComm(comm1) // Has context!
val result2 = agent2.processComm(comm2) // Has context!
val graphResult = runner.run(graph, input) // Has context!
// Even custom services can access it
val context = coroutineContext[ExecutionContext]
auditLog.log("Request processed", context?.tenantId)
}
}
Benefits:
- β Set once, use everywhere
- β No context parameter passing
- β Automatically propagates to graphs, agents, tools
- β Works with suspend functions
Pattern 3: Context Enrichmentβ
Add context as workflow progresses:
class EnrichmentNode(override val id: String) : Node {
override suspend fun run(ctx: NodeContext): SpiceResult<NodeResult> {
val userId = ctx.context.userId
// Load user profile
val userProfile = userService.getProfile(userId)
// Enrich context for downstream nodes
return SpiceResult.success(
NodeResult.fromContext(
ctx,
data = "user-loaded",
additional = mapOf(
"userName" to userProfile.name,
"userRole" to userProfile.role,
"userRegion" to userProfile.region,
"enrichedAt" to Instant.now().toString()
)
)
)
}
}
// Later nodes automatically get enriched context
class ProcessingNode(override val id: String) : Node {
override suspend fun run(ctx: NodeContext): SpiceResult<NodeResult> {
val userName = ctx.context.getAs<String>("userName")
val userRole = ctx.context.getAs<String>("userRole")
println("Processing for $userName with role $userRole")
return SpiceResult.success(
NodeResult.fromContext(ctx, data = "processed")
)
}
}
Pattern:
- Early node loads data
- Enriches context with additional fields
- Later nodes access enriched data
- Context accumulates throughout graph
Pattern 4: Context-Aware Toolsβ
Tools receive ExecutionContext automatically:
contextAwareTool("database_query") {
description = "Query tenant-specific database"
parameter("query", "string", "SQL query")
execute { params, context ->
// Context available in tool execution
val tenantId = context.tenantId ?: throw IllegalStateException("No tenant")
val userId = context.userId
// Use tenant-specific connection
val connection = connectionPool.getConnection(tenantId)
val results = connection.execute(params["query"] as String)
// Log with context
logger.info("Query executed", mapOf(
"tenantId" to tenantId,
"userId" to userId,
"query" to params["query"]
))
ToolResult.success(results.toString())
}
}
Automatic Context Propagation:
- Graph β Node β Tool (all automatic)
- No manual context passing
- Tools get
ToolContextwith all ExecutionContext data
Pattern 5: Custom Context Keysβ
Add domain-specific context:
// Initialize graph with custom keys
val result = runner.run(
graph,
mapOf(
"input" to orderData,
"metadata" to mapOf(
// Standard keys
"tenantId" to "ACME",
"userId" to "user-123",
// Custom domain keys
"businessUnit" to "SALES",
"region" to "US-WEST",
"priority" to "HIGH",
"retryPolicy" to "exponential",
"maxRetries" to 3
)
)
)
// Access in nodes
class PolicyNode(override val id: String) : Node {
override suspend fun run(ctx: NodeContext): SpiceResult<NodeResult> {
val priority = ctx.context.getAs<String>("priority")
val maxRetries = ctx.context.getAs<Int>("maxRetries")
when (priority) {
"HIGH" -> processImmediately()
"LOW" -> queueForLater()
else -> processNormal()
}
return SpiceResult.success(
NodeResult.fromContext(ctx, data = "processed")
)
}
}
Best Practices:
- β Use meaningful key names
- β Document custom keys in your domain model
- β
Use
getAs<T>for type-safe access - β Provide defaults for optional keys
Advanced Patternsβ
Pattern 6: Context Validationβ
Ensure required context exists:
class RequiredContextValidator : MetadataValidator {
override fun validate(metadata: Map<String, Any>): SpiceResult<Unit> {
val required = setOf("tenantId", "userId", "correlationId")
val missing = required.filter { !metadata.containsKey(it) }
return if (missing.isEmpty()) {
SpiceResult.success(Unit)
} else {
SpiceResult.failure(
SpiceError.validationError(
"Missing required context keys: ${missing.joinToString()}"
)
)
}
}
}
// Use with runner
val runner = DefaultGraphRunner(
metadataValidator = RequiredContextValidator()
)
Use Cases:
- Enforce tenant ID presence
- Validate correlation ID exists
- Check authorization context
- Prevent missing critical data
Pattern 7: Context Hierarchyβ
Build layered context:
// Application-level context
val appContext = ExecutionContext.of(mapOf(
"environment" to "production",
"version" to "1.0.0",
"region" to "us-west-1"
))
// Request-level context (adds to app context)
suspend fun handleRequest(request: Request) = withContext(appContext) {
withAgentContext(
"tenantId" to request.tenantId,
"userId" to request.userId,
"requestId" to UUID.randomUUID().toString()
) {
// Graph inherits both layers
runner.run(graph, input)
// All nodes have access to:
// - environment (app-level)
// - tenantId (request-level)
// - version (app-level)
// - userId (request-level)
}
}
Pattern:
- Base context (app/service level)
- Enriched context (request level)
- All layers accessible in nodes
Pattern 8: Context-Driven Routingβ
Use context to drive graph flow:
val graph = graph("context-router") {
agent("processor", processorAgent)
// Route based on context
edge("processor", "premium-path") { result ->
val ctx = (result.metadata["_executionContext"] as? ExecutionContext)
ctx?.getAs<String>("tier") == "PREMIUM"
}
edge("processor", "standard-path") { result ->
val ctx = (result.metadata["_executionContext"] as? ExecutionContext)
ctx?.getAs<String>("tier") != "PREMIUM"
}
agent("premium-handler", premiumAgent)
agent("standard-handler", standardAgent)
}
// Initialize with routing context
val result = runner.run(
graph,
mapOf(
"input" to data,
"metadata" to mapOf(
"tenantId" to "ACME",
"tier" to "PREMIUM" // Drives routing!
)
)
)
Production Patternsβ
Pattern 9: Logging & Observabilityβ
Consistent logging with context:
class AuditMiddleware : Middleware {
private val logger = LoggerFactory.getLogger(this::class.java)
override suspend fun onStart(ctx: RunContext, next: suspend () -> Unit) {
val tenantId = ctx.context.tenantId
val userId = ctx.context.userId
val correlationId = ctx.context.correlationId
MDC.put("tenantId", tenantId)
MDC.put("userId", userId)
MDC.put("correlationId", correlationId)
MDC.put("graphId", ctx.graphId)
MDC.put("runId", ctx.runId)
try {
logger.info("Graph execution started")
next()
logger.info("Graph execution completed")
} finally {
MDC.clear()
}
}
override suspend fun onNode(
req: NodeRequest,
next: suspend (NodeRequest) -> SpiceResult<NodeResult>
): SpiceResult<NodeResult> {
logger.info("Node executing: ${req.nodeId}")
val result = next(req)
when (result) {
is SpiceResult.Success -> logger.info("Node succeeded: ${req.nodeId}")
is SpiceResult.Failure -> logger.error("Node failed: ${req.nodeId}", result.error.toException())
}
return result
}
}
Benefits:
- β All logs tagged with tenant/user
- β Correlation ID for request tracing
- β MDC automatically cleared
- β Works with ELK/Datadog/etc.
Pattern 10: Error Context Preservationβ
Preserve context in error scenarios:
class ErrorHandlingNode(override val id: String) : Node {
override suspend fun run(ctx: NodeContext): SpiceResult<NodeResult> {
return SpiceResult.catching {
val result = riskyOperation()
NodeResult.fromContext(ctx, data = result)
}.recoverWith { error ->
// Even in error, preserve context for debugging
logger.error(
"Operation failed",
mapOf(
"tenantId" to ctx.context.tenantId,
"userId" to ctx.context.userId,
"correlationId" to ctx.context.correlationId,
"error" to error.message
)
)
// Return error with full context
SpiceResult.success(
NodeResult.fromContext(
ctx,
data = null,
additional = mapOf(
"error" to error.message,
"errorType" to error::class.simpleName,
"failedAt" to Instant.now().toString()
)
)
)
}
}
}
Key Points:
- β Context preserved in error paths
- β Debugging info includes tenant/user
- β Error metadata tracked
- β Downstream nodes know about failure
Common Pitfalls & Solutionsβ
Pitfall 1: Forgetting to Preserve Contextβ
β Wrong:
return SpiceResult.success(
NodeResult.create(
data = result,
metadata = mapOf("myKey" to "value") // β Lost all context!
)
)
β Correct:
return SpiceResult.success(
NodeResult.fromContext(
ctx,
data = result,
additional = mapOf("myKey" to "value") // β
Preserves context!
)
)
Pitfall 2: Mutating Stateβ
β Wrong (0.6.0 won't compile):
override suspend fun run(ctx: NodeContext): SpiceResult<NodeResult> {
ctx.state["result"] = computeValue() // β State is immutable!
return SpiceResult.success(NodeResult.fromContext(ctx, data = "done"))
}
β Correct:
override suspend fun run(ctx: NodeContext): SpiceResult<NodeResult> {
val value = computeValue()
// Return state updates via metadata
return SpiceResult.success(
NodeResult.fromContext(
ctx,
data = "done",
additional = mapOf("result" to value) // GraphRunner propagates to state
)
)
}
Pitfall 3: Not Using Type-Safe Accessorsβ
β Avoid:
val tenantId = ctx.context.get("tenantId") as? String // Verbose
val userId = ctx.context.toMap()["userId"] as? String // Unsafe
β Prefer:
val tenantId = ctx.context.tenantId // Type-safe!
val userId = ctx.context.userId // Built-in accessor
val custom = ctx.context.getAs<String>("customKey") // Generic type-safe
Pitfall 4: Metadata Size Explosionβ
β Wrong:
// Adding large objects to context
NodeResult.fromContext(
ctx,
data = result,
additional = mapOf(
"fullDocument" to largePdfBytes, // β Huge!
"entireDataset" to millionRecords // β Will exceed size limit
)
)
β Correct:
// Store references, not data
NodeResult.fromContext(
ctx,
data = result,
additional = mapOf(
"documentId" to documentId, // β
Small reference
"documentUrl" to s3Url, // β
URL reference
"recordCount" to 1_000_000 // β
Metadata only
)
)
Size Policies:
// Default: warn at 5KB
NodeResult.METADATA_WARN_THRESHOLD // 5000
// Configure hard limit if needed
NodeResult.HARD_LIMIT = 10_000
NodeResult.onOverflow = NodeResult.OverflowPolicy.FAIL
Testing Patternsβ
Pattern 11: Testing with Contextβ
@Test
fun `should process with tenant context`() = runTest {
// Given: Graph and context
val graph = graph("test-graph") {
agent("processor", testAgent)
output("result")
}
val runner = DefaultGraphRunner()
// When: Execute with context
val result = withAgentContext(
"tenantId" to "TEST_TENANT",
"userId" to "test-user"
) {
runner.run(
graph,
mapOf(
"input" to "test data",
"metadata" to mapOf(
"testMode" to true,
"mockServices" to true
)
)
)
}.getOrThrow()
// Then: Verify result
assertEquals("expected", result.result)
}
Pattern 12: Mocking Context-Aware Servicesβ
class MockTenantService : TenantService {
override suspend fun getData(key: String): String {
val context = coroutineContext[ExecutionContext]
val tenantId = context?.tenantId
return when (tenantId) {
"TENANT_A" -> "data-for-A"
"TENANT_B" -> "data-for-B"
else -> "default-data"
}
}
}
Migration Patternsβ
Pattern 13: Gradual Migration from AgentContextβ
// Step 1: Bridge usage (backward compatible)
val agentCtx = AgentContext.of("tenantId" to "ACME")
val comm = Comm(content = "test", from = "user", context = agentCtx)
// Automatically converts to ExecutionContext internally
// Step 2: Direct ExecutionContext usage (recommended)
val execCtx = ExecutionContext.of(mapOf("tenantId" to "ACME"))
val comm2 = Comm(content = "test", from = "user", context = execCtx)
// Step 3: Convert existing AgentContext
val converted = agentCtx.toExecutionContext(
additionalFields = mapOf("newKey" to "value")
)
Pattern 14: Converting Legacy Nodesβ
Before (0.5.x):
class LegacyNode : Node {
override suspend fun run(ctx: NodeContext): SpiceResult<NodeResult> {
val tenant = ctx.agentContext?.tenantId
val custom = ctx.metadata["customKey"]
ctx.state["result"] = "value"
return SpiceResult.success(
NodeResult(
data = "done",
metadata = ctx.metadata + mapOf("processed" to true)
)
)
}
}
After (0.6.0):
class ModernNode : Node {
override suspend fun run(ctx: NodeContext): SpiceResult<NodeResult> {
val tenant = ctx.context.tenantId // Unified!
val custom = ctx.context.get("customKey")
return SpiceResult.success(
NodeResult.fromContext(
ctx,
data = "done",
additional = mapOf(
"result" to "value", // State update
"processed" to true // Metadata
)
)
)
}
}
Performance Patternsβ
Pattern 15: Efficient Context Updatesβ
// β
Efficient: Single update with all changes
val enriched = ctx.context.plusAll(mapOf(
"key1" to "value1",
"key2" to "value2",
"key3" to "value3"
))
// β Less efficient: Multiple chained updates
val enriched = ctx.context
.plus("key1", "value1")
.plus("key2", "value2")
.plus("key3", "value3")
// Still works, but creates intermediate objects
Pattern 16: Context Cleanupβ
Remove temporary context after use:
class TemporaryContextNode(override val id: String) : Node {
override suspend fun run(ctx: NodeContext): SpiceResult<NodeResult> {
// Process with temporary context
val result = processWithTempContext(ctx.context.plus("temp", "value"))
// Don't propagate temporary keys
return SpiceResult.success(
NodeResult.fromContext(
ctx, // Original context (no "temp" key)
data = result,
additional = mapOf("processedAt" to Instant.now())
)
)
}
}
Observability Patternsβ
Pattern 17: Metadata Delta Trackingβ
// NodeReport includes metadata changes
report.nodeReports.forEach { nodeReport ->
println("Node: ${nodeReport.nodeId}")
// See what changed
nodeReport.metadataChanges?.forEach { (key, value) ->
println(" Added/Modified: $key = $value")
}
// Full metadata state
nodeReport.metadata?.let { fullMeta ->
println(" Full context keys: ${fullMeta.keys}")
}
}
Use Cases:
- Debug where context keys were added
- Track context growth over execution
- Identify which nodes enrich context
Pattern 18: Context Monitoringβ
class ContextMonitoringMiddleware : Middleware {
override suspend fun onNode(
req: NodeRequest,
next: suspend (NodeRequest) -> SpiceResult<NodeResult>
): SpiceResult<NodeResult> {
val beforeSize = req.context.context.toMap().size
val result = next(req)
if (result is SpiceResult.Success) {
val afterSize = result.value.metadata.size
val growth = afterSize - beforeSize
if (growth > 10) {
logger.warn(
"Context grew by $growth keys in node ${req.nodeId}",
mapOf("before" to beforeSize, "after" to afterSize)
)
}
}
return result
}
}
Security Patternsβ
Pattern 19: Tenant Isolation Verificationβ
class TenantIsolationMiddleware : Middleware {
override suspend fun onStart(ctx: RunContext, next: suspend () -> Unit) {
val tenantId = ctx.context.tenantId
?: throw SecurityException("No tenant ID - execution blocked")
// Verify tenant is active
if (!tenantRegistry.isActive(tenantId)) {
throw SecurityException("Tenant $tenantId is inactive")
}
next()
}
}
Pattern 20: Context Sanitizationβ
class ContextSanitizer : Node {
override suspend fun run(ctx: NodeContext): SpiceResult<NodeResult> {
// Remove sensitive keys before external API call
val sanitizedCtx = ctx.context.toMap()
.filterKeys { it !in setOf("internalToken", "secretKey") }
val apiResult = externalApi.call(
data = ctx.state["input"],
context = ExecutionContext.of(sanitizedCtx)
)
return SpiceResult.success(
NodeResult.fromContext(ctx, data = apiResult)
)
}
}