Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
5cb5119
llama-atmosphere-agent: REPL commands, approval prompt, status line, …
bernardladenthin Sep 22, 2026
02d0d04
llama-atmosphere-agent: add a "Try it" walkthrough to the README
bernardladenthin Sep 22, 2026
1d6efbd
llama-atmosphere-agent: a real terminal via JLine (editing, history, …
bernardladenthin Sep 22, 2026
8751cb1
llama-atmosphere-agent: /loop — keep working on one task, state in a …
bernardladenthin Sep 22, 2026
68d4cac
llama-atmosphere-agent: own read_file, edit_file and grep
bernardladenthin Sep 23, 2026
356fef8
llama-atmosphere-agent: keep tool calls in the history, add /calls
bernardladenthin Sep 23, 2026
0785f4c
llama-atmosphere-agent: live progress, and stop the model parroting t…
bernardladenthin Sep 23, 2026
2a1bca8
llama-atmosphere-agent: our own spinner words
bernardladenthin Sep 23, 2026
b4fd055
llama-atmosphere-agent: add the missing REUSE sidecars for the /loop …
bernardladenthin Sep 23, 2026
6172e12
llama-atmosphere-agent: print the status above the prompt instead of …
bernardladenthin Sep 23, 2026
311dbec
llama-atmosphere-agent: pin the status again on a real terminal
bernardladenthin Sep 23, 2026
daaf47e
llama-atmosphere-agent: two-row status block, so the spinner word is …
bernardladenthin Sep 23, 2026
ae899c3
llama-atmosphere-agent: run the turn on its own thread, so the activi…
bernardladenthin Sep 23, 2026
18ed0f2
llama-atmosphere-agent: keep the pinned block intact under long output
bernardladenthin Sep 23, 2026
6e6735d
llama-atmosphere-agent: auto-compaction before the request that would…
bernardladenthin Sep 23, 2026
0ab8010
llama-atmosphere-agent: refuse to compact an already compacted history
bernardladenthin Sep 23, 2026
e8cbb6c
llama-atmosphere-agent: one printed line is one screen line, a live c…
bernardladenthin Sep 23, 2026
35a072e
llama-atmosphere-agent: shift+tab switches the approval mode
bernardladenthin Sep 23, 2026
7366204
llama-atmosphere-agent: the prompt stays at the bottom, and typing st…
bernardladenthin Sep 23, 2026
cc91731
llama-atmosphere-agent: frame the input into the bottom block, and cl…
bernardladenthin Sep 23, 2026
335b42d
llama-atmosphere-agent: the input box left a rule behind on every Enter
bernardladenthin Sep 23, 2026
beddb16
llama-atmosphere-agent: stop three writers from fighting over the ter…
bernardladenthin Sep 23, 2026
1ad2965
llama-atmosphere-agent: scroll to the bottom once, so the input start…
bernardladenthin Sep 23, 2026
10deecd
llama-atmosphere-agent: /cls wipes the screen, /clear wipes it and th…
bernardladenthin Sep 23, 2026
38d8e08
llama-atmosphere-agent: put the block back after a screen wipe
bernardladenthin Sep 23, 2026
dc0689c
llama-atmosphere-agent: handle window resizes, and icons in the statu…
bernardladenthin Sep 23, 2026
19f2765
llama-atmosphere-agent: drop the resize handler that made resizing worse
bernardladenthin Sep 23, 2026
3dd4581
llama-atmosphere-agent: pin the resize path with a test that drives t…
bernardladenthin Sep 23, 2026
55aaaf0
llama-atmosphere-agent: README — say what the resize test proves and …
bernardladenthin Sep 23, 2026
1030404
llama-atmosphere-agent: drive interrupt-then-continue end to end in a…
bernardladenthin Sep 23, 2026
877ec54
llama-atmosphere-agent: --plain, a second console that only appends l…
bernardladenthin Sep 24, 2026
49b19e5
llama-atmosphere-agent: record what was said, with timestamps, and /s…
bernardladenthin Sep 24, 2026
91210b6
llama-atmosphere-agent: /retry and --system-file
bernardladenthin Sep 24, 2026
2c1b729
llama-atmosphere-agent: multi-line input between """ fences
bernardladenthin Sep 24, 2026
06980c6
llama-atmosphere-agent: /load reads a saved transcript back as the co…
bernardladenthin Sep 24, 2026
64dbb1d
llama-atmosphere-agent: fix a wrapped line in the help text
bernardladenthin Sep 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
300 changes: 300 additions & 0 deletions CLAUDE.md

Large diffs are not rendered by default.

10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1042,8 +1042,14 @@ mvn -q compile exec:java \
```

> [!WARNING]
> With `--allow-shell` the model runs any command it decides to run, with your user's rights and
> without asking. Use a machine and a workspace you are willing to hand to the model.
> `--allow-shell` lets the model run any command with your user's rights. By default every write and
> every command is confirmed on the console (`[y]es / [n]o / [a]uto`); `--auto` turns that off. Use a
> workspace you are willing to hand to the model.

In the REPL, `/help` lists the commands (`/status`, `/tools`, `/mode manual|auto`, `/compact`,
`/clear`, `/exit`); anything else goes to the model. A status line shows the approval mode and the
context used (`[manual · ctx ~3.1k/16k · 9 tools · local-model]`), and the answer is rendered with
headings, bullets and code spans.

On Windows PowerShell quote the whole argument (`"-Dexec.args=--model models\… --allow-shell"`); for
the GPU add e.g. `-Dllama.classifier=vulkan-windows-x86-64` and `--ngl 99`. The agent's
Expand Down
430 changes: 419 additions & 11 deletions llama-atmosphere-agent/README.md

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions llama-atmosphere-agent/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ SPDX-License-Identifier: MIT
e.g. -Dllama.classifier=cuda13-linux-x86-64 or vulkan-windows-x86-64 (runtime on PATH). -->
<llama.classifier></llama.classifier>
<atmosphere.version>4.0.70</atmosphere.version>
<jline.version>4.4.5</jline.version>
<slf4j.version>2.0.19</slf4j.version>
<jspecify.version>1.0.1</jspecify.version>
<junit.version>6.1.3</junit.version>
Expand Down Expand Up @@ -91,6 +92,15 @@ SPDX-License-Identifier: MIT
<version>${atmosphere.version}</version>
</dependency>

<!-- The terminal: line editing, history, tab completion, a pinned status line and single-key
answers. One jar, no transitive dependencies; the agent falls back to a plain stream
whenever JLine finds no usable terminal (piped input, tests, one-shot runs). -->
<dependency>
<groupId>org.jline</groupId>
<artifactId>jline</artifactId>
<version>${jline.version}</version>
</dependency>

<dependency>
<groupId>org.jspecify</groupId>
<artifactId>jspecify</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ public final class AgentOptions {
/** Context size for the in-process model ({@code --model}). */
public static final int DEFAULT_CTX_SIZE = 8192;

/** Whether the history is summarized on its own before it overflows the context. */
public static final boolean DEFAULT_AUTO_COMPACT = true;

/**
* How full the context may get before that happens, in percent.
*
* <p>Lower than the ~85 % a hosted agent uses, and deliberately so: this number is usually an
* estimate from the text length (llama.cpp reports its own count only to clients that ask for it),
* and the reply still has to fit next to the prompt.
*/
public static final int DEFAULT_COMPACT_AT = 70;

/**
* Log verbosity threshold of the in-process model ({@code --model}): llama.cpp's {@code -lv}
* scale, {@code 0} output only, {@code 1} errors, {@code 2} warnings, {@code 3} info, {@code 4}
Expand All @@ -56,6 +68,11 @@ public final class AgentOptions {
private final String modelId;
private final Path workspace;
private final boolean allowShell;
private final boolean plain;
private final java.nio.file.@org.jspecify.annotations.Nullable Path transcript;
private final boolean auto;
private final boolean autoCompact;
private final int compactAt;
private final double temperature;
private final int maxTokens;
private final int maxToolRounds;
Expand All @@ -74,6 +91,11 @@ private AgentOptions(Builder b) {
this.modelId = b.modelId;
this.workspace = b.workspace;
this.allowShell = b.allowShell;
this.plain = b.plain;
this.transcript = b.transcript;
this.auto = b.auto;
this.autoCompact = b.autoCompact;
this.compactAt = b.compactAt;
this.temperature = b.temperature;
this.maxTokens = b.maxTokens;
this.maxToolRounds = b.maxToolRounds;
Expand All @@ -97,6 +119,11 @@ public static AgentOptions parse(String[] args) {
switch (a) {
case "-h", "--help" -> b.help = true;
case "--allow-shell" -> b.allowShell = true;
case "--plain" -> b.plain = true;
case "--transcript" -> b.transcript = java.nio.file.Path.of(value(args, ++i, a));
case "--auto" -> b.auto = true;
case "--auto-compact" -> b.autoCompact = booleanValue(args, ++i, a);
case "--compact-at" -> b.compactAt = percentValue(args, ++i, a);
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);
Expand All @@ -112,6 +139,7 @@ public static AgentOptions parse(String[] args) {
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 "--system-file" -> b.systemPrompt = readSystemPrompt(value(args, ++i, a));
case "--prompt", "-p" -> b.prompt = value(args, ++i, a);
default -> throw new IllegalArgumentException("Unknown argument: " + a);
}
Expand All @@ -131,6 +159,25 @@ private static String value(String[] args, int index, String flag) {
return args[index];
}

private static boolean booleanValue(String[] args, int index, String flag) {
String raw = value(args, index, flag).trim();
if ("true".equalsIgnoreCase(raw) || "yes".equalsIgnoreCase(raw) || "on".equalsIgnoreCase(raw)) {
return true;
}
if ("false".equalsIgnoreCase(raw) || "no".equalsIgnoreCase(raw) || "off".equalsIgnoreCase(raw)) {
return false;
}
throw new IllegalArgumentException("Expected true or false for " + flag + ", got: " + raw);
}

private static int percentValue(String[] args, int index, String flag) {
int percent = intValue(args, index, flag);
if (percent < 10 || percent > 95) {
throw new IllegalArgumentException(flag + " must be between 10 and 95, got: " + percent);
}
return percent;
}

private static int intValue(String[] args, int index, String flag) {
String raw = value(args, index, flag);
try {
Expand Down Expand Up @@ -168,6 +215,13 @@ public static String usage() {
"Agent:",
" --workspace <dir> directory the file tools are confined to (default: cwd)",
" --allow-shell add the run_command tool (runs any command line, starting in the workspace)",
" --system-file <file> replace the system prompt with the content of a file",
" --plain line-oriented console: no pinned block, no cursor control",
" --transcript <file> append what is said, with timestamps, as it happens",
" --auto run tools without asking (default: ask before writes and commands)",
" --auto-compact <bool> summarize the history before it overflows the context (default "
+ DEFAULT_AUTO_COMPACT + ")",
" --compact-at <percent> how full the context may get first (default " + DEFAULT_COMPACT_AT + ")",
" --system <text> replace the default system prompt",
" --prompt <text>, -p run one turn and exit (default: interactive; /exit to quit)",
" --temperature <t> sampling temperature (default " + DEFAULT_TEMPERATURE + ")",
Expand Down Expand Up @@ -259,6 +313,34 @@ public Path getWorkspace() {
return workspace;
}

/**
* Whether the history is summarized before it overflows the context.
*
* @return {@code true} when auto-compaction is on
*/
public boolean isAutoCompact() {
return autoCompact;
}

/**
* How full the context may get before the history is summarized.
*
* @return the threshold in percent
*/
public int getCompactAt() {
return compactAt;
}

/**
* Whether tool calls run without asking.
*
* @return {@code true} when {@code --auto} was given, i.e. the session starts in
* {@link ApprovalMode#AUTO}
*/
public boolean isAuto() {
return auto;
}

/**
* Whether the {@code run_command} tool is registered.
*
Expand All @@ -268,6 +350,35 @@ public boolean isAllowShell() {
return allowShell;
}

/**
* Whether to use the line-oriented console even when a full terminal is available.
*
* <p>The rich console positions the cursor: it pins a block to the bottom of the window and keeps
* the input line there while output scrolls above it. That needs a terminal that reports its size
* and understands the sequences, which is the normal case over SSH as well — but not in a plain
* pipe, a CI log, a `dumb` terminal, an editor's run window or a serial console, and not when the
* session is being recorded as text. This flag chooses the console that only ever appends lines,
* which is also what the agent falls back to on its own when there is no usable terminal.
*
* @return {@code true} when {@code --plain} was passed
*/
public boolean isPlain() {
return plain;
}

/**
* Where to append the session transcript as it happens, if anywhere.
*
* <p>{@code /save} writes the whole thing on request; this writes each line as it is said, so a
* session that is killed still leaves what it had. A failure to write is swallowed: a record that
* exists to survive a bad ending must not cause one.
*
* @return the file, or {@code null} when the transcript is kept in memory only
*/
public java.nio.file.@org.jspecify.annotations.Nullable Path getTranscript() {
return transcript;
}

/**
* Sampling temperature.
*
Expand Down Expand Up @@ -304,6 +415,25 @@ public int getMaxToolRounds() {
return systemPrompt;
}

/**
* Read a system prompt from a file.
*
* <p>Read here rather than when it is used, so a path that does not exist is a usage error at
* startup instead of a surprise on the first turn. A prompt long enough to be worth a file is also
* long enough that a typo in the path is easy to miss.
*
* @param path the file
* @return its content
* @throws IllegalArgumentException when it cannot be read
*/
private static String readSystemPrompt(String path) {
try {
return java.nio.file.Files.readString(java.nio.file.Path.of(path), java.nio.charset.StandardCharsets.UTF_8);
} catch (java.io.IOException | RuntimeException e) {
throw new IllegalArgumentException("--system-file cannot be read: " + path + " (" + e.getMessage() + ")");
}
}

/**
* One-shot prompt.
*
Expand All @@ -327,7 +457,11 @@ public String toString() {
return "AgentOptions{baseUrl=" + baseUrl + ", modelPath=" + modelPath + ", gpuLayers=" + gpuLayers
+ ", ctxSize=" + ctxSize + ", logVerbosity=" + (verbose ? "verbose" : logVerbosity)
+ ", modelId=" + modelId + ", workspace=" + workspace
+ ", allowShell=" + allowShell + ", temperature=" + temperature + ", maxTokens=" + maxTokens
+ ", allowShell=" + allowShell + ", plain=" + plain + ", transcript=" + transcript + ", auto=" + auto
+ ", autoCompact=" + autoCompact
+ ", temperature="
+ temperature + ", maxTokens="
+ maxTokens
+ ", maxToolRounds=" + maxToolRounds + ", prompt=" + (prompt == null ? "<interactive>" : "<set>")
+ "}";
}
Expand All @@ -347,6 +481,11 @@ private static final class Builder {
String modelId = DEFAULT_MODEL_ID;
Path workspace = Paths.get("").toAbsolutePath().normalize();
boolean allowShell;
boolean plain;
java.nio.file.@org.jspecify.annotations.Nullable Path transcript;
boolean auto;
boolean autoCompact = DEFAULT_AUTO_COMPACT;
int compactAt = DEFAULT_COMPACT_AT;
double temperature = DEFAULT_TEMPERATURE;
int maxTokens = DEFAULT_MAX_TOKENS;
int maxToolRounds = DEFAULT_MAX_TOOL_ROUNDS;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,17 @@
import java.util.Map;
import org.atmosphere.ai.AgentExecutionContext;
import org.atmosphere.ai.AiConfig;
import org.atmosphere.ai.ExecutionHandle;
import org.atmosphere.ai.RetryPolicy;
import org.atmosphere.ai.StreamingSession;
import org.atmosphere.ai.approval.ApprovalStrategy;
import org.atmosphere.ai.approval.ToolApprovalPolicy;
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;
import org.jspecify.annotations.Nullable;

/**
* The minimal wiring between Atmosphere's built-in OpenAI-compatible agent runtime and an
Expand All @@ -36,6 +40,8 @@ public final class AgentRunner {
private final String systemPrompt;
private final int maxToolRounds;
private RetryPolicy retryPolicy = RetryPolicy.DEFAULT;
private @Nullable ApprovalStrategy approvalStrategy;
private @Nullable ToolApprovalPolicy approvalPolicy;

/**
* Configure the runtime for one endpoint.
Expand Down Expand Up @@ -83,6 +89,24 @@ public AgentRunner retryPolicy(RetryPolicy retryPolicy) {
return this;
}

/**
* Gate the tools {@code policy} selects behind {@code strategy}: Atmosphere's tool loop then blocks
* on the strategy before such a tool runs, and turns a denial into a {@code cancelled} tool result
* for the model on its own.
*
* <p>Without this, no tool is gated — Atmosphere's default policy honours a tool's own
* {@code requiresApproval()}, and none of this agent's tools set it.
*
* @param strategy what asks the user, e.g. {@link ConsoleApprovalStrategy}
* @param policy which tools it is asked about, e.g. {@link ConsoleApprovalStrategy#policy()}
* @return this runner
*/
public AgentRunner approval(ApprovalStrategy strategy, ToolApprovalPolicy policy) {
this.approvalStrategy = strategy;
this.approvalPolicy = policy;
return this;
}

/**
* The model ids the endpoint advertises on {@code GET /v1/models}, falling back to the configured
* id when enumeration fails.
Expand Down Expand Up @@ -110,6 +134,47 @@ public List<String> toolNames() {
* @param session receives streamed text, tool events and the terminal complete/error
*/
public void run(String message, List<ChatMessage> history, StreamingSession session) {
runtime.execute(context(message, history, tools, systemPrompt, session), session);
}

/**
* Start one user turn and return at once, with a handle that can stop it.
*
* <p>This is the same turn {@link #run} performs, on Atmosphere's cancellation-aware entry point:
* the turn runs on a virtual thread of the framework's, and {@link ExecutionHandle#cancel()}
* closes the HTTP stream the model is answering on, which unblocks the read loop. That is what
* lets a request typed while the agent is working take effect immediately instead of at the end
* of a tool loop that may run for minutes.
*
* @param message the user message
* @param history prior turns, replayed before the message
* @param session receives streamed text, tool events and the terminal complete/error
* @return the handle; the session's own completion stays the signal that the turn is over
*/
public ExecutionHandle start(String message, List<ChatMessage> history, StreamingSession session) {
return runtime.executeWithHandle(context(message, history, tools, systemPrompt, session), session);
}

/**
* Run one turn with no tools at all and a system prompt of its own — what {@code /compact} needs:
* a summary must not read files or run commands, it must only condense what is already there.
*
* @param message the user message
* @param history prior turns, replayed before the message
* @param session receives the streamed summary
* @param systemPrompt the system prompt for this one turn
*/
public void runWithoutTools(
String message, List<ChatMessage> history, StreamingSession session, String systemPrompt) {
runtime.execute(context(message, history, List.of(), systemPrompt, session), session);
}

private AgentExecutionContext context(
String message,
List<ChatMessage> history,
List<ToolDefinition> tools,
String systemPrompt,
StreamingSession session) {
AgentExecutionContext context = new AgentExecutionContext(
message,
systemPrompt,
Expand All @@ -127,7 +192,9 @@ public void run(String message, List<ChatMessage> history, StreamingSession sess
null,
null);
context = context.withRetryPolicy(retryPolicy);
context = ToolLoopPolicies.attach(context, ToolLoopPolicy.maxIterations(maxToolRounds));
runtime.execute(context, session);
if (approvalStrategy != null && approvalPolicy != null) {
context = context.withApprovalStrategy(approvalStrategy).withApprovalPolicy(approvalPolicy);
}
return ToolLoopPolicies.attach(context, ToolLoopPolicy.maxIterations(maxToolRounds));
}
}
Loading
Loading