feat: import Go implementation as v2 kernel (renamed duo → reasonix)

Bring in the finished Go rewrite (developed under the placeholder name
"duo") as the v2 codebase. Code-only import — no upstream git history.

Config- and plugin-driven coding agent: registry core, OpenAI-compatible
providers, builtin tools, MCP (stdio + HTTP) plugins, permission + sandbox,
custom slash commands, @-references, two-model collaboration.

Renamed throughout: duo→reasonix, Duo→Reasonix, DUO→REASONIX — module path,
imports, CLI name, cmd/ dirs, reasonix.toml, .reasonix/commands, and
$REASONIX_* env prefixes. Kept the v2 MIT LICENSE (the Go project had none).

Verified: go build ./... · go vet ./... · go test ./... all green; gofmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
yhhandClaude Opus 4.8 committed 2026-05-29 18:13:20 -07:00
1 parent 32a4c02e54
commit 7de6a2474f
92 files changed
+13485 -65

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
# Copy to .env and fill in. Only secrets belong here — a provider's base_url and
# model are configured in reasonix.toml, where api_key_env points at these variables.
DEEPSEEK_API_KEY=
MIMO_API_KEY=
+10 -32
View File
@@ -1,40 +1,18 @@
# Go
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
# Build artifacts
/bin/
/dist/
coverage.out
coverage.html
go.work
go.work.sum
/reasonix
# Dependencies (uncomment if vendoring)
# /vendor/
# Desktop build artifacts
/desktop/build/
/desktop/dist/
node_modules/
# Environment / secrets
# Environment / local config (reasonix.example.toml is the shared template)
.env
.env.*
!.env.example
.env.local
/reasonix.toml
# Reasonix runtime
.reasonix/
# Editors / IDE
# Editor / OS
.DS_Store
.idea/
.vscode/
*.swp
*~
# OS
.DS_Store
Thumbs.db
# Go
*.test
*.out
+5
View File
@@ -0,0 +1,5 @@
---
description: Review a file for bugs
argument-hint: [path]
---
Read $1 and list any correctness bugs or risky patterns, with file:line references, most important first. Focus: $ARGUMENTS.
+28
View File
@@ -0,0 +1,28 @@
VERSION := $(shell git describe --tags --always 2>/dev/null || echo dev)
LDFLAGS := -s -w -X main.version=$(VERSION)
.PHONY: build vet fmt test cross clean
build:
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/reasonix ./cmd/reasonix
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/reasonix-plugin-example ./cmd/reasonix-plugin-example
vet:
go vet ./...
fmt:
gofmt -w .
test:
go test ./...
cross:
@mkdir -p dist
@for p in darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64 windows/arm64; do \
os=$${p%/*}; arch=$${p#*/}; ext=; [ $$os = windows ] && ext=.exe; \
echo "build $$os/$$arch"; \
CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch go build -ldflags "$(LDFLAGS)" -o dist/reasonix-$$os-$$arch$$ext ./cmd/reasonix; \
done
clean:
rm -rf bin dist
+198 -33
View File
@@ -1,39 +1,204 @@
# Reasonix v2
# Reasonix
> DeepSeek-native coding agent — ground-up rewrite.
English | [简体中文](README.zh-CN.md)
This branch is a clean-slate rebuild:
A **config- and plugin-driven** coding agent: a thin harness driving multiple
models. The core knows only interfaces — concrete models and tools are resolved
from configuration or injected by plugins. Ships as a single static binary.
- **Kernel rewritten in Go** — the agent loop, tooling, and runtime move from TypeScript to Go.
- **Web removed** — no web client in v2.
- **Desktop client redone** — rebuilt from scratch.
## Features
- **Config-driven.** Providers, the agent, enabled tools, and plugins are all
declared in `reasonix.toml`. No hardcoded models.
- **Multi-model & composable.** DeepSeek (flash/pro) and MiMo ship as presets;
any OpenAI-compatible endpoint is a config entry, not new code. Optionally run
two models together (executor + planner) in separate, cache-stable sessions.
- **Plugin-driven.** External tools run as subprocesses over stdio JSON-RPC
(MCP-compatible). Built-in tools self-register at compile time.
- **Zero-friction distribution.** `CGO_ENABLED=0` single binary; cross-compile
to six targets with one command. The only dependency is a TOML parser.
## Install / Build
```sh
make build # -> bin/reasonix
make cross # -> dist/ (darwin|linux|windows × amd64|arm64)
```
## Quick start
```sh
reasonix init # scaffold ./reasonix.toml
export DEEPSEEK_API_KEY=sk-... # or put it in .env (see .env.example)
reasonix run "implement the TODOs in main.go"
reasonix run --model mimo-pro "add unit tests for this function"
echo "explain this code" | reasonix run
```
## Configuration
Resolution order: **flag > `./reasonix.toml` > `~/.config/reasonix/config.toml` >
built-in defaults**. Secrets come from the environment via `api_key_env` and are
never stored in config files.
```toml
default_model = "deepseek-flash" # executor; set [agent].planner_model to add a planner
# language = "zh" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG
[[providers]]
name = "deepseek-flash"
kind = "openai"
base_url = "https://api.deepseek.com"
model = "deepseek-v4-flash"
api_key_env = "DEEPSEEK_API_KEY"
# also preset: deepseek-pro, mimo-pro (mimo-v2.5-pro), mimo-flash (mimo-v2-flash) @ api.xiaomimimo.com/v1
[tools]
enabled = [] # omit/empty = all built-ins
[permissions]
mode = "ask" # writer fallback when no rule matches: ask|allow|deny
deny = ["bash(rm -rf*)", "bash(git push*)"] # hard-blocked in every mode
allow = ["bash(go test*)"] # never prompted
[sandbox]
# workspace_root = "" # file-writers confined here; empty = current dir
# allow_write = ["/tmp"] # extra dirs write_file/edit_file/multi_edit may touch
[[plugins]]
name = "example"
command = "reasonix-plugin-example"
```
Permissions gate each tool call: `deny` > `ask` > `allow` > fallback (readers
always allow; writers fall back to `mode`). `reasonix chat` prompts before writers
(`y` once · `a` this session · `n` no); `reasonix run` stays autonomous but still
honours `deny`. See [`docs/SPEC.md`](docs/SPEC.md) for the full schema and contract.
Permissions are *policy* (which calls to allow / prompt). The **sandbox** is
*enforcement*: the file-writers (`write_file` / `edit_file` / `multi_edit`)
refuse any path outside `[sandbox] workspace_root` (default: the current dir, so
edits stay in the project), resolving symlinks and `..` so a link can't tunnel
out. Reads are unrestricted. `bash` is itself jailed on macOS by default
(`[sandbox] bash`, Seatbelt): commands may write only those same roots (plus
temp and toolchain caches) and reach the network only when `[sandbox] network`
is set. Other platforms fall back to running unconfined for now (see
`docs/SPEC.md` §9 for the escape-prompt and Linux support still to come).
### Plugins (MCP)
Reasonix is an MCP client. A `[[plugins]]` entry's `type` selects the transport:
`stdio` (default) launches a local subprocess (`command`/`args`/`env`); `http`
(Streamable HTTP) connects to a remote `url` with optional static `headers`
(`${VAR}` / `${VAR:-default}` expanded from the environment, so tokens stay out
of the file). Tools surface to the model as `mcp__<server>__<tool>`, matching
Claude Code; a tool declaring MCP's `readOnlyHint: true` joins parallel dispatch
and the permission reader-default.
A server's **prompts** surface as `/mcp__<server>__<prompt>` slash commands
(positional args after the command); its **resources** are pulled in by writing
`@<server>:<uri>` in a message; `/mcp` lists connected servers and what each
exposes. `make build` also produces `bin/reasonix-plugin-example` — a runnable
reference stdio server (`echo`, `wordcount`, a `review` prompt, a style-guide
resource) you can copy.
```toml
[[plugins]] # local stdio server
name = "example"
command = "reasonix-plugin-example"
[[plugins]] # remote server over Streamable HTTP
name = "stripe"
type = "http"
url = "https://mcp.stripe.com"
headers = { Authorization = "Bearer ${STRIPE_KEY}" }
```
**Already have a Claude Code `.mcp.json`?** Drop it in the project root and Reasonix
reads it as-is — the `mcpServers` spec (`command`/`args`/`env`, `type`/`url`/
`headers`, `${VAR}` expansion) maps field-for-field onto `[[plugins]]`. Both
sources are merged; on a name collision `reasonix.toml` wins.
```json
{
"mcpServers": {
"filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path"] },
"stripe": { "type": "http", "url": "https://mcp.stripe.com", "headers": { "Authorization": "Bearer ${STRIPE_KEY}" } }
}
}
```
### Slash commands
In `reasonix chat`, built-in commands (`/compact`, `/new`, `/mcp`, `/help`) run
locally. **Custom commands** are Markdown files under `.reasonix/commands/` (project)
or `~/.config/reasonix/commands/` (user) — `review.md` becomes `/review`, a
subdirectory namespaces it (`git/commit.md` → `/git:commit`). The body is a
prompt template; invoking the command sends it as a turn.
```markdown
---
description: Review the staged diff
argument-hint: [focus-area]
---
Review the staged diff. Focus on $ARGUMENTS, list bugs with file:line.
```
`$ARGUMENTS` expands to all space-separated args, `$1`…`$N` to positional ones.
MCP prompts also appear here as `/mcp__<server>__<prompt>`.
### @ references
Embed `@` references in a message and Reasonix resolves them before sending, as
tagged context blocks: `@path/to/file` (or `@dir`) injects a local file's
contents (or a directory listing), and `@<server>:<uri>` injects an MCP
resource. A local path is only treated as a reference when it actually exists,
so ordinary `@mentions` stay literal. Typing `/` or `@` opens an autocomplete
menu — slash commands, or hierarchical file navigation (one directory level at a
time, descend into folders) plus MCP resources.
### Two-model collaboration (optional)
`reasonix init` keeps first-run minimal: pick provider → keys (every SKU of a
chosen provider is enabled). Running two models together (executor + planner,
separate cache-stable sessions) is a one-line edit afterwards — set
`planner_model` to any other enabled provider:
```toml
[agent]
planner_model = "deepseek-pro" # used as the low-frequency planner
```
## Architecture
Three tiers of extensibility, all behind registries the core resolves by name:
1. **Registry** — `Provider` and `Tool` are interfaces; the core has no
`switch model`.
2. **Compile-time built-ins** — providers (`provider/openai`) and tools
(`tool/builtin`) self-register via `init()`; `main` blank-imports them.
Adding a built-in is one file plus one import.
3. **Runtime plugins** — executables declared in config, spoken to over
newline-delimited JSON-RPC 2.0 on stdin/stdout (the MCP stdio convention).
Each remote tool is adapted to the `Tool` interface.
## Status
🚧 Early work in progress. The architecture and layout are still taking shape.
The previous TypeScript implementation (CLI + web + desktop) remains available on the
[`main`](https://github.com/esengine/DeepSeek-Reasonix/tree/main) branch for reference.
## License
[MIT](./LICENSE) © 2026 Reasonix Contributors
---
# Reasonix v2(简体中文)
> DeepSeek 原生编码 agent —— 从零重写。
本分支是全新重建:
- **内核改用 Go 重写** —— agent 循环、工具与运行时从 TypeScript 迁移到 Go。
- **去掉 web** —— v2 不再包含 web 端。
- **重做桌面端** —— 从头重建。
## 状态
🚧 早期开发中,架构与目录结构尚在成形。
旧版 TypeScript 实现(CLI + web + 桌面端)仍保留在
[`main`](https://github.com/esengine/DeepSeek-Reasonix/tree/main) 分支以供参考。
Done: registry-based providers/tools, OpenAI-compatible streaming with tool
calls (bounded retry on 429/5xx), nine built-in tools (read_file, write_file,
edit_file, multi_edit, bash, ls, glob, grep, web_fetch), TOML config, an
interactive `reasonix init` wizard, two-model collaboration (executor + planner in
separate, cache-stable sessions), low-frequency context compaction, sub-agents
(`task`), a bubbletea chat TUI (markdown, plan mode, context gauge, `/compact`
`/new`), session persistence + resume, per-call **permissions** (allow/ask/deny
rules; chat prompts before writers, deny rules hard-block everywhere), a
**workspace sandbox** confining file-writers to the project (symlink/`..`-safe),
stdio (MCP) client — **stdio + Streamable HTTP** transports, tools (`mcp__server__tool`,
`readOnlyHint`-aware), prompts (slash commands), resources (`@`-references), and
`/mcp`, configured via `[[plugins]]` or a Claude-style project `.mcp.json` —
custom slash commands (`.reasonix/commands/*.md`), `@file` / `@resource`
references, plus a runnable reference plugin (`cmd/reasonix-plugin-example`), the
harness loop, and CLI. The chat runs in the terminal's normal buffer (native
scrollback) with `/` and `@` autocomplete. Next: an OS-level sandbox for `bash`
(macOS Seatbelt / Linux bubblewrap, "allow inside the box, prompt at its edge"),
an Anthropic-native provider, MCP OAuth + legacy SSE. See `docs/SPEC.md` §9.
+184
View File
@@ -0,0 +1,184 @@
# Reasonix
[English](README.md) | 简体中文
一个**由配置和插件驱动**的 coding agent:极薄的 harness 驱动多个模型。内核只认
接口——具体的模型与工具由配置解析或由插件注入。以单一静态二进制分发。
## 特性
- **配置驱动**:provider、agent、启用的工具、插件全部在 `reasonix.toml` 中声明,
内核无硬编码模型。
- **多模型 · 可组合**:DeepSeek(flash/pro)与 MiMo 作为预设内置;任何 OpenAI 兼容
端点都只是一条配置。可选让两个模型协同(执行器 + 规划器),各自独立、缓存稳定的 session。
- **插件驱动**:外部工具以子进程形式运行,通过 stdio JSON-RPC 通信(MCP 兼容);
内置工具在编译期自注册。
- **零摩擦分发**:`CGO_ENABLED=0` 单二进制;一条命令交叉编译到六个目标平台。
唯一依赖是一个 TOML 解析库。
## 安装 / 构建
```sh
make build # -> bin/reasonix
make cross # -> dist/(darwin|linux|windows × amd64|arm64)
```
## 快速开始
```sh
reasonix init # 生成 ./reasonix.toml
export DEEPSEEK_API_KEY=sk-... # 或写入 .env(见 .env.example)
reasonix run "把 main.go 里的 TODO 实现掉"
reasonix run --model mimo-pro "给这个函数补单元测试"
echo "解释这段代码" | reasonix run
```
## 配置
优先级:**flag > `./reasonix.toml` > `~/.config/reasonix/config.toml` > 内置默认值**。
密钥经环境变量通过 `api_key_env` 注入,绝不写入配置文件。
```toml
default_model = "deepseek-flash" # 执行器;设 [agent].planner_model 可加规划器
# language = "zh" # 界面语言;为空则按 $LANG / $REASONIX_LANG 自动检测
[[providers]]
name = "deepseek-flash"
kind = "openai"
base_url = "https://api.deepseek.com"
model = "deepseek-v4-flash"
api_key_env = "DEEPSEEK_API_KEY"
# 还有预设:deepseek-pro、mimo-pro(mimo-v2.5-pro)、mimo-flash(mimo-v2-flash) @ api.xiaomimimo.com/v1
[tools]
enabled = [] # 省略/为空 = 全部内置工具
[permissions]
mode = "ask" # 无规则命中时 writer 的兜底:ask|allow|deny
deny = ["bash(rm -rf*)", "bash(git push*)"] # 任何模式下都硬阻断
allow = ["bash(go test*)"] # 从不询问
[sandbox]
# workspace_root = "" # 文件写工具被限制在此目录;留空 = 当前目录
# allow_write = ["/tmp"] # write_file/edit_file/multi_edit 额外可写的目录
[[plugins]]
name = "example"
command = "reasonix-plugin-example"
```
权限逐次调用把关:`deny` > `ask` > `allow` > 兜底(只读工具永远 allow,writer 落到
`mode`)。`reasonix chat` 会在 writer 调用前征求同意(`y` 本次 · `a` 本会话 · `n` 拒绝);
`reasonix run` 保持自主运行但仍然遵守 `deny`。完整 schema 与契约见
[`docs/SPEC.md`](docs/SPEC.md)。
权限是**策略**(哪些调用放行/询问),**沙盒**是**强制**:文件写工具
(`write_file` / `edit_file` / `multi_edit`)拒绝 `[sandbox] workspace_root`
之外的任何路径(默认当前目录,编辑不出项目),并解析符号链接与 `..`,使链接无法
打洞越界。读不受限。`bash` 本身在 macOS 默认进沙盒(`[sandbox] bash`,Seatbelt):
命令只能写这些 root(外加临时目录与工具链缓存),`[sandbox] network` 为真时才能联网;
其它平台暂回退为不沙盒运行(越界问一次与 Linux 支持见 `docs/SPEC.md` §9)。
### 插件(MCP)
Reasonix 是一个 MCP 客户端。`[[plugins]]` 的 `type` 选择传输:`stdio`(默认)启动本地子进
程(`command`/`args`/`env`);`http`(Streamable HTTP)连接远程 `url`,可带静态
`headers`(`${VAR}` / `${VAR:-default}` 从环境展开,密钥不入文件)。工具以
`mcp__<server>__<tool>` 暴露给模型,与 Claude Code 一致;声明 MCP `readOnlyHint: true`
的工具会参与并行调度并命中权限层的只读默认放行。
服务器的 **prompts** 会暴露成 `/mcp__<server>__<prompt>` 斜杠命令(命令后空格分隔参
数);**resources** 通过在消息里写 `@<server>:<uri>` 拉入;`/mcp` 列出已连接服务器及
各自暴露的内容。`make build` 还会产出 `bin/reasonix-plugin-example`——一个可直接运行的
stdio 参考实现(`echo`、`wordcount`、一个 `review` prompt、一个 style-guide 资源),
可照抄。
```toml
[[plugins]] # 本地 stdio 服务器
name = "example"
command = "reasonix-plugin-example"
[[plugins]] # 远程 Streamable HTTP 服务器
name = "stripe"
type = "http"
url = "https://mcp.stripe.com"
headers = { Authorization = "Bearer ${STRIPE_KEY}" }
```
**已有 Claude Code 的 `.mcp.json`?** 直接放到项目根目录,Reasonix 会原样读取——其
`mcpServers` 规范(`command`/`args`/`env`、`type`/`url`/`headers`、`${VAR}` 展开)
与 `[[plugins]]` 字段一一对应。两处来源会合并加载;同名时以 `reasonix.toml` 为准。
```json
{
"mcpServers": {
"filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path"] },
"stripe": { "type": "http", "url": "https://mcp.stripe.com", "headers": { "Authorization": "Bearer ${STRIPE_KEY}" } }
}
}
```
### 斜杠命令
`reasonix chat` 里,内置命令(`/compact`、`/new`、`/mcp`、`/help`)在本地执行。**自定义命令**
是放在 `.reasonix/commands/`(项目)或 `~/.config/reasonix/commands/`(用户)下的 Markdown 文件——
`review.md` 即 `/review`,子目录构成命名空间(`git/commit.md` → `/git:commit`)。文件正文
是 prompt 模板,调用即作为一轮对话发出。
```markdown
---
description: Review the staged diff
argument-hint: [focus-area]
---
Review the staged diff. Focus on $ARGUMENTS, list bugs with file:line.
```
`$ARGUMENTS` 展开为全部空格分隔参数,`$1`…`$N` 为位置参数。MCP prompts 也以
`/mcp__<server>__<prompt>` 形式出现在这里。
### @ 引用
在消息里写 `@` 引用,Reasonix 会在发送前解析成带标签的上下文块:`@path/to/file`(或
`@dir`)注入本地文件内容(或目录清单),`@<server>:<uri>` 注入 MCP 资源。本地路径**只有
真实存在**时才当作引用,普通 `@mention` 保持原文。敲 `/` 或 `@` 会弹出补全菜单——斜杠
命令,或**逐层**的文件导航(一次只列当前一层目录、可下钻进子目录)外加 MCP 资源。
### 双模型协同(可选)
`reasonix init` 刻意保持首次体验极简:选 provider → 输入 key(所选 provider 的所有
SKU 都会启用)。若要让两个模型协同(执行器 + 规划器,各自独立、缓存稳定的
session),向导后手动在 `reasonix.toml` 加一行即可:
```toml
[agent]
planner_model = "deepseek-pro" # 作为低频规划器
```
## 架构
三层可扩展性,全部藏在内核按名解析的 registry 之后:
1. **Registry**:`Provider` 与 `Tool` 是接口;内核没有 `switch model`。
2. **编译期内置**:provider(`provider/openai`)和 tool(`tool/builtin`)通过
`init()` 自注册,`main` 用 blank import 拉入。新增内置 = 一个文件 + 一行 import。
3. **运行时插件**:配置里声明的可执行文件,通过 stdin/stdout 上的
newline-delimited JSON-RPC 2.0(MCP stdio 约定)通信,每个远程 tool 适配成
`Tool` 接口。
## 状态
已完成:基于 registry 的 provider/tool、OpenAI 兼容流式 + 工具调用(429/5xx 有界重
试)、九个内置工具(read_file、write_file、edit_file、multi_edit、bash、ls、glob、
grep、web_fetch)、TOML 配置、交互式 `reasonix init` 向导、双模型协同(执行器 + 规划器,
各自独立、缓存稳定的 session)、低频上下文压缩、子 agent(`task`)、bubbletea 聊天
TUI(markdown、plan mode、上下文仪表盘、`/compact` `/new`)、会话持久化 + 恢复、
逐次调用**权限**(allow/ask/deny 规则;chat 在 writer 前询问,deny 在各模式硬阻断)、
**工作区沙盒**(把文件写工具限制在项目内,符号链接/`..` 安全)、
MCP 客户端——**stdio + Streamable HTTP** 传输、工具(`mcp__server__tool`,支持
`readOnlyHint`)、prompts(斜杠命令)、resources(`@` 引用)、`/mcp`,可经
`[[plugins]]` 或 Claude 风格的项目 `.mcp.json` 配置——自定义斜杠命令
(`.reasonix/commands/*.md`)、`@file` / `@resource` 引用、外加可运行的参考插件
(`cmd/reasonix-plugin-example`)、harness 主循环、CLI。chat 在终端普通缓冲区运行(原生
scrollback)并带 `/` 与 `@` 输入补全。后续:给 `bash` 套 OS 级沙盒(macOS Seatbelt /
Linux bubblewrap,"盒子里放行、边界上询问")、Anthropic 原生 provider、MCP OAuth +
legacy SSE。见 `docs/SPEC.md` §9。
+349
View File
@@ -0,0 +1,349 @@
// Command reasonix-plugin-example is a reference Reasonix plugin: a minimal MCP stdio
// server speaking newline-delimited JSON-RPC 2.0 on stdin/stdout. It exists to
// document the contract end-to-end (the protocol the internal/plugin client
// drives) and to give users a working example to copy.
//
// Wire it up in reasonix.toml:
//
// [[plugins]]
// name = "example"
// command = "reasonix-plugin-example"
//
// Then reasonix surfaces its tools as "mcp__example__echo" / "mcp__example__wordcount",
// its prompt as the "/mcp__example__review" slash command, and its resource as
// the "@example:doc://style-guide" reference.
//
// Protocol, one JSON object per line:
// - initialize → {protocolVersion, capabilities, serverInfo}
// - notifications/initialized (notification, no id) → ignored
// - tools/list → {tools: [{name, description, inputSchema, annotations}]}
// - tools/call {name, arguments} → {content: [{type:"text", text}], isError}
// - prompts/list → {prompts: [{name, description, arguments}]}
// - prompts/get {name, arguments} → {messages: [{role, content:{type,text}}]}
// - resources/list → {resources: [{uri, name, description, mimeType}]}
// - resources/read {uri} → {contents: [{uri, mimeType, text}]}
//
// Logs go to stderr (reasonix forwards plugin stderr to the terminal); stdout is
// reserved for JSON-RPC so it must never carry stray prose.
package main
import (
"bufio"
"encoding/json"
"fmt"
"log"
"os"
"strings"
"unicode/utf8"
)
// version is overridable via -ldflags "-X main.version=...". Reported in
// initialize's serverInfo so reasonix (and humans) can see which build is running.
var version = "dev"
func main() {
log.SetPrefix("reasonix-plugin-example: ")
log.SetFlags(0)
if err := serve(os.Stdin, os.Stdout); err != nil {
log.Fatal(err)
}
}
// --- JSON-RPC framing ---
type request struct {
JSONRPC string `json:"jsonrpc"`
ID *json.RawMessage `json:"id"` // nil ⇒ notification (no reply); echoed back verbatim otherwise
Method string `json:"method"`
Params json.RawMessage `json:"params"`
}
type response struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Result any `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
const (
protocolVersion = "2024-11-05"
codeMethodNotFound = -32601
codeInvalidParams = -32602
)
// serve runs the read-dispatch-reply loop until stdin closes (reasonix closed the
// pipe / is shutting down). Each line is one JSON-RPC message.
func serve(in *os.File, out *os.File) error {
r := bufio.NewReader(in)
w := bufio.NewWriter(out)
defer w.Flush()
for {
line, err := r.ReadBytes('\n')
if len(line) > 0 {
if rerr := handleLine(line, w); rerr != nil {
return rerr
}
if ferr := w.Flush(); ferr != nil {
return ferr
}
}
if err != nil {
return nil // EOF or pipe closed: clean shutdown
}
}
}
func handleLine(line []byte, w *bufio.Writer) error {
line = trimSpace(line)
if len(line) == 0 {
return nil
}
var req request
if err := json.Unmarshal(line, &req); err != nil {
log.Printf("skipping unparseable line: %v", err)
return nil
}
if req.ID == nil {
return nil // notification (e.g. notifications/initialized): no reply
}
resp := response{JSONRPC: "2.0", ID: *req.ID}
switch req.Method {
case "initialize":
resp.Result = map[string]any{
"protocolVersion": protocolVersion,
"capabilities": map[string]any{
"tools": map[string]any{},
"prompts": map[string]any{},
"resources": map[string]any{},
},
"serverInfo": map[string]any{"name": "reasonix-plugin-example", "version": version},
}
case "tools/list":
resp.Result = map[string]any{"tools": toolList()}
case "tools/call":
resp.Result, resp.Error = callTool(req.Params)
case "prompts/list":
resp.Result = map[string]any{"prompts": promptList()}
case "prompts/get":
resp.Result, resp.Error = getPrompt(req.Params)
case "resources/list":
resp.Result = map[string]any{"resources": resourceList()}
case "resources/read":
resp.Result, resp.Error = readResource(req.Params)
default:
resp.Error = &rpcError{Code: codeMethodNotFound, Message: "method not found: " + req.Method}
}
b, err := json.Marshal(resp)
if err != nil {
return fmt.Errorf("marshal response: %w", err)
}
if _, err := w.Write(append(b, '\n')); err != nil {
return err
}
return nil
}
// --- tools ---
// toolDef is one exposed tool: its advertised metadata plus the handler. run
// returns the text result, or an error which becomes an isError tool result the
// model can read and adapt to.
type toolDef struct {
name string
description string
schema map[string]any
readOnly bool
run func(args map[string]any) (string, error)
}
// tools is the registry. Both demo tools are read-only and declare it via the
// readOnlyHint annotation, so reasonix runs them in parallel batches and (with the
// permission layer) auto-allows them without prompting.
var tools = []toolDef{
{
name: "echo",
description: "Echo the given text back. The simplest possible proof the plugin round-trip works.",
readOnly: true,
schema: map[string]any{
"type": "object",
"properties": map[string]any{
"text": map[string]any{"type": "string", "description": "Text to echo back"},
},
"required": []string{"text"},
},
run: func(args map[string]any) (string, error) {
text, ok := args["text"].(string)
if !ok {
return "", fmt.Errorf("argument 'text' must be a string")
}
return text, nil
},
},
{
name: "wordcount",
description: "Count the lines, words, and bytes of the given text (like wc).",
readOnly: true,
schema: map[string]any{
"type": "object",
"properties": map[string]any{
"text": map[string]any{"type": "string", "description": "Text to measure"},
},
"required": []string{"text"},
},
run: func(args map[string]any) (string, error) {
text, ok := args["text"].(string)
if !ok {
return "", fmt.Errorf("argument 'text' must be a string")
}
return fmt.Sprintf("lines: %d, words: %d, bytes: %d, runes: %d",
countLines(text), len(strings.Fields(text)), len(text), utf8.RuneCountInString(text)), nil
},
},
}
func toolList() []map[string]any {
out := make([]map[string]any, 0, len(tools))
for _, t := range tools {
out = append(out, map[string]any{
"name": t.name,
"description": t.description,
"inputSchema": t.schema,
"annotations": map[string]any{
"readOnlyHint": t.readOnly,
"title": t.name,
},
})
}
return out
}
// callTool dispatches a tools/call. A handler error is reported as an isError
// content result (in-band, so the model sees it) rather than a JSON-RPC error;
// JSON-RPC errors are reserved for protocol-level faults (bad params, unknown
// tool).
func callTool(params json.RawMessage) (any, *rpcError) {
var p struct {
Name string `json:"name"`
Arguments map[string]any `json:"arguments"`
}
if err := json.Unmarshal(params, &p); err != nil {
return nil, &rpcError{Code: codeInvalidParams, Message: "invalid params: " + err.Error()}
}
for _, t := range tools {
if t.name != p.Name {
continue
}
text, err := t.run(p.Arguments)
if err != nil {
return textResult(err.Error(), true), nil
}
return textResult(text, false), nil
}
return nil, &rpcError{Code: codeInvalidParams, Message: "unknown tool: " + p.Name}
}
func textResult(text string, isError bool) map[string]any {
return map[string]any{
"content": []map[string]any{{"type": "text", "text": text}},
"isError": isError,
}
}
// --- prompts ---
// promptList advertises the server's prompts. They surface in reasonix as
// /mcp__example__<name> slash commands.
func promptList() []map[string]any {
return []map[string]any{{
"name": "review",
"description": "Draft a focused code-review request for a file.",
"arguments": []map[string]any{
{"name": "path", "description": "File to review", "required": true},
},
}}
}
// getPrompt renders a prompt into MCP messages. The returned text becomes the
// next user turn in reasonix, so it's phrased as an instruction to the model.
func getPrompt(params json.RawMessage) (any, *rpcError) {
var p struct {
Name string `json:"name"`
Arguments map[string]string `json:"arguments"`
}
if err := json.Unmarshal(params, &p); err != nil {
return nil, &rpcError{Code: codeInvalidParams, Message: "invalid params: " + err.Error()}
}
if p.Name != "review" {
return nil, &rpcError{Code: codeInvalidParams, Message: "unknown prompt: " + p.Name}
}
path := p.Arguments["path"]
if path == "" {
path = "the current file"
}
text := fmt.Sprintf("Please review %s. Read it, then list any correctness bugs and risky patterns with file:line references, most important first.", path)
return map[string]any{
"description": "Code review request",
"messages": []map[string]any{
{"role": "user", "content": map[string]any{"type": "text", "text": text}},
},
}, nil
}
// --- resources ---
// resourceContents is the demo resource store, keyed by uri.
var resourceContents = map[string]string{
"doc://style-guide": "Project style: tabs for indentation; comments explain why, not what; keep functions short.",
}
func resourceList() []map[string]any {
return []map[string]any{{
"uri": "doc://style-guide",
"name": "Style guide",
"description": "The project's coding style notes.",
"mimeType": "text/plain",
}}
}
func readResource(params json.RawMessage) (any, *rpcError) {
var p struct {
URI string `json:"uri"`
}
if err := json.Unmarshal(params, &p); err != nil {
return nil, &rpcError{Code: codeInvalidParams, Message: "invalid params: " + err.Error()}
}
text, ok := resourceContents[p.URI]
if !ok {
return nil, &rpcError{Code: codeInvalidParams, Message: "unknown resource: " + p.URI}
}
return map[string]any{
"contents": []map[string]any{
{"uri": p.URI, "mimeType": "text/plain", "text": text},
},
}, nil
}
// countLines counts newline-separated lines, counting a final unterminated line.
func countLines(s string) int {
if s == "" {
return 0
}
n := strings.Count(s, "\n")
if !strings.HasSuffix(s, "\n") {
n++
}
return n
}
// trimSpace trims leading/trailing ASCII whitespace without pulling in bytes.
func trimSpace(b []byte) []byte {
return []byte(strings.TrimSpace(string(b)))
}
+19
View File
@@ -0,0 +1,19 @@
// Command reasonix is a config- and plugin-driven coding agent CLI.
package main
import (
"os"
"reasonix/internal/cli"
// Blank imports wire compile-time built-ins into their registries.
_ "reasonix/internal/provider/openai"
_ "reasonix/internal/tool/builtin"
)
// version is injected at build time via -ldflags "-X main.version=...".
var version = "dev"
func main() {
os.Exit(cli.Run(os.Args[1:], version))
}
+455
View File
@@ -0,0 +1,455 @@
# Reasonix Engineering Spec
> Reasonix is a coding agent: a thin harness driving multiple models, with **all
> capabilities supplied by configuration and plugins**. This document is the
> contract — code follows it. Change the contract first, then the code.
## 1. Design Principles
1. **Config- and plugin-driven core.** The core knows only interfaces. Concrete
models and tools are resolved by name from registries, declared in config, or
injected by plugins. No hardcoded `switch model`.
2. **Single static binary.** `CGO_ENABLED=0`; cross-compile with one command;
CLI works out of the box.
3. **Lean dependencies.** Standard library by default. A third-party dependency
must be pure-Go, lightweight, and must not compromise the single-binary /
cross-platform / distribution story. TOML parsing is the one accepted dependency.
4. **Two extension tiers.** Compile-time built-ins (self-register via `init()`),
and runtime external plugins (stdio JSON-RPC subprocesses, MCP-compatible).
5. **Interface-first & registry-based.** `Provider` and `Tool` are interfaces.
6. **Evolve, don't over-engineer.**
Language: **English is the primary language for all code** — comments,
user-facing strings, tool descriptions, system prompts, and this spec. The
README is bilingual (`README.md` English + `README.zh-CN.md`).
## 2. Layout
```
reasonix/
├── go.mod / go.sum # module reasonix; require BurntSushi/toml
├── Makefile # build / cross / vet / fmt / test
├── README.md / README.zh-CN.md
├── reasonix.example.toml # sample config
├── docs/SPEC.md # this file
├── cmd/reasonix/main.go # entry; blank-imports built-in providers/tools
├── cmd/reasonix-plugin-example/ # reference MCP stdio plugin (a runnable example)
└── internal/
├── cli/ # subcommand routing, flags, assembly, exit codes
├── config/ # TOML loading (flag > project > user > defaults)
├── provider/ # Provider interface + types + kind→factory registry
│ └── openai/ # OpenAI-compatible impl; init() registers "openai"
├── tool/ # Tool interface + Registry
│ └── builtin/ # read_file/write_file/edit_file/bash/ls/glob/grep
├── permission/ # per-call Policy: allow/ask/deny rules → Decision
├── command/ # custom slash commands loaded from .reasonix/commands/*.md
├── plugin/ # stdio JSON-RPC (MCP) client; adapts remote tools
└── agent/ # Session + harness loop
```
Dependency direction (acyclic): `cli → {agent, plugin, config} → {tool, provider}`.
Built-in subpackages (`provider/openai`, `tool/builtin`) import their parent to
self-register; parents never import children.
## 3. Core Abstractions
### 3.1 Provider + registry (`internal/provider`)
```go
type Provider interface {
Name() string
Stream(ctx context.Context, req Request) (<-chan Chunk, error)
}
// Factory builds a Provider from a resolved config instance.
type Factory func(cfg Config) (Provider, error)
// Register adds a factory under a kind (e.g. "openai"). Called from init().
func Register(kind string, f Factory)
// New instantiates the provider of the given kind.
func New(kind string, cfg Config) (Provider, error)
type Config struct {
Name string // instance name, e.g. "deepseek"
BaseURL string
Model string
APIKey string
Extra map[string]any // kind-specific options
}
```
- The `openai` kind is an OpenAI-compatible `/chat/completions` implementation.
- **DeepSeek and MiMo are not code — they are config instances** of `kind = "openai"`,
differing only in `base_url` / `model` / `api_key_env`. Adding another OpenAI-
compatible model is a config edit, not a code change.
- Streaming tool-call deltas are accumulated by index inside the provider; only
complete `ToolCall`s are emitted.
### 3.2 Tool + registry (`internal/tool`)
```go
type Tool interface {
Name() string
Description() string
Schema() json.RawMessage // JSON Schema for parameters
Execute(ctx context.Context, args json.RawMessage) (string, error)
}
```
- Built-in tools self-register into a process-global builtin set via `init()`
(`tool.RegisterBuiltin(t)`); `tool.Builtins()` lists them.
- A runtime `*Registry` is assembled per run: enabled built-ins (filtered by
config) **plus** plugin-provided tools. The agent only sees the `*Registry`.
- `Execute` parses raw JSON args itself. Errors are returned, not fatal — the
agent feeds them back so the model can self-correct.
### 3.3 Plugins (`internal/plugin`) — MCP client
An external plugin is an MCP server declared in config. The wire protocol is
**JSON-RPC 2.0** in every case; only the transport differs. A `transport`
interface (`call` / `notify` / `close`) abstracts that, so the MCP-level logic
(handshake, `tools/list`, `tools/call`, …) is written once.
- **Transports** (config `type`):
- `stdio` (default) — a local subprocess; one JSON message per line over the
child's stdin/stdout (the MCP stdio convention). Declared with
`command` / `args` / `env`; terminated on ctx cancel / shutdown.
- `http` (a.k.a. `streamable-http`) — a remote server at `url`. Each request
is an HTTP POST; the server replies with either `application/json` (one
response) or `text/event-stream` (an SSE stream carrying the response plus
any server notifications). The `Mcp-Session-Id` response header, once seen,
is echoed on subsequent requests. Static `headers` (e.g. a bearer token) are
sent on every request. OAuth is out of scope for now (see §9).
- `sse` — the legacy 2024-11-05 HTTP+SSE transport; recognised but deferred
(deprecated upstream — use `http`). Configuring it returns a clear error.
- `${VAR}` / `${VAR:-default}` are expanded in `command`, `args`, `env`, `url`,
and `headers` so secrets come from the environment, not the config file.
- Lifecycle: `initialize` → `notifications/initialized` → `tools/list`;
invocation via `tools/call {name, arguments}`.
- Each remote tool is adapted to the `Tool` interface and injected into the run
registry, namespaced `mcp__<server>__<tool>` (spaces normalised to `_`) to
match Claude Code and avoid clashes.
- A tool's MCP `annotations.readOnlyHint` maps to `Tool.ReadOnly()`. It defaults
to false (a remote tool is opaque — we can't see its side effects), so a
plugin opts a tool into parallel-batch dispatch and the permission layer's
reader-default by declaring `readOnlyHint: true` in `tools/list`.
- `prompts/list` + `prompts/get` surface as `/mcp__<server>__<prompt>` slash
commands; `resources/list` + `resources/read` are referenced as
`@<server>:<uri>` in chat. `/mcp` shows connected servers and their counts.
- `cmd/reasonix-plugin-example` is a runnable reference stdio server (`echo`,
`wordcount`), driven by an end-to-end test that builds the real binary.
### 3.4 Agent (`internal/agent`)
- `Session` holds `[]Message`.
- `Run(ctx, input)` loop: build `Request` (with tool schemas) → `provider.Stream`
→ print text deltas live, collect complete tool calls → if none, done; else
execute each tool (built-in or plugin) and append results → repeat, bounded by
`maxSteps`. `ctx` threads throughout (Ctrl-C aborts in-flight requests).
- A `Runner` is anything with `Run(ctx, input) error`; both `Agent` and
`Coordinator` satisfy it, so the CLI is agnostic to single- vs two-model mode.
### 3.5 Two-model collaboration (`Coordinator`)
When `agent.planner_model` names a provider different from the executor, a
`Coordinator` runs two models in **separate sessions** to keep each one's prompt
prefix cache-stable:
- The **planner** (low-frequency) runs in its own session with no tools and
produces a concise plan.
- The plan is handed off as structured text to the **executor** — a full
tool-using `Agent` in its own session — which carries it out.
- The sessions never mix, so neither model's prefix is disturbed by the other's
turns; both grow prepend-only and stay cache-friendly. This reconciles
"cache-first" with "two-model collaboration": switching models *inside one
shared conversation* would break the prefix and tank cache hits, so we don't.
### 3.6 Context management (compaction)
Long tasks eventually fill the model's context window. Reasonix manages this with
**low-frequency compaction** that respects the cache-first design:
- Each provider declares its `context_window` (tokens). When a turn's reported
`prompt_tokens` reach `compactRatio` (default `0.8`) of that window, the
executor compacts **once** before the next turn.
- Compaction summarizes the older middle of the session into a single briefing —
using the executor's own provider, no tools — and replaces it in place: the
session becomes `system + summary + recentKeep` (default `8`) verbatim
messages. The boundary is aligned backward off any tool result so the recent
tail never begins with an orphan tool message whose `tool_calls` were
summarized away.
- The dropped originals are archived to `~/.config/reasonix/archive/<timestamp>.jsonl`
(one message per line), so the full history stays traceable.
This is the **only** point where the prompt prefix changes — a deliberate, rare
"cache-reset point". Between compactions the session grows prepend-only and
stays cache-friendly, so cache hit rate (the key observability signal) stays
high. `context_window = 0` disables compaction for an instance.
### 3.7 Permissions (`internal/permission`) — per-call gating
A coding agent runs shell commands and edits files autonomously. The permission
layer decides, **per tool call**, whether to allow it, deny it, or ask the user
first. It is independent of the model and of the CLI — the agent consults a
`Gate` interface at execute time; the gate is built from a static `Policy` plus
an optional interactive `Approver`.
```go
type Decision int // permission package
const (Allow Decision = iota; Ask; Deny)
// Policy evaluates static rules against a tool call. Pure, no I/O.
type Policy struct { Mode Decision; Allow, Ask, Deny []Rule }
func (p Policy) Decide(toolName string, readOnly bool, args json.RawMessage) Decision
```
- **Rule syntax.** A rule is `ToolName` (matches any call to that tool) or
`ToolName(glob)` (matches when the call's *subject* matches the glob, via
`path.Match`). The subject is extracted generically from the call's JSON args
by a small set of known keys — `command` (bash), `path` / `file_path`
(file tools), `pattern` (grep/glob) — so tools need not change. A rule whose
subject the args don't expose only matches in its bare `ToolName` form.
- **Precedence.** `deny` > `ask` > `allow` > fallback. Fallback is `Allow` for
read-only tools and `Mode` (default `Ask`) for writers. `deny` always wins, so
a broad `allow = ["bash"]` can still be carved by `deny = ["bash(rm -rf*)"]`;
conversely `ask` overrides a broad `allow` to force a prompt on a risky subset.
- **Resolving `Ask`.** The interactive front-end (the chat TUI) prompts the user
— allow once / always allow / deny — via an `Approver`. A non-interactive run
(`reasonix run`, a sub-agent, anything with no TTY / no approver) cannot prompt, so
it resolves `Ask` to **allow** — preserving autonomous behaviour. A `Deny` is a
hard block in *every* mode: the tool never executes and the model receives a
"blocked" result it can adapt to (the same shape as a plan-mode refusal).
- **Relationship to plan mode.** Plan mode (§3.4) is an orthogonal, coarser gate
that refuses *all* writers regardless of policy; it is checked first. The
permission layer is the fine-grained, always-on gate underneath it.
Out of the box (`mode = "ask"`, no rules) `reasonix run` behaves exactly as before
(writers resolve `Ask`→allow with no TTY), while `reasonix chat` now prompts before
each writer/bash call. `deny` rules harden both modes.
### 3.8 Slash commands (`internal/command`)
The chat TUI accepts `/command` input. Three kinds share one dispatch:
- **Built-in actions** (`/compact`, `/new`, `/mcp`, `/help`) manipulate session
state locally and never reach the model.
- **Custom commands** are Markdown files under `.reasonix/commands/` (project) and
`~/.config/reasonix/commands/` (user); the project dir overrides the user dir on a
name clash. A file `review.md` becomes `/review`; a subdirectory namespaces it
(`git/commit.md` → `/git:commit`). Invoking one renders its body and sends the
result as the next user turn.
- **MCP prompts** (§3.3) appear as `/mcp__<server>__<prompt>`.
```markdown
---
description: Review the staged diff
argument-hint: [focus-area]
---
Review the staged diff. Focus on $ARGUMENTS, list bugs with file:line.
```
- Frontmatter is an optional `---`-fenced block of simple `key: value` lines;
`description` and `argument-hint` are recognised (no YAML dependency — Reasonix
stays lean). The remainder is the body template.
- Substitution in the body: `$ARGUMENTS` (all args, space-joined), `$1`…`$N`
(positional, empty when absent), `$$` (a literal `$`). Arguments are the
space-separated tokens after the command.
- Loading is pure (`command.Load(dirs...)`) and tested; a malformed file is
skipped, not fatal. Custom and MCP-prompt commands both resolve to text and
reuse the same "start a turn" path as a typed message.
### 3.9 Chat references (`@`)
A chat message may embed `@` references; before the turn is sent, each is
resolved and prepended to the message as a tagged block the model can read.
- `@<server>:<uri>` where `<server>` is a connected MCP server → an MCP
resource (`resources/read`), wrapped `<resource ref="…">…</resource>`.
- `@<path>` otherwise → a **local file or directory**, but only when the path
actually exists on disk. This existence gate is the disambiguator: an ordinary
`@mention` or an email address resolves to no file and stays literal text. A
file is wrapped `<file path="…">…</file>` (size-capped, binary files noted not
dumped); a directory becomes a one-level listing.
- Resolution is asynchronous (off the TUI event loop); a fetch failure surfaces
as a notice but doesn't block the turn. Reads are user-initiated and read-only
— they do **not** pass the permission gate (§3.7).
- Typing `/` or `@` opens an autocomplete menu above the input. The `@` menu
navigates **one directory level at a time** (`os.ReadDir`, never a recursive
walk — bounded for huge directories): a directory entry descends, a file
completes, and MCP resources appear alongside top-level entries. The
bottom-region menu changes height only on these discrete actions, never per
streamed token, so scrollback stays clean (§ rendering).
## 4. Data Types (`internal/provider`)
```go
type Role string
const (RoleSystem Role = "system"; RoleUser Role = "user"
RoleAssistant Role = "assistant"; RoleTool Role = "tool")
type Message struct {
Role Role `json:"role"`
Content string `json:"content,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
Name string `json:"name,omitempty"`
}
type ToolCall struct { ID, Name, Arguments string } // Arguments: raw JSON
type ToolSchema struct { Name, Description string; Parameters json.RawMessage }
type Request struct { Messages []Message; Tools []ToolSchema; Temperature float64; MaxTokens int }
type ChunkType int
const (ChunkText ChunkType = iota; ChunkToolCall; ChunkDone; ChunkError)
type Chunk struct {
Type ChunkType
Text string // ChunkText
ToolCall *ToolCall // ChunkToolCall
Err error // ChunkError
}
```
## 5. Configuration (TOML)
Resolution order: **flag > project `./reasonix.toml` > user `~/.config/reasonix/config.toml`
> built-in defaults**. Secrets come from the environment via `api_key_env` and
are never stored in config files. A `.env` in the working directory is loaded if
present.
```toml
default_model = "deepseek-flash" # the executor
# language = "zh" # ui language tag; empty = auto-detect from $LANG / $REASONIX_LANG
[agent]
system_prompt = "You are Reasonix, a coding agent..." # or system_prompt_file = "..."
max_steps = 25
temperature = 0.0
# planner_model = "mimo" # optional: two-model collaboration (low-frequency planner)
[[providers]]
name = "deepseek-flash"
kind = "openai"
base_url = "https://api.deepseek.com"
model = "deepseek-v4-flash"
api_key_env = "DEEPSEEK_API_KEY"
context_window = 1000000 # tokens; harness compacts older history near this limit (0 disables)
[[providers]]
name = "deepseek-pro"
kind = "openai"
base_url = "https://api.deepseek.com"
model = "deepseek-v4-pro"
api_key_env = "DEEPSEEK_API_KEY"
[[providers]]
name = "mimo-pro"
kind = "openai"
base_url = "https://api.xiaomimimo.com/v1"
model = "mimo-v2.5-pro"
api_key_env = "MIMO_API_KEY"
[[providers]]
name = "mimo-flash"
kind = "openai"
base_url = "https://api.xiaomimimo.com/v1"
model = "mimo-v2-flash"
api_key_env = "MIMO_API_KEY"
[tools]
enabled = [] # omit/empty = all built-ins
[permissions]
mode = "ask" # writer fallback when no rule matches: ask|allow|deny
deny = ["bash(rm -rf*)", "bash(git push*)"] # hard-blocked in every mode
allow = ["bash(go test*)", "bash(git status*)"] # never prompted
ask = [] # force a prompt even if otherwise allowed
[sandbox]
# workspace_root = "" # file-writers confined here; empty = cwd (writes stay in-project)
# allow_write = ["/tmp"] # extra dirs write_file/edit_file/multi_edit may modify
[[plugins]]
name = "example" # type defaults to "stdio"
command = "reasonix-plugin-example"
args = []
# env = { FOO = "bar" }
# [[plugins]] # a remote MCP server over Streamable HTTP
# name = "stripe"
# type = "http" # "stdio" (default) | "http" | "sse"
# url = "https://mcp.stripe.com"
# headers = { Authorization = "Bearer ${STRIPE_KEY}" } # ${VAR} / ${VAR:-default} expanded
```
`reasonix init` writes this default config so the CLI is usable out of the box.
MCP servers may also be declared in a project-root `.mcp.json` using Claude
Code's exact `mcpServers` schema (`command`/`args`/`env`, `type`/`url`/`headers`,
`${VAR}` expansion). It is read after the TOML files and merged into
`[[plugins]]`; on a name collision `reasonix.toml` wins (it is the more explicit,
Reasonix-specific source). This lets a server already configured for Claude work in
Reasonix unchanged.
```json
{ "mcpServers": {
"stripe": { "type": "http", "url": "https://mcp.stripe.com",
"headers": { "Authorization": "Bearer ${STRIPE_KEY}" } }
} }
```
`[sandbox]` is the *enforcement* layer beneath permissions (which are *policy*).
Phase 0 confines the file-writing built-ins (`write_file`, `edit_file`,
`multi_edit`) to `workspace_root` (default cwd) plus `allow_write`: a write whose
target — resolved to an absolute, symlink-free path so a symlinked dir or `..`
cannot tunnel out — falls outside every root is refused, and the error is fed
back to the model. Confinement is on by default (root = cwd), so edits stay in
the project; reads are unrestricted. `bash` is itself jailed on macOS by default
(`[sandbox] bash = "enforce"`, Seatbelt): each command runs under sandbox-exec
allowed to write only the same roots (+ temp and toolchain caches) and to reach
the network only when `network = true`. Unsupported platforms fall back to
running unconfined. The escape-prompt and Linux support are Phase 1's remainder (§9).
## 6. Error Handling
- Library code wraps with `fmt.Errorf("...: %w", err)` and returns; it never
prints or calls `os.Exit`.
- Only `cli` / `main` decide exit codes and user-facing messages.
- Tool execution errors are fed back to the model, not fatal.
- Network layer should apply bounded exponential backoff on 429 / 5xx
(interface reserved; implementation may follow).
## 7. Code Style
- `gofmt` + `go vet` must be clean; package names lowercase; exported
identifiers documented; comments explain *why*, not *what*.
- No premature generalization. Prefer clear and direct.
## 8. Distribution
- Build: `CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=$(VERSION)" -o reasonix ./cmd/reasonix`
- Cross matrix: `darwin|linux|windows` × `amd64|arm64`.
- Version injected via ldflags (`git describe --tags --always`).
- Install: prebuilt binary / `go install` / future `brew tap`.
## 9. Roadmap (not in current scope)
- Sandbox Phase 1: an OS-level jail for `bash` so commands — not just the
file-writer built-ins (Phase 0) — are confined to the workspace. **macOS
(Seatbelt via `sandbox-exec`) ships, on by default** (see §5). Remaining: (a)
the escape-prompt — detect a sandbox-denied failure and offer to re-run the
command unconfined via the permission gate (in `reasonix run`, the command just
fails and the model adapts), which completes the "allow inside the box, prompt
at its edge" model; (b) Linux (bubblewrap / landlock). Shells out to OS tooling
so the binary stays dependency-free; Windows is out of scope. With this in
place, "always allow" rule persistence becomes optional rather than load-bearing.
- MCP long tail (deferred deliberately — no consumer / no foundation yet): OAuth
2.0 + `headersHelper` auth for remote servers; the remaining `.mcp.json` scopes
(local / user — project scope shipped, see §5); tool-search deferral;
`list_changed` live updates; channels / elicitation / roots; plugins that
provide *providers*, not just tools.
- An Anthropic-native provider `kind` (native prompt-cache control), proving the
registry generalises beyond one wire format.
- "Always allow" persistence writing learned rules back to project config; a
per-session permission override flag for `reasonix run`.
+31
View File
@@ -0,0 +1,31 @@
module reasonix
go 1.25.0
require (
charm.land/bubbles/v2 v2.1.0
charm.land/bubbletea/v2 v2.0.6
charm.land/lipgloss/v2 v2.0.3
github.com/BurntSushi/toml v1.6.0
github.com/mattn/go-runewidth v0.0.23
github.com/rivo/uniseg v0.4.7
github.com/yuin/goldmark v1.8.2
golang.org/x/term v0.43.0
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect
github.com/charmbracelet/x/ansi v0.11.7 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.44.0 // indirect
)
+52
View File
@@ -0,0 +1,52 @@
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo=
charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g=
charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0y1Zo5KB/5Vu8JZoLGm1N3RzF9bNj3Ao3xoR+Ac=
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
+469
View File
@@ -0,0 +1,469 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"io"
"strings"
"sync"
"unicode/utf8"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
// maxToolOutputBytes caps a single tool result before it goes into the model's
// context. ~32KB is roughly 8K tokens — enough for a full file read or a busy
// grep, while preventing one accidental "read this 5 MB log" from blowing the
// window before the next compaction runs.
const maxToolOutputBytes = 32 * 1024
// Renderer redraws the assistant's final-answer text as styled output. It is
// applied only after a turn's text stream completes, so the user sees raw
// markdown stream live, then a single redraw replaces it with formatted
// output. The renderer is intentionally interface-shaped so the agent stays
// independent of the cli's markdown library choice.
type Renderer interface {
Render(text string) string
}
// Gate decides, per tool call, whether it may run. The agent consults it at
// execute time (after the plan-mode gate). It is interface-shaped so the agent
// stays independent of the permission package and of how "ask" is resolved
// (silently in headless runs, interactively in the chat TUI). A nil gate means
// no gating — every call runs, preserving behaviour for callers that don't wire
// one in. reason is fed back to the model when allow is false; a non-nil err
// (e.g. ctx cancelled awaiting approval) is treated as a block for that call.
type Gate interface {
Check(ctx context.Context, toolName string, args json.RawMessage, readOnly bool) (allow bool, reason string, err error)
}
// Agent drives a single task: a Provider, a tool Registry, and a Session wired
// into the main loop.
type Agent struct {
prov provider.Provider
tools *tool.Registry
session *Session
maxSteps int
temperature float64
pricing *provider.Pricing
out io.Writer
// Optional post-stream redraw of assistant text as styled markdown. nil
// keeps the raw stream as-is (useful for non-tty / piped output and tests).
renderer Renderer
termWidth int
// lastUsage caches the most recent per-turn telemetry the provider
// reported so the CLI can expose a context gauge without re-scraping the
// usage line out of the output writer.
lastUsage *provider.Usage
// planMode, when true, refuses any tool call whose ReadOnly() is false.
// The system prompt and tool list never change with the toggle so the
// prompt-cache prefix stays valid; the gating happens at execute time
// and the model sees a "blocked" result it can adapt to. Toggled from
// the outside via SetPlanMode.
planMode bool
// gate, when non-nil, is the per-call permission gate consulted after the
// plan-mode check. nil disables gating entirely.
gate Gate
// Context management: when a turn's prompt nears contextWindow, the older
// middle of the session is summarized away, keeping recentKeep messages
// verbatim and archiving the originals under archiveDir.
contextWindow int
compactRatio float64
recentKeep int
archiveDir string
}
// SetPlanMode flips the read-only gate. While true, executeOne refuses any
// non-ReadOnly tool the model calls and returns a "blocked" result instead of
// running it. The cache-friendly bits — system prompt, tools schema, message
// history — are left untouched, so the toggle costs nothing in cache hits.
func (a *Agent) SetPlanMode(v bool) { a.planMode = v }
// SetGate installs the per-call permission gate. Used by `reasonix chat` to swap the
// headless gate built in setup for an interactive one that prompts the user;
// nil disables gating. Safe to call before the run loop starts.
func (a *Agent) SetGate(g Gate) { a.gate = g }
// Session returns the agent's current conversation, useful for persistence
// hooks that need to read the message log between turns.
func (a *Agent) Session() *Session { return a.session }
// SetSession replaces the agent's conversation wholesale. Used by
// `reasonix chat --resume` to load a saved JSONL transcript before the first turn,
// so the model picks up exactly where it left off.
func (a *Agent) SetSession(s *Session) { a.session = s }
// LastUsage returns the most recent per-turn token telemetry the provider
// reported (nil if no turn has run yet). The TUI uses it to show a context
// gauge alongside the prompt; the actual cache decisions still live inside
// maybeCompact.
func (a *Agent) LastUsage() *provider.Usage { return a.lastUsage }
// ContextWindow returns the configured context-window size in tokens. 0
// means compaction is disabled for this agent.
func (a *Agent) ContextWindow() int { return a.contextWindow }
// CompactNow runs one compaction pass immediately, regardless of the
// usage-ratio threshold maybeCompact normally honours. Used by the chat
// TUI's `/compact` command so the user can reset the prefix before it
// naturally fills up.
func (a *Agent) CompactNow(ctx context.Context) error { return a.compact(ctx) }
// Options configures an Agent.
type Options struct {
MaxSteps int
Temperature float64
Pricing *provider.Pricing // optional, for per-turn cost display
// Renderer, when set, replaces the streamed raw text with styled markdown
// after each assistant turn. TermWidth is the column count used both for
// the renderer's wrapping and for counting how many rows the raw stream
// occupied (so the cursor lands on the right line before redrawing).
Renderer Renderer
TermWidth int
// Gate is the per-call permission gate. nil disables gating.
Gate Gate
// Context management. ContextWindow <= 0 disables compaction. CompactRatio
// and RecentKeep fall back to defaults when unset.
ContextWindow int
CompactRatio float64
RecentKeep int
ArchiveDir string
}
// New constructs an Agent. MaxSteps <= 0 defaults to 25.
func New(prov provider.Provider, tools *tool.Registry, session *Session, opts Options, out io.Writer) *Agent {
if opts.MaxSteps <= 0 {
opts.MaxSteps = 25
}
if opts.CompactRatio <= 0 {
opts.CompactRatio = defaultCompactRatio
}
if opts.RecentKeep <= 0 {
opts.RecentKeep = defaultRecentKeep
}
return &Agent{
prov: prov,
tools: tools,
session: session,
maxSteps: opts.MaxSteps,
temperature: opts.Temperature,
pricing: opts.Pricing,
out: out,
renderer: opts.Renderer,
termWidth: opts.TermWidth,
gate: opts.Gate,
contextWindow: opts.ContextWindow,
compactRatio: opts.CompactRatio,
recentKeep: opts.RecentKeep,
archiveDir: opts.ArchiveDir,
}
}
// Run appends the user input and runs the loop until the model stops requesting
// tools or maxSteps is reached.
func (a *Agent) Run(ctx context.Context, input string) error {
a.session.Add(provider.Message{Role: provider.RoleUser, Content: input})
for step := 0; step < a.maxSteps; step++ {
text, reasoning, calls, usage, err := a.stream(ctx)
if err != nil {
return err
}
printUsage(a.out, usage, a.pricing)
printFinishReasonWarning(a.out, usage)
// Round-trip reasoning_content on the assistant turn so multi-turn
// thinking chains stay coherent (MiMo / DeepSeek-reasoner ask for this).
a.session.Add(provider.Message{
Role: provider.RoleAssistant,
Content: text,
ReasoningContent: reasoning,
ToolCalls: calls,
})
if len(calls) == 0 {
return nil // model gave a final answer
}
results := a.executeBatch(ctx, calls)
for i, call := range calls {
a.session.Add(provider.Message{
Role: provider.RoleTool,
Content: results[i],
ToolCallID: call.ID,
Name: call.Name,
})
}
// The prompt only grows from here; compact before the next turn so it
// stays within the model's window.
a.maybeCompact(ctx, usage)
}
return fmt.Errorf("reached max steps (%d) without completing", a.maxSteps)
}
// stream runs one completion, printing text deltas live and collecting complete
// tool calls. Reasoning deltas (thinking-mode chain-of-thought) are shown in
// muted prose under a "thinking" header so the user can follow the model's
// reasoning without confusing it with the final answer, and accumulated so the
// caller can round-trip it on the next turn.
func (a *Agent) stream(ctx context.Context) (string, string, []provider.ToolCall, *provider.Usage, error) {
ch, err := a.prov.Stream(ctx, provider.Request{
Messages: a.session.Messages,
Tools: a.tools.Schemas(),
Temperature: a.temperature,
})
if err != nil {
return "", "", nil, nil, err
}
var text, reasoning strings.Builder
var calls []provider.ToolCall
var usage *provider.Usage
wroteReasoningHeader := false
for chunk := range ch {
switch chunk.Type {
case provider.ChunkReasoning:
if !wroteReasoningHeader {
fmt.Fprintln(a.out, dimText(" ▎ thinking"))
wroteReasoningHeader = true
}
reasoning.WriteString(chunk.Text)
fmt.Fprint(a.out, dimText(chunk.Text))
case provider.ChunkText:
if wroteReasoningHeader && text.Len() == 0 {
fmt.Fprintln(a.out) // separate the reasoning block from the answer
}
text.WriteString(chunk.Text)
fmt.Fprint(a.out, chunk.Text)
case provider.ChunkToolCall:
calls = append(calls, *chunk.ToolCall)
case provider.ChunkUsage:
usage = chunk.Usage
a.lastUsage = chunk.Usage
case provider.ChunkError:
return "", "", nil, nil, chunk.Err
}
}
// If a renderer is wired in, replace the raw streamed text with styled
// markdown: move the cursor back to the row where text streaming began,
// clear from there to the end of the screen, and re-emit the rendered
// version. Reasoning above the text stays untouched. Very long messages
// keep the raw stream — the move-up would otherwise sail past the screen
// top and leave artifacts.
if text.Len() > 0 && a.renderer != nil {
if moved := streamedRows(text.String(), a.termWidth); moved < 200 {
if moved == 0 {
fmt.Fprint(a.out, "\r\033[0J")
} else {
fmt.Fprintf(a.out, "\r\033[%dA\033[0J", moved)
}
fmt.Fprint(a.out, a.renderer.Render(text.String()))
return text.String(), reasoning.String(), calls, usage, nil
}
}
if text.Len() > 0 || reasoning.Len() > 0 {
fmt.Fprintln(a.out)
}
return text.String(), reasoning.String(), calls, usage, nil
}
// dimText wraps s in the ANSI dim SGR sequence so reasoning streams visually
// recede from the final answer. Lives here to avoid importing the cli style
// helpers — the agent must stay independent of CLI rendering choices, so this
// uses raw codes the writer can strip if needed.
func dimText(s string) string { return "\x1b[2m" + s + "\x1b[0m" }
// executeBatch dispatches one model turn's tool calls. The schedule lines
// "-> tool args" are always printed in call order up front so the timeline
// reads chronologically. Calls fan out across goroutines only when every
// call's tool is ReadOnly (canParallelise); a single non-ReadOnly call drops
// the whole batch back to sequential to preserve write/read ordering and
// keep effects deterministic. Truncation notices, if any, print serially in
// call order after the dispatch completes.
func (a *Agent) executeBatch(ctx context.Context, calls []provider.ToolCall) []string {
for _, c := range calls {
fmt.Fprintf(a.out, " -> %s %s\n", c.Name, compactArgs(c.Arguments))
}
results := make([]string, len(calls))
notices := make([]string, len(calls))
run := func(i int) {
results[i], notices[i] = a.executeOne(ctx, calls[i])
}
if canParallelise(a.tools, calls) && len(calls) > 1 {
const maxParallel = 8
sem := make(chan struct{}, maxParallel)
var wg sync.WaitGroup
for i := range calls {
i := i
sem <- struct{}{}
wg.Add(1)
go func() {
defer wg.Done()
defer func() { <-sem }()
run(i)
}()
}
wg.Wait()
} else {
for i := range calls {
run(i)
}
}
for _, n := range notices {
if n != "" {
fmt.Fprintln(a.out, n)
}
}
return results
}
// executeOne runs a single tool call and returns (resultText, noticeText).
// It is pure with respect to a.out — the caller is responsible for the live
// schedule line, so this is safe to invoke from parallel goroutines.
func (a *Agent) executeOne(ctx context.Context, call provider.ToolCall) (string, string) {
t, ok := a.tools.Get(call.Name)
if !ok {
return fmt.Sprintf("error: unknown tool %q", call.Name), ""
}
if a.planMode && !t.ReadOnly() {
return fmt.Sprintf("blocked: %q is a writer tool and plan mode is read-only — propose the change in your final answer instead. The user will toggle plan mode off (Tab) to execute.", call.Name), ""
}
if a.gate != nil {
allow, reason, err := a.gate.Check(ctx, call.Name, json.RawMessage(call.Arguments), t.ReadOnly())
if err != nil {
return fmt.Sprintf("blocked: %s (%v)", reason, err), fmt.Sprintf(" ⊘ %s blocked: %v", call.Name, err)
}
if !allow {
return "blocked: " + reason, fmt.Sprintf(" ⊘ %s blocked by permission policy", call.Name)
}
}
result, err := t.Execute(ctx, json.RawMessage(call.Arguments))
if err != nil {
return truncateToolOutput(fmt.Sprintf("error: %v\n%s", err, result))
}
return truncateToolOutput(result)
}
// canParallelise returns true iff every call targets a known, ReadOnly tool.
// Any unknown tool name (let the sequential path produce a clean error) or any
// non-ReadOnly tool (preserve write ordering) forces serial execution.
func canParallelise(r *tool.Registry, calls []provider.ToolCall) bool {
for _, c := range calls {
t, ok := r.Get(c.Name)
if !ok || !t.ReadOnly() {
return false
}
}
return true
}
// truncateToolOutput head+tails s when it exceeds maxToolOutputBytes, slicing
// on rune boundaries so we never split a multibyte glyph. Returns the possibly
// trimmed body plus a one-line user-facing notice when truncation happened
// (empty when it didn't), so callers can render notices in deterministic
// order even when called from parallel goroutines.
func truncateToolOutput(s string) (string, string) {
if len(s) <= maxToolOutputBytes {
return s, ""
}
keep := maxToolOutputBytes / 2
head := snapToRuneBoundary(s, 0, keep)
tail := snapToRuneBoundary(s, len(s)-keep, len(s))
omitted := len(s) - len(head) - len(tail)
notice := fmt.Sprintf(" · tool output truncated: %d of %d bytes elided", omitted, len(s))
body := head + fmt.Sprintf("\n\n…[truncated %d of %d bytes — rerun with narrower args to see the middle]…\n\n", omitted, len(s)) + tail
return body, notice
}
// snapToRuneBoundary returns s[lo:hi] with the bounds nudged outward until
// both land on rune-start positions.
func snapToRuneBoundary(s string, lo, hi int) string {
for lo > 0 && !utf8.RuneStart(s[lo]) {
lo--
}
for hi < len(s) && !utf8.RuneStart(s[hi]) {
hi++
}
return s[lo:hi]
}
func compactArgs(s string) string {
s = strings.TrimSpace(s)
r := []rune(s)
if len(r) > 120 {
return string(r[:120]) + "..."
}
return s
}
// printFinishReasonWarning emits a one-line notice when the model stopped for
// a non-normal reason — length truncation, content filter, or repetition
// truncation (MiMo-specific). "stop" / "tool_calls" are the normal terminations
// and produce no output.
func printFinishReasonWarning(w io.Writer, u *provider.Usage) {
if u == nil {
return
}
var msg string
switch u.FinishReason {
case "length":
msg = "response truncated: hit max output tokens"
case "content_filter":
msg = "response blocked by content filter"
case "repetition_truncation":
msg = "response truncated: model repetition detected"
default:
return
}
fmt.Fprintln(w, " ! "+msg)
}
// printUsage prints a one-line token/cache summary — the key signal for the
// cache-first design. Cache is reported as absolute "(N cached / M new)" so a
// turn that adds a lot of fresh content (e.g. a long tool result) doesn't read
// as "cache broke" the way a falling percentage would; the cached prefix is
// still hitting, the denominator just grew. Thinking-capable models also
// report reasoning_tokens, a subset of the completion count we display so
// users can see the cost of the chain-of-thought. No-op when usage is unset.
func printUsage(w io.Writer, u *provider.Usage, p *provider.Pricing) {
if u == nil || u.TotalTokens == 0 {
return
}
cacheCol := ""
if u.PromptTokens > 0 {
cached := u.CacheHitTokens
fresh := u.CacheMissTokens
if fresh == 0 {
// Derive when the provider only reported the hit half; the miss
// equals the remaining prompt unless something underflows.
if d := u.PromptTokens - cached; d > 0 {
fresh = d
}
}
cacheCol = fmt.Sprintf(" (%d cached / %d new)", cached, fresh)
}
reasoning := ""
if u.ReasoningTokens > 0 {
reasoning = fmt.Sprintf(" (%d reasoning)", u.ReasoningTokens)
}
cost := ""
if p != nil {
cost = fmt.Sprintf(" · %s%.4f", p.Symbol(), p.Cost(u))
}
fmt.Fprintf(w, " · %d tok · in %d%s · out %d%s%s\n",
u.TotalTokens, u.PromptTokens, cacheCol, u.CompletionTokens, reasoning, cost)
}
+190
View File
@@ -0,0 +1,190 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"reasonix/internal/provider"
)
// Compaction defaults. Compaction is a low-frequency cache-reset point: prompts
// grow prepend-only (high cache hits) until a turn's prompt nears the model's
// context window, then we compact once — summarizing the older history and
// archiving the originals — so a long task can keep going.
const (
defaultCompactRatio = 0.8 // compact when prompt_tokens reach this fraction of the window
defaultRecentKeep = 8 // recent messages kept verbatim, never summarized
minCompactMessages = 2 // skip compaction below this many compactable messages
)
// summarySystemPrompt steers the executor to distill older history into a
// briefing it can keep relying on after the originals are dropped.
const summarySystemPrompt = `You are compacting the earlier part of a coding agent's conversation to save context.
Summarize the messages below into a compact briefing the agent can rely on to continue the task. Preserve:
- the user's goal and any explicit requirements or constraints
- key decisions made and their rationale
- files read or modified and the important facts learned about them
- commands run and their relevant outcomes
- what is still pending or in progress
Omit small talk and redundant detail. Use terse bullet points. Do not invent information.`
// maybeCompact compacts the session when the last turn's prompt has grown to the
// configured fraction of the context window. It is a no-op when compaction is
// disabled (no window) or usage is unavailable.
func (a *Agent) maybeCompact(ctx context.Context, u *provider.Usage) {
if a.contextWindow <= 0 || u == nil || u.PromptTokens == 0 {
return
}
if u.PromptTokens < int(float64(a.contextWindow)*a.compactRatio) {
return
}
if err := a.compact(ctx); err != nil {
fmt.Fprintf(a.out, " · compaction skipped: %v\n", err)
}
}
// compact summarizes the older middle of the session and replaces it in place:
// the session becomes system + summary + recent tail. The dropped originals are
// archived first, so the full history stays traceable.
func (a *Agent) compact(ctx context.Context) error {
msgs := a.session.Messages
head, start, ok := compactBounds(msgs, a.recentKeep, minCompactMessages)
if !ok {
return nil // recent tail already covers everything worth keeping
}
region := msgs[head:start]
archived := ""
if a.archiveDir != "" {
path, err := archiveMessages(a.archiveDir, region)
if err != nil {
return fmt.Errorf("archive: %w", err)
}
archived = path
}
summary, err := a.summarize(ctx, region)
if err != nil {
return err
}
compacted := make([]provider.Message, 0, head+1+len(msgs)-start)
compacted = append(compacted, msgs[:head]...)
compacted = append(compacted, provider.Message{
Role: provider.RoleUser,
Content: "Summary of earlier conversation (older messages were compacted to save context):\n" + summary,
})
compacted = append(compacted, msgs[start:]...)
a.session.Messages = compacted
note := fmt.Sprintf(" · compacted %d messages → summary", len(region))
if archived != "" {
note += " (archived " + archived + ")"
}
fmt.Fprintln(a.out, note)
return nil
}
// compactBounds locates the region to summarize. head is the count of leading
// messages preserved verbatim (the system prompt, if any); start is where the
// preserved recent tail begins, so msgs[head:start] is compacted. The boundary
// is aligned backward off any tool result so the recent tail never begins with
// an orphan tool message whose assistant tool_calls were summarized away. ok is
// false when there is too little to compact.
func compactBounds(msgs []provider.Message, recentKeep, minCompact int) (head, start int, ok bool) {
if len(msgs) > 0 && msgs[0].Role == provider.RoleSystem {
head = 1
}
start = len(msgs) - recentKeep
if start <= head {
return head, start, false
}
for start > head && msgs[start].Role == provider.RoleTool {
start--
}
if start-head < minCompact {
return head, start, false
}
return head, start, true
}
// summarize asks the executor's own provider (no tools) to distill the region
// into a briefing, returning the collected text.
func (a *Agent) summarize(ctx context.Context, region []provider.Message) (string, error) {
ch, err := a.prov.Stream(ctx, provider.Request{
Messages: []provider.Message{
{Role: provider.RoleSystem, Content: summarySystemPrompt},
{Role: provider.RoleUser, Content: renderTranscript(region)},
},
Temperature: a.temperature,
})
if err != nil {
return "", err
}
var b strings.Builder
for chunk := range ch {
switch chunk.Type {
case provider.ChunkText:
b.WriteString(chunk.Text)
case provider.ChunkError:
return "", chunk.Err
}
}
s := strings.TrimSpace(b.String())
if s == "" {
return "", fmt.Errorf("summarizer returned empty output")
}
return s, nil
}
// renderTranscript flattens messages into a readable transcript for summarization.
func renderTranscript(msgs []provider.Message) string {
var b strings.Builder
for _, m := range msgs {
switch m.Role {
case provider.RoleUser:
fmt.Fprintf(&b, "[user]\n%s\n\n", m.Content)
case provider.RoleAssistant:
if m.Content != "" {
fmt.Fprintf(&b, "[assistant]\n%s\n", m.Content)
}
for _, tc := range m.ToolCalls {
fmt.Fprintf(&b, "[assistant calls %s] %s\n", tc.Name, tc.Arguments)
}
b.WriteString("\n")
case provider.RoleTool:
fmt.Fprintf(&b, "[tool %s result]\n%s\n\n", m.Name, m.Content)
case provider.RoleSystem:
fmt.Fprintf(&b, "[system]\n%s\n\n", m.Content)
}
}
return b.String()
}
// archiveMessages writes the dropped originals to a timestamped .jsonl (one
// message per line) under dir, returning the file path.
func archiveMessages(dir string, msgs []provider.Message) (string, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
path := filepath.Join(dir, time.Now().Format("20060102-150405.000")+".jsonl")
f, err := os.Create(path)
if err != nil {
return "", err
}
defer f.Close()
enc := json.NewEncoder(f)
for _, m := range msgs {
if err := enc.Encode(m); err != nil {
return "", err
}
}
return path, nil
}
+162
View File
@@ -0,0 +1,162 @@
package agent
import (
"context"
"io"
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
// fakeProvider returns a fixed reply and records the messages it was asked to
// complete, so tests can drive summarization without a network call.
type fakeProvider struct {
reply string
got []provider.Message
}
func (f *fakeProvider) Name() string { return "fake" }
func (f *fakeProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
f.got = req.Messages
ch := make(chan provider.Chunk, 2)
ch <- provider.Chunk{Type: provider.ChunkText, Text: f.reply}
ch <- provider.Chunk{Type: provider.ChunkDone}
close(ch)
return ch, nil
}
func TestCompactBounds(t *testing.T) {
sys := provider.Message{Role: provider.RoleSystem}
u := provider.Message{Role: provider.RoleUser}
as := provider.Message{Role: provider.RoleAssistant}
ac := provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: "f"}}}
to := provider.Message{Role: provider.RoleTool, ToolCallID: "1", Name: "f"}
cases := []struct {
name string
msgs []provider.Message
keep int
wantHead, wantStr int
wantOK bool
}{
{"no-system", []provider.Message{u, as, u, as, u, as}, 2, 0, 4, true},
{"with-system", []provider.Message{sys, u, as, u, as, u, as}, 3, 1, 4, true},
// Recent tail of 1 lands on an orphan tool result; the boundary must move
// back onto its assistant so the tail starts with the tool_calls.
{"align-off-tool", []provider.Message{sys, u, ac, to, as, u, ac, to}, 1, 1, 6, true},
{"too-short", []provider.Message{sys, u, as}, 8, 1, 0, false},
{"below-min-compact", []provider.Message{sys, u, as, u}, 2, 1, 0, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
head, start, ok := compactBounds(tc.msgs, tc.keep, minCompactMessages)
if ok != tc.wantOK {
t.Fatalf("ok = %v, want %v", ok, tc.wantOK)
}
if head != tc.wantHead {
t.Errorf("head = %d, want %d", head, tc.wantHead)
}
if ok && start != tc.wantStr {
t.Errorf("start = %d, want %d", start, tc.wantStr)
}
// The aligned tail must never begin with an orphan tool result.
if ok && tc.msgs[start].Role == provider.RoleTool {
t.Errorf("recent tail begins with orphan tool message at %d", start)
}
})
}
}
func TestCompactReplacesHistory(t *testing.T) {
prov := &fakeProvider{reply: "- goal: do X\n- changed file Y"}
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "task"},
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: "read_file", Arguments: "{}"}}},
{Role: provider.RoleTool, ToolCallID: "1", Name: "read_file", Content: "file contents"},
{Role: provider.RoleAssistant, Content: "did a step"},
{Role: provider.RoleUser, Content: "next"},
{Role: provider.RoleAssistant, Content: "ok"},
}}
dir := t.TempDir()
a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2, ArchiveDir: dir}, io.Discard)
if err := a.compact(context.Background()); err != nil {
t.Fatalf("compact: %v", err)
}
// system + summary + last 2 verbatim.
if got := len(sess.Messages); got != 4 {
t.Fatalf("len = %d, want 4: %+v", got, sess.Messages)
}
if sess.Messages[0].Role != provider.RoleSystem {
t.Errorf("message 0 = %s, want system", sess.Messages[0].Role)
}
summary := sess.Messages[1]
if summary.Role != provider.RoleUser || !strings.Contains(summary.Content, "Summary of earlier") || !strings.Contains(summary.Content, "do X") {
t.Errorf("summary message = %+v", summary)
}
if sess.Messages[2].Content != "next" || sess.Messages[3].Content != "ok" {
t.Errorf("recent tail not preserved: %+v", sess.Messages[2:])
}
// The 4 dropped originals were archived, one JSON object per line.
entries, err := os.ReadDir(dir)
if err != nil || len(entries) != 1 {
t.Fatalf("archive dir: entries=%d err=%v", len(entries), err)
}
data, err := os.ReadFile(filepath.Join(dir, entries[0].Name()))
if err != nil {
t.Fatalf("read archive: %v", err)
}
if lines := strings.Count(strings.TrimSpace(string(data)), "\n") + 1; lines != 4 {
t.Errorf("archived %d lines, want 4:\n%s", lines, data)
}
if !strings.HasSuffix(entries[0].Name(), ".jsonl") {
t.Errorf("archive name = %q, want .jsonl", entries[0].Name())
}
}
func TestMaybeCompactThreshold(t *testing.T) {
newSess := func() *Session {
return &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "a"},
{Role: provider.RoleAssistant, Content: "b"},
{Role: provider.RoleUser, Content: "c"},
{Role: provider.RoleAssistant, Content: "d"},
{Role: provider.RoleUser, Content: "e"},
{Role: provider.RoleAssistant, Content: "f"},
}}
}
// Below 80% of the window: untouched.
sess := newSess()
a := New(&fakeProvider{reply: "s"}, tool.NewRegistry(), sess, Options{ContextWindow: 100, RecentKeep: 2, ArchiveDir: t.TempDir()}, io.Discard)
a.maybeCompact(context.Background(), &provider.Usage{PromptTokens: 50})
if len(sess.Messages) != 7 {
t.Errorf("below threshold should not compact, len = %d", len(sess.Messages))
}
// At/above 80%: compacts.
sess = newSess()
a = New(&fakeProvider{reply: "s"}, tool.NewRegistry(), sess, Options{ContextWindow: 100, RecentKeep: 2, ArchiveDir: t.TempDir()}, io.Discard)
a.maybeCompact(context.Background(), &provider.Usage{PromptTokens: 90})
if len(sess.Messages) >= 7 {
t.Errorf("above threshold should compact, len = %d", len(sess.Messages))
}
// No context window: compaction disabled.
sess = newSess()
a = New(&fakeProvider{reply: "s"}, tool.NewRegistry(), sess, Options{RecentKeep: 2, ArchiveDir: t.TempDir()}, io.Discard)
a.maybeCompact(context.Background(), &provider.Usage{PromptTokens: 1 << 30})
if len(sess.Messages) != 7 {
t.Errorf("no window should disable compaction, len = %d", len(sess.Messages))
}
}
+98
View File
@@ -0,0 +1,98 @@
package agent
import (
"context"
"fmt"
"io"
"strings"
"reasonix/internal/provider"
)
// Runner carries out one task turn. Both Agent (single model) and Coordinator
// (two-model) satisfy it, so the CLI stays agnostic to which is in use.
type Runner interface {
Run(ctx context.Context, input string) error
}
// DefaultPlannerPrompt steers the planner toward concise plans, not execution.
const DefaultPlannerPrompt = `You are the planner in a two-model coding agent.
Given a task, produce a concise, ordered plan for the executor model to carry out.
Do not write full implementations or call tools — outline the steps, which files
to touch, and the key decisions. Keep it short and actionable.`
// Coordinator runs two models in separate sessions to keep each one's prompt
// prefix cache-stable: a low-frequency planner proposes an approach, then the
// executor (a full tool-using Agent) carries it out. The sessions never mix, so
// neither model's prefix is disturbed by the other's turns.
type Coordinator struct {
planner provider.Provider
plannerSess *Session
plannerPricing *provider.Pricing
executor *Agent
temperature float64
out io.Writer
}
// NewCoordinator wires a planner provider (with its own session) to an executor.
func NewCoordinator(planner provider.Provider, plannerSession *Session, plannerPricing *provider.Pricing, executor *Agent, temperature float64, out io.Writer) *Coordinator {
return &Coordinator{
planner: planner,
plannerSess: plannerSession,
plannerPricing: plannerPricing,
executor: executor,
temperature: temperature,
out: out,
}
}
// Run plans with the planner model, then hands the plan to the executor.
func (c *Coordinator) Run(ctx context.Context, input string) error {
fmt.Fprintf(c.out, "[%s · planning]\n", c.planner.Name())
plan, err := c.plan(ctx, input)
if err != nil {
return fmt.Errorf("planner: %w", err)
}
fmt.Fprintf(c.out, "\n[%s · executing]\n", c.executor.prov.Name())
return c.executor.Run(ctx, formatHandoff(input, plan))
}
// plan streams a plan from the planner (no tools) and appends it to the planner
// session, so that session grows prepend-only and stays cache-friendly.
func (c *Coordinator) plan(ctx context.Context, input string) (string, error) {
c.plannerSess.Add(provider.Message{Role: provider.RoleUser, Content: input})
ch, err := c.planner.Stream(ctx, provider.Request{
Messages: c.plannerSess.Messages,
Temperature: c.temperature,
})
if err != nil {
return "", err
}
var text strings.Builder
var usage *provider.Usage
for chunk := range ch {
switch chunk.Type {
case provider.ChunkText:
text.WriteString(chunk.Text)
fmt.Fprint(c.out, chunk.Text)
case provider.ChunkUsage:
usage = chunk.Usage
case provider.ChunkError:
return "", chunk.Err
}
}
if text.Len() > 0 {
fmt.Fprintln(c.out)
}
printUsage(c.out, usage, c.plannerPricing)
plan := text.String()
c.plannerSess.Add(provider.Message{Role: provider.RoleAssistant, Content: plan})
return plan, nil
}
func formatHandoff(task, plan string) string {
return fmt.Sprintf("Task: %s\n\nA planner proposed this approach:\n%s\n\nCarry it out, adapting as needed.", task, plan)
}
+72
View File
@@ -0,0 +1,72 @@
package agent
import (
"context"
"io"
"strings"
"testing"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
// mockProvider replays preset chunks and records the last request it received.
type mockProvider struct {
name string
chunks []provider.Chunk
lastReq provider.Request
}
func (m *mockProvider) Name() string { return m.name }
func (m *mockProvider) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {
m.lastReq = req
ch := make(chan provider.Chunk, len(m.chunks))
for _, c := range m.chunks {
ch <- c
}
close(ch)
return ch, nil
}
func lastUser(req provider.Request) string {
for i := len(req.Messages) - 1; i >= 0; i-- {
if req.Messages[i].Role == provider.RoleUser {
return req.Messages[i].Content
}
}
return ""
}
// TestCoordinatorHandsPlanToExecutor checks the two-session handoff: the planner
// sees the raw task in its own session, and the executor receives the plan.
func TestCoordinatorHandsPlanToExecutor(t *testing.T) {
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "1. read main.go\n2. fix the loop"},
{Type: provider.ChunkDone},
}}
exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Done."},
{Type: provider.ChunkDone},
}}
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, io.Discard)
plannerSess := NewSession("planner-sys")
coord := NewCoordinator(planner, plannerSess, nil, executor, 0, io.Discard)
if err := coord.Run(context.Background(), "fix the bug"); err != nil {
t.Fatalf("Run: %v", err)
}
if got := lastUser(planner.lastReq); !strings.Contains(got, "fix the bug") {
t.Errorf("planner saw user %q, want it to contain the task", got)
}
if got := lastUser(exec.lastReq); !strings.Contains(got, "read main.go") || !strings.Contains(got, "fix the bug") {
t.Errorf("executor saw user %q, want task + plan", got)
}
// planner session must accumulate (system, user, assistant-plan) so its
// prefix grows prepend-only and stays cache-stable.
if n := len(plannerSess.Messages); n != 3 {
t.Errorf("planner session has %d messages, want 3", n)
}
}
+68
View File
@@ -0,0 +1,68 @@
package agent
import (
"context"
"encoding/json"
"io"
"strings"
"testing"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
// stubGate denies any call whose tool name is in deny; everything else allows.
type stubGate struct {
deny map[string]bool
checked []string
}
func (g *stubGate) Check(ctx context.Context, toolName string, args json.RawMessage, readOnly bool) (bool, string, error) {
g.checked = append(g.checked, toolName)
if g.deny[toolName] {
return false, "denied by test policy", nil
}
return true, "", nil
}
// TestGateBlocksDeniedCall proves executeOne consults the gate after the
// plan-mode check: a denied tool returns a "blocked:" result plus a notice and
// never runs, while an allowed tool runs normally.
func TestGateBlocksDeniedCall(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "bash", readOnly: false})
reg.Add(fakeTool{name: "read_file", readOnly: true})
g := &stubGate{deny: map[string]bool{"bash": true}}
a := New(nil, reg, NewSession(""), Options{Gate: g}, io.Discard)
blocked, notice := a.executeOne(context.Background(), provider.ToolCall{Name: "bash", Arguments: `{"command":"rm -rf /"}`})
if !strings.HasPrefix(blocked, "blocked:") {
t.Errorf("denied call result = %q, want a 'blocked:' result", blocked)
}
if notice == "" {
t.Errorf("denied call should surface a user-facing notice")
}
ok, _ := a.executeOne(context.Background(), provider.ToolCall{Name: "read_file", Arguments: `{"path":"/a"}`})
if !strings.Contains(ok, "done") {
t.Errorf("allowed call should run, got %q", ok)
}
if len(g.checked) != 2 {
t.Errorf("gate consulted %d times, want 2 (%v)", len(g.checked), g.checked)
}
}
// TestNilGateRunsEverything confirms gating is opt-in: with no gate wired, a
// writer call runs unimpeded (backward-compatible default).
func TestNilGateRunsEverything(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "write_file", readOnly: false})
a := New(nil, reg, NewSession(""), Options{}, io.Discard) // no Gate
out, _ := a.executeOne(context.Background(), provider.ToolCall{Name: "write_file", Arguments: `{"path":"/a"}`})
if strings.HasPrefix(out, "blocked:") {
t.Errorf("nil gate should not block: %q", out)
}
}
+193
View File
@@ -0,0 +1,193 @@
package agent
import (
"context"
"encoding/json"
"io"
"strings"
"sync/atomic"
"testing"
"time"
"unicode/utf8"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
// TestTruncateToolOutputUnderCap leaves small payloads alone — the cap should
// never rewrite content that already fits.
func TestTruncateToolOutputUnderCap(t *testing.T) {
in := strings.Repeat("a", maxToolOutputBytes)
got, notice := truncateToolOutput(in)
if got != in {
t.Errorf("payload at exactly the cap was rewritten")
}
if notice != "" {
t.Errorf("at-cap payload should not emit a notice, got %q", notice)
}
}
// TestTruncateToolOutputHeadTail keeps head+tail of an oversize payload and
// inserts a marker; the notice must report the elided byte count truthfully.
func TestTruncateToolOutputHeadTail(t *testing.T) {
head := strings.Repeat("H", maxToolOutputBytes)
tail := strings.Repeat("T", maxToolOutputBytes)
in := head + tail
out, notice := truncateToolOutput(in)
if !strings.HasPrefix(out, "H") || !strings.HasSuffix(out, "T") {
t.Errorf("head/tail not preserved at the edges: %q…%q", out[:20], out[len(out)-20:])
}
if !strings.Contains(out, "truncated") {
t.Errorf("truncation marker missing: %q", out)
}
if len(out) >= len(in) {
t.Errorf("output not shorter than input: in=%d out=%d", len(in), len(out))
}
if !strings.Contains(notice, "truncated") {
t.Errorf("notice missing: %q", notice)
}
}
// TestTruncateToolOutputRuneBoundaries puts multibyte runes exactly across the
// head and tail cut points; the result must still be valid UTF-8.
func TestTruncateToolOutputRuneBoundaries(t *testing.T) {
in := strings.Repeat("中", maxToolOutputBytes) // 3 bytes each — guarantees a cut inside a rune
out, _ := truncateToolOutput(in)
if !utf8.ValidString(out) {
t.Errorf("truncated output is not valid UTF-8")
}
}
// TestPrintFinishReasonWarning only fires for abnormal terminations. Normal
// stops are silent so the per-turn line stays clean.
func TestPrintFinishReasonWarning(t *testing.T) {
silent := []string{"", "stop", "tool_calls"}
for _, r := range silent {
var b strings.Builder
printFinishReasonWarning(&b, &provider.Usage{FinishReason: r})
if b.Len() != 0 {
t.Errorf("finish_reason=%q should be silent, got %q", r, b.String())
}
}
loud := map[string]string{
"length": "max output",
"content_filter": "content filter",
"repetition_truncation": "repetition",
}
for reason, fragment := range loud {
var b strings.Builder
printFinishReasonWarning(&b, &provider.Usage{FinishReason: reason})
if !strings.Contains(b.String(), fragment) {
t.Errorf("finish_reason=%q: got %q, want fragment %q", reason, b.String(), fragment)
}
}
}
// --- parallel-dispatch tests ---
// fakeTool is a minimal Tool stand-in for dispatch tests; ReadOnly is
// configurable and Execute sleeps a fixed duration so we can measure
// serial vs parallel behaviour by wall-clock.
type fakeTool struct {
name string
readOnly bool
delay time.Duration
calls *int32 // shared counter to assert all dispatched
}
func (f fakeTool) Name() string { return f.name }
func (f fakeTool) Description() string { return "" }
func (f fakeTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
func (f fakeTool) ReadOnly() bool { return f.readOnly }
func (f fakeTool) Execute(ctx context.Context, _ json.RawMessage) (string, error) {
if f.calls != nil {
atomic.AddInt32(f.calls, 1)
}
select {
case <-time.After(f.delay):
case <-ctx.Done():
return "", ctx.Err()
}
return f.name + " done", nil
}
func TestCanParalleliseAllReadOnly(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "ro1", readOnly: true})
reg.Add(fakeTool{name: "ro2", readOnly: true})
calls := []provider.ToolCall{{Name: "ro1"}, {Name: "ro2"}}
if !canParallelise(reg, calls) {
t.Error("all-readonly batch should be parallelisable")
}
}
// TestCanParalleliseMixedSerial verifies that a single write in a batch flips
// the whole batch back to serial, so read-after-write hazards can't reorder.
func TestCanParalleliseMixedSerial(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "ro", readOnly: true})
reg.Add(fakeTool{name: "rw", readOnly: false})
calls := []provider.ToolCall{{Name: "ro"}, {Name: "rw"}, {Name: "ro"}}
if canParallelise(reg, calls) {
t.Error("mixed batch must be sequential")
}
}
// TestCanParalleliseUnknownToolSerial keeps unknown-tool errors deterministic
// by forcing the batch through the sequential path.
func TestCanParalleliseUnknownToolSerial(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "ro", readOnly: true})
calls := []provider.ToolCall{{Name: "ro"}, {Name: "vanished"}}
if canParallelise(reg, calls) {
t.Error("unknown tool should force sequential dispatch")
}
}
// TestExecuteBatchParallelReadOnly checks that three 80ms read-only calls
// complete in well under 3×80ms — the wall-clock proof of true parallelism.
func TestExecuteBatchParallelReadOnly(t *testing.T) {
const delay = 80 * time.Millisecond
calls := int32(0)
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "a", readOnly: true, delay: delay, calls: &calls})
reg.Add(fakeTool{name: "b", readOnly: true, delay: delay, calls: &calls})
reg.Add(fakeTool{name: "c", readOnly: true, delay: delay, calls: &calls})
a := New(nil, reg, NewSession(""), Options{}, io.Discard)
start := time.Now()
results := a.executeBatch(context.Background(), []provider.ToolCall{{Name: "a"}, {Name: "b"}, {Name: "c"}})
elapsed := time.Since(start)
if calls != 3 {
t.Errorf("dispatched %d calls, want 3", calls)
}
if len(results) != 3 || results[0] != "a done" || results[1] != "b done" || results[2] != "c done" {
t.Errorf("results out of order or wrong: %v", results)
}
// Allow generous slack for CI; even 2x serial would prove we got parallelism.
if elapsed >= 2*delay {
t.Errorf("read-only batch took %v (>= %v) — not parallel", elapsed, 2*delay)
}
}
// TestExecuteBatchSerialOnWrite ensures a single write turn forces total
// serial time even though the other calls would otherwise parallelise.
func TestExecuteBatchSerialOnWrite(t *testing.T) {
const delay = 40 * time.Millisecond
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "ro", readOnly: true, delay: delay})
reg.Add(fakeTool{name: "rw", readOnly: false, delay: delay})
a := New(nil, reg, NewSession(""), Options{}, io.Discard)
start := time.Now()
a.executeBatch(context.Background(), []provider.ToolCall{{Name: "ro"}, {Name: "rw"}, {Name: "ro"}})
elapsed := time.Since(start)
// Three calls of `delay` in serial ≈ 3*delay; permit some slack.
if elapsed < 2*delay {
t.Errorf("mixed batch took only %v — looks like it ran in parallel", elapsed)
}
}
+84
View File
@@ -0,0 +1,84 @@
package agent
import (
"context"
"io"
"strings"
"testing"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
// TestPlanModeBlocksWriters proves the read-only gate refuses non-ReadOnly
// tools while leaving the read-only ones to run normally. The returned tool
// result starts with "blocked:" so the model can adapt mid-turn.
func TestPlanModeBlocksWriters(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "read_only_tool", readOnly: true})
reg.Add(fakeTool{name: "writer_tool", readOnly: false})
a := New(nil, reg, NewSession(""), Options{}, io.Discard)
a.SetPlanMode(true)
ro, _ := a.executeOne(context.Background(), provider.ToolCall{Name: "read_only_tool"})
if !strings.Contains(ro, "done") {
t.Errorf("read-only tool in plan mode should still run: %q", ro)
}
wr, _ := a.executeOne(context.Background(), provider.ToolCall{Name: "writer_tool"})
if !strings.HasPrefix(wr, "blocked:") {
t.Errorf("writer tool in plan mode should return a 'blocked:' result, got: %q", wr)
}
}
// TestPlanModeDoesNotMutateSystemOrTools is the cache-stability test. Toggling
// plan mode between two stream calls must not change the system prompt or the
// tool list seen by the provider — those are the cache-key prefix, and any
// change there forces an expensive cache miss.
func TestPlanModeDoesNotMutateSystemOrTools(t *testing.T) {
prov := &mockProvider{name: "p", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "ok"},
{Type: provider.ChunkDone},
}}
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "read_file", readOnly: true})
reg.Add(fakeTool{name: "write_file", readOnly: false})
a := New(prov, reg, NewSession("STABLE-SYS"), Options{}, io.Discard)
// Auto-mode round-trip.
if err := a.Run(context.Background(), "explore"); err != nil {
t.Fatalf("auto Run: %v", err)
}
autoSys := prov.lastReq.Messages[0]
autoTools := serializeToolNames(prov.lastReq.Tools)
// Flip the gate and run again. The user message changes (new turn), but
// the system message and the tool list both have to come back identical.
prov.chunks = []provider.Chunk{
{Type: provider.ChunkText, Text: "ok"},
{Type: provider.ChunkDone},
}
a.SetPlanMode(true)
if err := a.Run(context.Background(), "now in plan mode"); err != nil {
t.Fatalf("plan Run: %v", err)
}
planSys := prov.lastReq.Messages[0]
planTools := serializeToolNames(prov.lastReq.Tools)
if planSys.Role != autoSys.Role || planSys.Content != autoSys.Content {
t.Errorf("system message changed across mode toggle:\n auto: %+v\n plan: %+v", autoSys, planSys)
}
if planTools != autoTools {
t.Errorf("tool list changed across mode toggle:\n auto: %s\n plan: %s", autoTools, planTools)
}
}
func serializeToolNames(ts []provider.ToolSchema) string {
var names []string
for _, t := range ts {
names = append(names, t.Name)
}
return strings.Join(names, ",")
}
+165
View File
@@ -0,0 +1,165 @@
package agent
import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"reasonix/internal/provider"
)
// Save writes the session's messages to path in JSONL — one provider.Message
// per line — so a user can resume the conversation later. The file is
// rewritten in full on every save: chat sessions are small (kilobytes), and
// append-only would have to be reconciled with the compaction pass that
// mutates the middle of session.Messages.
func (s *Session) Save(path string) error {
if path == "" {
return fmt.Errorf("empty session path")
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create session dir: %w", err)
}
// Write to a sibling tmp file then rename, so a crash mid-write can't
// leave a partial JSONL that won't reload.
tmp, err := os.CreateTemp(filepath.Dir(path), ".session.*.tmp")
if err != nil {
return fmt.Errorf("create session tmp: %w", err)
}
tmpPath := tmp.Name()
enc := json.NewEncoder(tmp)
for _, m := range s.Messages {
if err := enc.Encode(m); err != nil {
tmp.Close()
os.Remove(tmpPath)
return fmt.Errorf("encode message: %w", err)
}
}
if err := tmp.Close(); err != nil {
os.Remove(tmpPath)
return err
}
return os.Rename(tmpPath, path)
}
// LoadSession reads a JSONL file written by Save into a fresh Session value.
// Missing files surface as os.IsNotExist so callers can fall through to a
// new session.
func LoadSession(path string) (*Session, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
s := &Session{}
sc := bufio.NewScanner(f)
// Chat messages can be large after a long read_file result; raise the
// scanner buffer to a few MiB rather than failing on long lines.
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for sc.Scan() {
var m provider.Message
if err := json.Unmarshal(sc.Bytes(), &m); err != nil {
return nil, fmt.Errorf("decode %s: %w", path, err)
}
s.Messages = append(s.Messages, m)
}
if err := sc.Err(); err != nil {
return nil, err
}
return s, nil
}
// SessionInfo summarises a saved session for the --resume picker: where it
// is on disk, when it was last touched, the first user message as a preview,
// and a rough turn count.
type SessionInfo struct {
Path string
ModTime time.Time
Preview string
Turns int
}
// ListSessions returns every *.jsonl session under dir, newest first, each
// with a preview line so the picker can show something the user recognises.
// A missing directory is not an error — it just means there's nothing to
// resume yet.
func ListSessions(dir string) ([]SessionInfo, error) {
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var out []SessionInfo
for _, e := range entries {
if e.IsDir() || filepath.Ext(e.Name()) != ".jsonl" {
continue
}
info, err := e.Info()
if err != nil {
continue
}
full := filepath.Join(dir, e.Name())
preview, turns := previewSession(full)
out = append(out, SessionInfo{
Path: full,
ModTime: info.ModTime(),
Preview: preview,
Turns: turns,
})
}
sort.Slice(out, func(i, j int) bool {
return out[i].ModTime.After(out[j].ModTime)
})
return out, nil
}
// previewSession returns the first user message (truncated) and the number of
// user-role messages so the picker can show "5 turns · 'help me debug the…'".
// Errors are swallowed — a malformed file just shows up with an empty preview.
func previewSession(path string) (string, int) {
f, err := os.Open(path)
if err != nil {
return "", 0
}
defer f.Close()
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 8192), 1<<20)
first := ""
turns := 0
for sc.Scan() {
var m provider.Message
if err := json.Unmarshal(sc.Bytes(), &m); err != nil {
continue
}
if m.Role == provider.RoleUser {
turns++
if first == "" {
s := strings.TrimSpace(m.Content)
if r := []rune(s); len(r) > 80 {
s = string(r[:77]) + "…"
}
first = s
}
}
}
return first, turns
}
// NewSessionPath returns the path to use for a fresh session, namespaced by
// the model so the filename hints at what the conversation was with. dir is
// typically config.SessionDir().
func NewSessionPath(dir, model string) string {
safe := strings.NewReplacer("/", "-", "\\", "-").Replace(model)
if safe == "" {
safe = "session"
}
return filepath.Join(dir, fmt.Sprintf("%s-%s.jsonl", time.Now().UTC().Format("20060102-150405"), safe))
}
+111
View File
@@ -0,0 +1,111 @@
package agent
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"reasonix/internal/provider"
)
// touch sets a file's mtime to t. Used by the listing-order test so it
// doesn't have to sleep between Saves.
func touch(path string, t time.Time) error {
return os.Chtimes(path, t, t)
}
// TestSaveLoadRoundTrip is the contract `reasonix chat --resume` depends on: a
// session written to disk reloads byte-for-byte, including tool calls and
// reasoning content (which the model wants to keep across resumes for cache
// hits on thinking-mode providers).
func TestSaveLoadRoundTrip(t *testing.T) {
s := NewSession("you are reasonix")
s.Add(provider.Message{Role: provider.RoleUser, Content: "find the bug"})
s.Add(provider.Message{
Role: provider.RoleAssistant,
Content: "Let me check.",
ReasoningContent: "I should look at main.go first.",
ToolCalls: []provider.ToolCall{{
ID: "call_1", Name: "read_file", Arguments: `{"path":"main.go"}`,
}},
})
s.Add(provider.Message{
Role: provider.RoleTool, Name: "read_file", ToolCallID: "call_1",
Content: "package main\nfunc main() {}\n",
})
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "It's fine."})
path := filepath.Join(t.TempDir(), "s.jsonl")
if err := s.Save(path); err != nil {
t.Fatalf("Save: %v", err)
}
loaded, err := LoadSession(path)
if err != nil {
t.Fatalf("LoadSession: %v", err)
}
if got, want := len(loaded.Messages), len(s.Messages); got != want {
t.Fatalf("message count after round-trip = %d, want %d", got, want)
}
for i, m := range s.Messages {
if loaded.Messages[i].Role != m.Role {
t.Errorf("message %d role mismatch", i)
}
if loaded.Messages[i].Content != m.Content {
t.Errorf("message %d content mismatch", i)
}
if loaded.Messages[i].ReasoningContent != m.ReasoningContent {
t.Errorf("message %d reasoning mismatch", i)
}
if len(loaded.Messages[i].ToolCalls) != len(m.ToolCalls) {
t.Errorf("message %d tool_calls count mismatch", i)
}
}
}
// TestListSessionsOrdersByMTime makes sure the picker shows the most
// recently used conversation first — that's what users reach for when they
// hit `reasonix chat --continue`.
func TestListSessionsOrdersByMTime(t *testing.T) {
dir := t.TempDir()
// Write two sessions with explicit mtimes so the order is deterministic.
for _, name := range []string{"a.jsonl", "b.jsonl"} {
s := NewSession("")
s.Add(provider.Message{Role: provider.RoleUser, Content: "preview for " + name})
if err := s.Save(filepath.Join(dir, name)); err != nil {
t.Fatal(err)
}
}
oldT := time.Now().Add(-1 * time.Hour)
newT := time.Now()
if err := touch(filepath.Join(dir, "a.jsonl"), oldT); err != nil {
t.Fatal(err)
}
if err := touch(filepath.Join(dir, "b.jsonl"), newT); err != nil {
t.Fatal(err)
}
got, err := ListSessions(dir)
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("len = %d, want 2", len(got))
}
if !strings.HasSuffix(got[0].Path, "b.jsonl") {
t.Errorf("first entry = %s, want the newer 'b.jsonl'", got[0].Path)
}
if got[0].Turns != 1 || got[0].Preview != "preview for b.jsonl" {
t.Errorf("preview/turns wrong on newest: turns=%d preview=%q", got[0].Turns, got[0].Preview)
}
}
// TestListSessionsMissingDir returns nil + no error so callers can fall
// through to a fresh session without special-casing.
func TestListSessionsMissingDir(t *testing.T) {
got, err := ListSessions(filepath.Join(t.TempDir(), "never-created"))
if err != nil || got != nil {
t.Errorf("missing dir = %v / %v, want nil/nil", got, err)
}
}
+24
View File
@@ -0,0 +1,24 @@
// Package agent wires a Provider, a tool Registry, and a Session into the
// harness loop that drives a coding task to completion.
package agent
import "reasonix/internal/provider"
// Session holds the conversation history for one task.
type Session struct {
Messages []provider.Message
}
// NewSession initializes a session with an optional system prompt.
func NewSession(system string) *Session {
s := &Session{}
if system != "" {
s.Messages = append(s.Messages, provider.Message{Role: provider.RoleSystem, Content: system})
}
return s
}
// Add appends a message.
func (s *Session) Add(m provider.Message) {
s.Messages = append(s.Messages, m)
}
+158
View File
@@ -0,0 +1,158 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"io"
"strings"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
// DefaultTaskSystemPrompt steers a sub-agent toward focused, terse delivery —
// it doesn't see the parent's conversation so it must self-contain.
const DefaultTaskSystemPrompt = `You are a sub-agent invoked by a parent coding agent to carry out one focused task.
Use the provided tools to investigate or act. Return a single final answer that is concise
and self-contained — the parent will see only that answer, not your tool calls or reasoning.
If you need to ask for clarification, fail with a precise question instead of guessing.`
// TaskTool spawns a sub-agent in its own session for a focused sub-task. The
// sub-agent runs with a filtered tool whitelist, a lower default step cap,
// and a discard-output writer — only its final assistant message is returned
// to the parent. Use cases: keep noisy tool sequences (multi-file exploration,
// repeated grep / read_file) out of the parent's context budget, or parallel
// research across independent areas (the parallel-dispatch path picks these up
// only when readOnly, which task is not).
type TaskTool struct {
prov provider.Provider
pricing *provider.Pricing
parentReg *tool.Registry
maxSteps int
contextWindow int
temperature float64
archiveDir string
sysPrompt string
gate Gate
}
// NewTaskTool wires a task tool to the parent agent's environment so its
// sub-agents can use the same provider and tools. sysPrompt is the system
// prompt every sub-agent starts with; pass "" for DefaultTaskSystemPrompt. gate
// is the permission gate sub-agents inherit — pass the headless variant so
// deny rules still bite while autonomous sub-agents are never blocked on an
// interactive prompt (there is no UI to answer one).
func NewTaskTool(prov provider.Provider, pricing *provider.Pricing, parentReg *tool.Registry,
maxSteps, contextWindow int, temperature float64, archiveDir, sysPrompt string, gate Gate) *TaskTool {
if sysPrompt == "" {
sysPrompt = DefaultTaskSystemPrompt
}
return &TaskTool{
prov: prov,
pricing: pricing,
parentReg: parentReg,
maxSteps: maxSteps,
contextWindow: contextWindow,
temperature: temperature,
archiveDir: archiveDir,
sysPrompt: sysPrompt,
gate: gate,
}
}
func (t *TaskTool) Name() string { return "task" }
func (t *TaskTool) Description() string {
return "Spawn a sub-agent for a focused sub-task. The sub-agent runs in its own session with the same provider and a filtered tool list (defaults to every parent tool except 'task' — no recursive nesting). Only its final answer is returned. Use this to (a) keep long exploration sequences out of the parent's context budget, or (b) delegate self-contained work like 'find every place that calls X and summarise the patterns'."
}
func (t *TaskTool) Schema() json.RawMessage {
return json.RawMessage(`{
"type":"object",
"properties":{
"prompt":{"type":"string","description":"What the sub-agent should accomplish. Be specific about the deliverable — the sub-agent does not see this conversation."},
"description":{"type":"string","description":"Short label for the sub-task (3-7 words). Surfaced in the dispatch line so the user sees what's running."},
"tools":{"type":"array","items":{"type":"string"},"description":"Optional tool whitelist. Defaults to every parent tool except 'task'."},
"max_steps":{"type":"integer","description":"Optional cap on tool-call rounds. Defaults to half the parent's cap (min 5).","minimum":1}
},
"required":["prompt"]
}`)
}
// ReadOnly is false: a sub-agent can invoke any whitelisted tool, including
// writers. Conservative classification keeps the parallel-dispatch path from
// running two sub-agents at once and letting their writes race.
func (t *TaskTool) ReadOnly() bool { return false }
func (t *TaskTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
var p struct {
Prompt string `json:"prompt"`
Description string `json:"description"`
Tools []string `json:"tools"`
MaxSteps int `json:"max_steps"`
}
if err := json.Unmarshal(args, &p); err != nil {
return "", fmt.Errorf("invalid args: %w", err)
}
if p.Prompt == "" {
return "", fmt.Errorf("prompt is required")
}
maxSteps := p.MaxSteps
if maxSteps <= 0 {
maxSteps = t.maxSteps / 2
if maxSteps < 5 {
maxSteps = 5
}
}
subReg := tool.NewRegistry()
if len(p.Tools) > 0 {
for _, name := range p.Tools {
if name == t.Name() {
continue // no recursive nesting
}
if tl, ok := t.parentReg.Get(name); ok {
subReg.Add(tl)
}
}
} else {
for _, name := range t.parentReg.Names() {
if name == t.Name() {
continue
}
if tl, ok := t.parentReg.Get(name); ok {
subReg.Add(tl)
}
}
}
// Sub-agent runs silently — its noise (tool dispatch lines, per-turn
// usage, reasoning) would clutter the parent UI without buying anything,
// since only the final answer surfaces to the caller anyway.
subSession := NewSession(t.sysPrompt)
subAgent := New(t.prov, subReg, subSession, Options{
MaxSteps: maxSteps,
Temperature: t.temperature,
Pricing: t.pricing,
Gate: t.gate,
ContextWindow: t.contextWindow,
ArchiveDir: t.archiveDir,
}, io.Discard)
if err := subAgent.Run(ctx, p.Prompt); err != nil {
return "", fmt.Errorf("sub-agent: %w", err)
}
// Walk the session backwards for the last assistant message with content —
// that's the sub-agent's final answer. Intermediate assistant messages
// with tool_calls but no text don't count.
for i := len(subSession.Messages) - 1; i >= 0; i-- {
m := subSession.Messages[i]
if m.Role == provider.RoleAssistant && strings.TrimSpace(m.Content) != "" {
return m.Content, nil
}
}
return "", fmt.Errorf("sub-agent finished without producing a final answer")
}
+94
View File
@@ -0,0 +1,94 @@
package agent
import (
"context"
"strings"
"testing"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
// TestTaskToolReturnsSubAgentFinalAnswer runs a task against a mock provider
// that emits a single text turn, and verifies the tool returns exactly that
// text — sub-agent intermediate state isn't supposed to leak.
func TestTaskToolReturnsSubAgentFinalAnswer(t *testing.T) {
sub := &mockProvider{name: "sub", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "found 3 callers of Foo"},
{Type: provider.ChunkDone},
}}
parentReg := tool.NewRegistry()
task := NewTaskTool(sub, nil, parentReg, 20, 0, 0.0, "", "test-sys-prompt", nil)
out, err := task.Execute(context.Background(), []byte(`{"prompt":"find callers of Foo"}`))
if err != nil {
t.Fatalf("Execute: %v", err)
}
if !strings.Contains(out, "found 3 callers of Foo") {
t.Errorf("got %q, want sub-agent final answer", out)
}
// The sub-agent must have received the prompt as its user message and
// the configured system prompt at the top — proving the session was
// fresh, not the parent's.
if sys := sub.lastReq.Messages[0]; sys.Role != provider.RoleSystem || sys.Content != "test-sys-prompt" {
t.Errorf("first message = %+v, want system 'test-sys-prompt'", sys)
}
if got := lastUser(sub.lastReq); got != "find callers of Foo" {
t.Errorf("sub-agent user = %q, want the prompt verbatim", got)
}
}
// TestTaskToolFiltersTools verifies the whitelist behaviour: when the caller
// names a subset of tools, the sub-agent's registry contains exactly that
// set (with "task" stripped to prevent recursion).
func TestTaskToolFiltersTools(t *testing.T) {
sub := &mockProvider{name: "sub", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "ok"},
{Type: provider.ChunkDone},
}}
parentReg := tool.NewRegistry()
parentReg.Add(fakeTool{name: "read_file", readOnly: true})
parentReg.Add(fakeTool{name: "write_file", readOnly: false})
parentReg.Add(fakeTool{name: "bash", readOnly: false})
task := NewTaskTool(sub, nil, parentReg, 20, 0, 0.0, "", "sys", nil)
parentReg.Add(task) // simulate the wiring in cli.setup
args := []byte(`{"prompt":"x","tools":["read_file","task","write_file"]}`)
if _, err := task.Execute(context.Background(), args); err != nil {
t.Fatalf("Execute: %v", err)
}
// The sub-agent's tool schemas should reflect the whitelist minus task.
got := map[string]bool{}
for _, s := range sub.lastReq.Tools {
got[s.Name] = true
}
if !got["read_file"] || !got["write_file"] || got["task"] || got["bash"] {
t.Errorf("sub-agent tools = %v, want {read_file, write_file} (task stripped, bash not requested)", got)
}
}
// TestTaskToolDefaultsToParentToolsWithoutTask covers the no-whitelist path:
// the sub-agent inherits every parent tool except the task tool itself.
func TestTaskToolDefaultsToParentToolsWithoutTask(t *testing.T) {
sub := &mockProvider{name: "sub", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "ok"},
{Type: provider.ChunkDone},
}}
parentReg := tool.NewRegistry()
parentReg.Add(fakeTool{name: "read_file", readOnly: true})
parentReg.Add(fakeTool{name: "grep", readOnly: true})
task := NewTaskTool(sub, nil, parentReg, 20, 0, 0.0, "", "sys", nil)
parentReg.Add(task)
if _, err := task.Execute(context.Background(), []byte(`{"prompt":"x"}`)); err != nil {
t.Fatalf("Execute: %v", err)
}
got := map[string]bool{}
for _, s := range sub.lastReq.Tools {
got[s.Name] = true
}
if !got["read_file"] || !got["grep"] || got["task"] {
t.Errorf("default sub-agent tools = %v, want {read_file, grep} only", got)
}
}
+57
View File
@@ -0,0 +1,57 @@
package agent
import (
"strings"
"testing"
"reasonix/internal/provider"
)
func TestPrintUsage(t *testing.T) {
u := &provider.Usage{
PromptTokens: 1000,
CompletionTokens: 200,
TotalTokens: 1200,
CacheHitTokens: 900,
CacheMissTokens: 100,
}
var b strings.Builder
printUsage(&b, u, nil)
if out := b.String(); !strings.Contains(out, "1200 tok") || !strings.Contains(out, "900 cached / 100 new") {
t.Errorf("usage line = %q (want 1200 tok and 900 cached / 100 new)", out)
}
// With pricing: 900*0.02 + 100*1 + 200*2 = 518 per 1M = 0.000518 -> "¥0.0005".
var c strings.Builder
printUsage(&c, u, &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: "¥"})
if out := c.String(); !strings.Contains(out, "¥0.0005") {
t.Errorf("cost line = %q (want ¥0.0005...)", out)
}
// nil or zero usage prints nothing.
var empty strings.Builder
printUsage(&empty, nil, nil)
printUsage(&empty, &provider.Usage{}, nil)
if empty.Len() != 0 {
t.Errorf("nil/zero usage should print nothing, got %q", empty.String())
}
}
// TestPrintUsageDerivesMissFromHit covers the OpenAI/MiMo shape where only the
// cached count is reported; the displayed "new" value comes from
// PromptTokens - CacheHitTokens. Verifies the absolute split doesn't show 0.
func TestPrintUsageDerivesMissFromHit(t *testing.T) {
u := &provider.Usage{
PromptTokens: 3540,
CompletionTokens: 378,
TotalTokens: 3918,
CacheHitTokens: 1133,
// CacheMissTokens deliberately 0 — provider only reported the hit
}
var b strings.Builder
printUsage(&b, u, nil)
if out := b.String(); !strings.Contains(out, "1133 cached / 2407 new") {
t.Errorf("usage line = %q (want 1133 cached / 2407 new)", out)
}
}
+41
View File
@@ -0,0 +1,41 @@
package agent
import (
"regexp"
"strings"
"github.com/mattn/go-runewidth"
)
// ansiSGR matches ANSI Select-Graphic-Rendition sequences (\e[…m). Width
// measurement strips these so styled streamed text still gets counted by its
// visible column footprint.
var ansiSGR = regexp.MustCompile("\x1b\\[[0-9;]*m")
// visibleWidth returns the column count of s after stripping ANSI SGR codes.
// Delegates to go-runewidth so emoji, fullwidth forms, and ZWJ sequences all
// measure correctly — a hand-rolled CJK-only table missed every emoji range
// and made the streamed-text row count drift on emoji-heavy answers.
func visibleWidth(s string) int {
return runewidth.StringWidth(ansiSGR.ReplaceAllString(s, ""))
}
// streamedRows counts how many rows the cursor has descended after raw text
// of length s was printed at the given terminal width. Used by the markdown
// redraw to know how far up to move before clearing. Each \n descends one
// row; lines whose visible width exceeds the terminal width descend an extra
// row per wrap. A line exactly the terminal width does not wrap on its own —
// terminals "lazy-wrap" only when the next visible character lands.
func streamedRows(s string, width int) int {
if width <= 0 {
width = 80
}
rows := 0
for _, line := range strings.Split(s, "\n") {
if w := visibleWidth(line); w > 0 {
rows += (w - 1) / width
}
}
rows += strings.Count(s, "\n")
return rows
}
+50
View File
@@ -0,0 +1,50 @@
package agent
import (
"strings"
"testing"
)
// TestStreamedRowsBasic covers the four cursor-position cases the markdown
// redraw uses to decide how far up to move before clearing.
func TestStreamedRowsBasic(t *testing.T) {
cases := []struct {
name string
input string
width int
want int
}{
{"no newline fits", "hello", 80, 0},
{"newline only", "hello\n", 80, 1},
{"two lines no trailing", "hello\nworld", 80, 1},
{"two lines trailing", "hello\nworld\n", 80, 2},
{"empty", "", 80, 0},
{"single wrap", "abcdefghij", 5, 1}, // 10 cols / width 5 → 1 wrap
{"two wraps no nl", "abcdefghijklmno", 5, 2},
{"line exactly width", strings.Repeat("a", 80), 80, 0}, // lazy wrap, no extra row
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := streamedRows(tc.input, tc.width); got != tc.want {
t.Errorf("streamedRows(%q, %d) = %d, want %d", tc.input, tc.width, got, tc.want)
}
})
}
}
// TestStreamedRowsCJK proves CJK doubles the column footprint so a Chinese
// line wraps at half the column count.
func TestStreamedRowsCJK(t *testing.T) {
in := strings.Repeat("中", 6) // 12 cols at width 10 → 1 wrap
if got := streamedRows(in, 10); got != 1 {
t.Errorf("streamedRows(6×中, 10) = %d, want 1", got)
}
}
// TestStreamedRowsIgnoresAnsi: ANSI SGR codes must not inflate the row count.
func TestStreamedRowsIgnoresAnsi(t *testing.T) {
in := "\x1b[1mhello\x1b[0m world"
if got := streamedRows(in, 80); got != 0 {
t.Errorf("ANSI in 11-char line at width 80 should be 0 rows, got %d", got)
}
}
+68
View File
@@ -0,0 +1,68 @@
package cli
import (
"regexp"
"strings"
"github.com/rivo/uniseg"
)
// ansiSGR matches ANSI Select-Graphic-Rendition sequences (\e[…m). Width
// measurement strips these so colored content lines fit the box correctly.
var ansiSGR = regexp.MustCompile("\x1b\\[[0-9;]*m")
// visibleWidth returns the printable column width of s after stripping ANSI
// SGR codes. It goes through rivo/uniseg, which measures by *grapheme cluster*
// rather than by rune: emoji ZWJ sequences (👨‍👩‍👧‍👦), keycaps (1️⃣), flags, and
// skin-tone modifiers each occupy one cell-pair, where a rune-by-rune sum (the
// old go-runewidth path) over-counted them and drifted the box/table rails.
// uniseg is already in the dep tree via bubbletea/lipgloss.
func visibleWidth(s string) int {
return uniseg.StringWidth(ansiSGR.ReplaceAllString(s, ""))
}
// padRight returns s padded with spaces on the right until it occupies w
// terminal columns (visible width, not bytes). Strings already at or beyond
// width are returned unchanged. Use this instead of fmt's %-Ns when content
// may contain CJK or ANSI SGR codes.
func padRight(s string, w int) string {
pad := w - visibleWidth(s)
if pad <= 0 {
return s
}
return s + strings.Repeat(" ", pad)
}
// boxed wraps content in a rounded box drawn with the brand accent. Width
// auto-fits the longest line plus one column of padding on each side. The
// result always ends with a trailing newline so callers can Print it directly.
func boxed(lines []string) string {
inner := 0
for _, l := range lines {
if w := visibleWidth(l); w > inner {
inner = w
}
}
inner += 2 // one space of padding on each side
bar := strings.Repeat("─", inner)
var b strings.Builder
b.WriteString(accent("╭" + bar + "╮"))
b.WriteByte('\n')
for _, l := range lines {
gap := inner - visibleWidth(l) - 2
if gap < 0 {
gap = 0
}
b.WriteString(accent("│"))
b.WriteByte(' ')
b.WriteString(l)
b.WriteString(strings.Repeat(" ", gap))
b.WriteByte(' ')
b.WriteString(accent("│"))
b.WriteByte('\n')
}
b.WriteString(accent("╰" + bar + "╯"))
b.WriteByte('\n')
return b.String()
}
+79
View File
@@ -0,0 +1,79 @@
package cli
import (
"context"
"testing"
)
// TestChannelApproverAllowOnce drives the happy path: a request lands on reqCh,
// the (fake) TUI replies allow, and Approve returns allow with no session grant.
func TestChannelApproverAllowOnce(t *testing.T) {
reqCh := make(chan approvalReq, 1)
ap := newChannelApprover(reqCh)
go func() {
req := <-reqCh
if req.tool != "bash" || req.subject != "go test" {
t.Errorf("request = {%q,%q}, want {bash, go test}", req.tool, req.subject)
}
req.reply <- approvalResp{allow: true}
}()
allow, remember, err := ap.Approve(context.Background(), "bash", "go test", nil)
if err != nil || !allow || remember {
t.Fatalf("Approve = (%v,%v,%v), want allow once", allow, remember, err)
}
}
// TestChannelApproverDeny confirms a declined call returns allow=false.
func TestChannelApproverDeny(t *testing.T) {
reqCh := make(chan approvalReq, 1)
ap := newChannelApprover(reqCh)
go func() {
req := <-reqCh
req.reply <- approvalResp{allow: false}
}()
allow, _, err := ap.Approve(context.Background(), "bash", "rm -rf /", nil)
if err != nil || allow {
t.Fatalf("Approve = (%v,%v), want deny", allow, err)
}
}
// TestChannelApproverSessionGrant proves an "allow this session" answer
// short-circuits later prompts for the same tool+subject: only the first call
// reaches the TUI.
func TestChannelApproverSessionGrant(t *testing.T) {
reqCh := make(chan approvalReq, 1)
ap := newChannelApprover(reqCh)
prompts := 0
go func() {
for req := range reqCh {
prompts++
req.reply <- approvalResp{allow: true, session: true}
}
}()
for i := 0; i < 3; i++ {
allow, _, err := ap.Approve(context.Background(), "bash", "go build", nil)
if err != nil || !allow {
t.Fatalf("call %d = (%v,%v), want allow", i, allow, err)
}
}
if prompts != 1 {
t.Errorf("prompted %d times, want 1 (session grant should short-circuit)", prompts)
}
}
// TestChannelApproverCtxCancel ensures a cancelled turn unblocks Approve with an
// error (rather than hanging) when no one is reading the request channel.
func TestChannelApproverCtxCancel(t *testing.T) {
reqCh := make(chan approvalReq) // unbuffered + no reader → send blocks
ap := newChannelApprover(reqCh)
ctx, cancel := context.WithCancel(context.Background())
cancel()
allow, _, err := ap.Approve(ctx, "bash", "x", nil)
if err == nil || allow {
t.Fatalf("Approve on cancelled ctx = (%v,%v), want (false, error)", allow, err)
}
}
+45
View File
@@ -0,0 +1,45 @@
package cli
import (
"strings"
"testing"
)
// TestCommitReasoningWrapsToWidth guards the ghost-border fix: every line a
// reasoning commit queues for scrollback must fit the viewport width, so
// bubbletea's Println erases each line to its end and the old input-box border
// can't bleed through after a wrapped reasoning row.
func TestCommitReasoningWrapsToWidth(t *testing.T) {
const width = 40
commit := []string{}
m := &chatTUI{
width: width,
reasoning: &strings.Builder{},
pendingCommit: &commit,
}
// Header (short, indented) + a long single-line reasoning paragraph, both
// dim-wrapped exactly as the agent streams them.
m.reasoning.WriteString("\x1b[2m ▎ thinking\x1b[0m\n")
m.reasoning.WriteString("\x1b[2m" + strings.Repeat("reason ", 30) + "\x1b[0m")
m.commitReasoning()
if len(commit) == 0 {
t.Fatal("commitReasoning queued nothing")
}
for _, block := range commit {
for _, line := range strings.Split(block, "\n") {
if w := visibleWidth(line); w > width {
t.Errorf("committed line exceeds width %d: width=%d %q", width, w, line)
}
}
}
// The header keeps its leading indent (short lines pass through verbatim).
if !strings.Contains(commit[0], " ▎ thinking") {
t.Errorf("header indent not preserved: %q", commit[0])
}
// Reasoning must be cleared after committing.
if m.reasoning.Len() != 0 {
t.Error("reasoning buffer not reset")
}
}
+74
View File
@@ -0,0 +1,74 @@
package cli
import (
"strings"
"testing"
"charm.land/bubbles/v2/textarea"
)
// newTestChatTUI builds a chatTUI with just the pieces the streaming/commit and
// completion paths need, for unit tests that don't run the bubbletea loop.
func newTestChatTUI() chatTUI {
commit := []string{}
ti := textarea.New()
ti.SetWidth(80)
return chatTUI{
input: ti,
reasoning: &strings.Builder{},
pending: &strings.Builder{},
pendingCommit: &commit,
turnAccumulator: &strings.Builder{},
renderer: newMarkdownRenderer(80),
}
}
// TestIngestSeparatesReasoningFromAnswer proves the print-above model commits
// the reasoning block and the answer as distinct scrollback entries (joined
// with a newline downstream), so the answer never butts up against the last
// line of thinking — and that the answer stays live until it's flushed.
func TestIngestSeparatesReasoningFromAnswer(t *testing.T) {
m := newTestChatTUI()
m.ingestChunk("\x1b[2m ▎ thinking\x1b[0m\n") // reasoning header → live buffer
m.ingestChunk("\x1b[2m…reasoning…\x1b[0m") // reasoning fragment → live buffer
if len(*m.pendingCommit) != 0 {
t.Fatalf("reasoning should stay live until the answer begins, committed=%v", *m.pendingCommit)
}
m.ingestChunk("Hello answer") // answer begins → reasoning finalizes
if n := len(*m.pendingCommit); n != 1 || !strings.Contains((*m.pendingCommit)[0], "thinking") {
t.Fatalf("reasoning should commit when the answer begins, committed=%v", *m.pendingCommit)
}
if m.pending.String() != "Hello answer" {
t.Errorf("answer should be live in pending, got %q", m.pending.String())
}
if m.reasoning.Len() != 0 {
t.Errorf("reasoning buffer should be cleared after commit")
}
m.commitPending() // turn end
if n := len(*m.pendingCommit); n != 2 || !strings.Contains((*m.pendingCommit)[1], "Hello") {
t.Fatalf("answer should commit on flush as a separate entry, committed=%v", *m.pendingCommit)
}
}
// TestIngestEventFlushesAnswer confirms an event line (e.g. a tool dispatch)
// finalizes the answer streamed before it, preserving order in scrollback.
func TestIngestEventFlushesAnswer(t *testing.T) {
m := newTestChatTUI()
m.ingestChunk("partial answer ")
m.ingestChunk(" -> read_file {\"path\":\"x\"}\n")
if n := len(*m.pendingCommit); n != 2 {
t.Fatalf("answer then event line should be two commits, got %d: %v", n, *m.pendingCommit)
}
if !strings.Contains((*m.pendingCommit)[0], "partial answer") {
t.Errorf("first commit should be the buffered answer, got %q", (*m.pendingCommit)[0])
}
if !strings.Contains((*m.pendingCommit)[1], "-> read_file") {
t.Errorf("second commit should be the event line, got %q", (*m.pendingCommit)[1])
}
if m.pending.Len() != 0 {
t.Errorf("answer buffer should be drained after the event line")
}
}
File diff suppressed because it is too large. Load diff
+62
View File
@@ -0,0 +1,62 @@
package cli
import (
"strings"
"testing"
)
// TestEventClassifierAgentPrefixes covers the agent-emitted event lines that
// must finalize the streaming answer before being committed: tool dispatch,
// usage / notice, finish warning, and the two coordinator markers.
func TestEventClassifierAgentPrefixes(t *testing.T) {
cases := []string{
" · 1200 tok · in 1000 (900 cached / 100 new) · out 200\n",
" · tool output truncated: 12345 of 50000 bytes elided\n",
" · compacted 8 messages → summary\n",
" -> read_file {\"path\":\"foo\"}\n",
" ! response truncated: hit max output tokens\n",
"[planner · planning]\n",
"[executor · executing]\n",
}
for _, c := range cases {
if !isEventChunk(c) {
t.Errorf("agent-shape chunk should be an event line: %q", c)
}
}
}
// TestReasoningRoutedByDimPrefix locks in that reasoning is recognised by its
// dim prefix (handled before isEventChunk in ingestChunk), not by isEventChunk.
func TestReasoningRoutedByDimPrefix(t *testing.T) {
for _, c := range []string{"\x1b[2m ▎ thinking\x1b[0m\n", "\x1b[2mthinking continuation\x1b[0m"} {
if !strings.HasPrefix(c, "\x1b[2m") {
t.Errorf("reasoning chunk should carry the dim prefix: %q", c)
}
if isEventChunk(c) {
t.Errorf("reasoning is routed by the dim prefix, not isEventChunk: %q", c)
}
}
}
// TestEventClassifierModelContent locks in that model output starting with "["
// — links, slice literals, admonitions — is NOT treated as an event line, so it
// stays in the answer buffer and renders as one markdown block.
func TestEventClassifierModelContent(t *testing.T) {
cases := []string{
"[]SessionInfo, error)\n", // continuation of a table cell
"[link](https://example.com)", // markdown link at line start
"[1, 2, 3]", // array literal
"[]string{\"a\"}\n", // Go slice literal
"[Note]: this is important.\n", // admonition / footnote ref
"[A] some content\n", // bracketed tag
"[planner | planning]\n", // wrong separator, not coordinator
"plain model text", // no special prefix
"## heading", // markdown heading
"| col1 | col2 |\n", // table row
}
for _, c := range cases {
if isEventChunk(c) {
t.Errorf("model content should NOT be an event line: %q", c)
}
}
}
+971
View File
@@ -0,0 +1,971 @@
// Package cli implements reasonix's command-line entry: subcommand routing, flag
// parsing, assembly from config, and exit codes. The core is config-driven —
// providers and tools are resolved from configuration, not hardcoded.
package cli
import (
"bufio"
"context"
"flag"
"fmt"
"io"
"os"
"os/signal"
"strings"
"reasonix/internal/agent"
"reasonix/internal/command"
"reasonix/internal/config"
"reasonix/internal/i18n"
"reasonix/internal/permission"
"reasonix/internal/plugin"
"reasonix/internal/provider"
"reasonix/internal/sandbox"
"reasonix/internal/tool"
"reasonix/internal/tool/builtin"
tea "charm.land/bubbletea/v2"
"golang.org/x/term"
)
// Run is the CLI entry point; it returns a process exit code.
func Run(args []string, version string) int {
// Pick the UI language up front so even pre-config paths (the first-run
// welcome banner) come through localized. Env-only first; if a config
// exists and pins a language, that wins.
i18n.DetectLanguage("")
if cfg, err := config.Load(); err == nil && cfg.Language != "" {
i18n.DetectLanguage(cfg.Language)
}
if len(args) == 0 {
return welcome(version)
}
cmd, rest := args[0], args[1:]
switch cmd {
case "run":
return runAgent(rest)
case "chat":
return chatREPL(rest)
case "init":
return initConfig(rest)
case "version", "--version", "-v":
fmt.Println("reasonix", version)
return 0
case "help", "--help", "-h":
usage()
return 0
default:
fmt.Fprintf(os.Stderr, i18n.M.UnknownCommandFmt+"\n\n", cmd)
usage()
return 2
}
}
// runtime bundles everything needed to drive an agent, assembled from config.
type runtime struct {
runner agent.Runner
executor *agent.Agent // kept so the CLI can read/write the executor's session for persistence
label string // e.g. "deepseek-flash" or "deepseek-flash + planner mimo"
systemPrompt string // remembered so `/new` can mint a fresh session with the same persona
sessionDir string // where new files land; "" disables persistence
sessionPath string // active file — auto-save targets this and /new rotates it
cleanup func()
setPlanMode func(bool) // flips the executor's read-only gate without touching the cache-friendly prefix
policy permission.Policy // the resolved permission policy; chat wraps it in an interactive gate
host *plugin.Host // running MCP servers; nil when no plugins — chat reads prompts from it
commands []command.Command // custom slash commands loaded from .reasonix/commands
}
// replaceSession swaps the executor's conversation for the loaded one — used
// to seed a `reasonix chat --resume` run with the saved history.
func (rt *runtime) replaceSession(s *agent.Session) {
if rt.executor != nil {
rt.executor.SetSession(s)
}
}
// snapshotSession writes the executor's current conversation to the active
// session path. Called after every turn so a crash or kill loses at most one
// in-flight prompt. No-op when persistence is unavailable.
func (rt *runtime) snapshotSession() error {
if rt.executor == nil || rt.sessionPath == "" {
return nil
}
return rt.executor.Session().Save(rt.sessionPath)
}
// startNewSession snapshots the current conversation, mints a fresh file
// path, and resets the executor's session to a clean state carrying the
// same system prompt. Powers the chat TUI's `/new` command.
func (rt *runtime) startNewSession() error {
if rt.executor == nil {
return nil
}
if err := rt.snapshotSession(); err != nil {
return err
}
if rt.sessionDir != "" {
rt.sessionPath = agent.NewSessionPath(rt.sessionDir, rt.label)
}
rt.executor.SetSession(agent.NewSession(rt.systemPrompt))
return nil
}
// replayHistory returns the executor's current message log, used by the TUI
// to repopulate the viewport when a session was resumed.
func (rt *runtime) replayHistory() []provider.Message {
if rt.executor == nil {
return nil
}
return rt.executor.Session().Messages
}
// contextSnapshot returns (promptTokens, contextWindow) from the executor's
// most recent turn. Both zero means we don't have data yet — the status-line
// indicator simply hides itself in that case.
func (rt *runtime) contextSnapshot() (int, int) {
if rt.executor == nil {
return 0, 0
}
u := rt.executor.LastUsage()
if u == nil {
return 0, rt.executor.ContextWindow()
}
return u.PromptTokens, rt.executor.ContextWindow()
}
// compactNow triggers one compaction pass on the executor's session,
// regardless of the usual usage-ratio trigger. Used by /compact.
func (rt *runtime) compactNow(ctx context.Context) error {
if rt.executor == nil {
return nil
}
return rt.executor.CompactNow(ctx)
}
// setup loads config, resolves the model(s), and builds a Runner: a single Agent,
// or a two-model Coordinator when agent.planner_model is set. requireKey forces
// the executor's API key to be present (used by run); chat passes false so the
// session UI is reachable before a key is set. out is where the agent prints
// streamed tokens / tool dispatch lines / usage — runAgent passes os.Stdout
// for live output, the TUI passes a channel writer so events become tea.Msgs.
// The markdown post-stream redraw uses ANSI cursor moves the TUI's viewport
// can't interpret, so it's only enabled when out == os.Stdout on a TTY. The
// cleanup stops plugin subprocesses.
func setup(ctx context.Context, modelName string, maxStepsOverride int, requireKey bool, out io.Writer) (*runtime, error) {
cfg, err := config.Load()
if err != nil {
return nil, err
}
if modelName == "" {
modelName = cfg.DefaultModel
}
entry, ok := cfg.Provider(modelName)
if !ok {
return nil, fmt.Errorf("unknown model %q (configured: %s)", modelName, providerNames(cfg))
}
if requireKey {
if err := cfg.Validate(modelName); err != nil {
return nil, err
}
}
execProv, err := newProvider(entry)
if err != nil {
return nil, err
}
sysPrompt, err := cfg.ResolveSystemPrompt()
if err != nil {
return nil, err
}
reg := tool.NewRegistry()
bashSpec := sandbox.Spec{Mode: cfg.BashMode(), WriteRoots: cfg.WriteRoots(), Network: cfg.Sandbox.Network}
if bashSpec.Mode == "enforce" && !sandbox.Available() {
fmt.Fprintln(os.Stderr, "warning: bash sandbox requested but unavailable on this platform; running bash unconfined")
}
addBuiltins(reg, cfg.Tools.Enabled, cfg.WriteRoots(), bashSpec)
cleanup := func() {}
var pluginHost *plugin.Host
if len(cfg.Plugins) > 0 {
host, ptools, err := plugin.StartAll(ctx, pluginSpecs(cfg.Plugins))
if err != nil {
return nil, fmt.Errorf("plugin: %w", err)
}
pluginHost = host
cleanup = host.Close
for _, t := range ptools {
reg.Add(t)
}
}
maxSteps := cfg.Agent.MaxSteps
if maxStepsOverride > 0 {
maxSteps = maxStepsOverride
}
// Permission policy gates every tool call. The headless gate (no Approver)
// resolves "ask" to allow — preserving `reasonix run` autonomy — while deny
// rules hard-block in every mode. `reasonix chat` swaps in an interactive gate
// later (see chat). Sub-agents always run headless: they have no UI to
// answer a prompt, so they inherit this same gate.
policy := permission.New(cfg.Permissions.Mode, cfg.Permissions.Allow, cfg.Permissions.Ask, cfg.Permissions.Deny)
headlessGate := permission.NewGate(policy, nil)
// The `task` tool spawns sub-agents that reuse the parent's provider and
// tool registry. Wired here after the built-ins / plugins are loaded so
// sub-agents inherit the full tool set (minus `task` itself, to keep
// nesting out of the picture). It registers into the same reg the
// executor uses, so the model surfaces it like any other tool.
reg.Add(agent.NewTaskTool(execProv, entry.Price, reg, maxSteps,
entry.ContextWindow, cfg.Agent.Temperature, config.ArchiveDir(), "", headlessGate))
// Markdown rendering replaces the streamed raw text with styled output
// once a turn's stream completes. The cursor-move escapes it emits would
// confuse the TUI's viewport, so only enable when out is os.Stdout on a
// TTY (the live `reasonix run` path); piped / captured / TUI cases get raw.
var renderer agent.Renderer
termW := 80
if out == os.Stdout && isTTY(os.Stdout) {
if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 {
termW = w
}
renderer = newMarkdownRenderer(termW)
}
execSess := agent.NewSession(sysPrompt)
executor := agent.New(execProv, reg, execSess, agent.Options{
MaxSteps: maxSteps,
Temperature: cfg.Agent.Temperature,
Pricing: entry.Price,
Renderer: renderer,
TermWidth: termW,
Gate: headlessGate,
ContextWindow: entry.ContextWindow,
ArchiveDir: config.ArchiveDir(),
}, out)
// Custom slash commands (.reasonix/commands + user dir). Best-effort: a malformed
// file is skipped, and a load error never blocks the session.
cmds, _ := command.Load(config.CommandDirs()...)
var runner agent.Runner = executor
label := modelName
// Two-model collaboration: a distinct planner_model wraps the executor in a
// Coordinator with its own session, kept separate for cache stability.
if pm := cfg.Agent.PlannerModel; pm != "" && pm != modelName {
pe, ok := cfg.Provider(pm)
if !ok {
return nil, fmt.Errorf("planner_model %q is not a configured provider", pm)
}
plannerProv, err := newProvider(pe)
if err != nil {
return nil, fmt.Errorf("planner %q: %w", pm, err)
}
plannerSess := agent.NewSession(agent.DefaultPlannerPrompt)
runner = agent.NewCoordinator(plannerProv, plannerSess, pe.Price, executor, cfg.Agent.Temperature, out)
label = modelName + " + planner " + pm
}
return &runtime{
runner: runner,
executor: executor,
label: label,
systemPrompt: sysPrompt,
sessionDir: config.SessionDir(),
cleanup: cleanup,
setPlanMode: executor.SetPlanMode,
policy: policy,
host: pluginHost,
commands: cmds,
}, nil
}
func newProvider(e *config.ProviderEntry) (provider.Provider, error) {
return provider.New(e.Kind, provider.Config{
Name: e.Name,
BaseURL: e.BaseURL,
Model: e.Model,
APIKey: e.APIKey(),
// Pass the key's env var so auth failures can name where to fix it.
Extra: map[string]any{"api_key_env": e.APIKeyEnv},
})
}
func runAgent(args []string) int {
fs := flag.NewFlagSet("run", flag.ContinueOnError)
model := fs.String("model", "", "provider name (default: config default_model)")
maxSteps := fs.Int("max-steps", 0, "max tool-call rounds (0 = use config/default)")
if err := fs.Parse(args); err != nil {
return 2
}
prompt := strings.TrimSpace(strings.Join(fs.Args(), " "))
if prompt == "" {
prompt = readStdin()
}
if prompt == "" {
fmt.Fprintln(os.Stderr, i18n.M.UsageRunHint)
return 2
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
rt, err := setup(ctx, *model, *maxSteps, true, os.Stdout)
if err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
defer rt.cleanup()
if err := rt.runner.Run(ctx, prompt); err != nil {
fmt.Fprintln(os.Stderr, "\n"+i18n.M.ErrorPrefix, err)
return 1
}
return 0
}
// chatREPL is an interactive session: a single persistent agent/session and a
// prompt loop that keeps conversation context across turns. Exit with
// 'exit'/'quit' or Ctrl-D.
func chatREPL(args []string) int {
fs := flag.NewFlagSet("chat", flag.ContinueOnError)
model := fs.String("model", "", "provider name (default: config default_model)")
maxSteps := fs.Int("max-steps", 0, "max tool-call rounds (0 = use config/default)")
cont := fs.Bool("continue", false, "resume the most recent saved session")
fs.BoolVar(cont, "c", false, "shorthand for --continue")
resume := fs.Bool("resume", false, "list saved sessions and pick one to resume")
if err := fs.Parse(args); err != nil {
return 2
}
// Decide whether we're starting fresh or resuming. --resume opens an
// interactive picker; --continue / -c jumps straight into the newest.
var resumePath string
switch {
case *resume:
path, rc := pickSessionToResume()
if rc != 0 {
return rc
}
resumePath = path
case *cont:
sessions, err := agent.ListSessions(config.SessionDir())
if err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
if len(sessions) == 0 {
fmt.Fprintln(os.Stderr, i18n.M.NoSessionToResume)
return 1
}
resumePath = sessions[0].Path
}
ctx := context.Background()
// Plumb agent output through a channel so it can become tea.Msgs inside
// the TUI's update loop. Buffered generously: streaming bursts (tool
// results, long answers) shouldn't backpressure the agent goroutine.
textCh := make(chan string, 1024)
doneCh := make(chan error, 1)
chanOut := &channelWriter{ch: textCh}
rt, err := setup(ctx, *model, *maxSteps, false, chanOut)
if err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
defer rt.cleanup()
// Decide where this conversation's auto-save lands. A resume reuses the
// file so closing/reopening keeps appending to the same history; a fresh
// session lands in a new file stamped with the model name.
if resumePath != "" {
rt.sessionPath = resumePath
if loaded, err := agent.LoadSession(resumePath); err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
} else {
rt.replaceSession(loaded)
}
} else if rt.sessionDir != "" {
rt.sessionPath = agent.NewSessionPath(rt.sessionDir, rt.label)
}
// Surface a missing-key warning inside the TUI banner so the first message
// failing is at least pre-announced; the user can still enter chat.
missing := ""
if cfg, loadErr := config.Load(); loadErr == nil {
name := *model
if name == "" {
name = cfg.DefaultModel
}
if vErr := cfg.Validate(name); vErr != nil {
missing = vErr.Error()
}
}
// Initial terminal width — the TUI re-flows on every WindowSizeMsg so
// this is just a starting estimate before the first resize event lands.
termW := 80
if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 {
termW = w
}
hooks := chatHooks{
setPlanMode: rt.setPlanMode,
save: rt.snapshotSession,
compact: func() error { return rt.compactNow(ctx) },
newSession: rt.startNewSession,
contextSnapshot: rt.contextSnapshot,
}
// Swap the executor's headless gate for an interactive one: when the policy
// says "ask", the approver hands the call to the TUI over approvalCh and
// blocks until the user answers. Sub-agents (the task tool) keep their
// headless gate from setup — they have no UI to prompt through.
approvalCh := make(chan approvalReq, 1)
approver := newChannelApprover(approvalCh)
rt.executor.SetGate(permission.NewGate(rt.policy, approver))
m := newChatTUI(rt.runner, rt.label, missing, textCh, doneCh, termW, hooks, rt.replayHistory(), approvalCh, rt.host, rt.commands)
// No alt-screen: finalized transcript lines are committed to the terminal's
// normal buffer (via tea.Println) so native scrollback, the wheel, and copy
// all work — the bubbletea-managed region is just the bottom input/status.
p := tea.NewProgram(m)
if _, err := p.Run(); err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
return 0
}
// addBuiltins adds enabled built-in tools to reg. An empty list means all of
// them. writeRoots confines the file-writing built-ins to the workspace: after
// the (unconfined) defaults are added, each enabled writer is replaced by an
// instance bound to writeRoots (preserving registry order).
func addBuiltins(reg *tool.Registry, enabled, writeRoots []string, bashSpec sandbox.Spec) {
if len(enabled) == 0 {
for _, t := range tool.Builtins() {
reg.Add(t)
}
} else {
for _, name := range enabled {
if t, ok := tool.LookupBuiltin(name); ok {
reg.Add(t)
} else {
fmt.Fprintf(os.Stderr, "warning: unknown built-in tool %q\n", name)
}
}
}
// Replace the unconfined defaults with confined instances (registry order is
// preserved on replace): file-writers bound to the workspace, bash to the OS
// sandbox. Only replace tools actually enabled/present.
confined := append(builtin.ConfineWriters(writeRoots), builtin.ConfineBash(bashSpec))
for _, t := range confined {
if _, ok := reg.Get(t.Name()); ok {
reg.Add(t)
}
}
}
func pluginSpecs(entries []config.PluginEntry) []plugin.Spec {
specs := make([]plugin.Spec, len(entries))
for i, e := range entries {
e = e.ExpandedPlugin() // resolve ${VAR} / ${VAR:-default} from the environment
specs[i] = plugin.Spec{
Name: e.Name,
Type: e.Type,
Command: e.Command,
Args: e.Args,
Env: e.Env,
URL: e.URL,
Headers: e.Headers,
}
}
return specs
}
func initConfig(args []string) int {
path := "reasonix.toml"
if len(args) > 0 {
path = args[0]
}
if _, err := os.Stat(path); err == nil {
// Non-interactive must not clobber an existing config silently.
if !isInteractive() {
fmt.Fprintf(os.Stderr, i18n.M.NotOverwritingFmt+"\n", path)
return 1
}
in := bufio.NewScanner(os.Stdin)
ans := ask(in, os.Stdout, fmt.Sprintf(i18n.M.ConfirmReconfigureFmt, path), "N")
if ans != "y" && ans != "Y" {
fmt.Println(i18n.M.KeepingExisting)
return 0
}
}
// Interactive wizard on a TTY; fall back to the annotated default when piped.
if isInteractive() {
rc := interactiveInit(path)
if rc == 0 {
fmt.Printf(i18n.M.TryHintFmt+"\n", bold("reasonix chat"))
}
return rc
}
return writeDefaultConfig(path)
}
func writeDefaultConfig(path string) int {
if err := config.Default().WriteFile(path); err != nil {
fmt.Fprintln(os.Stderr, i18n.M.WriteConfigErr, err)
return 1
}
fmt.Printf(i18n.M.WroteFileFmt+"\n", path)
fmt.Println(i18n.M.NextHint)
return 0
}
// interactiveInit runs the setup wizard, then writes reasonix.toml (plus .env for any
// keys entered). The wizard is intentionally minimal: pick language, pick
// provider, enter API keys. Language is asked first so every subsequent prompt
// is already in the user's language even when env auto-detection got it wrong.
// Two-model collaboration is left as a manual config edit (planner_model) so
// first-run never confronts newcomers with advanced choices.
func interactiveInit(path string) int {
// Seed from the existing config when reconfiguring, so a re-run to fix a key
// preserves the user's providers / agent settings instead of resetting to
// defaults. First run (no file) falls back to the built-in defaults.
cfg := config.LoadForEdit(path)
prevDefault := cfg.DefaultModel
lang, err := selectLanguage()
if err != nil {
fmt.Fprintln(os.Stderr, "\nsetup cancelled.")
return 1
}
cfg.Language = lang
i18n.DetectLanguage(lang)
// Now that the catalogue matches the user's choice, show the welcome banner
// in their language before any substantive prompt.
fmt.Println()
fmt.Print(boxed([]string{
accent("◆") + " " + fmt.Sprintf(i18n.M.WelcomeTitleFmt, bold("reasonix")),
"",
dim(i18n.M.NoConfigYet),
}))
fmt.Println()
enabled, err := selectEnabledProviders(cfg.Providers)
if err != nil {
fmt.Fprintln(os.Stderr, "\n"+i18n.M.SetupCancelled)
return 1
}
envLines := configureKeys(enabled, os.Stdin, os.Stdout)
cfg.Providers = enabled
// Keep the previous default model if it's still enabled; otherwise fall back
// to the first selected provider.
cfg.DefaultModel = enabled[0].Name
for _, p := range enabled {
if p.Name == prevDefault {
cfg.DefaultModel = prevDefault
break
}
}
if err := cfg.WriteFile(path); err != nil {
fmt.Fprintln(os.Stderr, i18n.M.WriteConfigErr, err)
return 1
}
fmt.Printf("\n%s %s\n", green("✓"), fmt.Sprintf(i18n.M.WroteFileFmt, path))
if len(envLines) > 0 {
if err := appendEnv(".env", envLines); err != nil {
fmt.Fprintln(os.Stderr, i18n.M.WriteEnvErr, err)
return 1
}
fmt.Printf("%s %s\n", green("✓"), fmt.Sprintf(i18n.M.WroteFileFmt, ".env"))
}
fmt.Printf("\n%s %s\n", accent("◆"), i18n.M.SetupComplete)
return 0
}
// pickSessionToResume scans the session dir, takes the 10 most recent, and
// shows a single-choice menu with timestamp + turn count + first user
// message so the user can pick one. Returns the chosen path and a process
// exit code (non-zero when there's nothing to pick or the user cancelled).
func pickSessionToResume() (string, int) {
sessions, err := agent.ListSessions(config.SessionDir())
if err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return "", 1
}
if len(sessions) == 0 {
fmt.Fprintln(os.Stderr, i18n.M.NoSessionToResume)
return "", 1
}
if !isInteractive() {
fmt.Fprintln(os.Stderr, i18n.M.ResumeRequiresTTY)
return "", 1
}
const cap = 10
if len(sessions) > cap {
sessions = sessions[:cap]
}
items := make([]menuItem, len(sessions))
for i, s := range sessions {
when := s.ModTime.Local().Format("01-02 15:04")
preview := s.Preview
if preview == "" {
preview = "(no user message yet)"
}
items[i] = menuItem{
name: when,
desc: fmt.Sprintf("%d turns · %s", s.Turns, preview),
}
}
idx, err := selectOne(i18n.M.PickSessionLabel, items)
if err != nil {
return "", 1
}
return sessions[idx].Path, 0
}
// selectLanguage is the wizard's first prompt: it shows the two UI languages
// in their native form and pre-selects the env-detected one (so a single Enter
// confirms the auto-detection, a single arrow + Enter picks the other). The
// label is bilingual because we don't yet know which catalogue to trust.
func selectLanguage() (string, error) {
detected := i18n.DetectLanguage("")
items := []menuItem{{name: "English"}, {name: "中文 (简体)"}}
tags := []string{"en", "zh"}
if detected == "zh" {
items[0], items[1] = items[1], items[0]
tags[0], tags[1] = tags[1], tags[0]
}
idx, err := selectOne("Language · 语言", items)
if err != nil {
return "", err
}
return tags[idx], nil
}
// selectEnabledProviders prompts a single multi-select of provider families
// (DeepSeek / MiMo / …) and returns every SKU of every chosen family. Picking
// a family enables all of its SKUs; users who want to exclude a specific SKU
// edit reasonix.toml afterward — keeping first-run a single decision instead of two.
func selectEnabledProviders(providers []config.ProviderEntry) ([]config.ProviderEntry, error) {
famOrder, famMembers, famInfo := groupByFamily(providers)
famItems := make([]menuItem, len(famOrder))
for i, k := range famOrder {
famItems[i] = menuItem{name: famInfo[k].name, desc: famInfo[k].desc}
}
famIdxs, err := selectMany(i18n.M.SelectProvidersLabel, famItems)
if err != nil {
return nil, err
}
var enabled []config.ProviderEntry
for _, fi := range famIdxs {
for _, mi := range famMembers[famOrder[fi]] {
enabled = append(enabled, providers[mi])
}
}
return enabled, nil
}
// providerFamily is a wizard-only grouping of provider SKUs by vendor; it does
// not exist in config because users editing reasonix.toml deal with SKU names
// directly. Keys mirror the SKU name prefix (deepseek-*, mimo) so adding a new
// preset only requires a familyOf case.
type providerFamily struct {
key string
name string
desc string
}
func familyOf(name string) providerFamily {
switch {
case strings.HasPrefix(name, "deepseek"):
return providerFamily{key: "deepseek", name: "DeepSeek", desc: "fast & cheap, plus a stronger Pro SKU"}
case strings.HasPrefix(name, "mimo"):
return providerFamily{key: "mimo", name: "MiMo (Xiaomi)", desc: "long-horizon agentic"}
default:
return providerFamily{key: name, name: name}
}
}
func groupByFamily(providers []config.ProviderEntry) ([]string, map[string][]int, map[string]providerFamily) {
var order []string
members := map[string][]int{}
info := map[string]providerFamily{}
for i, p := range providers {
f := familyOf(p.Name)
if _, seen := members[f.key]; !seen {
order = append(order, f.key)
info[f.key] = f
}
members[f.key] = append(members[f.key], i)
}
return order, members, info
}
// promptMissingKeys re-runs the wizard's key-entry step for any enabled
// provider whose api_key_env is unset. Newly entered values are appended to
// .env so the chat session that follows picks them up via config.Load. The
// user can hit Enter to skip — the chat banner falls back to a one-line
// warning so they still see what's missing. Returns a non-zero exit code only
// when writing .env fails.
func promptMissingKeys(cfg *config.Config) int {
missing := providersWithMissingKeys(cfg)
if len(missing) == 0 {
return 0
}
fmt.Println()
fmt.Println(dim(" " + i18n.M.MissingKeyIntro))
envLines := configureKeys(missing, os.Stdin, os.Stdout)
if len(envLines) == 0 {
return 0
}
if err := appendEnv(".env", envLines); err != nil {
fmt.Fprintln(os.Stderr, i18n.M.WriteEnvErr, err)
return 1
}
fmt.Printf("%s %s\n", green("✓"), fmt.Sprintf(i18n.M.WroteFileFmt, ".env"))
return 0
}
// providersWithMissingKeys returns the subset of cfg.Providers whose api_key_env
// is declared but not currently set in the environment. configureKeys dedupes
// shared envs, so duplicates are fine to leave in.
func providersWithMissingKeys(cfg *config.Config) []config.ProviderEntry {
var out []config.ProviderEntry
for _, p := range cfg.Providers {
if p.APIKeyEnv != "" && os.Getenv(p.APIKeyEnv) == "" {
out = append(out, p)
}
}
return out
}
// configureKeys asks for the API key of each distinct api_key_env among the
// selected providers — shared keys (e.g. both DeepSeek models) are asked once.
// Reads answers from r, writes prompts to w; returns KEY=value lines entered.
func configureKeys(selected []config.ProviderEntry, r io.Reader, w io.Writer) []string {
in := bufio.NewScanner(r)
fmt.Fprintln(w, "\n"+i18n.M.EnterAPIKeysHeader)
seen := map[string]bool{}
var envLines []string
for _, p := range selected {
if p.APIKeyEnv == "" || seen[p.APIKeyEnv] {
continue
}
seen[p.APIKeyEnv] = true
if key := ask(in, w, " "+p.APIKeyEnv, ""); key != "" {
envLines = append(envLines, p.APIKeyEnv+"="+key)
}
}
return envLines
}
// ask prints a prompt to w and returns the entered line, or def if input is empty.
func ask(in *bufio.Scanner, w io.Writer, label, def string) string {
if def != "" {
fmt.Fprintf(w, "%s [%s]: ", label, def)
} else {
fmt.Fprintf(w, "%s: ", label)
}
if !in.Scan() {
return def
}
if v := strings.TrimSpace(in.Text()); v != "" {
return v
}
return def
}
// isInteractive reports whether we're attached to a real terminal on both
// stdin and stdout — required for prompting. Redirected or piped I/O is not
// interactive, so wizards never block or auto-default in scripts and CI.
func isInteractive() bool {
return isTTY(os.Stdin) && isTTY(os.Stdout)
}
func isTTY(f *os.File) bool {
return term.IsTerminal(int(f.Fd()))
}
func providerNames(cfg *config.Config) string {
names := make([]string, len(cfg.Providers))
for i, p := range cfg.Providers {
names[i] = p.Name
}
return strings.Join(names, "/")
}
// appendEnv merges KEY=value lines into a .env file. Existing assignments of
// any key that's about to be written are dropped first, then the new values
// are appended — so re-running `reasonix init` with a corrected key replaces the
// stale one instead of stacking duplicates (loadDotEnv is first-wins, so a
// naive append would leave the old key in effect). The new values are also
// pinned into the current process env so a chat session started right after
// init picks up the fresh keys without a restart.
func appendEnv(path string, lines []string) error {
target := map[string]bool{}
for _, l := range lines {
if k, _, ok := strings.Cut(l, "="); ok {
target[strings.TrimSpace(k)] = true
}
}
var kept []string
if data, err := os.ReadFile(path); err == nil {
for _, raw := range strings.Split(string(data), "\n") {
trimmed := strings.TrimSpace(raw)
check := strings.TrimPrefix(trimmed, "export ")
if k, _, ok := strings.Cut(check, "="); ok && target[strings.TrimSpace(k)] {
continue
}
kept = append(kept, raw)
}
// strings.Split on a string ending with \n leaves a trailing empty
// element; trim it so we don't grow a blank line on every rewrite.
if n := len(kept); n > 0 && kept[n-1] == "" {
kept = kept[:n-1]
}
} else if !os.IsNotExist(err) {
return err
}
var b strings.Builder
for _, l := range kept {
b.WriteString(l)
b.WriteByte('\n')
}
for _, l := range lines {
b.WriteString(l)
b.WriteByte('\n')
if k, v, ok := strings.Cut(l, "="); ok {
os.Setenv(strings.TrimSpace(k), v)
}
}
return os.WriteFile(path, []byte(b.String()), 0o600)
}
// readStdin reads piped input if present; an interactive terminal yields "".
func readStdin() string {
stat, err := os.Stdin.Stat()
if err != nil || stat.Mode()&os.ModeCharDevice != 0 {
return ""
}
data, _ := io.ReadAll(os.Stdin)
return strings.TrimSpace(string(data))
}
// welcome is the zero-arg landing screen: it reports config and key readiness,
// then guides the user to the next concrete step.
func welcome(version string) int {
src := config.SourcePath()
// First run on an interactive terminal: actively guide setup rather than
// printing a static screen and exiting. interactiveInit owns the language
// prompt and welcome banner so every prompt the user sees is already
// localized to their choice.
if src == "" && isInteractive() {
if rc := interactiveInit("reasonix.toml"); rc != 0 {
return rc
}
// Config just written; reload so .env (and any pinned language) is
// picked up. If the chosen provider's key is ready, drop into chat.
if cfg, err := config.Load(); err == nil && cfg.Validate(cfg.DefaultModel) == nil {
if cfg.Language != "" {
i18n.DetectLanguage(cfg.Language)
}
fmt.Printf("\n"+i18n.M.StartingChatFmt+"\n\n", bold("reasonix chat"))
return chatREPL(nil)
}
fmt.Println("\n" + i18n.M.SetKeyHint)
return 0
}
cfg, cfgErr := config.Load()
if cfgErr != nil {
cfg = config.Default()
}
// reasonix.toml exists and parses on a terminal: go into chat. If any enabled
// provider's key isn't set yet, re-run the wizard's key-entry step inline
// — first run already chose language and providers, so we don't re-ask
// those. Skipping the prompts is still fine; the chat banner falls back to
// a one-line warning.
if src != "" && cfgErr == nil && isInteractive() {
if rc := promptMissingKeys(cfg); rc != 0 {
return rc
}
return chatREPL(nil)
}
var b strings.Builder
b.WriteString(boxed([]string{
accent("◆") + " " + bold("reasonix") + " " + dim(version),
dim(i18n.M.Subtitle),
}))
switch {
case src == "":
fmt.Fprintf(&b, "\n %s %s\n", padRight(i18n.M.ConfigLabel, 8), dim(i18n.M.ConfigNotFound))
case cfgErr != nil:
fmt.Fprintf(&b, "\n %s %s\n", padRight(i18n.M.ConfigLabel, 8), yellow(fmt.Sprintf(i18n.M.ConfigErrorFmt, src, cfgErr)))
default:
fmt.Fprintf(&b, "\n %s %s\n", padRight(i18n.M.ConfigLabel, 8), src)
}
ready := 0
for i, p := range cfg.Providers {
label := i18n.M.ModelsLabel
if i > 0 {
label = ""
}
dot, status := yellow("●"), dim(i18n.M.NoKey)
if p.APIKey() != "" {
dot, status = green("●"), green(i18n.M.Ready)
ready++
}
fmt.Fprintf(&b, " %s %s %s%s\n", padRight(label, 8), dot, padRight(p.Name, 16), status)
}
fmt.Fprintf(&b, "\n %s %s\n", accent("▌"), bold(i18n.M.GetStarted))
n := 1
step := func(cmd, desc string) {
fmt.Fprintf(&b, " %s %s %s\n", accent(fmt.Sprint(n)), padRight(cmd, 16), dim(desc))
n++
}
if src == "" {
step("reasonix init", i18n.M.StepScaffold)
}
if ready == 0 {
step(i18n.M.StepSetKey, i18n.M.StepSetKeyHint)
}
step("reasonix chat", i18n.M.StepChatDesc)
step(`reasonix run "task"`, i18n.M.StepRunDesc)
fmt.Fprintf(&b, "\n %s\n", dim(i18n.M.HelpFooter))
fmt.Print(b.String())
return 0
}
func usage() {
fmt.Print(i18n.M.UsageBody)
}
+89
View File
@@ -0,0 +1,89 @@
package cli
import (
"io"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"reasonix/internal/config"
)
// TestConfigureKeys verifies that a shared api_key_env (each vendor's SKUs use
// the same env var) is asked only once, and entered keys become env lines.
func TestConfigureKeys(t *testing.T) {
selected := config.Default().Providers // deepseek-flash, deepseek-pro, mimo-pro, mimo-flash
// Two distinct keys to enter: DEEPSEEK_API_KEY, then MIMO_API_KEY.
input := "ds-key\nmi-key\n"
env := configureKeys(selected, strings.NewReader(input), io.Discard)
if len(env) != 2 {
t.Fatalf("env = %v (want 2: DeepSeek asked once + MiMo asked once)", env)
}
if env[0] != "DEEPSEEK_API_KEY=ds-key" {
t.Errorf("env[0] = %q", env[0])
}
if env[1] != "MIMO_API_KEY=mi-key" {
t.Errorf("env[1] = %q", env[1])
}
}
// TestAppendEnvUpsertReplacesExistingKey covers the bug where re-running the
// wizard with a corrected key would append a second line for the same env
// var. loadDotEnv is first-wins, so without dedupe the stale key kept
// authenticating, and the user saw a 401 with no obvious cause.
func TestAppendEnvUpsertReplacesExistingKey(t *testing.T) {
t.Setenv("DEEPSEEK_API_KEY", "") // also covers the os.Setenv pin path
p := filepath.Join(t.TempDir(), ".env")
os.WriteFile(p, []byte("# initial\nDEEPSEEK_API_KEY=stale\nMIMO_API_KEY=keepme\n"), 0o600)
if err := appendEnv(p, []string{"DEEPSEEK_API_KEY=fresh"}); err != nil {
t.Fatalf("appendEnv: %v", err)
}
got, _ := os.ReadFile(p)
want := "# initial\nMIMO_API_KEY=keepme\nDEEPSEEK_API_KEY=fresh\n"
if string(got) != want {
t.Errorf("after upsert =\n%s\nwant =\n%s", got, want)
}
if got := os.Getenv("DEEPSEEK_API_KEY"); got != "fresh" {
t.Errorf("process env DEEPSEEK_API_KEY = %q, want %q (upsert should pin in-process)", got, "fresh")
}
}
// TestAppendEnvUpsertHandlesExportPrefix proves `export FOO=...` style lines
// also get replaced, since users might hand-edit .env in shell-friendly form.
func TestAppendEnvUpsertHandlesExportPrefix(t *testing.T) {
t.Setenv("FOO", "")
p := filepath.Join(t.TempDir(), ".env")
os.WriteFile(p, []byte("export FOO=old\nKEEP=yes\n"), 0o600)
if err := appendEnv(p, []string{"FOO=new"}); err != nil {
t.Fatalf("appendEnv: %v", err)
}
got, _ := os.ReadFile(p)
if !strings.Contains(string(got), "FOO=new") || strings.Contains(string(got), "FOO=old") {
t.Errorf("export-prefixed line not replaced:\n%s", got)
}
}
// TestGroupByFamily verifies the wizard groups the default preset into
// "deepseek" (flash + pro) and "mimo" (pro + flash), preserving the order
// each family first appears in.
func TestGroupByFamily(t *testing.T) {
order, members, info := groupByFamily(config.Default().Providers)
if got := order; !reflect.DeepEqual(got, []string{"deepseek", "mimo"}) {
t.Fatalf("family order = %v, want [deepseek mimo]", got)
}
if got := members["deepseek"]; !reflect.DeepEqual(got, []int{0, 1}) {
t.Errorf("deepseek members = %v, want [0 1]", got)
}
if got := members["mimo"]; !reflect.DeepEqual(got, []int{2, 3}) {
t.Errorf("mimo members = %v, want [2 3]", got)
}
if info["deepseek"].name != "DeepSeek" || info["mimo"].name != "MiMo (Xiaomi)" {
t.Errorf("display names = %q / %q", info["deepseek"].name, info["mimo"].name)
}
}
+320
View File
@@ -0,0 +1,320 @@
package cli
import (
"os"
"sort"
"strings"
"charm.land/lipgloss/v2"
"reasonix/internal/i18n"
)
// compKind distinguishes the two completion menus.
type compKind int
const (
compSlash compKind = iota // slash commands, when the line starts with "/"
compAt // @-references (files / MCP resources)
)
// compItem is one menu row: label shown, insert applied on accept, hint dimmed.
// descend marks a directory entry — accepting it fills the input and re-opens
// the menu one level deeper instead of closing.
type compItem struct {
label string
insert string
hint string
descend bool
}
// completion is the live autocomplete menu state. Empty value = inactive.
// replaceFrom is the byte offset in the input where the completed token starts
// (0 for a slash line, the '@' index for an @-reference).
type completion struct {
active bool
kind compKind
items []compItem
sel int
replaceFrom int
}
const (
// maxCompRows caps how many menu rows show at once; the list windows around
// the selection when longer.
maxCompRows = 8
// maxCompItems caps how many entries a single directory contributes, so a
// pathologically large directory can't blow up the menu — we read only one
// level (os.ReadDir), never the whole tree.
maxCompItems = 200
)
// slashItems is the full set of slash commands offered for completion.
func (m *chatTUI) slashItems() []compItem {
items := []compItem{
{label: "/compact", insert: "/compact ", hint: "compact context"},
{label: "/new", insert: "/new ", hint: "fork a fresh session"},
{label: "/mcp", insert: "/mcp ", hint: "MCP servers"},
{label: "/help", insert: "/help ", hint: "list commands"},
}
for _, c := range m.commands {
items = append(items, compItem{label: "/" + c.Name, insert: "/" + c.Name + " ", hint: c.Description})
}
for _, p := range m.prompts() {
items = append(items, compItem{label: "/" + p.Name, insert: "/" + p.Name + " ", hint: p.Description})
}
return items
}
// updateCompletion recomputes the menu from the current input: a slash menu
// while the line is a single "/word" token, or an @-reference menu while the
// token under the cursor is "@…".
func (m *chatTUI) updateCompletion() {
val := m.input.Value()
if strings.HasPrefix(val, "/") && !strings.ContainsAny(val, " \t\n") {
if items := filterByPrefix(m.slashItems(), val); len(items) > 0 {
m.setCompletion(compSlash, items, 0)
return
}
}
if at, token, ok := activeAtToken(val); ok {
if items := m.atItems(token); len(items) > 0 {
m.setCompletion(compAt, items, at)
return
}
}
m.completion = completion{}
}
// setCompletion installs items, preserving the selection index only while the
// same menu kind stays open.
func (m *chatTUI) setCompletion(kind compKind, items []compItem, replaceFrom int) {
sel := 0
if m.completion.active && m.completion.kind == kind && m.completion.sel < len(items) {
sel = m.completion.sel
}
m.completion = completion{active: true, kind: kind, items: items, sel: sel, replaceFrom: replaceFrom}
}
// filterByPrefix keeps items whose label starts with prefix (case-insensitive).
func filterByPrefix(items []compItem, prefix string) []compItem {
lp := strings.ToLower(prefix)
var out []compItem
for _, it := range items {
if strings.HasPrefix(strings.ToLower(it.label), lp) {
out = append(out, it)
}
}
return out
}
// activeAtToken finds the @-reference token ending at the cursor (assumed at the
// input's end). The '@' must start the line or follow whitespace, so emails
// like "a@b" don't trigger it. Returns the '@' offset and the text after it.
func activeAtToken(val string) (int, string, bool) {
for i := len(val) - 1; i >= 0; i-- {
switch val[i] {
case ' ', '\t', '\n':
return 0, "", false // hit whitespace before an '@' → no active token
case '@':
if i == 0 || val[i-1] == ' ' || val[i-1] == '\t' || val[i-1] == '\n' {
return i, val[i+1:], true
}
return 0, "", false
}
}
return 0, "", false
}
// atItems builds the @-reference menu for a token. A "server:uri" token whose
// server is connected lists that server's MCP resources; otherwise the token is
// a path and we list one directory level (never a recursive walk), plus — at the
// top level — any matching MCP resources.
func (m *chatTUI) atItems(token string) []compItem {
if i := strings.Index(token, ":"); i > 0 && m.isMCPServer(token[:i]) {
return m.resourceItems(token[:i], token[i+1:])
}
return m.fileItems(token)
}
// fileItems lists one directory level for a path token. dir is the part up to
// the last '/', frag the part after; entries of dir starting with frag are
// offered (directories descend, files complete). Hidden entries are skipped
// unless frag starts with '.'. Top-level tokens also surface MCP resources.
func (m *chatTUI) fileItems(token string) []compItem {
dir, frag := splitPathToken(token)
readDir := dir
if readDir == "" {
readDir = "."
}
entries, err := os.ReadDir(readDir)
if err != nil {
entries = nil
}
// Directories first, then files; ReadDir is already name-sorted.
sort.SliceStable(entries, func(i, j int) bool {
return entries[i].IsDir() && !entries[j].IsDir()
})
showHidden := strings.HasPrefix(frag, ".")
var items []compItem
for _, e := range entries {
name := e.Name()
if !strings.HasPrefix(name, frag) {
continue
}
if !showHidden && strings.HasPrefix(name, ".") {
continue
}
if e.IsDir() {
items = append(items, compItem{label: name + "/", insert: "@" + dir + name + "/", hint: "dir", descend: true})
} else {
items = append(items, compItem{label: name, insert: "@" + dir + name})
}
if len(items) >= maxCompItems {
break
}
}
// At the top level (still naming the first segment) MCP resources share the
// '@' namespace, so offer the matching ones too.
if !strings.Contains(token, "/") {
items = append(items, m.resourceItems("", token)...)
}
return items
}
// splitPathToken splits a path token into (dir, frag): dir keeps its trailing
// slash ("internal/" ), frag is the segment being typed.
func splitPathToken(token string) (dir, frag string) {
if i := strings.LastIndex(token, "/"); i >= 0 {
return token[:i+1], token[i+1:]
}
return "", token
}
// isMCPServer reports whether name is a connected MCP server.
func (m *chatTUI) isMCPServer(name string) bool {
if m.host == nil {
return false
}
for _, s := range m.host.ServerNames() {
if s == name {
return true
}
}
return false
}
// resourceItems lists MCP resources as @server:uri completions. When server is
// "" (top level) it matches by the whole "server:uri" prefix; otherwise it lists
// the named server's resources filtered by the uri prefix.
func (m *chatTUI) resourceItems(server, frag string) []compItem {
if m.host == nil {
return nil
}
var items []compItem
for _, r := range m.host.Resources() {
ref := r.Server + ":" + r.URI
switch {
case server == "":
if !strings.HasPrefix(ref, frag) {
continue
}
case r.Server == server:
if !strings.HasPrefix(r.URI, frag) {
continue
}
default:
continue
}
label := r.Name
if label == "" {
label = "resource"
}
items = append(items, compItem{label: "@" + ref, insert: "@" + ref, hint: label})
}
return items
}
// moveCompletion advances the selection by delta, wrapping around.
func (m *chatTUI) moveCompletion(delta int) {
n := len(m.completion.items)
if n == 0 {
return
}
m.completion.sel = ((m.completion.sel+delta)%n + n) % n
}
// acceptCompletion applies the selected item to the input. A directory descends
// (the input is filled and the menu re-opens one level deeper); anything else
// completes and closes the menu.
func (m *chatTUI) acceptCompletion() {
if m.completion.sel >= len(m.completion.items) {
m.completion = completion{}
return
}
it := m.completion.items[m.completion.sel]
val := m.input.Value()
rf := m.completion.replaceFrom
if rf > len(val) {
rf = len(val)
}
m.input.SetValue(val[:rf] + it.insert)
m.input.CursorEnd()
if it.descend {
m.updateCompletion() // re-list the directory we just descended into
return
}
m.completion = completion{}
}
var compSelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("173")).Bold(true)
// renderCompletion draws the menu above the input box: matching items, windowed
// around the selection, the current row highlighted, hints dimmed.
func (m chatTUI) renderCompletion() string {
if !m.completion.active || len(m.completion.items) == 0 {
return ""
}
items := m.completion.items
start := 0
if len(items) > maxCompRows {
start = m.completion.sel - maxCompRows/2
if start < 0 {
start = 0
}
if start > len(items)-maxCompRows {
start = len(items) - maxCompRows
}
}
end := start + maxCompRows
if end > len(items) {
end = len(items)
}
var b strings.Builder
for i := start; i < end; i++ {
it := items[i]
if i == m.completion.sel {
b.WriteString(accent("› ") + compSelStyle.Render(it.label))
} else {
b.WriteString(" " + it.label)
}
if it.hint != "" {
b.WriteString(" " + dim(it.hint))
}
b.WriteByte('\n')
}
// A key-hint footer so users discover Tab — many won't know it accepts a
// completion, let alone descends into a folder.
hint := i18n.M.CompHintSlash
if m.completion.kind == compAt {
hint = i18n.M.CompHintFile
}
b.WriteString(dim(hint))
return b.String()
}
+183
View File
@@ -0,0 +1,183 @@
package cli
import (
"os"
"path/filepath"
"testing"
"reasonix/internal/command"
)
// writeAt creates dir/rel (with parents) holding content, for fs-backed tests.
func writeAt(t *testing.T, dir, rel, content string) {
t.Helper()
p := filepath.Join(dir, rel)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func TestSlashCompletionFilterAndAccept(t *testing.T) {
m := newTestChatTUI()
m.input.SetValue("/co")
m.updateCompletion()
if !m.completion.active || m.completion.kind != compSlash {
t.Fatalf("typing /co should open the slash menu: %+v", m.completion)
}
// Only /compact matches the "/co" prefix among the built-ins.
if len(m.completion.items) != 1 || m.completion.items[0].label != "/compact" {
t.Fatalf("filter = %v, want just /compact", labels(m.completion.items))
}
m.acceptCompletion()
if got := m.input.Value(); got != "/compact " {
t.Errorf("accept should fill the input, got %q", got)
}
if m.completion.active {
t.Error("menu should close after accept")
}
}
func TestSlashCompletionIncludesCustomCommands(t *testing.T) {
m := newTestChatTUI()
m.commands = []command.Command{{Name: "review", Description: "review the diff"}}
m.input.SetValue("/re")
m.updateCompletion()
if !hasLabel(m.completion.items, "/review") {
t.Errorf("custom command should appear in completion: %v", labels(m.completion.items))
}
}
func TestCompletionClosesOnSpaceAndNonMatch(t *testing.T) {
m := newTestChatTUI()
m.input.SetValue("/compact ") // space → typing args, not naming a command
m.updateCompletion()
if m.completion.active {
t.Error("menu should close once a space is typed (now entering args)")
}
m.input.SetValue("/zzz") // no command matches
m.updateCompletion()
if m.completion.active {
t.Error("menu should close when nothing matches")
}
m.input.SetValue("hello") // not a slash line
m.updateCompletion()
if m.completion.active {
t.Error("menu should be inactive for non-slash input")
}
}
func TestMoveCompletionWraps(t *testing.T) {
m := newTestChatTUI()
m.completion = completion{active: true, kind: compSlash, items: []compItem{{label: "/a"}, {label: "/b"}, {label: "/c"}}, sel: 0}
m.moveCompletion(-1)
if m.completion.sel != 2 {
t.Errorf("up from first should wrap to last, got %d", m.completion.sel)
}
m.moveCompletion(1)
if m.completion.sel != 0 {
t.Errorf("down from last should wrap to first, got %d", m.completion.sel)
}
}
func TestActiveAtToken(t *testing.T) {
cases := []struct {
val string
wantTok string
wantOK bool
wantAt int
}{
{"@", "", true, 0},
{"look at @src/m", "src/m", true, 8},
{"@internal/agent/", "internal/agent/", true, 0},
{"[email protected]", "", false, 0}, // '@' not whitespace-preceded → not a ref
{"@foo bar", "", false, 0}, // cursor token after the space isn't an @ref
{"plain text", "", false, 0},
}
for _, c := range cases {
at, tok, ok := activeAtToken(c.val)
if ok != c.wantOK || (ok && (tok != c.wantTok || at != c.wantAt)) {
t.Errorf("activeAtToken(%q) = (%d,%q,%v), want (%d,%q,%v)", c.val, at, tok, ok, c.wantAt, c.wantTok, c.wantOK)
}
}
}
func TestSplitPathToken(t *testing.T) {
cases := []struct{ in, dir, frag string }{
{"main", "", "main"},
{"internal/age", "internal/", "age"},
{"a/b/c", "a/b/", "c"},
{"internal/", "internal/", ""},
}
for _, c := range cases {
if d, f := splitPathToken(c.in); d != c.dir || f != c.frag {
t.Errorf("splitPathToken(%q) = (%q,%q), want (%q,%q)", c.in, d, f, c.dir, c.frag)
}
}
}
// TestFileItemsOneLevel verifies @ completion lists exactly one directory level
// (no recursion): a subdir shows as a descendable entry, its contents do not.
func TestFileItemsOneLevel(t *testing.T) {
dir := t.TempDir()
writeAt(t, dir, "alpha.go", "x")
writeAt(t, dir, "sub/deep.go", "y") // creates sub/ with a file inside
writeAt(t, dir, ".hidden", "z")
m := newTestChatTUI()
items := m.fileItems(dir + "/") // token = "<tmp>/", frag = ""
if !hasLabel(items, "alpha.go") {
t.Errorf("file alpha.go should be listed: %v", labels(items))
}
if !hasLabel(items, "sub/") {
t.Errorf("subdir should be listed as 'sub/': %v", labels(items))
}
if hasLabel(items, "deep.go") {
t.Errorf("nested file deep.go must NOT be listed (one level only): %v", labels(items))
}
if hasLabel(items, ".hidden") {
t.Errorf("hidden file should be skipped unless frag starts with '.': %v", labels(items))
}
// The subdir entry must be a descend (accepting it navigates into it).
for _, it := range items {
if it.label == "sub/" && !it.descend {
t.Error("directory entry should be a descend")
}
}
}
func TestFileItemsHiddenWhenDotTyped(t *testing.T) {
dir := t.TempDir()
writeAt(t, dir, ".hidden", "z")
m := newTestChatTUI()
items := m.fileItems(dir + "/.") // frag = "." → show hidden
if !hasLabel(items, ".hidden") {
t.Errorf("hidden file should appear when frag starts with '.': %v", labels(items))
}
}
func labels(items []compItem) []string {
out := make([]string, len(items))
for i, it := range items {
out[i] = it.label
}
return out
}
func hasLabel(items []compItem, label string) bool {
for _, it := range items {
if it.label == label {
return true
}
}
return false
}
+176
View File
@@ -0,0 +1,176 @@
package cli
import (
"bytes"
"context"
"fmt"
"io"
"os"
"regexp"
"strings"
tea "charm.land/bubbletea/v2"
)
// maxFileRefBytes caps how much of an @-referenced file is injected into a
// message, so "@somehuge.log" can't blow the context window. The head is kept
// and the rest noted as truncated.
const maxFileRefBytes = 64 * 1024
// refKind distinguishes the two things an @reference can resolve to.
type refKind int
const (
refResource refKind = iota // an MCP resource: @<server>:<uri>
refFile // a local file or directory: @<path>
)
// ref is a resolved @reference found in a submitted line.
type ref struct {
kind refKind
server string // refResource
uri string // refResource
path string // refFile
raw string // the original token after '@', for labelling
}
// refTokenRe matches an @reference token: '@' then a run of non-space chars.
var refTokenRe = regexp.MustCompile(`@([^\s]+)`)
// parseRefTokens extracts the deduped, punctuation-trimmed tokens following '@'
// in a line. Pure: classification (server? file?) happens in classifyRef.
func parseRefTokens(line string) []string {
var toks []string
seen := map[string]bool{}
for _, g := range refTokenRe.FindAllStringSubmatch(line, -1) {
t := strings.TrimRight(g[1], ".,;!?)]}")
if t == "" || seen[t] {
continue
}
seen[t] = true
toks = append(toks, t)
}
return toks
}
// classifyRef decides what a token refers to. A "server:uri" token whose server
// is connected is an MCP resource; otherwise a token that names an existing path
// is a file. Anything else (an @mention, an email) is not a reference. exists is
// injected so the rule is testable without touching the filesystem.
func classifyRef(token string, known map[string]bool, exists func(string) bool) (ref, bool) {
if i := strings.Index(token, ":"); i > 0 && i+1 < len(token) && known[token[:i]] {
return ref{kind: refResource, server: token[:i], uri: token[i+1:], raw: token}, true
}
if exists(token) {
return ref{kind: refFile, path: token, raw: token}, true
}
return ref{}, false
}
// detectRefs finds the @references in a submitted line: MCP resources for
// connected servers, and local paths that exist on disk.
func (m *chatTUI) detectRefs(line string) []ref {
known := map[string]bool{}
if m.host != nil {
for _, n := range m.host.ServerNames() {
known[n] = true
}
}
exists := func(p string) bool { _, err := os.Stat(p); return err == nil }
var refs []ref
for _, tok := range parseRefTokens(line) {
if r, ok := classifyRef(tok, known, exists); ok {
refs = append(refs, r)
}
}
return refs
}
// fetchRefs resolves each reference off the event loop and returns a
// refsResolvedMsg with the concatenated, tagged content (or per-ref errors).
func (m *chatTUI) fetchRefs(line string, refs []ref) tea.Cmd {
host := m.host
return func() tea.Msg {
var b strings.Builder
var errs []string
for _, r := range refs {
switch r.kind {
case refResource:
text, err := host.ReadResource(context.Background(), r.server, r.uri)
if err != nil {
errs = append(errs, "@"+r.raw+" — "+err.Error())
continue
}
appendRefBlock(&b, "resource", `ref="@`+r.raw+`"`, text)
case refFile:
text, isDir, err := readFileRef(r.path)
if err != nil {
errs = append(errs, "@"+r.raw+" — "+err.Error())
continue
}
tag := "file"
if isDir {
tag = "dir"
}
appendRefBlock(&b, tag, `path="`+r.path+`"`, text)
}
}
return refsResolvedMsg{line: line, block: b.String(), errs: errs}
}
}
func appendRefBlock(b *strings.Builder, tag, attr, body string) {
if b.Len() > 0 {
b.WriteString("\n\n")
}
fmt.Fprintf(b, "<%s %s>\n%s\n</%s>", tag, attr, body, tag)
}
// readFileRef reads an @-referenced path for injection. A directory yields a
// one-level listing; a binary file (NUL in the first 8 KiB) is noted rather than
// dumped; a large file is truncated to maxFileRefBytes with a marker. isDir lets
// the caller pick the wrapping tag.
func readFileRef(path string) (content string, isDir bool, err error) {
info, err := os.Stat(path)
if err != nil {
return "", false, err
}
if info.IsDir() {
entries, err := os.ReadDir(path)
if err != nil {
return "", true, err
}
var b strings.Builder
for _, e := range entries {
name := e.Name()
if e.IsDir() {
name += "/"
}
b.WriteString(name)
b.WriteByte('\n')
}
return b.String(), true, nil
}
f, err := os.Open(path)
if err != nil {
return "", false, err
}
defer f.Close()
buf := make([]byte, maxFileRefBytes+1)
n, rerr := io.ReadFull(f, buf)
if rerr != nil && rerr != io.ErrUnexpectedEOF && rerr != io.EOF {
return "", false, rerr
}
data := buf[:n]
if bytes.IndexByte(data[:min(n, 8192)], 0) >= 0 {
return fmt.Sprintf("[binary file %s, %d bytes — not shown]", path, info.Size()), false, nil
}
if n > maxFileRefBytes {
return string(data[:maxFileRefBytes]) + fmt.Sprintf("\n…[truncated; file is %d bytes]…", info.Size()), false, nil
}
return string(data), false, nil
}
+57
View File
@@ -0,0 +1,57 @@
package cli
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestReadFileRef(t *testing.T) {
dir := t.TempDir()
textPath := filepath.Join(dir, "hello.txt")
if err := os.WriteFile(textPath, []byte("line one\nline two\n"), 0o644); err != nil {
t.Fatal(err)
}
binPath := filepath.Join(dir, "blob.bin")
if err := os.WriteFile(binPath, []byte{'a', 0x00, 'b'}, 0o644); err != nil {
t.Fatal(err)
}
bigPath := filepath.Join(dir, "big.txt")
if err := os.WriteFile(bigPath, []byte(strings.Repeat("a", maxFileRefBytes+100)), 0o644); err != nil {
t.Fatal(err)
}
// Text file: content verbatim, not a directory.
if got, isDir, err := readFileRef(textPath); err != nil || isDir || got != "line one\nline two\n" {
t.Errorf("text file = (%q, %v, %v)", got, isDir, err)
}
// Binary file: noted, not dumped.
if got, _, err := readFileRef(binPath); err != nil || !strings.Contains(got, "binary file") {
t.Errorf("binary file = (%q, %v), want a binary note", got, err)
}
// Large file: truncated with a marker.
if got, _, err := readFileRef(bigPath); err != nil || !strings.Contains(got, "truncated") {
t.Errorf("big file should be truncated, got len=%d err=%v", len(got), err)
}
// Directory: one-level listing including a trailing slash for subdirs.
if err := os.Mkdir(filepath.Join(dir, "sub"), 0o755); err != nil {
t.Fatal(err)
}
got, isDir, err := readFileRef(dir)
if err != nil || !isDir {
t.Fatalf("dir = (isDir=%v, err=%v)", isDir, err)
}
if !strings.Contains(got, "hello.txt") || !strings.Contains(got, "sub/") {
t.Errorf("dir listing = %q, want hello.txt and sub/", got)
}
// Missing path: error.
if _, _, err := readFileRef(filepath.Join(dir, "nope")); err == nil {
t.Error("missing path should error")
}
}
+57
View File
@@ -0,0 +1,57 @@
package cli
import (
"reflect"
"testing"
)
func TestParseRefTokens(t *testing.T) {
cases := []struct {
line string
want []string
}{
{"see @docs:doc://x and @src/main.go", []string{"docs:doc://x", "src/main.go"}},
{"trailing @file.go.", []string{"file.go"}},
{"dedup @a @a", []string{"a"}},
{"no refs here", nil},
{"email [email protected] keeps token", []string{"b.com"}},
}
for _, c := range cases {
got := parseRefTokens(c.line)
if len(got) == 0 && len(c.want) == 0 {
continue
}
if !reflect.DeepEqual(got, c.want) {
t.Errorf("parseRefTokens(%q) = %v, want %v", c.line, got, c.want)
}
}
}
func TestClassifyRef(t *testing.T) {
known := map[string]bool{"docs": true}
files := map[string]bool{"src/main.go": true, "README.md": true}
exists := func(p string) bool { return files[p] }
cases := []struct {
token string
wantOK bool
wantKnd refKind
}{
{"docs:doc://style", true, refResource}, // known server + uri
{"src/main.go", true, refFile}, // existing file
{"README.md", true, refFile}, // existing file
{"ghost:issue://1", false, 0}, // unknown server, no such file
{"missing.go", false, 0}, // nonexistent path → not a ref
{"docs:", false, 0}, // empty uri → not a resource, no file
}
for _, c := range cases {
r, ok := classifyRef(c.token, known, exists)
if ok != c.wantOK {
t.Errorf("classifyRef(%q) ok = %v, want %v", c.token, ok, c.wantOK)
continue
}
if ok && r.kind != c.wantKnd {
t.Errorf("classifyRef(%q) kind = %v, want %v", c.token, r.kind, c.wantKnd)
}
}
}
+528
View File
@@ -0,0 +1,528 @@
package cli
import (
"fmt"
"strings"
"github.com/rivo/uniseg"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
extast "github.com/yuin/goldmark/extension/ast"
"github.com/yuin/goldmark/text"
)
// mdRenderer turns the model's markdown answer into ANSI-styled terminal text
// using the brand palette. It implements only the constructs a chat-style
// model reliably emits — headings, paragraphs, lists, fenced code, blockquotes,
// strong/em/code-spans, links, thematic breaks — and degrades to plain text
// for anything else. Word-wrapping respects CJK widths and skips over ANSI
// SGR codes when counting columns.
type mdRenderer struct {
md goldmark.Markdown
width int
}
func newMarkdownRenderer(width int) *mdRenderer {
if width <= 0 {
width = 80
}
// Enable the GFM table extension so | header | rows | get parsed into
// a Table node rather than falling through as a literal text block.
return &mdRenderer{
md: goldmark.New(goldmark.WithExtensions(extension.Table)),
width: width,
}
}
func italic(s string) string {
if !colorEnabled {
return s
}
return "\033[3m" + s + "\033[0m"
}
// Render parses input as markdown and returns ANSI-styled output with a
// trailing newline. Empty input returns an empty string so callers can
// reliably distinguish "nothing to draw" from "draw a blank line".
func (r *mdRenderer) Render(input string) string {
if strings.TrimSpace(input) == "" {
return ""
}
src := []byte(input)
doc := r.md.Parser().Parse(text.NewReader(src))
var buf strings.Builder
r.renderBlocks(&buf, doc, src, 0)
out := strings.TrimRight(buf.String(), "\n")
if out == "" {
return ""
}
return out + "\n"
}
func (r *mdRenderer) renderBlocks(buf *strings.Builder, parent ast.Node, src []byte, indent int) {
for c := parent.FirstChild(); c != nil; c = c.NextSibling() {
r.renderBlock(buf, c, src, indent)
}
}
func (r *mdRenderer) renderBlock(buf *strings.Builder, node ast.Node, src []byte, indent int) {
switch n := node.(type) {
case *ast.Heading:
r.renderHeading(buf, n, src, indent)
case *ast.Paragraph:
r.renderParagraph(buf, n, src, indent)
case *ast.TextBlock:
// TextBlock is goldmark's container for tight-list-item inline content
// (no trailing blank). Treat it like a paragraph but skip the spacer.
r.renderTextBlock(buf, n, src, indent)
case *ast.List:
r.renderList(buf, n, src, indent)
case *ast.FencedCodeBlock, *ast.CodeBlock:
r.renderFenced(buf, n, src, indent)
case *ast.Blockquote:
r.renderBlockquote(buf, n, src, indent)
case *extast.Table:
r.renderTable(buf, n, src, indent)
case *ast.ThematicBreak:
w := r.width - indent
if w < 8 {
w = 8
}
buf.WriteString(strings.Repeat(" ", indent))
buf.WriteString(dim(strings.Repeat("─", w)))
buf.WriteString("\n\n")
default:
// Unknown block: drop into children rather than dropping content.
r.renderBlocks(buf, node, src, indent)
}
}
func (r *mdRenderer) renderHeading(buf *strings.Builder, n *ast.Heading, src []byte, indent int) {
inline := r.collectInline(n, src)
buf.WriteString(strings.Repeat(" ", indent))
buf.WriteString(bold(accent(inline)))
buf.WriteString("\n")
// Level-1 headings get an accent underline; deeper levels rely on
// bold+colour alone so the hierarchy reads at a glance without piling
// on visual weight on every "###" in a long response.
if n.Level == 1 {
buf.WriteString(strings.Repeat(" ", indent))
buf.WriteString(accent(strings.Repeat("─", visibleWidth(inline))))
buf.WriteString("\n")
}
buf.WriteString("\n")
}
func (r *mdRenderer) renderParagraph(buf *strings.Builder, n *ast.Paragraph, src []byte, indent int) {
r.renderInlineBlock(buf, n, src, indent, true)
}
func (r *mdRenderer) renderTextBlock(buf *strings.Builder, n *ast.TextBlock, src []byte, indent int) {
r.renderInlineBlock(buf, n, src, indent, false)
}
func (r *mdRenderer) renderInlineBlock(buf *strings.Builder, n ast.Node, src []byte, indent int, trailingBlank bool) {
inline := r.collectInline(n, src)
prefix := strings.Repeat(" ", indent)
wrapped := wrapAnsi(inline, r.width-indent)
for _, line := range strings.Split(wrapped, "\n") {
buf.WriteString(prefix)
buf.WriteString(line)
buf.WriteString("\n")
}
if trailingBlank {
buf.WriteString("\n")
}
}
func (r *mdRenderer) renderList(buf *strings.Builder, n *ast.List, src []byte, indent int) {
idx := 1
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
item, ok := c.(*ast.ListItem)
if !ok {
continue
}
var marker string
if n.IsOrdered() {
marker = fmt.Sprintf("%d.", idx)
idx++
} else {
marker = "•"
}
buf.WriteString(strings.Repeat(" ", indent))
buf.WriteString(accent(marker) + " ")
markerW := visibleWidth(marker) + 1
first := item.FirstChild()
// goldmark uses TextBlock for tight list items, Paragraph for loose
// ones; treat both as the marker-line carrier so the inline content
// lands next to the bullet either way.
inlineHost := inlineCarrier(first)
if inlineHost != nil {
inline := r.collectInline(inlineHost, src)
wrapped := wrapAnsi(inline, r.width-indent-markerW)
lines := strings.Split(wrapped, "\n")
buf.WriteString(lines[0] + "\n")
for _, l := range lines[1:] {
buf.WriteString(strings.Repeat(" ", indent+markerW))
buf.WriteString(l + "\n")
}
for s := first.NextSibling(); s != nil; s = s.NextSibling() {
r.renderBlock(buf, s, src, indent+markerW)
}
} else {
buf.WriteString("\n")
r.renderBlocks(buf, item, src, indent+2)
}
}
buf.WriteString("\n")
}
func (r *mdRenderer) renderFenced(buf *strings.Builder, n ast.Node, src []byte, indent int) {
prefix := strings.Repeat(" ", indent) + dim("│ ")
for i := 0; i < n.Lines().Len(); i++ {
l := n.Lines().At(i)
line := strings.TrimRight(string(l.Value(src)), "\n")
buf.WriteString(prefix)
buf.WriteString(accent(line))
buf.WriteString("\n")
}
buf.WriteString("\n")
}
func (r *mdRenderer) renderBlockquote(buf *strings.Builder, n *ast.Blockquote, src []byte, indent int) {
var inner strings.Builder
r.renderBlocks(&inner, n, src, 0)
prefix := strings.Repeat(" ", indent) + dim("▎ ")
for _, line := range strings.Split(strings.TrimRight(inner.String(), "\n"), "\n") {
buf.WriteString(prefix)
buf.WriteString(dim(line))
buf.WriteString("\n")
}
buf.WriteString("\n")
}
// collectInline walks an inline subtree and returns its ANSI-styled flat text.
func (r *mdRenderer) collectInline(n ast.Node, src []byte) string {
var b strings.Builder
r.appendInline(&b, n, src)
return b.String()
}
func (r *mdRenderer) appendInline(b *strings.Builder, n ast.Node, src []byte) {
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
switch v := c.(type) {
case *ast.Text:
b.Write(v.Segment.Value(src))
switch {
case v.HardLineBreak():
b.WriteByte('\n')
case v.SoftLineBreak():
b.WriteByte(' ')
}
case *ast.Emphasis:
var inner strings.Builder
r.appendInline(&inner, v, src)
if v.Level == 2 {
b.WriteString(bold(inner.String()))
} else {
b.WriteString(italic(inner.String()))
}
case *ast.CodeSpan:
var inner strings.Builder
r.appendInline(&inner, v, src)
b.WriteString(accent(inner.String()))
case *ast.Link:
var inner strings.Builder
r.appendInline(&inner, v, src)
b.WriteString(inner.String())
b.WriteString(dim(" (" + string(v.Destination) + ")"))
case *ast.AutoLink:
b.WriteString(string(v.URL(src)))
case *ast.RawHTML:
// drop — rare in chat output and would print as literal escapes
case *ast.String:
b.Write(v.Value)
default:
r.appendInline(b, c, src)
}
}
}
// renderTable lays out a GFM table as terminal columns separated by dim
// "│" rails with a "─┼─" rule under the header. Column widths auto-fit the
// widest cell in each column and are capped to a fair share of the terminal
// width so a wide table can't push the input off-screen. Long cells are
// wrapped across multiple visual rows (the whole logical row inflates to
// the tallest cell), not truncated, so no content is lost. Alignment is
// left-only — Markdown's ":---:" hints are read but not honoured yet.
func (r *mdRenderer) renderTable(buf *strings.Builder, n *extast.Table, src []byte, indent int) {
var header []string
var rows [][]string
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
switch row := c.(type) {
case *extast.TableHeader:
header = r.collectCells(row, src)
case *extast.TableRow:
rows = append(rows, r.collectCells(row, src))
}
}
if len(header) == 0 && len(rows) == 0 {
return
}
cols := len(header)
for _, row := range rows {
if len(row) > cols {
cols = len(row)
}
}
if cols == 0 {
return
}
// Initial widths fit the widest cell content per column.
widths := make([]int, cols)
pick := func(i, w int) {
if i < cols && w > widths[i] {
widths[i] = w
}
}
for i, h := range header {
pick(i, visibleWidth(h))
}
for _, row := range rows {
for i, c := range row {
pick(i, visibleWidth(c))
}
}
// Cap each column so the whole table fits the terminal: total = sum of
// widths + separators (3 chars each) + indent. Distribute the budget
// proportionally to the natural widths so columns with rich content
// keep more space than narrow ones.
available := r.width - indent - 3*(cols-1)
if available < cols*3 {
available = cols * 3
}
total := 0
for _, w := range widths {
total += w
}
if total > available {
for i := range widths {
widths[i] = widths[i] * available / total
if widths[i] < 3 {
widths[i] = 3
}
}
}
prefix := strings.Repeat(" ", indent)
sep := dim(" │ ")
if len(header) > 0 {
r.renderTableRow(buf, prefix, sep, header, widths, true)
buf.WriteString(prefix)
for i := range widths {
if i > 0 {
buf.WriteString(dim("─┼─"))
}
buf.WriteString(dim(strings.Repeat("─", widths[i])))
}
buf.WriteByte('\n')
}
for _, row := range rows {
r.renderTableRow(buf, prefix, sep, row, widths, false)
}
buf.WriteByte('\n')
}
// renderTableRow lays out one logical row across multiple visual rows when
// any cell wraps. wrapAnsi handles per-cell word + hard-break wrapping; the
// row's visual height = max wrapped lines across all cells. Cells that ran
// out of content get padded with spaces so the rail "│" stays aligned.
func (r *mdRenderer) renderTableRow(buf *strings.Builder, prefix, sep string, cells []string, widths []int, isHeader bool) {
cols := len(widths)
wrapped := make([][]string, cols)
maxLines := 1
for i := 0; i < cols; i++ {
var text string
if i < len(cells) {
text = cells[i]
}
wrapped[i] = strings.Split(wrapAnsi(text, widths[i]), "\n")
if len(wrapped[i]) > maxLines {
maxLines = len(wrapped[i])
}
}
for line := 0; line < maxLines; line++ {
buf.WriteString(prefix)
for i := 0; i < cols; i++ {
if i > 0 {
buf.WriteString(sep)
}
var cell string
if line < len(wrapped[i]) {
cell = wrapped[i][line]
}
padded := padRight(cell, widths[i])
if isHeader {
padded = bold(padded)
}
buf.WriteString(padded)
}
buf.WriteByte('\n')
}
}
// collectCells walks a TableHeader / TableRow node and pulls each TableCell's
// inline content as an ANSI-styled string. Non-cell children are ignored.
func (r *mdRenderer) collectCells(parent ast.Node, src []byte) []string {
var out []string
for c := parent.FirstChild(); c != nil; c = c.NextSibling() {
if cell, ok := c.(*extast.TableCell); ok {
out = append(out, strings.TrimSpace(r.collectInline(cell, src)))
}
}
return out
}
// inlineCarrier returns n when it's a paragraph or text-block (both hold
// inline runs), else nil. Used by list rendering so the marker line gets the
// inline content regardless of whether the list is tight or loose.
func inlineCarrier(n ast.Node) ast.Node {
switch n.(type) {
case *ast.Paragraph, *ast.TextBlock:
return n
}
return nil
}
// wrapAnsi wraps text to width columns. ANSI SGR escapes are treated as zero-
// width when counting, and CJK runes count as two columns (matches the box
// helper). Lines already split with `\n` keep their breaks. A single "word"
// wider than width is hard-broken at character boundaries — this is how CJK
// text (no inter-word spaces) reflows correctly.
func wrapAnsi(text string, width int) string {
if width < 4 {
width = 4
}
var out strings.Builder
for li, line := range strings.Split(text, "\n") {
if li > 0 {
out.WriteByte('\n')
}
words := splitWords(line)
curW := 0
for _, w := range words {
wWidth := visibleWidth(w)
// Word doesn't fit and is too wide to fit alone — hard-break it
// at character boundaries. This is the path CJK takes (no spaces
// to break on).
if wWidth > width {
if curW > 0 {
out.WriteByte('\n')
curW = 0
}
chunks := chunkByWidth(w, width)
for ci, ch := range chunks {
if ci > 0 {
out.WriteByte('\n')
}
out.WriteString(ch)
}
curW = visibleWidth(chunks[len(chunks)-1])
continue
}
if curW == 0 {
out.WriteString(w)
curW = wWidth
} else if curW+1+wWidth > width {
out.WriteByte('\n')
out.WriteString(w)
curW = wWidth
} else {
out.WriteByte(' ')
out.WriteString(w)
curW += 1 + wWidth
}
}
}
return out.String()
}
// chunkByWidth splits s into pieces each at most `width` visible columns,
// preserving ANSI SGR sequences (which contribute no width). It breaks on
// *grapheme cluster* boundaries (via uniseg), so an emoji ZWJ sequence, keycap,
// or flag is never split across a line and its width is counted as one unit —
// a rune-by-rune break would both miscount and tear the cluster apart. Used to
// hard-break a long word, typically CJK or emoji-dense text, when wrapping.
func chunkByWidth(s string, width int) []string {
var chunks []string
var cur strings.Builder
curW := 0
state := -1
for i := 0; i < len(s); {
// ANSI SGR escapes carry no width and must stay intact: copy verbatim
// and reset the grapheme state (an escape ends any pending cluster).
if s[i] == 0x1b {
j := i + 1
for j < len(s) && s[j] != 'm' {
j++
}
if j < len(s) {
j++ // include the terminating 'm'
}
cur.WriteString(s[i:j])
i = j
state = -1
continue
}
cluster, _, w, newState := uniseg.FirstGraphemeClusterInString(s[i:], state)
if curW+w > width && cur.Len() > 0 {
chunks = append(chunks, cur.String())
cur.Reset()
curW = 0
}
cur.WriteString(cluster)
curW += w
i += len(cluster)
state = newState
}
if cur.Len() > 0 {
chunks = append(chunks, cur.String())
}
return chunks
}
// splitWords splits s on whitespace while keeping any ANSI SGR escape inside
// the word that contains it. Words that begin with an escape (e.g. styled
// punctuation) keep the escape attached to the next visible run.
func splitWords(s string) []string {
var out []string
var cur strings.Builder
inEsc := false
for _, r := range s {
if r == 0x1b {
inEsc = true
}
if r == ' ' && !inEsc {
if cur.Len() > 0 {
out = append(out, cur.String())
cur.Reset()
}
continue
}
cur.WriteRune(r)
if inEsc && r == 'm' {
inEsc = false
}
}
if cur.Len() > 0 {
out = append(out, cur.String())
}
return out
}
+129
View File
@@ -0,0 +1,129 @@
package cli
import (
"strings"
"testing"
)
// TestRenderEmpty covers the contract that empty / whitespace-only input
// returns "" — callers rely on this to skip a redraw when there's nothing
// substantive to show.
func TestRenderEmpty(t *testing.T) {
r := newMarkdownRenderer(80)
for _, in := range []string{"", " ", "\n", "\t\n \n"} {
if got := r.Render(in); got != "" {
t.Errorf("Render(%q) = %q, want empty", in, got)
}
}
}
// TestRenderConstructsRound-trip checks each major construct emits something
// styled while preserving the underlying text. We don't assert exact ANSI
// sequences (palette could shift) — only that key visible text survives and
// that we don't degrade to literal markdown.
func TestRenderConstructs(t *testing.T) {
r := newMarkdownRenderer(80)
cases := []struct {
name string
in string
contains []string
notRaw []string // substrings that must NOT appear (raw markdown leaking through)
}{
{
name: "heading",
in: "# Hello\n",
contains: []string{"Hello"},
notRaw: []string{"# Hello", "## "},
},
{
name: "heading h2 drops prefix",
in: "## Section\n",
contains: []string{"Section"},
notRaw: []string{"## ", "###"},
},
{
name: "bold",
in: "this is **important** text",
contains: []string{"important", "this is", "text"},
},
{
name: "italic",
in: "see *here* for details",
contains: []string{"here", "see", "for details"},
},
{
name: "code span",
in: "use `os.Setenv` to set",
contains: []string{"os.Setenv", "use", "to set"},
},
{
name: "unordered list",
in: "- one\n- two\n- three\n",
contains: []string{"one", "two", "three", "•"},
},
{
name: "ordered list",
in: "1. first\n2. second\n",
contains: []string{"first", "second", "1.", "2."},
},
{
name: "fenced code",
in: "```go\nfunc main() {}\n```\n",
contains: []string{"func main()"},
},
{
name: "thematic break",
in: "above\n\n---\n\nbelow",
contains: []string{"above", "below", "─"},
},
{
name: "gfm table",
in: "| name | size |\n|------|------|\n| a | 12 |\n| bb | 345 |\n",
contains: []string{"name", "size", "a", "12", "bb", "345", "│"},
notRaw: []string{"|------|"}, // raw separator must be transformed
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out := r.Render(tc.in)
for _, want := range tc.contains {
if !strings.Contains(out, want) {
t.Errorf("Render(%q) missing %q\n--- output ---\n%s", tc.in, want, out)
}
}
for _, leak := range tc.notRaw {
if strings.Contains(out, leak) {
t.Errorf("Render(%q) leaked raw markdown %q", tc.in, leak)
}
}
})
}
}
// TestWrapAnsiCJK proves the wrap counter treats CJK as 2 cols, so a line of
// Chinese characters wraps at half the column count.
func TestWrapAnsiCJK(t *testing.T) {
// Width 10 = room for 5 Chinese characters per row.
in := strings.Repeat("中", 8)
out := wrapAnsi(in, 10)
lines := strings.Split(out, "\n")
if len(lines) < 2 {
t.Fatalf("expected wrap, got 1 line: %q", out)
}
if visibleWidth(lines[0]) > 10 {
t.Errorf("first line exceeds width: %d > 10", visibleWidth(lines[0]))
}
}
// TestSplitWordsKeepsAnsiAttached ensures word boundaries don't split through
// an ANSI escape that styles a single word.
func TestSplitWordsKeepsAnsiAttached(t *testing.T) {
in := "alpha \x1b[1mbeta\x1b[0m gamma"
got := splitWords(in)
if len(got) != 3 {
t.Fatalf("words = %v (want 3)", got)
}
if !strings.Contains(got[1], "beta") || !strings.Contains(got[1], "\x1b[1m") {
t.Errorf("styled word lost its escape: %q", got[1])
}
}
+165
View File
@@ -0,0 +1,165 @@
package cli
import (
"errors"
"fmt"
"os"
"golang.org/x/term"
"reasonix/internal/i18n"
)
// errCancelled is returned by selectOne when the user aborts (q or Ctrl-C).
var errCancelled = errors.New("selection cancelled")
type menuItem struct {
name string
desc string
}
// selectOne renders an interactive single-choice menu navigated with the arrow
// keys (or j/k), confirmed with Enter, aborted with q or Ctrl-C. It puts the
// terminal in raw mode, so it requires a TTY (callers gate on isInteractive).
func selectOne(label string, items []menuItem) (int, error) {
fd := int(os.Stdin.Fd())
old, err := term.MakeRaw(fd)
if err != nil {
return 0, err
}
defer term.Restore(fd, old)
w := os.Stdout
fmt.Fprintf(w, "%s %s %s\r\n\r\n", accent("▌"), bold(label), dim(i18n.M.SelectOneHint))
sel := 0
render := func() {
for i, it := range items {
name := fmt.Sprintf("%-10s", it.name)
if i == sel {
fmt.Fprintf(w, "\r\033[K%s\r\n", reverse(fmt.Sprintf(" ❯ %s %s ", name, it.desc)))
} else {
fmt.Fprintf(w, "\r\033[K %s %s\r\n", name, dim(it.desc))
}
}
}
render()
buf := make([]byte, 8)
for {
n, err := os.Stdin.Read(buf)
if err != nil {
return 0, err
}
k := buf[:n]
switch {
case k[0] == '\r' || k[0] == '\n':
fmt.Fprint(w, "\r\n")
return sel, nil
case k[0] == 3 || k[0] == 'q': // Ctrl-C or q
fmt.Fprint(w, "\r\n")
return 0, errCancelled
case len(k) >= 3 && k[0] == 27 && k[1] == '[' && k[2] == 'A': // up
if sel > 0 {
sel--
}
case len(k) >= 3 && k[0] == 27 && k[1] == '[' && k[2] == 'B': // down
if sel < len(items)-1 {
sel++
}
case k[0] == 'k':
if sel > 0 {
sel--
}
case k[0] == 'j':
if sel < len(items)-1 {
sel++
}
default:
continue // ignore other keys, no redraw
}
fmt.Fprintf(w, "\033[%dA", len(items)) // move back up to the first item
render()
}
}
// selectMany renders an interactive multi-choice menu: arrow keys (or j/k) move,
// Space toggles, Enter confirms (at least one required), q/Ctrl-C aborts. It
// returns the checked indices in order and requires a TTY.
func selectMany(label string, items []menuItem) ([]int, error) {
fd := int(os.Stdin.Fd())
old, err := term.MakeRaw(fd)
if err != nil {
return nil, err
}
defer term.Restore(fd, old)
w := os.Stdout
fmt.Fprintf(w, "%s %s %s\r\n\r\n", accent("▌"), bold(label), dim(i18n.M.SelectManyHint))
cur := 0
checked := make([]bool, len(items))
render := func() {
for i, it := range items {
box := "[ ]"
if checked[i] {
box = "[x]"
}
name := fmt.Sprintf("%-14s", it.name)
if i == cur {
fmt.Fprintf(w, "\r\033[K%s\r\n", reverse(fmt.Sprintf(" ❯ %s %s %s ", box, name, it.desc)))
} else {
fmt.Fprintf(w, "\r\033[K %s %s %s\r\n", box, name, dim(it.desc))
}
}
}
render()
buf := make([]byte, 8)
for {
n, err := os.Stdin.Read(buf)
if err != nil {
return nil, err
}
k := buf[:n]
switch {
case k[0] == '\r' || k[0] == '\n':
var out []int
for i, c := range checked {
if c {
out = append(out, i)
}
}
if len(out) == 0 {
continue // need at least one selection
}
fmt.Fprint(w, "\r\n")
return out, nil
case k[0] == 3 || k[0] == 'q':
fmt.Fprint(w, "\r\n")
return nil, errCancelled
case k[0] == ' ':
checked[cur] = !checked[cur]
case len(k) >= 3 && k[0] == 27 && k[1] == '[' && k[2] == 'A':
if cur > 0 {
cur--
}
case len(k) >= 3 && k[0] == 27 && k[1] == '[' && k[2] == 'B':
if cur < len(items)-1 {
cur++
}
case k[0] == 'k':
if cur > 0 {
cur--
}
case k[0] == 'j':
if cur < len(items)-1 {
cur++
}
default:
continue
}
fmt.Fprintf(w, "\033[%dA", len(items))
render()
}
}
+28
View File
@@ -0,0 +1,28 @@
package cli
import (
"testing"
"reasonix/internal/command"
)
func TestChatCommandLookupAndNames(t *testing.T) {
m := chatTUI{commands: []command.Command{{Name: "review"}, {Name: "git:commit"}}}
if _, ok := m.lookupCommand("review"); !ok {
t.Error("review should be found")
}
if _, ok := m.lookupCommand("git:commit"); !ok {
t.Error("git:commit should be found")
}
if _, ok := m.lookupCommand("missing"); ok {
t.Error("missing should not be found")
}
if got := m.commandNames(); got != "/review · /git:commit" {
t.Errorf("commandNames = %q", got)
}
if got := (&chatTUI{}).commandNames(); got != "" {
t.Errorf("empty commandNames = %q, want \"\"", got)
}
}
+49
View File
@@ -0,0 +1,49 @@
package cli
import (
"os"
"golang.org/x/term"
)
// colorEnabled is decided once at startup: only colorize when writing to a real
// terminal and the user hasn't opted out via NO_COLOR (https://no-color.org) or
// a dumb TERM. Piped/redirected output and CI stay plain so scripts aren't broken.
var colorEnabled = detectColor()
func detectColor() bool {
if os.Getenv("NO_COLOR") != "" {
return false
}
if os.Getenv("TERM") == "dumb" {
return false
}
return term.IsTerminal(int(os.Stdout.Fd()))
}
const (
ansiReset = "\033[0m"
ansiBold = "\033[1m"
ansiDim = "\033[2m"
ansiGreen = "\033[32m"
ansiYellow = "\033[33m"
ansiReverse = "\033[7m"
// ansiAccent is reasonix's brand colour: a warm copper (xterm-256 #173) chosen
// to read on both dark and light terminals. It marks titles, prompts, and
// box borders so the eye finds the same hue every time.
ansiAccent = "\033[38;5;173m"
)
func sgr(code, s string) string {
if !colorEnabled {
return s
}
return code + s + ansiReset
}
func bold(s string) string { return sgr(ansiBold, s) }
func dim(s string) string { return sgr(ansiDim, s) }
func green(s string) string { return sgr(ansiGreen, s) }
func yellow(s string) string { return sgr(ansiYellow, s) }
func accent(s string) string { return sgr(ansiAccent, s) }
func reverse(s string) string { return sgr(ansiReverse, s) }
+60
View File
@@ -0,0 +1,60 @@
package cli
import (
"strings"
"testing"
)
func TestVisibleWidthGraphemeClusters(t *testing.T) {
cases := []struct {
name string
s string
want int
}{
{"ascii", "abc", 3},
{"cjk", "中文", 4},
{"emoji", "🔥", 2},
// uniseg measures the keycap sequence as 1 (terminals disagree on VS16
// width — this is the library's UAX#11 choice, and we follow it).
{"keycap", "1️⃣", 1},
// The regression that motivated the switch: a ZWJ family is one cluster
// occupying one emoji's width, not the rune-by-rune sum (which was 8).
{"zwj-family", "👨‍👩‍👧‍👦", 2},
{"ansi-stripped", "\x1b[31mab\x1b[0m", 2},
{"mixed", "a中🔥", 5},
}
for _, c := range cases {
if got := visibleWidth(c.s); got != c.want {
t.Errorf("%s: visibleWidth(%q) = %d, want %d", c.name, c.s, got, c.want)
}
}
}
func TestChunkByWidthKeepsClustersIntact(t *testing.T) {
// A ZWJ family (width 2) fills a width-2 line; the trailing ascii spills to
// the next chunk. The cluster must never be torn across chunks.
family := "👨‍👩‍👧‍👦"
chunks := chunkByWidth(family+"x", 2)
if len(chunks) != 2 || chunks[0] != family || chunks[1] != "x" {
t.Fatalf("family split wrong: %q (want [%q x])", chunks, family)
}
// CJK hard-breaks at the column boundary.
cjk := chunkByWidth("中文字", 4)
if len(cjk) != 2 || cjk[0] != "中文" || cjk[1] != "字" {
t.Errorf("cjk chunks = %q, want [中文 字]", cjk)
}
// ANSI SGR escapes are preserved verbatim and counted as zero width, so the
// two visible chars fit a width-2 line on one chunk with styling intact.
styled := chunkByWidth("\x1b[31mab\x1b[0m", 2)
if len(styled) != 1 || styled[0] != "\x1b[31mab\x1b[0m" {
t.Errorf("styled chunks = %q, want one styled chunk", styled)
}
// Every chunk stays within the column budget.
for _, ch := range chunkByWidth(strings.Repeat("中", 10), 6) {
if visibleWidth(ch) > 6 {
t.Errorf("chunk %q exceeds width 6 (got %d)", ch, visibleWidth(ch))
}
}
}
+137
View File
@@ -0,0 +1,137 @@
// Package command loads custom slash commands from Markdown files. A command is
// a prompt template: invoking /name substitutes the arguments into the body and
// sends the result as a chat turn. Loading is pure and dependency-free — a small
// "key: value" frontmatter parser keeps Reasonix's single-(TOML)-dependency promise
// rather than pulling in a YAML library.
package command
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
)
// Command is a custom slash command loaded from a .md file.
type Command struct {
Name string // "review" or "git:commit", derived from the file path
Description string // from frontmatter
ArgHint string // from frontmatter (argument-hint)
Body string // template with $ARGUMENTS / $1..$N / $$
Source string // originating file path, for diagnostics
}
// substRe matches the substitution tokens recognised in a command body.
var substRe = regexp.MustCompile(`\$(\$|ARGUMENTS|[0-9]+)`)
// Render substitutes args into the command body: $ARGUMENTS is all args joined
// by spaces, $1..$N are positional (empty when absent), and $$ is a literal $.
func (c Command) Render(args []string) string {
return substRe.ReplaceAllStringFunc(c.Body, func(m string) string {
switch tok := m[1:]; tok {
case "$":
return "$"
case "ARGUMENTS":
return strings.Join(args, " ")
default:
n, _ := strconv.Atoi(tok) // regex guarantees digits
if n >= 1 && n <= len(args) {
return args[n-1]
}
return ""
}
})
}
// Load reads every *.md command file under each dir, in order, so a later dir
// overrides an earlier one on a name clash (pass the user dir first, project
// dir last). Missing dirs are skipped. Individual file failures are collected
// into the returned error but don't prevent the others from loading. The result
// is sorted by name.
func Load(dirs ...string) ([]Command, error) {
byName := map[string]Command{}
var errs []string
for _, dir := range dirs {
root, err := filepath.Abs(dir)
if err != nil {
continue
}
_ = filepath.WalkDir(root, func(path string, d fs.DirEntry, werr error) error {
if werr != nil {
return nil // missing dir / unreadable entry: skip, don't abort
}
if d.IsDir() || !strings.EqualFold(filepath.Ext(d.Name()), ".md") {
return nil
}
c, perr := parseFile(root, path)
if perr != nil {
errs = append(errs, perr.Error())
return nil
}
byName[c.Name] = c
return nil
})
}
cmds := make([]Command, 0, len(byName))
for _, c := range byName {
cmds = append(cmds, c)
}
sort.Slice(cmds, func(i, j int) bool { return cmds[i].Name < cmds[j].Name })
if len(errs) > 0 {
return cmds, fmt.Errorf("command load: %s", strings.Join(errs, "; "))
}
return cmds, nil
}
// parseFile reads one command file and derives its name from the path relative
// to root: drop the .md suffix and turn subdirectories into ":" namespaces
// (git/commit.md → git:commit).
func parseFile(root, path string) (Command, error) {
b, err := os.ReadFile(path)
if err != nil {
return Command{}, err
}
rel, err := filepath.Rel(root, path)
if err != nil {
rel = filepath.Base(path)
}
name := strings.ReplaceAll(strings.TrimSuffix(filepath.ToSlash(rel), ".md"), "/", ":")
// Normalise line endings and strip a leading UTF-8 BOM if present.
content := strings.TrimPrefix(strings.ReplaceAll(string(b), "\r\n", "\n"), string(rune(0xFEFF)))
fm, body := splitFrontmatter(content)
return Command{
Name: name,
Description: fm["description"],
ArgHint: fm["argument-hint"],
Body: strings.TrimSpace(body),
Source: path,
}, nil
}
// splitFrontmatter separates an optional leading ---fenced block of simple
// "key: value" lines from the body. Returns the parsed keys (lowercased) and the
// remaining body. With no opening/closing fence, the whole input is the body.
func splitFrontmatter(s string) (map[string]string, string) {
fm := map[string]string{}
lines := strings.Split(s, "\n")
if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
return fm, s
}
for i := 1; i < len(lines); i++ {
if strings.TrimSpace(lines[i]) != "---" {
continue
}
for _, l := range lines[1:i] {
if k, v, ok := strings.Cut(l, ":"); ok {
fm[strings.ToLower(strings.TrimSpace(k))] = strings.Trim(strings.TrimSpace(v), `"'`)
}
}
return fm, strings.Join(lines[i+1:], "\n")
}
return fm, s // opened but never closed: treat all as body
}
+91
View File
@@ -0,0 +1,91 @@
package command
import (
"os"
"path/filepath"
"testing"
)
func TestRender(t *testing.T) {
c := Command{Body: "Review $1 focusing on $ARGUMENTS. Cost: $$5. Missing: [$3]"}
got := c.Render([]string{"main.go", "bugs"})
want := "Review main.go focusing on main.go bugs. Cost: $5. Missing: []"
if got != want {
t.Errorf("Render = %q, want %q", got, want)
}
// No args: $ARGUMENTS and $N collapse to empty.
if got := (Command{Body: "x=$ARGUMENTS y=$1"}).Render(nil); got != "x= y=" {
t.Errorf("empty-args Render = %q", got)
}
}
func write(t *testing.T, dir, rel, content string) {
t.Helper()
p := filepath.Join(dir, rel)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func TestLoad(t *testing.T) {
dir := t.TempDir()
write(t, dir, "review.md", "---\ndescription: Review the diff\nargument-hint: [area]\n---\nReview, focus on $ARGUMENTS.")
write(t, dir, "plain.md", "No frontmatter, just $1.")
write(t, dir, "git/commit.md", "---\ndescription: Commit\n---\nWrite a commit message.")
write(t, dir, "notes.txt", "ignored — not markdown")
cmds, err := Load(dir)
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(cmds) != 3 {
t.Fatalf("loaded %d commands, want 3 (%v)", len(cmds), names(cmds))
}
byName := map[string]Command{}
for _, c := range cmds {
byName[c.Name] = c
}
r, ok := byName["review"]
if !ok || r.Description != "Review the diff" || r.ArgHint != "[area]" {
t.Errorf("review parsed wrong: %+v", r)
}
if r.Body != "Review, focus on $ARGUMENTS." {
t.Errorf("review body = %q", r.Body)
}
if p := byName["plain"]; p.Body != "No frontmatter, just $1." || p.Description != "" {
t.Errorf("plain parsed wrong: %+v", p)
}
if _, ok := byName["git:commit"]; !ok {
t.Errorf("subdir namespacing failed: %v", names(cmds))
}
}
func TestLoadOverrideAndMissingDir(t *testing.T) {
user := t.TempDir()
project := t.TempDir()
write(t, user, "review.md", "USER version")
write(t, project, "review.md", "PROJECT version")
// Later dir (project) wins on a name clash; a non-existent dir is skipped.
cmds, err := Load("/no/such/dir", user, project)
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(cmds) != 1 || cmds[0].Body != "PROJECT version" {
t.Errorf("override failed: %+v", cmds)
}
}
func names(cmds []Command) []string {
out := make([]string, len(cmds))
for i, c := range cmds {
out[i] = c.Name
}
return out
}
+339
View File
@@ -0,0 +1,339 @@
// Package config loads Reasonix's runtime configuration from TOML. Resolution order:
// flag > project ./reasonix.toml > user ~/.config/reasonix/config.toml > built-in defaults.
// Secrets come from the environment via api_key_env and are never stored in
// config files.
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/BurntSushi/toml"
"reasonix/internal/provider"
)
// Config is Reasonix's runtime configuration.
type Config struct {
DefaultModel string `toml:"default_model"`
Language string `toml:"language"` // ui language tag (e.g. "zh"); empty = auto-detect from $LANG / $REASONIX_LANG
Agent AgentConfig `toml:"agent"`
Providers []ProviderEntry `toml:"providers"`
Tools ToolsConfig `toml:"tools"`
Permissions PermissionsConfig `toml:"permissions"`
Sandbox SandboxConfig `toml:"sandbox"`
Plugins []PluginEntry `toml:"plugins"`
}
// SandboxConfig bounds the blast radius of tool calls (Phase 0: file-writer
// confinement). WorkspaceRoot is the directory the built-in file writers
// (write_file / edit_file / multi_edit) may modify; empty means the current
// working directory, so writes stay inside the project by default. AllowWrite
// lists extra directories writers may also touch (e.g. a sibling repo or a temp
// dir). Both support ${VAR} / ${VAR:-default} expansion. Reads are unrestricted;
// confining `bash` is Phase 1 (OS-level sandbox).
type SandboxConfig struct {
WorkspaceRoot string `toml:"workspace_root"`
AllowWrite []string `toml:"allow_write"`
// Bash is the OS-sandbox mode for the bash tool: "enforce" (default) jails
// each command, "off" runs it unconfined. Phase 1; macOS only for now, with
// a graceful fallback elsewhere (see internal/sandbox).
Bash string `toml:"bash"`
// Network allows network egress from inside the bash sandbox. Defaults true
// so module/package downloads keep working; the boundary is then writes.
Network bool `toml:"network"`
}
// WriteRoots returns the directories file-writer tools may modify: the
// workspace root (defaulting to the current working directory when unset) plus
// any AllowWrite extras, with ${VAR} expanded. The roots are returned as given
// (relative or absolute); the confiner resolves them to absolute, symlink-free
// paths. The result is always non-empty, so confinement is on by default.
func (c *Config) WriteRoots() []string {
root := ExpandVars(c.Sandbox.WorkspaceRoot)
if root == "" {
if wd, err := os.Getwd(); err == nil {
root = wd
} else {
root = "."
}
}
roots := []string{root}
for _, d := range c.Sandbox.AllowWrite {
if d = ExpandVars(d); d != "" {
roots = append(roots, d)
}
}
return roots
}
// BashMode normalises the bash-sandbox mode: only an explicit "off" disables
// it; empty or any other value resolves to "enforce", so the sandbox is on by
// default and fails safe.
func (c *Config) BashMode() string {
if c.Sandbox.Bash == "off" {
return "off"
}
return "enforce"
}
// AgentConfig configures the harness loop. PlannerModel is optional: when set
// to another provider's name it enables two-model collaboration, where the
// planner handles low-frequency planning in its own session (kept separate so
// each model's prompt prefix stays cache-stable).
type AgentConfig struct {
SystemPrompt string `toml:"system_prompt"`
SystemPromptFile string `toml:"system_prompt_file"`
MaxSteps int `toml:"max_steps"`
Temperature float64 `toml:"temperature"`
PlannerModel string `toml:"planner_model"`
}
// ProviderEntry declares a model provider instance. ContextWindow is the model's
// token budget; the harness compacts older history as a turn's prompt approaches
// it (see agent compaction). 0 disables compaction for the instance.
type ProviderEntry struct {
Name string `toml:"name"`
Kind string `toml:"kind"`
BaseURL string `toml:"base_url"`
Model string `toml:"model"`
APIKeyEnv string `toml:"api_key_env"`
ContextWindow int `toml:"context_window"`
Price *provider.Pricing `toml:"price"`
}
// ToolsConfig selects which built-in tools are enabled. Empty means all of them.
type ToolsConfig struct {
Enabled []string `toml:"enabled"`
}
// PermissionsConfig declares the per-call permission policy (see
// internal/permission). Mode is the fallback decision for writer tools when no
// rule matches ("ask" | "allow" | "deny"; default "ask"); read-only tools always
// fall back to allow. Allow/Ask/Deny are rule lists of the form "ToolName" or
// "ToolName(glob)". Precedence: deny > ask > allow > fallback.
type PermissionsConfig struct {
Mode string `toml:"mode"`
Allow []string `toml:"allow"`
Ask []string `toml:"ask"`
Deny []string `toml:"deny"`
}
// PluginEntry declares an external MCP server. Type selects the transport:
// "stdio" (default) launches Command/Args/Env as a subprocess; "http"
// (a.k.a. streamable-http) and "sse" connect to a remote URL with optional
// static Headers. String fields support ${VAR} / ${VAR:-default} expansion so
// secrets (bearer tokens, keys) come from the environment, not the file. The
// fields mirror Claude Code's mcpServers spec, so entries can come from either
// reasonix.toml's [[plugins]] or a project-root .mcp.json (see loadMCPJSON).
type PluginEntry struct {
Name string `toml:"name"`
Type string `toml:"type"` // "stdio" (default) | "http" | "sse"
Command string `toml:"command"`
Args []string `toml:"args"`
Env map[string]string `toml:"env"`
URL string `toml:"url"`
Headers map[string]string `toml:"headers"`
}
// DefaultSystemPrompt is used when config provides none.
const DefaultSystemPrompt = `You are Reasonix, a coding agent focused on executing code tasks.
Use the provided tools to read and write files and run shell commands.
Principles: understand the request before acting; verify with tools instead of
guessing; keep changes minimal and correct; briefly summarize what you did.`
// Default returns the built-in default configuration (DeepSeek + MiMo presets).
func Default() *Config {
return &Config{
DefaultModel: "deepseek-flash",
Agent: AgentConfig{
SystemPrompt: DefaultSystemPrompt,
MaxSteps: 25,
},
// Mode "ask" with no rules keeps `reasonix run` autonomous (no TTY → ask
// resolves to allow) while `reasonix chat` prompts before writers. Users add
// deny/allow rules to harden or quiet specific tools.
Permissions: PermissionsConfig{Mode: "ask"},
// Sandbox on by default: bash is jailed (macOS), network allowed so
// builds/downloads work. Set bash = "off" to disable. Network=true here
// so an absent [sandbox] in a user's file keeps egress (zero value would
// wrongly deny it).
Sandbox: SandboxConfig{Bash: "enforce", Network: true},
Providers: []ProviderEntry{
{Name: "deepseek-flash", Kind: "openai", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY", ContextWindow: 1_000_000, Price: &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: "¥"}},
{Name: "deepseek-pro", Kind: "openai", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-pro", APIKeyEnv: "DEEPSEEK_API_KEY", ContextWindow: 1_000_000, Price: &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: "¥"}},
{Name: "mimo-pro", Kind: "openai", BaseURL: "https://api.xiaomimimo.com/v1", Model: "mimo-v2.5-pro", APIKeyEnv: "MIMO_API_KEY", ContextWindow: 1_000_000},
{Name: "mimo-flash", Kind: "openai", BaseURL: "https://api.xiaomimimo.com/v1", Model: "mimo-v2-flash", APIKeyEnv: "MIMO_API_KEY", ContextWindow: 65_536},
},
}
}
// Load builds the configuration: defaults, then user config, then project
// config, then any MCP servers from Claude Code's .mcp.json. A .env in the
// working directory is loaded first so api_key_env can resolve.
func Load() (*Config, error) {
loadDotEnv()
cfg := Default()
if uc := userConfigPath(); uc != "" {
if err := mergeFile(cfg, uc); err != nil {
return nil, err
}
}
if err := mergeFile(cfg, "reasonix.toml"); err != nil {
return nil, err
}
// Claude Code's .mcp.json (project root) is read last and merged into
// [[plugins]], so a server configured for Claude works here unchanged.
// reasonix.toml wins on a name collision (see mergeMCPJSON).
entries, err := loadMCPJSON(mcpJSONFile)
if err != nil {
return nil, err
}
cfg.mergeMCPJSON(entries)
return cfg, nil
}
// LoadForEdit returns a config to seed the `reasonix init` wizard when reconfiguring:
// the built-in defaults with the file at path (if present) decoded on top, so a
// reconfigure preserves the user's existing providers and agent settings instead
// of resetting to defaults. .env is loaded so api_key_env resolution works while
// the wizard decides which keys are still missing.
func LoadForEdit(path string) *Config {
loadDotEnv()
cfg := Default()
_ = mergeFile(cfg, path)
return cfg
}
// mergeFile decodes a TOML file onto cfg if it exists. An absent file is not an error.
func mergeFile(cfg *Config, path string) error {
if _, err := os.Stat(path); err != nil {
return nil
}
if _, err := toml.DecodeFile(path, cfg); err != nil {
return fmt.Errorf("config %s: %w", path, err)
}
return nil
}
func userConfigPath() string {
dir, err := os.UserConfigDir()
if err != nil {
return ""
}
return filepath.Join(dir, "reasonix", "config.toml")
}
// ArchiveDir is where compacted conversation history is archived for
// traceability (one timestamped .jsonl per compaction). Empty if the user config
// directory cannot be resolved, in which case archiving is skipped.
func ArchiveDir() string {
dir, err := os.UserConfigDir()
if err != nil {
return ""
}
return filepath.Join(dir, "reasonix", "archive")
}
// SessionDir is where chat sessions are persisted (one .jsonl per session).
// Used by `reasonix chat --continue` / `--resume` to find the recent ones. Empty
// if the user config dir can't be resolved — sessions then aren't saved.
func SessionDir() string {
dir, err := os.UserConfigDir()
if err != nil {
return ""
}
return filepath.Join(dir, "reasonix", "sessions")
}
// CommandDirs returns the directories scanned for custom slash commands, lowest
// priority first: the user dir (~/.config/reasonix/commands) then the project dir
// (.reasonix/commands), so a project command overrides a user one with the same name.
func CommandDirs() []string {
var dirs []string
if dir, err := os.UserConfigDir(); err == nil {
dirs = append(dirs, filepath.Join(dir, "reasonix", "commands"))
}
dirs = append(dirs, filepath.Join(".reasonix", "commands"))
return dirs
}
// SourcePath returns the highest-priority config file that exists, or "" if none.
func SourcePath() string {
if _, err := os.Stat("reasonix.toml"); err == nil {
return "reasonix.toml"
}
if uc := userConfigPath(); uc != "" {
if _, err := os.Stat(uc); err == nil {
return uc
}
}
return ""
}
// WriteFile writes the configuration to path as annotated TOML.
func (c *Config) WriteFile(path string) error {
return os.WriteFile(path, []byte(RenderTOML(c)), 0o644)
}
// Provider returns the named provider entry.
func (c *Config) Provider(name string) (*ProviderEntry, bool) {
for i := range c.Providers {
if c.Providers[i].Name == name {
return &c.Providers[i], true
}
}
return nil, false
}
// APIKey resolves the entry's API key from its api_key_env.
func (e *ProviderEntry) APIKey() string {
if e.APIKeyEnv == "" {
return ""
}
return os.Getenv(e.APIKeyEnv)
}
// ResolveSystemPrompt returns the system prompt, reading system_prompt_file if set.
func (c *Config) ResolveSystemPrompt() (string, error) {
if c.Agent.SystemPromptFile != "" {
b, err := os.ReadFile(c.Agent.SystemPromptFile)
if err != nil {
return "", fmt.Errorf("system_prompt_file: %w", err)
}
return strings.TrimSpace(string(b)), nil
}
if strings.TrimSpace(c.Agent.SystemPrompt) == "" {
return DefaultSystemPrompt, nil
}
return c.Agent.SystemPrompt, nil
}
// Validate checks that the selected model's provider is usable.
func (c *Config) Validate(model string) error {
e, ok := c.Provider(model)
if !ok {
return fmt.Errorf("unknown model %q (configured: %s)", model, c.providerNames())
}
if e.Kind == "" {
return fmt.Errorf("provider %q: kind is required", model)
}
if e.BaseURL == "" {
return fmt.Errorf("provider %q: base_url is required", model)
}
if e.APIKey() == "" {
return fmt.Errorf("provider %q: missing env %s", model, e.APIKeyEnv)
}
return nil
}
func (c *Config) providerNames() string {
names := make([]string, len(c.Providers))
for i, p := range c.Providers {
names[i] = p.Name
}
return strings.Join(names, ", ")
}
+39
View File
@@ -0,0 +1,39 @@
package config
import (
"bufio"
"os"
"strings"
)
// loadDotEnv reads a .env file in the working directory (if present) and sets
// any keys not already present in the environment. Existing env vars win.
// Lenient, zero-dependency parsing.
func loadDotEnv() {
f, err := os.Open(".env")
if err != nil {
return
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
line = strings.TrimPrefix(line, "export ")
key, val, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
val = strings.Trim(strings.TrimSpace(val), `"'`)
if key == "" {
continue
}
if _, exists := os.LookupEnv(key); !exists {
os.Setenv(key, val)
}
}
}
+61
View File
@@ -0,0 +1,61 @@
package config
import (
"os"
"regexp"
"strings"
)
// varRef matches ${VAR} and ${VAR:-default}: a shell-style reference with an
// optional ":-default" fallback used when the variable is unset or empty.
var varRef = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)(:-([^}]*))?\}`)
// ExpandVars substitutes ${VAR} / ${VAR:-default} references from the process
// environment. An unset variable with no default expands to "" (matching the
// MCP / Claude Code convention), so a missing secret yields an empty header
// rather than a literal "${TOKEN}" leaking onto the wire.
func ExpandVars(s string) string {
if !strings.Contains(s, "${") {
return s
}
return varRef.ReplaceAllStringFunc(s, func(m string) string {
g := varRef.FindStringSubmatch(m)
name, hasDefault, def := g[1], g[2] != "", g[3]
if v, ok := os.LookupEnv(name); ok && v != "" {
return v
}
if hasDefault {
return def
}
return ""
})
}
// ExpandedPlugin returns a copy of e with ${VAR} references expanded across the
// command, args, env values, url, and header values — the fields Claude Code
// also expands. The entry itself is left untouched.
func (e PluginEntry) ExpandedPlugin() PluginEntry {
out := e
out.Command = ExpandVars(e.Command)
out.URL = ExpandVars(e.URL)
if len(e.Args) > 0 {
out.Args = make([]string, len(e.Args))
for i, a := range e.Args {
out.Args[i] = ExpandVars(a)
}
}
out.Env = expandMap(e.Env)
out.Headers = expandMap(e.Headers)
return out
}
func expandMap(m map[string]string) map[string]string {
if len(m) == 0 {
return m
}
out := make(map[string]string, len(m))
for k, v := range m {
out[k] = ExpandVars(v)
}
return out
}
+49
View File
@@ -0,0 +1,49 @@
package config
import "testing"
func TestExpandVars(t *testing.T) {
t.Setenv("REASONIX_TEST_TOKEN", "sk-123")
t.Setenv("REASONIX_TEST_EMPTY", "")
cases := []struct{ in, want string }{
{"Bearer ${REASONIX_TEST_TOKEN}", "Bearer sk-123"},
{"${REASONIX_TEST_MISSING}", ""}, // unset, no default → empty
{"${REASONIX_TEST_MISSING:-fallback}", "fallback"}, // unset → default
{"${REASONIX_TEST_EMPTY:-fallback}", "fallback"}, // set-but-empty → default
{"${REASONIX_TEST_TOKEN:-fallback}", "sk-123"}, // set → value, default ignored
{"no vars here", "no vars here"}, // untouched
{"a${REASONIX_TEST_TOKEN}b${REASONIX_TEST_MISSING}c", "ask-123bc"}, // multiple refs
}
for _, c := range cases {
if got := ExpandVars(c.in); got != c.want {
t.Errorf("ExpandVars(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestExpandedPlugin(t *testing.T) {
t.Setenv("REASONIX_TEST_KEY", "secret")
e := PluginEntry{
Name: "x",
Type: "http",
URL: "https://api/${REASONIX_TEST_MISSING:-v1}",
Args: []string{"--token", "${REASONIX_TEST_KEY}"},
Env: map[string]string{"K": "${REASONIX_TEST_KEY}"},
Headers: map[string]string{"Authorization": "Bearer ${REASONIX_TEST_KEY}"},
}
out := e.ExpandedPlugin()
if out.URL != "https://api/v1" {
t.Errorf("URL = %q", out.URL)
}
if out.Args[1] != "secret" {
t.Errorf("Args = %v", out.Args)
}
if out.Env["K"] != "secret" || out.Headers["Authorization"] != "Bearer secret" {
t.Errorf("env/headers not expanded: %v %v", out.Env, out.Headers)
}
// The original entry must be untouched (we returned a copy).
if e.Headers["Authorization"] != "Bearer ${REASONIX_TEST_KEY}" {
t.Error("ExpandedPlugin mutated the original entry")
}
}
+38
View File
@@ -0,0 +1,38 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadForEdit(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "reasonix.toml")
custom := `default_model = "custom"
[[providers]]
name = "custom"
kind = "openai"
base_url = "https://x"
model = "m"
api_key_env = "X_KEY"
`
if err := os.WriteFile(path, []byte(custom), 0o644); err != nil {
t.Fatal(err)
}
// Existing file: its providers/default override the built-in defaults, so a
// reconfigure preserves the user's setup.
cfg := LoadForEdit(path)
if cfg.DefaultModel != "custom" {
t.Errorf("default_model = %q, want custom", cfg.DefaultModel)
}
if len(cfg.Providers) != 1 || cfg.Providers[0].Name != "custom" {
t.Errorf("providers = %v, want a single custom provider", cfg.Providers)
}
// Missing file: falls back to the built-in defaults.
if cfg := LoadForEdit(filepath.Join(dir, "absent.toml")); cfg.DefaultModel != Default().DefaultModel {
t.Errorf("missing-file default = %q, want %q", cfg.DefaultModel, Default().DefaultModel)
}
}
+82
View File
@@ -0,0 +1,82 @@
package config
import (
"encoding/json"
"fmt"
"os"
"sort"
)
// mcpJSONFile is the project-root file Claude Code calls .mcp.json. Reasonix reads
// it so an MCP server already configured for Claude works here unchanged — the
// server specs map field-for-field onto PluginEntry.
const mcpJSONFile = ".mcp.json"
// mcpServerSpec mirrors one entry of Claude Code's "mcpServers" map. The field
// names and semantics match PluginEntry (and Claude): command/args/env describe
// a local stdio server; type/url/headers describe a remote one.
type mcpServerSpec struct {
Type string `json:"type"`
Command string `json:"command"`
Args []string `json:"args"`
Env map[string]string `json:"env"`
URL string `json:"url"`
Headers map[string]string `json:"headers"`
}
// loadMCPJSON reads path (Claude Code's .mcp.json) and returns its servers as
// PluginEntry values, sorted by name for a stable connection order. An absent
// file is not an error (returns nil, nil). A present-but-malformed file is an
// error so a typo surfaces loudly instead of silently dropping every server.
func loadMCPJSON(path string) ([]PluginEntry, error) {
b, err := os.ReadFile(path)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("mcp config %s: %w", path, err)
}
var doc struct {
MCPServers map[string]mcpServerSpec `json:"mcpServers"`
}
if err := json.Unmarshal(b, &doc); err != nil {
return nil, fmt.Errorf("mcp config %s: %w", path, err)
}
names := make([]string, 0, len(doc.MCPServers))
for name := range doc.MCPServers {
names = append(names, name)
}
sort.Strings(names)
entries := make([]PluginEntry, 0, len(names))
for _, name := range names {
s := doc.MCPServers[name]
entries = append(entries, PluginEntry{
Name: name,
Type: s.Type,
Command: s.Command,
Args: s.Args,
Env: s.Env,
URL: s.URL,
Headers: s.Headers,
})
}
return entries, nil
}
// mergeMCPJSON appends servers from .mcp.json that the TOML config did not
// already declare. reasonix.toml's [[plugins]] win on a name collision: it is the
// Reasonix-specific, more explicit of the two, so it overrides the shared,
// checked-in .mcp.json rather than the other way round.
func (c *Config) mergeMCPJSON(entries []PluginEntry) {
have := make(map[string]bool, len(c.Plugins))
for _, p := range c.Plugins {
have[p.Name] = true
}
for _, e := range entries {
if have[e.Name] {
continue
}
have[e.Name] = true
c.Plugins = append(c.Plugins, e)
}
}
+131
View File
@@ -0,0 +1,131 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadMCPJSON(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, mcpJSONFile)
doc := `{
"mcpServers": {
"stripe": {
"type": "http",
"url": "https://mcp.stripe.com",
"headers": { "Authorization": "Bearer ${STRIPE_KEY}" }
},
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
"env": { "FOO": "bar" }
}
}
}`
if err := os.WriteFile(path, []byte(doc), 0o644); err != nil {
t.Fatal(err)
}
got, err := loadMCPJSON(path)
if err != nil {
t.Fatal(err)
}
// Sorted by name: filesystem before stripe.
if len(got) != 2 || got[0].Name != "filesystem" || got[1].Name != "stripe" {
t.Fatalf("entries = %+v, want [filesystem stripe] sorted", got)
}
fs := got[0]
if fs.Command != "npx" || len(fs.Args) != 3 || fs.Env["FOO"] != "bar" {
t.Errorf("filesystem decoded wrong: %+v", fs)
}
st := got[1]
if st.Type != "http" || st.URL != "https://mcp.stripe.com" ||
st.Headers["Authorization"] != "Bearer ${STRIPE_KEY}" {
t.Errorf("stripe decoded wrong: %+v", st)
}
}
func TestLoadMCPJSONAbsentAndMalformed(t *testing.T) {
dir := t.TempDir()
// Absent file: not an error, no entries.
got, err := loadMCPJSON(filepath.Join(dir, "missing.json"))
if err != nil || got != nil {
t.Errorf("absent file: got (%v, %v), want (nil, nil)", got, err)
}
// Malformed file: an error so a typo surfaces instead of dropping servers.
bad := filepath.Join(dir, mcpJSONFile)
if err := os.WriteFile(bad, []byte("{not json"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := loadMCPJSON(bad); err == nil {
t.Error("malformed .mcp.json: want error, got nil")
}
}
func TestLoadMergesMCPJSON(t *testing.T) {
// Point the user-config and home dirs at an empty temp dir so Load picks up
// no global config, then chdir into a project dir holding both files.
empty := t.TempDir()
t.Setenv("HOME", empty)
t.Setenv("XDG_CONFIG_HOME", empty)
t.Chdir(t.TempDir())
toml := `[[plugins]]
name = "shared"
command = "local-bin"
`
if err := os.WriteFile("reasonix.toml", []byte(toml), 0o644); err != nil {
t.Fatal(err)
}
mcp := `{ "mcpServers": {
"shared": { "type": "http", "url": "https://override.example" },
"extra": { "command": "extra-bin" }
} }`
if err := os.WriteFile(mcpJSONFile, []byte(mcp), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := Load()
if err != nil {
t.Fatal(err)
}
byName := map[string]PluginEntry{}
for _, p := range cfg.Plugins {
byName[p.Name] = p
}
if len(byName) != 2 {
t.Fatalf("plugins = %+v, want shared + extra", cfg.Plugins)
}
if byName["shared"].Command != "local-bin" || byName["shared"].URL != "" {
t.Errorf("reasonix.toml should win the collision, got %+v", byName["shared"])
}
if byName["extra"].Command != "extra-bin" {
t.Errorf("extra not merged from .mcp.json, got %+v", byName["extra"])
}
}
func TestMergeMCPJSONPrecedence(t *testing.T) {
// reasonix.toml already declares "shared" (stdio); .mcp.json offers a colliding
// "shared" (http) plus a fresh "extra". reasonix.toml must win on the collision;
// "extra" gets appended.
cfg := &Config{Plugins: []PluginEntry{
{Name: "shared", Command: "local-bin"},
}}
cfg.mergeMCPJSON([]PluginEntry{
{Name: "shared", Type: "http", URL: "https://override.example"},
{Name: "extra", Command: "extra-bin"},
})
if len(cfg.Plugins) != 2 {
t.Fatalf("plugins = %+v, want 2 (shared kept, extra added)", cfg.Plugins)
}
if cfg.Plugins[0].Name != "shared" || cfg.Plugins[0].Command != "local-bin" || cfg.Plugins[0].URL != "" {
t.Errorf("collision not won by reasonix.toml: %+v", cfg.Plugins[0])
}
if cfg.Plugins[1].Name != "extra" || cfg.Plugins[1].Command != "extra-bin" {
t.Errorf("non-colliding entry not appended: %+v", cfg.Plugins[1])
}
}
+210
View File
@@ -0,0 +1,210 @@
package config
import (
"fmt"
"sort"
"strconv"
"strings"
)
// RenderTOML renders the config as annotated TOML in the `reasonix init` house style:
// comments preserved, system_prompt as a multi-line string, helpful hints. The
// output round-trips back through Load (see render_test.go).
func RenderTOML(c *Config) string {
var b strings.Builder
b.WriteString("# Reasonix configuration.\n")
b.WriteString("# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n")
b.WriteString("# Secrets come from the environment via api_key_env; never put keys here.\n\n")
fmt.Fprintf(&b, "default_model = %q\n", c.DefaultModel)
if c.Language != "" {
fmt.Fprintf(&b, "language = %q # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n", c.Language)
} else {
b.WriteString("# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n")
}
b.WriteString("\n")
b.WriteString("[agent]\n")
b.WriteString("system_prompt = \"\"\"\n")
b.WriteString(c.Agent.SystemPrompt)
b.WriteString("\"\"\"\n")
if c.Agent.SystemPromptFile != "" {
fmt.Fprintf(&b, "system_prompt_file = %q\n", c.Agent.SystemPromptFile)
} else {
b.WriteString("# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\n")
}
fmt.Fprintf(&b, "max_steps = %d\n", c.Agent.MaxSteps)
fmt.Fprintf(&b, "temperature = %s\n", formatFloat(c.Agent.Temperature))
if c.Agent.PlannerModel != "" {
fmt.Fprintf(&b, "planner_model = %q # low-frequency planner (two-model collaboration)\n", c.Agent.PlannerModel)
} else {
b.WriteString("# planner_model = \"mimo\" # optional: enable two-model collaboration\n")
}
b.WriteString("\n")
for _, p := range c.Providers {
b.WriteString("[[providers]]\n")
fmt.Fprintf(&b, "name = %q\n", p.Name)
fmt.Fprintf(&b, "kind = %q\n", p.Kind)
fmt.Fprintf(&b, "base_url = %q\n", p.BaseURL)
fmt.Fprintf(&b, "model = %q\n", p.Model)
fmt.Fprintf(&b, "api_key_env = %q\n", p.APIKeyEnv)
if p.ContextWindow > 0 {
fmt.Fprintf(&b, "context_window = %d # tokens; compaction triggers near this limit\n", p.ContextWindow)
}
if p.Price != nil {
fmt.Fprintf(&b, "price = { cache_hit = %v, input = %v, output = %v, currency = %q } # per 1M tokens\n",
p.Price.CacheHit, p.Price.Input, p.Price.Output, p.Price.Symbol())
}
b.WriteString("\n")
}
b.WriteString("[tools]\n")
if len(c.Tools.Enabled) == 0 {
b.WriteString("enabled = [] # empty = all built-in tools\n\n")
} else {
b.WriteString("enabled = [")
for i, t := range c.Tools.Enabled {
if i > 0 {
b.WriteString(", ")
}
fmt.Fprintf(&b, "%q", t)
}
b.WriteString("]\n\n")
}
b.WriteString("[permissions]\n")
b.WriteString("# Per-call gating. mode = writer fallback when no rule matches: ask|allow|deny.\n")
b.WriteString("# Readers always default to allow. Precedence: deny > ask > allow > fallback.\n")
b.WriteString("# Rules are \"ToolName\" or \"ToolName(glob)\"; '*' matches any run, '?' one char.\n")
mode := c.Permissions.Mode
if mode == "" {
mode = "ask"
}
fmt.Fprintf(&b, "mode = %q\n", mode)
b.WriteString(renderRuleList("deny", c.Permissions.Deny, `["bash(rm -rf*)", "bash(git push*)"] # hard-blocked in every mode`))
b.WriteString(renderRuleList("allow", c.Permissions.Allow, `["bash(go test*)", "bash(git status*)"] # never prompted`))
b.WriteString(renderRuleList("ask", c.Permissions.Ask, `["write_file"] # force a prompt even if otherwise allowed`))
b.WriteString("\n")
b.WriteString("[sandbox]\n")
b.WriteString("# Confine tool blast radius. File-writers (write_file/edit_file/multi_edit)\n")
b.WriteString("# may only write under workspace_root (empty = current dir) + allow_write.\n")
b.WriteString("# bash = \"enforce\" (default) jails each command in an OS sandbox (macOS now;\n")
b.WriteString("# graceful fallback elsewhere); \"off\" disables it. network allows egress.\n")
if c.Sandbox.WorkspaceRoot != "" {
fmt.Fprintf(&b, "workspace_root = %q\n", c.Sandbox.WorkspaceRoot)
} else {
b.WriteString("# workspace_root = \"\" # default: current working directory\n")
}
if len(c.Sandbox.AllowWrite) > 0 {
fmt.Fprintf(&b, "allow_write = %s\n", renderStringArray(c.Sandbox.AllowWrite))
} else {
b.WriteString("# allow_write = [\"/tmp\"] # extra dirs writers may also modify\n")
}
fmt.Fprintf(&b, "bash = %q\n", c.BashMode())
fmt.Fprintf(&b, "network = %v\n", c.Sandbox.Network)
b.WriteString("\n")
b.WriteString("# External MCP servers. type: \"stdio\" (default, a subprocess) | \"http\" | \"sse\".\n")
b.WriteString("# ${VAR} / ${VAR:-default} are expanded from the environment in command/args/env/url/headers.\n")
if len(c.Plugins) == 0 {
b.WriteString("# [[plugins]]\n")
b.WriteString("# name = \"example\"\n")
b.WriteString("# command = \"reasonix-plugin-example\"\n")
b.WriteString("# [[plugins]] # a remote server over Streamable HTTP\n")
b.WriteString("# name = \"stripe\"\n")
b.WriteString("# type = \"http\"\n")
b.WriteString("# url = \"https://mcp.stripe.com\"\n")
b.WriteString("# headers = { Authorization = \"Bearer ${STRIPE_KEY}\" }\n")
} else {
for _, pl := range c.Plugins {
b.WriteString("\n[[plugins]]\n")
fmt.Fprintf(&b, "name = %q\n", pl.Name)
if pl.Type != "" {
fmt.Fprintf(&b, "type = %q\n", pl.Type)
}
if pl.Command != "" {
fmt.Fprintf(&b, "command = %q\n", pl.Command)
}
if len(pl.Args) > 0 {
fmt.Fprintf(&b, "args = %s\n", renderStringArray(pl.Args))
}
if pl.URL != "" {
fmt.Fprintf(&b, "url = %q\n", pl.URL)
}
if len(pl.Headers) > 0 {
fmt.Fprintf(&b, "headers = %s\n", renderStringMap(pl.Headers))
}
if len(pl.Env) > 0 {
fmt.Fprintf(&b, "env = %s\n", renderStringMap(pl.Env))
}
}
}
return b.String()
}
// renderStringArray renders a []string as a TOML inline array.
func renderStringArray(ss []string) string {
var b strings.Builder
b.WriteByte('[')
for i, s := range ss {
if i > 0 {
b.WriteString(", ")
}
fmt.Fprintf(&b, "%q", s)
}
b.WriteByte(']')
return b.String()
}
// renderStringMap renders a map[string]string as a TOML inline table with keys
// in sorted order so output is deterministic (round-trips cleanly).
func renderStringMap(m map[string]string) string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
b.WriteString("{ ")
for i, k := range keys {
if i > 0 {
b.WriteString(", ")
}
fmt.Fprintf(&b, "%s = %q", k, m[k])
}
b.WriteString(" }")
return b.String()
}
// renderRuleList emits a permission rule list. A populated list renders as an
// active TOML array; an empty one renders as a commented example so `reasonix init`
// scaffolds discoverable guidance without imposing surprising rules.
func renderRuleList(key string, rules []string, example string) string {
if len(rules) == 0 {
return fmt.Sprintf("# %s = %s\n", key, example)
}
var b strings.Builder
fmt.Fprintf(&b, "%s = [", key)
for i, r := range rules {
if i > 0 {
b.WriteString(", ")
}
fmt.Fprintf(&b, "%q", r)
}
b.WriteString("]\n")
return b.String()
}
// formatFloat ensures a float renders with a decimal point so TOML types it as a
// float, not an integer (e.g. 0 -> "0.0").
func formatFloat(f float64) string {
s := strconv.FormatFloat(f, 'f', -1, 64)
if !strings.Contains(s, ".") {
s += ".0"
}
return s
}
+74
View File
@@ -0,0 +1,74 @@
package config
import (
"testing"
"github.com/BurntSushi/toml"
)
// TestRenderTOMLRoundTrips ensures the annotated TOML we emit parses back into
// an equivalent config — i.e. the wizard never writes a file it can't read.
func TestRenderTOMLRoundTrips(t *testing.T) {
orig := Default()
orig.DefaultModel = "mimo-pro"
orig.Language = "zh"
orig.Permissions = PermissionsConfig{
Mode: "deny",
Deny: []string{"bash(rm -rf*)"},
Allow: []string{"bash(go test*)", "read_file"},
}
orig.Plugins = []PluginEntry{
{Name: "example", Command: "reasonix-plugin-example"},
{Name: "stripe", Type: "http", URL: "https://mcp.stripe.com", Headers: map[string]string{"Authorization": "Bearer x"}},
}
mm, _ := orig.Provider("mimo-pro")
mm.BaseURL = "http://localhost:8000/v1"
rendered := RenderTOML(orig)
var got Config
if _, err := toml.Decode(rendered, &got); err != nil {
t.Fatalf("rendered TOML does not parse: %v\n---\n%s", err, rendered)
}
if got.DefaultModel != "mimo-pro" {
t.Errorf("default_model = %q, want mimo-pro", got.DefaultModel)
}
if got.Language != "zh" {
t.Errorf("language = %q, want zh", got.Language)
}
if got.Agent.MaxSteps != orig.Agent.MaxSteps {
t.Errorf("max_steps = %d, want %d", got.Agent.MaxSteps, orig.Agent.MaxSteps)
}
if got.Agent.Temperature != orig.Agent.Temperature {
t.Errorf("temperature = %v, want %v", got.Agent.Temperature, orig.Agent.Temperature)
}
if got.Agent.SystemPrompt != orig.Agent.SystemPrompt {
t.Errorf("system_prompt mismatch:\n got %q\nwant %q", got.Agent.SystemPrompt, orig.Agent.SystemPrompt)
}
if g, _ := got.Provider("mimo-pro"); g == nil || g.BaseURL != "http://localhost:8000/v1" {
t.Errorf("mimo-pro base_url not preserved: %+v", g)
}
if len(got.Providers) != len(orig.Providers) {
t.Errorf("providers count = %d, want %d", len(got.Providers), len(orig.Providers))
}
if got.Permissions.Mode != "deny" {
t.Errorf("permissions.mode = %q, want deny", got.Permissions.Mode)
}
if len(got.Permissions.Deny) != 1 || got.Permissions.Deny[0] != "bash(rm -rf*)" {
t.Errorf("permissions.deny = %v, want [bash(rm -rf*)]", got.Permissions.Deny)
}
if len(got.Permissions.Allow) != 2 {
t.Errorf("permissions.allow = %v, want 2 entries", got.Permissions.Allow)
}
if len(got.Plugins) != 2 {
t.Fatalf("plugins count = %d, want 2", len(got.Plugins))
}
stripe := got.Plugins[1]
if stripe.Name != "stripe" || stripe.Type != "http" || stripe.URL != "https://mcp.stripe.com" {
t.Errorf("http plugin not preserved: %+v", stripe)
}
if stripe.Headers["Authorization"] != "Bearer x" {
t.Errorf("plugin headers not preserved: %v", stripe.Headers)
}
}
+156
View File
@@ -0,0 +1,156 @@
// Package i18n holds the CLI's translatable strings and a small detection
// helper. Architecture: a single Messages struct of exported string fields
// (plain text or fmt format strings, suffix *Fmt flags the latter). Each
// language declares one Messages value in its own file. Call sites read
// i18n.M.SomeField; for parameterised messages they pass it to fmt.Sprintf.
//
// Adding a field requires updating every messages_*.go file — drift is caught
// at test time by TestCatalogsComplete via reflection, so a missing translation
// fails CI instead of surfacing as a blank line at runtime.
//
// Scope (v1): CLI surface only — welcome, init wizard, chat REPL banner, usage,
// user-facing CLI errors. System prompts, internal error wrappers, and agent
// runtime telemetry stay English so model behaviour and developer logs are
// language-stable.
package i18n
import (
"os"
"strings"
)
// Messages is the catalogue of translatable CLI strings. Plain fields are
// printed verbatim; *Fmt fields are fmt format strings the caller passes to
// fmt.Sprintf. Catalogue values do not include trailing newlines — call sites
// add framing whitespace, so the same field works wherever it appears.
type Messages struct {
// welcome / status screen
Subtitle string // tagline under the product name in the welcome box
WelcomeTitleFmt string // first-run box title — %s = product name (styled)
NoConfigYet string // first-run cue under the welcome box
StartingChatFmt string // "Starting %s…" before dropping into chat
SetKeyHint string // shown when key is missing after init
ConfigLabel string // "config" status row label
ModelsLabel string // "models" status row label
ConfigNotFound string // shown when no config file exists
ConfigErrorFmt string // "%s — error: %v" — config path + parse error
NoKey string // status dot — no API key set
Ready string // status dot — provider ready
GetStarted string // section title above numbered steps
StepScaffold string // step 1 desc — reasonix init
StepSetKey string // step 2 command label
StepSetKeyHint string // step 2 desc — env var hint
StepChatDesc string // reasonix chat step desc
StepRunDesc string // reasonix run step desc
HelpFooter string // dim footer linking to reasonix help
// chat REPL
ChatTip string // tip line under the chat banner
TurnCancelled string // shown when Ctrl-C aborts the in-flight turn but the chat keeps running
NoSessionToResume string // shown when --continue / --resume finds nothing
ResumeRequiresTTY string // shown when --resume runs piped instead of on a terminal
PickSessionLabel string // header on the --resume picker
// chat TUI status line / approval banner.
ChatStatusThinkingFmt string // "%s thinking… (%ds · <cancel hint>)" — %s = spinner, %d = elapsed s
ChatStatusIdle string // shortcuts hint when idle
ChatStatusPlanApproval string // shortcuts hint while a plan is pending
PlanApprovalPrompt string // one-line "plan above is ready" banner shown above the input
ChatStatusToolApproval string // shortcuts hint while a tool call awaits approval
ToolApprovalPromptFmt string // "Allow %s%s?" banner — %s = tool name, %s = subject (leading space, or empty)
// chat TUI slash commands.
SlashCompactDone string // "/compact" succeeded
SlashCompactFailed string // "/compact" errored, prefixed before the underlying error
SlashNewDone string // "/new" succeeded
SlashNewFailed string // "/new" errored
SlashUnavailable string // the command is configured off (no callback wired)
SlashUnknown string // shown when the user types an unrecognised "/cmd"
SlashHelp string // listed commands
SlashPromptEmpty string // an MCP prompt returned no text to send
SlashMCPNone string // /mcp when no MCP servers are connected
CompHintSlash string // key hint footer under the slash-command menu
CompHintFile string // key hint footer under the @ file/resource menu
// init wizard
SelectProvidersLabel string // multi-select label
EnterAPIKeysHeader string // header before the per-env-var prompts
MissingKeyIntro string // shown when re-running the key step on a configured setup
WroteFileFmt string // "Wrote %s" — used for reasonix.toml and .env both
SetupComplete string // success line at end of init
SetupCancelled string // shown when the user aborts the wizard
TryHintFmt string // "Try: %s" — %s = command to try (styled)
NextHint string // non-interactive post-write hint
ConfirmReconfigureFmt string // "%s already exists. Reconfigure and overwrite?"
KeepingExisting string // when the user declines to overwrite
NotOverwritingFmt string // non-interactive overwrite refusal
// top-level / runAgent
UnknownCommandFmt string // "unknown command %q"
UsageRunHint string // "usage: reasonix run [--model NAME] <task>"
ErrorPrefix string // "error:" — prefix for fatal-error output
WriteConfigErr string // "write config:" — prefix for write failure
WriteEnvErr string // "write .env:" — prefix for env-write failure
// selection menus
SelectOneHint string // "(↑/↓ · Enter · q to cancel)"
SelectManyHint string // "(↑/↓ · Space · Enter · q)"
// usage / help
UsageBody string // full multi-line help text
}
// M is the active catalogue. DetectLanguage replaces it; English is the
// default so any code path that runs before detection still has text.
var M = English
// DetectLanguage selects a catalogue from override (e.g. cfg.Language) or the
// environment and installs it as M. Returns the resolved tag ("en", "zh") so
// callers can log or expose it.
//
// Priority: override > REASONIX_LANG > LC_ALL > LC_MESSAGES > LANG > "en".
func DetectLanguage(override string) string {
for _, c := range append([]string{override}, envCandidates()...) {
if tag := normalize(c); tag != "" {
return setLanguage(tag)
}
}
return setLanguage("en")
}
func envCandidates() []string {
keys := []string{"REASONIX_LANG", "LC_ALL", "LC_MESSAGES", "LANG"}
out := make([]string, len(keys))
for i, k := range keys {
out[i] = os.Getenv(k)
}
return out
}
func setLanguage(tag string) string {
switch tag {
case "zh":
M = Chinese
return "zh"
default:
M = English
return "en"
}
}
// normalize maps a locale string (e.g. "zh_CN.UTF-8", "zh-Hans-CN", "Chinese
// (China)") to a short tag this package knows about. Returns "" for empty or
// unrecognised input so DetectLanguage can fall through to the next candidate.
func normalize(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
if s == "" {
return ""
}
if strings.HasPrefix(s, "zh") || strings.Contains(s, "chinese") || strings.Contains(s, "中文") {
return "zh"
}
if strings.HasPrefix(s, "en") || strings.Contains(s, "english") {
return "en"
}
return ""
}
+113
View File
@@ -0,0 +1,113 @@
package i18n
import (
"reflect"
"strings"
"testing"
)
// TestCatalogsComplete reflects over English (the baseline) and asserts every
// other catalogue populates the same fields. Empty strings count as missing
// translations so drift fails CI instead of surfacing as blank output. As new
// languages land they get added to the catalogs map below.
func TestCatalogsComplete(t *testing.T) {
en := reflect.ValueOf(English)
typ := en.Type()
catalogs := map[string]reflect.Value{"zh": reflect.ValueOf(Chinese)}
for tag, cat := range catalogs {
for i := 0; i < typ.NumField(); i++ {
name := typ.Field(i).Name
if strings.TrimSpace(cat.Field(i).String()) == "" {
t.Errorf("%s catalogue: field %q is empty", tag, name)
}
}
}
}
// TestCatalogsAgreeOnPlaceholders catches translations that silently drop or
// gain %s/%d/%q placeholders — a class of bug that only blows up when the
// affected message is rendered. Compares the count per format verb across
// languages for any field whose name ends in "Fmt".
func TestCatalogsAgreeOnPlaceholders(t *testing.T) {
en := reflect.ValueOf(English)
typ := en.Type()
for i := 0; i < typ.NumField(); i++ {
name := typ.Field(i).Name
if !strings.HasSuffix(name, "Fmt") {
continue
}
want := countVerbs(en.Field(i).String())
got := countVerbs(reflect.ValueOf(Chinese).Field(i).String())
if want != got {
t.Errorf("%s: en has %d verbs, zh has %d", name, want, got)
}
}
}
// countVerbs counts unescaped fmt placeholders (%s, %d, %q, %v, …). %% does
// not count.
func countVerbs(s string) int {
n := 0
for i := 0; i < len(s); i++ {
if s[i] != '%' {
continue
}
if i+1 < len(s) && s[i+1] == '%' {
i++
continue
}
n++
}
return n
}
// TestNormalize covers the locale-string shapes likely to land in $LANG /
// $LC_ALL / $REASONIX_LANG. Unknown locales return "" so DetectLanguage falls
// through to the next candidate instead of mis-routing.
func TestNormalize(t *testing.T) {
cases := map[string]string{
"": "",
"en": "en",
"en_US.UTF-8": "en",
"zh": "zh",
"zh_CN.UTF-8": "zh",
"zh-Hans-CN": "zh",
"Chinese (China)": "zh",
"中文": "zh",
"fr_FR.UTF-8": "",
" ZH_TW ": "zh",
}
for in, want := range cases {
if got := normalize(in); got != want {
t.Errorf("normalize(%q) = %q, want %q", in, got, want)
}
}
}
// TestDetectLanguagePriority verifies override beats env and that REASONIX_LANG
// beats LANG. With a clean env we fall back to English.
func TestDetectLanguagePriority(t *testing.T) {
t.Setenv("REASONIX_LANG", "")
t.Setenv("LC_ALL", "")
t.Setenv("LC_MESSAGES", "")
t.Setenv("LANG", "")
defer DetectLanguage("") // restore default for other tests
if got := DetectLanguage(""); got != "en" {
t.Errorf("clean env: got %q, want en", got)
}
t.Setenv("LANG", "zh_CN.UTF-8")
if got := DetectLanguage(""); got != "zh" {
t.Errorf("LANG=zh_CN.UTF-8: got %q, want zh", got)
}
t.Setenv("REASONIX_LANG", "en")
if got := DetectLanguage(""); got != "en" {
t.Errorf("REASONIX_LANG=en overriding LANG=zh: got %q, want en", got)
}
if got := DetectLanguage("zh"); got != "zh" {
t.Errorf("override=zh: got %q, want zh", got)
}
}
+91
View File
@@ -0,0 +1,91 @@
package i18n
// English is the baseline catalogue. The drift-guard test reflects over its
// fields, so every other catalogue must populate the same set.
var English = Messages{
Subtitle: "config + plugin driven coding agent",
WelcomeTitleFmt: "Welcome to %s",
NoConfigYet: "No configuration found yet — let's set it up.",
StartingChatFmt: "Starting %s…",
SetKeyHint: "Set your API key, then run `reasonix chat`.",
ConfigLabel: "config",
ModelsLabel: "models",
ConfigNotFound: "not found — using built-in defaults",
ConfigErrorFmt: "%s — error: %v",
NoKey: "no key",
Ready: "ready",
GetStarted: "Get started",
StepScaffold: "scaffold reasonix.toml",
StepSetKey: "set API key",
StepSetKeyHint: "export DEEPSEEK_API_KEY=… or add to .env",
StepChatDesc: "interactive session",
StepRunDesc: "one-shot task",
HelpFooter: "reasonix help · all commands",
ChatTip: "Context is kept across turns. Type 'exit' or Ctrl-D to quit.",
TurnCancelled: "cancelled — back to prompt",
NoSessionToResume: "no saved session to resume — start a new one with `reasonix chat`",
ResumeRequiresTTY: "--resume needs an interactive terminal; pass --continue for the most recent session",
PickSessionLabel: "Resume which session?",
ChatStatusThinkingFmt: "%s thinking… (%ds · Esc cancels)",
ChatStatusIdle: "Tab toggles plan · Enter sends · Esc clears/exits state · PgUp/PgDn scrolls · Ctrl-D quits",
ChatStatusPlanApproval: "Enter approves · type to revise · Esc dismisses · PgUp/PgDn scrolls",
PlanApprovalPrompt: "Plan ready above — Enter to approve, or type to revise",
ChatStatusToolApproval: "y approve once · a allow this session · n deny · Ctrl-C cancels turn",
ToolApprovalPromptFmt: "Allow %s%s? — [y] once · [a] this session · [n] no",
SlashCompactDone: "session compacted — older middle replaced by a summary, recent turns kept",
SlashCompactFailed: "compaction failed",
SlashNewDone: "fresh session started — previous transcript saved",
SlashNewFailed: "could not start a new session",
SlashUnavailable: "command unavailable in this build",
SlashUnknown: "unknown command",
SlashHelp: "commands: /compact (manual context compaction) · /new (fork a fresh session) · /mcp (MCP servers) · /help",
SlashPromptEmpty: "the MCP prompt returned no content to send",
SlashMCPNone: "no MCP servers configured — add a [[plugins]] entry in reasonix.toml",
CompHintSlash: "↑/↓ move · Tab/Enter select · Esc close",
CompHintFile: "↑/↓ move · Tab/Enter open folder or pick file · Esc close",
SelectProvidersLabel: "Select providers to enable",
EnterAPIKeysHeader: "Enter API keys (Enter to skip and set later in .env):",
MissingKeyIntro: "reasonix.toml is ready — just an API key away.",
WroteFileFmt: "Wrote %s",
SetupComplete: "Setup complete.",
SetupCancelled: "setup cancelled.",
TryHintFmt: "Try: %s",
NextHint: "Next: set your API key (export DEEPSEEK_API_KEY=... or add to .env), then run `reasonix run \"your task\"`.",
ConfirmReconfigureFmt: "%s already exists. Reconfigure and overwrite?",
KeepingExisting: "Keeping existing config.",
NotOverwritingFmt: "%s already exists; not overwriting",
UnknownCommandFmt: "unknown command %q",
UsageRunHint: "usage: reasonix run [--model NAME] <task>",
ErrorPrefix: "error:",
WriteConfigErr: "write config:",
WriteEnvErr: "write .env:",
SelectOneHint: "(↑/↓ · Enter · q to cancel)",
SelectManyHint: "(↑/↓ · Space · Enter · q)",
UsageBody: `reasonix — a config- and plugin-driven coding agent (multi-model)
Usage:
reasonix chat [--model NAME] interactive session (multi-turn)
reasonix run [--model NAME] [--max-steps N] <task> run one task and exit
reasonix init [path] interactive setup; writes reasonix.toml (+ .env)
reasonix version
reasonix help
Examples:
reasonix chat
reasonix run "implement the TODOs in main.go"
reasonix run --model mimo-pro "add unit tests for this function"
echo "explain this code" | reasonix run
Configuration:
Resolution: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults
Secrets come from the environment via api_key_env (e.g. DEEPSEEK_API_KEY).
Run 'reasonix init' to scaffold a config; see docs/SPEC.md.
`,
}
+92
View File
@@ -0,0 +1,92 @@
package i18n
// Chinese is the zh-Hans catalogue. Keep the %s placeholders in the same order
// as English unless a phrase genuinely demands re-ordering — call sites pass
// arguments positionally and won't reshuffle.
var Chinese = Messages{
Subtitle: "配置与插件驱动的 coding agent",
WelcomeTitleFmt: "欢迎使用 %s",
NoConfigYet: "还没有配置 — 现在来设置一下吧。",
StartingChatFmt: "正在启动 %s…",
SetKeyHint: "设置好 API key 后运行 `reasonix chat`。",
ConfigLabel: "配置",
ModelsLabel: "模型",
ConfigNotFound: "未找到 — 使用内置默认值",
ConfigErrorFmt: "%s — 错误:%v",
NoKey: "未设置 key",
Ready: "已就绪",
GetStarted: "开始使用",
StepScaffold: "生成 reasonix.toml",
StepSetKey: "设置 API key",
StepSetKeyHint: "执行 export DEEPSEEK_API_KEY=… 或写入 .env",
StepChatDesc: "交互式会话",
StepRunDesc: "执行单次任务",
HelpFooter: "reasonix help · 查看全部命令",
ChatTip: "对话上下文将跨轮保留。输入 'exit' 或按 Ctrl-D 退出。",
TurnCancelled: "已取消 — 回到提示符",
NoSessionToResume: "没有可恢复的会话 — 用 `reasonix chat` 开一个新的",
ResumeRequiresTTY: "--resume 需要交互式终端;用 --continue 直接恢复最近一次",
PickSessionLabel: "恢复哪个会话?",
ChatStatusThinkingFmt: "%s 思考中… (%d 秒 · Esc 取消)",
ChatStatusIdle: "Tab 切换 plan · Enter 发送 · Esc 退出当前状态 · PgUp/PgDn 滚动 · Ctrl-D 退出",
ChatStatusPlanApproval: "Enter 同意 · 输入文本反馈以让 AI 修改 · Esc 撤掉横幅 · PgUp/PgDn 滚动",
PlanApprovalPrompt: "计划已生成(看上方)— 回车通过,或输入修改建议",
ChatStatusToolApproval: "y 同意一次 · a 本会话允许 · n 拒绝 · Ctrl-C 取消本轮",
ToolApprovalPromptFmt: "允许 %s%s?— [y] 本次 · [a] 本会话 · [n] 拒绝",
SlashCompactDone: "已压缩 — 旧的中段换成一段摘要,最近几轮保留原样",
SlashCompactFailed: "压缩失败",
SlashNewDone: "已新建会话 — 之前的对话已存档",
SlashNewFailed: "新建会话失败",
SlashUnavailable: "当前构建不支持该命令",
SlashUnknown: "未知命令",
SlashHelp: "命令:/compact(手动压缩上下文)· /new(开新会话)· /mcp(MCP 服务器)· /help",
SlashPromptEmpty: "该 MCP prompt 没有返回可发送的内容",
SlashMCPNone: "没有配置 MCP 服务器 — 在 reasonix.toml 加一个 [[plugins]] 条目",
CompHintSlash: "↑/↓ 移动 · Tab/Enter 选中 · Esc 关闭",
CompHintFile: "↑/↓ 移动 · Tab/Enter 进入文件夹或选中文件 · Esc 关闭",
SelectProvidersLabel: "选择要启用的 provider",
EnterAPIKeysHeader: "输入 API key(回车跳过、稍后写入 .env):",
MissingKeyIntro: "reasonix.toml 已配置好 — 只差一个 API key 就可以开始。",
WroteFileFmt: "已写入 %s",
SetupComplete: "设置完成。",
SetupCancelled: "设置已取消。",
TryHintFmt: "试试: %s",
NextHint: "下一步:设置 API key(export DEEPSEEK_API_KEY=... 或写入 .env),然后运行 `reasonix run \"你的任务\"`。",
ConfirmReconfigureFmt: "%s 已存在。重新配置并覆盖?",
KeepingExisting: "保留原配置不变。",
NotOverwritingFmt: "%s 已存在,不覆盖",
UnknownCommandFmt: "未知命令 %q",
UsageRunHint: "用法:reasonix run [--model NAME] <task>",
ErrorPrefix: "错误:",
WriteConfigErr: "写入配置失败:",
WriteEnvErr: "写入 .env 失败:",
SelectOneHint: "(↑/↓ · Enter · q 取消)",
SelectManyHint: "(↑/↓ · Space · Enter · q)",
UsageBody: `reasonix — 由配置和插件驱动的 coding agent(多模型)
用法:
reasonix chat [--model NAME] 交互式会话(多轮)
reasonix run [--model NAME] [--max-steps N] <task> 执行单次任务后退出
reasonix init [path] 交互式设置;生成 reasonix.toml(及 .env)
reasonix version
reasonix help
示例:
reasonix chat
reasonix run "把 main.go 里的 TODO 实现掉"
reasonix run --model mimo-pro "给这个函数补单元测试"
echo "解释这段代码" | reasonix run
配置:
优先级:flag > ./reasonix.toml > ~/.config/reasonix/config.toml > 内置默认值
密钥通过 api_key_env 从环境变量注入(如 DEEPSEEK_API_KEY)。
运行 'reasonix init' 生成配置;详见 docs/SPEC.md。
`,
}
+265
View File
@@ -0,0 +1,265 @@
// Package permission decides, per tool call, whether to allow it, deny it, or
// ask the user first. The core is a pure Policy (rule evaluation, no I/O); a
// Gate wraps a Policy with an optional interactive Approver and is what the
// agent consults at execute time. Keeping rule evaluation pure makes it
// trivially testable and keeps the agent independent of how "ask" is resolved.
package permission
import (
"context"
"encoding/json"
"strings"
)
// Decision is the outcome of evaluating a tool call against a Policy.
type Decision int
const (
// Allow runs the tool without prompting.
Allow Decision = iota
// Ask defers to an interactive Approver (or, with none, resolves to Allow).
Ask
// Deny blocks the tool in every mode.
Deny
)
func (d Decision) String() string {
switch d {
case Allow:
return "allow"
case Ask:
return "ask"
case Deny:
return "deny"
default:
return "unknown"
}
}
// ParseDecision maps a config string to a Decision. Unknown / empty input
// defaults to Ask — the conservative posture for a writer fallback.
func ParseDecision(s string) Decision {
switch strings.ToLower(strings.TrimSpace(s)) {
case "allow":
return Allow
case "deny":
return Deny
default:
return Ask
}
}
// Rule matches tool calls. Tool is the tool name; Subject, when non-empty, is a
// glob (see matchGlob) the call's subject must match. An empty Subject matches
// every call to Tool.
type Rule struct {
Tool string
Subject string
}
// ParseRule parses "ToolName" or "ToolName(glob)". Surrounding whitespace is
// trimmed. ok is false for a malformed entry (empty tool name) so the caller
// can warn rather than silently install a rule that matches nothing.
func ParseRule(s string) (Rule, bool) {
s = strings.TrimSpace(s)
if s == "" {
return Rule{}, false
}
if i := strings.IndexByte(s, '('); i >= 0 && strings.HasSuffix(s, ")") {
tool := strings.TrimSpace(s[:i])
if tool == "" {
return Rule{}, false
}
return Rule{Tool: tool, Subject: s[i+1 : len(s)-1]}, true
}
return Rule{Tool: s}, true
}
func parseRules(ss []string) []Rule {
var out []Rule
for _, s := range ss {
if r, ok := ParseRule(s); ok {
out = append(out, r)
}
}
return out
}
// Policy is a set of rules plus the writer fallback mode. It is the pure,
// I/O-free heart of the permission layer.
type Policy struct {
// Mode is the fallback decision for writer tools when no rule matches.
// Read-only tools always fall back to Allow.
Mode Decision
Allow []Rule
Ask []Rule
Deny []Rule
}
// New builds a Policy from config string slices and a mode string ("ask" by
// default). Malformed rule strings are dropped.
func New(mode string, allow, ask, deny []string) Policy {
return Policy{
Mode: ParseDecision(mode),
Allow: parseRules(allow),
Ask: parseRules(ask),
Deny: parseRules(deny),
}
}
// Decide evaluates a tool call. readOnly is the tool's own classification; args
// is the raw JSON the model sent, from which the call's subject is extracted
// for glob matching. Precedence: deny > ask > allow > fallback (Allow for
// readers, Mode for writers).
func (p Policy) Decide(toolName string, readOnly bool, args json.RawMessage) Decision {
subject := Subject(args)
switch {
case matchAny(p.Deny, toolName, subject):
return Deny
case matchAny(p.Ask, toolName, subject):
return Ask
case matchAny(p.Allow, toolName, subject):
return Allow
case readOnly:
return Allow
default:
return p.Mode
}
}
// matchAny reports whether any rule matches the (toolName, subject) pair. A
// subject-specific rule cannot match a call that exposes no subject.
func matchAny(rules []Rule, toolName, subject string) bool {
for _, r := range rules {
if r.Tool != toolName {
continue
}
if r.Subject == "" {
return true
}
if subject != "" && matchGlob(r.Subject, subject) {
return true
}
}
return false
}
// subjectKeys are the JSON argument keys, in priority order, that carry a tool
// call's "subject" — the thing a Subject glob matches against. Generic so tools
// need not implement a permission-specific method: bash exposes command, the
// file tools expose path / file_path, grep & glob expose pattern.
var subjectKeys = []string{"command", "file_path", "path", "pattern"}
// Subject extracts the matchable subject string from a call's raw JSON args,
// returning "" when none of the known keys is present (such a call only matches
// bare "ToolName" rules).
func Subject(args json.RawMessage) string {
if len(args) == 0 {
return ""
}
var m map[string]any
if err := json.Unmarshal(args, &m); err != nil {
return ""
}
for _, k := range subjectKeys {
if v, ok := m[k]; ok {
if s, ok := v.(string); ok && s != "" {
return s
}
}
}
return ""
}
// matchGlob reports whether name matches pattern, where '*' matches any run of
// characters (including separators) and '?' matches exactly one. Unlike
// path.Match, '*' is not stopped by '/', which is what command-line and path
// prefixes ("rm -rf*", "/etc/*") intuitively expect. Linear time with
// backtracking, byte-oriented.
func matchGlob(pattern, name string) bool {
var px, nx, starPx, starNx int
starPx = -1
for nx < len(name) {
switch {
case px < len(pattern) && (pattern[px] == '?' || pattern[px] == name[nx]):
px++
nx++
case px < len(pattern) && pattern[px] == '*':
starPx = px
starNx = nx
px++
case starPx != -1:
px = starPx + 1
starNx++
nx = starNx
default:
return false
}
}
for px < len(pattern) && pattern[px] == '*' {
px++
}
return px == len(pattern)
}
// Approver resolves an Ask decision interactively. Implementations live in the
// front-end (the chat TUI); a non-interactive run passes a nil Approver, which
// the Gate treats as "allow" to preserve autonomous behaviour.
type Approver interface {
// Approve asks the user about a pending call. It returns whether to allow
// it and whether to remember that choice as a new rule. A non-nil err (e.g.
// the context was cancelled while waiting) aborts the turn.
Approve(ctx context.Context, toolName, subject string, args json.RawMessage) (allow, remember bool, err error)
}
// Gate is what the agent consults at execute time: a Policy plus an optional
// Approver. It satisfies the agent's Gate interface structurally.
type Gate struct {
Policy Policy
Approver Approver
// OnRemember, when set, is invoked with a new allow rule the user chose to
// remember (e.g. "bash(go build*)"), so the front-end can persist it.
OnRemember func(rule string)
}
// NewGate wires a Policy to an Approver (nil for non-interactive use).
func NewGate(p Policy, a Approver) *Gate { return &Gate{Policy: p, Approver: a} }
// Check decides whether a tool call may run. It is the method the agent's Gate
// interface expects. A denied or refused call returns allow=false with a short
// reason the agent feeds back to the model.
func (g *Gate) Check(ctx context.Context, toolName string, args json.RawMessage, readOnly bool) (bool, string, error) {
switch g.Policy.Decide(toolName, readOnly, args) {
case Deny:
return false, "denied by permission policy — this tool/command is on the deny list. Do not retry it; choose another approach or stop and explain.", nil
case Ask:
if g.Approver == nil {
return true, "", nil // non-interactive: preserve autonomy
}
subject := Subject(args)
allow, remember, err := g.Approver.Approve(ctx, toolName, subject, args)
if err != nil {
return false, "approval aborted", err
}
if !allow {
return false, "the user declined this tool call — do not retry it; ask how they would like to proceed or choose another approach.", nil
}
if remember && g.OnRemember != nil {
g.OnRemember(rememberRule(toolName, subject))
}
return true, "", nil
default:
return true, "", nil
}
}
// rememberRule builds the rule string persisted when the user picks "always
// allow". It pins the exact subject (command / path) so the remembered grant is
// narrow; the user can broaden it by hand later.
func rememberRule(toolName, subject string) string {
if subject == "" {
return toolName
}
return toolName + "(" + subject + ")"
}
+191
View File
@@ -0,0 +1,191 @@
package permission
import (
"context"
"encoding/json"
"errors"
"testing"
)
func TestParseRule(t *testing.T) {
cases := []struct {
in string
wantTool string
wantSubj string
wantOK bool
}{
{"bash", "bash", "", true},
{"bash(rm -rf*)", "bash", "rm -rf*", true},
{" read_file ", "read_file", "", true},
{"bash( go test ./... )", "bash", " go test ./... ", true}, // subject preserved verbatim
{"bash(echo (hi))", "bash", "echo (hi)", true}, // first '(' wins, trailing ')'
{"", "", "", false},
{"(noTool)", "", "", false},
}
for _, c := range cases {
r, ok := ParseRule(c.in)
if ok != c.wantOK {
t.Errorf("ParseRule(%q) ok = %v, want %v", c.in, ok, c.wantOK)
continue
}
if ok && (r.Tool != c.wantTool || r.Subject != c.wantSubj) {
t.Errorf("ParseRule(%q) = {%q,%q}, want {%q,%q}", c.in, r.Tool, r.Subject, c.wantTool, c.wantSubj)
}
}
}
func TestMatchGlob(t *testing.T) {
cases := []struct {
pattern, name string
want bool
}{
{"rm -rf*", "rm -rf /tmp/x", true}, // '*' crosses '/'
{"go test*", "go test ./...", true},
{"go test*", "go build", false},
{"*", "anything at all", true},
{"git ?ush", "git push", true},
{"git ?ush", "git rush", true},
{"git ?ush", "git pull", false},
{"exact", "exact", true},
{"exact", "exactly", false},
{"a*c", "abbbc", true},
{"a*c", "abbbd", false},
{"*.go", "main.go", true},
{"*.go", "main.rs", false},
}
for _, c := range cases {
if got := matchGlob(c.pattern, c.name); got != c.want {
t.Errorf("matchGlob(%q, %q) = %v, want %v", c.pattern, c.name, got, c.want)
}
}
}
func TestSubject(t *testing.T) {
cases := []struct {
args string
want string
}{
{`{"command":"go test ./..."}`, "go test ./..."},
{`{"file_path":"/a/b.go"}`, "/a/b.go"},
{`{"path":"/c/d"}`, "/c/d"},
{`{"pattern":"TODO","path":"/x"}`, "/x"}, // file_path/path beats pattern by key order
{`{"other":"x"}`, ""},
{`{}`, ""},
{``, ""},
{`not json`, ""},
}
for _, c := range cases {
if got := Subject(json.RawMessage(c.args)); got != c.want {
t.Errorf("Subject(%q) = %q, want %q", c.args, got, c.want)
}
}
}
func TestPolicyDecide(t *testing.T) {
p := New("ask",
[]string{"bash(go test*)", "ls"},
[]string{"read_file"}, // force a prompt even though readers default allow
[]string{"bash(rm -rf*)"},
)
cases := []struct {
name string
tool string
readOnly bool
args string
want Decision
}{
{"deny wins over fallback", "bash", false, `{"command":"rm -rf /"}`, Deny},
{"allow-listed command", "bash", false, `{"command":"go test ./..."}`, Allow},
{"writer fallback to mode(ask)", "bash", false, `{"command":"git commit"}`, Ask},
{"reader defaults allow", "grep", true, `{"pattern":"x"}`, Allow},
{"ask rule overrides reader-allow", "read_file", true, `{"path":"/a"}`, Ask},
{"bare allow rule", "ls", true, `{"path":"/a"}`, Allow},
{"subject rule needs subject", "bash", false, `{}`, Ask}, // no command → go test* can't match → fallback
}
for _, c := range cases {
got := p.Decide(c.tool, c.readOnly, json.RawMessage(c.args))
if got != c.want {
t.Errorf("%s: Decide(%q, ro=%v, %s) = %v, want %v", c.name, c.tool, c.readOnly, c.args, got, c.want)
}
}
}
func TestPolicyModeAllow(t *testing.T) {
// mode=allow: writers with no matching rule are allowed; deny still wins.
p := New("allow", nil, nil, []string{"bash(curl*)"})
if d := p.Decide("write_file", false, json.RawMessage(`{"path":"/a"}`)); d != Allow {
t.Errorf("writer fallback under mode=allow = %v, want Allow", d)
}
if d := p.Decide("bash", false, json.RawMessage(`{"command":"curl evil.sh"}`)); d != Deny {
t.Errorf("deny under mode=allow = %v, want Deny", d)
}
}
// stubApprover lets tests drive the Ask branch of Gate.Check.
type stubApprover struct {
allow bool
remember bool
err error
calls int
}
func (s *stubApprover) Approve(ctx context.Context, tool, subject string, args json.RawMessage) (bool, bool, error) {
s.calls++
return s.allow, s.remember, s.err
}
func TestGateHeadlessAllowsAsk(t *testing.T) {
// No approver → Ask resolves to allow (autonomy preserved), deny still blocks.
g := NewGate(New("ask", nil, nil, []string{"bash(rm*)"}), nil)
allow, _, err := g.Check(context.Background(), "bash", json.RawMessage(`{"command":"git commit"}`), false)
if err != nil || !allow {
t.Errorf("headless ask = (%v,%v), want allow", allow, err)
}
allow, reason, err := g.Check(context.Background(), "bash", json.RawMessage(`{"command":"rm file"}`), false)
if err != nil || allow || reason == "" {
t.Errorf("headless deny = (%v,%q,%v), want blocked with reason", allow, reason, err)
}
}
func TestGateInteractive(t *testing.T) {
var remembered string
ap := &stubApprover{allow: true, remember: true}
g := NewGate(New("ask", nil, nil, nil), ap)
g.OnRemember = func(rule string) { remembered = rule }
allow, _, err := g.Check(context.Background(), "bash", json.RawMessage(`{"command":"go build"}`), false)
if err != nil || !allow {
t.Fatalf("approved call = (%v,%v), want allow", allow, err)
}
if ap.calls != 1 {
t.Errorf("approver calls = %d, want 1", ap.calls)
}
if remembered != "bash(go build)" {
t.Errorf("remembered rule = %q, want %q", remembered, "bash(go build)")
}
// Decline path.
ap2 := &stubApprover{allow: false}
g2 := NewGate(New("ask", nil, nil, nil), ap2)
allow, reason, _ := g2.Check(context.Background(), "write_file", json.RawMessage(`{"path":"/a"}`), false)
if allow || reason == "" {
t.Errorf("declined call = (%v,%q), want blocked with reason", allow, reason)
}
// Error path aborts the turn.
ap3 := &stubApprover{err: errors.New("ctx cancelled")}
g3 := NewGate(New("ask", nil, nil, nil), ap3)
if _, _, err := g3.Check(context.Background(), "bash", json.RawMessage(`{"command":"x"}`), false); err == nil {
t.Error("approver error should propagate")
}
// Allowed-by-policy never reaches the approver.
ap4 := &stubApprover{allow: false}
g4 := NewGate(New("ask", []string{"bash(ok*)"}, nil, nil), ap4)
allow, _, _ = g4.Check(context.Background(), "bash", json.RawMessage(`{"command":"ok go"}`), false)
if !allow || ap4.calls != 0 {
t.Errorf("allow-listed call reached approver: allow=%v calls=%d", allow, ap4.calls)
}
}
+126
View File
@@ -0,0 +1,126 @@
package plugin
import (
"context"
"encoding/json"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"reasonix/internal/tool"
)
// buildExamplePlugin compiles cmd/reasonix-plugin-example into a temp binary and
// returns its path. Building from inside the module lets `go build` resolve the
// import path regardless of the test's working directory.
func buildExamplePlugin(t *testing.T) string {
t.Helper()
bin := filepath.Join(t.TempDir(), "reasonix-plugin-example")
if runtime.GOOS == "windows" {
bin += ".exe"
}
out, err := exec.Command("go", "build", "-o", bin, "reasonix/cmd/reasonix-plugin-example").CombinedOutput()
if err != nil {
t.Fatalf("build example plugin: %v\n%s", err, out)
}
return bin
}
// TestExamplePluginEndToEnd builds the real reference plugin and drives it
// through StartAll over actual stdio pipes — the genuine end-to-end contract,
// not a mock. It also asserts the readOnlyHint annotation flows through to
// ReadOnly(), which is what lets plugin tools join parallel batches and the
// permission reader-default.
func TestExamplePluginEndToEnd(t *testing.T) {
if testing.Short() {
t.Skip("compiles a subprocess; skipped under -short")
}
bin := buildExamplePlugin(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
host, tools, err := StartAll(ctx, []Spec{{Name: "example", Command: bin}})
if err != nil {
t.Fatalf("StartAll: %v", err)
}
defer host.Close()
byName := map[string]tool.Tool{}
for _, tl := range tools {
byName[tl.Name()] = tl
}
if len(tools) != 2 {
t.Fatalf("want 2 tools, got %d (%v)", len(tools), byName)
}
echo, ok := byName["mcp__example__echo"]
if !ok {
t.Fatal("mcp__example__echo not listed")
}
wc, ok := byName["mcp__example__wordcount"]
if !ok {
t.Fatal("mcp__example__wordcount not listed")
}
// readOnlyHint: true must surface as ReadOnly() == true.
if !echo.ReadOnly() || !wc.ReadOnly() {
t.Errorf("read-only annotation not honoured: echo=%v wordcount=%v", echo.ReadOnly(), wc.ReadOnly())
}
// echo round-trips its argument.
if got, err := echo.Execute(ctx, json.RawMessage(`{"text":"hi there"}`)); err != nil || got != "hi there" {
t.Errorf("echo = (%q, %v), want (\"hi there\", nil)", got, err)
}
// wordcount produces a structured count.
got, err := wc.Execute(ctx, json.RawMessage(`{"text":"alpha beta gamma"}`))
if err != nil {
t.Fatalf("wordcount: %v", err)
}
if !strings.Contains(got, "words: 3") {
t.Errorf("wordcount = %q, want it to contain \"words: 3\"", got)
}
// A handler-level failure (wrong arg type) comes back as an isError result,
// which the adapter surfaces as a Go error the model can read.
if _, err := echo.Execute(ctx, json.RawMessage(`{"text":123}`)); err == nil {
t.Error("echo with non-string text should return an error (isError result)")
}
// Prompts: the server advertises the capability, so the host discovers the
// "review" prompt and can render it with arguments.
prompts := host.Prompts()
if len(prompts) != 1 || prompts[0].Name != "mcp__example__review" {
t.Fatalf("prompts = %+v, want one mcp__example__review", prompts)
}
if len(prompts[0].Args) != 1 || prompts[0].Args[0].Name != "path" {
t.Errorf("prompt args = %+v, want one 'path'", prompts[0].Args)
}
rendered, err := prompts[0].Get(ctx, map[string]string{"path": "main.go"})
if err != nil {
t.Fatalf("prompt Get: %v", err)
}
if !strings.Contains(rendered, "main.go") {
t.Errorf("rendered prompt = %q, want it to mention main.go", rendered)
}
// Resources: discovered via the advertised capability, read by uri.
res := host.Resources()
if len(res) != 1 || res[0].URI != "doc://style-guide" {
t.Fatalf("resources = %+v, want one doc://style-guide", res)
}
content, err := host.ReadResource(ctx, "example", "doc://style-guide")
if err != nil {
t.Fatalf("ReadResource: %v", err)
}
if !strings.Contains(content, "style") {
t.Errorf("resource content = %q, want it to mention style", content)
}
if _, err := host.ReadResource(ctx, "example", "doc://missing"); err == nil {
t.Error("reading an unknown resource uri should error")
}
}
+389
View File
@@ -0,0 +1,389 @@
// Package plugin is Reasonix's MCP client. It connects to external MCP servers and
// adapts their tools to the tool.Tool interface, so the agent treats plugin
// tools and built-ins uniformly. The wire protocol is JSON-RPC 2.0 in every
// case; only the transport differs (stdio subprocess, Streamable HTTP, or the
// legacy HTTP+SSE). A transport interface hides that difference so the MCP-level
// logic — handshake, tools/list, tools/call — is written once.
package plugin
import (
"context"
"encoding/json"
"fmt"
"strings"
"reasonix/internal/tool"
)
// protocolVersion is the MCP revision Reasonix advertises during initialize.
const protocolVersion = "2024-11-05"
// Spec declares an external MCP server. Type selects the transport: "stdio"
// (default) runs Command/Args/Env as a subprocess; "http" / "streamable-http"
// and "sse" connect to URL with optional static Headers.
type Spec struct {
Name string
Type string
Command string
Args []string
Env map[string]string
URL string
Headers map[string]string
}
// transport carries JSON-RPC messages to and from one MCP server. call sends a
// request and returns its result (correlating by id internally); notify sends a
// fire-and-forget notification; close releases resources. Server-initiated
// messages (notifications, requests like roots/list) are ignored — Reasonix is a
// tools/prompts/resources consumer, not a sampling/roots provider (see SPEC §9).
type transport interface {
call(ctx context.Context, method string, params any) (json.RawMessage, error)
notify(ctx context.Context, method string, params any) error
close()
}
// Host owns the running plugin connections and closes them together. It also
// aggregates the prompts and resources discovered across servers, which the
// chat UI surfaces (prompts as slash commands, resources as @-references).
type Host struct {
clients []*Client
prompts []Prompt
resources []Resource
}
// Prompts returns every MCP prompt discovered across connected servers.
func (h *Host) Prompts() []Prompt { return h.prompts }
// Resources returns every MCP resource discovered across connected servers.
func (h *Host) Resources() []Resource { return h.resources }
// ServerNames returns the connected servers' names, in connection order.
func (h *Host) ServerNames() []string {
names := make([]string, len(h.clients))
for i, c := range h.clients {
names[i] = c.name
}
return names
}
// ReadResource reads a resource uri from the named server. It is how the chat
// UI resolves an @server:uri reference — the uri need not be one listed by
// resources/list (servers may expose templated uris), so we read it directly.
func (h *Host) ReadResource(ctx context.Context, server, uri string) (string, error) {
for _, c := range h.clients {
if c.name == server {
return c.readResource(ctx, uri)
}
}
return "", fmt.Errorf("no MCP server named %q", server)
}
// StartAll connects every plugin, performs the MCP handshake, and returns the
// union of their tools (namespaced "mcp__<server>__<tool>"). On any failure it
// tears down everything started so far. The caller must Close the Host.
//
// For stdio plugins, subprocess lifetime is bound to ctx (via
// exec.CommandContext): cancelling ctx kills the children and unblocks reads.
func StartAll(ctx context.Context, specs []Spec) (*Host, []tool.Tool, error) {
h := &Host{}
var tools []tool.Tool
for _, s := range specs {
c, err := start(ctx, s)
if err != nil {
h.Close()
return nil, nil, fmt.Errorf("start plugin %q: %w", s.Name, err)
}
h.clients = append(h.clients, c)
ts, err := c.listTools(ctx)
if err != nil {
h.Close()
return nil, nil, fmt.Errorf("list tools from %q: %w", s.Name, err)
}
tools = append(tools, ts...)
c.toolCount = len(ts)
// Prompts and resources are auxiliary: only fetched when the server
// advertised the capability, and a listing error is tolerated (skipped)
// rather than failing the whole session over a non-essential surface.
if c.hasPrompts {
if ps, perr := c.listPrompts(ctx); perr == nil {
h.prompts = append(h.prompts, ps...)
}
}
if c.hasResources {
if rs, rerr := c.listResources(ctx); rerr == nil {
h.resources = append(h.resources, rs...)
}
}
}
return h, tools, nil
}
// Close terminates all plugin connections.
func (h *Host) Close() {
for _, c := range h.clients {
c.close()
}
}
// Client is one MCP server connection: a name plus the transport carrying its
// JSON-RPC. The MCP-level methods (initialize, listTools, …) are transport-
// agnostic — they go through t.
type Client struct {
name string
t transport
// Capabilities advertised by the server at initialize. prompts/list and
// resources/list are only called when advertised, so we never provoke a
// "method not found" on a tools-only server.
hasPrompts bool
hasResources bool
toolCount int // tools discovered, for /mcp status
transport string // declared transport type, for /mcp status ("stdio"/"http")
}
// ServerStatus summarises one connected server for the /mcp command.
type ServerStatus struct {
Name string
Transport string
Tools int
Prompts int
Resources int
}
// Servers returns a status summary per connected server, in connection order.
func (h *Host) Servers() []ServerStatus {
out := make([]ServerStatus, 0, len(h.clients))
for _, c := range h.clients {
s := ServerStatus{Name: c.name, Transport: c.transport, Tools: c.toolCount}
for _, p := range h.prompts {
if p.Server == c.name {
s.Prompts++
}
}
for _, r := range h.resources {
if r.Server == c.name {
s.Resources++
}
}
out = append(out, s)
}
return out
}
func start(ctx context.Context, s Spec) (*Client, error) {
t, err := newTransport(ctx, s)
if err != nil {
return nil, err
}
tt := strings.ToLower(strings.TrimSpace(s.Type))
if tt == "" {
tt = "stdio"
}
c := &Client{name: s.Name, t: t, transport: tt}
if err := c.initialize(ctx); err != nil {
c.close()
return nil, err
}
return c, nil
}
// newTransport builds the transport for a spec's declared type. Empty / unknown
// defaults to stdio.
func newTransport(ctx context.Context, s Spec) (transport, error) {
switch strings.ToLower(strings.TrimSpace(s.Type)) {
case "", "stdio":
return newStdioTransport(ctx, s)
case "http", "streamable-http", "streamable_http":
return newHTTPTransport(s)
case "sse":
// The legacy 2024-11-05 HTTP+SSE transport needs a persistent GET stream
// with a background dispatcher — deprecated upstream ("avoid for new
// work"). Use type="http" (Streamable HTTP), which most remote servers
// now speak. Tracked for later (SPEC §9).
return nil, fmt.Errorf("plugin %q: legacy sse transport not yet supported — use type=\"http\" (Streamable HTTP)", s.Name)
default:
return nil, fmt.Errorf("unknown transport type %q (want stdio|http|sse)", s.Type)
}
}
func (c *Client) call(ctx context.Context, method string, params any) (json.RawMessage, error) {
return c.t.call(ctx, method, params)
}
func (c *Client) notify(ctx context.Context, method string, params any) error {
return c.t.notify(ctx, method, params)
}
func (c *Client) close() { c.t.close() }
func (c *Client) initialize(ctx context.Context) error {
res, err := c.call(ctx, "initialize", map[string]any{
"protocolVersion": protocolVersion,
"capabilities": map[string]any{},
"clientInfo": map[string]any{"name": "reasonix", "version": "dev"},
})
if err != nil {
return err
}
// Record which optional capabilities the server advertises. Presence of the
// key (even with an empty object) signals support.
var ir struct {
Capabilities map[string]json.RawMessage `json:"capabilities"`
}
_ = json.Unmarshal(res, &ir)
_, c.hasPrompts = ir.Capabilities["prompts"]
_, c.hasResources = ir.Capabilities["resources"]
return c.notify(ctx, "notifications/initialized", map[string]any{})
}
type mcpTool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema json.RawMessage `json:"inputSchema"`
// Annotations carries MCP's optional tool hints. We read readOnlyHint: a
// plugin that declares a tool read-only opts it into Reasonix's parallel-dispatch
// path and the permission layer's "readers default to allow". Absent
// annotations stay false — opaque by default, never trusted implicitly.
Annotations *struct {
ReadOnlyHint bool `json:"readOnlyHint"`
} `json:"annotations"`
}
func (c *Client) listTools(ctx context.Context) ([]tool.Tool, error) {
res, err := c.call(ctx, "tools/list", map[string]any{})
if err != nil {
return nil, err
}
var out struct {
Tools []mcpTool `json:"tools"`
}
if err := json.Unmarshal(res, &out); err != nil {
return nil, fmt.Errorf("plugin %q: decode tools/list: %w", c.name, err)
}
tools := make([]tool.Tool, 0, len(out.Tools))
for _, t := range out.Tools {
tools = append(tools, &remoteTool{
client: c,
name: toolName(c.name, t.Name),
rawName: t.Name,
desc: t.Description,
schema: t.InputSchema,
readOnly: t.Annotations != nil && t.Annotations.ReadOnlyHint,
})
}
return tools, nil
}
// toolName builds the model-visible namespaced name "mcp__<server>__<tool>",
// matching Claude Code. Spaces in either part are normalised to underscores so
// the name is a clean identifier the model can call.
func toolName(server, raw string) string {
return "mcp__" + normalizeName(server) + "__" + normalizeName(raw)
}
func normalizeName(s string) string { return strings.ReplaceAll(s, " ", "_") }
// --- JSON-RPC message types (shared by every transport) ---
type rpcRequest struct {
JSONRPC string `json:"jsonrpc"`
ID int `json:"id,omitempty"` // omitted for notifications (id 0 unused)
Method string `json:"method"`
Params any `json:"params,omitempty"`
}
type rpcResponse struct {
JSONRPC string `json:"jsonrpc"`
ID int `json:"id"`
Result json.RawMessage `json:"result"`
Error *rpcError `json:"error"`
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
func (e *rpcError) Error() string { return fmt.Sprintf("rpc error %d: %s", e.Code, e.Message) }
// --- remote tool adapter ---
type remoteTool struct {
client *Client
name string // namespaced "mcp__<server>__<tool>"
rawName string // original name for tools/call
desc string
schema json.RawMessage
readOnly bool // from the tool's MCP readOnlyHint annotation
}
func (t *remoteTool) Name() string { return t.name }
func (t *remoteTool) Description() string { return t.desc }
// ReadOnly reflects the tool's MCP readOnlyHint annotation. It defaults to
// false (opaque — we can't inspect a remote tool's side effects), so plugins
// opt into parallel-batch dispatch and the permission layer's reader-default
// by explicitly declaring readOnlyHint: true in tools/list.
func (t *remoteTool) ReadOnly() bool { return t.readOnly }
func (t *remoteTool) Schema() json.RawMessage {
if len(t.schema) == 0 {
return json.RawMessage(`{"type":"object"}`)
}
return t.schema
}
func (t *remoteTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
var argMap map[string]any
if len(args) > 0 {
if err := json.Unmarshal(args, &argMap); err != nil {
return "", fmt.Errorf("invalid args: %w", err)
}
}
res, err := t.client.call(ctx, "tools/call", map[string]any{
"name": t.rawName,
"arguments": argMap,
})
if err != nil {
return "", err
}
return parseToolResult(res)
}
// parseToolResult flattens an MCP tools/call result into plain text.
func parseToolResult(res json.RawMessage) (string, error) {
var out struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
IsError bool `json:"isError"`
}
if err := json.Unmarshal(res, &out); err != nil {
return "", fmt.Errorf("decode tool result: %w", err)
}
var sb strings.Builder
for _, c := range out.Content {
if c.Type == "text" {
sb.WriteString(c.Text)
}
}
text := sb.String()
if out.IsError {
return text, fmt.Errorf("plugin tool reported error: %s", text)
}
return text, nil
}
func envSlice(m map[string]string) []string {
out := make([]string, 0, len(m))
for k, v := range m {
out = append(out, k+"="+v)
}
return out
}
+113
View File
@@ -0,0 +1,113 @@
package plugin
import (
"bufio"
"bytes"
"context"
"encoding/json"
"os"
"testing"
"time"
)
// TestStdioEndToEnd drives a real subprocess (this test binary re-invoked in
// helper mode) through the full MCP handshake and a tool call, exercising
// StartAll, tools/list, and tools/call over stdio JSON-RPC.
func TestStdioEndToEnd(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
spec := Spec{
Name: "mock",
Command: os.Args[0],
Args: []string{"-test.run=TestHelperProcess", "--"},
Env: map[string]string{"GO_WANT_HELPER_PROCESS": "1"},
}
host, tools, err := StartAll(ctx, []Spec{spec})
if err != nil {
t.Fatalf("StartAll: %v", err)
}
defer host.Close()
if len(tools) != 1 {
t.Fatalf("want 1 tool, got %d", len(tools))
}
if got := tools[0].Name(); got != "mcp__mock__echo" {
t.Fatalf("tool name: want mcp__mock__echo, got %q", got)
}
out, err := tools[0].Execute(ctx, json.RawMessage(`{"msg":"hi"}`))
if err != nil {
t.Fatalf("Execute: %v", err)
}
if out != "echo: hi" {
t.Fatalf("result: want %q, got %q", "echo: hi", out)
}
}
// TestHelperProcess is not a real test; it acts as a minimal MCP stdio server
// when invoked by TestStdioEndToEnd. It exits before the test framework can
// print to stdout, keeping the JSON-RPC channel clean.
func TestHelperProcess(t *testing.T) {
if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
return
}
defer os.Exit(0)
in := bufio.NewReader(os.Stdin)
for {
line, err := in.ReadBytes('\n')
if err != nil {
return
}
line = bytes.TrimSpace(line)
if len(line) == 0 {
continue
}
var req struct {
ID *int `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
}
if err := json.Unmarshal(line, &req); err != nil {
continue
}
if req.ID == nil {
continue // notification: no response
}
var result any
switch req.Method {
case "initialize":
result = map[string]any{
"protocolVersion": protocolVersion,
"serverInfo": map[string]any{"name": "mock", "version": "0"},
}
case "tools/list":
result = map[string]any{"tools": []map[string]any{{
"name": "echo",
"description": "Echo back the message.",
"inputSchema": map[string]any{
"type": "object",
"properties": map[string]any{"msg": map[string]any{"type": "string"}},
},
}}}
case "tools/call":
var p struct {
Arguments struct {
Msg string `json:"msg"`
} `json:"arguments"`
}
_ = json.Unmarshal(req.Params, &p)
result = map[string]any{"content": []map[string]any{
{"type": "text", "text": "echo: " + p.Arguments.Msg},
}}
}
resp := map[string]any{"jsonrpc": "2.0", "id": *req.ID, "result": result}
b, _ := json.Marshal(resp)
os.Stdout.Write(append(b, '\n'))
}
}
+96
View File
@@ -0,0 +1,96 @@
package plugin
import (
"context"
"encoding/json"
"fmt"
"strings"
)
// Prompt is an MCP prompt exposed by a server. It surfaces in the chat TUI as a
// slash command "/mcp__<server>__<prompt>"; running it fetches the rendered
// prompt and sends it to the model as a turn.
type Prompt struct {
Name string // "mcp__<server>__<prompt>" — the slash-command body
Server string // owning server name
Raw string // original prompt name for prompts/get
Description string // human-readable summary
Args []PromptArg // declared arguments, in order
client *Client
}
// PromptArg is one declared prompt argument. Reasonix maps space-separated
// positional command arguments onto these in order, matching Claude Code.
type PromptArg struct {
Name string `json:"name"`
Description string `json:"description"`
Required bool `json:"required"`
}
// Get fetches the prompt with the given arguments and flattens its returned
// messages into a single text block to send to the model.
func (p Prompt) Get(ctx context.Context, args map[string]string) (string, error) {
return p.client.getPrompt(ctx, p.Raw, args)
}
func (c *Client) listPrompts(ctx context.Context) ([]Prompt, error) {
res, err := c.call(ctx, "prompts/list", map[string]any{})
if err != nil {
return nil, err
}
var out struct {
Prompts []struct {
Name string `json:"name"`
Description string `json:"description"`
Arguments []PromptArg `json:"arguments"`
} `json:"prompts"`
}
if err := json.Unmarshal(res, &out); err != nil {
return nil, fmt.Errorf("plugin %q: decode prompts/list: %w", c.name, err)
}
prompts := make([]Prompt, 0, len(out.Prompts))
for _, p := range out.Prompts {
prompts = append(prompts, Prompt{
Name: "mcp__" + normalizeName(c.name) + "__" + normalizeName(p.Name),
Server: c.name,
Raw: p.Name,
Description: p.Description,
Args: p.Arguments,
client: c,
})
}
return prompts, nil
}
func (c *Client) getPrompt(ctx context.Context, name string, args map[string]string) (string, error) {
params := map[string]any{"name": name}
if len(args) > 0 {
params["arguments"] = args
}
res, err := c.call(ctx, "prompts/get", params)
if err != nil {
return "", err
}
var out struct {
Messages []struct {
Role string `json:"role"`
Content struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
} `json:"messages"`
}
if err := json.Unmarshal(res, &out); err != nil {
return "", fmt.Errorf("plugin %q: decode prompts/get: %w", c.name, err)
}
var sb strings.Builder
for _, m := range out.Messages {
if m.Content.Type == "text" && m.Content.Text != "" {
if sb.Len() > 0 {
sb.WriteString("\n\n")
}
sb.WriteString(m.Content.Text)
}
}
return sb.String(), nil
}
+81
View File
@@ -0,0 +1,81 @@
package plugin
import (
"context"
"encoding/json"
"fmt"
"strings"
)
// Resource is an MCP resource a server exposes. In chat it is referenced as
// "@<server>:<uri>" (e.g. "@docs:file://README.md"); the referenced content is
// fetched and prepended to the message sent to the model.
type Resource struct {
Server string // owning server name
URI string // canonical resource uri
Name string // human-readable label
Description string
MimeType string
}
func (c *Client) listResources(ctx context.Context) ([]Resource, error) {
res, err := c.call(ctx, "resources/list", map[string]any{})
if err != nil {
return nil, err
}
var out struct {
Resources []struct {
URI string `json:"uri"`
Name string `json:"name"`
Description string `json:"description"`
MimeType string `json:"mimeType"`
} `json:"resources"`
}
if err := json.Unmarshal(res, &out); err != nil {
return nil, fmt.Errorf("plugin %q: decode resources/list: %w", c.name, err)
}
resources := make([]Resource, 0, len(out.Resources))
for _, r := range out.Resources {
resources = append(resources, Resource{
Server: c.name,
URI: r.URI,
Name: r.Name,
Description: r.Description,
MimeType: r.MimeType,
})
}
return resources, nil
}
// readResource fetches a resource by uri and flattens its text contents. Binary
// (blob) contents are noted but not decoded — a coding agent consumes text.
func (c *Client) readResource(ctx context.Context, uri string) (string, error) {
res, err := c.call(ctx, "resources/read", map[string]any{"uri": uri})
if err != nil {
return "", err
}
var out struct {
Contents []struct {
URI string `json:"uri"`
MimeType string `json:"mimeType"`
Text string `json:"text"`
Blob string `json:"blob"`
} `json:"contents"`
}
if err := json.Unmarshal(res, &out); err != nil {
return "", fmt.Errorf("plugin %q: decode resources/read: %w", c.name, err)
}
var sb strings.Builder
for _, ct := range out.Contents {
if sb.Len() > 0 {
sb.WriteString("\n\n")
}
switch {
case ct.Text != "":
sb.WriteString(ct.Text)
case ct.Blob != "":
fmt.Fprintf(&sb, "[binary resource %s, %s — %d base64 bytes omitted]", ct.URI, ct.MimeType, len(ct.Blob))
}
}
return sb.String(), nil
}
+199
View File
@@ -0,0 +1,199 @@
package plugin
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
)
// maxHTTPBody caps how much of a JSON / SSE response body we read, so a
// misbehaving server can't make us buffer without bound.
const maxHTTPBody = 16 << 20 // 16 MiB
// httpTransport speaks MCP's Streamable HTTP transport: every JSON-RPC message
// is an HTTP POST to the server URL. The server replies with either
// application/json (one response) or text/event-stream (an SSE stream carrying
// the response plus any server notifications). The Mcp-Session-Id header, once
// the server assigns one, is echoed on every subsequent request.
//
// The mutex serialises a request and its response. That means concurrent tool
// calls to the *same* server run one at a time; calls to different servers use
// different transports and stay concurrent. Correctness over latency for P1 —
// it also keeps nextID and the session id race-free.
type httpTransport struct {
name string
url string
headers map[string]string
client *http.Client
mu sync.Mutex
nextID int
session string // Mcp-Session-Id, captured from responses
}
func newHTTPTransport(s Spec) (*httpTransport, error) {
if s.URL == "" {
return nil, fmt.Errorf("http plugin %q: url is required", s.Name)
}
return &httpTransport{
name: s.Name,
url: s.URL,
headers: s.Headers,
client: &http.Client{},
}, nil
}
func (t *httpTransport) call(ctx context.Context, method string, params any) (json.RawMessage, error) {
t.mu.Lock()
defer t.mu.Unlock()
t.nextID++
id := t.nextID
body, err := json.Marshal(rpcRequest{JSONRPC: "2.0", ID: id, Method: method, Params: params})
if err != nil {
return nil, err
}
resp, err := t.do(ctx, body)
if err != nil {
return nil, fmt.Errorf("plugin %q: %s: %w", t.name, method, err)
}
defer resp.Body.Close()
t.captureSession(resp)
if resp.StatusCode/100 != 2 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("plugin %q: %s: http %d: %s", t.name, method, resp.StatusCode, strings.TrimSpace(string(b)))
}
if strings.HasPrefix(resp.Header.Get("Content-Type"), "text/event-stream") {
return t.readSSEResponse(resp.Body, id)
}
return decodeRPCResult(resp.Body, t.name)
}
func (t *httpTransport) notify(ctx context.Context, method string, params any) error {
t.mu.Lock()
defer t.mu.Unlock()
body, err := json.Marshal(rpcRequest{JSONRPC: "2.0", Method: method, Params: params})
if err != nil {
return err
}
resp, err := t.do(ctx, body)
if err != nil {
return fmt.Errorf("plugin %q: %s: %w", t.name, method, err)
}
defer resp.Body.Close()
t.captureSession(resp)
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxHTTPBody))
if resp.StatusCode/100 != 2 {
return fmt.Errorf("plugin %q: %s: http %d", t.name, method, resp.StatusCode)
}
return nil
}
func (t *httpTransport) close() {
t.client.CloseIdleConnections()
}
// do POSTs one JSON-RPC body with the standard MCP headers, the configured
// static headers, and the session id (once known). Caller holds t.mu.
func (t *httpTransport) do(ctx context.Context, body []byte) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, t.url, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
for k, v := range t.headers {
req.Header.Set(k, v)
}
if t.session != "" {
req.Header.Set("Mcp-Session-Id", t.session)
}
return t.client.Do(req)
}
func (t *httpTransport) captureSession(resp *http.Response) {
if sid := resp.Header.Get("Mcp-Session-Id"); sid != "" {
t.session = sid
}
}
// readSSEResponse scans an SSE stream for the JSON-RPC response matching id,
// skipping server notifications and any other-id messages. Per the SSE spec,
// consecutive data: lines within one event are joined with "\n" and an event is
// dispatched on the blank line that terminates it.
func (t *httpTransport) readSSEResponse(body io.Reader, id int) (json.RawMessage, error) {
sc := bufio.NewScanner(io.LimitReader(body, maxHTTPBody))
sc.Buffer(make([]byte, 0, 64*1024), maxHTTPBody)
var data strings.Builder
// match reports whether the accumulated event data is our response; it
// returns (result, matched, error).
match := func() (json.RawMessage, bool, error) {
if data.Len() == 0 {
return nil, false, nil
}
payload := data.String()
data.Reset()
var resp rpcResponse
if err := json.Unmarshal([]byte(payload), &resp); err != nil {
return nil, false, nil // not a JSON-RPC message we care about
}
if resp.ID != id {
return nil, false, nil // a notification or another call's response
}
if resp.Error != nil {
return nil, false, fmt.Errorf("plugin %q: %w", t.name, resp.Error)
}
return resp.Result, true, nil
}
for sc.Scan() {
line := sc.Text()
if line == "" { // event boundary
if res, ok, err := match(); err != nil || ok {
return res, err
}
continue
}
if v, found := strings.CutPrefix(line, "data:"); found {
if data.Len() > 0 {
data.WriteByte('\n')
}
data.WriteString(strings.TrimPrefix(v, " "))
}
// event:, id:, retry: and comments (":") are ignored
}
if err := sc.Err(); err != nil {
return nil, fmt.Errorf("plugin %q: read SSE: %w", t.name, err)
}
if res, ok, err := match(); err != nil || ok { // stream ended on a final unterminated event
return res, err
}
return nil, fmt.Errorf("plugin %q: SSE stream ended without a response to id %d", t.name, id)
}
// decodeRPCResult parses a single application/json JSON-RPC response body.
func decodeRPCResult(body io.Reader, name string) (json.RawMessage, error) {
b, err := io.ReadAll(io.LimitReader(body, maxHTTPBody))
if err != nil {
return nil, fmt.Errorf("plugin %q: read response: %w", name, err)
}
var resp rpcResponse
if err := json.Unmarshal(bytes.TrimSpace(b), &resp); err != nil {
return nil, fmt.Errorf("plugin %q: decode response: %w", name, err)
}
if resp.Error != nil {
return nil, fmt.Errorf("plugin %q: %w", name, resp.Error)
}
return resp.Result, nil
}
+163
View File
@@ -0,0 +1,163 @@
package plugin
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"reasonix/internal/tool"
)
// mcpHTTPServer is a minimal Streamable HTTP MCP server for tests. When sse is
// true it replies as text/event-stream (prefixing a server notification event
// to prove the client skips non-matching messages); otherwise application/json.
// It assigns a session id on initialize and fails any later request that
// doesn't echo it, and requires the Authorization header — so the test proves
// session + header plumbing, not just the happy path.
func mcpHTTPServer(t *testing.T, sse bool) *httptest.Server {
t.Helper()
const sessionID = "sess-xyz"
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer secret" {
http.Error(w, "missing auth", http.StatusUnauthorized)
return
}
var req struct {
ID *int `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad body", http.StatusBadRequest)
return
}
if req.Method == "initialize" {
w.Header().Set("Mcp-Session-Id", sessionID)
} else if got := r.Header.Get("Mcp-Session-Id"); got != sessionID {
http.Error(w, "missing session id", http.StatusBadRequest)
return
}
if req.ID == nil { // notification
w.WriteHeader(http.StatusAccepted)
return
}
var result any
switch req.Method {
case "initialize":
result = map[string]any{"protocolVersion": protocolVersion, "serverInfo": map[string]any{"name": "h", "version": "0"}}
case "tools/list":
result = map[string]any{"tools": []map[string]any{{
"name": "greet",
"description": "Greet someone.",
"inputSchema": map[string]any{"type": "object"},
"annotations": map[string]any{"readOnlyHint": true},
}}}
case "tools/call":
var p struct {
Arguments struct {
Name string `json:"name"`
} `json:"arguments"`
}
_ = json.Unmarshal(req.Params, &p)
result = map[string]any{"content": []map[string]any{{"type": "text", "text": "hello " + p.Arguments.Name}}}
}
resp := map[string]any{"jsonrpc": "2.0", "id": *req.ID, "result": result}
b, _ := json.Marshal(resp)
if sse {
w.Header().Set("Content-Type", "text/event-stream")
// A server notification first: the client must skip it and keep
// reading for the id-matching response.
fmt.Fprint(w, "event: message\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"notifications/message\",\"params\":{}}\n\n")
fmt.Fprintf(w, "event: message\ndata: %s\n\n", b)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(b)
}))
}
func runHTTPTransportTest(t *testing.T, sse bool) {
srv := mcpHTTPServer(t, sse)
defer srv.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
host, tools, err := StartAll(ctx, []Spec{{
Name: "h",
Type: "http",
URL: srv.URL,
Headers: map[string]string{"Authorization": "Bearer secret"},
}})
if err != nil {
t.Fatalf("StartAll: %v", err)
}
defer host.Close()
if len(tools) != 1 || tools[0].Name() != "mcp__h__greet" {
t.Fatalf("tools = %v, want [mcp__h__greet]", names(tools))
}
if !tools[0].ReadOnly() {
t.Error("readOnlyHint not honoured over HTTP")
}
got, err := tools[0].Execute(ctx, json.RawMessage(`{"name":"sam"}`))
if err != nil {
t.Fatalf("Execute: %v", err)
}
if got != "hello sam" {
t.Errorf("Execute = %q, want %q", got, "hello sam")
}
}
func TestHTTPTransportJSON(t *testing.T) { runHTTPTransportTest(t, false) }
func TestHTTPTransportSSE(t *testing.T) { runHTTPTransportTest(t, true) }
// TestHTTPTransportRPCError checks a JSON-RPC error response surfaces as an
// error rather than an empty result.
func TestHTTPTransportRPCError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req struct {
ID *int `json:"id"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
if req.ID == nil {
w.WriteHeader(http.StatusAccepted)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%d,"error":{"code":-32000,"message":"boom"}}`, *req.ID)
}))
defer srv.Close()
ctx := context.Background()
_, _, err := StartAll(ctx, []Spec{{Name: "e", Type: "http", URL: srv.URL}})
if err == nil || !strings.Contains(err.Error(), "boom") {
t.Fatalf("want initialize to fail with rpc error, got %v", err)
}
}
// TestSSETransportUnsupported documents that the legacy sse transport is
// recognised but deferred with a clear, actionable error.
func TestSSETransportUnsupported(t *testing.T) {
_, _, err := StartAll(context.Background(), []Spec{{Name: "legacy", Type: "sse", URL: "http://x"}})
if err == nil || !strings.Contains(err.Error(), "http") {
t.Fatalf("sse should error pointing to http, got %v", err)
}
}
func names(ts []tool.Tool) []string {
out := make([]string, len(ts))
for i, t := range ts {
out[i] = t.Name()
}
return out
}
+118
View File
@@ -0,0 +1,118 @@
package plugin
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"sync"
)
// stdioTransport speaks newline-delimited JSON-RPC 2.0 over a subprocess's
// stdin/stdout — the MCP stdio convention (one JSON message per line, no
// embedded newlines). The mutex serialises a request and its response on the
// shared pipe so concurrent tool calls don't interleave.
type stdioTransport struct {
name string
cmd *exec.Cmd
stdin io.WriteCloser
stdout *bufio.Reader
mu sync.Mutex
nextID int
}
func newStdioTransport(ctx context.Context, s Spec) (*stdioTransport, error) {
if s.Command == "" {
return nil, fmt.Errorf("stdio plugin %q: command is required", s.Name)
}
cmd := exec.CommandContext(ctx, s.Command, s.Args...)
cmd.Env = append(os.Environ(), envSlice(s.Env)...)
cmd.Stderr = os.Stderr // surface plugin logs to the terminal
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, err
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
if err := cmd.Start(); err != nil {
return nil, err
}
return &stdioTransport{name: s.Name, cmd: cmd, stdin: stdin, stdout: bufio.NewReader(stdout)}, nil
}
func (t *stdioTransport) call(ctx context.Context, method string, params any) (json.RawMessage, error) {
t.mu.Lock()
defer t.mu.Unlock()
t.nextID++
id := t.nextID
if err := t.write(rpcRequest{JSONRPC: "2.0", ID: id, Method: method, Params: params}); err != nil {
return nil, fmt.Errorf("plugin %q: write %s: %w", t.name, method, err)
}
for {
if err := ctx.Err(); err != nil {
return nil, err
}
line, err := t.stdout.ReadBytes('\n')
if err != nil {
return nil, fmt.Errorf("plugin %q: read: %w", t.name, err)
}
line = bytes.TrimSpace(line)
if len(line) == 0 {
continue
}
// Skip server-initiated notifications/requests (they carry a method).
var probe struct {
Method string `json:"method"`
}
_ = json.Unmarshal(line, &probe)
if probe.Method != "" {
continue
}
var resp rpcResponse
if err := json.Unmarshal(line, &resp); err != nil {
return nil, fmt.Errorf("plugin %q: decode response: %w", t.name, err)
}
if resp.ID != id {
continue
}
if resp.Error != nil {
return nil, fmt.Errorf("plugin %q: %w", t.name, resp.Error)
}
return resp.Result, nil
}
}
func (t *stdioTransport) notify(_ context.Context, method string, params any) error {
t.mu.Lock()
defer t.mu.Unlock()
return t.write(rpcRequest{JSONRPC: "2.0", Method: method, Params: params})
}
func (t *stdioTransport) write(v any) error {
b, err := json.Marshal(v) // marshaled JSON never contains a literal newline
if err != nil {
return err
}
_, err = t.stdin.Write(append(b, '\n'))
return err
}
func (t *stdioTransport) close() {
if t.stdin != nil {
_ = t.stdin.Close()
}
if t.cmd != nil && t.cmd.Process != nil {
_ = t.cmd.Process.Kill()
_ = t.cmd.Wait()
}
}
+374
View File
@@ -0,0 +1,374 @@
// Package openai implements the OpenAI-compatible /chat/completions provider.
// It self-registers under the "openai" kind, so DeepSeek, MiMo, and any other
// OpenAI-compatible endpoint are just config instances rather than code.
package openai
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math/rand"
"net/http"
"sort"
"strings"
"time"
"reasonix/internal/provider"
)
func init() {
provider.Register("openai", New)
}
// New builds an OpenAI-compatible provider from a resolved config.
func New(cfg provider.Config) (provider.Provider, error) {
if cfg.BaseURL == "" {
return nil, fmt.Errorf("openai: base_url is required for provider %q", cfg.Name)
}
if cfg.Model == "" {
return nil, fmt.Errorf("openai: model is required for provider %q", cfg.Name)
}
name := cfg.Name
if name == "" {
name = "openai"
}
keyEnv, _ := cfg.Extra["api_key_env"].(string) // for actionable auth errors
return &client{
name: name,
apiKey: cfg.APIKey,
keyEnv: keyEnv,
baseURL: strings.TrimRight(cfg.BaseURL, "/"),
model: cfg.Model,
http: &http.Client{}, // no overall timeout; lifecycle is ctx-driven
}, nil
}
type client struct {
name string
apiKey string
keyEnv string // api_key_env name, surfaced in auth errors
baseURL string
model string
http *http.Client
}
func (c *client) Name() string { return c.name }
func (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {
body, err := json.Marshal(c.buildRequest(req))
if err != nil {
return nil, fmt.Errorf("%s: marshal request: %w", c.name, err)
}
resp, err := c.sendWithRetry(ctx, body)
if err != nil {
return nil, err
}
out := make(chan provider.Chunk)
go c.readStream(resp, out)
return out, nil
}
// sendWithRetry POSTs the request body and returns the streaming response,
// retrying on transient network errors and retryable HTTP statuses (408, 429,
// 5xx) with exponential backoff + jitter. Retries only cover the connection +
// header phase; once we hand the response to readStream, mid-stream failures
// surface as ChunkError without retry, since the model has already started
// emitting tokens we'd otherwise duplicate.
func (c *client) sendWithRetry(ctx context.Context, body []byte) (*http.Response, error) {
const maxAttempts = 3
var lastErr error
for attempt := 0; attempt < maxAttempts; attempt++ {
if attempt > 0 {
delay := time.Duration(1<<(attempt-1))*500*time.Millisecond + time.Duration(rand.Intn(250))*time.Millisecond
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(delay):
}
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/chat/completions", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("%s: build request: %w", c.name, err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
httpReq.Header.Set("Accept", "text/event-stream")
resp, err := c.http.Do(httpReq)
if err != nil {
if !isTransientErr(err) {
return nil, fmt.Errorf("%s: request failed: %w", c.name, err)
}
lastErr = fmt.Errorf("%s: request failed: %w", c.name, err)
continue
}
if resp.StatusCode == http.StatusOK {
return resp, nil
}
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
resp.Body.Close()
// A rejected key is a configuration problem, not a transient one — give
// an actionable error instead of dumping the raw status body.
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return nil, &provider.AuthError{Provider: c.name, KeyEnv: c.keyEnv, Status: resp.StatusCode}
}
statusErr := fmt.Errorf("%s: status %d: %s", c.name, resp.StatusCode, strings.TrimSpace(string(msg)))
if !isRetryableStatus(resp.StatusCode) {
return nil, statusErr
}
lastErr = statusErr
}
return nil, lastErr
}
// isRetryableStatus returns true for HTTP status codes a transient backoff can
// reasonably recover from: 408 (request timeout), 429 (rate limit), and 5xx.
// 4xx other than 408/429 (auth, validation, not-found) are caller bugs and
// won't fix themselves on retry.
func isRetryableStatus(s int) bool {
return s == http.StatusRequestTimeout || s == http.StatusTooManyRequests || (s >= 500 && s <= 599)
}
// isTransientErr classifies HTTP client errors. ctx cancellation and deadline
// expiry are caller intent — never retry those. Everything else (DNS failures,
// connection resets, abrupt EOF, etc.) gets one more shot.
func isTransientErr(err error) bool {
if err == nil {
return false
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false
}
return true
}
func (c *client) buildRequest(req provider.Request) chatRequest {
msgs := make([]chatMessage, len(req.Messages))
for i, m := range req.Messages {
cm := chatMessage{
Role: string(m.Role),
Content: m.Content,
ReasoningContent: m.ReasoningContent,
ToolCallID: m.ToolCallID,
Name: m.Name,
}
for _, tc := range m.ToolCalls {
wire := chatToolCall{ID: tc.ID, Type: "function"}
wire.Function.Name = tc.Name
wire.Function.Arguments = tc.Arguments
cm.ToolCalls = append(cm.ToolCalls, wire)
}
msgs[i] = cm
}
var tools []chatTool
for _, t := range req.Tools {
tools = append(tools, chatTool{
Type: "function",
Function: chatFunction{Name: t.Name, Description: t.Description, Parameters: t.Parameters},
})
}
return chatRequest{
Model: c.model,
Messages: msgs,
Tools: tools,
Stream: true,
StreamOptions: &streamOptions{IncludeUsage: true},
Temperature: req.Temperature,
MaxTokens: req.MaxTokens,
}
}
// readStream parses the SSE stream, emits text deltas live, accumulates tool-call
// fragments internally, and emits complete ToolCalls (by index) when done.
func (c *client) readStream(resp *http.Response, out chan<- provider.Chunk) {
defer resp.Body.Close()
defer close(out)
acc := map[int]*provider.ToolCall{}
var order []int
var lastFinishReason string
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || !strings.HasPrefix(line, "data:") {
continue
}
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if data == "[DONE]" {
break
}
var sr streamResponse
if err := json.Unmarshal([]byte(data), &sr); err != nil {
out <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf("%s: decode stream: %w", c.name, err)}
return
}
if sr.Error != nil {
out <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf("%s: %s", c.name, sr.Error.Message)}
return
}
if len(sr.Choices) > 0 && sr.Choices[0].FinishReason != nil && *sr.Choices[0].FinishReason != "" {
lastFinishReason = *sr.Choices[0].FinishReason
}
if sr.Usage != nil {
u := normaliseUsage(sr.Usage)
u.FinishReason = lastFinishReason
out <- provider.Chunk{Type: provider.ChunkUsage, Usage: u}
}
if len(sr.Choices) == 0 {
continue
}
delta := sr.Choices[0].Delta
if delta.ReasoningContent != "" {
out <- provider.Chunk{Type: provider.ChunkReasoning, Text: delta.ReasoningContent}
}
if delta.Content != "" {
out <- provider.Chunk{Type: provider.ChunkText, Text: delta.Content}
}
for _, tc := range delta.ToolCalls {
cur, ok := acc[tc.Index]
if !ok {
cur = &provider.ToolCall{}
acc[tc.Index] = cur
order = append(order, tc.Index)
}
if tc.ID != "" {
cur.ID = tc.ID
}
if tc.Function.Name != "" {
cur.Name = tc.Function.Name
}
cur.Arguments += tc.Function.Arguments
}
}
if err := scanner.Err(); err != nil {
out <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf("%s: read stream: %w", c.name, err)}
return
}
sort.Ints(order)
for _, idx := range order {
out <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: acc[idx]}
}
out <- provider.Chunk{Type: provider.ChunkDone}
}
// normaliseUsage folds the two cache-hit shapes the OpenAI-compatible ecosystem
// uses into a single Usage: DeepSeek puts prompt_cache_{hit,miss}_tokens at the
// top of usage; OpenAI and MiMo put it nested under prompt_tokens_details.
// Whichever side reports non-zero wins; miss is derived when only hit is given.
// Reasoning tokens land in completion_tokens_details on thinking-mode models.
func normaliseUsage(u *wireUsage) *provider.Usage {
hit := u.PromptCacheHitTokens
miss := u.PromptCacheMissTokens
if hit == 0 && u.PromptTokensDetails != nil {
hit = u.PromptTokensDetails.CachedTokens
}
if miss == 0 && hit > 0 && u.PromptTokens > hit {
miss = u.PromptTokens - hit
}
reasoning := 0
if u.CompletionTokensDetails != nil {
reasoning = u.CompletionTokensDetails.ReasoningTokens
}
return &provider.Usage{
PromptTokens: u.PromptTokens,
CompletionTokens: u.CompletionTokens,
TotalTokens: u.TotalTokens,
CacheHitTokens: hit,
CacheMissTokens: miss,
ReasoningTokens: reasoning,
}
}
// --- OpenAI-compatible wire protocol ---
type chatRequest struct {
Model string `json:"model"`
Messages []chatMessage `json:"messages"`
Tools []chatTool `json:"tools,omitempty"`
Stream bool `json:"stream"`
StreamOptions *streamOptions `json:"stream_options,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
}
type streamOptions struct {
IncludeUsage bool `json:"include_usage"`
}
type chatMessage struct {
Role string `json:"role"`
Content string `json:"content,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"`
ToolCalls []chatToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
Name string `json:"name,omitempty"`
}
type chatTool struct {
Type string `json:"type"`
Function chatFunction `json:"function"`
}
type chatFunction struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters json.RawMessage `json:"parameters"`
}
type chatToolCall struct {
Index int `json:"index"`
ID string `json:"id,omitempty"`
Type string `json:"type,omitempty"`
Function struct {
Name string `json:"name,omitempty"`
Arguments string `json:"arguments,omitempty"`
} `json:"function"`
}
type streamResponse struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
ToolCalls []chatToolCall `json:"tool_calls"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
} `json:"choices"`
Usage *wireUsage `json:"usage"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
// wireUsage covers both DeepSeek's top-level cache fields and the
// OpenAI/MiMo nested details — normaliseUsage chooses whichever side
// reports values.
type wireUsage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
PromptCacheHitTokens int `json:"prompt_cache_hit_tokens"`
PromptCacheMissTokens int `json:"prompt_cache_miss_tokens"`
PromptTokensDetails *struct {
CachedTokens int `json:"cached_tokens"`
} `json:"prompt_tokens_details"`
CompletionTokensDetails *struct {
ReasoningTokens int `json:"reasoning_tokens"`
} `json:"completion_tokens_details"`
}
+117
View File
@@ -0,0 +1,117 @@
package openai
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"reasonix/internal/provider"
)
// TestIsRetryableStatus covers the boundary: 408/429/5xx retry, other 4xx don't.
func TestIsRetryableStatus(t *testing.T) {
retry := []int{408, 429, 500, 502, 503, 504, 599}
noRetry := []int{200, 400, 401, 403, 404, 422}
for _, s := range retry {
if !isRetryableStatus(s) {
t.Errorf("status %d should be retryable", s)
}
}
for _, s := range noRetry {
if isRetryableStatus(s) {
t.Errorf("status %d should not be retryable", s)
}
}
}
// TestIsTransientErr keeps user-intent errors (ctx cancel / deadline) out of
// the retry path while letting network-level failures through.
func TestIsTransientErr(t *testing.T) {
if isTransientErr(nil) {
t.Error("nil error should not be transient")
}
if isTransientErr(context.Canceled) {
t.Error("ctx canceled should not be transient")
}
if isTransientErr(context.DeadlineExceeded) {
t.Error("ctx deadline should not be transient")
}
if !isTransientErr(errors.New("connection reset")) {
t.Error("generic network-ish error should be transient")
}
}
// TestStreamAuthError verifies a 401 surfaces as an actionable *provider.AuthError
// (naming the provider and its key env var) rather than a raw status body.
func TestStreamAuthError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":{"message":"Authentication Fails, Your api key: ****ae54 is invalid"}}`))
}))
defer srv.Close()
p, err := New(provider.Config{
Name: "deepseek",
BaseURL: srv.URL,
Model: "deepseek-v4",
APIKey: "bad",
Extra: map[string]any{"api_key_env": "DEEPSEEK_API_KEY"},
})
if err != nil {
t.Fatalf("New: %v", err)
}
_, err = p.Stream(context.Background(), provider.Request{
Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}},
})
var authErr *provider.AuthError
if !errors.As(err, &authErr) {
t.Fatalf("want *provider.AuthError, got %T: %v", err, err)
}
if authErr.Provider != "deepseek" || authErr.KeyEnv != "DEEPSEEK_API_KEY" || authErr.Status != 401 {
t.Errorf("AuthError fields wrong: %+v", authErr)
}
if msg := authErr.Error(); !strings.Contains(msg, "DEEPSEEK_API_KEY") || strings.Contains(msg, "ae54") {
t.Errorf("message should name the env var and not dump the raw body: %q", msg)
}
}
// TestNormaliseUsageDeepSeekShape covers DeepSeek's top-level cache fields.
func TestNormaliseUsageDeepSeekShape(t *testing.T) {
u := normaliseUsage(&wireUsage{
PromptTokens: 1000,
CompletionTokens: 200,
TotalTokens: 1200,
PromptCacheHitTokens: 900,
PromptCacheMissTokens: 100,
})
if u.CacheHitTokens != 900 || u.CacheMissTokens != 100 {
t.Errorf("DeepSeek-shape cache fields lost: hit=%d miss=%d", u.CacheHitTokens, u.CacheMissTokens)
}
}
// TestNormaliseUsageMiMoShape covers the nested prompt_tokens_details /
// completion_tokens_details path used by OpenAI and MiMo. Miss is derived
// from prompt - hit when only hit is provided.
func TestNormaliseUsageMiMoShape(t *testing.T) {
u := normaliseUsage(&wireUsage{
PromptTokens: 1000,
CompletionTokens: 500,
TotalTokens: 1500,
PromptTokensDetails: &struct {
CachedTokens int `json:"cached_tokens"`
}{CachedTokens: 600},
CompletionTokensDetails: &struct {
ReasoningTokens int `json:"reasoning_tokens"`
}{ReasoningTokens: 180},
})
if u.CacheHitTokens != 600 || u.CacheMissTokens != 400 {
t.Errorf("nested cache normalisation wrong: hit=%d miss=%d (want 600 / 400)", u.CacheHitTokens, u.CacheMissTokens)
}
if u.ReasoningTokens != 180 {
t.Errorf("reasoning tokens lost: %d", u.ReasoningTokens)
}
}
+191
View File
@@ -0,0 +1,191 @@
// Package provider defines the model-backend abstraction and a registry mapping
// a provider "kind" to a factory. Concrete implementations live in subpackages
// (e.g. provider/openai) and self-register via init(). The core resolves
// providers by kind from config and never hardcodes a specific model.
package provider
import (
"context"
"encoding/json"
"fmt"
"sort"
)
// Role is the role of a message.
type Role string
const (
RoleSystem Role = "system"
RoleUser Role = "user"
RoleAssistant Role = "assistant"
RoleTool Role = "tool"
)
// Message is a single conversation message.
type Message struct {
Role Role `json:"role"`
Content string `json:"content,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"` // assistant: thinking-mode chain-of-thought, round-tripped on multi-turn
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // set by assistant
ToolCallID string `json:"tool_call_id,omitempty"` // links a tool result to its call
Name string `json:"name,omitempty"` // tool message: tool name
}
// ToolCall is a tool invocation requested by the model. Arguments is raw JSON.
type ToolCall struct {
ID string `json:"id"`
Name string `json:"name"`
Arguments string `json:"arguments"`
}
// ToolSchema is a tool definition exposed to the model. Parameters is JSON Schema.
type ToolSchema struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters json.RawMessage `json:"parameters"`
}
// Request is a single completion request.
type Request struct {
Messages []Message
Tools []ToolSchema
Temperature float64
MaxTokens int
}
// ChunkType identifies the kind of a streamed increment.
type ChunkType int
const (
ChunkText ChunkType = iota // text delta
ChunkReasoning // thinking-mode reasoning delta (before the visible answer)
ChunkToolCall // one complete tool call
ChunkUsage // token usage for the completion
ChunkDone // completion finished normally
ChunkError // an error occurred
)
// Usage reports token accounting for a completion. Cache hit/miss come from
// either DeepSeek's top-level prompt_cache_{hit,miss}_tokens or the OpenAI/MiMo
// standard prompt_tokens_details.cached_tokens — the openai provider normalises
// both shapes into these fields. ReasoningTokens is the thinking-mode subset of
// CompletionTokens reported by thinking-capable models. FinishReason carries
// the model's last reported choices[0].finish_reason so the agent can surface
// abnormal terminations ("length", "content_filter", "repetition_truncation").
type Usage struct {
PromptTokens int
CompletionTokens int
TotalTokens int
CacheHitTokens int // prompt tokens served from cache
CacheMissTokens int // prompt tokens not cached
ReasoningTokens int // subset of CompletionTokens spent on chain-of-thought
FinishReason string // "stop", "tool_calls", "length", "content_filter", "repetition_truncation", …
}
// Pricing is a provider's per-1M-token rates, used to estimate spend. Currency
// is just a display symbol (default "¥"). toml tags let config decode it.
type Pricing struct {
CacheHit float64 `toml:"cache_hit"` // per 1M cached prompt tokens
Input float64 `toml:"input"` // per 1M uncached prompt tokens
Output float64 `toml:"output"` // per 1M completion tokens
Currency string `toml:"currency"`
}
// Cost estimates the spend for a usage record.
func (p *Pricing) Cost(u *Usage) float64 {
if p == nil || u == nil {
return 0
}
return (float64(u.CacheHitTokens)*p.CacheHit +
float64(u.CacheMissTokens)*p.Input +
float64(u.CompletionTokens)*p.Output) / 1e6
}
// Symbol returns the currency display symbol, defaulting to "¥".
func (p *Pricing) Symbol() string {
if p == nil || p.Currency == "" {
return "¥"
}
return p.Currency
}
// Chunk is a single streamed event. Read the field matching Type.
type Chunk struct {
Type ChunkType
Text string // ChunkText, ChunkReasoning
ToolCall *ToolCall // ChunkToolCall
Usage *Usage // ChunkUsage
Err error // ChunkError
}
// Provider is a chat-capable model backend.
type Provider interface {
// Name returns the provider instance name, e.g. "deepseek" / "mimo".
Name() string
// Stream starts a streaming completion, pushing increments on the channel.
// Cancelling ctx must abort the underlying request; a closed channel marks
// the end of the completion.
Stream(ctx context.Context, req Request) (<-chan Chunk, error)
}
// Config is a resolved provider instance configuration.
type Config struct {
Name string // instance name, e.g. "deepseek"
BaseURL string // OpenAI-compatible endpoint
Model string // model id
APIKey string // resolved from api_key_env
Extra map[string]any // kind-specific options
}
// AuthError reports that a provider rejected the API key (HTTP 401/403). Its
// message is already user-facing and actionable — it names the provider and,
// when known, the environment variable the key comes from — so the CLI can
// surface it verbatim instead of dumping a raw status body. Providers should
// return this (rather than a generic status error) for auth failures.
type AuthError struct {
Provider string // the provider instance name, e.g. "deepseek"
KeyEnv string // the api_key_env the key is read from, when known
Status int // the HTTP status (401 or 403)
}
func (e *AuthError) Error() string {
key := "the API key"
if e.KeyEnv != "" {
key = e.KeyEnv
}
return fmt.Sprintf("authentication failed for provider %q (HTTP %d): %s is invalid or expired — update it (in .env or your environment) and retry, or run `reasonix init`",
e.Provider, e.Status, key)
}
// Factory builds a Provider from a resolved Config.
type Factory func(cfg Config) (Provider, error)
var registry = map[string]Factory{}
// Register adds a factory under a kind (e.g. "openai"). Intended for init().
// It panics on a duplicate kind, since that is a compile-time wiring mistake.
func Register(kind string, f Factory) {
if _, dup := registry[kind]; dup {
panic("provider: duplicate kind " + kind)
}
registry[kind] = f
}
// New instantiates the provider of the given kind.
func New(kind string, cfg Config) (Provider, error) {
f, ok := registry[kind]
if !ok {
return nil, fmt.Errorf("provider: unknown kind %q (registered: %v)", kind, Kinds())
}
return f(cfg)
}
// Kinds returns the registered kinds, sorted.
func Kinds() []string {
out := make([]string, 0, len(registry))
for k := range registry {
out = append(out, k)
}
sort.Strings(out)
return out
}
+30
View File
@@ -0,0 +1,30 @@
// Package sandbox wraps a shell command in an OS-level jail so the model's
// `bash` calls are confined: it may read freely but write only inside the
// workspace (plus temp and toolchain caches) and reach the network only when
// allowed. This is the *enforcement* layer beneath the permission rules
// (*policy*): a permitted command still cannot escape the box.
//
// Only macOS (Seatbelt via sandbox-exec) is implemented; on every other OS, or
// when the OS tooling is missing, Command falls back to running the command
// unwrapped (see Available). Confining the in-process file-writer built-ins is
// handled separately, in package tool/builtin.
package sandbox
// Spec describes how to confine one command. The zero value (Mode == "") does
// not enforce, so an unconfigured caller runs commands unchanged.
type Spec struct {
// Mode is "enforce" to wrap the command, anything else (incl. "off" and "")
// to run it unwrapped.
Mode string
// WriteRoots are directories the command may write to (the workspace root
// plus any configured extras). Temp dirs and common toolchain caches are
// added automatically so builds and package managers keep working.
WriteRoots []string
// Network allows network egress from inside the sandbox. Off blocks it so a
// command cannot exfiltrate or fetch; many dev commands (module/package
// downloads) need it, so it defaults on at the config layer.
Network bool
}
// enforce reports whether the spec asks for confinement.
func (s Spec) enforce() bool { return s.Mode == "enforce" }
+89
View File
@@ -0,0 +1,89 @@
package sandbox
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
// Command returns the argv to run `command` through `shell -c`, wrapped in
// sandbox-exec when the spec enforces and the tool is available. The second
// return is whether wrapping happened; false means the command runs unconfined
// (sandbox off, or sandbox-exec missing — a graceful fallback rather than a
// hard failure, since the permission layer still gates the call).
func Command(spec Spec, shell, command string) ([]string, bool) {
if !spec.enforce() || !Available() {
return []string{shell, "-c", command}, false
}
return []string{"sandbox-exec", "-p", seatbeltProfile(spec), shell, "-c", command}, true
}
// Available reports whether sandbox-exec is on PATH (it ships with macOS).
func Available() bool {
_, err := exec.LookPath("sandbox-exec")
return err == nil
}
// seatbeltProfile builds an SBPL profile that allows everything, then denies
// all file writes and re-allows them only under the write-roots (workspace +
// temp + caches). Network is denied unless allowed. Reads are left open so the
// toolchain (compilers reading GOROOT, git reading ~/.gitconfig, …) keeps
// working — the boundary this draws is "can't write outside the workspace, and
// optionally can't talk to the network", which is the Phase 0 blast-radius made
// to also cover arbitrary shell commands.
func seatbeltProfile(spec Spec) string {
var b strings.Builder
b.WriteString("(version 1)\n(allow default)\n(deny file-write*)\n(allow file-write*\n")
for _, p := range writeAllowDirs(spec.WriteRoots) {
fmt.Fprintf(&b, " (subpath %s)\n", sbplString(p))
}
b.WriteString(")\n")
if !spec.Network {
b.WriteString("(deny network*)\n")
}
return b.String()
}
// writeAllowDirs is the deduplicated, symlink-resolved set of directories the
// sandbox permits writes to: the caller's roots plus temp dirs, /dev, and the
// common toolchain caches under $HOME. Symlinks are resolved because macOS's
// /tmp and $TMPDIR live under /private, which is the path Seatbelt matches.
func writeAllowDirs(roots []string) []string {
dirs := append([]string{}, roots...)
dirs = append(dirs, "/dev", "/tmp", "/private/tmp", "/private/var/folders", os.TempDir())
if home, err := os.UserHomeDir(); err == nil {
// go build/test → Library/Caches + go; pip/etc → .cache; npm/cargo too.
for _, sub := range []string{"Library/Caches", ".cache", ".npm", ".cargo", "go"} {
dirs = append(dirs, filepath.Join(home, sub))
}
}
seen := map[string]bool{}
out := make([]string, 0, len(dirs))
for _, d := range dirs {
if d == "" {
continue
}
abs, err := filepath.Abs(d)
if err != nil {
continue
}
if real, err := filepath.EvalSymlinks(abs); err == nil {
abs = real
}
if !seen[abs] {
seen[abs] = true
out = append(out, abs)
}
}
return out
}
// sbplString quotes a path as an SBPL string literal, escaping backslash and
// double-quote so a path can't break out of the profile syntax.
func sbplString(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `"`, `\"`)
return `"` + s + `"`
}
+131
View File
@@ -0,0 +1,131 @@
package sandbox
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestCommandUnwrappedWhenOff(t *testing.T) {
argv, wrapped := Command(Spec{Mode: "off"}, "bash", "echo hi")
if wrapped {
t.Error("Mode=off should not wrap")
}
if len(argv) != 3 || argv[0] != "bash" || argv[1] != "-c" || argv[2] != "echo hi" {
t.Errorf("argv = %v, want [bash -c echo hi]", argv)
}
}
func TestProfileNetworkAndRoots(t *testing.T) {
with := seatbeltProfile(Spec{Mode: "enforce", WriteRoots: []string{"/work/proj"}, Network: true})
if strings.Contains(with, "(deny network*)") {
t.Error("network=true should not deny network")
}
if !strings.Contains(with, "(allow default)") || !strings.Contains(with, "(deny file-write*)") {
t.Error("profile missing base allow/deny structure")
}
if !strings.Contains(with, `(subpath "/work/proj")`) {
t.Errorf("profile missing the write-root subpath:\n%s", with)
}
without := seatbeltProfile(Spec{Mode: "enforce", Network: false})
if !strings.Contains(without, "(deny network*)") {
t.Error("network=false should deny network")
}
}
// TestSandboxEnforcesWrites runs real commands through sandbox-exec and checks
// the boundary: a write under a write-root succeeds, a write elsewhere under
// $HOME (not a root, not a cache dir) is refused, and reads are unrestricted.
// Dirs are created under $HOME (not /tmp, which the profile always allows) so
// the test exercises the root mechanism itself.
func TestSandboxEnforcesWrites(t *testing.T) {
if !Available() {
t.Skip("sandbox-exec not available")
}
home, err := os.UserHomeDir()
if err != nil {
t.Skipf("no home dir: %v", err)
}
workRoot, err := os.MkdirTemp(home, ".reasonix-sbtest-work-*")
if err != nil {
t.Skipf("cannot create work dir under home: %v", err)
}
t.Cleanup(func() { os.RemoveAll(workRoot) })
outside, err := os.MkdirTemp(home, ".reasonix-sbtest-out-*")
if err != nil {
t.Skipf("cannot create outside dir under home: %v", err)
}
t.Cleanup(func() { os.RemoveAll(outside) })
spec := Spec{Mode: "enforce", WriteRoots: []string{workRoot}, Network: true}
run := func(command string) error {
argv, wrapped := Command(spec, "bash", command)
if !wrapped {
t.Fatalf("expected wrapping for command %q", command)
}
return exec.Command(argv[0], argv[1:]...).Run()
}
// Write inside the root: allowed.
inFile := filepath.Join(workRoot, "in.txt")
if err := run("echo hi > " + inFile); err != nil {
t.Fatalf("write inside root failed: %v", err)
}
if _, err := os.Stat(inFile); err != nil {
t.Errorf("file not created inside root: %v", err)
}
// Write outside every root: refused (the command exits non-zero).
outFile := filepath.Join(outside, "out.txt")
if err := run("echo nope > " + outFile); err == nil {
t.Error("write outside root should be denied by the sandbox")
}
if _, err := os.Stat(outFile); !os.IsNotExist(err) {
t.Error("file outside root must not be created")
}
// Reading outside the root is allowed (read-all).
if err := run("cat /etc/hosts > " + filepath.Join(workRoot, "hosts.txt")); err != nil {
t.Errorf("read of /etc/hosts inside sandbox failed: %v", err)
}
}
// TestGoBuildUnderSandbox guards the default-on profile against the main risk:
// breaking the toolchain. `go build` writes to GOCACHE (under ~/Library/Caches)
// and a temp work dir, both of which the profile must allow, while output lands
// in the workspace. If this fails, the default profile is too tight.
func TestGoBuildUnderSandbox(t *testing.T) {
if !Available() {
t.Skip("sandbox-exec not available")
}
if _, err := exec.LookPath("go"); err != nil {
t.Skip("go not on PATH")
}
home, err := os.UserHomeDir()
if err != nil {
t.Skipf("no home dir: %v", err)
}
work, err := os.MkdirTemp(home, ".reasonix-sbtest-go-*")
if err != nil {
t.Skipf("cannot create work dir under home: %v", err)
}
t.Cleanup(func() { os.RemoveAll(work) })
write := func(name, body string) {
if err := os.WriteFile(filepath.Join(work, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
write("go.mod", "module sbtest\n\ngo 1.25\n")
write("main.go", "package main\nfunc main() { println(\"ok\") }\n")
spec := Spec{Mode: "enforce", WriteRoots: []string{work}, Network: true}
argv, _ := Command(spec, "bash", "cd "+work+" && go build -o sbtest .")
if out, err := exec.Command(argv[0], argv[1:]...).CombinedOutput(); err != nil {
t.Fatalf("go build under sandbox failed (profile too tight?): %v\n%s", err, out)
}
if _, err := os.Stat(filepath.Join(work, "sbtest")); err != nil {
t.Errorf("build output missing: %v", err)
}
}
+13
View File
@@ -0,0 +1,13 @@
//go:build !darwin
package sandbox
// Command runs the command unwrapped: no OS sandbox is implemented for this
// platform yet (Linux bubblewrap/landlock is the next step). The permission
// layer still gates the call.
func Command(spec Spec, shell, command string) ([]string, bool) {
return []string{shell, "-c", command}, false
}
// Available reports that no OS sandbox is available on this platform.
func Available() bool { return false }
+70
View File
@@ -0,0 +1,70 @@
package builtin
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os/exec"
"time"
"reasonix/internal/sandbox"
"reasonix/internal/tool"
)
const bashTimeout = 120 * time.Second
func init() { tool.RegisterBuiltin(bash{}) }
// bash runs a shell command with a timeout to avoid hangs. sb, when it enforces,
// wraps the command in an OS sandbox; the zero value registered at init runs
// unconfined and is overridden per run by ConfineBash.
type bash struct{ sb sandbox.Spec }
func (bash) Name() string { return "bash" }
func (bash) Description() string {
return "Execute a command in the shell and return combined stdout/stderr. Use for builds, tests, git, etc."
}
func (bash) Schema() json.RawMessage {
return json.RawMessage(`{"type":"object","properties":{"command":{"type":"string","description":"Shell command to execute"}},"required":["command"]}`)
}
// ReadOnly is false: bash's effect cannot be inferred from args (rm, curl,
// git commit, etc. are all reachable). Conservative even when a particular
// command happens to be read-only — the agent batch decision can't tell.
func (bash) ReadOnly() bool { return false }
func (b bash) Execute(ctx context.Context, args json.RawMessage) (string, error) {
var p struct {
Command string `json:"command"`
}
if err := json.Unmarshal(args, &p); err != nil {
return "", fmt.Errorf("invalid args: %w", err)
}
if p.Command == "" {
return "", fmt.Errorf("command is required")
}
ctx, cancel := context.WithTimeout(ctx, bashTimeout)
defer cancel()
// Wrap in the OS sandbox when configured; otherwise argv is just bash -c.
argv, _ := sandbox.Command(b.sb, "bash", p.Command)
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
var buf bytes.Buffer
cmd.Stdout = &buf
cmd.Stderr = &buf
err := cmd.Run()
out := buf.String()
if ctx.Err() == context.DeadlineExceeded {
return out, fmt.Errorf("command timed out (> %s)", bashTimeout)
}
if err != nil {
// Non-zero exit: feed output and error back so the model can self-correct.
return out, fmt.Errorf("command exited: %w", err)
}
return out, nil
}
+277
View File
@@ -0,0 +1,277 @@
package builtin
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/tool"
)
// argsJSON marshals m into the JSON form a tool expects. Tests must not build
// the JSON by concatenating Go strings: on Windows, t.TempDir() returns a path
// like C:\Users\… and the embedded backslashes are interpreted as JSON string
// escapes (\U triggers a parse error). json.Marshal handles the escaping.
func argsJSON(t *testing.T, m map[string]any) json.RawMessage {
t.Helper()
b, err := json.Marshal(m)
if err != nil {
t.Fatalf("marshal args: %v", err)
}
return json.RawMessage(b)
}
func runTool(t *testing.T, tl tool.Tool, m map[string]any) string {
t.Helper()
out, err := tl.Execute(context.Background(), argsJSON(t, m))
if err != nil {
t.Fatalf("%s: %v", tl.Name(), err)
}
return out
}
func TestBuiltinsRegistered(t *testing.T) {
want := []string{"bash", "edit_file", "glob", "grep", "ls", "multi_edit", "read_file", "web_fetch", "write_file"}
for _, name := range want {
if _, ok := tool.LookupBuiltin(name); !ok {
t.Errorf("built-in %q not registered", name)
}
}
}
// TestBuiltinReadOnlyClassification locks in which built-ins the agent may
// parallelise. Flipping a writer (write_file, edit_file, bash) to ReadOnly
// would re-order writes against reads in the same turn; this test fails fast
// if that ever happens. bash specifically must stay non-ReadOnly even though
// many invocations are pure reads — args aren't introspected.
func TestBuiltinReadOnlyClassification(t *testing.T) {
readOnly := map[string]bool{
"read_file": true, "ls": true, "glob": true, "grep": true, "web_fetch": true,
"write_file": false, "edit_file": false, "multi_edit": false, "bash": false,
}
for name, want := range readOnly {
tl, ok := tool.LookupBuiltin(name)
if !ok {
t.Fatalf("built-in %q not registered", name)
}
if got := tl.ReadOnly(); got != want {
t.Errorf("%s.ReadOnly() = %v, want %v", name, got, want)
}
}
}
func TestReadFile(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "src.go")
body := "package main\n\nfunc main() {}\n"
os.WriteFile(f, []byte(body), 0o644)
out := runTool(t, readFile{}, map[string]any{"path": f})
// Line numbers must be present, right-aligned, with the arrow separator.
for _, want := range []string{"1→package main", "2→", "3→func main"} {
if !strings.Contains(out, want) {
t.Errorf("missing %q in:\n%s", want, out)
}
}
}
func TestReadFileOffsetLimit(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "many.txt")
var b strings.Builder
for i := 1; i <= 50; i++ {
fmt.Fprintf(&b, "line %d\n", i)
}
os.WriteFile(f, []byte(b.String()), 0o644)
out := runTool(t, readFile{}, map[string]any{"path": f, "offset": 10, "limit": 5})
// Should see lines 11-15 only.
for _, want := range []string{"11→line 11", "15→line 15"} {
if !strings.Contains(out, want) {
t.Errorf("missing %q in:\n%s", want, out)
}
}
for _, leak := range []string{"line 5\n", "line 16\n", "line 20\n"} {
if strings.Contains(out, leak) {
t.Errorf("leaked %q (outside the slice)\n%s", leak, out)
}
}
// Trailer announces what's left so the model can paginate.
if !strings.Contains(out, "more line") || !strings.Contains(out, "offset=15") {
t.Errorf("pagination hint missing:\n%s", out)
}
}
func TestReadFileBinary(t *testing.T) {
f := filepath.Join(t.TempDir(), "blob")
os.WriteFile(f, []byte{0x7f, 'E', 'L', 'F', 0, 0, 0}, 0o644)
_, err := readFile{}.Execute(context.Background(), argsJSON(t, map[string]any{"path": f}))
if err == nil || !strings.Contains(err.Error(), "binary") {
t.Errorf("expected binary-file error, got %v", err)
}
}
func TestReadFileEmpty(t *testing.T) {
f := filepath.Join(t.TempDir(), "empty.txt")
os.WriteFile(f, nil, 0o644)
if out := runTool(t, readFile{}, map[string]any{"path": f}); !strings.Contains(out, "empty") {
t.Errorf("empty file should report empty, got %q", out)
}
}
func TestEditFile(t *testing.T) {
f := filepath.Join(t.TempDir(), "a.txt")
os.WriteFile(f, []byte("hello world\n"), 0o644)
runTool(t, editFile{}, map[string]any{"path": f, "old_string": "world", "new_string": "reasonix"})
if b, _ := os.ReadFile(f); string(b) != "hello reasonix\n" {
t.Fatalf("after edit = %q", b)
}
// Non-unique old_string must error and not modify the file.
os.WriteFile(f, []byte("x x x"), 0o644)
args := argsJSON(t, map[string]any{"path": f, "old_string": "x", "new_string": "y"})
if _, err := (editFile{}).Execute(context.Background(), args); err == nil {
t.Fatal("expected not-unique error")
}
if b, _ := os.ReadFile(f); string(b) != "x x x" {
t.Fatalf("file modified despite error: %q", b)
}
}
func TestMultiEdit(t *testing.T) {
f := filepath.Join(t.TempDir(), "src.go")
body := "package old\n\nfunc old() {\n\told()\n}\n"
os.WriteFile(f, []byte(body), 0o644)
// Two edits: rename the package (unique) then sweep every old → new.
out := runTool(t, multiEdit{}, map[string]any{
"path": f,
"edits": []map[string]any{
{"old_string": "package old", "new_string": "package new"},
{"old_string": "old", "new_string": "reasonix", "replace_all": true},
},
})
if !strings.Contains(out, "multi_edit") || !strings.Contains(out, "2 edits applied") {
t.Errorf("summary unexpected: %q", out)
}
got, _ := os.ReadFile(f)
want := "package new\n\nfunc reasonix() {\n\treasonix()\n}\n"
if string(got) != want {
t.Errorf("after multi_edit = %q\n want = %q", got, want)
}
}
// TestMultiEditAtomicity is the safety guarantee: if any edit fails, the file
// stays exactly as it was. A chained sequence of single edit_file calls would
// have left a half-written intermediate state.
func TestMultiEditAtomicity(t *testing.T) {
f := filepath.Join(t.TempDir(), "a.txt")
original := "alpha\nbeta\ngamma\n"
os.WriteFile(f, []byte(original), 0o644)
args := argsJSON(t, map[string]any{
"path": f,
"edits": []map[string]any{
{"old_string": "alpha", "new_string": "ALPHA"},
{"old_string": "no-such-text", "new_string": "x"},
{"old_string": "gamma", "new_string": "GAMMA"},
},
})
if _, err := (multiEdit{}).Execute(context.Background(), args); err == nil {
t.Fatal("expected failure on the missing edit")
}
got, _ := os.ReadFile(f)
if string(got) != original {
t.Errorf("file was modified despite failure:\n got %q\nwant %q", got, original)
}
}
func TestGrep(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "a.go"), []byte("package main\nfunc Foo() {}\n"), 0o644)
os.WriteFile(filepath.Join(dir, "b.go"), []byte("var x = 1\n"), 0o644)
out := runTool(t, grepTool{}, map[string]any{"pattern": "func ", "path": dir})
if !strings.Contains(out, "Foo") || strings.Contains(out, "var x") {
t.Fatalf("grep result = %q", out)
}
}
// TestWebFetchHTML serves a tiny HTML page and checks the reducer keeps the
// readable text while removing scripts, styles, and tags.
func TestWebFetchHTML(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(`<!doctype html>
<html><head><title>T</title><style>body{color:red}</style></head>
<body>
<h1>Hello &amp; world</h1>
<script>alert("bad")</script>
<p>Visible text.</p>
</body></html>`))
}))
defer srv.Close()
out := runTool(t, webFetch{}, map[string]any{"url": srv.URL})
for _, want := range []string{"Hello & world", "Visible text", "text/html"} {
if !strings.Contains(out, want) {
t.Errorf("missing %q in:\n%s", want, out)
}
}
for _, leak := range []string{"<script", "alert(", "<style", "<h1>", "&amp;"} {
if strings.Contains(out, leak) {
t.Errorf("leaked raw HTML/script %q", leak)
}
}
}
// TestWebFetchPlain confirms non-HTML bodies pass through untouched (apart
// from the prepended status header).
func TestWebFetchPlain(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte("line1\nline2\n"))
}))
defer srv.Close()
out := runTool(t, webFetch{}, map[string]any{"url": srv.URL})
if !strings.Contains(out, "line1") || !strings.Contains(out, "line2") {
t.Errorf("plain body content missing:\n%s", out)
}
if strings.Contains(out, "<") {
t.Errorf("html reducer ran on text/plain: %s", out)
}
}
// TestWebFetchSchemeRejected blocks anything that's not http(s) so the tool
// can't be tricked into reading file:// or arbitrary URI schemes.
func TestWebFetchSchemeRejected(t *testing.T) {
_, err := webFetch{}.Execute(context.Background(), argsJSON(t, map[string]any{"url": "file:///etc/passwd"}))
if err == nil || !strings.Contains(err.Error(), "http(s)") {
t.Errorf("expected scheme rejection, got %v", err)
}
}
func TestLsAndGlob(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "x.txt"), []byte("hi"), 0o644)
os.Mkdir(filepath.Join(dir, "sub"), 0o755)
ls := runTool(t, listDir{}, map[string]any{"path": dir})
if !strings.Contains(ls, "x.txt") || !strings.Contains(ls, "sub/") {
t.Fatalf("ls result = %q", ls)
}
g := runTool(t, globTool{}, map[string]any{"pattern": filepath.Join(dir, "*.txt")})
if !strings.Contains(g, "x.txt") {
t.Fatalf("glob result = %q", g)
}
}
+102
View File
@@ -0,0 +1,102 @@
package builtin
import (
"fmt"
"path/filepath"
"strings"
"reasonix/internal/sandbox"
"reasonix/internal/tool"
)
// ConfineBash returns the bash built-in bound to an OS-sandbox spec, overriding
// the unconfined instance registered at init. When the spec enforces, bash runs
// each command through the sandbox (see package sandbox).
func ConfineBash(spec sandbox.Spec) tool.Tool { return bash{sb: spec} }
// ConfineWriters returns the file-writing built-ins (write_file, edit_file,
// multi_edit) bound to roots — the only directories they may modify. The
// composition root adds these to the per-run registry to override the
// unconfined instances registered at init time, so writes stay inside the
// workspace by default. roots may be relative; they are resolved to absolute,
// symlink-free paths once here. An empty roots slice yields unconfined writers.
func ConfineWriters(roots []string) []tool.Tool {
rs := realRoots(roots)
return []tool.Tool{
writeFile{roots: rs},
editFile{roots: rs},
multiEdit{roots: rs},
}
}
// realRoots resolves each root to an absolute, symlink-free path, dropping any
// that cannot be made absolute. Resolving here (once) means the per-call check
// only has to resolve the target.
func realRoots(roots []string) []string {
out := make([]string, 0, len(roots))
for _, r := range roots {
if real, err := realPath(r); err == nil {
out = append(out, real)
}
}
return out
}
// confine reports an error when target resolves outside every root. An empty
// roots slice is unconfined (returns nil) — the safe default for the built-in
// templates before a run configures the workspace. The error text is written
// for the model: it names the boundary and how the user can widen it.
func confine(roots []string, target string) error {
if len(roots) == 0 {
return nil
}
abs, err := realPath(target)
if err != nil {
return fmt.Errorf("resolve %s: %w", target, err)
}
for _, r := range roots {
if within(r, abs) {
return nil
}
}
return fmt.Errorf("path %q is outside the workspace (writes are confined to %s); "+
"write inside it, or widen [sandbox] workspace_root / allow_write in reasonix.toml",
target, strings.Join(roots, ", "))
}
// realPath resolves path to an absolute, symlink-free form. Because a write
// target need not exist yet (write_file creates it), it resolves the deepest
// existing ancestor with EvalSymlinks and re-appends the not-yet-existing tail.
// This stops a symlinked directory from smuggling a write outside a root.
func realPath(path string) (string, error) {
abs, err := filepath.Abs(path)
if err != nil {
return "", err
}
abs = filepath.Clean(abs)
tail := ""
cur := abs
for {
if real, err := filepath.EvalSymlinks(cur); err == nil {
return filepath.Join(real, tail), nil
}
parent := filepath.Dir(cur)
if parent == cur {
return abs, nil // nothing along the path exists; use the cleaned abs
}
tail = filepath.Join(filepath.Base(cur), tail)
cur = parent
}
}
// within reports whether path is at or below root. Both must be absolute,
// cleaned, symlink-free. It uses filepath.Rel so it is correct across volumes
// and is not fooled by a prefix that only matches a partial path component
// (e.g. /work-other is not within /work).
func within(root, path string) bool {
rel, err := filepath.Rel(root, path)
if err != nil {
return false
}
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
}
+142
View File
@@ -0,0 +1,142 @@
package builtin
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"reasonix/internal/sandbox"
)
func TestWithin(t *testing.T) {
root := filepath.FromSlash("/work/proj")
cases := []struct {
path string
want bool
}{
{filepath.FromSlash("/work/proj"), true}, // the root itself
{filepath.FromSlash("/work/proj/a/b.go"), true}, // nested
{filepath.FromSlash("/work/proj/../proj/x"), true}, // normalises back inside
{filepath.FromSlash("/work/other"), false}, // sibling
{filepath.FromSlash("/work/proj-2"), false}, // prefix collision, not within
{filepath.FromSlash("/etc/passwd"), false}, // elsewhere
{filepath.FromSlash("/work"), false}, // parent
}
for _, c := range cases {
if got := within(root, filepath.Clean(c.path)); got != c.want {
t.Errorf("within(%q, %q) = %v, want %v", root, c.path, got, c.want)
}
}
}
func TestConfineUnconfinedWhenNoRoots(t *testing.T) {
if err := confine(nil, "/anywhere/at/all"); err != nil {
t.Errorf("empty roots should be unconfined, got %v", err)
}
}
func TestConfineInsideAndOutside(t *testing.T) {
root := t.TempDir()
roots := realRoots([]string{root})
if err := confine(roots, filepath.Join(root, "src", "main.go")); err != nil {
t.Errorf("path inside root rejected: %v", err)
}
// A sibling of the root and a parent escape must both be refused.
if err := confine(roots, filepath.Join(root, "..", "escape.txt")); err == nil {
t.Error("parent-escape path accepted, want error")
}
if err := confine(roots, filepath.Join(filepath.Dir(root), "neighbour", "x")); err == nil {
t.Error("sibling path accepted, want error")
}
}
func TestConfineRejectsSymlinkEscape(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
// A symlinked directory inside the root pointing outside must not become a
// tunnel: a write "within" the link still resolves outside the root.
link := filepath.Join(root, "out")
if err := os.Symlink(outside, link); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
roots := realRoots([]string{root})
if err := confine(roots, filepath.Join(link, "evil.txt")); err == nil {
t.Error("write through symlinked dir escaped the root, want error")
}
// A normal file under the real root still passes.
if err := confine(roots, filepath.Join(root, "ok.txt")); err != nil {
t.Errorf("legit path rejected: %v", err)
}
}
func TestWriteFileConfinement(t *testing.T) {
root := t.TempDir()
w := writeFile{roots: realRoots([]string{root})}
// Inside: written.
in := filepath.Join(root, "a", "in.txt")
args, _ := json.Marshal(map[string]string{"path": in, "content": "hi"})
if _, err := w.Execute(context.Background(), args); err != nil {
t.Fatalf("write inside root failed: %v", err)
}
if _, err := os.Stat(in); err != nil {
t.Errorf("file not created inside root: %v", err)
}
// Outside: refused, and the file must not be created.
out := filepath.Join(t.TempDir(), "out.txt")
args, _ = json.Marshal(map[string]string{"path": out, "content": "nope"})
if _, err := w.Execute(context.Background(), args); err == nil {
t.Error("write outside root should error")
}
if _, err := os.Stat(out); !os.IsNotExist(err) {
t.Error("file outside root must not be created")
}
}
func TestBashSandboxConfinement(t *testing.T) {
if !sandbox.Available() {
t.Skip("OS sandbox not available")
}
home, err := os.UserHomeDir()
if err != nil {
t.Skipf("no home dir: %v", err)
}
work, err := os.MkdirTemp(home, ".reasonix-bashsb-*")
if err != nil {
t.Skipf("cannot create work dir under home: %v", err)
}
t.Cleanup(func() { os.RemoveAll(work) })
b := ConfineBash(sandbox.Spec{Mode: "enforce", WriteRoots: []string{work}, Network: false})
// Writing inside the root works; writing to a sibling under $HOME is denied
// by the sandbox the bash tool wrapped the command in.
inArgs, _ := json.Marshal(map[string]string{"command": "echo hi > " + filepath.Join(work, "in.txt")})
if _, err := b.Execute(context.Background(), inArgs); err != nil {
t.Fatalf("bash write inside root failed: %v", err)
}
outPath := filepath.Join(home, ".reasonix-bashsb-escape.txt")
t.Cleanup(func() { os.Remove(outPath) })
outArgs, _ := json.Marshal(map[string]string{"command": "echo nope > " + outPath})
if _, err := b.Execute(context.Background(), outArgs); err == nil {
t.Error("bash write outside the workspace should be denied by the sandbox")
}
if _, err := os.Stat(outPath); !os.IsNotExist(err) {
t.Error("escaping write must not create the file")
}
}
func TestUnconfinedWriterWritesAnywhere(t *testing.T) {
// A zero-value writer (roots nil, as registered at init) is unconfined.
out := filepath.Join(t.TempDir(), "free.txt")
args, _ := json.Marshal(map[string]string{"path": out, "content": "ok"})
if _, err := (writeFile{}).Execute(context.Background(), args); err != nil {
t.Fatalf("unconfined write failed: %v", err)
}
if _, err := os.Stat(out); err != nil {
t.Errorf("unconfined writer did not write: %v", err)
}
}
+70
View File
@@ -0,0 +1,70 @@
package builtin
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"reasonix/internal/tool"
)
func init() { tool.RegisterBuiltin(editFile{}) }
// editFile replaces an exact string in a file. roots confines the target to the
// workspace when non-empty (see writeFile).
type editFile struct{ roots []string }
func (editFile) Name() string { return "edit_file" }
func (editFile) Description() string {
return "Replace an exact string in a file with another. old_string must occur exactly once; add surrounding context to disambiguate. Use for targeted edits instead of rewriting the whole file."
}
func (editFile) Schema() json.RawMessage {
return json.RawMessage(`{"type":"object","properties":{"path":{"type":"string","description":"File path"},"old_string":{"type":"string","description":"Exact text to replace (must be unique in the file)"},"new_string":{"type":"string","description":"Replacement text (may be empty to delete)"}},"required":["path","old_string","new_string"]}`)
}
func (editFile) ReadOnly() bool { return false }
func (e editFile) Execute(ctx context.Context, args json.RawMessage) (string, error) {
var p struct {
Path string `json:"path"`
OldString string `json:"old_string"`
NewString string `json:"new_string"`
}
if err := json.Unmarshal(args, &p); err != nil {
return "", fmt.Errorf("invalid args: %w", err)
}
if p.Path == "" {
return "", fmt.Errorf("path is required")
}
if p.OldString == "" {
return "", fmt.Errorf("old_string is required")
}
if err := confine(e.roots, p.Path); err != nil {
return "", err
}
b, err := os.ReadFile(p.Path)
if err != nil {
return "", fmt.Errorf("read %s: %w", p.Path, err)
}
content := string(b)
switch strings.Count(content, p.OldString) {
case 0:
return "", fmt.Errorf("old_string not found in %s", p.Path)
case 1:
// ok
default:
return "", fmt.Errorf("old_string is not unique in %s; add more surrounding context", p.Path)
}
updated := strings.Replace(content, p.OldString, p.NewString, 1)
if err := os.WriteFile(p.Path, []byte(updated), 0o644); err != nil {
return "", fmt.Errorf("write %s: %w", p.Path, err)
}
return fmt.Sprintf("edited %s", p.Path), nil
}
+48
View File
@@ -0,0 +1,48 @@
package builtin
import (
"context"
"encoding/json"
"fmt"
"path/filepath"
"strings"
"reasonix/internal/tool"
)
func init() { tool.RegisterBuiltin(globTool{}) }
type globTool struct{}
func (globTool) Name() string { return "glob" }
func (globTool) Description() string {
return "Find files matching a glob pattern (e.g. \"*.go\", \"internal/*/*.go\"). Supports the shell metacharacters * ? [ ]; does not support ** — use bash find for recursive matching."
}
func (globTool) Schema() json.RawMessage {
return json.RawMessage(`{"type":"object","properties":{"pattern":{"type":"string","description":"Glob pattern"}},"required":["pattern"]}`)
}
func (globTool) ReadOnly() bool { return true }
func (globTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
var p struct {
Pattern string `json:"pattern"`
}
if err := json.Unmarshal(args, &p); err != nil {
return "", fmt.Errorf("invalid args: %w", err)
}
if p.Pattern == "" {
return "", fmt.Errorf("pattern is required")
}
matches, err := filepath.Glob(p.Pattern)
if err != nil {
return "", fmt.Errorf("glob %q: %w", p.Pattern, err)
}
if len(matches) == 0 {
return "(no matches)", nil
}
return strings.Join(matches, "\n"), nil
}
+118
View File
@@ -0,0 +1,118 @@
package builtin
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strings"
"reasonix/internal/tool"
)
const grepMaxMatches = 200
func init() { tool.RegisterBuiltin(grepTool{}) }
type grepTool struct{}
func (grepTool) Name() string { return "grep" }
func (grepTool) Description() string {
return "Search for a regular expression in a file, or recursively under a directory. Returns matching lines as path:line:text, capped at 200 matches."
}
func (grepTool) Schema() json.RawMessage {
return json.RawMessage(`{"type":"object","properties":{"pattern":{"type":"string","description":"Regular expression (RE2 syntax)"},"path":{"type":"string","description":"File or directory to search (default \".\")"}},"required":["pattern"]}`)
}
func (grepTool) ReadOnly() bool { return true }
func (grepTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
var p struct {
Pattern string `json:"pattern"`
Path string `json:"path"`
}
if err := json.Unmarshal(args, &p); err != nil {
return "", fmt.Errorf("invalid args: %w", err)
}
if p.Pattern == "" {
return "", fmt.Errorf("pattern is required")
}
if p.Path == "" {
p.Path = "."
}
re, err := regexp.Compile(p.Pattern)
if err != nil {
return "", fmt.Errorf("invalid pattern: %w", err)
}
var out []string
truncated := false
// searchFile returns io.EOF as a sentinel once the cap is reached.
searchFile := func(file string) error {
f, err := os.Open(file)
if err != nil {
return nil // skip unreadable files
}
defer f.Close()
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
ln := 0
for sc.Scan() {
ln++
line := sc.Text()
if strings.IndexByte(line, 0) >= 0 {
return nil // looks binary, skip the file
}
if re.MatchString(line) {
out = append(out, fmt.Sprintf("%s:%d:%s", file, ln, line))
if len(out) >= grepMaxMatches {
truncated = true
return io.EOF
}
}
}
return nil
}
info, err := os.Stat(p.Path)
if err != nil {
return "", fmt.Errorf("grep %s: %w", p.Path, err)
}
if info.IsDir() {
_ = filepath.WalkDir(p.Path, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() {
if d.Name() == ".git" {
return filepath.SkipDir
}
return nil
}
if searchFile(path) == io.EOF {
return filepath.SkipAll
}
return nil
})
} else {
_ = searchFile(p.Path)
}
if len(out) == 0 {
return "(no matches)", nil
}
res := strings.Join(out, "\n")
if truncated {
res += fmt.Sprintf("\n... (truncated at %d matches)", grepMaxMatches)
}
return res, nil
}
+63
View File
@@ -0,0 +1,63 @@
package builtin
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"reasonix/internal/tool"
)
func init() { tool.RegisterBuiltin(listDir{}) }
type listDir struct{}
func (listDir) Name() string { return "ls" }
func (listDir) Description() string {
return "List the entries of a directory. Directories are shown with a trailing slash; files show their byte size."
}
func (listDir) Schema() json.RawMessage {
return json.RawMessage(`{"type":"object","properties":{"path":{"type":"string","description":"Directory path (default \".\")"}}}`)
}
func (listDir) ReadOnly() bool { return true }
func (listDir) Execute(ctx context.Context, args json.RawMessage) (string, error) {
p := struct {
Path string `json:"path"`
}{Path: "."}
if len(args) > 0 {
if err := json.Unmarshal(args, &p); err != nil {
return "", fmt.Errorf("invalid args: %w", err)
}
}
if p.Path == "" {
p.Path = "."
}
entries, err := os.ReadDir(p.Path)
if err != nil {
return "", fmt.Errorf("ls %s: %w", p.Path, err)
}
var b strings.Builder
for _, e := range entries {
if e.IsDir() {
fmt.Fprintf(&b, "%s/\n", e.Name())
continue
}
size := int64(-1)
if info, err := e.Info(); err == nil {
size = info.Size()
}
fmt.Fprintf(&b, "%s\t%d\n", e.Name(), size)
}
if b.Len() == 0 {
return "(empty directory)", nil
}
return b.String(), nil
}
+118
View File
@@ -0,0 +1,118 @@
package builtin
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"reasonix/internal/tool"
)
func init() { tool.RegisterBuiltin(multiEdit{}) }
// multiEdit applies a batch of edits to one file. roots confines the target to
// the workspace when non-empty (see writeFile).
type multiEdit struct{ roots []string }
// editStep is one edit in a multi_edit operation. Mirrors edit_file's args
// plus a per-step replace_all toggle so a single call can mix targeted and
// sweep replacements (e.g. rename a function with replace_all, then patch
// one specific call site with a unique-match edit).
type editStep struct {
OldString string `json:"old_string"`
NewString string `json:"new_string"`
ReplaceAll bool `json:"replace_all,omitempty"`
}
func (multiEdit) Name() string { return "multi_edit" }
func (multiEdit) Description() string {
return "Apply a list of edits to a single file atomically: each edit runs against the result of the previous one, all in memory; the file is rewritten only if every edit succeeds. Cheaper and safer than chaining edit_file calls — a failure in step 3 leaves the file untouched instead of half-edited."
}
func (multiEdit) Schema() json.RawMessage {
return json.RawMessage(`{
"type":"object",
"properties":{
"path":{"type":"string","description":"File path"},
"edits":{
"type":"array",
"minItems":1,
"description":"Ordered edits. Each step sees the file as left by the previous step.",
"items":{
"type":"object",
"properties":{
"old_string":{"type":"string","description":"Exact text to find. Without replace_all, must match exactly once."},
"new_string":{"type":"string","description":"Replacement text (empty deletes)."},
"replace_all":{"type":"boolean","description":"Replace every occurrence instead of requiring uniqueness."}
},
"required":["old_string","new_string"]
}
}
},
"required":["path","edits"]
}`)
}
func (multiEdit) ReadOnly() bool { return false }
func (m multiEdit) Execute(ctx context.Context, args json.RawMessage) (string, error) {
var p struct {
Path string `json:"path"`
Edits []editStep `json:"edits"`
}
if err := json.Unmarshal(args, &p); err != nil {
return "", fmt.Errorf("invalid args: %w", err)
}
if p.Path == "" {
return "", fmt.Errorf("path is required")
}
if len(p.Edits) == 0 {
return "", fmt.Errorf("edits must not be empty")
}
if err := confine(m.roots, p.Path); err != nil {
return "", err
}
b, err := os.ReadFile(p.Path)
if err != nil {
return "", fmt.Errorf("read %s: %w", p.Path, err)
}
content := string(b)
// Apply edits in order against the running in-memory buffer. Any failure
// returns before the write, leaving the file untouched — that's the
// safety guarantee that makes multi_edit preferable to chained
// edit_file calls.
applied := 0
for i, step := range p.Edits {
if step.OldString == "" {
return "", fmt.Errorf("edit %d: old_string is required", i+1)
}
if step.ReplaceAll {
count := strings.Count(content, step.OldString)
if count == 0 {
return "", fmt.Errorf("edit %d: old_string not found", i+1)
}
content = strings.ReplaceAll(content, step.OldString, step.NewString)
applied += count
continue
}
switch strings.Count(content, step.OldString) {
case 0:
return "", fmt.Errorf("edit %d: old_string not found", i+1)
case 1:
content = strings.Replace(content, step.OldString, step.NewString, 1)
applied++
default:
return "", fmt.Errorf("edit %d: old_string is not unique; add more surrounding context or set replace_all", i+1)
}
}
if err := os.WriteFile(p.Path, []byte(content), 0o644); err != nil {
return "", fmt.Errorf("write %s: %w", p.Path, err)
}
return fmt.Sprintf("multi_edit %s: %d edits applied (%d total replacements)", p.Path, len(p.Edits), applied), nil
}
+135
View File
@@ -0,0 +1,135 @@
// Package builtin provides Reasonix's compile-time built-in tools. Each tool
// self-registers via init(); main blank-imports this package to wire them in.
package builtin
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"reasonix/internal/tool"
)
func init() { tool.RegisterBuiltin(readFile{}) }
type readFile struct{}
const (
readFileDefaultLimit = 2000 // lines returned when limit is unset
readFileBinaryPeek = 8 * 1024 // bytes scanned for a NUL to flag binary
)
func (readFile) Name() string { return "read_file" }
func (readFile) Description() string {
return "Read a text file with optional line offset/limit. Output prefixes each line with its 1-based number (e.g. ` 42→...`) so subsequent edit_file calls can target exact lines. Use `offset` and `limit` to page through large files; the tool reports total length and pagination hints in a trailer."
}
func (readFile) Schema() json.RawMessage {
return json.RawMessage(`{
"type":"object",
"properties":{
"path":{"type":"string","description":"File path"},
"offset":{"type":"integer","description":"0-based line offset to start reading from (default 0)","minimum":0},
"limit":{"type":"integer","description":"Maximum lines to return (default 2000)","minimum":1}
},
"required":["path"]
}`)
}
func (readFile) ReadOnly() bool { return true }
func (readFile) Execute(ctx context.Context, args json.RawMessage) (string, error) {
var p struct {
Path string `json:"path"`
Offset int `json:"offset,omitempty"`
Limit int `json:"limit,omitempty"`
}
if err := json.Unmarshal(args, &p); err != nil {
return "", fmt.Errorf("invalid args: %w", err)
}
if p.Path == "" {
return "", fmt.Errorf("path is required")
}
if p.Offset < 0 {
p.Offset = 0
}
if p.Limit <= 0 {
p.Limit = readFileDefaultLimit
}
f, err := os.Open(p.Path)
if err != nil {
return "", fmt.Errorf("read %s: %w", p.Path, err)
}
defer f.Close()
// Refuse binary files up front. A NUL byte anywhere in the leading 8 KB
// is the cheapest reliable signal — UTF-16 / textual config files don't
// embed NULs in the way executables, archives, or images do.
peek := make([]byte, readFileBinaryPeek)
n, _ := io.ReadFull(f, peek)
if bytes.IndexByte(peek[:n], 0) >= 0 {
return "", fmt.Errorf("binary file %s (NUL byte detected); use `bash hexdump` or another tool", p.Path)
}
if _, err := f.Seek(0, io.SeekStart); err != nil {
return "", fmt.Errorf("seek %s: %w", p.Path, err)
}
// Scan up to offset+limit+1 lines (the extra is just to know whether
// trimming a trailer is warranted). 1 MB per-line cap matches what other
// scanners in this package allow — well above any reasonable source line.
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
upTo := p.Offset + p.Limit + 1
var collected []string
lineNo := 0
for scanner.Scan() {
lineNo++
if lineNo > p.Offset && len(collected) < p.Limit {
collected = append(collected, scanner.Text())
}
if lineNo >= upTo {
// Keep counting to know how many more lines remain.
break
}
}
// Drain any remainder to learn the true total without buffering the rest.
remaining := 0
for scanner.Scan() {
remaining++
}
if err := scanner.Err(); err != nil {
return "", fmt.Errorf("read %s: %w", p.Path, err)
}
totalSeen := lineNo + remaining
if totalSeen == 0 {
return "(empty file)", nil
}
if len(collected) == 0 {
return fmt.Sprintf("(offset %d is past EOF — file has %d lines)", p.Offset, totalSeen), nil
}
// Right-align line numbers to the largest one we'll print, so the arrow
// "→" column lines up. Add 1 for the 1-based display.
maxShown := p.Offset + len(collected)
w := len(fmt.Sprint(maxShown))
var b strings.Builder
for i, line := range collected {
fmt.Fprintf(&b, "%*d→%s\n", w, p.Offset+i+1, line)
}
more := totalSeen - (p.Offset + len(collected))
if more > 0 {
fmt.Fprintf(&b, "\n[%d more line(s); pass offset=%d to continue]\n",
more, p.Offset+len(collected))
}
return b.String(), nil
}
+145
View File
@@ -0,0 +1,145 @@
package builtin
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"reasonix/internal/tool"
)
func init() { tool.RegisterBuiltin(webFetch{}) }
type webFetch struct{}
const (
webFetchTimeout = 15 * time.Second
webFetchMaxRead = 1 << 20 // 1 MiB cap before extraction
)
func (webFetch) Name() string { return "web_fetch" }
func (webFetch) Description() string {
return "Fetch a URL over HTTPS/HTTP and return its text content. HTML pages are reduced to readable text (scripts, styles, tags stripped, whitespace collapsed); JSON / plain text / markdown bodies come back verbatim. Use to read documentation pages, API responses, or source files hosted somewhere the local filesystem can't reach."
}
func (webFetch) Schema() json.RawMessage {
return json.RawMessage(`{
"type":"object",
"properties":{
"url":{"type":"string","description":"Absolute URL beginning with http:// or https://"}
},
"required":["url"]
}`)
}
func (webFetch) ReadOnly() bool { return true }
func (webFetch) Execute(ctx context.Context, args json.RawMessage) (string, error) {
var p struct {
URL string `json:"url"`
}
if err := json.Unmarshal(args, &p); err != nil {
return "", fmt.Errorf("invalid args: %w", err)
}
if p.URL == "" {
return "", fmt.Errorf("url is required")
}
u, err := url.Parse(p.URL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
return "", fmt.Errorf("url must be an absolute http(s) address")
}
reqCtx, cancel := context.WithTimeout(ctx, webFetchTimeout)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, p.URL, nil)
if err != nil {
return "", fmt.Errorf("build request: %w", err)
}
// A plain UA + Accept tip the server toward returning text/HTML rather
// than minified asset bundles or binary content.
req.Header.Set("User-Agent", "reasonix-web-fetch/1.0")
req.Header.Set("Accept", "text/html,text/plain,text/markdown,application/json,*/*;q=0.5")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("fetch %s: %w", p.URL, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, webFetchMaxRead))
if err != nil {
return "", fmt.Errorf("read body: %w", err)
}
ct := strings.ToLower(resp.Header.Get("Content-Type"))
out := string(body)
if strings.Contains(ct, "text/html") || looksLikeHTML(out) {
out = htmlToText(out)
}
out = strings.TrimSpace(out)
if out == "" {
return fmt.Sprintf("(empty body — status %s)", resp.Status), nil
}
header := fmt.Sprintf("status %s · %s · %d bytes\n\n", resp.Status, contentTypeShort(ct), len(body))
return header + out, nil
}
// looksLikeHTML lets servers that misreport Content-Type still hit the HTML
// reducer — GitHub raw pages and many docs sites lie about content type.
func looksLikeHTML(s string) bool {
head := s
if len(head) > 512 {
head = head[:512]
}
low := strings.ToLower(head)
return strings.Contains(low, "<!doctype html") || strings.Contains(low, "<html")
}
var (
scriptStyle = regexp.MustCompile(`(?is)<(script|style)[^>]*>.*?</(?:script|style)>`)
htmlComment = regexp.MustCompile(`(?s)<!--.*?-->`)
anyTag = regexp.MustCompile(`(?s)<[^>]+>`)
multiBlank = regexp.MustCompile(`\n[\t ]*\n([\t ]*\n)+`)
trailingWS = regexp.MustCompile(`[\t ]+\n`)
)
// htmlToText strips <script>/<style> blocks, HTML comments, and every other
// tag, then unescapes the common entities and collapses runs of blank lines.
// It is intentionally lossy — we want to give the model readable text rather
// than preserve structure for re-rendering.
func htmlToText(s string) string {
s = scriptStyle.ReplaceAllString(s, "")
s = htmlComment.ReplaceAllString(s, "")
s = anyTag.ReplaceAllString(s, "")
// Unescape the entities the model is most likely to encounter. Avoids
// pulling in html.UnescapeString just to handle five characters.
repl := strings.NewReplacer(
"&amp;", "&",
"&lt;", "<",
"&gt;", ">",
"&quot;", `"`,
"&#39;", "'",
"&apos;", "'",
"&nbsp;", " ",
)
s = repl.Replace(s)
s = trailingWS.ReplaceAllString(s, "\n")
s = multiBlank.ReplaceAllString(s, "\n\n")
return s
}
func contentTypeShort(ct string) string {
if i := strings.IndexByte(ct, ';'); i >= 0 {
ct = ct[:i]
}
return strings.TrimSpace(ct)
}
+55
View File
@@ -0,0 +1,55 @@
package builtin
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"reasonix/internal/tool"
)
func init() { tool.RegisterBuiltin(writeFile{}) }
// writeFile writes a file. roots, when non-empty, confines the target to the
// workspace (see confine); the zero value registered at init is unconfined and
// is overridden per run by ConfineWriters.
type writeFile struct{ roots []string }
func (writeFile) Name() string { return "write_file" }
func (writeFile) Description() string {
return "Write content to a file at the given path (overwriting existing content). Creates parent directories as needed."
}
func (writeFile) Schema() json.RawMessage {
return json.RawMessage(`{"type":"object","properties":{"path":{"type":"string","description":"File path"},"content":{"type":"string","description":"Full content to write"}},"required":["path","content"]}`)
}
func (writeFile) ReadOnly() bool { return false }
func (w writeFile) Execute(ctx context.Context, args json.RawMessage) (string, error) {
var p struct {
Path string `json:"path"`
Content string `json:"content"`
}
if err := json.Unmarshal(args, &p); err != nil {
return "", fmt.Errorf("invalid args: %w", err)
}
if p.Path == "" {
return "", fmt.Errorf("path is required")
}
if err := confine(w.roots, p.Path); err != nil {
return "", err
}
if dir := filepath.Dir(p.Path); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", fmt.Errorf("mkdir %s: %w", dir, err)
}
}
if err := os.WriteFile(p.Path, []byte(p.Content), 0o644); err != nil {
return "", fmt.Errorf("write %s: %w", p.Path, err)
}
return fmt.Sprintf("wrote %d bytes to %s", len(p.Content), p.Path), nil
}
+116
View File
@@ -0,0 +1,116 @@
// Package tool defines the Tool abstraction and a Registry. Built-in tools live
// in tool/builtin and self-register via init(); plugin-provided tools are added
// to a runtime Registry alongside the enabled built-ins. The agent sees only a
// *Registry, never the global built-in set directly.
package tool
import (
"context"
"encoding/json"
"sort"
"reasonix/internal/provider"
)
// Tool is a capability the model can invoke.
type Tool interface {
Name() string
Description() string
// Schema returns the JSON Schema for the tool's parameters.
Schema() json.RawMessage
// Execute parses the model-generated raw JSON args and returns result text
// to feed back to the model.
Execute(ctx context.Context, args json.RawMessage) (string, error)
// ReadOnly reports whether the tool has no observable side effects on the
// host. The agent parallelises a batch of tool calls only when every call
// in the batch is ReadOnly; mixed batches stay sequential so write/read
// ordering is preserved. bash and plugin tools must return false because
// their effects can't be inferred statically from args.
ReadOnly() bool
}
// --- process-global built-in set (populated by builtin subpackage init) ---
var builtins = map[string]Tool{}
// RegisterBuiltin registers a compile-time built-in tool. Intended for init().
// It panics on a duplicate name, which is a compile-time wiring mistake.
func RegisterBuiltin(t Tool) {
name := t.Name()
if _, dup := builtins[name]; dup {
panic("tool: duplicate built-in " + name)
}
builtins[name] = t
}
// Builtins returns all registered built-in tools, sorted by name.
func Builtins() []Tool {
names := make([]string, 0, len(builtins))
for n := range builtins {
names = append(names, n)
}
sort.Strings(names)
out := make([]Tool, 0, len(names))
for _, n := range names {
out = append(out, builtins[n])
}
return out
}
// LookupBuiltin returns a registered built-in by name.
func LookupBuiltin(name string) (Tool, bool) {
t, ok := builtins[name]
return t, ok
}
// --- per-run registry instance ---
// Registry is a per-run set of tools: enabled built-ins plus plugin tools.
type Registry struct {
tools map[string]Tool
order []string
}
// NewRegistry returns an empty registry.
func NewRegistry() *Registry {
return &Registry{tools: map[string]Tool{}}
}
// Add inserts (or replaces) a tool, preserving first-seen order.
func (r *Registry) Add(t Tool) {
name := t.Name()
if _, ok := r.tools[name]; !ok {
r.order = append(r.order, name)
}
r.tools[name] = t
}
// Get looks up a tool by name.
func (r *Registry) Get(name string) (Tool, bool) {
t, ok := r.tools[name]
return t, ok
}
// Len returns the number of registered tools.
func (r *Registry) Len() int { return len(r.order) }
// Names returns the registered tool names in insertion order.
func (r *Registry) Names() []string {
out := make([]string, len(r.order))
copy(out, r.order)
return out
}
// Schemas exports tool definitions in insertion order for the provider.
func (r *Registry) Schemas() []provider.ToolSchema {
out := make([]provider.ToolSchema, 0, len(r.order))
for _, name := range r.order {
t := r.tools[name]
out = append(out, provider.ToolSchema{
Name: t.Name(),
Description: t.Description(),
Parameters: t.Schema(),
})
}
return out
}
+57
View File
@@ -0,0 +1,57 @@
# Reasonix configuration.
# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.
# Secrets come from the environment via api_key_env; never put keys here.
default_model = "deepseek-flash"
# language = "zh" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG
[agent]
system_prompt = """
You are Reasonix, a coding agent focused on executing code tasks.
Use the provided tools to read and write files and run shell commands.
Principles: understand the request before acting; verify with tools instead of
guessing; keep changes minimal and correct; briefly summarize what you did."""
# system_prompt_file = "prompts/system.md" # overrides system_prompt when set
max_steps = 25
temperature = 0.0
# planner_model = "mimo-pro" # optional: enable two-model collaboration
[[providers]]
name = "deepseek-flash"
kind = "openai"
base_url = "https://api.deepseek.com"
model = "deepseek-v4-flash"
api_key_env = "DEEPSEEK_API_KEY"
price = { cache_hit = 0.02, input = 1, output = 2, currency = "¥" } # per 1M tokens
[[providers]]
name = "deepseek-pro"
kind = "openai"
base_url = "https://api.deepseek.com"
model = "deepseek-v4-pro"
api_key_env = "DEEPSEEK_API_KEY"
price = { cache_hit = 0.025, input = 3, output = 6, currency = "¥" } # per 1M tokens
[[providers]]
name = "mimo-pro"
kind = "openai"
base_url = "https://api.xiaomimimo.com/v1"
model = "mimo-v2.5-pro" # thinking-mode on by default; reasoning_content rendered dim above the answer
api_key_env = "MIMO_API_KEY"
context_window = 1000000
[[providers]]
name = "mimo-flash"
kind = "openai"
base_url = "https://api.xiaomimimo.com/v1"
model = "mimo-v2-flash" # thinking-mode off by default; cheaper / faster
api_key_env = "MIMO_API_KEY"
context_window = 65536
[tools]
enabled = [] # empty = all built-in tools
# External stdio plugins (MCP-compatible); each is a standalone executable.
# [[plugins]]
# name = "example"
# command = "reasonix-plugin-example"