Interceptors
Interceptors are composable middleware for the agent loop: they wrap iterations, LLM calls, and tool executions (onion-style, like sttp backend wrappers), observe provider-reported token usage, and can end the loop gracefully when a budget is exhausted.
Writing an interceptor
Extend AgentInterceptor[F] and override only the stages you need. All methods default to pass-through.
import sttp.ai.core.agent.*
import sttp.shared.Identity
val timing = new AgentInterceptor[Identity]:
override def aroundToolCall(ctx: ToolCallContext)(next: => Identity[ToolCallRecord]): Identity[ToolCallRecord] =
val start = System.nanoTime()
val record = next
println(s"${ctx.toolCall.toolName} took ${(System.nanoTime() - start) / 1000000} ms")
record
Rules of the trade:
nextis by-name — don’t force it before you mean to run the stage, and call it at most once. Skipping it short-circuits the stage.Exceptions thrown by interceptor code fail the whole run; they are never swallowed.
decideis pure: it judges the accumulatedAgentRunStatebefore each iteration and can returnLoopDecision.FinishNow(cause, instruction)to force a graceful final answer. The cause is aFinishReason.ForcedStop—BudgetExceededorCustom("your reason")— so interceptors cannot misreport loop-owned reasons likeNaturalStop. On a forced-final iteration the backend’sIterationInfo.isLastIterationis true, so per-iteration model selection picks the same (usually stronger) model as for a regular last iteration.
Composition and ordering
Add interceptors on the builder. The first added is outermost for around* stages; for decide, interceptors are
consulted in order and the first FinishNow wins:
import sttp.ai.core.agent.*
import sttp.ai.core.agent.interceptor.*
import sttp.ai.openai.agent.OpenAIAgent
import sttp.ai.openai.requests.completions.chat.ChatRequestBody.ChatCompletionModel
import sttp.monad.IdentityMonad
import sttp.shared.Identity
given sttp.monad.MonadError[Identity] = IdentityMonad
val agent = OpenAIAgent
.synchronous("api-key", ChatCompletionModel.GPT4oMini)
.addInterceptor(LoggingInterceptor[Identity]((level, msg) => println(s"[$level] $msg")))
.addInterceptor(BudgetInterceptor[Identity](maxTotalTokens = Some(Tokens(200_000L))))
.build
Usage accounting
Every LLM call’s provider-reported usage lands on the response and accumulates into the result:
import sttp.ai.core.agent.*
def report(result: AgentResult[String]): Unit =
println(s"total tokens: ${result.usage.totalTokens.value}")
result.llmCalls.foreach { call =>
println(s"${call.model.getOrElse("?")}: in=${call.usage.inputTokens.value} out=${call.usage.outputTokens.value}")
}
AgentResult.finishReason reports why the loop stopped: NaturalStop, MaxIterations, TokenLimit, Error, or
an interceptor-forced stop (BudgetExceeded, or Custom(reason) from your own steering interceptor).
Provider notes: cached input tokens are reported in cachedInputTokens (reads) and cacheWriteInputTokens
(writes, e.g. Claude’s cache_creation_input_tokens) — both are subsets of inputTokens. Gemini reports thought
tokens separately from output; the mapping folds them into outputTokens (with reasoningTokens as the subset),
so totalTokens matches the provider-reported total.
Budgets
BudgetInterceptor ends the loop gracefully — it injects a final-answer instruction and withholds tools, mirroring
the last-iteration behavior — instead of failing:
import sttp.ai.core.agent.*
import sttp.ai.core.agent.interceptor.*
import sttp.shared.Identity
val prices = PriceTable(Map(
"gpt-4o-mini" -> ModelPrice(inputPerMTok = BigDecimal("0.15"), outputPerMTok = BigDecimal("0.60"))
))
val budget = BudgetInterceptor[Identity](
maxTotalTokens = Some(Tokens(200_000L)),
maxCost = Some(Cost(BigDecimal("2.50"))),
priceTable = Some(prices)
)
The library ships no prices — supply your own table, keyed by the provider-reported model id. ModelPrice takes
optional dedicated rates for cache reads (cachedInputPerMTok) and cache writes (cacheWriteInputPerMTok, which
some providers bill at a premium); both default to the plain input rate. Calls whose model id is missing from the
table contribute zero to the cost check; prefer maxTotalTokens (which needs no table) when not every model in
play is priced. A BudgetInterceptor with no limit at all, or maxCost without a priceTable, fails fast at
construction.
Budgets are soft by one LLM call: a breach is detected at the next iteration boundary, and the forced final answer
itself is one more (tool-free) LLM call whose usage also counts. Size limits with that headroom in mind — a
maxTotalTokens of 200k may end the run at roughly 200k plus one final call.
Bridging the logging sink
LoggingInterceptor takes a sink (LogLevel, String) => F[Unit], so core needs no logging dependency. It requires
an implicit MonadError[F] in scope (for Identity, sttp.monad.IdentityMonad).
slf4j via log4cats:
import cats.effect.IO
import org.typelevel.log4cats.slf4j.Slf4jLogger
import sttp.ai.core.agent.interceptor.{LoggingInterceptor, LogLevel}
import sttp.client4.impl.cats.CatsMonadAsyncError
import sttp.monad.MonadError
given MonadError[IO] = new CatsMonadAsyncError[IO]()
val logger = Slf4jLogger.getLogger[IO]
val logging = new LoggingInterceptor[IO]({
case (LogLevel.Debug, msg) => logger.debug(msg)
case (LogLevel.Info, msg) => logger.info(msg)
case (LogLevel.Warn, msg) => logger.warn(msg)
})
ZIO logging:
import sttp.ai.core.agent.interceptor.{LoggingInterceptor, LogLevel}
import sttp.client4.impl.zio.RIOMonadAsyncError
import sttp.monad.MonadError
import zio.*
given MonadError[Task] = new RIOMonadAsyncError[Any]()
val logging = new LoggingInterceptor[Task]({
case (LogLevel.Debug, msg) => ZIO.logDebug(msg)
case (LogLevel.Info, msg) => ZIO.logInfo(msg)
case (LogLevel.Warn, msg) => ZIO.logWarning(msg)
})
For tracing (otel4s, ZIO Telemetry), wrap stages in spans with a custom interceptor instead of the logging sink:
import org.typelevel.otel4s.trace.Tracer
import sttp.ai.core.agent.*
class TracingInterceptor[F[_]: Tracer] extends AgentInterceptor[F]:
override def aroundLlmCall(ctx: LlmCallContext)(next: => F[AgentResponse]): F[AgentResponse] =
Tracer[F].span("agent.llm-call").surround(next)
Migrating from the tool-call hooks
The 0.5.x–0.7.x hookBeforeToolCall / hookAfterToolCall builder methods (and the corresponding AgentConfig
fields) were removed in 0.8.0. The equivalent interceptor:
import sttp.ai.core.agent.*
import sttp.shared.Identity
val hooks = new AgentInterceptor[Identity]:
override def aroundToolCall(ctx: ToolCallContext)(next: => Identity[ToolCallRecord]): Identity[ToolCallRecord] =
println(s"before ${ctx.toolCall.toolName}")
val record = next
println(s"after ${record.toolName}")
record