-
Notifications
You must be signed in to change notification settings - Fork 0
feat(agent): Java Agent builder + durable java_tool worker (Track 9 Phase B) #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
1cb01a0
feat(agent): bare-route engine client with work-item methods + WireMo…
sunilp ca34ead
feat(agent): @Tool + registry + Agent builder compiling to agent-loop…
sunilp 121300b
feat(agent): durable java_tool worker (fence + heartbeat + registry-g…
sunilp 005b1bf
feat(agent): Agent.runDurable + durable result extraction (B-4)
sunilp 4bed136
test(agent): worked governed durable agent example + e2e (B-5)
sunilp 1e6b376
docs(agent): honest PII scope on runDurable + snake_case terminal status
sunilp 74be915
fix(agent): address CodeRabbit review on Track 9 Phase B
sunilp File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| <?xml version="1.0" encoding="UTF-8"?> | ||
| <project xmlns="http://maven.apache.org/POM/4.0.0" | ||
| xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | ||
| xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> | ||
| <modelVersion>4.0.0</modelVersion> | ||
|
|
||
| <parent> | ||
| <groupId>dev.jamjet</groupId> | ||
| <artifactId>jamjet-runtime-java-parent</artifactId> | ||
| <version>0.3.1</version> | ||
| </parent> | ||
|
|
||
| <artifactId>jamjet-agent</artifactId> | ||
| <packaging>jar</packaging> | ||
|
|
||
| <name>JamJet Agent (Java)</name> | ||
| <description> | ||
| 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). | ||
| </description> | ||
|
|
||
| <dependencies> | ||
| <!-- Typed IR + the shared snake_case JamjetJson mapper (Rust-wire-compatible). --> | ||
| <dependency> | ||
| <groupId>dev.jamjet</groupId> | ||
| <artifactId>jamjet-runtime-core</artifactId> | ||
| <version>${project.version}</version> | ||
| </dependency> | ||
|
|
||
| <!-- JSON: same Jackson the parent project already pins. --> | ||
| <dependency> | ||
| <groupId>com.fasterxml.jackson.core</groupId> | ||
| <artifactId>jackson-databind</artifactId> | ||
| </dependency> | ||
|
|
||
| <!-- HTTP: java.net.http (built-in JDK 11+). No extra dep. --> | ||
|
|
||
| <!-- Test scope --> | ||
| <dependency> | ||
| <groupId>org.junit.jupiter</groupId> | ||
| <artifactId>junit-jupiter</artifactId> | ||
| <scope>test</scope> | ||
| </dependency> | ||
| <dependency> | ||
| <groupId>org.assertj</groupId> | ||
| <artifactId>assertj-core</artifactId> | ||
| <scope>test</scope> | ||
| </dependency> | ||
| <!-- Hermetic engine stub for the bare-route round-trip test. --> | ||
| <dependency> | ||
| <groupId>org.wiremock</groupId> | ||
| <artifactId>wiremock-standalone</artifactId> | ||
| <version>3.9.2</version> | ||
| <scope>test</scope> | ||
| </dependency> | ||
| <!-- SLF4J binding so WireMock/Jetty logging has a provider in tests. --> | ||
| <dependency> | ||
| <groupId>ch.qos.logback</groupId> | ||
| <artifactId>logback-classic</artifactId> | ||
| <scope>test</scope> | ||
| </dependency> | ||
| </dependencies> | ||
| </project> | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <em>same</em> 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}). | ||
| * | ||
| * <p>Construct via the {@link #builder(String)} fluent builder: | ||
| * <pre>{@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 | ||
| * }</pre> | ||
| * | ||
| * <p>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<String> 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}. | ||
| * | ||
| * <p>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). | ||
| * | ||
| * <h2>Required running services (mirrors the Python {@code run_durable})</h2> | ||
| * A durable run is NOT self-contained — three services must be running: | ||
| * <ol> | ||
| * <li><b>the JamJet engine</b> at {@link RunOptions#runtimeUrl()} (the | ||
| * {@code jamjet-server} that owns the {@code java_tool} queue);</li> | ||
| * <li><b>the model sidecar</b> ({@code JAMJET_MODEL_SEAM_URL}) — the engine routes | ||
| * every governed model call through it (no Java model code);</li> | ||
| * <li><b>a {@link dev.jamjet.agent.worker.JavaToolWorker}</b> 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.</li> | ||
| * </ol> | ||
| * | ||
| * @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<String> 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<String> 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<String> 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); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: jamjet-labs/jamjet-runtime-java
Length of output: 282
🏁 Script executed:
Repository: jamjet-labs/jamjet-runtime-java
Length of output: 16692
🏁 Script executed:
Repository: jamjet-labs/jamjet-runtime-java
Length of output: 2447
🌐 Web query:
Jackson databind 2.17.2 advisories CVE high severity 2025💡 Result:
As of June 28, 2026, Jackson Databind 2.17.2 is affected by several high-severity security vulnerabilities discovered in 2025 and 2026. Because 2.17.2 is no longer the latest maintained version, it remains vulnerable to these issues, and users are strongly advised to upgrade to a patched version (such as 2.18.8 or higher) [1][2][3][4]. Key vulnerabilities affecting version 2.17.2 include: 1. PolymorphicTypeValidator (PTV) Bypass (CVE-2026-54512): This high-severity vulnerability allows an attacker to bypass the PTV allow-list by using generic type parameters (e.g., ArrayList<com.evil.Gadget>), potentially leading to arbitrary class instantiation and remote code execution [1][2][5]. It affects all versions from 2.10.0 up to 2.18.7 [1][5]. 2. Array Subtype Allowlist Bypass (CVE-2026-54513): This issue exists in the BasicPolymorphicTypeValidator's allowIfSubTypeIsArray method, which fails to validate the component type of an array, allowing an attacker to instantiate non-allowlisted types [3]. It affects versions 2.10.0 through 2.18.7 [3]. 3. SSRF via InetSocketAddress (CVE-2026-54514): Deserializing InetSocketAddress objects triggers eager DNS resolution, which can be exploited for Server-Side Request Forgery (SSRF) or internal-resolver probing. This affects versions 2.0.0 through 2.18.7 [4]. Note on CVE-2025-52999: While widely reported as a high-severity denial-of-service vulnerability affecting jackson-core, it primarily impacts versions prior to 2.15.0 [6][7]. Jackson Databind 2.17.2 uses a more recent jackson-core and is not directly vulnerable to this specific stack-overflow issue [6]. To remediate these risks, you should update your project dependencies to use Jackson Databind 2.18.8 or a newer release [1][2][3][4].
Citations:
Bump the parent Jackson baseline.
jamjet-agent/pom.xmlinheritsjackson-databindfrom the root BOM, andpom.xml:61-79still pinsjackson.versionto2.17.2. Upgrade that shared version to a patched release (for example2.18.8+) so this module and the rest of the build stop pulling the vulnerable databind line.🤖 Prompt for AI Agents
Source: Linters/SAST tools