Skip to main content

Online edition for BLOGE 0.9.8-RC1 · facts verified 2026-09-15 · 中文

Appendix E — Agent Extension

Chapter 17 teaches how to think about agents on top of bloge. This appendix is the lookup table you keep open while writing real ones: every DSL keyword, every record field, every SPI callback, every default value, and every finish-reason string the runtime can emit.

When You Need This Reference

QuestionSection
What DSL keywords can I use inside agent { … }?DSL Keyword Reference
How does a tool { … } block become an LLM tool schema?Tool Block Reference
Which memory strategies exist and what do they cost?Memory Strategies
What context variables can I use inside exit_condition?Exit Condition
What can AgentOutput.finishReason actually contain?Finish Reasons and Exceptions
Which callbacks fire and in what order?AgentExecutionListener Cheat Sheet
How do I build the agent from Java instead of DSL?Java Builder API
What chat-model SPIs do I have to satisfy?LLM Provider Surface
What Spring auto-configuration kicks in when this module is on the classpath?Spring Wiring
When should I reach for an agent versus a state machine or session?Decision Table

This is the reference layer for Chapter 17 — Agent Orchestration. Start there for the conceptual walk-through.

Module Surface

PropertyValue
Maven coordinatescom.leanowtech.bloge:bloge-agent-ext:${version}
DSL extension kind"agent"
RegistrationAgentExtensionProvider discovered via DslExtensionProvider SPI
Hard dependenciesbloge-core, bloge-dsl
Runtime dependenciesbloge-common-operators (for LlmProvider / llmChat / llmStreamingChat / TokenEstimator); optional JsonCodec for tool argument deserialization

When the JAR is on the classpath the DSL compiler picks up the agent block syntax automatically. No manual wiring is required for compilation. At runtime, the agent loop expects a registered LLM chat operator (sync: "llmChat"; streaming: "llmStreamingChat").

ReAct Turn Cycle

The loop, the listener callback points, and the streaming chunk types in one picture:

Diagram: appendix-e-agent-ext figure 1

DSL Keyword Reference

KeywordScopeType / valueDefaultPurpose
agentroot, graph body, phase body, state bodyblock prefixDeclares an agent extension block.
stream agentsame as agentblock prefixShorthand that sets streaming = true.
modelagent bodyStringrequiredLLM model identifier passed to llmChat.
system_promptagent bodyStringnullLeading system message added to every LLM call.
max_turnsagent bodyint10Hard cap on think-act-observe rounds. Exceeding it throws MaxTurnsExceededException.
max_tool_concurrencyagent bodyIntegernull (unlimited)Concurrent tool dispatch cap per turn.
temperatureagent bodyDoublenull (provider default)Sampling temperature passed to llmChat.
memoryagent bodystrategy expressionfull()Conversation memory strategy — see below.
exit_conditionagent bodyDSL expressionnull (never early-exit)Evaluated after each tool round; truthy result exits the loop.
streamingagent bodybooleanfalseMaterializes a StreamingAgentLoopOperator instead of AgentLoopOperator.
streaming_buffer_capacityagent bodyInteger16Internal LLM-token buffer size used by the streaming variant.
tool <name> : <OperatorRef>agent body, repeatableblockDeclares one tool. The operator referenced becomes its implementation.
descriptiontool bodyString""Tool description sent to the LLM.
input { … }tool bodyblock of assignmentsMaps tool-call arguments into operator inputs.
tool_argsinside input { … }context variableThe arguments object provided by the LLM for a tool call.

Memory Strategies

memory = … accepts these forms (all backed by AgentMemoryStrategy):

DSL formBehaviorNotes
full()Keeps every message ever appended.Default. Cheapest in CPU, most expensive in tokens.
sliding_window(n)Keeps all system messages plus the last n non-system messages.n must be ≥ 1.
token_budget(n)Prunes oldest non-system messages until the TokenEstimator reports ≤ n total tokens.Needs TokenEstimator (registered as an SPI; a heuristic fallback exists).
summary(n)Keeps the last n non-system messages verbatim and replaces older ones with an LLM-generated summary.Triggers a separate LLM call to summarize — measure cost.
AgentMemoryStrategy.Custom(factory)Java only — supply your own ConversationMemory.Factory.Not expressible in DSL.

Exit Condition

exit_condition is a DSL expression evaluated against this context after each tool round:

VariableTypeMeaning
finish_reasonStringFinish reason from the most recent LLM response.
contentStringAssistant content string from the most recent LLM response.
turnint1-based turn number that just completed.
tool_callsList<Map>Tool calls produced this turn — each has id, name, arguments.
tool_resultsMap<String,Object>Results from the current tool batch, keyed by tool name.
messagesList<LlmMessage>Full conversation state including newly-appended tool messages.

