diff --git a/jamjet-agent/pom.xml b/jamjet-agent/pom.xml
new file mode 100644
index 0000000..b17f213
--- /dev/null
+++ b/jamjet-agent/pom.xml
@@ -0,0 +1,65 @@
+
+
+ 4.0.0
+
+
+ dev.jamjet
+ jamjet-runtime-java-parent
+ 0.3.1
+
+
+ jamjet-agent
+ jar
+
+ JamJet Agent (Java)
+
+ Idiomatic Java agent authoring on the governed durable JamJet engine: a thin
+ Agent builder that compiles to the shared agent-loop WorkflowIr plus a durable
+ Java tool-worker. Plain Java 21 (virtual threads), framework-free. This module
+ owns the bare-route engine HTTP client (workflows, executions, work-items).
+
+
+
+
+
+ dev.jamjet
+ jamjet-runtime-core
+ ${project.version}
+
+
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+
+
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ test
+
+
+ org.assertj
+ assertj-core
+ test
+
+
+
+ org.wiremock
+ wiremock-standalone
+ 3.9.2
+ test
+
+
+
+ ch.qos.logback
+ logback-classic
+ test
+
+
+
diff --git a/jamjet-agent/src/main/java/dev/jamjet/agent/Agent.java b/jamjet-agent/src/main/java/dev/jamjet/agent/Agent.java
new file mode 100644
index 0000000..c585344
--- /dev/null
+++ b/jamjet-agent/src/main/java/dev/jamjet/agent/Agent.java
@@ -0,0 +1,329 @@
+package dev.jamjet.agent;
+
+import dev.jamjet.agent.client.JamjetEngineClient;
+import dev.jamjet.agent.tools.ToolRegistry;
+import dev.jamjet.runtime.core.ir.PolicySetIr;
+import dev.jamjet.runtime.core.ir.WorkflowIr;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Idiomatic Java authoring for a governed, durable JamJet agent — the Java analog
+ * of the Python {@code jamjet.Agent}. A {@code model} + {@code @Tool} methods +
+ * {@code instructions} + governance knobs, compiled by {@link #compileToIr()} to
+ * the same agent-loop {@link WorkflowIr} the Python ADK emits, so a Java
+ * agent and a Python agent run on the identical Rust engine (the Java tool nodes
+ * being {@code java_fn} where Python's are {@code python_fn}).
+ *
+ *
Construct via the {@link #builder(String)} fluent builder:
+ *
{@code
+ * Agent agent = Agent.builder("research_agent")
+ * .model("anthropic/claude-sonnet-4-6")
+ * .instructions("You are a helpful research assistant.")
+ * .tools(new WebSearchTools(), new MathTools())
+ * .policy(new PolicySetIr(List.of("delete_db"), List.of(), List.of()))
+ * .approvalRequired(List.of("delete_*"))
+ * .budget(new Budget(100_000, 2.5))
+ * .build();
+ *
+ * WorkflowIr ir = agent.compileToIr(); // the durable agent-loop IR
+ * }
+ *
+ * The model side is free: a Java agent emits only {@code Model} nodes; the Rust
+ * engine routes model calls through the governed Python model-seam sidecar. No
+ * Java model code is needed.
+ */
+public final class Agent {
+
+ /** Default static-unroll bound for the agent loop (mirrors the Python default). */
+ public static final int DEFAULT_MAX_TURNS = 8;
+
+ private final String name;
+ private final String model;
+ private final String instructions;
+ private final String strategy;
+ private final ToolRegistry registry;
+ private final PolicySetIr policy;
+ private final boolean approvalAll;
+ private final List approvalGlobs;
+ private final Budget budget;
+ private final boolean pii;
+ private final int timeoutSeconds;
+
+ private Agent(Builder b) {
+ this.name = b.name;
+ this.model = b.model;
+ this.instructions = b.instructions;
+ this.strategy = b.strategy;
+ this.registry = b.registry;
+ this.policy = b.policy;
+ this.approvalAll = b.approvalAll;
+ this.approvalGlobs = List.copyOf(b.approvalGlobs);
+ this.budget = b.budget;
+ this.pii = b.pii;
+ this.timeoutSeconds = b.timeoutSeconds;
+ }
+
+ /** Start building an agent with the given logical name (used as the workflow id). */
+ public static Builder builder(String name) {
+ return new Builder(name);
+ }
+
+ /**
+ * Compile this agent to the durable agent-loop {@link WorkflowIr} with the
+ * default unroll bound ({@link #DEFAULT_MAX_TURNS}).
+ */
+ public WorkflowIr compileToIr() {
+ return compileToIr(DEFAULT_MAX_TURNS);
+ }
+
+ /**
+ * Compile this agent to the durable agent-loop {@link WorkflowIr}, statically
+ * unrolling up to {@code maxTurns} {@code model -> tools} turns plus a final
+ * tool-less answer turn. The start node is always the first Model node.
+ */
+ public WorkflowIr compileToIr(int maxTurns) {
+ return AgentIrCompiler.compile(this, maxTurns);
+ }
+
+ // -- durable run (B-4) ------------------------------------------------------
+
+ /**
+ * Run this agent durably on the JamJet engine with the default {@link RunOptions}
+ * (local runtime, {@link #DEFAULT_MAX_TURNS} turns), returning the final assistant
+ * text + tool-call trace as an {@link AgentResult}. The Java mirror of the Python
+ * {@code Agent.run_durable}.
+ *
+ * This compiles the agent to the agent-loop {@link WorkflowIr}, registers it
+ * ({@code POST /workflows}), starts an execution seeded with the system+user
+ * {@code messages} ({@code POST /executions}), polls {@code GET /executions/{id}}
+ * to a terminal state, and extracts the answer from the terminal
+ * {@code current_state.last_model_output} (falling back to the last assistant
+ * message). Every model call and tool dispatch runs through the durable engine, so
+ * the run is event-sourced, replayable, idempotent, and governed: budget and policy
+ * (the model allowlist plus approval gates) are enforced fail-closed by the engine,
+ * and PII redaction is applied at the model-seam sidecar (the {@code data_policy} IR
+ * signals it; redaction is the sidecar's job, not an IR-level guarantee).
+ *
+ *
Required running services (mirrors the Python {@code run_durable})
+ * A durable run is NOT self-contained — three services must be running:
+ *
+ * the JamJet engine at {@link RunOptions#runtimeUrl()} (the
+ * {@code jamjet-server} that owns the {@code java_tool} queue);
+ * the model sidecar ({@code JAMJET_MODEL_SEAM_URL}) — the engine routes
+ * every governed model call through it (no Java model code);
+ * a {@link dev.jamjet.agent.worker.JavaToolWorker} draining the
+ * {@code java_tool} queue with THIS agent's tool registry, so the
+ * {@code @Tool} methods execute durably exactly-once. Run it in a separate
+ * thread/process; {@code runDurable} does not start one.
+ *
+ *
+ * @throws AgentRunException if the run reaches a non-{@code completed} terminal
+ * state ({@code failed} / {@code cancelled} /
+ * {@code limit_exceeded})
+ * @throws AgentRunTimeoutException if no terminal state is reached before the deadline
+ */
+ public AgentResult runDurable(String prompt) {
+ return runDurable(prompt, RunOptions.defaults());
+ }
+
+ /**
+ * Run this agent durably with the given {@link RunOptions}, building (and closing) a
+ * {@link JamjetEngineClient} for {@link RunOptions#runtimeUrl()}. See
+ * {@link #runDurable(String)} for the running-services contract.
+ */
+ public AgentResult runDurable(String prompt, RunOptions options) {
+ try (JamjetEngineClient client =
+ new JamjetEngineClient(options.runtimeUrl(), options.bearerToken(), options.tenantId())) {
+ return runDurable(prompt, client, options);
+ }
+ }
+
+ /**
+ * Run this agent durably over a caller-provided {@link JamjetEngineClient}. The
+ * caller owns the client's lifecycle (this overload does NOT close it), so a run
+ * and a {@link dev.jamjet.agent.worker.JavaToolWorker} can share one client against
+ * the same engine. See {@link #runDurable(String)} for the running-services contract.
+ */
+ public AgentResult runDurable(String prompt, JamjetEngineClient client, RunOptions options) {
+ return DurableRunner.run(this, prompt, client, options);
+ }
+
+ // -- accessors (read by AgentIrCompiler) ------------------------------------
+
+ public String name() {
+ return name;
+ }
+
+ public String model() {
+ return model;
+ }
+
+ public String instructions() {
+ return instructions;
+ }
+
+ public String strategy() {
+ return strategy;
+ }
+
+ public ToolRegistry registry() {
+ return registry;
+ }
+
+ /** The inline policy, or {@code null} when none is set. */
+ public PolicySetIr policy() {
+ return policy;
+ }
+
+ /** {@code true} when every tool call requires human approval ({@code approvalRequired(true)}). */
+ public boolean approvalAll() {
+ return approvalAll;
+ }
+
+ /** The per-tool approval globs (possibly empty). */
+ public List approvalGlobs() {
+ return approvalGlobs;
+ }
+
+ /** The per-run budget, or {@code null} when uncapped. */
+ public Budget budget() {
+ return budget;
+ }
+
+ /** Whether PII governance is on (emits the default {@code data_policy} IR). */
+ public boolean pii() {
+ return pii;
+ }
+
+ /** The workflow timeout in seconds (compiled into {@code timeouts.workflow_timeout}). */
+ public int timeoutSeconds() {
+ return timeoutSeconds;
+ }
+
+ @Override
+ public String toString() {
+ return "Agent(name=" + name + ", model=" + model
+ + ", tools=" + registry.tools().stream().map(t -> t.name()).toList()
+ + ", strategy=" + strategy + ")";
+ }
+
+ /** Fluent builder for {@link Agent}. */
+ public static final class Builder {
+ private final String name;
+ private String model;
+ private String instructions = "";
+ private String strategy = "react";
+ private ToolRegistry registry = new ToolRegistry();
+ private PolicySetIr policy;
+ private boolean approvalAll;
+ private List approvalGlobs = List.of();
+ private Budget budget;
+ private boolean pii = true;
+ private int timeoutSeconds = 300;
+
+ private Builder(String name) {
+ this.name = Objects.requireNonNull(name, "agent name must not be null");
+ }
+
+ /** The model reference, e.g. {@code "anthropic/claude-sonnet-4-6"}. */
+ public Builder model(String model) {
+ this.model = model;
+ return this;
+ }
+
+ /** The system instructions for the agent. */
+ public Builder instructions(String instructions) {
+ this.instructions = instructions == null ? "" : instructions;
+ return this;
+ }
+
+ /**
+ * The reasoning strategy. v1 always compiles the durable IR as the
+ * react-style {@code model -> tools -> model} loop regardless of this
+ * value (parity with the Python durable path); the field is carried for
+ * forward compatibility.
+ */
+ public Builder strategy(String strategy) {
+ this.strategy = strategy == null ? "react" : strategy;
+ return this;
+ }
+
+ /** The tool-holder instances whose {@code @Tool} methods the agent may call. */
+ public Builder tools(Object... holders) {
+ this.registry = ToolRegistry.of(holders);
+ return this;
+ }
+
+ /** The tool-holder instances whose {@code @Tool} methods the agent may call. */
+ public Builder tools(List> holders) {
+ this.registry = ToolRegistry.of(holders);
+ return this;
+ }
+
+ /** Use a pre-built tool registry. */
+ public Builder registry(ToolRegistry registry) {
+ this.registry = registry == null ? new ToolRegistry() : registry;
+ return this;
+ }
+
+ /** An inline policy ({@code blocked_tools} / {@code require_approval_for} / {@code model_allowlist}). */
+ public Builder policy(PolicySetIr policy) {
+ this.policy = policy;
+ return this;
+ }
+
+ /**
+ * Require human approval for tools. {@code true} requires approval for
+ * every tool call ({@code require_approval_for = ["*"]}); {@code false}
+ * clears the gate.
+ */
+ public Builder approvalRequired(boolean all) {
+ this.approvalAll = all;
+ this.approvalGlobs = List.of();
+ return this;
+ }
+
+ /**
+ * Require human approval for the given tool-name globs (e.g.
+ * {@code ["delete_*", "send_*"]}). Unioned into {@code require_approval_for}.
+ */
+ public Builder approvalRequired(List globs) {
+ this.approvalAll = false;
+ this.approvalGlobs = globs == null ? List.of() : new ArrayList<>(globs);
+ return this;
+ }
+
+ /** The per-run budget (token and/or cost cap). */
+ public Builder budget(Budget budget) {
+ this.budget = budget;
+ return this;
+ }
+
+ /** Toggle PII governance (on by default). */
+ public Builder pii(boolean pii) {
+ this.pii = pii;
+ return this;
+ }
+
+ /** The workflow timeout in seconds (default 300; must be positive). */
+ public Builder timeoutSeconds(int timeoutSeconds) {
+ if (timeoutSeconds <= 0) {
+ throw new IllegalArgumentException("timeoutSeconds must be positive (got " + timeoutSeconds + ")");
+ }
+ this.timeoutSeconds = timeoutSeconds;
+ return this;
+ }
+
+ /** Build the immutable {@link Agent}. */
+ public Agent build() {
+ Objects.requireNonNull(model, "agent model must be set");
+ if (model.isBlank()) {
+ throw new IllegalArgumentException("agent model must not be blank");
+ }
+ return new Agent(this);
+ }
+ }
+}
diff --git a/jamjet-agent/src/main/java/dev/jamjet/agent/AgentIrCompiler.java b/jamjet-agent/src/main/java/dev/jamjet/agent/AgentIrCompiler.java
new file mode 100644
index 0000000..25f248a
--- /dev/null
+++ b/jamjet-agent/src/main/java/dev/jamjet/agent/AgentIrCompiler.java
@@ -0,0 +1,357 @@
+package dev.jamjet.agent;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import dev.jamjet.agent.tools.RegisteredTool;
+import dev.jamjet.agent.tools.ToolDispatcher;
+import dev.jamjet.runtime.core.JamjetJson;
+import dev.jamjet.runtime.core.TimeoutConfig;
+import dev.jamjet.runtime.core.ir.ConditionalBranch;
+import dev.jamjet.runtime.core.ir.DataPolicyIr;
+import dev.jamjet.runtime.core.ir.EdgeDef;
+import dev.jamjet.runtime.core.ir.NodeDef;
+import dev.jamjet.runtime.core.ir.NodeKind;
+import dev.jamjet.runtime.core.ir.PolicySetIr;
+import dev.jamjet.runtime.core.ir.TokenBudgetIr;
+import dev.jamjet.runtime.core.ir.WorkflowIr;
+
+import java.security.MessageDigest;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.HexFormat;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+/**
+ * Compiles a {@link Agent} into a durable agent-loop {@link WorkflowIr}, mirroring
+ * the Python {@code jamjet.compiler.agent_ir.compile_agent_to_ir} so a Java agent
+ * and a Python agent produce the same graph for the Rust engine.
+ *
+ * Because the model picks tools dynamically, the loop is statically
+ * unrolled into {@code maxTurns} turns, each turn being three nodes:
+ *
+ * {@code __model_{t}__} — a {@link NodeKind.Model} carrying the agent's
+ * OpenAI tool schemas;
+ * {@code __tool_gate_{t}__} — a {@link NodeKind.Condition} on
+ * {@code state.last_model_finish_reason == "tool_calls"} (true → the
+ * turn's tool-dispatch node, false → the terminal {@code end});
+ * {@code __tools_{t}__} — a {@link NodeKind.JavaFn} pointing at the fixed
+ * Java tool dispatcher (the analog of Python's
+ * {@code jamjet.agents.tool_runtime:dispatch_tool_calls}); it routes to the
+ * next turn's model node.
+ *
+ * A final tool-less {@code __model_{maxTurns}__} produces the answer and routes to
+ * {@code end}, so the terminal is always reached via a model turn.
+ *
+ * M2 : {@code start_node} is always {@code __model_0__} (the first Model
+ * node), never a tool node.
+ */
+final class AgentIrCompiler {
+
+ /**
+ * The fixed Java tool-dispatch coordinate every {@code __tools_{t}__} node
+ * carries — the Java analog of Python's
+ * {@code jamjet.agents.tool_runtime:dispatch_tool_calls}. The Phase B-3 durable
+ * worker resolves this dispatcher, which reads {@code last_model_tool_calls}
+ * from state and fans out to the requested {@code @Tool} methods via the
+ * {@link dev.jamjet.agent.tools.ToolRegistry}. (A single dispatcher per turn,
+ * not one node per dynamic call, mirrors the Python loop exactly.)
+ *
+ *
Single source of truth. These reference {@link ToolDispatcher}'s own
+ * constants — the SAME constants the {@link dev.jamjet.agent.worker.JavaToolWorker}
+ * RCE gate checks a claimed {@code java_fn} payload against. So the coordinate the
+ * builder emits and the coordinate the worker accepts are one
+ * literal and can never silently diverge (a B-3-review DRY fix).
+ */
+ static final String DISPATCH_CLASS = ToolDispatcher.DISPATCH_CLASS;
+ static final String DISPATCH_METHOD = ToolDispatcher.DISPATCH_METHOD;
+
+ /** The condition the tool gate branches on (matches the Model executor's recorded finish reason). */
+ static final String TOOL_CALLS_EXPR = "state.last_model_finish_reason == \"tool_calls\"";
+
+ /** Graph terminal sentinel (edges target it; it is not a node). */
+ static final String END = "end";
+
+ private static final String MODEL_RETRY_POLICY = "llm_default";
+ private static final String TOOLS_RETRY_POLICY = "no_retry";
+ private static final String VERSION_BASE = "0.1.0";
+ private static final int HEARTBEAT_INTERVAL_SECS = 30;
+
+ /** The five built-in PII detectors, matching the Rust PiiRedactor + the Python default. */
+ private static final List DEFAULT_PII_DETECTORS =
+ List.of("email", "ssn", "credit_card", "phone", "ip_address");
+
+ /**
+ * Canonical (sorted-key) snake_case mapper used only to derive the immutable
+ * content-version hash. Sorting map entries makes the hash independent of
+ * {@code Map.of}/{@code LinkedHashMap} iteration order, so the same agent
+ * always yields the same version across runs and JVMs.
+ */
+ private static final ObjectMapper CANON =
+ JamjetJson.copy().enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS);
+
+ private AgentIrCompiler() {}
+
+ static WorkflowIr compile(Agent agent, int maxTurns) {
+ if (maxTurns < 1) {
+ throw new IllegalArgumentException("maxTurns must be >= 1");
+ }
+
+ String modelRef = litellmModel(agent.model());
+ List> toolSchemas = agent.registry().openAiToolSchemas();
+ String systemPrompt = blankToNull(agent.instructions());
+
+ Map nodes = new LinkedHashMap<>();
+ List edges = new ArrayList<>();
+
+ for (int t = 0; t < maxTurns; t++) {
+ String modelId = "__model_" + t + "__";
+ String gateId = "__tool_gate_" + t + "__";
+ String toolsId = "__tools_" + t + "__";
+
+ // Model node: carries the tool schemas, reads messages from state.
+ nodes.put(modelId, node(
+ modelId,
+ new NodeKind.Model(modelRef, "", "", systemPrompt, toolSchemas),
+ MODEL_RETRY_POLICY,
+ "agent turn " + t + ": model call",
+ Map.of("jamjet.agent.loop", "model", "jamjet.agent.turn", String.valueOf(t))));
+ edges.add(new EdgeDef(modelId, gateId, null));
+
+ // Condition gate: tool_calls -> dispatch, else -> terminal answer.
+ nodes.put(gateId, node(
+ gateId,
+ new NodeKind.Condition(List.of(
+ new ConditionalBranch(TOOL_CALLS_EXPR, toolsId),
+ new ConditionalBranch(null, END))),
+ null,
+ "agent turn " + t + ": tool-call gate",
+ Map.of("jamjet.agent.loop", "gate", "jamjet.agent.turn", String.valueOf(t))));
+ edges.add(new EdgeDef(gateId, toolsId, TOOL_CALLS_EXPR));
+ edges.add(new EdgeDef(gateId, END, null));
+
+ // Tool-dispatch node: a JavaFn pointing at the fixed dispatcher.
+ // no_retry: the dispatch runs user @Tool methods (possible
+ // non-idempotent writes), so an already-succeeded dispatch must not
+ // re-run on retry (mirrors the Python tool node).
+ nodes.put(toolsId, node(
+ toolsId,
+ new NodeKind.JavaFn(DISPATCH_CLASS, DISPATCH_METHOD, ""),
+ TOOLS_RETRY_POLICY,
+ "agent turn " + t + ": dispatch tool calls",
+ Map.of("jamjet.agent.loop", "tools", "jamjet.agent.turn", String.valueOf(t))));
+ // Always route forward to the NEXT model node (the last turn's dispatch
+ // flows into the final model node below); a tool node never targets end.
+ edges.add(new EdgeDef(toolsId, "__model_" + (t + 1) + "__", null));
+ }
+
+ // Final model node: no tool schemas, so the model must return a text
+ // answer; routes straight to end. Guarantees the terminal is reached via
+ // a model turn that saw the tool results.
+ String finalId = "__model_" + maxTurns + "__";
+ nodes.put(finalId, node(
+ finalId,
+ new NodeKind.Model(modelRef, "", "", systemPrompt, List.of()),
+ MODEL_RETRY_POLICY,
+ "agent turn " + maxTurns + ": final answer (no tools)",
+ Map.of(
+ "jamjet.agent.loop", "model",
+ "jamjet.agent.turn", String.valueOf(maxTurns),
+ "jamjet.agent.final", "true")));
+ edges.add(new EdgeDef(finalId, END, null));
+
+ Gov gov = compileGovernance(agent);
+
+ TimeoutConfig timeouts = new TimeoutConfig(
+ null,
+ Duration.ofSeconds(agent.timeoutSeconds()),
+ Duration.ofSeconds(HEARTBEAT_INTERVAL_SECS),
+ null);
+
+ Map labels = new LinkedHashMap<>();
+ labels.put("jamjet.agent.id", agent.name());
+ labels.put("jamjet.agent.loop", "true");
+ labels.put("jamjet.agent.max_turns", String.valueOf(maxTurns));
+
+ String description = blankToNull(agent.instructions()) != null
+ ? agent.instructions()
+ : "Durable agent: " + agent.name();
+
+ // Build once with a placeholder version, content-hash it, rebuild with the
+ // real version so a changed agent (tools / instructions / governance /
+ // maxTurns) never reuses an immutably-cached graph (mirrors agent_ir.py).
+ WorkflowIr draft = assemble("", agent.name(), description, nodes, edges, timeouts, labels, gov);
+ return assemble(contentVersion(draft), agent.name(), description, nodes, edges, timeouts, labels, gov);
+ }
+
+ // -- governance (mirrors agent_ir._compile_governance_ir) -------------------
+
+ private record Gov(PolicySetIr policy, TokenBudgetIr tokenBudget, Double costBudgetUsd, DataPolicyIr dataPolicy) {}
+
+ private static Gov compileGovernance(Agent agent) {
+ Double costBudgetUsd = null;
+ TokenBudgetIr tokenBudget = null;
+ Budget budget = agent.budget();
+ if (budget != null) {
+ if (budget.costUsd() != null) {
+ costBudgetUsd = budget.costUsd();
+ }
+ if (budget.tokens() != null) {
+ // total_tokens only: the single most useful combined input+output cap.
+ tokenBudget = new TokenBudgetIr(null, null, budget.tokens());
+ }
+ }
+ PolicySetIr policy = compilePolicy(agent);
+ DataPolicyIr dataPolicy = agent.pii()
+ ? new DataPolicyIr(List.of(), DEFAULT_PII_DETECTORS, "mask", false, true, null)
+ : null;
+ return new Gov(policy, tokenBudget, costBudgetUsd, dataPolicy);
+ }
+
+ /** Mirrors {@code agent_ir._compile_agent_policy_ir}: returns {@code null} when no rules are needed. */
+ private static PolicySetIr compilePolicy(Agent agent) {
+ boolean hasApproval = agent.approvalAll() || !agent.approvalGlobs().isEmpty();
+ boolean hasPolicy = agent.policy() != null;
+ if (!hasPolicy && !hasApproval) {
+ return null;
+ }
+
+ List blocked = new ArrayList<>();
+ List require = new ArrayList<>();
+ List allowlist = new ArrayList<>();
+ if (agent.policy() != null) {
+ blocked.addAll(agent.policy().blockedTools());
+ require.addAll(agent.policy().requireApprovalFor());
+ allowlist.addAll(agent.policy().modelAllowlist());
+ }
+
+ if (agent.approvalAll()) {
+ require = new ArrayList<>(List.of("*")); // every tool requires approval
+ } else if (!agent.approvalGlobs().isEmpty()) {
+ // Union the caller globs with any policy require_approval_for,
+ // preserving declaration order and deduplicating.
+ LinkedHashSet merged = new LinkedHashSet<>(require);
+ merged.addAll(agent.approvalGlobs());
+ require = new ArrayList<>(merged);
+ }
+
+ return new PolicySetIr(blocked, require, allowlist);
+ }
+
+ // -- helpers ----------------------------------------------------------------
+
+ private static NodeDef node(String id, NodeKind kind, String retryPolicy, String description,
+ Map labels) {
+ return new NodeDef(id, kind, retryPolicy, null, description, labels, null, null);
+ }
+
+ private static WorkflowIr assemble(String version, String name, String description,
+ Map nodes, List edges,
+ TimeoutConfig timeouts, Map labels, Gov gov) {
+ return new WorkflowIr(
+ name, // workflowId
+ version,
+ name, // name
+ description,
+ "", // stateSchema
+ "__model_0__", // startNode — always the first Model node (M2)
+ nodes,
+ edges,
+ Map.of(), // retryPolicies
+ timeouts,
+ Map.of(), // models
+ Map.of(), // tools
+ Map.of(), // mcpServers
+ Map.of(), // remoteAgents
+ labels,
+ gov.policy(),
+ gov.tokenBudget(),
+ gov.costBudgetUsd(),
+ null, // onBudgetExceeded
+ gov.dataPolicy());
+ }
+
+ /**
+ * The litellm model string, mirroring Python {@code parse_model_ref}: a
+ * {@code provider/rest} string lowercases the provider; a bare string passes
+ * through unchanged.
+ */
+ static String litellmModel(String model) {
+ String raw = model.strip();
+ int slash = raw.indexOf('/');
+ if (slash >= 0) {
+ String provider = raw.substring(0, slash).strip().toLowerCase(Locale.ROOT);
+ String rest = raw.substring(slash + 1);
+ return provider + "/" + rest;
+ }
+ return raw;
+ }
+
+ // -- durable-run initial state (mirrors agent_ir.build_initial_state) --------
+
+ /**
+ * The execution {@code initial_input} for a compiled agent-loop IR, mirroring the
+ * Python {@code build_initial_state}: it seeds the running {@code messages}
+ * (system + user prompt) plus the {@code {name: "class#method"}} tool-resolver map
+ * into workflow state, so the first Model node reads the messages and every
+ * {@code java_fn} tool node finds them. The Rust engine copies this verbatim into
+ * {@code current_state} at {@code start_execution} time.
+ *
+ * The prompt is per-run and private, so it is seeded HERE (not embedded in the
+ * workflow definition) — the compiled IR's {@code description} never carries it.
+ */
+ static Map buildInitialState(Agent agent, String prompt) {
+ String system = blankToNull(agent.instructions()) != null
+ ? agent.instructions()
+ : "You are a helpful assistant.";
+
+ List> messages = List.of(
+ Map.of("role", "system", "content", system),
+ Map.of("role", "user", "content", prompt == null ? "" : prompt));
+
+ Map out = new LinkedHashMap<>();
+ out.put("messages", messages);
+ out.put("tools", toolsMap(agent));
+ return out;
+ }
+
+ /**
+ * {@code {tool_name: "class#method"}} — the Java analog of Python's {@code _tools_map}
+ * ({@code {name: "module:qualname"}}). Carried in the seeded state for shape-parity
+ * with the Python durable run; the Java {@code JavaToolWorker} resolves tools by name
+ * through its own {@link dev.jamjet.agent.tools.ToolRegistry}, so it does not consume
+ * this map (the dispatch coordinate is the same {@code class#method}, see
+ * {@link RegisteredTool#key()}).
+ */
+ static Map toolsMap(Agent agent) {
+ Map map = new LinkedHashMap<>();
+ for (RegisteredTool t : agent.registry().tools()) {
+ map.put(t.name(), t.key());
+ }
+ return map;
+ }
+
+ /**
+ * The immutable content-version: {@code "0.1.0+" + sha256(canonical_ir)[:12]}.
+ * The hash differs from Python's (the Java IR has {@code java_fn} tool nodes
+ * and no python_fn input block), but its purpose — a fresh cache key whenever
+ * the agent's compiled content changes — is identical.
+ */
+ static String contentVersion(WorkflowIr ir) {
+ try {
+ byte[] canonical = CANON.writeValueAsBytes(ir);
+ byte[] digest = MessageDigest.getInstance("SHA-256").digest(canonical);
+ String hex = HexFormat.of().formatHex(digest);
+ return VERSION_BASE + "+" + hex.substring(0, 12);
+ } catch (Exception e) {
+ throw new IllegalStateException("failed to content-version agent IR", e);
+ }
+ }
+
+ private static String blankToNull(String s) {
+ return (s == null || s.isBlank()) ? null : s;
+ }
+}
diff --git a/jamjet-agent/src/main/java/dev/jamjet/agent/AgentResult.java b/jamjet-agent/src/main/java/dev/jamjet/agent/AgentResult.java
new file mode 100644
index 0000000..49167d0
--- /dev/null
+++ b/jamjet-agent/src/main/java/dev/jamjet/agent/AgentResult.java
@@ -0,0 +1,63 @@
+package dev.jamjet.agent;
+
+import dev.jamjet.agent.client.ExecutionState;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * The result of a durable agent run ({@link Agent#runDurable(String)}) — the Java
+ * analog of the Python {@code jamjet.agents.agent.AgentResult}. It carries the SAME
+ * load-bearing fields the Python durable path extracts:
+ *
+ *
+ * {@link #output()} — the final assistant text. On the durable engine this is
+ * the terminal {@code current_state.last_model_output} (written inline by the
+ * Rust Model executor's {@code state_patch}); it falls back to the last
+ * assistant message's content in {@code current_state.messages}.
+ * {@link #toolCalls()} — the tool-call trace, reconstructed from the
+ * accumulated {@code messages} (each {@code role: tool} result paired with the
+ * arguments of the assistant {@code tool_call} that requested it, matched by
+ * {@code tool_call_id}), mirroring the Python {@code _tool_calls_from_messages}.
+ * {@link #terminalState()} — the raw terminal execution snapshot
+ * ({@code GET /executions/{id}}), so callers can inspect the full
+ * {@code current_state} / status without re-fetching.
+ *
+ *
+ * Governance artifacts (budget enforcement, policy gates, PII redaction, the audit
+ * log) ride in the durable engine itself — they are compiled into the {@link Agent}'s
+ * {@code WorkflowIr} (see {@link Agent#compileToIr()}) and enforced fail-closed by the
+ * Rust engine, not re-derived here. The terminal state reflects their outcome (e.g. a
+ * {@code limit_exceeded} status surfaces as an {@link AgentRunException}).
+ */
+public record AgentResult(
+ String output,
+ List toolCalls,
+ ExecutionState terminalState
+) {
+ public AgentResult {
+ output = output == null ? "" : output;
+ toolCalls = toolCalls == null ? List.of() : List.copyOf(toolCalls);
+ }
+
+ /**
+ * One reconstructed tool call from the durable run's message history — the Java
+ * analog of the Python {@code ToolCallRecord} dict ({@code tool} / {@code input} /
+ * {@code output}). Per-call durations are not carried in the message history, so
+ * (unlike the in-process path) no duration is reconstructed here.
+ *
+ * @param tool the tool name the model called
+ * @param input the parsed arguments the model passed (possibly empty)
+ * @param output the tool's returned content (the {@code role: tool} message body)
+ */
+ public record ToolCall(String tool, Map input, String output) {
+ public ToolCall {
+ input = input == null ? Map.of() : Map.copyOf(input);
+ }
+ }
+
+ @Override
+ public String toString() {
+ return "AgentResult(output=" + output + ", toolCalls=" + toolCalls.size() + ")";
+ }
+}
diff --git a/jamjet-agent/src/main/java/dev/jamjet/agent/AgentRunException.java b/jamjet-agent/src/main/java/dev/jamjet/agent/AgentRunException.java
new file mode 100644
index 0000000..5776ecc
--- /dev/null
+++ b/jamjet-agent/src/main/java/dev/jamjet/agent/AgentRunException.java
@@ -0,0 +1,29 @@
+package dev.jamjet.agent;
+
+/**
+ * Raised when a durable agent run ({@link Agent#runDurable(String)}) reaches a
+ * terminal state that is not {@code completed} — i.e. {@code failed},
+ * {@code cancelled}, or {@code limit_exceeded}. This mirrors the Python
+ * {@code run_durable}, which raises {@code RuntimeError} for a non-{@code completed}
+ * terminal state, so a budget breach or a policy denial surfaces loudly rather than
+ * returning a hollow result.
+ *
+ * {@link #status()} carries the terminal {@link dev.jamjet.runtime.core.workflow}
+ * status string the engine reported ({@code failed} / {@code cancelled} /
+ * {@code limit_exceeded}), so callers can branch on the failure mode (e.g. treat a
+ * {@code limit_exceeded} budget breach differently from a hard {@code failed}).
+ */
+public class AgentRunException extends RuntimeException {
+
+ private final String status;
+
+ public AgentRunException(String status, String message) {
+ super(message);
+ this.status = status;
+ }
+
+ /** The terminal status the engine reported ({@code failed} / {@code cancelled} / {@code limit_exceeded}). */
+ public String status() {
+ return status;
+ }
+}
diff --git a/jamjet-agent/src/main/java/dev/jamjet/agent/AgentRunTimeoutException.java b/jamjet-agent/src/main/java/dev/jamjet/agent/AgentRunTimeoutException.java
new file mode 100644
index 0000000..ddf9c7e
--- /dev/null
+++ b/jamjet-agent/src/main/java/dev/jamjet/agent/AgentRunTimeoutException.java
@@ -0,0 +1,18 @@
+package dev.jamjet.agent;
+
+/**
+ * Raised when a durable agent run ({@link Agent#runDurable(String)}) does not reach a
+ * terminal state within the poll deadline ({@link RunOptions#timeout()}, or the
+ * agent's {@link Agent#timeoutSeconds()} when unset). Mirrors the Python
+ * {@code run_durable}, which raises {@code TimeoutError} when no terminal state is
+ * reached within {@code self.limits.timeout_seconds}.
+ *
+ *
{@link #status()} carries the last status observed before the deadline
+ * (typically {@code running}), to aid diagnosis of a stalled run.
+ */
+public final class AgentRunTimeoutException extends AgentRunException {
+
+ public AgentRunTimeoutException(String lastStatus, String message) {
+ super(lastStatus, message);
+ }
+}
diff --git a/jamjet-agent/src/main/java/dev/jamjet/agent/Budget.java b/jamjet-agent/src/main/java/dev/jamjet/agent/Budget.java
new file mode 100644
index 0000000..6d721e4
--- /dev/null
+++ b/jamjet-agent/src/main/java/dev/jamjet/agent/Budget.java
@@ -0,0 +1,45 @@
+package dev.jamjet.agent;
+
+/**
+ * A per-run spending cap for a governed agent — the Java analog of the Python
+ * {@code jamjet.agents.governance.Budget}. At least one field must be set (an entirely
+ * empty budget is rejected); set either or both.
+ *
+ *
At compile time {@code costUsd} maps to the IR's {@code cost_budget_usd} and
+ * {@code tokens} maps to {@code token_budget.total_tokens} (a combined input+output
+ * cap); the Rust engine enforces both fail-closed. A {@code null} field is uncapped.
+ *
+ * @param tokens total token cap (input + output combined), or {@code null} for uncapped
+ * @param costUsd wall-cost cap in US dollars, or {@code null} for uncapped
+ */
+public record Budget(Integer tokens, Double costUsd) {
+
+ public Budget {
+ if (tokens == null && costUsd == null) {
+ throw new IllegalArgumentException("Budget must cap tokens and/or cost (both were null)");
+ }
+ if (tokens != null && tokens <= 0) {
+ throw new IllegalArgumentException("Budget.tokens must be positive");
+ }
+ if (costUsd != null) {
+ // Guard non-finite first: NaN/Infinity slip past the `<= 0` check (every NaN
+ // comparison is false), so they would otherwise be accepted as a "cap".
+ if (!Double.isFinite(costUsd)) {
+ throw new IllegalArgumentException("Budget.costUsd must be a finite number");
+ }
+ if (costUsd <= 0) {
+ throw new IllegalArgumentException("Budget.costUsd must be positive");
+ }
+ }
+ }
+
+ /** A token-only budget. */
+ public static Budget ofTokens(int tokens) {
+ return new Budget(tokens, null);
+ }
+
+ /** A cost-only budget. */
+ public static Budget ofCostUsd(double costUsd) {
+ return new Budget(null, costUsd);
+ }
+}
diff --git a/jamjet-agent/src/main/java/dev/jamjet/agent/DurableRunner.java b/jamjet-agent/src/main/java/dev/jamjet/agent/DurableRunner.java
new file mode 100644
index 0000000..94d42bf
--- /dev/null
+++ b/jamjet-agent/src/main/java/dev/jamjet/agent/DurableRunner.java
@@ -0,0 +1,266 @@
+package dev.jamjet.agent;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import dev.jamjet.agent.client.ExecutionState;
+import dev.jamjet.agent.client.JamjetEngineClient;
+import dev.jamjet.agent.client.StartExecutionResult;
+import dev.jamjet.runtime.core.JamjetJson;
+import dev.jamjet.runtime.core.ir.WorkflowIr;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Drives a compiled {@link Agent} through one durable run on the JamJet engine —
+ * the mechanics behind {@link Agent#runDurable(String)}, mirroring the Python
+ * {@code Agent.run_durable}: compile → {@code create_workflow} →
+ * {@code start_execution} (seeded with the system+user {@code messages} and the
+ * tool-resolver map) → poll {@code get_execution} to a terminal state →
+ * extract the final assistant text + tool-call trace into an {@link AgentResult}.
+ *
+ *
Unlike the in-process path, every model call and tool dispatch runs through the
+ * durable event-sourced engine, so the run gets the event log, replay, idempotency,
+ * and fail-closed governance enforcement (budget / policy / PII) compiled into the IR.
+ *
+ *
Where the terminal result lives
+ * The Rust Model executor writes the final model turn's content inline into
+ * {@code current_state.last_model_output} (via its {@code state_patch}; never spilled
+ * to the artifact store), so the canonical answer is {@code last_model_output}. The
+ * fallback is the last assistant message's content in the accumulated
+ * {@code current_state.messages} (which the Java tool dispatcher replaces each turn).
+ * The terminal is always reached via a model turn (the final tool-less
+ * {@code __model_{maxTurns}__} node), so the answer is a real model turn that saw the
+ * tool results — exactly as in the Python contract.
+ */
+final class DurableRunner {
+
+ /**
+ * Terminal {@code WorkflowStatus} strings, snake_case as the Rust engine
+ * serializes them ({@code runtime/core/src/workflow.rs}: {@code rename_all =
+ * "snake_case"}), matching the Python {@code _TERMINAL_STATUSES}.
+ */
+ private static final Set TERMINAL_STATUSES =
+ Set.of("completed", "failed", "cancelled", "limit_exceeded");
+
+ /** The single status the run treats as success; every other terminal is an error. */
+ private static final String COMPLETED = "completed";
+
+ private static final ObjectMapper JSON = JamjetJson.shared();
+
+ private DurableRunner() {}
+
+ /**
+ * Run {@code agent} on {@code prompt} over {@code client}, returning the extracted
+ * {@link AgentResult}. The caller owns {@code client}'s lifecycle (this method does
+ * NOT close it), so a worker and a run can share one client.
+ *
+ * @throws AgentRunException if the run reaches a non-{@code completed} terminal state
+ * @throws AgentRunTimeoutException if no terminal state is reached before the deadline
+ */
+ static AgentResult run(Agent agent, String prompt, JamjetEngineClient client, RunOptions options) {
+ WorkflowIr ir = agent.compileToIr(options.maxTurns());
+ Map initialInput = AgentIrCompiler.buildInitialState(agent, prompt);
+
+ // Register the compiled IR, then start an execution seeded with the running
+ // messages + tool-resolver map (build_initial_state). The version is the
+ // content hash so a changed agent never reuses an immutably-cached graph.
+ client.createWorkflow(ir);
+ StartExecutionResult started = client.startExecution(ir.workflowId(), initialInput, ir.version());
+ String execId = started == null ? null : started.executionId();
+ if (execId == null || execId.isBlank()) {
+ throw new AgentRunException("unknown",
+ "start_execution returned no execution_id (started=" + started + ")");
+ }
+
+ Duration timeout = options.timeout() != null
+ ? options.timeout()
+ : Duration.ofSeconds(agent.timeoutSeconds());
+ ExecutionState terminal = pollToTerminal(client, execId, options.pollInterval(), timeout);
+ return extractResult(terminal);
+ }
+
+ /** Poll {@code get_execution} until a terminal status, mirroring Python {@code _poll_to_terminal}. */
+ private static ExecutionState pollToTerminal(JamjetEngineClient client, String execId,
+ Duration pollInterval, Duration timeout) {
+ // Monotonic deadline: time spent inside slow get_execution() calls counts
+ // toward the timeout, so a stalled run can't blow past it.
+ long deadlineNanos = System.nanoTime() + timeout.toNanos();
+ String lastStatus = "unknown";
+ while (true) {
+ ExecutionState execution = client.getExecution(execId);
+ lastStatus = execution == null ? "unknown" : execution.status();
+ if (isTerminal(lastStatus)) {
+ return execution;
+ }
+ if (System.nanoTime() >= deadlineNanos) {
+ throw new AgentRunTimeoutException(lastStatus,
+ "durable agent run " + execId + " did not reach a terminal state within "
+ + timeout.toSeconds() + "s (last status: " + lastStatus + ")");
+ }
+ if (sleep(pollInterval.toMillis())) {
+ throw new AgentRunTimeoutException(lastStatus,
+ "durable agent run " + execId + " interrupted while polling (last status: " + lastStatus + ")");
+ }
+ }
+ }
+
+ /**
+ * Build an {@link AgentResult} from a terminal execution snapshot, mirroring the
+ * Python {@code _extract_result}: the answer is {@code current_state.last_model_output}
+ * (the inline final model turn), falling back to the last assistant message; the
+ * tool-call trace is reconstructed from {@code current_state.messages}.
+ *
+ * @throws AgentRunException if the terminal status is not {@code completed}
+ */
+ static AgentResult extractResult(ExecutionState execution) {
+ String status = normalize(execution == null ? null : execution.status());
+ if (!COMPLETED.equals(status)) {
+ String detail = failureDetail(execution, status);
+ throw new AgentRunException(status,
+ "durable agent run ended in non-completed state: " + detail);
+ }
+
+ Map state = execution.currentState() == null ? Map.of() : execution.currentState();
+ List messages = asList(state.get("messages"));
+
+ Object output = state.get("last_model_output");
+ if (output == null) {
+ output = lastAssistantContent(messages);
+ }
+ String outputStr = output == null ? "" : String.valueOf(output);
+
+ return new AgentResult(outputStr, toolCallsFromMessages(messages), execution);
+ }
+
+ // -- result reconstruction (mirrors agent.py helpers) -----------------------
+
+ /** The content of the last assistant message that carried text. */
+ private static String lastAssistantContent(List messages) {
+ for (int i = messages.size() - 1; i >= 0; i--) {
+ Map msg = asMap(messages.get(i));
+ if ("assistant".equals(msg.get("role"))) {
+ Object content = msg.get("content");
+ if (content != null && !String.valueOf(content).isEmpty()) {
+ return String.valueOf(content);
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Reconstruct the tool-call trace from the message history, mirroring the Python
+ * {@code _tool_calls_from_messages}: pair each {@code role: tool} result with the
+ * arguments of the assistant {@code tool_call} that requested it (matched by
+ * {@code tool_call_id}).
+ */
+ private static List toolCallsFromMessages(List messages) {
+ Map> argsById = new LinkedHashMap<>();
+ for (Object raw : messages) {
+ Map msg = asMap(raw);
+ if (!"assistant".equals(msg.get("role"))) {
+ continue;
+ }
+ for (Object callRaw : asList(msg.get("tool_calls"))) {
+ Map call = asMap(callRaw);
+ Map function = asMap(call.get("function"));
+ Map parsed = parseArguments(function.get("arguments"));
+ Object id = call.get("id");
+ if (id != null) {
+ argsById.put(id, parsed);
+ }
+ }
+ }
+
+ List calls = new ArrayList<>();
+ for (Object raw : messages) {
+ Map msg = asMap(raw);
+ if (!"tool".equals(msg.get("role"))) {
+ continue;
+ }
+ Object name = msg.get("name");
+ Map input = argsById.getOrDefault(msg.get("tool_call_id"), Map.of());
+ Object content = msg.get("content");
+ calls.add(new AgentResult.ToolCall(
+ name == null ? null : String.valueOf(name),
+ input,
+ content == null ? null : String.valueOf(content)));
+ }
+ return calls;
+ }
+
+ /** Tool-call arguments arrive as a JSON object or a JSON string — normalise to a map. */
+ @SuppressWarnings("unchecked")
+ private static Map parseArguments(Object arguments) {
+ if (arguments instanceof Map, ?> m) {
+ return (Map) m;
+ }
+ if (arguments instanceof String s && !s.isBlank()) {
+ try {
+ return JSON.readValue(s, Map.class);
+ } catch (Exception e) {
+ return Map.of();
+ }
+ }
+ return Map.of();
+ }
+
+ // -- status / detail --------------------------------------------------------
+
+ private static boolean isTerminal(String status) {
+ return TERMINAL_STATUSES.contains(normalize(status));
+ }
+
+ /** Lower-case the status so a snake_case engine ({@code completed}) and any capitalized stub agree. */
+ private static String normalize(String status) {
+ return status == null ? "" : status.toLowerCase(Locale.ROOT);
+ }
+
+ /** Best-effort failure detail for a non-completed terminal: a {@code detail}/{@code error} in state, else the status. */
+ private static String failureDetail(ExecutionState execution, String status) {
+ if (execution != null && execution.currentState() != null) {
+ Object detail = execution.currentState().get("detail");
+ if (detail == null) {
+ detail = execution.currentState().get("error");
+ }
+ if (detail != null && !String.valueOf(detail).isBlank()) {
+ return status + " (" + detail + ")";
+ }
+ }
+ return status;
+ }
+
+ // -- coercion helpers -------------------------------------------------------
+
+ @SuppressWarnings("unchecked")
+ private static List asList(Object value) {
+ if (value instanceof List> list) {
+ return (List) list;
+ }
+ return List.of();
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map asMap(Object value) {
+ if (value instanceof Map, ?> m) {
+ return (Map) m;
+ }
+ return Map.of();
+ }
+
+ /** Sleep, returning true if interrupted (so the poll loop can exit cleanly). */
+ private static boolean sleep(long millis) {
+ try {
+ Thread.sleep(millis);
+ return false;
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return true;
+ }
+ }
+}
diff --git a/jamjet-agent/src/main/java/dev/jamjet/agent/RunOptions.java b/jamjet-agent/src/main/java/dev/jamjet/agent/RunOptions.java
new file mode 100644
index 0000000..69b4546
--- /dev/null
+++ b/jamjet-agent/src/main/java/dev/jamjet/agent/RunOptions.java
@@ -0,0 +1,86 @@
+package dev.jamjet.agent;
+
+import java.time.Duration;
+
+/**
+ * Tuning knobs for a durable agent run ({@link Agent#runDurable(String, RunOptions)}),
+ * mirroring the keyword arguments of the Python {@code Agent.run_durable}
+ * ({@code max_turns}, {@code runtime_url}). All fields have sensible defaults via
+ * {@link #defaults()}; override individually with the {@code with*} methods.
+ *
+ * @param maxTurns static-unroll bound for the compiled agent loop (default
+ * {@link Agent#DEFAULT_MAX_TURNS}); matches the Python default.
+ * @param runtimeUrl the JamJet engine base URL the run targets (default
+ * {@value #DEFAULT_RUNTIME_URL}); a bare run never targets a
+ * remote URL, mirroring the Python dev default.
+ * @param bearerToken optional {@code Authorization: Bearer} token, or {@code null}.
+ * @param tenantId optional {@code X-Tenant-Id}, or {@code null}.
+ * @param pollInterval how often to poll {@code GET /executions/{id}} while waiting for
+ * a terminal state (default {@value #DEFAULT_POLL_INTERVAL_MS} ms,
+ * matching the Python {@code _POLL_INTERVAL_SECONDS}).
+ * @param timeout overall poll deadline, or {@code null} to derive it from the
+ * agent's {@link Agent#timeoutSeconds()} (the Python behaviour:
+ * {@code self.limits.timeout_seconds}).
+ */
+public record RunOptions(
+ int maxTurns,
+ String runtimeUrl,
+ String bearerToken,
+ String tenantId,
+ Duration pollInterval,
+ Duration timeout
+) {
+ /** The Python dev default ({@code http://127.0.0.1:7700}); a bare run never goes remote. */
+ public static final String DEFAULT_RUNTIME_URL = "http://127.0.0.1:7700";
+
+ /** The default poll interval in milliseconds (mirrors Python's 0.5s). */
+ public static final long DEFAULT_POLL_INTERVAL_MS = 500;
+
+ public RunOptions {
+ if (maxTurns < 1) {
+ throw new IllegalArgumentException("maxTurns must be >= 1");
+ }
+ if (runtimeUrl == null || runtimeUrl.isBlank()) {
+ throw new IllegalArgumentException("runtimeUrl must not be blank");
+ }
+ if (pollInterval == null || pollInterval.isNegative() || pollInterval.isZero()) {
+ throw new IllegalArgumentException("pollInterval must be positive");
+ }
+ // timeout is nullable (null = derive from the agent's timeoutSeconds); when set it
+ // must be a positive duration, never zero/negative.
+ if (timeout != null && (timeout.isNegative() || timeout.isZero())) {
+ throw new IllegalArgumentException("timeout must be positive when set");
+ }
+ }
+
+ /** The default options: {@code maxTurns=8}, local runtime, no auth, 500ms poll, agent-derived timeout. */
+ public static RunOptions defaults() {
+ return new RunOptions(
+ Agent.DEFAULT_MAX_TURNS,
+ DEFAULT_RUNTIME_URL,
+ null,
+ null,
+ Duration.ofMillis(DEFAULT_POLL_INTERVAL_MS),
+ null);
+ }
+
+ public RunOptions withMaxTurns(int maxTurns) {
+ return new RunOptions(maxTurns, runtimeUrl, bearerToken, tenantId, pollInterval, timeout);
+ }
+
+ public RunOptions withRuntimeUrl(String runtimeUrl) {
+ return new RunOptions(maxTurns, runtimeUrl, bearerToken, tenantId, pollInterval, timeout);
+ }
+
+ public RunOptions withAuth(String bearerToken, String tenantId) {
+ return new RunOptions(maxTurns, runtimeUrl, bearerToken, tenantId, pollInterval, timeout);
+ }
+
+ public RunOptions withPollInterval(Duration pollInterval) {
+ return new RunOptions(maxTurns, runtimeUrl, bearerToken, tenantId, pollInterval, timeout);
+ }
+
+ public RunOptions withTimeout(Duration timeout) {
+ return new RunOptions(maxTurns, runtimeUrl, bearerToken, tenantId, pollInterval, timeout);
+ }
+}
diff --git a/jamjet-agent/src/main/java/dev/jamjet/agent/Tool.java b/jamjet-agent/src/main/java/dev/jamjet/agent/Tool.java
new file mode 100644
index 0000000..32f9245
--- /dev/null
+++ b/jamjet-agent/src/main/java/dev/jamjet/agent/Tool.java
@@ -0,0 +1,49 @@
+package dev.jamjet.agent;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Marks a method as a JamJet agent tool — the Java authoring analog of the
+ * Python {@code @jamjet.tool} decorator.
+ *
+ * A {@code @Tool} method on a tool-holder object is discovered by
+ * {@link dev.jamjet.agent.tools.ToolRegistry}, which derives an OpenAI-format
+ * function schema from the method's name + parameters and offers it to the model
+ * in the agent loop's Model nodes. When the model requests the tool, the durable
+ * Java tool-worker (Phase B-3) resolves it back to this method by
+ * class name + method name via the same registry and invokes it
+ * reflectively — only declared {@code @Tool} methods are ever callable, never an
+ * arbitrary class named in a payload.
+ *
+ *
This is a net-new authoring annotation, deliberately distinct from the
+ * unrelated {@code dev.jamjet.cloud.agentboundary.Tool} (receipt metadata) and
+ * {@code org.springframework.ai.tool.annotation.Tool} (Spring AI) types.
+ *
+ *
Usage:
+ *
{@code
+ * class WeatherTools {
+ * @Tool(description = "Look up the current weather for a city.")
+ * public String getWeather(String city) { ... }
+ * }
+ * }
+ */
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface Tool {
+
+ /**
+ * The tool name offered to the model. Defaults to the method name when empty.
+ * Set this when the idiomatic Java method name (camelCase) should be exposed
+ * to the model under a different identifier (e.g. {@code "web_search"}).
+ */
+ String name() default "";
+
+ /**
+ * A human-readable description of what the tool does, surfaced to the model
+ * in the function schema. Empty by default.
+ */
+ String description() default "";
+}
diff --git a/jamjet-agent/src/main/java/dev/jamjet/agent/client/ClaimedWorkItem.java b/jamjet-agent/src/main/java/dev/jamjet/agent/client/ClaimedWorkItem.java
new file mode 100644
index 0000000..c642c04
--- /dev/null
+++ b/jamjet-agent/src/main/java/dev/jamjet/agent/client/ClaimedWorkItem.java
@@ -0,0 +1,26 @@
+package dev.jamjet.agent.client;
+
+import java.util.Map;
+
+/**
+ * A work item claimed from the engine via {@code POST /work-items/claim}.
+ * Mirrors the {@code work_item} object the Python client returns
+ * ({@code {id, execution_id, node_id, queue_type, payload, attempt, lease_fence}}).
+ *
+ * {@code leaseFence} (added by engine PR #108) MUST be threaded back on both
+ * {@code heartbeat} and {@code complete} so a reclaimed worker cannot duplicate a
+ * completion: a stale fence yields HTTP 409. It is {@code null} only for legacy
+ * unfenced claims.
+ *
+ *
For a {@code java_tool} item the {@code payload} carries the enriched
+ * {@code class} / {@code method} / {@code input} the durable tool-worker dispatches on.
+ */
+public record ClaimedWorkItem(
+ String id,
+ String executionId,
+ String nodeId,
+ String queueType,
+ Map payload,
+ int attempt,
+ Long leaseFence
+) {}
diff --git a/jamjet-agent/src/main/java/dev/jamjet/agent/client/CreateWorkflowResult.java b/jamjet-agent/src/main/java/dev/jamjet/agent/client/CreateWorkflowResult.java
new file mode 100644
index 0000000..d335eda
--- /dev/null
+++ b/jamjet-agent/src/main/java/dev/jamjet/agent/client/CreateWorkflowResult.java
@@ -0,0 +1,8 @@
+package dev.jamjet.agent.client;
+
+/**
+ * Response of {@code POST /workflows}. Mirrors the Python client's
+ * {@code create_workflow} return ({@code {"workflow_id": ...}}); {@code version}
+ * is populated when the engine echoes it.
+ */
+public record CreateWorkflowResult(String workflowId, String version) {}
diff --git a/jamjet-agent/src/main/java/dev/jamjet/agent/client/ExecutionState.java b/jamjet-agent/src/main/java/dev/jamjet/agent/client/ExecutionState.java
new file mode 100644
index 0000000..7c27b3b
--- /dev/null
+++ b/jamjet-agent/src/main/java/dev/jamjet/agent/client/ExecutionState.java
@@ -0,0 +1,17 @@
+package dev.jamjet.agent.client;
+
+import java.util.Map;
+
+/**
+ * Response of {@code GET /executions/{id}} — the polled execution snapshot.
+ * Mirrors the Python client's {@code get_execution} shape ({@code {status,
+ * current_state, ...}}). The engine serializes status in snake_case, so a
+ * terminal status is {@code "completed"} (or {@code "failed"} /
+ * {@code "cancelled"} / {@code "limit_exceeded"}); {@code currentState} carries
+ * the accumulated execution state.
+ */
+public record ExecutionState(
+ String executionId,
+ String status,
+ Map currentState
+) {}
diff --git a/jamjet-agent/src/main/java/dev/jamjet/agent/client/JamjetEngineClient.java b/jamjet-agent/src/main/java/dev/jamjet/agent/client/JamjetEngineClient.java
new file mode 100644
index 0000000..95f0afe
--- /dev/null
+++ b/jamjet-agent/src/main/java/dev/jamjet/agent/client/JamjetEngineClient.java
@@ -0,0 +1,319 @@
+package dev.jamjet.agent.client;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import dev.jamjet.runtime.core.JamjetJson;
+import dev.jamjet.runtime.core.ir.WorkflowIr;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.net.http.HttpTimeoutException;
+import java.time.Duration;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.Executors;
+
+/**
+ * Plain-Java HTTP client for the JamJet engine REST API — the load-bearing
+ * Java→engine transport for the Track-9 Java ADK.
+ *
+ * Built on the JDK {@link java.net.http.HttpClient} over a virtual-thread
+ * executor (no third-party HTTP dependency), serializing every body through
+ * {@link JamjetJson#shared()} (snake_case, {@code NON_NULL}) so Java JSON is
+ * wire-compatible with the Rust engine. It mirrors the canonical Python
+ * {@code jamjet.client.JamjetClient}: the engine serves bare
+ * routes ({@code /workflows}, {@code /executions}, {@code /work-items/...}),
+ * not {@code /api/v1/...}.
+ *
+ *
Unlike jamjet-spring's {@code JamjetRuntimeClient} (which posts to the wrong
+ * {@code /api/v1} routes and lacks the worker protocol), this client uses bare
+ * routes and adds the four work-item methods the durable Java tool-worker needs:
+ * {@link #claimWorkItem}, {@link #completeWorkItem}, {@link #failWorkItem},
+ * {@link #heartbeatWorkItem}.
+ *
+ *
Lease fencing. The claim response carries a {@code lease_fence}
+ * (engine PR #108). A worker must thread it back on both {@code heartbeat} and
+ * {@code complete}; a stale fence on complete is rejected with HTTP 409, which this
+ * client surfaces as a {@link JamjetHttpException} where {@link JamjetHttpException#isConflict()}
+ * is true, so the worker can treat it as a lost lease rather than a generic error.
+ *
+ *
Request bodies are hand-built with literal snake_case keys (faithful to the
+ * Python client). This is deliberate: {@code JamjetJson}'s SNAKE_CASE strategy
+ * renames record/bean property names but NOT {@code Map} keys, so building bodies
+ * as maps with camelCase keys would emit the wrong wire shape.
+ */
+public final class JamjetEngineClient implements AutoCloseable {
+
+ private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10);
+ private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(30);
+
+ private final String baseUrl;
+ private final String bearerToken;
+ private final String tenantId;
+ private final HttpClient http;
+ private final ObjectMapper json;
+
+ /** Anonymous client (no auth headers) against {@code baseUrl}. */
+ public JamjetEngineClient(String baseUrl) {
+ this(baseUrl, null, null);
+ }
+
+ /**
+ * @param baseUrl engine base URL, e.g. {@code http://localhost:7700}
+ * @param bearerToken optional; sent as {@code Authorization: Bearer ...} when non-blank
+ * @param tenantId optional; sent as {@code X-Tenant-Id} when non-blank
+ */
+ public JamjetEngineClient(String baseUrl, String bearerToken, String tenantId) {
+ this.baseUrl = stripTrailingSlash(baseUrl);
+ this.bearerToken = bearerToken;
+ this.tenantId = tenantId;
+ this.http = HttpClient.newBuilder()
+ .executor(Executors.newVirtualThreadPerTaskExecutor())
+ .connectTimeout(CONNECT_TIMEOUT)
+ .build();
+ this.json = JamjetJson.shared();
+ }
+
+ // -- Workflows --------------------------------------------------------------
+
+ /** {@code POST /workflows} with a typed IR. Body {@code {"ir": ir}}. */
+ public CreateWorkflowResult createWorkflow(WorkflowIr ir) {
+ return postJson("/workflows", Map.of("ir", ir), CreateWorkflowResult.class);
+ }
+
+ /** {@code POST /workflows} with a raw IR map. Body {@code {"ir": ir}}. */
+ public CreateWorkflowResult createWorkflow(Map ir) {
+ return postJson("/workflows", Map.of("ir", ir), CreateWorkflowResult.class);
+ }
+
+ // -- Executions -------------------------------------------------------------
+
+ /**
+ * {@code POST /executions}. Body {@code {workflow_id, input, workflow_version?}}
+ * (note: the version key is {@code workflow_version}, matching the Python client).
+ */
+ public StartExecutionResult startExecution(String workflowId,
+ Map initialInput,
+ String workflowVersion) {
+ Map body = new LinkedHashMap<>();
+ body.put("workflow_id", workflowId);
+ body.put("input", initialInput == null ? Map.of() : initialInput);
+ if (workflowVersion != null && !workflowVersion.isBlank()) {
+ body.put("workflow_version", workflowVersion);
+ }
+ return postJson("/executions", body, StartExecutionResult.class);
+ }
+
+ /** {@code GET /executions/{id}} — the current execution snapshot for polling. */
+ public ExecutionState getExecution(String executionId) {
+ return getJson("/executions/" + executionId, ExecutionState.class);
+ }
+
+ /** {@code GET /executions/{id}/events} — the event stream (response {@code {"events": [...]}}). */
+ @SuppressWarnings("unchecked")
+ public List> listEvents(String executionId) {
+ String body = execute(buildGet("/executions/" + executionId + "/events"));
+ Map resp = (Map) parse(body, Map.class);
+ if (resp == null) {
+ return List.of();
+ }
+ Object events = resp.get("events");
+ if (events instanceof List> list) {
+ return list.stream().map(e -> (Map) e).toList();
+ }
+ return List.of();
+ }
+
+ // -- Work items (worker protocol) ------------------------------------------
+
+ /**
+ * {@code POST /work-items/claim}. Body {@code {worker_id, queue_types}}.
+ * Returns the claimed item, or {@link Optional#empty()} when the queue is empty
+ * (engine responds {@code {"claimed": false}}).
+ */
+ public Optional claimWorkItem(String workerId, List queueTypes) {
+ Map body = new LinkedHashMap<>();
+ body.put("worker_id", workerId);
+ body.put("queue_types", queueTypes);
+ JsonNode root = readTree(execute(buildPost("/work-items/claim", body)));
+ if (root == null || !root.path("claimed").asBoolean(false)) {
+ return Optional.empty();
+ }
+ JsonNode item = root.get("work_item");
+ if (item == null || item.isNull()) {
+ return Optional.empty();
+ }
+ return Optional.of(json.convertValue(item, ClaimedWorkItem.class));
+ }
+
+ /**
+ * {@code POST /work-items/{id}/complete} — mark the item done.
+ * Body {@code {output, state_patch, duration_ms, execution_id?, node_id?,
+ * gen_ai_model?, finish_reason?, lease_fence?}}.
+ *
+ * {@code leaseFence} is included in the body only when non-null; when present
+ * the engine fences the completion, so a stale fence (the item was reclaimed)
+ * yields HTTP 409, surfaced as a {@link JamjetHttpException} with
+ * {@link JamjetHttpException#isConflict()} true.
+ */
+ public void completeWorkItem(String itemId,
+ String executionId,
+ String nodeId,
+ Object output,
+ Map statePatch,
+ long durationMs,
+ String genAiModel,
+ String finishReason,
+ Long leaseFence) {
+ Map body = new LinkedHashMap<>();
+ body.put("output", output);
+ body.put("state_patch", statePatch == null ? Map.of() : statePatch);
+ body.put("duration_ms", durationMs);
+ if (executionId != null) {
+ body.put("execution_id", executionId);
+ }
+ if (nodeId != null) {
+ body.put("node_id", nodeId);
+ }
+ if (genAiModel != null) {
+ body.put("gen_ai_model", genAiModel);
+ }
+ if (finishReason != null) {
+ body.put("finish_reason", finishReason);
+ }
+ if (leaseFence != null) {
+ body.put("lease_fence", leaseFence);
+ }
+ execute(buildPost("/work-items/" + itemId + "/complete", body));
+ }
+
+ /** {@code POST /work-items/{id}/fail}. Body {@code {error}}. */
+ public void failWorkItem(String itemId, String error) {
+ execute(buildPost("/work-items/" + itemId + "/fail", Map.of("error", error)));
+ }
+
+ /**
+ * {@code POST /work-items/{id}/heartbeat} — renew the lease.
+ * Body {@code {worker_id, lease_fence?}}; the fence must match the claim's value
+ * or the engine rejects the renewal.
+ *
+ * {@code leaseFence} is nullable — consistent with {@link #completeWorkItem} and
+ * {@link ClaimedWorkItem#leaseFence()} — and included in the body only when non-null.
+ * For a real fenced claim it is always present; null is only the legacy unfenced case.
+ */
+ public void heartbeatWorkItem(String itemId, String workerId, Long leaseFence) {
+ Map body = new LinkedHashMap<>();
+ body.put("worker_id", workerId);
+ if (leaseFence != null) {
+ body.put("lease_fence", leaseFence);
+ }
+ execute(buildPost("/work-items/" + itemId + "/heartbeat", body));
+ }
+
+ // -- Internal HTTP plumbing -------------------------------------------------
+
+ private T postJson(String path, Object body, Class type) {
+ return parse(execute(buildPost(path, body)), type);
+ }
+
+ private T getJson(String path, Class type) {
+ return parse(execute(buildGet(path)), type);
+ }
+
+ private HttpRequest buildPost(String path, Object body) {
+ byte[] payload;
+ try {
+ payload = json.writeValueAsBytes(body);
+ } catch (IOException e) {
+ throw new JamjetHttpException("Failed to serialize request body for " + path, e);
+ }
+ return withHeaders(HttpRequest.newBuilder(uri(path)))
+ .timeout(REQUEST_TIMEOUT)
+ .header("Content-Type", "application/json")
+ .POST(HttpRequest.BodyPublishers.ofByteArray(payload))
+ .build();
+ }
+
+ private HttpRequest buildGet(String path) {
+ return withHeaders(HttpRequest.newBuilder(uri(path)))
+ .timeout(REQUEST_TIMEOUT)
+ .GET()
+ .build();
+ }
+
+ private HttpRequest.Builder withHeaders(HttpRequest.Builder builder) {
+ builder.header("Accept", "application/json");
+ if (bearerToken != null && !bearerToken.isBlank()) {
+ builder.header("Authorization", "Bearer " + bearerToken);
+ }
+ if (tenantId != null && !tenantId.isBlank()) {
+ builder.header("X-Tenant-Id", tenantId);
+ }
+ return builder;
+ }
+
+ private URI uri(String path) {
+ return URI.create(baseUrl + path);
+ }
+
+ /** Send, throwing {@link JamjetHttpException} on a non-2xx (status + body preserved). */
+ private String execute(HttpRequest request) {
+ HttpResponse resp;
+ try {
+ resp = http.send(request, HttpResponse.BodyHandlers.ofString());
+ } catch (HttpTimeoutException e) {
+ throw new JamjetHttpException("Request timed out: " + request.uri(), e);
+ } catch (IOException e) {
+ throw new JamjetHttpException("HTTP request failed: " + request.uri(), e);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new JamjetHttpException("HTTP request interrupted: " + request.uri(), e);
+ }
+ int status = resp.statusCode();
+ String body = resp.body();
+ if (status < 200 || status >= 300) {
+ throw new JamjetHttpException(status, body);
+ }
+ return body;
+ }
+
+ private T parse(String body, Class type) {
+ if (body == null || body.isBlank()) {
+ return null;
+ }
+ try {
+ return json.readValue(body, type);
+ } catch (IOException e) {
+ throw new JamjetHttpException("Failed to parse response body: " + e.getMessage(), e);
+ }
+ }
+
+ private JsonNode readTree(String body) {
+ if (body == null || body.isBlank()) {
+ return null;
+ }
+ try {
+ return json.readTree(body);
+ } catch (IOException e) {
+ throw new JamjetHttpException("Failed to parse response body: " + e.getMessage(), e);
+ }
+ }
+
+ private static String stripTrailingSlash(String url) {
+ if (url == null) {
+ throw new IllegalArgumentException("baseUrl must not be null");
+ }
+ return url.endsWith("/") ? url.substring(0, url.length() - 1) : url;
+ }
+
+ /** HttpClient is JVM-managed and the virtual-thread executor has no pool to drain. */
+ @Override
+ public void close() {
+ // no-op
+ }
+}
diff --git a/jamjet-agent/src/main/java/dev/jamjet/agent/client/JamjetHttpException.java b/jamjet-agent/src/main/java/dev/jamjet/agent/client/JamjetHttpException.java
new file mode 100644
index 0000000..987b7da
--- /dev/null
+++ b/jamjet-agent/src/main/java/dev/jamjet/agent/client/JamjetHttpException.java
@@ -0,0 +1,49 @@
+package dev.jamjet.agent.client;
+
+/**
+ * Raised when an engine call fails — either a non-2xx HTTP response (carrying the
+ * status code + raw body) or a transport-level error (carrying a cause).
+ *
+ * The status code is surfaced so callers can branch on it. In particular a
+ * {@code 409 Conflict} on {@code POST /work-items/{id}/complete} means a stale
+ * lease fence (the item was reclaimed): the durable tool-worker must treat this
+ * as a lost lease, not a generic failure. Use {@link #isConflict()} for that
+ * branch.
+ */
+public final class JamjetHttpException extends RuntimeException {
+
+ /** -1 when the failure is transport-level (no HTTP status was received). */
+ private final int statusCode;
+
+ /** The raw response body for a non-2xx; {@code null} for transport errors. */
+ private final String body;
+
+ /** A non-2xx HTTP response. */
+ public JamjetHttpException(int statusCode, String body) {
+ super("engine returned HTTP " + statusCode + (body == null || body.isBlank() ? "" : ": " + body));
+ this.statusCode = statusCode;
+ this.body = body;
+ }
+
+ /** A transport-level failure (timeout, connection refused, serialization, ...). */
+ public JamjetHttpException(String message, Throwable cause) {
+ super(message, cause);
+ this.statusCode = -1;
+ this.body = null;
+ }
+
+ /** The HTTP status code, or -1 for a transport-level error. */
+ public int statusCode() {
+ return statusCode;
+ }
+
+ /** The raw response body for a non-2xx, or {@code null} for a transport error. */
+ public String body() {
+ return body;
+ }
+
+ /** True iff this is a {@code 409 Conflict} (e.g. a stale lease fence on complete). */
+ public boolean isConflict() {
+ return statusCode == 409;
+ }
+}
diff --git a/jamjet-agent/src/main/java/dev/jamjet/agent/client/StartExecutionResult.java b/jamjet-agent/src/main/java/dev/jamjet/agent/client/StartExecutionResult.java
new file mode 100644
index 0000000..65c99a9
--- /dev/null
+++ b/jamjet-agent/src/main/java/dev/jamjet/agent/client/StartExecutionResult.java
@@ -0,0 +1,7 @@
+package dev.jamjet.agent.client;
+
+/**
+ * Response of {@code POST /executions}. Mirrors the Python client's
+ * {@code start_execution} return ({@code {"execution_id": ..., "status": ...}}).
+ */
+public record StartExecutionResult(String executionId, String status) {}
diff --git a/jamjet-agent/src/main/java/dev/jamjet/agent/tools/RegisteredTool.java b/jamjet-agent/src/main/java/dev/jamjet/agent/tools/RegisteredTool.java
new file mode 100644
index 0000000..5490a2d
--- /dev/null
+++ b/jamjet-agent/src/main/java/dev/jamjet/agent/tools/RegisteredTool.java
@@ -0,0 +1,39 @@
+package dev.jamjet.agent.tools;
+
+import java.lang.reflect.Method;
+import java.util.Map;
+
+/**
+ * One discovered {@link dev.jamjet.agent.Tool @Tool} method, the single source of
+ * truth for both authoring (the OpenAI schema offered to the model) and durable
+ * dispatch (the reflective handle the Phase B-3 worker resolves by class+method).
+ *
+ * @param name the tool name offered to the model ({@code @Tool.name} or the method name)
+ * @param description the {@code @Tool.description} (may be empty)
+ * @param className the fully-qualified declaring class name (the JavaFn dispatch coordinate)
+ * @param methodName the method name (the JavaFn dispatch coordinate)
+ * @param method the reflective handle (used by the B-3 worker to invoke; never serialized)
+ * @param instance the tool-holder instance the method is invoked on (never serialized)
+ * @param inputSchema the JSON-schema {@code {type, properties, required}} for the parameters
+ * @param openAiSchema the full OpenAI function wrapper {@code {type:function, function:{...}}}
+ */
+public record RegisteredTool(
+ String name,
+ String description,
+ String className,
+ String methodName,
+ Method method,
+ Object instance,
+ Map inputSchema,
+ Map openAiSchema
+) {
+ /** A stable registry key: {@code className#methodName} (the B-3 dispatch coordinate). */
+ public String key() {
+ return key(className, methodName);
+ }
+
+ /** Build the {@code className#methodName} registry key. */
+ public static String key(String className, String methodName) {
+ return className + "#" + methodName;
+ }
+}
diff --git a/jamjet-agent/src/main/java/dev/jamjet/agent/tools/ToolDispatcher.java b/jamjet-agent/src/main/java/dev/jamjet/agent/tools/ToolDispatcher.java
new file mode 100644
index 0000000..783a8a9
--- /dev/null
+++ b/jamjet-agent/src/main/java/dev/jamjet/agent/tools/ToolDispatcher.java
@@ -0,0 +1,311 @@
+package dev.jamjet.agent.tools;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import dev.jamjet.runtime.core.JamjetJson;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.lang.reflect.Parameter;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * The Java analog of Python's {@code jamjet.agents.tool_runtime.dispatch_tool_calls}:
+ * runs one model turn's requested tool calls and returns the updated message list.
+ *
+ * This is the method every {@code __tools_{t}__} {@code JavaFn} node points at
+ * ({@code dev.jamjet.agent.tools.ToolDispatcher#dispatchToolCalls}). On the durable
+ * engine the Phase B-3 {@code JavaToolWorker} resolves that fixed coordinate to a
+ * {@code ToolDispatcher} bound to the agent's {@link ToolRegistry} and invokes
+ * {@link #dispatchToolCalls(Map)} with the work item's {@code input} (the full
+ * accumulated workflow state the scheduler enriched in — see the Rust scheduler's
+ * {@code JavaFn} arm). The return replaces {@code state["messages"]} for the next
+ * model turn, exactly as the Python dispatcher does.
+ *
+ *
Input / output contract (mirrors {@code dispatch_tool_calls})
+ * Reads from the state map, with the same fallbacks so it is unit-testable without
+ * the engine:
+ *
+ * {@code messages} — the running message list (default {@code []}).
+ * {@code tool_calls} then {@code last_model_tool_calls} — the requested calls
+ * {@code [{id, name, arguments}, ...]} (the Model executor records the latter).
+ * {@code assistant_content} then {@code last_model_output} — the assistant text.
+ *
+ * Returns {@code {"messages": }}: the original messages, plus the
+ * assistant message that requested the calls (OpenAI shape), plus one {@code role:
+ * tool} message per call. The worker uses this both as the node {@code output} and as
+ * the {@code state_patch} (the engine merge-patches it into state, replacing the
+ * top-level {@code messages} key) — identical to the Python {@code python_tool} worker.
+ *
+ * Tool resolution is registry-gated (the RCE gate)
+ * Each call is resolved by name through {@link ToolRegistry#byName(String)}
+ * only . The registry's name map is populated exclusively from
+ * {@code @Tool}-annotated methods scanned off the agent's tool holders, so the only
+ * reflective handles that exist are those pre-captured {@link RegisteredTool#method()}
+ * objects. A model-supplied (hence untrusted) tool name that is not a declared
+ * {@code @Tool} simply misses the map: there is no code path that
+ * turns a name into a {@code Class.forName(...)} or an arbitrary {@code Method}, so a
+ * hallucinated or malicious tool name can never invoke arbitrary code. An unknown tool
+ * yields a clean {@code role: tool} error message surfaced to the model (so a single
+ * hallucinated name does not abort the durable run), never an exception that crashes
+ * the worker and never an arbitrary invocation. A registered tool that throws
+ * at runtime propagates (the worker fails the item), mirroring the Python dispatcher.
+ */
+public final class ToolDispatcher {
+
+ /** The fixed dispatch class coordinate the {@code JavaFn} tool nodes carry. */
+ public static final String DISPATCH_CLASS = "dev.jamjet.agent.tools.ToolDispatcher";
+
+ /** The fixed dispatch method coordinate the {@code JavaFn} tool nodes carry. */
+ public static final String DISPATCH_METHOD = "dispatchToolCalls";
+
+ private final ToolRegistry registry;
+ private final ObjectMapper json;
+
+ /** Bind a dispatcher to the agent's tool registry (the only callable tool set). */
+ public ToolDispatcher(ToolRegistry registry) {
+ if (registry == null) {
+ throw new IllegalArgumentException("registry must not be null");
+ }
+ this.registry = registry;
+ this.json = JamjetJson.shared();
+ }
+
+ /**
+ * Run the tool calls from one model turn and return the updated message list as
+ * {@code {"messages": }}.
+ *
+ * @param input the accumulated workflow state (the work item's enriched {@code input})
+ * @throws ToolInvocationException if a registered tool throws, or the
+ * dispatch thread is interrupted (lease lost) between calls
+ */
+ public Map dispatchToolCalls(Map input) {
+ Map state = input == null ? Map.of() : input;
+
+ List messages = new ArrayList<>(asList(state.get("messages")));
+ List> toolCalls = readToolCalls(state);
+ String assistantContent = readAssistantContent(state);
+
+ // 1. The assistant message that requested these calls (OpenAI shape, so the
+ // history replays cleanly into the next model turn).
+ messages.add(assistantMessage(assistantContent, toolCalls));
+
+ // 2. Execute each requested tool; append its result as a `role: tool` message.
+ for (Map call : toolCalls) {
+ // Cooperative abort checkpoint: if the worker cancelled us because the
+ // lease was lost (M3), stop before running another (possibly
+ // side-effecting) tool. The worker's lease-lost gate is the real
+ // safety net; this just avoids extra work after an abort.
+ if (Thread.currentThread().isInterrupted()) {
+ throw new ToolInvocationException(
+ "", new InterruptedException("tool dispatch aborted (lease lost)"));
+ }
+ Object callId = call.get("id");
+ String name = stringOrNull(call.get("name"));
+ Map arguments = coerceArguments(call.get("arguments"));
+
+ String content = invokeTool(name, arguments);
+ messages.add(toolMessage(callId, name, content));
+ }
+
+ // 3. Return the FULL updated list — replaces state["messages"].
+ Map out = new LinkedHashMap<>();
+ out.put("messages", messages);
+ return out;
+ }
+
+ // -- tool invocation (registry-gated) ---------------------------------------
+
+ /**
+ * Resolve and invoke one tool by name. A registry miss (unknown/unregistered
+ * name) returns a clean error string for a {@code role: tool} message rather than
+ * throwing — this is the safe, model-recoverable path and never touches an
+ * arbitrary class. A registered tool that throws propagates as a
+ * {@link ToolInvocationException} (the worker fails the item).
+ */
+ private String invokeTool(String name, Map arguments) {
+ RegisteredTool tool = name == null ? null : registry.byName(name);
+ if (tool == null) {
+ // RCE GATE: an unknown name is not a declared @Tool. Do NOT reflect on it;
+ // surface a clean tool error to the model.
+ return "ERROR: tool '" + name + "' is not a registered @Tool";
+ }
+
+ Method method = tool.method();
+ // Required-arg gate: every @Tool parameter is required (ToolRegistry.buildInputSchema
+ // marks them all). A call that omits one must surface a clean role:tool error to the
+ // model, NOT invoke the tool with a null-coerced (or primitive-defaulted) argument.
+ List missing = missingRequiredArgs(method, arguments);
+ if (!missing.isEmpty()) {
+ return "ERROR: tool '" + name + "' missing required argument(s): " + String.join(", ", missing);
+ }
+
+ Object[] args = coerceToParameters(method, arguments);
+ try {
+ method.setAccessible(true);
+ Object result = method.invoke(tool.instance(), args);
+ return stringify(result);
+ } catch (InvocationTargetException e) {
+ Throwable cause = e.getCause() != null ? e.getCause() : e;
+ throw new ToolInvocationException(name, cause);
+ } catch (ReflectiveOperationException | IllegalArgumentException e) {
+ throw new ToolInvocationException(name, e);
+ }
+ }
+
+ /**
+ * The names of the tool's required parameters that are absent from {@code arguments}.
+ * Every {@code @Tool} parameter is required (parity with the emitted schema), so any
+ * parameter whose name is not a key is missing. A present-but-{@code null} value is the
+ * model's explicit choice and is left to the tool.
+ */
+ private static List missingRequiredArgs(Method method, Map arguments) {
+ List missing = new ArrayList<>();
+ for (Parameter p : method.getParameters()) {
+ if (!arguments.containsKey(p.getName())) {
+ missing.add(p.getName());
+ }
+ }
+ return missing;
+ }
+
+ /**
+ * Coerce the JSON argument map to the method's typed positional parameters via
+ * Jackson, matching each parameter by its (compiled {@code -parameters}) name.
+ * Callers must have already rejected missing required args (see
+ * {@link #missingRequiredArgs}); a present-but-null value coerces to {@code null}.
+ */
+ private Object[] coerceToParameters(Method method, Map arguments) {
+ Parameter[] params = method.getParameters();
+ Object[] out = new Object[params.length];
+ for (int i = 0; i < params.length; i++) {
+ Parameter p = params[i];
+ Object raw = arguments.get(p.getName());
+ out[i] = json.convertValue(raw, p.getType());
+ }
+ return out;
+ }
+
+ // -- message shaping (mirrors tool_runtime helpers) -------------------------
+
+ private Map assistantMessage(String content, List> toolCalls) {
+ List calls = new ArrayList<>(toolCalls.size());
+ for (Map call : toolCalls) {
+ calls.add(assistantToolCall(call));
+ }
+ Map m = new LinkedHashMap<>();
+ m.put("role", "assistant");
+ m.put("content", content == null ? "" : content);
+ m.put("tool_calls", calls);
+ return m;
+ }
+
+ /** Render a {@code {id, name, arguments}} call into the OpenAI assistant shape. */
+ private Map assistantToolCall(Map call) {
+ Object rawArgs = call.get("arguments");
+ String argString;
+ if (rawArgs instanceof String s) {
+ argString = s;
+ } else {
+ try {
+ argString = json.writeValueAsString(rawArgs == null ? Map.of() : rawArgs);
+ } catch (Exception e) {
+ argString = "{}";
+ }
+ }
+ Map function = new LinkedHashMap<>();
+ function.put("name", call.get("name"));
+ function.put("arguments", argString);
+
+ Map m = new LinkedHashMap<>();
+ m.put("id", call.get("id"));
+ m.put("type", "function");
+ m.put("function", function);
+ return m;
+ }
+
+ private Map toolMessage(Object callId, String name, String content) {
+ Map m = new LinkedHashMap<>();
+ m.put("role", "tool");
+ m.put("tool_call_id", callId);
+ m.put("name", name);
+ m.put("content", content);
+ return m;
+ }
+
+ // -- state reading (with Python fallbacks) ----------------------------------
+
+ @SuppressWarnings("unchecked")
+ private List> readToolCalls(Map state) {
+ Object calls = state.get("tool_calls");
+ if (calls == null) {
+ calls = state.get("last_model_tool_calls");
+ }
+ if (calls instanceof List> list) {
+ List> out = new ArrayList<>(list.size());
+ for (Object e : list) {
+ if (e instanceof Map, ?> m) {
+ out.add((Map) m);
+ }
+ }
+ return out;
+ }
+ return List.of();
+ }
+
+ private String readAssistantContent(Map state) {
+ Object content = state.get("assistant_content");
+ if (content == null) {
+ content = state.get("last_model_output");
+ }
+ return content == null ? null : String.valueOf(content);
+ }
+
+ /** Tool arguments arrive as a JSON object or a JSON string — normalise to a map. */
+ @SuppressWarnings("unchecked")
+ private Map coerceArguments(Object arguments) {
+ if (arguments instanceof String s) {
+ if (s.isBlank()) {
+ return Map.of();
+ }
+ try {
+ return json.readValue(s, Map.class);
+ } catch (Exception e) {
+ return Map.of();
+ }
+ }
+ if (arguments instanceof Map, ?> m) {
+ return (Map) m;
+ }
+ return Map.of();
+ }
+
+ /** Coerce a tool result to the string content a {@code role: tool} message needs. */
+ private String stringify(Object result) {
+ if (result == null) {
+ return "null";
+ }
+ if (result instanceof String s) {
+ return s;
+ }
+ try {
+ return json.writeValueAsString(result);
+ } catch (Exception e) {
+ return String.valueOf(result);
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private static List asList(Object value) {
+ if (value instanceof List> list) {
+ return (List) list;
+ }
+ return List.of();
+ }
+
+ private static String stringOrNull(Object value) {
+ return value == null ? null : String.valueOf(value);
+ }
+}
diff --git a/jamjet-agent/src/main/java/dev/jamjet/agent/tools/ToolInvocationException.java b/jamjet-agent/src/main/java/dev/jamjet/agent/tools/ToolInvocationException.java
new file mode 100644
index 0000000..5694e74
--- /dev/null
+++ b/jamjet-agent/src/main/java/dev/jamjet/agent/tools/ToolInvocationException.java
@@ -0,0 +1,33 @@
+package dev.jamjet.agent.tools;
+
+/**
+ * Raised by {@link ToolDispatcher} when a registered {@code @Tool} method
+ * throws during reflective invocation, or when the dispatch thread is interrupted
+ * (the worker aborted it because the lease was lost). It carries the offending tool
+ * name plus the underlying cause so the durable worker can fail the work item with a
+ * faithful message (mirroring the Python dispatcher, which lets a tool exception
+ * propagate out of {@code dispatch_tool_calls}).
+ *
+ * An unknown /unregistered tool name does NOT raise this — it is surfaced
+ * to the model as a clean {@code role: tool} error message instead, so a hallucinated
+ * name cannot fail the run (and, critically, never triggers an arbitrary invocation).
+ *
+ *
The surfaced message stays generic (the tool name only). The raw cause is retained
+ * as the exception {@code cause} (for stack traces) but deliberately NOT embedded in the
+ * message text, because that message is logged and persisted to the engine via
+ * {@code failWorkItem}, where a raw {@code cause.toString()} could leak sensitive detail.
+ */
+public final class ToolInvocationException extends RuntimeException {
+
+ private final String toolName;
+
+ public ToolInvocationException(String toolName, Throwable cause) {
+ super("tool '" + toolName + "' failed", cause);
+ this.toolName = toolName;
+ }
+
+ /** The name of the tool whose invocation failed. */
+ public String toolName() {
+ return toolName;
+ }
+}
diff --git a/jamjet-agent/src/main/java/dev/jamjet/agent/tools/ToolRegistry.java b/jamjet-agent/src/main/java/dev/jamjet/agent/tools/ToolRegistry.java
new file mode 100644
index 0000000..9676de0
--- /dev/null
+++ b/jamjet-agent/src/main/java/dev/jamjet/agent/tools/ToolRegistry.java
@@ -0,0 +1,223 @@
+package dev.jamjet.agent.tools;
+
+import dev.jamjet.agent.Tool;
+
+import java.lang.reflect.Method;
+import java.lang.reflect.Parameter;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Discovers {@link Tool @Tool} methods on tool-holder objects and is the single
+ * source of truth for both halves of the Java agent loop:
+ *
+ *
+ * Authoring — {@link #openAiToolSchemas()} returns the OpenAI-format
+ * function schemas (in deterministic order) that the {@code Agent} builder
+ * threads into every Model node's {@code tools}, mirroring the Python
+ * {@code agent_ir._tool_schema}.
+ * Dispatch — {@link #byClassAndMethod} lets the Phase B-3 durable
+ * worker resolve a {@code JavaFn} node's {@code class_name}+{@code method}
+ * back to the exact {@link RegisteredTool} (and its reflective handle),
+ * gating invocation to declared {@code @Tool} methods only (no arbitrary
+ * {@code Class.forName} from a payload field).
+ *
+ *
+ * Tool order is deterministic: holders are kept in registration order and,
+ * within a holder, {@code @Tool} methods are ordered by name (since
+ * {@link Class#getDeclaredMethods()} is unordered). This keeps the emitted IR
+ * stable across runs and JVMs, which the content-version cache key relies on.
+ *
+ *
The OpenAI schema mirrors the Python {@code @tool} decorator exactly,
+ * including its bare-type-string property values ({@code {"query": "string"}},
+ * not {@code {"query": {"type": "string"}}}) so the schemas a Java agent offers
+ * the model are byte-identical to the Python equivalent.
+ */
+public final class ToolRegistry {
+
+ private final List tools = new ArrayList<>();
+ private final Map byKey = new LinkedHashMap<>();
+ private final Map byName = new LinkedHashMap<>();
+
+ /** An empty registry. */
+ public ToolRegistry() {}
+
+ /**
+ * Build a registry from the given tool-holder instances, registered in order.
+ * Each holder is scanned for {@code @Tool} methods.
+ */
+ public static ToolRegistry of(Object... holders) {
+ ToolRegistry registry = new ToolRegistry();
+ for (Object holder : holders) {
+ registry.register(holder);
+ }
+ return registry;
+ }
+
+ /**
+ * Build a registry from the given tool-holder instances, registered in order.
+ */
+ public static ToolRegistry of(List> holders) {
+ ToolRegistry registry = new ToolRegistry();
+ for (Object holder : holders) {
+ registry.register(holder);
+ }
+ return registry;
+ }
+
+ /**
+ * Scan {@code holder}'s class for {@code @Tool} methods and add them, in
+ * method-name order. Returns {@code this} for chaining.
+ *
+ * @throws IllegalArgumentException if two registered tools share a name
+ * @throws IllegalStateException if two {@code @Tool} methods overload the same
+ * class+method dispatch key (which would corrupt
+ * {@link #byClassAndMethod} dispatch)
+ */
+ public ToolRegistry register(Object holder) {
+ if (holder == null) {
+ throw new IllegalArgumentException("tool holder must not be null");
+ }
+ Class> clazz = holder.getClass();
+
+ List annotated = new ArrayList<>();
+ for (Method m : clazz.getDeclaredMethods()) {
+ if (m.isAnnotationPresent(Tool.class)) {
+ annotated.add(m);
+ }
+ }
+ // getDeclaredMethods() has no defined order — sort by name for determinism.
+ annotated.sort(Comparator.comparing(Method::getName));
+
+ for (Method m : annotated) {
+ Tool ann = m.getAnnotation(Tool.class);
+ String name = ann.name().isBlank() ? m.getName() : ann.name();
+ String description = ann.description();
+
+ Map inputSchema = buildInputSchema(m);
+ Map openAi = openAiSchema(name, description, inputSchema);
+
+ RegisteredTool rt = new RegisteredTool(
+ name, description, clazz.getName(), m.getName(), m, holder, inputSchema, openAi);
+
+ String key = rt.key();
+ if (byName.containsKey(name)) {
+ throw new IllegalArgumentException(
+ "Duplicate @Tool name '" + name + "' (from " + clazz.getName() + "#" + m.getName()
+ + "); tool names must be unique within an agent.");
+ }
+ // Overloaded @Tool methods (same declaring class + method name, but distinct
+ // @Tool names) collide on the class+method dispatch key. Silently letting the
+ // second overwrite the first would corrupt byClassAndMethod, so reject it.
+ if (byKey.containsKey(key)) {
+ throw new IllegalStateException(
+ "Overloaded @Tool method '" + clazz.getName() + "#" + m.getName()
+ + "': another @Tool already maps to the same class+method dispatch key '"
+ + key + "'. Tool dispatch keys on class+method, so overloads collide; "
+ + "give each @Tool a distinct method name.");
+ }
+ tools.add(rt);
+ byKey.put(key, rt);
+ byName.put(name, rt);
+ }
+ return this;
+ }
+
+ /** The registered tools in deterministic order. */
+ public List tools() {
+ return Collections.unmodifiableList(tools);
+ }
+
+ /** {@code true} if no tools are registered. */
+ public boolean isEmpty() {
+ return tools.isEmpty();
+ }
+
+ /**
+ * The OpenAI-format function schemas for every registered tool, in order —
+ * the value the {@code Agent} builder puts in each Model node's {@code tools}.
+ */
+ public List> openAiToolSchemas() {
+ List> out = new ArrayList<>(tools.size());
+ for (RegisteredTool t : tools) {
+ out.add(t.openAiSchema());
+ }
+ return out;
+ }
+
+ /**
+ * Resolve a tool by its dispatch coordinate ({@code className}+{@code method}).
+ * This is the B-3 worker's lookup: a registry hit means the method is a
+ * declared {@code @Tool}, so reflective invocation is safe.
+ */
+ public RegisteredTool byClassAndMethod(String className, String methodName) {
+ return byKey.get(RegisteredTool.key(className, methodName));
+ }
+
+ /** Resolve a tool by the name the model used in its {@code tool_call}. */
+ public RegisteredTool byName(String name) {
+ return byName.get(name);
+ }
+
+ // -- schema construction (mirrors Python jamjet.tools.decorators) -----------
+
+ private static Map buildInputSchema(Method m) {
+ LinkedHashMap properties = new LinkedHashMap<>();
+ List required = new ArrayList<>();
+ for (Parameter p : m.getParameters()) {
+ String paramName = p.getName();
+ properties.put(paramName, jsonType(p.getType()));
+ // Java reflection exposes no notion of an optional/defaulted parameter,
+ // so every @Tool parameter is required (parity with the Python schema
+ // where a param without a default is required).
+ required.add(paramName);
+ }
+ LinkedHashMap schema = new LinkedHashMap<>();
+ schema.put("type", "object");
+ schema.put("properties", properties);
+ schema.put("required", required);
+ return schema;
+ }
+
+ private static Map openAiSchema(String name, String description, Map inputSchema) {
+ LinkedHashMap function = new LinkedHashMap<>();
+ function.put("name", name);
+ function.put("description", description);
+ function.put("parameters", inputSchema);
+
+ LinkedHashMap schema = new LinkedHashMap<>();
+ schema.put("type", "function");
+ schema.put("function", function);
+ return schema;
+ }
+
+ /**
+ * Map a Java parameter type to the bare JSON-schema type string the Python
+ * {@code _type_to_schema} emits ({@code "string"}/{@code "integer"}/
+ * {@code "number"}/{@code "boolean"}/{@code "object"}). Integer-family and
+ * floating-family Java types widen to {@code "integer"}/{@code "number"}.
+ */
+ private static String jsonType(Class> t) {
+ if (t == String.class || t == CharSequence.class || t == char.class || t == Character.class) {
+ return "string";
+ }
+ if (t == int.class || t == Integer.class
+ || t == long.class || t == Long.class
+ || t == short.class || t == Short.class
+ || t == byte.class || t == Byte.class) {
+ return "integer";
+ }
+ if (t == float.class || t == Float.class || t == double.class || t == Double.class) {
+ return "number";
+ }
+ if (t == boolean.class || t == Boolean.class) {
+ return "boolean";
+ }
+ // Anything else (records, POJOs, collections, ...) is an object.
+ return "object";
+ }
+}
diff --git a/jamjet-agent/src/main/java/dev/jamjet/agent/worker/JavaToolWorker.java b/jamjet-agent/src/main/java/dev/jamjet/agent/worker/JavaToolWorker.java
new file mode 100644
index 0000000..fef8c06
--- /dev/null
+++ b/jamjet-agent/src/main/java/dev/jamjet/agent/worker/JavaToolWorker.java
@@ -0,0 +1,341 @@
+package dev.jamjet.agent.worker;
+
+import dev.jamjet.agent.client.ClaimedWorkItem;
+import dev.jamjet.agent.client.JamjetEngineClient;
+import dev.jamjet.agent.client.JamjetHttpException;
+import dev.jamjet.agent.tools.ToolDispatcher;
+import dev.jamjet.agent.tools.ToolRegistry;
+
+import java.lang.System.Logger;
+import java.lang.System.Logger.Level;
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * The durable Java tool-worker: drains the {@code java_tool} queue over the
+ * {@link JamjetEngineClient}, dispatching each claimed agent-loop tool turn to the
+ * registry-bound {@link ToolDispatcher} and settling the work item back to the engine
+ * exactly-once. It is the Java mirror of the hardened Python {@code jamjet worker}
+ * loop ({@code jamjet.cli.main._worker_loop}), framework-free plain Java 21.
+ *
+ * Lease fencing — the enforcement axis (M3 + PR #108)
+ * A reclaimed / zombie worker must never complete an item the engine has handed to a
+ * new claimant. Two independent guards enforce this:
+ *
+ * Proactive heartbeat abort (M3). While a tool runs, a heartbeat task
+ * renews the lease with the claim's {@code lease_fence}. If a heartbeat is
+ * rejected by the engine (any definitive HTTP error response, {@code status >=
+ * 400}), the lease is treated as lost: the in-flight dispatch is cancelled
+ * ({@code Future.cancel(true)} interrupts the tool thread) and the worker does
+ * NOT complete. The engine surfaces a stale heartbeat fence as HTTP 500
+ * ({@code FenceLost -> Internal}), so we key the abort on a definitive HTTP
+ * error rather than a specific code; a pure transport error (no HTTP status) is
+ * treated as a transient blip and does not abort (matching the Python worker's
+ * tolerance), because the complete-time fence below is the authoritative
+ * backstop.
+ * Authoritative complete-time fence (the backstop). The completion echoes
+ * the same {@code lease_fence}; the engine's {@code /complete} fences on it and
+ * rejects a stale/reclaimed lease with HTTP 409 , emitting NO
+ * {@code NodeCompleted}. The worker treats a 409 on complete as a lost-lease
+ * no-op: it does NOT {@code fail} the item (failing would clobber the work the
+ * new claimant is running). This mirrors the hardened Python worker exactly and
+ * guarantees correctness even if the heartbeat abort has not fired yet.
+ *
+ *
+ * Registry-gated dispatch — the RCE gate
+ * The worker never reflects on an attacker-influenceable string. Its reflective
+ * surface is a closed set: (a) the single fixed {@link ToolDispatcher} — the
+ * worker verifies the work item's {@code class}/{@code method} payload equals the
+ * known dispatch coordinate and fails cleanly otherwise, so a forged {@code java_fn}
+ * node naming an arbitrary class can never be {@code Class.forName}'d; and (b) the
+ * declared {@code @Tool} methods the dispatcher resolves by name through the
+ * {@link ToolRegistry}. No payload or model field becomes a class/method to load.
+ */
+public final class JavaToolWorker implements AutoCloseable {
+
+ /** The single queue this worker drains. */
+ public static final String JAVA_TOOL_QUEUE = "java_tool";
+
+ private static final Logger LOG = System.getLogger(JavaToolWorker.class.getName());
+ private static final Duration DEFAULT_HEARTBEAT = Duration.ofSeconds(10);
+ private static final Duration DEFAULT_POLL_BACKOFF = Duration.ofSeconds(2);
+
+ /** The outcome of processing a single claim. */
+ public enum ItemResult {
+ /** The queue was empty; nothing was claimed. */
+ EMPTY,
+ /** The tool ran and the completion was accepted by the engine. */
+ COMPLETED,
+ /** A genuine tool / dispatch / completion error; the item was failed. */
+ FAILED,
+ /**
+ * The item was abandoned without failing it: the lease was lost (heartbeat abort or
+ * a 409 on complete), or the worker was interrupted mid-dispatch (shutdown). A no-op,
+ * NOT a failure — the engine reclaims the item on lease expiry.
+ */
+ LOST_LEASE
+ }
+
+ private final JamjetEngineClient client;
+ private final String workerId;
+ private final ToolDispatcher dispatcher;
+ private final long heartbeatMillis;
+ private final long pollBackoffMillis;
+
+ private final ScheduledExecutorService heartbeats;
+ private final ExecutorService dispatchPool;
+ private volatile boolean stopped;
+
+ /** Build a worker with default heartbeat (10s) and poll backoff (2s). */
+ public JavaToolWorker(JamjetEngineClient client, String workerId, ToolRegistry registry) {
+ this(client, workerId, registry, DEFAULT_HEARTBEAT, DEFAULT_POLL_BACKOFF);
+ }
+
+ /**
+ * @param client the engine client (work-item protocol)
+ * @param workerId this worker's stable id (echoed on claim + heartbeat)
+ * @param registry the agent's tool registry — the ONLY callable tools
+ * @param heartbeatInterval lease-renewal interval while a tool runs
+ * @param pollBackoff sleep between empty claims in {@link #run()}
+ */
+ public JavaToolWorker(JamjetEngineClient client, String workerId, ToolRegistry registry,
+ Duration heartbeatInterval, Duration pollBackoff) {
+ this.client = Objects.requireNonNull(client, "client");
+ this.workerId = Objects.requireNonNull(workerId, "workerId");
+ this.dispatcher = new ToolDispatcher(Objects.requireNonNull(registry, "registry"));
+ this.heartbeatMillis = heartbeatInterval.toMillis();
+ this.pollBackoffMillis = pollBackoff.toMillis();
+ this.heartbeats = Executors.newSingleThreadScheduledExecutor(r -> {
+ Thread t = new Thread(r, "jamjet-java-tool-heartbeat");
+ t.setDaemon(true);
+ return t;
+ });
+ this.dispatchPool = Executors.newThreadPerTaskExecutor(Thread.ofVirtual().name("jamjet-java-tool-", 0).factory());
+ }
+
+ /** Request a clean stop of {@link #run()} (takes effect at the next loop boundary). */
+ public void stop() {
+ this.stopped = true;
+ }
+
+ /**
+ * Claim and process exactly one work item, then return its outcome. Returns
+ * {@link ItemResult#EMPTY} when the queue is empty. Mirrors the Python worker's
+ * {@code --once} mode.
+ */
+ public ItemResult runOnce() {
+ Optional claimed = client.claimWorkItem(workerId, List.of(JAVA_TOOL_QUEUE));
+ if (claimed.isEmpty()) {
+ return ItemResult.EMPTY;
+ }
+ return process(claimed.get());
+ }
+
+ /**
+ * Continuously claim and process {@code java_tool} items until {@link #stop()} is
+ * called or the thread is interrupted, backing off when the queue is empty. A poll
+ * error is logged and retried after the backoff (never fatal).
+ */
+ public void run() {
+ while (!stopped && !Thread.currentThread().isInterrupted()) {
+ try {
+ Optional claimed = client.claimWorkItem(workerId, List.of(JAVA_TOOL_QUEUE));
+ if (claimed.isEmpty()) {
+ if (sleep(pollBackoffMillis)) {
+ return; // interrupted
+ }
+ continue;
+ }
+ process(claimed.get());
+ } catch (Exception pollErr) {
+ LOG.log(Level.WARNING, () -> "poll error: " + pollErr);
+ if (sleep(pollBackoffMillis)) {
+ return;
+ }
+ }
+ }
+ }
+
+ // -- per-item processing ----------------------------------------------------
+
+ private ItemResult process(ClaimedWorkItem item) {
+ Map payload = item.payload() == null ? Map.of() : item.payload();
+
+ // RCE GATE (dispatch coordinate): the only java_fn coordinate the Agent builder
+ // emits is the fixed ToolDispatcher. A payload naming any other class/method is
+ // a forged/unknown node: fail cleanly, NEVER Class.forName a payload string.
+ String cls = String.valueOf(payload.get("class"));
+ String method = String.valueOf(payload.get("method"));
+ if (!ToolDispatcher.DISPATCH_CLASS.equals(cls) || !ToolDispatcher.DISPATCH_METHOD.equals(method)) {
+ String msg = "unsupported java_fn dispatch coordinate: " + cls + "#" + method
+ + " (only " + ToolDispatcher.DISPATCH_CLASS + "#" + ToolDispatcher.DISPATCH_METHOD + " is callable)";
+ LOG.log(Level.WARNING, () -> "rejecting work item " + item.id() + ": " + msg);
+ client.failWorkItem(item.id(), msg);
+ return ItemResult.FAILED;
+ }
+
+ Map input = asMap(payload.get("input"));
+ long fence = item.leaseFence() == null ? 0L : item.leaseFence();
+ AtomicBoolean leaseLost = new AtomicBoolean(false);
+
+ long startNanos = System.nanoTime();
+ Future> dispatchFuture = dispatchPool.submit(() -> dispatcher.dispatchToolCalls(input));
+ ScheduledFuture> heartbeat = scheduleHeartbeat(item, fence, leaseLost, dispatchFuture);
+ try {
+ Map output;
+ try {
+ output = dispatchFuture.get();
+ } catch (InterruptedException e) {
+ // Worker shutdown / cancellation (the WORKER thread was interrupted while
+ // waiting) is NOT a tool failure. Restore the interrupt and stop cleanly so
+ // the engine reclaims the item on lease expiry — never fail a reclaimable
+ // item (the same invariant as a lost lease). Best-effort: stop the in-flight
+ // tool so a side-effecting call doesn't keep running after we abandon it.
+ Thread.currentThread().interrupt();
+ dispatchFuture.cancel(true);
+ LOG.log(Level.INFO, () -> "worker interrupted mid-dispatch; abandoning item "
+ + item.id() + " for reclaim (not failing)");
+ return ItemResult.LOST_LEASE;
+ } catch (CancellationException | ExecutionException e) {
+ // Lease-lost gate FIRST: if the heartbeat aborted us (Future.cancel), this is
+ // a no-op, never a failure (the new claimant owns the item now).
+ if (leaseLost.get()) {
+ LOG.log(Level.INFO, () -> "lease lost mid-dispatch; aborting item " + item.id() + " (not completing)");
+ return ItemResult.LOST_LEASE;
+ }
+ // A genuine tool / dispatch failure -> fail the item (mirror Python). An
+ // ExecutionException here wraps the real tool error.
+ Throwable cause = (e instanceof ExecutionException) ? e.getCause() : e;
+ String err = cause == null ? String.valueOf(e) : String.valueOf(cause.getMessage() != null ? cause.getMessage() : cause);
+ LOG.log(Level.WARNING, () -> "tool dispatch failed for item " + item.id() + ": " + err);
+ client.failWorkItem(item.id(), err);
+ return ItemResult.FAILED;
+ }
+
+ // Dispatch succeeded. If the lease was lost during/just after it, do NOT
+ // complete (proactive M3 gate; the 409 backstop covers the residual race).
+ if (leaseLost.get()) {
+ LOG.log(Level.INFO, () -> "lease lost; not completing item " + item.id());
+ return ItemResult.LOST_LEASE;
+ }
+
+ long durationMs = (System.nanoTime() - startNanos) / 1_000_000L;
+ return complete(item, output, durationMs, fence);
+ } finally {
+ heartbeat.cancel(true);
+ }
+ }
+
+ /** Settle the item, threading the lease fence; a 409 is a lost-lease no-op. */
+ private ItemResult complete(ClaimedWorkItem item, Map output, long durationMs, long fence) {
+ // The dispatcher return ({"messages": [...]}) is BOTH the node output and the
+ // state_patch: the engine merge-patches it into state (top-level keys replaced),
+ // replacing state["messages"] for the next model turn. Identical to the Python
+ // python_tool worker (return-as-state_patch).
+ String genAiModel = stringOrNull(output.get("gen_ai_model"));
+ String finishReason = stringOrNull(output.get("finish_reason"));
+ try {
+ client.completeWorkItem(
+ item.id(), item.executionId(), item.nodeId(),
+ output, output, durationMs, genAiModel, finishReason,
+ // Echo the claim's fence so the engine fences this completion.
+ fence == 0L ? null : fence);
+ LOG.log(Level.DEBUG, () -> "completed item " + item.id() + " in " + durationMs + "ms");
+ return ItemResult.COMPLETED;
+ } catch (JamjetHttpException e) {
+ if (e.isConflict()) {
+ // 409: our fence no longer matches -> the lease was reclaimed and a NEW
+ // worker owns this item. Do NOT fail (that would clobber the reclaimed
+ // work). Treat the lost lease as a no-op. Mirrors the Python worker.
+ LOG.log(Level.INFO, () -> "completion rejected (409); lease lost for item " + item.id());
+ return ItemResult.LOST_LEASE;
+ }
+ // Any other completion error keeps the fail behavior (mirror Python re-raise).
+ LOG.log(Level.WARNING, () -> "completion failed for item " + item.id() + ": " + e.getMessage());
+ client.failWorkItem(item.id(), "complete failed: " + e.getMessage());
+ return ItemResult.FAILED;
+ }
+ }
+
+ /**
+ * Schedule the lease-renewal heartbeat. On a definitive engine rejection
+ * ({@code status >= 400}) it flags the lease lost and cancels the in-flight
+ * dispatch (M3); a transport-level blip is logged and tolerated.
+ */
+ private ScheduledFuture> scheduleHeartbeat(ClaimedWorkItem item, long fence,
+ AtomicBoolean leaseLost, Future> dispatchFuture) {
+ return heartbeats.scheduleAtFixedRate(() -> {
+ if (leaseLost.get()) {
+ return;
+ }
+ try {
+ // Echo the claim's fence (null only for a legacy unfenced claim), matching
+ // the nullable contract the complete path uses.
+ client.heartbeatWorkItem(item.id(), workerId, fence == 0L ? null : fence);
+ } catch (JamjetHttpException e) {
+ if (e.statusCode() >= 400) {
+ // Definitive engine rejection: the lease is gone (FenceLost -> 500,
+ // or a 4xx). Abort the in-flight tool and stop renewing (M3).
+ LOG.log(Level.WARNING, () -> "heartbeat rejected for item " + item.id()
+ + " (HTTP " + e.statusCode() + "); lease lost -> aborting dispatch");
+ leaseLost.set(true);
+ dispatchFuture.cancel(true);
+ } else {
+ // Transport-level blip (no HTTP status): tolerate; the complete-time
+ // fence is the authoritative backstop.
+ LOG.log(Level.WARNING, () -> "heartbeat transport error for item " + item.id() + ": " + e.getMessage());
+ }
+ } catch (Exception e) {
+ LOG.log(Level.WARNING, () -> "heartbeat error for item " + item.id() + ": " + e);
+ }
+ }, heartbeatMillis, heartbeatMillis, TimeUnit.MILLISECONDS);
+ }
+
+ // -- helpers ----------------------------------------------------------------
+
+ @SuppressWarnings("unchecked")
+ private static Map asMap(Object value) {
+ if (value instanceof Map, ?> m) {
+ return (Map) m;
+ }
+ return Map.of();
+ }
+
+ private static String stringOrNull(Object value) {
+ if (value == null) {
+ return null;
+ }
+ String s = String.valueOf(value);
+ return s.isBlank() ? null : s;
+ }
+
+ /** Sleep, returning true if interrupted (so the caller can exit cleanly). */
+ private static boolean sleep(long millis) {
+ try {
+ Thread.sleep(millis);
+ return false;
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return true;
+ }
+ }
+
+ @Override
+ public void close() {
+ stopped = true;
+ heartbeats.shutdownNow();
+ dispatchPool.shutdownNow();
+ }
+}
diff --git a/jamjet-agent/src/test/java/dev/jamjet/agent/AgentBuilderValidationTest.java b/jamjet-agent/src/test/java/dev/jamjet/agent/AgentBuilderValidationTest.java
new file mode 100644
index 0000000..7d88bb1
--- /dev/null
+++ b/jamjet-agent/src/test/java/dev/jamjet/agent/AgentBuilderValidationTest.java
@@ -0,0 +1,46 @@
+package dev.jamjet.agent;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Input-validation guards on the {@link Agent.Builder}: a non-positive timeout and a
+ * blank/empty model are rejected eagerly rather than producing a nonsensical IR.
+ */
+class AgentBuilderValidationTest {
+
+ @Test
+ void rejectsNonPositiveTimeoutSeconds() {
+ assertThatThrownBy(() -> Agent.builder("a").timeoutSeconds(0))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("timeoutSeconds");
+ assertThatThrownBy(() -> Agent.builder("a").timeoutSeconds(-1))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("timeoutSeconds");
+ }
+
+ @Test
+ void acceptsPositiveTimeoutSeconds() {
+ Agent agent = Agent.builder("a").model("anthropic/claude-sonnet-4-6")
+ .timeoutSeconds(120).build();
+ assertThat(agent.timeoutSeconds()).isEqualTo(120);
+ }
+
+ @Test
+ void rejectsBlankModel() {
+ assertThatThrownBy(() -> Agent.builder("a").model(" ").build())
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("model");
+ assertThatThrownBy(() -> Agent.builder("a").model("").build())
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("model");
+ }
+
+ @Test
+ void rejectsNullModel() {
+ assertThatThrownBy(() -> Agent.builder("a").build())
+ .isInstanceOf(NullPointerException.class);
+ }
+}
diff --git a/jamjet-agent/src/test/java/dev/jamjet/agent/AgentIrCompilerTest.java b/jamjet-agent/src/test/java/dev/jamjet/agent/AgentIrCompilerTest.java
new file mode 100644
index 0000000..981632f
--- /dev/null
+++ b/jamjet-agent/src/test/java/dev/jamjet/agent/AgentIrCompilerTest.java
@@ -0,0 +1,167 @@
+package dev.jamjet.agent;
+
+import dev.jamjet.runtime.core.JamjetJson;
+import dev.jamjet.runtime.core.ir.NodeKind;
+import dev.jamjet.runtime.core.ir.PolicySetIr;
+import dev.jamjet.runtime.core.ir.WorkflowIr;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Structural unit tests for {@link AgentIrCompiler} that do not require the Python
+ * golden: the static unroll shape, the M2 start-node invariant, the JavaFn tool
+ * nodes, governance omission, model-ref parsing, and content-version behaviour.
+ */
+class AgentIrCompilerTest {
+
+ private static Agent agent(int timeout, String instructions) {
+ return Agent.builder("a1")
+ .model("anthropic/claude-sonnet-4-6")
+ .instructions(instructions)
+ .tools(new TestTools.WebSearchTool())
+ .timeoutSeconds(timeout)
+ .build();
+ }
+
+ @Test
+ void rejectsNonPositiveMaxTurns() {
+ assertThatThrownBy(() -> agent(300, "hi").compileToIr(0))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("maxTurns");
+ }
+
+ @Test
+ void startNodeIsAlwaysFirstModelNode_M2() {
+ WorkflowIr ir = agent(300, "hi").compileToIr(3);
+ assertThat(ir.startNode()).isEqualTo("__model_0__");
+ // The start node must be a Model node, never a tool node.
+ assertThat(ir.node(ir.startNode()).kind()).isInstanceOf(NodeKind.Model.class);
+ }
+
+ @Test
+ void unrollEmitsThreeNodesPerTurnPlusFinalModel() {
+ WorkflowIr ir = agent(300, "hi").compileToIr(4);
+ // 4 turns * (model + gate + tools) + 1 final model = 13 nodes.
+ assertThat(ir.nodes()).hasSize(4 * 3 + 1);
+ assertThat(ir.node("__model_4__")).isNotNull(); // final model
+ assertThat(ir.node("__tools_3__").kind()).isInstanceOf(NodeKind.JavaFn.class);
+ assertThat(ir.node("__tools_4__")).isNull(); // no tools after the final model
+ }
+
+ @Test
+ void toolNodesAreJavaFnPointingAtTheFixedDispatcher() {
+ WorkflowIr ir = agent(300, "hi").compileToIr(2);
+ NodeKind.JavaFn tools = (NodeKind.JavaFn) ir.node("__tools_0__").kind();
+ assertThat(tools.className()).isEqualTo("dev.jamjet.agent.tools.ToolDispatcher");
+ assertThat(tools.method()).isEqualTo("dispatchToolCalls");
+ assertThat(tools.outputSchema()).isEmpty();
+ assertThat(tools.queueType().getValue()).isEqualTo("java_tool");
+ assertThat(ir.node("__tools_0__").retryPolicy()).isEqualTo("no_retry");
+ }
+
+ @Test
+ void perTurnModelsCarryToolSchemasFinalModelDoesNot() {
+ WorkflowIr ir = agent(300, "hi").compileToIr(2);
+ NodeKind.Model turn0 = (NodeKind.Model) ir.node("__model_0__").kind();
+ NodeKind.Model finalModel = (NodeKind.Model) ir.node("__model_2__").kind();
+ assertThat(turn0.tools()).hasSize(1);
+ assertThat(finalModel.tools()).isEmpty();
+ assertThat(ir.node("__model_0__").retryPolicy()).isEqualTo("llm_default");
+ }
+
+ @Test
+ void blankInstructionsYieldNullSystemPromptAndDefaultDescription() {
+ WorkflowIr ir = Agent.builder("a1").model("gpt-4o").tools().build().compileToIr(1);
+ NodeKind.Model m = (NodeKind.Model) ir.node("__model_0__").kind();
+ assertThat(m.systemPrompt()).isNull();
+ assertThat(ir.description()).isEqualTo("Durable agent: a1");
+ }
+
+ @Test
+ void litellmModelMirrorsPythonParseModelRef() {
+ // provider/rest lowercases the provider; bare strings pass through.
+ assertThat(AgentIrCompiler.litellmModel("anthropic/claude-sonnet-4-6"))
+ .isEqualTo("anthropic/claude-sonnet-4-6");
+ assertThat(AgentIrCompiler.litellmModel("Anthropic/Claude-Opus")).isEqualTo("anthropic/Claude-Opus");
+ assertThat(AgentIrCompiler.litellmModel("gpt-4o")).isEqualTo("gpt-4o");
+ assertThat(AgentIrCompiler.litellmModel(" openai/gpt-4o ")).isEqualTo("openai/gpt-4o");
+ }
+
+ @Test
+ void defaultMaxTurnsIsEight() {
+ WorkflowIr ir = agent(300, "hi").compileToIr();
+ assertThat(ir.node("__model_8__")).isNotNull(); // final answer node at maxTurns=8
+ assertThat(ir.node("__tools_7__")).isNotNull();
+ assertThat(ir.node("__tools_8__")).isNull();
+ }
+
+ @Test
+ void governanceFieldsOmittedWhenUnset() throws Exception {
+ // No policy, no budget, pii off -> none of the governance IR fields emit.
+ Agent bare = Agent.builder("a1").model("gpt-4o").tools(new TestTools.WebSearchTool())
+ .pii(false).build();
+ WorkflowIr ir = bare.compileToIr(1);
+ assertThat(ir.policy()).isNull();
+ assertThat(ir.tokenBudget()).isNull();
+ assertThat(ir.costBudgetUsd()).isNull();
+ assertThat(ir.dataPolicy()).isNull();
+
+ String json = JamjetJson.shared().writeValueAsString(ir);
+ assertThat(json).doesNotContain("\"policy\"");
+ assertThat(json).doesNotContain("\"cost_budget_usd\"");
+ assertThat(json).doesNotContain("\"token_budget\"");
+ assertThat(json).doesNotContain("\"data_policy\"");
+ }
+
+ @Test
+ void approvalAllSetsWildcardRequireApproval() {
+ Agent a = Agent.builder("a1").model("gpt-4o").tools(new TestTools.WebSearchTool())
+ .approvalRequired(true).build();
+ PolicySetIr policy = a.compileToIr(1).policy();
+ assertThat(policy).isNotNull();
+ assertThat(policy.requireApprovalFor()).containsExactly("*");
+ }
+
+ @Test
+ void approvalGlobsUnionWithInlinePolicyPreservingOrder() {
+ Agent a = Agent.builder("a1").model("gpt-4o").tools(new TestTools.WebSearchTool())
+ .policy(new PolicySetIr(List.of(), List.of("wire_*"), List.of()))
+ .approvalRequired(List.of("delete_*", "wire_*")) // wire_* is a dup
+ .build();
+ PolicySetIr policy = a.compileToIr(1).policy();
+ // Union preserves the policy's order first, then new globs; dedups wire_*.
+ assertThat(policy.requireApprovalFor()).containsExactly("wire_*", "delete_*");
+ }
+
+ @Test
+ void emittedIrRoundTripsThroughTypedPort() throws Exception {
+ // The emitted agent IR must survive a serialize -> deserialize through the
+ // same typed WorkflowIr port the engine uses, with JavaFn tool nodes and
+ // tool-carrying Model nodes intact.
+ WorkflowIr ir = agent(300, "hi").compileToIr(2);
+ String json = JamjetJson.shared().writeValueAsString(ir);
+ WorkflowIr back = WorkflowIr.fromJson(json);
+
+ assertThat(back.startNode()).isEqualTo("__model_0__");
+ assertThat(back.nodes()).hasSize(7);
+ assertThat(back.node("__tools_0__").kind()).isInstanceOf(NodeKind.JavaFn.class);
+ NodeKind.Model m = (NodeKind.Model) back.node("__model_0__").kind();
+ assertThat(m.tools()).hasSize(1);
+ }
+
+ @Test
+ void contentVersionIsDeterministicAndContentSensitive() {
+ String v1 = agent(300, "hello").compileToIr(2).version();
+ String v1Again = agent(300, "hello").compileToIr(2).version();
+ String vDifferentInstructions = agent(300, "different").compileToIr(2).version();
+ String vDifferentTurns = agent(300, "hello").compileToIr(3).version();
+
+ assertThat(v1).startsWith("0.1.0+").isEqualTo(v1Again); // same agent -> same version
+ assertThat(vDifferentInstructions).isNotEqualTo(v1); // changed content -> new key
+ assertThat(vDifferentTurns).isNotEqualTo(v1); // changed unroll -> new key
+ }
+}
diff --git a/jamjet-agent/src/test/java/dev/jamjet/agent/AgentIrParityTest.java b/jamjet-agent/src/test/java/dev/jamjet/agent/AgentIrParityTest.java
new file mode 100644
index 0000000..b912345
--- /dev/null
+++ b/jamjet-agent/src/test/java/dev/jamjet/agent/AgentIrParityTest.java
@@ -0,0 +1,291 @@
+package dev.jamjet.agent;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import dev.jamjet.runtime.core.JamjetJson;
+import dev.jamjet.runtime.core.ir.PolicySetIr;
+import dev.jamjet.runtime.core.ir.WorkflowIr;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.io.InputStream;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.TreeSet;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * GOLDEN-FILE PARITY: the Java {@code Agent} builder must emit the SAME agent-loop
+ * graph the Python ADK emits, so both run on the identical Rust engine.
+ *
+ * The golden {@code golden/agent_loop_ir.python.json} is the output of the
+ * canonical Python {@code jamjet.compiler.agent_ir.compile_agent_to_ir} for a small
+ * fixture agent (model + two @tool functions + instructions + inline policy +
+ * approval globs + token/cost budget + pii) at {@code max_turns=2}. This test builds
+ * the structurally identical Java {@link Agent} and asserts every load-bearing field
+ * matches: node ids, edges, the gate condition, the Model nodes' tool schemas,
+ * governance (policy / budget / data_policy), turn count, and {@code start_node}.
+ *
+ *
Documented, expected differences (asserted explicitly below)
+ *
+ * Tool node kind : Python emits {@code python_fn{module,function,input}}
+ * pointing at {@code jamjet.agents.tool_runtime:dispatch_tool_calls}; Java emits
+ * {@code java_fn{class_name,method}} pointing at the fixed Java dispatcher. The
+ * Rust {@code JavaFn} struct has no {@code input} field (the scheduler enriches
+ * the payload from state), so the Java tool node carries no {@code input} block.
+ * Version : both are {@code "0.1.0+"+sha256(content)[:12]}, but the hash
+ * differs because the Java IR's content differs (java_fn vs python_fn).
+ * Per-node {@code description} : cosmetic human text the engine ignores;
+ * Java uses house punctuation. Excluded from the structural comparison.
+ * Gate {@code expression} : Python adds a redundant {@code expression}
+ * alongside {@code branches}; Java emits only {@code branches} (the engine reads
+ * branches and ignores expression).
+ * Explicit nulls : the snake_case {@code JamjetJson} mapper omits null
+ * fields (NON_NULL) where Python writes {@code "condition": null} etc.; both
+ * trees are null-stripped before comparison.
+ *
+ */
+class AgentIrParityTest {
+
+ private static final ObjectMapper M = JamjetJson.shared();
+
+ private static final List EXPECTED_NODE_IDS = List.of(
+ "__model_0__", "__tool_gate_0__", "__tools_0__",
+ "__model_1__", "__tool_gate_1__", "__tools_1__",
+ "__model_2__");
+
+ private static JsonNode py; // the Python golden IR
+ private static JsonNode jv; // the Java-emitted IR
+
+ @BeforeAll
+ static void compileBoth() throws Exception {
+ py = loadGolden();
+ jv = M.readTree(M.writeValueAsBytes(buildJavaAgent().compileToIr(2)));
+ }
+
+ /** The structurally identical Java agent matching the Python golden fixture. */
+ private static Agent buildJavaAgent() {
+ return Agent.builder("research_agent")
+ .model("anthropic/claude-sonnet-4-6")
+ .instructions("You are a helpful research assistant.")
+ .tools(new TestTools.WebSearchTool(), new TestTools.MathTool())
+ .policy(new PolicySetIr(
+ List.of("delete_db"), // blocked_tools
+ List.of(), // require_approval_for (from approval globs)
+ List.of("anthropic/claude-sonnet-4-6"))) // model_allowlist
+ .approvalRequired(List.of("delete_*"))
+ .budget(new Budget(100_000, 2.5))
+ .pii(true)
+ .build();
+ }
+
+ // -- structural parity (must match) -----------------------------------------
+
+ @Test
+ void sameTopLevelIdentityAndStartNode() {
+ for (String f : List.of("workflow_id", "name", "description", "state_schema", "start_node")) {
+ assertThat(jv.get(f)).as(f).isEqualTo(py.get(f));
+ }
+ // M2: the loop ALWAYS starts on the first Model node, never a tool node.
+ assertThat(jv.get("start_node").asText()).isEqualTo("__model_0__");
+ }
+
+ @Test
+ void sameNodeIdSet() {
+ Set pyIds = nodeIds(py);
+ Set jvIds = nodeIds(jv);
+ assertThat(jvIds).isEqualTo(pyIds);
+ assertThat(jvIds).containsExactlyInAnyOrderElementsOf(EXPECTED_NODE_IDS);
+ }
+
+ @Test
+ void sameTurnCount() {
+ // 2 model->tools turns + 1 final model node; max_turns label = "2".
+ assertThat(jv.get("labels").get("jamjet.agent.max_turns").asText()).isEqualTo("2");
+ assertThat(jv.get("labels").get("jamjet.agent.max_turns").asText())
+ .isEqualTo(py.get("labels").get("jamjet.agent.max_turns").asText());
+ long jvToolNodes = nodeIds(jv).stream().filter(id -> id.startsWith("__tools_")).count();
+ assertThat(jvToolNodes).isEqualTo(2);
+ }
+
+ @Test
+ void sameEdgesGraph() {
+ assertThat(edgeSet(jv)).isEqualTo(edgeSet(py));
+ // 9 edges: per turn {model->gate, gate->tools, gate->end, tools->model}, + final model->end.
+ assertThat(jv.get("edges")).hasSize(9);
+ }
+
+ @Test
+ void sameModelNodesIncludingToolSchemas() {
+ // The Model node kinds must be byte-identical: model_ref, prompt_ref,
+ // output_schema, system_prompt, AND the OpenAI tool schemas offered to the
+ // model. This is what makes "the same graph the engine runs" true.
+ for (String id : List.of("__model_0__", "__model_1__", "__model_2__")) {
+ JsonNode pk = canonicalize(py.get("nodes").get(id).get("kind"));
+ JsonNode jk = canonicalize(jv.get("nodes").get(id).get("kind"));
+ assertThat(jk).as("model kind %s", id).isEqualTo(pk);
+ }
+ // The per-turn models carry both tools; the final model carries none.
+ assertThat(jv.get("nodes").get("__model_0__").get("kind").get("tools")).hasSize(2);
+ assertThat(jv.get("nodes").get("__model_2__").get("kind").get("tools")).hasSize(0);
+ assertThat(jv.get("nodes").get("__model_0__").get("kind").get("model_ref").asText())
+ .isEqualTo("anthropic/claude-sonnet-4-6");
+ }
+
+ @Test
+ void sameGateBranches() {
+ for (String id : List.of("__tool_gate_0__", "__tool_gate_1__")) {
+ JsonNode pb = canonicalize(py.get("nodes").get(id).get("kind").get("branches"));
+ JsonNode jb = canonicalize(jv.get("nodes").get(id).get("kind").get("branches"));
+ assertThat(jb).as("gate branches %s", id).isEqualTo(pb);
+ }
+ // The condition the gate branches on.
+ assertThat(jv.get("nodes").get("__tool_gate_0__").get("kind")
+ .get("branches").get(0).get("condition").asText())
+ .isEqualTo("state.last_model_finish_reason == \"tool_calls\"");
+ }
+
+ @Test
+ void sameGovernanceFields() {
+ for (String f : List.of("policy", "cost_budget_usd", "token_budget", "data_policy")) {
+ assertThat(canonicalize(jv.get(f))).as(f).isEqualTo(canonicalize(py.get(f)));
+ }
+ // Spot-check the merged require_approval_for (the ["delete_*"] approval glob).
+ assertThat(jv.get("policy").get("require_approval_for").get(0).asText()).isEqualTo("delete_*");
+ assertThat(jv.get("token_budget").get("total_tokens").asInt()).isEqualTo(100_000);
+ assertThat(jv.get("cost_budget_usd").asDouble()).isEqualTo(2.5);
+ assertThat(jv.get("data_policy").get("pii_detectors")).hasSize(5);
+ }
+
+ @Test
+ void sameTimeoutsLabelsAndEmptyMaps() {
+ assertThat(canonicalize(jv.get("timeouts"))).isEqualTo(canonicalize(py.get("timeouts")));
+ assertThat(canonicalize(jv.get("labels"))).isEqualTo(canonicalize(py.get("labels")));
+ for (String f : List.of("retry_policies", "models", "tools", "mcp_servers", "remote_agents")) {
+ assertThat(jv.get(f)).as(f).isEqualTo(py.get(f));
+ }
+ }
+
+ // -- documented differences (asserted explicitly) ---------------------------
+
+ @Test
+ void toolNodesAreJavaFnNotPythonFn() {
+ for (String id : List.of("__tools_0__", "__tools_1__")) {
+ JsonNode pyTool = py.get("nodes").get(id);
+ JsonNode jvTool = jv.get("nodes").get(id);
+
+ // THE key difference: python_fn -> java_fn, with the Java dispatch ref.
+ assertThat(pyTool.get("kind").get("type").asText()).isEqualTo("python_fn");
+ assertThat(jvTool.get("kind").get("type").asText()).isEqualTo("java_fn");
+
+ assertThat(jvTool.get("kind").get("class_name").asText())
+ .isEqualTo("dev.jamjet.agent.tools.ToolDispatcher");
+ assertThat(jvTool.get("kind").get("method").asText()).isEqualTo("dispatchToolCalls");
+ assertThat(jvTool.get("kind").get("output_schema").asText()).isEmpty();
+
+ // The Rust JavaFn struct has no `input` field; the scheduler enriches the
+ // payload from state. Python's python_fn carries a redundant `input` block.
+ assertThat(jvTool.get("kind").has("input")).isFalse();
+ assertThat(pyTool.get("kind").has("input")).isTrue();
+
+ // Everything else about the tool node matches: id, retry policy, labels,
+ // and the surrounding edges (verified in sameEdgesGraph).
+ assertThat(jvTool.get("id").asText()).isEqualTo(pyTool.get("id").asText());
+ assertThat(jvTool.get("retry_policy").asText())
+ .isEqualTo(pyTool.get("retry_policy").asText())
+ .isEqualTo("no_retry");
+ assertThat(canonicalize(jvTool.get("labels"))).isEqualTo(canonicalize(pyTool.get("labels")));
+ }
+ }
+
+ @Test
+ void versionDiffersButBothAreContentHashed() {
+ assertThat(py.get("version").asText()).startsWith("0.1.0+");
+ assertThat(jv.get("version").asText()).startsWith("0.1.0+");
+ // The java_fn content yields a different hash than the python_fn content.
+ assertThat(jv.get("version").asText()).isNotEqualTo(py.get("version").asText());
+ }
+
+ // -- backstop: deep-equal of the whole graph after normalization ------------
+
+ @Test
+ void deepEqualAfterNormalization() {
+ // The strongest statement: once the documented differences are normalized
+ // away (version, per-node description, gate expression, the tool-node kind),
+ // the ENTIRE Java IR tree deep-equals the Python IR tree.
+ assertThat(normalize(jv)).isEqualTo(normalize(py));
+ }
+
+ // -- helpers ----------------------------------------------------------------
+
+ private static JsonNode loadGolden() throws Exception {
+ try (InputStream in = AgentIrParityTest.class.getResourceAsStream("/golden/agent_loop_ir.python.json")) {
+ assertThat(in).as("golden resource present").isNotNull();
+ return M.readTree(in);
+ }
+ }
+
+ private static Set nodeIds(JsonNode ir) {
+ Set ids = new TreeSet<>();
+ ir.get("nodes").fieldNames().forEachRemaining(ids::add);
+ return ids;
+ }
+
+ private static Set edgeSet(JsonNode ir) {
+ Set edges = new HashSet<>();
+ canonicalize(ir.get("edges")).forEach(edges::add);
+ return edges;
+ }
+
+ /** Recursively strip null-valued object fields (harmonize NON_NULL vs explicit null). */
+ private static JsonNode canonicalize(JsonNode node) {
+ if (node == null) {
+ return null;
+ }
+ if (node.isObject()) {
+ ObjectNode out = M.createObjectNode();
+ node.fields().forEachRemaining(e -> {
+ if (!e.getValue().isNull()) {
+ out.set(e.getKey(), canonicalize(e.getValue()));
+ }
+ });
+ return out;
+ }
+ if (node.isArray()) {
+ ArrayNode out = M.createArrayNode();
+ node.forEach(c -> out.add(canonicalize(c)));
+ return out;
+ }
+ return node;
+ }
+
+ /**
+ * Null-strip the whole IR and remove the documented-divergent fields so the
+ * remaining graph (identity / model nodes incl. tool schemas / gate branches /
+ * governance / edges / timeouts / labels) can be deep-compared. The tool-node
+ * {@code kind} legitimately differs (java_fn vs python_fn) and is masked with a
+ * sentinel so the rest of each tool node (id / retry_policy / labels) is still
+ * compared.
+ */
+ private static JsonNode normalize(JsonNode root) {
+ ObjectNode r = (ObjectNode) canonicalize(root);
+ r.remove("version");
+ ObjectNode nodes = (ObjectNode) r.get("nodes");
+ nodes.fields().forEachRemaining(e -> {
+ ObjectNode n = (ObjectNode) e.getValue();
+ n.remove("description"); // cosmetic; Java uses house punctuation
+ ObjectNode kind = (ObjectNode) n.get("kind");
+ if ("condition".equals(kind.path("type").asText())) {
+ kind.remove("expression"); // Python-only redundant metadata
+ }
+ if (e.getKey().startsWith("__tools_")) {
+ n.set("kind", M.createObjectNode().put("type", "__tool_dispatch__"));
+ }
+ });
+ return r;
+ }
+}
diff --git a/jamjet-agent/src/test/java/dev/jamjet/agent/AgentRunDurableTest.java b/jamjet-agent/src/test/java/dev/jamjet/agent/AgentRunDurableTest.java
new file mode 100644
index 0000000..6b951d2
--- /dev/null
+++ b/jamjet-agent/src/test/java/dev/jamjet/agent/AgentRunDurableTest.java
@@ -0,0 +1,243 @@
+package dev.jamjet.agent;
+
+import com.github.tomakehurst.wiremock.WireMockServer;
+import dev.jamjet.agent.client.JamjetEngineClient;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+
+import java.time.Duration;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath;
+import static com.github.tomakehurst.wiremock.client.WireMock.okJson;
+import static com.github.tomakehurst.wiremock.client.WireMock.post;
+import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
+import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options;
+import static com.github.tomakehurst.wiremock.stubbing.Scenario.STARTED;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Hermetic integration test for {@link Agent#runDurable} against a WireMock stub of the
+ * engine's bare routes ({@code /workflows}, {@code /executions}, {@code /executions/{id}}).
+ * Proves the B-4 durable-run contract end-to-end at the transport boundary:
+ *
+ *
+ * create → start → poll → extract — the workflow is created,
+ * the execution is started with the seeded {@code messages} + tool-resolver map and
+ * the content-hashed {@code workflow_version}, the run polls to a terminal state, and
+ * the final assistant text + tool-call trace are extracted into the
+ * {@link AgentResult}.
+ * terminal failure surfaces — a {@code failed} / {@code limit_exceeded}
+ * terminal raises a clear {@link AgentRunException} (mirrors the Python
+ * {@code RuntimeError}); never a hollow result.
+ * timeout surfaces — a run that never terminates raises
+ * {@link AgentRunTimeoutException} (mirrors the Python {@code TimeoutError}).
+ * last-assistant fallback — when {@code last_model_output} is absent the
+ * answer falls back to the last assistant message's content.
+ *
+ *
+ * The status strings are the real snake_case the Rust engine emits
+ * ({@code running} / {@code completed} / {@code failed} / {@code limit_exceeded};
+ * {@code runtime/core/src/workflow.rs}), not capitalized stubs.
+ */
+class AgentRunDurableTest {
+
+ WireMockServer wm;
+
+ @BeforeEach
+ void up() {
+ wm = new WireMockServer(options().dynamicPort());
+ wm.start();
+ }
+
+ @AfterEach
+ void down() {
+ wm.stop();
+ }
+
+ /** A small governed agent with two @Tool methods (mirrors the golden fixture). */
+ private static Agent demoAgent() {
+ return Agent.builder("research_agent")
+ .model("anthropic/claude-sonnet-4-6")
+ .instructions("You are a helpful research assistant.")
+ .tools(new TestTools.WebSearchTool(), new TestTools.MathTool())
+ .budget(new Budget(100_000, 2.5))
+ .build();
+ }
+
+ /** Run options pinned at the WireMock base URL with a fast poll + bounded timeout. */
+ private RunOptions fastOptions() {
+ return RunOptions.defaults()
+ .withMaxTurns(2)
+ .withRuntimeUrl(wm.baseUrl())
+ .withPollInterval(Duration.ofMillis(15))
+ .withTimeout(Duration.ofSeconds(5));
+ }
+
+ @Test
+ @Timeout(20)
+ void createStartPollExtractFinalAssistantText() {
+ Agent agent = demoAgent();
+ // The version runDurable will send on start is the content hash of the SAME IR.
+ String expectedVersion = agent.compileToIr(2).version();
+
+ wm.stubFor(post(urlEqualTo("/workflows"))
+ .willReturn(okJson("{\"workflow_id\":\"research_agent\",\"version\":\"" + expectedVersion + "\"}")));
+ wm.stubFor(post(urlEqualTo("/executions"))
+ .willReturn(okJson("{\"execution_id\":\"ex_99\",\"status\":\"running\"}")));
+
+ // First poll -> running, second poll -> completed (prove the loop actually polls).
+ wm.stubFor(get(urlEqualTo("/executions/ex_99")).inScenario("poll")
+ .whenScenarioStateIs(STARTED)
+ .willReturn(okJson("{\"execution_id\":\"ex_99\",\"status\":\"running\"}"))
+ .willSetStateTo("done"));
+ wm.stubFor(get(urlEqualTo("/executions/ex_99")).inScenario("poll")
+ .whenScenarioStateIs("done")
+ .willReturn(okJson(completedBody())));
+
+ AgentResult result;
+ try (var client = new JamjetEngineClient(wm.baseUrl())) {
+ result = agent.runDurable("add 2 and 3", client, fastOptions());
+ }
+
+ // The answer is the terminal current_state.last_model_output (the inline final turn).
+ assertThat(result.output()).isEqualTo("The sum of 2 and 3 is 5.");
+ // The tool-call trace is reconstructed from the accumulated messages.
+ assertThat(result.toolCalls()).hasSize(1);
+ AgentResult.ToolCall call = result.toolCalls().get(0);
+ assertThat(call.tool()).isEqualTo("add_numbers");
+ assertThat(call.input()).containsEntry("a", 2).containsEntry("b", 3);
+ assertThat(call.output()).isEqualTo("5");
+ // The raw terminal snapshot is carried through.
+ assertThat(result.terminalState().status()).isEqualTo("completed");
+
+ // CREATE wire shape: the compiled agent-loop IR, java_fn tool node, M2 start node.
+ wm.verify(postRequestedFor(urlEqualTo("/workflows"))
+ .withRequestBody(matchingJsonPath("$.ir.workflow_id", equalTo("research_agent")))
+ .withRequestBody(matchingJsonPath("$.ir.start_node", equalTo("__model_0__")))
+ .withRequestBody(matchingJsonPath("$.ir.nodes.__tools_0__.kind.type", equalTo("java_fn")))
+ .withRequestBody(matchingJsonPath("$.ir.nodes.__tools_0__.kind.class_name",
+ equalTo("dev.jamjet.agent.tools.ToolDispatcher"))));
+ // START wire shape: workflow_version = the content hash; the seeded user prompt +
+ // tool-resolver map are the execution input (build_initial_state).
+ wm.verify(postRequestedFor(urlEqualTo("/executions"))
+ .withRequestBody(matchingJsonPath("$.workflow_id", equalTo("research_agent")))
+ .withRequestBody(matchingJsonPath("$.workflow_version", equalTo(expectedVersion)))
+ .withRequestBody(matchingJsonPath("$.input.messages[0].role", equalTo("system")))
+ .withRequestBody(matchingJsonPath("$.input.messages[1].role", equalTo("user")))
+ .withRequestBody(matchingJsonPath("$.input.messages[1].content", equalTo("add 2 and 3")))
+ .withRequestBody(matchingJsonPath("$.input.tools.add_numbers")));
+ }
+
+ @Test
+ @Timeout(20)
+ void terminalFailedSurfacesAsAgentRunException() {
+ stubCreateAndStart();
+ wm.stubFor(get(urlEqualTo("/executions/ex_99"))
+ .willReturn(okJson("{\"execution_id\":\"ex_99\",\"status\":\"failed\","
+ + "\"current_state\":{\"error\":\"model adapter exploded\"}}")));
+
+ try (var client = new JamjetEngineClient(wm.baseUrl())) {
+ assertThatThrownBy(() -> demoAgent().runDurable("hi", client, fastOptions()))
+ .isInstanceOf(AgentRunException.class)
+ .hasMessageContaining("non-completed")
+ .hasMessageContaining("model adapter exploded")
+ .extracting(e -> ((AgentRunException) e).status())
+ .isEqualTo("failed");
+ }
+ }
+
+ @Test
+ @Timeout(20)
+ void terminalLimitExceededSurfacesAsAgentRunException() {
+ // A budget breach is a terminal limit_exceeded — it must surface loudly, not as a result.
+ stubCreateAndStart();
+ wm.stubFor(get(urlEqualTo("/executions/ex_99"))
+ .willReturn(okJson("{\"execution_id\":\"ex_99\",\"status\":\"limit_exceeded\"}")));
+
+ try (var client = new JamjetEngineClient(wm.baseUrl())) {
+ assertThatThrownBy(() -> demoAgent().runDurable("hi", client, fastOptions()))
+ .isInstanceOf(AgentRunException.class)
+ .extracting(e -> ((AgentRunException) e).status())
+ .isEqualTo("limit_exceeded");
+ }
+ }
+
+ @Test
+ @Timeout(20)
+ void neverTerminalRaisesTimeout() {
+ stubCreateAndStart();
+ wm.stubFor(get(urlEqualTo("/executions/ex_99"))
+ .willReturn(okJson("{\"execution_id\":\"ex_99\",\"status\":\"running\"}")));
+
+ RunOptions tinyTimeout = fastOptions().withTimeout(Duration.ofMillis(120));
+ try (var client = new JamjetEngineClient(wm.baseUrl())) {
+ assertThatThrownBy(() -> demoAgent().runDurable("hi", client, tinyTimeout))
+ .isInstanceOf(AgentRunTimeoutException.class)
+ .extracting(e -> ((AgentRunException) e).status())
+ .isEqualTo("running");
+ }
+ }
+
+ @Test
+ @Timeout(20)
+ void twoArgOverloadBuildsAndClosesItsOwnClient() {
+ // The common public path: runDurable(prompt, options) builds + closes its own
+ // JamjetEngineClient for options.runtimeUrl(). Point it at WireMock end-to-end.
+ stubCreateAndStart();
+ wm.stubFor(get(urlEqualTo("/executions/ex_99"))
+ .willReturn(okJson("{\"execution_id\":\"ex_99\",\"status\":\"completed\","
+ + "\"current_state\":{\"last_model_output\":\"done\"}}")));
+
+ AgentResult result = demoAgent().runDurable("hi", fastOptions());
+
+ assertThat(result.output()).isEqualTo("done");
+ wm.verify(postRequestedFor(urlEqualTo("/workflows")));
+ wm.verify(postRequestedFor(urlEqualTo("/executions")));
+ }
+
+ @Test
+ @Timeout(20)
+ void extractsFromLastAssistantMessageWhenNoLastModelOutput() {
+ stubCreateAndStart();
+ // No last_model_output; the answer must fall back to the last assistant message.
+ wm.stubFor(get(urlEqualTo("/executions/ex_99"))
+ .willReturn(okJson("{\"execution_id\":\"ex_99\",\"status\":\"completed\","
+ + "\"current_state\":{\"messages\":["
+ + "{\"role\":\"user\",\"content\":\"hi\"},"
+ + "{\"role\":\"assistant\",\"content\":\"fallback answer\"}]}}")));
+
+ try (var client = new JamjetEngineClient(wm.baseUrl())) {
+ AgentResult result = demoAgent().runDurable("hi", client, fastOptions());
+ assertThat(result.output()).isEqualTo("fallback answer");
+ assertThat(result.toolCalls()).isEmpty();
+ }
+ }
+
+ // -- helpers ----------------------------------------------------------------
+
+ private void stubCreateAndStart() {
+ wm.stubFor(post(urlEqualTo("/workflows"))
+ .willReturn(okJson("{\"workflow_id\":\"research_agent\",\"version\":\"v1\"}")));
+ wm.stubFor(post(urlEqualTo("/executions"))
+ .willReturn(okJson("{\"execution_id\":\"ex_99\",\"status\":\"running\"}")));
+ }
+
+ /** A terminal completed snapshot: an inline last_model_output + a tool-interleaved thread. */
+ private static String completedBody() {
+ return "{\"execution_id\":\"ex_99\",\"status\":\"completed\",\"current_state\":{"
+ + "\"last_model_output\":\"The sum of 2 and 3 is 5.\","
+ + "\"messages\":["
+ + "{\"role\":\"system\",\"content\":\"You are a helpful research assistant.\"},"
+ + "{\"role\":\"user\",\"content\":\"add 2 and 3\"},"
+ + "{\"role\":\"assistant\",\"content\":\"\",\"tool_calls\":[{\"id\":\"tc1\",\"type\":\"function\","
+ + "\"function\":{\"name\":\"add_numbers\",\"arguments\":\"{\\\"a\\\":2,\\\"b\\\":3}\"}}]},"
+ + "{\"role\":\"tool\",\"tool_call_id\":\"tc1\",\"name\":\"add_numbers\",\"content\":\"5\"}"
+ + "]}}";
+ }
+}
diff --git a/jamjet-agent/src/test/java/dev/jamjet/agent/BudgetTest.java b/jamjet-agent/src/test/java/dev/jamjet/agent/BudgetTest.java
new file mode 100644
index 0000000..abf3eb2
--- /dev/null
+++ b/jamjet-agent/src/test/java/dev/jamjet/agent/BudgetTest.java
@@ -0,0 +1,47 @@
+package dev.jamjet.agent;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Validation guards on {@link Budget}: besides the existing non-positive checks, an
+ * entirely-empty budget (no tokens AND no cost) and a non-finite {@code costUsd}
+ * (NaN / Infinity) are rejected — a NaN would otherwise slip past {@code costUsd <= 0}.
+ */
+class BudgetTest {
+
+ @Test
+ void rejectsEntirelyEmptyBudget() {
+ assertThatThrownBy(() -> new Budget(null, null))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void rejectsNonFiniteCostUsd() {
+ assertThatThrownBy(() -> new Budget(null, Double.NaN))
+ .isInstanceOf(IllegalArgumentException.class);
+ assertThatThrownBy(() -> new Budget(null, Double.POSITIVE_INFINITY))
+ .isInstanceOf(IllegalArgumentException.class);
+ assertThatThrownBy(() -> new Budget(1_000, Double.NaN))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void stillRejectsNonPositiveValues() {
+ assertThatThrownBy(() -> new Budget(0, null)).isInstanceOf(IllegalArgumentException.class);
+ assertThatThrownBy(() -> new Budget(null, 0.0)).isInstanceOf(IllegalArgumentException.class);
+ assertThatThrownBy(() -> new Budget(-1, null)).isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void acceptsValidBudgets() {
+ assertThatCode(() -> new Budget(100_000, null)).doesNotThrowAnyException();
+ assertThatCode(() -> new Budget(null, 2.5)).doesNotThrowAnyException();
+ assertThatCode(() -> new Budget(100_000, 2.5)).doesNotThrowAnyException();
+ assertThat(Budget.ofTokens(50_000).tokens()).isEqualTo(50_000);
+ assertThat(Budget.ofCostUsd(0.5).costUsd()).isEqualTo(0.5);
+ }
+}
diff --git a/jamjet-agent/src/test/java/dev/jamjet/agent/RunOptionsValidationTest.java b/jamjet-agent/src/test/java/dev/jamjet/agent/RunOptionsValidationTest.java
new file mode 100644
index 0000000..d525fb0
--- /dev/null
+++ b/jamjet-agent/src/test/java/dev/jamjet/agent/RunOptionsValidationTest.java
@@ -0,0 +1,42 @@
+package dev.jamjet.agent;
+
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Validation guards on {@link RunOptions}: the compact constructor already rejects a
+ * non-positive {@code pollInterval}; it must equally reject a zero/negative
+ * {@code timeout} (a {@code null} timeout is still allowed — it derives from the agent).
+ */
+class RunOptionsValidationTest {
+
+ @Test
+ void rejectsZeroTimeout() {
+ assertThatThrownBy(() -> RunOptions.defaults().withTimeout(Duration.ZERO))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("timeout");
+ }
+
+ @Test
+ void rejectsNegativeTimeout() {
+ assertThatThrownBy(() -> RunOptions.defaults().withTimeout(Duration.ofSeconds(-1)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("timeout");
+ }
+
+ @Test
+ void allowsNullTimeout() {
+ // null timeout = derive the deadline from the agent's timeoutSeconds.
+ assertThatCode(() -> RunOptions.defaults().withTimeout(null)).doesNotThrowAnyException();
+ }
+
+ @Test
+ void allowsPositiveTimeout() {
+ assertThatCode(() -> RunOptions.defaults().withTimeout(Duration.ofSeconds(5)))
+ .doesNotThrowAnyException();
+ }
+}
diff --git a/jamjet-agent/src/test/java/dev/jamjet/agent/TestTools.java b/jamjet-agent/src/test/java/dev/jamjet/agent/TestTools.java
new file mode 100644
index 0000000..452b427
--- /dev/null
+++ b/jamjet-agent/src/test/java/dev/jamjet/agent/TestTools.java
@@ -0,0 +1,44 @@
+package dev.jamjet.agent;
+
+/**
+ * Tool-holder fixtures shared by the agent tests. The two tools mirror the Python
+ * golden fixture used to generate {@code golden/agent_loop_ir.python.json}:
+ * {@code web_search(query: str) -> str} and {@code add_numbers(a: int, b: int) -> str}.
+ */
+public final class TestTools {
+
+ private TestTools() {}
+
+ /** One @Tool method: {@code web_search(query)}. */
+ public static final class WebSearchTool {
+ @Tool(name = "web_search", description = "Search the web for a query.")
+ public String webSearch(String query) {
+ return "results for " + query;
+ }
+ }
+
+ /** One @Tool method: {@code add_numbers(a, b)}. */
+ public static final class MathTool {
+ @Tool(name = "add_numbers", description = "Add two integers.")
+ public String addNumbers(int a, int b) {
+ return String.valueOf(a + b);
+ }
+ }
+
+ /** Two @Tool methods in one holder, plus a non-tool method, for ordering tests. */
+ public static final class MultiTool {
+ @Tool(description = "Z tool.")
+ public String zebra(String s) {
+ return s;
+ }
+
+ @Tool(description = "A tool.")
+ public boolean alpha(boolean flag) {
+ return flag;
+ }
+
+ public String notATool(String ignored) {
+ return ignored;
+ }
+ }
+}
diff --git a/jamjet-agent/src/test/java/dev/jamjet/agent/client/JamjetEngineClientTest.java b/jamjet-agent/src/test/java/dev/jamjet/agent/client/JamjetEngineClientTest.java
new file mode 100644
index 0000000..7124e88
--- /dev/null
+++ b/jamjet-agent/src/test/java/dev/jamjet/agent/client/JamjetEngineClientTest.java
@@ -0,0 +1,223 @@
+package dev.jamjet.agent.client;
+
+import com.github.tomakehurst.wiremock.WireMockServer;
+import com.github.tomakehurst.wiremock.verification.LoggedRequest;
+import dev.jamjet.runtime.core.ir.NodeDef;
+import dev.jamjet.runtime.core.ir.NodeKind;
+import dev.jamjet.runtime.core.ir.WorkflowIr;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath;
+import static com.github.tomakehurst.wiremock.client.WireMock.ok;
+import static com.github.tomakehurst.wiremock.client.WireMock.okJson;
+import static com.github.tomakehurst.wiremock.client.WireMock.post;
+import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
+import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options;
+import static com.github.tomakehurst.wiremock.stubbing.Scenario.STARTED;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.catchThrowableOfType;
+
+/**
+ * Hermetic round-trip proof for {@link JamjetEngineClient} against a WireMock stub
+ * of the engine's BARE routes. Asserts both the typed results AND the exact request
+ * bodies/headers WireMock received, so the test proves the wire shape (snake_case
+ * keys, the lease fence, bare paths), not just that the code runs.
+ */
+class JamjetEngineClientTest {
+
+ WireMockServer wm;
+
+ @BeforeEach
+ void up() {
+ wm = new WireMockServer(options().dynamicPort());
+ wm.start();
+ }
+
+ @AfterEach
+ void down() {
+ wm.stop();
+ }
+
+ /** A minimal agent-loop-shaped IR: a single Model start node (never a tool node). */
+ private static WorkflowIr demoIr() {
+ var model = new NodeKind.Model("gpt-4o", null, null, "you are helpful");
+ var node = new NodeDef("model_0", model, null, null, null, null, null, null);
+ return new WorkflowIr(
+ "demo", "v1", "demo agent", null, null,
+ "model_0", // startNode = the Model node (M2)
+ Map.of("model_0", node),
+ List.of(),
+ null, null, null, null, null, null, null,
+ null, null, null, null, null);
+ }
+
+ @Test
+ void createStartPollToCompleted() {
+ wm.stubFor(post(urlEqualTo("/workflows"))
+ .willReturn(okJson("{\"workflow_id\":\"wf_123\",\"version\":\"v1\"}")));
+ wm.stubFor(post(urlEqualTo("/executions"))
+ .willReturn(okJson("{\"execution_id\":\"ex_123\",\"status\":\"Running\"}")));
+ // First poll -> Running, second poll -> Completed (WireMock scenario transition).
+ wm.stubFor(get(urlEqualTo("/executions/ex_123")).inScenario("poll")
+ .whenScenarioStateIs(STARTED)
+ .willReturn(okJson("{\"execution_id\":\"ex_123\",\"status\":\"Running\"}"))
+ .willSetStateTo("done"));
+ wm.stubFor(get(urlEqualTo("/executions/ex_123")).inScenario("poll")
+ .whenScenarioStateIs("done")
+ .willReturn(okJson(
+ "{\"execution_id\":\"ex_123\",\"status\":\"Completed\","
+ + "\"current_state\":{\"answer\":\"42\"}}")));
+
+ var client = new JamjetEngineClient(wm.baseUrl(), "tkn", "t1");
+
+ CreateWorkflowResult wf = client.createWorkflow(demoIr());
+ assertThat(wf.workflowId()).isEqualTo("wf_123");
+ assertThat(wf.version()).isEqualTo("v1");
+
+ StartExecutionResult exec = client.startExecution("wf_123", Map.of("prompt", "hello"), "v1");
+ assertThat(exec.executionId()).isEqualTo("ex_123");
+
+ ExecutionState state = null;
+ for (int i = 0; i < 10; i++) {
+ state = client.getExecution("ex_123");
+ if ("Completed".equals(state.status())) {
+ break;
+ }
+ }
+ assertThat(state).isNotNull();
+ assertThat(state.status()).isEqualTo("Completed");
+ assertThat(state.currentState()).containsEntry("answer", "42");
+
+ // Wire shape: bare path, auth + tenant headers, typed IR serialized snake_case.
+ wm.verify(postRequestedFor(urlEqualTo("/workflows"))
+ .withHeader("Authorization", equalTo("Bearer tkn"))
+ .withHeader("X-Tenant-Id", equalTo("t1"))
+ .withRequestBody(matchingJsonPath("$.ir.workflow_id", equalTo("demo")))
+ .withRequestBody(matchingJsonPath("$.ir.start_node", equalTo("model_0"))));
+ // Wire shape: the version key is workflow_version (not version), input nested.
+ wm.verify(postRequestedFor(urlEqualTo("/executions"))
+ .withRequestBody(matchingJsonPath("$.workflow_id", equalTo("wf_123")))
+ .withRequestBody(matchingJsonPath("$.workflow_version", equalTo("v1")))
+ .withRequestBody(matchingJsonPath("$.input.prompt", equalTo("hello"))));
+ }
+
+ @Test
+ void claimThreadsLeaseFenceThroughHeartbeatAndComplete() {
+ wm.stubFor(post(urlEqualTo("/work-items/claim")).willReturn(okJson(
+ "{\"claimed\":true,\"work_item\":{"
+ + "\"id\":\"wi_1\",\"execution_id\":\"ex_123\",\"node_id\":\"tool_0\","
+ + "\"queue_type\":\"java_tool\","
+ + "\"payload\":{\"class\":\"C\",\"method\":\"m\",\"input\":{\"x\":1}},"
+ + "\"attempt\":1,\"lease_fence\":7}}")));
+ wm.stubFor(post(urlEqualTo("/work-items/wi_1/heartbeat")).willReturn(ok()));
+ wm.stubFor(post(urlEqualTo("/work-items/wi_1/complete")).willReturn(ok()));
+ wm.stubFor(post(urlEqualTo("/work-items/wi_1/fail")).willReturn(ok()));
+
+ var client = new JamjetEngineClient(wm.baseUrl());
+
+ Optional claimed = client.claimWorkItem("w1", List.of("java_tool"));
+ assertThat(claimed).isPresent();
+ ClaimedWorkItem wi = claimed.get();
+ assertThat(wi.id()).isEqualTo("wi_1");
+ assertThat(wi.executionId()).isEqualTo("ex_123");
+ assertThat(wi.nodeId()).isEqualTo("tool_0");
+ assertThat(wi.queueType()).isEqualTo("java_tool");
+ assertThat(wi.attempt()).isEqualTo(1);
+ assertThat(wi.leaseFence()).isEqualTo(7L);
+ assertThat(wi.payload()).containsEntry("method", "m");
+
+ client.heartbeatWorkItem(wi.id(), "w1", wi.leaseFence());
+ client.completeWorkItem(wi.id(), wi.executionId(), wi.nodeId(),
+ Map.of("result", "ok"), Map.of("done", true), 12L, null, "stop", wi.leaseFence());
+ client.failWorkItem(wi.id(), "boom");
+
+ wm.verify(postRequestedFor(urlEqualTo("/work-items/claim"))
+ .withRequestBody(matchingJsonPath("$.worker_id", equalTo("w1")))
+ .withRequestBody(matchingJsonPath("$.queue_types[0]", equalTo("java_tool"))));
+ // The fence is echoed on heartbeat...
+ wm.verify(postRequestedFor(urlEqualTo("/work-items/wi_1/heartbeat"))
+ .withRequestBody(matchingJsonPath("$.worker_id", equalTo("w1")))
+ .withRequestBody(matchingJsonPath("$.lease_fence", equalTo("7"))));
+ // ...and in the complete body (the fenced completion path).
+ wm.verify(postRequestedFor(urlEqualTo("/work-items/wi_1/complete"))
+ .withRequestBody(matchingJsonPath("$.lease_fence", equalTo("7")))
+ .withRequestBody(matchingJsonPath("$.execution_id", equalTo("ex_123")))
+ .withRequestBody(matchingJsonPath("$.node_id", equalTo("tool_0")))
+ .withRequestBody(matchingJsonPath("$.duration_ms", equalTo("12")))
+ .withRequestBody(matchingJsonPath("$.finish_reason", equalTo("stop")))
+ .withRequestBody(matchingJsonPath("$.output.result", equalTo("ok")))
+ .withRequestBody(matchingJsonPath("$.state_patch.done", equalTo("true"))));
+ wm.verify(postRequestedFor(urlEqualTo("/work-items/wi_1/fail"))
+ .withRequestBody(matchingJsonPath("$.error", equalTo("boom"))));
+ }
+
+ @Test
+ void emptyQueueClaimReturnsEmpty() {
+ wm.stubFor(post(urlEqualTo("/work-items/claim"))
+ .willReturn(okJson("{\"claimed\":false}")));
+
+ var client = new JamjetEngineClient(wm.baseUrl());
+
+ assertThat(client.claimWorkItem("w1", List.of("java_tool"))).isEmpty();
+ }
+
+ @Test
+ void completeOmitsLeaseFenceWhenNull() {
+ wm.stubFor(post(urlEqualTo("/work-items/wi_nf/complete")).willReturn(ok()));
+
+ var client = new JamjetEngineClient(wm.baseUrl());
+ client.completeWorkItem("wi_nf", "ex_1", "n1",
+ Map.of("r", "ok"), Map.of(), 1L, null, null, null);
+
+ List reqs = wm.findAll(postRequestedFor(urlEqualTo("/work-items/wi_nf/complete")));
+ assertThat(reqs).hasSize(1);
+ String body = reqs.get(0).getBodyAsString();
+ assertThat(body).doesNotContain("lease_fence");
+ assertThat(body).contains("output");
+ assertThat(body).contains("duration_ms");
+ }
+
+ @Test
+ void heartbeatOmitsLeaseFenceWhenNull() {
+ wm.stubFor(post(urlEqualTo("/work-items/wi_nf/heartbeat")).willReturn(ok()));
+
+ var client = new JamjetEngineClient(wm.baseUrl());
+ // A null fence (legacy unfenced claim) must be omitted from the body, mirroring the
+ // nullable contract of completeWorkItem.
+ client.heartbeatWorkItem("wi_nf", "w1", null);
+
+ List reqs = wm.findAll(postRequestedFor(urlEqualTo("/work-items/wi_nf/heartbeat")));
+ assertThat(reqs).hasSize(1);
+ String body = reqs.get(0).getBodyAsString();
+ assertThat(body).doesNotContain("lease_fence");
+ assertThat(body).contains("worker_id");
+ }
+
+ @Test
+ void staleFenceCompleteSurfacesAsConflict() {
+ wm.stubFor(post(urlEqualTo("/work-items/wi_stale/complete"))
+ .willReturn(aResponse().withStatus(409).withBody("{\"error\":\"stale lease fence\"}")));
+
+ var client = new JamjetEngineClient(wm.baseUrl());
+
+ JamjetHttpException ex = catchThrowableOfType(
+ JamjetHttpException.class,
+ () -> client.completeWorkItem("wi_stale", "ex_1", "n1",
+ Map.of("r", "ok"), Map.of(), 1L, null, null, 1L));
+
+ assertThat(ex).isNotNull();
+ assertThat(ex.statusCode()).isEqualTo(409);
+ assertThat(ex.isConflict()).isTrue();
+ assertThat(ex.body()).contains("stale");
+ }
+}
diff --git a/jamjet-agent/src/test/java/dev/jamjet/agent/examples/CalculatorAgentExample.java b/jamjet-agent/src/test/java/dev/jamjet/agent/examples/CalculatorAgentExample.java
new file mode 100644
index 0000000..8086c0b
--- /dev/null
+++ b/jamjet-agent/src/test/java/dev/jamjet/agent/examples/CalculatorAgentExample.java
@@ -0,0 +1,86 @@
+package dev.jamjet.agent.examples;
+
+import dev.jamjet.agent.Agent;
+import dev.jamjet.agent.AgentResult;
+import dev.jamjet.agent.Budget;
+import dev.jamjet.agent.RunOptions;
+import dev.jamjet.agent.client.JamjetEngineClient;
+import dev.jamjet.agent.worker.JavaToolWorker;
+import dev.jamjet.runtime.core.ir.PolicySetIr;
+
+import java.util.List;
+
+/**
+ * End-to-end example: a governed, durable calculator agent authored in idiomatic Java.
+ *
+ * {@link #buildAgent()} is the whole authoring surface — a {@link Agent} with a model,
+ * instructions, one {@code @Tool} ({@link CalculatorTools#calculate}), and governance
+ * defaults (a token+cost {@link Budget}, an inline {@link PolicySetIr} with a blocked
+ * tool + a model allowlist, and an approval gate). It compiles to the SAME agent-loop
+ * {@code WorkflowIr} the Python ADK emits, so it runs on the identical Rust engine; the
+ * governance knobs ride in the IR and the engine enforces them fail-closed.
+ *
+ *
Running it for real ({@link #main})
+ * A durable run needs three services live (mirrors the Python {@code agent.run_durable}):
+ *
+ * the JamJet engine ({@code jamjet-server}) at {@link RunOptions#runtimeUrl()}
+ * — it owns the {@code java_tool} queue;
+ * the model sidecar ({@code JAMJET_MODEL_SEAM_URL}) — the engine routes every
+ * governed model call through it;
+ * a {@link JavaToolWorker} draining the {@code java_tool} queue with this
+ * agent's tool registry, so {@code calculate} executes durably exactly-once.
+ *
+ * {@code main} starts the worker on a background thread and shares ONE
+ * {@link JamjetEngineClient} between the worker and the run. (The hermetic
+ * {@code CalculatorAgentExampleTest} drives this same agent against a WireMock stub of
+ * the engine, since a live engine + sidecar is not available in CI.)
+ *
+ * This example uses only the real shipped API — no fictional methods.
+ */
+public final class CalculatorAgentExample {
+
+ private CalculatorAgentExample() {}
+
+ /** The complete authoring surface: a governed, tool-using durable agent in idiomatic Java. */
+ public static Agent buildAgent() {
+ return Agent.builder("calculator_agent")
+ .model("anthropic/claude-sonnet-4-6")
+ .instructions("You are a precise calculator. Use the calculate tool for every arithmetic step.")
+ .tools(new CalculatorTools())
+ // -- governance: compiled into the IR, enforced fail-closed by the engine --
+ .budget(new Budget(50_000, 0.50)) // cap tokens AND cost
+ .policy(new PolicySetIr(
+ List.of("delete_*"), // blocked_tools
+ List.of(), // require_approval_for (from globs below)
+ List.of("anthropic/claude-sonnet-4-6"))) // model_allowlist
+ .approvalRequired(List.of("wire_*")) // HITL gate on any wire_* tool
+ .pii(true) // PII redaction metadata in the IR
+ .build();
+ }
+
+ /**
+ * Run the agent durably against a local engine, with a background
+ * {@link JavaToolWorker} executing the {@code @Tool} call. Requires the three
+ * services above to be running; intended as a copy-paste starting point, not a CI test.
+ */
+ public static void main(String[] args) {
+ Agent agent = buildAgent();
+ RunOptions options = RunOptions.defaults(); // local engine at http://127.0.0.1:7700
+
+ try (JamjetEngineClient client = new JamjetEngineClient(options.runtimeUrl());
+ JavaToolWorker worker = new JavaToolWorker(client, "calculator-worker-1", agent.registry())) {
+
+ // Drain the java_tool queue on a background thread for the duration of the run.
+ Thread workerThread = new Thread(worker::run, "calculator-java-tool-worker");
+ workerThread.setDaemon(true);
+ workerThread.start();
+
+ AgentResult result = agent.runDurable("What is (5 + 3) * 2?", client, options);
+
+ System.out.println("answer: " + result.output());
+ System.out.println("toolCalls: " + result.toolCalls());
+
+ worker.stop();
+ }
+ }
+}
diff --git a/jamjet-agent/src/test/java/dev/jamjet/agent/examples/CalculatorAgentExampleTest.java b/jamjet-agent/src/test/java/dev/jamjet/agent/examples/CalculatorAgentExampleTest.java
new file mode 100644
index 0000000..d88eb56
--- /dev/null
+++ b/jamjet-agent/src/test/java/dev/jamjet/agent/examples/CalculatorAgentExampleTest.java
@@ -0,0 +1,188 @@
+package dev.jamjet.agent.examples;
+
+import com.github.tomakehurst.wiremock.WireMockServer;
+import dev.jamjet.agent.Agent;
+import dev.jamjet.agent.AgentResult;
+import dev.jamjet.agent.RunOptions;
+import dev.jamjet.agent.client.JamjetEngineClient;
+import dev.jamjet.agent.tools.ToolDispatcher;
+import dev.jamjet.agent.worker.JavaToolWorker;
+import dev.jamjet.runtime.core.JamjetJson;
+import dev.jamjet.runtime.core.ir.NodeKind;
+import dev.jamjet.runtime.core.ir.WorkflowIr;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+
+import java.time.Duration;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath;
+import static com.github.tomakehurst.wiremock.client.WireMock.ok;
+import static com.github.tomakehurst.wiremock.client.WireMock.okJson;
+import static com.github.tomakehurst.wiremock.client.WireMock.post;
+import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
+import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Hermetic END-TO-END proof for the {@link CalculatorAgentExample}: the idiomatic Java
+ * agent builds, compiles to the agent-loop IR, has its {@code @Tool} executed durably by
+ * a {@link JavaToolWorker}, and {@code runDurable} returns the expected governed result —
+ * all against a WireMock stub of the engine's bare routes (a live Rust engine + sidecar
+ * is not available in CI).
+ *
+ *
Parity with the Python equivalent. The asserted result shape matches the
+ * Python {@code agent.run_durable} result: the {@link AgentResult#output()} is the final
+ * assistant text, and the tool actually ran (proven twice — the worker's completion body
+ * carries the tool's output, and the reconstructed {@link AgentResult#toolCalls()} carries
+ * the call + its result).
+ *
+ *
Because WireMock cannot run the engine's scheduler, the two halves of the durable
+ * loop are stubbed independently and asserted together: (1) the {@link JavaToolWorker}
+ * draining a stubbed {@code java_tool} claim dispatches the real {@code @Tool}; (2)
+ * {@code runDurable} extracts the final answer from the terminal {@code current_state}.
+ */
+class CalculatorAgentExampleTest {
+
+ WireMockServer wm;
+
+ @BeforeEach
+ void up() {
+ wm = new WireMockServer(options().dynamicPort());
+ wm.start();
+ }
+
+ @AfterEach
+ void down() {
+ wm.stop();
+ }
+
+ @Test
+ @Timeout(20)
+ void buildsCompilesDispatchesToolDurablyAndReturnsGovernedResult() {
+ Agent agent = CalculatorAgentExample.buildAgent();
+
+ // -- (a) the example compiles to a governed agent-loop IR -------------------
+ WorkflowIr ir = agent.compileToIr(2);
+ assertThat(ir.startNode()).isEqualTo("__model_0__"); // M2: start is a Model node
+ assertThat(ir.costBudgetUsd()).isEqualTo(0.50); // governance rode into the IR
+ assertThat(ir.tokenBudget().totalTokens()).isEqualTo(50_000);
+ assertThat(ir.policy().blockedTools()).contains("delete_*");
+ assertThat(ir.policy().requireApprovalFor()).contains("wire_*");
+ assertThat(ir.dataPolicy().piiDetectors()).hasSize(5);
+
+ // -- (b) DRY: the worker accepts EXACTLY the coordinate the compiler emits ---
+ NodeKind.JavaFn toolNode = (NodeKind.JavaFn) ir.node("__tools_0__").kind();
+ // The emitted JavaFn coordinate is the same literal the worker's RCE gate checks.
+ assertThat(toolNode.className()).isEqualTo(ToolDispatcher.DISPATCH_CLASS);
+ assertThat(toolNode.method()).isEqualTo(ToolDispatcher.DISPATCH_METHOD);
+
+ // -- stubs: the bare engine routes for BOTH the worker and the run ----------
+ Map dispatchInput = Map.of(
+ "last_model_tool_calls",
+ List.of(toolCall("tc1", "calculate", Map.of("a", 5, "b", 3, "op", "add"))));
+ // The claim carries the coordinate the COMPILER emitted (not a hardcoded literal),
+ // so a green dispatch proves the worker accepts the builder's emitted coordinate.
+ wm.stubFor(post(urlEqualTo("/work-items/claim"))
+ .willReturn(okJson(claim(toolNode.className(), toolNode.method(), dispatchInput, 7L))));
+ wm.stubFor(post(urlEqualTo("/work-items/wi_1/complete")).willReturn(ok()));
+ wm.stubFor(post(urlEqualTo("/work-items/wi_1/heartbeat")).willReturn(ok()));
+
+ wm.stubFor(post(urlEqualTo("/workflows"))
+ .willReturn(okJson("{\"workflow_id\":\"calculator_agent\",\"version\":\"" + ir.version() + "\"}")));
+ wm.stubFor(post(urlEqualTo("/executions"))
+ .willReturn(okJson("{\"execution_id\":\"ex_1\",\"status\":\"running\"}")));
+ wm.stubFor(get(urlEqualTo("/executions/ex_1"))
+ .willReturn(okJson(completedBody())));
+
+ RunOptions runOptions = RunOptions.defaults()
+ .withMaxTurns(2)
+ .withRuntimeUrl(wm.baseUrl())
+ .withPollInterval(Duration.ofMillis(15))
+ .withTimeout(Duration.ofSeconds(5));
+
+ AgentResult result;
+ try (JamjetEngineClient client = new JamjetEngineClient(wm.baseUrl());
+ JavaToolWorker worker = new JavaToolWorker(client, "calculator-worker-1", agent.registry(),
+ Duration.ofSeconds(2), Duration.ofMillis(10))) {
+
+ // -- (c) the @Tool runs DURABLY: the worker dispatches the claimed java_tool item.
+ JavaToolWorker.ItemResult dispatched = worker.runOnce();
+ assertThat(dispatched).isEqualTo(JavaToolWorker.ItemResult.COMPLETED);
+
+ // -- (d) the durable run extracts the final governed answer -------------
+ result = agent.runDurable("What is (5 + 3) * 2?", client, runOptions);
+ }
+
+ // The tool actually ran: calculate(5, 3, "add") -> "8" in the completion body.
+ wm.verify(postRequestedFor(urlEqualTo("/work-items/wi_1/complete"))
+ .withRequestBody(matchingJsonPath("$.lease_fence", equalTo("7")))
+ .withRequestBody(matchingJsonPath("$.output.messages[1].role", equalTo("tool")))
+ .withRequestBody(matchingJsonPath("$.output.messages[1].content", equalTo("8"))));
+
+ // The result PARALLELS the Python run_durable result: final assistant text + the tool call.
+ assertThat(result.output()).isEqualTo("(5 + 3) * 2 = 16");
+ assertThat(result.terminalState().status()).isEqualTo("completed");
+ assertThat(result.toolCalls()).hasSize(1);
+ AgentResult.ToolCall call = result.toolCalls().get(0);
+ assertThat(call.tool()).isEqualTo("calculate");
+ assertThat(call.input()).containsEntry("op", "add");
+ assertThat(call.output()).isEqualTo("8");
+ }
+
+ // -- helpers ----------------------------------------------------------------
+
+ private static Map toolCall(String id, String name, Map args) {
+ Map c = new LinkedHashMap<>();
+ c.put("id", id);
+ c.put("name", name);
+ c.put("arguments", args);
+ return c;
+ }
+
+ /** A {@code {claimed, work_item}} claim response carrying the given dispatch coordinate + input. */
+ private static String claim(String dispatchClass, String dispatchMethod, Map input, long fence) {
+ Map payload = new LinkedHashMap<>();
+ payload.put("class", dispatchClass);
+ payload.put("method", dispatchMethod);
+ payload.put("input", input);
+
+ Map wi = new LinkedHashMap<>();
+ wi.put("id", "wi_1");
+ wi.put("execution_id", "ex_1");
+ wi.put("node_id", "__tools_0__");
+ wi.put("queue_type", "java_tool");
+ wi.put("payload", payload);
+ wi.put("attempt", 1);
+ wi.put("lease_fence", fence);
+
+ Map resp = new LinkedHashMap<>();
+ resp.put("claimed", true);
+ resp.put("work_item", wi);
+ try {
+ return JamjetJson.shared().writeValueAsString(resp);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /** A terminal completed snapshot: inline last_model_output + a calculate tool turn. */
+ private static String completedBody() {
+ return "{\"execution_id\":\"ex_1\",\"status\":\"completed\",\"current_state\":{"
+ + "\"last_model_output\":\"(5 + 3) * 2 = 16\","
+ + "\"messages\":["
+ + "{\"role\":\"system\",\"content\":\"You are a precise calculator.\"},"
+ + "{\"role\":\"user\",\"content\":\"What is (5 + 3) * 2?\"},"
+ + "{\"role\":\"assistant\",\"content\":\"\",\"tool_calls\":[{\"id\":\"tc1\",\"type\":\"function\","
+ + "\"function\":{\"name\":\"calculate\",\"arguments\":\"{\\\"a\\\":5,\\\"b\\\":3,\\\"op\\\":\\\"add\\\"}\"}}]},"
+ + "{\"role\":\"tool\",\"tool_call_id\":\"tc1\",\"name\":\"calculate\",\"content\":\"8\"}"
+ + "]}}";
+ }
+}
diff --git a/jamjet-agent/src/test/java/dev/jamjet/agent/examples/CalculatorTools.java b/jamjet-agent/src/test/java/dev/jamjet/agent/examples/CalculatorTools.java
new file mode 100644
index 0000000..59f179c
--- /dev/null
+++ b/jamjet-agent/src/test/java/dev/jamjet/agent/examples/CalculatorTools.java
@@ -0,0 +1,55 @@
+package dev.jamjet.agent.examples;
+
+import dev.jamjet.agent.Tool;
+
+import java.math.BigDecimal;
+import java.math.MathContext;
+
+/**
+ * A tiny tool-holder for the {@link CalculatorAgentExample}: one {@code @Tool} method
+ * the model can call to do exact arithmetic. Idiomatic Java — a plain class with a
+ * {@code @Tool}-annotated method; the {@code Agent} builder derives the OpenAI function
+ * schema from the method's name + typed parameters, and the durable
+ * {@link dev.jamjet.agent.worker.JavaToolWorker} invokes it reflectively when the model
+ * calls it.
+ */
+public final class CalculatorTools {
+
+ /**
+ * Evaluate a binary arithmetic operation. The {@code op} is one of
+ * {@code add} / {@code subtract} / {@code multiply} / {@code divide}.
+ *
+ * The JSON arguments the model sends ({@code {"a": 5, "b": 3, "op": "add"}}) are
+ * coerced to these typed parameters by name (Jackson), so the method stays plain Java.
+ */
+ @Tool(name = "calculate", description = "Evaluate a binary arithmetic operation: add, subtract, multiply, or divide.")
+ public String calculate(double a, double b, String op) {
+ // Exact decimal arithmetic via BigDecimal: avoids binary-float noise (0.1 + 0.2)
+ // and lets divide-by-zero be a clean error rather than producing "Infinity".
+ // valueOf(double) uses the canonical short decimal string, so "0.1" stays "0.1".
+ BigDecimal x = BigDecimal.valueOf(a);
+ BigDecimal y = BigDecimal.valueOf(b);
+ BigDecimal result;
+ switch (op) {
+ case "add" -> result = x.add(y);
+ case "subtract" -> result = x.subtract(y);
+ case "multiply" -> result = x.multiply(y);
+ case "divide" -> {
+ if (y.signum() == 0) {
+ return "ERROR: division by zero";
+ }
+ result = x.divide(y, MathContext.DECIMAL64);
+ }
+ default -> {
+ return "ERROR: unknown op '" + op + "' (expected add, subtract, multiply, or divide)";
+ }
+ }
+ // Render whole-number results without a trailing ".0"; otherwise strip trailing
+ // zeros and render in plain (non-scientific) decimal notation.
+ result = result.stripTrailingZeros();
+ if (result.scale() <= 0) {
+ return result.toBigInteger().toString();
+ }
+ return result.toPlainString();
+ }
+}
diff --git a/jamjet-agent/src/test/java/dev/jamjet/agent/examples/CalculatorToolsTest.java b/jamjet-agent/src/test/java/dev/jamjet/agent/examples/CalculatorToolsTest.java
new file mode 100644
index 0000000..44e66f1
--- /dev/null
+++ b/jamjet-agent/src/test/java/dev/jamjet/agent/examples/CalculatorToolsTest.java
@@ -0,0 +1,43 @@
+package dev.jamjet.agent.examples;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Unit tests for the example {@link CalculatorTools}: exact decimal arithmetic via
+ * BigDecimal, a clean error (not {@code Infinity}) on divide-by-zero, and whole-number
+ * results rendered without a trailing {@code .0}.
+ */
+class CalculatorToolsTest {
+
+ private final CalculatorTools calc = new CalculatorTools();
+
+ @Test
+ void wholeNumberResultsHaveNoTrailingDecimal() {
+ assertThat(calc.calculate(5, 3, "add")).isEqualTo("8");
+ assertThat(calc.calculate(5, 3, "subtract")).isEqualTo("2");
+ assertThat(calc.calculate(5, 3, "multiply")).isEqualTo("15");
+ assertThat(calc.calculate(10, 2, "divide")).isEqualTo("5");
+ }
+
+ @Test
+ void decimalDivisionIsExactNotFloatingPointNoise() {
+ assertThat(calc.calculate(10, 4, "divide")).isEqualTo("2.5");
+ // 0.1 + 0.2 is 0.3 in decimal arithmetic (not 0.30000000000000004).
+ assertThat(calc.calculate(0.1, 0.2, "add")).isEqualTo("0.3");
+ }
+
+ @Test
+ void divisionByZeroReturnsCleanErrorNotInfinity() {
+ String out = calc.calculate(5, 0, "divide");
+ assertThat(out).doesNotContain("Infinity");
+ assertThat(out.toLowerCase()).contains("zero");
+ }
+
+ @Test
+ void unknownOperationReturnsCleanError() {
+ String out = calc.calculate(1, 2, "modulo");
+ assertThat(out.toLowerCase()).contains("modulo");
+ }
+}
diff --git a/jamjet-agent/src/test/java/dev/jamjet/agent/examples/README.md b/jamjet-agent/src/test/java/dev/jamjet/agent/examples/README.md
new file mode 100644
index 0000000..3a38650
--- /dev/null
+++ b/jamjet-agent/src/test/java/dev/jamjet/agent/examples/README.md
@@ -0,0 +1,75 @@
+# Example: a governed, durable calculator agent (Java)
+
+A worked end-to-end example of authoring a JamJet agent in idiomatic Java with
+`dev.jamjet.agent.Agent`, running it durably and governed on the Rust engine. Every
+call here is the real shipped `jamjet-agent` API.
+
+- `CalculatorTools` — a plain class with one `@Tool` method (`calculate`).
+- `CalculatorAgentExample` — `buildAgent()` is the whole authoring surface; `main`
+ runs it durably against a local engine.
+- `CalculatorAgentExampleTest` — drives the same agent end-to-end against a WireMock
+ stub of the engine (a live engine + sidecar is not available in CI).
+
+## Authoring
+
+```java
+Agent agent = Agent.builder("calculator_agent")
+ .model("anthropic/claude-sonnet-4-6")
+ .instructions("You are a precise calculator. Use the calculate tool for every arithmetic step.")
+ .tools(new CalculatorTools())
+ // governance: compiled into the IR, enforced fail-closed by the engine
+ .budget(new Budget(50_000, 0.50)) // token + cost cap
+ .policy(new PolicySetIr(
+ List.of("delete_*"), // blocked_tools
+ List.of(), // require_approval_for
+ List.of("anthropic/claude-sonnet-4-6"))) // model_allowlist
+ .approvalRequired(List.of("wire_*")) // HITL gate on wire_* tools
+ .pii(true) // PII redaction metadata
+ .build();
+```
+
+`agent.compileToIr()` produces the same agent-loop `WorkflowIr` the Python ADK emits
+(the Java tool nodes are `java_fn` where Python's are `python_fn`), so a Java agent and
+a Python agent run on the identical engine. The governance knobs ride in the IR; the
+engine enforces them, not the Java SDK.
+
+## Running it durably
+
+```java
+RunOptions options = RunOptions.defaults(); // local engine at http://127.0.0.1:7700
+
+try (JamjetEngineClient client = new JamjetEngineClient(options.runtimeUrl());
+ JavaToolWorker worker = new JavaToolWorker(client, "calculator-worker-1", agent.registry())) {
+
+ Thread workerThread = new Thread(worker::run, "calculator-java-tool-worker");
+ workerThread.setDaemon(true);
+ workerThread.start();
+
+ AgentResult result = agent.runDurable("What is (5 + 3) * 2?", client, options);
+ System.out.println(result.output()); // the final assistant text
+ System.out.println(result.toolCalls()); // the calculate call + its result
+
+ worker.stop();
+}
+```
+
+`runDurable` compiles the IR, registers it (`POST /workflows`), starts an execution
+seeded with the system+user messages (`POST /executions`), polls
+`GET /executions/{id}` to a terminal state, and extracts the answer from
+`current_state.last_model_output` (falling back to the last assistant message). A
+non-`completed` terminal (`failed` / `cancelled` / `limit_exceeded`, e.g. a budget
+breach) raises `AgentRunException`; a run that never terminates raises
+`AgentRunTimeoutException`.
+
+## Required running services
+
+A durable run is not self-contained. Three services must be live (the same contract as
+the Python `agent.run_durable`):
+
+1. **the JamJet engine** (`jamjet-server`) at `RunOptions.runtimeUrl()`, which owns the
+ `java_tool` queue;
+2. **the model sidecar** (`JAMJET_MODEL_SEAM_URL`), through which the engine routes
+ every governed model call (there is no Java model code);
+3. **a `JavaToolWorker`** draining the `java_tool` queue with this agent's tool
+ registry, so the `@Tool` methods execute durably exactly-once. Run it in a separate
+ thread or process; `runDurable` does not start one.
diff --git a/jamjet-agent/src/test/java/dev/jamjet/agent/tools/ToolDispatcherTest.java b/jamjet-agent/src/test/java/dev/jamjet/agent/tools/ToolDispatcherTest.java
new file mode 100644
index 0000000..a5c9377
--- /dev/null
+++ b/jamjet-agent/src/test/java/dev/jamjet/agent/tools/ToolDispatcherTest.java
@@ -0,0 +1,236 @@
+package dev.jamjet.agent.tools;
+
+import dev.jamjet.agent.TestTools;
+import dev.jamjet.agent.Tool;
+import org.junit.jupiter.api.Test;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Unit tests for {@link ToolDispatcher} — the registry-gated tool-call dispatcher
+ * the {@code JavaFn} nodes point at. Mirrors the Python {@code dispatch_tool_calls}
+ * INPUT/OUTPUT contract: reads {@code last_model_tool_calls} / {@code last_model_output}
+ * (with the {@code tool_calls} / {@code assistant_content} fallbacks) from state, and
+ * returns {@code {"messages": }} (assistant message + one tool message
+ * per call). Includes the adversarial cases: arg coercion, unknown-tool clean error, and
+ * a registered tool that throws.
+ */
+class ToolDispatcherTest {
+
+ /** A tool that always throws, to prove a registered failure propagates. */
+ public static final class BoomTool {
+ @Tool(name = "boom", description = "always throws")
+ public String boom(String why) {
+ throw new IllegalStateException("kaboom: " + why);
+ }
+ }
+
+ /** A tool that records whether it was invoked, to prove a null-coerced call never happens. */
+ public static final class GreetTool {
+ volatile boolean invoked = false;
+
+ @Tool(name = "greet", description = "greets a person")
+ public String greet(String name) {
+ invoked = true;
+ return "hello " + name;
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private static List> messagesOf(Map out) {
+ assertThat(out).containsKey("messages");
+ return (List>) out.get("messages");
+ }
+
+ private static Map toolCall(String id, String name, Map args) {
+ Map c = new LinkedHashMap<>();
+ c.put("id", id);
+ c.put("name", name);
+ c.put("arguments", args);
+ return c;
+ }
+
+ @Test
+ void dispatchesRegisteredToolAndAppendsAssistantThenToolMessage() {
+ var dispatcher = new ToolDispatcher(ToolRegistry.of(new TestTools.WebSearchTool()));
+
+ Map state = new LinkedHashMap<>();
+ state.put("last_model_output", "let me search");
+ state.put("last_model_tool_calls",
+ List.of(toolCall("tc1", "web_search", Map.of("query", "jamjet"))));
+
+ List> messages = messagesOf(dispatcher.dispatchToolCalls(state));
+
+ // [0] the assistant message that requested the call (OpenAI shape).
+ assertThat(messages).hasSize(2);
+ Map assistant = messages.get(0);
+ assertThat(assistant).containsEntry("role", "assistant").containsEntry("content", "let me search");
+ @SuppressWarnings("unchecked")
+ List> calls = (List>) assistant.get("tool_calls");
+ assertThat(calls).hasSize(1);
+ assertThat(calls.get(0)).containsEntry("id", "tc1").containsEntry("type", "function");
+ @SuppressWarnings("unchecked")
+ Map fn = (Map) calls.get(0).get("function");
+ assertThat(fn).containsEntry("name", "web_search");
+ assertThat((String) fn.get("arguments")).contains("jamjet"); // arguments are a JSON string
+
+ // [1] the tool result message.
+ Map toolMsg = messages.get(1);
+ assertThat(toolMsg)
+ .containsEntry("role", "tool")
+ .containsEntry("tool_call_id", "tc1")
+ .containsEntry("name", "web_search")
+ .containsEntry("content", "results for jamjet");
+ }
+
+ @Test
+ void coercesJsonArgsToTypedMethodParameters() {
+ // add_numbers(int a, int b) with JSON numbers -> "5".
+ var dispatcher = new ToolDispatcher(ToolRegistry.of(new TestTools.MathTool()));
+
+ Map state = Map.of(
+ "last_model_tool_calls",
+ List.of(toolCall("tc1", "add_numbers", Map.of("a", 2, "b", 3))));
+
+ List> messages = messagesOf(dispatcher.dispatchToolCalls(state));
+ assertThat(messages.get(1)).containsEntry("content", "5");
+ }
+
+ @Test
+ void argumentsSuppliedAsJsonStringAreParsed() {
+ var dispatcher = new ToolDispatcher(ToolRegistry.of(new TestTools.WebSearchTool()));
+
+ Map call = new LinkedHashMap<>();
+ call.put("id", "tc1");
+ call.put("name", "web_search");
+ call.put("arguments", "{\"query\":\"from-string\"}"); // a JSON STRING, not an object
+
+ Map state = Map.of("last_model_tool_calls", List.of(call));
+ List> messages = messagesOf(dispatcher.dispatchToolCalls(state));
+ assertThat(messages.get(1)).containsEntry("content", "results for from-string");
+ }
+
+ @Test
+ void unknownToolYieldsCleanErrorMessageNotExceptionAndNoInvocation() {
+ // RCE gate at the name-resolution level: an unregistered name is never turned
+ // into a class/method; it surfaces a clean tool error to the model.
+ var dispatcher = new ToolDispatcher(ToolRegistry.of(new TestTools.WebSearchTool()));
+
+ Map state = Map.of(
+ "last_model_tool_calls",
+ List.of(toolCall("tc1", "java.lang.Runtime", Map.of("cmd", "rm -rf /"))));
+
+ Map out = dispatcher.dispatchToolCalls(state); // must NOT throw
+ List> messages = messagesOf(out);
+ assertThat(messages.get(1))
+ .containsEntry("role", "tool")
+ .containsEntry("tool_call_id", "tc1");
+ assertThat((String) messages.get(1).get("content"))
+ .contains("not a registered @Tool")
+ .contains("java.lang.Runtime");
+ }
+
+ @Test
+ void missingRequiredArgYieldsCleanToolErrorNotANullCall() {
+ // Every @Tool param is required (buildInputSchema marks them all). A call missing
+ // a required arg must surface a clean role:tool error to the model, NOT invoke the
+ // tool with a null-coerced argument.
+ GreetTool greet = new GreetTool();
+ var dispatcher = new ToolDispatcher(ToolRegistry.of(greet));
+
+ Map state = Map.of(
+ "last_model_tool_calls",
+ List.of(toolCall("tc1", "greet", Map.of()))); // no "name" arg
+
+ Map out = dispatcher.dispatchToolCalls(state); // must NOT throw
+ List> messages = messagesOf(out);
+ assertThat(messages.get(1))
+ .containsEntry("role", "tool")
+ .containsEntry("tool_call_id", "tc1");
+ assertThat((String) messages.get(1).get("content"))
+ .contains("missing required argument")
+ .contains("name");
+ // CRITICAL: the tool was never invoked with a null arg.
+ assertThat(greet.invoked).isFalse();
+ }
+
+ @Test
+ void toolInvocationExceptionMessageDoesNotLeakCauseDetail() {
+ // The surfaced message is logged + persisted to the engine via failWorkItem, so it
+ // must stay generic (tool name only) — never embed the raw cause.toString().
+ ToolInvocationException ex = new ToolInvocationException(
+ "wire_money", new IllegalStateException("secret-account-9988"));
+ assertThat(ex.getMessage())
+ .contains("wire_money")
+ .doesNotContain("secret-account-9988")
+ .doesNotContain("IllegalStateException");
+ // The cause is retained as the exception cause (just not in the message text).
+ assertThat(ex.getCause()).isInstanceOf(IllegalStateException.class);
+ assertThat(ex.toolName()).isEqualTo("wire_money");
+ }
+
+ @Test
+ void registeredToolThatThrowsPropagatesAsToolInvocationException() {
+ var dispatcher = new ToolDispatcher(ToolRegistry.of(new BoomTool()));
+
+ Map state = Map.of(
+ "last_model_tool_calls",
+ List.of(toolCall("tc1", "boom", Map.of("why", "test"))));
+
+ assertThatThrownBy(() -> dispatcher.dispatchToolCalls(state))
+ .isInstanceOf(ToolInvocationException.class)
+ .hasMessageContaining("boom")
+ .hasRootCauseInstanceOf(IllegalStateException.class);
+ }
+
+ @Test
+ void preservesExistingMessagesAndOrdersMultipleToolCalls() {
+ var dispatcher = new ToolDispatcher(
+ ToolRegistry.of(new TestTools.WebSearchTool(), new TestTools.MathTool()));
+
+ Map prior = Map.of("role", "user", "content", "hi");
+ Map state = new LinkedHashMap<>();
+ state.put("messages", List.of(prior));
+ state.put("last_model_tool_calls", List.of(
+ toolCall("a", "web_search", Map.of("query", "q")),
+ toolCall("b", "add_numbers", Map.of("a", 10, "b", 1))));
+
+ List> messages = messagesOf(dispatcher.dispatchToolCalls(state));
+ // prior user msg + 1 assistant + 2 tool msgs, in order.
+ assertThat(messages).hasSize(4);
+ assertThat(messages.get(0)).containsEntry("role", "user");
+ assertThat(messages.get(1)).containsEntry("role", "assistant");
+ assertThat(messages.get(2)).containsEntry("tool_call_id", "a").containsEntry("content", "results for q");
+ assertThat(messages.get(3)).containsEntry("tool_call_id", "b").containsEntry("content", "11");
+ }
+
+ @Test
+ void prefersExplicitToolCallsAndAssistantContentFallbackKeys() {
+ var dispatcher = new ToolDispatcher(ToolRegistry.of(new TestTools.WebSearchTool()));
+
+ // Both keys present: tool_calls / assistant_content take precedence.
+ Map state = new LinkedHashMap<>();
+ state.put("assistant_content", "preferred");
+ state.put("last_model_output", "ignored");
+ state.put("tool_calls", List.of(toolCall("tc1", "web_search", Map.of("query", "x"))));
+ state.put("last_model_tool_calls", List.of()); // would be empty if used
+
+ List> messages = messagesOf(dispatcher.dispatchToolCalls(state));
+ assertThat(messages).hasSize(2);
+ assertThat(messages.get(0)).containsEntry("content", "preferred");
+ assertThat(messages.get(1)).containsEntry("content", "results for x");
+ }
+
+ @Test
+ void emptyInputYieldsOnlyTheAssistantMessage() {
+ var dispatcher = new ToolDispatcher(ToolRegistry.of(new TestTools.WebSearchTool()));
+ List> messages = messagesOf(dispatcher.dispatchToolCalls(Map.of()));
+ assertThat(messages).hasSize(1);
+ assertThat(messages.get(0)).containsEntry("role", "assistant").containsEntry("content", "");
+ }
+}
diff --git a/jamjet-agent/src/test/java/dev/jamjet/agent/tools/ToolRegistryTest.java b/jamjet-agent/src/test/java/dev/jamjet/agent/tools/ToolRegistryTest.java
new file mode 100644
index 0000000..38bb8a2
--- /dev/null
+++ b/jamjet-agent/src/test/java/dev/jamjet/agent/tools/ToolRegistryTest.java
@@ -0,0 +1,138 @@
+package dev.jamjet.agent.tools;
+
+import dev.jamjet.agent.TestTools;
+import dev.jamjet.agent.Tool;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Proves the registry extracts OpenAI-format schemas from {@code @Tool} methods
+ * exactly as the Python {@code @tool} decorator does (bare-type-string property
+ * values, all params required), keys tools by class+method for B-3 dispatch, and
+ * gates invocation to declared tools only.
+ */
+class ToolRegistryTest {
+
+ @Test
+ void extractsOpenAiSchemaForStringParamTool() {
+ ToolRegistry registry = ToolRegistry.of(new TestTools.WebSearchTool());
+
+ assertThat(registry.tools()).hasSize(1);
+ RegisteredTool t = registry.tools().get(0);
+
+ assertThat(t.name()).isEqualTo("web_search");
+ assertThat(t.description()).isEqualTo("Search the web for a query.");
+ assertThat(t.className()).isEqualTo(TestTools.WebSearchTool.class.getName());
+ assertThat(t.methodName()).isEqualTo("webSearch");
+
+ // The OpenAI function wrapper, mirroring agent_ir._tool_schema.
+ assertThat(t.openAiSchema()).isEqualTo(Map.of(
+ "type", "function",
+ "function", Map.of(
+ "name", "web_search",
+ "description", "Search the web for a query.",
+ "parameters", Map.of(
+ "type", "object",
+ // Bare type string, NOT {"type":"string"} — matches Python.
+ "properties", Map.of("query", "string"),
+ "required", List.of("query")))));
+ }
+
+ @Test
+ void mapsPrimitiveParamTypesToBareJsonSchemaStrings() {
+ ToolRegistry registry = ToolRegistry.of(new TestTools.MathTool());
+ RegisteredTool t = registry.tools().get(0);
+
+ @SuppressWarnings("unchecked")
+ Map params = (Map) t.inputSchema();
+ assertThat(params).containsEntry("type", "object");
+ assertThat(params.get("properties")).isEqualTo(Map.of("a", "integer", "b", "integer"));
+ // Every Java @Tool parameter is required (no optional-param notion).
+ assertThat(params.get("required")).isEqualTo(List.of("a", "b"));
+ }
+
+ @Test
+ void resolvesByClassAndMethodForWorkerDispatch() {
+ TestTools.WebSearchTool holder = new TestTools.WebSearchTool();
+ ToolRegistry registry = ToolRegistry.of(holder);
+
+ RegisteredTool byKey = registry.byClassAndMethod(
+ TestTools.WebSearchTool.class.getName(), "webSearch");
+ assertThat(byKey).isNotNull();
+ assertThat(byKey.name()).isEqualTo("web_search");
+ // The reflective handle + instance the B-3 worker invokes.
+ assertThat(byKey.method().getName()).isEqualTo("webSearch");
+ assertThat(byKey.instance()).isSameAs(holder);
+
+ // The model uses the tool NAME in its tool_call; that resolves too.
+ assertThat(registry.byName("web_search")).isSameAs(byKey);
+ }
+
+ @Test
+ void unregisteredClassMethodResolvesToNull() {
+ ToolRegistry registry = ToolRegistry.of(new TestTools.WebSearchTool());
+ // No registry entry -> the worker must fail, never Class.forName an
+ // arbitrary class named in a payload.
+ assertThat(registry.byClassAndMethod("com.evil.Rce", "exploit")).isNull();
+ assertThat(registry.byName("nonexistent")).isNull();
+ }
+
+ @Test
+ void preservesHolderOrderAndSortsMethodsWithinHolderByName() {
+ // Holder order preserved across holders; @Tool methods within a holder
+ // are name-sorted for determinism (getDeclaredMethods is unordered).
+ ToolRegistry registry = ToolRegistry.of(
+ new TestTools.WebSearchTool(), new TestTools.MultiTool());
+
+ assertThat(registry.tools().stream().map(RegisteredTool::name).toList())
+ .containsExactly("web_search", "alpha", "zebra");
+
+ // Non-@Tool methods are not registered.
+ assertThat(registry.byName("notATool")).isNull();
+ }
+
+ @Test
+ void rejectsDuplicateToolNames() {
+ assertThatThrownBy(() -> ToolRegistry.of(
+ new TestTools.WebSearchTool(), new TestTools.WebSearchTool()))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Duplicate @Tool name 'web_search'");
+ }
+
+ /**
+ * Two {@code @Tool} overloads (same Java method name) collide on the class+method
+ * dispatch key, even with distinct {@code @Tool.name}s. The second would silently
+ * overwrite the first in {@code byClassAndMethod} and corrupt dispatch, so
+ * registration must reject it loudly.
+ */
+ public static final class OverloadedTools {
+ @Tool(name = "compute_one", description = "first overload")
+ public String compute(String a) {
+ return a;
+ }
+
+ @Tool(name = "compute_two", description = "second overload")
+ public String compute(int a) {
+ return String.valueOf(a);
+ }
+ }
+
+ @Test
+ void rejectsOverloadedToolMethodsSameClassAndMethod() {
+ assertThatThrownBy(() -> ToolRegistry.of(new OverloadedTools()))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("compute");
+ }
+
+ @Test
+ void emptyRegistryHasNoSchemas() {
+ ToolRegistry registry = new ToolRegistry();
+ assertThat(registry.isEmpty()).isTrue();
+ assertThat(registry.openAiToolSchemas()).isEmpty();
+ }
+}
diff --git a/jamjet-agent/src/test/java/dev/jamjet/agent/worker/JavaToolWorkerTest.java b/jamjet-agent/src/test/java/dev/jamjet/agent/worker/JavaToolWorkerTest.java
new file mode 100644
index 0000000..1b151ca
--- /dev/null
+++ b/jamjet-agent/src/test/java/dev/jamjet/agent/worker/JavaToolWorkerTest.java
@@ -0,0 +1,350 @@
+package dev.jamjet.agent.worker;
+
+import com.github.tomakehurst.wiremock.WireMockServer;
+import dev.jamjet.agent.TestTools;
+import dev.jamjet.agent.Tool;
+import dev.jamjet.agent.client.JamjetEngineClient;
+import dev.jamjet.agent.tools.ToolDispatcher;
+import dev.jamjet.agent.tools.ToolRegistry;
+import dev.jamjet.runtime.core.JamjetJson;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+
+import java.time.Duration;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.BooleanSupplier;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.containing;
+import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
+import static com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath;
+import static com.github.tomakehurst.wiremock.client.WireMock.ok;
+import static com.github.tomakehurst.wiremock.client.WireMock.okJson;
+import static com.github.tomakehurst.wiremock.client.WireMock.post;
+import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
+import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Adversarial integration tests for {@link JavaToolWorker} against a WireMock stub of
+ * the engine's bare work-item routes. This is the enforcement surface (a stale/zombie
+ * worker must not complete a reclaimed item), so every guard is exercised end-to-end
+ * over real HTTP:
+ *
+ *
+ * Happy path — claim → dispatch a registered {@code @Tool}
+ * → complete WITH the lease fence (asserted in the request body).
+ * Fence-lost mid-flight (M3) — a heartbeat is rejected while a tool
+ * runs → the worker aborts the tool and NEVER calls complete.
+ * 409 on complete = lost lease — the worker does NOT fail the item.
+ * Unknown dispatch coordinate = clean fail, not RCE — a forged
+ * {@code java_fn} payload class is failed cleanly, never {@code Class.forName}'d.
+ * Arg coercion — JSON args coerce to typed params end-to-end.
+ *
+ */
+class JavaToolWorkerTest {
+
+ WireMockServer wm;
+
+ @BeforeEach
+ void up() {
+ wm = new WireMockServer(options().dynamicPort());
+ wm.start();
+ }
+
+ @AfterEach
+ void down() {
+ wm.stop();
+ }
+
+ // -- fixtures ---------------------------------------------------------------
+
+ /** A tool that blocks until interrupted, so the M3 heartbeat abort can hit it mid-flight. */
+ public static final class SlowTool {
+ final CountDownLatch started = new CountDownLatch(1);
+ volatile boolean interrupted = false;
+
+ @Tool(name = "slow_tool", description = "blocks until interrupted")
+ public String slow(String ignored) {
+ started.countDown();
+ try {
+ Thread.sleep(5_000);
+ } catch (InterruptedException e) {
+ interrupted = true;
+ Thread.currentThread().interrupt();
+ throw new RuntimeException("interrupted while running slow_tool");
+ }
+ return "should never be reached";
+ }
+ }
+
+ private static Map toolCall(String id, String name, Map args) {
+ Map c = new LinkedHashMap<>();
+ c.put("id", id);
+ c.put("name", name);
+ c.put("arguments", args);
+ return c;
+ }
+
+ /** Serialize a {@code {claimed, work_item}} claim response with the given payload + fence. */
+ private static String claimBody(String dispatchClass, String dispatchMethod,
+ Map input, long fence) {
+ Map payload = new LinkedHashMap<>();
+ payload.put("class", dispatchClass);
+ payload.put("method", dispatchMethod);
+ payload.put("input", input);
+
+ Map wi = new LinkedHashMap<>();
+ wi.put("id", "wi_1");
+ wi.put("execution_id", "ex_1");
+ wi.put("node_id", "__tools_0__");
+ wi.put("queue_type", "java_tool");
+ wi.put("payload", payload);
+ wi.put("attempt", 1);
+ wi.put("lease_fence", fence);
+
+ Map resp = new LinkedHashMap<>();
+ resp.put("claimed", true);
+ resp.put("work_item", wi);
+ try {
+ return JamjetJson.shared().writeValueAsString(resp);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /** A claim carrying the FIXED, valid dispatch coordinate the Agent builder emits. */
+ private static String validClaim(Map input, long fence) {
+ return claimBody(ToolDispatcher.DISPATCH_CLASS, ToolDispatcher.DISPATCH_METHOD, input, fence);
+ }
+
+ private static boolean waitFor(BooleanSupplier cond, long timeoutMillis) {
+ long deadline = System.currentTimeMillis() + timeoutMillis;
+ while (System.currentTimeMillis() < deadline) {
+ if (cond.getAsBoolean()) {
+ return true;
+ }
+ try {
+ Thread.sleep(5);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return cond.getAsBoolean();
+ }
+ }
+ return cond.getAsBoolean();
+ }
+
+ // -- tests ------------------------------------------------------------------
+
+ @Test
+ @Timeout(15)
+ void happyPathDispatchesToolAndCompletesWithTheFence() {
+ Map input = Map.of(
+ "last_model_output", "searching",
+ "last_model_tool_calls", List.of(toolCall("tc1", "web_search", Map.of("query", "jamjet"))));
+
+ wm.stubFor(post(urlEqualTo("/work-items/claim")).willReturn(okJson(validClaim(input, 7))));
+ wm.stubFor(post(urlEqualTo("/work-items/wi_1/complete")).willReturn(ok()));
+ wm.stubFor(post(urlEqualTo("/work-items/wi_1/heartbeat")).willReturn(ok()));
+ wm.stubFor(post(urlEqualTo("/work-items/wi_1/fail")).willReturn(ok()));
+
+ try (var client = new JamjetEngineClient(wm.baseUrl());
+ var worker = new JavaToolWorker(client, "w1",
+ ToolRegistry.of(new TestTools.WebSearchTool()),
+ Duration.ofSeconds(2), Duration.ofMillis(10))) {
+
+ JavaToolWorker.ItemResult result = worker.runOnce();
+ assertThat(result).isEqualTo(JavaToolWorker.ItemResult.COMPLETED);
+ }
+
+ // The completion MUST echo the claim's lease fence (the fenced completion path)
+ // and carry the dispatcher's {messages:[assistant, tool]} as output + state_patch.
+ wm.verify(postRequestedFor(urlEqualTo("/work-items/wi_1/complete"))
+ .withRequestBody(matchingJsonPath("$.lease_fence", equalTo("7")))
+ .withRequestBody(matchingJsonPath("$.execution_id", equalTo("ex_1")))
+ .withRequestBody(matchingJsonPath("$.node_id", equalTo("__tools_0__")))
+ .withRequestBody(matchingJsonPath("$.output.messages[0].role", equalTo("assistant")))
+ .withRequestBody(matchingJsonPath("$.output.messages[1].role", equalTo("tool")))
+ .withRequestBody(matchingJsonPath("$.output.messages[1].content", equalTo("results for jamjet")))
+ .withRequestBody(matchingJsonPath("$.state_patch.messages[1].content", equalTo("results for jamjet"))));
+ // No failure on the happy path.
+ wm.verify(0, postRequestedFor(urlEqualTo("/work-items/wi_1/fail")));
+ }
+
+ @Test
+ @Timeout(15)
+ void argsCoerceToTypedParametersEndToEnd() {
+ Map input = Map.of(
+ "last_model_tool_calls", List.of(toolCall("tc1", "add_numbers", Map.of("a", 2, "b", 3))));
+
+ wm.stubFor(post(urlEqualTo("/work-items/claim")).willReturn(okJson(validClaim(input, 5))));
+ wm.stubFor(post(urlEqualTo("/work-items/wi_1/complete")).willReturn(ok()));
+ wm.stubFor(post(urlEqualTo("/work-items/wi_1/heartbeat")).willReturn(ok()));
+
+ try (var client = new JamjetEngineClient(wm.baseUrl());
+ var worker = new JavaToolWorker(client, "w1",
+ ToolRegistry.of(new TestTools.MathTool()),
+ Duration.ofSeconds(2), Duration.ofMillis(10))) {
+ assertThat(worker.runOnce()).isEqualTo(JavaToolWorker.ItemResult.COMPLETED);
+ }
+
+ // add_numbers(int,int) with JSON {a:2,b:3} -> "5".
+ wm.verify(postRequestedFor(urlEqualTo("/work-items/wi_1/complete"))
+ .withRequestBody(matchingJsonPath("$.lease_fence", equalTo("5")))
+ .withRequestBody(matchingJsonPath("$.output.messages[1].content", equalTo("5"))));
+ }
+
+ @Test
+ @Timeout(15)
+ void fenceLostMidFlightAbortsAndNeverCompletes_M3() {
+ Map input = Map.of(
+ "last_model_tool_calls", List.of(toolCall("tc1", "slow_tool", Map.of("ignored", "x"))));
+
+ // Heartbeat is rejected: the engine surfaces a stale fence on renew as HTTP 500
+ // (FenceLost -> Internal). The worker must abort the in-flight tool and NOT complete.
+ wm.stubFor(post(urlEqualTo("/work-items/claim")).willReturn(okJson(validClaim(input, 7))));
+ wm.stubFor(post(urlEqualTo("/work-items/wi_1/heartbeat"))
+ .willReturn(aResponse().withStatus(500).withBody("{\"error\":\"FenceLost(wi_1)\"}")));
+ wm.stubFor(post(urlEqualTo("/work-items/wi_1/complete")).willReturn(ok()));
+ wm.stubFor(post(urlEqualTo("/work-items/wi_1/fail")).willReturn(ok()));
+
+ SlowTool slow = new SlowTool();
+ try (var client = new JamjetEngineClient(wm.baseUrl());
+ var worker = new JavaToolWorker(client, "w1",
+ ToolRegistry.of(slow),
+ Duration.ofMillis(20), Duration.ofMillis(10))) {
+
+ JavaToolWorker.ItemResult result = worker.runOnce();
+
+ // Lost lease is a no-op, NOT a completion and NOT a failure.
+ assertThat(result).isEqualTo(JavaToolWorker.ItemResult.LOST_LEASE);
+ }
+
+ // The in-flight tool was actually signalled to abort (M3).
+ assertThat(waitFor(() -> slow.interrupted, 2_000)).as("slow tool was interrupted").isTrue();
+ // A heartbeat fired (and was rejected).
+ wm.verify(postRequestedFor(urlEqualTo("/work-items/wi_1/heartbeat")));
+ // CRITICAL: the reclaimed item was NEVER completed and NEVER failed by this worker.
+ wm.verify(0, postRequestedFor(urlEqualTo("/work-items/wi_1/complete")));
+ wm.verify(0, postRequestedFor(urlEqualTo("/work-items/wi_1/fail")));
+ }
+
+ @Test
+ @Timeout(15)
+ void interruptedDispatchIsNotAToolFailureAndExitsCleanly() throws InterruptedException {
+ // The WORKER thread is interrupted (shutdown / cancellation) while it blocks on
+ // dispatchFuture.get(). That is NOT a tool failure: the item must be left for the
+ // engine to reclaim on lease expiry, never failed (which would clobber a reclaimer).
+ Map input = Map.of(
+ "last_model_tool_calls", List.of(toolCall("tc1", "slow_tool", Map.of("ignored", "x"))));
+
+ wm.stubFor(post(urlEqualTo("/work-items/claim")).willReturn(okJson(validClaim(input, 7))));
+ wm.stubFor(post(urlEqualTo("/work-items/wi_1/complete")).willReturn(ok()));
+ // Heartbeat interval is far longer than the test: the ONLY signal is the interrupt,
+ // so this isolates the interrupt path from the M3 heartbeat-abort path.
+ wm.stubFor(post(urlEqualTo("/work-items/wi_1/heartbeat")).willReturn(ok()));
+ wm.stubFor(post(urlEqualTo("/work-items/wi_1/fail")).willReturn(ok()));
+
+ SlowTool slow = new SlowTool();
+ AtomicReference resultRef = new AtomicReference<>();
+ CountDownLatch done = new CountDownLatch(1);
+
+ try (var client = new JamjetEngineClient(wm.baseUrl());
+ var worker = new JavaToolWorker(client, "w1",
+ ToolRegistry.of(slow),
+ Duration.ofSeconds(30), Duration.ofMillis(10))) {
+
+ Thread runner = new Thread(() -> {
+ try {
+ resultRef.set(worker.runOnce());
+ } finally {
+ done.countDown();
+ }
+ }, "worker-runner");
+ runner.start();
+
+ // Once the tool is actually executing, interrupt the worker thread mid-get().
+ assertThat(slow.started.await(5, TimeUnit.SECONDS)).isTrue();
+ runner.interrupt();
+ assertThat(done.await(5, TimeUnit.SECONDS)).isTrue();
+ }
+
+ // A clean stop (no-op), NOT a failure: the item was neither failed nor completed.
+ assertThat(resultRef.get()).isEqualTo(JavaToolWorker.ItemResult.LOST_LEASE);
+ wm.verify(0, postRequestedFor(urlEqualTo("/work-items/wi_1/fail")));
+ wm.verify(0, postRequestedFor(urlEqualTo("/work-items/wi_1/complete")));
+ }
+
+ @Test
+ @Timeout(15)
+ void conflictOnCompleteIsLostLeaseNotFailure() {
+ Map input = Map.of(
+ "last_model_tool_calls", List.of(toolCall("tc1", "web_search", Map.of("query", "x"))));
+
+ wm.stubFor(post(urlEqualTo("/work-items/claim")).willReturn(okJson(validClaim(input, 9))));
+ // The completion is fence-rejected: a reclaimed lease yields HTTP 409.
+ wm.stubFor(post(urlEqualTo("/work-items/wi_1/complete"))
+ .willReturn(aResponse().withStatus(409).withBody("{\"reason\":\"stale or invalid lease fence\"}")));
+ wm.stubFor(post(urlEqualTo("/work-items/wi_1/heartbeat")).willReturn(ok()));
+ wm.stubFor(post(urlEqualTo("/work-items/wi_1/fail")).willReturn(ok()));
+
+ try (var client = new JamjetEngineClient(wm.baseUrl());
+ var worker = new JavaToolWorker(client, "w1",
+ ToolRegistry.of(new TestTools.WebSearchTool()),
+ Duration.ofSeconds(2), Duration.ofMillis(10))) {
+ assertThat(worker.runOnce()).isEqualTo(JavaToolWorker.ItemResult.LOST_LEASE);
+ }
+
+ // It attempted the (fenced) complete...
+ wm.verify(postRequestedFor(urlEqualTo("/work-items/wi_1/complete"))
+ .withRequestBody(matchingJsonPath("$.lease_fence", equalTo("9"))));
+ // ...but a 409 must NOT escalate to a fail (that would clobber the new claimant).
+ wm.verify(0, postRequestedFor(urlEqualTo("/work-items/wi_1/fail")));
+ }
+
+ @Test
+ @Timeout(15)
+ void unknownDispatchCoordinateFailsCleanlyAndIsNotRce() {
+ // A FORGED java_fn payload names an arbitrary class/method. The worker must
+ // fail it cleanly and NEVER Class.forName / invoke it.
+ Map input = Map.of("last_model_tool_calls", List.of());
+ String forged = claimBody("java.lang.Runtime", "exec", input, 3);
+
+ wm.stubFor(post(urlEqualTo("/work-items/claim")).willReturn(okJson(forged)));
+ wm.stubFor(post(urlEqualTo("/work-items/wi_1/fail")).willReturn(ok()));
+ wm.stubFor(post(urlEqualTo("/work-items/wi_1/complete")).willReturn(ok()));
+
+ try (var client = new JamjetEngineClient(wm.baseUrl());
+ var worker = new JavaToolWorker(client, "w1",
+ ToolRegistry.of(new TestTools.WebSearchTool()),
+ Duration.ofSeconds(2), Duration.ofMillis(10))) {
+ assertThat(worker.runOnce()).isEqualTo(JavaToolWorker.ItemResult.FAILED);
+ }
+
+ // Failed with a clear message naming the rejected coordinate; the arbitrary
+ // class was NEVER invoked or completed.
+ wm.verify(postRequestedFor(urlEqualTo("/work-items/wi_1/fail"))
+ .withRequestBody(matchingJsonPath("$.error", containing("unsupported java_fn dispatch coordinate"))));
+ wm.verify(0, postRequestedFor(urlEqualTo("/work-items/wi_1/complete")));
+ }
+
+ @Test
+ @Timeout(15)
+ void emptyQueueReturnsEmpty() {
+ wm.stubFor(post(urlEqualTo("/work-items/claim")).willReturn(okJson("{\"claimed\":false}")));
+ try (var client = new JamjetEngineClient(wm.baseUrl());
+ var worker = new JavaToolWorker(client, "w1",
+ ToolRegistry.of(new TestTools.WebSearchTool()),
+ Duration.ofSeconds(2), Duration.ofMillis(10))) {
+ assertThat(worker.runOnce()).isEqualTo(JavaToolWorker.ItemResult.EMPTY);
+ }
+ }
+}
diff --git a/jamjet-agent/src/test/resources/golden/agent_loop_ir.python.json b/jamjet-agent/src/test/resources/golden/agent_loop_ir.python.json
new file mode 100644
index 0000000..fa7b0da
--- /dev/null
+++ b/jamjet-agent/src/test/resources/golden/agent_loop_ir.python.json
@@ -0,0 +1,322 @@
+{
+ "workflow_id": "research_agent",
+ "version": "0.1.0+8476bdc8e9fb",
+ "name": "research_agent",
+ "description": "You are a helpful research assistant.",
+ "state_schema": "",
+ "start_node": "__model_0__",
+ "nodes": {
+ "__model_0__": {
+ "id": "__model_0__",
+ "kind": {
+ "type": "model",
+ "model_ref": "anthropic/claude-sonnet-4-6",
+ "prompt_ref": "",
+ "output_schema": "",
+ "system_prompt": "You are a helpful research assistant.",
+ "tools": [
+ {
+ "type": "function",
+ "function": {
+ "name": "web_search",
+ "description": "Search the web for a query.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "query": "string"
+ },
+ "required": [
+ "query"
+ ]
+ }
+ }
+ },
+ {
+ "type": "function",
+ "function": {
+ "name": "add_numbers",
+ "description": "Add two integers.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "a": "integer",
+ "b": "integer"
+ },
+ "required": [
+ "a",
+ "b"
+ ]
+ }
+ }
+ }
+ ]
+ },
+ "retry_policy": "llm_default",
+ "node_timeout_secs": null,
+ "description": "agent turn 0 — model call",
+ "labels": {
+ "jamjet.agent.loop": "model",
+ "jamjet.agent.turn": "0"
+ }
+ },
+ "__tool_gate_0__": {
+ "id": "__tool_gate_0__",
+ "kind": {
+ "type": "condition",
+ "branches": [
+ {
+ "condition": "state.last_model_finish_reason == \"tool_calls\"",
+ "target": "__tools_0__"
+ },
+ {
+ "condition": null,
+ "target": "end"
+ }
+ ],
+ "expression": "state.last_model_finish_reason == \"tool_calls\""
+ },
+ "retry_policy": null,
+ "node_timeout_secs": null,
+ "description": "agent turn 0 — tool-call gate",
+ "labels": {
+ "jamjet.agent.loop": "gate",
+ "jamjet.agent.turn": "0"
+ }
+ },
+ "__tools_0__": {
+ "id": "__tools_0__",
+ "kind": {
+ "type": "python_fn",
+ "module": "jamjet.agents.tool_runtime",
+ "function": "dispatch_tool_calls",
+ "output_schema": "",
+ "input": {
+ "messages": "$state.messages",
+ "assistant_content": "$state.last_model_output",
+ "tool_calls": "$state.last_model_tool_calls",
+ "tools": {
+ "web_search": "__main__:web_search",
+ "add_numbers": "__main__:add_numbers"
+ }
+ }
+ },
+ "retry_policy": "no_retry",
+ "node_timeout_secs": null,
+ "description": "agent turn 0 — dispatch tool calls",
+ "labels": {
+ "jamjet.agent.loop": "tools",
+ "jamjet.agent.turn": "0"
+ }
+ },
+ "__model_1__": {
+ "id": "__model_1__",
+ "kind": {
+ "type": "model",
+ "model_ref": "anthropic/claude-sonnet-4-6",
+ "prompt_ref": "",
+ "output_schema": "",
+ "system_prompt": "You are a helpful research assistant.",
+ "tools": [
+ {
+ "type": "function",
+ "function": {
+ "name": "web_search",
+ "description": "Search the web for a query.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "query": "string"
+ },
+ "required": [
+ "query"
+ ]
+ }
+ }
+ },
+ {
+ "type": "function",
+ "function": {
+ "name": "add_numbers",
+ "description": "Add two integers.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "a": "integer",
+ "b": "integer"
+ },
+ "required": [
+ "a",
+ "b"
+ ]
+ }
+ }
+ }
+ ]
+ },
+ "retry_policy": "llm_default",
+ "node_timeout_secs": null,
+ "description": "agent turn 1 — model call",
+ "labels": {
+ "jamjet.agent.loop": "model",
+ "jamjet.agent.turn": "1"
+ }
+ },
+ "__tool_gate_1__": {
+ "id": "__tool_gate_1__",
+ "kind": {
+ "type": "condition",
+ "branches": [
+ {
+ "condition": "state.last_model_finish_reason == \"tool_calls\"",
+ "target": "__tools_1__"
+ },
+ {
+ "condition": null,
+ "target": "end"
+ }
+ ],
+ "expression": "state.last_model_finish_reason == \"tool_calls\""
+ },
+ "retry_policy": null,
+ "node_timeout_secs": null,
+ "description": "agent turn 1 — tool-call gate",
+ "labels": {
+ "jamjet.agent.loop": "gate",
+ "jamjet.agent.turn": "1"
+ }
+ },
+ "__tools_1__": {
+ "id": "__tools_1__",
+ "kind": {
+ "type": "python_fn",
+ "module": "jamjet.agents.tool_runtime",
+ "function": "dispatch_tool_calls",
+ "output_schema": "",
+ "input": {
+ "messages": "$state.messages",
+ "assistant_content": "$state.last_model_output",
+ "tool_calls": "$state.last_model_tool_calls",
+ "tools": {
+ "web_search": "__main__:web_search",
+ "add_numbers": "__main__:add_numbers"
+ }
+ }
+ },
+ "retry_policy": "no_retry",
+ "node_timeout_secs": null,
+ "description": "agent turn 1 — dispatch tool calls",
+ "labels": {
+ "jamjet.agent.loop": "tools",
+ "jamjet.agent.turn": "1"
+ }
+ },
+ "__model_2__": {
+ "id": "__model_2__",
+ "kind": {
+ "type": "model",
+ "model_ref": "anthropic/claude-sonnet-4-6",
+ "prompt_ref": "",
+ "output_schema": "",
+ "system_prompt": "You are a helpful research assistant.",
+ "tools": []
+ },
+ "retry_policy": "llm_default",
+ "node_timeout_secs": null,
+ "description": "agent turn 2 — final answer (no tools)",
+ "labels": {
+ "jamjet.agent.loop": "model",
+ "jamjet.agent.turn": "2",
+ "jamjet.agent.final": "true"
+ }
+ }
+ },
+ "edges": [
+ {
+ "from": "__model_0__",
+ "to": "__tool_gate_0__",
+ "condition": null
+ },
+ {
+ "from": "__tool_gate_0__",
+ "to": "__tools_0__",
+ "condition": "state.last_model_finish_reason == \"tool_calls\""
+ },
+ {
+ "from": "__tool_gate_0__",
+ "to": "end",
+ "condition": null
+ },
+ {
+ "from": "__tools_0__",
+ "to": "__model_1__",
+ "condition": null
+ },
+ {
+ "from": "__model_1__",
+ "to": "__tool_gate_1__",
+ "condition": null
+ },
+ {
+ "from": "__tool_gate_1__",
+ "to": "__tools_1__",
+ "condition": "state.last_model_finish_reason == \"tool_calls\""
+ },
+ {
+ "from": "__tool_gate_1__",
+ "to": "end",
+ "condition": null
+ },
+ {
+ "from": "__tools_1__",
+ "to": "__model_2__",
+ "condition": null
+ },
+ {
+ "from": "__model_2__",
+ "to": "end",
+ "condition": null
+ }
+ ],
+ "retry_policies": {},
+ "timeouts": {
+ "workflow_timeout": 300,
+ "heartbeat_interval": 30
+ },
+ "models": {},
+ "tools": {},
+ "mcp_servers": {},
+ "remote_agents": {},
+ "labels": {
+ "jamjet.agent.id": "research_agent",
+ "jamjet.agent.loop": "true",
+ "jamjet.agent.max_turns": "2"
+ },
+ "cost_budget_usd": 2.5,
+ "token_budget": {
+ "total_tokens": 100000
+ },
+ "policy": {
+ "blocked_tools": [
+ "delete_db"
+ ],
+ "require_approval_for": [
+ "delete_*"
+ ],
+ "model_allowlist": [
+ "anthropic/claude-sonnet-4-6"
+ ]
+ },
+ "data_policy": {
+ "pii_fields": [],
+ "pii_detectors": [
+ "email",
+ "ssn",
+ "credit_card",
+ "phone",
+ "ip_address"
+ ],
+ "redaction_mode": "mask",
+ "retain_prompts": false,
+ "retain_outputs": true
+ }
+}
diff --git a/jamjet-runtime-core/src/main/java/dev/jamjet/runtime/core/QueueType.java b/jamjet-runtime-core/src/main/java/dev/jamjet/runtime/core/QueueType.java
index d2129bd..008afd5 100644
--- a/jamjet-runtime-core/src/main/java/dev/jamjet/runtime/core/QueueType.java
+++ b/jamjet-runtime-core/src/main/java/dev/jamjet/runtime/core/QueueType.java
@@ -7,6 +7,7 @@ public enum QueueType {
MODEL("model"),
TOOL("tool"),
PYTHON_TOOL("python_tool"),
+ JAVA_TOOL("java_tool"),
RETRIEVAL("retrieval"),
PRIVILEGED("privileged"),
GENERAL("general");
diff --git a/jamjet-runtime-core/src/main/java/dev/jamjet/runtime/core/ir/NodeKind.java b/jamjet-runtime-core/src/main/java/dev/jamjet/runtime/core/ir/NodeKind.java
index 5a59ec6..844eb56 100644
--- a/jamjet-runtime-core/src/main/java/dev/jamjet/runtime/core/ir/NodeKind.java
+++ b/jamjet-runtime-core/src/main/java/dev/jamjet/runtime/core/ir/NodeKind.java
@@ -1,9 +1,13 @@
package dev.jamjet.runtime.core.ir;
+import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import dev.jamjet.runtime.core.QueueType;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -12,6 +16,7 @@
@JsonSubTypes.Type(value = NodeKind.Model.class, name = "model"),
@JsonSubTypes.Type(value = NodeKind.Tool.class, name = "tool"),
@JsonSubTypes.Type(value = NodeKind.PythonFn.class, name = "python_fn"),
+ @JsonSubTypes.Type(value = NodeKind.JavaFn.class, name = "java_fn"),
@JsonSubTypes.Type(value = NodeKind.Condition.class, name = "condition"),
@JsonSubTypes.Type(value = NodeKind.Parallel.class, name = "parallel"),
@JsonSubTypes.Type(value = NodeKind.Join.class, name = "join"),
@@ -30,11 +35,16 @@
})
public sealed interface NodeKind {
+ // Computed routing/durability helpers — NOT IR fields. @JsonIgnore keeps them
+ // off the wire so a serialized node matches the Rust NodeKind shape exactly
+ // (the Rust structs have no `queue_type`/`durable` field on a node kind).
+ @JsonIgnore
default QueueType queueType() {
return switch (this) {
case Model m -> QueueType.MODEL;
case Tool t -> QueueType.TOOL;
case PythonFn p -> QueueType.PYTHON_TOOL;
+ case JavaFn jf -> QueueType.JAVA_TOOL;
case MemoryRetrieval r -> QueueType.RETRIEVAL;
case Finalizer f -> QueueType.TOOL;
case McpTool m -> QueueType.TOOL;
@@ -43,6 +53,7 @@ default QueueType queueType() {
};
}
+ @JsonIgnore
default boolean isDurable() {
return !(this instanceof Condition);
}
@@ -51,8 +62,64 @@ record Model(
String modelRef,
String promptRef,
String outputSchema,
- String systemPrompt
- ) implements NodeKind {}
+ String systemPrompt,
+ List> tools
+ ) implements NodeKind {
+ public Model {
+ // OpenAI-format tool/function schemas offered to the model for this
+ // call (mirrors the Rust Model.tools / Python agent_ir _model_kind).
+ // Empty (never null) means no tools are offered; it serializes as
+ // "tools":[] matching the Rust #[serde(default)] Vec field.
+ //
+ // Deep-copy + freeze ALL THE WAY DOWN: List.copyOf only freezes the outer
+ // list, leaving nested schema maps/lists mutable so a caller could mutate a
+ // node's tool schema post-construction. The serde shape is unchanged.
+ tools = deepImmutableTools(tools);
+ }
+
+ /** Back-compat constructor: a Model node offering no tools (plain text completion). */
+ public Model(String modelRef, String promptRef, String outputSchema, String systemPrompt) {
+ this(modelRef, promptRef, outputSchema, systemPrompt, List.of());
+ }
+
+ private static List> deepImmutableTools(List> tools) {
+ if (tools == null || tools.isEmpty()) {
+ return List.of();
+ }
+ List> out = new ArrayList<>(tools.size());
+ for (Map tool : tools) {
+ out.add(deepImmutableMap(tool));
+ }
+ return Collections.unmodifiableList(out);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map deepImmutableMap(Map map) {
+ if (map == null || map.isEmpty()) {
+ return Map.of();
+ }
+ LinkedHashMap copy = new LinkedHashMap<>(map.size());
+ for (Map.Entry e : map.entrySet()) {
+ copy.put(e.getKey(), deepImmutableValue(e.getValue()));
+ }
+ return Collections.unmodifiableMap(copy);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Object deepImmutableValue(Object value) {
+ if (value instanceof Map, ?> m) {
+ return deepImmutableMap((Map) m);
+ }
+ if (value instanceof List> list) {
+ List out = new ArrayList<>(list.size());
+ for (Object e : list) {
+ out.add(deepImmutableValue(e));
+ }
+ return Collections.unmodifiableList(out);
+ }
+ return value;
+ }
+ }
record Tool(
String toolRef,
@@ -70,6 +137,20 @@ record PythonFn(
String outputSchema
) implements NodeKind {}
+ /**
+ * Arbitrary Java method executed by an external durable Java tool-worker —
+ * the Java analog of {@link PythonFn}. Serializes with the snake_case type
+ * tag {@code "java_fn"} and fields {@code class_name}/{@code method}/
+ * {@code output_schema}, exactly matching the Rust engine's
+ * {@code JavaFn { class_name, method, output_schema }} (Phase A). Routes to
+ * the {@link QueueType#JAVA_TOOL} queue, which the Java tool-worker drains.
+ */
+ record JavaFn(
+ String className,
+ String method,
+ String outputSchema
+ ) implements NodeKind {}
+
record Condition(
List branches
) implements NodeKind {
diff --git a/jamjet-runtime-core/src/test/java/dev/jamjet/runtime/core/ir/NodeKindSerializationTest.java b/jamjet-runtime-core/src/test/java/dev/jamjet/runtime/core/ir/NodeKindSerializationTest.java
index 04de3bd..734f2ee 100644
--- a/jamjet-runtime-core/src/test/java/dev/jamjet/runtime/core/ir/NodeKindSerializationTest.java
+++ b/jamjet-runtime-core/src/test/java/dev/jamjet/runtime/core/ir/NodeKindSerializationTest.java
@@ -6,10 +6,13 @@
import dev.jamjet.runtime.core.QueueType;
import org.junit.jupiter.api.Test;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
class NodeKindSerializationTest {
@@ -29,6 +32,131 @@ void modelNodeRoundTrip() throws JsonProcessingException {
assertThat(deserialized).isEqualTo(original);
}
+ @Test
+ void javaFnNodeRoundTrip() throws JsonProcessingException {
+ NodeKind original = new NodeKind.JavaFn("com.example.AgentTools", "dispatch", "");
+ String json = mapper.writeValueAsString(original);
+
+ // Exactly the Rust engine's Phase-A wire shape:
+ // JavaFn { class_name, method, output_schema } with the "java_fn" tag.
+ assertThat(json).contains("\"type\":\"java_fn\"");
+ assertThat(json).contains("\"class_name\":\"com.example.AgentTools\"");
+ assertThat(json).contains("\"method\":\"dispatch\"");
+ assertThat(json).contains("\"output_schema\":\"\"");
+ // It must NOT leak the camelCase Java property name.
+ assertThat(json).doesNotContain("className");
+
+ NodeKind deserialized = mapper.readValue(json, NodeKind.class);
+ assertThat(deserialized).isEqualTo(original);
+ assertThat(deserialized).isInstanceOf(NodeKind.JavaFn.class);
+ }
+
+ @Test
+ void modelNodeCarriesToolSchemas() throws JsonProcessingException {
+ var schema = Map.of(
+ "type", "function",
+ "function", Map.of(
+ "name", "web_search",
+ "description", "Search the web.",
+ "parameters", Map.of(
+ "type", "object",
+ "properties", Map.of("query", "string"),
+ "required", List.of("query"))));
+ NodeKind original = new NodeKind.Model("gpt4", "", "", "be helpful", List.of(schema));
+ String json = mapper.writeValueAsString(original);
+
+ assertThat(json).contains("\"type\":\"model\"");
+ assertThat(json).contains("\"tools\":[");
+ assertThat(json).contains("\"name\":\"web_search\"");
+
+ NodeKind deserialized = mapper.readValue(json, NodeKind.class);
+ assertThat(deserialized).isEqualTo(original);
+ assertThat(((NodeKind.Model) deserialized).tools()).hasSize(1);
+ }
+
+ @Test
+ void modelNodeWithoutToolsEmitsEmptyToolsArray() throws JsonProcessingException {
+ // The 4-arg back-compat constructor offers no tools; it serializes as
+ // "tools":[] (never null), matching the Rust #[serde(default)] Vec and
+ // the Python final-answer node's "tools": [].
+ NodeKind original = new NodeKind.Model("gpt4", "", "", null);
+ String json = mapper.writeValueAsString(original);
+ assertThat(json).contains("\"tools\":[]");
+
+ NodeKind deserialized = mapper.readValue(json, NodeKind.class);
+ assertThat(deserialized).isEqualTo(original);
+ assertThat(((NodeKind.Model) deserialized).tools()).isEmpty();
+ }
+
+ @Test
+ void modelToolSchemasAreDeeplyImmutable() {
+ // A Model node freezes its tool schemas all the way down: mutating the SOURCE
+ // structures after construction must not leak in, and the node's nested maps are
+ // themselves unmodifiable (the outer-list-only copy of List.copyOf was not enough).
+ Map nestedProps = new LinkedHashMap<>();
+ nestedProps.put("query", "string");
+ Map params = new LinkedHashMap<>();
+ params.put("type", "object");
+ params.put("properties", nestedProps);
+ Map tool = new LinkedHashMap<>();
+ tool.put("name", "web_search");
+ tool.put("parameters", params);
+ List> mutableTools = new ArrayList<>();
+ mutableTools.add(tool);
+
+ NodeKind.Model model = new NodeKind.Model("gpt4", "", "", "be helpful", mutableTools);
+
+ // Mutating the source structures must NOT affect the node (deep copy).
+ nestedProps.put("injected", "boom");
+ mutableTools.add(Map.of("name", "evil"));
+
+ assertThat(model.tools()).hasSize(1);
+ @SuppressWarnings("unchecked")
+ Map p = (Map) model.tools().get(0).get("parameters");
+ @SuppressWarnings("unchecked")
+ Map props = (Map) p.get("properties");
+ assertThat(props).doesNotContainKey("injected");
+
+ // The node's nested maps are unmodifiable.
+ assertThatThrownBy(() -> model.tools().get(0).put("x", "y"))
+ .isInstanceOf(UnsupportedOperationException.class);
+ assertThatThrownBy(() -> props.put("x", "y"))
+ .isInstanceOf(UnsupportedOperationException.class);
+ }
+
+ @Test
+ void deserializesOlderModelJsonWithoutToolsFieldToEmpty() throws JsonProcessingException {
+ // Back-compat: older Model JSON had NO `tools` field at all. Jackson must default
+ // it to an empty list (compact ctor maps null -> List.of()), matching the Rust
+ // #[serde(default)] Vec, and round-trip to the canonical "tools":[] shape.
+ String olderJson = "{\"type\":\"model\",\"model_ref\":\"gpt4\","
+ + "\"prompt_ref\":\"summarize\",\"output_schema\":\"{}\","
+ + "\"system_prompt\":\"You are helpful\"}";
+
+ NodeKind deserialized = mapper.readValue(olderJson, NodeKind.class);
+ assertThat(deserialized).isInstanceOf(NodeKind.Model.class);
+ assertThat(((NodeKind.Model) deserialized).tools()).isEmpty();
+
+ String reserialized = mapper.writeValueAsString(deserialized);
+ assertThat(reserialized).contains("\"tools\":[]");
+ assertThat(mapper.readValue(reserialized, NodeKind.class)).isEqualTo(deserialized);
+ }
+
+ @Test
+ void computedHelpersNeverSerialize() throws JsonProcessingException {
+ // queueType()/isDurable() are computed routing helpers, not IR fields:
+ // they must not leak onto the wire (the Rust NodeKind has no such field).
+ for (NodeKind kind : List.of(
+ new NodeKind.Model("m", null, null, null),
+ new NodeKind.JavaFn("C", "m", ""),
+ new NodeKind.Condition(List.of()))) {
+ String json = mapper.writeValueAsString(kind);
+ assertThat(json).doesNotContain("durable");
+ assertThat(json).doesNotContain("queue_type");
+ assertThat(json).doesNotContain("queueType");
+ }
+ }
+
@Test
void toolNodeRoundTrip() throws JsonProcessingException {
NodeKind original = new NodeKind.Tool("search_tool", Map.of("query", "$.input.query"), "{}");
@@ -83,6 +211,7 @@ void queueTypeMapping() {
assertThat(new NodeKind.Model("m", null, null, null).queueType()).isEqualTo(QueueType.MODEL);
assertThat(new NodeKind.Tool("t", null, null).queueType()).isEqualTo(QueueType.TOOL);
assertThat(new NodeKind.PythonFn("m", "f", null).queueType()).isEqualTo(QueueType.PYTHON_TOOL);
+ assertThat(new NodeKind.JavaFn("C", "m", null).queueType()).isEqualTo(QueueType.JAVA_TOOL);
assertThat(new NodeKind.MemoryRetrieval("c", "q", null).queueType()).isEqualTo(QueueType.RETRIEVAL);
assertThat(new NodeKind.Finalizer("t", FinalizerTrigger.ALWAYS).queueType()).isEqualTo(QueueType.TOOL);
assertThat(new NodeKind.McpTool("s", "t", null, null).queueType()).isEqualTo(QueueType.TOOL);
diff --git a/pom.xml b/pom.xml
index ee1a7a5..7fb4548 100644
--- a/pom.xml
+++ b/pom.xml
@@ -50,6 +50,7 @@
jamjet-spring-boot-starter
jamjet-cloud-sdk
jamjet-cloud-spring-boot-starter
+ jamjet-agent