diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 718d5811..61abf90c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -532,6 +532,101 @@ jobs: # build falls back to the empty-asset stub. npm runs only here, in one controlled # job — never in the dockcross cross-compilers (which have no node) or per-platform. # --------------------------------------------------------------------------- + # --------------------------------------------------------------------------- + # llama-atmosphere-agent: the standalone (non-reactor, unpublished) local coding-agent + # project that wires Atmosphere's built-in OpenAI-compatible agent runtime to this + # project's OpenAiCompatServer. Two jobs, mirroring the langchain4j pair: + # - model-free: unit tests + the wire-contract tests, which drive the REAL + # OpenAiCompatServer over a loopback socket with a scripted backend (no native lib, + # no GGUF) and pin the streamed tool_calls / role=tool / multi-round shape — seconds, + # on every PR. + # - model-backed: the same loop against the cached Qwen2.5-1.5B tool model through the + # downloaded Linux native library (chat, streaming, tool call + result, read/write/read + # loop). Validation-only, not a publish gate: a small model's wording is not a release + # signal, the deterministic contract is the model-free job. + # The project is built with -Dllama.version= against the core that was just + # installed to the local repo, so it always tests the code of this checkout. + # --------------------------------------------------------------------------- + + test-java-llama-atmosphere-agent: + name: Build and Test llama-atmosphere-agent (model-free) + needs: startgate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-java@v6 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: temurin + - name: Install parent + core net.ladenthin:llama into the local repo (Java only) + run: > + mvn -B --no-transfer-progress -pl llama -am -DskipTests -Denforcer.skip=true + -Dspotless.check.skip=true -Dspotbugs.skip=true + -Dmaven.javadoc.skip=true -Dmaven.source.skip=true -Dgpg.skip=true install + - name: Resolve the reactor version + run: echo "VERSION=$(mvn -q -DforceStdout help:evaluate -Dexpression=project.version | tail -n1)" >> "$GITHUB_ENV" + - name: Spotless check + run: mvn -B --no-transfer-progress -f llama-atmosphere-agent/pom.xml "-Dllama.version=${VERSION}" spotless:check + - name: Build and test (unit + model-free wire contract against the real OpenAiCompatServer) + run: mvn -B --no-transfer-progress -f llama-atmosphere-agent/pom.xml "-Dllama.version=${VERSION}" verify + + test-java-llama-atmosphere-agent-integration: + name: Integration Test llama-atmosphere-agent (model-backed) + needs: [crosscompile-linux-x86_64, verify-model-cache] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Download Linux x86_64 native library (reused, not rebuilt) + uses: actions/download-artifact@v8 + with: + name: Linux-x86_64-libraries + path: ${{ github.workspace }}/llama/src/main/resources/net/ladenthin/llama/ + - name: Restore shared GGUF model cache (populated by download-models; no re-download) + uses: actions/cache/restore@v6 + with: + path: models/ + key: gguf-models-${{ hashFiles('.github/models.csv') }} + enableCrossOsArchive: true + - uses: actions/setup-java@v6 + with: + distribution: 'temurin' + java-version: ${{ env.JAVA_VERSION }} + - name: Install parent + core net.ladenthin:llama (bundles the downloaded native library) + run: > + mvn -B --no-transfer-progress -pl llama -am -DskipTests -Denforcer.skip=true + -Dspotless.check.skip=true -Dspotbugs.skip=true + -Dmaven.javadoc.skip=true -Dmaven.source.skip=true -Dgpg.skip=true install + - name: Resolve the reactor version + run: echo "VERSION=$(mvn -q -DforceStdout help:evaluate -Dexpression=project.version | tail -n1)" >> "$GITHUB_ENV" + - name: Run the Atmosphere tool-loop integration test (cached Qwen2.5-1.5B tool model, CPU) + run: > + mvn -B --no-transfer-progress -f llama-atmosphere-agent/pom.xml "-Dllama.version=${VERSION}" test + -Dtest=AtmosphereToolLoopIntegrationTest -Dsurefire.failIfNoSpecifiedTests=false + -Dnet.ladenthin.llama.tool.model=models/${TOOL_MODEL_NAME} + -Dnet.ladenthin.llama.test.ngl=0 + # Model-backed and crossing JNI: same crash diagnostics as the langchain4j integration job. + - name: Print crash logs (on failure) + if: failure() + shell: bash + run: | + shopt -s nullglob + for f in llama-atmosphere-agent/hs_err_pid*.log; do + echo "===== $f (first 200 lines; full file in the uploaded artifact) =====" + sed -n '1,200p' "$f" + done + for f in llama-atmosphere-agent/target/surefire-reports/*.dumpstream llama-atmosphere-agent/target/surefire-reports/*.dump; do + echo "===== $f =====" + cat "$f" + done + - if: failure() + uses: actions/upload-artifact@v7 + with: + name: error-log-atmosphere-agent-integration + path: | + ${{ github.workspace }}/llama-atmosphere-agent/hs_err_pid*.log + ${{ github.workspace }}/core.* + ${{ github.workspace }}/llama-atmosphere-agent/*.hprof + build-webui: name: Build WebUI assets (shared) needs: startgate diff --git a/CHANGELOG.md b/CHANGELOG.md index f7cd31bf..5289a3de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,23 @@ from version 5.0.0 onward. Pre-fork releases (`1.x`–`4.2.0`) were authored by ## [Unreleased] +### Added +- **`llama-atmosphere-agent/` — a local, offline JVM coding agent** (Claude Code / OpenCode reduced to + the essentials) that drives [Atmosphere](https://github.com/Atmosphere/atmosphere)'s built-in + OpenAI-compatible agent runtime **headless** (no Spring Boot, no servlet container) against this + project's `OpenAiCompatServer`: streaming, the model→tool→model loop, Atmosphere's workspace-confined + file tools and an opt-in `run_command` tool. Standalone Maven project (not a reactor module, not + published): `mvn compile exec:java -Dexec.args="--base-url http://127.0.0.1:8080/v1 …"` against a + running java-llama.cpp / llama-server, or `--model x.gguf` to host the model in-process. Verified two + ways and wired into CI: model-free wire-contract tests drive the *real* `OpenAiCompatServer` with a + scripted engine (tool-call deltas by index, parallel calls, four consecutive rounds with full history, + 401 handling, the one known Atmosphere gap on in-stream errors), and a model-backed job runs the loop + against the Qwen2.5-1.5B tool model. Result: Atmosphere works **unchanged** (verdict A). +- `OpenAiBackend`, `ChunkSink` and `OpenAiCompatServer(OpenAiBackend, OpenAiServerConfig)` are now + **public** — the inference-engine seam behind the OpenAI-compatible server, previously package-private + and used only by the core's own tests, so that sibling projects can drive the real HTTP surface + without a native library or model. + ### Changed - **BREAKING (runtime): the shipped SLF4J binding is now `slf4j-simple`, not `logback-classic`.** Two independent reasons, and the first is a hard failure rather than a preference: diff --git a/CLAUDE.md b/CLAUDE.md index 743f3755..93b0fe83 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2003,6 +2003,9 @@ missed again.) release version now appears in only ~4 spots here, not ~20 — the runtime details live once in the classifier table.) - **`llama-langchain4j/README.md`** — its own `` snippet. +- **`llama-atmosphere-agent/pom.xml`** — the `llama.version` property default (standalone project, + outside the reactor, so `versions:set` skips it), plus the `-Dllama.version=` snippets + in the root README's "Local coding agent" section and the project's own README. - **`llama-android/README.md`** and **`llama-kotlin/README.md`** — their Gradle dependency snippets, plus the `llama-android`/`llama-kotlin` snippets in the root README's "Importing in Android" section. @@ -2132,6 +2135,68 @@ snapshots to the Central snapshots repo (`publishAllPublicationsToCentralSnapsho releases as a signed Central Portal bundle upload (staging repo → zip → Publisher API). `llama-kotlin` rides the normal reactor `mvn -P release deploy`. +## Local coding agent with Atmosphere (`llama-atmosphere-agent/`, standalone) + +A **copy-and-run terminal coding agent** (Claude Code / OpenCode reduced to the essentials, offline) +that pairs [Atmosphere](https://github.com/Atmosphere/atmosphere)'s built-in OpenAI-compatible +agent runtime with this project's `OpenAiCompatServer`. Like `android-llmservice/` it is a +**standalone Maven project, NOT a reactor module and NOT published** — it is an application, and it +needs Java 21 (Atmosphere's floor) while the core stays Java 8. CI builds it against the core it just +installed (`-Dllama.version=`); a user copies the folder, sets a released +`llama.version`, and runs `mvn compile exec:java -Dexec.args="…"`. + +**What Atmosphere is, for this purpose.** `org.atmosphere:atmosphere-ai` (4.0.70) ships +`BuiltInAgentRuntime` + `OpenAiCompatibleClient`: a zero-framework OpenAI client that *always* +streams (`stream:true`), accumulates `delta.tool_calls` by `index`, executes `ToolDefinition` +executors, re-submits the conversation (assistant `tool_calls` message **without** a `content` key, +then one `role:"tool"` message per call with `tool_call_id` + `name`), and loops until +`finish_reason` is not `tool_calls`. It reads `LLM_BASE_URL`/`LLM_MODEL`/`LLM_API_KEY` or takes +`AiConfig.configure(mode, model, apiKey, baseUrl)`; `GET /models` is best-effort; the Responses API +is used only when the base URL contains `api.openai.com`; `tool_choice`/`parallel_tool_calls`/ +`response_format` are not sent. It runs headless — `runtime.execute(AgentExecutionContext, +StreamingSession)` — so no Spring Boot, servlet container or `@Agent` scanning is involved; its +built-in `FileSystemTools` resolve the `AgentFileSystem` from `StreamingSession.injectables()`, +which is how the tools are confined to a workspace. The `@Agent`/`@AiTool` annotations and the +Spring Boot starter are a deployment layer on top of the same runtime. + +**Verified compatibility (verdict A — works unchanged).** Two test layers, both in the project: + +- `AtmosphereWireContractTest` + `LocalAgentTest` — **model-free, every PR, seconds**: the *real* + `OpenAiCompatServer` (routing, bearer auth, `/v1/models`, SSE framing) over a loopback socket with + a `ScriptedBackend` replaying llama.cpp-shaped chunks (role delta, `tool_calls` deltas with + `index`/`id`/`name` and fragmented `arguments`, `finish_reason:"tool_calls"`). Pins: one tool + round; four rounds incl. a parallel pair with interleaved fragments and the whole history kept; + chunk-by-chunk streaming and history replay; `temperature`/`max_tokens` on the wire; 401 on a + wrong key before the backend is reached; and the **one known gap** — an engine failure *after* the + stream started is an SSE `data: {"error":…}` under HTTP 200 (upstream llama-server does the same), + which Atmosphere's parser ignores (it reads only `choices[0]`), so the turn completes with the + text so far instead of erroring. That is a SHOULD for Atmosphere's `OpenAiCompatibleClient`, not + for this project. +- `AtmosphereToolLoopIntegrationTest` — **model-backed, CI only** (`test-java-llama-atmosphere-agent-integration`, + validation-only, not a publish gate): the same loop against the cached Qwen2.5-1.5B tool model + through the downloaded Linux natives — plain chat, streaming (≥ 2 chunks), a tool call whose result + is answered, a read→write→read loop that changes a temp file. Self-skips without the GGUF. + +**The one core change this needed:** `OpenAiBackend`, `ChunkSink` and +`OpenAiCompatServer(OpenAiBackend, OpenAiServerConfig)` are now **public** (they were the +package-private test seam). A sibling module cannot otherwise drive the real server without a model; +the alternative — a same-named package in the sibling's test tree — is a split package that breaks +the moment anything runs on the module path. + +**Layout.** `AgentOptions` (CLI parsing, pure), `AgentRunner` (the whole Atmosphere wiring, ~40 +lines: `AiConfig.configure` → `BuiltInAgentRuntime` → `AgentExecutionContext` + `ToolLoopPolicies`), +`ConsoleSession` (streams to stdout, prints `⚙ tool {args}` / `↳ result`, supplies the +`WorkspaceAgentFileSystem` via `injectables()`), `ShellTool` (opt-in `run_command`, `sh -c` / +`cmd /c` in the workspace, timeout kills the process tree, output tail-truncated), `LocalAgent` +(`--base-url` = external server, `--model` = in-process `LlamaModel` + loopback `OpenAiCompatServer` +with `enableJinja()`, one-shot `--prompt` or a `you>` REPL with `/clear` `/exit`). Spotless (palantir) +is configured in its own pom; the model-free CI job runs `spotless:check`. + +**Version bump note.** The pom's `llama.version` property defaults to the current reactor version +(CI always overrides it). `versions:set` does not touch this standalone pom, so bump the default by +hand together with the two README snippets (`README.md` "Local coding agent" + the project's own +README) — the same class as the `llama-langchain4j/README.md` snippet. + ## Android app "LLM Service" (`android-llmservice/`) A shippable, **KISS fully-offline on-device chat app** consuming the `llama-android` AAR + diff --git a/README.md b/README.md index 7b8573f0..ec983ffb 100644 --- a/README.md +++ b/README.md @@ -1019,6 +1019,29 @@ See [`llama-langchain4j/README.md`](llama-langchain4j/) for streaming/embedding/ examples and the current mapping limitations (tool calling, JSON mode, and multimodal input are not yet forwarded). +### Local coding agent with Atmosphere (`llama-atmosphere-agent/`) + +A copy-and-run **terminal coding agent on the JVM** — Claude Code / OpenCode reduced to the +essentials, fully offline — built from [Atmosphere](https://github.com/Atmosphere/atmosphere)'s +built-in OpenAI-compatible agent runtime (streaming, tool loop, workspace file tools) driven +**headless** against this project's OpenAI-compatible server. It is a standalone Maven project (not a +reactor module, not published); you copy the folder and run it: + +```bash +# against a server you started (java-llama.cpp fat jar with --jinja, or llama-server) ... +mvn -q compile exec:java -Dllama.version= \ + -Dexec.args="--base-url http://127.0.0.1:8080/v1 --workspace /path/to/project --allow-shell" +# ... or with the GGUF loaded in-process +mvn -q compile exec:java -Dllama.version= \ + -Dexec.args="--model /models/Qwen2.5-7B-Instruct-Q4_K_M.gguf --ngl 99 --workspace /path/to/project" +``` + +The full streaming tool-calling loop (tools → `delta.tool_calls` → Java tool → `role:"tool"` result → +next turn, over several rounds) is verified on every PR against the real `OpenAiCompatServer` with +no model, and in CI against the Qwen2.5-1.5B tool model. See +[`llama-atmosphere-agent/README.md`](llama-atmosphere-agent/) for the options and the verified +compatibility matrix. + ### Model/Inference Configuration There are two sets of parameters you can configure, `ModelParameters` and `InferenceParameters`. Both provide builder diff --git a/TODO.md b/TODO.md index 5927fc88..17878234 100644 --- a/TODO.md +++ b/TODO.md @@ -17,6 +17,32 @@ so everything below is genuinely still open. ## Open — jllama-specific +### Atmosphere coding agent (`llama-atmosphere-agent/`) — follow-ups + +The headless loop is verified (see CLAUDE.md "Local coding agent with Atmosphere"). Still open: + +- **First model-backed CI run.** `test-java-llama-atmosphere-agent-integration` was added without a + run on GitHub's runners; the three assertions are about the loop (tool invoked, result answered, + file changed), but a 1.5B model on a CPU runner may still need a prompt or budget tweak. Read its + first run before trusting it as a signal. +- **Tool rounds are not carried across REPL turns** — only `user`/`assistant` text is replayed, so a + second question cannot refer to a tool result of the first. Keep the full Atmosphere + `ChatMessage` list (incl. `tool_calls`/`tool` messages) per turn instead. +- **Approval for destructive tools.** `write_file`/`delete`/`run_command` run unasked. Atmosphere's + `ToolDefinition.requiresApproval` + an `ApprovalStrategy` on the context would give a Claude-Code + style "allow this?" prompt on the console. +- **In-stream engine errors are swallowed by Atmosphere** (pinned in + `AtmosphereWireContractTest.midStreamEngineFailureCompletesSilentlyRatherThanErroring`): an SSE + `data: {"error":…}` after HTTP 200 is ignored by `OpenAiCompatibleClient.processSSELine` (it reads + only `choices[0]`). Worth an upstream PR to Atmosphere; until then the console shows an empty turn. +- **Spring Boot `@Agent` variant** (WebSocket/SSE UI via `atmosphere-ai-spring-boot-starter` and + `LLM_BASE_URL`) is expected to work on the same runtime but is not CI-covered; a smoke that boots + the starter against the scripted `OpenAiCompatServer` would close that. +- **Anthropic Messages surface.** The server also speaks `/v1/messages`; the Anthropic adapter + (`org.atmosphere:atmosphere-anthropic`) was not tested against it. +- **Model recommendation table** for the agent (which local GGUFs actually complete an + edit→build→test loop) — needs a GPU host, not CI. + ### LlamaLoader extraction-directory isolation (optional follow-up, low priority) Left over from the 2026-06-20 code audit (18/18 findings fixed in PRs #258/#260, regression tests in diff --git a/llama-atmosphere-agent/README.md b/llama-atmosphere-agent/README.md new file mode 100644 index 00000000..2cdefae6 --- /dev/null +++ b/llama-atmosphere-agent/README.md @@ -0,0 +1,147 @@ + + +# llama-atmosphere-agent — a local JVM coding agent on java-llama.cpp + +A minimal, copy-and-run **terminal coding agent** (think Claude Code / OpenCode, reduced to the +essentials) that runs entirely on the JVM and entirely offline: + +- **Model:** any GGUF served by java-llama.cpp's OpenAI-compatible HTTP surface — either a server + you start yourself, or the GGUF loaded **in this process**. +- **Agent:** [Atmosphere](https://github.com/Atmosphere/atmosphere)'s built-in OpenAI-compatible + runtime (`org.atmosphere:atmosphere-ai`): streaming, the model→tool→model loop, and its + workspace-confined file tools (`ls`, `read_file`, `write_file`, `edit_file`, `glob`, `grep`, + `delete`, `rename`). Driven **headless** — no Spring Boot, no servlet container, no `@Agent` + scanning — through `BuiltInAgentRuntime`. +- **Shell:** an opt-in `run_command` tool (`--allow-shell`) so the model can build and test. + +This folder is a **standalone Maven project**, deliberately *not* a reactor module and *not* +published: CI builds and tests it against the core of the same checkout; you copy the folder, set +`llama.version`, and run it. + +## Quick start + +Requirements: JDK 21+ and Maven. No native toolchain: the core jar ships the natives. + +**A. Against a server you run yourself** (you keep every llama.cpp flag): + +```bash +# 1. start java-llama.cpp's full upstream server (WebUI included) from the fat jar of a release; +# --jinja enables the model's tool-call template, which tool calling needs +java -jar llama--jar-with-dependencies.jar -m /models/Qwen2.5-7B-Instruct-Q4_K_M.gguf \ + --jinja --port 8080 --api-key sk-local +# (or upstream llama-server with the same flags — any OpenAI-compatible endpoint works) + +# 2. run the agent from this folder +mvn -q compile exec:java -Dllama.version= \ + -Dexec.args="--base-url http://127.0.0.1:8080/v1 --workspace /path/to/project --allow-shell" +``` + +**B. In-process** (one command, the GGUF is loaded into the agent's JVM and served over a loopback +`OpenAiCompatServer`): + +```bash +mvn -q compile exec:java -Dllama.version= \ + -Dexec.args="--model /models/Qwen2.5-7B-Instruct-Q4_K_M.gguf --ngl 99 --workspace /path/to/project" +``` + +GPU natives: pick the core classifier, e.g. `-Dllama.classifier=cuda13-linux-x86-64` or +`vulkan-windows-x86-64` (the vendor runtime must be installed — see the root README's classifier +table). Without it the default CPU jar (incl. macOS Metal) is used. + +Then type a request at the `you>` prompt (`/clear` drops the history, `/exit` quits), or run a single +turn with `--prompt "…"`. Streamed text appears as it is generated; every tool call and its result +are printed as `⚙ read_file {path=…}` / `↳ …` lines. + +### Options + +| Flag | Meaning | Default | +|---|---|---| +| `--base-url ` | OpenAI-compatible base URL of a running server | — | +| `--model ` | load this GGUF in-process instead | — | +| `--ngl ` / `--ctx-size ` | GPU layers / context size for `--model` | `0` / `8192` | +| `--workspace ` | directory the file tools (and `run_command`) are confined to | cwd | +| `--allow-shell` | register `run_command` | off | +| `--system ` | replace the default system prompt | built-in | +| `--prompt `, `-p` | one turn, then exit | interactive | +| `--temperature ` / `--max-tokens ` | sampling / per-call budget | `0.2` / `2048` | +| `--max-tool-rounds ` | tool rounds per turn | `25` | +| `--api-key ` / `--model-id ` | bearer token / `model` field | `sk-local` / `local-model` | + +Exactly one of `--base-url` / `--model` is required. Exit code 0 = turn completed, 1 = the turn +errored, 2 = usage error. Set `-Dorg.slf4j.simpleLogger.defaultLogLevel=debug` to see every request. + +Pick a **tool-capable instruct model** (Qwen2.5/Qwen3-Instruct, Llama-3.x-Instruct, Mistral, +Hermes, …). Quality of the loop is the model's: a 1.5B model calls one tool and reads its result, a +7B–32B model does multi-step edit/build/test work. + +## What is verified, and where + +| Feature | java-llama.cpp | Atmosphere needs it | Test | Change needed | +|---|---|---|---|---| +| `POST /v1/chat/completions` (always `stream:true`) | yes | yes | wire + model | none | +| SSE streaming, `data:`/`[DONE]`, chunk-by-chunk `delta.content` | yes | yes | wire + model | none | +| `system` / `user` / `assistant` / `tool` roles, history replay | yes | yes | wire + model | none | +| `tools` (JSON-Schema function definitions) | forwarded verbatim | yes | wire + model | none | +| streamed `delta.tool_calls` with `index`, `id`, `function.name`, fragmented `arguments` | yes (upstream chunk shape) | yes | wire | none | +| `finish_reason:"tool_calls"` ends the round | yes | yes | wire + model | none | +| assistant `tool_calls` message **without** `content` on the follow-up | accepted (`common_chat_msgs_parse_oaicompat`: `content` *or* `tool_calls`) | sent that way | wire | none | +| `role:"tool"` + `tool_call_id` (+ `name`) | forwarded verbatim | yes | wire + model | none | +| several tool calls in one turn (index 0/1, interleaved fragments) | yes | yes | wire | none | +| 3+ consecutive tool rounds, full history kept | yes | yes | wire (4 rounds) + model (read/write/read) | none | +| `temperature`, `max_tokens` | mapped to `temperature` / `n_predict` | sent | wire | none | +| `tool_choice`, `parallel_tool_calls`, `response_format`, `/v1/responses`, `/v1/embeddings` | available | **not used** by the built-in runtime (Responses API only for `api.openai.com`) | — | none | +| `GET /v1/models` | yes | optional (best-effort enumeration) | wire | none | +| API key | `--api-key` → `401` on mismatch | sends `Authorization: Bearer`; a dummy key is fine | wire | none | +| custom base URL | — | `LLM_BASE_URL` / `AiConfig.configure(...)` | wire + model | none | +| error before the stream starts (401, 413, 500) | HTTP status + JSON error | surfaced as `session.error` (5xx retried) | wire | none | +| **engine error after the stream started** | HTTP 200 already sent → `data: {"error":…}`, no `[DONE]` | **ignored**: the SSE parser reads only `choices[0]`, the turn completes with the text so far | wire (pinned as a known gap) | SHOULD, in Atmosphere's `OpenAiCompatibleClient` | + +*wire* = `AtmosphereWireContractTest` / `LocalAgentTest`: the **real** `OpenAiCompatServer` (routing, +bearer auth, `/v1/models`, SSE framing) over a loopback socket with a scripted engine replaying +llama.cpp-shaped chunks; no native library, no model, seconds, on every PR. *model* = +`AtmosphereToolLoopIntegrationTest`: the same loop against the Qwen2.5-1.5B-Instruct tool model in +CI (plain chat, streaming, a tool call whose result is answered, a read→write→read loop that +changes a temp file). It self-skips without the GGUF: + +```bash +mvn -f llama-atmosphere-agent/pom.xml test -Dtest=AtmosphereToolLoopIntegrationTest \ + -Dnet.ladenthin.llama.tool.model=models/Qwen2.5-1.5B-Instruct-Q4_K_M.gguf -Dnet.ladenthin.llama.test.ngl=0 +``` + +**Verdict: A — Atmosphere's built-in runtime runs a complete streaming tool-calling agent loop +against java-llama.cpp unchanged.** The only change on the java-llama.cpp side was to make the +existing test seam (`OpenAiBackend`, `ChunkSink`, the backend constructor of `OpenAiCompatServer`) +public so this project can drive the real server without a model. + +## How the wiring works (all of it) + +```java +AiConfig.LlmSettings settings = AiConfig.configure("local", modelId, apiKey, baseUrl); // LLM_MODE/LLM_MODEL/LLM_API_KEY/LLM_BASE_URL +BuiltInAgentRuntime runtime = new BuiltInAgentRuntime(); +runtime.configure(settings); + +List tools = new ArrayList<>(FileSystemTools.all()); // Atmosphere's file tools +tools.add(ShellTool.definition(workspace, Duration.ofSeconds(120), 20_000)); // optional + +AgentExecutionContext context = new AgentExecutionContext(message, systemPrompt, modelId, null, "console", + null, null, tools, null, null, List.of(), Map.of(), history, null, null); +context = ToolLoopPolicies.attach(context, ToolLoopPolicy.maxIterations(25)); +runtime.execute(context, session); // session.injectables() carries the AgentFileSystem the file tools resolve +``` + +`AgentRunner` is exactly that; `ConsoleSession` renders the stream and supplies the +`WorkspaceAgentFileSystem` (path-confined, size-limited) through `injectables()`; `LocalAgent` parses +the options and optionally hosts the model. The `@Agent`/`@AiTool` annotations and the Spring Boot +starter are the *deployment* layer on top of the same runtime — not needed for a local terminal agent. + +## Limitations / next steps + +- Tool rounds are not kept in the cross-turn history (only `user`/`assistant` text is replayed). +- No approval prompts for destructive tools yet (`ToolDefinition.requiresApproval` exists in Atmosphere). +- An engine error after the stream started ends the turn silently (see the table). +- The Spring Boot `@Agent` + WebSocket/SSE UI variant is untested here; it uses the same runtime and + the same `LLM_BASE_URL`, so it is expected to work but is not CI-covered. diff --git a/llama-atmosphere-agent/pom.xml b/llama-atmosphere-agent/pom.xml new file mode 100644 index 00000000..d6feebc9 --- /dev/null +++ b/llama-atmosphere-agent/pom.xml @@ -0,0 +1,161 @@ + + + + 4.0.0 + + + net.ladenthin + llama-atmosphere-agent + 1.0.0-SNAPSHOT + jar + + ${project.groupId}:${project.artifactId} + Local JVM coding agent: Atmosphere's built-in OpenAI-compatible agent runtime + (tool calling, streaming) driven against java-llama.cpp's OpenAI-compatible server. + https://github.com/bernardladenthin/java-llama.cpp + + + + MIT License + https://www.opensource.org/licenses/mit-license.php + repo + + + + + UTF-8 + + 21 + + 5.2.0-SNAPSHOT + + + 4.0.70 + 2.0.19 + 1.0.1 + 6.1.3 + 3.0 + 3.16.0 + 3.6.0 + 3.5.0 + 3.10.2 + 2.98.0 + net.ladenthin.llama.atmosphere.LocalAgent + + + + + + sonatype-snapshots + https://central.sonatype.com/repository/maven-snapshots/ + + false + + + true + + + + + + + + net.ladenthin + llama + ${llama.version} + ${llama.classifier} + + + + + org.atmosphere + atmosphere-ai + ${atmosphere.version} + + + + org.jspecify + jspecify + ${jspecify.version} + + + + org.slf4j + slf4j-simple + ${slf4j.version} + runtime + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + org.hamcrest + hamcrest + ${hamcrest.version} + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${compiler.plugin.version} + + + org.apache.maven.plugins + maven-surefire-plugin + ${surefire.version} + + + false + false + + + + + org.codehaus.mojo + exec-maven-plugin + ${exec.plugin.version} + + ${agent.main} + + false + + + + com.diffplug.spotless + spotless-maven-plugin + ${spotless.version} + + + + ${palantir-java-format.version} + + + + + + + diff --git a/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/AgentOptions.java b/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/AgentOptions.java new file mode 100644 index 00000000..fad23854 --- /dev/null +++ b/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/AgentOptions.java @@ -0,0 +1,323 @@ +// SPDX-FileCopyrightText: 2026 Bernard Ladenthin +// +// SPDX-License-Identifier: MIT + +package net.ladenthin.llama.atmosphere; + +import java.nio.file.Path; +import java.nio.file.Paths; +import org.jspecify.annotations.Nullable; + +/** + * Command-line options of {@link LocalAgent}, parsed without any framework so the parsing is a plain + * unit-testable function. + * + *