A truthy result records "exit_condition" in the listener's LoopExitedPayload.exitReason and stops the loop without another model call. The returned AgentOutput.finishReason still carries the latest provider reason.

Tool Block Reference

A tool block compiles into an AgentToolRef. Three things flow out of it:

  1. The LLM-facing ToolDefinitionname (the DSL label), description (your description = …), and a JSON-schema parameters object derived from the input { … } block.
  2. A subgraph that wraps the referenced operator. The compiler builds it so the agent loop can invoke the operator like any other bloge node.
  3. The resultNodeId the loop uses to extract the tool's output from that subgraph.

Tool Schema Inference

The compiler walks each assignment in input { … }. Every path of the form tool_args.<field> becomes a required parameter in the JSON-schema. Other assignments (constants or references to outer context) do not appear in the schema — they are filled in at dispatch time.

tool searchKnowledgeBase : KBSearchOperator {
description = "Search the knowledge base"
input {
query = tool_args.query
locale = ctx.user.locale // not part of the LLM-visible schema
}
}

The LLM sees a tool named searchKnowledgeBase with one required string parameter query. locale is filled from the surrounding graph context. If your tool inputs include nested objects, configure a JsonCodec on the DSL compiler so the arguments JSON can be deserialized into structured values.

Worked Example

The shortest end-to-end agent — DSL plus the Java bootstrap needed to run it.

agent customerSupport {
model = "gpt-4o"
system_prompt = "You are a helpful support agent."
max_turns = 15
temperature = 0.7
memory = sliding_window(20)

tool searchKnowledgeBase : KBSearchOperator {
description = "Search the knowledge base"
input { query = tool_args.query }
}

tool escalateToHuman : EscalateOperator {
description = "Escalate to a human agent"
input { reason = tool_args.reason }
}

exit_condition = finish_reason == "stop" || tool_call("escalateToHuman")
}
OperatorRegistry registry = OperatorRegistry.builder()
.register("KBSearchOperator", new KBSearchOperator())
.register("EscalateOperator", new EscalateOperator())
.register("llmChat", new LlmChatOperator(myLlmProvider))
.build();

AgentDef def = new AgentDslCompiler(registry).compile(dslSource);
AgentLoopOperator agent = new AgentLoopOperator("supportAgent", def, registry);

AgentOutput result = agent.execute("My account is locked", OperatorContext.root());
System.out.println(result.content());
System.out.println(result.finishReason()); // provider reason, e.g. "stop" / "tool_calls"

For the streaming variant call toStreamingOperator(...) on AgentBuilder or set streaming = true in DSL; the runtime emits AgentStreamChunk values through a NodeChannel.

Java Builder API

AgentBuilder mirrors every DSL keyword:

MethodPurpose
AgentBuilder.create(id)Start a fresh builder.
AgentBuilder.from(def)Start from an existing AgentDef.
.model(String)Sets the LLM model id.
.systemPrompt(String)Sets the system prompt.
.maxTurns(int)Sets the turn cap.
.maxToolConcurrency(int)Sets the per-turn tool concurrency cap.
.temperature(Double)Sets the sampling temperature.
.memory(AgentMemoryStrategy)Picks a memory strategy.
.tool(AgentToolRef)Adds one tool definition.
.exitCondition(Expression | String)Compiles or attaches an exit-condition expression.
.streaming(boolean)Toggles the streaming variant.
.streamingBufferCapacity(int)Sets the streaming buffer size.
.build()Produces an immutable AgentDef.
.toOperator(ref [, registry])Materializes an AgentLoopOperator.
.toStreamingOperator(ref [, registry])Materializes a StreamingAgentLoopOperator.

Record Schemas

Every record is immutable. Fields are listed in declaration order.

AgentDef

FieldTypeNotes
idStringIdentifier from the DSL.
modelStringLLM model id; required.
systemPromptStringMay be null.
maxTurnsintDefault 10.
maxToolConcurrencyIntegernull => unlimited.
temperatureDoublenull => provider default.
memoryStrategyAgentMemoryStrategyDefaults to Full.
toolsList<AgentToolRef>Order preserved from DSL.
exitConditionExpressionMay be null.
streamingbooleantrue => use streaming operator.
streamingBufferCapacityIntegerBuffer for streaming variant, default 16.

AgentInput

FieldTypeNotes
messagesList<LlmMessage>Normalized form. AgentInput.from(Object) coerces strings, single messages, and lists.

AgentOutput

