Compare commits

...
9 Commits
10 changed files with 1516 additions and 247 deletions
+118 -17
View File
@@ -2,7 +2,7 @@
## 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, shell interaction, real-time response streaming, markdown terminal rendering with box-drawing tables, Fibonacci backoff network resilience, and subagent delegation using any OpenAI-compatible completions API.
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, 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, conversation compaction, and subagent delegation using any OpenAI-compatible completions API.
The entire philosophy of Bantam is built upon two principles:
@@ -44,6 +44,7 @@ All implementations read the same `model.cfg` and `system.txt` from the current
temperature=0.7
api_key=your_api_key_here
stream=true
context_window=200000
```
2. Interactive mode:
@@ -52,19 +53,31 @@ All implementations read the same `model.cfg` and `system.txt` from the current
./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 (arrow keys move the cursor, Up/Down browse history, Backspace edits, Ctrl+C/Ctrl+D exit), so this works everywhere without dependencies.
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 (arrow keys move the cursor, Up/Down browse history, Backspace edits, Ctrl+C clears line / interrupts in-flight run, Ctrl+D exits), working everywhere without third-party dependencies.
After every interaction, Bantam displays token usage (prompt tokens, cached/uncached breakdown when supported by the provider, completion tokens, and context window utilization):
```text
[tokens: 1420 prompt (1000 cached, 420 uncached) + 85 completion | context: 1420/200000 (0.7%)]
```
Sessions are saved under `~/.bantam/sessions/` and can be managed with these commands:
- `/save` — save the entire conversation to a new session file (auto-id like `20260808-190038`) and generate its summary
- `/list` — list saved sessions (newest first) with their ids, timestamps, message counts and summaries
- `/load <id>` — load a saved session (exact id or unique prefix) and continue from there
- `/compact` — summarize the conversation with the LLM and compact the context down to just the system message plus the summary
- `/compact` — compact context down to the system message and a concise summary using the LLM; the compaction prompt is appended to the conversation to derive the summary, then the conversation is reset to `[system, summary-user-message]` (a fresh prefix, so downstream prompt-cache hits depend on the provider and are not guaranteed)
- `/cfg <param> [val]` — inspect or update a configuration parameter in `model.cfg` live
- `!<cmd>` — execute a shell command directly through `shell_exec` 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
When context window usage reaches **60% or higher**, Bantam automatically offers to compact the conversation:
```text
Context usage is at 62.4% (124800 / 200000 tokens). Compact conversation? [Y/n]:
```
Pressing **Ctrl+C** during an active inference run or long-running shell execution cleanly interrupts the turn without appending partial or malformed responses to the conversation context.
The current conversation is also **auto-saved** to `~/.bantam/sessions/autosave.json` after every turn, on `/clear`, `/load`, `/compact`, and on exit — so you can always `/load autosave` to resume where you left off.
3. File input mode:
@@ -79,39 +92,45 @@ Using these rules, everyone can build their own copy of Bantam from scratch in l
### High-Level Overview
1. **Initialization**: Read `system.txt` and `model.cfg`. Prepare an array of messages starting with the system prompt `{"role": "system", "content": system_prompt}`.
2. **Input Processing**: Take user prompt (via command-line file parameter or interactive stdin), append `{"role": "user", "content": prompt}`, and invoke `AL(cfg, messages)`.
1. **Initialization**: Read `system.txt` and `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 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)`.
3. **Agentic Loop (`AL`)**:
- Send `messages` and tool definitions to the OpenAI-compatible `/chat/completions` API endpoint with custom `User-Agent` headers.
- Send `messages` and tool definitions to the OpenAI-compatible `/chat/completions` API endpoint with custom `User-Agent` headers 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.
- Reconstruct the assistant message. If `tool_calls` exist, trace the call (`[tool call: name(args)]`), validate JSON arguments, execute the requested tool (`shell_exec` or `run_subagent`), 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.
- 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 `run_subagent`), 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.
- If context usage is >= 60%, prompt user to compact.
- Compaction appends `"You are now acting as a compaction engine. Summarize the preceding conversation concisely but completely..."` as a user message to the conversation, invokes the LLM, and then resets the conversation to the system prompt plus a `user` message carrying the resulting summary. (The compaction prompt is not retained; the post-compaction conversation is a new prefix, so prompt-cache hits are provider-dependent.)
### Main program
1. Read system prompt from `system.txt` (default if missing).
2. Read model parameters from `model.cfg` (`key=value` format).
2. Read model parameters from `model.cfg` (`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)`, 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 equal to `/save`, write the whole `messages` array to `~/.bantam/sessions/<id>.json` (with an auto-generated summary) and return to step 5. If equal to `/list`, print saved sessions and their summaries 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, replace `messages` with `[system, summary-user-message]`, and return to step 5. If starting with `/cfg`, display the current value (`/cfg <param>`) or update `model.cfg` live (`/cfg <param> <val>`) and 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/autosave.json`.
6. Append user prompt to `messages` (`role: "user"`), run `AL(cfg, messages)`, and go to step 5.
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 equal to `/save`, write the whole `messages` array to `~/.bantam/sessions/<id>.json` (with an auto-generated summary) and return to step 5. If equal to `/list`, print saved sessions and their summaries 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 `model.cfg` live (`/cfg <param> <val>`) and 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/autosave.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`) and optional `api_key` bearer header.
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.
- 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)]`).
- Validate JSON arguments. If invalid, format a tool-error response so the LLM can self-correct.
- 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 `run_subagent`).
- 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).
- 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.
4. If no pending tool calls (or if `max_al_iterations` is reached), stop and return `messages`.
4. If no pending tool calls (or if `max_al_iterations` is reached), stop and return `messages` and accumulated usage.
If the API rejects the request with an `Invalid assistant message: content or tool_calls must be set` error (usually caused by a previously cut-off stream that left an empty assistant message in the session), all implementations strip the last `assistant`-role message from the session and retry the call.
@@ -128,6 +147,7 @@ If the API rejects the request with an `Invalid assistant message: content or to
- `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)
- `shell_timeout` (timeout in seconds for `shell_exec` commands, default 120)
- `max_al_iterations` (max tool-call loop iterations per `AL()` invocation, default 1000)
- `context_window` (context window size in tokens, auto-discovered from `/models` API if available, fallback to this setting, default 200000)
The Go port's built-in editor tracks the cursor with its own column math (terminal auto-wrap aware) and redraws from the first line of the buffer, so wrapped input stays clean at any terminal width.
@@ -154,11 +174,12 @@ MicroBantam (`mb`) is a compressed Perl 5 reference implementation of the same a
### Features
- Full agentic loop: LLM calls, `shell_exec` / `run_subagent` tool execution with JSON argument validation (invalid args are fed back so the model can self-correct), and the 5-level subagent recursion depth limit
- 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
- Interactive mode (`/quit`, `/clear`, `/save`, `/list`, `/load <id>`, `/cfg`, `/help`) and file input mode
- Same built-in default system prompt and `OPENAI_API_KEY` fallback as the Go implementation
- Automatic recovery from `Invalid assistant message: content or tool_calls must be set` API errors: strips the last assistant message and retries
- Automatic recovery from `Invalid assistant message: content or tool_calls must be set` API errors: strips the last assistant message and retries (matches the Go port; note that a 4xx response other than this specific error aborts the run with an `API error` message, unlike the Go port which retries only on 5xx/408/429)
### What it drops
@@ -182,6 +203,86 @@ MicroBantam (`mb`) is a compressed Perl 5 reference implementation of the same a
- `model.cfg`, `system.txt` — shared configuration and system prompt
- `README.md` — this document
## Extra tools
The `extras/` directory contains small, dependency-light shell scripts that extend Bantam without changing its core. Because Bantam's only built-in tools are `shell_exec` and `run_subagent`, 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.
### `extras/websearch`
A simple web search tool for Bantam built on [Exa's MCP server](https://mcp.exa.ai/mcp)
(Streamable HTTP). It speaks the JSON-RPC MCP protocol (`initialize` ->
`notifications/initialized` -> `tools/call` with `web_search_exa`) and prints
the formatted result text. Usage:
```sh
extras/websearch "your query" # default 5 results
extras/websearch "your query" 10 # custom number of results
```
Environment:
- `EXA_MCP_ENDPOINT` — endpoint URL (default `https://mcp.exa.ai/mcp`).
- `EXA_API_KEY` — optional; when set, it is passed as an `?api_key=` query
parameter so no extra HTTP headers are added beyond `Content-Type`.
Dependencies: `curl`, `jq`.
### `extras/weather`
A wrapper around the [wttr.in](https://github.com/chubin/wttr.in) weather API
that queries current conditions and forecasts for any location. It supports the
graphical ANSI terminal view (default), one-line presets (`1`-`4`), custom
`%`-notation formats, and the rich JSON document (`?format=j1`). Usage:
```sh
extras/weather # auto-detect location from request IP
extras/weather London # graphical report
extras/weather -f 3 "New York" # one-line preset
extras/weather -f "%l: %c %t" Paris # custom one-line format
extras/weather -u u -L de Berlin # USCS units, German output
extras/weather -j Tokyo # raw JSON (pretty-printed via jq)
```
Options include `-l/--location`, `-u/--units` (`m`/`u`/`M`), `-L/--lang`,
`-f/--format`, `-j/--json`, `-0`/`-1`/`-2` (view depth), `-q`/`--quiet`,
`-A` (force ANSI) and `-h/--help`. Environment overrides: `WTTRAPI` (default
`https://wttr.in`) and `WEATHER_TIMEOUT` (default `20`s).
Dependencies: `curl`, `jq` (only required for the JSON format).
Dependencies: `curl`, `jq` (only required for the JSON format).
### `extras/context7`
A documentation lookup tool for Bantam backed by the
[Context7 public MCP server](https://mcp.context7.com/mcp). It speaks the same
JSON-RPC MCP protocol as `websearch` (`initialize` ->
`notifications/initialized` -> `tools/call`) and prints the returned text.
Context7 keeps up-to-date docs and code examples for thousands of libraries and
exposes two tools, both wrapped here:
- `resolve-library-id` — maps a library name to a Context7 ID
(`/org/project`).
- `query-docs` — fetches documentation and code examples for a resolved ID.
Usage:
```sh
extras/context7 resolve "React" "hooks" # list candidate library IDs
extras/context7 query "/reactjs/react.dev" "useEffect cleanup" # fetch docs
extras/context7 docs "Express" "middleware error handling" # resolve + query
extras/context7 --help
```
The `docs` subcommand is a convenience that resolves the library, auto-selects
the top-ranked match, and immediately queries it (the chosen ID is printed to
stderr so stdout stays clean for piping). Environment overrides:
`CONTEXT7_MCP_ENDPOINT` (default `https://mcp.context7.com/mcp`) and
`CONTEXT7_API_KEY` (optional; sent as the `X-Context7-API-Key` header for
higher rate limits / private docs).
Dependencies: `curl`, `jq`.
## FAQ
### Does Bantam support `AGENTS.md` etc?
+199
View File
@@ -0,0 +1,199 @@
#!/bin/sh
# context7 - query library/framework documentation via the Context7 public
# MCP server (https://mcp.context7.com/mcp).
#
# Context7 keeps up-to-date docs and code examples for thousands of libraries
# and frameworks and exposes them through an MCP (Model Context Protocol)
# server. This script speaks the JSON-RPC MCP protocol over HTTP:
# 1. initialize -> obtains an Mcp-Session-Id (optional)
# 2. notifications/initialized -> no reply
# 3. tools/call -> runs a Context7 tool and prints text
#
# Context7 provides two tools:
# * resolve-library-id maps a library name to a Context7 ID (/org/project).
# * query-docs fetches docs + examples for a resolved library ID.
#
# Usage:
# context7 resolve <libraryName> [query]
# Search for a library and print the candidate Context7 IDs with their
# description, snippet count, reputation and benchmark score.
#
# context7 query <libraryId> <query>
# Fetch documentation for an explicit library ID (format /org/project
# or /org/project/version). The ID usually comes from `resolve`.
#
# context7 docs <libraryName> <query>
# Convenience: resolve the library, auto-pick the top-ranked match and
# immediately query its documentation.
#
# context7 --help
#
# Environment:
# CONTEXT7_MCP_ENDPOINT optional; default https://mcp.context7.com/mcp
# CONTEXT7_API_KEY optional; if set, sent as the X-Context7-API-Key
# request header (for higher rate limits / private
# docs). No extra headers are added when unset.
#
# Dependencies: curl, jq.
set -eu
usage() {
sed -n '/^# Usage:/,/^# Dependencies:/p' "$0" | sed 's/^# \{0,1\}//'
}
if [ "$#" -lt 1 ]; then
usage >&2
exit 1
fi
CMD="$1"
shift
case "$CMD" in
-h|--help|help)
usage
exit 0
;;
resolve|query|docs)
;;
*)
echo "context7: unknown command '$CMD' (try --help)" >&2
exit 1
;;
esac
# --- Validate argument counts per command. ---
case "$CMD" in
resolve)
if [ "$#" -lt 1 ]; then
echo "context7: resolve requires <libraryName> [query]" >&2
exit 1
fi
LIB_NAME="$1"
QUERY="${2:-$1}"
;;
query)
if [ "$#" -lt 2 ]; then
echo "context7: query requires <libraryId> <query>" >&2
exit 1
fi
LIB_ID="$1"
QUERY="$2"
;;
docs)
if [ "$#" -lt 2 ]; then
echo "context7: docs requires <libraryName> <query>" >&2
exit 1
fi
LIB_NAME="$1"
QUERY="$2"
;;
esac
ENDPOINT="${CONTEXT7_MCP_ENDPOINT:-https://mcp.context7.com/mcp}"
# Optional auth header (single token, no spaces expected in an API key).
AUTH_ARGS=""
if [ -n "${CONTEXT7_API_KEY:-}" ]; then
AUTH_ARGS="-H X-Context7-API-Key:${CONTEXT7_API_KEY}"
fi
# --- MCP handshake + tool call helper. ---
# Args: $1 = tool name, $2 = arguments JSON. Prints the parsed text content.
mcp_call() {
TOOL="$1"
ARGS="$2"
INIT_HEADERS=$(mktemp)
INIT_BODY=$(mktemp)
curl -s -D "$INIT_HEADERS" -X POST "$ENDPOINT" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
$AUTH_ARGS \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"context7","version":"1.0"}}}' \
> "$INIT_BODY"
SID=$(grep -i '^mcp-session-id:' "$INIT_HEADERS" | tr -d '\r' | awk '{print $2}')
if [ -n "$SID" ]; then
curl -s -X POST "$ENDPOINT" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Mcp-Session-Id: $SID" \
$AUTH_ARGS \
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}' > /dev/null
SID_ARGS="-H Mcp-Session-Id:$SID"
else
SID_ARGS=""
fi
CALL_BODY=$(mktemp)
curl -s -X POST "$ENDPOINT" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
$SID_ARGS \
$AUTH_ARGS \
-d "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"$TOOL\",\"arguments\":$ARGS}}" \
> "$CALL_BODY"
# Take the last "data:" SSE payload (or the whole body if plain JSON).
DATA=$(grep -E '^data:[[:space:]]' "$CALL_BODY" | tail -n1 | sed 's/^data:[[:space:]]*//')
if [ -z "$DATA" ]; then
DATA=$(cat "$CALL_BODY")
fi
rm -f "$INIT_HEADERS" "$INIT_BODY" "$CALL_BODY"
if [ -z "$DATA" ]; then
echo "context7: no response from MCP server" >&2
return 1
fi
# Surface protocol errors.
ERROR_MSG=$(printf '%s' "$DATA" | jq -r 'if .error then (.error|tostring) else empty end')
if [ -n "$ERROR_MSG" ]; then
echo "context7 error: $ERROR_MSG" >&2
return 1
fi
printf '%s' "$DATA" | jq -r '
(.result.content // [])
| map(select(.type == "text"))
| map(.text)
| .[]
'
}
# --- Dispatch. ---
case "$CMD" in
resolve)
ARGS=$(jq -n --arg n "$LIB_NAME" --arg q "$QUERY" \
'{libraryName:$n, query:$q}')
mcp_call "resolve-library-id" "$ARGS"
;;
query)
ARGS=$(jq -n --arg id "$LIB_ID" --arg q "$QUERY" \
'{libraryId:$id, query:$q}')
mcp_call "query-docs" "$ARGS"
;;
docs)
RARGS=$(jq -n --arg n "$LIB_NAME" --arg q "$QUERY" \
'{libraryName:$n, query:$q}')
RESOLVE_OUT=$(mcp_call "resolve-library-id" "$RARGS") || exit 1
# Auto-pick the first candidate library ID from the resolve output.
TOP_ID=$(printf '%s\n' "$RESOLVE_OUT" \
| grep -m1 -oE '/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+' || true)
if [ -z "$TOP_ID" ]; then
echo "context7: could not extract a library ID from resolve output:" >&2
printf '%s\n' "$RESOLVE_OUT" >&2
exit 1
fi
echo "context7: using library ID $TOP_ID (from resolve of '$LIB_NAME')" >&2
QARGS=$(jq -n --arg id "$TOP_ID" --arg q "$QUERY" \
'{libraryId:$id, query:$q}')
mcp_call "query-docs" "$QARGS"
;;
esac
exit 0
Executable
+190
View File
@@ -0,0 +1,190 @@
#!/bin/sh
# weather - query current weather and forecasts for any location via wttr.in.
#
# wttr.in (https://github.com/chubin/wttr.in) is a console-oriented weather
# service backed by World Weather Online data. It supports several output
# formats: a graphical ANSI view for terminals (the default), one-line text
# formats (built-in presets 1-4 or a custom %-notation string), a rich JSON
# document (?format=j1 / j2) for scripts and APIs, plus PNG / HTML /
# Prometheus metrics.
#
# This script is a thin, friendly wrapper around the wttr.in HTTP API. It
# builds the request URL from the supplied location and options, fetches the
# data with curl and (when JSON is requested) pretty-prints it with jq.
#
# Usage:
# weather [options] [location]
#
# Options:
# -l, --location LOC Location: city, airport code, domain, IP,
# "lat,lon" coordinates, or "~Name" for a custom label.
# Default: auto-detect from the request IP.
# -u, --units U Unit system: m (metric, default), u (USCS/imperial),
# M (metric with wind speed in m/s).
# -L, --lang LANG Output language code, e.g. de, fr, ru, zh-cn.
# -f, --format FMT Output format:
# j1 -> rich JSON document (default for --json)
# j2 -> JSON document, imperial units
# 1-4 -> built-in one-line presets
# "%..." -> custom one-line %-notation format
# Omit to get the default graphical terminal view.
# -j, --json Alias for --format j1 (emit JSON).
# -0 Current weather only.
# -1 Current weather + today's forecast.
# -2 Current weather + today's + tomorrow's forecast.
# -q, --quiet Quiet: no "Weather report" header / city name.
# -A Force ANSI output (ignore User-Agent detection).
# -h, --help Show this help text.
#
# Environment:
# WTTRAPI Base endpoint. Default: https://wttr.in
# WEATHER_TIMEOUT curl connect/read timeout in seconds. Default: 20
#
# Dependencies: curl, jq (jq only needed for the JSON format).
#
# Examples:
# weather
# weather London
# weather -u u -L de Berlin
# weather -f 3 "New York"
# weather -f "%l: %c %t (feels %f), wind %w %m" Paris
# weather -j Tokyo
set -eu
WTTRAPI="${WTTRAPI:-https://wttr.in}"
TIMEOUT="${WEATHER_TIMEOUT:-20}"
# Single-letter options (view/quiet/units) concatenated into one run.
SHORT=""
LANG_CODE=""
FORMAT=""
LOCATION=""
usage() {
sed -n '/^# Usage:/,/^# Examples:/p' "$0" | sed 's/^# \{0,1\}//'
}
# --- Parse arguments. ---
while [ "$#" -gt 0 ]; do
case "$1" in
-h|--help)
usage
exit 0
;;
-l|--location)
[ "$#" -ge 2 ] || { echo "weather: $1 requires an argument" >&2; exit 1; }
LOCATION="$2"
shift 2
;;
-u|--units)
[ "$#" -ge 2 ] || { echo "weather: $1 requires an argument" >&2; exit 1; }
case "$2" in
m|u|M) SHORT="${SHORT}$2" ;;
*) echo "weather: unknown unit '$2' (use m, u or M)" >&2; exit 1 ;;
esac
shift 2
;;
-L|--lang)
[ "$#" -ge 2 ] || { echo "weather: $1 requires an argument" >&2; exit 1; }
LANG_CODE="$2"
shift 2
;;
-f|--format)
[ "$#" -ge 2 ] || { echo "weather: $1 requires an argument" >&2; exit 1; }
FORMAT="$2"
shift 2
;;
-j|--json)
FORMAT="j1"
shift
;;
-0|-1|-2|-q|-A)
SHORT="${SHORT}${1#-}"
shift
;;
--)
shift
LOCATION="${LOCATION:+$LOCATION }$(printf '%s' "$*" | sed 's/^ //')"
break
;;
-*)
echo "weather: unknown option '$1' (try --help)" >&2
exit 1
;;
*)
# Positional: append to location (preserves multi-word locations).
LOCATION="${LOCATION:+$LOCATION }$1"
shift
;;
esac
done
# --- Assemble the query string. ---
# wttr.in accepts a leading run of single-letter options, then '&'-joined
# long options (key=value). We build both parts and join them.
QUERY="$SHORT"
# Long options: format and lang.
LONG=""
if [ -n "$FORMAT" ]; then
case "$FORMAT" in
j1|j2)
LONG="${LONG:+$LONG&}format=${FORMAT}"
;;
[1-4])
LONG="${LONG:+$LONG&}format=${FORMAT}"
;;
*)
# Custom %-notation one-line format -> URL-encode it.
ENC=$(printf '%s' "$FORMAT" | jq -sRr @uri)
LONG="${LONG:+$LONG&}format=${ENC}"
;;
esac
fi
if [ -n "$LANG_CODE" ]; then
LONG="${LONG:+$LONG&}lang=$(printf '%s' "$LANG_CODE" | jq -sRr @uri)"
fi
# Combine short + long parts into a single query string.
if [ -n "$QUERY" ] && [ -n "$LONG" ]; then
QUERY="${QUERY}&${LONG}"
elif [ -z "$QUERY" ] && [ -n "$LONG" ]; then
QUERY="$LONG"
fi
# URL-encode the location (spaces -> %20, etc.). Empty means auto-detect.
if [ -n "$LOCATION" ]; then
LOC_ENC=$(printf '%s' "$LOCATION" | jq -sRr @uri)
else
LOC_ENC=""
fi
URL="${WTTRAPI}/${LOC_ENC}"
if [ -n "$QUERY" ]; then
URL="${URL}?${QUERY}"
fi
# --- Fetch. ---
# wttr.in returns the graphical ANSI art whenever the request looks like it
# comes from a console client (curl's default User-Agent), and HTML for
# browsers. So we keep curl's default identity. The "-A" flag above is a
# wttr.in URL option ("force ANSI") and is already part of the query string.
RESP=$(curl -s -L --max-time "$TIMEOUT" "$URL") || {
echo "weather: failed to reach $WTTRAPI" >&2
exit 1
}
# --- Emit. ---
if [ "$FORMAT" = "j1" ] || [ "$FORMAT" = "j2" ]; then
if command -v jq >/dev/null 2>&1; then
printf '%s\n' "$RESP" | jq . 2>/dev/null || printf '%s\n' "$RESP"
else
printf '%s\n' "$RESP"
fi
else
printf '%s\n' "$RESP"
fi
exit 0
+106
View File
@@ -0,0 +1,106 @@
#!/bin/sh
# websearch - simple web search for Bantam via Exa's MCP server (Streamable HTTP).
#
# Talks to https://mcp.exa.ai/mcp using the JSON-RPC MCP protocol:
# 1. initialize -> obtains an Mcp-Session-Id from response headers
# 2. notifications/initialized -> no reply
# 3. tools/call (web_search_exa) -> prints the formatted result text
#
# Usage:
# websearch "your query"
# websearch "your query" [num_results]
#
# Environment:
# EXA_MCP_ENDPOINT optional; default https://mcp.exa.ai/mcp
# EXA_API_KEY optional; if set, sent as an URL query parameter (?api_key=)
# so that NO extra HTTP headers are added beyond Content-Type.
#
# Dependencies: curl, jq.
set -eu
if [ "$#" -lt 1 ]; then
echo "Usage: websearch \"query\" [num_results]" >&2
exit 1
fi
QUERY="$1"
NUM="${2:-5}"
ENDPOINT="${EXA_MCP_ENDPOINT:-https://mcp.exa.ai/mcp}"
# Append API key as a query parameter (no extra headers).
if [ -n "${EXA_API_KEY:-}" ]; then
URL="${ENDPOINT}?api_key=${EXA_API_KEY}"
else
URL="${ENDPOINT}"
fi
# Build the tools/call arguments JSON safely (proper query escaping).
ARGS=$(jq -n --arg q "$QUERY" --argjson n "$NUM" \
'{query:$q, numResults:$n}')
# --- Step 1: initialize, capture headers (for Mcp-Session-Id) and body. ---
INIT_HEADERS=$(mktemp)
INIT_BODY=$(mktemp)
curl -s -D "$INIT_HEADERS" -X POST "$URL" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"websearch","version":"1.0"}}}' \
> "$INIT_BODY"
SID=$(grep -i '^mcp-session-id:' "$INIT_HEADERS" | tr -d '\r' | awk '{print $2}')
if [ -z "$SID" ]; then
echo "websearch: failed to obtain MCP session id" >&2
cat "$INIT_BODY" >&2
rm -f "$INIT_HEADERS" "$INIT_BODY"
exit 1
fi
# --- Step 2: send initialized notification (fire and forget). ---
curl -s -X POST "$URL" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Mcp-Session-Id: $SID" \
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}' > /dev/null
# --- Step 3: call web_search_exa and extract the text result. ---
CALL_BODY=$(mktemp)
curl -s -X POST "$URL" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Mcp-Session-Id: $SID" \
-d "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"web_search_exa\",\"arguments\":$ARGS}}" \
> "$CALL_BODY"
# Parse the response: take the last "data:" SSE payload (or the whole body
# if it is plain JSON) and print the text blocks from the result.
DATA=$(grep -E '^data:[[:space:]]' "$CALL_BODY" | tail -n1 | sed 's/^data:[[:space:]]*//')
if [ -z "$DATA" ]; then
DATA=$(cat "$CALL_BODY")
fi
if [ -z "$DATA" ]; then
echo "websearch: no response from MCP server" >&2
rm -f "$INIT_HEADERS" "$INIT_BODY" "$CALL_BODY"
exit 1
fi
# Surface server/protocol errors, then emit text content.
ERROR_MSG=$(printf '%s' "$DATA" | jq -r 'if .error then (.error|tostring) else empty end')
if [ -n "$ERROR_MSG" ]; then
echo "websearch error: $ERROR_MSG" >&2
rm -f "$INIT_HEADERS" "$INIT_BODY" "$CALL_BODY"
exit 1
fi
printf '%s' "$DATA" | jq -r '
(.result.content // [])
| map(select(.type == "text"))
| map(.text)
| .[]
'
rm -f "$INIT_HEADERS" "$INIT_BODY" "$CALL_BODY"
exit 0
+369 -130
View File
@@ -19,9 +19,11 @@ import (
"path/filepath"
"regexp"
"sort"
"sync"
"strconv"
"strings"
"time"
"unicode"
"unicode/utf8"
)
@@ -44,10 +46,28 @@ type Cfg struct {
MaxALIterations int
Stream bool
Color string
ContextWindow int
Raw map[string]string
}
var defCfg = Cfg{"https://opencode.ai/zen/v1", "big-pickle", "-", 0.7, 300, 120, 1000, true, "auto", nil}
// internalKey reports whether a model.cfg key is an agent-internal parameter
// that must never be forwarded to the chat completions API.
func internalKey(k string) bool {
switch k {
case "endpoint", "model", "temperature", "stream", "api_key", "timeout",
"shell_timeout", "max_al_iterations", "color", "context_window":
return true
}
return false
}
// llmTransport is a shared HTTP transport reused across all LLM calls so that
// connections are pooled instead of recreated per request.
var llmTransport = &http.Transport{
DialContext: (&net.Dialer{Timeout: 300 * time.Second}).DialContext,
}
var defCfg = Cfg{"https://opencode.ai/zen/v1", "big-pickle", "-", 0.7, 300, 120, 1000, true, "auto", 262144, nil}
func atoiD(s string, d int) int {
if v, e := strconv.Atoi(strings.TrimSpace(s)); e == nil {
@@ -56,6 +76,66 @@ func atoiD(s string, d int) int {
return d
}
func queryModelsContextWindow(cfg *Cfg) int {
client := &http.Client{Timeout: 3 * time.Second}
req, err := http.NewRequest("GET", strings.TrimRight(cfg.Endpoint, "/")+"/models", nil)
if err != nil { return 0 }
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; Bantam/1.0)")
if cfg.APIKey != "" && cfg.APIKey != "-" { req.Header.Set("Authorization", "Bearer "+cfg.APIKey) }
resp, err := client.Do(req)
if err != nil || resp.StatusCode >= 400 { return 0 }
defer resp.Body.Close()
var res struct {
Data []map[string]any `json:"data"`
Models []map[string]any `json:"models"`
}
if json.NewDecoder(resp.Body).Decode(&res) != nil { return 0 }
list := res.Data
if len(list) == 0 { list = res.Models }
for _, item := range list {
id, _ := item["id"].(string)
if id == cfg.Model || strings.EqualFold(id, cfg.Model) {
for _, key := range []string{"context_window", "context_length", "max_context_length", "max_model_len", "context_size", "max_tokens", "max_input_tokens"} {
if val, ok := item[key]; ok {
switch v := val.(type) {
case float64:
if v > 0 { return int(v) }
case string:
if n := atoiD(v, 0); n > 0 { return n }
}
}
}
}
}
return 0
}
var cwCacheMu sync.Mutex
var cwCache = map[string]int{}
func fetchContextWindow(cfg *Cfg) int {
// Only values discovered from the /models endpoint are cached, keyed by
// endpoint+model. The context_window-override and 262144 default are derived
// per call from cfg so they never shadow each other across configs.
key := cfg.Endpoint + "\x00" + cfg.Model
cwCacheMu.Lock()
if cw, ok := cwCache[key]; ok {
cwCacheMu.Unlock()
return cw
}
cwCacheMu.Unlock()
if cw := queryModelsContextWindow(cfg); cw > 0 {
cwCacheMu.Lock()
cwCache[key] = cw
cwCacheMu.Unlock()
return cw
}
if v, ok := cfg.Raw["context_window"]; ok {
return atoiD(v, 262144)
}
return 262144
}
func getCfg(path string) Cfg {
cfg := defCfg
cfg.Raw = map[string]string{
@@ -63,6 +143,7 @@ func getCfg(path string) Cfg {
"api_key": cfg.APIKey, "stream": strconv.FormatBool(cfg.Stream), "color": cfg.Color,
"timeout": strconv.Itoa(cfg.Timeout), "shell_timeout": strconv.Itoa(cfg.ShellTimeout),
"max_al_iterations": strconv.Itoa(cfg.MaxALIterations),
"context_window": strconv.Itoa(cfg.ContextWindow),
}
if d, err := os.ReadFile(path); err == nil {
for _, ln := range strings.Split(string(d), "\n") {
@@ -81,6 +162,7 @@ func getCfg(path string) Cfg {
case "max_al_iterations": cfg.MaxALIterations = atoiD(v, cfg.MaxALIterations)
case "stream": cfg.Stream = v == "true" || v == "1" || v == "yes"
case "color": cfg.Color = v
case "context_window": cfg.ContextWindow = atoiD(v, cfg.ContextWindow)
}
}
}
@@ -590,11 +672,60 @@ var TOOLS = []map[string]any{
func strp(s string) *string { return &s }
type Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
PromptTokensDetails struct {
CachedTokens int `json:"cached_tokens"`
} `json:"prompt_tokens_details"`
CachedTokens int `json:"cached_tokens"`
}
func (u Usage) Cached() int {
if u.PromptTokensDetails.CachedTokens > 0 { return u.PromptTokensDetails.CachedTokens }
return u.CachedTokens
}
// estTokens is a coarse chars/4 fallback used only when the provider omits
// usage in its response; when real usage is present it is never used.
func estTokens(msgs []Message) int {
chars := 0
for _, m := range msgs {
if m.Content != nil { chars += len(*m.Content) }
chars += len(m.ReasoningContent)
for _, tc := range m.ToolCalls {
chars += len(tc.Function.Name) + len(tc.Function.Arguments)
}
}
if chars == 0 { return 0 }
t := chars / 4
if t == 0 { t = 1 }
return t
}
func contextPct(u Usage, cw int) float64 {
if cw <= 0 { cw = 262144 }
return float64(u.PromptTokens) * 100.0 / float64(cw)
}
func formatUsage(u Usage, cw int) string {
pct := contextPct(u, cw)
cached := u.Cached()
if cached > 0 {
uncached := u.PromptTokens - cached
if uncached < 0 { uncached = 0 }
return fmt.Sprintf("[tokens: %d prompt (%d cached, %d uncached) + %d completion | context: %d/%d (%.1f%%)]", u.PromptTokens, cached, uncached, u.CompletionTokens, u.PromptTokens, cw, pct)
}
return fmt.Sprintf("[tokens: %d prompt + %d completion | context: %d/%d (%.1f%%)]", u.PromptTokens, u.CompletionTokens, u.PromptTokens, cw, pct)
}
type streamDelta struct {
Choices []struct {
Delta struct {
ReasoningContent string `json:"reasoning_content"`
Reasoning string `json:"reasoning"`
Thought string `json:"thought"`
Content string `json:"content"`
ToolCalls []struct {
Index int `json:"index"`
@@ -606,6 +737,28 @@ type streamDelta struct {
} `json:"tool_calls"`
} `json:"delta"`
} `json:"choices"`
Usage *Usage `json:"usage"`
}
func cleanMessagesForLLM(msgs []Message) []Message {
out := make([]Message, len(msgs))
for i, m := range msgs {
out[i] = Message{Role: m.Role, Content: m.Content, ToolCalls: m.ToolCalls, ToolCallID: m.ToolCallID}
}
return out
}
func filterText(s string) string {
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
if r == ' ' || r == '\t' || r == '\n' {
b.WriteRune(r)
} else if unicode.IsPrint(r) {
b.WriteRune(r)
}
}
return b.String()
}
func sanitizeMessages(msgs []Message) {
@@ -613,6 +766,7 @@ func sanitizeMessages(msgs []Message) {
if msgs[i].Role == "assistant" && len(msgs[i].ToolCalls) > 0 {
for j := range msgs[i].ToolCalls {
tc := &msgs[i].ToolCalls[j]
tc.Function.Arguments = filterText(tc.Function.Arguments)
astr := tc.Function.Arguments
var a map[string]any
if err := json.Unmarshal([]byte(astr), &a); err != nil || a == nil {
@@ -620,25 +774,33 @@ func sanitizeMessages(msgs []Message) {
tc.Function.Arguments = string(fixed)
}
}
} else if msgs[i].Role == "tool" && msgs[i].Content != nil {
msgs[i].Content = strp(filterText(*msgs[i].Content))
}
}
}
func isInvalidAssistantErr(err error) bool {
s := err.Error()
return strings.Contains(s, "Invalid assistant message") || strings.Contains(s, "content or tool_calls must be set")
if err == nil { return false }
s := strings.ToLower(err.Error())
return strings.Contains(s, "invalid assistant message") ||
strings.Contains(s, "content or tool_calls must be set") ||
strings.Contains(s, "tool_calls must be set") ||
strings.Contains(s, "content must be set")
}
func llm(ctx context.Context, cfg *Cfg, msgs []Message, tools []map[string]any) (Message, error) {
if err := ctx.Err(); err != nil { return Message{}, err }
sanitizeMessages(msgs)
p := map[string]any{"model": cfg.Model, "temperature": cfg.Temperature, "messages": msgs, "stream": cfg.Stream}
func llm(ctx context.Context, cfg *Cfg, msgs []Message, tools []map[string]any) (Message, Usage, error) {
if err := ctx.Err(); err != nil { return Message{}, Usage{}, err }
cleanMsgs := cleanMessagesForLLM(msgs)
sanitizeMessages(cleanMsgs)
p := map[string]any{"model": cfg.Model, "temperature": cfg.Temperature, "messages": cleanMsgs, "stream": cfg.Stream}
if tools != nil { p["tools"] = tools }
if cfg.Stream { p["stream_options"] = map[string]any{"include_usage": true} }
for k, v := range cfg.Raw {
switch k {
case "endpoint", "api_key", "timeout", "shell_timeout", "max_al_iterations", "color":
if internalKey(k) {
continue
default:
}
{
var jv any
if err := json.Unmarshal([]byte(v), &jv); err == nil {
p[k] = jv
@@ -648,11 +810,8 @@ func llm(ctx context.Context, cfg *Cfg, msgs []Message, tools []map[string]any)
}
}
body, _ := json.Marshal(p)
client := &http.Client{Transport: &http.Transport{
DialContext: (&net.Dialer{Timeout: time.Duration(cfg.Timeout) * time.Second}).DialContext,
ResponseHeaderTimeout: time.Duration(cfg.Timeout) * time.Second,
}}
defer client.CloseIdleConnections()
llmTransport.ResponseHeaderTimeout = time.Duration(cfg.Timeout) * time.Second
client := &http.Client{Transport: llmTransport}
fib := []int{1, 1, 2, 3, 5, 8, 13, 21, 34}
pend := c("...requesting...", 1, 2)
var resp *http.Response
@@ -660,7 +819,7 @@ func llm(ctx context.Context, cfg *Cfg, msgs []Message, tools []map[string]any)
for i := 0; i <= len(fib); i++ {
if err := ctx.Err(); err != nil {
if COL { fmt.Print("\r\033[K") }
return Message{}, err
return Message{}, Usage{}, err
}
if COL { fmt.Print("\r" + pend) } else { fmt.Println(pend) }
req, _ := http.NewRequestWithContext(ctx, "POST", strings.TrimRight(cfg.Endpoint, "/")+"/chat/completions", bytes.NewReader(body))
@@ -681,23 +840,23 @@ func llm(ctx context.Context, cfg *Cfg, msgs []Message, tools []map[string]any)
if err == nil { break }
if COL { fmt.Print("\r\033[K") }
if errors.Is(err, context.Canceled) || ctx.Err() != nil {
return Message{}, ctx.Err()
return Message{}, Usage{}, ctx.Err()
}
if is4xxClientErr {
return Message{}, err
return Message{}, Usage{}, err
}
if i < len(fib) {
fmt.Println(c(fmt.Sprintf("[network error: %v, retrying in %ds...]", err, fib[i]), 31))
select {
case <-ctx.Done():
return Message{}, ctx.Err()
return Message{}, Usage{}, ctx.Err()
case <-time.After(time.Duration(fib[i]) * time.Second):
}
}
}
if err != nil {
if COL { fmt.Print("\r\033[K") }
return Message{}, err
return Message{}, Usage{}, err
}
defer resp.Body.Close()
if COL { fmt.Print("\r\033[K") }
@@ -707,27 +866,43 @@ func llm(ctx context.Context, cfg *Cfg, msgs []Message, tools []map[string]any)
Message struct {
Message
Reasoning string `json:"reasoning"`
Thought string `json:"thought"`
} `json:"message"`
} `json:"choices"`
Usage Usage `json:"usage"`
}
if err := json.NewDecoder(resp.Body).Decode(&cr); err != nil {
if errors.Is(err, context.Canceled) || ctx.Err() != nil { return Message{}, ctx.Err() }
return Message{}, err
if errors.Is(err, context.Canceled) || ctx.Err() != nil { return Message{}, Usage{}, ctx.Err() }
return Message{}, Usage{}, err
}
if len(cr.Choices) == 0 { return Message{}, errors.New("empty choices in LLM response") }
if len(cr.Choices) == 0 { return Message{}, Usage{}, errors.New("empty choices in LLM response") }
m := cr.Choices[0].Message.Message
if m.ReasoningContent == "" { m.ReasoningContent = cr.Choices[0].Message.Reasoning }
return m, nil
if m.ReasoningContent == "" { m.ReasoningContent = cr.Choices[0].Message.Thought }
u := cr.Usage
if u.PromptTokens == 0 {
u.PromptTokens = estTokens(msgs)
u.CompletionTokens = estTokens([]Message{m})
u.TotalTokens = u.PromptTokens + u.CompletionTokens
}
return m, u, nil
}
return parseStream(ctx, resp.Body)
m, u, err := parseStream(ctx, resp.Body)
if err == nil && u.PromptTokens == 0 {
u.PromptTokens = estTokens(msgs)
u.CompletionTokens = estTokens([]Message{m})
u.TotalTokens = u.PromptTokens + u.CompletionTokens
}
return m, u, err
}
func parseStream(ctx context.Context, r io.Reader) (Message, error) {
func parseStream(ctx context.Context, r io.Reader) (Message, Usage, error) {
var content, reas string
var rh, ch bool
var inReasoning bool
var lineBuf string
var mdSt mdState
var tblBuf []string
var lastUsage Usage
tcs := map[int]*ToolCall{}
var order []int
@@ -741,24 +916,39 @@ func parseStream(ctx context.Context, r io.Reader) (Message, error) {
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for sc.Scan() {
if err := ctx.Err(); err != nil { return Message{}, err }
if err := ctx.Err(); err != nil { return Message{}, lastUsage, err }
ln := strings.TrimSpace(sc.Text())
if !strings.HasPrefix(ln, "data:") { continue }
data := strings.TrimSpace(ln[5:])
if data == "[DONE]" { break }
var d streamDelta
if json.Unmarshal([]byte(data), &d) != nil || len(d.Choices) == 0 { continue }
if json.Unmarshal([]byte(data), &d) != nil { continue }
if d.Usage != nil && (d.Usage.PromptTokens > 0 || d.Usage.TotalTokens > 0) {
lastUsage = *d.Usage
}
if len(d.Choices) == 0 { continue }
dl := d.Choices[0].Delta
rc := dl.ReasoningContent
if rc == "" { rc = dl.Reasoning }
if rc == "" { rc = dl.Thought }
if rc != "" {
if !rh { fmt.Println(c("--- reasoning start ---", 36)); rh = true }
if !inReasoning {
if content != "" {
flushTable()
if lineBuf != "" { fmt.Println(renderMDLine(lineBuf, &mdSt)); lineBuf = "" }
fmt.Println()
}
fmt.Println(c("--- reasoning start ---", 36))
inReasoning = true
}
fmt.Print(c(rc, 2))
reas += rc
}
if dl.Content != "" {
if rh && !ch { fmt.Print("\n" + c("--- reasoning end ---", 36) + "\n\n") }
ch = true
if inReasoning {
fmt.Print("\n" + c("--- reasoning end ---", 36) + "\n\n")
inReasoning = false
}
content += dl.Content
lineBuf += dl.Content
for {
@@ -792,7 +982,7 @@ func parseStream(ctx context.Context, r io.Reader) (Message, error) {
if tc.Function.Arguments != "" { t.Function.Arguments += tc.Function.Arguments }
}
}
if err := ctx.Err(); err != nil { return Message{}, err }
if err := ctx.Err(); err != nil { return Message{}, lastUsage, err }
flushTable()
if lineBuf != "" {
if !mdSt.inCode && isTableLine(lineBuf) {
@@ -802,7 +992,7 @@ func parseStream(ctx context.Context, r io.Reader) (Message, error) {
fmt.Println(renderMDLine(lineBuf, &mdSt))
}
}
if rh && !ch {
if inReasoning {
fmt.Print("\n" + c("--- reasoning end ---", 36) + "\n")
}
m := Message{Role: "assistant"}
@@ -812,16 +1002,20 @@ func parseStream(ctx context.Context, r io.Reader) (Message, error) {
m.ToolCalls = make([]ToolCall, 0, len(order))
for _, idx := range order { m.ToolCalls = append(m.ToolCalls, *tcs[idx]) }
}
return m, sc.Err()
if lastUsage.TotalTokens == 0 && lastUsage.PromptTokens > 0 {
lastUsage.TotalTokens = lastUsage.PromptTokens + lastUsage.CompletionTokens
}
return m, lastUsage, sc.Err()
}
func shell(ctx context.Context, cmd string, timeout int) string {
cmd = filterText(cmd)
cmdCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
defer cancel()
c := exec.CommandContext(cmdCtx, "sh", "-c", cmd)
c.WaitDelay = 100 * time.Millisecond
out, err := c.CombinedOutput()
res := strings.TrimSpace(string(out))
res := strings.TrimSpace(filterText(string(out)))
if ctx.Err() != nil {
return "[interrupted]\n\nexit: -1"
}
@@ -848,14 +1042,15 @@ func last(msgs []Message) string {
return ""
}
func AL(ctx context.Context, cfg *Cfg, msgs []Message, sp string, depth int) ([]Message, error) {
func AL(ctx context.Context, cfg *Cfg, msgs []Message, sp string, depth int) ([]Message, Usage, error) {
done := false
var turnUsage Usage
for i := 0; i < cfg.MaxALIterations && !done; i++ {
if err := ctx.Err(); err != nil { return msgs, err }
m, err := llm(ctx, cfg, msgs, TOOLS)
if err := ctx.Err(); err != nil { return msgs, turnUsage, err }
m, u, err := llm(ctx, cfg, msgs, TOOLS)
if err != nil {
if errors.Is(err, context.Canceled) || ctx.Err() != nil {
return msgs, err
return msgs, turnUsage, err
}
if isInvalidAssistantErr(err) {
stripped := false
@@ -869,10 +1064,15 @@ func AL(ctx context.Context, cfg *Cfg, msgs []Message, sp string, depth int) ([]
}
if stripped { continue }
}
return msgs, err
return msgs, turnUsage, err
}
turnUsage.PromptTokens = u.PromptTokens
turnUsage.CompletionTokens += u.CompletionTokens
turnUsage.TotalTokens += u.TotalTokens
if u.Cached() > 0 { turnUsage.CachedTokens = u.Cached() }
for j := range m.ToolCalls {
tc := &m.ToolCalls[j]
tc.Function.Arguments = filterText(tc.Function.Arguments)
astr := tc.Function.Arguments
var a map[string]any
if err := json.Unmarshal([]byte(astr), &a); err != nil || a == nil {
@@ -887,10 +1087,20 @@ func AL(ctx context.Context, cfg *Cfg, msgs []Message, sp string, depth int) ([]
}
if m.Content != nil { fmt.Println(renderMD(*m.Content)) }
}
if len(m.ToolCalls) == 0 { done = true; break }
if len(m.ToolCalls) == 0 {
// If the model returned only a reasoning block with no non-reasoning
// tokens or tool calls, nudge it to continue rather than ending the turn.
if m.ReasoningContent != "" && (m.Content == nil || strings.TrimSpace(*m.Content) == "") {
fmt.Println(c("[auto continue: response was reasoning-only]", 33))
msgs = append(msgs, Message{Role: "user", Content: strp("continue")})
continue
}
done = true
break
}
for _, tc := range m.ToolCalls {
if err := ctx.Err(); err != nil { return msgs, err }
fn, astr := tc.Function.Name, tc.Function.Arguments
if err := ctx.Err(); err != nil { return msgs, turnUsage, err }
fn, astr := tc.Function.Name, filterText(tc.Function.Arguments)
fmt.Println(c(fmt.Sprintf("[tool call: %s(%s)]", fn, astr), 33))
res, sty := "", 2
var a map[string]any
@@ -900,10 +1110,12 @@ func AL(ctx context.Context, cfg *Cfg, msgs []Message, sp string, depth int) ([]
switch fn {
case "shell_exec":
cmd, _ := a["command"].(string)
cmd = filterText(cmd)
res = shell(ctx, cmd, cfg.ShellTimeout)
if err := ctx.Err(); err != nil { return msgs, err }
if err := ctx.Err(); err != nil { return msgs, turnUsage, err }
case "run_subagent":
pr, _ := a["prompt"].(string)
pr = filterText(pr)
if depth >= MAX_DEPTH {
res, sty = fmt.Sprintf("[subagent depth limit (%d) reached, child not spawned]", MAX_DEPTH), 31
} else {
@@ -911,19 +1123,22 @@ func AL(ctx context.Context, cfg *Cfg, msgs []Message, sp string, depth int) ([]
{Role: "system", Content: strp(sp + "\n\nImportant: this is a child agent")},
{Role: "user", Content: strp(pr)},
}
if subr, err := AL(ctx, cfg, subMsgs, sp, depth+1); err != nil {
if subr, subu, err := AL(ctx, cfg, subMsgs, sp, depth+1); err != nil {
if errors.Is(err, context.Canceled) || ctx.Err() != nil {
return msgs, err
return msgs, turnUsage, err
}
res, sty = "[subagent error: "+err.Error()+"]", 31
} else {
res, sty = last(subr), 2
turnUsage.CompletionTokens += subu.CompletionTokens
turnUsage.TotalTokens += subu.TotalTokens
res, sty = filterText(last(subr)), 2
}
}
default:
res, sty = "Unknown tool: " + fn, 31
}
}
res = filterText(res)
fmt.Println(c("[tool result: "+fn+"]", 32) + "\n" + c(res, sty) + "\n")
msgs = append(msgs, Message{Role: "tool", ToolCallID: tc.ID, Content: strp(res)})
}
@@ -931,7 +1146,7 @@ func AL(ctx context.Context, cfg *Cfg, msgs []Message, sp string, depth int) ([]
if !done {
msgs = append(msgs, Message{Role: "assistant", Content: strp(fmt.Sprintf("[max AL iterations (%d) reached]", cfg.MaxALIterations))})
}
return msgs, nil
return msgs, turnUsage, nil
}
func homeDir() string {
@@ -979,8 +1194,14 @@ func saveSession(msgs []Message) (string, string) {
path = filepath.Join(d, sid+".json")
}
s := Session{sid, time.Now().Format("2006-01-02 15:04:05"), summary(msgs), msgs}
b, _ := json.MarshalIndent(s, "", " ")
os.WriteFile(path, b, 0644)
b, err := json.MarshalIndent(s, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "saveSession: marshal error: %v\n", err)
return sid, s.Summary
}
if err := os.WriteFile(path, b, 0644); err != nil {
fmt.Fprintf(os.Stderr, "saveSession: write error: %v\n", err)
}
return sid, s.Summary
}
@@ -1024,50 +1245,48 @@ func loadSession(sid string) ([]Message, error) {
func autosave(msgs []Message) {
s := Session{"autosave", time.Now().Format("2006-01-02 15:04:05"), summary(msgs), msgs}
b, _ := json.MarshalIndent(s, "", " ")
os.WriteFile(filepath.Join(sdir(), "autosave.json"), b, 0644)
b, err := json.MarshalIndent(s, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "autosave: marshal error: %v\n", err)
return
}
if err := os.WriteFile(filepath.Join(sdir(), "autosave.json"), b, 0644); err != nil {
fmt.Fprintf(os.Stderr, "autosave: write error: %v\n", err)
}
}
func summarize(ctx context.Context, cfg *Cfg, msgs []Message) (string, error) {
var sb strings.Builder
for _, m := range msgs {
if m.Role == "system" { continue }
ct := ""
if m.Content != nil { ct = *m.Content }
if ct == "" && len(m.ToolCalls) > 0 {
jc := make([]map[string]any, 0, len(m.ToolCalls))
for _, tc := range m.ToolCalls {
jc = append(jc, map[string]any{"function": map[string]any{"name": tc.Function.Name, "arguments": tc.Function.Arguments}})
}
b, _ := json.Marshal(jc)
ct = string(b)
}
if ct == "" { continue }
if len(ct) > 4000 { ct = ct[:4000] + "...[truncated]" }
sb.WriteString(m.Role + ": " + ct + "\n\n")
}
if sb.Len() == 0 { return "", errors.New("no conversation to summarize") }
joined := sb.String()
if len(joined) > 100000 { joined = joined[len(joined)-100000:] + "\n...[earlier parts truncated]" }
sys := "You are a conversation summarizer for an AI agent's context window. Summarize concisely but completely, preserving all important facts, decisions, code, errors, and the current task state, so the agent can continue the work without the original messages. Output only the summary."
cc := *cfg
cc.Stream = false
m, err := llm(ctx, &cc, []Message{{Role: "system", Content: strp(sys)}, {Role: "user", Content: strp("Summarize this conversation:\n\n" + joined)}}, nil)
if err != nil { return "", err }
s := ""
if m.Content != nil { s = *m.Content }
if s == "" { s = m.ReasoningContent }
if strings.TrimSpace(s) == "" { return "", errors.New("LLM returned an empty summary") }
return strings.TrimSpace(s), nil
}
const compactionPrompt = "You are now acting as a compaction engine. Summarize the preceding conversation concisely but completely, preserving all important facts, decisions, code snippets, tool outputs, errors, and current task state so work can seamlessly continue. Output only the summary."
func compact(ctx context.Context, cfg *Cfg, msgs []Message) ([]Message, string, error) {
if len(msgs) == 0 || msgs[0].Role != "system" {
return msgs, "", errors.New("session has no system message")
}
s, err := summarize(ctx, cfg, msgs)
if len(msgs) <= 1 {
return msgs, "", errors.New("nothing to compact")
}
cMsgs := append(append([]Message{}, msgs...), Message{
Role: "user",
Content: strp(compactionPrompt),
})
cc := *cfg
cc.Stream = false
m, _, err := llm(ctx, &cc, cMsgs, nil)
if err != nil { return msgs, "", err }
return []Message{{Role: "system", Content: msgs[0].Content}, {Role: "user", Content: strp("Summary of the previous conversation:\n" + s + "\n\nPlease continue from here.")}}, s, nil
s := ""
if m.Content != nil { s = *m.Content }
if s == "" { s = m.ReasoningContent }
s = strings.TrimSpace(s)
if s == "" { return msgs, "", errors.New("LLM returned an empty summary") }
newMsgs := []Message{
{Role: "system", Content: msgs[0].Content},
{Role: "user", Content: strp("Summary of the previous conversation:\n" + s + "\n\nPlease continue from here.")},
}
return newMsgs, s, nil
}
func summarize(ctx context.Context, cfg *Cfg, msgs []Message) (string, error) {
_, s, err := compact(ctx, cfg, msgs)
return s, err
}
func loadHistory() {
@@ -1267,9 +1486,46 @@ func readLine(prompt string) (string, bool) {
}
}
func runDirectShell(cmd string, timeout int) {
cmd = filterText(strings.TrimSpace(cmd))
if cmd == "" { return }
astr, _ := json.Marshal(map[string]string{"command": cmd})
fmt.Println(c(fmt.Sprintf("[tool call: shell_exec(%s)]", string(astr)), 33))
sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
res := shell(sigCtx, cmd, timeout)
cancel()
fmt.Println(c("[tool result: shell_exec]", 32) + "\n" + c(res, 2))
}
func doCompact(cfg *Cfg, msgs []Message) []Message {
if len(msgs) <= 1 {
fmt.Println(c("Nothing to compact yet.", 33))
return msgs
}
fmt.Println(c("[compacting conversation...]", 33))
sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
nm, sm, err := compact(sigCtx, cfg, msgs)
interrupted := sigCtx.Err() != nil
cancel()
if err != nil {
if interrupted || errors.Is(err, context.Canceled) {
fmt.Println(c("\n[interrupted]", 33))
} else {
fmt.Println(c("[compact failed: "+err.Error()+"]", 31))
}
return msgs
}
msgs = nm
autosave(msgs)
fmt.Println(c(fmt.Sprintf("[compacted to %d messages]", len(msgs)), 32))
fmt.Println(c("--- summary ---", 33) + "\n" + c(sm, 2))
return msgs
}
func main() {
sp := prompt("system.txt")
cfg := getCfg("model.cfg")
cfg.ContextWindow = fetchContextWindow(&cfg)
COL = col(cfg)
stdin = bufio.NewReader(os.Stdin)
msgs := []Message{{Role: "system", Content: strp(sp)}}
@@ -1282,35 +1538,30 @@ func main() {
}
u := strings.TrimSpace(string(data))
if strings.HasPrefix(u, "!") {
cmd := strings.TrimSpace(strings.TrimPrefix(u, "!"))
if cmd != "" {
astr, _ := json.Marshal(map[string]string{"command": cmd})
fmt.Println(c(fmt.Sprintf("[tool call: shell_exec(%s)]", string(astr)), 33))
sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
res := shell(sigCtx, cmd, cfg.ShellTimeout)
cancel()
fmt.Println(c("[tool result: shell_exec]", 32) + "\n" + c(res, 2))
}
runDirectShell(strings.TrimPrefix(u, "!"), cfg.ShellTimeout)
return
}
msgs = append(msgs, Message{Role: "user", Content: strp(u)})
sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
msgs, err = AL(sigCtx, &cfg, msgs, sp, 0)
var usg Usage
msgs, usg, err = AL(sigCtx, &cfg, msgs, sp, 0)
interrupted := sigCtx.Err() != nil
cancel()
if err != nil {
if errors.Is(err, context.Canceled) || sigCtx.Err() != nil {
if interrupted || errors.Is(err, context.Canceled) {
fmt.Println(c("\n[interrupted]", 33))
} else {
fmt.Println(c("[error: "+err.Error()+"]", 31))
}
os.Exit(1)
}
fmt.Println(c(formatUsage(usg, cfg.ContextWindow), 2))
autosave(msgs)
return
}
loadHistory()
fmt.Println(c("Bantam Agent ready", 1, 32) + c(" (Ctrl+J = new line)", 2))
fmt.Println(c(fmt.Sprintf("endpoint: %s model: %s temp: %v", cfg.Endpoint, cfg.Model, cfg.Temperature), 2))
fmt.Println(c(fmt.Sprintf("endpoint: %s model: %s temp: %v context: %d", cfg.Endpoint, cfg.Model, cfg.Temperature, cfg.ContextWindow), 2))
for {
u, ok := readLine(c("> ", 1, 36))
if !ok {
@@ -1360,26 +1611,7 @@ func main() {
fmt.Println(c("[session loaded: "+parts[1]+"]", 32) + " " + c(summary(msgs), 2))
continue
case u == "/compact":
if len(msgs) <= 1 {
fmt.Println(c("Nothing to compact yet.", 33))
continue
}
fmt.Println(c("[compacting conversation...]", 33))
sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
nm, sm, err := compact(sigCtx, &cfg, msgs)
cancel()
if err != nil {
if errors.Is(err, context.Canceled) || sigCtx.Err() != nil {
fmt.Println(c("\n[interrupted]", 33))
} else {
fmt.Println(c("[compact failed: "+err.Error()+"]", 31))
}
continue
}
msgs = nm
autosave(msgs)
fmt.Println(c(fmt.Sprintf("[compacted to %d messages]", len(msgs)), 32))
fmt.Println(c("--- summary ---", 33) + "\n" + c(sm, 2))
msgs = doCompact(&cfg, msgs)
continue
case strings.HasPrefix(u, "/cfg"):
parts := strings.SplitN(u, " ", 3)
@@ -1397,6 +1629,9 @@ func main() {
continue
}
cfg = getCfg("model.cfg")
if k == "model" || k == "endpoint" || k == "api_key" {
cfg.ContextWindow = fetchContextWindow(&cfg)
}
COL = col(cfg)
fmt.Println(c(fmt.Sprintf("[config updated: %s=%s]", k, v), 32))
} else {
@@ -1404,15 +1639,7 @@ func main() {
}
continue
case strings.HasPrefix(u, "!"):
cmd := strings.TrimSpace(strings.TrimPrefix(u, "!"))
if cmd != "" {
astr, _ := json.Marshal(map[string]string{"command": cmd})
fmt.Println(c(fmt.Sprintf("[tool call: shell_exec(%s)]", string(astr)), 33))
sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
res := shell(sigCtx, cmd, cfg.ShellTimeout)
cancel()
fmt.Println(c("[tool result: shell_exec]", 32) + "\n" + c(res, 2))
}
runDirectShell(strings.TrimPrefix(u, "!"), cfg.ShellTimeout)
continue
case u == "/help":
fmt.Println(c("Bantam commands:", 1, 36))
@@ -1424,10 +1651,11 @@ func main() {
turnMsgs := append([]Message{}, msgs...)
turnMsgs = append(turnMsgs, Message{Role: "user", Content: strp(u)})
sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
resMsgs, err := AL(sigCtx, &cfg, turnMsgs, sp, 0)
resMsgs, usg, err := AL(sigCtx, &cfg, turnMsgs, sp, 0)
interrupted := sigCtx.Err() != nil
cancel()
if err != nil {
if errors.Is(err, context.Canceled) || sigCtx.Err() != nil {
if interrupted || errors.Is(err, context.Canceled) {
fmt.Println(c("\n[interrupted]", 33))
} else {
fmt.Println(c("[error: "+err.Error()+"]", 31))
@@ -1436,6 +1664,17 @@ func main() {
}
msgs = resMsgs
autosave(msgs)
fmt.Println(c(formatUsage(usg, cfg.ContextWindow), 2))
pct := contextPct(usg, cfg.ContextWindow)
if pct >= 60.0 && len(msgs) > 1 {
fmt.Print(c(fmt.Sprintf("Context usage is at %.1f%% (%d / %d tokens). Compact conversation? [Y/n]: ", pct, usg.PromptTokens, cfg.ContextWindow), 33))
if ans, ok := readPlain(""); ok {
ans = strings.TrimSpace(strings.ToLower(ans))
if ans == "" || ans == "y" || ans == "yes" {
msgs = doCompact(&cfg, msgs)
}
}
}
}
done:
autosave(msgs)
+453 -29
View File
@@ -59,7 +59,7 @@ func TestParseStreamReasoningNoDuplication(t *testing.T) {
`data: [DONE]`,
}, "\n")
msg, err := parseStream(context.Background(), bytes.NewBufferString(sseData))
msg, _, err := parseStream(context.Background(), bytes.NewBufferString(sseData))
if err != nil {
t.Fatalf("unexpected parseStream error: %v", err)
}
@@ -91,7 +91,7 @@ func TestParseStreamReasoningAlias(t *testing.T) {
`data: [DONE]`,
}, "\n")
msg, err := parseStream(context.Background(), bytes.NewBufferString(sseData))
msg, _, err := parseStream(context.Background(), bytes.NewBufferString(sseData))
if err != nil {
t.Fatalf("unexpected parseStream error: %v", err)
}
@@ -109,7 +109,7 @@ func TestParseStreamContentOnly(t *testing.T) {
`data: [DONE]`,
}, "\n")
msg, err := parseStream(context.Background(), bytes.NewBufferString(sseData))
msg, _, err := parseStream(context.Background(), bytes.NewBufferString(sseData))
if err != nil {
t.Fatalf("unexpected parseStream error: %v", err)
}
@@ -130,7 +130,7 @@ func TestParseStreamReasoningOnly(t *testing.T) {
`data: [DONE]`,
}, "\n")
msg, err := parseStream(context.Background(), bytes.NewBufferString(sseData))
msg, _, err := parseStream(context.Background(), bytes.NewBufferString(sseData))
if err != nil {
t.Fatalf("unexpected parseStream error: %v", err)
}
@@ -144,7 +144,7 @@ func TestParseStreamReasoningOnly(t *testing.T) {
func TestParseStreamEmpty(t *testing.T) {
for _, in := range []string{"", "\n\n", "event: message\n\n"} {
msg, err := parseStream(context.Background(), strings.NewReader(in))
msg, _, err := parseStream(context.Background(), strings.NewReader(in))
if err != nil {
t.Fatalf("unexpected parseStream error for input %q: %v", in, err)
}
@@ -162,7 +162,7 @@ func TestParseStreamToolCallSplitAcrossChunks(t *testing.T) {
`data: [DONE]`,
}, "\n")
msg, err := parseStream(context.Background(), bytes.NewBufferString(sseData))
msg, _, err := parseStream(context.Background(), bytes.NewBufferString(sseData))
if err != nil {
t.Fatalf("unexpected parseStream error: %v", err)
}
@@ -188,7 +188,7 @@ func TestParseStreamMultipleToolCallsKeepFirstAppearanceOrder(t *testing.T) {
`data: [DONE]`,
}, "\n")
msg, err := parseStream(context.Background(), bytes.NewBufferString(sseData))
msg, _, err := parseStream(context.Background(), bytes.NewBufferString(sseData))
if err != nil {
t.Fatalf("unexpected parseStream error: %v", err)
}
@@ -210,7 +210,7 @@ func TestParseStreamJunkAndNoChoicesIgnored(t *testing.T) {
`data: [DONE]`,
}, "\n")
msg, err := parseStream(context.Background(), bytes.NewBufferString(sseData))
msg, _, err := parseStream(context.Background(), bytes.NewBufferString(sseData))
if err != nil {
t.Fatalf("unexpected parseStream error: %v", err)
}
@@ -226,7 +226,7 @@ func TestParseStreamReasoningAfterContent(t *testing.T) {
`data: [DONE]`,
}, "\n")
msg, err := parseStream(context.Background(), bytes.NewBufferString(sseData))
msg, _, err := parseStream(context.Background(), bytes.NewBufferString(sseData))
if err != nil {
t.Fatalf("unexpected parseStream error: %v", err)
}
@@ -707,11 +707,11 @@ func TestAutosave(t *testing.T) {
// ---------- summarize / compact (error paths only, no network) ----------
func TestSummarizeEmpty(t *testing.T) {
if _, err := summarize(context.Background(), &Cfg{}, nil); err == nil || !strings.Contains(err.Error(), "no conversation") {
t.Errorf("expected no-conversation error, got %v", err)
if _, err := summarize(context.Background(), &Cfg{}, nil); err == nil || !strings.Contains(err.Error(), "no system message") {
t.Errorf("expected no-system-message error, got %v", err)
}
if _, err := summarize(context.Background(), &Cfg{}, []Message{{Role: "system", Content: strp("sys")}}); err == nil {
t.Errorf("expected error for system-only conversation")
if _, err := summarize(context.Background(), &Cfg{}, []Message{{Role: "system", Content: strp("sys")}}); err == nil || !strings.Contains(err.Error(), "nothing to compact") {
t.Errorf("expected nothing-to-compact error for system-only conversation, got %v", err)
}
}
@@ -724,12 +724,19 @@ func TestCompactNoSystem(t *testing.T) {
t.Errorf("expected original messages on error")
}
msgs2, _, err2 := compact(context.Background(), &Cfg{}, []Message{{Role: "user", Content: strp("x")}})
if err2 == nil {
t.Errorf("expected error when first message is not system")
if err2 == nil || !strings.Contains(err2.Error(), "no system message") {
t.Errorf("expected error when first message is not system, got %v", err2)
}
if len(msgs2) != 1 {
t.Errorf("expected original messages returned, got %d", len(msgs2))
}
msgs3, _, err3 := compact(context.Background(), &Cfg{}, []Message{{Role: "system", Content: strp("sys")}})
if err3 == nil || !strings.Contains(err3.Error(), "nothing to compact") {
t.Errorf("expected nothing to compact error, got %v", err3)
}
if len(msgs3) != 1 {
t.Errorf("expected original messages returned, got %d", len(msgs3))
}
}
// ---------- history ----------
@@ -943,7 +950,7 @@ func TestLLMNonStreamingAndHeaders(t *testing.T) {
cfg.Endpoint = srv.URL
cfg.Stream = false
cfg.APIKey = "secret"
m, err := llm(context.Background(), &cfg, []Message{{Role: "user", Content: strp("hi")}}, TOOLS)
m, _, err := llm(context.Background(), &cfg, []Message{{Role: "user", Content: strp("hi")}}, TOOLS)
if err != nil {
t.Fatalf("llm: %v", err)
}
@@ -973,7 +980,7 @@ func TestLLMNoAuthHeaderWhenNoKey(t *testing.T) {
cfg.Endpoint = srv.URL
cfg.Stream = false
cfg.APIKey = "-"
if _, err := llm(context.Background(), &cfg, []Message{{Role: "user", Content: strp("hi")}}, nil); err != nil {
if _, _, err := llm(context.Background(), &cfg, []Message{{Role: "user", Content: strp("hi")}}, nil); err != nil {
t.Fatalf("llm: %v", err)
}
if gotAuth != "" {
@@ -994,7 +1001,7 @@ func TestLLMStreaming(t *testing.T) {
cfg.Endpoint = srv.URL
cfg.Stream = true
cfg.APIKey = "-"
m, err := llm(context.Background(), &cfg, []Message{{Role: "user", Content: strp("hi")}}, nil)
m, _, err := llm(context.Background(), &cfg, []Message{{Role: "user", Content: strp("hi")}}, nil)
if err != nil {
t.Fatalf("llm: %v", err)
}
@@ -1018,7 +1025,7 @@ func TestLLM4xxReturnsImmediately(t *testing.T) {
cfg.Stream = false
cfg.APIKey = "-"
start := time.Now()
_, err := llm(context.Background(), &cfg, []Message{{Role: "user", Content: strp("hi")}}, nil)
_, _, err := llm(context.Background(), &cfg, []Message{{Role: "user", Content: strp("hi")}}, nil)
if err == nil || !strings.Contains(err.Error(), "Invalid assistant message") {
t.Fatalf("expected 400 error, got %v", err)
}
@@ -1044,7 +1051,7 @@ func TestLLMRetriesOn5xxThenSucceeds(t *testing.T) {
cfg.Endpoint = srv.URL
cfg.Stream = false
cfg.APIKey = "-"
m, err := llm(context.Background(), &cfg, []Message{{Role: "user", Content: strp("hi")}}, nil)
m, _, err := llm(context.Background(), &cfg, []Message{{Role: "user", Content: strp("hi")}}, nil)
if err != nil {
t.Fatalf("llm after retries: %v", err)
}
@@ -1066,7 +1073,7 @@ func TestLLMEmptyChoices(t *testing.T) {
cfg.Endpoint = srv.URL
cfg.Stream = false
cfg.APIKey = "-"
if _, err := llm(context.Background(), &cfg, []Message{{Role: "user", Content: strp("hi")}}, nil); err == nil || !strings.Contains(err.Error(), "empty choices") {
if _, _, err := llm(context.Background(), &cfg, []Message{{Role: "user", Content: strp("hi")}}, nil); err == nil || !strings.Contains(err.Error(), "empty choices") {
t.Errorf("expected empty choices error, got %v", err)
}
}
@@ -1093,7 +1100,7 @@ func TestALToolLoop(t *testing.T) {
cfg.Endpoint = srv.URL
cfg.Stream = false
cfg.APIKey = "-"
msgs, err := AL(context.Background(), &cfg, []Message{{Role: "system", Content: strp("sys")}, {Role: "user", Content: strp("run")}}, "sys", 0)
msgs, _, err := AL(context.Background(), &cfg, []Message{{Role: "system", Content: strp("sys")}, {Role: "user", Content: strp("run")}}, "sys", 0)
if err != nil {
t.Fatalf("AL: %v", err)
}
@@ -1117,6 +1124,49 @@ func TestALToolLoop(t *testing.T) {
}
}
func TestALReasoningOnlyAutoContinue(t *testing.T) {
// First response is reasoning-only (no content, no tool calls); the agent
// must auto-append a "continue" user message and keep looping until a real
// answer arrives.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req struct {
Messages []Message `json:"messages"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Errorf("decode request: %v", err)
}
for _, m := range req.Messages {
if m.Role == "user" && m.Content != nil && strings.TrimSpace(*m.Content) == "continue" {
w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"final answer"}}]}`))
return
}
}
w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":"thinking hard"}}]}`))
}))
defer srv.Close()
cfg := defCfg
cfg.Endpoint = srv.URL
cfg.Stream = false
cfg.APIKey = "-"
msgs, _, err := AL(context.Background(), &cfg, []Message{{Role: "system", Content: strp("sys")}, {Role: "user", Content: strp("start")}}, "sys", 0)
if err != nil {
t.Fatalf("AL: %v", err)
}
if got := last(msgs); got != "final answer" {
t.Errorf("last = %q, want %q", got, "final answer")
}
var continues int
for _, m := range msgs {
if m.Role == "user" && m.Content != nil && strings.TrimSpace(*m.Content) == "continue" {
continues++
}
}
if continues != 1 {
t.Errorf("expected exactly 1 auto continue message, got %d", continues)
}
}
func TestALRunSubagent(t *testing.T) {
var n int
var mu sync.Mutex
@@ -1155,7 +1205,7 @@ func TestALRunSubagent(t *testing.T) {
cfg.Endpoint = srv.URL
cfg.Stream = false
cfg.APIKey = "-"
msgs, err := AL(context.Background(), &cfg, []Message{{Role: "system", Content: strp("sys")}, {Role: "user", Content: strp("parent task")}}, "sys", 0)
msgs, _, err := AL(context.Background(), &cfg, []Message{{Role: "system", Content: strp("sys")}, {Role: "user", Content: strp("parent task")}}, "sys", 0)
if err != nil {
t.Fatalf("AL: %v", err)
}
@@ -1198,7 +1248,7 @@ func TestALSubagentDepthLimit(t *testing.T) {
cfg.Endpoint = srv.URL
cfg.Stream = false
cfg.APIKey = "-"
msgs, err := AL(context.Background(), &cfg, []Message{{Role: "user", Content: strp("go")}}, "sys", MAX_DEPTH)
msgs, _, err := AL(context.Background(), &cfg, []Message{{Role: "user", Content: strp("go")}}, "sys", MAX_DEPTH)
if err != nil {
t.Fatalf("AL: %v", err)
}
@@ -1245,7 +1295,7 @@ func TestALStripsInvalidAssistantAndRetries(t *testing.T) {
cfg.Endpoint = srv.URL
cfg.Stream = false
cfg.APIKey = "-"
msgs, err := AL(context.Background(), &cfg, []Message{{Role: "system", Content: strp("sys")}, {Role: "user", Content: strp("go")}}, "sys", 0)
msgs, _, err := AL(context.Background(), &cfg, []Message{{Role: "system", Content: strp("sys")}, {Role: "user", Content: strp("go")}}, "sys", 0)
if err != nil {
t.Fatalf("AL: %v", err)
}
@@ -1269,7 +1319,8 @@ func TestSummarizeHappyPath(t *testing.T) {
cfg.Endpoint = srv.URL
cfg.Stream = false
cfg.APIKey = "-"
s, err := summarize(context.Background(), &cfg, []Message{{Role: "user", Content: strp("hello world")}})
orig := []Message{{Role: "system", Content: strp("sys")}, {Role: "user", Content: strp("hello world")}}
s, err := summarize(context.Background(), &cfg, orig)
if err != nil {
t.Fatalf("summarize: %v", err)
}
@@ -1279,7 +1330,13 @@ func TestSummarizeHappyPath(t *testing.T) {
}
func TestCompactHappyPath(t *testing.T) {
var receivedMessages []Message
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)
receivedMessages = req.Messages
w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"the summary"}}]}`))
}))
defer srv.Close()
@@ -1302,6 +1359,19 @@ func TestCompactHappyPath(t *testing.T) {
if msgs[1].Role != "user" || msgs[1].Content == nil || !strings.Contains(*msgs[1].Content, "the summary") {
t.Errorf("continuation message = %+v", msgs[1])
}
// Verify request sent to LLM contains the original conversation prefix plus compaction prompt
if len(receivedMessages) != 3 {
t.Fatalf("expected 3 messages sent to LLM, got %d", len(receivedMessages))
}
if receivedMessages[0].Role != "system" || *receivedMessages[0].Content != "sys" {
t.Errorf("message 0 mismatch: %+v", receivedMessages[0])
}
if receivedMessages[1].Role != "user" || *receivedMessages[1].Content != "hello world" {
t.Errorf("message 1 mismatch: %+v", receivedMessages[1])
}
if receivedMessages[2].Role != "user" || !strings.Contains(*receivedMessages[2].Content, "compaction engine") {
t.Errorf("message 2 mismatch (expected compaction prompt): %+v", receivedMessages[2])
}
}
// ---------- setCfg and LLM parameter forwarding ----------
@@ -1352,7 +1422,7 @@ func TestLLMForwardsRelevantParameters(t *testing.T) {
}, "\n"))
cfg := getCfg(cfgFile)
_, err := llm(context.Background(), &cfg, []Message{{Role: "user", Content: strp("hi")}}, nil)
_, _, err := llm(context.Background(), &cfg, []Message{{Role: "user", Content: strp("hi")}}, nil)
if err != nil {
t.Fatalf("llm: %v", err)
}
@@ -1644,7 +1714,7 @@ func TestALContextCancellation(t *testing.T) {
origMsgs := []Message{{Role: "system", Content: strp("sys")}, {Role: "user", Content: strp("hello")}}
inputMsgs := append([]Message{}, origMsgs...)
msgs, err := AL(ctx, &cfg, inputMsgs, "sys", 0)
msgs, _, err := AL(ctx, &cfg, inputMsgs, "sys", 0)
if err == nil {
t.Fatalf("expected context cancellation error, got nil")
}
@@ -1682,7 +1752,7 @@ func TestLLMContextCancellation(t *testing.T) {
cancel()
}()
_, err := llm(ctx, &cfg, []Message{{Role: "user", Content: strp("hi")}}, nil)
_, _, err := llm(ctx, &cfg, []Message{{Role: "user", Content: strp("hi")}}, nil)
if err == nil {
t.Fatalf("expected context cancellation error, got nil")
}
@@ -1691,6 +1761,360 @@ func TestLLMContextCancellation(t *testing.T) {
}
}
func TestTokenCounterAndUsageNonStreaming(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{
"choices":[{"message":{"role":"assistant","content":"hello world"}}],
"usage":{
"prompt_tokens": 120,
"completion_tokens": 30,
"total_tokens": 150,
"prompt_tokens_details": {"cached_tokens": 80}
}
}`))
}))
defer srv.Close()
cfg := defCfg
cfg.Endpoint = srv.URL
cfg.Stream = false
cfg.APIKey = "-"
m, u, err := llm(context.Background(), &cfg, []Message{{Role: "user", Content: strp("hi")}}, nil)
if err != nil {
t.Fatalf("llm: %v", err)
}
if m.Content == nil || *m.Content != "hello world" {
t.Errorf("unexpected content: %+v", m.Content)
}
if u.PromptTokens != 120 || u.CompletionTokens != 30 || u.TotalTokens != 150 {
t.Errorf("unexpected usage: %+v", u)
}
if u.Cached() != 80 {
t.Errorf("expected cached tokens 80, got %d", u.Cached())
}
}
func TestTokenCounterAndUsageStreaming(t *testing.T) {
sseData := strings.Join([]string{
`data: {"choices":[{"delta":{"content":"streaming "}}]}`,
`data: {"choices":[{"delta":{"content":"response"}}]}`,
`data: {"choices":[],"usage":{"prompt_tokens":250,"completion_tokens":45,"total_tokens":295,"cached_tokens":100}}`,
`data: [DONE]`,
}, "\n")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Write([]byte(sseData))
}))
defer srv.Close()
cfg := defCfg
cfg.Endpoint = srv.URL
cfg.Stream = true
cfg.APIKey = "-"
m, u, err := llm(context.Background(), &cfg, []Message{{Role: "user", Content: strp("hi")}}, nil)
if err != nil {
t.Fatalf("llm streaming: %v", err)
}
if m.Content == nil || *m.Content != "streaming response" {
t.Errorf("unexpected content: %+v", m.Content)
}
if u.PromptTokens != 250 || u.CompletionTokens != 45 || u.TotalTokens != 295 {
t.Errorf("unexpected usage: %+v", u)
}
if u.Cached() != 100 {
t.Errorf("expected cached tokens 100, got %d", u.Cached())
}
}
func TestFormatUsage(t *testing.T) {
// With cached tokens
u1 := Usage{PromptTokens: 1000, CompletionTokens: 200, TotalTokens: 1200, CachedTokens: 800}
s1 := formatUsage(u1, 200000)
if s1 != "[tokens: 1000 prompt (800 cached, 200 uncached) + 200 completion | context: 1000/200000 (0.5%)]" {
t.Errorf("formatUsage u1 = %q", s1)
}
// Without cached tokens
u2 := Usage{PromptTokens: 120000, CompletionTokens: 500, TotalTokens: 120500}
s2 := formatUsage(u2, 200000)
if s2 != "[tokens: 120000 prompt + 500 completion | context: 120000/200000 (60.0%)]" {
t.Errorf("formatUsage u2 = %q", s2)
}
}
func TestFetchContextWindowFromModelsAPI(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/models" {
http.NotFound(w, r)
return
}
w.Write([]byte(`{
"data": [
{"id": "other-model", "context_window": 32000},
{"id": "my-target-model", "max_context_length": 131072}
]
}`))
}))
defer srv.Close()
cfg := defCfg
cfg.Endpoint = srv.URL
cfg.Model = "my-target-model"
cfg.APIKey = "-"
cw := fetchContextWindow(&cfg)
if cw != 131072 {
t.Errorf("expected context window 131072 from /models API, got %d", cw)
}
}
func TestFetchContextWindowFallbackConfig(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "server error", 500)
}))
defer srv.Close()
// Case 1: Config specifies context_window
cfg1 := defCfg
cfg1.Endpoint = srv.URL
cfg1.Raw = map[string]string{"context_window": "65536"}
if cw := fetchContextWindow(&cfg1); cw != 65536 {
t.Errorf("expected fallback to raw context_window 65536, got %d", cw)
}
// Case 2: Config does not specify context_window -> default 262144
cfg2 := defCfg
cfg2.Endpoint = srv.URL
cfg2.Raw = map[string]string{}
if cw := fetchContextWindow(&cfg2); cw != 262144 {
t.Errorf("expected default 262144, got %d", cw)
}
}
func TestFilterText(t *testing.T) {
cases := []struct {
name string
input string
expected string
}{
{
name: "ASCII printable, spaces, tabs, newlines",
input: "Hello World!\t123\nLine 2 ~`@#$%",
expected: "Hello World!\t123\nLine 2 ~`@#$%",
},
{
name: "CRLF normalization",
input: "line1\r\nline2\r\n",
expected: "line1\nline2\n",
},
{
name: "Unicode printable letters, numbers, punctuation",
input: "こんにちは世界! Привет мир! 123 αβγ €$¥",
expected: "こんにちは世界! Привет мир! 123 αβγ €$¥",
},
{
name: "Control characters stripped",
input: "null\x00bell\x07esc\x1b[31mred\x1b[0m\x7fdel",
expected: "nullbellesc[31mred[0mdel",
},
{
name: "Zero-width and format characters stripped",
input: "hidden\u200Binjection\u200Cand\u200Djoiner\uFEFFbom\u202Ebidi\U000E0001tag",
expected: "hiddeninjectionandjoinerbombiditag",
},
{
name: "Space-like unicode characters stripped",
input: "nbsp\u00A0space\u2000enquad\u2001emquad\u2009thin\u202Fnarrow\u3000ideo\u1680ogham\u2028lsep\u2029psep",
expected: "nbspspaceenquademquadthinnarrowideooghamlseppsep",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := filterText(tc.input)
if got != tc.expected {
t.Errorf("filterText(%q) = %q, expected %q", tc.input, got, tc.expected)
}
})
}
}
func TestSanitizeMessagesWithInvisibles(t *testing.T) {
msgs := []Message{
{
Role: "assistant",
ToolCalls: []ToolCall{
{
ID: "call_1",
Type: "function",
Function: struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}{
Name: "shell_exec",
Arguments: "{\"command\": \"cat\u200B \u00A0file.txt\"}",
},
},
},
},
{
Role: "tool",
ToolCallID: "call_1",
Content: strp("output\u200B\x00with\u00A0invisible\r\nexit: 0"),
},
}
sanitizeMessages(msgs)
tcArgs := msgs[0].ToolCalls[0].Function.Arguments
if strings.Contains(tcArgs, "\u200B") || strings.Contains(tcArgs, "\u00A0") {
t.Errorf("Tool call arguments still contain invisible characters: %q", tcArgs)
}
toolContent := *msgs[1].Content
if strings.Contains(toolContent, "\u200B") || strings.Contains(toolContent, "\x00") || strings.Contains(toolContent, "\u00A0") || strings.Contains(toolContent, "\r") {
t.Errorf("Tool content still contains invisible characters: %q", toolContent)
}
if !strings.Contains(toolContent, "outputwithinvisible\nexit: 0") {
t.Errorf("Tool content unexpected: %q", toolContent)
}
}
// ---------- new coverage from review ----------
// #14: subagent token usage must be accumulated into the parent turn usage.
func TestALRunSubagentAccumulatesUsage(t *testing.T) {
var n int
var mu sync.Mutex
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
n++
cur := n
mu.Unlock()
switch cur {
case 1:
w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"run_subagent","arguments":"{\"prompt\":\"inner\"}"}}]}}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15,"prompt_tokens_details":{"cached_tokens":7}}}`))
case 2:
w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"child done"}}],"usage":{"prompt_tokens":20,"completion_tokens":3,"total_tokens":23,"prompt_tokens_details":{"cached_tokens":12}}}`))
case 3:
w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"parent done"}}],"usage":{"prompt_tokens":30,"completion_tokens":4,"total_tokens":34,"prompt_tokens_details":{"cached_tokens":15}}}`))
default:
t.Errorf("unexpected request #%d", cur)
}
}))
defer srv.Close()
cfg := defCfg
cfg.Endpoint = srv.URL
cfg.Stream = false
cfg.APIKey = "-"
_, usg, err := AL(context.Background(), &cfg, []Message{{Role: "system", Content: strp("sys")}, {Role: "user", Content: strp("parent task")}}, "sys", 0)
if err != nil {
t.Fatalf("AL: %v", err)
}
// PromptTokens/Cached reflect the parent's own final context (30 / 15); they are
// overwritten per iteration, not accumulated. Completion/Total accumulate every
// assistant call: parent's tool-call call (5/15) + child (3/23) + parent final (4/34)
// => completion 12, total 72.
if usg.PromptTokens != 30 {
t.Errorf("PromptTokens = %d, want 30", usg.PromptTokens)
}
if usg.CompletionTokens != 12 {
t.Errorf("CompletionTokens = %d, want 12", usg.CompletionTokens)
}
if usg.TotalTokens != 72 {
t.Errorf("TotalTokens = %d, want 72", usg.TotalTokens)
}
if usg.Cached() != 15 {
t.Errorf("CachedTokens = %d, want 15", usg.Cached())
}
}
// #7: invalid-assistant detection must match common provider error variants.
func TestIsInvalidAssistantErrVariants(t *testing.T) {
cases := []struct {
in string
want bool
}{
{"HTTP 400: {\"error\":\"Invalid assistant message: content or tool_calls must be set\"}", true},
{"invalid assistant message: content or tool_calls must be set", true},
{"content or tool_calls must be set", true},
{"Assistant message content must be set", true},
{"tool_calls must be set", true},
{"rate limit exceeded", false},
{"model overloaded", false},
{"", false},
}
for _, c := range cases {
if got := isInvalidAssistantErr(errors.New(c.in)); got != c.want {
t.Errorf("isInvalidAssistantErr(%q) = %v, want %v", c.in, got, c.want)
}
}
if isInvalidAssistantErr(nil) {
t.Errorf("isInvalidAssistantErr(nil) = true, want false")
}
}
// #10: internalKey must reject all agent-internal params and allow forwarding extras.
func TestInternalKey(t *testing.T) {
internal := []string{"endpoint", "model", "temperature", "stream", "api_key", "timeout", "shell_timeout", "max_al_iterations", "color", "context_window"}
for _, k := range internal {
if !internalKey(k) {
t.Errorf("internalKey(%q) = false, want true", k)
}
}
extra := []string{"reasoning_effort", "top_p", "max_tokens", "stop", "frequency_penalty"}
for _, k := range extra {
if internalKey(k) {
t.Errorf("internalKey(%q) = true, want false", k)
}
}
}
// #17: the retry loop must make exactly len(fib)+1 attempts on persistent 5xx
// (initial attempt + one retry per Fibonacci delay) and no extra attempt.
func TestLLMRetryAttemptCount(t *testing.T) {
var n int
var mu sync.Mutex
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
n++
mu.Unlock()
w.WriteHeader(503)
w.Write([]byte(`{"error":"unavailable"}`))
}))
defer srv.Close()
cfg := defCfg
cfg.Endpoint = srv.URL
cfg.Stream = false
cfg.APIKey = "-"
_, _, err := llm(context.Background(), &cfg, []Message{{Role: "user", Content: strp("hi")}}, nil)
if err == nil {
t.Fatalf("expected error from persistent 5xx")
}
fib := []int{1, 1, 2, 3, 5, 8, 13, 21, 34}
want := len(fib) + 1
if n != want {
t.Errorf("attempts = %d, want %d", n, want)
}
}
// #9: filterText keeps only printable runs plus ASCII space/tab/newline.
func TestFilterTextPrintableOnly(t *testing.T) {
in := "ok\t\n" + "a" + "\x00" + "\u200B" + "\u00A0" + "\r" + "b"
got := filterText(in)
if strings.ContainsAny(got, "\x00\r") || strings.Contains(got, "\u200B") || strings.Contains(got, "\u00A0") {
t.Errorf("filterText left control/invisible chars: %q", got)
}
if got != "ok\t\nab" {
t.Errorf("filterText = %q, want %q", got, "ok\t\nab")
}
}
+40 -66
View File
@@ -1,118 +1,92 @@
#!/usr/bin/env perl
# MicroBantam (mb): the Bantam agent in <100 SLOC - readable, core modules only
# Created by Luxferre in 2026, released into the public domain
use strict; use warnings; use HTTP::Tiny; use JSON::PP; use POSIX qw(strftime); use File::Path qw(make_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- run_subagent: delegate a sub-task to a child agent; returns its reply.\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. Delegate large or independent sub-tasks to run_subagent. Stop as soon as the goal is met and report concisely: results, not process.\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 braces 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, GEMINI.md, CLAUDE.md contents in the project.";
my $SDIR = ($ENV{HOME} || $ENV{USERPROFILE} || '.') . '/.bantam/sessions';
sub cfg { my %d = (endpoint=>'https://opencode.ai/zen/v1', model=>'big-pickle', temperature=>0.7, api_key=>'-', timeout=>300, shell_timeout=>120, max_al_iterations=>1000);
if (open my $f, '<:encoding(UTF-8)', 'model.cfg') { while (<$f>) { /^(\w+)\s*=\s*(.+)$/ and $d{$1} = $2; } }
$d{api_key} = $ENV{OPENAI_API_KEY} if $d{api_key} eq q{-} && $ENV{OPENAI_API_KEY};
\%d; }
sub sp { my $p = '';
if (open my $f, '<:encoding(UTF-8)', 'system.txt') { local $/; $p = <$f>; }
$p =~ s/^\s+|\s+$//g;
length($p) ? $p : $DEF_SP; }
if (open my $f, '<:encoding(UTF-8)', 'model.cfg') { while (<$f>) { /^([^\s=]+)\s*=\s*(.+)$/ and $d{$1} = $2; } }
$d{api_key} = $ENV{OPENAI_API_KEY} if $d{api_key} eq '-' && $ENV{OPENAI_API_KEY}; \%d }
sub sp { my $p = ''; if (open my $f, '<:encoding(UTF-8)', 'system.txt') { local $/; $p = <$f>; } $p =~ s/^\s+|\s+$//g; length($p) ? $p : $DEF_SP }
sub filter_text { my $s = shift // ''; $s =~ s/[^\x20\t\n\p{L}\p{N}\p{P}\p{S}\p{M}\p{Zs}]//g; $s }
sub T { my ($n, $d, $p) = @_; {type=>'function', function=>{name=>$n, description=>$d, parameters=>{type=>'object', properties=>$p, required=>[keys %$p]}}} }
sub sanitize_msgs { for my $m (@{$_[0]}) { if (ref $m eq 'HASH' && $m->{tool_calls}) { for my $tc (@{$m->{tool_calls}}) { my $a = eval { decode_json($tc->{function}{arguments} // '{}') }; $tc->{function}{arguments} = encode_json({invalid_raw => $tc->{function}{arguments} // ''}) if ref $a ne 'HASH'; } } } }
sub llm { my ($c, $msgs) = @_; # one non-streaming chat completion
sanitize_msgs($msgs);
sub sanitize_msgs { for my $m (@{$_[0]}) { if (ref $m eq 'HASH') {
for my $tc (@{$m->{tool_calls} // []}) { $tc->{function}{arguments} = filter_text($tc->{function}{arguments});
my $a = eval { decode_json($tc->{function}{arguments} // '{}') };
$tc->{function}{arguments} = encode_json({invalid_raw => $tc->{function}{arguments} // ''}) if ref $a ne 'HASH'; }
$m->{content} = filter_text($m->{content}) if ($m->{role} // '') eq 'tool' && defined $m->{content};
} } }
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)'};
$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('run_subagent', 'Run a child agent with a prompt.', {prompt=>{type=>'string'}})], 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 $body = encode_json(\%p); 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=>$body});
print "\r\e[K" if $tty; # clear the spinner line
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)});
print "\r\e[K" if $tty;
my $d = $r->{success} ? eval { decode_json($r->{content}) } : undef;
return $d->{choices}[0]{message} if $d && $d->{choices} && @{$d->{choices}};
my $rb = $r->{content} // '';
$rb = substr($rb, 0, 500) if length($rb) > 500;
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) = @_; # run a command under a hard timeout
my $out = '';
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; };
$out =~ s/\s+$//;
utf8::decode($out);
$out =~ s/\s+$//; utf8::decode($out); $out = filter_text($out);
$@ ? "$out\n[timeout after ${t}s]\nexit: -1" : "$out\nexit: " . ($? >> 8); }
sub last_assistant { for my $m (reverse @{$_[0]}) { return $m->{content} if $m->{role} eq 'assistant' && defined $m->{content} && length $m->{content}; } '' }
sub AL { my ($c, $msgs, $sp, $depth) = @_; # the agentic loop: LLM <-> tools until done
$depth ||= 0;
sub last_assistant { for my $m (reverse @{$_[0]}) { return $m->{content} if ($m->{role} // '') eq 'assistant' && defined $m->{content} && length $m->{content}; } '' }
sub AL { my ($c, $msgs, $sp, $depth) = ($_[0], $_[1], $_[2], $_[3] || 0);
for (1 .. $c->{max_al_iterations}) {
my $m = eval { llm($c, $msgs) };
if ($@ && $@ =~ /Invalid assistant message|content or tool_calls must be set/ && grep({ $_->{role} eq 'assistant' } @$msgs)) {
for (my $j = @$msgs - 1; $j >= 0; $j--) { if ($msgs->[$j]{role} eq 'assistant') { splice @$msgs, $j, 1; last; } }
if ($@ && $@ =~ /Invalid assistant message|content or tool_calls must be set/ && grep({ ($_->{role} // '') eq 'assistant' } @$msgs)) {
for (my $j = @$msgs - 1; $j >= 0; $j--) { if (($msgs->[$j]{role} // '') eq 'assistant') { splice @$msgs, $j, 1; last; } }
print "[stripped malformed assistant message]\n"; redo;
}
if ($@) { print $@; return $msgs; }
push @$msgs, $m;
print $m->{content}, "\n" if defined $m->{content} && length $m->{content};
my $tcs = $m->{tool_calls};
last unless $tcs && @$tcs;
my $tcs = $m->{tool_calls}; last unless $tcs && @$tcs;
for my $tc (@$tcs) {
my $fn = $tc->{function}{name};
my ($fn, $res) = ($tc->{function}{name});
$tc->{function}{arguments} = filter_text($tc->{function}{arguments});
my $a = eval { decode_json($tc->{function}{arguments} // '{}') };
my $res;
if (ref $a ne 'HASH') { $tc->{function}{arguments} = encode_json({invalid_raw => $tc->{function}{arguments} // ''}); $res = "bad JSON args for $fn: " . ($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 'run_subagent') { $res = $depth >= 5 ? '[subagent depth limit (5) reached, child not spawned]' : last_assistant(AL($c, [{role=>'system', content=>"$sp\n\nImportant: this is a child agent"}, {role=>'user', content=>$a->{prompt} // ''}], $sp, $depth + 1)); }
elsif ($fn eq 'run_subagent') { $res = $depth >= 5 ? '[subagent depth limit (5) reached, child not spawned]' : filter_text(last_assistant(AL($c, [{role=>'system', content=>"$sp\n\nImportant: this is a child agent"}, {role=>'user', content=>filter_text($a->{prompt} // '')}], $sp, $depth + 1))); }
else { $res = "unknown tool: $fn"; }
$res = filter_text($res);
print "[tool] $fn: $res\n";
push @$msgs, {role=>'tool', tool_call_id=>$tc->{id}, content=>$res};
}
}
$msgs; }
sub sessions { my @s; # all saved sessions, newest first
for my $f (glob "$SDIR/*.json") { open my $fh, '<', $f or next; local $/; my $d = eval { decode_json(<$fh>) }; push @s, $d if $d; }
sort { $b->{id} cmp $a->{id} } @s; }
sub sessions { my @s; for my $f (glob "$SDIR/*.json") { open my $fh, '<', $f or next; local $/; my $d = eval { decode_json(<$fh>) }; push @s, $d if $d; } sort { $b->{id} cmp $a->{id} } @s }
sub sdir { make_path($SDIR) unless -d $SDIR; $SDIR }
sub save { sdir(); my $id = strftime('%Y%m%d-%H%M%S', localtime); my $i = 0;
$id .= '-' . ++$i while -f "$SDIR/$id.json";
open my $f, '>', "$SDIR/$id.json" or die "cannot save: $!";
print $f JSON::PP->new->utf8->pretty->encode({id=>$id, messages=>$_[0]}); close $f; $id; }
sub load { my ($want) = @_; my ($hit) = grep { $_->{id} eq $want } sessions(); die "no session: $want\n" unless $hit; $hit->{messages}; }
sub save { sdir(); my ($id, $i) = (strftime('%Y%m%d-%H%M%S', localtime), 0); $id .= '-' . ++$i while -f "$SDIR/$id.json";
open my $f, '>', "$SDIR/$id.json" or die "cannot save: $!"; print $f JSON::PP->new->utf8->pretty->encode({id=>$id, messages=>$_[0]}); close $f; $id }
sub load { my ($hit) = grep { $_->{id} eq $_[0] } sessions(); die "no session: $_[0]\n" unless $hit; $hit->{messages} }
sub autosave { sdir(); open my $f, '>', "$SDIR/autosave.json" or return; print $f JSON::PP->new->utf8->pretty->encode({id=>'autosave', messages=>$_[0]}); }
sub list_sessions { map { [$_->{id}, scalar @{$_->{messages} // []}] } sessions() }
sub set_cfg { my ($k, $v) = @_; my (@ls, $f);
if (open my $fh, '<:encoding(UTF-8)', 'model.cfg') { while (<$fh>) { if (!/^#/ && /^(\w+)\s*=/ && $1 eq $k) { push @ls, "$k=$v\n"; $f = 1; } else { push @ls, $_; } } }
sub set_cfg { my ($k, $v, @ls, $f) = @_;
if (open my $fh, '<:encoding(UTF-8)', 'model.cfg') { while (<$fh>) { push @ls, (!/^#/ && /^([^\s=]+)\s*=/ && $1 eq $k) ? ($f = 1, "$k=$v\n") : $_; } }
push @ls, "$k=$v\n" unless $f;
if (open my $fh, '>:encoding(UTF-8)', 'model.cfg') { print $fh @ls; close $fh; } }
sub main {
my ($c, $sp) = (cfg(), sp());
my $msgs = [{role=>'system', content=>$sp}];
if (@ARGV) { open my $f, '<:encoding(UTF-8)', $ARGV[0] or die "cannot open $ARGV[0]: $!"; # file mode
local $/; push @$msgs, {role=>'user', content=><$f>}; AL($c, $msgs, $sp); autosave($msgs); return; }
my ($c, $sp) = (cfg(), sp()); my $msgs = [{role=>'system', content=>$sp}];
if (@ARGV) { open my $f, '<:encoding(UTF-8)', $ARGV[0] or die "cannot open $ARGV[0]: $!"; local $/; push @$msgs, {role=>'user', content=><$f>}; AL($c, $msgs, $sp); autosave($msgs); return; }
print "MicroBantam ready ($c->{model}). Commands: /quit /clear /save /list /load <id> /cfg <k> [v] /help\n";
while (1) {
print "> "; my $u = <STDIN>; last unless defined $u;
$u =~ s/^\s+|\s+$//g; next unless length $u;
if ($u eq '/quit') { last; }
print "> "; my $u = <STDIN>; last unless defined $u; $u =~ s/^\s+|\s+$//g; next unless length $u;
if ($u eq '/quit') { last; }
elsif ($u eq '/clear') { $msgs = [{role=>'system', content=>$sp}]; autosave($msgs); }
elsif ($u eq '/save') { print "session saved: ", save($msgs), "\n"; }
elsif ($u eq '/list') { print "$_->[0] [$_->[1] msgs]\n" for list_sessions(); }
elsif ($u eq '/save') { print "session saved: ", save($msgs), "\n"; }
elsif ($u eq '/list') { print "$_->[0] [$_->[1] msgs]\n" for list_sessions(); }
elsif ($u =~ /^\/load(?:\s+(\S+))?$/) { if (defined $1) { $msgs = eval { load($1) }; $@ ? print($@) : (autosave($msgs), print "loaded: $1\n"); } else { print "usage: /load <session id>\n"; } }
elsif ($u =~ /^\/cfg(?:\s+(\S+)(?:\s+(.+))?)?$/) { if (defined $2) { set_cfg($1, $2); $c = cfg(); print "config: $1=$2\n"; } elsif (defined $1) { print exists $c->{$1} ? "$1=$c->{$1}\n" : "$1 not set\n"; } else { print "usage: /cfg <param> [val]\n"; } }
elsif ($u eq '/help') { print "Commands: /quit /clear /save /list /load <id> /cfg <k> [v] /help\n"; }
elsif ($u eq '/help') { print "Commands: /quit /clear /save /list /load <id> /cfg <k> [v] /help\n"; }
else { push @$msgs, {role=>'user', content=>$u}; AL($c, $msgs, $sp); autosave($msgs); }
}
autosave($msgs);
}
main() unless caller();
+1 -1
View File
@@ -1,5 +1,5 @@
endpoint=https://opencode.ai/zen/v1
model=big-pickle
model=hy3-free
temperature=0.7
api_key=-
stream=true
+20 -2
View File
@@ -2,9 +2,27 @@
package main
import "fmt"
import (
"fmt"
"os"
"os/exec"
"strconv"
"strings"
)
func termWidth() int { return 80 }
func termWidth() int {
if w := os.Getenv("COLUMNS"); w != "" {
if n, err := strconv.Atoi(strings.TrimSpace(w)); err == nil && n > 0 {
return n
}
}
if out, err := exec.Command("tput", "cols").Output(); err == nil {
if n, err := strconv.Atoi(strings.TrimSpace(string(out))); err == nil && n > 0 {
return n
}
}
return 80
}
func isTerminal(fd int) bool { return false }
+20 -2
View File
@@ -2,9 +2,27 @@
package main
import "errors"
import (
"errors"
"os"
"os/exec"
"strconv"
"strings"
)
func termWidth() int { return 80 }
func termWidth() int {
if w := os.Getenv("COLUMNS"); w != "" {
if n, err := strconv.Atoi(strings.TrimSpace(w)); err == nil && n > 0 {
return n
}
}
if out, err := exec.Command("tput", "cols").Output(); err == nil {
if n, err := strconv.Atoi(strings.TrimSpace(string(out))); err == nil && n > 0 {
return n
}
}
return 80
}
func isTerminal(fd int) bool { return false }