Fluent DSL and Kotlin DSL for building autonomous agents with planning capabilities on the JVM, featuring annotation-based and programmatic configuration for agentic flows with Spring Boot integration
The IO Binding system enables data flow between actions by defining how inputs and outputs are named, typed, and bound to the blackboard. Bindings connect action results to subsequent action parameters, enabling sophisticated workflow patterns.
Core binding definition that connects variables to types for data flow.
@JvmInline
value class IoBinding(val value: String) {
constructor(name: String, type: String)
val type: String
val name: String
fun resolveJvmType(): JvmType?
companion object {
const val DEFAULT_BINDING = "it"
const val LAST_RESULT_BINDING = "lastResult"
operator fun invoke(name: String? = DEFAULT_BINDING, type: Class<*>): IoBinding
operator fun invoke(name: String? = DEFAULT_BINDING, type: KClass<*>): IoBinding
}
}Properties:
value - String representation in format "name:Type"type - The type name extracted from the bindingname - The variable name, defaults to "it" if not specifiedvariable - Alias for name (the binding variable)typeName - Alias for type (the type name)jvmType - Resolved JVM type, may be null for dynamic typesBasic Binding Examples:
@Agent(name = "data-processor", provider = "processing", description = "Processes data")
class DataProcessorAgent {
@Action(
description = "Load user data",
outputBinding = "user" // Bind output to "user"
)
fun loadUser(userId: String): User {
return userRepository.findById(userId)
}
@Action(
description = "Validate user",
outputBinding = "validatedUser"
)
fun validateUser(
@RequireNameMatch("user") user: User // Requires "user" binding
): ValidatedUser {
return validator.validate(user)
}
@Action(
description = "Process validated user"
)
fun processUser(
@RequireNameMatch("validatedUser") validatedUser: ValidatedUser
): ProcessedResult {
return processor.process(validatedUser)
}
}Default Binding Pattern:
The default binding name "it" has special meaning - it matches the last result.
@Agent(name = "pipeline", provider = "processing", description = "Data pipeline")
class PipelineAgent {
@Action(description = "Step 1: Parse input")
fun parse(input: String): ParsedData {
// Output bound to "it" by default
return parser.parse(input)
}
@Action(description = "Step 2: Transform data")
fun transform(data: ParsedData): TransformedData {
// Input matches "it" binding from previous action
return transformer.transform(data)
}
@Action(description = "Step 3: Validate result")
fun validate(data: TransformedData): ValidationResult {
// Continues the chain using default binding
return validator.validate(data)
}
}Convert binding strings to JVM types for runtime type checking.
fun IoBinding.resolveJvmType(): JvmType?Type Resolution Examples:
@Agent(name = "type-aware", provider = "processing", description = "Type-aware processing")
class TypeAwareAgent {
@Action(description = "Process with type checking")
fun processWithTypeCheck(
data: Any,
context: ActionContext
): ProcessResult {
// Resolve the type of the data on the blackboard
val binding = IoBinding("data", data::class.java)
val jvmType = binding.resolveJvmType()
if (jvmType != null) {
context.updateProgress("Processing ${jvmType.ownLabel}")
// Check type compatibility
if (jvmType.isAssignableFrom(SpecialData::class.java)) {
return specialProcessor.process(data as SpecialData)
}
}
return standardProcessor.process(data)
}
@Action(description = "Dynamic type handling")
fun handleDynamicType(
typeName: String,
context: ActionContext
): TypeInfo {
val binding = IoBinding("it", typeName)
val jvmType = binding.resolveJvmType()
return if (jvmType != null) {
TypeInfo(
name = jvmType.name,
label = jvmType.ownLabel,
description = jvmType.description,
canCreate = jvmType.creationPermitted
)
} else {
TypeInfo(
name = typeName,
label = typeName,
description = "Dynamic type",
canCreate = false
)
}
}
}
data class TypeInfo(
val name: String,
val label: String,
val description: String,
val canCreate: Boolean
)Sentinel object for actions that don't return meaningful results.
object ActionVoidResult {
override fun toString(): String = "ActionVoidResult"
}When an action returns null, Unit, or Void, the system places ActionVoidResult on the blackboard. This invalidates any @Trigger precondition that was satisfied before the action ran.
Void Result Example:
@Agent(name = "notification-sender", provider = "messaging", description = "Sends notifications")
class NotificationSenderAgent {
@Action(
description = "Send notification",
post = ["notification_sent"]
)
fun sendNotification(message: String): Unit {
// Returns Unit - ActionVoidResult will be placed on blackboard
messagingService.send(message)
}
@Action(
description = "Log notification sent",
pre = ["notification_sent"]
)
fun logNotification(message: String) {
// Runs after notification is sent, despite void result
auditLog.record("Notification sent: $message")
}
@Action(
description = "Triggered action",
trigger = NotificationComplete::class
)
fun onNotificationComplete() {
// This trigger will be invalidated after void-returning actions
// because ActionVoidResult replaces the previous result
cleanupService.cleanup()
}
}
data class NotificationComplete(val timestamp: Instant)Interface for operations that have inputs and outputs.
interface DataFlowStep : AgentSystemStep {
/** Data inputs to this step */
val inputs: Set<IoBinding>
/** Expected data outputs of the step */
val outputs: Set<IoBinding>
}Actions automatically implement DataFlowStep based on their parameters and return types.
DataFlowStep Example:
@Agent(name = "workflow", provider = "processing", description = "Multi-step workflow")
class WorkflowAgent {
// inputs: Set<IoBinding> = { IoBinding("it", RawData::class) }
// outputs: Set<IoBinding> = { IoBinding("it", CleanedData::class) }
@Action(
description = "Clean raw data",
outputBinding = "cleaned"
)
fun cleanData(raw: RawData): CleanedData {
return cleaner.clean(raw)
}
// inputs: Set<IoBinding> = { IoBinding("cleaned", CleanedData::class) }
// outputs: Set<IoBinding> = { IoBinding("it", EnrichedData::class) }
@Action(
description = "Enrich cleaned data",
outputBinding = "enriched"
)
fun enrichData(
@RequireNameMatch("cleaned") cleaned: CleanedData,
context: ActionContext
): EnrichedData {
// Multiple inputs from blackboard
val metadata = context.get("metadata", Metadata::class.java)
return enricher.enrich(cleaned, metadata)
}
// inputs: Set<IoBinding> = { IoBinding("enriched", EnrichedData::class) }
// outputs: Set<IoBinding> = { IoBinding("it", FinalResult::class) }
@Action(description = "Generate final result")
fun generateResult(
@RequireNameMatch("enriched") enriched: EnrichedData
): FinalResult {
return generator.generate(enriched)
}
}Tag interface for combining multiple blackboard values into a single input.
/**
* Tag interface to indicate that an implementing type should be built
* from the context from its bound fields.
* Makes a megazord!
*/
interface AggregationAn Aggregation waits for all its fields to be available on the blackboard, then constructs an instance from them. This provides a strongly-typed way to wait on combined results.
When to Use Aggregation:
Aggregation Examples:
@Agent(name = "report-generator", provider = "reporting", description = "Generates reports")
class ReportGeneratorAgent {
// Aggregation waits for all fields to be available
data class ReportInputs(
val salesData: SalesData,
val inventoryData: InventoryData,
val customerData: CustomerData
) : Aggregation
@Action(description = "Fetch sales data")
fun fetchSales(): SalesData {
return salesRepository.fetch()
}
@Action(description = "Fetch inventory data")
fun fetchInventory(): InventoryData {
return inventoryRepository.fetch()
}
@Action(description = "Fetch customer data")
fun fetchCustomers(): CustomerData {
return customerRepository.fetch()
}
@Action(
description = "Generate comprehensive report",
// Only runs when all three data types are available
)
fun generateReport(inputs: ReportInputs): Report {
// All required data is guaranteed to be present
return Report(
sales = inputs.salesData,
inventory = inputs.inventoryData,
customers = inputs.customerData,
generatedAt = Instant.now()
)
}
}Parallel Processing with Aggregation:
@Agent(name = "parallel-processor", provider = "processing", description = "Parallel processing")
class ParallelProcessorAgent {
data class ParallelResults(
val analysisResult: AnalysisResult,
val validationResult: ValidationResult,
val enrichmentResult: EnrichmentResult
) : Aggregation
@Action(description = "Analyze data", cost = 0.3)
fun analyzeData(data: InputData): AnalysisResult {
return analyzer.analyze(data)
}
@Action(description = "Validate data", cost = 0.2)
fun validateData(data: InputData): ValidationResult {
return validator.validate(data)
}
@Action(description = "Enrich data", cost = 0.4)
fun enrichData(data: InputData): EnrichmentResult {
return enricher.enrich(data)
}
@Action(
description = "Combine results",
// Waits for all three operations to complete
)
fun combineResults(results: ParallelResults): FinalResult {
return FinalResult(
quality = results.validationResult.quality,
insights = results.analysisResult.insights,
metadata = results.enrichmentResult.metadata
)
}
}Tag interface for partial binding - only some fields need to be present.
/**
* Tag interface used as an action return type.
* Indicates that some of the fields will be bound to the blackboard.
* Fields are usually nullable.
*/
interface SomeOf {
companion object {
/** Fields of this SomeOf that are domain types and can be bound */
fun eligibleFields(outputClass: Class<*>): Set<Field>
}
}Unlike Aggregation (which requires all fields), SomeOf allows returning one of several possible types. Each eligible field is individually bound to the blackboard.
When to Use SomeOf:
SomeOf Examples:
@Agent(name = "animal-processor", provider = "processing", description = "Processes animals")
class AnimalProcessorAgent {
// Only one field will be non-null
data class FrogOrDog(
val frog: Frog? = null,
val dog: Dog? = null
) : SomeOf
@Action(description = "Identify animal")
fun identifyAnimal(description: String, context: ActionContext): FrogOrDog {
val result = context.promptRunner()
.createObject<AnimalIdentification>("""
What animal is this: $description?
Is it a frog or a dog?
""")
return when (result.type) {
"frog" -> FrogOrDog(frog = Frog(result.name))
"dog" -> FrogOrDog(dog = Dog(result.name, result.breed))
else -> FrogOrDog() // Neither
}
}
// These actions will run based on what's available
@Action(description = "Process frog")
fun processFrog(frog: Frog): FrogResult {
return FrogResult("Ribbit from ${frog.name}")
}
@Action(description = "Process dog")
fun processDog(dog: Dog): DogResult {
return DogResult("Woof from ${dog.name} the ${dog.breed}")
}
}
data class Frog(val name: String)
data class Dog(val name: String, val breed: String)
data class AnimalIdentification(val type: String, val name: String, val breed: String = "")Conditional Data Enrichment with SomeOf:
@Agent(name = "data-enricher", provider = "enrichment", description = "Enriches data conditionally")
class DataEnricherAgent {
data class EnrichmentResult(
val geoData: GeoData? = null,
val demographicData: DemographicData? = null,
val behaviorData: BehaviorData? = null
) : SomeOf
@Action(description = "Enrich customer data")
fun enrichCustomerData(
customer: Customer,
context: ActionContext
): EnrichmentResult {
// Try to enrich with available data sources
val geo = if (customer.hasAddress()) {
geoService.lookup(customer.address)
} else null
val demographic = if (customer.hasAge()) {
demographicService.lookup(customer.age, customer.location)
} else null
val behavior = if (customer.hasPurchaseHistory()) {
behaviorService.analyze(customer.purchaseHistory)
} else null
// Return whatever data we could get
// Each non-null field will be bound separately
return EnrichmentResult(
geoData = geo,
demographicData = demographic,
behaviorData = behavior
)
}
// These actions run if their respective data is available
@Action(description = "Process geo data")
fun processGeo(geo: GeoData): GeoInsight {
return geoAnalyzer.analyze(geo)
}
@Action(description = "Process demographic data")
fun processDemographic(demographic: DemographicData): DemographicInsight {
return demographicAnalyzer.analyze(demographic)
}
@Action(description = "Process behavior data")
fun processBehavior(behavior: BehaviorData): BehaviorInsight {
return behaviorAnalyzer.analyze(behavior)
}
}Complex data flows with multiple binding patterns.
Multi-Stage Pipeline Example:
@Agent(
name = "document-processor",
provider = "document",
description = "Processes documents through multiple stages"
)
class DocumentProcessorAgent {
@Action(
description = "Parse document",
outputBinding = "parsed"
)
fun parseDocument(file: DocumentFile): ParsedDocument {
return parser.parse(file)
}
@Action(
description = "Extract entities",
outputBinding = "entities"
)
fun extractEntities(
@RequireNameMatch("parsed") parsed: ParsedDocument,
context: ActionContext
): EntityList {
return context.promptRunner()
.createObject<EntityList>("""
Extract all named entities from this document:
${parsed.text}
""")
}
@Action(
description = "Generate summary",
outputBinding = "summary"
)
fun generateSummary(
@RequireNameMatch("parsed") parsed: ParsedDocument,
context: ActionContext
): Summary {
return context.promptRunner()
.createObject<Summary>("""
Summarize this document:
${parsed.text}
""")
}
// Aggregation waits for both parallel operations
data class DocumentAnalysis(
val entities: EntityList,
val summary: Summary
) : Aggregation
@Action(
description = "Create final document",
outputBinding = "document"
)
fun createFinalDocument(
@RequireNameMatch("parsed") parsed: ParsedDocument,
analysis: DocumentAnalysis
): ProcessedDocument {
return ProcessedDocument(
originalText = parsed.text,
entities = analysis.entities,
summary = analysis.summary,
metadata = parsed.metadata
)
}
}Conditional Branching with Bindings:
@Agent(name = "order-router", provider = "orders", description = "Routes orders")
class OrderRouterAgent {
data class OrderClassification(
val standardOrder: StandardOrder? = null,
val expressOrder: ExpressOrder? = null,
val bulkOrder: BulkOrder? = null
) : SomeOf
@Action(
description = "Classify incoming order",
outputBinding = "classification"
)
fun classifyOrder(order: IncomingOrder): OrderClassification {
return when {
order.priority == "express" -> OrderClassification(
expressOrder = ExpressOrder(order.id, order.items)
)
order.items.size > 100 -> OrderClassification(
bulkOrder = BulkOrder(order.id, order.items)
)
else -> OrderClassification(
standardOrder = StandardOrder(order.id, order.items)
)
}
}
// Branch 1: Standard processing
@Action(description = "Process standard order")
fun processStandard(order: StandardOrder): OrderResult {
return standardProcessor.process(order)
}
// Branch 2: Express processing
@Action(description = "Process express order")
fun processExpress(order: ExpressOrder): OrderResult {
return expressProcessor.process(order)
}
// Branch 3: Bulk processing
@Action(description = "Process bulk order")
fun processBulk(order: BulkOrder): OrderResult {
return bulkProcessor.process(order)
}
}Complex Data Flow with Multiple Aggregations:
@Agent(
name = "analytics-engine",
provider = "analytics",
description = "Complex analytics pipeline"
)
class AnalyticsEngineAgent {
// Stage 1: Parallel data collection
@Action(description = "Collect sales data", cost = 0.2)
fun collectSales(dateRange: DateRange): SalesData {
return salesRepository.query(dateRange)
}
@Action(description = "Collect traffic data", cost = 0.2)
fun collectTraffic(dateRange: DateRange): TrafficData {
return trafficRepository.query(dateRange)
}
@Action(description = "Collect customer data", cost = 0.3)
fun collectCustomers(dateRange: DateRange): CustomerData {
return customerRepository.query(dateRange)
}
// Aggregation 1: All raw data
data class RawAnalyticsData(
val sales: SalesData,
val traffic: TrafficData,
val customers: CustomerData
) : Aggregation
// Stage 2: Parallel analysis
@Action(description = "Analyze sales trends", cost = 0.4)
fun analyzeSalesTrends(
raw: RawAnalyticsData,
context: ActionContext
): SalesTrends {
return context.promptRunner()
.createObject<SalesTrends>("""
Analyze sales trends:
${raw.sales.summary()}
""")
}
@Action(description = "Analyze user behavior", cost = 0.4)
fun analyzeUserBehavior(
raw: RawAnalyticsData,
context: ActionContext
): UserBehavior {
return context.promptRunner()
.createObject<UserBehavior>("""
Analyze user behavior:
Traffic: ${raw.traffic.summary()}
Customers: ${raw.customers.summary()}
""")
}
@Action(description = "Calculate metrics", cost = 0.2)
fun calculateMetrics(raw: RawAnalyticsData): Metrics {
return metricsCalculator.calculate(raw)
}
// Aggregation 2: All analysis results
data class AnalysisResults(
val trends: SalesTrends,
val behavior: UserBehavior,
val metrics: Metrics
) : Aggregation
// Stage 3: Final report
@Action(description = "Generate analytics report")
fun generateReport(
results: AnalysisResults,
context: ActionContext
): AnalyticsReport {
return context.promptRunner()
.createObject<AnalyticsReport>("""
Generate comprehensive analytics report:
Trends: ${results.trends}
Behavior: ${results.behavior}
Metrics: ${results.metrics}
""")
}
}@JvmInline
value class IoBinding(val value: String)
object ActionVoidResult
interface DataFlowStep : AgentSystemStep {
val inputs: Set<IoBinding>
val outputs: Set<IoBinding>
}
interface Aggregation
interface SomeOf {
companion object {
fun eligibleFields(outputClass: Class<*>): Set<Field>
}
}
data class JvmType(val className: String) : DomainType {
constructor(clazz: Class<*>)
val clazz: Class<*>
val name: String
val ownLabel: String
val description: String
val creationPermitted: Boolean
val parents: List<JvmType>
val ownProperties: List<PropertyDefinition>
fun isAssignableFrom(other: Class<*>): Boolean
fun isAssignableFrom(other: DomainType): Boolean
fun isAssignableTo(other: Class<*>): Boolean
fun isAssignableTo(other: DomainType): Boolean
}Install with Tessl CLI
npx tessl i tessl/maven-com-embabel-agent--embabel-agent-apidocs