CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl/maven-com-embabel-agent--embabel-agent-api

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

Overview
Eval results
Files

io-binding.mddocs/

Input/Output Binding

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.

Capabilities

IoBinding

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 binding
  • name - The variable name, defaults to "it" if not specified
  • variable - Alias for name (the binding variable)
  • typeName - Alias for type (the type name)
  • jvmType - Resolved JVM type, may be null for dynamic types

Basic 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)
    }
}

Type Resolution

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
)

ActionVoidResult

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)

DataFlowStep

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)
    }
}

Aggregation

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 Aggregation

An 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:

  • Wait for multiple independent actions to complete
  • Combine results from parallel operations
  • Create a composite input from separate blackboard values
  • Build complex domain objects from multiple sources

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
        )
    }
}

SomeOf

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:

  • Return one of several possible result types
  • Conditional outputs based on runtime decisions
  • Optional enrichment where only available data is returned
  • Partial results from operations that might partially fail

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)
    }
}

Action Chaining with Bindings

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}
            """)
    }
}

Types

@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-api@0.3.0

docs

actions-goals.md

agent-definition.md

builtin-tools.md

chat.md

conditions.md

events.md

human-in-the-loop.md

index.md

invocation.md

io-binding.md

llm-interaction.md

models.md

planning-workflows.md

platform-management.md

runtime-context.md

state-management.md

streaming.md

subagents.md

tools.md

type-system.md

typed-operations.md

validation.md

tile.json