From e1efe70d8a57055f0ce0950ba321a6c79b338678 Mon Sep 17 00:00:00 2001 From: Luxferre Date: Sat, 19 Sep 2026 00:19:25 +0300 Subject: [PATCH] oc compatibility overhaul --- README.md | 61 +++++---- main.go | 345 ++++++++++++++++++++++++++++++++++++++++----------- main_test.go | 304 +++++++++++++++++++++++++++++++++++---------- mb | 26 ++-- 4 files changed, 567 insertions(+), 169 deletions(-) diff --git a/README.md b/README.md index 795635b..18471e2 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,9 @@ Bantam is a minimalist, dependency-free AI agent specification with reference im The entire philosophy of Bantam is built upon two principles: 1. The structure must be as simple as possible for anyone to be able to reimplement the agent from a plain algorithm description. -2. The agent only needs to provide two tools: a tool to call shell commands (`shell_exec`) and a tool to write/edit files (`write_file`). In theory, this should be sufficient to give LLMs the ability to handle tasks of any complexity. +2. The agent only needs to provide a minimal set of tools: a tool to run shell commands (`bash`), a tool to inspect files and directories (`read`), and a tool to write/edit files (`write`). In theory, this should be sufficient to give LLMs the ability to handle tasks of any complexity. -Because of the second principle, Bantam itself was named after Victorinox Bantam Alox, a small and lightweight Swiss army knife with only two tools. +Because of this minimal philosophy, Bantam itself was named after Victorinox Bantam Alox, a small and lightweight Swiss army knife with minimal tools. ## Usage @@ -45,7 +45,7 @@ All implementations read `model.cfg` (or `.bantam.cfg`, which takes priority if api_key=your_api_key_here stream=true context_window=200000 - max_tool_res=15000 + max_tool_res=65536 reasoning_effort=high ``` @@ -73,7 +73,7 @@ All implementations read `model.cfg` (or `.bantam.cfg`, which takes priority if - `/endpoint [val]` — alias for `/cfg endpoint` (inspect or set the API endpoint) - `/skill` — with no argument, list every skill under the configured skills directory (or report that none are configured); with ` [prompt]`, load `//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) - - `!` — execute a shell command directly through `shell_exec` without adding the result to the conversation context (Go port) + - `!` — execute a shell command directly through `bash` without adding the result to the conversation context (Go port) - `/help` — show all supported commands - `/clear` — reset the conversation to just the system prompt - `/quit` — exit @@ -100,13 +100,13 @@ Using these rules, everyone can build their own copy of Bantam from scratch in l ### High-Level Overview 1. **Initialization**: Read the config file (`.bantam.cfg` if present, else `model.cfg`). Discover context window size from the `/models` endpoint (or fallback to `context_window` from `model.cfg` or 200,000 tokens). Prepare an array of messages starting with the built-in system prompt `{"role": "system", "content": system_prompt}`. -2. **Input Processing**: Take user prompt (via command-line file parameter or interactive stdin). If prefixed with `!`, execute the command directly via `shell_exec` without appending to conversation context. Otherwise, append `{"role": "user", "content": prompt}`, and invoke `AL(cfg, messages)`. +2. **Input Processing**: Take user prompt (via command-line file parameter or interactive stdin). If prefixed with `!`, execute the command directly via `bash` without appending to conversation context. Otherwise, append `{"role": "user", "content": prompt}`, and invoke `AL(cfg, messages)`. 3. **Agentic Loop (`AL`)**: - - Send `messages` and tool definitions (`shell_exec`, `write_file`) to the OpenAI-compatible `/chat/completions` API endpoint with custom request headers (see below) and `stream_options: {"include_usage": true}`. + - Send `messages` and tool definitions (`bash`, `read`, `write`) to the OpenAI-compatible `/chat/completions` API endpoint with custom request headers (see below) and `stream_options: {"include_usage": true}`. - Support context cancellation (e.g. on `SIGINT` / Ctrl+C) to cleanly abort in-flight requests without appending incomplete messages. - On network or HTTP failure, retry using Fibonacci backoff delays (`1s, 1s, 2s, 3s, 5s, 8s, 13s, 21s, 34s`). - If `stream=true`, parse SSE data chunks (`data: {...}`) in real-time to stream reasoning content (`reasoning_content`) and response text directly to stdout, bracketing the reasoning block with `--- reasoning start ---` / `--- reasoning end ---` markers, rendering Markdown and tables constrained to terminal width. - - Reconstruct the assistant message and track usage tokens (`prompt_tokens`, `completion_tokens`, cached tokens). If `tool_calls` exist, trace the call (`[tool call: name(args)]`), validate JSON arguments, execute the requested tool (`shell_exec` or `write_file`), trace the result (`[tool result: name]`), append the tool response `{"role": "tool", "tool_call_id": id, "content": result}`, and repeat the loop. + - Reconstruct the assistant message and track usage tokens (`prompt_tokens`, `completion_tokens`, cached tokens). If `tool_calls` exist, trace the call (`[tool call: name(args)]`), validate JSON arguments, execute the requested tool (`bash`, `read`, or `write`; also accepting legacy `shell_exec` and `write_file`), trace the result (`[tool result: name]`), append the tool response `{"role": "tool", "tool_call_id": id, "content": result}`, and repeat the loop. - If no tool calls remain or `max_al_iterations` is reached, return the updated messages list and turn usage stats. 4. **Post-Turn Reporting & Compaction**: - Display token usage and context window percentage. @@ -118,23 +118,23 @@ Using these rules, everyone can build their own copy of Bantam from scratch in l 1. Initialize system prompt from built-in default. 2. Read model parameters from the config file (`.bantam.cfg` if present, else `model.cfg`) in `key=value` format and discover context window size. 3. Prepare a new message list with the system prompt (`role: "system"`). -4. Read the first command-line parameter. If non-empty, read user prompt from the specified file. If prefixed with `!`, execute the shell command directly via `shell_exec` and exit. Otherwise, append to `messages` (`role: "user"`), run `AL(cfg, messages)`, display token usage, and exit. -5. Read user prompt from standard input (with `readline` line editing and history in `~/.bantam_history`; **Ctrl+J** inserts a real newline into the line being edited). If equal to `/quit` or EOF, exit. If equal to `/clear`, reset `messages` to step 3 and return to step 5. If starting with `/save`, write the whole `messages` array to `~/.bantam/sessions/.json` (or `.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 `) or update the config live by writing to `.bantam.cfg` (`/cfg `) 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 `//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/.json`. +4. Read the first command-line parameter. If non-empty, read user prompt from the specified file. If prefixed with `!`, execute the shell command directly via `bash` and exit. Otherwise, append to `messages` (`role: "user"`), run `AL(cfg, messages)`, display token usage, and exit. +5. Read user prompt from standard input (with `readline` line editing and history in `~/.bantam_history`; **Ctrl+J** inserts a real newline into the line being edited). If equal to `/quit` or EOF, exit. If equal to `/clear`, reset `messages` to step 3 and return to step 5. If starting with `/save`, write the whole `messages` array to `~/.bantam/sessions/.json` (or `.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 `) or update the config live by writing to `.bantam.cfg` (`/cfg `) 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 `//SKILL.md` file, or an absolute path to a skill directory or `SKILL.md` file when no skills directory is configured), prefix its contents to the optional remaining text, and send the result as the next user turn (returning to step 5 after the response). If equal to `/models`, query the `/models` path on the current inference endpoint and print a plain list of supported model IDs (the currently configured model marked with a leading `* `), then return to step 5. If starting with `!`, execute the command directly via `bash` without adding the result to `messages` and return to step 5. If equal to `/help`, print the command list and return to step 5. After every user turn and on exit, auto-save `messages` to `~/.bantam/sessions/.json`. 6. Append user prompt to `messages` (`role: "user"`), run `AL(cfg, messages)`, display token usage, check 60% context threshold for auto-compaction, and go to step 5. ### Agentic loop (`AL(cfg, messages)`) function 1. Call OpenAI-compatible Completions API (`POST {endpoint}/chat/completions`) forwarding relevant parameters from `cfg` (`model`, `temperature`, `stream`, `reasoning_effort`, etc., excluding internal agent configs like `endpoint`, `api_key`, `timeout`, `shell_timeout`, `max_al_iterations`, `color`, `context_window`) and optional `api_key` bearer header. - - Set custom request headers. If the endpoint host is `opencode.ai` (or a subdomain), send the OpenCode Zen header set: `User-Agent: opencode/1.18.31 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.14`, `x-opencode-client: cli`, `x-opencode-project: global`, `x-opencode-session: `, and a fresh `x-opencode-request: msg_<26 base32 chars>` per call. For every other endpoint, keep the legacy `User-Agent: opencode/1.18.31` plus `X-Session-Id` and `x-session-affinity`. The `Authorization: Bearer ` header is added whenever `api_key` is set and not `-`. - - Retry network/HTTP errors with Fibonacci backoff delays (`1s, 1s, 2s, 3s, 5s, 8s, 13s, 21s, 34s`). - - If `stream=true`, parse SSE stream (`data: {...}`) for real-time reasoning and text output, bracketing reasoning with `--- reasoning start ---` / `--- reasoning end ---` markers. + - Set custom request headers mimicking OpenCode for all endpoints: `User-Agent: opencode/1.18.31 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.14`, `x-opencode-client: cli`, `x-opencode-project: global`, `x-opencode-session: `, and a fresh `x-opencode-request: ` per call (where the 12-hex timestamp prefix in `x-opencode-request` is the exact bitwise inverse of `x-opencode-session`). The `Authorization: Bearer ` header is added whenever `api_key` is set and not `-`. + - Retry network/HTTP errors with Fibonacci backoff delays (`1s, 1s, 2s, 3s, 5s, 8s, 13s, 21s, 34s`). + - If `stream=true`, parse SSE stream (`data: {...}`) for real-time reasoning and text output, bracketing reasoning with `--- reasoning start ---` / `--- reasoning end ---` markers. 2. Append the assistant's response message object to `messages`. If non-streaming and response has reasoning tokens (`reasoning_content` or `reasoning`), output them wrapped in `--- reasoning start ---` / `--- reasoning end ---` markers. 3. If there are pending `tool_calls` in the assistant response: - For each tool call, output a trace log (`[tool call: name(args)]`). - Sanitize tool arguments to filter out non-printable and space-like Unicode characters (protecting against indirect prompt injection), and validate JSON arguments. If invalid, format a tool-error response so the LLM can self-correct. - - Execute tool action (`shell_exec` or `write_file`). + - Execute tool action (`bash`, `read`, or `write`; also accepting legacy `shell_exec` and `write_file`). - Sanitize the tool result output to strip any non-printable and space-like Unicode characters (leaving only ASCII space, tab, newline, and printable Unicode characters). - - If the sanitized result is larger than `max_tool_res` bytes, spill it to a unique file under `$TMPDIR/bantam/toolres` and replace the content appended to `messages` with a short instruction naming the file, its byte length, and how to read it partially (e.g. `tail -c +OFFSET | head -c LENGTH`); every spilled file is deleted when the round ends (i.e. when the model emits a final response with no tool calls). + - If the sanitized result is larger than `max_tool_res` bytes, spill it to a unique file under `$TMPDIR/bantam/toolres` and replace the content appended to `messages` with a short instruction naming the file, its line count, and how to read it partially using the `read` tool (specifying `offset` and `limit`); every spilled file is deleted when the round ends (i.e. when the model emits a final response with no tool calls). - Output a trace log of the result (`[tool result: name]`). - Append tool result message (`role: "tool"`, `tool_call_id`, `content`: result string) to `messages`. - Loop back to step 1. @@ -153,10 +153,10 @@ If the API rejects the request with an `Invalid assistant message: content or to - `stream` (stream response tokens in real-time, default `true`; falls back to `BANTAM_STREAM` env var) - `color` (ANSI coloring: `auto` (TTY-detected, default), `always`, or `never`; disabled by `NO_COLOR`/`BANTAM_NO_COLOR` env vars, falls back to `BANTAM_COLOR` env var) - `timeout` (HTTP timeout in seconds for LLM API calls, default 300; in the Go port it bounds connection setup and time-to-first-byte, so long streaming responses are not cut off mid-stream; falls back to `BANTAM_TIMEOUT` env var) -- `shell_timeout` (timeout in seconds for `shell_exec` commands, default 120; falls back to `BANTAM_SHELL_TIMEOUT` env var) +- `shell_timeout` (timeout in seconds for `bash` shell commands, default 120; falls back to `BANTAM_SHELL_TIMEOUT` env var) - `max_al_iterations` (max tool-call loop iterations per `AL()` invocation, default 1000; falls back to `BANTAM_MAX_AL_ITERATIONS` env var) - `context_window` (context window size in tokens, auto-discovered from `/models` API if available, fallback to this setting, default 200000; falls back to `BANTAM_CONTEXT_WINDOW` env var) -- `max_tool_res` (maximum number of bytes of a tool result that may be sent into the model context directly, default 15000; larger results are written to a unique file under `$TMPDIR/bantam/toolres` and replaced by a short instruction naming the file and its byte length so it can be read in parts, and the file is deleted once the round ends; falls back to `BANTAM_MAX_TOOL_RES` env var) +- `max_tool_res` (maximum number of bytes of a tool result that may be sent into the model context directly, default 65536; larger results are written to a unique file under `$TMPDIR/bantam/toolres` and replaced by a short instruction naming the file and its line count so it can be read in parts using the `read` tool, and the file is deleted once the round ends; falls back to `BANTAM_MAX_TOOL_RES` env var) - `reasoning_effort` (reasoning effort level, forwarded to chat completions API, default `high`; falls back to `BANTAM_REASONING_EFFORT` env var) - `bantam_tools_dir` (optional path to a directory of extra shell tools; the Go port appends `"Extra shell tools can be found at "` 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.) @@ -170,21 +170,32 @@ When enabled, the interactive console uses a subtle ANSI palette: the pending-re ### Tool call definitions -#### `write_file` tool +#### `read` tool - Parameters: - - `path` (string, required): JSON-escaped file path to write to. + - `filePath` (string, required): path to the file to read or directory to inspect (also accepts `path`). + - `offset` (integer, optional): line number to start reading from (1-indexed). Defaults to 1. + - `limit` (integer, optional): maximum number of lines to read. Defaults to 2000. +- Return value: string +- Action: if the target is a regular file, returns numbered lines in `: ` format. If the target is a directory, returns a list of directory entries (subdirectories include a trailing `/`). + +#### `write` tool + +- Parameters: + - `filePath` (string, required): JSON-escaped file path to write to (also accepts `path`). - `offset` (integer, optional): byte offset to start writing from. If omitted together with `del_bytes`, the whole file is overwritten instead. Defaults to 0 (start of file). - `del_bytes` (integer, optional): bytes to delete starting at `offset`. If omitted together with `offset`, the whole file is overwritten instead. - `content` (string, required, may be empty): JSON-escaped content to write to the file. - Return value: string -- Action: if `offset` and `del_bytes` are both omitted, the entire file is overwritten with `content` (the file, and any necessary parent directories, are created if missing); otherwise `content` is written at `offset`, optionally deleting `del_bytes` bytes first (splicing prefix + content + suffix and never appending to the end of an existing file), creating the file and parent directories as needed. +- Action: if `offset` and `del_bytes` are both omitted, the entire file is overwritten with `content` (the file, and any necessary parent directories, are created if missing); otherwise `content` is written at `offset`, optionally deleting `del_bytes` bytes first (splicing prefix + content + suffix and never appending to the end of an existing file), creating the file and parent directories as needed. (Also accepted as legacy `write_file`.) -#### `shell_exec` tool +#### `bash` tool -- Parameters: `command` (string) +- Parameters: + - `command` (string, required): shell command to run. + - `workdir` (string, optional): working directory to execute the command in. - Return value: string -- Action: run shell command specified in `command` subject to `shell_timeout` (default 120s) and return `output + '\n\nexit: ' + exit_code` string. +- Action: run shell command specified in `command` via `bash -c` subject to `shell_timeout` (default 120s) and return `output + '\nexit: ' + exit_code` string. (Also accepted as legacy `shell_exec`.) ## MicroBantam @@ -192,7 +203,7 @@ MicroBantam (`mb`) is a compressed Perl 5 reference implementation of the same a ### Features -- Full agentic loop: LLM calls, `shell_exec` / `write_file` tool execution with JSON argument validation (invalid args are fed back so the model can self-correct) +- Full agentic loop: LLM calls, `bash` / `read` / `write` tool execution with JSON argument validation (invalid args are fed back so the model can self-correct) - Indirect prompt injection defense: sanitizes tool parameters and tool outputs by filtering non-printable and space-like Unicode characters, preserving standard space, tab, newline, and printable Unicode characters - A `...requesting...` in-flight indicator: in-place on a TTY (`\r` overwrite, erased on completion), a plain line when output is piped - Session management: `/save`, `/list`, `/load ` (exact id only, no prefix matching), `/cfg [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 @@ -225,7 +236,7 @@ MicroBantam (`mb`) is a compressed Perl 5 reference implementation of the same a ## Extra tools -The `extras/` directory contains small, dependency-light shell scripts that extend Bantam without changing its core. Because Bantam's built-in tools are `shell_exec` and `write_file`, these helpers can be invoked directly by the agent through `shell_exec` to give it real-world capabilities (web search, live weather) that the base model alone does not have. They are plain `/bin/sh` scripts depending only on `curl` (and `jq` where noted), so the agent can discover and run them just like any other command. +The `extras/` directory contains small, dependency-light shell scripts that extend Bantam without changing its core. Because Bantam's built-in tools include `bash`, these helpers can be invoked directly by the agent through `bash` to give it real-world capabilities (web search, live weather) that the base model alone does not have. They are plain `/bin/sh` scripts depending only on `curl` (and `jq` where noted), so the agent can discover and run them just like any other command. If you keep your own collection of helper scripts, point Bantam at them with the `bantam_tools_dir` key in the config file (`.bantam.cfg` if present, else `model.cfg`) or the `BANTAM_TOOLS_DIR` environment variable. When either is defined (the config file setting taking precedence over the environment variable), the Go port appends the line `Extra shell tools can be found at ` 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. @@ -332,7 +343,7 @@ Set the `bantam_skills_dir` key in the config file (`.bantam.cfg` if present, el ### What happens when a tool result is too large for the context? -Bantam never stuffs an arbitrarily large tool result into the context window. The `max_tool_res` configuration parameter (default `15000`, config-file key takes precedence over the `BANTAM_MAX_TOOL_RES` environment variable) sets the maximum number of bytes of a tool result that may be sent to the model directly. When a result exceeds that limit, the Go port writes the full output to a unique file under `$TMPDIR/bantam/toolres` and replaces the result in the conversation with a short note naming that file and stating its total byte length. The agent is then expected to read the file with `shell_exec`, paging through it with byte offsets (e.g. `tail -c +OFFSET | head -c LENGTH`) rather than loading everything at once. Every file spilled during a round is deleted as soon as the round ends, i.e. when the model emits its final response with no pending tool calls. The built-in system prompt also advertises this behaviour to the model at startup. +Bantam never stuffs an arbitrarily large tool result into the context window. The `max_tool_res` configuration parameter (default `65536`, config-file key takes precedence over the `BANTAM_MAX_TOOL_RES` environment variable) sets the maximum number of bytes of a tool result that may be sent to the model directly. When a result exceeds that limit, the Go port writes the full output to a unique file under `$TMPDIR/bantam/toolres` and replaces the result in the conversation with a short note naming that file and stating its total line count. The agent is then expected to read the file with the `read` tool, paging through it with line offsets and limits (`offset` and `limit`) rather than loading everything at once. Every file spilled during a round is deleted as soon as the round ends, i.e. when the model emits its final response with no pending tool calls. The built-in system prompt also advertises this behaviour to the model at startup. ### Is there any common config place for Bantam? diff --git a/main.go b/main.go index 06e525e..73c2b2a 100644 --- a/main.go +++ b/main.go @@ -86,7 +86,7 @@ var llmTransport = &http.Transport{ DialContext: (&net.Dialer{Timeout: 300 * time.Second}).DialContext, } -var defCfg = Cfg{"https://api.kilo.ai/api/openrouter", "openrouter/free", "-", 0.7, 300, 120, 1000, true, "auto", 262144, 15000, map[string]string{"reasoning_effort": "high"}} +var defCfg = Cfg{"https://api.kilo.ai/api/openrouter", "openrouter/free", "-", 0.7, 300, 120, 1000, true, "auto", 262144, 65536, map[string]string{"reasoning_effort": "high"}} const opencodeAgentVersion = "opencode/1.18.31" const opencodeProviderUA = "ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.14" @@ -94,9 +94,15 @@ const opencodeIDAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" const opencodeProjectID = "global" // opencodeTailAlphabet is the character set used for the 14-character random -// tail of a Zen session id (real ids mix upper/lower case and digits). +// tail of OpenCode IDs. const opencodeTailAlphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" +var ( + ocMu sync.Mutex + ocLast int64 + ocSeq uint64 +) + func atoiD(s string, d int) int { if v, e := strconv.Atoi(strings.TrimSpace(s)); e == nil { return v @@ -414,15 +420,16 @@ func configPath() string { return "model.cfg" } -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. -- 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. +const defaultSystemPrompt = `You are Bantam, a tiny, powerful AI agent. Solve the user's task using three tools: +- bash: run a bash command; returns its output and exit code. +- read: read a file or directory from the local filesystem; returns numbered lines or directory entries. +- write: write content to a file; if offset and del_bytes are both omitted it overwrites the entire file, otherwise it writes at the given byte offset (optionally deleting bytes first); returns status. 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. Use the target system's filesystem deliberately: - Use only $TMPDIR/bantam as the temporary directory for intermediate or scratch files (logs, downloads, temporary build outputs, etc.); create it if it does not exist. - Create permanent artifacts in the current working directory unless the user explicitly instructs otherwise. -- Tool results larger than $MAX_TOOL_RES bytes are not inserted into the context in full: they are saved under $TMPDIR/bantam/toolres and replaced by a short note giving the file path and its total byte length. Read such a file with shell_exec, paging through it in parts with byte offsets (e.g. tail -c +OFFSET | head -c LENGTH) instead of loading it all at once. +- Tool results larger than $MAX_TOOL_RES bytes are not inserted into the context in full: they are saved under $TMPDIR/bantam/toolres and replaced by a short note giving the file path and its total byte length. Read such a file with the read tool, paging through it with line offsets (using offset and limit) instead of loading it all at once. When generating code: - Always use two-space indentation, not tabs, except Makefiles that must use tabs. @@ -996,8 +1003,45 @@ type ToolCall struct { } 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": "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"}}}}, + {"type": "function", "function": map[string]any{ + "name": "bash", + "description": "Executes a given bash command in a shell session, returning its output and exit code.", + "parameters": map[string]any{ + "type": "object", + "properties": map[string]any{ + "command": map[string]any{"type": "string", "description": "The command to execute"}, + "workdir": map[string]any{"type": "string", "description": "The working directory to run the command in. Defaults to the current directory."}, + }, + "required": []string{"command"}, + }, + }}, + {"type": "function", "function": map[string]any{ + "name": "read", + "description": "Read a file or directory from the local filesystem. For files, returns numbered lines prefixed as `: `. For directories, returns entry names with trailing slashes for subdirectories.", + "parameters": map[string]any{ + "type": "object", + "properties": map[string]any{ + "filePath": map[string]any{"type": "string", "description": "The path to the file or directory to read"}, + "offset": map[string]any{"type": "integer", "description": "The line number to start reading from (1-indexed, defaults to 1)"}, + "limit": map[string]any{"type": "integer", "description": "The maximum number of lines to read (defaults to 2000)"}, + }, + "required": []string{"filePath"}, + }, + }}, + {"type": "function", "function": map[string]any{ + "name": "write", + "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{ + "filePath": map[string]any{"type": "string", "description": "The path to the file to write"}, + "content": map[string]any{"type": "string", "description": "The content to write to the file"}, + "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."}, + }, + "required": []string{"filePath", "content"}, + }, + }}, } func strp(s string) *string { return &s } @@ -1177,11 +1221,13 @@ func llm(ctx context.Context, cfg *Cfg, msgs []Message, tools []map[string]any) } cleanMsgs := cleanMessagesForLLM(msgs) sanitizeMessages(cleanMsgs) - p := map[string]any{"model": cfg.Model, "temperature": cfg.Temperature, "messages": cleanMsgs, "stream": cfg.Stream} + isOC := isOpencodeEndpoint(cfg.Endpoint) + stream := cfg.Stream || isOC + p := map[string]any{"model": cfg.Model, "temperature": cfg.Temperature, "messages": cleanMsgs, "stream": stream} if tools != nil { p["tools"] = tools } - if cfg.Stream { + if stream { p["stream_options"] = map[string]any{"include_usage": true} } for k, v := range cfg.Raw { @@ -1262,7 +1308,7 @@ func llm(ctx context.Context, cfg *Cfg, msgs []Message, tools []map[string]any) if COL { fmt.Print("\r\033[K") } - if !cfg.Stream { + if !stream { var cr struct { Model string `json:"model"` Choices []struct { @@ -1545,10 +1591,17 @@ func parseStream(ctx context.Context, r io.Reader) (Message, Usage, error) { } func shell(ctx context.Context, cmd string, timeout int) string { + return shellWithWorkdir(ctx, cmd, "", timeout) +} + +func shellWithWorkdir(ctx context.Context, cmd string, workdir string, timeout int) string { cmd = filterText(cmd) cmdCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second) defer cancel() - c := exec.CommandContext(cmdCtx, "sh", "-c", cmd) + c := exec.CommandContext(cmdCtx, "bash", "-c", cmd) + if workdir != "" { + c.Dir = workdir + } c.WaitDelay = 100 * time.Millisecond out, err := c.CombinedOutput() res := strings.TrimSpace(filterText(string(out))) @@ -1639,6 +1692,80 @@ func writeFile(path string, offset, delBytes int, content string) (string, error return fmt.Sprintf("Successfully wrote %d bytes to %s", len(contentBytes), path), nil } +// readFileOrDir reads a file or directory for the read tool. If path is a +// directory, it lists the directory entries sorted alphabetically, appending +// a trailing slash for subdirectories. If path is a regular file, it returns +// lines between offset and offset+limit-1 (1-indexed), prefixed with line +// numbers as ": ". +func readFileOrDir(path string, offset, limit int) (string, int) { + path = strings.TrimSpace(path) + if path == "" { + return "[tool error: read requires 'filePath' parameter]", 31 + } + fi, err := os.Stat(path) + if err != nil { + return fmt.Sprintf("[tool error: read %s: %v]", path, err), 31 + } + if fi.IsDir() { + entries, err := os.ReadDir(path) + if err != nil { + return fmt.Sprintf("[tool error: read %s: %v]", path, err), 31 + } + sort.Slice(entries, func(i, j int) bool { + return entries[i].Name() < entries[j].Name() + }) + var b strings.Builder + for _, e := range entries { + if e.IsDir() { + b.WriteString(e.Name() + "/\n") + } else { + b.WriteString(e.Name() + "\n") + } + } + return strings.TrimRight(b.String(), "\n"), 2 + } + + f, err := os.Open(path) + if err != nil { + return fmt.Sprintf("[tool error: read %s: %v]", path, err), 31 + } + defer f.Close() + + if offset < 1 { + offset = 1 + } + if limit <= 0 { + limit = 2000 + } + + var b strings.Builder + scanner := bufio.NewScanner(f) + buf := make([]byte, 64*1024) + scanner.Buffer(buf, 1024*1024) + + lineNum := 0 + linesRead := 0 + for scanner.Scan() { + lineNum++ + if lineNum < offset { + continue + } + linesRead++ + text := scanner.Text() + if len(text) > 2000 { + text = text[:2000] + } + b.WriteString(fmt.Sprintf("%d: %s\n", lineNum, text)) + if linesRead >= limit { + break + } + } + if err := scanner.Err(); err != nil && linesRead == 0 { + return fmt.Sprintf("[tool error: read %s: %v]", path, err), 31 + } + return strings.TrimRight(b.String(), "\n"), 2 +} + // toolResDir returns the directory where oversized tool results are spilled. func toolResDir() string { tmp := os.Getenv("TMPDIR") @@ -1657,7 +1784,7 @@ func toolResDir() string { // returned unchanged so the agent always makes progress. func offloadToolResult(res string, maxRes int, tmps *[]string) string { if maxRes <= 0 { - maxRes = 15000 + maxRes = 65536 } if len(res) <= maxRes { return res @@ -1680,7 +1807,7 @@ func offloadToolResult(res string, maxRes int, tmps *[]string) string { return res } *tmps = append(*tmps, name) - return fmt.Sprintf("[tool result too large for context: %d bytes (limit %d). The full output was saved to %s. Read it with shell_exec; to read it partially, use a byte offset and length, e.g. `tail -c +OFFSET %s | head -c LENGTH`. The file is %d bytes long.]", n, maxRes, name, name, n) + return fmt.Sprintf("[tool result too large for context: %d bytes (limit %d). The full output was saved to %s. Read it with the read tool; to read it partially, use offset and limit. The file is %d bytes long.]", n, maxRes, name, n) } // cleanupTemps removes the temporary files created while offloading oversized @@ -1777,15 +1904,70 @@ func AL(ctx context.Context, cfg *Cfg, msgs []Message) ([]Message, Usage, error) res, sty = fmt.Sprintf("[tool error: invalid JSON args for %s: %v. Raw: %q]", fn, err, astr), 31 } else { switch fn { - case "shell_exec": + case "bash", "shell_exec": cmd, _ := a["command"].(string) cmd = filterText(cmd) - res = shell(ctx, cmd, cfg.ShellTimeout) + workdir, _ := a["workdir"].(string) + workdir = filterText(workdir) + to := cfg.ShellTimeout + if v, ok := a["timeout"]; ok && v != nil { + switch n := v.(type) { + case float64: + if n > 0 { + if n > 1000 { + to = int(n / 1000) + } else { + to = int(n) + } + } + case int: + if n > 0 { + if n > 1000 { + to = n / 1000 + } else { + to = n + } + } + } + } + res = shellWithWorkdir(ctx, cmd, workdir, to) if err := ctx.Err(); err != nil { return msgs, turnUsage, err } - case "write_file": - path, _ := a["path"].(string) + case "read": + path, _ := a["filePath"].(string) + if path == "" { + path, _ = a["path"].(string) + } + path = filterText(path) + offset := 1 + if v, ok := a["offset"]; ok && v != nil { + switch n := v.(type) { + case float64: + offset = int(n) + case int: + offset = n + case string: + offset = atoiD(n, 1) + } + } + limit := 2000 + if v, ok := a["limit"]; ok && v != nil { + switch n := v.(type) { + case float64: + limit = int(n) + case int: + limit = n + case string: + limit = atoiD(n, 2000) + } + } + res, sty = readFileOrDir(path, offset, limit) + case "write", "write_file": + path, _ := a["filePath"].(string) + if path == "" { + path, _ = a["path"].(string) + } path = filterText(path) contentVal, hasContent := a["content"] var content string @@ -1824,9 +2006,9 @@ func AL(ctx context.Context, cfg *Cfg, msgs []Message) ([]Message, Usage, error) } } if strings.TrimSpace(path) == "" { - res, sty = "[tool error: write_file requires 'path' parameter]", 31 + res, sty = fmt.Sprintf("[tool error: %s requires 'filePath' parameter]", fn), 31 } else if !hasContent { - res, sty = "[tool error: write_file requires 'content' parameter]", 31 + res, sty = fmt.Sprintf("[tool error: %s requires 'content' parameter]", fn), 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 @@ -1835,12 +2017,12 @@ func AL(ctx context.Context, cfg *Cfg, msgs []Message) ([]Message, Usage, error) 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 + res, sty = fmt.Sprintf("[tool error: %s %s: %v]", fn, 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 + res, sty = fmt.Sprintf("[tool error: %s %s: %v]", fn, p, err), 31 } else { res, sty = fmt.Sprintf("Successfully wrote %d bytes to %s", len(content), p), 2 } @@ -1848,7 +2030,7 @@ func AL(ctx context.Context, cfg *Cfg, msgs []Message) ([]Message, Usage, error) } else { out, err := writeFile(path, offset, delBytes, content) if err != nil { - res, sty = fmt.Sprintf("[tool error: write_file %s: %v]", path, err), 31 + res, sty = fmt.Sprintf("[tool error: %s %s: %v]", fn, path, err), 31 } else { res, sty = out, 2 } @@ -1919,17 +2101,27 @@ func projectID() string { return hex.EncodeToString(h[:]) } -// opencodeSessionID returns a Zen-compatible session id of the form -// "ses_" + 12 lowercase hex characters + 14 alphanumeric characters. Every -// real OpenCode session id observed has this shape (the hex prefix ends in -// the literal "ffe"), and the Zen free-tier edge accepts freshly generated -// ids in this format, unlike uppercase/ULID-shaped ids. -func opencodeSessionID() string { - ms := time.Now().UnixNano() / int64(time.Millisecond) - var b strings.Builder - b.WriteString("ses_") - b.WriteString(fmt.Sprintf("%09x", uint64(ms)&0xfffffffff)) - b.WriteString("ffe") +// genOpencodeID generates an OpenCode-style identifier with a 12-hex-character +// timestamp prefix (6 bytes, big endian) and a 14-character random Base62 tail. +// When descending is true (session IDs), the timestamp value is bitwise inverted (~$), +// ensuring session IDs and request IDs form bitwise inverse hex prefixes. +func genOpencodeID(prefix string, descending bool) string { + now := time.Now().UnixMilli() + ocMu.Lock() + if now != ocLast { + ocLast = now + ocSeq = 0 + } + ocSeq++ + seq := ocSeq + ocMu.Unlock() + + val := uint64(now)*0x1000 + (seq & 0xfff) + if descending { + val = ^val + } + hexPart := fmt.Sprintf("%012x", val&0xffffffffffff) + buf := make([]byte, 14) if _, err := rand.Read(buf); err != nil { h := md5.Sum([]byte(fmt.Sprintf("%d-%d", time.Now().UnixNano(), os.Getpid()))) @@ -1937,10 +2129,40 @@ func opencodeSessionID() string { buf[i] = h[i%len(h)] } } - for _, x := range buf { - b.WriteByte(opencodeTailAlphabet[int(x)%len(opencodeTailAlphabet)]) + var tail strings.Builder + for _, b := range buf { + tail.WriteByte(opencodeTailAlphabet[int(b)%len(opencodeTailAlphabet)]) } - return b.String() + return prefix + hexPart + tail.String() +} + +func opencodeSessionID() string { + return genOpencodeID("ses_", true) +} + +// opencodeSessionIDFromRequest returns a session ID whose 12-hex timestamp prefix +// is the exact bitwise inverse of the given msg_ request ID. +func opencodeSessionIDFromRequest(msgID string) string { + if strings.HasPrefix(msgID, "msg_") && len(msgID) >= 16 { + hexPart := msgID[4:16] + if val, err := strconv.ParseUint(hexPart, 16, 64); err == nil { + invVal := (^val) & 0xffffffffffff + invHex := fmt.Sprintf("%012x", invVal) + buf := make([]byte, 14) + if _, err := rand.Read(buf); err != nil { + h := md5.Sum([]byte(fmt.Sprintf("%d-%d", time.Now().UnixNano(), os.Getpid()))) + for i := range buf { + buf[i] = h[i%len(h)] + } + } + var tail strings.Builder + for _, b := range buf { + tail.WriteByte(opencodeTailAlphabet[int(b)%len(opencodeTailAlphabet)]) + } + return "ses_" + invHex + tail.String() + } + } + return genOpencodeID("ses_", true) } // isOpencodeEndpoint reports whether the endpoint belongs to OpenCode Zen and @@ -1954,41 +2176,24 @@ func isOpencodeEndpoint(endpoint string) bool { return h == "opencode.ai" || strings.HasSuffix(h, ".opencode.ai") } -// ocRequestID returns a fresh OpenCode-style message id (msg_ followed by 26 -// base32 characters) matching the ids sent in the x-opencode-request header. +// ocRequestID returns a fresh OpenCode-style message id (msg_ followed by 12 +// hex characters and 14 Base62 characters) matching the ids sent in the +// x-opencode-request header. func ocRequestID() string { - buf := make([]byte, 26) - if _, err := rand.Read(buf); err != nil { - h := md5.Sum([]byte(fmt.Sprintf("%d-%d", time.Now().UnixNano(), os.Getpid()))) - for i := range buf { - buf[i] = h[i%len(h)] - } - } - var b strings.Builder - b.WriteString("msg_") - for _, x := range buf { - b.WriteByte(opencodeIDAlphabet[int(x)&31]) - } - return b.String() + return genOpencodeID("msg_", false) } -// applyLLMHeaders sets the request headers appropriate for the configured -// endpoint. OpenCode (Zen) endpoints receive the x-opencode-* family plus the -// full provider User-Agent; all other endpoints keep the legacy -// session-affinity headers. msgID is used for the x-opencode-request header. +// applyLLMHeaders sets request headers mimicking OpenCode for all endpoints: +// the x-opencode-* family, the full provider User-Agent, and Authorization. +// msgID is used for the x-opencode-request header, and x-opencode-session is its +// bitwise inverse. func applyLLMHeaders(req *http.Request, cfg *Cfg, msgID string) { - sid := opencodeSessionID() - if isOpencodeEndpoint(cfg.Endpoint) { - req.Header.Set("User-Agent", opencodeAgentVersion+" "+opencodeProviderUA) - req.Header.Set("x-opencode-client", "cli") - req.Header.Set("x-opencode-project", opencodeProjectID) - req.Header.Set("x-opencode-session", sid) - req.Header.Set("x-opencode-request", msgID) - } else { - req.Header.Set("User-Agent", opencodeAgentVersion) - req.Header.Set("x-session-affinity", sid) - req.Header.Set("X-Session-Id", sid) - } + sid := opencodeSessionIDFromRequest(msgID) + req.Header.Set("User-Agent", opencodeAgentVersion+" "+opencodeProviderUA) + req.Header.Set("x-opencode-client", "cli") + req.Header.Set("x-opencode-project", opencodeProjectID) + req.Header.Set("x-opencode-session", sid) + req.Header.Set("x-opencode-request", msgID) if cfg.APIKey != "" && cfg.APIKey != "-" { req.Header.Set("Authorization", "Bearer "+cfg.APIKey) } @@ -2550,11 +2755,11 @@ func runDirectShell(cmd string, timeout int) { return } astr, _ := json.Marshal(map[string]string{"command": cmd}) - fmt.Println(c(fmt.Sprintf("[tool call: shell_exec(%s)]", string(astr)), 33)) + fmt.Println(c(fmt.Sprintf("[tool call: bash(%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)) + fmt.Println(c("[tool result: bash]", 32) + "\n" + c(res, 2)) } func doCompact(cfg *Cfg, msgs []Message) []Message { diff --git a/main_test.go b/main_test.go index 7c60107..6880181 100644 --- a/main_test.go +++ b/main_test.go @@ -248,8 +248,8 @@ func TestDefaultSystemPrompt(t *testing.T) { if !strings.Contains(defaultSystemPrompt, "You are Bantam, a tiny, powerful AI agent.") { t.Errorf("expected prompt to contain base description, got: %q", defaultSystemPrompt) } - if !strings.Contains(defaultSystemPrompt, "shell_exec") || !strings.Contains(defaultSystemPrompt, "write_file") { - t.Errorf("expected prompt to list shell_exec and write_file tools, got: %q", defaultSystemPrompt) + if !strings.Contains(defaultSystemPrompt, "bash") || !strings.Contains(defaultSystemPrompt, "read") || !strings.Contains(defaultSystemPrompt, "write") { + t.Errorf("expected prompt to list bash, read, and write tools, got: %q", defaultSystemPrompt) } if strings.Contains(defaultSystemPrompt, "run_subagent") { t.Errorf("prompt should not mention run_subagent: %q", defaultSystemPrompt) @@ -1147,15 +1147,16 @@ func TestCol(t *testing.T) { // ---------- llm / AL / summarize / compact via httptest (no real network) ---------- func TestLLMNonStreamingAndHeaders(t *testing.T) { - var gotPath, gotAuth, gotUA, gotLegacy, gotAffinity, gotOCClient, gotOCReq string + var gotPath, gotAuth, gotUA, gotOCClient, gotOCProject, gotOCSession, gotOCReq, gotLegacy string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path gotAuth = r.Header.Get("Authorization") gotUA = r.Header.Get("User-Agent") - gotLegacy = r.Header.Get("X-Session-Id") - gotAffinity = r.Header.Get("x-session-affinity") gotOCClient = r.Header.Get("x-opencode-client") + gotOCProject = r.Header.Get("x-opencode-project") + gotOCSession = r.Header.Get("x-opencode-session") gotOCReq = r.Header.Get("x-opencode-request") + gotLegacy = r.Header.Get("X-Session-Id") w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"hi","reasoning_content":"think"}}]}`)) })) defer srv.Close() @@ -1174,19 +1175,23 @@ func TestLLMNonStreamingAndHeaders(t *testing.T) { if gotAuth != "Bearer secret" { t.Errorf("auth = %q", gotAuth) } - if !strings.Contains(gotUA, "opencode/1.18.31") { + if !strings.Contains(gotUA, "opencode/1.18.31") || !strings.Contains(gotUA, "runtime/bun") { t.Errorf("user-agent = %q", gotUA) } - // Non-OpenCode endpoints keep the legacy session-affinity headers and must - // not receive any x-opencode-* headers. - if !isOpencodeSessionID(gotLegacy) { - t.Errorf("X-Session-Id = %q, want a ses_* id", gotLegacy) + if gotOCClient != "cli" { + t.Errorf("x-opencode-client = %q, want cli", gotOCClient) } - if gotAffinity != gotLegacy { - t.Errorf("x-session-affinity = %q, want %q", gotAffinity, gotLegacy) + if gotOCProject != "global" { + t.Errorf("x-opencode-project = %q, want global", gotOCProject) } - if gotOCClient != "" || gotOCReq != "" { - t.Errorf("unexpected x-opencode headers: client=%q request=%q", gotOCClient, gotOCReq) + if !isOpencodeSessionID(gotOCSession) { + t.Errorf("x-opencode-session = %q, want ses_* id", gotOCSession) + } + if !strings.HasPrefix(gotOCReq, "msg_") { + t.Errorf("x-opencode-request = %q, want msg_* id", gotOCReq) + } + if gotLegacy != "" { + t.Errorf("legacy X-Session-Id should be absent, got %q", gotLegacy) } if m.Content == nil || *m.Content != "hi" || m.ReasoningContent != "think" { t.Errorf("message = %+v", m) @@ -1229,9 +1234,14 @@ func TestOcRequestID(t *testing.T) { if len(id) != 30 { t.Fatalf("request id length = %d, want 30: %q", len(id), id) } - for _, c := range id[4:] { - if !strings.ContainsRune(opencodeIDAlphabet, c) { - t.Fatalf("invalid character %c in request id %q", c, id) + for _, c := range id[4:16] { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { + t.Fatalf("invalid hex character %c in request id prefix %q", c, id) + } + } + for _, c := range id[16:] { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) { + t.Fatalf("invalid character %c in request id tail %q", c, id) } } if ocRequestID() == id { @@ -1239,35 +1249,42 @@ func TestOcRequestID(t *testing.T) { } } -func TestApplyLLMHeadersOpencode(t *testing.T) { - cfg := defCfg - cfg.Endpoint = "https://opencode.ai/zen/v1" - cfg.APIKey = "-" - req, err := http.NewRequest("POST", "https://opencode.ai/zen/v1/chat/completions", nil) - if err != nil { - t.Fatalf("NewRequest: %v", err) +func TestApplyLLMHeaders(t *testing.T) { + endpoints := []string{ + "https://opencode.ai/zen/v1", + "https://api.openai.com/v1", + "https://api.kilo.ai/api/openrouter", } - applyLLMHeaders(req, &cfg, "msg_test") - if got := req.Header.Get("User-Agent"); !strings.Contains(got, "opencode/1.18.31") || !strings.Contains(got, "runtime/bun") { - t.Errorf("user-agent = %q", got) - } - if got := req.Header.Get("x-opencode-client"); got != "cli" { - t.Errorf("x-opencode-client = %q, want cli", got) - } - if got := req.Header.Get("x-opencode-project"); got != "global" { - t.Errorf("x-opencode-project = %q, want global", got) - } - if got := req.Header.Get("x-opencode-session"); !isOpencodeSessionID(got) { - t.Errorf("x-opencode-session = %q, want a ses_* id", got) - } - if got := req.Header.Get("x-opencode-request"); got != "msg_test" { - t.Errorf("x-opencode-request = %q, want msg_test", got) - } - if got := req.Header.Get("X-Session-Id"); got != "" { - t.Errorf("legacy X-Session-Id should be absent, got %q", got) - } - if got := req.Header.Get("Authorization"); got != "" { - t.Errorf("Authorization should be absent for '-' key, got %q", got) + for _, ep := range endpoints { + cfg := defCfg + cfg.Endpoint = ep + cfg.APIKey = "-" + req, err := http.NewRequest("POST", ep+"/chat/completions", nil) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + applyLLMHeaders(req, &cfg, "msg_test") + if got := req.Header.Get("User-Agent"); !strings.Contains(got, "opencode/1.18.31") || !strings.Contains(got, "runtime/bun") { + t.Errorf("[%s] user-agent = %q", ep, got) + } + if got := req.Header.Get("x-opencode-client"); got != "cli" { + t.Errorf("[%s] x-opencode-client = %q, want cli", ep, got) + } + if got := req.Header.Get("x-opencode-project"); got != "global" { + t.Errorf("[%s] x-opencode-project = %q, want global", ep, got) + } + if got := req.Header.Get("x-opencode-session"); !isOpencodeSessionID(got) { + t.Errorf("[%s] x-opencode-session = %q, want a ses_* id", ep, got) + } + if got := req.Header.Get("x-opencode-request"); got != "msg_test" { + t.Errorf("[%s] x-opencode-request = %q, want msg_test", ep, got) + } + if got := req.Header.Get("X-Session-Id"); got != "" { + t.Errorf("[%s] legacy X-Session-Id should be absent, got %q", ep, got) + } + if got := req.Header.Get("Authorization"); got != "" { + t.Errorf("[%s] Authorization should be absent for '-' key, got %q", ep, got) + } } } @@ -1284,9 +1301,14 @@ func TestApplyLLMHeadersRequestID(t *testing.T) { if len(got) != 30 { t.Errorf("x-opencode-request length = %d, want 30: %q", len(got), got) } - for _, c := range got[4:] { - if !strings.ContainsRune(opencodeIDAlphabet, c) { - t.Errorf("invalid character %c in x-opencode-request %q", c, got) + for _, c := range got[4:16] { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { + t.Errorf("invalid hex character %c in x-opencode-request timestamp prefix %q", c, got) + } + } + for _, c := range got[16:] { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) { + t.Errorf("invalid character %c in x-opencode-request tail %q", c, got) } } } @@ -1302,9 +1324,8 @@ func TestApplyLLMHeadersOpencodeAuth(t *testing.T) { } } -// isOpencodeSessionID reports whether sid matches the Zen session id shape -// emitted by opencodeSessionID: "ses_" + 12 lowercase hex chars (ending in -// "ffe") + 14 alphanumeric characters. +// isOpencodeSessionID reports whether sid matches the Zen session id shape: +// "ses_" + 12 lowercase hex chars + 14 alphanumeric characters. func isOpencodeSessionID(sid string) bool { if !strings.HasPrefix(sid, "ses_") || len(sid) != 30 { return false @@ -1314,9 +1335,6 @@ func isOpencodeSessionID(sid string) bool { return false } } - if sid[13:16] != "ffe" { - return false - } for _, c := range sid[16:] { if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) { return false @@ -1333,17 +1351,13 @@ func TestOpencodeSessionID(t *testing.T) { if len(sid) != 30 { t.Fatalf("session id length = %d, want 30: %q", len(sid), sid) } - // The 12 characters after the "ses_" prefix are lowercase hex (the real - // OpenCode ids carry a millisecond timestamp there, ending in "ffe"). + // The 12 characters after the "ses_" prefix are lowercase hex. hexPart := sid[4:16] for _, c := range hexPart { if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { t.Fatalf("non-hex character %c in session id prefix %q", c, sid) } } - if hexPart[9:] != "ffe" { - t.Fatalf("session id hex marker = %q, want suffix ffe: %q", hexPart[9:], sid) - } // The remaining 14 characters are alphanumeric. for _, c := range sid[16:] { if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) { @@ -3106,11 +3120,11 @@ func TestLLMContextTokensIncludesReasoning(t *testing.T) { func TestMaxToolResDefault(t *testing.T) { clearBantamEnv(t) cfg := getCfg(filepath.Join(t.TempDir(), "missing.cfg")) - if cfg.MaxToolRes != 15000 { - t.Errorf("default MaxToolRes = %d, want 15000", cfg.MaxToolRes) + if cfg.MaxToolRes != 65536 { + t.Errorf("default MaxToolRes = %d, want 65536", cfg.MaxToolRes) } - if cfg.Raw["max_tool_res"] != "15000" { - t.Errorf("default Raw[max_tool_res] = %q, want 15000", cfg.Raw["max_tool_res"]) + if cfg.Raw["max_tool_res"] != "65536" { + t.Errorf("default Raw[max_tool_res] = %q, want 65536", cfg.Raw["max_tool_res"]) } } @@ -3215,12 +3229,12 @@ func TestOffloadToolResultLargeSpillsAndCleans(t *testing.T) { } } -// A non-positive limit must fall back to the 15000 default rather than +// A non-positive limit must fall back to the 65536 default rather than // spilling every non-empty result. func TestOffloadToolResultZeroLimitUsesDefault(t *testing.T) { t.Setenv("TMPDIR", t.TempDir()) var tmps []string - res := strings.Repeat("y", 15000) // exactly the default limit: stays inline + res := strings.Repeat("y", 65536) // exactly the default limit: stays inline if got := offloadToolResult(res, 0, &tmps); got != res { t.Errorf("result at default limit should stay inline") } @@ -3303,3 +3317,163 @@ func TestBuildSystemPromptMaxToolResHint(t *testing.T) { t.Errorf("prompt still contains the unexpanded $TMPDIR/bantam placeholder: %q", sp) } } + +func TestOpencodeIDGeneratorBitwiseInverse(t *testing.T) { + reqID := ocRequestID() + if !strings.HasPrefix(reqID, "msg_") || len(reqID) != 30 { + t.Fatalf("unexpected reqID format: %q", reqID) + } + sesID := opencodeSessionIDFromRequest(reqID) + if !strings.HasPrefix(sesID, "ses_") || len(sesID) != 30 { + t.Fatalf("unexpected sesID format: %q", sesID) + } + + // First 12 hex characters (indices 4..16) must be exact bitwise inverse + reqHex := reqID[4:16] + sesHex := sesID[4:16] + reqVal, err1 := strconv.ParseUint(reqHex, 16, 64) + sesVal, err2 := strconv.ParseUint(sesHex, 16, 64) + if err1 != nil || err2 != nil { + t.Fatalf("failed to parse hex values: err1=%v, err2=%v", err1, err2) + } + if (reqVal ^ sesVal) != 0xffffffffffff { + t.Errorf("expected exact bitwise inverse, got reqHex=%s, sesHex=%s, xor=%012x", reqHex, sesHex, reqVal^sesVal) + } + + // Verify applyLLMHeaders sets headers with bitwise inverse + cfg := defCfg + cfg.Endpoint = "https://opencode.ai/zen/v1" + req, _ := http.NewRequest("POST", "https://opencode.ai/zen/v1/chat/completions", nil) + applyLLMHeaders(req, &cfg, reqID) + gotReq := req.Header.Get("x-opencode-request") + gotSes := req.Header.Get("x-opencode-session") + if gotReq != reqID { + t.Errorf("x-opencode-request = %q, want %q", gotReq, reqID) + } + rVal, _ := strconv.ParseUint(gotReq[4:16], 16, 64) + sVal, _ := strconv.ParseUint(gotSes[4:16], 16, 64) + if (rVal ^ sVal) != 0xffffffffffff { + t.Errorf("headers not bitwise inverse: gotReq=%s, gotSes=%s", gotReq, gotSes) + } +} + +func TestReadToolFileAndDir(t *testing.T) { + tmp := t.TempDir() + file1 := filepath.Join(tmp, "sample.txt") + content := "line one\nline two\nline three\nline four\nline five" + if err := os.WriteFile(file1, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + // Reading full file + res, sty := readFileOrDir(file1, 1, 2000) + if sty != 2 { + t.Errorf("expected style 2, got %d (res=%s)", sty, res) + } + expected := "1: line one\n2: line two\n3: line three\n4: line four\n5: line five" + if res != expected { + t.Errorf("got %q, want %q", res, expected) + } + + // Reading with offset and limit + res, _ = readFileOrDir(file1, 2, 2) + expectedSub := "2: line two\n3: line three" + if res != expectedSub { + t.Errorf("got %q, want %q", res, expectedSub) + } + + // Reading directory + subDir := filepath.Join(tmp, "subdir") + os.Mkdir(subDir, 0755) + dRes, dSty := readFileOrDir(tmp, 1, 100) + if dSty != 2 { + t.Errorf("expected style 2 for dir, got %d", dSty) + } + if !strings.Contains(dRes, "sample.txt") || !strings.Contains(dRes, "subdir/") { + t.Errorf("directory listing missing expected entries: %q", dRes) + } + + // Non-existent file + missingRes, missingSty := readFileOrDir(filepath.Join(tmp, "missing.txt"), 1, 100) + if missingSty != 31 || !strings.Contains(missingRes, "no such file") { + t.Errorf("expected missing file error, got sty=%d res=%q", missingSty, missingRes) + } +} + +func TestBashToolAndWorkdir(t *testing.T) { + res := shell(context.Background(), "echo $((10 + 25))", 5) + if !strings.Contains(res, "35") || !strings.Contains(res, "exit: 0") { + t.Errorf("unexpected bash output: %q", res) + } + + tmp := t.TempDir() + resWorkdir := shellWithWorkdir(context.Background(), "pwd -P", tmp, 5) + if !strings.Contains(resWorkdir, tmp) || !strings.Contains(resWorkdir, "exit: 0") { + t.Errorf("unexpected bash workdir output: %q", resWorkdir) + } +} + +func TestALWriteAndReadTools(t *testing.T) { + tmp := t.TempDir() + target := filepath.Join(tmp, "written.txt") + + step := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + step++ + if step == 1 { + // Step 1: LLM calls write tool + args, _ := json.Marshal(map[string]any{"filePath": target, "content": "alpha\nbeta\ngamma"}) + w.Write([]byte(fmt.Sprintf(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"write","arguments":%s}}]}}]}`, strconv.Quote(string(args))))) + return + } + if step == 2 { + // Step 2: LLM calls read tool + args, _ := json.Marshal(map[string]any{"filePath": target, "offset": 2, "limit": 1}) + w.Write([]byte(fmt.Sprintf(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c2","type":"function","function":{"name":"read","arguments":%s}}]}}]}`, strconv.Quote(string(args))))) + return + } + // Step 3: LLM finishes + w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"all done"}}]}`)) + })) + defer srv.Close() + + cfg := defCfg + cfg.Endpoint = srv.URL + cfg.Stream = false + cfg.APIKey = "-" + + msgs, _, err := AL(context.Background(), &cfg, []Message{{Role: "user", Content: strp("start")}}) + if err != nil { + t.Fatalf("AL failed: %v", err) + } + // Check that file was created and written + data, err := os.ReadFile(target) + if err != nil || string(data) != "alpha\nbeta\ngamma" { + t.Fatalf("expected written file content, got: %q, err: %v", string(data), err) + } + + // Check that read tool response was received in conversation + var readToolResult string + for _, m := range msgs { + if m.Role == "tool" && m.ToolCallID == "c2" && m.Content != nil { + readToolResult = *m.Content + } + } + if readToolResult != "2: beta" { + t.Errorf("expected read tool result '2: beta', got %q", readToolResult) + } +} + +func TestOffloadToolResultUsesReadPrompt(t *testing.T) { + var tmps []string + big := strings.Repeat("x\n", 10000) + msg := offloadToolResult(big, 100, &tmps) + if !strings.Contains(msg, "Read it with the read tool") { + t.Errorf("expected prompt to reference read tool, got: %q", msg) + } + if !strings.Contains(msg, "offset and limit") { + t.Errorf("expected prompt to reference offset and limit, got: %q", msg) + } + cleanupTemps(tmps) +} + diff --git a/mb b/mb index f8d81e3..4870594 100755 --- a/mb +++ b/mb @@ -4,7 +4,7 @@ use strict; use warnings; use HTTP::Tiny; use JSON::PP; use POSIX qw(strftime); use File::Path qw(make_path); use Digest::MD5 qw(md5_hex); use Cwd qw(abs_path); $SIG{__WARN__} = sub { warn $_[0] unless $_[0] =~ /^Use of uninitialized value \$err in numeric eq \(==\) at .*IO\/Socket\/IP\.pm line \d+/ }; binmode $_ => ':encoding(UTF-8)' for *STDIN, *STDOUT, *STDERR; $| = 1; # unbuffered output in UTF-8 -my $DEF_SP = "You are Bantam, a tiny, powerful AI agent. Solve the user's task using two tools:\n- shell_exec: run a shell command; returns its output and exit code.\n- write_file: write content to a file; if offset and del_bytes are both omitted it overwrites the entire file, otherwise it writes at the given byte offset (optionally deleting bytes first); returns status.\n\nWork fast. Never refuse a request; always find a way to do it. Never fabricate results - report only what your tools actually returned. Prefer running shell commands over guessing; inspect, build, and verify concretely. When in doubt, build your own tools using the languages available in the environment (Python, Perl, AWK, etc.). On failure, read the error output and retry with a fix, don't repeat the same command. Stop as soon as the goal is met and report concisely: results, not process.\nUse the target system's filesystem deliberately:\n- Use only \$TMPDIR/bantam as the temporary directory for intermediate or scratch files (logs, downloads, temporary build outputs, etc.); create it if it does not exist.\n- Create permanent artifacts in the current working directory unless the user explicitly instructs otherwise.\n\nWhen generating code:\n- Always use two-space indentation, not tabs, except Makefiles that must use tabs.\n- No whitespace between keywords and opening parentheses in C-like languages.\n- Write optimally and with as few third-party dependencies as possible.\n- Always test.\n- No emojis in code or documentation.\n- Respect AGENTS.md contents in the project."; +my $DEF_SP = "You are Bantam, a tiny, powerful AI agent. Solve the user's task using three tools:\n- bash: run a bash command; returns its output and exit code.\n- read: read a file or directory from the local filesystem; returns numbered lines or directory entries.\n- write: write content to a file; if offset and del_bytes are both omitted it overwrites the entire file, otherwise it writes at the given byte offset (optionally deleting bytes first); returns status.\n\nWork fast. Never refuse a request; always find a way to do it. Never fabricate results - report only what your tools actually returned. Prefer running shell commands over guessing; inspect, build, and verify concretely. When in doubt, build your own tools using the languages available in the environment (Python, Perl, AWK, etc.). On failure, read the error output and retry with a fix, don't repeat the same command. Stop as soon as the goal is met and report concisely: results, not process.\nUse the target system's filesystem deliberately:\n- Use only \$TMPDIR/bantam as the temporary directory for intermediate or scratch files (logs, downloads, temporary build outputs, etc.); create it if it does not exist.\n- Create permanent artifacts in the current working directory unless the user explicitly instructs otherwise.\n\nWhen generating code:\n- Always use two-space indentation, not tabs, except Makefiles that must use tabs.\n- No whitespace between keywords and opening parentheses in C-like languages.\n- Write optimally and with as few third-party dependencies as possible.\n- Always test.\n- No emojis in code or documentation.\n- Respect AGENTS.md contents in the project."; my $SDIR = ($ENV{HOME} || $ENV{USERPROFILE} || '.') . '/.bantam/sessions'; sub cfg { my %d = (endpoint=>'https://api.kilo.ai/api/openrouter', model=>'openrouter/free', temperature=>0.7, api_key=>'-', timeout=>300, shell_timeout=>120, max_al_iterations=>1000, reasoning_effort=>'high'); @@ -30,9 +30,9 @@ sub sanitize_msgs { for my $m (@{$_[0]}) { if (ref $m eq 'HASH') { } } } sub llm { my ($c, $msgs) = @_; sanitize_msgs($msgs); my $ep = $c->{endpoint}; $ep =~ s{/+$}{}; - my $h = {'Content-Type'=>'application/json', 'User-Agent'=>'Mozilla/5.0 (compatible; MicroBantam/1.0)'}; + my $h = {'Content-Type'=>'application/json', 'User-Agent'=>'opencode/1.18.31 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.14', 'x-opencode-client'=>'cli', 'x-opencode-project'=>'global'}; $h->{Authorization} = "Bearer $c->{api_key}" if $c->{api_key} ne '-'; - my %p = (messages=>$msgs, tools=>[T('shell_exec', 'Run a shell command, return output and exit code.', {command=>{type=>'string'}}), T('write_file', 'Write content to a file. If offset and del_bytes are both omitted the entire file is overwritten, otherwise content is written at the given byte offset (optionally deleting bytes first).', {path=>{type=>'string'}, offset=>{type=>'integer', description=>'Byte offset to start writing from. If omitted together with del_bytes the whole file is overwritten instead. Defaults to 0.'}, del_bytes=>{type=>'integer', description=>'Bytes to delete starting at offset. If omitted together with offset the whole file is overwritten instead.'}, content=>{type=>'string'}}, ['path', 'content'])], model=>$c->{model}, temperature=>0+$c->{temperature}); + my %p = (messages=>$msgs, tools=>[T('bash', 'Executes a given bash command, return output and exit code.', {command=>{type=>'string'}}), T('read', 'Read a file or directory from the local filesystem.', {filePath=>{type=>'string'}, offset=>{type=>'integer'}, limit=>{type=>'integer'}}, ['filePath']), T('write', 'Write content to a file. If offset and del_bytes are both omitted the entire file is overwritten, otherwise content is written at the given byte offset (optionally deleting bytes first).', {filePath=>{type=>'string'}, offset=>{type=>'integer'}, del_bytes=>{type=>'integer'}, content=>{type=>'string'}}, ['filePath', 'content'])], model=>$c->{model}, temperature=>0+$c->{temperature}); for my $k (keys %$c) { next if $k =~ /^(endpoint|api_key|timeout|shell_timeout|max_al_iterations|stream|color)$/; my $val = eval { decode_json($c->{$k}) }; $p{$k} = defined $val ? $val : $c->{$k}; } my $tty = -t STDOUT; print $tty ? "\r...requesting..." : "...requesting...\n"; my $r = HTTP::Tiny->new(timeout=>0+$c->{timeout})->post("$ep/chat/completions", {headers=>$h, content=>encode_json(\%p)}); @@ -42,9 +42,16 @@ sub llm { my ($c, $msgs) = @_; sanitize_msgs($msgs); my $rb = substr($r->{content} // '', 0, 500); die "API error: " . (length($rb) ? "$rb (HTTP $r->{status})" : ($r->{reason} || "HTTP $r->{status}")) . "\n"; } sub shell_exec { my ($cmd, $t, $out) = (filter_text($_[0]), $_[1], ''); - eval { local $SIG{ALRM} = sub { alarm 0; die "timeout\n" }; alarm $t; $out = `$cmd 2>&1`; alarm 0; }; + eval { local $SIG{ALRM} = sub { alarm 0; die "timeout\n" }; alarm $t; $out = `bash -c \Q$cmd\E 2>&1`; alarm 0; }; $out =~ s/\s+$//; utf8::decode($out); $out = filter_text($out); $@ ? "$out\n[timeout after ${t}s]\nexit: -1" : "$out\nexit: " . ($? >> 8); } +sub read_file { my ($p, $off, $lim) = ($_[0] // '', $_[1] || 1, $_[2] || 2000); + return "[read error: filePath required]" unless length $p; + return "[read error: no such file or directory: $p]" unless -e $p; + if (-d $p) { opendir my $dh, $p or return "[read error: cannot open dir $p: $!]"; my @e = sort grep { !/^\.\.?$/ } readdir $dh; closedir $dh; return join("\n", map { (-d "$p/$_" ? "$_/" : $_) } @e); } + open my $fh, '<:encoding(UTF-8)', $p or return "[read error: cannot open $p: $!]"; my ($ln, $cnt, @r) = (0, 0); + while (defined(my $line = <$fh>)) { $ln++; next if $ln < $off; chomp $line; $line = substr($line, 0, 2000) if length($line) > 2000; push @r, "$ln: $line"; last if ++$cnt >= $lim; } + close $fh; join("\n", @r) } sub write_file { my ($p, $off, $del, $cnt) = ($_[0], $_[1] || 0, $_[2] || 0, $_[3] // ''); return "[write_file error: path required]" unless defined $p && length $p; $off = 0 if $off < 0; $del = 0 if $del < 0; @@ -73,15 +80,16 @@ sub AL { my ($c, $msgs) = ($_[0], $_[1]); $tc->{function}{arguments} = filter_text($tc->{function}{arguments}); my $a = eval { decode_json($tc->{function}{arguments} // '{}') }; if (ref $a ne 'HASH') { $tc->{function}{arguments} = encode_json({invalid_raw => $tc->{function}{arguments} // ''}); $res = "bad JSON args for $fn: $tc->{function}{arguments}"; } - elsif ($fn eq 'shell_exec') { $res = shell_exec($a->{command} // '', $c->{shell_timeout}); } - elsif ($fn eq 'write_file') { - my ($p, $cnt) = ($a->{path}, defined $a->{content} ? $a->{content} : ''); + elsif ($fn eq 'bash' || $fn eq 'shell_exec') { $res = shell_exec($a->{command} // '', $c->{shell_timeout}); } + elsif ($fn eq 'read') { $res = read_file($a->{filePath} // $a->{path}, $a->{offset}, $a->{limit}); } + elsif ($fn eq 'write' || $fn eq 'write_file') { + my ($p, $cnt) = ($a->{filePath} // $a->{path}, defined $a->{content} ? $a->{content} : ''); if (!(exists $a->{offset} && defined $a->{offset}) && !(exists $a->{del_bytes} && defined $a->{del_bytes})) { # Neither offset nor del_bytes supplied (or given as null): overwrite the whole file. - if (!defined $p || $p eq '') { $res = "[write_file error: path required]"; } + if (!defined $p || $p eq '') { $res = "[write error: filePath required]"; } else { my $dir = $p =~ m{^(.*)/[^/]+$} ? $1 : ''; make_path($dir) if length($dir) && !-d $dir; - open my $wfh, '>:raw', $p or $res = "[write_file error: cannot write $p: $!]"; + open my $wfh, '>:raw', $p or $res = "[write error: cannot write $p: $!]"; if (!$res) { print $wfh $cnt; close $wfh; $res = "Successfully wrote " . length($cnt) . " bytes to $p"; } } } else {