FieldTypeNotes
contentStringFinal assistant content.
finishReasonStringSee Finish Reasons.
turnsUsedintTurns the loop actually consumed.
messagesList<LlmMessage>Full conversation, including system / assistant / tool messages.
toolResultsMap<String,Object>Latest-turn tool results, keyed by tool-call id.
checkpointAgentCheckpointSnapshot for resume; never null.

AgentCheckpoint

FieldTypeNotes
conversationList<LlmMessage>Messages up to and including the snapshot point.
turnCountintTurn at the snapshot.
toolResultsMap<String,Object>Tool results at the snapshot.

Persist this object alongside your own session/state-machine checkpoint to resume an agent run after restart.

AgentToolRef

FieldTypeNotes
nameStringTool name exposed to the LLM.
operatorRefStringOperator registered in OperatorRegistry.
descriptionStringSame string the LLM sees.
graphGraphCompiled subgraph wrapping the operator.
resultNodeIdStringNode whose output becomes the tool result.
toolDefinitionLlmProvider.ToolDefinitionLLM-facing schema (name + description + parameters).

AgentStreamChunk (sealed)

VariantFieldsWhen
Tokentext: String, turnNumber: intOne token (or token group) from the LLM.
ToolStarttoolName: String, toolCallId: String, turnNumber: intJust before a tool is dispatched.
ToolEndtoolName: String, toolCallId: String, success: boolean, turnNumber: intRight after a tool completes.
DonefinalOutput: AgentOutputLast chunk; the loop has exited.

Finish Reasons and Exceptions

SurfaceValue and timing
AgentOutput.finishReasonLatest provider reason such as "stop", "length", or "tool_calls"; preserved unchanged.
LoopExitedPayload.exitReason"stop", "exit_condition", or "max_turns", describing why the loop exited.
MaxTurnsExceededExceptionThrown directly at max_turns with a checkpoint; there is no normal output whose finish reason is "max_turns".

MaxTurnsExceededException is thrown from AgentLoopOperator.execute — the boundary code is responsible for catching it and surfacing a meaningful error to the caller.

AgentExecutionListener Cheat Sheet

CallbackPayload (record)Fields
onAgentTurnStartedTurnStartedPayloadturnNumber, messageCount
onAgentLlmCalledLlmCalledPayloadturnNumber, model, promptTokens, completionTokens, latencyMs, finishReason
onAgentToolDispatchedToolDispatchedPayloadturnNumber, toolCallId, toolName, argumentsJson
onAgentToolCompletedToolCompletedPayloadturnNumber, toolCallId, toolName, success, latencyMs, resultSummary
onAgentLoopExitedLoopExitedPayloadtotalTurns, totalLlmCalls, totalLlmTokens, totalLatencyMs, exitReason

Every callback receives an AgentCallbackContext with executionId, graphName, nodeId, operatorRef. All callbacks have default no-op implementations; override only the ones you care about.

The order in a typical turn is: onAgentTurnStartedonAgentLlmCalled → (per tool call) onAgentToolDispatched → (per tool call) onAgentToolCompleted → next turn or onAgentLoopExited.

ConversationMemory SPI

ConversationMemory is a sealed interface (FullConversationMemory, SlidingWindowConversationMemory, TokenBudgetConversationMemory, SummaryConversationMemory). Construct an instance with one of the static factory methods:

FactoryNotes
ConversationMemory.full(initialMessages)No pruning.
ConversationMemory.slidingWindow(initialMessages, windowSize)Keeps all system messages plus the last windowSize non-system.
ConversationMemory.tokenBudget(initialMessages, maxTokens, tokenEstimator)Prunes from the oldest non-system message until total tokens fit.
ConversationMemory.summary(initialMessages, summarizer, summaryThreshold)Calls summarizer once the non-system count exceeds summaryThreshold.

Two functional SPIs let you plug in custom behavior:

  • ConversationMemory.Factorycreate(List<LlmMessage> initial) lets you return any ConversationMemory instance and is the hook for AgentMemoryStrategy.Custom(factory).
  • ConversationMemory.Summarizersummarize(List<LlmMessage>) is what the Summary strategy calls. The bloge runtime does not include a default summarizer; wire your own (typically an llmChat call against the same model with a "summarize the conversation" prompt).

LLM Provider Surface (bloge-common-operators)

The agent loop never talks to OpenAI / Anthropic / Azure directly. It calls operators registered in OperatorRegistry under standard names. The contract lives in bloge-common-operators:

