examples/multiagent-patterns/handoffs-singleagent/README.md
This module implements the handoffs (state machine) pattern with a single agent. The agent’s behavior changes dynamically based on workflow state: tools update state variables (current_step, warranty_status, issue_type), and a model interceptor reads the current step to apply the right system prompt and tools.
Single agent, step-based configuration
One ReactAgent runs the whole flow. Each “step” is a different configuration (system prompt + tool set) of the same agent, selected by a ModelInterceptor that reads current_step from the request context (graph state).
State-driven steps
warranty_collector: Ask if the device is under warranty; only tool: record_warranty_status.issue_classifier: Ask for issue description and classify as hardware/software; only tool: record_issue_type.resolution_specialist: Provide solution or escalate; tools: provide_solution, escalate_to_human.Tools that update state
record_warranty_status and record_issue_type write to the graph state via ToolContextHelper.getStateForUpdate(toolContext) and set current_step to the next step. The framework merges these updates into the graph state so the next model call sees the new step.
Checkpointer
A MemorySaver is used so that state (and thus current_step, warranty_status, issue_type) persists across turns when you use the same thread_id in RunnableConfig.
State keys
current_step, warranty_status, issue_type are stored in the graph state. A Hook adds key strategies (ReplaceStrategy) for these keys so they merge correctly when tools return updates.
Step-config interceptor
StepConfigInterceptor runs before each model call. It reads current_step from the request context, looks up the step config, and overrides the system message and the list of tools so the model only sees the tools for that step.
Tool responses
State-updating tools return a plain string. The framework turns that into the tool response message; the state update is applied separately via the tool context update map.
examples/multiagent-patterns/handoffs-singleagent/
├── pom.xml
├── README.md
└── src/main/
├── java/.../handoffs/singleagent/
│ ├── HandoffsApplication.java
│ ├── HandoffsConfig.java # supportAgent bean (tools, hook, saver)
│ ├── HandoffsRunner.java # optional 4-turn demo runner
│ ├── support/
│ │ ├── SupportStateConstants.java
│ │ ├── StepConfigInterceptor.java # step-based prompt + tools
│ │ └── HandoffsSupportHook.java # key strategies + interceptor
│ └── tools/
│ └── SupportTools.java # record_warranty_status, record_issue_type, provide_solution, escalate_to_human
└── resources/
└── application.yml
export AI_DASHSCOPE_API_KEY=your-keyFrom the repo root or module directory:
cd examples/multiagent-patterns/handoffs-singleagent
./mvnw -B package -DskipTests
Default: the app starts without running the demo:
java -jar target/handoffs-singleagent-0.0.1-SNAPSHOT.jar
# or
./mvnw spring-boot:run
Set handoffs.run-examples=true. The runner uses a fixed thread_id so the checkpointer keeps state across the four turns:
# application.yml: handoffs.run-examples: true
# or
export HANDOFFS_RUN_EXAMPLES=true
java -jar target/handoffs-singleagent-0.0.1-SNAPSHOT.jar
Inject the agent and call it with a stable thread_id so state persists across turns:
@Qualifier("supportAgent")
@Autowired
ReactAgent supportAgent;
RunnableConfig config = RunnableConfig.builder().threadId("my-support-session").build();
// Turn 1
AssistantMessage r1 = supportAgent.call(new UserMessage("Hi, my device is broken"), config);
// Turn 2 (same thread_id → state is loaded from checkpoint)
AssistantMessage r2 = supportAgent.call(new UserMessage("Yes, it's still under warranty"), config);
Without a checkpointer and without reusing thread_id, each call would start from a clean state and the step machine would not advance.
spring.ai.dashscope.api-key
Required. Defaults to AI_DASHSCOPE_API_KEY env var.
handoffs.run-examples
If true, runs the four-turn demo on startup. Default: false.