diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000..2478fe0c72 --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore index ee057977b6..c88a7aba1f 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.reasonix/commands/review.md b/.reasonix/commands/review.md new file mode 100644 index 0000000000..1de8a8256f --- /dev/null +++ b/.reasonix/commands/review.md @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000..46f39d64b8 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 9a17ee2701..06cc21143d 100644 --- a/README.md +++ b/README.md @@ -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____`, 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____` slash commands +(positional args after the command); its **resources** are pulled in by writing +`@:` 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____`. + +### @ 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 `@:` 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. diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000000..d783df94d0 --- /dev/null +++ b/README.zh-CN.md @@ -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____` 暴露给模型,与 Claude Code 一致;声明 MCP `readOnlyHint: true` +的工具会参与并行调度并命中权限层的只读默认放行。 + +服务器的 **prompts** 会暴露成 `/mcp____` 斜杠命令(命令后空格分隔参 +数);**resources** 通过在消息里写 `@:` 拉入;`/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____` 形式出现在这里。 + +### @ 引用 + +在消息里写 `@` 引用,Reasonix 会在发送前解析成带标签的上下文块:`@path/to/file`(或 +`@dir`)注入本地文件内容(或目录清单),`@:` 注入 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。 diff --git a/cmd/reasonix-plugin-example/main.go b/cmd/reasonix-plugin-example/main.go new file mode 100644 index 0000000000..3d900a68b1 --- /dev/null +++ b/cmd/reasonix-plugin-example/main.go @@ -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__ 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))) +} diff --git a/cmd/reasonix/main.go b/cmd/reasonix/main.go new file mode 100644 index 0000000000..550b5f1dc6 --- /dev/null +++ b/cmd/reasonix/main.go @@ -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)) +} diff --git a/docs/SPEC.md b/docs/SPEC.md new file mode 100644 index 0000000000..5c18bc671a --- /dev/null +++ b/docs/SPEC.md @@ -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____` (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____` slash + commands; `resources/list` + `resources/read` are referenced as + `@:` 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/.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____`. + +```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. + +- `@:` where `` is a connected MCP server → an MCP + resource (`resources/read`), wrapped `…`. +- `@` 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 `…` (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`. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000000..b8cbea5550 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000000..43e692fabb --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/agent/agent.go b/internal/agent/agent.go new file mode 100644 index 0000000000..ffdeb33e6d --- /dev/null +++ b/internal/agent/agent.go @@ -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) +} diff --git a/internal/agent/compact.go b/internal/agent/compact.go new file mode 100644 index 0000000000..f7f7f8e422 --- /dev/null +++ b/internal/agent/compact.go @@ -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 +} diff --git a/internal/agent/compact_test.go b/internal/agent/compact_test.go new file mode 100644 index 0000000000..fd70aa2c58 --- /dev/null +++ b/internal/agent/compact_test.go @@ -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)) + } +} diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go new file mode 100644 index 0000000000..b76528925a --- /dev/null +++ b/internal/agent/coordinator.go @@ -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) +} diff --git a/internal/agent/coordinator_test.go b/internal/agent/coordinator_test.go new file mode 100644 index 0000000000..d4e354429e --- /dev/null +++ b/internal/agent/coordinator_test.go @@ -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) + } +} diff --git a/internal/agent/gate_test.go b/internal/agent/gate_test.go new file mode 100644 index 0000000000..0391d56a15 --- /dev/null +++ b/internal/agent/gate_test.go @@ -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) + } +} diff --git a/internal/agent/guards_test.go b/internal/agent/guards_test.go new file mode 100644 index 0000000000..bad398feff --- /dev/null +++ b/internal/agent/guards_test.go @@ -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) + } +} diff --git a/internal/agent/planmode_test.go b/internal/agent/planmode_test.go new file mode 100644 index 0000000000..4b53c5f8ed --- /dev/null +++ b/internal/agent/planmode_test.go @@ -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, ",") +} diff --git a/internal/agent/save.go b/internal/agent/save.go new file mode 100644 index 0000000000..ef067bfee6 --- /dev/null +++ b/internal/agent/save.go @@ -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)) +} diff --git a/internal/agent/save_test.go b/internal/agent/save_test.go new file mode 100644 index 0000000000..7fac85c1d8 --- /dev/null +++ b/internal/agent/save_test.go @@ -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) + } +} diff --git a/internal/agent/session.go b/internal/agent/session.go new file mode 100644 index 0000000000..c5e295029f --- /dev/null +++ b/internal/agent/session.go @@ -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) +} diff --git a/internal/agent/task.go b/internal/agent/task.go new file mode 100644 index 0000000000..448816c1b6 --- /dev/null +++ b/internal/agent/task.go @@ -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") +} diff --git a/internal/agent/task_test.go b/internal/agent/task_test.go new file mode 100644 index 0000000000..eced30efe4 --- /dev/null +++ b/internal/agent/task_test.go @@ -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) + } +} diff --git a/internal/agent/usage_test.go b/internal/agent/usage_test.go new file mode 100644 index 0000000000..64719885d4 --- /dev/null +++ b/internal/agent/usage_test.go @@ -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) + } +} diff --git a/internal/agent/width.go b/internal/agent/width.go new file mode 100644 index 0000000000..42498422f2 --- /dev/null +++ b/internal/agent/width.go @@ -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 +} diff --git a/internal/agent/width_test.go b/internal/agent/width_test.go new file mode 100644 index 0000000000..0ac4a49c12 --- /dev/null +++ b/internal/agent/width_test.go @@ -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) + } +} diff --git a/internal/cli/box.go b/internal/cli/box.go new file mode 100644 index 0000000000..c55e29e7c0 --- /dev/null +++ b/internal/cli/box.go @@ -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() +} diff --git a/internal/cli/chat_approval_test.go b/internal/cli/chat_approval_test.go new file mode 100644 index 0000000000..f8b797567a --- /dev/null +++ b/internal/cli/chat_approval_test.go @@ -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) + } +} diff --git a/internal/cli/chat_ghost_test.go b/internal/cli/chat_ghost_test.go new file mode 100644 index 0000000000..942961a3e4 --- /dev/null +++ b/internal/cli/chat_ghost_test.go @@ -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") + } +} diff --git a/internal/cli/chat_render_test.go b/internal/cli/chat_render_test.go new file mode 100644 index 0000000000..94277b3ff3 --- /dev/null +++ b/internal/cli/chat_render_test.go @@ -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") + } +} diff --git a/internal/cli/chat_tui.go b/internal/cli/chat_tui.go new file mode 100644 index 0000000000..0db8bc7b51 --- /dev/null +++ b/internal/cli/chat_tui.go @@ -0,0 +1,1068 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "image/color" + "strings" + "sync" + "time" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/spinner" + "charm.land/bubbles/v2/textarea" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "reasonix/internal/agent" + "reasonix/internal/command" + "reasonix/internal/i18n" + "reasonix/internal/plugin" + "reasonix/internal/provider" +) + +// chatTUI is a bubbletea Model that runs a chat session in the terminal's +// normal buffer (no alt-screen). Finalized output — user bubbles, tool dispatch +// lines, usage lines, reasoning, and the rendered assistant answer — is +// committed to the native scrollback via tea.Println, so the wheel, scrollbar, +// and copy all work like any CLI. The bubbletea-managed region is only the +// bottom — input box, status line, an optional approval/plan banner, and the +// autocomplete menu — and it is kept a stable height (it changes only on +// discrete user actions, never per streamed token) so the renderer commits +// scrollback cleanly without stranding the input box's border lines. This +// mirrors how Claude Code uses Ink's to freeze finished output into +// scrollback while re-rendering just the active prompt. +type chatTUI struct { + runner agent.Runner + label string + missing string // missing-key warning surfaced once in the banner, "" when ready + + width int + height int + + input textarea.Model + spinner spinner.Model + + state tuiState + runStart time.Time + elapsed int + cancel context.CancelFunc + + // planMode mirrors the agent's read-only gate (Tab toggles it). The marker + // rides in outgoing user messages so the cache-stable prompt prefix is left + // untouched. + planMode bool + hooks chatHooks + + // turnAccumulator collects this turn's assistant free text so the plan-mode + // path can tell whether the turn produced a substantive proposal. + turnAccumulator *strings.Builder + + // pendingPlan is non-empty while a plan-mode proposal awaits Enter-approval. + pendingPlan string + + // history is a resumed session's messages, committed to scrollback once on + // the first WindowSizeMsg so a reopened chat shows its prior transcript. + history []provider.Message + + // reasoning accumulates the in-progress thinking stream (dim); pending + // accumulates the in-progress answer (raw markdown). They are committed to + // scrollback (reasoning verbatim, answer markdown-rendered) when they + // finalize — at a tool/usage boundary or turn end — not previewed live, so + // the bottom region stays a stable height. pendingCommit queues finalized + // lines so a single Update emits exactly one ordered tea.Println. + reasoning *strings.Builder + pending *strings.Builder + pendingCommit *[]string + renderer *mdRenderer + textCh chan string + doneCh chan error + started bool // banner + resumed history committed once + + // approvalCh delivers tool-call approval requests from the agent goroutine + // (sent by channelApprover). pendingApproval holds the one currently shown + // in the banner; nil means none is awaiting a decision. While set, the + // agent is blocked inside the gate and key input is captured to answer it. + approvalCh chan approvalReq + pendingApproval *approvalReq + + // host is the running MCP servers (nil when no plugins). The TUI reads + // prompts (slash commands), resources (@-references), and server status + // (/mcp) from it. + host *plugin.Host + + // commands are custom slash commands loaded from .reasonix/commands; each renders + // its template with the typed args and sends the result as a turn. + commands []command.Command + + // completion is the live autocomplete menu (slash commands; @-refs later). + completion completion +} + +type tuiState int + +const ( + tuiIdle tuiState = iota + tuiRunning +) + +// agentTextMsg is one chunk of bytes the agent wrote to its output writer. +type agentTextMsg string + +// agentDoneMsg signals that a single Run() call returned; err is non-nil when +// the turn errored out (ctx cancellation surfaces as nil — not a user error). +type agentDoneMsg struct{ err error } + +// elapsedTickMsg fires once a second while a turn runs, driving the "thinking +// Ns" counter in the status line. +type elapsedTickMsg struct{} + +// promptResolvedMsg carries the result of fetching an MCP prompt (an async +// prompts/get). display is the command line echoed as the user bubble; sent is +// the rendered prompt text that becomes the model turn. +type promptResolvedMsg struct { + display string + sent string + err error +} + +// refsResolvedMsg carries the result of resolving the @references in a +// submitted line (async file reads / MCP resources/read). +type refsResolvedMsg struct { + line string + block string + errs []string +} + +// chatHooks bundles the CLI-side callbacks the TUI calls into. +type chatHooks struct { + setPlanMode func(bool) + save func() error // auto-save the executor's session + compact func() error // run one compaction pass on demand + newSession func() error // snapshot + fork a fresh session + contextSnapshot func() (used, window int) // for the prompt/context gauge +} + +// newChatTUI assembles the initial model. The agent runner has already been +// constructed with a writer that feeds textCh; the TUI owns the channels and +// the UI state. history pre-populates scrollback from a resumed session. +func newChatTUI(runner agent.Runner, label, missing string, textCh chan string, doneCh chan error, termW int, hooks chatHooks, history []provider.Message, approvalCh chan approvalReq, host *plugin.Host, commands []command.Command) chatTUI { + ti := textarea.New() + ti.Prompt = "" + ti.CharLimit = 16384 + ti.SetHeight(1) + ti.ShowLineNumbers = false + // Use the real terminal cursor (not a styled virtual one) so View can place + // it at the insertion point and IME candidate windows anchor to the input. + ti.SetVirtualCursor(false) + // Plain Enter submits (the chatTUI handler intercepts it), so the textarea's + // own InsertNewline binding moves to Alt+Enter / Ctrl+J. + ti.KeyMap.InsertNewline = key.NewBinding(key.WithKeys("alt+enter", "ctrl+j")) + ti.Focus() + + sp := spinner.New() + sp.Spinner = spinner.Dot + sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("173")) + + commitBuf := []string{} + return chatTUI{ + runner: runner, + label: label, + missing: missing, + input: ti, + spinner: sp, + reasoning: &strings.Builder{}, + pending: &strings.Builder{}, + pendingCommit: &commitBuf, + turnAccumulator: &strings.Builder{}, + renderer: newMarkdownRenderer(termW), + textCh: textCh, + doneCh: doneCh, + hooks: hooks, + history: history, + approvalCh: approvalCh, + host: host, + commands: commands, + } +} + +// prompts returns the MCP prompts discovered at startup (nil when no plugins). +func (m *chatTUI) prompts() []plugin.Prompt { + if m.host == nil { + return nil + } + return m.host.Prompts() +} + +// planModeMarker is prepended to every user message while plan mode is on. It +// is part of the user message (not the system prompt or tools), so the existing +// cache prefix stays valid. +const planModeMarker = "[Plan mode — read-only. Explore and propose; do not write files, edit, or run side-effecting bash. Read-only tools (read_file, ls, grep, glob, web_fetch, task) are available; writers are refused by the harness. End with a concrete plan the user can approve.]" + +func (m chatTUI) Init() tea.Cmd { + return tea.Batch( + textarea.Blink, + waitForAgentText(m.textCh), + waitForAgentDone(m.doneCh), + waitForApproval(m.approvalCh), + ) +} + +func (m chatTUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.input.SetWidth(msg.Width - 4) + m.renderer = newMarkdownRenderer(msg.Width) + // Commit the banner — and a resumed session's transcript — to scrollback + // once, now that the width is known. + if !m.started { + m.started = true + var b strings.Builder + b.WriteString(renderTUIBanner(m.label, m.missing, msg.Width)) + if len(m.history) > 0 { + r := newMarkdownRenderer(msg.Width) + for _, sec := range replaySectionsFor(m.history, msg.Width, r) { + b.WriteString(sec) + } + m.history = nil + } + m.commitLine(strings.TrimRight(b.String(), "\n")) + } + + case tea.KeyPressMsg: + // A pending tool approval is modal: keystrokes answer it (y/a/n, Enter, + // Esc) rather than reaching the input. + if m.pendingApproval != nil { + return m.handleApprovalKey(msg) + } + // While the autocomplete menu is open it captures navigation/accept keys + // (↑/↓ move, Tab/Enter accept, Esc close); everything else falls through + // to the textarea and re-filters the menu at the end of Update. + if m.completion.active { + switch msg.String() { + case "up": + m.moveCompletion(-1) + return m, nil + case "down": + m.moveCompletion(1) + return m, nil + case "tab", "enter": + m.acceptCompletion() + return m, nil + case "esc": + m.completion = completion{} + return m, nil + } + } + switch msg.String() { + case "esc": + // "Back out" of the most specific in-progress state: cancel a turn, + // turn plan mode off, or clear typed-but-unsent input. Scrollback is + // the terminal's now, so there's no viewport to dismiss. + switch { + case m.state == tuiRunning: + if m.cancel != nil { + m.cancel() + } + case m.pendingPlan != "": + m.pendingPlan = "" + case m.planMode: + m.planMode = false + if m.hooks.setPlanMode != nil { + m.hooks.setPlanMode(false) + } + default: + m.input.Reset() + } + return m, nil + case "ctrl+c": + if m.state == tuiRunning { + if m.cancel != nil { + m.cancel() + } + return m, nil + } + return m, tea.Quit + case "ctrl+d": + return m, tea.Quit + case "tab": + if m.state == tuiRunning { + break + } + m.planMode = !m.planMode + if m.hooks.setPlanMode != nil { + m.hooks.setPlanMode(m.planMode) + } + return m, nil + case "enter": + if m.state == tuiRunning { + return m, nil // ignore Enter while a turn is in flight + } + line := strings.TrimSpace(m.input.Value()) + + // Empty Enter while a plan is pending approves it and auto-sends a + // brief "proceed" message. + if line == "" && m.pendingPlan != "" { + m.pendingPlan = "" + m.planMode = false + if m.hooks.setPlanMode != nil { + m.hooks.setPlanMode(false) + } + cmds = append(cmds, m.startTurn("Plan approved — proceed with the steps you laid out, executing as needed.", "(plan approved — executing)")) + return m, finalize(m, cmds) + } + + if line == "" { + return m, nil + } + if line == "exit" || line == "quit" || line == ":q" { + return m, tea.Quit + } + + // Slash commands run locally without going through the model. + if strings.HasPrefix(line, "/") { + m.input.Reset() + m.input.SetHeight(1) + cmds = append(cmds, m.runSlashCommand(line)) + return m, finalize(m, cmds) + } + + // A non-empty submission while a plan is pending counts as feedback. + m.pendingPlan = "" + m.input.Reset() + m.input.SetHeight(1) + + // @references (local files / MCP resources) are fetched off the event + // loop; the turn starts when they resolve (refsResolvedMsg). + if refs := m.detectRefs(line); len(refs) > 0 { + cmds = append(cmds, m.fetchRefs(line, refs)) + return m, finalize(m, cmds) + } + + cmds = append(cmds, m.startTurn(m.composeSent(line), line)) + return m, finalize(m, cmds) + } + + case agentTextMsg: + m.ingestChunk(string(msg)) + cmds = append(cmds, waitForAgentText(m.textCh)) + + case agentDoneMsg: + // Finalize whatever's still streaming, then settle the turn. + m.commitReasoning() + m.commitPending() + m.state = tuiIdle + m.cancel = nil + if m.hooks.save != nil { + _ = m.hooks.save() // best-effort; never the user's problem mid-chat + } + if msg.err != nil && msg.err.Error() != "" && !strings.Contains(msg.err.Error(), "context canceled") { + m.commitLine(wrapForViewport(i18n.M.ErrorPrefix+" "+msg.err.Error(), m.width, lipgloss.Color("3"))) + } + // A plan-mode turn that produced substantive text waits for approval. + if msg.err == nil && m.planMode && strings.TrimSpace(m.turnAccumulator.String()) != "" { + m.pendingPlan = "pending" + } + cmds = append(cmds, waitForAgentDone(m.doneCh)) + + case approvalReqMsg: + // The agent is now blocked inside the permission gate waiting for this + // decision; the banner shows in View. The listener re-arms only after the + // user answers (handleApprovalKey), so at most one prompt shows at once. + req := approvalReq(msg) + m.pendingApproval = &req + + case promptResolvedMsg: + switch { + case msg.err != nil: + m.commitLine(wrapForViewport(i18n.M.ErrorPrefix+" "+msg.err.Error(), m.width, lipgloss.Color("3"))) + case strings.TrimSpace(msg.sent) == "": + m.notice(i18n.M.SlashPromptEmpty) + default: + cmds = append(cmds, m.startTurn(m.composeSent(msg.sent), msg.display)) + } + + case refsResolvedMsg: + for _, e := range msg.errs { + m.notice(e) // surface a fetch failure but still send the turn + } + sent := msg.line + if msg.block != "" { + sent = "Referenced context:\n\n" + msg.block + "\n\n" + msg.line + } + cmds = append(cmds, m.startTurn(m.composeSent(sent), msg.line)) + + case elapsedTickMsg: + if m.state == tuiRunning { + m.elapsed = int(time.Since(m.runStart).Seconds()) + cmds = append(cmds, elapsedTick()) + } + + case spinner.TickMsg: + if m.state == tuiRunning { + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + cmds = append(cmds, cmd) + } + } + + var ic tea.Cmd + m.input, ic = m.input.Update(msg) + cmds = append(cmds, ic) + m.growInputToFit() + // Re-filter the autocomplete menu against the freshly-edited input. + if _, ok := msg.(tea.KeyPressMsg); ok { + m.updateCompletion() + } + + return m, finalize(m, cmds) +} + +// finalize flushes any queued scrollback lines into a single ordered tea.Println +// (Batch doesn't preserve order across multiple Println cmds, so we coalesce per +// Update) and batches it with the turn's other commands. +func finalize(m chatTUI, cmds []tea.Cmd) tea.Cmd { + if len(*m.pendingCommit) > 0 { + out := strings.Join(*m.pendingCommit, "\n") + *m.pendingCommit = (*m.pendingCommit)[:0] + cmds = append(cmds, tea.Println(strings.TrimRight(out, "\n"))) + } + return tea.Batch(cmds...) +} + +// commitLine queues one finalized block for the next scrollback flush. +func (m *chatTUI) commitLine(s string) { + *m.pendingCommit = append(*m.pendingCommit, s) +} + +// commitReasoning freezes the accumulated thinking stream (verbatim, already +// dim) into scrollback and clears the live buffer. +func (m *chatTUI) commitReasoning() { + if m.reasoning.Len() == 0 { + return + } + // Wrap to the viewport width before committing. bubbletea's non-alt-screen + // Println adds an erase-to-end only for message lines *narrower* than the + // terminal and never truncates them, so an over-wide reasoning line wraps + // and its short final row leaves the old input-box border (the live region + // it printed over) bleeding through on the right — the "ghost ────". The + // rendered answer is already wrapped, which is why only reasoning stranded. + // Wrap each over-long line; keep short ones (the "▎ thinking" header) + // verbatim so their indent survives. + raw := strings.TrimRight(m.reasoning.String(), "\n") + var b strings.Builder + for i, line := range strings.Split(raw, "\n") { + if i > 0 { + b.WriteByte('\n') + } + if m.width > 0 && visibleWidth(line) > m.width { + b.WriteString(wrapAnsi(line, m.width)) + } else { + b.WriteString(line) // width unknown (pre-sizing) or already fits: verbatim + } + } + m.commitLine(b.String()) + m.reasoning.Reset() +} + +// commitPending renders the accumulated answer as markdown and freezes it into +// scrollback. Joining commitReasoning then commitPending puts the answer on its +// own line, restoring the thinking→answer break the renderer strips. +func (m *chatTUI) commitPending() { + if m.pending.Len() == 0 { + return + } + raw := m.pending.String() + rendered := m.renderer.Render(raw) + if rendered == "" { + rendered = raw + } + m.commitLine(strings.TrimRight(rendered, "\n")) + m.pending.Reset() +} + +// handleApprovalKey resolves a pending tool-call approval from a keystroke and +// re-arms the approval listener. y/Enter allows once, a allows for the rest of +// the session, n/Esc denies. Ctrl-C cancels the whole turn via the run context. +func (m chatTUI) handleApprovalKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + answer := func(resp approvalResp) (tea.Model, tea.Cmd) { + m.pendingApproval.reply <- resp // reply is buffered, never blocks + m.pendingApproval = nil + return m, waitForApproval(m.approvalCh) + } + switch msg.String() { + case "ctrl+c": + if m.cancel != nil { + m.cancel() // cancels the run ctx; the approver unblocks via ctx.Done() + } + return answer(approvalResp{allow: false}) + case "enter": + return answer(approvalResp{allow: true}) + case "esc": + return answer(approvalResp{allow: false}) + } + switch strings.ToLower(msg.String()) { + case "y": + return answer(approvalResp{allow: true}) + case "a": + return answer(approvalResp{allow: true, session: true}) + case "n": + return answer(approvalResp{allow: false}) + } + return m, nil // ignore anything else while awaiting a decision +} + +var ( + // Input box: only top + bottom borders, no sides. + inputBoxStyle = lipgloss.NewStyle(). + Border(lipgloss.NormalBorder(), true, false, true, false). + BorderForeground(lipgloss.Color("173")). + PaddingLeft(1) + + // Approval banner: same frame as the input box, recoloured yellow. + approvalBannerStyle = lipgloss.NewStyle(). + Border(lipgloss.NormalBorder(), true, false, true, false). + BorderForeground(lipgloss.Color("220")). + Foreground(lipgloss.Color("220")). + Bold(true). + PaddingLeft(1) + + statusStyle = lipgloss.NewStyle().Faint(true) +) + +func (m chatTUI) View() tea.View { + boxW := m.width + if boxW < 10 { + boxW = 10 + } + box := inputBoxStyle.Width(boxW).Render(m.input.View()) + + var modeTag string + if m.planMode { + modeTag = yellow("[plan]") + } else { + modeTag = dim("[auto]") + } + + ctxTag := m.contextTag() + var status string + switch { + case m.pendingApproval != nil: + status = " " + modeTag + " · " + i18n.M.ChatStatusToolApproval + case m.state == tuiRunning: + status = fmt.Sprintf(" %s · "+i18n.M.ChatStatusThinkingFmt, modeTag, m.spinner.View(), m.elapsed) + case m.pendingPlan != "": + status = " " + modeTag + " · " + i18n.M.ChatStatusPlanApproval + default: + status = " " + modeTag + " · " + i18n.M.ChatStatusIdle + } + if ctxTag != "" { + status += " · " + ctxTag + } + + // The bottom region must stay a stable height: bubbletea's non-alt-screen + // renderer commits scrollback via tea.Println by clearing the previous + // frame's lines, so a frame whose height changed every streamed token (a + // growing live preview) drifts and strands input-box border lines in the + // history. So we don't preview the streaming text here — it lands in + // scrollback at boundaries (tool lines stream live; reasoning and the + // rendered answer commit at their edges). The menu/banner change height only + // on discrete user actions, never mid-stream. + var parts []string + rowsAboveBox := 0 // terminal rows occupied by banner/menu before the input box + if banner := m.renderApprovalBanner(); banner != "" { + parts = append(parts, banner) + rowsAboveBox += strings.Count(banner, "\n") + 1 + } + if menu := m.renderCompletion(); menu != "" { + parts = append(parts, menu) + rowsAboveBox += strings.Count(menu, "\n") + 1 + } + parts = append(parts, box, statusStyle.Render(status)) + + v := tea.NewView(strings.Join(parts, "\n")) + // Anchor the real terminal cursor at the textarea's insertion point so IME + // candidate windows appear in the input box, not at the bottom of the frame. + // input.Cursor() is relative to the textarea; offset it by the box's screen + // position (rows above + the box's top border row; +1 column for PaddingLeft). + if cur := m.input.Cursor(); cur != nil { + cur.X += 1 + cur.Y += rowsAboveBox + 1 + v.Cursor = cur + } + return v +} + +// contextTag renders the prompt-vs-context-window gauge for the status line. +func (m chatTUI) contextTag() string { + if m.hooks.contextSnapshot == nil { + return "" + } + used, window := m.hooks.contextSnapshot() + if used == 0 || window == 0 { + return "" + } + pct := used * 100 / window + body := fmt.Sprintf("%s / %s ctx (%d%%)", shortTokens(used), shortTokens(window), pct) + switch { + case pct >= 85: + return lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(body) + case pct >= 60: + return lipgloss.NewStyle().Foreground(lipgloss.Color("220")).Render(body) + default: + return dim(body) + } +} + +// shortTokens prints token counts compactly: 142_000 → "142K", 1_000_000 → "1M". +func shortTokens(n int) string { + switch { + case n >= 1_000_000: + return fmt.Sprintf("%.1fM", float64(n)/1_000_000) + case n >= 1_000: + return fmt.Sprintf("%dK", n/1_000) + default: + return fmt.Sprintf("%d", n) + } +} + +// renderApprovalBanner is the slim notice shown above the input while a tool +// call (or a plan) awaits the user's decision. +func (m chatTUI) renderApprovalBanner() string { + w := m.width + if w < 10 { + w = 10 + } + if m.pendingApproval != nil { + subj := strings.TrimSpace(m.pendingApproval.subject) + if subj != "" { + subj = " " + truncateSubject(subj, w) + } + text := fmt.Sprintf(i18n.M.ToolApprovalPromptFmt, m.pendingApproval.tool, subj) + return approvalBannerStyle.Width(w).Render("⏸ " + text) + } + if m.pendingPlan == "" { + return "" + } + return approvalBannerStyle.Width(w).Render("⏸ " + i18n.M.PlanApprovalPrompt) +} + +// truncateSubject trims a tool subject so the approval banner fits one line. +func truncateSubject(s string, width int) string { + max := width - 28 + if max < 16 { + max = 16 + } + r := []rune(s) + if len(r) > max { + return string(r[:max]) + "…" + } + return s +} + +// growInputToFit resizes the textarea to the number of lines its value spans, +// capped at maxInputRows so a long paste doesn't crowd the screen. +const maxInputRows = 5 + +func (m *chatTUI) growInputToFit() { + lines := strings.Count(m.input.Value(), "\n") + 1 + if lines < 1 { + lines = 1 + } + if lines > maxInputRows { + lines = maxInputRows + } + if lines != m.input.Height() { + m.input.SetHeight(lines) + } +} + +// startTurn commits the user bubble to scrollback, resets the turn accumulator, +// and kicks off runner.Run. `sent` goes to the model (may carry a plan-mode +// marker); `displayed` is what the transcript shows. +func (m *chatTUI) startTurn(sent, displayed string) tea.Cmd { + // Flush any half-streamed leftover before the new turn (defensive). + m.commitReasoning() + m.commitPending() + m.turnAccumulator.Reset() + m.commitLine("") // blank line separating turns + m.commitLine(renderUserBubble(displayed, m.width, m.planMode)) + + ctx, cancel := context.WithCancel(context.Background()) + m.cancel = cancel + m.state = tuiRunning + m.runStart = time.Now() + m.elapsed = 0 + runner := m.runner + done := m.doneCh + go func() { + done <- runner.Run(ctx, sent) + }() + return tea.Batch(m.spinner.Tick, elapsedTick()) +} + +// ingestChunk routes one slice of agent-emitted bytes. Reasoning (dim) and +// answer free-text accumulate in their live buffers; an event line (tool +// dispatch, usage, warning, coordinator marker) first finalizes the reasoning +// and answer streamed so far, then is itself committed — preserving order. +func (m *chatTUI) ingestChunk(chunk string) { + if strings.HasPrefix(chunk, "\x1b[2m") { + m.reasoning.WriteString(chunk) // dim reasoning header / fragment + return + } + if isEventChunk(chunk) { + m.commitReasoning() + m.commitPending() + m.commitLine(strings.TrimRight(chunk, "\n")) + return + } + // Free text (the answer). Reasoning ends as the answer begins. + m.commitReasoning() + m.pending.WriteString(chunk) + m.turnAccumulator.WriteString(chunk) +} + +// isEventChunk decides whether a chunk is an agent-generated event line (tool +// dispatch, usage telemetry, warning, coordinator marker) rather than reasoning +// (handled separately) or model free text. Prefix matching is reliable for the +// agent-controlled prefixes; the coordinator's "[" marker needs the suffix +// check so model output starting with "[" stays in the answer buffer. +func isEventChunk(chunk string) bool { + switch { + case strings.HasPrefix(chunk, " · "): + return true // usage line, truncation / compaction notice + case strings.HasPrefix(chunk, " -> "): + return true // tool dispatch + case strings.HasPrefix(chunk, " ! "): + return true // finish_reason warning + case strings.HasPrefix(chunk, "["): + end := strings.IndexByte(chunk, '\n') + if end < 0 { + end = len(chunk) + } + head := chunk[:end] + return strings.Contains(head, " · planning]") || strings.Contains(head, " · executing]") + } + return false +} + +func waitForAgentText(ch chan string) tea.Cmd { + return func() tea.Msg { return agentTextMsg(<-ch) } +} + +func waitForAgentDone(ch chan error) tea.Cmd { + return func() tea.Msg { return agentDoneMsg{err: <-ch} } +} + +// approvalReqMsg carries a tool-call approval request into the update loop. +type approvalReqMsg approvalReq + +// waitForApproval blocks on the approval channel and delivers the next request +// as a tea.Msg. Re-armed only after the user answers the current one. +func waitForApproval(ch chan approvalReq) tea.Cmd { + if ch == nil { + return nil + } + return func() tea.Msg { return approvalReqMsg(<-ch) } +} + +func elapsedTick() tea.Cmd { + return tea.Tick(time.Second, func(_ time.Time) tea.Msg { return elapsedTickMsg{} }) +} + +// runSlashCommand handles "/ " input. Local commands queue their +// output to scrollback; MCP prompt / custom commands resolve to a model turn. +func (m *chatTUI) runSlashCommand(input string) tea.Cmd { + cmd := strings.TrimSpace(strings.SplitN(input, " ", 2)[0]) + + if strings.HasPrefix(cmd, "/mcp__") { + return m.runMCPPrompt(input) + } + + switch cmd { + case "/compact": + if m.hooks.compact == nil { + m.notice(i18n.M.SlashUnavailable) + return nil + } + if err := m.hooks.compact(); err != nil { + m.notice(fmt.Sprintf("%s: %v", i18n.M.SlashCompactFailed, err)) + return nil + } + m.notice(i18n.M.SlashCompactDone) + if m.hooks.save != nil { + _ = m.hooks.save() + } + case "/new": + if m.hooks.newSession == nil { + m.notice(i18n.M.SlashUnavailable) + return nil + } + if err := m.hooks.newSession(); err != nil { + m.notice(fmt.Sprintf("%s: %v", i18n.M.SlashNewFailed, err)) + return nil + } + // Native scrollback keeps the old transcript; mark the fork with a fresh + // banner and reset live state. + m.pending.Reset() + m.reasoning.Reset() + m.turnAccumulator.Reset() + m.pendingPlan = "" + m.commitLine("") + m.commitLine(strings.TrimRight(renderTUIBanner(m.label, "", m.width), "\n")) + m.notice(i18n.M.SlashNewDone) + case "/mcp": + m.showMCPStatus() + case "/help": + m.notice(i18n.M.SlashHelp) + if names := m.commandNames(); names != "" { + m.notice("custom: " + names) + } + default: + if c, ok := m.lookupCommand(strings.TrimPrefix(cmd, "/")); ok { + args := strings.Fields(input) + return m.startTurn(m.composeSent(c.Render(args[1:])), input) + } + m.notice(fmt.Sprintf("%s: %s", i18n.M.SlashUnknown, cmd)) + } + return nil +} + +// lookupCommand finds a custom slash command by name. +func (m *chatTUI) lookupCommand(name string) (command.Command, bool) { + for _, c := range m.commands { + if c.Name == name { + return c, true + } + } + return command.Command{}, false +} + +// commandNames renders the custom command list for /help, "" when there are none. +func (m *chatTUI) commandNames() string { + if len(m.commands) == 0 { + return "" + } + names := make([]string, len(m.commands)) + for i, c := range m.commands { + names[i] = "/" + c.Name + } + return strings.Join(names, " · ") +} + +// showMCPStatus queues the connected MCP servers, their counts, and the prompt +// commands / resource refs they expose — the discovery surface for /mcp. +func (m *chatTUI) showMCPStatus() { + if m.host == nil || len(m.host.Servers()) == 0 { + m.notice(i18n.M.SlashMCPNone) + return + } + servers := m.host.Servers() + var b strings.Builder + fmt.Fprintf(&b, "%s\n", dim(fmt.Sprintf(" · MCP servers (%d)", len(servers)))) + for _, s := range servers { + fmt.Fprintf(&b, " %s %s %s\n", accent("✓"), bold(s.Name), + dim(fmt.Sprintf("(%s) — %d tools · %d prompts · %d resources", s.Transport, s.Tools, s.Prompts, s.Resources))) + } + for _, p := range m.host.Prompts() { + fmt.Fprintf(&b, " %s %s\n", "/"+p.Name, dim(p.Description)) + } + for _, r := range m.host.Resources() { + label := r.Name + if label == "" { + label = r.Description + } + fmt.Fprintf(&b, " %s %s\n", "@"+r.Server+":"+r.URI, dim(label)) + } + m.commitLine(strings.TrimRight(b.String(), "\n")) +} + +// notice queues a dim informational line to scrollback. +func (m *chatTUI) notice(note string) { + m.commitLine(dim(" · " + note)) +} + +// composeSent applies the plan-mode marker (when active) to a turn's input. +func (m *chatTUI) composeSent(text string) string { + if m.planMode { + return planModeMarker + "\n\n" + text + } + return text +} + +// runMCPPrompt resolves a /mcp__server__prompt command: it maps positional args +// onto the prompt's declared arguments and returns a tea.Cmd that fetches the +// rendered prompt off the event loop, delivering a promptResolvedMsg. +func (m *chatTUI) runMCPPrompt(input string) tea.Cmd { + fields := strings.Fields(input) + name := strings.TrimPrefix(fields[0], "/") + + prompts := m.prompts() + var found *plugin.Prompt + for i := range prompts { + if prompts[i].Name == name { + found = &prompts[i] + break + } + } + if found == nil { + m.notice(fmt.Sprintf("%s: /%s", i18n.M.SlashUnknown, name)) + return nil + } + + args := map[string]string{} + for i, a := range found.Args { + if i+1 < len(fields) { + args[a.Name] = fields[i+1] + } + } + + prompt := *found + display := input + return func() tea.Msg { + text, err := prompt.Get(context.Background(), args) + return promptResolvedMsg{display: display, sent: text, err: err} + } +} + +// replaySectionsFor turns a loaded session into scrollback blocks: user bubbles +// and assistant markdown. Tool messages are dropped — needed in session state +// but noise in the visible transcript on resume. +func replaySectionsFor(history []provider.Message, width int, renderer *mdRenderer) []string { + var out []string + for _, m := range history { + switch m.Role { + case provider.RoleUser: + content := strings.TrimPrefix(m.Content, planModeMarker+"\n\n") + out = append(out, renderUserBubble(content, width, false)+"\n\n") + case provider.RoleAssistant: + body := strings.TrimSpace(m.Content) + if body == "" { + continue + } + rendered := renderer.Render(body) + if rendered == "" { + rendered = body + } + out = append(out, rendered+"\n") + } + } + return out +} + +// renderTUIBanner is the title + tip + optional missing-key warning printed once +// at the top of the session. +func renderTUIBanner(label, missing string, width int) string { + var b strings.Builder + b.WriteString(accent("◆") + " " + bold("reasonix chat") + " " + dim("· "+label) + "\n") + b.WriteString(dim(" "+i18n.M.ChatTip) + "\n") + if missing != "" { + b.WriteString(wrapForViewport(" ! "+missing, width, lipgloss.Color("3")) + "\n") + } + return b.String() +} + +// wrapForViewport hard-wraps text to fit width columns and colours every line. +func wrapForViewport(text string, width int, fg color.Color) string { + if width <= 0 { + width = 80 + } + return lipgloss.NewStyle(). + Foreground(fg). + Width(width). + Render(text) +} + +// renderUserBubble styles the just-submitted line with a filled dim background. +func renderUserBubble(line string, width int, planMode bool) string { + prefix := "› " + if planMode { + prefix = "› [plan] " + } + if !colorEnabled { + return "│ " + prefix + line + } + w := width - 4 + if w < 10 { + w = 10 + } + bubble := lipgloss.NewStyle(). + Background(lipgloss.Color("236")). + Width(w). + Padding(0, 1) + return bubble.Render(prefix + line) +} + +// channelWriter is the io.Writer the agent writes to in TUI mode. Each Write +// becomes an agentTextMsg. The channel is generously buffered so streaming +// bursts don't back-pressure the agent goroutine. +type channelWriter struct { + ch chan<- string +} + +func (w *channelWriter) Write(p []byte) (int, error) { + w.ch <- string(p) + return len(p), nil +} + +// approvalReq is a pending tool-call approval handed from the agent goroutine to +// the TUI. reply (buffered) carries the decision back. +type approvalReq struct { + tool string + subject string + reply chan approvalResp +} + +// approvalResp is the user's answer: allow once / for the session, or deny. +type approvalResp struct { + allow bool + session bool +} + +// channelApprover is the permission.Approver used by `reasonix chat`. It bridges the +// agent goroutine to the bubbletea loop: a request is sent over reqCh and +// Approve blocks on the reply until the user answers — or the turn's context is +// cancelled. A per-session grant short-circuits future prompts for the same +// tool+subject; the mutex serialises prompts so concurrent calls queue. +type channelApprover struct { + reqCh chan approvalReq + mu sync.Mutex + granted map[string]bool +} + +func newChannelApprover(reqCh chan approvalReq) *channelApprover { + return &channelApprover{reqCh: reqCh, granted: map[string]bool{}} +} + +func (a *channelApprover) Approve(ctx context.Context, tool, subject string, _ json.RawMessage) (bool, bool, error) { + a.mu.Lock() + defer a.mu.Unlock() + + key := tool + "\x00" + subject + if a.granted[key] { + return true, false, nil // a "this session" grant already covers this call + } + + reply := make(chan approvalResp, 1) + select { + case a.reqCh <- approvalReq{tool: tool, subject: subject, reply: reply}: + case <-ctx.Done(): + return false, false, ctx.Err() + } + select { + case r := <-reply: + if r.allow && r.session { + a.granted[key] = true + } + // remember=false: session grants live here, not in the on-disk policy + // (persistence is future scope — see SPEC §9). + return r.allow, false, nil + case <-ctx.Done(): + return false, false, ctx.Err() + } +} diff --git a/internal/cli/chat_tui_test.go b/internal/cli/chat_tui_test.go new file mode 100644 index 0000000000..805c558108 --- /dev/null +++ b/internal/cli/chat_tui_test.go @@ -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) + } + } +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 0000000000..cfafe53b7f --- /dev/null +++ b/internal/cli/cli.go @@ -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) +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 0000000000..b973e96c33 --- /dev/null +++ b/internal/cli/cli_test.go @@ -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) + } +} diff --git a/internal/cli/complete.go b/internal/cli/complete.go new file mode 100644 index 0000000000..ac8089e4f4 --- /dev/null +++ b/internal/cli/complete.go @@ -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() +} diff --git a/internal/cli/complete_test.go b/internal/cli/complete_test.go new file mode 100644 index 0000000000..8c80836f22 --- /dev/null +++ b/internal/cli/complete_test.go @@ -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}, + {"a@b.com", "", 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 = "/", 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 +} diff --git a/internal/cli/fileref.go b/internal/cli/fileref.go new file mode 100644 index 0000000000..85189bdabd --- /dev/null +++ b/internal/cli/fileref.go @@ -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: @: + refFile // a local file or directory: @ +) + +// 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", 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 +} diff --git a/internal/cli/fileref_test.go b/internal/cli/fileref_test.go new file mode 100644 index 0000000000..fd7cb02a50 --- /dev/null +++ b/internal/cli/fileref_test.go @@ -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") + } +} diff --git a/internal/cli/mcp_refs_test.go b/internal/cli/mcp_refs_test.go new file mode 100644 index 0000000000..5c3a6f6814 --- /dev/null +++ b/internal/cli/mcp_refs_test.go @@ -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 a@b.com 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) + } + } +} diff --git a/internal/cli/md.go b/internal/cli/md.go new file mode 100644 index 0000000000..0929f32925 --- /dev/null +++ b/internal/cli/md.go @@ -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 +} diff --git a/internal/cli/md_test.go b/internal/cli/md_test.go new file mode 100644 index 0000000000..ced761cde2 --- /dev/null +++ b/internal/cli/md_test.go @@ -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]) + } +} diff --git a/internal/cli/select.go b/internal/cli/select.go new file mode 100644 index 0000000000..ace1f7369b --- /dev/null +++ b/internal/cli/select.go @@ -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() + } +} diff --git a/internal/cli/slash_test.go b/internal/cli/slash_test.go new file mode 100644 index 0000000000..49162d890c --- /dev/null +++ b/internal/cli/slash_test.go @@ -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) + } +} diff --git a/internal/cli/style.go b/internal/cli/style.go new file mode 100644 index 0000000000..d3a1738bf3 --- /dev/null +++ b/internal/cli/style.go @@ -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) } diff --git a/internal/cli/width_test.go b/internal/cli/width_test.go new file mode 100644 index 0000000000..e54474ffcf --- /dev/null +++ b/internal/cli/width_test.go @@ -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)) + } + } +} diff --git a/internal/command/command.go b/internal/command/command.go new file mode 100644 index 0000000000..acadd9f9d2 --- /dev/null +++ b/internal/command/command.go @@ -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 +} diff --git a/internal/command/command_test.go b/internal/command/command_test.go new file mode 100644 index 0000000000..c09b371113 --- /dev/null +++ b/internal/command/command_test.go @@ -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 +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000000..535104d288 --- /dev/null +++ b/internal/config/config.go @@ -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, ", ") +} diff --git a/internal/config/dotenv.go b/internal/config/dotenv.go new file mode 100644 index 0000000000..5281e9597b --- /dev/null +++ b/internal/config/dotenv.go @@ -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) + } + } +} diff --git a/internal/config/expand.go b/internal/config/expand.go new file mode 100644 index 0000000000..929733cad5 --- /dev/null +++ b/internal/config/expand.go @@ -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 +} diff --git a/internal/config/expand_test.go b/internal/config/expand_test.go new file mode 100644 index 0000000000..92d1841d30 --- /dev/null +++ b/internal/config/expand_test.go @@ -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") + } +} diff --git a/internal/config/loadedit_test.go b/internal/config/loadedit_test.go new file mode 100644 index 0000000000..f36ac5469a --- /dev/null +++ b/internal/config/loadedit_test.go @@ -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) + } +} diff --git a/internal/config/mcpjson.go b/internal/config/mcpjson.go new file mode 100644 index 0000000000..cae664b875 --- /dev/null +++ b/internal/config/mcpjson.go @@ -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) + } +} diff --git a/internal/config/mcpjson_test.go b/internal/config/mcpjson_test.go new file mode 100644 index 0000000000..153b94f245 --- /dev/null +++ b/internal/config/mcpjson_test.go @@ -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]) + } +} diff --git a/internal/config/render.go b/internal/config/render.go new file mode 100644 index 0000000000..71876e9aa8 --- /dev/null +++ b/internal/config/render.go @@ -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 +} diff --git a/internal/config/render_test.go b/internal/config/render_test.go new file mode 100644 index 0000000000..96c3591034 --- /dev/null +++ b/internal/config/render_test.go @@ -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) + } +} diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go new file mode 100644 index 0000000000..3753984ea8 --- /dev/null +++ b/internal/i18n/i18n.go @@ -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 · )" — %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] " + 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 "" +} diff --git a/internal/i18n/i18n_test.go b/internal/i18n/i18n_test.go new file mode 100644 index 0000000000..a3a35a560d --- /dev/null +++ b/internal/i18n/i18n_test.go @@ -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) + } +} diff --git a/internal/i18n/messages_en.go b/internal/i18n/messages_en.go new file mode 100644 index 0000000000..bc73e12af7 --- /dev/null +++ b/internal/i18n/messages_en.go @@ -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] ", + 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] 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. +`, +} diff --git a/internal/i18n/messages_zh.go b/internal/i18n/messages_zh.go new file mode 100644 index 0000000000..9788e5d47b --- /dev/null +++ b/internal/i18n/messages_zh.go @@ -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] ", + 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] 执行单次任务后退出 + 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。 +`, +} diff --git a/internal/permission/permission.go b/internal/permission/permission.go new file mode 100644 index 0000000000..5217efa664 --- /dev/null +++ b/internal/permission/permission.go @@ -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 + ")" +} diff --git a/internal/permission/permission_test.go b/internal/permission/permission_test.go new file mode 100644 index 0000000000..978b4d2eba --- /dev/null +++ b/internal/permission/permission_test.go @@ -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) + } +} diff --git a/internal/plugin/example_test.go b/internal/plugin/example_test.go new file mode 100644 index 0000000000..7907bb40ce --- /dev/null +++ b/internal/plugin/example_test.go @@ -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") + } +} diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go new file mode 100644 index 0000000000..0e00e76322 --- /dev/null +++ b/internal/plugin/plugin.go @@ -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____"). 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____", +// 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____" + 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 +} diff --git a/internal/plugin/plugin_test.go b/internal/plugin/plugin_test.go new file mode 100644 index 0000000000..0473e20828 --- /dev/null +++ b/internal/plugin/plugin_test.go @@ -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')) + } +} diff --git a/internal/plugin/prompts.go b/internal/plugin/prompts.go new file mode 100644 index 0000000000..31bd95c5c3 --- /dev/null +++ b/internal/plugin/prompts.go @@ -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____"; running it fetches the rendered +// prompt and sends it to the model as a turn. +type Prompt struct { + Name string // "mcp____" — 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 +} diff --git a/internal/plugin/resources.go b/internal/plugin/resources.go new file mode 100644 index 0000000000..930475a3c0 --- /dev/null +++ b/internal/plugin/resources.go @@ -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 +// "@:" (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 +} diff --git a/internal/plugin/transport_http.go b/internal/plugin/transport_http.go new file mode 100644 index 0000000000..7a1c873e09 --- /dev/null +++ b/internal/plugin/transport_http.go @@ -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 +} diff --git a/internal/plugin/transport_http_test.go b/internal/plugin/transport_http_test.go new file mode 100644 index 0000000000..4ef285a3ae --- /dev/null +++ b/internal/plugin/transport_http_test.go @@ -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 +} diff --git a/internal/plugin/transport_stdio.go b/internal/plugin/transport_stdio.go new file mode 100644 index 0000000000..4fd1ba590c --- /dev/null +++ b/internal/plugin/transport_stdio.go @@ -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() + } +} diff --git a/internal/provider/openai/openai.go b/internal/provider/openai/openai.go new file mode 100644 index 0000000000..07c07eaae0 --- /dev/null +++ b/internal/provider/openai/openai.go @@ -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"` +} diff --git a/internal/provider/openai/openai_test.go b/internal/provider/openai/openai_test.go new file mode 100644 index 0000000000..be48e47d3b --- /dev/null +++ b/internal/provider/openai/openai_test.go @@ -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) + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go new file mode 100644 index 0000000000..5c2c7eabff --- /dev/null +++ b/internal/provider/provider.go @@ -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 +} diff --git a/internal/sandbox/sandbox.go b/internal/sandbox/sandbox.go new file mode 100644 index 0000000000..7c2d91b16a --- /dev/null +++ b/internal/sandbox/sandbox.go @@ -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" } diff --git a/internal/sandbox/seatbelt_darwin.go b/internal/sandbox/seatbelt_darwin.go new file mode 100644 index 0000000000..c360b7f483 --- /dev/null +++ b/internal/sandbox/seatbelt_darwin.go @@ -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 + `"` +} diff --git a/internal/sandbox/seatbelt_darwin_test.go b/internal/sandbox/seatbelt_darwin_test.go new file mode 100644 index 0000000000..dfe5326885 --- /dev/null +++ b/internal/sandbox/seatbelt_darwin_test.go @@ -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) + } +} diff --git a/internal/sandbox/seatbelt_other.go b/internal/sandbox/seatbelt_other.go new file mode 100644 index 0000000000..3fa3a69b2e --- /dev/null +++ b/internal/sandbox/seatbelt_other.go @@ -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 } diff --git a/internal/tool/builtin/bash.go b/internal/tool/builtin/bash.go new file mode 100644 index 0000000000..ce49468bbd --- /dev/null +++ b/internal/tool/builtin/bash.go @@ -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 +} diff --git a/internal/tool/builtin/builtin_test.go b/internal/tool/builtin/builtin_test.go new file mode 100644 index 0000000000..a9afd9b7bc --- /dev/null +++ b/internal/tool/builtin/builtin_test.go @@ -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(` +T + +

