From 96cbbc7cde23eed9cf6bd11e76b4498ded66163f Mon Sep 17 00:00:00 2001 From: Bernard Ladenthin Date: Mon, 21 Sep 2026 21:01:18 +0200 Subject: [PATCH 1/3] llama-atmosphere-agent: general-purpose default prompt, run_command for any command The default system prompt called the agent a "careful coding agent" and described run_command as a way "to build, test or inspect the project"; the tool description said "build, test, grep or list files". Qwen3-4B read that as a prohibition and refused "list the docker images" ("my tools are only for files") although the tool was registered and docker was on PATH -- in a fresh single-turn run too, so it was the prompt, not the chat history. - LocalAgent.systemPrompt: a general-purpose assistant on the user's machine. With --allow-shell it states that run_command executes any command line through the named shell, starting in the workspace but not limited to it, and that the model should run a command instead of explaining one; without it, the prompt says commands are unavailable and names the flag. Answers in the user's language. - ShellTool: description widened to "any command line on this machine through "; isWindows()/shellName() shared with the prompt. - READMEs: general-purpose wording, an "everything at once" example with --allow-shell --system, a "The system prompt" section, generic paths only. - CLAUDE.md: why the prompt must not be narrowed back; ShellToolTest is POSIX-only. Verified with Qwen3-4B-Instruct-2507 on Vulkan: the same question now calls run_command {command=docker images} with the default prompt. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01E2h8gXyyE5UeimkL9vQv9G --- CLAUDE.md | 17 ++++++- README.md | 9 +++- llama-atmosphere-agent/README.md | 45 ++++++++++++++++--- .../llama/atmosphere/AgentOptions.java | 2 +- .../llama/atmosphere/LocalAgent.java | 29 ++++++++---- .../ladenthin/llama/atmosphere/ShellTool.java | 35 +++++++++++---- .../llama/atmosphere/AgentOptionsTest.java | 30 +++++++++++++ 7 files changed, 139 insertions(+), 28 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0c2bed6a..04baa807 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2140,7 +2140,7 @@ releases as a signed Central Portal bundle upload (staging repo → zip → Publ ## Local coding agent with Atmosphere (`llama-atmosphere-agent/`, standalone) -A **copy-and-run terminal coding agent** (Claude Code / OpenCode reduced to the essentials, offline) +A **copy-and-run general-purpose terminal 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 @@ -2192,7 +2192,7 @@ the moment anything runs on the module path. 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` +`cmd /c` starting in the workspace, timeout kills the process tree, output tail-truncated), `LocalAgent` (`--base-url` = external server, `--model` = in-process `LlamaModel` + loopback `OpenAiCompatServer` with `enableJinja()` and `setLogVerbosity(2)` by default — llama.cpp logs to **stderr**, the console the streamed answer shares, so the per-request `slot …` INFO lines would interleave with it; `--log-verbosity ` @@ -2205,6 +2205,19 @@ a `jvm.config` takes no comments, so REUSE can only read its metadata from that `REUSE Compliance Check` job fails on `main` — which is how it was found, the PR run having been cancelled. Spotless (palantir) is configured in its own pom; the model-free CI job runs `spotless:check`. +**The default system prompt is general-purpose on purpose — do not narrow it back.** `LocalAgent.systemPrompt` +and the `ShellTool` description describe `run_command` as running *any* command line through the named +shell (`ShellTool.shellName()`), not limited to the workspace, and tell the model to run a command rather +than explain one. The earlier wording ("careful *coding agent*", `run_command` "to build, test or inspect +the project" / "build, test, grep or list files") made Qwen3-4B refuse "list the docker images" — "my +tools are only for files" — with the tool registered and `docker` on `PATH`; a fresh single-turn run +refused too, so it was the prompt, not the chat history. Without `--allow-shell` the prompt says commands +are unavailable and names the flag, so the model does not invent its own limitation. Pinned by +`AgentOptionsTest.defaultSystemPromptIsGeneralPurposeAndAllowsAnyCommandWithTheShell` and +`shellToolDescriptionDoesNotNarrowItToTheProject`. `ShellToolTest` uses POSIX commands (`ls`, `sleep`, +`exit 3`), so it passes only on Linux/macOS — CI runs it on Linux; on Windows run the suite with +`-Dtest=!ShellToolTest`. + **Version bump note.** The pom's `llama.version` property is the **release** version, not the reactor's `-SNAPSHOT` (CI always overrides it, so a not-yet-published default never breaks CI). `versions:set` does not touch this standalone pom, so at release time bump it by hand together with diff --git a/README.md b/README.md index 59b2b2ec..9ac10627 100644 --- a/README.md +++ b/README.md @@ -1021,8 +1021,9 @@ 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 +A copy-and-run **general-purpose terminal agent on the JVM** — Claude Code / OpenCode reduced to the +essentials, fully offline; it edits files and, with `--allow-shell`, runs any command on your machine +(`docker`, `git`, build tools) — 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. With java-llama.cpp already running @@ -1043,6 +1044,10 @@ mvn -q compile exec:java \ # or without a separate server: load the GGUF in-process mvn -q compile exec:java \ -Dexec.args="--model /models/Qwen2.5-7B-Instruct-Q4_K_M.gguf --ngl 99 --workspace /path/to/project" + +# everything at once: shell access plus your own system prompt (replaces the built-in one) +mvn -q compile exec:java \ + -Dexec.args="--model /models/Qwen3-4B-Instruct-2507-Q4_K_M.gguf --ngl 99 --ctx-size 16384 --workspace /path/to/project --allow-shell --system 'You are a local assistant on this machine with full shell access. run_command executes any command line, including docker, git and build tools. When asked about the system, run a command instead of explaining it. Answer in the language of the user.'" ``` The full streaming tool-calling loop (tools → `delta.tool_calls` → Java tool → `role:"tool"` result → diff --git a/llama-atmosphere-agent/README.md b/llama-atmosphere-agent/README.md index f2f23be2..58a47386 100644 --- a/llama-atmosphere-agent/README.md +++ b/llama-atmosphere-agent/README.md @@ -4,10 +4,12 @@ SPDX-FileCopyrightText: 2026 Bernard Ladenthin SPDX-License-Identifier: MIT --> -# llama-atmosphere-agent — a local JVM coding agent on java-llama.cpp +# llama-atmosphere-agent — a local, general-purpose JVM 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: +A minimal, copy-and-run **general-purpose terminal agent** (think Claude Code / OpenCode, reduced to +the essentials): it reads and edits files, and with `--allow-shell` it runs any command on your +machine — `docker`, `git`, build tools, system information. It 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**. @@ -16,7 +18,8 @@ essentials) that runs entirely on the JVM and entirely offline: 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. +- **Shell:** an opt-in `run_command` tool (`--allow-shell`) that runs any command line through the + system shell (`cmd.exe` on Windows, `sh` elsewhere). 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 and @@ -66,6 +69,18 @@ mvn -q compile exec:java \ -Dexec.args="--model /models/Qwen2.5-7B-Instruct-Q4_K_M.gguf --ngl 99 --workspace /path/to/project" ``` +**Everything at once — shell access and your own system prompt:** + +```bash +mvn -q compile exec:java \ + -Dexec.args="--model /models/Qwen3-4B-Instruct-2507-Q4_K_M.gguf --ngl 99 --ctx-size 16384 --workspace /path/to/project --allow-shell --system 'You are a local assistant on this machine with full shell access. run_command executes any command line, including docker, git and build tools. When asked about the system, run a command instead of explaining it. Read a file before you edit it. Answer in the language of the user.'" +``` + +Then ask, for example, *"which docker images are available?"* or *"build the project and fix the +first compiler error"*. On Windows PowerShell, quote the whole argument instead: +`"-Dexec.args=--model C:\models\… --allow-shell --system '…'"`. Inside `--system '…'` avoid the +apostrophe (write *the user* rather than *user's*): the value is already single-quoted. + 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. In mode A the classifier is @@ -79,8 +94,8 @@ irrelevant: inference stays in the running server, the agent's JVM loads no mode | `--model ` | load this GGUF in-process instead | — | | `--ngl ` / `--ctx-size ` | GPU layers / context size for `--model` | `0` / `8192` | | `--log-verbosity ` / `--verbose` | llama.cpp log threshold for `--model` (1 errors, 2 warnings, 3 info, 4 trace, 5 debug) / log everything | `2` / off | -| `--workspace ` | directory the file tools (and `run_command`) are confined to | cwd | -| `--allow-shell` | register `run_command` | off | +| `--workspace ` | directory the file tools are confined to, and where `run_command` starts | cwd | +| `--allow-shell` | register `run_command`: any command line, starting in the workspace | 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` | @@ -103,9 +118,25 @@ code page it saw at startup, so umlauts and emoji in the answer would turn into project's `.mvn/jvm.config` pins `-Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8` for the `mvn` JVM so both sides agree. +### The system prompt + +Without `--system` the agent uses a built-in **general-purpose** prompt: it names the file tools and +the workspace they work on, and — only with `--allow-shell` — states that `run_command` runs *any* +command line on this machine (the shell is named, so the model writes the right syntax) and that the +model should run a command rather than explain one. Without `--allow-shell` it tells the model it +cannot run commands and to suggest the flag, so the model does not invent a limitation of its own. + +This wording matters more than it looks: an earlier default called the agent a *coding agent* and +described `run_command` as a way to *"build, test or inspect the project"*, and Qwen3-4B then refused +*"list the docker images"* ("my tools are only for files") although the tool was registered and the +command worked. `--system ` replaces the default **completely** — include whatever the model +still needs to know (the workspace, the shell, your language) in your own text. + 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. +7B–32B model does multi-step edit/build/test work. Qwen3-4B-Instruct-2507 is a good fast default (fits +an 8 GB GPU with a 16k context); Qwen2.5-Coder-7B, in contrast, wrote the call as a JSON code block +into its answer instead of calling the tool. ## What is verified, and where 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 index 95647980..cdc63af3 100644 --- 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 @@ -167,7 +167,7 @@ public static String usage() { "", "Agent:", " --workspace directory the file tools are confined to (default: cwd)", - " --allow-shell add the run_command tool (runs shell commands in the workspace)", + " --allow-shell add the run_command tool (runs any command line, starting 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 + ")", 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 index 53cb2c14..a174ef47 100644 --- 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 @@ -23,7 +23,7 @@ import org.jspecify.annotations.Nullable; /** - * A local, terminal coding agent in the spirit of Claude Code / OpenCode, built from two parts that + * A local, general-purpose terminal 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. * @@ -212,6 +212,13 @@ static ModelParameters modelParameters(AgentOptions options) { /** * The default system prompt, or the {@code --system} override. * + *

The default describes a general-purpose agent on this machine, not a coding agent confined to a + * project: a small model reads a narrow role or tool description as a prohibition and then refuses + * requests such as "list the docker images" even though {@code run_command} could do it. With + * {@code --allow-shell} the prompt therefore states that any command line is allowed and that the + * model should run a command rather than explain one; without it, the prompt says so honestly + * instead of letting the model invent a limitation. + * * @param options the options * @return the system prompt */ @@ -219,13 +226,19 @@ static String systemPrompt(AgentOptions options) { if (options.getSystemPrompt() != null) { return options.getSystemPrompt(); } + String files = " The file tools ls, read_file, write_file, edit_file, glob, grep, delete and rename work" + + " on the directory " + options.getWorkspace() + "; their paths are relative to it."; 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."; + ? " You have full shell access: run_command executes any command line through " + + ShellTool.shellName() + " on this machine, starting in that directory but not" + + " limited to it, e.g. docker, git, package managers, build tools or system" + + " information. When the user asks about this machine or wants something done," + + " run the command instead of explaining how to do it." + : " You cannot run shell commands in this session; if a request needs one, say so and" + + " suggest restarting the agent with --allow-shell."; + return "You are a helpful general-purpose assistant running locally on the user's computer, with" + + " tools to act on it." + files + shell + + " Work step by step: read a file before you edit it, check the result after a change," + + " and finish with a short summary. Answer in the user's language."; } } 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 index e7cb3f21..69bec10c 100644 --- 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 @@ -15,7 +15,7 @@ import org.atmosphere.ai.tool.ToolDefinition; /** - * The {@code run_command} tool: runs a shell command inside the workspace and returns its exit code and + * The {@code run_command} tool: runs any shell command (starting in 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. */ @@ -40,9 +40,11 @@ private ShellTool() {} 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) + "Run any command line on this machine through " + shellName() + " and return its exit" + + " code and output (stdout and stderr merged). It starts in the workspace" + + " directory but is not limited to it: use it for every task a terminal can do," + + " e.g. docker, git, package managers, build tools or system information.") + .parameter(PARAM_COMMAND, "The command line to run through " + shellName(), "string", true) .parameter(PARAM_TIMEOUT, "Seconds to wait before the command is killed", "integer", false) .executor(args -> { Object command = args.get(PARAM_COMMAND); @@ -55,6 +57,26 @@ public static ToolDefinition definition(Path workspace, Duration defaultTimeout, .build(); } + /** + * Whether commands run through {@code cmd.exe} rather than {@code sh}. + * + * @return {@code true} on Windows + */ + static boolean isWindows() { + return System.getProperty("os.name", "") + .toLowerCase(java.util.Locale.ROOT) + .contains("win"); + } + + /** + * The shell {@link #run} uses, named for the model so it writes commands in the right syntax. + * + * @return {@code "cmd.exe on Windows"} or {@code "sh"} + */ + static String shellName() { + return isWindows() ? "cmd.exe on Windows" : "sh"; + } + private static Duration timeoutOf(Object raw, Duration fallback) { if (raw instanceof Number n && n.longValue() > 0) { return Duration.ofSeconds(n.longValue()); @@ -85,11 +107,8 @@ private static Duration timeoutOf(Object raw, Duration fallback) { */ 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); + isWindows() ? new ProcessBuilder("cmd.exe", "/c", command) : new ProcessBuilder("sh", "-c", command); builder.directory(workspace.toFile()); builder.redirectErrorStream(true); Process process = builder.start(); 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 index 6be50bf3..ceedf487 100644 --- 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 @@ -10,7 +10,9 @@ import static org.hamcrest.Matchers.nullValue; import static org.junit.jupiter.api.Assertions.assertThrows; +import java.nio.file.Path; import java.nio.file.Paths; +import java.time.Duration; import org.junit.jupiter.api.Test; class AgentOptionsTest { @@ -145,6 +147,34 @@ void systemPromptMentionsTheShellToolOnlyWhenEnabled() { assertThat(LocalAgent.systemPrompt(plain).contains("run_command"), is(false)); assertThat(LocalAgent.systemPrompt(shell), containsString("run_command")); + } + + @Test + void defaultSystemPromptIsGeneralPurposeAndAllowsAnyCommandWithTheShell() { + // A narrow "coding agent ... build, test or inspect the project" framing made a 4B model refuse + // "list the docker images" although run_command could run it; the prompt must grant it outright. + String shell = LocalAgent.systemPrompt( + AgentOptions.parse(new String[] {"--base-url", "http://x/v1", "--allow-shell"})); + assertThat(shell, containsString("general-purpose")); + assertThat(shell, containsString("any command line through " + ShellTool.shellName())); + assertThat(shell, containsString("run the command instead of explaining")); + assertThat(shell.contains("coding agent"), is(false)); + + // Without the shell the model must not invent a limitation: it is told why and how to lift it. + String plain = LocalAgent.systemPrompt(AgentOptions.parse(new String[] {"--base-url", "http://x/v1"})); + assertThat(plain, containsString("--allow-shell")); + } + + @Test + void shellToolDescriptionDoesNotNarrowItToTheProject() { + String description = + ShellTool.definition(Path.of("."), Duration.ofSeconds(1), 100).description(); + assertThat(description, containsString("any command line")); + assertThat(description, containsString(ShellTool.shellName())); + } + + @Test + void systemPromptOverrideReplacesTheDefault() { assertThat( LocalAgent.systemPrompt(AgentOptions.parse(new String[] {"--base-url", "u", "--system", "custom"})), is("custom")); From 8070817934a13ad4baef89571debec1575b352be Mon Sep 17 00:00:00 2001 From: Bernard Ladenthin Date: Mon, 21 Sep 2026 21:16:50 +0200 Subject: [PATCH 2/3] llama-atmosphere-agent: make ShellToolTest run on Windows too ShellToolTest used POSIX commands (ls, sleep, printf, `; exit 3`) and failed 4 of 5 tests on Windows, where ShellTool runs cmd.exe /c. Each test now picks its command line with ShellTool.isWindows() -- the same detection ShellTool.run uses to choose the shell -- so it always speaks the shell the tool actually starts: - ls / dir /b - `echo boom 1>&2; exit 3` / `echo boom 1>&2 & exit 3` - truncation: echo on both, the expected length counts the platform's line separator (\n from sh, \r\n from cmd.exe) - sleep 30 / ping -n 30 127.0.0.1 >nul (also a child of the shell, so the descendant kill stays covered) Verified: Windows 11 (all 31 agent tests), Linux in maven:3.9-eclipse-temurin-21 (ShellToolTest 5/5, AgentOptionsTest 11/11). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01E2h8gXyyE5UeimkL9vQv9G --- CLAUDE.md | 8 ++-- .../llama/atmosphere/ShellToolTest.java | 39 +++++++++++++++---- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 04baa807..07558b10 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2214,9 +2214,11 @@ tools are only for files" — with the tool registered and `docker` on `PATH`; a refused too, so it was the prompt, not the chat history. Without `--allow-shell` the prompt says commands are unavailable and names the flag, so the model does not invent its own limitation. Pinned by `AgentOptionsTest.defaultSystemPromptIsGeneralPurposeAndAllowsAnyCommandWithTheShell` and -`shellToolDescriptionDoesNotNarrowItToTheProject`. `ShellToolTest` uses POSIX commands (`ls`, `sleep`, -`exit 3`), so it passes only on Linux/macOS — CI runs it on Linux; on Windows run the suite with -`-Dtest=!ShellToolTest`. +`shellToolDescriptionDoesNotNarrowItToTheProject`. `ShellToolTest` runs on every platform: each test +picks its command line with `ShellTool.isWindows()` — the same detection `ShellTool.run` uses to choose +`cmd.exe /c` over `sh -c` — so `ls`/`dir /b`, `sleep 30`/`ping -n 30 127.0.0.1 >nul`, and the truncation +test counts the platform's line separator. It used plain POSIX commands before and failed 4 of 5 on +Windows; never skip it per OS, give a new test both command forms instead. **Version bump note.** The pom's `llama.version` property is the **release** version, not the reactor's `-SNAPSHOT` (CI always overrides it, so a not-yet-published default never breaks CI). 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 index d4cada4b..0d92693e 100644 --- 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 @@ -17,17 +17,33 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +/** + * Runs on every platform: each test picks its command line with {@link ShellTool#isWindows()}, the same + * detection {@link ShellTool#run} uses to choose between {@code cmd.exe /c} and {@code sh -c}, so the test + * always speaks the shell the tool actually starts. + */ class ShellToolTest { @TempDir Path workspace; + /** + * The command line for the shell {@link ShellTool} starts on this platform. + * + * @param posix the {@code sh} form + * @param windows the {@code cmd.exe} form + * @return the form matching {@link ShellTool#isWindows()} + */ + private static String shell(String posix, String windows) { + return ShellTool.isWindows() ? windows : posix; + } + @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")); + Object result = tool.executor().execute(Map.of("command", shell("ls", "dir /b"))); assertThat(tool.name(), is(ShellTool.TOOL_NAME)); assertThat(String.valueOf(result), startsWith("exit code: 0")); @@ -38,7 +54,8 @@ void runsInTheWorkspaceAndReportsExitCodeAndOutput() throws Exception { 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")); + Object result = + tool.executor().execute(Map.of("command", shell("echo boom 1>&2; exit 3", "echo boom 1>&2 & exit 3"))); assertThat(String.valueOf(result), startsWith("exit code: 3")); assertThat(String.valueOf(result), containsString("boom")); @@ -53,15 +70,23 @@ void missingCommandIsAnErrorString() throws Exception { @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")); + // echo is the one output command both shells share; it ends the line with the platform's + // separator (\n from sh, \r\n from cmd.exe), which is part of the counted output. + String newline = ShellTool.isWindows() ? "\r\n" : "\n"; + String result = ShellTool.run(workspace, "echo aaaaaaaaaaaaaaaaaaaaZZ", Duration.ofSeconds(30), 5); + + assertThat( + result, + containsString("[output truncated to the last 5 of " + (22 + newline.length()) + " characters]")); + assertThat(result, containsString("ZZ" + newline)); } @Test void timeoutKillsTheProcess() throws Exception { - String result = ShellTool.run(workspace, "sleep 30", Duration.ofMillis(300), 10_000); + // cmd.exe has no sleep; ping waits about one second per echo request. Like `sh -c "sleep 30"`, + // it is a child of the shell, so this also covers killing the descendants. + String result = ShellTool.run( + workspace, shell("sleep 30", "ping -n 30 127.0.0.1 >nul"), Duration.ofMillis(300), 10_000); assertThat(result, startsWith("exit code: (killed after 0 s)")); } From 7dc26c05f38f945cdf7d3afcaad722ddc09eb782 Mon Sep 17 00:00:00 2001 From: Bernard Ladenthin Date: Mon, 21 Sep 2026 21:43:43 +0200 Subject: [PATCH 3/3] llama-atmosphere-agent: move model-facing texts into resource files The system prompt and the run_command description were Java string concatenations. They are now plain text next to the classes, in src/main/resources/net/ladenthin/llama/atmosphere/: - system-prompt.txt ({workspace}, {shell_section}) - system-prompt-shell.txt ({shell}; the section with --allow-shell) - system-prompt-no-shell.txt (the section without it) - run-command-tool.txt ({shell}; the tool description) each with a .license sidecar so REUSE stays green without an SPDX comment reaching the model. LocalAgent.prompt(name) loads them (UTF-8, trimmed; a missing file throws). New test promptResourcesLoadAndEveryPlaceholderIsFilled fails on a missing file or a placeholder left unfilled; the tool-description test checks the same. README and CLAUDE.md point at the files. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01E2h8gXyyE5UeimkL9vQv9G --- CLAUDE.md | 7 ++- llama-atmosphere-agent/README.md | 6 ++ .../llama/atmosphere/LocalAgent.java | 56 ++++++++++++++----- .../ladenthin/llama/atmosphere/ShellTool.java | 9 ++- .../llama/atmosphere/run-command-tool.txt | 1 + .../atmosphere/run-command-tool.txt.license | 3 + .../atmosphere/system-prompt-no-shell.txt | 1 + .../system-prompt-no-shell.txt.license | 3 + .../llama/atmosphere/system-prompt-shell.txt | 1 + .../system-prompt-shell.txt.license | 3 + .../llama/atmosphere/system-prompt.txt | 7 +++ .../atmosphere/system-prompt.txt.license | 3 + .../llama/atmosphere/AgentOptionsTest.java | 24 ++++++++ 13 files changed, 103 insertions(+), 21 deletions(-) create mode 100644 llama-atmosphere-agent/src/main/resources/net/ladenthin/llama/atmosphere/run-command-tool.txt create mode 100644 llama-atmosphere-agent/src/main/resources/net/ladenthin/llama/atmosphere/run-command-tool.txt.license create mode 100644 llama-atmosphere-agent/src/main/resources/net/ladenthin/llama/atmosphere/system-prompt-no-shell.txt create mode 100644 llama-atmosphere-agent/src/main/resources/net/ladenthin/llama/atmosphere/system-prompt-no-shell.txt.license create mode 100644 llama-atmosphere-agent/src/main/resources/net/ladenthin/llama/atmosphere/system-prompt-shell.txt create mode 100644 llama-atmosphere-agent/src/main/resources/net/ladenthin/llama/atmosphere/system-prompt-shell.txt.license create mode 100644 llama-atmosphere-agent/src/main/resources/net/ladenthin/llama/atmosphere/system-prompt.txt create mode 100644 llama-atmosphere-agent/src/main/resources/net/ladenthin/llama/atmosphere/system-prompt.txt.license diff --git a/CLAUDE.md b/CLAUDE.md index 07558b10..ddfc1db2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2205,7 +2205,12 @@ a `jvm.config` takes no comments, so REUSE can only read its metadata from that `REUSE Compliance Check` job fails on `main` — which is how it was found, the PR run having been cancelled. Spotless (palantir) is configured in its own pom; the model-free CI job runs `spotless:check`. -**The default system prompt is general-purpose on purpose — do not narrow it back.** `LocalAgent.systemPrompt` +**The default system prompt is general-purpose on purpose — do not narrow it back.** Every model-facing +text is a resource, not a Java literal: `src/main/resources/net/ladenthin/llama/atmosphere/` holds +`system-prompt.txt`, `system-prompt-shell.txt`, `system-prompt-no-shell.txt` and `run-command-tool.txt` +(each with a `.license` sidecar for REUSE), loaded by `LocalAgent.prompt(name)` with `{placeholder}` +substitution; `AgentOptionsTest.promptResourcesLoadAndEveryPlaceholderIsFilled` fails on a missing file or +an unfilled placeholder. `LocalAgent.systemPrompt` and the `ShellTool` description describe `run_command` as running *any* command line through the named shell (`ShellTool.shellName()`), not limited to the workspace, and tell the model to run a command rather than explain one. The earlier wording ("careful *coding agent*", `run_command` "to build, test or inspect diff --git a/llama-atmosphere-agent/README.md b/llama-atmosphere-agent/README.md index 58a47386..aeacf95f 100644 --- a/llama-atmosphere-agent/README.md +++ b/llama-atmosphere-agent/README.md @@ -126,6 +126,12 @@ command line on this machine (the shell is named, so the model writes the right model should run a command rather than explain one. Without `--allow-shell` it tells the model it cannot run commands and to suggest the flag, so the model does not invent a limitation of its own. +The wording is plain text, not Java: [`src/main/resources/net/ladenthin/llama/atmosphere/`](src/main/resources/net/ladenthin/llama/atmosphere/) +holds `system-prompt.txt` (placeholders `{workspace}` and `{shell_section}`), `system-prompt-shell.txt` / +`system-prompt-no-shell.txt` (the `{shell_section}` with and without `--allow-shell`; `{shell}` is the +shell's name) and `run-command-tool.txt` (the `run_command` description the model reads). Edit them +there to change the default for everyone; `--system` overrides it per run. + This wording matters more than it looks: an earlier default called the agent a *coding agent* and described `run_command` as a way to *"build, test or inspect the project"*, and Qwen3-4B then refused *"list the docker images"* ("my tools are only for files") although the tool was registered and the 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 index a174ef47..e551b26a 100644 --- 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 @@ -5,8 +5,11 @@ package net.ladenthin.llama.atmosphere; import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; import java.io.InputStreamReader; import java.io.PrintStream; +import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.ArrayList; @@ -45,6 +48,15 @@ public final class LocalAgent { /** Wall-clock bound on one user turn, including every tool round. */ private static final Duration TURN_TIMEOUT = Duration.ofMinutes(30); + /** The default system prompt; placeholders {@code {workspace}} and {@code {shell_section}}. */ + static final String SYSTEM_PROMPT = "system-prompt.txt"; + + /** The {@code {shell_section}} with {@code --allow-shell}; placeholder {@code {shell}}. */ + static final String SHELL_PROMPT = "system-prompt-shell.txt"; + + /** The {@code {shell_section}} without {@code --allow-shell}. */ + static final String NO_SHELL_PROMPT = "system-prompt-no-shell.txt"; + private static final Duration SHELL_TIMEOUT = Duration.ofSeconds(120); private static final int SHELL_MAX_OUTPUT_CHARS = 20_000; @@ -217,7 +229,8 @@ static ModelParameters modelParameters(AgentOptions options) { * requests such as "list the docker images" even though {@code run_command} could do it. With * {@code --allow-shell} the prompt therefore states that any command line is allowed and that the * model should run a command rather than explain one; without it, the prompt says so honestly - * instead of letting the model invent a limitation. + * instead of letting the model invent a limitation. The text itself is in the resources + * {@value #SYSTEM_PROMPT}, {@value #SHELL_PROMPT} and {@value #NO_SHELL_PROMPT} (see {@link #prompt}). * * @param options the options * @return the system prompt @@ -226,19 +239,32 @@ static String systemPrompt(AgentOptions options) { if (options.getSystemPrompt() != null) { return options.getSystemPrompt(); } - String files = " The file tools ls, read_file, write_file, edit_file, glob, grep, delete and rename work" - + " on the directory " + options.getWorkspace() + "; their paths are relative to it."; - String shell = options.isAllowShell() - ? " You have full shell access: run_command executes any command line through " - + ShellTool.shellName() + " on this machine, starting in that directory but not" - + " limited to it, e.g. docker, git, package managers, build tools or system" - + " information. When the user asks about this machine or wants something done," - + " run the command instead of explaining how to do it." - : " You cannot run shell commands in this session; if a request needs one, say so and" - + " suggest restarting the agent with --allow-shell."; - return "You are a helpful general-purpose assistant running locally on the user's computer, with" - + " tools to act on it." + files + shell - + " Work step by step: read a file before you edit it, check the result after a change," - + " and finish with a short summary. Answer in the user's language."; + String shellSection = options.isAllowShell() + ? prompt(SHELL_PROMPT).replace("{shell}", ShellTool.shellName()) + : prompt(NO_SHELL_PROMPT); + return prompt(SYSTEM_PROMPT) + .replace("{workspace}", options.getWorkspace().toString()) + .replace("{shell_section}", shellSection); + } + + /** + * A prompt text from the resources next to this class, trimmed. + * + *

The wording lives in {@code src/main/resources/net/ladenthin/llama/atmosphere/*.txt} so it can + * be read and edited as text; {@code {placeholders}} are filled in by {@link #systemPrompt}. + * + * @param name the file name, e.g. {@value #SYSTEM_PROMPT} + * @return the file content without leading or trailing whitespace + * @throws IllegalStateException when the resource is missing from the jar + */ + static String prompt(String name) { + try (InputStream in = LocalAgent.class.getResourceAsStream(name)) { + if (in == null) { + throw new IllegalStateException("Prompt resource missing: " + name); + } + return new String(in.readAllBytes(), StandardCharsets.UTF_8).strip(); + } catch (IOException e) { + throw new UncheckedIOException("Cannot read prompt resource " + name, e); + } } } 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 index 69bec10c..7e2604d2 100644 --- 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 @@ -24,6 +24,9 @@ public final class ShellTool { /** Tool name as offered to the model. */ public static final String TOOL_NAME = "run_command"; + /** The tool description the model reads (a resource next to this class); placeholder {@code {shell}}. */ + static final String DESCRIPTION_RESOURCE = "run-command-tool.txt"; + private static final String PARAM_COMMAND = "command"; private static final String PARAM_TIMEOUT = "timeout_seconds"; @@ -39,11 +42,7 @@ private ShellTool() {} */ public static ToolDefinition definition(Path workspace, Duration defaultTimeout, int maxOutputChars) { return ToolDefinition.builder( - TOOL_NAME, - "Run any command line on this machine through " + shellName() + " and return its exit" - + " code and output (stdout and stderr merged). It starts in the workspace" - + " directory but is not limited to it: use it for every task a terminal can do," - + " e.g. docker, git, package managers, build tools or system information.") + TOOL_NAME, LocalAgent.prompt(DESCRIPTION_RESOURCE).replace("{shell}", shellName())) .parameter(PARAM_COMMAND, "The command line to run through " + shellName(), "string", true) .parameter(PARAM_TIMEOUT, "Seconds to wait before the command is killed", "integer", false) .executor(args -> { diff --git a/llama-atmosphere-agent/src/main/resources/net/ladenthin/llama/atmosphere/run-command-tool.txt b/llama-atmosphere-agent/src/main/resources/net/ladenthin/llama/atmosphere/run-command-tool.txt new file mode 100644 index 00000000..8de9b256 --- /dev/null +++ b/llama-atmosphere-agent/src/main/resources/net/ladenthin/llama/atmosphere/run-command-tool.txt @@ -0,0 +1 @@ +Run any command line on this machine through {shell} and return its exit code and output (stdout and stderr merged). It starts in the workspace directory but is not limited to it: use it for every task a terminal can do, e.g. docker, git, package managers, build tools or system information. diff --git a/llama-atmosphere-agent/src/main/resources/net/ladenthin/llama/atmosphere/run-command-tool.txt.license b/llama-atmosphere-agent/src/main/resources/net/ladenthin/llama/atmosphere/run-command-tool.txt.license new file mode 100644 index 00000000..b918686f --- /dev/null +++ b/llama-atmosphere-agent/src/main/resources/net/ladenthin/llama/atmosphere/run-command-tool.txt.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2026 Bernard Ladenthin + +SPDX-License-Identifier: MIT diff --git a/llama-atmosphere-agent/src/main/resources/net/ladenthin/llama/atmosphere/system-prompt-no-shell.txt b/llama-atmosphere-agent/src/main/resources/net/ladenthin/llama/atmosphere/system-prompt-no-shell.txt new file mode 100644 index 00000000..99ab4336 --- /dev/null +++ b/llama-atmosphere-agent/src/main/resources/net/ladenthin/llama/atmosphere/system-prompt-no-shell.txt @@ -0,0 +1 @@ +You cannot run shell commands in this session; if a request needs one, say so and suggest restarting the agent with --allow-shell. diff --git a/llama-atmosphere-agent/src/main/resources/net/ladenthin/llama/atmosphere/system-prompt-no-shell.txt.license b/llama-atmosphere-agent/src/main/resources/net/ladenthin/llama/atmosphere/system-prompt-no-shell.txt.license new file mode 100644 index 00000000..b918686f --- /dev/null +++ b/llama-atmosphere-agent/src/main/resources/net/ladenthin/llama/atmosphere/system-prompt-no-shell.txt.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2026 Bernard Ladenthin + +SPDX-License-Identifier: MIT diff --git a/llama-atmosphere-agent/src/main/resources/net/ladenthin/llama/atmosphere/system-prompt-shell.txt b/llama-atmosphere-agent/src/main/resources/net/ladenthin/llama/atmosphere/system-prompt-shell.txt new file mode 100644 index 00000000..3e22b97a --- /dev/null +++ b/llama-atmosphere-agent/src/main/resources/net/ladenthin/llama/atmosphere/system-prompt-shell.txt @@ -0,0 +1 @@ +You have full shell access: run_command executes any command line through {shell} on this machine, starting in that directory but not limited to it, e.g. docker, git, package managers, build tools or system information. When the user asks about this machine or wants something done, run the command instead of explaining how to do it. diff --git a/llama-atmosphere-agent/src/main/resources/net/ladenthin/llama/atmosphere/system-prompt-shell.txt.license b/llama-atmosphere-agent/src/main/resources/net/ladenthin/llama/atmosphere/system-prompt-shell.txt.license new file mode 100644 index 00000000..b918686f --- /dev/null +++ b/llama-atmosphere-agent/src/main/resources/net/ladenthin/llama/atmosphere/system-prompt-shell.txt.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2026 Bernard Ladenthin + +SPDX-License-Identifier: MIT diff --git a/llama-atmosphere-agent/src/main/resources/net/ladenthin/llama/atmosphere/system-prompt.txt b/llama-atmosphere-agent/src/main/resources/net/ladenthin/llama/atmosphere/system-prompt.txt new file mode 100644 index 00000000..1cc6d722 --- /dev/null +++ b/llama-atmosphere-agent/src/main/resources/net/ladenthin/llama/atmosphere/system-prompt.txt @@ -0,0 +1,7 @@ +You are a helpful general-purpose assistant running locally on the user's computer, with tools to act on it. + +The file tools ls, read_file, write_file, edit_file, glob, grep, delete and rename work on the directory {workspace}; their paths are relative to it. + +{shell_section} + +Work step by step: read a file before you edit it, check the result after a change, and finish with a short summary. Answer in the user's language. diff --git a/llama-atmosphere-agent/src/main/resources/net/ladenthin/llama/atmosphere/system-prompt.txt.license b/llama-atmosphere-agent/src/main/resources/net/ladenthin/llama/atmosphere/system-prompt.txt.license new file mode 100644 index 00000000..b918686f --- /dev/null +++ b/llama-atmosphere-agent/src/main/resources/net/ladenthin/llama/atmosphere/system-prompt.txt.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2026 Bernard Ladenthin + +SPDX-License-Identifier: MIT 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 index ceedf487..44117a05 100644 --- 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 @@ -165,12 +165,36 @@ void defaultSystemPromptIsGeneralPurposeAndAllowsAnyCommandWithTheShell() { assertThat(plain, containsString("--allow-shell")); } + @Test + void promptResourcesLoadAndEveryPlaceholderIsFilled() { + for (String name : new String[] { + LocalAgent.SYSTEM_PROMPT, + LocalAgent.SHELL_PROMPT, + LocalAgent.NO_SHELL_PROMPT, + ShellTool.DESCRIPTION_RESOURCE + }) { + assertThat(name, LocalAgent.prompt(name).isBlank(), is(false)); + } + for (boolean allowShell : new boolean[] {true, false}) { + AgentOptions options = AgentOptions.parse( + allowShell + ? new String[] {"--base-url", "u", "--workspace", "ws", "--allow-shell"} + : new String[] {"--base-url", "u", "--workspace", "ws"}); + String prompt = LocalAgent.systemPrompt(options); + + // a renamed or mistyped placeholder would otherwise reach the model verbatim + assertThat(prompt, prompt.contains("{"), is(false)); + assertThat(prompt, containsString(options.getWorkspace().toString())); + } + } + @Test void shellToolDescriptionDoesNotNarrowItToTheProject() { String description = ShellTool.definition(Path.of("."), Duration.ofSeconds(1), 100).description(); assertThat(description, containsString("any command line")); assertThat(description, containsString(ShellTool.shellName())); + assertThat(description, description.contains("{"), is(false)); } @Test