TypeFQNPurpose
LlmProvidercom.leanowtech.bloge.operators.spi.LlmProviderSPI for the underlying chat-model client.
LlmProvider.LlmMessagenested recordrole, content, parts, toolCalls, toolCallId.
LlmProvider.ToolDefinitionnested recordname, description, parameters (JSON-schema map).
LlmChatOperatorcom.leanowtech.bloge.operators.ai.LlmChatOperatorSynchronous chat operator; default registration name "llmChat".
Streaming chat operator(same module)Registered as "llmStreamingChat" for the streaming agent variant.
TokenEstimatorcom.leanowtech.bloge.operators.spi.TokenEstimatorEstimates message token count for token_budget memory. A heuristic default ships in the module.

If you write a brand-new provider, implement LlmProvider and pass it into LlmChatOperator when you register the operator. The agent loop will route through it unchanged.

Spring Wiring

bloge-agent-ext ships no Spring auto-configuration of its own. Two conditional hooks in bloge-spring cover everything Spring users need:

BeanDefined inActivation
AgentEventJournalBridgeBlogeEventJournalAutoConfigurationRegistered when bloge-agent-ext is on the classpath, bloge-event-journal is on the classpath, and spring.bloge.event-journal.enabled=true. Pipes every AgentExecutionListener callback into the engine's event journal.
Optional listener wiringOptionalAgentListenerSupportSoft class-loads AgentExecutionListener so the rest of bloge-spring works without the agent-ext JAR. Any AgentExecutionListener beans in the context are picked up automatically.

There is exactly one property:

spring.bloge:
event-journal:
enabled: true

Beyond that, expose your provider, your operators, and your listeners as ordinary Spring beans — they will be picked up via the usual OperatorRegistryAutoConfiguration path from Chapter 20.

Decision Table

Where does the agent loop fit alongside other bloge concepts?

QuestionReach for
Do I have a fixed pipeline of operators that always runs in the same order?Plain bloge graph. No agent.
Do I need an LLM to choose which operators to call, in what order, with arguments it synthesizes?agent { … }.
Do I need to drive a deterministic workflow through phases with guard-based transitions?State machine (Appendix D).
Do I need crash-safe multi-turn conversation with the user (not the LLM picking tools)?Session (Chapter 14).
Do I need streaming tokens out to a UI?Streaming variant — streaming = true or stream agent. See Appendix C for the broader streaming model.
Do I need fine-grained traces for LLM calls, tool dispatches, and timings?AgentExecutionListener + AgentEventJournalBridge.

Agents and state machines compose: an agent can be a single state's operator, and a state machine can route through several specialized agents.

Common Mistakes

  • Forgetting model = "…". Compilation fails. The model id is required and passed verbatim to the chat operator.
  • No llmChat operator registered. The DSL compiles, but the loop fails at the first turn with an "unknown operator" error. Wire a LlmChatOperator (or llmStreamingChat for streaming) when you build the registry.
  • tool_args.x outside an input { … } block. tool_args is only in scope inside a tool's input block. Using it in exit_condition or outside any tool is a compile error.
  • Unbounded full() memory on long conversations. Every turn re-sends the entire history. Switch to sliding_window(n) or token_budget(n) before going to production.
  • summary() strategy with no Summarizer wired. The default Summary strategy expects a summarizer callback (typically an LLM call). Without one configured, the strategy degrades to keeping the threshold windowed view only.
  • exit_condition that can never be true. If exit_condition references a tool result key that no tool ever produces, the loop runs to max_turns every time. Test with both the happy-path and the cap path.
  • JSON-schema mismatch for nested-object tool inputs. If your tool needs structured input, supply a JsonCodec to the DSL compiler so tool_args.foo.bar resolves through the deserialized object. Without a codec, only top-level string fields work cleanly.
  • MaxTurnsExceededException not caught at the boundary. The exception propagates up through execute(). Operator code calling the agent must catch it and surface a meaningful failure (otherwise the surrounding graph marks the node as crashed).

Relationship to Main Chapters

  • Chapter 7 — Designing Good Operators. The operator metadata fields (promptHint, usageExample, constraintsDescription) are what the agent loop reads to populate description and the JSON-schema hints surfaced to the LLM as tool definitions.
  • Chapter 11 — Batch and Iteration and Appendix C — Streaming. The streaming variant emits AgentStreamChunk values through the same NodeChannel mechanism Appendix C documents.
  • Chapter 19 — Observability in Production. AgentEventJournalBridge hooks every callback into the event journal Chapter 19 describes.
  • Chapter 9 — Tooling Workflow. The operator-metadata.json exporter describes each registered operator in the same shape the agent loop sends to the model as a ToolDefinition.
  • Chapter 20 — Spring and Production Wiring. Conditional bean registration described above; OperatorRegistryAutoConfiguration is the standard path for exposing operators and listeners.
  • Chapter 17 — Agent Orchestration. The conceptual chapter and worked walk-through this appendix backs.