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: + *
    + *
  1. the JamJet engine at {@link RunOptions#runtimeUrl()} (the + * {@code jamjet-server} that owns the {@code java_tool} queue);
  2. + *
  3. the model sidecar ({@code JAMJET_MODEL_SEAM_URL}) — the engine routes + * every governed model call through it (no Java model code);
  4. + *
  5. 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.
  6. + *
+ * + * @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: + *

    + *
  1. {@code __model_{t}__} — a {@link NodeKind.Model} carrying the agent's + * OpenAI tool schemas;
  2. + *
  3. {@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});
  4. + *
  5. {@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.
  6. + *
+ * 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: + *
    + *
  1. 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.
  2. + *
  3. 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.
  4. + *
+ * + *

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}): + *
    + *
  1. the JamJet engine ({@code jamjet-server}) at {@link RunOptions#runtimeUrl()} + * — it owns the {@code java_tool} queue;
  2. + *
  3. the model sidecar ({@code JAMJET_MODEL_SEAM_URL}) — the engine routes every + * governed model call through it;
  4. + *
  5. a {@link JavaToolWorker} draining the {@code java_tool} queue with this + * agent's tool registry, so {@code calculate} executes durably exactly-once.
  6. + *
+ * {@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