Exactly one of {@code --base-url} (connect to a running OpenAI-compatible server, typically + * java-llama.cpp's {@code NativeServer} or {@code OpenAiCompatServer}) and {@code --model} (load the + * GGUF in this JVM and serve it to the agent over a loopback {@code OpenAiCompatServer}) must be + * given. + */ +public final class AgentOptions { + + /** Bearer token sent by the agent; a local server that runs without {@code --api-key} ignores it. */ + public static final String DEFAULT_API_KEY = "sk-local"; + + /** Model id carried in every request; single-model servers ignore it, a router selects by it. */ + public static final String DEFAULT_MODEL_ID = "local-model"; + + /** Low temperature: coding agents want reproducible tool calls, not creative prose. */ + public static final double DEFAULT_TEMPERATURE = 0.2; + + /** Per-turn generation budget ({@code max_tokens}). */ + public static final int DEFAULT_MAX_TOKENS = 2048; + + /** Upper bound on model→tool→model rounds per user turn. */ + public static final int DEFAULT_MAX_TOOL_ROUNDS = 25; + + /** Context size for the in-process model ({@code --model}). */ + public static final int DEFAULT_CTX_SIZE = 8192; + + private final @Nullable String baseUrl; + private final @Nullable String modelPath; + private final int gpuLayers; + private final int ctxSize; + private final String apiKey; + private final String modelId; + private final Path workspace; + private final boolean allowShell; + private final double temperature; + private final int maxTokens; + private final int maxToolRounds; + private final @Nullable String systemPrompt; + private final @Nullable String prompt; + private final boolean help; + + private AgentOptions(Builder b) { + this.baseUrl = b.baseUrl; + this.modelPath = b.modelPath; + this.gpuLayers = b.gpuLayers; + this.ctxSize = b.ctxSize; + this.apiKey = b.apiKey; + this.modelId = b.modelId; + this.workspace = b.workspace; + this.allowShell = b.allowShell; + this.temperature = b.temperature; + this.maxTokens = b.maxTokens; + this.maxToolRounds = b.maxToolRounds; + this.systemPrompt = b.systemPrompt; + this.prompt = b.prompt; + this.help = b.help; + } + + /** + * Parse the command line. + * + * @param args the raw arguments + * @return the parsed options + * @throws IllegalArgumentException on an unknown flag, a missing value, or when neither/both of + * {@code --base-url} and {@code --model} are given + */ + public static AgentOptions parse(String[] args) { + Builder b = new Builder(); + for (int i = 0; i < args.length; i++) { + String a = args[i]; + switch (a) { + case "-h", "--help" -> b.help = true; + case "--allow-shell" -> b.allowShell = true; + case "--base-url" -> b.baseUrl = stripTrailingSlash(value(args, ++i, a)); + case "--model" -> b.modelPath = value(args, ++i, a); + case "--ngl", "--gpu-layers" -> b.gpuLayers = intValue(args, ++i, a); + case "--ctx-size" -> b.ctxSize = intValue(args, ++i, a); + case "--api-key" -> b.apiKey = value(args, ++i, a); + case "--model-id" -> b.modelId = value(args, ++i, a); + case "--workspace" -> + b.workspace = + Paths.get(value(args, ++i, a)).toAbsolutePath().normalize(); + case "--temperature" -> b.temperature = Double.parseDouble(value(args, ++i, a)); + case "--max-tokens" -> b.maxTokens = intValue(args, ++i, a); + case "--max-tool-rounds" -> b.maxToolRounds = intValue(args, ++i, a); + case "--system" -> b.systemPrompt = value(args, ++i, a); + case "--prompt", "-p" -> b.prompt = value(args, ++i, a); + default -> throw new IllegalArgumentException("Unknown argument: " + a); + } + } + if (!b.help) { + if ((b.baseUrl == null) == (b.modelPath == null)) { + throw new IllegalArgumentException("Exactly one of --base-url or --model is required"); + } + } + return new AgentOptions(b); + } + + private static String value(String[] args, int index, String flag) { + if (index >= args.length) { + throw new IllegalArgumentException("Missing value for " + flag); + } + return args[index]; + } + + private static int intValue(String[] args, int index, String flag) { + String raw = value(args, index, flag); + try { + return Integer.parseInt(raw); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Expected an integer for " + flag + ", got: " + raw, e); + } + } + + private static String stripTrailingSlash(String url) { + return url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + } + + /** + * The usage text. + * + * @return one line per option + */ + public static String usage() { + return String.join( + System.lineSeparator(), + "Usage: LocalAgent (--base-url | --model ) [options]", + "", + "Endpoint (exactly one):", + " --base-url OpenAI-compatible base URL of a running server,", + " e.g. http://127.0.0.1:8080/v1 (java-llama.cpp NativeServer", + " started with --jinja, OpenAiCompatServer, or llama-server)", + " --model load this GGUF in-process and serve it to the agent", + " --ngl GPU layers for --model (default 0 = CPU only)", + " --ctx-size context size for --model (default " + DEFAULT_CTX_SIZE + ")", + "", + "Agent:", + " --workspace

directory the file tools are confined to (default: cwd)", + " --allow-shell add the run_command tool (runs shell commands in the workspace)", + " --system replace the default system prompt", + " --prompt , -p run one turn and exit (default: interactive; /exit to quit)", + " --temperature sampling temperature (default " + DEFAULT_TEMPERATURE + ")", + " --max-tokens max_tokens per model call (default " + DEFAULT_MAX_TOKENS + ")", + " --max-tool-rounds tool rounds per turn (default " + DEFAULT_MAX_TOOL_ROUNDS + ")", + " --api-key bearer token (default " + DEFAULT_API_KEY + ")", + " --model-id model id in requests (default " + DEFAULT_MODEL_ID + ")", + " -h, --help this text"); + } + + /** + * External endpoint, or {@code null} in in-process mode. + * + * @return the base URL without a trailing slash + */ + public @Nullable String getBaseUrl() { + return baseUrl; + } + + /** + * GGUF to load in-process, or {@code null} when connecting to an external server. + * + * @return the model path + */ + public @Nullable String getModelPath() { + return modelPath; + } + + /** + * GPU layers for the in-process model. + * + * @return the layer count, {@code 0} for CPU only + */ + public int getGpuLayers() { + return gpuLayers; + } + + /** + * Context size for the in-process model. + * + * @return the context size in tokens + */ + public int getCtxSize() { + return ctxSize; + } + + /** + * Bearer token. + * + * @return the API key + */ + public String getApiKey() { + return apiKey; + } + + /** + * Model id sent in requests. + * + * @return the model id + */ + public String getModelId() { + return modelId; + } + + /** + * Root directory of the file tools. + * + * @return the absolute, normalized workspace path + */ + public Path getWorkspace() { + return workspace; + } + + /** + * Whether the {@code run_command} tool is registered. + * + * @return {@code true} when shell access was opted into + */ + public boolean isAllowShell() { + return allowShell; + } + + /** + * Sampling temperature. + * + * @return the temperature + */ + public double getTemperature() { + return temperature; + } + + /** + * Generation budget per model call. + * + * @return {@code max_tokens} + */ + public int getMaxTokens() { + return maxTokens; + } + + /** + * Tool-round cap per user turn. + * + * @return the maximum number of tool rounds + */ + public int getMaxToolRounds() { + return maxToolRounds; + } + + /** + * System prompt override. + * + * @return the prompt, or {@code null} for the built-in default + */ + public @Nullable String getSystemPrompt() { + return systemPrompt; + } + + /** + * One-shot prompt. + * + * @return the prompt, or {@code null} for interactive mode + */ + public @Nullable String getPrompt() { + return prompt; + } + + /** + * Whether {@code --help} was given. + * + * @return {@code true} to print usage and exit + */ + public boolean isHelp() { + return help; + } + + @Override + public String toString() { + return "AgentOptions{baseUrl=" + baseUrl + ", modelPath=" + modelPath + ", gpuLayers=" + gpuLayers + + ", ctxSize=" + ctxSize + ", modelId=" + modelId + ", workspace=" + workspace + + ", allowShell=" + allowShell + ", temperature=" + temperature + ", maxTokens=" + maxTokens + + ", maxToolRounds=" + maxToolRounds + ", prompt=" + (prompt == null ? "" : "") + + "}"; + } + + private static final class Builder { + @Nullable + String baseUrl; + + @Nullable + String modelPath; + + int gpuLayers = 0; + int ctxSize = DEFAULT_CTX_SIZE; + String apiKey = DEFAULT_API_KEY; + String modelId = DEFAULT_MODEL_ID; + Path workspace = Paths.get("").toAbsolutePath().normalize(); + boolean allowShell; + double temperature = DEFAULT_TEMPERATURE; + int maxTokens = DEFAULT_MAX_TOKENS; + int maxToolRounds = DEFAULT_MAX_TOOL_ROUNDS; + + @Nullable + String systemPrompt; + + @Nullable + String prompt; + + boolean help; + } +} diff --git a/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/AgentRunner.java b/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/AgentRunner.java new file mode 100644 index 00000000..3ca6c9bd --- /dev/null +++ b/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/AgentRunner.java @@ -0,0 +1,133 @@ +// SPDX-FileCopyrightText: 2026 Bernard Ladenthin +// +// SPDX-License-Identifier: MIT + +package net.ladenthin.llama.atmosphere; + +import java.util.List; +import java.util.Map; +import org.atmosphere.ai.AgentExecutionContext; +import org.atmosphere.ai.AiConfig; +import org.atmosphere.ai.RetryPolicy; +import org.atmosphere.ai.StreamingSession; +import org.atmosphere.ai.llm.BuiltInAgentRuntime; +import org.atmosphere.ai.llm.ChatMessage; +import org.atmosphere.ai.llm.ToolLoopPolicies; +import org.atmosphere.ai.llm.ToolLoopPolicy; +import org.atmosphere.ai.tool.ToolDefinition; + +/** + * The minimal wiring between Atmosphere's built-in OpenAI-compatible agent runtime and an + * OpenAI-compatible base URL — no Spring Boot, no servlet container, no {@code @Agent} scanning. + * + *

One instance is one configured endpoint plus one tool set. Each {@link #run} call is one user turn: + * Atmosphere streams the model's answer into the session, executes every {@code tool_calls} round + * through the registered {@link ToolDefinition} executors, re-submits the tool results, and completes + * the session when the model produces a final answer (or the round cap is hit). + * + *

Atmosphere resolves its settings through a process-wide {@link AiConfig} singleton; constructing + * a runner (re)configures it, so build one runner per endpoint and reuse it. + */ +public final class AgentRunner { + + private final BuiltInAgentRuntime runtime; + private final String modelId; + private final List tools; + private final String systemPrompt; + private final int maxToolRounds; + private RetryPolicy retryPolicy = RetryPolicy.DEFAULT; + + /** + * Configure the runtime for one endpoint. + * + * @param baseUrl the OpenAI-compatible base URL, e.g. {@code http://127.0.0.1:8080/v1} + * @param apiKey the bearer token (sent as {@code Authorization: Bearer ...}) + * @param modelId the model id carried in every request + * @param tools the tools offered to the model on every turn + * @param systemPrompt the system prompt + * @param temperature the sampling temperature + * @param maxTokens the {@code max_tokens} budget per model call + * @param maxToolRounds the tool-round cap per turn + */ + public AgentRunner( + String baseUrl, + String apiKey, + String modelId, + List tools, + String systemPrompt, + double temperature, + int maxTokens, + int maxToolRounds) { + // GenerationParams are read from system properties when the settings are built. + System.setProperty(AiConfig.TEMPERATURE_PROPERTY, Double.toString(temperature)); + System.setProperty(AiConfig.MAX_TOKENS_PROPERTY, Integer.toString(maxTokens)); + // "local" mode: no provider auto-detection, and an explicit base URL always wins. + AiConfig.LlmSettings settings = AiConfig.configure("local", modelId, apiKey, baseUrl); + this.runtime = new BuiltInAgentRuntime(); + this.runtime.configure(settings); + this.modelId = modelId; + this.tools = List.copyOf(tools); + this.systemPrompt = systemPrompt; + this.maxToolRounds = maxToolRounds; + } + + /** + * Replace the HTTP retry policy (default: Atmosphere's, which retries 429/5xx and connection + * failures). Tests use {@link RetryPolicy#NONE} to make a failing endpoint fail fast. + * + * @param retryPolicy the policy + * @return this runner + */ + public AgentRunner retryPolicy(RetryPolicy retryPolicy) { + this.retryPolicy = retryPolicy; + return this; + } + + /** + * The model ids the endpoint advertises on {@code GET /v1/models}, falling back to the configured + * id when enumeration fails. + * + * @return the model ids + */ + public List models() { + return runtime.models(); + } + + /** + * The tool names offered on every turn. + * + * @return the names in registration order + */ + public List toolNames() { + return tools.stream().map(ToolDefinition::name).toList(); + } + + /** + * Run one user turn to completion. Returns when the session has been completed or errored. + * + * @param message the user message + * @param history prior turns ({@code user}/{@code assistant} messages), replayed before the message + * @param session receives streamed text, tool events and the terminal complete/error + */ + public void run(String message, List history, StreamingSession session) { + AgentExecutionContext context = new AgentExecutionContext( + message, + systemPrompt, + modelId, + null, + session.sessionId(), + null, + null, + tools, + null, + null, + List.of(), + Map.of(), + history, + null, + null); + context = context.withRetryPolicy(retryPolicy); + context = ToolLoopPolicies.attach(context, ToolLoopPolicy.maxIterations(maxToolRounds)); + runtime.execute(context, session); + } +} diff --git a/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/ConsoleSession.java b/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/ConsoleSession.java new file mode 100644 index 00000000..2da67842 --- /dev/null +++ b/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/ConsoleSession.java @@ -0,0 +1,184 @@ +// SPDX-FileCopyrightText: 2026 Bernard Ladenthin +// +// SPDX-License-Identifier: MIT + +package net.ladenthin.llama.atmosphere; + +import java.io.PrintStream; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import org.atmosphere.ai.AiEvent; +import org.atmosphere.ai.StreamingSession; +import org.atmosphere.ai.fs.AgentFileSystem; +import org.jspecify.annotations.Nullable; + +/** + * A {@link StreamingSession} that prints one agent turn to a console: streamed text as it arrives, + * one line per tool call and tool result, and the terminal state. + * + *

It also carries the {@link AgentFileSystem} the built-in file tools resolve at execution time: + * Atmosphere passes {@link #injectables()} into every tool executor, and {@code FileSystemTools} + * looks the filesystem up there — this is how the tools are confined to the workspace without any + * framework wiring. + */ +public final class ConsoleSession implements StreamingSession { + + private static final int RESULT_PREVIEW_CHARS = 400; + + private final PrintStream out; + private final Map, Object> injectables; + private final StringBuilder text = new StringBuilder(); + private final List chunks = new CopyOnWriteArrayList<>(); + private final CountDownLatch done = new CountDownLatch(1); + private volatile @Nullable Throwable failure; + private volatile int toolCalls; + + /** + * Create a session printing to {@code out}. + * + * @param out where streamed text and tool lines go + * @param fileSystem the workspace-confined filesystem handed to the file tools + */ + public ConsoleSession(PrintStream out, AgentFileSystem fileSystem) { + this.out = out; + this.injectables = Map.of(AgentFileSystem.class, fileSystem); + } + + @Override + public String sessionId() { + return "console"; + } + + @Override + public Map, Object> injectables() { + return injectables; + } + + @Override + public void send(String chunk) { + chunks.add(chunk); + text.append(chunk); + out.print(chunk); + out.flush(); + } + + @Override + public void sendMetadata(String key, Object value) { + // token usage, model id, tool-call argument deltas: not shown on the console + } + + @Override + public void progress(String message) { + // "Connecting to built-in..." and friends: not shown on the console + } + + @Override + public void complete() { + out.println(); + out.flush(); + done.countDown(); + } + + @Override + public void complete(String summary) { + if (summary != null && text.length() == 0) { + send(summary); + } + complete(); + } + + @Override + public void error(Throwable t) { + failure = t; + out.println(); + out.println("[error] " + t); + out.flush(); + done.countDown(); + } + + @Override + public boolean isClosed() { + return done.getCount() == 0; + } + + @Override + public void emit(AiEvent event) { + switch (event) { + case AiEvent.ToolStart start -> { + toolCalls++; + if (text.length() > 0 && text.charAt(text.length() - 1) != '\n') { + out.println(); + } + out.println("⚙ " + start.toolName() + " " + start.arguments()); + out.flush(); + } + case AiEvent.ToolResult result -> { + out.println("↳ " + preview(String.valueOf(result.result()))); + out.flush(); + } + case AiEvent.ToolError error -> { + out.println("↳ error: " + error.error()); + out.flush(); + } + default -> StreamingSession.super.emit(event); + } + } + + private static String preview(String value) { + String oneLine = value.replace("\r\n", "\n").replace('\n', ' '); + return oneLine.length() <= RESULT_PREVIEW_CHARS + ? oneLine + : oneLine.substring(0, RESULT_PREVIEW_CHARS) + " … (" + value.length() + " chars)"; + } + + /** + * Block until the turn completed or errored. + * + * @param timeout how long to wait + * @return {@code true} if the session terminated within the timeout + * @throws InterruptedException if interrupted while waiting + */ + public boolean await(Duration timeout) throws InterruptedException { + return done.await(timeout.toMillis(), TimeUnit.MILLISECONDS); + } + + /** + * The streamed assistant text of this turn. + * + * @return the text so far + */ + public String text() { + return text.toString(); + } + + /** + * Every streamed text chunk of this turn, in arrival order. + * + * @return the chunks as delivered by the server (one SSE {@code delta.content} each) + */ + public List chunks() { + return List.copyOf(chunks); + } + + /** + * The terminal error, if the turn failed. + * + * @return the throwable passed to {@link #error}, or {@code null} + */ + public @Nullable Throwable failure() { + return failure; + } + + /** + * How many tool calls the model made in this turn. + * + * @return the count of {@code ToolStart} events + */ + public int toolCalls() { + return toolCalls; + } +} diff --git a/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/LocalAgent.java b/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/LocalAgent.java new file mode 100644 index 00000000..c23de408 --- /dev/null +++ b/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/LocalAgent.java @@ -0,0 +1,215 @@ +// SPDX-FileCopyrightText: 2026 Bernard Ladenthin +// +// SPDX-License-Identifier: MIT + +package net.ladenthin.llama.atmosphere; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import net.ladenthin.llama.LlamaModel; +import net.ladenthin.llama.parameters.ModelParameters; +import net.ladenthin.llama.server.OpenAiCompatServer; +import net.ladenthin.llama.server.OpenAiServerConfig; +import org.atmosphere.ai.fs.AgentFileSystem; +import org.atmosphere.ai.fs.FileSystemTools; +import org.atmosphere.ai.fs.WorkspaceAgentFileSystem; +import org.atmosphere.ai.llm.ChatMessage; +import org.atmosphere.ai.tool.ToolDefinition; +import org.jspecify.annotations.Nullable; + +/** + * A local, terminal coding agent in the spirit of Claude Code / OpenCode, built from two parts that + * already exist: Atmosphere's built-in OpenAI-compatible agent runtime (streaming, tool loop, + * workspace file tools) and java-llama.cpp's OpenAI-compatible server. + * + *

Two ways to reach a model: + * + *

    + *
  • {@code --base-url http://127.0.0.1:8080/v1} — a server you started yourself (java-llama.cpp's + * fat jar {@code NativeServer} with {@code --jinja}, its {@code OpenAiCompatServer}, or upstream + * {@code llama-server}), so you keep full control over model parameters. + *
  • {@code --model model.gguf} — loads the GGUF in this JVM and serves it to the agent over a + * loopback {@link OpenAiCompatServer}: one process, one command. + *
+ * + *

Run from the source tree: {@code mvn -q compile exec:java -Dexec.args="--base-url ... --workspace + * /path --allow-shell"}. Exit code 0 on a completed turn, 1 when the turn errored, 2 on bad usage. + */ +public final class LocalAgent { + + /** Wall-clock bound on one user turn, including every tool round. */ + private static final Duration TURN_TIMEOUT = Duration.ofMinutes(30); + + private static final Duration SHELL_TIMEOUT = Duration.ofSeconds(120); + private static final int SHELL_MAX_OUTPUT_CHARS = 20_000; + + private LocalAgent() {} + + /** + * Entry point. + * + * @param args see {@link AgentOptions#usage()} + * @throws Exception on an unrecoverable setup failure (model load, socket bind) + */ + public static void main(String[] args) throws Exception { + AgentOptions options; + try { + options = AgentOptions.parse(args); + } catch (IllegalArgumentException e) { + System.err.println(e.getMessage()); + System.err.println(AgentOptions.usage()); + System.exit(2); + return; + } + if (options.isHelp()) { + System.out.println(AgentOptions.usage()); + return; + } + System.exit(run( + options, + System.in == null ? null : new InputStreamReader(System.in, StandardCharsets.UTF_8), + System.out, + System.err)); + } + + /** + * Run the agent with parsed options. + * + * @param options the options + * @param input the interactive input (ignored in one-shot mode), or {@code null} for none + * @param out the console the answer streams to + * @param err diagnostics + * @return the process exit code + * @throws Exception on an unrecoverable setup failure + */ + static int run(AgentOptions options, java.io.@Nullable Reader input, PrintStream out, PrintStream err) + throws Exception { + LlamaModel model = null; + OpenAiCompatServer server = null; + String baseUrl = options.getBaseUrl(); + try { + if (options.getModelPath() != null) { + err.println("Loading " + options.getModelPath() + " (gpu layers: " + options.getGpuLayers() + ", ctx: " + + options.getCtxSize() + ") ..."); + model = new LlamaModel(modelParameters(options)); + server = new OpenAiCompatServer( + model, + OpenAiServerConfig.builder() + .host("127.0.0.1") + .port(0) + .apiKey(options.getApiKey()) + .modelId(options.getModelId()) + .build()) + .start(); + baseUrl = "http://127.0.0.1:" + server.getPort() + "/v1"; + } + if (baseUrl == null) { + throw new IllegalStateException("no endpoint"); + } + AgentFileSystem fileSystem = + new WorkspaceAgentFileSystem(options.getWorkspace(), AgentFileSystem.Limits.defaults()); + List tools = new ArrayList<>(FileSystemTools.all()); + if (options.isAllowShell()) { + tools.add(ShellTool.definition(options.getWorkspace(), SHELL_TIMEOUT, SHELL_MAX_OUTPUT_CHARS)); + } + AgentRunner runner = new AgentRunner( + baseUrl, + options.getApiKey(), + options.getModelId(), + tools, + systemPrompt(options), + options.getTemperature(), + options.getMaxTokens(), + options.getMaxToolRounds()); + err.println("Endpoint " + baseUrl + " models=" + runner.models() + " workspace=" + options.getWorkspace() + + " tools=" + runner.toolNames()); + + List history = new ArrayList<>(); + if (options.getPrompt() != null) { + return turn(runner, fileSystem, options.getPrompt(), history, out) ? 0 : 1; + } + if (input == null) { + err.println("No interactive input available; pass --prompt ."); + return 2; + } + BufferedReader reader = new BufferedReader(input); + err.println("Interactive mode: type a request, /clear to drop the history, /exit to quit."); + while (true) { + out.print("you> "); + out.flush(); + String line = reader.readLine(); + if (line == null || line.trim().equals("/exit") || line.trim().equals("/quit")) { + return 0; + } + if (line.trim().isEmpty()) { + continue; + } + if (line.trim().equals("/clear")) { + history.clear(); + err.println("(history cleared)"); + continue; + } + turn(runner, fileSystem, line, history, out); + } + } finally { + if (server != null) { + server.close(); + } + if (model != null) { + model.close(); + } + } + } + + private static boolean turn( + AgentRunner runner, AgentFileSystem fileSystem, String message, List history, PrintStream out) + throws InterruptedException { + ConsoleSession session = new ConsoleSession(out, fileSystem); + runner.run(message, history, session); + boolean finished = session.await(TURN_TIMEOUT); + history.add(ChatMessage.user(message)); + if (!session.text().isEmpty()) { + history.add(ChatMessage.assistant(session.text())); + } + return finished && session.failure() == null; + } + + private static ModelParameters modelParameters(AgentOptions options) { + ModelParameters parameters = new ModelParameters() + .setModel(options.getModelPath()) + .setCtxSize(options.getCtxSize()) + .setGpuLayers(options.getGpuLayers()) + .setFit(false) + // Jinja rendering is what lets the native parser apply the model's tool-call template. + .enableJinja(); + if (options.getGpuLayers() == 0) { + parameters.setDevices("none"); + } + return parameters; + } + + /** + * The default system prompt, or the {@code --system} override. + * + * @param options the options + * @return the system prompt + */ + static String systemPrompt(AgentOptions options) { + if (options.getSystemPrompt() != null) { + return options.getSystemPrompt(); + } + String shell = options.isAllowShell() + ? " Use run_command to build, test or inspect the project with shell commands." + : ""; + return "You are a careful coding agent working in the directory " + options.getWorkspace() + "." + + " Use the tools to inspect and change files: ls, read_file, write_file, edit_file, glob," + + " grep, delete, rename. Paths are relative to that directory." + shell + + " Work step by step: read a file before you edit it, verify the result after a change," + + " and finish with a short summary of what you did."; + } +} diff --git a/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/ShellTool.java b/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/ShellTool.java new file mode 100644 index 00000000..e7cb3f21 --- /dev/null +++ b/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/ShellTool.java @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: 2026 Bernard Ladenthin +// +// SPDX-License-Identifier: MIT + +package net.ladenthin.llama.atmosphere; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import org.atmosphere.ai.tool.ToolDefinition; + +/** + * The {@code run_command} tool: runs a shell command inside the workspace and returns its exit code and + * (merged, truncated) output. Opt-in via {@code --allow-shell} — a model-driven shell is exactly as + * powerful as the user account it runs under. + */ +public final class ShellTool { + + /** Tool name as offered to the model. */ + public static final String TOOL_NAME = "run_command"; + + private static final String PARAM_COMMAND = "command"; + private static final String PARAM_TIMEOUT = "timeout_seconds"; + + private ShellTool() {} + + /** + * Build the tool definition. + * + * @param workspace the working directory of every command + * @param defaultTimeout the timeout applied when the model does not pass {@code timeout_seconds} + * @param maxOutputChars output is truncated to this many characters (tail kept, head marked) + * @return the definition + */ + public static ToolDefinition definition(Path workspace, Duration defaultTimeout, int maxOutputChars) { + return ToolDefinition.builder( + TOOL_NAME, + "Run a shell command in the workspace directory and return its exit code and output" + + " (stdout and stderr merged). Use it to build, test, grep or list files.") + .parameter(PARAM_COMMAND, "The command line to run through the system shell", "string", true) + .parameter(PARAM_TIMEOUT, "Seconds to wait before the command is killed", "integer", false) + .executor(args -> { + Object command = args.get(PARAM_COMMAND); + if (command == null || command.toString().isBlank()) { + return "Error: '" + PARAM_COMMAND + "' is required"; + } + Duration timeout = timeoutOf(args.get(PARAM_TIMEOUT), defaultTimeout); + return run(workspace, command.toString(), timeout, maxOutputChars); + }) + .build(); + } + + private static Duration timeoutOf(Object raw, Duration fallback) { + if (raw instanceof Number n && n.longValue() > 0) { + return Duration.ofSeconds(n.longValue()); + } + if (raw instanceof String s && !s.isBlank()) { + try { + long seconds = Long.parseLong(s.trim()); + if (seconds > 0) { + return Duration.ofSeconds(seconds); + } + } catch (NumberFormatException ignored) { + // fall through to the default + } + } + return fallback; + } + + /** + * Run one command through the platform shell ({@code sh -c} / {@code cmd.exe /c}). + * + * @param workspace the working directory + * @param command the command line + * @param timeout kill the process after this long + * @param maxOutputChars truncate the captured output to this many characters + * @return a text block starting with {@code exit code: N}, followed by the output + * @throws IOException if the process cannot be started + * @throws InterruptedException if interrupted while waiting + */ + static String run(Path workspace, String command, Duration timeout, int maxOutputChars) + throws IOException, InterruptedException { + boolean windows = System.getProperty("os.name", "") + .toLowerCase(java.util.Locale.ROOT) + .contains("win"); + ProcessBuilder builder = + windows ? new ProcessBuilder("cmd.exe", "/c", command) : new ProcessBuilder("sh", "-c", command); + builder.directory(workspace.toFile()); + builder.redirectErrorStream(true); + Process process = builder.start(); + process.getOutputStream().close(); + CompletableFuture output = CompletableFuture.supplyAsync(() -> { + try { + return process.getInputStream().readAllBytes(); + } catch (IOException e) { + return ("[output unreadable: " + e.getMessage() + "]").getBytes(StandardCharsets.UTF_8); + } + }); + boolean finished = process.waitFor(timeout.toMillis(), TimeUnit.MILLISECONDS); + if (!finished) { + // Kill the shell AND its children: `sh -c "sleep 30"` forks sleep, which would otherwise + // keep the output pipe open (and the read below blocked) for its full duration. + process.toHandle().descendants().forEach(ProcessHandle::destroyForcibly); + process.destroyForcibly(); + process.waitFor(5, TimeUnit.SECONDS); + } + String text; + try { + text = new String(output.get(5, TimeUnit.SECONDS), StandardCharsets.UTF_8); + } catch (ExecutionException | TimeoutException e) { + text = "[output unavailable: " + e.getMessage() + "]"; + } + StringBuilder result = new StringBuilder(); + if (finished) { + result.append("exit code: ").append(process.exitValue()).append('\n'); + } else { + result.append("exit code: (killed after ") + .append(timeout.getSeconds()) + .append(" s)\n"); + } + if (text.length() > maxOutputChars) { + result.append("[output truncated to the last ") + .append(maxOutputChars) + .append(" of ") + .append(text.length()) + .append(" characters]\n") + .append(text, text.length() - maxOutputChars, text.length()); + } else { + result.append(text); + } + return result.toString(); + } +} diff --git a/llama-atmosphere-agent/src/main/resources/simplelogger.properties b/llama-atmosphere-agent/src/main/resources/simplelogger.properties new file mode 100644 index 00000000..ed3aaf9a --- /dev/null +++ b/llama-atmosphere-agent/src/main/resources/simplelogger.properties @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: 2026 Bernard Ladenthin +# +# SPDX-License-Identifier: MIT +# +# Console agent defaults: keep the framework quiet so the streamed answer is what the user sees. +# Override per run with -Dorg.slf4j.simpleLogger.defaultLogLevel=debug (shows every request). +org.slf4j.simpleLogger.defaultLogLevel=warn +org.slf4j.simpleLogger.logFile=System.err +org.slf4j.simpleLogger.showDateTime=false +org.slf4j.simpleLogger.showThreadName=false diff --git a/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/AgentOptionsTest.java b/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/AgentOptionsTest.java new file mode 100644 index 00000000..d933020c --- /dev/null +++ b/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/AgentOptionsTest.java @@ -0,0 +1,125 @@ +// SPDX-FileCopyrightText: 2026 Bernard Ladenthin +// +// SPDX-License-Identifier: MIT + +package net.ladenthin.llama.atmosphere; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.file.Paths; +import org.junit.jupiter.api.Test; + +class AgentOptionsTest { + + @Test + void baseUrlModeWithDefaults() { + AgentOptions options = AgentOptions.parse(new String[] {"--base-url", "http://127.0.0.1:8080/v1/"}); + + assertThat(options.getBaseUrl(), is("http://127.0.0.1:8080/v1")); + assertThat(options.getModelPath(), is(nullValue())); + assertThat(options.getApiKey(), is(AgentOptions.DEFAULT_API_KEY)); + assertThat(options.getModelId(), is(AgentOptions.DEFAULT_MODEL_ID)); + assertThat(options.getWorkspace(), is(Paths.get("").toAbsolutePath().normalize())); + assertThat(options.isAllowShell(), is(false)); + assertThat(options.getTemperature(), is(AgentOptions.DEFAULT_TEMPERATURE)); + assertThat(options.getMaxTokens(), is(AgentOptions.DEFAULT_MAX_TOKENS)); + assertThat(options.getMaxToolRounds(), is(AgentOptions.DEFAULT_MAX_TOOL_ROUNDS)); + assertThat(options.getPrompt(), is(nullValue())); + assertThat(options.isHelp(), is(false)); + } + + @Test + void inProcessModeParsesEveryOption() { + AgentOptions options = AgentOptions.parse(new String[] { + "--model", + "m.gguf", + "--ngl", + "99", + "--ctx-size", + "4096", + "--api-key", + "k", + "--model-id", + "id", + "--workspace", + "/tmp/ws", + "--allow-shell", + "--temperature", + "0.5", + "--max-tokens", + "10", + "--max-tool-rounds", + "3", + "--system", + "sys", + "-p", + "do it" + }); + + assertThat(options.getBaseUrl(), is(nullValue())); + assertThat(options.getModelPath(), is("m.gguf")); + assertThat(options.getGpuLayers(), is(99)); + assertThat(options.getCtxSize(), is(4096)); + assertThat(options.getApiKey(), is("k")); + assertThat(options.getModelId(), is("id")); + assertThat( + options.getWorkspace(), is(Paths.get("/tmp/ws").toAbsolutePath().normalize())); + assertThat(options.isAllowShell(), is(true)); + assertThat(options.getTemperature(), is(0.5)); + assertThat(options.getMaxTokens(), is(10)); + assertThat(options.getMaxToolRounds(), is(3)); + assertThat(options.getSystemPrompt(), is("sys")); + assertThat(options.getPrompt(), is("do it")); + } + + @Test + void exactlyOneEndpointIsRequired() { + IllegalArgumentException none = + assertThrows(IllegalArgumentException.class, () -> AgentOptions.parse(new String[0])); + assertThat(none.getMessage(), containsString("Exactly one of --base-url")); + assertThrows( + IllegalArgumentException.class, + () -> AgentOptions.parse(new String[] {"--base-url", "http://x/v1", "--model", "m.gguf"})); + } + + @Test + void helpNeedsNoEndpoint() { + assertThat(AgentOptions.parse(new String[] {"--help"}).isHelp(), is(true)); + assertThat(AgentOptions.usage(), containsString("--base-url")); + assertThat(AgentOptions.usage(), containsString("--allow-shell")); + } + + @Test + void unknownFlagAndMissingValueAreRejected() { + assertThat( + assertThrows(IllegalArgumentException.class, () -> AgentOptions.parse(new String[] {"--bogus"})) + .getMessage(), + containsString("Unknown argument: --bogus")); + assertThat( + assertThrows(IllegalArgumentException.class, () -> AgentOptions.parse(new String[] {"--base-url"})) + .getMessage(), + containsString("Missing value for --base-url")); + assertThat( + assertThrows( + IllegalArgumentException.class, + () -> AgentOptions.parse(new String[] {"--base-url", "u", "--ngl", "many"})) + .getMessage(), + containsString("Expected an integer for --ngl")); + } + + @Test + void systemPromptMentionsTheShellToolOnlyWhenEnabled() { + AgentOptions plain = AgentOptions.parse(new String[] {"--base-url", "http://x/v1"}); + AgentOptions shell = AgentOptions.parse(new String[] {"--base-url", "http://x/v1", "--allow-shell"}); + + assertThat(LocalAgent.systemPrompt(plain).contains("run_command"), is(false)); + assertThat(LocalAgent.systemPrompt(shell), containsString("run_command")); + assertThat( + LocalAgent.systemPrompt(AgentOptions.parse(new String[] {"--base-url", "u", "--system", "custom"})), + is("custom")); + } +} diff --git a/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/AtmosphereToolLoopIntegrationTest.java b/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/AtmosphereToolLoopIntegrationTest.java new file mode 100644 index 00000000..1bfe8b26 --- /dev/null +++ b/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/AtmosphereToolLoopIntegrationTest.java @@ -0,0 +1,199 @@ +// SPDX-FileCopyrightText: 2026 Bernard Ladenthin +// +// SPDX-License-Identifier: MIT + +package net.ladenthin.llama.atmosphere; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; +import net.ladenthin.llama.LlamaModel; +import net.ladenthin.llama.parameters.ModelParameters; +import net.ladenthin.llama.server.OpenAiCompatServer; +import net.ladenthin.llama.server.OpenAiServerConfig; +import org.atmosphere.ai.fs.AgentFileSystem; +import org.atmosphere.ai.fs.WorkspaceAgentFileSystem; +import org.atmosphere.ai.tool.ToolDefinition; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * The real thing: Atmosphere's built-in runtime driving a llama.cpp model through java-llama.cpp's + * {@link OpenAiCompatServer} — plain chat, streaming, a tool call with its result fed back, and a + * multi-round read/write/read loop over a temp file. + * + *

Model: the Qwen2.5-1.5B-Instruct tool model the core's {@code OpenAiServerToolCallingIntegrationTest} + * uses (llama.cpp's own tool-call test matrix), resolved from {@code -Dnet.ladenthin.llama.tool.model} + * (module-relative, then reactor-root). Self-skips when the GGUF is absent so a model-free checkout + * stays green; CI runs it in a validation-only job (a small model's exact wording is not a release + * gate). GPU layers come from {@code -Dnet.ladenthin.llama.test.ngl} (default 0 = CPU, device + * {@code none}). + * + *

Every assertion here is about the loop — did a tool run, did the result reach the model, + * did the model answer afterwards, did the file change — not about exact prose, which a 1.5B model does + * not produce deterministically. The deterministic wire shape is pinned model-free in + * {@link AtmosphereWireContractTest}. + */ +class AtmosphereToolLoopIntegrationTest { + + private static final String PROP_TOOL_MODEL = "net.ladenthin.llama.tool.model"; + private static final String DEFAULT_TOOL_MODEL = "models/Qwen2.5-1.5B-Instruct-Q4_K_M.gguf"; + private static final String PROP_NGL = "net.ladenthin.llama.test.ngl"; + private static final String API_KEY = "sk-local"; + private static final String MODEL_ID = "qwen25-tools"; + private static final Duration TURN_TIMEOUT = Duration.ofMinutes(10); + private static final String SYSTEM_PROMPT = + "You are a precise assistant. When a tool can answer the request, call it. Keep answers short."; + + private static LlamaModel model; + private static OpenAiCompatServer server; + private static String baseUrl; + + @TempDir + Path workspace; + + @BeforeAll + static void startServer() throws Exception { + Path modelPath = TestModelPaths.resolve(System.getProperty(PROP_TOOL_MODEL, DEFAULT_TOOL_MODEL)); + Assumptions.assumeTrue( + modelPath != null && Files.exists(modelPath), + "Tool-calling model (Qwen2.5-1.5B) not found, skipping Atmosphere tool-loop test: " + modelPath); + int gpuLayers = Integer.getInteger(PROP_NGL, 0); + ModelParameters parameters = new ModelParameters() + .setModel(modelPath.toString()) + .setCtxSize(8192) + .setGpuLayers(gpuLayers) + .setFit(false) + .setParallel(1) + .enableJinja(); + if (gpuLayers == 0) { + parameters.setDevices("none"); + } + model = new LlamaModel(parameters); + server = new OpenAiCompatServer( + model, + OpenAiServerConfig.builder() + .host("127.0.0.1") + .port(0) + .apiKey(API_KEY) + .modelId(MODEL_ID) + .build()) + .start(); + baseUrl = "http://127.0.0.1:" + server.getPort() + "/v1"; + } + + @AfterAll + static void stopServer() { + if (server != null) { + server.close(); + } + if (model != null) { + model.close(); + } + } + + private AgentRunner runner(List tools) { + return new AgentRunner(baseUrl, API_KEY, MODEL_ID, tools, SYSTEM_PROMPT, 0.0, 256, 8); + } + + private ConsoleSession session() { + AgentFileSystem fs = new WorkspaceAgentFileSystem(workspace, AgentFileSystem.Limits.defaults()); + return new ConsoleSession(new PrintStream(new ByteArrayOutputStream(), true, StandardCharsets.UTF_8), fs); + } + + @Test + void plainChatStreamsAnAnswer() throws Exception { + ConsoleSession session = session(); + + runner(List.of()).run("Reply with exactly this word and nothing else: ATMOSPHERE_OK", List.of(), session); + + assertThat(session.await(TURN_TIMEOUT), is(true)); + assertThat(session.failure(), is(nullValue())); + assertThat( + "streamed text: " + session.text(), session.text().toUpperCase().contains("ATMOSPHERE_OK"), is(true)); + assertThat( + "the answer must arrive as several SSE chunks, not one blob", + session.chunks().size(), + greaterThanOrEqualTo(2)); + } + + @Test + void toolCallResultIsFedBackAndAnswered() throws Exception { + AtomicInteger invocations = new AtomicInteger(); + ToolDefinition tool = ToolDefinition.builder( + "get_current_test_value", "Returns the current test value. Call it to learn the value.") + .executor(args -> { + invocations.incrementAndGet(); + return "ATMOSPHERE_TOOL_OK"; + }) + .build(); + ConsoleSession session = session(); + + runner(List.of(tool)) + .run( + "Call the tool get_current_test_value and then tell me the value it returned.", + List.of(), + session); + + assertThat(session.await(TURN_TIMEOUT), is(true)); + assertThat(session.failure(), is(nullValue())); + assertThat("the model must call the tool", invocations.get(), greaterThanOrEqualTo(1)); + assertThat( + "final answer after the tool round: " + session.text(), + session.text().trim().isEmpty(), + is(false)); + assertThat(session.text(), containsString("ATMOSPHERE_TOOL_OK")); + } + + @Test + void multiRoundReadWriteReadLoopChangesTheFile() throws Exception { + Path file = workspace.resolve("test.txt"); + Files.writeString(file, "VALUE=1\n"); + List trace = new CopyOnWriteArrayList<>(); + ToolDefinition read = ToolDefinition.builder("read_test_file", "Read the content of test.txt") + .executor(args -> { + trace.add("read"); + return Files.readString(file); + }) + .build(); + ToolDefinition write = ToolDefinition.builder( + "write_test_file", "Replace the whole content of test.txt with the given content") + .parameter("content", "The new full content of the file", "string", true) + .executor(args -> { + trace.add("write"); + Files.writeString(file, String.valueOf(args.get("content"))); + return "written"; + }) + .build(); + ConsoleSession session = session(); + + runner(List.of(read, write)) + .run( + "Use the tools: first read test.txt, then change the line VALUE=1 to VALUE=2 by writing the" + + " file, then read the file again and tell me the new value.", + List.of(), + session); + + assertThat(session.await(TURN_TIMEOUT), is(true)); + assertThat(session.failure(), is(nullValue())); + assertThat("tool trace: " + trace, trace.contains("write"), is(true)); + assertThat("at least two tool rounds: " + trace, trace.size(), greaterThanOrEqualTo(2)); + assertThat(Files.readString(file), containsString("VALUE=2")); + assertThat("final answer: " + session.text(), session.text().trim().isEmpty(), is(false)); + } +} diff --git a/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/AtmosphereWireContractTest.java b/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/AtmosphereWireContractTest.java new file mode 100644 index 00000000..02b8153c --- /dev/null +++ b/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/AtmosphereWireContractTest.java @@ -0,0 +1,284 @@ +// SPDX-FileCopyrightText: 2026 Bernard Ladenthin +// +// SPDX-License-Identifier: MIT + +package net.ladenthin.llama.atmosphere; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; + +import com.fasterxml.jackson.databind.JsonNode; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import net.ladenthin.llama.server.OpenAiCompatServer; +import net.ladenthin.llama.server.OpenAiServerConfig; +import org.atmosphere.ai.RetryPolicy; +import org.atmosphere.ai.fs.AgentFileSystem; +import org.atmosphere.ai.fs.WorkspaceAgentFileSystem; +import org.atmosphere.ai.llm.ChatMessage; +import org.atmosphere.ai.tool.ToolDefinition; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * The wire contract between Atmosphere's built-in OpenAI-compatible runtime and java-llama.cpp's + * {@link OpenAiCompatServer}, proven over a real loopback socket with no model: the server's routing, + * bearer authentication, {@code /v1/models} and Server-Sent-Events framing are the real thing, only the + * inference engine is a {@link ScriptedBackend} replaying llama.cpp-shaped chunks. + * + *

What this pins, on every PR and in seconds: Atmosphere accumulates streamed {@code tool_calls} + * deltas by {@code index}, executes the Java tool, re-submits the conversation with the assistant's + * {@code tool_calls} message (no {@code content} key — accepted by llama.cpp's + * {@code common_chat_msgs_parse_oaicompat}, which requires {@code content} or + * {@code tool_calls}) plus one {@code role:"tool"} message per call carrying {@code tool_call_id}, + * keeps every earlier round in the history, and completes on {@code finish_reason:"stop"}. The + * model-backed counterpart is {@link AtmosphereToolLoopIntegrationTest}. + */ +class AtmosphereWireContractTest { + + private static final String API_KEY = "secret-key"; + private static final String MODEL_ID = "local-model"; + private static final Duration TIMEOUT = Duration.ofSeconds(30); + + @TempDir + Path workspace; + + private static OpenAiServerConfig config(String apiKey) { + return OpenAiServerConfig.builder() + .host("127.0.0.1") + .port(0) + .apiKey(apiKey) + .modelId(MODEL_ID) + .build(); + } + + private static String baseUrl(OpenAiCompatServer server) { + return "http://127.0.0.1:" + server.getPort() + "/v1"; + } + + private AgentRunner runner(OpenAiCompatServer server, String apiKey, List tools) { + return new AgentRunner(baseUrl(server), apiKey, MODEL_ID, tools, "You are a test agent.", 0.0, 64, 10) + .retryPolicy(RetryPolicy.NONE); + } + + private ConsoleSession session() { + AgentFileSystem fs = new WorkspaceAgentFileSystem(workspace, AgentFileSystem.Limits.defaults()); + return new ConsoleSession(new PrintStream(new ByteArrayOutputStream(), true, StandardCharsets.UTF_8), fs); + } + + private static ToolDefinition tool(String name, List invocations, String result) { + return ToolDefinition.builder(name, "Test tool " + name) + .parameter("value", "An optional value", "string", false) + .executor(args -> { + invocations.add(name + ":" + args.getOrDefault("value", "")); + return result; + }) + .build(); + } + + private static List roles(JsonNode request) { + List roles = new ArrayList<>(); + for (JsonNode message : request.path("messages")) { + roles.add(message.path("role").asText()); + } + return roles; + } + + @Test + void oneToolRoundTravelsThroughTheRealServer() throws Exception { + List invocations = new CopyOnWriteArrayList<>(); + ScriptedBackend backend = new ScriptedBackend((call, request) -> call == 1 + ? ScriptedBackend.toolCallTurn("call_1", "get_current_test_value", "{}") + : ScriptedBackend.textTurn("Result: ", "ATMOSPHERE_TOOL_OK")); + try (OpenAiCompatServer server = new OpenAiCompatServer(backend, config(API_KEY)).start()) { + AgentRunner runner = + runner(server, API_KEY, List.of(tool("get_current_test_value", invocations, "ATMOSPHERE_TOOL_OK"))); + ConsoleSession session = session(); + + runner.run("Call get_current_test_value and print its result.", List.of(), session); + + assertThat(session.await(TIMEOUT), is(true)); + assertThat(session.failure(), is(nullValue())); + assertThat(session.text(), is("Result: ATMOSPHERE_TOOL_OK")); + assertThat(invocations, contains("get_current_test_value:")); + + List requests = backend.requests(); + assertThat(requests, hasSize(2)); + JsonNode first = requests.get(0); + assertThat(first.path("stream").asBoolean(), is(true)); + assertThat(first.path("model").asText(), is(MODEL_ID)); + assertThat(first.path("tools").size(), is(1)); + assertThat(first.path("tools").get(0).path("function").path("name").asText(), is("get_current_test_value")); + assertThat(roles(first), contains("system", "user")); + + JsonNode second = requests.get(1); + assertThat(roles(second), contains("system", "user", "assistant", "tool")); + JsonNode assistant = second.path("messages").get(2); + // llama.cpp accepts an assistant message that carries tool_calls without a content key. + assertThat(assistant.has("content"), is(false)); + assertThat(assistant.path("tool_calls").get(0).path("id").asText(), is("call_1")); + assertThat( + assistant + .path("tool_calls") + .get(0) + .path("function") + .path("name") + .asText(), + is("get_current_test_value")); + assertThat( + assistant + .path("tool_calls") + .get(0) + .path("function") + .path("arguments") + .isTextual(), + is(true)); + JsonNode toolMessage = second.path("messages").get(3); + assertThat(toolMessage.path("tool_call_id").asText(), is("call_1")); + assertThat(toolMessage.path("content").asText(), is("ATMOSPHERE_TOOL_OK")); + } + } + + @Test + void threeToolRoundsWithAParallelPairKeepTheWholeConversation() throws Exception { + List invocations = new CopyOnWriteArrayList<>(); + ScriptedBackend backend = new ScriptedBackend((call, request) -> switch (call) { + case 1 -> + List.of( + ScriptedBackend.roleChunk(), + ScriptedBackend.toolCallStart(0, "call_a", "read_test_file"), + ScriptedBackend.toolCallStart(1, "call_b", "list_test_files"), + ScriptedBackend.toolCallArguments(0, "{\"value\":"), + ScriptedBackend.toolCallArguments(1, "{}"), + ScriptedBackend.toolCallArguments(0, "\"test.txt\"}"), + ScriptedBackend.finish("tool_calls")); + case 2 -> ScriptedBackend.toolCallTurn("call_c", "write_test_file", "{\"value\":\"VALUE=2\"}"); + case 3 -> ScriptedBackend.toolCallTurn("call_d", "read_test_file", "{\"value\":\"test.txt\"}"); + default -> ScriptedBackend.textTurn("The new value is VALUE=2."); + }); + try (OpenAiCompatServer server = new OpenAiCompatServer(backend, config(API_KEY)).start()) { + AgentRunner runner = runner( + server, + API_KEY, + List.of( + tool("read_test_file", invocations, "VALUE=1"), + tool("list_test_files", invocations, "test.txt"), + tool("write_test_file", invocations, "ok"))); + ConsoleSession session = session(); + + runner.run("Read test.txt, change VALUE=1 to VALUE=2, read it again.", List.of(), session); + + assertThat(session.await(TIMEOUT), is(true)); + assertThat(session.failure(), is(nullValue())); + assertThat(session.text(), is("The new value is VALUE=2.")); + // Fragmented arguments were reassembled per index; both parallel calls ran, in index order. + assertThat( + invocations, + contains( + "read_test_file:test.txt", + "list_test_files:", + "write_test_file:VALUE=2", + "read_test_file:test.txt")); + assertThat(session.toolCalls(), is(4)); + + List requests = backend.requests(); + assertThat(requests, hasSize(4)); + // Every earlier round is still in the history of the last request. + assertThat( + roles(requests.get(3)), + contains("system", "user", "assistant", "tool", "tool", "assistant", "tool", "assistant", "tool")); + JsonNode lastRequest = requests.get(3); + assertThat(lastRequest.path("messages").get(2).path("tool_calls").size(), is(2)); + assertThat(lastRequest.path("messages").get(3).path("tool_call_id").asText(), is("call_a")); + assertThat(lastRequest.path("messages").get(4).path("tool_call_id").asText(), is("call_b")); + assertThat(lastRequest.path("messages").get(6).path("tool_call_id").asText(), is("call_c")); + assertThat(lastRequest.path("messages").get(8).path("tool_call_id").asText(), is("call_d")); + } + } + + @Test + void streamedTextArrivesChunkByChunkAndHistoryIsReplayed() throws Exception { + ScriptedBackend backend = + new ScriptedBackend((call, request) -> ScriptedBackend.textTurn("ATMOS", "PHERE", "_OK")); + try (OpenAiCompatServer server = new OpenAiCompatServer(backend, config(API_KEY)).start()) { + AgentRunner runner = runner(server, API_KEY, List.of()); + ConsoleSession session = session(); + List history = + List.of(ChatMessage.user("earlier question"), ChatMessage.assistant("earlier answer")); + + runner.run("Answer exactly with ATMOSPHERE_OK", history, session); + + assertThat(session.await(TIMEOUT), is(true)); + assertThat(session.text(), is("ATMOSPHERE_OK")); + assertThat(session.chunks(), contains("ATMOS", "PHERE", "_OK")); + JsonNode request = backend.requests().get(0); + assertThat(roles(request), contains("system", "user", "assistant", "user")); + assertThat(request.path("messages").get(1).path("content").asText(), is("earlier question")); + assertThat( + request.path("messages").get(3).path("content").asText(), is("Answer exactly with ATMOSPHERE_OK")); + assertThat(request.path("temperature").asDouble(), is(0.0)); + assertThat(request.path("max_tokens").asInt(), is(64)); + } + } + + @Test + void modelsAreEnumeratedFromTheServer() throws Exception { + ScriptedBackend backend = new ScriptedBackend((call, request) -> ScriptedBackend.textTurn("unused")); + try (OpenAiCompatServer server = new OpenAiCompatServer(backend, config(API_KEY)).start()) { + assertThat(runner(server, API_KEY, List.of()).models(), contains(MODEL_ID)); + } + } + + @Test + void wrongApiKeyIsRejectedBeforeReachingTheBackend() throws Exception { + ScriptedBackend backend = new ScriptedBackend((call, request) -> ScriptedBackend.textTurn("never")); + try (OpenAiCompatServer server = new OpenAiCompatServer(backend, config(API_KEY)).start()) { + AgentRunner runner = runner(server, "wrong-key", List.of()); + ConsoleSession session = session(); + + runner.run("hello", List.of(), session); + + assertThat(session.await(TIMEOUT), is(true)); + assertThat(session.failure(), is(notNullValue())); + assertThat(session.text(), is("")); + assertThat(backend.requests(), is(empty())); + } + } + + /** + * Known gap, pinned so a change on either side is noticed. When the engine fails after the stream + * started, java-llama.cpp (like upstream llama-server) has already sent HTTP 200 and reports the + * failure as an SSE {@code data: {"error":{...}}} object without a terminating {@code [DONE]}. + * Atmosphere's SSE parser only reads {@code choices[0]}, so it ignores that object and completes + * the session normally with whatever text arrived before — an empty answer here, not an error. + * An in-stream error event would be a "SHOULD" for Atmosphere's {@code OpenAiCompatibleClient}. + */ + @Test + void midStreamEngineFailureCompletesSilentlyRatherThanErroring() throws Exception { + ScriptedBackend backend = new ScriptedBackend((call, request) -> { + throw new IllegalStateException("model exploded"); + }); + try (OpenAiCompatServer server = new OpenAiCompatServer(backend, config(API_KEY)).start()) { + AgentRunner runner = runner(server, API_KEY, List.of()); + ConsoleSession session = session(); + + runner.run("hello", List.of(), session); + + assertThat(session.await(TIMEOUT), is(true)); + assertThat(backend.requests(), hasSize(1)); + assertThat(session.text(), is("")); + assertThat("Atmosphere does not surface an in-stream error object", session.failure(), is(nullValue())); + } + } +} diff --git a/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/LocalAgentTest.java b/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/LocalAgentTest.java new file mode 100644 index 00000000..12d8c470 --- /dev/null +++ b/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/LocalAgentTest.java @@ -0,0 +1,148 @@ +// SPDX-FileCopyrightText: 2026 Bernard Ladenthin +// +// SPDX-License-Identifier: MIT + +package net.ladenthin.llama.atmosphere; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; + +import com.fasterxml.jackson.databind.JsonNode; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import net.ladenthin.llama.server.OpenAiCompatServer; +import net.ladenthin.llama.server.OpenAiServerConfig; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Drives {@link LocalAgent#run} end to end — option parsing, the workspace-confined built-in file + * tools, the console rendering and the exit code — against the real {@link OpenAiCompatServer} with a + * scripted engine. This is the one test that proves Atmosphere's own {@code FileSystemTools} work + * headless: they resolve the {@code AgentFileSystem} from the session's injectables, which + * {@link ConsoleSession} supplies. + */ +class LocalAgentTest { + + @TempDir + Path workspace; + + private static OpenAiCompatServer server(ScriptedBackend backend) throws Exception { + return new OpenAiCompatServer( + backend, + OpenAiServerConfig.builder() + .host("127.0.0.1") + .port(0) + .apiKey("sk-local") + .modelId("local-model") + .build()) + .start(); + } + + @Test + void oneShotTurnReadsAWorkspaceFileThroughTheBuiltInFileTools() throws Exception { + Files.writeString(workspace.resolve("hello.txt"), "VALUE=42\n"); + ScriptedBackend backend = new ScriptedBackend((call, request) -> call == 1 + ? ScriptedBackend.toolCallTurn("call_1", "read_file", "{\"path\":\"hello.txt\"}") + : ScriptedBackend.textTurn("The file says VALUE=42.")); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ByteArrayOutputStream err = new ByteArrayOutputStream(); + try (OpenAiCompatServer server = server(backend)) { + AgentOptions options = AgentOptions.parse(new String[] { + "--base-url", + "http://127.0.0.1:" + server.getPort() + "/v1", + "--workspace", + workspace.toString(), + "--prompt", + "What does hello.txt say?" + }); + + int exit = LocalAgent.run( + options, + null, + new PrintStream(out, true, StandardCharsets.UTF_8), + new PrintStream(err, true, StandardCharsets.UTF_8)); + + assertThat(exit, is(0)); + } + String console = out.toString(StandardCharsets.UTF_8); + assertThat(console, containsString("⚙ read_file {path=hello.txt}")); + assertThat(console, containsString("↳ VALUE=42")); + assertThat(console, containsString("The file says VALUE=42.")); + List requests = backend.requests(); + assertThat(requests, hasSize(2)); + // The built-in file tools were offered to the model ... + assertThat(requests.get(0).path("tools").toString(), containsString("\"name\":\"read_file\"")); + assertThat(requests.get(0).path("tools").toString(), containsString("\"name\":\"edit_file\"")); + assertThat(requests.get(0).path("tools").toString().contains(ShellTool.TOOL_NAME), is(false)); + // ... and the tool's real result (the file content) travelled back to the model. + JsonNode toolMessage = requests.get(1).path("messages").get(3); + assertThat(toolMessage.path("role").asText(), is("tool")); + assertThat(toolMessage.path("content").asText(), containsString("VALUE=42")); + assertThat(err.toString(StandardCharsets.UTF_8), containsString("tools=[ls, read_file")); + } + + @Test + void interactiveModeRunsTurnsUntilExitAndKeepsHistory() throws Exception { + ScriptedBackend backend = new ScriptedBackend((call, request) -> ScriptedBackend.textTurn("answer " + call)); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (OpenAiCompatServer server = server(backend)) { + AgentOptions options = AgentOptions.parse(new String[] { + "--base-url", + "http://127.0.0.1:" + server.getPort() + "/v1", + "--workspace", + workspace.toString(), + "--allow-shell" + }); + + int exit = LocalAgent.run( + options, + new StringReader("first\n\nsecond\n/exit\n"), + new PrintStream(out, true, StandardCharsets.UTF_8), + new PrintStream(new ByteArrayOutputStream(), true, StandardCharsets.UTF_8)); + + assertThat(exit, is(0)); + } + List requests = backend.requests(); + assertThat(requests, hasSize(2)); + // The second turn carries the first turn as history: system, user, assistant, user. + assertThat(requests.get(1).path("messages").size(), is(4)); + assertThat(requests.get(1).path("messages").get(2).path("content").asText(), is("answer 1")); + assertThat(requests.get(1).path("messages").get(3).path("content").asText(), is("second")); + assertThat( + requests.get(0).path("tools").toString(), containsString("\"name\":\"" + ShellTool.TOOL_NAME + "\"")); + assertThat(out.toString(StandardCharsets.UTF_8), containsString("answer 2")); + } + + @Test + void failedTurnExitsNonZero() throws Exception { + ScriptedBackend backend = new ScriptedBackend((call, request) -> ScriptedBackend.textTurn("never")); + try (OpenAiCompatServer server = server(backend)) { + AgentOptions options = AgentOptions.parse(new String[] { + "--base-url", + "http://127.0.0.1:" + server.getPort() + "/v1", + "--api-key", + "wrong", + "--workspace", + workspace.toString(), + "-p", + "hello" + }); + + int exit = LocalAgent.run( + options, + null, + new PrintStream(new ByteArrayOutputStream(), true, StandardCharsets.UTF_8), + new PrintStream(new ByteArrayOutputStream(), true, StandardCharsets.UTF_8)); + + assertThat(exit, is(1)); + } + } +} diff --git a/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/ScriptedBackend.java b/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/ScriptedBackend.java new file mode 100644 index 00000000..f638dd11 --- /dev/null +++ b/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/ScriptedBackend.java @@ -0,0 +1,136 @@ +// SPDX-FileCopyrightText: 2026 Bernard Ladenthin +// +// SPDX-License-Identifier: MIT + +package net.ladenthin.llama.atmosphere; + +import com.fasterxml.jackson.databind.JsonNode; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import net.ladenthin.llama.server.ChunkSink; +import net.ladenthin.llama.server.OpenAiBackend; + +/** + * A model-free {@link OpenAiBackend} that answers each streaming chat request with a scripted sequence + * of {@code chat.completion.chunk} objects — the shapes llama.cpp's server emits (role delta first, + * {@code tool_calls} deltas keyed by {@code index}, {@code finish_reason:"tool_calls"} on the terminal + * chunk) — and records every request it received, so a test can assert what an OpenAI client actually + * put on the wire after the real {@code OpenAiCompatServer} routing, authentication and SSE framing. + */ +final class ScriptedBackend implements OpenAiBackend { + + /** Decides the chunks for the n-th request (1-based). */ + @FunctionalInterface + interface Script { + List chunksFor(int call, JsonNode request) throws IOException; + } + + private final Script script; + private final List requests = Collections.synchronizedList(new ArrayList<>()); + + ScriptedBackend(Script script) { + this.script = script; + } + + /** Every {@code /v1/chat/completions} body received, in order. */ + List requests() { + return List.copyOf(requests); + } + + @Override + public void stream(JsonNode request, ChunkSink sink) throws IOException { + requests.add(request.deepCopy()); + for (String chunk : script.chunksFor(requests.size(), request)) { + sink.accept(chunk); + } + } + + @Override + public String complete(JsonNode request) { + throw new UnsupportedOperationException("Atmosphere always streams; a blocking request is a contract change"); + } + + @Override + public String completions(JsonNode request) { + throw new UnsupportedOperationException("not part of the agent contract"); + } + + @Override + public String embeddings(JsonNode request) { + throw new UnsupportedOperationException("not part of the agent contract"); + } + + @Override + public String rerank(JsonNode request) { + throw new UnsupportedOperationException("not part of the agent contract"); + } + + @Override + public String infill(JsonNode request) { + throw new UnsupportedOperationException("not part of the agent contract"); + } + + // ----- chunk builders (llama.cpp server shapes) ----- + + static String roleChunk() { + return "{\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"model\":\"m\",\"choices\":[{\"index\":0," + + "\"delta\":{\"role\":\"assistant\",\"content\":null},\"finish_reason\":null}]}"; + } + + static String textChunk(String content) { + return "{\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"model\":\"m\",\"choices\":[{\"index\":0," + + "\"delta\":{\"content\":" + quote(content) + "},\"finish_reason\":null}]}"; + } + + /** First delta of a tool call: carries index, id, type and name; arguments start empty. */ + static String toolCallStart(int index, String id, String name) { + return "{\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"model\":\"m\",\"choices\":[{\"index\":0," + + "\"delta\":{\"tool_calls\":[{\"index\":" + index + ",\"id\":" + quote(id) + + ",\"type\":\"function\",\"function\":{\"name\":" + quote(name) + ",\"arguments\":\"\"}}]}," + + "\"finish_reason\":null}]}"; + } + + /** A later delta of the same tool call: only an arguments fragment, addressed by index. */ + static String toolCallArguments(int index, String fragment) { + return "{\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"model\":\"m\",\"choices\":[{\"index\":0," + + "\"delta\":{\"tool_calls\":[{\"index\":" + index + ",\"function\":{\"arguments\":" + quote(fragment) + + "}}]},\"finish_reason\":null}]}"; + } + + static String finish(String reason) { + return "{\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"model\":\"m\",\"choices\":[{\"index\":0," + + "\"delta\":{},\"finish_reason\":" + quote(reason) + "}]}"; + } + + /** A complete single-tool-call turn: role, start, arguments, {@code finish_reason:"tool_calls"}. */ + static List toolCallTurn(String id, String name, String argumentsJson) { + return List.of( + roleChunk(), toolCallStart(0, id, name), toolCallArguments(0, argumentsJson), finish("tool_calls")); + } + + /** A complete text turn split into one chunk per element, then {@code finish_reason:"stop"}. */ + static List textTurn(String... pieces) { + List chunks = new ArrayList<>(); + chunks.add(roleChunk()); + for (String piece : pieces) { + chunks.add(textChunk(piece)); + } + chunks.add(finish("stop")); + return chunks; + } + + private static String quote(String s) { + StringBuilder sb = new StringBuilder("\""); + for (char c : s.toCharArray()) { + switch (c) { + case '"' -> sb.append("\\\""); + case '\\' -> sb.append("\\\\"); + case '\n' -> sb.append("\\n"); + default -> sb.append(c); + } + } + return sb.append('"').toString(); + } +} diff --git a/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/ShellToolTest.java b/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/ShellToolTest.java new file mode 100644 index 00000000..d4cada4b --- /dev/null +++ b/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/ShellToolTest.java @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: 2026 Bernard Ladenthin +// +// SPDX-License-Identifier: MIT + +package net.ladenthin.llama.atmosphere; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.startsWith; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Map; +import org.atmosphere.ai.tool.ToolDefinition; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ShellToolTest { + + @TempDir + Path workspace; + + @Test + void runsInTheWorkspaceAndReportsExitCodeAndOutput() throws Exception { + Files.writeString(workspace.resolve("marker.txt"), "x"); + ToolDefinition tool = ShellTool.definition(workspace, Duration.ofSeconds(30), 10_000); + + Object result = tool.executor().execute(Map.of("command", "ls")); + + assertThat(tool.name(), is(ShellTool.TOOL_NAME)); + assertThat(String.valueOf(result), startsWith("exit code: 0")); + assertThat(String.valueOf(result), containsString("marker.txt")); + } + + @Test + void nonZeroExitAndStderrAreReturnedNotThrown() throws Exception { + ToolDefinition tool = ShellTool.definition(workspace, Duration.ofSeconds(30), 10_000); + + Object result = tool.executor().execute(Map.of("command", "echo boom 1>&2; exit 3")); + + assertThat(String.valueOf(result), startsWith("exit code: 3")); + assertThat(String.valueOf(result), containsString("boom")); + } + + @Test + void missingCommandIsAnErrorString() throws Exception { + ToolDefinition tool = ShellTool.definition(workspace, Duration.ofSeconds(30), 10_000); + + assertThat(String.valueOf(tool.executor().execute(Map.of())), containsString("'command' is required")); + } + + @Test + void outputIsTruncatedToTheTail() throws Exception { + String result = ShellTool.run(workspace, "printf 'aaaaaaaaaaaaaaaaaaaaZZ'", Duration.ofSeconds(30), 5); + + assertThat(result, containsString("[output truncated to the last 5 of 22 characters]")); + assertThat(result, containsString("aaaZZ")); + } + + @Test + void timeoutKillsTheProcess() throws Exception { + String result = ShellTool.run(workspace, "sleep 30", Duration.ofMillis(300), 10_000); + + assertThat(result, startsWith("exit code: (killed after 0 s)")); + } +} diff --git a/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/TestModelPaths.java b/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/TestModelPaths.java new file mode 100644 index 00000000..af3cd3ca --- /dev/null +++ b/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/TestModelPaths.java @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: 2026 Bernard Ladenthin +// +// SPDX-License-Identifier: MIT + +package net.ladenthin.llama.atmosphere; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import org.jspecify.annotations.Nullable; + +/** + * Resolves model paths the way the core's {@code TestConstants.resolveModelPath} does: relative to + * this project first, then to its parent (the reactor root, where CI restores the shared GGUF cache). + * Test classes are not shared between modules, so the resolver is carried here as well. + */ +final class TestModelPaths { + + private TestModelPaths() {} + + /** + * Resolves a configured fixture path against the working directory and then its parent. + * + * @param path the configured path, may be {@code null} or empty + * @return an existing path, or {@code null} when {@code path} is null/empty, or the unresolved + * path itself when it exists in neither location (so skip messages name what was looked for) + */ + static @Nullable Path resolve(@Nullable String path) { + if (path == null || path.isEmpty()) { + return null; + } + Path candidate = Paths.get(path); + if (candidate.isAbsolute() || Files.exists(candidate)) { + return candidate; + } + Path fromParent = Paths.get("..").resolve(candidate); + if (Files.exists(fromParent)) { + return fromParent.toAbsolutePath().normalize(); + } + return candidate; + } + + /** + * Resolves the path held by a system property. + * + * @param property the system-property name + * @return the resolved path, or {@code null} when the property is unset or empty + */ + static @Nullable Path fromProperty(String property) { + return resolve(System.getProperty(property)); + } +} diff --git a/llama/src/main/java/net/ladenthin/llama/server/ChunkSink.java b/llama/src/main/java/net/ladenthin/llama/server/ChunkSink.java index 375bea80..02cb145c 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/ChunkSink.java +++ b/llama/src/main/java/net/ladenthin/llama/server/ChunkSink.java @@ -15,7 +15,7 @@ * failure propagate so the in-flight generation can be cancelled. */ @FunctionalInterface -interface ChunkSink { +public interface ChunkSink { /** * Accept one streaming chunk's JSON text. diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiBackend.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiBackend.java index 2e469ff2..cb548623 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiBackend.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiBackend.java @@ -19,8 +19,13 @@ * handler) and returns the OpenAI-shaped response JSON, except {@link #stream} which delivers chunks * incrementally. The {@code GET /v1/models} response is built from configuration alone and so is not * part of this seam. + * + *

Public so that sibling modules can drive the real HTTP surface with a scripted backend — the + * {@code llama-atmosphere-agent} wire-contract tests replay llama.cpp-shaped + * {@code chat.completion.chunk} sequences through {@link OpenAiCompatServer} to prove an OpenAI client's + * tool-calling loop end to end without a model. */ -interface OpenAiBackend { +public interface OpenAiBackend { /** * Return llama.cpp server metrics, including per-slot cache counters. diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java index dd051c5d..efa75be7 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java @@ -161,14 +161,15 @@ public OpenAiCompatServer(LlamaModel model, OpenAiServerConfig config) throws IO } /** - * Create a server backed by an arbitrary {@link OpenAiBackend}. Used by tests to drive the full HTTP - * surface without a native library or model. + * Create a server backed by an arbitrary {@link OpenAiBackend}. Used by tests — this module's and + * sibling modules' (see {@code llama-atmosphere-agent}) — to drive the full HTTP surface without a + * native library or model. * * @param backend the inference engine seam * @param config the server configuration * @throws IOException if the listening socket cannot be bound */ - OpenAiCompatServer(OpenAiBackend backend, OpenAiServerConfig config) throws IOException { + public OpenAiCompatServer(OpenAiBackend backend, OpenAiServerConfig config) throws IOException { this.config = config; this.backend = backend; this.requestExecutor = Executors.newCachedThreadPool(namedFactory("jllama-openai-http"));