Compare commits
9
Commits
3c55ce14d7
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b26d8f485 | ||
|
|
a34ee1cf3c | ||
|
|
4a936e850d | ||
|
|
b8686c82ab | ||
|
|
e9dd44d97b | ||
|
|
b298c53406 | ||
|
|
dea6650b98 | ||
|
|
b84f98a2cc | ||
|
|
d1597444d0 |
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## About
|
## 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:
|
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
|
temperature=0.7
|
||||||
api_key=your_api_key_here
|
api_key=your_api_key_here
|
||||||
stream=true
|
stream=true
|
||||||
|
context_window=200000
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Interactive mode:
|
2. Interactive mode:
|
||||||
@@ -52,19 +53,31 @@ All implementations read the same `model.cfg` and `system.txt` from the current
|
|||||||
./mb # MicroBantam (Perl 5)
|
./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:
|
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
|
- `/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
|
- `/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
|
- `/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
|
- `/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)
|
- `!<cmd>` — execute a shell command directly through `shell_exec` without adding the result to the conversation context (Go port)
|
||||||
- `/help` — show all supported commands
|
- `/help` — show all supported commands
|
||||||
- `/clear` — reset the conversation to just the system prompt
|
- `/clear` — reset the conversation to just the system prompt
|
||||||
- `/quit` — exit
|
- `/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.
|
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:
|
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
|
### 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}`.
|
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), append `{"role": "user", "content": prompt}`, and invoke `AL(cfg, messages)`.
|
2. **Input Processing**: Take user prompt (via command-line file parameter or interactive stdin). If prefixed with `!`, execute the command directly via `shell_exec` without appending to conversation context. Otherwise, append `{"role": "user", "content": prompt}`, and invoke `AL(cfg, messages)`.
|
||||||
3. **Agentic Loop (`AL`)**:
|
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`).
|
- 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.
|
- 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. 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.
|
- 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.
|
- 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
|
### Main program
|
||||||
|
|
||||||
1. Read system prompt from `system.txt` (default if missing).
|
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"`).
|
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.
|
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, 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`.
|
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)`, and go to step 5.
|
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
|
### 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.
|
- 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`).
|
- 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.
|
- 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.
|
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:
|
3. If there are pending `tool_calls` in the assistant response:
|
||||||
- For each tool call, output a trace log (`[tool call: name(args)]`).
|
- 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`).
|
- 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]`).
|
- Output a trace log of the result (`[tool result: name]`).
|
||||||
- Append tool result message (`role: "tool"`, `tool_call_id`, `content`: result string) to `messages`.
|
- Append tool result message (`role: "tool"`, `tool_call_id`, `content`: result string) to `messages`.
|
||||||
- Loop back to step 1.
|
- 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.
|
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)
|
- `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)
|
- `shell_timeout` (timeout in seconds for `shell_exec` commands, default 120)
|
||||||
- `max_al_iterations` (max tool-call loop iterations per `AL()` invocation, default 1000)
|
- `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.
|
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
|
### 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
|
- 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
|
- 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
|
- 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
|
- 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
|
- 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
|
### 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
|
- `model.cfg`, `system.txt` — shared configuration and system prompt
|
||||||
- `README.md` — this document
|
- `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
|
## FAQ
|
||||||
|
|
||||||
### Does Bantam support `AGENTS.md` etc?
|
### Does Bantam support `AGENTS.md` etc?
|
||||||
|
|||||||
Executable
+199
@@ -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
@@ -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
|
||||||
Executable
+106
@@ -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
|
||||||
@@ -19,9 +19,11 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
|
"sync"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -44,10 +46,28 @@ type Cfg struct {
|
|||||||
MaxALIterations int
|
MaxALIterations int
|
||||||
Stream bool
|
Stream bool
|
||||||
Color string
|
Color string
|
||||||
|
ContextWindow int
|
||||||
Raw map[string]string
|
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 {
|
func atoiD(s string, d int) int {
|
||||||
if v, e := strconv.Atoi(strings.TrimSpace(s)); e == nil {
|
if v, e := strconv.Atoi(strings.TrimSpace(s)); e == nil {
|
||||||
@@ -56,6 +76,66 @@ func atoiD(s string, d int) int {
|
|||||||
return d
|
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 {
|
func getCfg(path string) Cfg {
|
||||||
cfg := defCfg
|
cfg := defCfg
|
||||||
cfg.Raw = map[string]string{
|
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,
|
"api_key": cfg.APIKey, "stream": strconv.FormatBool(cfg.Stream), "color": cfg.Color,
|
||||||
"timeout": strconv.Itoa(cfg.Timeout), "shell_timeout": strconv.Itoa(cfg.ShellTimeout),
|
"timeout": strconv.Itoa(cfg.Timeout), "shell_timeout": strconv.Itoa(cfg.ShellTimeout),
|
||||||
"max_al_iterations": strconv.Itoa(cfg.MaxALIterations),
|
"max_al_iterations": strconv.Itoa(cfg.MaxALIterations),
|
||||||
|
"context_window": strconv.Itoa(cfg.ContextWindow),
|
||||||
}
|
}
|
||||||
if d, err := os.ReadFile(path); err == nil {
|
if d, err := os.ReadFile(path); err == nil {
|
||||||
for _, ln := range strings.Split(string(d), "\n") {
|
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 "max_al_iterations": cfg.MaxALIterations = atoiD(v, cfg.MaxALIterations)
|
||||||
case "stream": cfg.Stream = v == "true" || v == "1" || v == "yes"
|
case "stream": cfg.Stream = v == "true" || v == "1" || v == "yes"
|
||||||
case "color": cfg.Color = v
|
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 }
|
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 {
|
type streamDelta struct {
|
||||||
Choices []struct {
|
Choices []struct {
|
||||||
Delta struct {
|
Delta struct {
|
||||||
ReasoningContent string `json:"reasoning_content"`
|
ReasoningContent string `json:"reasoning_content"`
|
||||||
Reasoning string `json:"reasoning"`
|
Reasoning string `json:"reasoning"`
|
||||||
|
Thought string `json:"thought"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
ToolCalls []struct {
|
ToolCalls []struct {
|
||||||
Index int `json:"index"`
|
Index int `json:"index"`
|
||||||
@@ -606,6 +737,28 @@ type streamDelta struct {
|
|||||||
} `json:"tool_calls"`
|
} `json:"tool_calls"`
|
||||||
} `json:"delta"`
|
} `json:"delta"`
|
||||||
} `json:"choices"`
|
} `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) {
|
func sanitizeMessages(msgs []Message) {
|
||||||
@@ -613,6 +766,7 @@ func sanitizeMessages(msgs []Message) {
|
|||||||
if msgs[i].Role == "assistant" && len(msgs[i].ToolCalls) > 0 {
|
if msgs[i].Role == "assistant" && len(msgs[i].ToolCalls) > 0 {
|
||||||
for j := range msgs[i].ToolCalls {
|
for j := range msgs[i].ToolCalls {
|
||||||
tc := &msgs[i].ToolCalls[j]
|
tc := &msgs[i].ToolCalls[j]
|
||||||
|
tc.Function.Arguments = filterText(tc.Function.Arguments)
|
||||||
astr := tc.Function.Arguments
|
astr := tc.Function.Arguments
|
||||||
var a map[string]any
|
var a map[string]any
|
||||||
if err := json.Unmarshal([]byte(astr), &a); err != nil || a == nil {
|
if err := json.Unmarshal([]byte(astr), &a); err != nil || a == nil {
|
||||||
@@ -620,25 +774,33 @@ func sanitizeMessages(msgs []Message) {
|
|||||||
tc.Function.Arguments = string(fixed)
|
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 {
|
func isInvalidAssistantErr(err error) bool {
|
||||||
s := err.Error()
|
if err == nil { return false }
|
||||||
return strings.Contains(s, "Invalid assistant message") || strings.Contains(s, "content or tool_calls must be set")
|
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) {
|
func llm(ctx context.Context, cfg *Cfg, msgs []Message, tools []map[string]any) (Message, Usage, error) {
|
||||||
if err := ctx.Err(); err != nil { return Message{}, err }
|
if err := ctx.Err(); err != nil { return Message{}, Usage{}, err }
|
||||||
sanitizeMessages(msgs)
|
cleanMsgs := cleanMessagesForLLM(msgs)
|
||||||
p := map[string]any{"model": cfg.Model, "temperature": cfg.Temperature, "messages": msgs, "stream": cfg.Stream}
|
sanitizeMessages(cleanMsgs)
|
||||||
|
p := map[string]any{"model": cfg.Model, "temperature": cfg.Temperature, "messages": cleanMsgs, "stream": cfg.Stream}
|
||||||
if tools != nil { p["tools"] = tools }
|
if tools != nil { p["tools"] = tools }
|
||||||
|
if cfg.Stream { p["stream_options"] = map[string]any{"include_usage": true} }
|
||||||
for k, v := range cfg.Raw {
|
for k, v := range cfg.Raw {
|
||||||
switch k {
|
if internalKey(k) {
|
||||||
case "endpoint", "api_key", "timeout", "shell_timeout", "max_al_iterations", "color":
|
|
||||||
continue
|
continue
|
||||||
default:
|
}
|
||||||
|
{
|
||||||
var jv any
|
var jv any
|
||||||
if err := json.Unmarshal([]byte(v), &jv); err == nil {
|
if err := json.Unmarshal([]byte(v), &jv); err == nil {
|
||||||
p[k] = jv
|
p[k] = jv
|
||||||
@@ -648,11 +810,8 @@ func llm(ctx context.Context, cfg *Cfg, msgs []Message, tools []map[string]any)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(p)
|
body, _ := json.Marshal(p)
|
||||||
client := &http.Client{Transport: &http.Transport{
|
llmTransport.ResponseHeaderTimeout = time.Duration(cfg.Timeout) * time.Second
|
||||||
DialContext: (&net.Dialer{Timeout: time.Duration(cfg.Timeout) * time.Second}).DialContext,
|
client := &http.Client{Transport: llmTransport}
|
||||||
ResponseHeaderTimeout: time.Duration(cfg.Timeout) * time.Second,
|
|
||||||
}}
|
|
||||||
defer client.CloseIdleConnections()
|
|
||||||
fib := []int{1, 1, 2, 3, 5, 8, 13, 21, 34}
|
fib := []int{1, 1, 2, 3, 5, 8, 13, 21, 34}
|
||||||
pend := c("...requesting...", 1, 2)
|
pend := c("...requesting...", 1, 2)
|
||||||
var resp *http.Response
|
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++ {
|
for i := 0; i <= len(fib); i++ {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
if COL { fmt.Print("\r\033[K") }
|
if COL { fmt.Print("\r\033[K") }
|
||||||
return Message{}, err
|
return Message{}, Usage{}, err
|
||||||
}
|
}
|
||||||
if COL { fmt.Print("\r" + pend) } else { fmt.Println(pend) }
|
if COL { fmt.Print("\r" + pend) } else { fmt.Println(pend) }
|
||||||
req, _ := http.NewRequestWithContext(ctx, "POST", strings.TrimRight(cfg.Endpoint, "/")+"/chat/completions", bytes.NewReader(body))
|
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 err == nil { break }
|
||||||
if COL { fmt.Print("\r\033[K") }
|
if COL { fmt.Print("\r\033[K") }
|
||||||
if errors.Is(err, context.Canceled) || ctx.Err() != nil {
|
if errors.Is(err, context.Canceled) || ctx.Err() != nil {
|
||||||
return Message{}, ctx.Err()
|
return Message{}, Usage{}, ctx.Err()
|
||||||
}
|
}
|
||||||
if is4xxClientErr {
|
if is4xxClientErr {
|
||||||
return Message{}, err
|
return Message{}, Usage{}, err
|
||||||
}
|
}
|
||||||
if i < len(fib) {
|
if i < len(fib) {
|
||||||
fmt.Println(c(fmt.Sprintf("[network error: %v, retrying in %ds...]", err, fib[i]), 31))
|
fmt.Println(c(fmt.Sprintf("[network error: %v, retrying in %ds...]", err, fib[i]), 31))
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return Message{}, ctx.Err()
|
return Message{}, Usage{}, ctx.Err()
|
||||||
case <-time.After(time.Duration(fib[i]) * time.Second):
|
case <-time.After(time.Duration(fib[i]) * time.Second):
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if COL { fmt.Print("\r\033[K") }
|
if COL { fmt.Print("\r\033[K") }
|
||||||
return Message{}, err
|
return Message{}, Usage{}, err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if COL { fmt.Print("\r\033[K") }
|
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 struct {
|
||||||
Message
|
Message
|
||||||
Reasoning string `json:"reasoning"`
|
Reasoning string `json:"reasoning"`
|
||||||
|
Thought string `json:"thought"`
|
||||||
} `json:"message"`
|
} `json:"message"`
|
||||||
} `json:"choices"`
|
} `json:"choices"`
|
||||||
|
Usage Usage `json:"usage"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&cr); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&cr); err != nil {
|
||||||
if errors.Is(err, context.Canceled) || ctx.Err() != nil { return Message{}, ctx.Err() }
|
if errors.Is(err, context.Canceled) || ctx.Err() != nil { return Message{}, Usage{}, ctx.Err() }
|
||||||
return Message{}, 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
|
m := cr.Choices[0].Message.Message
|
||||||
if m.ReasoningContent == "" { m.ReasoningContent = cr.Choices[0].Message.Reasoning }
|
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 parseStream(ctx, resp.Body)
|
return m, u, nil
|
||||||
|
}
|
||||||
|
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 content, reas string
|
||||||
var rh, ch bool
|
var inReasoning bool
|
||||||
var lineBuf string
|
var lineBuf string
|
||||||
var mdSt mdState
|
var mdSt mdState
|
||||||
var tblBuf []string
|
var tblBuf []string
|
||||||
|
var lastUsage Usage
|
||||||
tcs := map[int]*ToolCall{}
|
tcs := map[int]*ToolCall{}
|
||||||
var order []int
|
var order []int
|
||||||
|
|
||||||
@@ -741,24 +916,39 @@ func parseStream(ctx context.Context, r io.Reader) (Message, error) {
|
|||||||
sc := bufio.NewScanner(r)
|
sc := bufio.NewScanner(r)
|
||||||
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||||
for sc.Scan() {
|
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())
|
ln := strings.TrimSpace(sc.Text())
|
||||||
if !strings.HasPrefix(ln, "data:") { continue }
|
if !strings.HasPrefix(ln, "data:") { continue }
|
||||||
data := strings.TrimSpace(ln[5:])
|
data := strings.TrimSpace(ln[5:])
|
||||||
if data == "[DONE]" { break }
|
if data == "[DONE]" { break }
|
||||||
var d streamDelta
|
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
|
dl := d.Choices[0].Delta
|
||||||
rc := dl.ReasoningContent
|
rc := dl.ReasoningContent
|
||||||
if rc == "" { rc = dl.Reasoning }
|
if rc == "" { rc = dl.Reasoning }
|
||||||
|
if rc == "" { rc = dl.Thought }
|
||||||
if rc != "" {
|
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))
|
fmt.Print(c(rc, 2))
|
||||||
reas += rc
|
reas += rc
|
||||||
}
|
}
|
||||||
if dl.Content != "" {
|
if dl.Content != "" {
|
||||||
if rh && !ch { fmt.Print("\n" + c("--- reasoning end ---", 36) + "\n\n") }
|
if inReasoning {
|
||||||
ch = true
|
fmt.Print("\n" + c("--- reasoning end ---", 36) + "\n\n")
|
||||||
|
inReasoning = false
|
||||||
|
}
|
||||||
content += dl.Content
|
content += dl.Content
|
||||||
lineBuf += dl.Content
|
lineBuf += dl.Content
|
||||||
for {
|
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 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()
|
flushTable()
|
||||||
if lineBuf != "" {
|
if lineBuf != "" {
|
||||||
if !mdSt.inCode && isTableLine(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))
|
fmt.Println(renderMDLine(lineBuf, &mdSt))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if rh && !ch {
|
if inReasoning {
|
||||||
fmt.Print("\n" + c("--- reasoning end ---", 36) + "\n")
|
fmt.Print("\n" + c("--- reasoning end ---", 36) + "\n")
|
||||||
}
|
}
|
||||||
m := Message{Role: "assistant"}
|
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))
|
m.ToolCalls = make([]ToolCall, 0, len(order))
|
||||||
for _, idx := range order { m.ToolCalls = append(m.ToolCalls, *tcs[idx]) }
|
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 {
|
func shell(ctx context.Context, cmd string, timeout int) string {
|
||||||
|
cmd = filterText(cmd)
|
||||||
cmdCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
|
cmdCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
c := exec.CommandContext(cmdCtx, "sh", "-c", cmd)
|
c := exec.CommandContext(cmdCtx, "sh", "-c", cmd)
|
||||||
c.WaitDelay = 100 * time.Millisecond
|
c.WaitDelay = 100 * time.Millisecond
|
||||||
out, err := c.CombinedOutput()
|
out, err := c.CombinedOutput()
|
||||||
res := strings.TrimSpace(string(out))
|
res := strings.TrimSpace(filterText(string(out)))
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return "[interrupted]\n\nexit: -1"
|
return "[interrupted]\n\nexit: -1"
|
||||||
}
|
}
|
||||||
@@ -848,14 +1042,15 @@ func last(msgs []Message) string {
|
|||||||
return ""
|
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
|
done := false
|
||||||
|
var turnUsage Usage
|
||||||
for i := 0; i < cfg.MaxALIterations && !done; i++ {
|
for i := 0; i < cfg.MaxALIterations && !done; i++ {
|
||||||
if err := ctx.Err(); err != nil { return msgs, err }
|
if err := ctx.Err(); err != nil { return msgs, turnUsage, err }
|
||||||
m, err := llm(ctx, cfg, msgs, TOOLS)
|
m, u, err := llm(ctx, cfg, msgs, TOOLS)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, context.Canceled) || ctx.Err() != nil {
|
if errors.Is(err, context.Canceled) || ctx.Err() != nil {
|
||||||
return msgs, err
|
return msgs, turnUsage, err
|
||||||
}
|
}
|
||||||
if isInvalidAssistantErr(err) {
|
if isInvalidAssistantErr(err) {
|
||||||
stripped := false
|
stripped := false
|
||||||
@@ -869,10 +1064,15 @@ func AL(ctx context.Context, cfg *Cfg, msgs []Message, sp string, depth int) ([]
|
|||||||
}
|
}
|
||||||
if stripped { continue }
|
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 {
|
for j := range m.ToolCalls {
|
||||||
tc := &m.ToolCalls[j]
|
tc := &m.ToolCalls[j]
|
||||||
|
tc.Function.Arguments = filterText(tc.Function.Arguments)
|
||||||
astr := tc.Function.Arguments
|
astr := tc.Function.Arguments
|
||||||
var a map[string]any
|
var a map[string]any
|
||||||
if err := json.Unmarshal([]byte(astr), &a); err != nil || a == nil {
|
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 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 {
|
for _, tc := range m.ToolCalls {
|
||||||
if err := ctx.Err(); err != nil { return msgs, err }
|
if err := ctx.Err(); err != nil { return msgs, turnUsage, err }
|
||||||
fn, astr := tc.Function.Name, tc.Function.Arguments
|
fn, astr := tc.Function.Name, filterText(tc.Function.Arguments)
|
||||||
fmt.Println(c(fmt.Sprintf("[tool call: %s(%s)]", fn, astr), 33))
|
fmt.Println(c(fmt.Sprintf("[tool call: %s(%s)]", fn, astr), 33))
|
||||||
res, sty := "", 2
|
res, sty := "", 2
|
||||||
var a map[string]any
|
var a map[string]any
|
||||||
@@ -900,10 +1110,12 @@ func AL(ctx context.Context, cfg *Cfg, msgs []Message, sp string, depth int) ([]
|
|||||||
switch fn {
|
switch fn {
|
||||||
case "shell_exec":
|
case "shell_exec":
|
||||||
cmd, _ := a["command"].(string)
|
cmd, _ := a["command"].(string)
|
||||||
|
cmd = filterText(cmd)
|
||||||
res = shell(ctx, cmd, cfg.ShellTimeout)
|
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":
|
case "run_subagent":
|
||||||
pr, _ := a["prompt"].(string)
|
pr, _ := a["prompt"].(string)
|
||||||
|
pr = filterText(pr)
|
||||||
if depth >= MAX_DEPTH {
|
if depth >= MAX_DEPTH {
|
||||||
res, sty = fmt.Sprintf("[subagent depth limit (%d) reached, child not spawned]", MAX_DEPTH), 31
|
res, sty = fmt.Sprintf("[subagent depth limit (%d) reached, child not spawned]", MAX_DEPTH), 31
|
||||||
} else {
|
} 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: "system", Content: strp(sp + "\n\nImportant: this is a child agent")},
|
||||||
{Role: "user", Content: strp(pr)},
|
{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 {
|
if errors.Is(err, context.Canceled) || ctx.Err() != nil {
|
||||||
return msgs, err
|
return msgs, turnUsage, err
|
||||||
}
|
}
|
||||||
res, sty = "[subagent error: "+err.Error()+"]", 31
|
res, sty = "[subagent error: "+err.Error()+"]", 31
|
||||||
} else {
|
} else {
|
||||||
res, sty = last(subr), 2
|
turnUsage.CompletionTokens += subu.CompletionTokens
|
||||||
|
turnUsage.TotalTokens += subu.TotalTokens
|
||||||
|
res, sty = filterText(last(subr)), 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
res, sty = "Unknown tool: " + fn, 31
|
res, sty = "Unknown tool: " + fn, 31
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
res = filterText(res)
|
||||||
fmt.Println(c("[tool result: "+fn+"]", 32) + "\n" + c(res, sty) + "\n")
|
fmt.Println(c("[tool result: "+fn+"]", 32) + "\n" + c(res, sty) + "\n")
|
||||||
msgs = append(msgs, Message{Role: "tool", ToolCallID: tc.ID, Content: strp(res)})
|
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 {
|
if !done {
|
||||||
msgs = append(msgs, Message{Role: "assistant", Content: strp(fmt.Sprintf("[max AL iterations (%d) reached]", cfg.MaxALIterations))})
|
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 {
|
func homeDir() string {
|
||||||
@@ -979,8 +1194,14 @@ func saveSession(msgs []Message) (string, string) {
|
|||||||
path = filepath.Join(d, sid+".json")
|
path = filepath.Join(d, sid+".json")
|
||||||
}
|
}
|
||||||
s := Session{sid, time.Now().Format("2006-01-02 15:04:05"), summary(msgs), msgs}
|
s := Session{sid, time.Now().Format("2006-01-02 15:04:05"), summary(msgs), msgs}
|
||||||
b, _ := json.MarshalIndent(s, "", " ")
|
b, err := json.MarshalIndent(s, "", " ")
|
||||||
os.WriteFile(path, b, 0644)
|
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
|
return sid, s.Summary
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1024,50 +1245,48 @@ func loadSession(sid string) ([]Message, error) {
|
|||||||
|
|
||||||
func autosave(msgs []Message) {
|
func autosave(msgs []Message) {
|
||||||
s := Session{"autosave", time.Now().Format("2006-01-02 15:04:05"), summary(msgs), msgs}
|
s := Session{"autosave", time.Now().Format("2006-01-02 15:04:05"), summary(msgs), msgs}
|
||||||
b, _ := json.MarshalIndent(s, "", " ")
|
b, err := json.MarshalIndent(s, "", " ")
|
||||||
os.WriteFile(filepath.Join(sdir(), "autosave.json"), b, 0644)
|
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) {
|
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."
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
func compact(ctx context.Context, cfg *Cfg, msgs []Message) ([]Message, string, error) {
|
func compact(ctx context.Context, cfg *Cfg, msgs []Message) ([]Message, string, error) {
|
||||||
if len(msgs) == 0 || msgs[0].Role != "system" {
|
if len(msgs) == 0 || msgs[0].Role != "system" {
|
||||||
return msgs, "", errors.New("session has no system message")
|
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 }
|
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() {
|
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() {
|
func main() {
|
||||||
sp := prompt("system.txt")
|
sp := prompt("system.txt")
|
||||||
cfg := getCfg("model.cfg")
|
cfg := getCfg("model.cfg")
|
||||||
|
cfg.ContextWindow = fetchContextWindow(&cfg)
|
||||||
COL = col(cfg)
|
COL = col(cfg)
|
||||||
stdin = bufio.NewReader(os.Stdin)
|
stdin = bufio.NewReader(os.Stdin)
|
||||||
msgs := []Message{{Role: "system", Content: strp(sp)}}
|
msgs := []Message{{Role: "system", Content: strp(sp)}}
|
||||||
@@ -1282,35 +1538,30 @@ func main() {
|
|||||||
}
|
}
|
||||||
u := strings.TrimSpace(string(data))
|
u := strings.TrimSpace(string(data))
|
||||||
if strings.HasPrefix(u, "!") {
|
if strings.HasPrefix(u, "!") {
|
||||||
cmd := strings.TrimSpace(strings.TrimPrefix(u, "!"))
|
runDirectShell(strings.TrimPrefix(u, "!"), cfg.ShellTimeout)
|
||||||
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))
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
msgs = append(msgs, Message{Role: "user", Content: strp(u)})
|
msgs = append(msgs, Message{Role: "user", Content: strp(u)})
|
||||||
sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
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()
|
cancel()
|
||||||
if err != nil {
|
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))
|
fmt.Println(c("\n[interrupted]", 33))
|
||||||
} else {
|
} else {
|
||||||
fmt.Println(c("[error: "+err.Error()+"]", 31))
|
fmt.Println(c("[error: "+err.Error()+"]", 31))
|
||||||
}
|
}
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
fmt.Println(c(formatUsage(usg, cfg.ContextWindow), 2))
|
||||||
autosave(msgs)
|
autosave(msgs)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
loadHistory()
|
loadHistory()
|
||||||
fmt.Println(c("Bantam Agent ready", 1, 32) + c(" (Ctrl+J = new line)", 2))
|
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 {
|
for {
|
||||||
u, ok := readLine(c("> ", 1, 36))
|
u, ok := readLine(c("> ", 1, 36))
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -1360,26 +1611,7 @@ func main() {
|
|||||||
fmt.Println(c("[session loaded: "+parts[1]+"]", 32) + " " + c(summary(msgs), 2))
|
fmt.Println(c("[session loaded: "+parts[1]+"]", 32) + " " + c(summary(msgs), 2))
|
||||||
continue
|
continue
|
||||||
case u == "/compact":
|
case u == "/compact":
|
||||||
if len(msgs) <= 1 {
|
msgs = doCompact(&cfg, msgs)
|
||||||
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))
|
|
||||||
continue
|
continue
|
||||||
case strings.HasPrefix(u, "/cfg"):
|
case strings.HasPrefix(u, "/cfg"):
|
||||||
parts := strings.SplitN(u, " ", 3)
|
parts := strings.SplitN(u, " ", 3)
|
||||||
@@ -1397,6 +1629,9 @@ func main() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
cfg = getCfg("model.cfg")
|
cfg = getCfg("model.cfg")
|
||||||
|
if k == "model" || k == "endpoint" || k == "api_key" {
|
||||||
|
cfg.ContextWindow = fetchContextWindow(&cfg)
|
||||||
|
}
|
||||||
COL = col(cfg)
|
COL = col(cfg)
|
||||||
fmt.Println(c(fmt.Sprintf("[config updated: %s=%s]", k, v), 32))
|
fmt.Println(c(fmt.Sprintf("[config updated: %s=%s]", k, v), 32))
|
||||||
} else {
|
} else {
|
||||||
@@ -1404,15 +1639,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
case strings.HasPrefix(u, "!"):
|
case strings.HasPrefix(u, "!"):
|
||||||
cmd := strings.TrimSpace(strings.TrimPrefix(u, "!"))
|
runDirectShell(strings.TrimPrefix(u, "!"), cfg.ShellTimeout)
|
||||||
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))
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
case u == "/help":
|
case u == "/help":
|
||||||
fmt.Println(c("Bantam commands:", 1, 36))
|
fmt.Println(c("Bantam commands:", 1, 36))
|
||||||
@@ -1424,10 +1651,11 @@ func main() {
|
|||||||
turnMsgs := append([]Message{}, msgs...)
|
turnMsgs := append([]Message{}, msgs...)
|
||||||
turnMsgs = append(turnMsgs, Message{Role: "user", Content: strp(u)})
|
turnMsgs = append(turnMsgs, Message{Role: "user", Content: strp(u)})
|
||||||
sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
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()
|
cancel()
|
||||||
if err != nil {
|
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))
|
fmt.Println(c("\n[interrupted]", 33))
|
||||||
} else {
|
} else {
|
||||||
fmt.Println(c("[error: "+err.Error()+"]", 31))
|
fmt.Println(c("[error: "+err.Error()+"]", 31))
|
||||||
@@ -1436,6 +1664,17 @@ func main() {
|
|||||||
}
|
}
|
||||||
msgs = resMsgs
|
msgs = resMsgs
|
||||||
autosave(msgs)
|
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:
|
done:
|
||||||
autosave(msgs)
|
autosave(msgs)
|
||||||
|
|||||||
+453
-29
@@ -59,7 +59,7 @@ func TestParseStreamReasoningNoDuplication(t *testing.T) {
|
|||||||
`data: [DONE]`,
|
`data: [DONE]`,
|
||||||
}, "\n")
|
}, "\n")
|
||||||
|
|
||||||
msg, err := parseStream(context.Background(), bytes.NewBufferString(sseData))
|
msg, _, err := parseStream(context.Background(), bytes.NewBufferString(sseData))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected parseStream error: %v", err)
|
t.Fatalf("unexpected parseStream error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -91,7 +91,7 @@ func TestParseStreamReasoningAlias(t *testing.T) {
|
|||||||
`data: [DONE]`,
|
`data: [DONE]`,
|
||||||
}, "\n")
|
}, "\n")
|
||||||
|
|
||||||
msg, err := parseStream(context.Background(), bytes.NewBufferString(sseData))
|
msg, _, err := parseStream(context.Background(), bytes.NewBufferString(sseData))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected parseStream error: %v", err)
|
t.Fatalf("unexpected parseStream error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -109,7 +109,7 @@ func TestParseStreamContentOnly(t *testing.T) {
|
|||||||
`data: [DONE]`,
|
`data: [DONE]`,
|
||||||
}, "\n")
|
}, "\n")
|
||||||
|
|
||||||
msg, err := parseStream(context.Background(), bytes.NewBufferString(sseData))
|
msg, _, err := parseStream(context.Background(), bytes.NewBufferString(sseData))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected parseStream error: %v", err)
|
t.Fatalf("unexpected parseStream error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -130,7 +130,7 @@ func TestParseStreamReasoningOnly(t *testing.T) {
|
|||||||
`data: [DONE]`,
|
`data: [DONE]`,
|
||||||
}, "\n")
|
}, "\n")
|
||||||
|
|
||||||
msg, err := parseStream(context.Background(), bytes.NewBufferString(sseData))
|
msg, _, err := parseStream(context.Background(), bytes.NewBufferString(sseData))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected parseStream error: %v", err)
|
t.Fatalf("unexpected parseStream error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -144,7 +144,7 @@ func TestParseStreamReasoningOnly(t *testing.T) {
|
|||||||
|
|
||||||
func TestParseStreamEmpty(t *testing.T) {
|
func TestParseStreamEmpty(t *testing.T) {
|
||||||
for _, in := range []string{"", "\n\n", "event: message\n\n"} {
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("unexpected parseStream error for input %q: %v", in, err)
|
t.Fatalf("unexpected parseStream error for input %q: %v", in, err)
|
||||||
}
|
}
|
||||||
@@ -162,7 +162,7 @@ func TestParseStreamToolCallSplitAcrossChunks(t *testing.T) {
|
|||||||
`data: [DONE]`,
|
`data: [DONE]`,
|
||||||
}, "\n")
|
}, "\n")
|
||||||
|
|
||||||
msg, err := parseStream(context.Background(), bytes.NewBufferString(sseData))
|
msg, _, err := parseStream(context.Background(), bytes.NewBufferString(sseData))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected parseStream error: %v", err)
|
t.Fatalf("unexpected parseStream error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -188,7 +188,7 @@ func TestParseStreamMultipleToolCallsKeepFirstAppearanceOrder(t *testing.T) {
|
|||||||
`data: [DONE]`,
|
`data: [DONE]`,
|
||||||
}, "\n")
|
}, "\n")
|
||||||
|
|
||||||
msg, err := parseStream(context.Background(), bytes.NewBufferString(sseData))
|
msg, _, err := parseStream(context.Background(), bytes.NewBufferString(sseData))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected parseStream error: %v", err)
|
t.Fatalf("unexpected parseStream error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -210,7 +210,7 @@ func TestParseStreamJunkAndNoChoicesIgnored(t *testing.T) {
|
|||||||
`data: [DONE]`,
|
`data: [DONE]`,
|
||||||
}, "\n")
|
}, "\n")
|
||||||
|
|
||||||
msg, err := parseStream(context.Background(), bytes.NewBufferString(sseData))
|
msg, _, err := parseStream(context.Background(), bytes.NewBufferString(sseData))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected parseStream error: %v", err)
|
t.Fatalf("unexpected parseStream error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -226,7 +226,7 @@ func TestParseStreamReasoningAfterContent(t *testing.T) {
|
|||||||
`data: [DONE]`,
|
`data: [DONE]`,
|
||||||
}, "\n")
|
}, "\n")
|
||||||
|
|
||||||
msg, err := parseStream(context.Background(), bytes.NewBufferString(sseData))
|
msg, _, err := parseStream(context.Background(), bytes.NewBufferString(sseData))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected parseStream error: %v", err)
|
t.Fatalf("unexpected parseStream error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -707,11 +707,11 @@ func TestAutosave(t *testing.T) {
|
|||||||
// ---------- summarize / compact (error paths only, no network) ----------
|
// ---------- summarize / compact (error paths only, no network) ----------
|
||||||
|
|
||||||
func TestSummarizeEmpty(t *testing.T) {
|
func TestSummarizeEmpty(t *testing.T) {
|
||||||
if _, err := summarize(context.Background(), &Cfg{}, nil); err == nil || !strings.Contains(err.Error(), "no conversation") {
|
if _, err := summarize(context.Background(), &Cfg{}, nil); err == nil || !strings.Contains(err.Error(), "no system message") {
|
||||||
t.Errorf("expected no-conversation error, got %v", err)
|
t.Errorf("expected no-system-message error, got %v", err)
|
||||||
}
|
}
|
||||||
if _, err := summarize(context.Background(), &Cfg{}, []Message{{Role: "system", Content: strp("sys")}}); err == nil {
|
if _, err := summarize(context.Background(), &Cfg{}, []Message{{Role: "system", Content: strp("sys")}}); err == nil || !strings.Contains(err.Error(), "nothing to compact") {
|
||||||
t.Errorf("expected error for system-only conversation")
|
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")
|
t.Errorf("expected original messages on error")
|
||||||
}
|
}
|
||||||
msgs2, _, err2 := compact(context.Background(), &Cfg{}, []Message{{Role: "user", Content: strp("x")}})
|
msgs2, _, err2 := compact(context.Background(), &Cfg{}, []Message{{Role: "user", Content: strp("x")}})
|
||||||
if err2 == nil {
|
if err2 == nil || !strings.Contains(err2.Error(), "no system message") {
|
||||||
t.Errorf("expected error when first message is not system")
|
t.Errorf("expected error when first message is not system, got %v", err2)
|
||||||
}
|
}
|
||||||
if len(msgs2) != 1 {
|
if len(msgs2) != 1 {
|
||||||
t.Errorf("expected original messages returned, got %d", len(msgs2))
|
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 ----------
|
// ---------- history ----------
|
||||||
@@ -943,7 +950,7 @@ func TestLLMNonStreamingAndHeaders(t *testing.T) {
|
|||||||
cfg.Endpoint = srv.URL
|
cfg.Endpoint = srv.URL
|
||||||
cfg.Stream = false
|
cfg.Stream = false
|
||||||
cfg.APIKey = "secret"
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("llm: %v", err)
|
t.Fatalf("llm: %v", err)
|
||||||
}
|
}
|
||||||
@@ -973,7 +980,7 @@ func TestLLMNoAuthHeaderWhenNoKey(t *testing.T) {
|
|||||||
cfg.Endpoint = srv.URL
|
cfg.Endpoint = srv.URL
|
||||||
cfg.Stream = false
|
cfg.Stream = false
|
||||||
cfg.APIKey = "-"
|
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)
|
t.Fatalf("llm: %v", err)
|
||||||
}
|
}
|
||||||
if gotAuth != "" {
|
if gotAuth != "" {
|
||||||
@@ -994,7 +1001,7 @@ func TestLLMStreaming(t *testing.T) {
|
|||||||
cfg.Endpoint = srv.URL
|
cfg.Endpoint = srv.URL
|
||||||
cfg.Stream = true
|
cfg.Stream = true
|
||||||
cfg.APIKey = "-"
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("llm: %v", err)
|
t.Fatalf("llm: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1018,7 +1025,7 @@ func TestLLM4xxReturnsImmediately(t *testing.T) {
|
|||||||
cfg.Stream = false
|
cfg.Stream = false
|
||||||
cfg.APIKey = "-"
|
cfg.APIKey = "-"
|
||||||
start := time.Now()
|
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") {
|
if err == nil || !strings.Contains(err.Error(), "Invalid assistant message") {
|
||||||
t.Fatalf("expected 400 error, got %v", err)
|
t.Fatalf("expected 400 error, got %v", err)
|
||||||
}
|
}
|
||||||
@@ -1044,7 +1051,7 @@ func TestLLMRetriesOn5xxThenSucceeds(t *testing.T) {
|
|||||||
cfg.Endpoint = srv.URL
|
cfg.Endpoint = srv.URL
|
||||||
cfg.Stream = false
|
cfg.Stream = false
|
||||||
cfg.APIKey = "-"
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("llm after retries: %v", err)
|
t.Fatalf("llm after retries: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1066,7 +1073,7 @@ func TestLLMEmptyChoices(t *testing.T) {
|
|||||||
cfg.Endpoint = srv.URL
|
cfg.Endpoint = srv.URL
|
||||||
cfg.Stream = false
|
cfg.Stream = false
|
||||||
cfg.APIKey = "-"
|
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)
|
t.Errorf("expected empty choices error, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1093,7 +1100,7 @@ func TestALToolLoop(t *testing.T) {
|
|||||||
cfg.Endpoint = srv.URL
|
cfg.Endpoint = srv.URL
|
||||||
cfg.Stream = false
|
cfg.Stream = false
|
||||||
cfg.APIKey = "-"
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("AL: %v", err)
|
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) {
|
func TestALRunSubagent(t *testing.T) {
|
||||||
var n int
|
var n int
|
||||||
var mu sync.Mutex
|
var mu sync.Mutex
|
||||||
@@ -1155,7 +1205,7 @@ func TestALRunSubagent(t *testing.T) {
|
|||||||
cfg.Endpoint = srv.URL
|
cfg.Endpoint = srv.URL
|
||||||
cfg.Stream = false
|
cfg.Stream = false
|
||||||
cfg.APIKey = "-"
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("AL: %v", err)
|
t.Fatalf("AL: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1198,7 +1248,7 @@ func TestALSubagentDepthLimit(t *testing.T) {
|
|||||||
cfg.Endpoint = srv.URL
|
cfg.Endpoint = srv.URL
|
||||||
cfg.Stream = false
|
cfg.Stream = false
|
||||||
cfg.APIKey = "-"
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("AL: %v", err)
|
t.Fatalf("AL: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1245,7 +1295,7 @@ func TestALStripsInvalidAssistantAndRetries(t *testing.T) {
|
|||||||
cfg.Endpoint = srv.URL
|
cfg.Endpoint = srv.URL
|
||||||
cfg.Stream = false
|
cfg.Stream = false
|
||||||
cfg.APIKey = "-"
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("AL: %v", err)
|
t.Fatalf("AL: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1269,7 +1319,8 @@ func TestSummarizeHappyPath(t *testing.T) {
|
|||||||
cfg.Endpoint = srv.URL
|
cfg.Endpoint = srv.URL
|
||||||
cfg.Stream = false
|
cfg.Stream = false
|
||||||
cfg.APIKey = "-"
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("summarize: %v", err)
|
t.Fatalf("summarize: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1279,7 +1330,13 @@ func TestSummarizeHappyPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCompactHappyPath(t *testing.T) {
|
func TestCompactHappyPath(t *testing.T) {
|
||||||
|
var receivedMessages []Message
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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"}}]}`))
|
w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"the summary"}}]}`))
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
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") {
|
if msgs[1].Role != "user" || msgs[1].Content == nil || !strings.Contains(*msgs[1].Content, "the summary") {
|
||||||
t.Errorf("continuation message = %+v", msgs[1])
|
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 ----------
|
// ---------- setCfg and LLM parameter forwarding ----------
|
||||||
@@ -1352,7 +1422,7 @@ func TestLLMForwardsRelevantParameters(t *testing.T) {
|
|||||||
}, "\n"))
|
}, "\n"))
|
||||||
|
|
||||||
cfg := getCfg(cfgFile)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("llm: %v", err)
|
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")}}
|
origMsgs := []Message{{Role: "system", Content: strp("sys")}, {Role: "user", Content: strp("hello")}}
|
||||||
inputMsgs := append([]Message{}, origMsgs...)
|
inputMsgs := append([]Message{}, origMsgs...)
|
||||||
msgs, err := AL(ctx, &cfg, inputMsgs, "sys", 0)
|
msgs, _, err := AL(ctx, &cfg, inputMsgs, "sys", 0)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatalf("expected context cancellation error, got nil")
|
t.Fatalf("expected context cancellation error, got nil")
|
||||||
}
|
}
|
||||||
@@ -1682,7 +1752,7 @@ func TestLLMContextCancellation(t *testing.T) {
|
|||||||
cancel()
|
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 {
|
if err == nil {
|
||||||
t.Fatalf("expected context cancellation error, got 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,108 +1,83 @@
|
|||||||
#!/usr/bin/env perl
|
#!/usr/bin/env perl
|
||||||
# MicroBantam (mb): the Bantam agent in <100 SLOC - readable, core modules only
|
# MicroBantam (mb): the Bantam agent in <100 SLOC - readable, core modules only
|
||||||
# Created by Luxferre in 2026, released into the public domain
|
# 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);
|
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+/ };
|
$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
|
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 $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';
|
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);
|
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; } }
|
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 q{-} && $ENV{OPENAI_API_KEY};
|
$d{api_key} = $ENV{OPENAI_API_KEY} if $d{api_key} eq '-' && $ENV{OPENAI_API_KEY}; \%d }
|
||||||
\%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 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 T { my ($n, $d, $p) = @_; {type=>'function', function=>{name=>$n, description=>$d, parameters=>{type=>'object', properties=>$p, required=>[keys %$p]}}} }
|
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') {
|
||||||
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'; } } } }
|
for my $tc (@{$m->{tool_calls} // []}) { $tc->{function}{arguments} = filter_text($tc->{function}{arguments});
|
||||||
|
my $a = eval { decode_json($tc->{function}{arguments} // '{}') };
|
||||||
sub llm { my ($c, $msgs) = @_; # one non-streaming chat completion
|
$tc->{function}{arguments} = encode_json({invalid_raw => $tc->{function}{arguments} // ''}) if ref $a ne 'HASH'; }
|
||||||
sanitize_msgs($msgs);
|
$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 $ep = $c->{endpoint}; $ep =~ s{/+$}{};
|
||||||
my $h = {'Content-Type'=>'application/json', 'User-Agent'=>'Mozilla/5.0 (compatible; MicroBantam/1.0)'};
|
my $h = {'Content-Type'=>'application/json', 'User-Agent'=>'Mozilla/5.0 (compatible; MicroBantam/1.0)'};
|
||||||
$h->{Authorization} = "Bearer $c->{api_key}" if $c->{api_key} ne '-';
|
$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});
|
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}; }
|
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;
|
my $tty = -t STDOUT; print $tty ? "\r...requesting..." : "...requesting...\n";
|
||||||
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)});
|
||||||
my $r = HTTP::Tiny->new(timeout=>0+$c->{timeout})->post("$ep/chat/completions", {headers=>$h, content=>$body});
|
print "\r\e[K" if $tty;
|
||||||
print "\r\e[K" if $tty; # clear the spinner line
|
|
||||||
my $d = $r->{success} ? eval { decode_json($r->{content}) } : undef;
|
my $d = $r->{success} ? eval { decode_json($r->{content}) } : undef;
|
||||||
return $d->{choices}[0]{message} if $d && $d->{choices} && @{$d->{choices}};
|
return $d->{choices}[0]{message} if $d && $d->{choices} && @{$d->{choices}};
|
||||||
my $rb = $r->{content} // '';
|
my $rb = substr($r->{content} // '', 0, 500);
|
||||||
$rb = substr($rb, 0, 500) if length($rb) > 500;
|
|
||||||
die "API error: " . (length($rb) ? "$rb (HTTP $r->{status})" : ($r->{reason} || "HTTP $r->{status}")) . "\n"; }
|
die "API error: " . (length($rb) ? "$rb (HTTP $r->{status})" : ($r->{reason} || "HTTP $r->{status}")) . "\n"; }
|
||||||
|
sub shell_exec { my ($cmd, $t, $out) = (filter_text($_[0]), $_[1], '');
|
||||||
sub shell_exec { my ($cmd, $t) = @_; # run a command under a hard timeout
|
|
||||||
my $out = '';
|
|
||||||
eval { local $SIG{ALRM} = sub { alarm 0; die "timeout\n" }; alarm $t; $out = `$cmd 2>&1`; alarm 0; };
|
eval { local $SIG{ALRM} = sub { alarm 0; die "timeout\n" }; alarm $t; $out = `$cmd 2>&1`; alarm 0; };
|
||||||
$out =~ s/\s+$//;
|
$out =~ s/\s+$//; utf8::decode($out); $out = filter_text($out);
|
||||||
utf8::decode($out);
|
|
||||||
$@ ? "$out\n[timeout after ${t}s]\nexit: -1" : "$out\nexit: " . ($? >> 8); }
|
$@ ? "$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 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);
|
||||||
|
|
||||||
sub AL { my ($c, $msgs, $sp, $depth) = @_; # the agentic loop: LLM <-> tools until done
|
|
||||||
$depth ||= 0;
|
|
||||||
for (1 .. $c->{max_al_iterations}) {
|
for (1 .. $c->{max_al_iterations}) {
|
||||||
my $m = eval { llm($c, $msgs) };
|
my $m = eval { llm($c, $msgs) };
|
||||||
if ($@ && $@ =~ /Invalid assistant message|content or tool_calls must be set/ && grep({ $_->{role} eq 'assistant' } @$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; } }
|
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;
|
print "[stripped malformed assistant message]\n"; redo;
|
||||||
}
|
}
|
||||||
if ($@) { print $@; return $msgs; }
|
if ($@) { print $@; return $msgs; }
|
||||||
push @$msgs, $m;
|
push @$msgs, $m;
|
||||||
print $m->{content}, "\n" if defined $m->{content} && length $m->{content};
|
print $m->{content}, "\n" if defined $m->{content} && length $m->{content};
|
||||||
my $tcs = $m->{tool_calls};
|
my $tcs = $m->{tool_calls}; last unless $tcs && @$tcs;
|
||||||
last unless $tcs && @$tcs;
|
|
||||||
for my $tc (@$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 $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 '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"; }
|
else { $res = "unknown tool: $fn"; }
|
||||||
|
$res = filter_text($res);
|
||||||
print "[tool] $fn: $res\n";
|
print "[tool] $fn: $res\n";
|
||||||
push @$msgs, {role=>'tool', tool_call_id=>$tc->{id}, content=>$res};
|
push @$msgs, {role=>'tool', tool_call_id=>$tc->{id}, content=>$res};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$msgs; }
|
$msgs; }
|
||||||
|
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 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 sdir { make_path($SDIR) unless -d $SDIR; $SDIR }
|
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;
|
sub save { sdir(); my ($id, $i) = (strftime('%Y%m%d-%H%M%S', localtime), 0); $id .= '-' . ++$i while -f "$SDIR/$id.json";
|
||||||
$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 }
|
||||||
open my $f, '>', "$SDIR/$id.json" or die "cannot save: $!";
|
sub load { my ($hit) = grep { $_->{id} eq $_[0] } sessions(); die "no session: $_[0]\n" unless $hit; $hit->{messages} }
|
||||||
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 autosave { sdir(); open my $f, '>', "$SDIR/autosave.json" or return; print $f JSON::PP->new->utf8->pretty->encode({id=>'autosave', messages=>$_[0]}); }
|
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 list_sessions { map { [$_->{id}, scalar @{$_->{messages} // []}] } sessions() }
|
||||||
sub set_cfg { my ($k, $v) = @_; my (@ls, $f);
|
sub set_cfg { my ($k, $v, @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, $_; } } }
|
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;
|
push @ls, "$k=$v\n" unless $f;
|
||||||
if (open my $fh, '>:encoding(UTF-8)', 'model.cfg') { print $fh @ls; close $fh; } }
|
if (open my $fh, '>:encoding(UTF-8)', 'model.cfg') { print $fh @ls; close $fh; } }
|
||||||
|
|
||||||
sub main {
|
sub main {
|
||||||
my ($c, $sp) = (cfg(), sp());
|
my ($c, $sp) = (cfg(), sp()); my $msgs = [{role=>'system', content=>$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; }
|
||||||
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; }
|
|
||||||
print "MicroBantam ready ($c->{model}). Commands: /quit /clear /save /list /load <id> /cfg <k> [v] /help\n";
|
print "MicroBantam ready ($c->{model}). Commands: /quit /clear /save /list /load <id> /cfg <k> [v] /help\n";
|
||||||
while (1) {
|
while (1) {
|
||||||
print "> "; my $u = <STDIN>; last unless defined $u;
|
print "> "; my $u = <STDIN>; last unless defined $u; $u =~ s/^\s+|\s+$//g; next unless length $u;
|
||||||
$u =~ s/^\s+|\s+$//g; next unless length $u;
|
|
||||||
if ($u eq '/quit') { last; }
|
if ($u eq '/quit') { last; }
|
||||||
elsif ($u eq '/clear') { $msgs = [{role=>'system', content=>$sp}]; autosave($msgs); }
|
elsif ($u eq '/clear') { $msgs = [{role=>'system', content=>$sp}]; autosave($msgs); }
|
||||||
elsif ($u eq '/save') { print "session saved: ", save($msgs), "\n"; }
|
elsif ($u eq '/save') { print "session saved: ", save($msgs), "\n"; }
|
||||||
@@ -114,5 +89,4 @@ sub main {
|
|||||||
}
|
}
|
||||||
autosave($msgs);
|
autosave($msgs);
|
||||||
}
|
}
|
||||||
|
|
||||||
main() unless caller();
|
main() unless caller();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
endpoint=https://opencode.ai/zen/v1
|
endpoint=https://opencode.ai/zen/v1
|
||||||
model=big-pickle
|
model=hy3-free
|
||||||
temperature=0.7
|
temperature=0.7
|
||||||
api_key=-
|
api_key=-
|
||||||
stream=true
|
stream=true
|
||||||
|
|||||||
+20
-2
@@ -2,9 +2,27 @@
|
|||||||
|
|
||||||
package main
|
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 }
|
func isTerminal(fd int) bool { return false }
|
||||||
|
|
||||||
|
|||||||
+20
-2
@@ -2,9 +2,27 @@
|
|||||||
|
|
||||||
package main
|
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 }
|
func isTerminal(fd int) bool { return false }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user