Hello & world

+ +

Visible text.

+`)) + })) + 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{"", "&"} { + 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) + } +} diff --git a/internal/tool/builtin/confine.go b/internal/tool/builtin/confine.go new file mode 100644 index 0000000000..84c4123440 --- /dev/null +++ b/internal/tool/builtin/confine.go @@ -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))) +} diff --git a/internal/tool/builtin/confine_test.go b/internal/tool/builtin/confine_test.go new file mode 100644 index 0000000000..a959ef4639 --- /dev/null +++ b/internal/tool/builtin/confine_test.go @@ -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) + } +} diff --git a/internal/tool/builtin/editfile.go b/internal/tool/builtin/editfile.go new file mode 100644 index 0000000000..916889c9c8 --- /dev/null +++ b/internal/tool/builtin/editfile.go @@ -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 +} diff --git a/internal/tool/builtin/glob.go b/internal/tool/builtin/glob.go new file mode 100644 index 0000000000..e6b5e83be4 --- /dev/null +++ b/internal/tool/builtin/glob.go @@ -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 +} diff --git a/internal/tool/builtin/grep.go b/internal/tool/builtin/grep.go new file mode 100644 index 0000000000..9c7af7de6b --- /dev/null +++ b/internal/tool/builtin/grep.go @@ -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 +} diff --git a/internal/tool/builtin/ls.go b/internal/tool/builtin/ls.go new file mode 100644 index 0000000000..9d55720cf5 --- /dev/null +++ b/internal/tool/builtin/ls.go @@ -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 +} diff --git a/internal/tool/builtin/multiedit.go b/internal/tool/builtin/multiedit.go new file mode 100644 index 0000000000..2818fedb6c --- /dev/null +++ b/internal/tool/builtin/multiedit.go @@ -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 +} diff --git a/internal/tool/builtin/readfile.go b/internal/tool/builtin/readfile.go new file mode 100644 index 0000000000..22d619edb4 --- /dev/null +++ b/internal/tool/builtin/readfile.go @@ -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 +} diff --git a/internal/tool/builtin/webfetch.go b/internal/tool/builtin/webfetch.go new file mode 100644 index 0000000000..7a670de9da --- /dev/null +++ b/internal/tool/builtin/webfetch.go @@ -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, "]*>.*?`) + htmlComment = regexp.MustCompile(`(?s)`) + anyTag = regexp.MustCompile(`(?s)<[^>]+>`) + multiBlank = regexp.MustCompile(`\n[\t ]*\n([\t ]*\n)+`) + trailingWS = regexp.MustCompile(`[\t ]+\n`) +) + +// htmlToText strips