Compare commits
8
Commits
208d874cce
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9c7bb4407 | ||
|
|
b0a16e99e1 | ||
|
|
502a3f8c03 | ||
|
|
b3f2864f6a | ||
|
|
5b5853a2dd | ||
|
|
c0350f5d9b | ||
|
|
ed5eb88ba9 | ||
|
|
4ed12ed213 |
@@ -54,11 +54,11 @@ All implementations read `model.cfg` (or `.bantam.cfg`, which takes priority if
|
|||||||
./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 clears line / interrupts in-flight run, Ctrl+D exits), working everywhere without third-party 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, working everywhere without third-party dependencies. It supports arrow keys and Up/Down for history, Backspace and Ctrl+D (non-empty line) to delete, and Emacs-style editing combos: **Ctrl+A** (start of line), **Ctrl+E** (end of line), **Ctrl+B** / **Ctrl+F** (move by character), **Ctrl+W** (delete previous word), **Ctrl+K** (kill to end of line), **Ctrl+U** (kill to start of line), **Ctrl+Left** / **Ctrl+Right** (move by word), and **Home** / **End** keys. **Ctrl+C** clears the line / interrupts an in-flight run, and **Ctrl+D** on an empty line exits.
|
||||||
|
|
||||||
After every interaction, Bantam displays token usage (prompt tokens, cached/uncached breakdown when supported by the provider, completion tokens, and context window utilization):
|
After every interaction, Bantam displays token usage (prompt tokens, cached/uncached breakdown when supported by the provider, completion tokens, and context window utilization):
|
||||||
```text
|
```text
|
||||||
[tokens: 1420 prompt (1000 cached, 420 uncached) + 85 completion | context: 1420/200000 (0.7%)]
|
[openrouter/free: 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:
|
||||||
@@ -68,6 +68,9 @@ All implementations read `model.cfg` (or `.bantam.cfg`, which takes priority if
|
|||||||
- `/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` — 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)
|
- `/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 live (writes to `.bantam.cfg`)
|
- `/cfg <param> [val]` — inspect or update a configuration parameter live (writes to `.bantam.cfg`)
|
||||||
|
- `/model [val]` — alias for `/cfg model` (inspect or set the model)
|
||||||
|
- `/endpoint [val]` — alias for `/cfg endpoint` (inspect or set the API endpoint)
|
||||||
|
- `/skill` — with no argument, list every skill under the configured skills directory (or report that none are configured); with `<name> [prompt]`, load `<skills_dir>/<name>/SKILL.md` (or an absolute path when no skills dir is set) and send its contents prefixed to the optional `prompt` as the next user turn
|
||||||
- `/models` — query the `/models` path on the current inference endpoint and print a plain list of supported model IDs, marking the currently configured model with a leading `* ` (Go port)
|
- `/models` — query the `/models` path on the current inference endpoint and print a plain list of supported model IDs, marking the currently configured model with a leading `* ` (Go port)
|
||||||
- `!<cmd>` — execute a shell command directly through `shell_exec` without adding the result to the conversation context (Go port)
|
- `!<cmd>` — execute a shell command directly through `shell_exec` without adding the result to the conversation context (Go port)
|
||||||
- `/help` — show all supported commands
|
- `/help` — show all supported commands
|
||||||
@@ -115,7 +118,7 @@ Using these rules, everyone can build their own copy of Bantam from scratch in l
|
|||||||
2. Read model parameters from the config file (`.bantam.cfg` if present, else `model.cfg`) in `key=value` format and discover context window size.
|
2. Read model parameters from the config file (`.bantam.cfg` if present, else `model.cfg`) in `key=value` format and discover context window size.
|
||||||
3. Prepare a new message list with the system prompt (`role: "system"`).
|
3. Prepare a new message list with the system prompt (`role: "system"`).
|
||||||
4. Read the first command-line parameter. If non-empty, read user prompt from the specified file. If prefixed with `!`, execute the shell command directly via `shell_exec` and exit. Otherwise, append to `messages` (`role: "user"`), run `AL(cfg, messages)`, display token usage, and exit.
|
4. Read the first command-line parameter. If non-empty, read user prompt from the specified file. If prefixed with `!`, execute the shell command directly via `shell_exec` and exit. Otherwise, append to `messages` (`role: "user"`), run `AL(cfg, messages)`, display token usage, and exit.
|
||||||
5. Read user prompt from standard input (with `readline` line editing and history in `~/.bantam_history`; **Ctrl+J** inserts a real newline into the line being edited). If equal to `/quit` or EOF, exit. If equal to `/clear`, reset `messages` to step 3 and return to step 5. If starting with `/save`, write the whole `messages` array to `~/.bantam/sessions/<name>.json` (or `<project-md5>.json` if no name is given) and return to step 5. If equal to `/continue` or `/cont`, load the session corresponding to the current project's MD5 hash and return to step 5. If equal to `/list`, print saved sessions and their summaries (marking current project session) and return to step 5. If starting with `/load`, replace `messages` with the saved session's messages (by exact id or unique prefix) and return to step 5. If equal to `/compact`, ask the LLM to summarize the conversation by appending the compaction prompt to derive the summary, replace `messages` with `[system, summary-user-message]`, and return to step 5. If starting with `/cfg`, display the current value (`/cfg <param>`) or update the config live by writing to `.bantam.cfg` (`/cfg <param> <val>`) and return to step 5. If equal to `/models`, query the `/models` path on the current inference endpoint and print a plain list of supported model IDs (the currently configured model marked with a leading `* `), then return to step 5. If starting with `!`, execute the command directly via `shell_exec` without adding the result to `messages` and return to step 5. If equal to `/help`, print the command list and return to step 5. After every user turn and on exit, auto-save `messages` to `~/.bantam/sessions/<project-md5>.json`.
|
5. Read user prompt from standard input (with `readline` line editing and history in `~/.bantam_history`; **Ctrl+J** inserts a real newline into the line being edited). If equal to `/quit` or EOF, exit. If equal to `/clear`, reset `messages` to step 3 and return to step 5. If starting with `/save`, write the whole `messages` array to `~/.bantam/sessions/<name>.json` (or `<project-md5>.json` if no name is given) and return to step 5. If equal to `/continue` or `/cont`, load the session corresponding to the current project's MD5 hash and return to step 5. If equal to `/list`, print saved sessions and their summaries (marking current project session) and return to step 5. If starting with `/load`, replace `messages` with the saved session's messages (by exact id or unique prefix) and return to step 5. If equal to `/compact`, ask the LLM to summarize the conversation by appending the compaction prompt to derive the summary, replace `messages` with `[system, summary-user-message]`, and return to step 5. If starting with `/cfg`, display the current value (`/cfg <param>`) or update the config live by writing to `.bantam.cfg` (`/cfg <param> <val>`) and return to step 5. `/model` and `/endpoint` are aliases for `/cfg model` and `/cfg endpoint` respectively and behave the same way. If equal to `/skill` (no argument), list every skill under the configured skills directory (or report that none is configured) and return to step 5. If starting with `/skill`, resolve the skill (a `<skills_dir>/<name>/SKILL.md` file, or an absolute path to a skill directory or `SKILL.md` file when no skills directory is configured), prefix its contents to the optional remaining text, and send the result as the next user turn (returning to step 5 after the response). If equal to `/models`, query the `/models` path on the current inference endpoint and print a plain list of supported model IDs (the currently configured model marked with a leading `* `), then return to step 5. If starting with `!`, execute the command directly via `shell_exec` without adding the result to `messages` and return to step 5. If equal to `/help`, print the command list and return to step 5. After every user turn and on exit, auto-save `messages` to `~/.bantam/sessions/<project-md5>.json`.
|
||||||
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.
|
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
|
||||||
@@ -139,21 +142,22 @@ If the API rejects the request with an `Invalid assistant message: content or to
|
|||||||
|
|
||||||
### Model configuration parameters
|
### Model configuration parameters
|
||||||
|
|
||||||
(shared by all implementations; the config file — `.bantam.cfg` takes priority over `model.cfg` when both exist — is plain `key=value` with `#` comments)
|
(shared by all implementations; `.bantam.cfg` takes priority over `model.cfg` when both exist — both are plain `key=value` with `#` comments. Values defined in `.bantam.cfg` override `BANTAM_*` environment variables; when `.bantam.cfg` is absent, `BANTAM_*` environment variables override `model.cfg` and built-in defaults.)
|
||||||
|
|
||||||
- `endpoint` (base OpenAI-compatible API URL, default `https://api.kilo.ai/api/openrouter`)
|
- `endpoint` (base OpenAI-compatible API URL, default `https://api.kilo.ai/api/openrouter`; falls back to `BANTAM_ENDPOINT` env var)
|
||||||
- `model` (model name, default `openrouter/free`)
|
- `model` (model name, default `openrouter/free`; falls back to `BANTAM_MODEL` env var)
|
||||||
- `temperature` (model temperature, default 0.7)
|
- `temperature` (model temperature, default 0.7; falls back to `BANTAM_TEMP` or `BANTAM_TEMPERATURE` env var)
|
||||||
- `api_key` (API key / Bearer token, optional; fall back to `OPENAI_API_KEY` env var)
|
- `api_key` (API key / Bearer token, optional; falls back to `BANTAM_API_KEY` env var; set to `-` or left blank in config file to explicitly defer to `BANTAM_API_KEY`)
|
||||||
- `stream` (stream response tokens in real-time, default `true`)
|
- `stream` (stream response tokens in real-time, default `true`; falls back to `BANTAM_STREAM` env var)
|
||||||
- `color` (ANSI coloring: `auto` (TTY-detected, default), `always`, or `never`; also disabled by `NO_COLOR`/`BANTAM_NO_COLOR` env vars)
|
- `color` (ANSI coloring: `auto` (TTY-detected, default), `always`, or `never`; disabled by `NO_COLOR`/`BANTAM_NO_COLOR` env vars, falls back to `BANTAM_COLOR` env var)
|
||||||
- `timeout` (HTTP timeout in seconds for LLM API calls, default 300; in the Go port it bounds connection setup and time-to-first-byte, so long streaming responses are not cut off mid-stream)
|
- `timeout` (HTTP timeout in seconds for LLM API calls, default 300; in the Go port it bounds connection setup and time-to-first-byte, so long streaming responses are not cut off mid-stream; falls back to `BANTAM_TIMEOUT` env var)
|
||||||
- `shell_timeout` (timeout in seconds for `shell_exec` commands, default 120)
|
- `shell_timeout` (timeout in seconds for `shell_exec` commands, default 120; falls back to `BANTAM_SHELL_TIMEOUT` env var)
|
||||||
- `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; falls back to `BANTAM_MAX_AL_ITERATIONS` env var)
|
||||||
- `context_window` (context window size in tokens, auto-discovered from `/models` API if available, fallback to this setting, default 200000)
|
- `context_window` (context window size in tokens, auto-discovered from `/models` API if available, fallback to this setting, default 200000; falls back to `BANTAM_CONTEXT_WINDOW` env var)
|
||||||
- `reasoning_effort` (reasoning effort level, forwarded to chat completions API, default `high`)
|
- `reasoning_effort` (reasoning effort level, forwarded to chat completions API, default `high`; falls back to `BANTAM_REASONING_EFFORT` env var)
|
||||||
|
|
||||||
- `bantam_tools_dir` (optional path to a directory of extra shell tools; the Go port appends `"Extra shell tools can be found at <dir>"` to the system prompt at startup when set. The `BANTAM_TOOLS_DIR` environment variable overrides this and is checked first; if neither is set, nothing is appended. Note: this is an agent-internal hint, not forwarded to the API.)
|
- `bantam_tools_dir` (optional path to a directory of extra shell tools; the Go port appends `"Extra shell tools can be found at <dir>"` to the system prompt at startup when set. When unset in the config file, it falls back to the `BANTAM_TOOLS_DIR` environment variable; if neither is set, nothing is appended. Note: this is an agent-internal hint, not forwarded to the API.)
|
||||||
|
- `bantam_skills_dir` (optional path to a directory of reusable *skills*; each skill lives in its own subdirectory as `<skill_name>/SKILL.md`. When set, the Go port appends `"Skills may be discovered and invoked from <dir>"` to the system prompt at startup, and the `/skill <skill_name> [prompt]` command loads a skill's `SKILL.md` and runs it as a prompt. When unset in the config file, it falls back to the `BANTAM_SKILLS_DIR` environment variable; if neither is set, `/skill` accepts an absolute path to a skill directory or `SKILL.md` file instead. Note: this is an agent-internal hint, not forwarded to the API.)
|
||||||
|
|
||||||
The Go port also supports SOCKS5 proxying via the `SOCKS_PROXY` (or `socks_proxy`) environment variable (e.g. `SOCKS_PROXY=socks5://127.0.0.1:1080` or `SOCKS_PROXY=127.0.0.1:1080`), falling back to standard `HTTP_PROXY` / `HTTPS_PROXY` environment variables.
|
The Go port also supports SOCKS5 proxying via the `SOCKS_PROXY` (or `socks_proxy`) environment variable (e.g. `SOCKS_PROXY=socks5://127.0.0.1:1080` or `SOCKS_PROXY=127.0.0.1:1080`), falling back to standard `HTTP_PROXY` / `HTTPS_PROXY` environment variables.
|
||||||
|
|
||||||
@@ -166,12 +170,12 @@ When enabled, the interactive console uses a subtle ANSI palette: the pending-re
|
|||||||
#### `write_file` tool
|
#### `write_file` tool
|
||||||
|
|
||||||
- Parameters:
|
- Parameters:
|
||||||
- `path` (string, required): JSON-escaped file path to write to (must be created unless existing).
|
- `path` (string, required): JSON-escaped file path to write to.
|
||||||
- `offset` (integer, optional, default 0): byte offset to start writing from (defaults to 0, start of file; does not append).
|
- `offset` (integer, optional): byte offset to start writing from. If omitted together with `del_bytes`, the whole file is overwritten instead. Defaults to 0 (start of file).
|
||||||
- `del_bytes` (integer, optional, default 0): bytes to delete starting from the `offset` prior to writing.
|
- `del_bytes` (integer, optional): bytes to delete starting at `offset`. If omitted together with `offset`, the whole file is overwritten instead.
|
||||||
- `content` (string, required, may be empty): JSON-escaped content to write to the file.
|
- `content` (string, required, may be empty): JSON-escaped content to write to the file.
|
||||||
- Return value: string
|
- Return value: string
|
||||||
- Action: write `content` into the file at `path` starting at `offset` after deleting `del_bytes` bytes (creating the file and any necessary parent directories).
|
- Action: if `offset` and `del_bytes` are both omitted, the entire file is overwritten with `content` (the file, and any necessary parent directories, are created if missing); otherwise `content` is written at `offset`, optionally deleting `del_bytes` bytes first (splicing prefix + content + suffix and never appending to the end of an existing file), creating the file and parent directories as needed.
|
||||||
|
|
||||||
#### `shell_exec` tool
|
#### `shell_exec` tool
|
||||||
|
|
||||||
@@ -190,7 +194,7 @@ MicroBantam (`mb`) is a compressed Perl 5 reference implementation of the same a
|
|||||||
- 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 `BANTAM_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 (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)
|
- 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
|
||||||
@@ -219,7 +223,18 @@ MicroBantam (`mb`) is a compressed Perl 5 reference implementation of the same a
|
|||||||
|
|
||||||
The `extras/` directory contains small, dependency-light shell scripts that extend Bantam without changing its core. Because Bantam's built-in tools are `shell_exec` and `write_file`, these helpers can be invoked directly by the agent through `shell_exec` to give it real-world capabilities (web search, live weather) that the base model alone does not have. They are plain `/bin/sh` scripts depending only on `curl` (and `jq` where noted), so the agent can discover and run them just like any other command.
|
The `extras/` directory contains small, dependency-light shell scripts that extend Bantam without changing its core. Because Bantam's built-in tools are `shell_exec` and `write_file`, these helpers can be invoked directly by the agent through `shell_exec` to give it real-world capabilities (web search, live weather) that the base model alone does not have. They are plain `/bin/sh` scripts depending only on `curl` (and `jq` where noted), so the agent can discover and run them just like any other command.
|
||||||
|
|
||||||
If you keep your own collection of helper scripts, point Bantam at them with the `BANTAM_TOOLS_DIR` environment variable or the `bantam_tools_dir` key in the config file (`.bantam.cfg` if present, else `model.cfg`). When either is defined (environment variable taking precedence over the config key), the Go port appends the line `Extra shell tools can be found at <dir>` to the system prompt at startup, so the agent is aware of where to look for them. The `extras/` scripts shipped here are just examples of what such a directory can contain.
|
If you keep your own collection of helper scripts, point Bantam at them with the `bantam_tools_dir` key in the config file (`.bantam.cfg` if present, else `model.cfg`) or the `BANTAM_TOOLS_DIR` environment variable. When either is defined (the config file setting taking precedence over the environment variable), the Go port appends the line `Extra shell tools can be found at <dir>` to the system prompt at startup, so the agent is aware of where to look for them. The `extras/` scripts shipped here are just examples of what such a directory can contain.
|
||||||
|
|
||||||
|
## Skills
|
||||||
|
|
||||||
|
A *skill* is a reusable, self-contained instruction bundle the agent can load on demand. Each skill is a directory containing a `SKILL.md` file with the skill's prompt/instructions. Point Bantam at a skills directory with the `bantam_skills_dir` key in the config file (`.bantam.cfg` if present, else `model.cfg`) or the `BANTAM_SKILLS_DIR` environment variable; the config file setting takes precedence. When either is set, the Go port appends `Skills may be discovered and invoked from <dir>` to the system prompt at startup, and the agent can run a skill with:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/skill # list every skill under the configured skills directory
|
||||||
|
/skill <name> [prompt] # loads <skills_dir>/<name>/SKILL.md and runs it as a prompt
|
||||||
|
```
|
||||||
|
|
||||||
|
A bare `/skill` (no name) lists all skills found under the configured skills directory — a subdirectory is treated as a skill only if it contains a `SKILL.md` file — or reports that no skills directory is configured. The contents of `SKILL.md` are prefixed to the optional `prompt` and sent as the next user turn. If neither `BANTAM_SKILLS_DIR` nor `bantam_skills_dir` is set, `/skill <name>` accepts an absolute path instead: either a skill directory (e.g. `/abs/path/to/skill`, which resolves to `/abs/path/to/skill/SKILL.md`) or a direct path to a `SKILL.md` file.
|
||||||
|
|
||||||
### `extras/websearch`
|
### `extras/websearch`
|
||||||
|
|
||||||
@@ -305,7 +320,11 @@ The default system prompt instructs the agent to respect `AGENTS.md` contents in
|
|||||||
|
|
||||||
### How do I tell Bantam about extra shell tools?
|
### How do I tell Bantam about extra shell tools?
|
||||||
|
|
||||||
Set the `BANTAM_TOOLS_DIR` environment variable (or the `bantam_tools_dir` key in the config file — `.bantam.cfg` if present, else `model.cfg`) to a directory containing your helper scripts. When defined, the Go port appends `Extra shell tools can be found at <dir>` to the system prompt at startup, making the agent aware of them. The environment variable takes precedence over the config key; if neither is set, nothing is appended.
|
Set the `bantam_tools_dir` key in the config file (`.bantam.cfg` if present, else `model.cfg`) or the `BANTAM_TOOLS_DIR` environment variable to a directory containing your helper scripts. When defined, the Go port appends `Extra shell tools can be found at <dir>` to the system prompt at startup, making the agent aware of them. The config file key takes precedence over the environment variable; if neither is set, nothing is appended.
|
||||||
|
|
||||||
|
### How do I give Bantam reusable skills?
|
||||||
|
|
||||||
|
Set the `bantam_skills_dir` key in the config file (`.bantam.cfg` if present, else `model.cfg`) or the `BANTAM_SKILLS_DIR` environment variable to a directory where each subdirectory is a skill containing a `SKILL.md` file. When defined, the Go port appends `Skills may be discovered and invoked from <dir>` to the system prompt at startup, and you (or the agent) can run a skill with `/skill <name> [prompt]`. The config file key takes precedence over the environment variable; if neither is set, `/skill` accepts an absolute path to a skill directory or `SKILL.md` file instead.
|
||||||
|
|
||||||
### Is there any common config place for Bantam?
|
### Is there any common config place for Bantam?
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ type Cfg struct {
|
|||||||
func internalKey(k string) bool {
|
func internalKey(k string) bool {
|
||||||
switch k {
|
switch k {
|
||||||
case "endpoint", "model", "temperature", "stream", "api_key", "timeout",
|
case "endpoint", "model", "temperature", "stream", "api_key", "timeout",
|
||||||
"shell_timeout", "max_al_iterations", "color", "context_window":
|
"shell_timeout", "max_al_iterations", "color", "context_window",
|
||||||
|
"bantam_tools_dir", "bantam_skills_dir":
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -207,16 +208,7 @@ func listModels(cfg *Cfg) (string, error) {
|
|||||||
return b.String(), nil
|
return b.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getCfg(path string) Cfg {
|
func parseCfgFile(path string, cfg *Cfg) {
|
||||||
cfg := defCfg
|
|
||||||
cfg.Raw = map[string]string{
|
|
||||||
"endpoint": cfg.Endpoint, "model": cfg.Model, "temperature": fmt.Sprintf("%v", cfg.Temperature),
|
|
||||||
"api_key": cfg.APIKey, "stream": strconv.FormatBool(cfg.Stream), "color": cfg.Color,
|
|
||||||
"timeout": strconv.Itoa(cfg.Timeout), "shell_timeout": strconv.Itoa(cfg.ShellTimeout),
|
|
||||||
"max_al_iterations": strconv.Itoa(cfg.MaxALIterations),
|
|
||||||
"context_window": strconv.Itoa(cfg.ContextWindow),
|
|
||||||
"reasoning_effort": "high",
|
|
||||||
}
|
|
||||||
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") {
|
||||||
ln = strings.TrimSpace(ln)
|
ln = strings.TrimSpace(ln)
|
||||||
@@ -238,8 +230,95 @@ func getCfg(path string) Cfg {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (cfg.APIKey == "" || cfg.APIKey == "-") && os.Getenv("OPENAI_API_KEY") != "" {
|
}
|
||||||
cfg.APIKey = os.Getenv("OPENAI_API_KEY")
|
|
||||||
|
func applyEnvCfg(cfg *Cfg) {
|
||||||
|
if v := strings.TrimSpace(os.Getenv("BANTAM_ENDPOINT")); v != "" {
|
||||||
|
cfg.Endpoint = v
|
||||||
|
cfg.Raw["endpoint"] = v
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(os.Getenv("BANTAM_MODEL")); v != "" {
|
||||||
|
cfg.Model = v
|
||||||
|
cfg.Raw["model"] = v
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(os.Getenv("BANTAM_TEMP")); v != "" {
|
||||||
|
if f, e := strconv.ParseFloat(v, 64); e == nil {
|
||||||
|
cfg.Temperature = f
|
||||||
|
cfg.Raw["temperature"] = v
|
||||||
|
}
|
||||||
|
} else if v := strings.TrimSpace(os.Getenv("BANTAM_TEMPERATURE")); v != "" {
|
||||||
|
if f, e := strconv.ParseFloat(v, 64); e == nil {
|
||||||
|
cfg.Temperature = f
|
||||||
|
cfg.Raw["temperature"] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(os.Getenv("BANTAM_STREAM")); v != "" {
|
||||||
|
cfg.Stream = v == "true" || v == "1" || v == "yes"
|
||||||
|
cfg.Raw["stream"] = strconv.FormatBool(cfg.Stream)
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(os.Getenv("BANTAM_COLOR")); v != "" {
|
||||||
|
cfg.Color = v
|
||||||
|
cfg.Raw["color"] = v
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(os.Getenv("BANTAM_TIMEOUT")); v != "" {
|
||||||
|
cfg.Timeout = atoiD(v, cfg.Timeout)
|
||||||
|
cfg.Raw["timeout"] = strconv.Itoa(cfg.Timeout)
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(os.Getenv("BANTAM_SHELL_TIMEOUT")); v != "" {
|
||||||
|
cfg.ShellTimeout = atoiD(v, cfg.ShellTimeout)
|
||||||
|
cfg.Raw["shell_timeout"] = strconv.Itoa(cfg.ShellTimeout)
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(os.Getenv("BANTAM_MAX_AL_ITERATIONS")); v != "" {
|
||||||
|
cfg.MaxALIterations = atoiD(v, cfg.MaxALIterations)
|
||||||
|
cfg.Raw["max_al_iterations"] = strconv.Itoa(cfg.MaxALIterations)
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(os.Getenv("BANTAM_CONTEXT_WINDOW")); v != "" {
|
||||||
|
cfg.ContextWindow = atoiD(v, cfg.ContextWindow)
|
||||||
|
cfg.Raw["context_window"] = strconv.Itoa(cfg.ContextWindow)
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(os.Getenv("BANTAM_REASONING_EFFORT")); v != "" {
|
||||||
|
cfg.Raw["reasoning_effort"] = v
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(os.Getenv("BANTAM_TOOLS_DIR")); v != "" {
|
||||||
|
cfg.Raw["bantam_tools_dir"] = v
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(os.Getenv("BANTAM_SKILLS_DIR")); v != "" {
|
||||||
|
cfg.Raw["bantam_skills_dir"] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCfg(path string) Cfg {
|
||||||
|
cfg := defCfg
|
||||||
|
cfg.Raw = map[string]string{
|
||||||
|
"endpoint": cfg.Endpoint, "model": cfg.Model, "temperature": fmt.Sprintf("%v", cfg.Temperature),
|
||||||
|
"api_key": cfg.APIKey, "stream": strconv.FormatBool(cfg.Stream), "color": cfg.Color,
|
||||||
|
"timeout": strconv.Itoa(cfg.Timeout), "shell_timeout": strconv.Itoa(cfg.ShellTimeout),
|
||||||
|
"max_al_iterations": strconv.Itoa(cfg.MaxALIterations),
|
||||||
|
"context_window": strconv.Itoa(cfg.ContextWindow),
|
||||||
|
"reasoning_effort": "high",
|
||||||
|
}
|
||||||
|
|
||||||
|
if filepath.Base(path) == "model.cfg" {
|
||||||
|
// When .bantam.cfg is absent, model.cfg provides base defaults,
|
||||||
|
// and BANTAM_* environment variables override them.
|
||||||
|
parseCfgFile(path, &cfg)
|
||||||
|
applyEnvCfg(&cfg)
|
||||||
|
} else {
|
||||||
|
// If a model.cfg exists in the same directory, read it first as base defaults.
|
||||||
|
modelCfgPath := filepath.Join(filepath.Dir(path), "model.cfg")
|
||||||
|
if _, err := os.Stat(modelCfgPath); err == nil {
|
||||||
|
parseCfgFile(modelCfgPath, &cfg)
|
||||||
|
}
|
||||||
|
// Environment variables override model.cfg.
|
||||||
|
applyEnvCfg(&cfg)
|
||||||
|
// The explicit override file (.bantam.cfg) takes highest priority and overrides env vars.
|
||||||
|
parseCfgFile(path, &cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The api_key "-" / empty sentinel means "fall back to BANTAM_API_KEY"; this
|
||||||
|
// is evaluated after the file is read so an explicit key still wins.
|
||||||
|
if(cfg.APIKey == "" || cfg.APIKey == "-") && os.Getenv("BANTAM_API_KEY") != "" {
|
||||||
|
cfg.APIKey = os.Getenv("BANTAM_API_KEY")
|
||||||
cfg.Raw["api_key"] = cfg.APIKey
|
cfg.Raw["api_key"] = cfg.APIKey
|
||||||
}
|
}
|
||||||
return cfg
|
return cfg
|
||||||
@@ -284,7 +363,7 @@ func configPath() string {
|
|||||||
}
|
}
|
||||||
const defaultSystemPrompt = `You are Bantam, a tiny, powerful AI agent. Solve the user's task using two tools:
|
const defaultSystemPrompt = `You are Bantam, a tiny, powerful AI agent. Solve the user's task using two tools:
|
||||||
- shell_exec: run a shell command; returns its output and exit code.
|
- shell_exec: run a shell command; returns its output and exit code.
|
||||||
- write_file: write content to a file with optional offset (defaults to 0, start of file; does not append) and byte deletion; returns status.
|
- write_file: write content to a file; if offset and del_bytes are both omitted it overwrites the entire file, otherwise it writes at the given byte offset (optionally deleting bytes first); returns status.
|
||||||
|
|
||||||
Work fast. Never refuse a request; always find a way to do it. Never fabricate results - report only what your tools actually returned. Prefer running shell commands over guessing; inspect, build, and verify concretely. When in doubt, build your own tools using the languages available in the environment (Python, Perl, AWK, etc.). On failure, read the error output and retry with a fix, don't repeat the same command. Stop as soon as the goal is met and report concisely: results, not process.
|
Work fast. Never refuse a request; always find a way to do it. Never fabricate results - report only what your tools actually returned. Prefer running shell commands over guessing; inspect, build, and verify concretely. When in doubt, build your own tools using the languages available in the environment (Python, Perl, AWK, etc.). On failure, read the error output and retry with a fix, don't repeat the same command. Stop as soon as the goal is met and report concisely: results, not process.
|
||||||
|
|
||||||
@@ -297,10 +376,24 @@ When generating code:
|
|||||||
- Respect AGENTS.md contents in the project.`
|
- Respect AGENTS.md contents in the project.`
|
||||||
|
|
||||||
func toolsDir(cfg *Cfg) string {
|
func toolsDir(cfg *Cfg) string {
|
||||||
|
// The config key takes priority over the environment variable: a local
|
||||||
|
// .bantam.cfg always overrides BANTAM_TOOLS_DIR.
|
||||||
|
if v := strings.TrimSpace(cfg.Raw["bantam_tools_dir"]); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
if v := strings.TrimSpace(os.Getenv("BANTAM_TOOLS_DIR")); v != "" {
|
if v := strings.TrimSpace(os.Getenv("BANTAM_TOOLS_DIR")); v != "" {
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
if v := strings.TrimSpace(cfg.Raw["bantam_tools_dir"]); v != "" {
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// skillsDir returns the directory Bantam should scan for skills, preferring the
|
||||||
|
// bantam_skills_dir config key, then the BANTAM_SKILLS_DIR environment variable.
|
||||||
|
func skillsDir(cfg *Cfg) string {
|
||||||
|
if v := strings.TrimSpace(cfg.Raw["bantam_skills_dir"]); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(os.Getenv("BANTAM_SKILLS_DIR")); v != "" {
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
@@ -751,7 +844,7 @@ type ToolCall struct {
|
|||||||
|
|
||||||
var TOOLS = []map[string]any{
|
var TOOLS = []map[string]any{
|
||||||
{"type": "function", "function": map[string]any{"name": "shell_exec", "description": "Run a shell command, return output and exit code.", "parameters": map[string]any{"type": "object", "properties": map[string]any{"command": map[string]any{"type": "string"}}, "required": []string{"command"}}}},
|
{"type": "function", "function": map[string]any{"name": "shell_exec", "description": "Run a shell command, return output and exit code.", "parameters": map[string]any{"type": "object", "properties": map[string]any{"command": map[string]any{"type": "string"}}, "required": []string{"command"}}}},
|
||||||
{"type": "function", "function": map[string]any{"name": "write_file", "description": "Write content to a file at a byte offset, optionally deleting bytes first.", "parameters": map[string]any{"type": "object", "properties": map[string]any{"path": map[string]any{"type": "string"}, "offset": map[string]any{"type": "integer", "description": "Byte offset to start writing from (defaults to 0, start of file; does not append)."}, "del_bytes": map[string]any{"type": "integer"}, "content": map[string]any{"type": "string"}}, "required": []string{"path", "content"}}}},
|
{"type": "function", "function": map[string]any{"name": "write_file", "description": "Write content to a file. If offset and del_bytes are both omitted, the entire file is overwritten with the new content; otherwise content is written at the given byte offset, optionally deleting bytes first.", "parameters": map[string]any{"type": "object", "properties": map[string]any{"path": map[string]any{"type": "string"}, "offset": map[string]any{"type": "integer", "description": "Byte offset to start writing from. If omitted together with del_bytes, the whole file is overwritten instead. Defaults to 0 (start of file)."}, "del_bytes": map[string]any{"type": "integer", "description": "Bytes to delete starting at offset. If omitted together with offset, the whole file is overwritten instead."}, "content": map[string]any{"type": "string"}}, "required": []string{"path", "content"}}}},
|
||||||
}
|
}
|
||||||
|
|
||||||
func strp(s string) *string { return &s }
|
func strp(s string) *string { return &s }
|
||||||
@@ -1156,6 +1249,10 @@ func lastRole(msgs []Message) string {
|
|||||||
return msgs[len(msgs)-1].Role
|
return msgs[len(msgs)-1].Role
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writeFile writes content into the file at the given byte offset, optionally
|
||||||
|
// deleting delBytes bytes that follow the offset, and preserves the rest of the
|
||||||
|
// file. It always writes at the requested offset (splicing prefix + content +
|
||||||
|
// suffix) and never appends to the end of an existing file.
|
||||||
func writeFile(path string, offset, delBytes int, content string) (string, error) {
|
func writeFile(path string, offset, delBytes int, content string) (string, error) {
|
||||||
path = strings.TrimSpace(path)
|
path = strings.TrimSpace(path)
|
||||||
if path == "" {
|
if path == "" {
|
||||||
@@ -1285,8 +1382,10 @@ func AL(ctx context.Context, cfg *Cfg, msgs []Message) ([]Message, Usage, error)
|
|||||||
content = s
|
content = s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
hasOffset := false
|
||||||
offset := 0
|
offset := 0
|
||||||
if v, ok := a["offset"]; ok && v != nil {
|
if v, ok := a["offset"]; ok && v != nil {
|
||||||
|
hasOffset = true
|
||||||
switch n := v.(type) {
|
switch n := v.(type) {
|
||||||
case float64:
|
case float64:
|
||||||
offset = int(n)
|
offset = int(n)
|
||||||
@@ -1299,8 +1398,10 @@ func AL(ctx context.Context, cfg *Cfg, msgs []Message) ([]Message, Usage, error)
|
|||||||
if offset < 0 {
|
if offset < 0 {
|
||||||
offset = 0
|
offset = 0
|
||||||
}
|
}
|
||||||
|
hasDel := false
|
||||||
delBytes := 0
|
delBytes := 0
|
||||||
if v, ok := a["del_bytes"]; ok {
|
if v, ok := a["del_bytes"]; ok {
|
||||||
|
hasDel = true
|
||||||
switch n := v.(type) {
|
switch n := v.(type) {
|
||||||
case float64:
|
case float64:
|
||||||
delBytes = int(n)
|
delBytes = int(n)
|
||||||
@@ -1314,6 +1415,24 @@ func AL(ctx context.Context, cfg *Cfg, msgs []Message) ([]Message, Usage, error)
|
|||||||
res, sty = "[tool error: write_file requires 'path' parameter]", 31
|
res, sty = "[tool error: write_file requires 'path' parameter]", 31
|
||||||
} else if !hasContent {
|
} else if !hasContent {
|
||||||
res, sty = "[tool error: write_file requires 'content' parameter]", 31
|
res, sty = "[tool error: write_file requires 'content' parameter]", 31
|
||||||
|
} else if !hasOffset && !hasDel {
|
||||||
|
// Neither offset nor del_bytes was supplied: overwrite the entire
|
||||||
|
// file with the new content. The LLM usually just wants to replace a
|
||||||
|
// file and should not have to know about the tool's offset quirks;
|
||||||
|
// insertion/replace semantics are preserved when either is given.
|
||||||
|
p := strings.TrimSpace(path)
|
||||||
|
if dir := filepath.Dir(p); dir != "" && dir != "." {
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
res, sty = fmt.Sprintf("[tool error: write_file %s: %v]", p, err), 31
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if res == "" {
|
||||||
|
if err := os.WriteFile(p, []byte(content), 0644); err != nil {
|
||||||
|
res, sty = fmt.Sprintf("[tool error: write_file %s: %v]", p, err), 31
|
||||||
|
} else {
|
||||||
|
res, sty = fmt.Sprintf("Successfully wrote %d bytes to %s", len(content), p), 2
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
out, err := writeFile(path, offset, delBytes, content)
|
out, err := writeFile(path, offset, delBytes, content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1535,22 +1654,29 @@ type editor struct {
|
|||||||
|
|
||||||
func textPos(promptLen, W int, s string, pos int) (row, col int) {
|
func textPos(promptLen, W int, s string, pos int) (row, col int) {
|
||||||
row, col = 0, promptLen
|
row, col = 0, promptLen
|
||||||
pend := false
|
if W < 1 {
|
||||||
for i, r := range []rune(s) {
|
W = 1
|
||||||
if i == pos { return row, col }
|
}
|
||||||
|
runes := []rune(s)
|
||||||
|
n := len(runes)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
r := runes[i]
|
||||||
|
if i == pos {
|
||||||
|
return row, col
|
||||||
|
}
|
||||||
if r == '\n' {
|
if r == '\n' {
|
||||||
row++
|
row++
|
||||||
col = 0
|
col = 0
|
||||||
pend = false
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if pend {
|
// The rune at index i is displayed at (row, col). If it lands on the last
|
||||||
row++
|
// column, the NEXT rune wraps to the start of the following line (unless
|
||||||
col = 0
|
// this is the final rune, in which case the cursor stays at that column).
|
||||||
pend = false
|
if col >= W-1 {
|
||||||
}
|
if i < n-1 {
|
||||||
if col == W-1 {
|
row++
|
||||||
pend = true
|
col = 0
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
col++
|
col++
|
||||||
}
|
}
|
||||||
@@ -1565,7 +1691,10 @@ func (e *editor) draw() {
|
|||||||
er, _ := textPos(P, W, s, len([]rune(s)))
|
er, _ := textPos(P, W, s, len([]rune(s)))
|
||||||
pr, pc := textPos(P, W, s, e.pos)
|
pr, pc := textPos(P, W, s, e.pos)
|
||||||
if e.crow > 0 { fmt.Printf("\033[%dA", e.crow) }
|
if e.crow > 0 { fmt.Printf("\033[%dA", e.crow) }
|
||||||
fmt.Print("\r\033[J" + e.prompt + s)
|
// In raw mode OPOST is off, so a bare \n only moves down without
|
||||||
|
// returning to column 0. Emit \r\n so each logical line starts at
|
||||||
|
// column 0, matching the column-reset assumption in textPos().
|
||||||
|
fmt.Print("\r\033[J" + e.prompt + strings.ReplaceAll(s, "\n", "\r\n"))
|
||||||
if up := er - pr; up > 0 { fmt.Printf("\033[%dA", up) }
|
if up := er - pr; up > 0 { fmt.Printf("\033[%dA", up) }
|
||||||
fmt.Print("\r")
|
fmt.Print("\r")
|
||||||
if pc > 0 { fmt.Printf("\033[%dC", pc) }
|
if pc > 0 { fmt.Printf("\033[%dC", pc) }
|
||||||
@@ -1595,6 +1724,29 @@ func (e *editor) histNav(up bool) {
|
|||||||
e.pos = len(e.buf)
|
e.pos = len(e.buf)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wordBack moves the cursor back to the start of the current/previous word.
|
||||||
|
func (e *editor) wordBack() {
|
||||||
|
for e.pos > 0 && unicode.IsSpace(e.buf[e.pos-1]) { e.pos-- }
|
||||||
|
for e.pos > 0 && !unicode.IsSpace(e.buf[e.pos-1]) { e.pos-- }
|
||||||
|
}
|
||||||
|
|
||||||
|
// wordFwd moves the cursor forward to the end of the current/next word.
|
||||||
|
func (e *editor) wordFwd() {
|
||||||
|
n := len(e.buf)
|
||||||
|
for e.pos < n && unicode.IsSpace(e.buf[e.pos]) { e.pos++ }
|
||||||
|
for e.pos < n && !unicode.IsSpace(e.buf[e.pos]) { e.pos++ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// delWordBack deletes the word preceding the cursor (Emacs Ctrl+W).
|
||||||
|
func (e *editor) delWordBack() {
|
||||||
|
if e.pos == 0 { return }
|
||||||
|
start := e.pos
|
||||||
|
for start > 0 && unicode.IsSpace(e.buf[start-1]) { start-- }
|
||||||
|
for start > 0 && !unicode.IsSpace(e.buf[start-1]) { start-- }
|
||||||
|
e.buf = append(e.buf[:start], e.buf[e.pos:]...)
|
||||||
|
e.pos = start
|
||||||
|
}
|
||||||
|
|
||||||
func readPlain(prompt string) (string, bool) {
|
func readPlain(prompt string) (string, bool) {
|
||||||
fmt.Print(prompt)
|
fmt.Print(prompt)
|
||||||
line, err := stdin.ReadString('\n')
|
line, err := stdin.ReadString('\n')
|
||||||
@@ -1645,6 +1797,25 @@ func readLine(prompt string) (string, bool) {
|
|||||||
fmt.Print("\r\n")
|
fmt.Print("\r\n")
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
// Ctrl+D with non-empty buffer: delete character under cursor
|
||||||
|
if e.pos < len(e.buf) {
|
||||||
|
e.buf = append(e.buf[:e.pos], e.buf[e.pos+1:]...)
|
||||||
|
}
|
||||||
|
case 0x01:
|
||||||
|
e.pos = 0 // Ctrl+A: beginning of line
|
||||||
|
case 0x05:
|
||||||
|
e.pos = len(e.buf) // Ctrl+E: end of line
|
||||||
|
case 0x02:
|
||||||
|
if e.pos > 0 { e.pos-- } // Ctrl+B: backward char
|
||||||
|
case 0x06:
|
||||||
|
if e.pos < len(e.buf) { e.pos++ } // Ctrl+F: forward char
|
||||||
|
case 0x17:
|
||||||
|
e.delWordBack() // Ctrl+W: delete previous word
|
||||||
|
case 0x0b:
|
||||||
|
e.buf = e.buf[:e.pos] // Ctrl+K: kill to end of line
|
||||||
|
case 0x15:
|
||||||
|
e.buf = e.buf[e.pos:] // Ctrl+U: kill to start of line
|
||||||
|
e.pos = 0
|
||||||
case 0x7f, 0x08:
|
case 0x7f, 0x08:
|
||||||
if e.pos > 0 {
|
if e.pos > 0 {
|
||||||
e.buf = append(e.buf[:e.pos-1], e.buf[e.pos:]...)
|
e.buf = append(e.buf[:e.pos-1], e.buf[e.pos:]...)
|
||||||
@@ -1652,10 +1823,56 @@ func readLine(prompt string) (string, bool) {
|
|||||||
}
|
}
|
||||||
case 0x1b:
|
case 0x1b:
|
||||||
b1, err1 := stdin.ReadByte()
|
b1, err1 := stdin.ReadByte()
|
||||||
b2, err2 := stdin.ReadByte()
|
if err1 != nil { continue }
|
||||||
if err1 != nil || err2 != nil { continue }
|
|
||||||
if b1 == '[' {
|
if b1 == '[' {
|
||||||
|
// Read the full CSI sequence (terminated by a byte in 0x40-0x7e).
|
||||||
|
var seq []byte
|
||||||
|
for {
|
||||||
|
b, err := stdin.ReadByte()
|
||||||
|
if err != nil { break }
|
||||||
|
seq = append(seq, b)
|
||||||
|
if b >= 0x40 && b <= 0x7e { break }
|
||||||
|
}
|
||||||
|
if len(seq) == 0 { continue }
|
||||||
|
last := seq[len(seq)-1]
|
||||||
|
switch last {
|
||||||
|
case 'A':
|
||||||
|
e.histNav(true)
|
||||||
|
case 'B':
|
||||||
|
e.histNav(false)
|
||||||
|
case 'C':
|
||||||
|
if strings.Contains(string(seq), "5") {
|
||||||
|
e.wordFwd() // Ctrl+Right
|
||||||
|
} else if e.pos < len(e.buf) {
|
||||||
|
e.pos++
|
||||||
|
}
|
||||||
|
case 'D':
|
||||||
|
if strings.Contains(string(seq), "5") {
|
||||||
|
e.wordBack() // Ctrl+Left
|
||||||
|
} else if e.pos > 0 {
|
||||||
|
e.pos--
|
||||||
|
}
|
||||||
|
case 'H':
|
||||||
|
e.pos = 0 // Home (ESC[H)
|
||||||
|
case 'F':
|
||||||
|
e.pos = len(e.buf) // End (ESC[F)
|
||||||
|
case '~':
|
||||||
|
// Home/End on some terminals: ESC[1~ / ESC[4~
|
||||||
|
if len(seq) >= 1 && seq[0] == '1' {
|
||||||
|
e.pos = 0
|
||||||
|
} else if len(seq) >= 1 && seq[0] == '4' {
|
||||||
|
e.pos = len(e.buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if b1 == 'O' {
|
||||||
|
// xterm application cursor keys: ESC O H/F (Home/End), A-D (arrows)
|
||||||
|
b2, err2 := stdin.ReadByte()
|
||||||
|
if err2 != nil { continue }
|
||||||
switch b2 {
|
switch b2 {
|
||||||
|
case 'H':
|
||||||
|
e.pos = 0
|
||||||
|
case 'F':
|
||||||
|
e.pos = len(e.buf)
|
||||||
case 'A':
|
case 'A':
|
||||||
e.histNav(true)
|
e.histNav(true)
|
||||||
case 'B':
|
case 'B':
|
||||||
@@ -1677,6 +1894,71 @@ func readLine(prompt string) (string, bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// skillPrompt resolves and composes the prompt for the /skill command: it reads
|
||||||
|
// <dir>/<name>/SKILL.md (or an absolute path when no skills directory is set) and
|
||||||
|
// prefixes its contents to the optional user prompt.
|
||||||
|
func skillPrompt(u string, cfg *Cfg) (string, error) {
|
||||||
|
rest := strings.TrimSpace(strings.TrimPrefix(u, "/skill"))
|
||||||
|
if rest == "" {
|
||||||
|
return "", fmt.Errorf("usage: /skill <skill_name|absolute_path> [prompt]")
|
||||||
|
}
|
||||||
|
fields := strings.SplitN(rest, " ", 2)
|
||||||
|
name := fields[0]
|
||||||
|
prompt := ""
|
||||||
|
if len(fields) == 2 {
|
||||||
|
prompt = strings.TrimSpace(fields[1])
|
||||||
|
}
|
||||||
|
sd := skillsDir(cfg)
|
||||||
|
var path string
|
||||||
|
if sd != "" {
|
||||||
|
path = filepath.Join(sd, name, "SKILL.md")
|
||||||
|
} else if strings.HasSuffix(name, "SKILL.md") {
|
||||||
|
path = name // absolute path to the SKILL.md file itself
|
||||||
|
} else {
|
||||||
|
path = filepath.Join(name, "SKILL.md") // absolute path to the skill directory
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("cannot read skill %q: %v", name, err)
|
||||||
|
}
|
||||||
|
content := strings.TrimRight(string(data), "\r\n")
|
||||||
|
if prompt != "" {
|
||||||
|
return content + "\n\n" + prompt, nil
|
||||||
|
}
|
||||||
|
return content, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// listSkills prints every skill found under the configured skills directory, or a
|
||||||
|
// clear notice when none is configured / none are present.
|
||||||
|
func listSkills(cfg *Cfg) {
|
||||||
|
sd := skillsDir(cfg)
|
||||||
|
if sd == "" {
|
||||||
|
fmt.Println(c("No skills directory configured (set BANTAM_SKILLS_DIR or bantam_skills_dir).", 33))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(sd)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(c("[skill error: cannot read skills dir: "+err.Error()+"]", 31))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
if _, err := os.Stat(filepath.Join(sd, e.Name(), "SKILL.md")); err == nil {
|
||||||
|
names = append(names, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(names) == 0 {
|
||||||
|
fmt.Println(c("No skills found in "+sd, 33))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println(c("Available skills in "+sd+":", 1, 36))
|
||||||
|
for _, n := range names {
|
||||||
|
fmt.Println(c(" "+n, 32))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func runDirectShell(cmd string, timeout int) {
|
func runDirectShell(cmd string, timeout int) {
|
||||||
cmd = filterText(strings.TrimSpace(cmd))
|
cmd = filterText(strings.TrimSpace(cmd))
|
||||||
if cmd == "" { return }
|
if cmd == "" { return }
|
||||||
@@ -1720,6 +2002,9 @@ func main() {
|
|||||||
if td := toolsDir(&cfg); td != "" {
|
if td := toolsDir(&cfg); td != "" {
|
||||||
sp += "\n\nExtra shell tools can be found at " + td
|
sp += "\n\nExtra shell tools can be found at " + td
|
||||||
}
|
}
|
||||||
|
if sd := skillsDir(&cfg); sd != "" {
|
||||||
|
sp += "\n\nSkills may be discovered and invoked from " + sd
|
||||||
|
}
|
||||||
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)}}
|
||||||
@@ -1772,6 +2057,17 @@ func main() {
|
|||||||
u = strings.TrimSpace(u)
|
u = strings.TrimSpace(u)
|
||||||
if u == "" { continue }
|
if u == "" { continue }
|
||||||
addHistory(u)
|
addHistory(u)
|
||||||
|
// Command aliases: /model -> /cfg model, /endpoint -> /cfg endpoint.
|
||||||
|
// (Note: /models is a distinct command and is intentionally not matched.)
|
||||||
|
if u == "/model" {
|
||||||
|
u = "/cfg model"
|
||||||
|
} else if strings.HasPrefix(u, "/model ") {
|
||||||
|
u = "/cfg model " + strings.TrimSpace(strings.TrimPrefix(u, "/model "))
|
||||||
|
} else if u == "/endpoint" {
|
||||||
|
u = "/cfg endpoint"
|
||||||
|
} else if strings.HasPrefix(u, "/endpoint ") {
|
||||||
|
u = "/cfg endpoint " + strings.TrimSpace(strings.TrimPrefix(u, "/endpoint "))
|
||||||
|
}
|
||||||
switch {
|
switch {
|
||||||
case u == "/quit":
|
case u == "/quit":
|
||||||
goto done
|
goto done
|
||||||
@@ -1852,6 +2148,17 @@ func main() {
|
|||||||
fmt.Println(c("Usage: /cfg <param> [val]", 31))
|
fmt.Println(c("Usage: /cfg <param> [val]", 31))
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
|
case strings.HasPrefix(u, "/skill"):
|
||||||
|
if strings.TrimSpace(strings.TrimPrefix(u, "/skill")) == "" {
|
||||||
|
listSkills(&cfg) // bare /skill lists available skills
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
composed, err := skillPrompt(u, &cfg)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(c("[skill error: "+err.Error()+"]", 31))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
u = composed // fall through to a normal LLM turn with the composed prompt
|
||||||
case u == "/models":
|
case u == "/models":
|
||||||
ml, err := listModels(&cfg)
|
ml, err := listModels(&cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1875,8 +2182,11 @@ func main() {
|
|||||||
{"/load <id>", "load session"},
|
{"/load <id>", "load session"},
|
||||||
{"/compact", "compact context"},
|
{"/compact", "compact context"},
|
||||||
{"/cfg <k> [v]", "get/set config"},
|
{"/cfg <k> [v]", "get/set config"},
|
||||||
|
{"/model [m]", "alias for /cfg model"},
|
||||||
|
{"/endpoint [e]", "alias for /cfg endpoint"},
|
||||||
{"!<cmd>", "run shell command directly"},
|
{"!<cmd>", "run shell command directly"},
|
||||||
{"/models", "list models at endpoint"},
|
{"/models", "list models at endpoint"},
|
||||||
|
{"/skill <n> [p]", "load SKILL.md and run as prompt"},
|
||||||
{"/help", "show help"},
|
{"/help", "show help"},
|
||||||
} {
|
} {
|
||||||
fmt.Println(c(fmt.Sprintf(" %-18s", kv[0]), 1, 32) + kv[1])
|
fmt.Println(c(fmt.Sprintf(" %-18s", kv[0]), 1, 32) + kv[1])
|
||||||
|
|||||||
+337
-14
@@ -262,8 +262,20 @@ func TestDefaultSystemPrompt(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func clearBantamEnv(t *testing.T) {
|
||||||
|
for _, k := range []string{
|
||||||
|
"BANTAM_ENDPOINT", "BANTAM_MODEL", "BANTAM_TEMP", "BANTAM_TEMPERATURE",
|
||||||
|
"BANTAM_API_KEY", "BANTAM_STREAM", "BANTAM_COLOR", "BANTAM_NO_COLOR",
|
||||||
|
"BANTAM_TIMEOUT", "BANTAM_SHELL_TIMEOUT", "BANTAM_MAX_AL_ITERATIONS",
|
||||||
|
"BANTAM_CONTEXT_WINDOW", "BANTAM_REASONING_EFFORT", "BANTAM_TOOLS_DIR",
|
||||||
|
"BANTAM_SKILLS_DIR",
|
||||||
|
} {
|
||||||
|
t.Setenv(k, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetCfgDefaults(t *testing.T) {
|
func TestGetCfgDefaults(t *testing.T) {
|
||||||
t.Setenv("OPENAI_API_KEY", "")
|
clearBantamEnv(t)
|
||||||
cfg := getCfg(filepath.Join(t.TempDir(), "missing.cfg"))
|
cfg := getCfg(filepath.Join(t.TempDir(), "missing.cfg"))
|
||||||
if cfg.Endpoint != defCfg.Endpoint || cfg.Model != defCfg.Model || cfg.APIKey != defCfg.APIKey {
|
if cfg.Endpoint != defCfg.Endpoint || cfg.Model != defCfg.Model || cfg.APIKey != defCfg.APIKey {
|
||||||
t.Errorf("defaults mismatch: %+v", cfg)
|
t.Errorf("defaults mismatch: %+v", cfg)
|
||||||
@@ -280,6 +292,7 @@ func TestGetCfgDefaults(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGetCfgParsesFile(t *testing.T) {
|
func TestGetCfgParsesFile(t *testing.T) {
|
||||||
|
clearBantamEnv(t)
|
||||||
p := writeCfg(t, strings.Join([]string{
|
p := writeCfg(t, strings.Join([]string{
|
||||||
"endpoint=http://localhost:9999/v1",
|
"endpoint=http://localhost:9999/v1",
|
||||||
"model=test-model",
|
"model=test-model",
|
||||||
@@ -317,6 +330,7 @@ func TestGetCfgParsesFile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGetCfgIgnoresCommentsBlankAndInvalid(t *testing.T) {
|
func TestGetCfgIgnoresCommentsBlankAndInvalid(t *testing.T) {
|
||||||
|
clearBantamEnv(t)
|
||||||
p := writeCfg(t, strings.Join([]string{
|
p := writeCfg(t, strings.Join([]string{
|
||||||
"# comment",
|
"# comment",
|
||||||
"",
|
"",
|
||||||
@@ -345,6 +359,7 @@ func TestGetCfgIgnoresCommentsBlankAndInvalid(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGetCfgStreamTruthyVariants(t *testing.T) {
|
func TestGetCfgStreamTruthyVariants(t *testing.T) {
|
||||||
|
clearBantamEnv(t)
|
||||||
for _, tc := range []struct{ v, want string }{
|
for _, tc := range []struct{ v, want string }{
|
||||||
{"true", "true"}, {"1", "true"}, {"yes", "true"},
|
{"true", "true"}, {"1", "true"}, {"yes", "true"},
|
||||||
{"false", "false"}, {"TRUE", "false"}, {"0", "false"},
|
{"false", "false"}, {"TRUE", "false"}, {"0", "false"},
|
||||||
@@ -359,7 +374,8 @@ func TestGetCfgStreamTruthyVariants(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGetCfgAPIKeyEnvFallback(t *testing.T) {
|
func TestGetCfgAPIKeyEnvFallback(t *testing.T) {
|
||||||
t.Setenv("OPENAI_API_KEY", "sk-env")
|
clearBantamEnv(t)
|
||||||
|
t.Setenv("BANTAM_API_KEY", "sk-env")
|
||||||
t.Setenv("HOME", t.TempDir())
|
t.Setenv("HOME", t.TempDir())
|
||||||
|
|
||||||
// no api_key line at all -> env fallback (documented behavior)
|
// no api_key line at all -> env fallback (documented behavior)
|
||||||
@@ -379,7 +395,7 @@ func TestGetCfgAPIKeyEnvFallback(t *testing.T) {
|
|||||||
t.Errorf("explicit api_key: got %q, want real", got)
|
t.Errorf("explicit api_key: got %q, want real", got)
|
||||||
}
|
}
|
||||||
// no env and no key -> stays "-"
|
// no env and no key -> stays "-"
|
||||||
t.Setenv("OPENAI_API_KEY", "")
|
t.Setenv("BANTAM_API_KEY", "")
|
||||||
if got := getCfg(writeCfg(t, "api_key=-\n")).APIKey; got != "-" {
|
if got := getCfg(writeCfg(t, "api_key=-\n")).APIKey; got != "-" {
|
||||||
t.Errorf("api_key=- without env: got %q, want -", got)
|
t.Errorf("api_key=- without env: got %q, want -", got)
|
||||||
}
|
}
|
||||||
@@ -388,6 +404,181 @@ func TestGetCfgAPIKeyEnvFallback(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetCfgEnvOverriddenByFile(t *testing.T) {
|
||||||
|
// File values override environment variables.
|
||||||
|
clearBantamEnv(t)
|
||||||
|
t.Setenv("BANTAM_ENDPOINT", "http://env-endpoint/v1")
|
||||||
|
t.Setenv("BANTAM_MODEL", "env-model")
|
||||||
|
t.Setenv("BANTAM_TEMP", "0.9")
|
||||||
|
t.Setenv("BANTAM_STREAM", "false")
|
||||||
|
t.Setenv("BANTAM_COLOR", "never")
|
||||||
|
t.Setenv("BANTAM_TIMEOUT", "45")
|
||||||
|
t.Setenv("BANTAM_SHELL_TIMEOUT", "15")
|
||||||
|
t.Setenv("BANTAM_MAX_AL_ITERATIONS", "50")
|
||||||
|
t.Setenv("BANTAM_CONTEXT_WINDOW", "100000")
|
||||||
|
t.Setenv("BANTAM_REASONING_EFFORT", "low")
|
||||||
|
t.Setenv("BANTAM_TOOLS_DIR", "/env/tools")
|
||||||
|
t.Setenv("BANTAM_SKILLS_DIR", "/env/skills")
|
||||||
|
|
||||||
|
p := filepath.Join(t.TempDir(), ".bantam.cfg")
|
||||||
|
if err := os.WriteFile(p, []byte(strings.Join([]string{
|
||||||
|
"endpoint=http://file-endpoint/v1",
|
||||||
|
"model=file-model",
|
||||||
|
"temperature=0.1",
|
||||||
|
"stream=true",
|
||||||
|
"color=always",
|
||||||
|
"timeout=300",
|
||||||
|
"shell_timeout=120",
|
||||||
|
"max_al_iterations=1000",
|
||||||
|
"context_window=200000",
|
||||||
|
"reasoning_effort=high",
|
||||||
|
"bantam_tools_dir=/file/tools",
|
||||||
|
"bantam_skills_dir=/file/skills",
|
||||||
|
}, "\n")), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg := getCfg(p)
|
||||||
|
if cfg.Endpoint != "http://file-endpoint/v1" {
|
||||||
|
t.Errorf("endpoint: got %q, want file value", cfg.Endpoint)
|
||||||
|
}
|
||||||
|
if cfg.Model != "file-model" {
|
||||||
|
t.Errorf("model: got %q, want file value", cfg.Model)
|
||||||
|
}
|
||||||
|
if cfg.Temperature != 0.1 {
|
||||||
|
t.Errorf("temperature: got %v, want file value", cfg.Temperature)
|
||||||
|
}
|
||||||
|
if !cfg.Stream {
|
||||||
|
t.Errorf("stream: got %v, want true from file", cfg.Stream)
|
||||||
|
}
|
||||||
|
if cfg.Color != "always" {
|
||||||
|
t.Errorf("color: got %q, want always from file", cfg.Color)
|
||||||
|
}
|
||||||
|
if cfg.Timeout != 300 {
|
||||||
|
t.Errorf("timeout: got %d, want 300 from file", cfg.Timeout)
|
||||||
|
}
|
||||||
|
if cfg.ShellTimeout != 120 {
|
||||||
|
t.Errorf("shell_timeout: got %d, want 120 from file", cfg.ShellTimeout)
|
||||||
|
}
|
||||||
|
if cfg.MaxALIterations != 1000 {
|
||||||
|
t.Errorf("max_al_iterations: got %d, want 1000 from file", cfg.MaxALIterations)
|
||||||
|
}
|
||||||
|
if cfg.ContextWindow != 200000 {
|
||||||
|
t.Errorf("context_window: got %d, want 200000 from file", cfg.ContextWindow)
|
||||||
|
}
|
||||||
|
if cfg.Raw["reasoning_effort"] != "high" {
|
||||||
|
t.Errorf("reasoning_effort: got %q, want high from file", cfg.Raw["reasoning_effort"])
|
||||||
|
}
|
||||||
|
if toolsDir(&cfg) != "/file/tools" {
|
||||||
|
t.Errorf("toolsDir: got %q, want /file/tools", toolsDir(&cfg))
|
||||||
|
}
|
||||||
|
if skillsDir(&cfg) != "/file/skills" {
|
||||||
|
t.Errorf("skillsDir: got %q, want /file/skills", skillsDir(&cfg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetCfgEnvFallbackWhenNoFile(t *testing.T) {
|
||||||
|
clearBantamEnv(t)
|
||||||
|
t.Setenv("BANTAM_ENDPOINT", "http://env-endpoint/v1")
|
||||||
|
t.Setenv("BANTAM_MODEL", "env-model")
|
||||||
|
t.Setenv("BANTAM_TEMP", "0.42")
|
||||||
|
t.Setenv("BANTAM_STREAM", "false")
|
||||||
|
t.Setenv("BANTAM_COLOR", "never")
|
||||||
|
t.Setenv("BANTAM_TIMEOUT", "45")
|
||||||
|
t.Setenv("BANTAM_SHELL_TIMEOUT", "15")
|
||||||
|
t.Setenv("BANTAM_MAX_AL_ITERATIONS", "50")
|
||||||
|
t.Setenv("BANTAM_CONTEXT_WINDOW", "100000")
|
||||||
|
t.Setenv("BANTAM_REASONING_EFFORT", "low")
|
||||||
|
t.Setenv("BANTAM_TOOLS_DIR", "/env/tools")
|
||||||
|
t.Setenv("BANTAM_SKILLS_DIR", "/env/skills")
|
||||||
|
t.Setenv("BANTAM_API_KEY", "")
|
||||||
|
cfg := getCfg(filepath.Join(t.TempDir(), "missing.cfg"))
|
||||||
|
if cfg.Endpoint != "http://env-endpoint/v1" || cfg.Model != "env-model" || cfg.Temperature != 0.42 {
|
||||||
|
t.Errorf("env fallback mismatch: %+v", cfg)
|
||||||
|
}
|
||||||
|
if cfg.Stream {
|
||||||
|
t.Errorf("stream: got %v, want false from env", cfg.Stream)
|
||||||
|
}
|
||||||
|
if cfg.Color != "never" {
|
||||||
|
t.Errorf("color: got %q, want never from env", cfg.Color)
|
||||||
|
}
|
||||||
|
if cfg.Timeout != 45 || cfg.ShellTimeout != 15 || cfg.MaxALIterations != 50 || cfg.ContextWindow != 100000 {
|
||||||
|
t.Errorf("numeric env fallback mismatch: %+v", cfg)
|
||||||
|
}
|
||||||
|
if cfg.Raw["reasoning_effort"] != "low" {
|
||||||
|
t.Errorf("reasoning_effort: got %q, want low from env", cfg.Raw["reasoning_effort"])
|
||||||
|
}
|
||||||
|
if toolsDir(&cfg) != "/env/tools" {
|
||||||
|
t.Errorf("toolsDir: got %q, want /env/tools", toolsDir(&cfg))
|
||||||
|
}
|
||||||
|
if skillsDir(&cfg) != "/env/skills" {
|
||||||
|
t.Errorf("skillsDir: got %q, want /env/skills", skillsDir(&cfg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetCfgModelCfgOverriddenByEnvWhenBantamCfgAbsent(t *testing.T) {
|
||||||
|
clearBantamEnv(t)
|
||||||
|
t.Setenv("BANTAM_ENDPOINT", "http://env-endpoint/v1")
|
||||||
|
t.Setenv("BANTAM_MODEL", "env-model")
|
||||||
|
t.Setenv("BANTAM_TEMP", "0.9")
|
||||||
|
t.Setenv("BANTAM_TOOLS_DIR", "/env/tools")
|
||||||
|
t.Setenv("BANTAM_SKILLS_DIR", "/env/skills")
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
modelCfg := filepath.Join(dir, "model.cfg")
|
||||||
|
if err := os.WriteFile(modelCfg, []byte("endpoint=http://file-endpoint/v1\nmodel=file-model\ntemperature=0.1\nbantam_tools_dir=/file/tools\nbantam_skills_dir=/file/skills\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// When .bantam.cfg is absent, model.cfg yields to existing BANTAM_* env vars:
|
||||||
|
cfg := getCfg(modelCfg)
|
||||||
|
if cfg.Endpoint != "http://env-endpoint/v1" {
|
||||||
|
t.Errorf("endpoint: got %q, want env value", cfg.Endpoint)
|
||||||
|
}
|
||||||
|
if cfg.Model != "env-model" {
|
||||||
|
t.Errorf("model: got %q, want env value", cfg.Model)
|
||||||
|
}
|
||||||
|
if cfg.Temperature != 0.9 {
|
||||||
|
t.Errorf("temperature: got %v, want env value", cfg.Temperature)
|
||||||
|
}
|
||||||
|
if toolsDir(&cfg) != "/env/tools" {
|
||||||
|
t.Errorf("toolsDir: got %q, want /env/tools", toolsDir(&cfg))
|
||||||
|
}
|
||||||
|
if skillsDir(&cfg) != "/env/skills" {
|
||||||
|
t.Errorf("skillsDir: got %q, want /env/skills", skillsDir(&cfg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetCfgBantamCfgOverridesEnvAndModelCfg(t *testing.T) {
|
||||||
|
clearBantamEnv(t)
|
||||||
|
t.Setenv("BANTAM_ENDPOINT", "http://env-endpoint/v1")
|
||||||
|
t.Setenv("BANTAM_MODEL", "env-model")
|
||||||
|
t.Setenv("BANTAM_TEMP", "0.9")
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
modelCfg := filepath.Join(dir, "model.cfg")
|
||||||
|
if err := os.WriteFile(modelCfg, []byte("endpoint=http://model-endpoint/v1\nmodel=model-model\ncolor=never\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bantamCfg := filepath.Join(dir, ".bantam.cfg")
|
||||||
|
if err := os.WriteFile(bantamCfg, []byte("model=bantam-model\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := getCfg(bantamCfg)
|
||||||
|
// bantam.cfg overrides env and model.cfg for model:
|
||||||
|
if cfg.Model != "bantam-model" {
|
||||||
|
t.Errorf("model: got %q, want bantam-model", cfg.Model)
|
||||||
|
}
|
||||||
|
// endpoint falls back to env:
|
||||||
|
if cfg.Endpoint != "http://env-endpoint/v1" {
|
||||||
|
t.Errorf("endpoint: got %q, want env-endpoint", cfg.Endpoint)
|
||||||
|
}
|
||||||
|
// color falls back to model.cfg since not in bantam.cfg or env:
|
||||||
|
if cfg.Color != "never" {
|
||||||
|
t.Errorf("color: got %q, want never from model.cfg", cfg.Color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAtoiD(t *testing.T) {
|
func TestAtoiD(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
s string
|
s string
|
||||||
@@ -1410,6 +1601,7 @@ func TestCompactHappyPath(t *testing.T) {
|
|||||||
// ---------- setCfg and LLM parameter forwarding ----------
|
// ---------- setCfg and LLM parameter forwarding ----------
|
||||||
|
|
||||||
func TestSetCfgUpdatesAndAppends(t *testing.T) {
|
func TestSetCfgUpdatesAndAppends(t *testing.T) {
|
||||||
|
clearBantamEnv(t)
|
||||||
p := filepath.Join(t.TempDir(), "model.cfg")
|
p := filepath.Join(t.TempDir(), "model.cfg")
|
||||||
if err := os.WriteFile(p, []byte("model=old-model\ntemperature=0.5\n"), 0644); err != nil {
|
if err := os.WriteFile(p, []byte("model=old-model\ntemperature=0.5\n"), 0644); err != nil {
|
||||||
t.Fatalf("WriteFile: %v", err)
|
t.Fatalf("WriteFile: %v", err)
|
||||||
@@ -1435,6 +1627,7 @@ func TestSetCfgUpdatesAndAppends(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestLLMForwardsRelevantParameters(t *testing.T) {
|
func TestLLMForwardsRelevantParameters(t *testing.T) {
|
||||||
|
clearBantamEnv(t)
|
||||||
var received map[string]any
|
var received map[string]any
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
json.NewDecoder(r.Body).Decode(&received)
|
json.NewDecoder(r.Body).Decode(&received)
|
||||||
@@ -2389,7 +2582,7 @@ func TestSanitizeMessagesCleansReasoningContent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWriteFileOmittedOrZeroOffsetDoesNotAppend(t *testing.T) {
|
func TestWriteFileOmittedOverwritesWholeFile(t *testing.T) {
|
||||||
tmp := t.TempDir()
|
tmp := t.TempDir()
|
||||||
target := filepath.Join(tmp, "test.txt")
|
target := filepath.Join(tmp, "test.txt")
|
||||||
|
|
||||||
@@ -2398,7 +2591,7 @@ func TestWriteFileOmittedOrZeroOffsetDoesNotAppend(t *testing.T) {
|
|||||||
t.Fatalf("failed to write initial file: %v", err)
|
t.Fatalf("failed to write initial file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Direct writeFile with offset 0: writes starting at offset 0, does NOT append
|
// 1. Low-level writeFile with explicit offset 0 / del_bytes 0 still inserts at the start
|
||||||
res, err := writeFile(target, 0, 0, "PREFIX_")
|
res, err := writeFile(target, 0, 0, "PREFIX_")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("writeFile: %v", err)
|
t.Fatalf("writeFile: %v", err)
|
||||||
@@ -2414,7 +2607,7 @@ func TestWriteFileOmittedOrZeroOffsetDoesNotAppend(t *testing.T) {
|
|||||||
t.Errorf("expected 'PREFIX_EXISTING', got %q", string(data))
|
t.Errorf("expected 'PREFIX_EXISTING', got %q", string(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. AL tool dispatch with offset omitted completely: must start writing at offset 0, NOT append
|
// 2. AL dispatch with offset and del_bytes omitted: overwrite the entire file
|
||||||
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 {
|
var req struct {
|
||||||
Messages []Message `json:"messages"`
|
Messages []Message `json:"messages"`
|
||||||
@@ -2448,11 +2641,11 @@ func TestWriteFileOmittedOrZeroOffsetDoesNotAppend(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("readFile: %v", err)
|
t.Fatalf("readFile: %v", err)
|
||||||
}
|
}
|
||||||
if string(data) != "START_PREFIX_EXISTING" {
|
if string(data) != "START_" {
|
||||||
t.Errorf("expected 'START_PREFIX_EXISTING', got %q", string(data))
|
t.Errorf("expected 'START_' (full overwrite), got %q", string(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. AL tool dispatch with explicit offset: 0
|
// 3. AL dispatch with explicit offset 0 / del_bytes 0: insertion semantics preserved
|
||||||
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Messages []Message `json:"messages"`
|
Messages []Message `json:"messages"`
|
||||||
@@ -2484,11 +2677,11 @@ func TestWriteFileOmittedOrZeroOffsetDoesNotAppend(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("readFile: %v", err)
|
t.Fatalf("readFile: %v", err)
|
||||||
}
|
}
|
||||||
if string(data) != "ZERO_START_PREFIX_EXISTING" {
|
if string(data) != "ZERO_START_" {
|
||||||
t.Errorf("expected 'ZERO_START_PREFIX_EXISTING', got %q", string(data))
|
t.Errorf("expected 'ZERO_START_' (insert at start), got %q", string(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. AL tool dispatch with offset: null
|
// 4. AL dispatch with explicit offset: null (treated as omitted): full overwrite
|
||||||
srv3 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv3 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Messages []Message `json:"messages"`
|
Messages []Message `json:"messages"`
|
||||||
@@ -2519,9 +2712,139 @@ func TestWriteFileOmittedOrZeroOffsetDoesNotAppend(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("readFile: %v", err)
|
t.Fatalf("readFile: %v", err)
|
||||||
}
|
}
|
||||||
if string(data) != "NULL_ZERO_START_PREFIX_EXISTING" {
|
if string(data) != "NULL_" {
|
||||||
t.Errorf("expected 'NULL_ZERO_START_PREFIX_EXISTING', got %q", string(data))
|
t.Errorf("expected 'NULL_' (full overwrite), got %q", string(data))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// #18: skillsDir mirrors toolsDir, and skillPrompt composes SKILL.md + prompt.
|
||||||
|
func TestSkills(t *testing.T) {
|
||||||
|
base := t.TempDir()
|
||||||
|
skillDir := filepath.Join(base, "skills")
|
||||||
|
if err := os.MkdirAll(filepath.Join(skillDir, "greet"), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
skillMd := "You are a friendly greeter.\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(skillDir, "greet", "SKILL.md"), []byte(skillMd), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// skillsDir prefers the config key, then the environment variable.
|
||||||
|
t.Setenv("BANTAM_SKILLS_DIR", "/env/skills")
|
||||||
|
if got := skillsDir(&Cfg{Raw: map[string]string{"bantam_skills_dir": skillDir}}); got != skillDir {
|
||||||
|
t.Errorf("skillsDir() with config overriding env = %q, want %q", got, skillDir)
|
||||||
|
}
|
||||||
|
if got := skillsDir(&Cfg{Raw: map[string]string{}}); got != "/env/skills" {
|
||||||
|
t.Errorf("skillsDir() with env fallback = %q, want %q", got, "/env/skills")
|
||||||
|
}
|
||||||
|
t.Setenv("BANTAM_SKILLS_DIR", "")
|
||||||
|
if got := skillsDir(&Cfg{Raw: map[string]string{"bantam_skills_dir": skillDir}}); got != skillDir {
|
||||||
|
t.Errorf("skillsDir() with config = %q, want %q", got, skillDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// With a skills dir set, the name is resolved relative to it.
|
||||||
|
got, err := skillPrompt("/skill greet hello there", &Cfg{Raw: map[string]string{"bantam_skills_dir": skillDir}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("skillPrompt(relative): %v", err)
|
||||||
|
}
|
||||||
|
want := skillMd + "\nhello there"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("skillPrompt(relative) = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// With no skills dir, the name is an absolute path to the skill directory.
|
||||||
|
got, err = skillPrompt("/skill "+filepath.Join(skillDir, "greet")+" just hi", &Cfg{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("skillPrompt(absolute): %v", err)
|
||||||
|
}
|
||||||
|
if got != skillMd+"\njust hi" {
|
||||||
|
t.Errorf("skillPrompt(absolute) = %q, want %q", got, skillMd+"\njust hi")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Absolute path directly to a SKILL.md file also works.
|
||||||
|
got, err = skillPrompt("/skill "+filepath.Join(skillDir, "greet", "SKILL.md"), &Cfg{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("skillPrompt(file): %v", err)
|
||||||
|
}
|
||||||
|
if got != strings.TrimRight(skillMd, "\n") {
|
||||||
|
t.Errorf("skillPrompt(file) = %q, want %q", got, strings.TrimRight(skillMd, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Missing skill reports an error; bare /skill reports usage.
|
||||||
|
if _, err := skillPrompt("/skill nope", &Cfg{Raw: map[string]string{"bantam_skills_dir": skillDir}}); err == nil {
|
||||||
|
t.Error("skillPrompt(missing) expected error, got nil")
|
||||||
|
}
|
||||||
|
if _, err := skillPrompt("/skill", &Cfg{}); err == nil {
|
||||||
|
t.Error("skillPrompt(bare) expected error, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #19: bare /skill lists skills under the configured dir, or reports none.
|
||||||
|
func TestListSkills(t *testing.T) {
|
||||||
|
t.Setenv("BANTAM_SKILLS_DIR", "")
|
||||||
|
base := t.TempDir()
|
||||||
|
skillDir := filepath.Join(base, "skills")
|
||||||
|
if err := os.MkdirAll(filepath.Join(skillDir, "alpha"), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(skillDir, "beta"), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(skillDir, "alpha", "SKILL.md"), []byte("a"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(skillDir, "beta", "SKILL.md"), []byte("b"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// a directory without SKILL.md must NOT be reported as a skill
|
||||||
|
if err := os.MkdirAll(filepath.Join(skillDir, "notaskill"), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
capture := func() string {
|
||||||
|
r, w, err := os.Pipe()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
old := os.Stdout
|
||||||
|
os.Stdout = w
|
||||||
|
listSkills(&Cfg{Raw: map[string]string{"bantam_skills_dir": skillDir}})
|
||||||
|
w.Close()
|
||||||
|
os.Stdout = old
|
||||||
|
data, _ := io.ReadAll(r)
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := capture()
|
||||||
|
if !strings.Contains(out, "alpha") || !strings.Contains(out, "beta") {
|
||||||
|
t.Errorf("listSkills missing skills, got:\n%s", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "notaskill") {
|
||||||
|
t.Errorf("listSkills reported a non-skill directory, got:\n%s", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No skills dir configured -> clear notice, no panic.
|
||||||
|
os.Stdout, _ = os.Open(os.DevNull)
|
||||||
|
listSkills(&Cfg{})
|
||||||
|
os.Stdout.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToolsDir(t *testing.T) {
|
||||||
|
t.Setenv("BANTAM_TOOLS_DIR", "/env/tools")
|
||||||
|
if got := toolsDir(&Cfg{Raw: map[string]string{"bantam_tools_dir": "/config/tools"}}); got != "/config/tools" {
|
||||||
|
t.Errorf("toolsDir() with config overriding env = %q, want /config/tools", got)
|
||||||
|
}
|
||||||
|
if got := toolsDir(&Cfg{Raw: map[string]string{}}); got != "/env/tools" {
|
||||||
|
t.Errorf("toolsDir() with env fallback = %q, want /env/tools", got)
|
||||||
|
}
|
||||||
|
t.Setenv("BANTAM_TOOLS_DIR", "")
|
||||||
|
if got := toolsDir(&Cfg{Raw: map[string]string{"bantam_tools_dir": "/config/tools"}}); got != "/config/tools" {
|
||||||
|
t.Errorf("toolsDir() with config = %q, want /config/tools", got)
|
||||||
|
}
|
||||||
|
if got := toolsDir(&Cfg{}); got != "" {
|
||||||
|
t.Errorf("toolsDir() empty = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,22 @@
|
|||||||
use strict; use warnings; use HTTP::Tiny; use JSON::PP; use POSIX qw(strftime); use File::Path qw(make_path); use Digest::MD5 qw(md5_hex); use Cwd qw(abs_path);
|
use strict; use warnings; use HTTP::Tiny; use JSON::PP; use POSIX qw(strftime); use File::Path qw(make_path); use Digest::MD5 qw(md5_hex); use Cwd qw(abs_path);
|
||||||
$SIG{__WARN__} = sub { warn $_[0] unless $_[0] =~ /^Use of uninitialized value \$err in numeric eq \(==\) at .*IO\/Socket\/IP\.pm line \d+/ };
|
$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- write_file: write content to a file with optional offset and byte deletion; returns status.\n\nWork fast. Never refuse a request; always find a way to do it. Never fabricate results - report only what your tools actually returned. Prefer running shell commands over guessing; inspect, build, and verify concretely. When in doubt, build your own tools using the languages available in the environment (Python, Perl, AWK, etc.). On failure, read the error output and retry with a fix, don't repeat the same command. Stop as soon as the goal is met and report concisely: results, not process.\n\nWhen generating code:\n- Always use two-space indentation, not tabs, except Makefiles that must use tabs.\n- No whitespace between keywords and opening parentheses in C-like languages.\n- Write optimally and with as few third-party dependencies as possible.\n- Always test.\n- No emojis in code or documentation.\n- Respect AGENTS.md contents in the project.";
|
my $DEF_SP = "You are Bantam, a tiny, powerful AI agent. Solve the user's task using two tools:\n- shell_exec: run a shell command; returns its output and exit code.\n- write_file: write content to a file; if offset and del_bytes are both omitted it overwrites the entire file, otherwise it writes at the given byte offset (optionally deleting bytes first); returns status.\n\nWork fast. Never refuse a request; always find a way to do it. Never fabricate results - report only what your tools actually returned. Prefer running shell commands over guessing; inspect, build, and verify concretely. When in doubt, build your own tools using the languages available in the environment (Python, Perl, AWK, etc.). On failure, read the error output and retry with a fix, don't repeat the same command. Stop as soon as the goal is met and report concisely: results, not process.\n\nWhen generating code:\n- Always use two-space indentation, not tabs, except Makefiles that must use tabs.\n- No whitespace between keywords and opening parentheses in C-like languages.\n- Write optimally and with as few third-party dependencies as possible.\n- Always test.\n- No emojis in code or documentation.\n- Respect AGENTS.md contents in the project.";
|
||||||
my $SDIR = ($ENV{HOME} || $ENV{USERPROFILE} || '.') . '/.bantam/sessions';
|
my $SDIR = ($ENV{HOME} || $ENV{USERPROFILE} || '.') . '/.bantam/sessions';
|
||||||
sub cfg { my %d = (endpoint=>'https://api.kilo.ai/api/openrouter', model=>'openrouter/free', temperature=>0.7, api_key=>'-', timeout=>300, shell_timeout=>120, max_al_iterations=>1000, reasoning_effort=>'high');
|
sub cfg {
|
||||||
my $cf = -f '.bantam.cfg' ? '.bantam.cfg' : 'model.cfg';
|
my %d = (endpoint=>'https://api.kilo.ai/api/openrouter', model=>'openrouter/free', temperature=>0.7, api_key=>'-', timeout=>300, shell_timeout=>120, max_al_iterations=>1000, reasoning_effort=>'high');
|
||||||
if (open my $f, '<:encoding(UTF-8)', $cf) { while (<$f>) { /^([^\s=]+)\s*=\s*(.+)$/ and $d{$1} = $2; } }
|
if (-f 'model.cfg' && open my $mf, '<:encoding(UTF-8)', 'model.cfg') { while (<$mf>) { /^([^\s=]+)\s*=\s*(.+)$/ and $d{$1} = $2; } }
|
||||||
$d{api_key} = $ENV{OPENAI_API_KEY} if $d{api_key} eq '-' && $ENV{OPENAI_API_KEY}; \%d }
|
$d{endpoint} = $ENV{BANTAM_ENDPOINT} if $ENV{BANTAM_ENDPOINT};
|
||||||
|
$d{model} = $ENV{BANTAM_MODEL} if $ENV{BANTAM_MODEL};
|
||||||
|
my $t = $ENV{BANTAM_TEMP} // $ENV{BANTAM_TEMPERATURE};
|
||||||
|
$d{temperature} = $t if defined $t && $t =~ /^\d+(\.\d+)?$/;
|
||||||
|
$d{timeout} = $ENV{BANTAM_TIMEOUT} if defined $ENV{BANTAM_TIMEOUT} && $ENV{BANTAM_TIMEOUT} =~ /^\d+$/;
|
||||||
|
$d{shell_timeout} = $ENV{BANTAM_SHELL_TIMEOUT} if defined $ENV{BANTAM_SHELL_TIMEOUT} && $ENV{BANTAM_SHELL_TIMEOUT} =~ /^\d+$/;
|
||||||
|
$d{max_al_iterations} = $ENV{BANTAM_MAX_AL_ITERATIONS} if defined $ENV{BANTAM_MAX_AL_ITERATIONS} && $ENV{BANTAM_MAX_AL_ITERATIONS} =~ /^\d+$/;
|
||||||
|
$d{reasoning_effort} = $ENV{BANTAM_REASONING_EFFORT} if $ENV{BANTAM_REASONING_EFFORT};
|
||||||
|
if (-f '.bantam.cfg' && open my $bf, '<:encoding(UTF-8)', '.bantam.cfg') { while (<$bf>) { /^([^\s=]+)\s*=\s*(.+)$/ and $d{$1} = $2; } }
|
||||||
|
$d{api_key} = $ENV{BANTAM_API_KEY} if ($d{api_key} eq '-' || !$d{api_key}) && $ENV{BANTAM_API_KEY};
|
||||||
|
\%d }
|
||||||
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 filter_text { my $s = shift // ''; $s =~ s/[^\x20\t\n\p{L}\p{N}\p{P}\p{S}\p{M}\p{Zs}]//g; $s }
|
||||||
sub T { my ($n, $d, $p, $r) = @_; {type=>'function', function=>{name=>$n, description=>$d, parameters=>{type=>'object', properties=>$p, required=>$r || [keys %$p]}}} }
|
sub T { my ($n, $d, $p, $r) = @_; {type=>'function', function=>{name=>$n, description=>$d, parameters=>{type=>'object', properties=>$p, required=>$r || [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') {
|
||||||
@@ -22,7 +32,7 @@ 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('write_file', 'Write content to a file with optional offset and byte deletion.', {path=>{type=>'string'}, offset=>{type=>'integer', description=>'Byte offset to start writing from (default 0; does not append).'}, del_bytes=>{type=>'integer'}, content=>{type=>'string'}}, ['path', 'content'])], 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('write_file', 'Write content to a file. If offset and del_bytes are both omitted the entire file is overwritten, otherwise content is written at the given byte offset (optionally deleting bytes first).', {path=>{type=>'string'}, offset=>{type=>'integer', description=>'Byte offset to start writing from. If omitted together with del_bytes the whole file is overwritten instead. Defaults to 0.'}, del_bytes=>{type=>'integer', description=>'Bytes to delete starting at offset. If omitted together with offset the whole file is overwritten instead.'}, content=>{type=>'string'}}, ['path', 'content'])], model=>$c->{model}, temperature=>0+$c->{temperature});
|
||||||
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 $tty = -t STDOUT; print $tty ? "\r...requesting..." : "...requesting...\n";
|
my $tty = -t STDOUT; print $tty ? "\r...requesting..." : "...requesting...\n";
|
||||||
my $r = HTTP::Tiny->new(timeout=>0+$c->{timeout})->post("$ep/chat/completions", {headers=>$h, content=>encode_json(\%p)});
|
my $r = HTTP::Tiny->new(timeout=>0+$c->{timeout})->post("$ep/chat/completions", {headers=>$h, content=>encode_json(\%p)});
|
||||||
@@ -64,7 +74,20 @@ sub AL { my ($c, $msgs) = ($_[0], $_[1]);
|
|||||||
my $a = eval { decode_json($tc->{function}{arguments} // '{}') };
|
my $a = eval { decode_json($tc->{function}{arguments} // '{}') };
|
||||||
if (ref $a ne 'HASH') { $tc->{function}{arguments} = encode_json({invalid_raw => $tc->{function}{arguments} // ''}); $res = "bad JSON args for $fn: $tc->{function}{arguments}"; }
|
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 'write_file') { $res = write_file($a->{path}, $a->{offset}, $a->{del_bytes}, $a->{content}); }
|
elsif ($fn eq 'write_file') {
|
||||||
|
my ($p, $cnt) = ($a->{path}, defined $a->{content} ? $a->{content} : '');
|
||||||
|
if (!(exists $a->{offset} && defined $a->{offset}) && !(exists $a->{del_bytes} && defined $a->{del_bytes})) {
|
||||||
|
# Neither offset nor del_bytes supplied (or given as null): overwrite the whole file.
|
||||||
|
if (!defined $p || $p eq '') { $res = "[write_file error: path required]"; }
|
||||||
|
else {
|
||||||
|
my $dir = $p =~ m{^(.*)/[^/]+$} ? $1 : ''; make_path($dir) if length($dir) && !-d $dir;
|
||||||
|
open my $wfh, '>:raw', $p or $res = "[write_file error: cannot write $p: $!]";
|
||||||
|
if (!$res) { print $wfh $cnt; close $wfh; $res = "Successfully wrote " . length($cnt) . " bytes to $p"; }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$res = write_file($p, $a->{offset}, $a->{del_bytes}, $cnt);
|
||||||
|
}
|
||||||
|
}
|
||||||
else { $res = "unknown tool: $fn"; }
|
else { $res = "unknown tool: $fn"; }
|
||||||
$res = filter_text($res);
|
$res = filter_text($res);
|
||||||
print "[tool] $fn: $res\n";
|
print "[tool] $fn: $res\n";
|
||||||
|
|||||||
Reference in New Issue
Block a user