Compare commits

..
11 Commits
Author SHA1 Message Date
luxferre 676fc1013c Add deprecation notice
Signed-off-by: Luxferre <1+luxferre@noreply.code.luxferre.top>
2026-10-14 13:25:18 +00:00
Luxferre 4eee5d6ef9 enabled multi-line paste 2026-09-24 14:38:57 +03:00
Luxferre 3b5a8a5bfd some opts 2026-09-19 00:33:48 +03:00
Luxferre e1efe70d8a oc compatibility overhaul 2026-09-19 00:19:25 +03:00
Luxferre ebc62b93bf changed session id algo 2026-09-18 21:08:15 +03:00
Luxferre d5e69c4a0a changed session id algo 2026-09-18 19:44:59 +03:00
Luxferre 0cc2987dcb some more oc fixes 2026-09-18 14:25:13 +03:00
Luxferre f23c9132ce more oc fixes 2026-09-18 12:08:01 +03:00
Luxferre 0b2d9f2a90 context overflow prevention 2026-09-18 10:25:51 +03:00
Luxferre 1d8a19dd67 more opencode fixes 2026-09-17 10:35:16 +03:00
Luxferre a02b388b1a more OC headers 2026-09-17 10:02:08 +03:00
4 changed files with 1901 additions and 604 deletions
+41 -20
View File
@@ -1,5 +1,7 @@
# Bantam: tiny, powerful, DIY AI agent
**N.B. These Bantam versions have been officially deprecated.** Main development is being done on the [bantam-cpp](https://code.luxferre.top/luxferre/bantam-cpp) port, and it's recommended to switch to it whenever possible. This repo has been archived to preserve the codebase for historical purposes, and no further updates are expected here.
## About
Bantam is a minimalist, dependency-free AI agent specification with reference implementations in **Go** (`main.go` + `term_*.go`, module `code.luxferre.top/luxferre/bantam`) and **Perl 5** as **MicroBantam** (`mb`, under 100 SLOC). It provides an agentic loop capable of autonomous tool execution, direct shell interaction, file writing/editing, real-time response streaming, markdown terminal rendering with box-drawing tables, Fibonacci backoff network resilience, context window auto-discovery, token usage tracking with prompt cache breakdowns, and conversation compaction using any OpenAI-compatible completions API.
@@ -7,9 +9,9 @@ Bantam is a minimalist, dependency-free AI agent specification with reference im
The entire philosophy of Bantam is built upon two principles:
1. The structure must be as simple as possible for anyone to be able to reimplement the agent from a plain algorithm description.
2. The agent only needs to provide two tools: a tool to call shell commands (`shell_exec`) and a tool to write/edit files (`write_file`). In theory, this should be sufficient to give LLMs the ability to handle tasks of any complexity.
2. The agent only needs to provide a minimal set of tools: a tool to run shell commands (`bash`), a tool to inspect files and directories (`read`), and a tool to write/edit files (`write`). In theory, this should be sufficient to give LLMs the ability to handle tasks of any complexity.
Because of the second principle, Bantam itself was named after Victorinox Bantam Alox, a small and lightweight Swiss army knife with only two tools.
Because of this minimal philosophy, Bantam itself was named after Victorinox Bantam Alox, a small and lightweight Swiss army knife with minimal tools.
## Usage
@@ -45,6 +47,7 @@ All implementations read `model.cfg` (or `.bantam.cfg`, which takes priority if
api_key=your_api_key_here
stream=true
context_window=200000
max_tool_res=65536
reasoning_effort=high
```
@@ -54,7 +57,7 @@ All implementations read `model.cfg` (or `.bantam.cfg`, which takes priority if
./mb # MicroBantam (Perl 5)
```
In interactive mode, prompts can span multiple lines: press **Ctrl+J** to insert a real line break (the cursor moves to the next line), then **Enter** to submit the whole multi-line prompt. The Go port ships its own raw-mode line editor, working everywhere without third-party dependencies. It supports arrow keys and Up/Down for history, Backspace and Ctrl+D (non-empty line) to delete, and Emacs-style editing combos: **Ctrl+A** (start of line), **Ctrl+E** (end of line), **Ctrl+B** / **Ctrl+F** (move by character), **Ctrl+W** (delete previous word), **Ctrl+K** (kill to end of line), **Ctrl+U** (kill to start of line), **Ctrl+Left** / **Ctrl+Right** (move by word), and **Home** / **End** keys. **Ctrl+C** clears the line / interrupts an in-flight run, and **Ctrl+D** on an empty line exits.
In interactive mode, prompts can span multiple lines: press **Ctrl+J** to insert a real line break (the cursor moves to the next line), then **Enter** to submit the whole multi-line prompt. Bracketed paste is enabled (`ESC[?2004h`) while editing, so a multi-line block pasted from the clipboard is inserted as literal line breaks and only the final **Enter** submits it. The Go port ships its own raw-mode line editor, working everywhere without third-party dependencies. It supports arrow keys and Up/Down for history, Backspace and Ctrl+D (non-empty line) to delete, and Emacs-style editing combos: **Ctrl+A** (start of line), **Ctrl+E** (end of line), **Ctrl+B** / **Ctrl+F** (move by character), **Ctrl+W** (delete previous word), **Ctrl+K** (kill to end of line), **Ctrl+U** (kill to start of line), **Ctrl+Left** / **Ctrl+Right** (move by word), and **Home** / **End** keys. **Ctrl+C** clears the line / interrupts an in-flight run, and **Ctrl+D** on an empty line exits.
After every interaction, Bantam displays token usage (prompt tokens, cached/uncached breakdown when supported by the provider, completion tokens, and context window utilization):
```text
@@ -72,7 +75,7 @@ All implementations read `model.cfg` (or `.bantam.cfg`, which takes priority if
- `/endpoint [val]` — alias for `/cfg endpoint` (inspect or set the API endpoint)
- `/skill` — with no argument, list every skill under the configured skills directory (or report that none are configured); with `<name> [prompt]`, load `<skills_dir>/<name>/SKILL.md` (or an absolute path when no skills dir is set) and send its contents prefixed to the optional `prompt` as the next user turn
- `/models` — query the `/models` path on the current inference endpoint and print a plain list of supported model IDs, marking the currently configured model with a leading `* ` (Go port)
- `!<cmd>` — execute a shell command directly through `shell_exec` without adding the result to the conversation context (Go port)
- `!<cmd>` — execute a shell command directly through `bash` without adding the result to the conversation context (Go port)
- `/help` — show all supported commands
- `/clear` — reset the conversation to just the system prompt
- `/quit` — exit
@@ -99,13 +102,13 @@ Using these rules, everyone can build their own copy of Bantam from scratch in l
### High-Level Overview
1. **Initialization**: Read the config file (`.bantam.cfg` if present, else `model.cfg`). Discover context window size from the `/models` endpoint (or fallback to `context_window` from `model.cfg` or 200,000 tokens). Prepare an array of messages starting with the built-in system prompt `{"role": "system", "content": system_prompt}`.
2. **Input Processing**: Take user prompt (via command-line file parameter or interactive stdin). If prefixed with `!`, execute the command directly via `shell_exec` without appending to conversation context. Otherwise, append `{"role": "user", "content": prompt}`, and invoke `AL(cfg, messages)`.
2. **Input Processing**: Take user prompt (via command-line file parameter or interactive stdin). If prefixed with `!`, execute the command directly via `bash` without appending to conversation context. Otherwise, append `{"role": "user", "content": prompt}`, and invoke `AL(cfg, messages)`.
3. **Agentic Loop (`AL`)**:
- Send `messages` and tool definitions (`shell_exec`, `write_file`) to the OpenAI-compatible `/chat/completions` API endpoint with custom `User-Agent` headers and `stream_options: {"include_usage": true}`.
- Send `messages` and tool definitions (`bash`, `read`, `write`) to the OpenAI-compatible `/chat/completions` API endpoint with custom request headers (see below) and `stream_options: {"include_usage": true}`.
- Support context cancellation (e.g. on `SIGINT` / Ctrl+C) to cleanly abort in-flight requests without appending incomplete messages.
- On network or HTTP failure, retry using Fibonacci backoff delays (`1s, 1s, 2s, 3s, 5s, 8s, 13s, 21s, 34s`).
- If `stream=true`, parse SSE data chunks (`data: {...}`) in real-time to stream reasoning content (`reasoning_content`) and response text directly to stdout, bracketing the reasoning block with `--- reasoning start ---` / `--- reasoning end ---` markers, rendering Markdown and tables constrained to terminal width.
- Reconstruct the assistant message and track usage tokens (`prompt_tokens`, `completion_tokens`, cached tokens). If `tool_calls` exist, trace the call (`[tool call: name(args)]`), validate JSON arguments, execute the requested tool (`shell_exec` or `write_file`), trace the result (`[tool result: name]`), append the tool response `{"role": "tool", "tool_call_id": id, "content": result}`, and repeat the loop.
- Reconstruct the assistant message and track usage tokens (`prompt_tokens`, `completion_tokens`, cached tokens). If `tool_calls` exist, trace the call (`[tool call: name(args)]`), validate JSON arguments, execute the requested tool (`bash`, `read`, or `write`; also accepting legacy `shell_exec` and `write_file`), trace the result (`[tool result: name]`), append the tool response `{"role": "tool", "tool_call_id": id, "content": result}`, and repeat the loop.
- If no tool calls remain or `max_al_iterations` is reached, return the updated messages list and turn usage stats.
4. **Post-Turn Reporting & Compaction**:
- Display token usage and context window percentage.
@@ -117,22 +120,23 @@ Using these rules, everyone can build their own copy of Bantam from scratch in l
1. Initialize system prompt from built-in default.
2. Read model parameters from the config file (`.bantam.cfg` if present, else `model.cfg`) in `key=value` format and discover context window size.
3. Prepare a new message list with the system prompt (`role: "system"`).
4. Read the first command-line parameter. If non-empty, read user prompt from the specified file. If prefixed with `!`, execute the shell command directly via `shell_exec` and exit. Otherwise, append to `messages` (`role: "user"`), run `AL(cfg, messages)`, display token usage, and exit.
5. Read user prompt from standard input (with `readline` line editing and history in `~/.bantam_history`; **Ctrl+J** inserts a real newline into the line being edited). If equal to `/quit` or EOF, exit. If equal to `/clear`, reset `messages` to step 3 and return to step 5. If starting with `/save`, write the whole `messages` array to `~/.bantam/sessions/<name>.json` (or `<project-md5>.json` if no name is given) and return to step 5. If equal to `/continue` or `/cont`, load the session corresponding to the current project's MD5 hash and return to step 5. If equal to `/list`, print saved sessions and their summaries (marking current project session) and return to step 5. If starting with `/load`, replace `messages` with the saved session's messages (by exact id or unique prefix) and return to step 5. If equal to `/compact`, ask the LLM to summarize the conversation by appending the compaction prompt to derive the summary, replace `messages` with `[system, summary-user-message]`, and return to step 5. If starting with `/cfg`, display the current value (`/cfg <param>`) or update the config live by writing to `.bantam.cfg` (`/cfg <param> <val>`) and return to step 5. `/model` and `/endpoint` are aliases for `/cfg model` and `/cfg endpoint` respectively and behave the same way. If equal to `/skill` (no argument), list every skill under the configured skills directory (or report that none is configured) and return to step 5. If starting with `/skill`, resolve the skill (a `<skills_dir>/<name>/SKILL.md` file, or an absolute path to a skill directory or `SKILL.md` file when no skills directory is configured), prefix its contents to the optional remaining text, and send the result as the next user turn (returning to step 5 after the response). If equal to `/models`, query the `/models` path on the current inference endpoint and print a plain list of supported model IDs (the currently configured model marked with a leading `* `), then return to step 5. If starting with `!`, execute the command directly via `shell_exec` without adding the result to `messages` and return to step 5. If equal to `/help`, print the command list and return to step 5. After every user turn and on exit, auto-save `messages` to `~/.bantam/sessions/<project-md5>.json`.
4. Read the first command-line parameter. If non-empty, read user prompt from the specified file. If prefixed with `!`, execute the shell command directly via `bash` and exit. Otherwise, append to `messages` (`role: "user"`), run `AL(cfg, messages)`, display token usage, and exit.
5. Read user prompt from standard input (with `readline` line editing and history in `~/.bantam_history`; **Ctrl+J** inserts a real newline into the line being edited). If equal to `/quit` or EOF, exit. If equal to `/clear`, reset `messages` to step 3 and return to step 5. If starting with `/save`, write the whole `messages` array to `~/.bantam/sessions/<name>.json` (or `<project-md5>.json` if no name is given) and return to step 5. If equal to `/continue` or `/cont`, load the session corresponding to the current project's MD5 hash and return to step 5. If equal to `/list`, print saved sessions and their summaries (marking current project session) and return to step 5. If starting with `/load`, replace `messages` with the saved session's messages (by exact id or unique prefix) and return to step 5. If equal to `/compact`, ask the LLM to summarize the conversation by appending the compaction prompt to derive the summary, replace `messages` with `[system, summary-user-message]`, and return to step 5. If starting with `/cfg`, display the current value (`/cfg <param>`) or update the config live by writing to `.bantam.cfg` (`/cfg <param> <val>`) and return to step 5. `/model` and `/endpoint` are aliases for `/cfg model` and `/cfg endpoint` respectively and behave the same way. If equal to `/skill` (no argument), list every skill under the configured skills directory (or report that none is configured) and return to step 5. If starting with `/skill`, resolve the skill (a `<skills_dir>/<name>/SKILL.md` file, or an absolute path to a skill directory or `SKILL.md` file when no skills directory is configured), prefix its contents to the optional remaining text, and send the result as the next user turn (returning to step 5 after the response). If equal to `/models`, query the `/models` path on the current inference endpoint and print a plain list of supported model IDs (the currently configured model marked with a leading `* `), then return to step 5. If starting with `!`, execute the command directly via `bash` without adding the result to `messages` and return to step 5. If equal to `/help`, print the command list and return to step 5. After every user turn and on exit, auto-save `messages` to `~/.bantam/sessions/<project-md5>.json`.
6. Append user prompt to `messages` (`role: "user"`), run `AL(cfg, messages)`, display token usage, check 60% context threshold for auto-compaction, and go to step 5.
### Agentic loop (`AL(cfg, messages)`) function
1. Call OpenAI-compatible Completions API (`POST {endpoint}/chat/completions`) forwarding relevant parameters from `cfg` (`model`, `temperature`, `stream`, `reasoning_effort`, etc., excluding internal agent configs like `endpoint`, `api_key`, `timeout`, `shell_timeout`, `max_al_iterations`, `color`, `context_window`) and optional `api_key` bearer header.
- Set custom `User-Agent` header (`Mozilla/5.0 (compatible; Bantam/1.0)`) to avoid gateway 403 blocks.
- Set custom request headers mimicking OpenCode for all endpoints: `User-Agent: opencode/1.18.31 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.14`, `x-opencode-client: cli`, `x-opencode-project: global`, `x-opencode-session: <ses_...>`, and a fresh `x-opencode-request: <msg_...>` per call (where the 12-hex timestamp prefix in `x-opencode-request` is the exact bitwise inverse of `x-opencode-session`). The `Authorization: Bearer <api_key>` header is added whenever `api_key` is set and not `-`.
- Retry network/HTTP errors with Fibonacci backoff delays (`1s, 1s, 2s, 3s, 5s, 8s, 13s, 21s, 34s`).
- If `stream=true`, parse SSE stream (`data: {...}`) for real-time reasoning and text output, bracketing reasoning with `--- reasoning start ---` / `--- reasoning end ---` markers.
2. Append the assistant's response message object to `messages`. If non-streaming and response has reasoning tokens (`reasoning_content` or `reasoning`), output them wrapped in `--- reasoning start ---` / `--- reasoning end ---` markers.
3. If there are pending `tool_calls` in the assistant response:
- For each tool call, output a trace log (`[tool call: name(args)]`).
- Sanitize tool arguments to filter out non-printable and space-like Unicode characters (protecting against indirect prompt injection), and validate JSON arguments. If invalid, format a tool-error response so the LLM can self-correct.
- Execute tool action (`shell_exec` or `write_file`).
- Execute tool action (`bash`, `read`, or `write`; also accepting legacy `shell_exec` and `write_file`).
- Sanitize the tool result output to strip any non-printable and space-like Unicode characters (leaving only ASCII space, tab, newline, and printable Unicode characters).
- If the sanitized result is larger than `max_tool_res` bytes, spill it to a unique file under `$TMPDIR/bantam/toolres` and replace the content appended to `messages` with a short instruction naming the file, its line count, and how to read it partially using the `read` tool (specifying `offset` and `limit`); every spilled file is deleted when the round ends (i.e. when the model emits a final response with no tool calls).
- Output a trace log of the result (`[tool result: name]`).
- Append tool result message (`role: "tool"`, `tool_call_id`, `content`: result string) to `messages`.
- Loop back to step 1.
@@ -151,9 +155,10 @@ If the API rejects the request with an `Invalid assistant message: content or to
- `stream` (stream response tokens in real-time, default `true`; falls back to `BANTAM_STREAM` env var)
- `color` (ANSI coloring: `auto` (TTY-detected, default), `always`, or `never`; disabled by `NO_COLOR`/`BANTAM_NO_COLOR` env vars, falls back to `BANTAM_COLOR` env var)
- `timeout` (HTTP timeout in seconds for LLM API calls, default 300; in the Go port it bounds connection setup and time-to-first-byte, so long streaming responses are not cut off mid-stream; falls back to `BANTAM_TIMEOUT` env var)
- `shell_timeout` (timeout in seconds for `shell_exec` commands, default 120; falls back to `BANTAM_SHELL_TIMEOUT` env var)
- `shell_timeout` (timeout in seconds for `bash` shell commands, default 120; falls back to `BANTAM_SHELL_TIMEOUT` env var)
- `max_al_iterations` (max tool-call loop iterations per `AL()` invocation, default 1000; falls back to `BANTAM_MAX_AL_ITERATIONS` env var)
- `context_window` (context window size in tokens, auto-discovered from `/models` API if available, fallback to this setting, default 200000; falls back to `BANTAM_CONTEXT_WINDOW` env var)
- `max_tool_res` (maximum number of bytes of a tool result that may be sent into the model context directly, default 65536; larger results are written to a unique file under `$TMPDIR/bantam/toolres` and replaced by a short instruction naming the file and its line count so it can be read in parts using the `read` tool, and the file is deleted once the round ends; falls back to `BANTAM_MAX_TOOL_RES` env var)
- `reasoning_effort` (reasoning effort level, forwarded to chat completions API, default `high`; falls back to `BANTAM_REASONING_EFFORT` env var)
- `bantam_tools_dir` (optional path to a directory of extra shell tools; the Go port appends `"Extra shell tools can be found at <dir>"` to the system prompt at startup when set. When unset in the config file, it falls back to the `BANTAM_TOOLS_DIR` environment variable; if neither is set, nothing is appended. Note: this is an agent-internal hint, not forwarded to the API.)
@@ -167,21 +172,32 @@ When enabled, the interactive console uses a subtle ANSI palette: the pending-re
### Tool call definitions
#### `write_file` tool
#### `read` tool
- Parameters:
- `path` (string, required): JSON-escaped file path to write to.
- `filePath` (string, required): path to the file to read or directory to inspect (also accepts `path`).
- `offset` (integer, optional): line number to start reading from (1-indexed). Defaults to 1.
- `limit` (integer, optional): maximum number of lines to read. Defaults to 2000.
- Return value: string
- Action: if the target is a regular file, returns numbered lines in `<line>: <content>` format. If the target is a directory, returns a list of directory entries (subdirectories include a trailing `/`).
#### `write` tool
- Parameters:
- `filePath` (string, required): JSON-escaped file path to write to (also accepts `path`).
- `offset` (integer, optional): byte offset to start writing from. If omitted together with `del_bytes`, the whole file is overwritten instead. Defaults to 0 (start of file).
- `del_bytes` (integer, optional): bytes to delete starting at `offset`. If omitted together with `offset`, the whole file is overwritten instead.
- `content` (string, required, may be empty): JSON-escaped content to write to the file.
- Return value: string
- Action: if `offset` and `del_bytes` are both omitted, the entire file is overwritten with `content` (the file, and any necessary parent directories, are created if missing); otherwise `content` is written at `offset`, optionally deleting `del_bytes` bytes first (splicing prefix + content + suffix and never appending to the end of an existing file), creating the file and parent directories as needed.
- Action: if `offset` and `del_bytes` are both omitted, the entire file is overwritten with `content` (the file, and any necessary parent directories, are created if missing); otherwise `content` is written at `offset`, optionally deleting `del_bytes` bytes first (splicing prefix + content + suffix and never appending to the end of an existing file), creating the file and parent directories as needed. (Also accepted as legacy `write_file`.)
#### `shell_exec` tool
#### `bash` tool
- Parameters: `command` (string)
- Parameters:
- `command` (string, required): shell command to run.
- `workdir` (string, optional): working directory to execute the command in.
- Return value: string
- Action: run shell command specified in `command` subject to `shell_timeout` (default 120s) and return `output + '\n\nexit: ' + exit_code` string.
- Action: run shell command specified in `command` via `bash -c` subject to `shell_timeout` (default 120s) and return `output + '\nexit: ' + exit_code` string. (Also accepted as legacy `shell_exec`.)
## MicroBantam
@@ -189,7 +205,7 @@ MicroBantam (`mb`) is a compressed Perl 5 reference implementation of the same a
### Features
- Full agentic loop: LLM calls, `shell_exec` / `write_file` tool execution with JSON argument validation (invalid args are fed back so the model can self-correct)
- Full agentic loop: LLM calls, `bash` / `read` / `write` tool execution with JSON argument validation (invalid args are fed back so the model can self-correct)
- Indirect prompt injection defense: sanitizes tool parameters and tool outputs by filtering non-printable and space-like Unicode characters, preserving standard space, tab, newline, and printable Unicode characters
- A `...requesting...` in-flight indicator: in-place on a TTY (`\r` overwrite, erased on completion), a plain line when output is piped
- Session management: `/save`, `/list`, `/load <id>` (exact id only, no prefix matching), `/cfg <param> [val]`, and auto-save to `~/.bantam/sessions/autosave.json` after every turn and on exit; session ids get `-1`, `-2`, ... suffixes on same-second collisions
@@ -204,6 +220,7 @@ MicroBantam (`mb`) is a compressed Perl 5 reference implementation of the same a
- Line editing, Ctrl+J multi-line prompts and history (plain single-line prompts)
- Fibonacci backoff network retries (a failed request aborts with an `API error` message)
- `/compact` context summarization
- `max_tool_res` tool-result spilling (oversized results are sent to the model verbatim)
### Running MicroBantam
@@ -221,7 +238,7 @@ MicroBantam (`mb`) is a compressed Perl 5 reference implementation of the same a
## Extra tools
The `extras/` directory contains small, dependency-light shell scripts that extend Bantam without changing its core. Because Bantam's built-in tools are `shell_exec` and `write_file`, these helpers can be invoked directly by the agent through `shell_exec` to give it real-world capabilities (web search, live weather) that the base model alone does not have. They are plain `/bin/sh` scripts depending only on `curl` (and `jq` where noted), so the agent can discover and run them just like any other command.
The `extras/` directory contains small, dependency-light shell scripts that extend Bantam without changing its core. Because Bantam's built-in tools include `bash`, these helpers can be invoked directly by the agent through `bash` to give it real-world capabilities (web search, live weather) that the base model alone does not have. They are plain `/bin/sh` scripts depending only on `curl` (and `jq` where noted), so the agent can discover and run them just like any other command.
If you keep your own collection of helper scripts, point Bantam at them with the `bantam_tools_dir` key in the config file (`.bantam.cfg` if present, else `model.cfg`) or the `BANTAM_TOOLS_DIR` environment variable. When either is defined (the config file setting taking precedence over the environment variable), the Go port appends the line `Extra shell tools can be found at <dir>` to the system prompt at startup, so the agent is aware of where to look for them. The `extras/` scripts shipped here are just examples of what such a directory can contain.
@@ -326,6 +343,10 @@ Set the `bantam_tools_dir` key in the config file (`.bantam.cfg` if present, els
Set the `bantam_skills_dir` key in the config file (`.bantam.cfg` if present, else `model.cfg`) or the `BANTAM_SKILLS_DIR` environment variable to a directory where each subdirectory is a skill containing a `SKILL.md` file. When defined, the Go port appends `Skills may be discovered and invoked from <dir>` to the system prompt at startup, and you (or the agent) can run a skill with `/skill <name> [prompt]`. The config file key takes precedence over the environment variable; if neither is set, `/skill` accepts an absolute path to a skill directory or `SKILL.md` file instead.
### What happens when a tool result is too large for the context?
Bantam never stuffs an arbitrarily large tool result into the context window. The `max_tool_res` configuration parameter (default `65536`, config-file key takes precedence over the `BANTAM_MAX_TOOL_RES` environment variable) sets the maximum number of bytes of a tool result that may be sent to the model directly. When a result exceeds that limit, the Go port writes the full output to a unique file under `$TMPDIR/bantam/toolres` and replaces the result in the conversation with a short note naming that file and stating its total line count. The agent is then expected to read the file with the `read` tool, paging through it with line offsets and limits (`offset` and `limit`) rather than loading everything at once. Every file spilled during a round is deleted as soon as the round ends, i.e. when the model emits its final response with no pending tool calls. The built-in system prompt also advertises this behaviour to the model at startup.
### Is there any common config place for Bantam?
No, loading the config file (`.bantam.cfg` if present, else `model.cfg`) is deliberately only supported from the current working directory. This allows natural separation of configs per project. In case there's no config file inside the project, Bantam will use the `openrouter/free` model from Kilo Code with the temperature 0.7.
+1095 -521
View File
File diff suppressed because it is too large Load Diff
+699 -5
View File
@@ -248,8 +248,8 @@ func TestDefaultSystemPrompt(t *testing.T) {
if !strings.Contains(defaultSystemPrompt, "You are Bantam, a tiny, powerful AI agent.") {
t.Errorf("expected prompt to contain base description, got: %q", defaultSystemPrompt)
}
if !strings.Contains(defaultSystemPrompt, "shell_exec") || !strings.Contains(defaultSystemPrompt, "write_file") {
t.Errorf("expected prompt to list shell_exec and write_file tools, got: %q", defaultSystemPrompt)
if !strings.Contains(defaultSystemPrompt, "bash") || !strings.Contains(defaultSystemPrompt, "read") || !strings.Contains(defaultSystemPrompt, "write") {
t.Errorf("expected prompt to list bash, read, and write tools, got: %q", defaultSystemPrompt)
}
if strings.Contains(defaultSystemPrompt, "run_subagent") {
t.Errorf("prompt should not mention run_subagent: %q", defaultSystemPrompt)
@@ -268,7 +268,7 @@ func clearBantamEnv(t *testing.T) {
"BANTAM_API_KEY", "BANTAM_STREAM", "BANTAM_COLOR", "BANTAM_NO_COLOR",
"BANTAM_TIMEOUT", "BANTAM_SHELL_TIMEOUT", "BANTAM_MAX_AL_ITERATIONS",
"BANTAM_CONTEXT_WINDOW", "BANTAM_REASONING_EFFORT", "BANTAM_TOOLS_DIR",
"BANTAM_SKILLS_DIR",
"BANTAM_SKILLS_DIR", "BANTAM_MAX_TOOL_RES",
} {
t.Setenv(k, "")
}
@@ -1147,11 +1147,16 @@ func TestCol(t *testing.T) {
// ---------- llm / AL / summarize / compact via httptest (no real network) ----------
func TestLLMNonStreamingAndHeaders(t *testing.T) {
var gotPath, gotAuth, gotUA string
var gotPath, gotAuth, gotUA, gotOCClient, gotOCProject, gotOCSession, gotOCReq, gotLegacy string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotAuth = r.Header.Get("Authorization")
gotUA = r.Header.Get("User-Agent")
gotOCClient = r.Header.Get("x-opencode-client")
gotOCProject = r.Header.Get("x-opencode-project")
gotOCSession = r.Header.Get("x-opencode-session")
gotOCReq = r.Header.Get("x-opencode-request")
gotLegacy = r.Header.Get("X-Session-Id")
w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"hi","reasoning_content":"think"}}]}`))
}))
defer srv.Close()
@@ -1170,14 +1175,200 @@ func TestLLMNonStreamingAndHeaders(t *testing.T) {
if gotAuth != "Bearer secret" {
t.Errorf("auth = %q", gotAuth)
}
if !strings.Contains(gotUA, "opencode/1.18.30") {
if !strings.Contains(gotUA, "opencode/1.18.31") || !strings.Contains(gotUA, "runtime/bun") {
t.Errorf("user-agent = %q", gotUA)
}
if gotOCClient != "cli" {
t.Errorf("x-opencode-client = %q, want cli", gotOCClient)
}
if gotOCProject != "global" {
t.Errorf("x-opencode-project = %q, want global", gotOCProject)
}
if !isOpencodeSessionID(gotOCSession) {
t.Errorf("x-opencode-session = %q, want ses_* id", gotOCSession)
}
if !strings.HasPrefix(gotOCReq, "msg_") {
t.Errorf("x-opencode-request = %q, want msg_* id", gotOCReq)
}
if gotLegacy != "" {
t.Errorf("legacy X-Session-Id should be absent, got %q", gotLegacy)
}
if m.Content == nil || *m.Content != "hi" || m.ReasoningContent != "think" {
t.Errorf("message = %+v", m)
}
}
func TestIsOpencodeEndpoint(t *testing.T) {
yes := []string{
"https://opencode.ai/zen/v1",
"https://opencode.ai/zen/go/v1",
"https://opencode.ai/zen/v1/chat/completions",
"https://api.opencode.ai/v1",
"https://OPENCODE.AI/zen/v1",
"opencode.ai",
}
for _, e := range yes {
if !isOpencodeEndpoint(e) {
t.Errorf("isOpencodeEndpoint(%q) = false, want true", e)
}
}
no := []string{
"https://api.kilo.ai/api/openrouter",
"http://127.0.0.1:4321/v1",
"https://api.openai.com/v1",
"https://notopencode.ai.example.com/v1",
"https://example.com/opencode.ai",
}
for _, e := range no {
if isOpencodeEndpoint(e) {
t.Errorf("isOpencodeEndpoint(%q) = true, want false", e)
}
}
}
func TestOcRequestID(t *testing.T) {
id := ocRequestID()
if !strings.HasPrefix(id, "msg_") {
t.Fatalf("request id %q does not start with msg_", id)
}
if len(id) != 30 {
t.Fatalf("request id length = %d, want 30: %q", len(id), id)
}
for _, c := range id[4:16] {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
t.Fatalf("invalid hex character %c in request id prefix %q", c, id)
}
}
for _, c := range id[16:] {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) {
t.Fatalf("invalid character %c in request id tail %q", c, id)
}
}
if ocRequestID() == id {
t.Fatalf("consecutive request ids should differ: %q", id)
}
}
func TestApplyLLMHeaders(t *testing.T) {
endpoints := []string{
"https://opencode.ai/zen/v1",
"https://api.openai.com/v1",
"https://api.kilo.ai/api/openrouter",
}
for _, ep := range endpoints {
cfg := defCfg
cfg.Endpoint = ep
cfg.APIKey = "-"
req, err := http.NewRequest("POST", ep+"/chat/completions", nil)
if err != nil {
t.Fatalf("NewRequest: %v", err)
}
applyLLMHeaders(req, &cfg, "msg_test")
if got := req.Header.Get("User-Agent"); !strings.Contains(got, "opencode/1.18.31") || !strings.Contains(got, "runtime/bun") {
t.Errorf("[%s] user-agent = %q", ep, got)
}
if got := req.Header.Get("x-opencode-client"); got != "cli" {
t.Errorf("[%s] x-opencode-client = %q, want cli", ep, got)
}
if got := req.Header.Get("x-opencode-project"); got != "global" {
t.Errorf("[%s] x-opencode-project = %q, want global", ep, got)
}
if got := req.Header.Get("x-opencode-session"); !isOpencodeSessionID(got) {
t.Errorf("[%s] x-opencode-session = %q, want a ses_* id", ep, got)
}
if got := req.Header.Get("x-opencode-request"); got != "msg_test" {
t.Errorf("[%s] x-opencode-request = %q, want msg_test", ep, got)
}
if got := req.Header.Get("X-Session-Id"); got != "" {
t.Errorf("[%s] legacy X-Session-Id should be absent, got %q", ep, got)
}
if got := req.Header.Get("Authorization"); got != "" {
t.Errorf("[%s] Authorization should be absent for '-' key, got %q", ep, got)
}
}
}
func TestApplyLLMHeadersRequestID(t *testing.T) {
cfg := defCfg
cfg.Endpoint = "https://opencode.ai/zen/v1"
cfg.APIKey = "-"
req, _ := http.NewRequest("POST", "https://opencode.ai/zen/v1/chat/completions", nil)
applyLLMHeaders(req, &cfg, ocRequestID())
got := req.Header.Get("x-opencode-request")
if !strings.HasPrefix(got, "msg_") {
t.Errorf("x-opencode-request = %q, want msg_ prefix", got)
}
if len(got) != 30 {
t.Errorf("x-opencode-request length = %d, want 30: %q", len(got), got)
}
for _, c := range got[4:16] {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
t.Errorf("invalid hex character %c in x-opencode-request timestamp prefix %q", c, got)
}
}
for _, c := range got[16:] {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) {
t.Errorf("invalid character %c in x-opencode-request tail %q", c, got)
}
}
}
func TestApplyLLMHeadersOpencodeAuth(t *testing.T) {
cfg := defCfg
cfg.Endpoint = "https://opencode.ai/zen/v1"
cfg.APIKey = "public"
req, _ := http.NewRequest("POST", "https://opencode.ai/zen/v1/chat/completions", nil)
applyLLMHeaders(req, &cfg, "msg_test")
if got := req.Header.Get("Authorization"); got != "Bearer public" {
t.Errorf("Authorization = %q, want Bearer public", got)
}
}
// isOpencodeSessionID reports whether sid matches the Zen session id shape:
// "ses_" + 12 lowercase hex chars + 14 alphanumeric characters.
func isOpencodeSessionID(sid string) bool {
if !strings.HasPrefix(sid, "ses_") || len(sid) != 30 {
return false
}
for _, c := range sid[4:16] {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
return false
}
}
for _, c := range sid[16:] {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) {
return false
}
}
return true
}
func TestOpencodeSessionID(t *testing.T) {
sid := opencodeSessionID()
if !strings.HasPrefix(sid, "ses_") {
t.Fatalf("session id does not start with ses_: %q", sid)
}
if len(sid) != 30 {
t.Fatalf("session id length = %d, want 30: %q", len(sid), sid)
}
// The 12 characters after the "ses_" prefix are lowercase hex.
hexPart := sid[4:16]
for _, c := range hexPart {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
t.Fatalf("non-hex character %c in session id prefix %q", c, sid)
}
}
// The remaining 14 characters are alphanumeric.
for _, c := range sid[16:] {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) {
t.Fatalf("invalid character %c in session id tail %q", c, sid)
}
}
if opencodeSessionID() == sid {
t.Fatalf("two consecutive session ids are identical: %q", sid)
}
}
func TestLLMNoAuthHeaderWhenNoKey(t *testing.T) {
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -2922,3 +3113,506 @@ func TestLLMContextTokensIncludesReasoning(t *testing.T) {
t.Errorf("ctxTokens=%d should equal ContextTokens=%d when prompt_tokens is absent", ctxTokens(u), u.ContextTokens)
}
}
// ---------- context window overflow prevention (max_tool_res) ----------
func TestMaxToolResDefault(t *testing.T) {
clearBantamEnv(t)
cfg := getCfg(filepath.Join(t.TempDir(), "missing.cfg"))
if cfg.MaxToolRes != 65536 {
t.Errorf("default MaxToolRes = %d, want 65536", cfg.MaxToolRes)
}
if cfg.Raw["max_tool_res"] != "65536" {
t.Errorf("default Raw[max_tool_res] = %q, want 65536", cfg.Raw["max_tool_res"])
}
}
func TestMaxToolResFileParse(t *testing.T) {
clearBantamEnv(t)
p := writeCfg(t, "max_tool_res=4096\n")
if got := getCfg(p).MaxToolRes; got != 4096 {
t.Errorf("max_tool_res from file = %d, want 4096", got)
}
}
func TestMaxToolResEnvFallback(t *testing.T) {
clearBantamEnv(t)
t.Setenv("BANTAM_MAX_TOOL_RES", "2048")
if got := getCfg(filepath.Join(t.TempDir(), "missing.cfg")).MaxToolRes; got != 2048 {
t.Errorf("max_tool_res from env = %d, want 2048", got)
}
}
// The .bantam.cfg file takes precedence over the env var (as for every other
// tunable), matching the precedence rule stated for max_tool_res.
func TestMaxToolResFileOverridesEnv(t *testing.T) {
clearBantamEnv(t)
t.Setenv("BANTAM_MAX_TOOL_RES", "1111")
p := filepath.Join(t.TempDir(), ".bantam.cfg")
if err := os.WriteFile(p, []byte("max_tool_res=2222\n"), 0644); err != nil {
t.Fatal(err)
}
if got := getCfg(p).MaxToolRes; got != 2222 {
t.Errorf("file should override env: got %d, want 2222", got)
}
}
func TestMaxToolResIsInternalKey(t *testing.T) {
if !internalKey("max_tool_res") {
t.Errorf("internalKey(\"max_tool_res\") = false, want true (must not be forwarded to the API)")
}
}
func TestToolResDir(t *testing.T) {
t.Setenv("TMPDIR", "/custom/tmp")
if got := toolResDir(); got != filepath.Join("/custom/tmp", "bantam", "toolres") {
t.Errorf("toolResDir = %q, want /custom/tmp/bantam/toolres", got)
}
t.Setenv("TMPDIR", "")
if got := toolResDir(); got != filepath.Join("/tmp", "bantam", "toolres") {
t.Errorf("toolResDir (no TMPDIR) = %q, want /tmp/bantam/toolres", got)
}
}
// Small results pass through untouched and create no files.
func TestOffloadToolResultSmallPassthrough(t *testing.T) {
t.Setenv("TMPDIR", t.TempDir())
var tmps []string
res := "hello\n\nexit: 0"
if got := offloadToolResult(res, 15000, &tmps); got != res {
t.Errorf("small result altered: got %q, want %q", got, res)
}
if len(tmps) != 0 {
t.Errorf("small result should not create temp files, got %v", tmps)
}
}
// Large results are spilled to $TMPDIR/bantam/toolres and replaced by an
// instruction that names the file and its total length; cleanupTemps then
// removes the file.
func TestOffloadToolResultLargeSpillsAndCleans(t *testing.T) {
tmp := t.TempDir()
t.Setenv("TMPDIR", tmp)
var tmps []string
big := strings.Repeat("x", 20000)
got := offloadToolResult(big, 15000, &tmps)
if len(tmps) != 1 {
t.Fatalf("expected 1 temp file, got %d (%v)", len(tmps), tmps)
}
path := tmps[0]
wantDir := filepath.Join(tmp, "bantam", "toolres")
if filepath.Dir(path) != wantDir {
t.Errorf("temp file dir = %q, want %q", filepath.Dir(path), wantDir)
}
// The full result must be recoverable from the file.
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read spilled file: %v", err)
}
if string(data) != big {
t.Errorf("spilled file content mismatch (len %d vs %d)", len(data), len(big))
}
// The replacement must tell the model the path and the byte length.
if strings.Contains(got, big) {
t.Errorf("replacement still contains the full result")
}
if !strings.Contains(got, path) {
t.Errorf("replacement does not reference the file path: %q", got)
}
if !strings.Contains(got, "20000") {
t.Errorf("replacement does not state the file length: %q", got)
}
cleanupTemps(tmps)
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("cleanupTemps did not remove %q (err=%v)", path, err)
}
}
// A non-positive limit must fall back to the 65536 default rather than
// spilling every non-empty result.
func TestOffloadToolResultZeroLimitUsesDefault(t *testing.T) {
t.Setenv("TMPDIR", t.TempDir())
var tmps []string
res := strings.Repeat("y", 65536) // exactly the default limit: stays inline
if got := offloadToolResult(res, 0, &tmps); got != res {
t.Errorf("result at default limit should stay inline")
}
if len(tmps) != 0 {
t.Errorf("no spill expected at the limit, got %v", tmps)
}
}
// End-to-end: AL() offloads an oversized shell result, hands the model the
// instruction instead of the raw bytes, and deletes the spilled file once the
// round finishes.
func TestALOffloadsLargeToolResultAndCleans(t *testing.T) {
tmp := t.TempDir()
t.Setenv("TMPDIR", tmp)
seen := ""
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req struct {
Messages []Message `json:"messages"`
}
json.NewDecoder(r.Body).Decode(&req)
for _, m := range req.Messages {
if m.Role == "tool" && m.Content != nil {
seen = *m.Content
w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"done"}}]}`))
return
}
}
args, _ := json.Marshal(map[string]any{"command": "yes x | head -c 20000"})
w.Write([]byte(fmt.Sprintf(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"shell_exec","arguments":%s}}]}}]}`, strconv.Quote(string(args)))))
}))
defer srv.Close()
cfg := defCfg
cfg.Endpoint = srv.URL
cfg.Stream = false
cfg.APIKey = "-"
cfg.MaxToolRes = 15000
_, _, err := AL(context.Background(), &cfg, []Message{{Role: "system", Content: strp("sys")}, {Role: "user", Content: strp("run")}})
if err != nil {
t.Fatalf("AL: %v", err)
}
if seen == "" {
t.Fatalf("no tool message reached the model")
}
if len(seen) > cfg.MaxToolRes {
t.Errorf("tool message sent to the model is %d bytes, exceeds limit %d", len(seen), cfg.MaxToolRes)
}
if !strings.Contains(seen, "too large for context") {
t.Errorf("expected an overflow instruction, got %q", seen)
}
// The spilled file must have been deleted when the round ended.
dir := filepath.Join(tmp, "bantam", "toolres")
entries, err := os.ReadDir(dir)
if err != nil && !os.IsNotExist(err) {
t.Fatalf("read toolres dir: %v", err)
}
if len(entries) != 0 {
t.Errorf("expected no leftover temp files, found %d", len(entries))
}
}
// buildSystemPrompt must substitute the configured max_tool_res value and the
// concrete temporary directory into the built-in prompt.
func TestBuildSystemPromptMaxToolResHint(t *testing.T) {
t.Setenv("TMPDIR", "/custom/tmp")
cfg := defCfg
cfg.MaxToolRes = 12345
sp := buildSystemPrompt(&cfg)
if !strings.Contains(sp, "12345") {
t.Errorf("prompt should embed the configured max_tool_res (12345): %q", sp)
}
if strings.Contains(sp, "$MAX_TOOL_RES") {
t.Errorf("prompt still contains the unexpanded $MAX_TOOL_RES placeholder: %q", sp)
}
if !strings.Contains(sp, filepath.Join("/custom/tmp", "bantam", "toolres")) {
t.Errorf("prompt should reference the concrete toolres dir: %q", sp)
}
if strings.Contains(sp, "$TMPDIR/bantam") {
t.Errorf("prompt still contains the unexpanded $TMPDIR/bantam placeholder: %q", sp)
}
}
func TestOpencodeIDGeneratorBitwiseInverse(t *testing.T) {
reqID := ocRequestID()
if !strings.HasPrefix(reqID, "msg_") || len(reqID) != 30 {
t.Fatalf("unexpected reqID format: %q", reqID)
}
sesID := opencodeSessionIDFromRequest(reqID)
if !strings.HasPrefix(sesID, "ses_") || len(sesID) != 30 {
t.Fatalf("unexpected sesID format: %q", sesID)
}
// First 12 hex characters (indices 4..16) must be exact bitwise inverse
reqHex := reqID[4:16]
sesHex := sesID[4:16]
reqVal, err1 := strconv.ParseUint(reqHex, 16, 64)
sesVal, err2 := strconv.ParseUint(sesHex, 16, 64)
if err1 != nil || err2 != nil {
t.Fatalf("failed to parse hex values: err1=%v, err2=%v", err1, err2)
}
if (reqVal ^ sesVal) != 0xffffffffffff {
t.Errorf("expected exact bitwise inverse, got reqHex=%s, sesHex=%s, xor=%012x", reqHex, sesHex, reqVal^sesVal)
}
// Verify applyLLMHeaders sets headers with bitwise inverse
cfg := defCfg
cfg.Endpoint = "https://opencode.ai/zen/v1"
req, _ := http.NewRequest("POST", "https://opencode.ai/zen/v1/chat/completions", nil)
applyLLMHeaders(req, &cfg, reqID)
gotReq := req.Header.Get("x-opencode-request")
gotSes := req.Header.Get("x-opencode-session")
if gotReq != reqID {
t.Errorf("x-opencode-request = %q, want %q", gotReq, reqID)
}
rVal, _ := strconv.ParseUint(gotReq[4:16], 16, 64)
sVal, _ := strconv.ParseUint(gotSes[4:16], 16, 64)
if (rVal ^ sVal) != 0xffffffffffff {
t.Errorf("headers not bitwise inverse: gotReq=%s, gotSes=%s", gotReq, gotSes)
}
}
func TestReadToolFileAndDir(t *testing.T) {
tmp := t.TempDir()
file1 := filepath.Join(tmp, "sample.txt")
content := "line one\nline two\nline three\nline four\nline five"
if err := os.WriteFile(file1, []byte(content), 0644); err != nil {
t.Fatal(err)
}
// Reading full file
res, sty := readFileOrDir(file1, 1, 2000)
if sty != 2 {
t.Errorf("expected style 2, got %d (res=%s)", sty, res)
}
expected := "1: line one\n2: line two\n3: line three\n4: line four\n5: line five"
if res != expected {
t.Errorf("got %q, want %q", res, expected)
}
// Reading with offset and limit
res, _ = readFileOrDir(file1, 2, 2)
expectedSub := "2: line two\n3: line three"
if res != expectedSub {
t.Errorf("got %q, want %q", res, expectedSub)
}
// Reading directory
subDir := filepath.Join(tmp, "subdir")
os.Mkdir(subDir, 0755)
dRes, dSty := readFileOrDir(tmp, 1, 100)
if dSty != 2 {
t.Errorf("expected style 2 for dir, got %d", dSty)
}
if !strings.Contains(dRes, "sample.txt") || !strings.Contains(dRes, "subdir/") {
t.Errorf("directory listing missing expected entries: %q", dRes)
}
// Non-existent file
missingRes, missingSty := readFileOrDir(filepath.Join(tmp, "missing.txt"), 1, 100)
if missingSty != 31 || !strings.Contains(missingRes, "no such file") {
t.Errorf("expected missing file error, got sty=%d res=%q", missingSty, missingRes)
}
}
func TestBashToolAndWorkdir(t *testing.T) {
res := shell(context.Background(), "echo $((10 + 25))", 5)
if !strings.Contains(res, "35") || !strings.Contains(res, "exit: 0") {
t.Errorf("unexpected bash output: %q", res)
}
tmp := t.TempDir()
resWorkdir := shellWithWorkdir(context.Background(), "pwd -P", tmp, 5)
if !strings.Contains(resWorkdir, tmp) || !strings.Contains(resWorkdir, "exit: 0") {
t.Errorf("unexpected bash workdir output: %q", resWorkdir)
}
}
func TestALWriteAndReadTools(t *testing.T) {
tmp := t.TempDir()
target := filepath.Join(tmp, "written.txt")
step := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
step++
if step == 1 {
// Step 1: LLM calls write tool
args, _ := json.Marshal(map[string]any{"filePath": target, "content": "alpha\nbeta\ngamma"})
w.Write([]byte(fmt.Sprintf(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"write","arguments":%s}}]}}]}`, strconv.Quote(string(args)))))
return
}
if step == 2 {
// Step 2: LLM calls read tool
args, _ := json.Marshal(map[string]any{"filePath": target, "offset": 2, "limit": 1})
w.Write([]byte(fmt.Sprintf(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c2","type":"function","function":{"name":"read","arguments":%s}}]}}]}`, strconv.Quote(string(args)))))
return
}
// Step 3: LLM finishes
w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"all done"}}]}`))
}))
defer srv.Close()
cfg := defCfg
cfg.Endpoint = srv.URL
cfg.Stream = false
cfg.APIKey = "-"
msgs, _, err := AL(context.Background(), &cfg, []Message{{Role: "user", Content: strp("start")}})
if err != nil {
t.Fatalf("AL failed: %v", err)
}
// Check that file was created and written
data, err := os.ReadFile(target)
if err != nil || string(data) != "alpha\nbeta\ngamma" {
t.Fatalf("expected written file content, got: %q, err: %v", string(data), err)
}
// Check that read tool response was received in conversation
var readToolResult string
for _, m := range msgs {
if m.Role == "tool" && m.ToolCallID == "c2" && m.Content != nil {
readToolResult = *m.Content
}
}
if readToolResult != "2: beta" {
t.Errorf("expected read tool result '2: beta', got %q", readToolResult)
}
}
func TestOffloadToolResultUsesReadPrompt(t *testing.T) {
var tmps []string
big := strings.Repeat("x\n", 10000)
msg := offloadToolResult(big, 100, &tmps)
if !strings.Contains(msg, "Read it with the read tool") {
t.Errorf("expected prompt to reference read tool, got: %q", msg)
}
if !strings.Contains(msg, "offset and limit") {
t.Errorf("expected prompt to reference offset and limit, got: %q", msg)
}
cleanupTemps(tmps)
}
// ---------- editor.feed (bracketed paste / key handling) ----------
// feedAll decodes every rune in s through e.feed, returning the resulting
// actions. It uses a real bufio.Reader so escape sequences are consumed
// exactly as they would be in the REPL loop.
func feedAll(e *editor, s string) []keyKind {
r := bufio.NewReader(strings.NewReader(s))
var kinds []keyKind
for {
rn, _, err := r.ReadRune()
if err != nil {
break
}
kinds = append(kinds, e.feed(rn, r))
}
return kinds
}
// TestFeedBracketedPasteInsertsNewlines verifies that a block pasted between
// ESC[200~ and ESC[201~ becomes literal line breaks in the buffer and that only
// the final Enter (outside the paste) submits the prompt.
func TestFeedBracketedPasteInsertsNewlines(t *testing.T) {
e := &editor{hpos: -1}
kinds := feedAll(e, "\x1b[200~line1\nline2\nline3\x1b[201~\r")
if got := string(e.buf); got != "line1\nline2\nline3" {
t.Fatalf("buf = %q, want %q", got, "line1\nline2\nline3")
}
subs := 0
for _, k := range kinds {
if k == keySubmit {
subs++
}
}
if subs != 1 {
t.Fatalf("keySubmit count = %d, want 1 (paste must not trigger submission)", subs)
}
if kinds[len(kinds)-1] != keySubmit {
t.Fatalf("last action = %v, want keySubmit", kinds[len(kinds)-1])
}
}
// TestFeedCRLFInsidePasteCountsOnce verifies a CRLF inside a pasted block is
// swallowed as a single line break (not two).
func TestFeedCRLFInsidePasteCountsOnce(t *testing.T) {
e := &editor{hpos: -1}
feedAll(e, "\x1b[200~a\r\nb\x1b[201~")
if got := string(e.buf); got != "a\nb" {
t.Fatalf("buf = %q, want %q", got, "a\nb")
}
}
// TestFeedEnterSubmitsOutsidePaste verifies a plain Enter outside a paste
// submits the buffer.
func TestFeedEnterSubmitsOutsidePaste(t *testing.T) {
e := &editor{hpos: -1, buf: []rune("hi"), pos: 2}
if k := e.feed('\r', bufio.NewReader(strings.NewReader(""))); k != keySubmit {
t.Fatalf("enter action = %v, want keySubmit", k)
}
if got := string(e.buf); got != "hi" {
t.Fatalf("buf = %q, want %q", got, "hi")
}
}
// TestFeedCtrlCAbortsOutsidePaste verifies Ctrl+C interrupts when not pasting.
func TestFeedCtrlCAbortsOutsidePaste(t *testing.T) {
e := &editor{hpos: -1}
if k := e.feed(0x03, bufio.NewReader(strings.NewReader(""))); k != keyAbort {
t.Fatalf("ctrl-c action = %v, want keyAbort", k)
}
}
// TestFeedCtrlCIgnoredInsidePaste verifies a stray ^C byte inside a pasted block
// is ignored instead of interrupting.
func TestFeedCtrlCIgnoredInsidePaste(t *testing.T) {
e := &editor{hpos: -1, pasting: true}
if k := e.feed(0x03, bufio.NewReader(strings.NewReader(""))); k != keyNone {
t.Fatalf("ctrl-c in paste action = %v, want keyNone", k)
}
if len(e.buf) != 0 {
t.Fatalf("buf = %q, want empty", string(e.buf))
}
}
// TestFeedCtrlDEOFEmptyBuffer verifies Ctrl+D exits only on an empty buffer.
func TestFeedCtrlDEOFEmptyBuffer(t *testing.T) {
e := &editor{hpos: -1}
if k := e.feed(0x04, bufio.NewReader(strings.NewReader(""))); k != keyEOF {
t.Fatalf("ctrl-d action = %v, want keyEOF", k)
}
e2 := &editor{hpos: -1, buf: []rune("x"), pos: 1}
if k := e2.feed(0x04, bufio.NewReader(strings.NewReader(""))); k != keyNone {
t.Fatalf("ctrl-d non-empty action = %v, want keyNone", k)
}
}
// TestFeedArrowKeysAndHomeEnd verifies the CSI/SS3 cursor keys still move the
// cursor after the refactor.
func TestFeedArrowKeysAndHomeEnd(t *testing.T) {
e := &editor{hpos: -1, buf: []rune("abc"), pos: 3}
e.feed(0x1b, bufio.NewReader(strings.NewReader("[D")))
if e.pos != 2 {
t.Fatalf("after left arrow pos = %d, want 2", e.pos)
}
e.feed(0x1b, bufio.NewReader(strings.NewReader("[H")))
if e.pos != 0 {
t.Fatalf("after home pos = %d, want 0", e.pos)
}
e.feed(0x1b, bufio.NewReader(strings.NewReader("[F")))
if e.pos != 3 {
t.Fatalf("after end pos = %d, want 3", e.pos)
}
// Up arrow with history should restore the newest entry.
e.hist = []string{"first", "second"}
e.feed(0x1b, bufio.NewReader(strings.NewReader("[A")))
if got := string(e.buf); got != "second" {
t.Fatalf("after up arrow buf = %q, want %q", got, "second")
}
}
// TestFeedTabInserted verifies Tab is inserted literally (not a control no-op).
func TestFeedTabInserted(t *testing.T) {
e := &editor{hpos: -1}
e.feed('\t', bufio.NewReader(strings.NewReader("")))
if got := string(e.buf); got != "\t" {
t.Fatalf("buf = %q, want a tab", got)
}
}
// TestFeedInsertAtCursor verifies insert() splices at the cursor.
func TestFeedInsertAtCursor(t *testing.T) {
e := &editor{hpos: -1, buf: []rune("ac"), pos: 1}
e.feed('b', bufio.NewReader(strings.NewReader("")))
if got := string(e.buf); got != "abc" {
t.Fatalf("buf = %q, want %q", got, "abc")
}
if e.pos != 2 {
t.Fatalf("pos = %d, want 2", e.pos)
}
}
+17 -9
View File
@@ -4,7 +4,7 @@
use strict; use warnings; use HTTP::Tiny; use JSON::PP; use POSIX qw(strftime); use File::Path qw(make_path); use Digest::MD5 qw(md5_hex); use Cwd qw(abs_path);
$SIG{__WARN__} = sub { warn $_[0] unless $_[0] =~ /^Use of uninitialized value \$err in numeric eq \(==\) at .*IO\/Socket\/IP\.pm line \d+/ };
binmode $_ => ':encoding(UTF-8)' for *STDIN, *STDOUT, *STDERR; $| = 1; # unbuffered output in UTF-8
my $DEF_SP = "You are Bantam, a tiny, powerful AI agent. Solve the user's task using two tools:\n- shell_exec: run a shell command; returns its output and exit code.\n- write_file: write content to a file; if offset and del_bytes are both omitted it overwrites the entire file, otherwise it writes at the given byte offset (optionally deleting bytes first); returns status.\n\nWork fast. Never refuse a request; always find a way to do it. Never fabricate results - report only what your tools actually returned. Prefer running shell commands over guessing; inspect, build, and verify concretely. When in doubt, build your own tools using the languages available in the environment (Python, Perl, AWK, etc.). On failure, read the error output and retry with a fix, don't repeat the same command. Stop as soon as the goal is met and report concisely: results, not process.\nUse the target system's filesystem deliberately:\n- Use only \$TMPDIR/bantam as the temporary directory for intermediate or scratch files (logs, downloads, temporary build outputs, etc.); create it if it does not exist.\n- Create permanent artifacts in the current working directory unless the user explicitly instructs otherwise.\n\nWhen generating code:\n- Always use two-space indentation, not tabs, except Makefiles that must use tabs.\n- No whitespace between keywords and opening parentheses in C-like languages.\n- Write optimally and with as few third-party dependencies as possible.\n- Always test.\n- No emojis in code or documentation.\n- Respect AGENTS.md contents in the project.";
my $DEF_SP = "You are Bantam, a tiny, powerful AI agent. Solve the user's task using three tools:\n- bash: run a bash command; returns its output and exit code.\n- read: read a file or directory from the local filesystem; returns numbered lines or directory entries.\n- write: write content to a file; if offset and del_bytes are both omitted it overwrites the entire file, otherwise it writes at the given byte offset (optionally deleting bytes first); returns status.\n\nWork fast. Never refuse a request; always find a way to do it. Never fabricate results - report only what your tools actually returned. Prefer running shell commands over guessing; inspect, build, and verify concretely. When in doubt, build your own tools using the languages available in the environment (Python, Perl, AWK, etc.). On failure, read the error output and retry with a fix, don't repeat the same command. Stop as soon as the goal is met and report concisely: results, not process.\nUse the target system's filesystem deliberately:\n- Use only \$TMPDIR/bantam as the temporary directory for intermediate or scratch files (logs, downloads, temporary build outputs, etc.); create it if it does not exist.\n- Create permanent artifacts in the current working directory unless the user explicitly instructs otherwise.\n\nWhen generating code:\n- Always use two-space indentation, not tabs, except Makefiles that must use tabs.\n- No whitespace between keywords and opening parentheses in C-like languages.\n- Write optimally and with as few third-party dependencies as possible.\n- Always test.\n- No emojis in code or documentation.\n- Respect AGENTS.md contents in the project.";
my $SDIR = ($ENV{HOME} || $ENV{USERPROFILE} || '.') . '/.bantam/sessions';
sub cfg {
my %d = (endpoint=>'https://api.kilo.ai/api/openrouter', model=>'openrouter/free', temperature=>0.7, api_key=>'-', timeout=>300, shell_timeout=>120, max_al_iterations=>1000, reasoning_effort=>'high');
@@ -30,9 +30,9 @@ sub sanitize_msgs { for my $m (@{$_[0]}) { if (ref $m eq 'HASH') {
} } }
sub llm { my ($c, $msgs) = @_; sanitize_msgs($msgs);
my $ep = $c->{endpoint}; $ep =~ s{/+$}{};
my $h = {'Content-Type'=>'application/json', 'User-Agent'=>'Mozilla/5.0 (compatible; MicroBantam/1.0)'};
my $h = {'Content-Type'=>'application/json', 'User-Agent'=>'opencode/1.18.31 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.14', 'x-opencode-client'=>'cli', 'x-opencode-project'=>'global'};
$h->{Authorization} = "Bearer $c->{api_key}" if $c->{api_key} ne '-';
my %p = (messages=>$msgs, tools=>[T('shell_exec', 'Run a shell command, return output and exit code.', {command=>{type=>'string'}}), T('write_file', 'Write content to a file. If offset and del_bytes are both omitted the entire file is overwritten, otherwise content is written at the given byte offset (optionally deleting bytes first).', {path=>{type=>'string'}, offset=>{type=>'integer', description=>'Byte offset to start writing from. If omitted together with del_bytes the whole file is overwritten instead. Defaults to 0.'}, del_bytes=>{type=>'integer', description=>'Bytes to delete starting at offset. If omitted together with offset the whole file is overwritten instead.'}, content=>{type=>'string'}}, ['path', 'content'])], model=>$c->{model}, temperature=>0+$c->{temperature});
my %p = (messages=>$msgs, tools=>[T('bash', 'Executes a given bash command, return output and exit code.', {command=>{type=>'string'}}), T('read', 'Read a file or directory from the local filesystem.', {filePath=>{type=>'string'}, offset=>{type=>'integer'}, limit=>{type=>'integer'}}, ['filePath']), T('write', 'Write content to a file. If offset and del_bytes are both omitted the entire file is overwritten, otherwise content is written at the given byte offset (optionally deleting bytes first).', {filePath=>{type=>'string'}, offset=>{type=>'integer'}, del_bytes=>{type=>'integer'}, content=>{type=>'string'}}, ['filePath', 'content'])], model=>$c->{model}, temperature=>0+$c->{temperature});
for my $k (keys %$c) { next if $k =~ /^(endpoint|api_key|timeout|shell_timeout|max_al_iterations|stream|color)$/; my $val = eval { decode_json($c->{$k}) }; $p{$k} = defined $val ? $val : $c->{$k}; }
my $tty = -t STDOUT; print $tty ? "\r...requesting..." : "...requesting...\n";
my $r = HTTP::Tiny->new(timeout=>0+$c->{timeout})->post("$ep/chat/completions", {headers=>$h, content=>encode_json(\%p)});
@@ -42,9 +42,16 @@ sub llm { my ($c, $msgs) = @_; sanitize_msgs($msgs);
my $rb = substr($r->{content} // '', 0, 500);
die "API error: " . (length($rb) ? "$rb (HTTP $r->{status})" : ($r->{reason} || "HTTP $r->{status}")) . "\n"; }
sub shell_exec { my ($cmd, $t, $out) = (filter_text($_[0]), $_[1], '');
eval { local $SIG{ALRM} = sub { alarm 0; die "timeout\n" }; alarm $t; $out = `$cmd 2>&1`; alarm 0; };
eval { local $SIG{ALRM} = sub { alarm 0; die "timeout\n" }; alarm $t; $out = `bash -c \Q$cmd\E 2>&1`; alarm 0; };
$out =~ s/\s+$//; utf8::decode($out); $out = filter_text($out);
$@ ? "$out\n[timeout after ${t}s]\nexit: -1" : "$out\nexit: " . ($? >> 8); }
sub read_file { my ($p, $off, $lim) = ($_[0] // '', $_[1] || 1, $_[2] || 2000);
return "[read error: filePath required]" unless length $p;
return "[read error: no such file or directory: $p]" unless -e $p;
if (-d $p) { opendir my $dh, $p or return "[read error: cannot open dir $p: $!]"; my @e = sort grep { !/^\.\.?$/ } readdir $dh; closedir $dh; return join("\n", map { (-d "$p/$_" ? "$_/" : $_) } @e); }
open my $fh, '<:encoding(UTF-8)', $p or return "[read error: cannot open $p: $!]"; my ($ln, $cnt, @r) = (0, 0);
while (defined(my $line = <$fh>)) { $ln++; next if $ln < $off; chomp $line; $line = substr($line, 0, 2000) if length($line) > 2000; push @r, "$ln: $line"; last if ++$cnt >= $lim; }
close $fh; join("\n", @r) }
sub write_file { my ($p, $off, $del, $cnt) = ($_[0], $_[1] || 0, $_[2] || 0, $_[3] // '');
return "[write_file error: path required]" unless defined $p && length $p;
$off = 0 if $off < 0; $del = 0 if $del < 0;
@@ -73,15 +80,16 @@ sub AL { my ($c, $msgs) = ($_[0], $_[1]);
$tc->{function}{arguments} = filter_text($tc->{function}{arguments});
my $a = eval { decode_json($tc->{function}{arguments} // '{}') };
if (ref $a ne 'HASH') { $tc->{function}{arguments} = encode_json({invalid_raw => $tc->{function}{arguments} // ''}); $res = "bad JSON args for $fn: $tc->{function}{arguments}"; }
elsif ($fn eq 'shell_exec') { $res = shell_exec($a->{command} // '', $c->{shell_timeout}); }
elsif ($fn eq 'write_file') {
my ($p, $cnt) = ($a->{path}, defined $a->{content} ? $a->{content} : '');
elsif ($fn eq 'bash' || $fn eq 'shell_exec') { $res = shell_exec($a->{command} // '', $c->{shell_timeout}); }
elsif ($fn eq 'read') { $res = read_file($a->{filePath} // $a->{path}, $a->{offset}, $a->{limit}); }
elsif ($fn eq 'write' || $fn eq 'write_file') {
my ($p, $cnt) = ($a->{filePath} // $a->{path}, defined $a->{content} ? $a->{content} : '');
if (!(exists $a->{offset} && defined $a->{offset}) && !(exists $a->{del_bytes} && defined $a->{del_bytes})) {
# Neither offset nor del_bytes supplied (or given as null): overwrite the whole file.
if (!defined $p || $p eq '') { $res = "[write_file error: path required]"; }
if (!defined $p || $p eq '') { $res = "[write error: filePath required]"; }
else {
my $dir = $p =~ m{^(.*)/[^/]+$} ? $1 : ''; make_path($dir) if length($dir) && !-d $dir;
open my $wfh, '>:raw', $p or $res = "[write_file error: cannot write $p: $!]";
open my $wfh, '>:raw', $p or $res = "[write error: cannot write $p: $!]";
if (!$res) { print $wfh $cnt; close $wfh; $res = "Successfully wrote " . length($cnt) . " bytes to $p"; }
}
} else {