ExecutionContext API
Added in: 0.6.0
Unified execution context for Spice graphs - single source of truth for tenant, user, and custom metadata.
Overview
ExecutionContext replaces the dual context system (AgentContext + NodeContext.metadata) with a single, consistent API:
- Coroutine propagation - Works as
CoroutineContext.Element - Immutable - All modifications return new instances
- Type-safe - Built-in accessors for common fields
- Unified - Same context used in
NodeContext,RunContext, andComm
Core Structure
data class ExecutionContext(
private val data: Map<String, Any> = emptyMap()
) : AbstractCoroutineContextElement(ExecutionContext)
Creating Context
From Map
val context = ExecutionContext.of(mapOf(
"tenantId" to "tenant-123",
"userId" to "user-456",
"correlationId" to "corr-789",
"customKey" to customValue
))
From AgentContext (Migration)
val agentContext = AgentContext.of("tenantId" to "CHIC")
val execContext = agentContext.toExecutionContext()
// With additional fields
val enriched = agentContext.toExecutionContext(
mapOf("sessionId" to "sess-123")
)
Accessing Data
Type-Safe Accessors
// Built-in accessors
val tenantId: String? = context.tenantId
val userId: String? = context.userId
val correlationId: String? = context.correlationId
// Generic access
val raw: Any? = context.get("customKey")
val typed: String? = context.getAs<String>("customKey")
Convert to Map
val map: Map<String, Any> = context.toMap()
Modifying Context
All modifications return new instances (immutable):
// Add single value
val updated = context.plus("sessionId", "sess-123")
// Add multiple values
val enriched = context.plusAll(mapOf(
"key1" to "value1",
"key2" to "value2"
))
// Chaining
val final = context
.plus("sessionId", "sess-123")
.plusAll(mapOf("region" to "us-west"))
Usage in Graphs
NodeContext
class MyNode : Node {
override suspend fun run(ctx: NodeContext): SpiceResult<NodeResult> {
// Access context
val tenantId = ctx.context.tenantId
val customValue = ctx.context.get("customKey")
// Return enriched context
return SpiceResult.success(
NodeResult.fromContext(
ctx,
data = result,
additional = mapOf("nodeProcessed" to true)
)
)
}
}
Graph Invocation
val graph = graph("my-graph") {
agent("processor", myAgent)
output("result")
}
// Initialize with context via metadata
val result = runner.run(
graph,
mapOf(
"input" to "data",
"metadata" to mapOf(
"tenantId" to "tenant-123",
"userId" to "user-456"
)
)
).getOrThrow()
Coroutine Propagation
Accessor Functions
Added in: 0.6.0
Convenient functions to access ExecutionContext from anywhere in your code:
// Get current context (returns null if not in scope)
suspend fun myService() {
val context = currentExecutionContext()
val tenantId = context?.tenantId
}
// Require context (throws if not present)
suspend fun myService() {
val context = requireExecutionContext()
val tenantId = context.tenantId // Safe - won't be null
}
// Direct accessors (most convenient!)
suspend fun myService() {
val tenantId = getCurrentTenantId()
val userId = getCurrentUserId()
val correlationId = getCurrentCorrelationId()
}
Available Functions:
currentExecutionContext(): ExecutionContext?- Get context or nullrequireExecutionContext(): ExecutionContext- Get context or throwgetCurrentTenantId(): String?- Get tenant IDgetCurrentUserId(): String?- Get user IDgetCurrentCorrelationId(): String?- Get correlation ID
Setting Context
// ExecutionContext auto-propagates through coroutines
withContext(ExecutionContext.of(mapOf("tenantId" to "CHIC"))) {
// All graph/agent executions inherit context
runner.run(graph, input)
// Service layer can access it
myService() // getCurrentTenantId() works!
}
// Or use withAgentContext DSL (sets both AgentContext + ExecutionContext)
withAgentContext("tenantId" to "CHIC", "userId" to "user-123") {
runner.run(graph, input)
// Both work!
val ctx1 = currentAgentContext()
val ctx2 = currentExecutionContext()
}
Service Layer Pattern
// Service doesn't need context parameter!
suspend fun processOrder(orderId: String) {
val tenantId = getCurrentTenantId() ?: error("No tenant")
val userId = getCurrentUserId()
// Use context
val order = orderRepository.findByTenant(orderId, tenantId)
auditLog.log("Order processed", tenantId, userId)
}
// Called from controller with context
suspend fun handleRequest(request: OrderRequest) {
withAgentContext(
"tenantId" to request.tenantId,
"userId" to request.userId
) {
processOrder(request.orderId) // Context propagates!
}
}
RunContext Integration
Added in: 0.6.0
RunContext now uses ExecutionContext:
data class RunContext(
val graphId: String,
val runId: String,
val context: ExecutionContext // Unified!
)
Middleware usage:
class MyMiddleware : Middleware {
override suspend fun onStart(ctx: RunContext, next: suspend () -> Unit) {
val tenantId = ctx.context.tenantId
println("Starting graph for tenant: $tenantId")
next()
}
}
Comm Integration
Breaking Change in: 0.6.0
Comm.context now uses ExecutionContext:
val comm = Comm(
content = "Hello",
from = "user",
context = ExecutionContext.of(mapOf("tenantId" to "tenant-123"))
)
// Backward compatibility bridge
val agentContext = AgentContext.of("tenantId" to "CHIC")
val comm2 = Comm(
content = "Hello",
from = "user",
context = agentContext // Automatically converts to ExecutionContext
)
Migration from AgentContext
Converting
// AgentContext → ExecutionContext
val agentCtx = AgentContext.of("tenantId" to "CHIC")
val execCtx = agentCtx.toExecutionContext()
// ExecutionContext → AgentContext (if needed)
val execCtx = ExecutionContext.of(mapOf("tenantId" to "CHIC"))
val agentCtx = execCtx.toAgentContext()
Before/After
// 0.5.x
val agentCtx = AgentContext.of("tenantId" to "CHIC", "userId" to "user-123")
val comm = Comm(content = "test", from = "user", context = agentCtx)
class MyNode : 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"))
}
}
// 0.6.0
val execCtx = ExecutionContext.of(mapOf("tenantId" to "CHIC", "userId" to "user-123"))
val comm = Comm(content = "test", from = "user", context = execCtx)
class MyNode : Node {
override suspend fun run(ctx: NodeContext): SpiceResult<NodeResult> {
val tenant = ctx.context.tenantId // Unified!
val custom = ctx.context.get("customKey")
// State is immutable - return updates via metadata
return SpiceResult.success(
NodeResult.fromContext(ctx, data = "done", additional = mapOf("result" to "value"))
)
}
}
Best Practices
1. Use Type-Safe Accessors
// ✅ Good
val tenantId = ctx.context.tenantId
val userId = ctx.context.userId
// ⚠️ Avoid (unless custom keys)
val tenantId = ctx.context.get("tenantId") as? String
2. Preserve Context in Nodes
// ✅ Good - preserves all existing context
NodeResult.fromContext(ctx, data = result, additional = mapOf("newKey" to "value"))
// ❌ Bad - loses context
NodeResult.create(data = result, metadata = mapOf("newKey" to "value"))
3. Immutable Updates
// ✅ Good - functional style
val updated = ctx
.withState("key1", "value1")
.withState("key2", "value2")
.withContext(enrichedContext)
// ❌ Bad - won't compile (state is immutable)
ctx.state["key"] = "value"