Online edition for BLOGE
0.9.9-RC1· prepared 2026-11-02 · 中文
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
| Question | Section |
|---|---|
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
| Property | Value |
|---|---|
| Maven coordinates | com.leanowtech.bloge:bloge-agent-ext:${version} |
| DSL extension kind | "agent" |
| Registration | AgentExtensionProvider discovered via DslExtensionProvider SPI |
| Hard dependencies | bloge-core, bloge-dsl |
| Runtime dependencies | bloge-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:
DSL Keyword Reference
| Keyword | Scope | Type / value | Default | Purpose |
|---|---|---|---|---|
agent | root, graph body, phase body, state body | block prefix | — | Declares an agent extension block. |
stream agent | same as agent | block prefix | — | Shorthand that sets streaming = true. |
model | agent body | String | required | LLM model identifier passed to llmChat. |
system_prompt | agent body | String | null | Leading system message added to every LLM call. |
max_turns | agent body | int | 10 | Hard cap on think-act-observe rounds. Exceeding it throws MaxTurnsExceededException. |
max_tool_concurrency | agent body | Integer | null (unlimited) | Concurrent tool dispatch cap per turn. |
temperature | agent body | Double | null (provider default) | Sampling temperature passed to llmChat. |
memory | agent body | strategy expression | full() | Conversation memory strategy — see below. |
exit_condition | agent body | DSL expression | null (never early-exit) | Evaluated after each tool round; truthy result exits the loop. |
streaming | agent body | boolean | false | Materializes a StreamingAgentLoopOperator instead of AgentLoopOperator. |
streaming_buffer_capacity | agent body | Integer | 16 | Internal LLM-token buffer size used by the streaming variant. |
tool <name> : <OperatorRef> | agent body, repeatable | block | — | Declares one tool. The operator referenced becomes its implementation. |
description | tool body | String | "" | Tool description sent to the LLM. |
input { … } | tool body | block of assignments | — | Maps tool-call arguments into operator inputs. |
tool_args | inside input { … } | context variable | — | The arguments object provided by the LLM for a tool call. |
Memory Strategies
memory = … accepts these forms (all backed by AgentMemoryStrategy):
| DSL form | Behavior | Notes |
|---|---|---|
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:
| Variable | Type | Meaning |
|---|---|---|
finish_reason | String | Finish reason from the most recent LLM response. |
content | String | Assistant content string from the most recent LLM response. |
turn | int | 1-based turn number that just completed. |
tool_calls | List<Map> | Tool calls produced this turn — each has id, name, arguments. |
tool_results | Map<String,Object> | Results from the current tool batch, keyed by tool name. |
messages | List<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:
- The LLM-facing
ToolDefinition—name(the DSL label),description(yourdescription = …), and a JSON-schemaparametersobject derived from theinput { … }block. - A subgraph that wraps the referenced operator. The compiler builds it so the agent loop can invoke the operator like any other bloge node.
- The
resultNodeIdthe 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:
| Method | Purpose |
|---|---|
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
| Field | Type | Notes |
|---|---|---|
id | String | Identifier from the DSL. |
model | String | LLM model id; required. |
systemPrompt | String | May be null. |
maxTurns | int | Default 10. |
maxToolConcurrency | Integer | null => unlimited. |
temperature | Double | null => provider default. |
memoryStrategy | AgentMemoryStrategy | Defaults to Full. |
tools | List<AgentToolRef> | Order preserved from DSL. |
exitCondition | Expression | May be null. |
streaming | boolean | true => use streaming operator. |
streamingBufferCapacity | Integer | Buffer for streaming variant, default 16. |
AgentInput
| Field | Type | Notes |
|---|---|---|
messages | List<LlmMessage> | Normalized form. AgentInput.from(Object) coerces strings, single messages, and lists. |
AgentOutput
| Field | Type | Notes |
|---|---|---|
content | String | Final assistant content. |
finishReason | String | See Finish Reasons. |
turnsUsed | int | Turns the loop actually consumed. |
messages | List<LlmMessage> | Full conversation, including system / assistant / tool messages. |
toolResults | Map<String,Object> | Latest-turn tool results, keyed by tool-call id. |
checkpoint | AgentCheckpoint | Snapshot for resume; never null. |
AgentCheckpoint
| Field | Type | Notes |
|---|---|---|
conversation | List<LlmMessage> | Messages up to and including the snapshot point. |
turnCount | int | Turn at the snapshot. |
toolResults | Map<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
| Field | Type | Notes |
|---|---|---|
name | String | Tool name exposed to the LLM. |
operatorRef | String | Operator registered in OperatorRegistry. |
description | String | Same string the LLM sees. |
graph | Graph | Compiled subgraph wrapping the operator. |
resultNodeId | String | Node whose output becomes the tool result. |
toolDefinition | LlmProvider.ToolDefinition | LLM-facing schema (name + description + parameters). |
AgentStreamChunk (sealed)
| Variant | Fields | When |
|---|---|---|
Token | text: String, turnNumber: int | One token (or token group) from the LLM. |
ToolStart | toolName: String, toolCallId: String, turnNumber: int | Just before a tool is dispatched. |
ToolEnd | toolName: String, toolCallId: String, success: boolean, turnNumber: int | Right after a tool completes. |
Done | finalOutput: AgentOutput | Last chunk; the loop has exited. |
Finish Reasons and Exceptions
| Surface | Value and timing |
|---|---|
AgentOutput.finishReason | Latest provider reason such as "stop", "length", or "tool_calls"; preserved unchanged. |
LoopExitedPayload.exitReason | "stop", "exit_condition", or "max_turns", describing why the loop exited. |
MaxTurnsExceededException | Thrown 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
| Callback | Payload (record) | Fields |
|---|---|---|
onAgentTurnStarted | TurnStartedPayload | turnNumber, messageCount |
onAgentLlmCalled | LlmCalledPayload | turnNumber, model, promptTokens, completionTokens, latencyMs, finishReason |
onAgentToolDispatched | ToolDispatchedPayload | turnNumber, toolCallId, toolName, argumentsJson |
onAgentToolCompleted | ToolCompletedPayload | turnNumber, toolCallId, toolName, success, latencyMs, resultSummary |
onAgentLoopExited | LoopExitedPayload | totalTurns, 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: onAgentTurnStarted → onAgentLlmCalled →
(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:
| Factory | Notes |
|---|---|
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.Factory—create(List<LlmMessage> initial)lets you return anyConversationMemoryinstance and is the hook forAgentMemoryStrategy.Custom(factory).ConversationMemory.Summarizer—summarize(List<LlmMessage>)is what theSummarystrategy calls. The bloge runtime does not include a default summarizer; wire your own (typically anllmChatcall 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:
| Type | FQN | Purpose |
|---|---|---|
LlmProvider | com.leanowtech.bloge.operators.spi.LlmProvider | SPI for the underlying chat-model client. |
LlmProvider.LlmMessage | nested record | role, content, parts, toolCalls, toolCallId. |
LlmProvider.ToolDefinition | nested record | name, description, parameters (JSON-schema map). |
LlmChatOperator | com.leanowtech.bloge.operators.ai.LlmChatOperator | Synchronous chat operator; default registration name "llmChat". |
| Streaming chat operator | (same module) | Registered as "llmStreamingChat" for the streaming agent variant. |
TokenEstimator | com.leanowtech.bloge.operators.spi.TokenEstimator | Estimates 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:
| Bean | Defined in | Activation |
|---|---|---|
AgentEventJournalBridge | BlogeEventJournalAutoConfiguration | Registered 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 wiring | OptionalAgentListenerSupport | Soft 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?
| Question | Reach 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
llmChatoperator registered. The DSL compiles, but the loop fails at the first turn with an "unknown operator" error. Wire aLlmChatOperator(orllmStreamingChatfor streaming) when you build the registry. tool_args.xoutside aninput { … }block.tool_argsis only in scope inside a tool'sinputblock. Using it inexit_conditionor outside any tool is a compile error.- Unbounded
full()memory on long conversations. Every turn re-sends the entire history. Switch tosliding_window(n)ortoken_budget(n)before going to production. summary()strategy with noSummarizerwired. The defaultSummarystrategy expects a summarizer callback (typically an LLM call). Without one configured, the strategy degrades to keeping the threshold windowed view only.exit_conditionthat can never be true. Ifexit_conditionreferences a tool result key that no tool ever produces, the loop runs tomax_turnsevery 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
JsonCodecto the DSL compiler sotool_args.foo.barresolves through the deserialized object. Without a codec, only top-level string fields work cleanly. MaxTurnsExceededExceptionnot caught at the boundary. The exception propagates up throughexecute(). 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 populatedescriptionand the JSON-schema hints surfaced to the LLM as tool definitions. - Chapter 11 — Batch and Iteration and Appendix C — Streaming. The
streaming variant emits
AgentStreamChunkvalues through the sameNodeChannelmechanism Appendix C documents. - Chapter 19 — Observability in Production.
AgentEventJournalBridgehooks every callback into the event journal Chapter 19 describes. - Chapter 9 — Tooling Workflow. The
operator-metadata.jsonexporter describes each registered operator in the same shape the agent loop sends to the model as aToolDefinition. - Chapter 20 — Spring and Production Wiring. Conditional bean
registration described above;
OperatorRegistryAutoConfigurationis the standard path for exposing operators and listeners. - Chapter 17 — Agent Orchestration. The conceptual chapter and worked walk-through this appendix backs.