CtrlK
CommunityDocumentationLog inGet started
Tessl Logo

tessl/maven-com-embabel-agent--embabel-agent-common

Common AI framework utilities for the Embabel Agent system including LLM configuration, output converters, prompt contributors, and embedding service abstractions.

Overview
Eval results
Files

error-handling.mddocs/advanced/

Error Handling

Exception types and error recovery strategies for Embabel Agent Common.

Exception Types

NoSuitableModelException

Thrown when no model matches the selection criteria.

class NoSuitableModelException(
    criteria: ModelSelectionCriteria,
    modelNames: List<String>
) : RuntimeException {
    companion object {
        @JvmStatic
        fun forModels(
            criteria: ModelSelectionCriteria,
            models: List<AiModel<*>>
        ): NoSuitableModelException
    }
}

When thrown:

  • Model selection by name fails (model doesn't exist)
  • Model selection by role fails (no model assigned to role)
  • Fallback chain exhausted (all models unavailable)

Usage:

try {
    val model = selectModel(criteria, availableModels)
} catch (e: NoSuitableModelException) {
    logger.error("No suitable model: ${e.message}")
    // Fallback to default
    val model = selectModel(ModelSelectionCriteria.PlatformDefault, availableModels)
}

Creating manually:

val criteria = ModelSelectionCriteria.byName("nonexistent-model")
val available = listOf("gpt-4", "claude-3-opus")

// Direct construction
throw NoSuitableModelException(criteria, available)

// From AiModel list
val aiModels: List<AiModel<*>> = // ...
throw NoSuitableModelException.forModels(criteria, aiModels)

Conversion Failures

Output converters return null instead of throwing exceptions for parsing errors.

val result = converter.convert(llmResponse)
if (result == null) {
    // Parsing failed - handle error
    logger.error("Failed to parse LLM response: $llmResponse")

    // Options:
    // 1. Retry with different prompt
    // 2. Use default value
    // 3. Throw custom exception
    // 4. Return error response to user
}

Common causes:

  • Invalid JSON syntax
  • Missing required fields
  • Type mismatches
  • Malformed response even after lenient parsing

Streaming Errors

Streaming converters skip invalid lines and continue processing.

val stream = converter.convertStream(jsonlResponse)

stream.subscribe(
    { event -> processEvent(event) },
    { error ->
        // Stream-level error (rare)
        logger.error("Stream failed", error)
    },
    { logger.info("Stream complete") }
)

Per-line errors are silently skipped - invalid JSON lines don't break the stream.

Recovery Strategies

Model Selection Fallback

fun selectModelWithFallback(preferred: String): LlmOptions {
    return try {
        LlmOptions.withModel(preferred)
    } catch (e: NoSuitableModelException) {
        logger.warn("Preferred model $preferred unavailable, using fallback")
        LlmOptions.withDefaultLlm()
    }
}

Built-in fallback chain:

// Automatically tries models in order
val options = LlmOptions.withFirstAvailableLlmOf(
    "gpt-4-turbo",
    "gpt-4",
    "gpt-3.5-turbo"
)

Conversion Retry

fun parseWithRetry(
    llmClient: LLMClient,
    converter: JacksonOutputConverter<Person>,
    prompt: String,
    maxRetries: Int = 3
): Person? {
    repeat(maxRetries) { attempt ->
        val response = llmClient.call(prompt)
        val result = converter.convert(response)

        if (result != null) {
            return result
        }

        logger.warn("Parse attempt ${attempt + 1} failed, retrying...")
        Thread.sleep(1000 * (attempt + 1)) // Exponential backoff
    }

    return null
}

Partial Parsing

data class PartialPerson(
    val name: String?,
    val age: Int?,
    val email: String?
)

fun parsePartial(response: String): PartialPerson? {
    // All fields nullable - accepts partial data
    val partial = converter.convert(response)

    // Validate minimum requirements
    return partial?.takeIf { it.name != null }
}

Default Values

data class PersonWithDefaults(
    val name: String = "Unknown",
    val age: Int = 0,
    val email: String = ""
)

// Always succeeds with defaults for missing fields
val person = converter.convert(response) ?: PersonWithDefaults()

Streaming Error Recovery

fun processStreamWithRecovery(jsonl: String): List<Event> {
    val results = mutableListOf<Event>()
    val errors = mutableListOf<String>()

    val stream = converter.convertStream(jsonl)

    stream.subscribe(
        { event -> results.add(event) },
        { error -> errors.add(error.message ?: "Unknown") },
        {
            if (errors.isNotEmpty()) {
                logger.warn("Stream had ${errors.size} errors: $errors")
            }
            logger.info("Processed ${results.size} events")
        }
    )

    return results
}

Validation

Pre-conversion Validation

fun validateResponse(response: String): Boolean {
    return response.isNotBlank() &&
           (response.contains("{") || response.contains("[")) &&
           response.length < 1_000_000 // Sanity check
}

if (validateResponse(llmResponse)) {
    val result = converter.convert(llmResponse)
} else {
    logger.error("Invalid response format")
}

Post-conversion Validation

data class Person(val name: String, val age: Int, val email: String)

fun validatePerson(person: Person): Boolean {
    return person.name.isNotBlank() &&
           person.age in 0..150 &&
           person.email.contains("@")
}

val person = converter.convert(response)
if (person != null && validatePerson(person)) {
    // Use validated person
} else {
    logger.error("Invalid person data")
}

Schema Validation

import com.fasterxml.jackson.databind.JsonNode
import com.github.fge.jsonschema.core.report.ProcessingReport
import com.github.fge.jsonschema.main.JsonSchemaFactory

fun validateAgainstSchema(json: String, schema: String): Boolean {
    val factory = JsonSchemaFactory.byDefault()
    val schemaNode = mapper.readTree(schema)
    val jsonNode = mapper.readTree(json)

    val validator = factory.getJsonSchema(schemaNode)
    val report: ProcessingReport = validator.validate(jsonNode)

    return report.isSuccess
}

Error Logging

Structured Error Logging

fun logConversionError(response: String, error: Exception) {
    logger.error("""
        Conversion failed:
        - Response length: ${response.length}
        - First 100 chars: ${response.take(100)}
        - Error: ${error.message}
    """.trimIndent())
}

Error Metrics

class ErrorMetrics {
    private val conversionErrors = AtomicInteger(0)
    private val modelSelectionErrors = AtomicInteger(0)

    fun recordConversionError() {
        conversionErrors.incrementAndGet()
    }

    fun recordModelSelectionError() {
        modelSelectionErrors.incrementAndGet()
    }

    fun report(): String {
        return """
            Conversion errors: ${conversionErrors.get()}
            Model selection errors: ${modelSelectionErrors.get()}
        """.trimIndent()
    }
}

Timeout Handling

Request Timeout

val options = LlmOptions.withModel("gpt-4")
    .withTimeout(Duration.ofSeconds(30))

try {
    val response = llmClient.call(prompt, options)
} catch (e: TimeoutException) {
    logger.error("Request timed out after 30 seconds")
    // Retry with longer timeout or different model
}

Streaming Timeout

import java.time.Duration

stream
    .timeout(Duration.ofSeconds(60))
    .onErrorResume { error ->
        logger.error("Stream timeout", error)
        Flux.empty() // Return empty stream
    }
    .subscribe { event -> processEvent(event) }

Circuit Breaker Pattern

class LLMCircuitBreaker(
    private val failureThreshold: Int = 5,
    private val resetTimeout: Duration = Duration.ofMinutes(5)
) {
    private var failures = 0
    private var lastFailure: Instant? = null
    private var state = State.CLOSED

    enum class State { CLOSED, OPEN, HALF_OPEN }

    fun <T> execute(operation: () -> T): T? {
        when (state) {
            State.OPEN -> {
                val elapsed = Duration.between(lastFailure, Instant.now())
                if (elapsed > resetTimeout) {
                    state = State.HALF_OPEN
                    logger.info("Circuit breaker half-open, testing...")
                } else {
                    logger.warn("Circuit breaker open, rejecting call")
                    return null
                }
            }
            State.CLOSED, State.HALF_OPEN -> {}
        }

        return try {
            val result = operation()
            onSuccess()
            result
        } catch (e: Exception) {
            onFailure()
            throw e
        }
    }

    private fun onSuccess() {
        failures = 0
        state = State.CLOSED
    }

    private fun onFailure() {
        failures++
        lastFailure = Instant.now()

        if (failures >= failureThreshold) {
            state = State.OPEN
            logger.error("Circuit breaker opened after $failures failures")
        }
    }
}

Best Practices

1. Always Check Null Results

// BAD
val person = converter.convert(response)
println(person.name) // NullPointerException if parsing failed

// GOOD
val person = converter.convert(response)
if (person != null) {
    println(person.name)
} else {
    handleError()
}

// BETTER
val person = converter.convert(response) ?: return handleError()

2. Use Fallback Chains

// BAD
val options = LlmOptions.withModel("expensive-model")

// GOOD
val options = LlmOptions.withFirstAvailableLlmOf(
    "expensive-model",
    "fallback-model",
    "cheap-model"
)

3. Log Failures with Context

// BAD
logger.error("Parse failed")

// GOOD
logger.error("Parse failed for response: ${response.take(200)}...")

4. Validate Before and After

// Validate input
if (!validateResponse(response)) return null

// Parse
val result = converter.convert(response)

// Validate output
if (result == null || !validateResult(result)) return null

return result

5. Set Appropriate Timeouts

// Quick tasks
val quickOptions = LlmOptions.withModel("gpt-3.5-turbo")
    .withTimeout(Duration.ofSeconds(10))

// Complex tasks
val complexOptions = LlmOptions.withModel("gpt-4")
    .withTimeout(Duration.ofSeconds(60))

Testing Error Conditions

@Test
fun `test conversion with invalid JSON`() {
    val result = converter.convert("{invalid json}")
    assertNull(result)
}

@Test
fun `test model selection with unavailable model`() {
    val criteria = ModelSelectionCriteria.byName("nonexistent")

    assertThrows<NoSuitableModelException> {
        selectModel(criteria, emptyList())
    }
}

@Test
fun `test streaming with partial errors`() {
    val jsonl = """
        {"valid": "json"}
        {invalid}
        {"also": "valid"}
    """.trimIndent()

    val results = mutableListOf<Map<String, String>>()
    converter.convertStream(jsonl)
        .subscribe { results.add(it) }

    assertEquals(2, results.size) // Only valid lines
}
tessl i tessl/maven-com-embabel-agent--embabel-agent-common@0.3.1

docs

advanced

error-handling.md

integration-patterns.md

performance.md

index.md

quick-reference.md

README.md

tile.json