Langgraph4j - Agent Executor (AKA ReACT Agent)
The "Agent Executor" flow involves a sequence of steps where the agent receives a query, decides on necessary actions, invokes tools, processes responses, iteratively performs tasks if needed, and finally returns a synthesized response to the user.
This flow ensures that the agent can handle complex tasks efficiently by leveraging the capabilities of various integrated tools and the decision-making power of the language model.
Diagram
---
title: Agent Executor
---
flowchart TD
__START__((start))
__END__((stop))
agent("agent")
action("action")
%% condition1{"check state"}
__START__:::__START__ --> agent:::agent
%% agent:::agent --> condition1:::condition1
%% condition1:::condition1 -->|end| __END__:::__END__
agent:::agent -->|end| __END__:::__END__
%% condition1:::condition1 -->|continue| action:::action
agent:::agent -->|continue| action:::action
action:::action --> agent:::agent
How to use
Maven dependency
Starting with release 1.9.4, use:
<dependency>
<groupId>org.bsc.langgraph4j</groupId>
<artifactId>langgraph4j-langchain4j-agentexecutor</artifactId>
<version>1.9.7</version>
</dependency>
The previous artifact ID, langgraph4j-agent-executor, is deprecated. For the
1.9.6 release, its POM redirects Maven to the new artifact ID at the same version
and emits a migration warning. Java packages and APIs are unchanged. Versions
already published, including 1.9.3, keep their original coordinates.
Publishing the relocation
The root reactor includes langchain4j/langchain4j-agent-relocation, a POM-only
module with the old artifact ID. It inherits the parent version, publishing
configuration and release signing, so the existing root snapshot and release
workflows publish both coordinates. Run release version updates from the root
so both modules receive the same version. When publishing selected modules,
include both langchain4j/langchain4j-agent and
langchain4j/langchain4j-agent-relocation, together with their required modules.
The relocation module publishes no replacement JAR; consumers receive the JAR
from the new coordinates. The BOM manages both names to support migration.
This follows the Maven relocation guide.
Java usage
public class TestTool {
private String lastResult;
Optional<String> lastResult() {
return Optional.ofNullable(lastResult);
}
@Tool("tool for test AI agent executor")
String execTest(@P("test message") String message) {
lastResult = format( "test tool executed: %s", message);
return lastResult;
}
}
public static String getPCName() {
return "Langgraph4j";
}
public void main( String args[] ) throws Exception {
var toolSpecification = ToolSpecification.builder()
.name("getPCName")
.description("Returns a String - PC name the AI is currently running in. Returns null if station is not running")
.build();
ToolExecutor toolExecutor = (toolExecutionRequest, memoryId) -> getPCName();
var chatModel = OpenAiChatModel.builder()
.apiKey( System.getenv( "OPENAI_API_KEY" ) )
.modelName( "gpt-4o-mini" )
.logResponses(true)
.maxRetries(2)
.temperature(0.0)
.maxTokens(2000)
.build();
var agentExecutor = AgentExecutor.graphBuilder()
.chatModel(chatModel)
// add object with tools
.toolsFromObject(new TestTool())
// add dynamic tool
.tool(toolSpecification, toolExecutor)
.build();
var workflow = agentExecutor.compile();
var state = workflow.stream( Map.of( "messages", UserMessage.from("Run my test!") ) );
var lastNode = generator.stream().reduce((a, b) -> b).orElseThrow();
if (lastNode.isEND()) {
System.out.println(String.format( "result: %s\n", lastNode.state().finalResponse().orElseThrow()));
}
}
SkillInjector (dynamic skills via tool success)
Tool-call–triggered skills for LangChain4j AgentExecutor: activate skill ids in Graph State after successful tool results; inject skill bodies into the next model request via ConversationContextPolicy (bodies never enter Graph State).
Setup
var injector = SkillInjector.builder()
.resolver(new MapToolSkillResolver()
.bind("query_logistics", "order-reply"))
// pick one body source:
.skillsFromClassPath("skills") // classpath:skills/.../SKILL.md
// .skills(ClassPathSkillLoader.loadSkills("skills"))
// .skillsFromPath(Path.of("my-skills"))
// .skillBody(id -> "...") // custom
.build();
var graph = AgentExecutor.builder()
.chatModel(model)
.toolsFromObject(businessTools)
.skillInjector(injector) // executeTools hook + conversation policy
.build();
Static build-time .skills(...) on the builder still works for catalog listing in the system prompt; SkillInjector is for runtime activation after tools succeed.
Behaviour
| Step | What happens |
|---|---|
Tool succeeds (ToolExecutionResultMessage and isError != true) |
Resolver maps tool → skill ids → merge into active_skills via Command.update |
| Tool fails / missing result | No activation |
Next CallModel |
Policy prepends skill bodies to the request message view |
Graph State messages |
Unchanged by injection (no skill body persisted) |
Lifetime
active_skills follows Graph State / checkpoint / threadId. Same thread without releaseThread ≈ Sticky; new thread or CompileConfig.releaseThread(true) ≈ Ephemeral.
AgentExecutor.State.SCHEMA always includes the optional active_skills channel; without skillInjector, nothing is activated and ReAct behaviour is unchanged.
Hooks only
AgentExecutor.builder()
.addCallModelHook(...)
.addExecuteToolsHook(injector.executeToolsHook())
.conversationContextPolicy(injector.asConversationContextPolicy(existingPolicy))
...
Unload API (follow-up PR)
PR1 only describes active_skills lifetime in terms of Graph State. PR2 adds a
declarative unload target so you can make skills
one-shot (ephemeral) or selectively scoped without relying on
releaseThread or manual state surgery.
Two activation flavours
| Flavour | When active_skills survive |
How to enable |
|---|---|---|
| Sticky (default, PR1 behaviour) | across every callModel node until the thread is released |
omit unload config |
| Single-turn (new in PR2, recommended) | cleared after each callModel node returns |
the shorthand singleTurn() or unloadAfterCallModel(UnloadTarget.all()) / ephemeral() |
| Selective | drop specific ids / ids matching a predicate, keep the rest | unloadAfterCallModel(UnloadTarget.ids(...) / matching(...)) |
Builder setup (single-turn, recommended)
var injector = SkillInjector.builder()
.resolver(new MapToolSkillResolver()
.bind("query_logistics", "order-reply"))
.skillsFromClassPath("skills")
.singleTurn() // <== PR2: clear every active_skills after each callModel node returns.
// Alias .ephemeral() is still available if you prefer that wording.
.build();
Builder setup (selective scoping)
var injector = SkillInjector.builder()
.resolver(resolver)
.skills(skills)
// Drop a specific list of ids once the following callModel round succeeds.
// The non-listed ones remain sticky across subsequent turns.
.unloadAfterCallModel(UnloadTarget.ids("order-reply"))
// or drop by rule (OPTIONAL V1 CAPABILITY — see note below):
// .unloadAfterCallModel(UnloadTarget.matching(id -> id.startsWith("tool-guidance:")))
.build();
Optional V1 capability:
UnloadTarget.matching(...)The predicate-based variant (UnloadTarget.Matching) is included in this PR for completeness, but it is intentionally not shown in the primary builder example. If maintainers prefer a smaller V1 API surface, theMatchingvariant + its factory can be removed in ~30 seconds without touching any other file.Ids+Allalready cover 95% of the expected usages.
Static escape hatches (advanced)
Use SkillInjector.unloadMap(...) / SkillInjector.unloadCommand(...) from
your own hooks when you need to unload inside a channel that isn't the
default callModel wrap. Both share the same filter core — no allocation
when nothing would change.
// From a NodeHook.WrapCall that returns a Map channel:
return action.apply(state, config)
.thenApply(result -> SkillInjector.unloadMap(state.activeSkills(),
result,
UnloadTarget.all()));
// From an EdgeHook.WrapCall that returns a Command channel:
return action.apply(state, config)
.thenApply(cmd -> SkillInjector.unloadCommand(state.activeSkills(),
cmd,
UnloadTarget.ids("order-reply")));
UnloadTarget variants
UnloadTarget is a sealed interface over exactly four records. Construct
only via the static factories — never new the record types directly:
| Factory | Meaning |
|---|---|
UnloadTarget.none() |
keep everything (no-op) |
UnloadTarget.all() |
drop every active id (ephemeral) |
UnloadTarget.ids("a","b") |
drop the listed ids (null-safe, unique'd) |
UnloadTarget.matching(predicate) |
drop every id accepted by predicate |
Because the project still targets Java 17, internal dispatch over the
sealed hierarchy uses an if-else instanceof chain plus a reflective
assertPermitsCoverExpected() self-check — Java 21 switch patterns are
not available at this release level. The self-check runs lazily on the
first real dispatch (interfaces cannot host static init blocks on Java)
and is itself verified by the unit test
UnloadTargetTest#sealedPermitsCoversAllFourSubtypes.
Scope and exception semantics
- The recommended shorthand
singleTurn()(and its synonymephemeral(), carried over from earlier review discussions) really means call-model-turn scoped: skills live fromexecuteToolsactivation through the nextcallModelnode, and are cleared the moment that node returns — not at the end ofinvoke(), not atreleaseThread(). If another tool round activates skills afterwards the pattern repeats. - If the wrapped
callModelnode completes exceptionally, the unload step is not executed (thenApplyshort-circuits). The previous activations therefore remain visible on state / checkpoint. This is the fail-open default for V1. Callers that prefer fail-close can use the staticunloadMap(...)/unloadCommand(...)helpers on their recovery path. The testSkillInjectorUnloadCheckpointITest.exceptionInsideCallModelLeavesActiveSkillsUntouchednails this behaviour and guards it against regressions.
Go to code