mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 02:36:01 -10:00
feat: import Go implementation as v2 kernel (renamed duo → reasonix)
Bring in the finished Go rewrite (developed under the placeholder name "duo") as the v2 codebase. Code-only import — no upstream git history. Config- and plugin-driven coding agent: registry core, OpenAI-compatible providers, builtin tools, MCP (stdio + HTTP) plugins, permission + sandbox, custom slash commands, @-references, two-model collaboration. Renamed throughout: duo→reasonix, Duo→Reasonix, DUO→REASONIX — module path, imports, CLI name, cmd/ dirs, reasonix.toml, .reasonix/commands, and $REASONIX_* env prefixes. Kept the v2 MIT LICENSE (the Go project had none). Verified: go build ./... · go vet ./... · go test ./... all green; gofmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
1 parent
32a4c02e54
commit
7de6a2474f
92 files changed
+13485
-65
No files matched your search
@@ -0,0 +1,5 @@
|
||||
# Copy to .env and fill in. Only secrets belong here — a provider's base_url and
|
||||
# model are configured in reasonix.toml, where api_key_env points at these variables.
|
||||
|
||||
DEEPSEEK_API_KEY=
|
||||
MIMO_API_KEY=
|
||||
+10
-32
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -1,39 +1,204 @@
|
||||
# Reasonix v2
|
||||
# Reasonix
|
||||
|
||||
> DeepSeek-native coding agent — ground-up rewrite.
|
||||
English | [简体中文](README.zh-CN.md)
|
||||
|
||||
This branch is a clean-slate rebuild:
|
||||
A **config- and plugin-driven** coding agent: a thin harness driving multiple
|
||||
models. The core knows only interfaces — concrete models and tools are resolved
|
||||
from configuration or injected by plugins. Ships as a single static binary.
|
||||
|
||||
- **Kernel rewritten in Go** — the agent loop, tooling, and runtime move from TypeScript to Go.
|
||||
- **Web removed** — no web client in v2.
|
||||
- **Desktop client redone** — rebuilt from scratch.
|
||||
## Features
|
||||
|
||||
- **Config-driven.** Providers, the agent, enabled tools, and plugins are all
|
||||
declared in `reasonix.toml`. No hardcoded models.
|
||||
- **Multi-model & composable.** DeepSeek (flash/pro) and MiMo ship as presets;
|
||||
any OpenAI-compatible endpoint is a config entry, not new code. Optionally run
|
||||
two models together (executor + planner) in separate, cache-stable sessions.
|
||||
- **Plugin-driven.** External tools run as subprocesses over stdio JSON-RPC
|
||||
(MCP-compatible). Built-in tools self-register at compile time.
|
||||
- **Zero-friction distribution.** `CGO_ENABLED=0` single binary; cross-compile
|
||||
to six targets with one command. The only dependency is a TOML parser.
|
||||
|
||||
## Install / Build
|
||||
|
||||
```sh
|
||||
make build # -> bin/reasonix
|
||||
make cross # -> dist/ (darwin|linux|windows × amd64|arm64)
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```sh
|
||||
reasonix init # scaffold ./reasonix.toml
|
||||
export DEEPSEEK_API_KEY=sk-... # or put it in .env (see .env.example)
|
||||
reasonix run "implement the TODOs in main.go"
|
||||
reasonix run --model mimo-pro "add unit tests for this function"
|
||||
echo "explain this code" | reasonix run
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Resolution order: **flag > `./reasonix.toml` > `~/.config/reasonix/config.toml` >
|
||||
built-in defaults**. Secrets come from the environment via `api_key_env` and are
|
||||
never stored in config files.
|
||||
|
||||
```toml
|
||||
default_model = "deepseek-flash" # executor; set [agent].planner_model to add a planner
|
||||
# language = "zh" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG
|
||||
|
||||
[[providers]]
|
||||
name = "deepseek-flash"
|
||||
kind = "openai"
|
||||
base_url = "https://api.deepseek.com"
|
||||
model = "deepseek-v4-flash"
|
||||
api_key_env = "DEEPSEEK_API_KEY"
|
||||
# also preset: deepseek-pro, mimo-pro (mimo-v2.5-pro), mimo-flash (mimo-v2-flash) @ api.xiaomimimo.com/v1
|
||||
|
||||
[tools]
|
||||
enabled = [] # omit/empty = all built-ins
|
||||
|
||||
[permissions]
|
||||
mode = "ask" # writer fallback when no rule matches: ask|allow|deny
|
||||
deny = ["bash(rm -rf*)", "bash(git push*)"] # hard-blocked in every mode
|
||||
allow = ["bash(go test*)"] # never prompted
|
||||
|
||||
[sandbox]
|
||||
# workspace_root = "" # file-writers confined here; empty = current dir
|
||||
# allow_write = ["/tmp"] # extra dirs write_file/edit_file/multi_edit may touch
|
||||
|
||||
[[plugins]]
|
||||
name = "example"
|
||||
command = "reasonix-plugin-example"
|
||||
```
|
||||
|
||||
Permissions gate each tool call: `deny` > `ask` > `allow` > fallback (readers
|
||||
always allow; writers fall back to `mode`). `reasonix chat` prompts before writers
|
||||
(`y` once · `a` this session · `n` no); `reasonix run` stays autonomous but still
|
||||
honours `deny`. See [`docs/SPEC.md`](docs/SPEC.md) for the full schema and contract.
|
||||
|
||||
Permissions are *policy* (which calls to allow / prompt). The **sandbox** is
|
||||
*enforcement*: the file-writers (`write_file` / `edit_file` / `multi_edit`)
|
||||
refuse any path outside `[sandbox] workspace_root` (default: the current dir, so
|
||||
edits stay in the project), resolving symlinks and `..` so a link can't tunnel
|
||||
out. Reads are unrestricted. `bash` is itself jailed on macOS by default
|
||||
(`[sandbox] bash`, Seatbelt): commands may write only those same roots (plus
|
||||
temp and toolchain caches) and reach the network only when `[sandbox] network`
|
||||
is set. Other platforms fall back to running unconfined for now (see
|
||||
`docs/SPEC.md` §9 for the escape-prompt and Linux support still to come).
|
||||
|
||||
### Plugins (MCP)
|
||||
|
||||
Reasonix is an MCP client. A `[[plugins]]` entry's `type` selects the transport:
|
||||
`stdio` (default) launches a local subprocess (`command`/`args`/`env`); `http`
|
||||
(Streamable HTTP) connects to a remote `url` with optional static `headers`
|
||||
(`${VAR}` / `${VAR:-default}` expanded from the environment, so tokens stay out
|
||||
of the file). Tools surface to the model as `mcp__<server>__<tool>`, matching
|
||||
Claude Code; a tool declaring MCP's `readOnlyHint: true` joins parallel dispatch
|
||||
and the permission reader-default.
|
||||
|
||||
A server's **prompts** surface as `/mcp__<server>__<prompt>` slash commands
|
||||
(positional args after the command); its **resources** are pulled in by writing
|
||||
`@<server>:<uri>` in a message; `/mcp` lists connected servers and what each
|
||||
exposes. `make build` also produces `bin/reasonix-plugin-example` — a runnable
|
||||
reference stdio server (`echo`, `wordcount`, a `review` prompt, a style-guide
|
||||
resource) you can copy.
|
||||
|
||||
```toml
|
||||
[[plugins]] # local stdio server
|
||||
name = "example"
|
||||
command = "reasonix-plugin-example"
|
||||
|
||||
[[plugins]] # remote server over Streamable HTTP
|
||||
name = "stripe"
|
||||
type = "http"
|
||||
url = "https://mcp.stripe.com"
|
||||
headers = { Authorization = "Bearer ${STRIPE_KEY}" }
|
||||
```
|
||||
|
||||
**Already have a Claude Code `.mcp.json`?** Drop it in the project root and Reasonix
|
||||
reads it as-is — the `mcpServers` spec (`command`/`args`/`env`, `type`/`url`/
|
||||
`headers`, `${VAR}` expansion) maps field-for-field onto `[[plugins]]`. Both
|
||||
sources are merged; on a name collision `reasonix.toml` wins.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path"] },
|
||||
"stripe": { "type": "http", "url": "https://mcp.stripe.com", "headers": { "Authorization": "Bearer ${STRIPE_KEY}" } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Slash commands
|
||||
|
||||
In `reasonix chat`, built-in commands (`/compact`, `/new`, `/mcp`, `/help`) run
|
||||
locally. **Custom commands** are Markdown files under `.reasonix/commands/` (project)
|
||||
or `~/.config/reasonix/commands/` (user) — `review.md` becomes `/review`, a
|
||||
subdirectory namespaces it (`git/commit.md` → `/git:commit`). The body is a
|
||||
prompt template; invoking the command sends it as a turn.
|
||||
|
||||
```markdown
|
||||
---
|
||||
description: Review the staged diff
|
||||
argument-hint: [focus-area]
|
||||
---
|
||||
Review the staged diff. Focus on $ARGUMENTS, list bugs with file:line.
|
||||
```
|
||||
|
||||
`$ARGUMENTS` expands to all space-separated args, `$1`…`$N` to positional ones.
|
||||
MCP prompts also appear here as `/mcp__<server>__<prompt>`.
|
||||
|
||||
### @ references
|
||||
|
||||
Embed `@` references in a message and Reasonix resolves them before sending, as
|
||||
tagged context blocks: `@path/to/file` (or `@dir`) injects a local file's
|
||||
contents (or a directory listing), and `@<server>:<uri>` injects an MCP
|
||||
resource. A local path is only treated as a reference when it actually exists,
|
||||
so ordinary `@mentions` stay literal. Typing `/` or `@` opens an autocomplete
|
||||
menu — slash commands, or hierarchical file navigation (one directory level at a
|
||||
time, descend into folders) plus MCP resources.
|
||||
|
||||
### Two-model collaboration (optional)
|
||||
|
||||
`reasonix init` keeps first-run minimal: pick provider → keys (every SKU of a
|
||||
chosen provider is enabled). Running two models together (executor + planner,
|
||||
separate cache-stable sessions) is a one-line edit afterwards — set
|
||||
`planner_model` to any other enabled provider:
|
||||
|
||||
```toml
|
||||
[agent]
|
||||
planner_model = "deepseek-pro" # used as the low-frequency planner
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Three tiers of extensibility, all behind registries the core resolves by name:
|
||||
|
||||
1. **Registry** — `Provider` and `Tool` are interfaces; the core has no
|
||||
`switch model`.
|
||||
2. **Compile-time built-ins** — providers (`provider/openai`) and tools
|
||||
(`tool/builtin`) self-register via `init()`; `main` blank-imports them.
|
||||
Adding a built-in is one file plus one import.
|
||||
3. **Runtime plugins** — executables declared in config, spoken to over
|
||||
newline-delimited JSON-RPC 2.0 on stdin/stdout (the MCP stdio convention).
|
||||
Each remote tool is adapted to the `Tool` interface.
|
||||
|
||||
## Status
|
||||
|
||||
🚧 Early work in progress. The architecture and layout are still taking shape.
|
||||
|
||||
The previous TypeScript implementation (CLI + web + desktop) remains available on the
|
||||
[`main`](https://github.com/esengine/DeepSeek-Reasonix/tree/main) branch for reference.
|
||||
|
||||
## License
|
||||
|
||||
[MIT](./LICENSE) © 2026 Reasonix Contributors
|
||||
|
||||
---
|
||||
|
||||
# Reasonix v2(简体中文)
|
||||
|
||||
> DeepSeek 原生编码 agent —— 从零重写。
|
||||
|
||||
本分支是全新重建:
|
||||
|
||||
- **内核改用 Go 重写** —— agent 循环、工具与运行时从 TypeScript 迁移到 Go。
|
||||
- **去掉 web** —— v2 不再包含 web 端。
|
||||
- **重做桌面端** —— 从头重建。
|
||||
|
||||
## 状态
|
||||
|
||||
🚧 早期开发中,架构与目录结构尚在成形。
|
||||
|
||||
旧版 TypeScript 实现(CLI + web + 桌面端)仍保留在
|
||||
[`main`](https://github.com/esengine/DeepSeek-Reasonix/tree/main) 分支以供参考。
|
||||
Done: registry-based providers/tools, OpenAI-compatible streaming with tool
|
||||
calls (bounded retry on 429/5xx), nine built-in tools (read_file, write_file,
|
||||
edit_file, multi_edit, bash, ls, glob, grep, web_fetch), TOML config, an
|
||||
interactive `reasonix init` wizard, two-model collaboration (executor + planner in
|
||||
separate, cache-stable sessions), low-frequency context compaction, sub-agents
|
||||
(`task`), a bubbletea chat TUI (markdown, plan mode, context gauge, `/compact`
|
||||
`/new`), session persistence + resume, per-call **permissions** (allow/ask/deny
|
||||
rules; chat prompts before writers, deny rules hard-block everywhere), a
|
||||
**workspace sandbox** confining file-writers to the project (symlink/`..`-safe),
|
||||
stdio (MCP) client — **stdio + Streamable HTTP** transports, tools (`mcp__server__tool`,
|
||||
`readOnlyHint`-aware), prompts (slash commands), resources (`@`-references), and
|
||||
`/mcp`, configured via `[[plugins]]` or a Claude-style project `.mcp.json` —
|
||||
custom slash commands (`.reasonix/commands/*.md`), `@file` / `@resource`
|
||||
references, plus a runnable reference plugin (`cmd/reasonix-plugin-example`), the
|
||||
harness loop, and CLI. The chat runs in the terminal's normal buffer (native
|
||||
scrollback) with `/` and `@` autocomplete. Next: an OS-level sandbox for `bash`
|
||||
(macOS Seatbelt / Linux bubblewrap, "allow inside the box, prompt at its edge"),
|
||||
an Anthropic-native provider, MCP OAuth + legacy SSE. See `docs/SPEC.md` §9.
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
# Reasonix
|
||||
|
||||
[English](README.md) | 简体中文
|
||||
|
||||
一个**由配置和插件驱动**的 coding agent:极薄的 harness 驱动多个模型。内核只认
|
||||
接口——具体的模型与工具由配置解析或由插件注入。以单一静态二进制分发。
|
||||
|
||||
## 特性
|
||||
|
||||
- **配置驱动**:provider、agent、启用的工具、插件全部在 `reasonix.toml` 中声明,
|
||||
内核无硬编码模型。
|
||||
- **多模型 · 可组合**:DeepSeek(flash/pro)与 MiMo 作为预设内置;任何 OpenAI 兼容
|
||||
端点都只是一条配置。可选让两个模型协同(执行器 + 规划器),各自独立、缓存稳定的 session。
|
||||
- **插件驱动**:外部工具以子进程形式运行,通过 stdio JSON-RPC 通信(MCP 兼容);
|
||||
内置工具在编译期自注册。
|
||||
- **零摩擦分发**:`CGO_ENABLED=0` 单二进制;一条命令交叉编译到六个目标平台。
|
||||
唯一依赖是一个 TOML 解析库。
|
||||
|
||||
## 安装 / 构建
|
||||
|
||||
```sh
|
||||
make build # -> bin/reasonix
|
||||
make cross # -> dist/(darwin|linux|windows × amd64|arm64)
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
```sh
|
||||
reasonix init # 生成 ./reasonix.toml
|
||||
export DEEPSEEK_API_KEY=sk-... # 或写入 .env(见 .env.example)
|
||||
reasonix run "把 main.go 里的 TODO 实现掉"
|
||||
reasonix run --model mimo-pro "给这个函数补单元测试"
|
||||
echo "解释这段代码" | reasonix run
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
优先级:**flag > `./reasonix.toml` > `~/.config/reasonix/config.toml` > 内置默认值**。
|
||||
密钥经环境变量通过 `api_key_env` 注入,绝不写入配置文件。
|
||||
|
||||
```toml
|
||||
default_model = "deepseek-flash" # 执行器;设 [agent].planner_model 可加规划器
|
||||
# language = "zh" # 界面语言;为空则按 $LANG / $REASONIX_LANG 自动检测
|
||||
|
||||
[[providers]]
|
||||
name = "deepseek-flash"
|
||||
kind = "openai"
|
||||
base_url = "https://api.deepseek.com"
|
||||
model = "deepseek-v4-flash"
|
||||
api_key_env = "DEEPSEEK_API_KEY"
|
||||
# 还有预设:deepseek-pro、mimo-pro(mimo-v2.5-pro)、mimo-flash(mimo-v2-flash) @ api.xiaomimimo.com/v1
|
||||
|
||||
[tools]
|
||||
enabled = [] # 省略/为空 = 全部内置工具
|
||||
|
||||
[permissions]
|
||||
mode = "ask" # 无规则命中时 writer 的兜底:ask|allow|deny
|
||||
deny = ["bash(rm -rf*)", "bash(git push*)"] # 任何模式下都硬阻断
|
||||
allow = ["bash(go test*)"] # 从不询问
|
||||
|
||||
[sandbox]
|
||||
# workspace_root = "" # 文件写工具被限制在此目录;留空 = 当前目录
|
||||
# allow_write = ["/tmp"] # write_file/edit_file/multi_edit 额外可写的目录
|
||||
|
||||
[[plugins]]
|
||||
name = "example"
|
||||
command = "reasonix-plugin-example"
|
||||
```
|
||||
|
||||
权限逐次调用把关:`deny` > `ask` > `allow` > 兜底(只读工具永远 allow,writer 落到
|
||||
`mode`)。`reasonix chat` 会在 writer 调用前征求同意(`y` 本次 · `a` 本会话 · `n` 拒绝);
|
||||
`reasonix run` 保持自主运行但仍然遵守 `deny`。完整 schema 与契约见
|
||||
[`docs/SPEC.md`](docs/SPEC.md)。
|
||||
|
||||
权限是**策略**(哪些调用放行/询问),**沙盒**是**强制**:文件写工具
|
||||
(`write_file` / `edit_file` / `multi_edit`)拒绝 `[sandbox] workspace_root`
|
||||
之外的任何路径(默认当前目录,编辑不出项目),并解析符号链接与 `..`,使链接无法
|
||||
打洞越界。读不受限。`bash` 本身在 macOS 默认进沙盒(`[sandbox] bash`,Seatbelt):
|
||||
命令只能写这些 root(外加临时目录与工具链缓存),`[sandbox] network` 为真时才能联网;
|
||||
其它平台暂回退为不沙盒运行(越界问一次与 Linux 支持见 `docs/SPEC.md` §9)。
|
||||
|
||||
### 插件(MCP)
|
||||
|
||||
Reasonix 是一个 MCP 客户端。`[[plugins]]` 的 `type` 选择传输:`stdio`(默认)启动本地子进
|
||||
程(`command`/`args`/`env`);`http`(Streamable HTTP)连接远程 `url`,可带静态
|
||||
`headers`(`${VAR}` / `${VAR:-default}` 从环境展开,密钥不入文件)。工具以
|
||||
`mcp__<server>__<tool>` 暴露给模型,与 Claude Code 一致;声明 MCP `readOnlyHint: true`
|
||||
的工具会参与并行调度并命中权限层的只读默认放行。
|
||||
|
||||
服务器的 **prompts** 会暴露成 `/mcp__<server>__<prompt>` 斜杠命令(命令后空格分隔参
|
||||
数);**resources** 通过在消息里写 `@<server>:<uri>` 拉入;`/mcp` 列出已连接服务器及
|
||||
各自暴露的内容。`make build` 还会产出 `bin/reasonix-plugin-example`——一个可直接运行的
|
||||
stdio 参考实现(`echo`、`wordcount`、一个 `review` prompt、一个 style-guide 资源),
|
||||
可照抄。
|
||||
|
||||
```toml
|
||||
[[plugins]] # 本地 stdio 服务器
|
||||
name = "example"
|
||||
command = "reasonix-plugin-example"
|
||||
|
||||
[[plugins]] # 远程 Streamable HTTP 服务器
|
||||
name = "stripe"
|
||||
type = "http"
|
||||
url = "https://mcp.stripe.com"
|
||||
headers = { Authorization = "Bearer ${STRIPE_KEY}" }
|
||||
```
|
||||
|
||||
**已有 Claude Code 的 `.mcp.json`?** 直接放到项目根目录,Reasonix 会原样读取——其
|
||||
`mcpServers` 规范(`command`/`args`/`env`、`type`/`url`/`headers`、`${VAR}` 展开)
|
||||
与 `[[plugins]]` 字段一一对应。两处来源会合并加载;同名时以 `reasonix.toml` 为准。
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path"] },
|
||||
"stripe": { "type": "http", "url": "https://mcp.stripe.com", "headers": { "Authorization": "Bearer ${STRIPE_KEY}" } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 斜杠命令
|
||||
|
||||
`reasonix chat` 里,内置命令(`/compact`、`/new`、`/mcp`、`/help`)在本地执行。**自定义命令**
|
||||
是放在 `.reasonix/commands/`(项目)或 `~/.config/reasonix/commands/`(用户)下的 Markdown 文件——
|
||||
`review.md` 即 `/review`,子目录构成命名空间(`git/commit.md` → `/git:commit`)。文件正文
|
||||
是 prompt 模板,调用即作为一轮对话发出。
|
||||
|
||||
```markdown
|
||||
---
|
||||
description: Review the staged diff
|
||||
argument-hint: [focus-area]
|
||||
---
|
||||
Review the staged diff. Focus on $ARGUMENTS, list bugs with file:line.
|
||||
```
|
||||
|
||||
`$ARGUMENTS` 展开为全部空格分隔参数,`$1`…`$N` 为位置参数。MCP prompts 也以
|
||||
`/mcp__<server>__<prompt>` 形式出现在这里。
|
||||
|
||||
### @ 引用
|
||||
|
||||
在消息里写 `@` 引用,Reasonix 会在发送前解析成带标签的上下文块:`@path/to/file`(或
|
||||
`@dir`)注入本地文件内容(或目录清单),`@<server>:<uri>` 注入 MCP 资源。本地路径**只有
|
||||
真实存在**时才当作引用,普通 `@mention` 保持原文。敲 `/` 或 `@` 会弹出补全菜单——斜杠
|
||||
命令,或**逐层**的文件导航(一次只列当前一层目录、可下钻进子目录)外加 MCP 资源。
|
||||
|
||||
### 双模型协同(可选)
|
||||
|
||||
`reasonix init` 刻意保持首次体验极简:选 provider → 输入 key(所选 provider 的所有
|
||||
SKU 都会启用)。若要让两个模型协同(执行器 + 规划器,各自独立、缓存稳定的
|
||||
session),向导后手动在 `reasonix.toml` 加一行即可:
|
||||
|
||||
```toml
|
||||
[agent]
|
||||
planner_model = "deepseek-pro" # 作为低频规划器
|
||||
```
|
||||
|
||||
## 架构
|
||||
|
||||
三层可扩展性,全部藏在内核按名解析的 registry 之后:
|
||||
|
||||
1. **Registry**:`Provider` 与 `Tool` 是接口;内核没有 `switch model`。
|
||||
2. **编译期内置**:provider(`provider/openai`)和 tool(`tool/builtin`)通过
|
||||
`init()` 自注册,`main` 用 blank import 拉入。新增内置 = 一个文件 + 一行 import。
|
||||
3. **运行时插件**:配置里声明的可执行文件,通过 stdin/stdout 上的
|
||||
newline-delimited JSON-RPC 2.0(MCP stdio 约定)通信,每个远程 tool 适配成
|
||||
`Tool` 接口。
|
||||
|
||||
## 状态
|
||||
|
||||
已完成:基于 registry 的 provider/tool、OpenAI 兼容流式 + 工具调用(429/5xx 有界重
|
||||
试)、九个内置工具(read_file、write_file、edit_file、multi_edit、bash、ls、glob、
|
||||
grep、web_fetch)、TOML 配置、交互式 `reasonix init` 向导、双模型协同(执行器 + 规划器,
|
||||
各自独立、缓存稳定的 session)、低频上下文压缩、子 agent(`task`)、bubbletea 聊天
|
||||
TUI(markdown、plan mode、上下文仪表盘、`/compact` `/new`)、会话持久化 + 恢复、
|
||||
逐次调用**权限**(allow/ask/deny 规则;chat 在 writer 前询问,deny 在各模式硬阻断)、
|
||||
**工作区沙盒**(把文件写工具限制在项目内,符号链接/`..` 安全)、
|
||||
MCP 客户端——**stdio + Streamable HTTP** 传输、工具(`mcp__server__tool`,支持
|
||||
`readOnlyHint`)、prompts(斜杠命令)、resources(`@` 引用)、`/mcp`,可经
|
||||
`[[plugins]]` 或 Claude 风格的项目 `.mcp.json` 配置——自定义斜杠命令
|
||||
(`.reasonix/commands/*.md`)、`@file` / `@resource` 引用、外加可运行的参考插件
|
||||
(`cmd/reasonix-plugin-example`)、harness 主循环、CLI。chat 在终端普通缓冲区运行(原生
|
||||
scrollback)并带 `/` 与 `@` 输入补全。后续:给 `bash` 套 OS 级沙盒(macOS Seatbelt /
|
||||
Linux bubblewrap,"盒子里放行、边界上询问")、Anthropic 原生 provider、MCP OAuth +
|
||||
legacy SSE。见 `docs/SPEC.md` §9。
|
||||
@@ -0,0 +1,349 @@
|
||||
// Command reasonix-plugin-example is a reference Reasonix plugin: a minimal MCP stdio
|
||||
// server speaking newline-delimited JSON-RPC 2.0 on stdin/stdout. It exists to
|
||||
// document the contract end-to-end (the protocol the internal/plugin client
|
||||
// drives) and to give users a working example to copy.
|
||||
//
|
||||
// Wire it up in reasonix.toml:
|
||||
//
|
||||
// [[plugins]]
|
||||
// name = "example"
|
||||
// command = "reasonix-plugin-example"
|
||||
//
|
||||
// Then reasonix surfaces its tools as "mcp__example__echo" / "mcp__example__wordcount",
|
||||
// its prompt as the "/mcp__example__review" slash command, and its resource as
|
||||
// the "@example:doc://style-guide" reference.
|
||||
//
|
||||
// Protocol, one JSON object per line:
|
||||
// - initialize → {protocolVersion, capabilities, serverInfo}
|
||||
// - notifications/initialized (notification, no id) → ignored
|
||||
// - tools/list → {tools: [{name, description, inputSchema, annotations}]}
|
||||
// - tools/call {name, arguments} → {content: [{type:"text", text}], isError}
|
||||
// - prompts/list → {prompts: [{name, description, arguments}]}
|
||||
// - prompts/get {name, arguments} → {messages: [{role, content:{type,text}}]}
|
||||
// - resources/list → {resources: [{uri, name, description, mimeType}]}
|
||||
// - resources/read {uri} → {contents: [{uri, mimeType, text}]}
|
||||
//
|
||||
// Logs go to stderr (reasonix forwards plugin stderr to the terminal); stdout is
|
||||
// reserved for JSON-RPC so it must never carry stray prose.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// version is overridable via -ldflags "-X main.version=...". Reported in
|
||||
// initialize's serverInfo so reasonix (and humans) can see which build is running.
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
log.SetPrefix("reasonix-plugin-example: ")
|
||||
log.SetFlags(0)
|
||||
if err := serve(os.Stdin, os.Stdout); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- JSON-RPC framing ---
|
||||
|
||||
type request struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID *json.RawMessage `json:"id"` // nil ⇒ notification (no reply); echoed back verbatim otherwise
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
}
|
||||
|
||||
type response struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id"`
|
||||
Result any `json:"result,omitempty"`
|
||||
Error *rpcError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type rpcError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
const (
|
||||
protocolVersion = "2024-11-05"
|
||||
codeMethodNotFound = -32601
|
||||
codeInvalidParams = -32602
|
||||
)
|
||||
|
||||
// serve runs the read-dispatch-reply loop until stdin closes (reasonix closed the
|
||||
// pipe / is shutting down). Each line is one JSON-RPC message.
|
||||
func serve(in *os.File, out *os.File) error {
|
||||
r := bufio.NewReader(in)
|
||||
w := bufio.NewWriter(out)
|
||||
defer w.Flush()
|
||||
|
||||
for {
|
||||
line, err := r.ReadBytes('\n')
|
||||
if len(line) > 0 {
|
||||
if rerr := handleLine(line, w); rerr != nil {
|
||||
return rerr
|
||||
}
|
||||
if ferr := w.Flush(); ferr != nil {
|
||||
return ferr
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil // EOF or pipe closed: clean shutdown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleLine(line []byte, w *bufio.Writer) error {
|
||||
line = trimSpace(line)
|
||||
if len(line) == 0 {
|
||||
return nil
|
||||
}
|
||||
var req request
|
||||
if err := json.Unmarshal(line, &req); err != nil {
|
||||
log.Printf("skipping unparseable line: %v", err)
|
||||
return nil
|
||||
}
|
||||
if req.ID == nil {
|
||||
return nil // notification (e.g. notifications/initialized): no reply
|
||||
}
|
||||
|
||||
resp := response{JSONRPC: "2.0", ID: *req.ID}
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
resp.Result = map[string]any{
|
||||
"protocolVersion": protocolVersion,
|
||||
"capabilities": map[string]any{
|
||||
"tools": map[string]any{},
|
||||
"prompts": map[string]any{},
|
||||
"resources": map[string]any{},
|
||||
},
|
||||
"serverInfo": map[string]any{"name": "reasonix-plugin-example", "version": version},
|
||||
}
|
||||
case "tools/list":
|
||||
resp.Result = map[string]any{"tools": toolList()}
|
||||
case "tools/call":
|
||||
resp.Result, resp.Error = callTool(req.Params)
|
||||
case "prompts/list":
|
||||
resp.Result = map[string]any{"prompts": promptList()}
|
||||
case "prompts/get":
|
||||
resp.Result, resp.Error = getPrompt(req.Params)
|
||||
case "resources/list":
|
||||
resp.Result = map[string]any{"resources": resourceList()}
|
||||
case "resources/read":
|
||||
resp.Result, resp.Error = readResource(req.Params)
|
||||
default:
|
||||
resp.Error = &rpcError{Code: codeMethodNotFound, Message: "method not found: " + req.Method}
|
||||
}
|
||||
|
||||
b, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal response: %w", err)
|
||||
}
|
||||
if _, err := w.Write(append(b, '\n')); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- tools ---
|
||||
|
||||
// toolDef is one exposed tool: its advertised metadata plus the handler. run
|
||||
// returns the text result, or an error which becomes an isError tool result the
|
||||
// model can read and adapt to.
|
||||
type toolDef struct {
|
||||
name string
|
||||
description string
|
||||
schema map[string]any
|
||||
readOnly bool
|
||||
run func(args map[string]any) (string, error)
|
||||
}
|
||||
|
||||
// tools is the registry. Both demo tools are read-only and declare it via the
|
||||
// readOnlyHint annotation, so reasonix runs them in parallel batches and (with the
|
||||
// permission layer) auto-allows them without prompting.
|
||||
var tools = []toolDef{
|
||||
{
|
||||
name: "echo",
|
||||
description: "Echo the given text back. The simplest possible proof the plugin round-trip works.",
|
||||
readOnly: true,
|
||||
schema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"text": map[string]any{"type": "string", "description": "Text to echo back"},
|
||||
},
|
||||
"required": []string{"text"},
|
||||
},
|
||||
run: func(args map[string]any) (string, error) {
|
||||
text, ok := args["text"].(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("argument 'text' must be a string")
|
||||
}
|
||||
return text, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "wordcount",
|
||||
description: "Count the lines, words, and bytes of the given text (like wc).",
|
||||
readOnly: true,
|
||||
schema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"text": map[string]any{"type": "string", "description": "Text to measure"},
|
||||
},
|
||||
"required": []string{"text"},
|
||||
},
|
||||
run: func(args map[string]any) (string, error) {
|
||||
text, ok := args["text"].(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("argument 'text' must be a string")
|
||||
}
|
||||
return fmt.Sprintf("lines: %d, words: %d, bytes: %d, runes: %d",
|
||||
countLines(text), len(strings.Fields(text)), len(text), utf8.RuneCountInString(text)), nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func toolList() []map[string]any {
|
||||
out := make([]map[string]any, 0, len(tools))
|
||||
for _, t := range tools {
|
||||
out = append(out, map[string]any{
|
||||
"name": t.name,
|
||||
"description": t.description,
|
||||
"inputSchema": t.schema,
|
||||
"annotations": map[string]any{
|
||||
"readOnlyHint": t.readOnly,
|
||||
"title": t.name,
|
||||
},
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// callTool dispatches a tools/call. A handler error is reported as an isError
|
||||
// content result (in-band, so the model sees it) rather than a JSON-RPC error;
|
||||
// JSON-RPC errors are reserved for protocol-level faults (bad params, unknown
|
||||
// tool).
|
||||
func callTool(params json.RawMessage) (any, *rpcError) {
|
||||
var p struct {
|
||||
Name string `json:"name"`
|
||||
Arguments map[string]any `json:"arguments"`
|
||||
}
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return nil, &rpcError{Code: codeInvalidParams, Message: "invalid params: " + err.Error()}
|
||||
}
|
||||
for _, t := range tools {
|
||||
if t.name != p.Name {
|
||||
continue
|
||||
}
|
||||
text, err := t.run(p.Arguments)
|
||||
if err != nil {
|
||||
return textResult(err.Error(), true), nil
|
||||
}
|
||||
return textResult(text, false), nil
|
||||
}
|
||||
return nil, &rpcError{Code: codeInvalidParams, Message: "unknown tool: " + p.Name}
|
||||
}
|
||||
|
||||
func textResult(text string, isError bool) map[string]any {
|
||||
return map[string]any{
|
||||
"content": []map[string]any{{"type": "text", "text": text}},
|
||||
"isError": isError,
|
||||
}
|
||||
}
|
||||
|
||||
// --- prompts ---
|
||||
|
||||
// promptList advertises the server's prompts. They surface in reasonix as
|
||||
// /mcp__example__<name> slash commands.
|
||||
func promptList() []map[string]any {
|
||||
return []map[string]any{{
|
||||
"name": "review",
|
||||
"description": "Draft a focused code-review request for a file.",
|
||||
"arguments": []map[string]any{
|
||||
{"name": "path", "description": "File to review", "required": true},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// getPrompt renders a prompt into MCP messages. The returned text becomes the
|
||||
// next user turn in reasonix, so it's phrased as an instruction to the model.
|
||||
func getPrompt(params json.RawMessage) (any, *rpcError) {
|
||||
var p struct {
|
||||
Name string `json:"name"`
|
||||
Arguments map[string]string `json:"arguments"`
|
||||
}
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return nil, &rpcError{Code: codeInvalidParams, Message: "invalid params: " + err.Error()}
|
||||
}
|
||||
if p.Name != "review" {
|
||||
return nil, &rpcError{Code: codeInvalidParams, Message: "unknown prompt: " + p.Name}
|
||||
}
|
||||
path := p.Arguments["path"]
|
||||
if path == "" {
|
||||
path = "the current file"
|
||||
}
|
||||
text := fmt.Sprintf("Please review %s. Read it, then list any correctness bugs and risky patterns with file:line references, most important first.", path)
|
||||
return map[string]any{
|
||||
"description": "Code review request",
|
||||
"messages": []map[string]any{
|
||||
{"role": "user", "content": map[string]any{"type": "text", "text": text}},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// --- resources ---
|
||||
|
||||
// resourceContents is the demo resource store, keyed by uri.
|
||||
var resourceContents = map[string]string{
|
||||
"doc://style-guide": "Project style: tabs for indentation; comments explain why, not what; keep functions short.",
|
||||
}
|
||||
|
||||
func resourceList() []map[string]any {
|
||||
return []map[string]any{{
|
||||
"uri": "doc://style-guide",
|
||||
"name": "Style guide",
|
||||
"description": "The project's coding style notes.",
|
||||
"mimeType": "text/plain",
|
||||
}}
|
||||
}
|
||||
|
||||
func readResource(params json.RawMessage) (any, *rpcError) {
|
||||
var p struct {
|
||||
URI string `json:"uri"`
|
||||
}
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return nil, &rpcError{Code: codeInvalidParams, Message: "invalid params: " + err.Error()}
|
||||
}
|
||||
text, ok := resourceContents[p.URI]
|
||||
if !ok {
|
||||
return nil, &rpcError{Code: codeInvalidParams, Message: "unknown resource: " + p.URI}
|
||||
}
|
||||
return map[string]any{
|
||||
"contents": []map[string]any{
|
||||
{"uri": p.URI, "mimeType": "text/plain", "text": text},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// countLines counts newline-separated lines, counting a final unterminated line.
|
||||
func countLines(s string) int {
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
n := strings.Count(s, "\n")
|
||||
if !strings.HasSuffix(s, "\n") {
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// trimSpace trims leading/trailing ASCII whitespace without pulling in bytes.
|
||||
func trimSpace(b []byte) []byte {
|
||||
return []byte(strings.TrimSpace(string(b)))
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Command reasonix is a config- and plugin-driven coding agent CLI.
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"reasonix/internal/cli"
|
||||
|
||||
// Blank imports wire compile-time built-ins into their registries.
|
||||
_ "reasonix/internal/provider/openai"
|
||||
_ "reasonix/internal/tool/builtin"
|
||||
)
|
||||
|
||||
// version is injected at build time via -ldflags "-X main.version=...".
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
os.Exit(cli.Run(os.Args[1:], version))
|
||||
}
|
||||
+455
@@ -0,0 +1,455 @@
|
||||
# Reasonix Engineering Spec
|
||||
|
||||
> Reasonix is a coding agent: a thin harness driving multiple models, with **all
|
||||
> capabilities supplied by configuration and plugins**. This document is the
|
||||
> contract — code follows it. Change the contract first, then the code.
|
||||
|
||||
## 1. Design Principles
|
||||
|
||||
1. **Config- and plugin-driven core.** The core knows only interfaces. Concrete
|
||||
models and tools are resolved by name from registries, declared in config, or
|
||||
injected by plugins. No hardcoded `switch model`.
|
||||
2. **Single static binary.** `CGO_ENABLED=0`; cross-compile with one command;
|
||||
CLI works out of the box.
|
||||
3. **Lean dependencies.** Standard library by default. A third-party dependency
|
||||
must be pure-Go, lightweight, and must not compromise the single-binary /
|
||||
cross-platform / distribution story. TOML parsing is the one accepted dependency.
|
||||
4. **Two extension tiers.** Compile-time built-ins (self-register via `init()`),
|
||||
and runtime external plugins (stdio JSON-RPC subprocesses, MCP-compatible).
|
||||
5. **Interface-first & registry-based.** `Provider` and `Tool` are interfaces.
|
||||
6. **Evolve, don't over-engineer.**
|
||||
|
||||
Language: **English is the primary language for all code** — comments,
|
||||
user-facing strings, tool descriptions, system prompts, and this spec. The
|
||||
README is bilingual (`README.md` English + `README.zh-CN.md`).
|
||||
|
||||
## 2. Layout
|
||||
|
||||
```
|
||||
reasonix/
|
||||
├── go.mod / go.sum # module reasonix; require BurntSushi/toml
|
||||
├── Makefile # build / cross / vet / fmt / test
|
||||
├── README.md / README.zh-CN.md
|
||||
├── reasonix.example.toml # sample config
|
||||
├── docs/SPEC.md # this file
|
||||
├── cmd/reasonix/main.go # entry; blank-imports built-in providers/tools
|
||||
├── cmd/reasonix-plugin-example/ # reference MCP stdio plugin (a runnable example)
|
||||
└── internal/
|
||||
├── cli/ # subcommand routing, flags, assembly, exit codes
|
||||
├── config/ # TOML loading (flag > project > user > defaults)
|
||||
├── provider/ # Provider interface + types + kind→factory registry
|
||||
│ └── openai/ # OpenAI-compatible impl; init() registers "openai"
|
||||
├── tool/ # Tool interface + Registry
|
||||
│ └── builtin/ # read_file/write_file/edit_file/bash/ls/glob/grep
|
||||
├── permission/ # per-call Policy: allow/ask/deny rules → Decision
|
||||
├── command/ # custom slash commands loaded from .reasonix/commands/*.md
|
||||
├── plugin/ # stdio JSON-RPC (MCP) client; adapts remote tools
|
||||
└── agent/ # Session + harness loop
|
||||
```
|
||||
|
||||
Dependency direction (acyclic): `cli → {agent, plugin, config} → {tool, provider}`.
|
||||
Built-in subpackages (`provider/openai`, `tool/builtin`) import their parent to
|
||||
self-register; parents never import children.
|
||||
|
||||
## 3. Core Abstractions
|
||||
|
||||
### 3.1 Provider + registry (`internal/provider`)
|
||||
|
||||
```go
|
||||
type Provider interface {
|
||||
Name() string
|
||||
Stream(ctx context.Context, req Request) (<-chan Chunk, error)
|
||||
}
|
||||
|
||||
// Factory builds a Provider from a resolved config instance.
|
||||
type Factory func(cfg Config) (Provider, error)
|
||||
|
||||
// Register adds a factory under a kind (e.g. "openai"). Called from init().
|
||||
func Register(kind string, f Factory)
|
||||
|
||||
// New instantiates the provider of the given kind.
|
||||
func New(kind string, cfg Config) (Provider, error)
|
||||
|
||||
type Config struct {
|
||||
Name string // instance name, e.g. "deepseek"
|
||||
BaseURL string
|
||||
Model string
|
||||
APIKey string
|
||||
Extra map[string]any // kind-specific options
|
||||
}
|
||||
```
|
||||
|
||||
- The `openai` kind is an OpenAI-compatible `/chat/completions` implementation.
|
||||
- **DeepSeek and MiMo are not code — they are config instances** of `kind = "openai"`,
|
||||
differing only in `base_url` / `model` / `api_key_env`. Adding another OpenAI-
|
||||
compatible model is a config edit, not a code change.
|
||||
- Streaming tool-call deltas are accumulated by index inside the provider; only
|
||||
complete `ToolCall`s are emitted.
|
||||
|
||||
### 3.2 Tool + registry (`internal/tool`)
|
||||
|
||||
```go
|
||||
type Tool interface {
|
||||
Name() string
|
||||
Description() string
|
||||
Schema() json.RawMessage // JSON Schema for parameters
|
||||
Execute(ctx context.Context, args json.RawMessage) (string, error)
|
||||
}
|
||||
```
|
||||
|
||||
- Built-in tools self-register into a process-global builtin set via `init()`
|
||||
(`tool.RegisterBuiltin(t)`); `tool.Builtins()` lists them.
|
||||
- A runtime `*Registry` is assembled per run: enabled built-ins (filtered by
|
||||
config) **plus** plugin-provided tools. The agent only sees the `*Registry`.
|
||||
- `Execute` parses raw JSON args itself. Errors are returned, not fatal — the
|
||||
agent feeds them back so the model can self-correct.
|
||||
|
||||
### 3.3 Plugins (`internal/plugin`) — MCP client
|
||||
|
||||
An external plugin is an MCP server declared in config. The wire protocol is
|
||||
**JSON-RPC 2.0** in every case; only the transport differs. A `transport`
|
||||
interface (`call` / `notify` / `close`) abstracts that, so the MCP-level logic
|
||||
(handshake, `tools/list`, `tools/call`, …) is written once.
|
||||
|
||||
- **Transports** (config `type`):
|
||||
- `stdio` (default) — a local subprocess; one JSON message per line over the
|
||||
child's stdin/stdout (the MCP stdio convention). Declared with
|
||||
`command` / `args` / `env`; terminated on ctx cancel / shutdown.
|
||||
- `http` (a.k.a. `streamable-http`) — a remote server at `url`. Each request
|
||||
is an HTTP POST; the server replies with either `application/json` (one
|
||||
response) or `text/event-stream` (an SSE stream carrying the response plus
|
||||
any server notifications). The `Mcp-Session-Id` response header, once seen,
|
||||
is echoed on subsequent requests. Static `headers` (e.g. a bearer token) are
|
||||
sent on every request. OAuth is out of scope for now (see §9).
|
||||
- `sse` — the legacy 2024-11-05 HTTP+SSE transport; recognised but deferred
|
||||
(deprecated upstream — use `http`). Configuring it returns a clear error.
|
||||
- `${VAR}` / `${VAR:-default}` are expanded in `command`, `args`, `env`, `url`,
|
||||
and `headers` so secrets come from the environment, not the config file.
|
||||
- Lifecycle: `initialize` → `notifications/initialized` → `tools/list`;
|
||||
invocation via `tools/call {name, arguments}`.
|
||||
- Each remote tool is adapted to the `Tool` interface and injected into the run
|
||||
registry, namespaced `mcp__<server>__<tool>` (spaces normalised to `_`) to
|
||||
match Claude Code and avoid clashes.
|
||||
- A tool's MCP `annotations.readOnlyHint` maps to `Tool.ReadOnly()`. It defaults
|
||||
to false (a remote tool is opaque — we can't see its side effects), so a
|
||||
plugin opts a tool into parallel-batch dispatch and the permission layer's
|
||||
reader-default by declaring `readOnlyHint: true` in `tools/list`.
|
||||
- `prompts/list` + `prompts/get` surface as `/mcp__<server>__<prompt>` slash
|
||||
commands; `resources/list` + `resources/read` are referenced as
|
||||
`@<server>:<uri>` in chat. `/mcp` shows connected servers and their counts.
|
||||
- `cmd/reasonix-plugin-example` is a runnable reference stdio server (`echo`,
|
||||
`wordcount`), driven by an end-to-end test that builds the real binary.
|
||||
|
||||
### 3.4 Agent (`internal/agent`)
|
||||
|
||||
- `Session` holds `[]Message`.
|
||||
- `Run(ctx, input)` loop: build `Request` (with tool schemas) → `provider.Stream`
|
||||
→ print text deltas live, collect complete tool calls → if none, done; else
|
||||
execute each tool (built-in or plugin) and append results → repeat, bounded by
|
||||
`maxSteps`. `ctx` threads throughout (Ctrl-C aborts in-flight requests).
|
||||
- A `Runner` is anything with `Run(ctx, input) error`; both `Agent` and
|
||||
`Coordinator` satisfy it, so the CLI is agnostic to single- vs two-model mode.
|
||||
|
||||
### 3.5 Two-model collaboration (`Coordinator`)
|
||||
|
||||
When `agent.planner_model` names a provider different from the executor, a
|
||||
`Coordinator` runs two models in **separate sessions** to keep each one's prompt
|
||||
prefix cache-stable:
|
||||
|
||||
- The **planner** (low-frequency) runs in its own session with no tools and
|
||||
produces a concise plan.
|
||||
- The plan is handed off as structured text to the **executor** — a full
|
||||
tool-using `Agent` in its own session — which carries it out.
|
||||
- The sessions never mix, so neither model's prefix is disturbed by the other's
|
||||
turns; both grow prepend-only and stay cache-friendly. This reconciles
|
||||
"cache-first" with "two-model collaboration": switching models *inside one
|
||||
shared conversation* would break the prefix and tank cache hits, so we don't.
|
||||
|
||||
### 3.6 Context management (compaction)
|
||||
|
||||
Long tasks eventually fill the model's context window. Reasonix manages this with
|
||||
**low-frequency compaction** that respects the cache-first design:
|
||||
|
||||
- Each provider declares its `context_window` (tokens). When a turn's reported
|
||||
`prompt_tokens` reach `compactRatio` (default `0.8`) of that window, the
|
||||
executor compacts **once** before the next turn.
|
||||
- Compaction summarizes the older middle of the session into a single briefing —
|
||||
using the executor's own provider, no tools — and replaces it in place: the
|
||||
session becomes `system + summary + recentKeep` (default `8`) verbatim
|
||||
messages. The boundary is aligned backward off any tool result so the recent
|
||||
tail never begins with an orphan tool message whose `tool_calls` were
|
||||
summarized away.
|
||||
- The dropped originals are archived to `~/.config/reasonix/archive/<timestamp>.jsonl`
|
||||
(one message per line), so the full history stays traceable.
|
||||
|
||||
This is the **only** point where the prompt prefix changes — a deliberate, rare
|
||||
"cache-reset point". Between compactions the session grows prepend-only and
|
||||
stays cache-friendly, so cache hit rate (the key observability signal) stays
|
||||
high. `context_window = 0` disables compaction for an instance.
|
||||
|
||||
### 3.7 Permissions (`internal/permission`) — per-call gating
|
||||
|
||||
A coding agent runs shell commands and edits files autonomously. The permission
|
||||
layer decides, **per tool call**, whether to allow it, deny it, or ask the user
|
||||
first. It is independent of the model and of the CLI — the agent consults a
|
||||
`Gate` interface at execute time; the gate is built from a static `Policy` plus
|
||||
an optional interactive `Approver`.
|
||||
|
||||
```go
|
||||
type Decision int // permission package
|
||||
const (Allow Decision = iota; Ask; Deny)
|
||||
|
||||
// Policy evaluates static rules against a tool call. Pure, no I/O.
|
||||
type Policy struct { Mode Decision; Allow, Ask, Deny []Rule }
|
||||
func (p Policy) Decide(toolName string, readOnly bool, args json.RawMessage) Decision
|
||||
```
|
||||
|
||||
- **Rule syntax.** A rule is `ToolName` (matches any call to that tool) or
|
||||
`ToolName(glob)` (matches when the call's *subject* matches the glob, via
|
||||
`path.Match`). The subject is extracted generically from the call's JSON args
|
||||
by a small set of known keys — `command` (bash), `path` / `file_path`
|
||||
(file tools), `pattern` (grep/glob) — so tools need not change. A rule whose
|
||||
subject the args don't expose only matches in its bare `ToolName` form.
|
||||
- **Precedence.** `deny` > `ask` > `allow` > fallback. Fallback is `Allow` for
|
||||
read-only tools and `Mode` (default `Ask`) for writers. `deny` always wins, so
|
||||
a broad `allow = ["bash"]` can still be carved by `deny = ["bash(rm -rf*)"]`;
|
||||
conversely `ask` overrides a broad `allow` to force a prompt on a risky subset.
|
||||
- **Resolving `Ask`.** The interactive front-end (the chat TUI) prompts the user
|
||||
— allow once / always allow / deny — via an `Approver`. A non-interactive run
|
||||
(`reasonix run`, a sub-agent, anything with no TTY / no approver) cannot prompt, so
|
||||
it resolves `Ask` to **allow** — preserving autonomous behaviour. A `Deny` is a
|
||||
hard block in *every* mode: the tool never executes and the model receives a
|
||||
"blocked" result it can adapt to (the same shape as a plan-mode refusal).
|
||||
- **Relationship to plan mode.** Plan mode (§3.4) is an orthogonal, coarser gate
|
||||
that refuses *all* writers regardless of policy; it is checked first. The
|
||||
permission layer is the fine-grained, always-on gate underneath it.
|
||||
|
||||
Out of the box (`mode = "ask"`, no rules) `reasonix run` behaves exactly as before
|
||||
(writers resolve `Ask`→allow with no TTY), while `reasonix chat` now prompts before
|
||||
each writer/bash call. `deny` rules harden both modes.
|
||||
|
||||
### 3.8 Slash commands (`internal/command`)
|
||||
|
||||
The chat TUI accepts `/command` input. Three kinds share one dispatch:
|
||||
|
||||
- **Built-in actions** (`/compact`, `/new`, `/mcp`, `/help`) manipulate session
|
||||
state locally and never reach the model.
|
||||
- **Custom commands** are Markdown files under `.reasonix/commands/` (project) and
|
||||
`~/.config/reasonix/commands/` (user); the project dir overrides the user dir on a
|
||||
name clash. A file `review.md` becomes `/review`; a subdirectory namespaces it
|
||||
(`git/commit.md` → `/git:commit`). Invoking one renders its body and sends the
|
||||
result as the next user turn.
|
||||
- **MCP prompts** (§3.3) appear as `/mcp__<server>__<prompt>`.
|
||||
|
||||
```markdown
|
||||
---
|
||||
description: Review the staged diff
|
||||
argument-hint: [focus-area]
|
||||
---
|
||||
Review the staged diff. Focus on $ARGUMENTS, list bugs with file:line.
|
||||
```
|
||||
|
||||
- Frontmatter is an optional `---`-fenced block of simple `key: value` lines;
|
||||
`description` and `argument-hint` are recognised (no YAML dependency — Reasonix
|
||||
stays lean). The remainder is the body template.
|
||||
- Substitution in the body: `$ARGUMENTS` (all args, space-joined), `$1`…`$N`
|
||||
(positional, empty when absent), `$$` (a literal `$`). Arguments are the
|
||||
space-separated tokens after the command.
|
||||
- Loading is pure (`command.Load(dirs...)`) and tested; a malformed file is
|
||||
skipped, not fatal. Custom and MCP-prompt commands both resolve to text and
|
||||
reuse the same "start a turn" path as a typed message.
|
||||
|
||||
### 3.9 Chat references (`@`)
|
||||
|
||||
A chat message may embed `@` references; before the turn is sent, each is
|
||||
resolved and prepended to the message as a tagged block the model can read.
|
||||
|
||||
- `@<server>:<uri>` where `<server>` is a connected MCP server → an MCP
|
||||
resource (`resources/read`), wrapped `<resource ref="…">…</resource>`.
|
||||
- `@<path>` otherwise → a **local file or directory**, but only when the path
|
||||
actually exists on disk. This existence gate is the disambiguator: an ordinary
|
||||
`@mention` or an email address resolves to no file and stays literal text. A
|
||||
file is wrapped `<file path="…">…</file>` (size-capped, binary files noted not
|
||||
dumped); a directory becomes a one-level listing.
|
||||
- Resolution is asynchronous (off the TUI event loop); a fetch failure surfaces
|
||||
as a notice but doesn't block the turn. Reads are user-initiated and read-only
|
||||
— they do **not** pass the permission gate (§3.7).
|
||||
- Typing `/` or `@` opens an autocomplete menu above the input. The `@` menu
|
||||
navigates **one directory level at a time** (`os.ReadDir`, never a recursive
|
||||
walk — bounded for huge directories): a directory entry descends, a file
|
||||
completes, and MCP resources appear alongside top-level entries. The
|
||||
bottom-region menu changes height only on these discrete actions, never per
|
||||
streamed token, so scrollback stays clean (§ rendering).
|
||||
|
||||
## 4. Data Types (`internal/provider`)
|
||||
|
||||
```go
|
||||
type Role string
|
||||
const (RoleSystem Role = "system"; RoleUser Role = "user"
|
||||
RoleAssistant Role = "assistant"; RoleTool Role = "tool")
|
||||
|
||||
type Message struct {
|
||||
Role Role `json:"role"`
|
||||
Content string `json:"content,omitempty"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
type ToolCall struct { ID, Name, Arguments string } // Arguments: raw JSON
|
||||
type ToolSchema struct { Name, Description string; Parameters json.RawMessage }
|
||||
type Request struct { Messages []Message; Tools []ToolSchema; Temperature float64; MaxTokens int }
|
||||
|
||||
type ChunkType int
|
||||
const (ChunkText ChunkType = iota; ChunkToolCall; ChunkDone; ChunkError)
|
||||
|
||||
type Chunk struct {
|
||||
Type ChunkType
|
||||
Text string // ChunkText
|
||||
ToolCall *ToolCall // ChunkToolCall
|
||||
Err error // ChunkError
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Configuration (TOML)
|
||||
|
||||
Resolution order: **flag > project `./reasonix.toml` > user `~/.config/reasonix/config.toml`
|
||||
> built-in defaults**. Secrets come from the environment via `api_key_env` and
|
||||
are never stored in config files. A `.env` in the working directory is loaded if
|
||||
present.
|
||||
|
||||
```toml
|
||||
default_model = "deepseek-flash" # the executor
|
||||
# language = "zh" # ui language tag; empty = auto-detect from $LANG / $REASONIX_LANG
|
||||
|
||||
[agent]
|
||||
system_prompt = "You are Reasonix, a coding agent..." # or system_prompt_file = "..."
|
||||
max_steps = 25
|
||||
temperature = 0.0
|
||||
# planner_model = "mimo" # optional: two-model collaboration (low-frequency planner)
|
||||
|
||||
[[providers]]
|
||||
name = "deepseek-flash"
|
||||
kind = "openai"
|
||||
base_url = "https://api.deepseek.com"
|
||||
model = "deepseek-v4-flash"
|
||||
api_key_env = "DEEPSEEK_API_KEY"
|
||||
context_window = 1000000 # tokens; harness compacts older history near this limit (0 disables)
|
||||
|
||||
[[providers]]
|
||||
name = "deepseek-pro"
|
||||
kind = "openai"
|
||||
base_url = "https://api.deepseek.com"
|
||||
model = "deepseek-v4-pro"
|
||||
api_key_env = "DEEPSEEK_API_KEY"
|
||||
|
||||
[[providers]]
|
||||
name = "mimo-pro"
|
||||
kind = "openai"
|
||||
base_url = "https://api.xiaomimimo.com/v1"
|
||||
model = "mimo-v2.5-pro"
|
||||
api_key_env = "MIMO_API_KEY"
|
||||
|
||||
[[providers]]
|
||||
name = "mimo-flash"
|
||||
kind = "openai"
|
||||
base_url = "https://api.xiaomimimo.com/v1"
|
||||
model = "mimo-v2-flash"
|
||||
api_key_env = "MIMO_API_KEY"
|
||||
|
||||
[tools]
|
||||
enabled = [] # omit/empty = all built-ins
|
||||
|
||||
[permissions]
|
||||
mode = "ask" # writer fallback when no rule matches: ask|allow|deny
|
||||
deny = ["bash(rm -rf*)", "bash(git push*)"] # hard-blocked in every mode
|
||||
allow = ["bash(go test*)", "bash(git status*)"] # never prompted
|
||||
ask = [] # force a prompt even if otherwise allowed
|
||||
|
||||
[sandbox]
|
||||
# workspace_root = "" # file-writers confined here; empty = cwd (writes stay in-project)
|
||||
# allow_write = ["/tmp"] # extra dirs write_file/edit_file/multi_edit may modify
|
||||
|
||||
[[plugins]]
|
||||
name = "example" # type defaults to "stdio"
|
||||
command = "reasonix-plugin-example"
|
||||
args = []
|
||||
# env = { FOO = "bar" }
|
||||
|
||||
# [[plugins]] # a remote MCP server over Streamable HTTP
|
||||
# name = "stripe"
|
||||
# type = "http" # "stdio" (default) | "http" | "sse"
|
||||
# url = "https://mcp.stripe.com"
|
||||
# headers = { Authorization = "Bearer ${STRIPE_KEY}" } # ${VAR} / ${VAR:-default} expanded
|
||||
```
|
||||
|
||||
`reasonix init` writes this default config so the CLI is usable out of the box.
|
||||
|
||||
MCP servers may also be declared in a project-root `.mcp.json` using Claude
|
||||
Code's exact `mcpServers` schema (`command`/`args`/`env`, `type`/`url`/`headers`,
|
||||
`${VAR}` expansion). It is read after the TOML files and merged into
|
||||
`[[plugins]]`; on a name collision `reasonix.toml` wins (it is the more explicit,
|
||||
Reasonix-specific source). This lets a server already configured for Claude work in
|
||||
Reasonix unchanged.
|
||||
|
||||
```json
|
||||
{ "mcpServers": {
|
||||
"stripe": { "type": "http", "url": "https://mcp.stripe.com",
|
||||
"headers": { "Authorization": "Bearer ${STRIPE_KEY}" } }
|
||||
} }
|
||||
```
|
||||
|
||||
`[sandbox]` is the *enforcement* layer beneath permissions (which are *policy*).
|
||||
Phase 0 confines the file-writing built-ins (`write_file`, `edit_file`,
|
||||
`multi_edit`) to `workspace_root` (default cwd) plus `allow_write`: a write whose
|
||||
target — resolved to an absolute, symlink-free path so a symlinked dir or `..`
|
||||
cannot tunnel out — falls outside every root is refused, and the error is fed
|
||||
back to the model. Confinement is on by default (root = cwd), so edits stay in
|
||||
the project; reads are unrestricted. `bash` is itself jailed on macOS by default
|
||||
(`[sandbox] bash = "enforce"`, Seatbelt): each command runs under sandbox-exec
|
||||
allowed to write only the same roots (+ temp and toolchain caches) and to reach
|
||||
the network only when `network = true`. Unsupported platforms fall back to
|
||||
running unconfined. The escape-prompt and Linux support are Phase 1's remainder (§9).
|
||||
|
||||
## 6. Error Handling
|
||||
|
||||
- Library code wraps with `fmt.Errorf("...: %w", err)` and returns; it never
|
||||
prints or calls `os.Exit`.
|
||||
- Only `cli` / `main` decide exit codes and user-facing messages.
|
||||
- Tool execution errors are fed back to the model, not fatal.
|
||||
- Network layer should apply bounded exponential backoff on 429 / 5xx
|
||||
(interface reserved; implementation may follow).
|
||||
|
||||
## 7. Code Style
|
||||
|
||||
- `gofmt` + `go vet` must be clean; package names lowercase; exported
|
||||
identifiers documented; comments explain *why*, not *what*.
|
||||
- No premature generalization. Prefer clear and direct.
|
||||
|
||||
## 8. Distribution
|
||||
|
||||
- Build: `CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=$(VERSION)" -o reasonix ./cmd/reasonix`
|
||||
- Cross matrix: `darwin|linux|windows` × `amd64|arm64`.
|
||||
- Version injected via ldflags (`git describe --tags --always`).
|
||||
- Install: prebuilt binary / `go install` / future `brew tap`.
|
||||
|
||||
## 9. Roadmap (not in current scope)
|
||||
|
||||
- Sandbox Phase 1: an OS-level jail for `bash` so commands — not just the
|
||||
file-writer built-ins (Phase 0) — are confined to the workspace. **macOS
|
||||
(Seatbelt via `sandbox-exec`) ships, on by default** (see §5). Remaining: (a)
|
||||
the escape-prompt — detect a sandbox-denied failure and offer to re-run the
|
||||
command unconfined via the permission gate (in `reasonix run`, the command just
|
||||
fails and the model adapts), which completes the "allow inside the box, prompt
|
||||
at its edge" model; (b) Linux (bubblewrap / landlock). Shells out to OS tooling
|
||||
so the binary stays dependency-free; Windows is out of scope. With this in
|
||||
place, "always allow" rule persistence becomes optional rather than load-bearing.
|
||||
- MCP long tail (deferred deliberately — no consumer / no foundation yet): OAuth
|
||||
2.0 + `headersHelper` auth for remote servers; the remaining `.mcp.json` scopes
|
||||
(local / user — project scope shipped, see §5); tool-search deferral;
|
||||
`list_changed` live updates; channels / elicitation / roots; plugins that
|
||||
provide *providers*, not just tools.
|
||||
- An Anthropic-native provider `kind` (native prompt-cache control), proving the
|
||||
registry generalises beyond one wire format.
|
||||
- "Always allow" persistence writing learned rules back to project config; a
|
||||
per-session permission override flag for `reasonix run`.
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, ",")
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"charm.land/bubbles/v2/textarea"
|
||||
)
|
||||
|
||||
// newTestChatTUI builds a chatTUI with just the pieces the streaming/commit and
|
||||
// completion paths need, for unit tests that don't run the bubbletea loop.
|
||||
func newTestChatTUI() chatTUI {
|
||||
commit := []string{}
|
||||
ti := textarea.New()
|
||||
ti.SetWidth(80)
|
||||
return chatTUI{
|
||||
input: ti,
|
||||
reasoning: &strings.Builder{},
|
||||
pending: &strings.Builder{},
|
||||
pendingCommit: &commit,
|
||||
turnAccumulator: &strings.Builder{},
|
||||
renderer: newMarkdownRenderer(80),
|
||||
}
|
||||
}
|
||||
|
||||
// TestIngestSeparatesReasoningFromAnswer proves the print-above model commits
|
||||
// the reasoning block and the answer as distinct scrollback entries (joined
|
||||
// with a newline downstream), so the answer never butts up against the last
|
||||
// line of thinking — and that the answer stays live until it's flushed.
|
||||
func TestIngestSeparatesReasoningFromAnswer(t *testing.T) {
|
||||
m := newTestChatTUI()
|
||||
|
||||
m.ingestChunk("\x1b[2m ▎ thinking\x1b[0m\n") // reasoning header → live buffer
|
||||
m.ingestChunk("\x1b[2m…reasoning…\x1b[0m") // reasoning fragment → live buffer
|
||||
if len(*m.pendingCommit) != 0 {
|
||||
t.Fatalf("reasoning should stay live until the answer begins, committed=%v", *m.pendingCommit)
|
||||
}
|
||||
|
||||
m.ingestChunk("Hello answer") // answer begins → reasoning finalizes
|
||||
if n := len(*m.pendingCommit); n != 1 || !strings.Contains((*m.pendingCommit)[0], "thinking") {
|
||||
t.Fatalf("reasoning should commit when the answer begins, committed=%v", *m.pendingCommit)
|
||||
}
|
||||
if m.pending.String() != "Hello answer" {
|
||||
t.Errorf("answer should be live in pending, got %q", m.pending.String())
|
||||
}
|
||||
if m.reasoning.Len() != 0 {
|
||||
t.Errorf("reasoning buffer should be cleared after commit")
|
||||
}
|
||||
|
||||
m.commitPending() // turn end
|
||||
if n := len(*m.pendingCommit); n != 2 || !strings.Contains((*m.pendingCommit)[1], "Hello") {
|
||||
t.Fatalf("answer should commit on flush as a separate entry, committed=%v", *m.pendingCommit)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIngestEventFlushesAnswer confirms an event line (e.g. a tool dispatch)
|
||||
// finalizes the answer streamed before it, preserving order in scrollback.
|
||||
func TestIngestEventFlushesAnswer(t *testing.T) {
|
||||
m := newTestChatTUI()
|
||||
m.ingestChunk("partial answer ")
|
||||
m.ingestChunk(" -> read_file {\"path\":\"x\"}\n")
|
||||
if n := len(*m.pendingCommit); n != 2 {
|
||||
t.Fatalf("answer then event line should be two commits, got %d: %v", n, *m.pendingCommit)
|
||||
}
|
||||
if !strings.Contains((*m.pendingCommit)[0], "partial answer") {
|
||||
t.Errorf("first commit should be the buffered answer, got %q", (*m.pendingCommit)[0])
|
||||
}
|
||||
if !strings.Contains((*m.pendingCommit)[1], "-> read_file") {
|
||||
t.Errorf("second commit should be the event line, got %q", (*m.pendingCommit)[1])
|
||||
}
|
||||
if m.pending.Len() != 0 {
|
||||
t.Errorf("answer buffer should be drained after the event line")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/command"
|
||||
)
|
||||
|
||||
// writeAt creates dir/rel (with parents) holding content, for fs-backed tests.
|
||||
func writeAt(t *testing.T, dir, rel, content string) {
|
||||
t.Helper()
|
||||
p := filepath.Join(dir, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCompletionFilterAndAccept(t *testing.T) {
|
||||
m := newTestChatTUI()
|
||||
m.input.SetValue("/co")
|
||||
m.updateCompletion()
|
||||
|
||||
if !m.completion.active || m.completion.kind != compSlash {
|
||||
t.Fatalf("typing /co should open the slash menu: %+v", m.completion)
|
||||
}
|
||||
// Only /compact matches the "/co" prefix among the built-ins.
|
||||
if len(m.completion.items) != 1 || m.completion.items[0].label != "/compact" {
|
||||
t.Fatalf("filter = %v, want just /compact", labels(m.completion.items))
|
||||
}
|
||||
|
||||
m.acceptCompletion()
|
||||
if got := m.input.Value(); got != "/compact " {
|
||||
t.Errorf("accept should fill the input, got %q", got)
|
||||
}
|
||||
if m.completion.active {
|
||||
t.Error("menu should close after accept")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCompletionIncludesCustomCommands(t *testing.T) {
|
||||
m := newTestChatTUI()
|
||||
m.commands = []command.Command{{Name: "review", Description: "review the diff"}}
|
||||
m.input.SetValue("/re")
|
||||
m.updateCompletion()
|
||||
|
||||
if !hasLabel(m.completion.items, "/review") {
|
||||
t.Errorf("custom command should appear in completion: %v", labels(m.completion.items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletionClosesOnSpaceAndNonMatch(t *testing.T) {
|
||||
m := newTestChatTUI()
|
||||
|
||||
m.input.SetValue("/compact ") // space → typing args, not naming a command
|
||||
m.updateCompletion()
|
||||
if m.completion.active {
|
||||
t.Error("menu should close once a space is typed (now entering args)")
|
||||
}
|
||||
|
||||
m.input.SetValue("/zzz") // no command matches
|
||||
m.updateCompletion()
|
||||
if m.completion.active {
|
||||
t.Error("menu should close when nothing matches")
|
||||
}
|
||||
|
||||
m.input.SetValue("hello") // not a slash line
|
||||
m.updateCompletion()
|
||||
if m.completion.active {
|
||||
t.Error("menu should be inactive for non-slash input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveCompletionWraps(t *testing.T) {
|
||||
m := newTestChatTUI()
|
||||
m.completion = completion{active: true, kind: compSlash, items: []compItem{{label: "/a"}, {label: "/b"}, {label: "/c"}}, sel: 0}
|
||||
m.moveCompletion(-1)
|
||||
if m.completion.sel != 2 {
|
||||
t.Errorf("up from first should wrap to last, got %d", m.completion.sel)
|
||||
}
|
||||
m.moveCompletion(1)
|
||||
if m.completion.sel != 0 {
|
||||
t.Errorf("down from last should wrap to first, got %d", m.completion.sel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveAtToken(t *testing.T) {
|
||||
cases := []struct {
|
||||
val string
|
||||
wantTok string
|
||||
wantOK bool
|
||||
wantAt int
|
||||
}{
|
||||
{"@", "", true, 0},
|
||||
{"look at @src/m", "src/m", true, 8},
|
||||
{"@internal/agent/", "internal/agent/", true, 0},
|
||||
{"[email protected]", "", false, 0}, // '@' not whitespace-preceded → not a ref
|
||||
{"@foo bar", "", false, 0}, // cursor token after the space isn't an @ref
|
||||
{"plain text", "", false, 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
at, tok, ok := activeAtToken(c.val)
|
||||
if ok != c.wantOK || (ok && (tok != c.wantTok || at != c.wantAt)) {
|
||||
t.Errorf("activeAtToken(%q) = (%d,%q,%v), want (%d,%q,%v)", c.val, at, tok, ok, c.wantAt, c.wantTok, c.wantOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitPathToken(t *testing.T) {
|
||||
cases := []struct{ in, dir, frag string }{
|
||||
{"main", "", "main"},
|
||||
{"internal/age", "internal/", "age"},
|
||||
{"a/b/c", "a/b/", "c"},
|
||||
{"internal/", "internal/", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if d, f := splitPathToken(c.in); d != c.dir || f != c.frag {
|
||||
t.Errorf("splitPathToken(%q) = (%q,%q), want (%q,%q)", c.in, d, f, c.dir, c.frag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileItemsOneLevel verifies @ completion lists exactly one directory level
|
||||
// (no recursion): a subdir shows as a descendable entry, its contents do not.
|
||||
func TestFileItemsOneLevel(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeAt(t, dir, "alpha.go", "x")
|
||||
writeAt(t, dir, "sub/deep.go", "y") // creates sub/ with a file inside
|
||||
writeAt(t, dir, ".hidden", "z")
|
||||
|
||||
m := newTestChatTUI()
|
||||
items := m.fileItems(dir + "/") // token = "<tmp>/", frag = ""
|
||||
|
||||
if !hasLabel(items, "alpha.go") {
|
||||
t.Errorf("file alpha.go should be listed: %v", labels(items))
|
||||
}
|
||||
if !hasLabel(items, "sub/") {
|
||||
t.Errorf("subdir should be listed as 'sub/': %v", labels(items))
|
||||
}
|
||||
if hasLabel(items, "deep.go") {
|
||||
t.Errorf("nested file deep.go must NOT be listed (one level only): %v", labels(items))
|
||||
}
|
||||
if hasLabel(items, ".hidden") {
|
||||
t.Errorf("hidden file should be skipped unless frag starts with '.': %v", labels(items))
|
||||
}
|
||||
// The subdir entry must be a descend (accepting it navigates into it).
|
||||
for _, it := range items {
|
||||
if it.label == "sub/" && !it.descend {
|
||||
t.Error("directory entry should be a descend")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileItemsHiddenWhenDotTyped(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeAt(t, dir, ".hidden", "z")
|
||||
m := newTestChatTUI()
|
||||
items := m.fileItems(dir + "/.") // frag = "." → show hidden
|
||||
if !hasLabel(items, ".hidden") {
|
||||
t.Errorf("hidden file should appear when frag starts with '.': %v", labels(items))
|
||||
}
|
||||
}
|
||||
|
||||
func labels(items []compItem) []string {
|
||||
out := make([]string, len(items))
|
||||
for i, it := range items {
|
||||
out[i] = it.label
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hasLabel(items []compItem, label string) bool {
|
||||
for _, it := range items {
|
||||
if it.label == label {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
// maxFileRefBytes caps how much of an @-referenced file is injected into a
|
||||
// message, so "@somehuge.log" can't blow the context window. The head is kept
|
||||
// and the rest noted as truncated.
|
||||
const maxFileRefBytes = 64 * 1024
|
||||
|
||||
// refKind distinguishes the two things an @reference can resolve to.
|
||||
type refKind int
|
||||
|
||||
const (
|
||||
refResource refKind = iota // an MCP resource: @<server>:<uri>
|
||||
refFile // a local file or directory: @<path>
|
||||
)
|
||||
|
||||
// ref is a resolved @reference found in a submitted line.
|
||||
type ref struct {
|
||||
kind refKind
|
||||
server string // refResource
|
||||
uri string // refResource
|
||||
path string // refFile
|
||||
raw string // the original token after '@', for labelling
|
||||
}
|
||||
|
||||
// refTokenRe matches an @reference token: '@' then a run of non-space chars.
|
||||
var refTokenRe = regexp.MustCompile(`@([^\s]+)`)
|
||||
|
||||
// parseRefTokens extracts the deduped, punctuation-trimmed tokens following '@'
|
||||
// in a line. Pure: classification (server? file?) happens in classifyRef.
|
||||
func parseRefTokens(line string) []string {
|
||||
var toks []string
|
||||
seen := map[string]bool{}
|
||||
for _, g := range refTokenRe.FindAllStringSubmatch(line, -1) {
|
||||
t := strings.TrimRight(g[1], ".,;!?)]}")
|
||||
if t == "" || seen[t] {
|
||||
continue
|
||||
}
|
||||
seen[t] = true
|
||||
toks = append(toks, t)
|
||||
}
|
||||
return toks
|
||||
}
|
||||
|
||||
// classifyRef decides what a token refers to. A "server:uri" token whose server
|
||||
// is connected is an MCP resource; otherwise a token that names an existing path
|
||||
// is a file. Anything else (an @mention, an email) is not a reference. exists is
|
||||
// injected so the rule is testable without touching the filesystem.
|
||||
func classifyRef(token string, known map[string]bool, exists func(string) bool) (ref, bool) {
|
||||
if i := strings.Index(token, ":"); i > 0 && i+1 < len(token) && known[token[:i]] {
|
||||
return ref{kind: refResource, server: token[:i], uri: token[i+1:], raw: token}, true
|
||||
}
|
||||
if exists(token) {
|
||||
return ref{kind: refFile, path: token, raw: token}, true
|
||||
}
|
||||
return ref{}, false
|
||||
}
|
||||
|
||||
// detectRefs finds the @references in a submitted line: MCP resources for
|
||||
// connected servers, and local paths that exist on disk.
|
||||
func (m *chatTUI) detectRefs(line string) []ref {
|
||||
known := map[string]bool{}
|
||||
if m.host != nil {
|
||||
for _, n := range m.host.ServerNames() {
|
||||
known[n] = true
|
||||
}
|
||||
}
|
||||
exists := func(p string) bool { _, err := os.Stat(p); return err == nil }
|
||||
|
||||
var refs []ref
|
||||
for _, tok := range parseRefTokens(line) {
|
||||
if r, ok := classifyRef(tok, known, exists); ok {
|
||||
refs = append(refs, r)
|
||||
}
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
// fetchRefs resolves each reference off the event loop and returns a
|
||||
// refsResolvedMsg with the concatenated, tagged content (or per-ref errors).
|
||||
func (m *chatTUI) fetchRefs(line string, refs []ref) tea.Cmd {
|
||||
host := m.host
|
||||
return func() tea.Msg {
|
||||
var b strings.Builder
|
||||
var errs []string
|
||||
for _, r := range refs {
|
||||
switch r.kind {
|
||||
case refResource:
|
||||
text, err := host.ReadResource(context.Background(), r.server, r.uri)
|
||||
if err != nil {
|
||||
errs = append(errs, "@"+r.raw+" — "+err.Error())
|
||||
continue
|
||||
}
|
||||
appendRefBlock(&b, "resource", `ref="@`+r.raw+`"`, text)
|
||||
case refFile:
|
||||
text, isDir, err := readFileRef(r.path)
|
||||
if err != nil {
|
||||
errs = append(errs, "@"+r.raw+" — "+err.Error())
|
||||
continue
|
||||
}
|
||||
tag := "file"
|
||||
if isDir {
|
||||
tag = "dir"
|
||||
}
|
||||
appendRefBlock(&b, tag, `path="`+r.path+`"`, text)
|
||||
}
|
||||
}
|
||||
return refsResolvedMsg{line: line, block: b.String(), errs: errs}
|
||||
}
|
||||
}
|
||||
|
||||
func appendRefBlock(b *strings.Builder, tag, attr, body string) {
|
||||
if b.Len() > 0 {
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
fmt.Fprintf(b, "<%s %s>\n%s\n</%s>", tag, attr, body, tag)
|
||||
}
|
||||
|
||||
// readFileRef reads an @-referenced path for injection. A directory yields a
|
||||
// one-level listing; a binary file (NUL in the first 8 KiB) is noted rather than
|
||||
// dumped; a large file is truncated to maxFileRefBytes with a marker. isDir lets
|
||||
// the caller pick the wrapping tag.
|
||||
func readFileRef(path string) (content string, isDir bool, err error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if info.IsDir() {
|
||||
entries, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return "", true, err
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if e.IsDir() {
|
||||
name += "/"
|
||||
}
|
||||
b.WriteString(name)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String(), true, nil
|
||||
}
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
buf := make([]byte, maxFileRefBytes+1)
|
||||
n, rerr := io.ReadFull(f, buf)
|
||||
if rerr != nil && rerr != io.ErrUnexpectedEOF && rerr != io.EOF {
|
||||
return "", false, rerr
|
||||
}
|
||||
data := buf[:n]
|
||||
|
||||
if bytes.IndexByte(data[:min(n, 8192)], 0) >= 0 {
|
||||
return fmt.Sprintf("[binary file %s, %d bytes — not shown]", path, info.Size()), false, nil
|
||||
}
|
||||
if n > maxFileRefBytes {
|
||||
return string(data[:maxFileRefBytes]) + fmt.Sprintf("\n…[truncated; file is %d bytes]…", info.Size()), false, nil
|
||||
}
|
||||
return string(data), false, nil
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseRefTokens(t *testing.T) {
|
||||
cases := []struct {
|
||||
line string
|
||||
want []string
|
||||
}{
|
||||
{"see @docs:doc://x and @src/main.go", []string{"docs:doc://x", "src/main.go"}},
|
||||
{"trailing @file.go.", []string{"file.go"}},
|
||||
{"dedup @a @a", []string{"a"}},
|
||||
{"no refs here", nil},
|
||||
{"email [email protected] keeps token", []string{"b.com"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := parseRefTokens(c.line)
|
||||
if len(got) == 0 && len(c.want) == 0 {
|
||||
continue
|
||||
}
|
||||
if !reflect.DeepEqual(got, c.want) {
|
||||
t.Errorf("parseRefTokens(%q) = %v, want %v", c.line, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyRef(t *testing.T) {
|
||||
known := map[string]bool{"docs": true}
|
||||
files := map[string]bool{"src/main.go": true, "README.md": true}
|
||||
exists := func(p string) bool { return files[p] }
|
||||
|
||||
cases := []struct {
|
||||
token string
|
||||
wantOK bool
|
||||
wantKnd refKind
|
||||
}{
|
||||
{"docs:doc://style", true, refResource}, // known server + uri
|
||||
{"src/main.go", true, refFile}, // existing file
|
||||
{"README.md", true, refFile}, // existing file
|
||||
{"ghost:issue://1", false, 0}, // unknown server, no such file
|
||||
{"missing.go", false, 0}, // nonexistent path → not a ref
|
||||
{"docs:", false, 0}, // empty uri → not a resource, no file
|
||||
}
|
||||
for _, c := range cases {
|
||||
r, ok := classifyRef(c.token, known, exists)
|
||||
if ok != c.wantOK {
|
||||
t.Errorf("classifyRef(%q) ok = %v, want %v", c.token, ok, c.wantOK)
|
||||
continue
|
||||
}
|
||||
if ok && r.kind != c.wantKnd {
|
||||
t.Errorf("classifyRef(%q) kind = %v, want %v", c.token, r.kind, c.wantKnd)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, ", ")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// Package i18n holds the CLI's translatable strings and a small detection
|
||||
// helper. Architecture: a single Messages struct of exported string fields
|
||||
// (plain text or fmt format strings, suffix *Fmt flags the latter). Each
|
||||
// language declares one Messages value in its own file. Call sites read
|
||||
// i18n.M.SomeField; for parameterised messages they pass it to fmt.Sprintf.
|
||||
//
|
||||
// Adding a field requires updating every messages_*.go file — drift is caught
|
||||
// at test time by TestCatalogsComplete via reflection, so a missing translation
|
||||
// fails CI instead of surfacing as a blank line at runtime.
|
||||
//
|
||||
// Scope (v1): CLI surface only — welcome, init wizard, chat REPL banner, usage,
|
||||
// user-facing CLI errors. System prompts, internal error wrappers, and agent
|
||||
// runtime telemetry stay English so model behaviour and developer logs are
|
||||
// language-stable.
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Messages is the catalogue of translatable CLI strings. Plain fields are
|
||||
// printed verbatim; *Fmt fields are fmt format strings the caller passes to
|
||||
// fmt.Sprintf. Catalogue values do not include trailing newlines — call sites
|
||||
// add framing whitespace, so the same field works wherever it appears.
|
||||
type Messages struct {
|
||||
// welcome / status screen
|
||||
Subtitle string // tagline under the product name in the welcome box
|
||||
WelcomeTitleFmt string // first-run box title — %s = product name (styled)
|
||||
NoConfigYet string // first-run cue under the welcome box
|
||||
StartingChatFmt string // "Starting %s…" before dropping into chat
|
||||
SetKeyHint string // shown when key is missing after init
|
||||
ConfigLabel string // "config" status row label
|
||||
ModelsLabel string // "models" status row label
|
||||
ConfigNotFound string // shown when no config file exists
|
||||
ConfigErrorFmt string // "%s — error: %v" — config path + parse error
|
||||
NoKey string // status dot — no API key set
|
||||
Ready string // status dot — provider ready
|
||||
GetStarted string // section title above numbered steps
|
||||
StepScaffold string // step 1 desc — reasonix init
|
||||
StepSetKey string // step 2 command label
|
||||
StepSetKeyHint string // step 2 desc — env var hint
|
||||
StepChatDesc string // reasonix chat step desc
|
||||
StepRunDesc string // reasonix run step desc
|
||||
HelpFooter string // dim footer linking to reasonix help
|
||||
|
||||
// chat REPL
|
||||
ChatTip string // tip line under the chat banner
|
||||
TurnCancelled string // shown when Ctrl-C aborts the in-flight turn but the chat keeps running
|
||||
NoSessionToResume string // shown when --continue / --resume finds nothing
|
||||
ResumeRequiresTTY string // shown when --resume runs piped instead of on a terminal
|
||||
PickSessionLabel string // header on the --resume picker
|
||||
|
||||
// chat TUI status line / approval banner.
|
||||
ChatStatusThinkingFmt string // "%s thinking… (%ds · <cancel hint>)" — %s = spinner, %d = elapsed s
|
||||
ChatStatusIdle string // shortcuts hint when idle
|
||||
ChatStatusPlanApproval string // shortcuts hint while a plan is pending
|
||||
PlanApprovalPrompt string // one-line "plan above is ready" banner shown above the input
|
||||
ChatStatusToolApproval string // shortcuts hint while a tool call awaits approval
|
||||
ToolApprovalPromptFmt string // "Allow %s%s?" banner — %s = tool name, %s = subject (leading space, or empty)
|
||||
|
||||
// chat TUI slash commands.
|
||||
SlashCompactDone string // "/compact" succeeded
|
||||
SlashCompactFailed string // "/compact" errored, prefixed before the underlying error
|
||||
SlashNewDone string // "/new" succeeded
|
||||
SlashNewFailed string // "/new" errored
|
||||
SlashUnavailable string // the command is configured off (no callback wired)
|
||||
SlashUnknown string // shown when the user types an unrecognised "/cmd"
|
||||
SlashHelp string // listed commands
|
||||
SlashPromptEmpty string // an MCP prompt returned no text to send
|
||||
SlashMCPNone string // /mcp when no MCP servers are connected
|
||||
CompHintSlash string // key hint footer under the slash-command menu
|
||||
CompHintFile string // key hint footer under the @ file/resource menu
|
||||
|
||||
// init wizard
|
||||
SelectProvidersLabel string // multi-select label
|
||||
EnterAPIKeysHeader string // header before the per-env-var prompts
|
||||
MissingKeyIntro string // shown when re-running the key step on a configured setup
|
||||
WroteFileFmt string // "Wrote %s" — used for reasonix.toml and .env both
|
||||
SetupComplete string // success line at end of init
|
||||
SetupCancelled string // shown when the user aborts the wizard
|
||||
TryHintFmt string // "Try: %s" — %s = command to try (styled)
|
||||
NextHint string // non-interactive post-write hint
|
||||
ConfirmReconfigureFmt string // "%s already exists. Reconfigure and overwrite?"
|
||||
KeepingExisting string // when the user declines to overwrite
|
||||
NotOverwritingFmt string // non-interactive overwrite refusal
|
||||
|
||||
// top-level / runAgent
|
||||
UnknownCommandFmt string // "unknown command %q"
|
||||
UsageRunHint string // "usage: reasonix run [--model NAME] <task>"
|
||||
ErrorPrefix string // "error:" — prefix for fatal-error output
|
||||
WriteConfigErr string // "write config:" — prefix for write failure
|
||||
WriteEnvErr string // "write .env:" — prefix for env-write failure
|
||||
|
||||
// selection menus
|
||||
SelectOneHint string // "(↑/↓ · Enter · q to cancel)"
|
||||
SelectManyHint string // "(↑/↓ · Space · Enter · q)"
|
||||
|
||||
// usage / help
|
||||
UsageBody string // full multi-line help text
|
||||
}
|
||||
|
||||
// M is the active catalogue. DetectLanguage replaces it; English is the
|
||||
// default so any code path that runs before detection still has text.
|
||||
var M = English
|
||||
|
||||
// DetectLanguage selects a catalogue from override (e.g. cfg.Language) or the
|
||||
// environment and installs it as M. Returns the resolved tag ("en", "zh") so
|
||||
// callers can log or expose it.
|
||||
//
|
||||
// Priority: override > REASONIX_LANG > LC_ALL > LC_MESSAGES > LANG > "en".
|
||||
func DetectLanguage(override string) string {
|
||||
for _, c := range append([]string{override}, envCandidates()...) {
|
||||
if tag := normalize(c); tag != "" {
|
||||
return setLanguage(tag)
|
||||
}
|
||||
}
|
||||
return setLanguage("en")
|
||||
}
|
||||
|
||||
func envCandidates() []string {
|
||||
keys := []string{"REASONIX_LANG", "LC_ALL", "LC_MESSAGES", "LANG"}
|
||||
out := make([]string, len(keys))
|
||||
for i, k := range keys {
|
||||
out[i] = os.Getenv(k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func setLanguage(tag string) string {
|
||||
switch tag {
|
||||
case "zh":
|
||||
M = Chinese
|
||||
return "zh"
|
||||
default:
|
||||
M = English
|
||||
return "en"
|
||||
}
|
||||
}
|
||||
|
||||
// normalize maps a locale string (e.g. "zh_CN.UTF-8", "zh-Hans-CN", "Chinese
|
||||
// (China)") to a short tag this package knows about. Returns "" for empty or
|
||||
// unrecognised input so DetectLanguage can fall through to the next candidate.
|
||||
func normalize(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(s, "zh") || strings.Contains(s, "chinese") || strings.Contains(s, "中文") {
|
||||
return "zh"
|
||||
}
|
||||
if strings.HasPrefix(s, "en") || strings.Contains(s, "english") {
|
||||
return "en"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package i18n
|
||||
|
||||
// English is the baseline catalogue. The drift-guard test reflects over its
|
||||
// fields, so every other catalogue must populate the same set.
|
||||
var English = Messages{
|
||||
Subtitle: "config + plugin driven coding agent",
|
||||
WelcomeTitleFmt: "Welcome to %s",
|
||||
NoConfigYet: "No configuration found yet — let's set it up.",
|
||||
StartingChatFmt: "Starting %s…",
|
||||
SetKeyHint: "Set your API key, then run `reasonix chat`.",
|
||||
ConfigLabel: "config",
|
||||
ModelsLabel: "models",
|
||||
ConfigNotFound: "not found — using built-in defaults",
|
||||
ConfigErrorFmt: "%s — error: %v",
|
||||
NoKey: "no key",
|
||||
Ready: "ready",
|
||||
GetStarted: "Get started",
|
||||
StepScaffold: "scaffold reasonix.toml",
|
||||
StepSetKey: "set API key",
|
||||
StepSetKeyHint: "export DEEPSEEK_API_KEY=… or add to .env",
|
||||
StepChatDesc: "interactive session",
|
||||
StepRunDesc: "one-shot task",
|
||||
HelpFooter: "reasonix help · all commands",
|
||||
|
||||
ChatTip: "Context is kept across turns. Type 'exit' or Ctrl-D to quit.",
|
||||
TurnCancelled: "cancelled — back to prompt",
|
||||
NoSessionToResume: "no saved session to resume — start a new one with `reasonix chat`",
|
||||
ResumeRequiresTTY: "--resume needs an interactive terminal; pass --continue for the most recent session",
|
||||
PickSessionLabel: "Resume which session?",
|
||||
|
||||
ChatStatusThinkingFmt: "%s thinking… (%ds · Esc cancels)",
|
||||
ChatStatusIdle: "Tab toggles plan · Enter sends · Esc clears/exits state · PgUp/PgDn scrolls · Ctrl-D quits",
|
||||
ChatStatusPlanApproval: "Enter approves · type to revise · Esc dismisses · PgUp/PgDn scrolls",
|
||||
PlanApprovalPrompt: "Plan ready above — Enter to approve, or type to revise",
|
||||
ChatStatusToolApproval: "y approve once · a allow this session · n deny · Ctrl-C cancels turn",
|
||||
ToolApprovalPromptFmt: "Allow %s%s? — [y] once · [a] this session · [n] no",
|
||||
|
||||
SlashCompactDone: "session compacted — older middle replaced by a summary, recent turns kept",
|
||||
SlashCompactFailed: "compaction failed",
|
||||
SlashNewDone: "fresh session started — previous transcript saved",
|
||||
SlashNewFailed: "could not start a new session",
|
||||
SlashUnavailable: "command unavailable in this build",
|
||||
SlashUnknown: "unknown command",
|
||||
SlashHelp: "commands: /compact (manual context compaction) · /new (fork a fresh session) · /mcp (MCP servers) · /help",
|
||||
SlashPromptEmpty: "the MCP prompt returned no content to send",
|
||||
SlashMCPNone: "no MCP servers configured — add a [[plugins]] entry in reasonix.toml",
|
||||
CompHintSlash: "↑/↓ move · Tab/Enter select · Esc close",
|
||||
CompHintFile: "↑/↓ move · Tab/Enter open folder or pick file · Esc close",
|
||||
|
||||
SelectProvidersLabel: "Select providers to enable",
|
||||
EnterAPIKeysHeader: "Enter API keys (Enter to skip and set later in .env):",
|
||||
MissingKeyIntro: "reasonix.toml is ready — just an API key away.",
|
||||
WroteFileFmt: "Wrote %s",
|
||||
SetupComplete: "Setup complete.",
|
||||
SetupCancelled: "setup cancelled.",
|
||||
TryHintFmt: "Try: %s",
|
||||
NextHint: "Next: set your API key (export DEEPSEEK_API_KEY=... or add to .env), then run `reasonix run \"your task\"`.",
|
||||
ConfirmReconfigureFmt: "%s already exists. Reconfigure and overwrite?",
|
||||
KeepingExisting: "Keeping existing config.",
|
||||
NotOverwritingFmt: "%s already exists; not overwriting",
|
||||
|
||||
UnknownCommandFmt: "unknown command %q",
|
||||
UsageRunHint: "usage: reasonix run [--model NAME] <task>",
|
||||
ErrorPrefix: "error:",
|
||||
WriteConfigErr: "write config:",
|
||||
WriteEnvErr: "write .env:",
|
||||
|
||||
SelectOneHint: "(↑/↓ · Enter · q to cancel)",
|
||||
SelectManyHint: "(↑/↓ · Space · Enter · q)",
|
||||
|
||||
UsageBody: `reasonix — a config- and plugin-driven coding agent (multi-model)
|
||||
|
||||
Usage:
|
||||
reasonix chat [--model NAME] interactive session (multi-turn)
|
||||
reasonix run [--model NAME] [--max-steps N] <task> run one task and exit
|
||||
reasonix init [path] interactive setup; writes reasonix.toml (+ .env)
|
||||
reasonix version
|
||||
reasonix help
|
||||
|
||||
Examples:
|
||||
reasonix chat
|
||||
reasonix run "implement the TODOs in main.go"
|
||||
reasonix run --model mimo-pro "add unit tests for this function"
|
||||
echo "explain this code" | reasonix run
|
||||
|
||||
Configuration:
|
||||
Resolution: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults
|
||||
Secrets come from the environment via api_key_env (e.g. DEEPSEEK_API_KEY).
|
||||
Run 'reasonix init' to scaffold a config; see docs/SPEC.md.
|
||||
`,
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package i18n
|
||||
|
||||
// Chinese is the zh-Hans catalogue. Keep the %s placeholders in the same order
|
||||
// as English unless a phrase genuinely demands re-ordering — call sites pass
|
||||
// arguments positionally and won't reshuffle.
|
||||
var Chinese = Messages{
|
||||
Subtitle: "配置与插件驱动的 coding agent",
|
||||
WelcomeTitleFmt: "欢迎使用 %s",
|
||||
NoConfigYet: "还没有配置 — 现在来设置一下吧。",
|
||||
StartingChatFmt: "正在启动 %s…",
|
||||
SetKeyHint: "设置好 API key 后运行 `reasonix chat`。",
|
||||
ConfigLabel: "配置",
|
||||
ModelsLabel: "模型",
|
||||
ConfigNotFound: "未找到 — 使用内置默认值",
|
||||
ConfigErrorFmt: "%s — 错误:%v",
|
||||
NoKey: "未设置 key",
|
||||
Ready: "已就绪",
|
||||
GetStarted: "开始使用",
|
||||
StepScaffold: "生成 reasonix.toml",
|
||||
StepSetKey: "设置 API key",
|
||||
StepSetKeyHint: "执行 export DEEPSEEK_API_KEY=… 或写入 .env",
|
||||
StepChatDesc: "交互式会话",
|
||||
StepRunDesc: "执行单次任务",
|
||||
HelpFooter: "reasonix help · 查看全部命令",
|
||||
|
||||
ChatTip: "对话上下文将跨轮保留。输入 'exit' 或按 Ctrl-D 退出。",
|
||||
TurnCancelled: "已取消 — 回到提示符",
|
||||
NoSessionToResume: "没有可恢复的会话 — 用 `reasonix chat` 开一个新的",
|
||||
ResumeRequiresTTY: "--resume 需要交互式终端;用 --continue 直接恢复最近一次",
|
||||
PickSessionLabel: "恢复哪个会话?",
|
||||
|
||||
ChatStatusThinkingFmt: "%s 思考中… (%d 秒 · Esc 取消)",
|
||||
ChatStatusIdle: "Tab 切换 plan · Enter 发送 · Esc 退出当前状态 · PgUp/PgDn 滚动 · Ctrl-D 退出",
|
||||
ChatStatusPlanApproval: "Enter 同意 · 输入文本反馈以让 AI 修改 · Esc 撤掉横幅 · PgUp/PgDn 滚动",
|
||||
PlanApprovalPrompt: "计划已生成(看上方)— 回车通过,或输入修改建议",
|
||||
ChatStatusToolApproval: "y 同意一次 · a 本会话允许 · n 拒绝 · Ctrl-C 取消本轮",
|
||||
ToolApprovalPromptFmt: "允许 %s%s?— [y] 本次 · [a] 本会话 · [n] 拒绝",
|
||||
|
||||
SlashCompactDone: "已压缩 — 旧的中段换成一段摘要,最近几轮保留原样",
|
||||
SlashCompactFailed: "压缩失败",
|
||||
SlashNewDone: "已新建会话 — 之前的对话已存档",
|
||||
SlashNewFailed: "新建会话失败",
|
||||
SlashUnavailable: "当前构建不支持该命令",
|
||||
SlashUnknown: "未知命令",
|
||||
SlashHelp: "命令:/compact(手动压缩上下文)· /new(开新会话)· /mcp(MCP 服务器)· /help",
|
||||
SlashPromptEmpty: "该 MCP prompt 没有返回可发送的内容",
|
||||
SlashMCPNone: "没有配置 MCP 服务器 — 在 reasonix.toml 加一个 [[plugins]] 条目",
|
||||
CompHintSlash: "↑/↓ 移动 · Tab/Enter 选中 · Esc 关闭",
|
||||
CompHintFile: "↑/↓ 移动 · Tab/Enter 进入文件夹或选中文件 · Esc 关闭",
|
||||
|
||||
SelectProvidersLabel: "选择要启用的 provider",
|
||||
EnterAPIKeysHeader: "输入 API key(回车跳过、稍后写入 .env):",
|
||||
MissingKeyIntro: "reasonix.toml 已配置好 — 只差一个 API key 就可以开始。",
|
||||
WroteFileFmt: "已写入 %s",
|
||||
SetupComplete: "设置完成。",
|
||||
SetupCancelled: "设置已取消。",
|
||||
TryHintFmt: "试试: %s",
|
||||
NextHint: "下一步:设置 API key(export DEEPSEEK_API_KEY=... 或写入 .env),然后运行 `reasonix run \"你的任务\"`。",
|
||||
ConfirmReconfigureFmt: "%s 已存在。重新配置并覆盖?",
|
||||
KeepingExisting: "保留原配置不变。",
|
||||
NotOverwritingFmt: "%s 已存在,不覆盖",
|
||||
|
||||
UnknownCommandFmt: "未知命令 %q",
|
||||
UsageRunHint: "用法:reasonix run [--model NAME] <task>",
|
||||
ErrorPrefix: "错误:",
|
||||
WriteConfigErr: "写入配置失败:",
|
||||
WriteEnvErr: "写入 .env 失败:",
|
||||
|
||||
SelectOneHint: "(↑/↓ · Enter · q 取消)",
|
||||
SelectManyHint: "(↑/↓ · Space · Enter · q)",
|
||||
|
||||
UsageBody: `reasonix — 由配置和插件驱动的 coding agent(多模型)
|
||||
|
||||
用法:
|
||||
reasonix chat [--model NAME] 交互式会话(多轮)
|
||||
reasonix run [--model NAME] [--max-steps N] <task> 执行单次任务后退出
|
||||
reasonix init [path] 交互式设置;生成 reasonix.toml(及 .env)
|
||||
reasonix version
|
||||
reasonix help
|
||||
|
||||
示例:
|
||||
reasonix chat
|
||||
reasonix run "把 main.go 里的 TODO 实现掉"
|
||||
reasonix run --model mimo-pro "给这个函数补单元测试"
|
||||
echo "解释这段代码" | reasonix run
|
||||
|
||||
配置:
|
||||
优先级:flag > ./reasonix.toml > ~/.config/reasonix/config.toml > 内置默认值
|
||||
密钥通过 api_key_env 从环境变量注入(如 DEEPSEEK_API_KEY)。
|
||||
运行 'reasonix init' 生成配置;详见 docs/SPEC.md。
|
||||
`,
|
||||
}
|
||||
@@ -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 + ")"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
// Package plugin is Reasonix's MCP client. It connects to external MCP servers and
|
||||
// adapts their tools to the tool.Tool interface, so the agent treats plugin
|
||||
// tools and built-ins uniformly. The wire protocol is JSON-RPC 2.0 in every
|
||||
// case; only the transport differs (stdio subprocess, Streamable HTTP, or the
|
||||
// legacy HTTP+SSE). A transport interface hides that difference so the MCP-level
|
||||
// logic — handshake, tools/list, tools/call — is written once.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
// protocolVersion is the MCP revision Reasonix advertises during initialize.
|
||||
const protocolVersion = "2024-11-05"
|
||||
|
||||
// Spec declares an external MCP server. Type selects the transport: "stdio"
|
||||
// (default) runs Command/Args/Env as a subprocess; "http" / "streamable-http"
|
||||
// and "sse" connect to URL with optional static Headers.
|
||||
type Spec struct {
|
||||
Name string
|
||||
Type string
|
||||
Command string
|
||||
Args []string
|
||||
Env map[string]string
|
||||
URL string
|
||||
Headers map[string]string
|
||||
}
|
||||
|
||||
// transport carries JSON-RPC messages to and from one MCP server. call sends a
|
||||
// request and returns its result (correlating by id internally); notify sends a
|
||||
// fire-and-forget notification; close releases resources. Server-initiated
|
||||
// messages (notifications, requests like roots/list) are ignored — Reasonix is a
|
||||
// tools/prompts/resources consumer, not a sampling/roots provider (see SPEC §9).
|
||||
type transport interface {
|
||||
call(ctx context.Context, method string, params any) (json.RawMessage, error)
|
||||
notify(ctx context.Context, method string, params any) error
|
||||
close()
|
||||
}
|
||||
|
||||
// Host owns the running plugin connections and closes them together. It also
|
||||
// aggregates the prompts and resources discovered across servers, which the
|
||||
// chat UI surfaces (prompts as slash commands, resources as @-references).
|
||||
type Host struct {
|
||||
clients []*Client
|
||||
prompts []Prompt
|
||||
resources []Resource
|
||||
}
|
||||
|
||||
// Prompts returns every MCP prompt discovered across connected servers.
|
||||
func (h *Host) Prompts() []Prompt { return h.prompts }
|
||||
|
||||
// Resources returns every MCP resource discovered across connected servers.
|
||||
func (h *Host) Resources() []Resource { return h.resources }
|
||||
|
||||
// ServerNames returns the connected servers' names, in connection order.
|
||||
func (h *Host) ServerNames() []string {
|
||||
names := make([]string, len(h.clients))
|
||||
for i, c := range h.clients {
|
||||
names[i] = c.name
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// ReadResource reads a resource uri from the named server. It is how the chat
|
||||
// UI resolves an @server:uri reference — the uri need not be one listed by
|
||||
// resources/list (servers may expose templated uris), so we read it directly.
|
||||
func (h *Host) ReadResource(ctx context.Context, server, uri string) (string, error) {
|
||||
for _, c := range h.clients {
|
||||
if c.name == server {
|
||||
return c.readResource(ctx, uri)
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no MCP server named %q", server)
|
||||
}
|
||||
|
||||
// StartAll connects every plugin, performs the MCP handshake, and returns the
|
||||
// union of their tools (namespaced "mcp__<server>__<tool>"). On any failure it
|
||||
// tears down everything started so far. The caller must Close the Host.
|
||||
//
|
||||
// For stdio plugins, subprocess lifetime is bound to ctx (via
|
||||
// exec.CommandContext): cancelling ctx kills the children and unblocks reads.
|
||||
func StartAll(ctx context.Context, specs []Spec) (*Host, []tool.Tool, error) {
|
||||
h := &Host{}
|
||||
var tools []tool.Tool
|
||||
for _, s := range specs {
|
||||
c, err := start(ctx, s)
|
||||
if err != nil {
|
||||
h.Close()
|
||||
return nil, nil, fmt.Errorf("start plugin %q: %w", s.Name, err)
|
||||
}
|
||||
h.clients = append(h.clients, c)
|
||||
|
||||
ts, err := c.listTools(ctx)
|
||||
if err != nil {
|
||||
h.Close()
|
||||
return nil, nil, fmt.Errorf("list tools from %q: %w", s.Name, err)
|
||||
}
|
||||
tools = append(tools, ts...)
|
||||
c.toolCount = len(ts)
|
||||
|
||||
// Prompts and resources are auxiliary: only fetched when the server
|
||||
// advertised the capability, and a listing error is tolerated (skipped)
|
||||
// rather than failing the whole session over a non-essential surface.
|
||||
if c.hasPrompts {
|
||||
if ps, perr := c.listPrompts(ctx); perr == nil {
|
||||
h.prompts = append(h.prompts, ps...)
|
||||
}
|
||||
}
|
||||
if c.hasResources {
|
||||
if rs, rerr := c.listResources(ctx); rerr == nil {
|
||||
h.resources = append(h.resources, rs...)
|
||||
}
|
||||
}
|
||||
}
|
||||
return h, tools, nil
|
||||
}
|
||||
|
||||
// Close terminates all plugin connections.
|
||||
func (h *Host) Close() {
|
||||
for _, c := range h.clients {
|
||||
c.close()
|
||||
}
|
||||
}
|
||||
|
||||
// Client is one MCP server connection: a name plus the transport carrying its
|
||||
// JSON-RPC. The MCP-level methods (initialize, listTools, …) are transport-
|
||||
// agnostic — they go through t.
|
||||
type Client struct {
|
||||
name string
|
||||
t transport
|
||||
|
||||
// Capabilities advertised by the server at initialize. prompts/list and
|
||||
// resources/list are only called when advertised, so we never provoke a
|
||||
// "method not found" on a tools-only server.
|
||||
hasPrompts bool
|
||||
hasResources bool
|
||||
|
||||
toolCount int // tools discovered, for /mcp status
|
||||
transport string // declared transport type, for /mcp status ("stdio"/"http")
|
||||
}
|
||||
|
||||
// ServerStatus summarises one connected server for the /mcp command.
|
||||
type ServerStatus struct {
|
||||
Name string
|
||||
Transport string
|
||||
Tools int
|
||||
Prompts int
|
||||
Resources int
|
||||
}
|
||||
|
||||
// Servers returns a status summary per connected server, in connection order.
|
||||
func (h *Host) Servers() []ServerStatus {
|
||||
out := make([]ServerStatus, 0, len(h.clients))
|
||||
for _, c := range h.clients {
|
||||
s := ServerStatus{Name: c.name, Transport: c.transport, Tools: c.toolCount}
|
||||
for _, p := range h.prompts {
|
||||
if p.Server == c.name {
|
||||
s.Prompts++
|
||||
}
|
||||
}
|
||||
for _, r := range h.resources {
|
||||
if r.Server == c.name {
|
||||
s.Resources++
|
||||
}
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func start(ctx context.Context, s Spec) (*Client, error) {
|
||||
t, err := newTransport(ctx, s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tt := strings.ToLower(strings.TrimSpace(s.Type))
|
||||
if tt == "" {
|
||||
tt = "stdio"
|
||||
}
|
||||
c := &Client{name: s.Name, t: t, transport: tt}
|
||||
if err := c.initialize(ctx); err != nil {
|
||||
c.close()
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// newTransport builds the transport for a spec's declared type. Empty / unknown
|
||||
// defaults to stdio.
|
||||
func newTransport(ctx context.Context, s Spec) (transport, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(s.Type)) {
|
||||
case "", "stdio":
|
||||
return newStdioTransport(ctx, s)
|
||||
case "http", "streamable-http", "streamable_http":
|
||||
return newHTTPTransport(s)
|
||||
case "sse":
|
||||
// The legacy 2024-11-05 HTTP+SSE transport needs a persistent GET stream
|
||||
// with a background dispatcher — deprecated upstream ("avoid for new
|
||||
// work"). Use type="http" (Streamable HTTP), which most remote servers
|
||||
// now speak. Tracked for later (SPEC §9).
|
||||
return nil, fmt.Errorf("plugin %q: legacy sse transport not yet supported — use type=\"http\" (Streamable HTTP)", s.Name)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown transport type %q (want stdio|http|sse)", s.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) call(ctx context.Context, method string, params any) (json.RawMessage, error) {
|
||||
return c.t.call(ctx, method, params)
|
||||
}
|
||||
|
||||
func (c *Client) notify(ctx context.Context, method string, params any) error {
|
||||
return c.t.notify(ctx, method, params)
|
||||
}
|
||||
|
||||
func (c *Client) close() { c.t.close() }
|
||||
|
||||
func (c *Client) initialize(ctx context.Context) error {
|
||||
res, err := c.call(ctx, "initialize", map[string]any{
|
||||
"protocolVersion": protocolVersion,
|
||||
"capabilities": map[string]any{},
|
||||
"clientInfo": map[string]any{"name": "reasonix", "version": "dev"},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Record which optional capabilities the server advertises. Presence of the
|
||||
// key (even with an empty object) signals support.
|
||||
var ir struct {
|
||||
Capabilities map[string]json.RawMessage `json:"capabilities"`
|
||||
}
|
||||
_ = json.Unmarshal(res, &ir)
|
||||
_, c.hasPrompts = ir.Capabilities["prompts"]
|
||||
_, c.hasResources = ir.Capabilities["resources"]
|
||||
|
||||
return c.notify(ctx, "notifications/initialized", map[string]any{})
|
||||
}
|
||||
|
||||
type mcpTool struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema json.RawMessage `json:"inputSchema"`
|
||||
// Annotations carries MCP's optional tool hints. We read readOnlyHint: a
|
||||
// plugin that declares a tool read-only opts it into Reasonix's parallel-dispatch
|
||||
// path and the permission layer's "readers default to allow". Absent
|
||||
// annotations stay false — opaque by default, never trusted implicitly.
|
||||
Annotations *struct {
|
||||
ReadOnlyHint bool `json:"readOnlyHint"`
|
||||
} `json:"annotations"`
|
||||
}
|
||||
|
||||
func (c *Client) listTools(ctx context.Context) ([]tool.Tool, error) {
|
||||
res, err := c.call(ctx, "tools/list", map[string]any{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out struct {
|
||||
Tools []mcpTool `json:"tools"`
|
||||
}
|
||||
if err := json.Unmarshal(res, &out); err != nil {
|
||||
return nil, fmt.Errorf("plugin %q: decode tools/list: %w", c.name, err)
|
||||
}
|
||||
|
||||
tools := make([]tool.Tool, 0, len(out.Tools))
|
||||
for _, t := range out.Tools {
|
||||
tools = append(tools, &remoteTool{
|
||||
client: c,
|
||||
name: toolName(c.name, t.Name),
|
||||
rawName: t.Name,
|
||||
desc: t.Description,
|
||||
schema: t.InputSchema,
|
||||
readOnly: t.Annotations != nil && t.Annotations.ReadOnlyHint,
|
||||
})
|
||||
}
|
||||
return tools, nil
|
||||
}
|
||||
|
||||
// toolName builds the model-visible namespaced name "mcp__<server>__<tool>",
|
||||
// matching Claude Code. Spaces in either part are normalised to underscores so
|
||||
// the name is a clean identifier the model can call.
|
||||
func toolName(server, raw string) string {
|
||||
return "mcp__" + normalizeName(server) + "__" + normalizeName(raw)
|
||||
}
|
||||
|
||||
func normalizeName(s string) string { return strings.ReplaceAll(s, " ", "_") }
|
||||
|
||||
// --- JSON-RPC message types (shared by every transport) ---
|
||||
|
||||
type rpcRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID int `json:"id,omitempty"` // omitted for notifications (id 0 unused)
|
||||
Method string `json:"method"`
|
||||
Params any `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
type rpcResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID int `json:"id"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error *rpcError `json:"error"`
|
||||
}
|
||||
|
||||
type rpcError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (e *rpcError) Error() string { return fmt.Sprintf("rpc error %d: %s", e.Code, e.Message) }
|
||||
|
||||
// --- remote tool adapter ---
|
||||
|
||||
type remoteTool struct {
|
||||
client *Client
|
||||
name string // namespaced "mcp__<server>__<tool>"
|
||||
rawName string // original name for tools/call
|
||||
desc string
|
||||
schema json.RawMessage
|
||||
readOnly bool // from the tool's MCP readOnlyHint annotation
|
||||
}
|
||||
|
||||
func (t *remoteTool) Name() string { return t.name }
|
||||
func (t *remoteTool) Description() string { return t.desc }
|
||||
|
||||
// ReadOnly reflects the tool's MCP readOnlyHint annotation. It defaults to
|
||||
// false (opaque — we can't inspect a remote tool's side effects), so plugins
|
||||
// opt into parallel-batch dispatch and the permission layer's reader-default
|
||||
// by explicitly declaring readOnlyHint: true in tools/list.
|
||||
func (t *remoteTool) ReadOnly() bool { return t.readOnly }
|
||||
|
||||
func (t *remoteTool) Schema() json.RawMessage {
|
||||
if len(t.schema) == 0 {
|
||||
return json.RawMessage(`{"type":"object"}`)
|
||||
}
|
||||
return t.schema
|
||||
}
|
||||
|
||||
func (t *remoteTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
|
||||
var argMap map[string]any
|
||||
if len(args) > 0 {
|
||||
if err := json.Unmarshal(args, &argMap); err != nil {
|
||||
return "", fmt.Errorf("invalid args: %w", err)
|
||||
}
|
||||
}
|
||||
res, err := t.client.call(ctx, "tools/call", map[string]any{
|
||||
"name": t.rawName,
|
||||
"arguments": argMap,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parseToolResult(res)
|
||||
}
|
||||
|
||||
// parseToolResult flattens an MCP tools/call result into plain text.
|
||||
func parseToolResult(res json.RawMessage) (string, error) {
|
||||
var out struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
IsError bool `json:"isError"`
|
||||
}
|
||||
if err := json.Unmarshal(res, &out); err != nil {
|
||||
return "", fmt.Errorf("decode tool result: %w", err)
|
||||
}
|
||||
var sb strings.Builder
|
||||
for _, c := range out.Content {
|
||||
if c.Type == "text" {
|
||||
sb.WriteString(c.Text)
|
||||
}
|
||||
}
|
||||
text := sb.String()
|
||||
if out.IsError {
|
||||
return text, fmt.Errorf("plugin tool reported error: %s", text)
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
|
||||
func envSlice(m map[string]string) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k, v := range m {
|
||||
out = append(out, k+"="+v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -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'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Prompt is an MCP prompt exposed by a server. It surfaces in the chat TUI as a
|
||||
// slash command "/mcp__<server>__<prompt>"; running it fetches the rendered
|
||||
// prompt and sends it to the model as a turn.
|
||||
type Prompt struct {
|
||||
Name string // "mcp__<server>__<prompt>" — the slash-command body
|
||||
Server string // owning server name
|
||||
Raw string // original prompt name for prompts/get
|
||||
Description string // human-readable summary
|
||||
Args []PromptArg // declared arguments, in order
|
||||
client *Client
|
||||
}
|
||||
|
||||
// PromptArg is one declared prompt argument. Reasonix maps space-separated
|
||||
// positional command arguments onto these in order, matching Claude Code.
|
||||
type PromptArg struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Required bool `json:"required"`
|
||||
}
|
||||
|
||||
// Get fetches the prompt with the given arguments and flattens its returned
|
||||
// messages into a single text block to send to the model.
|
||||
func (p Prompt) Get(ctx context.Context, args map[string]string) (string, error) {
|
||||
return p.client.getPrompt(ctx, p.Raw, args)
|
||||
}
|
||||
|
||||
func (c *Client) listPrompts(ctx context.Context) ([]Prompt, error) {
|
||||
res, err := c.call(ctx, "prompts/list", map[string]any{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out struct {
|
||||
Prompts []struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Arguments []PromptArg `json:"arguments"`
|
||||
} `json:"prompts"`
|
||||
}
|
||||
if err := json.Unmarshal(res, &out); err != nil {
|
||||
return nil, fmt.Errorf("plugin %q: decode prompts/list: %w", c.name, err)
|
||||
}
|
||||
prompts := make([]Prompt, 0, len(out.Prompts))
|
||||
for _, p := range out.Prompts {
|
||||
prompts = append(prompts, Prompt{
|
||||
Name: "mcp__" + normalizeName(c.name) + "__" + normalizeName(p.Name),
|
||||
Server: c.name,
|
||||
Raw: p.Name,
|
||||
Description: p.Description,
|
||||
Args: p.Arguments,
|
||||
client: c,
|
||||
})
|
||||
}
|
||||
return prompts, nil
|
||||
}
|
||||
|
||||
func (c *Client) getPrompt(ctx context.Context, name string, args map[string]string) (string, error) {
|
||||
params := map[string]any{"name": name}
|
||||
if len(args) > 0 {
|
||||
params["arguments"] = args
|
||||
}
|
||||
res, err := c.call(ctx, "prompts/get", params)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var out struct {
|
||||
Messages []struct {
|
||||
Role string `json:"role"`
|
||||
Content struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal(res, &out); err != nil {
|
||||
return "", fmt.Errorf("plugin %q: decode prompts/get: %w", c.name, err)
|
||||
}
|
||||
var sb strings.Builder
|
||||
for _, m := range out.Messages {
|
||||
if m.Content.Type == "text" && m.Content.Text != "" {
|
||||
if sb.Len() > 0 {
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
sb.WriteString(m.Content.Text)
|
||||
}
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Resource is an MCP resource a server exposes. In chat it is referenced as
|
||||
// "@<server>:<uri>" (e.g. "@docs:file://README.md"); the referenced content is
|
||||
// fetched and prepended to the message sent to the model.
|
||||
type Resource struct {
|
||||
Server string // owning server name
|
||||
URI string // canonical resource uri
|
||||
Name string // human-readable label
|
||||
Description string
|
||||
MimeType string
|
||||
}
|
||||
|
||||
func (c *Client) listResources(ctx context.Context) ([]Resource, error) {
|
||||
res, err := c.call(ctx, "resources/list", map[string]any{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out struct {
|
||||
Resources []struct {
|
||||
URI string `json:"uri"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
MimeType string `json:"mimeType"`
|
||||
} `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(res, &out); err != nil {
|
||||
return nil, fmt.Errorf("plugin %q: decode resources/list: %w", c.name, err)
|
||||
}
|
||||
resources := make([]Resource, 0, len(out.Resources))
|
||||
for _, r := range out.Resources {
|
||||
resources = append(resources, Resource{
|
||||
Server: c.name,
|
||||
URI: r.URI,
|
||||
Name: r.Name,
|
||||
Description: r.Description,
|
||||
MimeType: r.MimeType,
|
||||
})
|
||||
}
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
// readResource fetches a resource by uri and flattens its text contents. Binary
|
||||
// (blob) contents are noted but not decoded — a coding agent consumes text.
|
||||
func (c *Client) readResource(ctx context.Context, uri string) (string, error) {
|
||||
res, err := c.call(ctx, "resources/read", map[string]any{"uri": uri})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var out struct {
|
||||
Contents []struct {
|
||||
URI string `json:"uri"`
|
||||
MimeType string `json:"mimeType"`
|
||||
Text string `json:"text"`
|
||||
Blob string `json:"blob"`
|
||||
} `json:"contents"`
|
||||
}
|
||||
if err := json.Unmarshal(res, &out); err != nil {
|
||||
return "", fmt.Errorf("plugin %q: decode resources/read: %w", c.name, err)
|
||||
}
|
||||
var sb strings.Builder
|
||||
for _, ct := range out.Contents {
|
||||
if sb.Len() > 0 {
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
switch {
|
||||
case ct.Text != "":
|
||||
sb.WriteString(ct.Text)
|
||||
case ct.Blob != "":
|
||||
fmt.Fprintf(&sb, "[binary resource %s, %s — %d base64 bytes omitted]", ct.URI, ct.MimeType, len(ct.Blob))
|
||||
}
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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 + `"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package builtin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
// argsJSON marshals m into the JSON form a tool expects. Tests must not build
|
||||
// the JSON by concatenating Go strings: on Windows, t.TempDir() returns a path
|
||||
// like C:\Users\… and the embedded backslashes are interpreted as JSON string
|
||||
// escapes (\U triggers a parse error). json.Marshal handles the escaping.
|
||||
func argsJSON(t *testing.T, m map[string]any) json.RawMessage {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal args: %v", err)
|
||||
}
|
||||
return json.RawMessage(b)
|
||||
}
|
||||
|
||||
func runTool(t *testing.T, tl tool.Tool, m map[string]any) string {
|
||||
t.Helper()
|
||||
out, err := tl.Execute(context.Background(), argsJSON(t, m))
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", tl.Name(), err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestBuiltinsRegistered(t *testing.T) {
|
||||
want := []string{"bash", "edit_file", "glob", "grep", "ls", "multi_edit", "read_file", "web_fetch", "write_file"}
|
||||
for _, name := range want {
|
||||
if _, ok := tool.LookupBuiltin(name); !ok {
|
||||
t.Errorf("built-in %q not registered", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuiltinReadOnlyClassification locks in which built-ins the agent may
|
||||
// parallelise. Flipping a writer (write_file, edit_file, bash) to ReadOnly
|
||||
// would re-order writes against reads in the same turn; this test fails fast
|
||||
// if that ever happens. bash specifically must stay non-ReadOnly even though
|
||||
// many invocations are pure reads — args aren't introspected.
|
||||
func TestBuiltinReadOnlyClassification(t *testing.T) {
|
||||
readOnly := map[string]bool{
|
||||
"read_file": true, "ls": true, "glob": true, "grep": true, "web_fetch": true,
|
||||
"write_file": false, "edit_file": false, "multi_edit": false, "bash": false,
|
||||
}
|
||||
for name, want := range readOnly {
|
||||
tl, ok := tool.LookupBuiltin(name)
|
||||
if !ok {
|
||||
t.Fatalf("built-in %q not registered", name)
|
||||
}
|
||||
if got := tl.ReadOnly(); got != want {
|
||||
t.Errorf("%s.ReadOnly() = %v, want %v", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
f := filepath.Join(dir, "src.go")
|
||||
body := "package main\n\nfunc main() {}\n"
|
||||
os.WriteFile(f, []byte(body), 0o644)
|
||||
|
||||
out := runTool(t, readFile{}, map[string]any{"path": f})
|
||||
// Line numbers must be present, right-aligned, with the arrow separator.
|
||||
for _, want := range []string{"1→package main", "2→", "3→func main"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("missing %q in:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFileOffsetLimit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
f := filepath.Join(dir, "many.txt")
|
||||
var b strings.Builder
|
||||
for i := 1; i <= 50; i++ {
|
||||
fmt.Fprintf(&b, "line %d\n", i)
|
||||
}
|
||||
os.WriteFile(f, []byte(b.String()), 0o644)
|
||||
|
||||
out := runTool(t, readFile{}, map[string]any{"path": f, "offset": 10, "limit": 5})
|
||||
// Should see lines 11-15 only.
|
||||
for _, want := range []string{"11→line 11", "15→line 15"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("missing %q in:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
for _, leak := range []string{"line 5\n", "line 16\n", "line 20\n"} {
|
||||
if strings.Contains(out, leak) {
|
||||
t.Errorf("leaked %q (outside the slice)\n%s", leak, out)
|
||||
}
|
||||
}
|
||||
// Trailer announces what's left so the model can paginate.
|
||||
if !strings.Contains(out, "more line") || !strings.Contains(out, "offset=15") {
|
||||
t.Errorf("pagination hint missing:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFileBinary(t *testing.T) {
|
||||
f := filepath.Join(t.TempDir(), "blob")
|
||||
os.WriteFile(f, []byte{0x7f, 'E', 'L', 'F', 0, 0, 0}, 0o644)
|
||||
|
||||
_, err := readFile{}.Execute(context.Background(), argsJSON(t, map[string]any{"path": f}))
|
||||
if err == nil || !strings.Contains(err.Error(), "binary") {
|
||||
t.Errorf("expected binary-file error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFileEmpty(t *testing.T) {
|
||||
f := filepath.Join(t.TempDir(), "empty.txt")
|
||||
os.WriteFile(f, nil, 0o644)
|
||||
if out := runTool(t, readFile{}, map[string]any{"path": f}); !strings.Contains(out, "empty") {
|
||||
t.Errorf("empty file should report empty, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFile(t *testing.T) {
|
||||
f := filepath.Join(t.TempDir(), "a.txt")
|
||||
os.WriteFile(f, []byte("hello world\n"), 0o644)
|
||||
|
||||
runTool(t, editFile{}, map[string]any{"path": f, "old_string": "world", "new_string": "reasonix"})
|
||||
if b, _ := os.ReadFile(f); string(b) != "hello reasonix\n" {
|
||||
t.Fatalf("after edit = %q", b)
|
||||
}
|
||||
|
||||
// Non-unique old_string must error and not modify the file.
|
||||
os.WriteFile(f, []byte("x x x"), 0o644)
|
||||
args := argsJSON(t, map[string]any{"path": f, "old_string": "x", "new_string": "y"})
|
||||
if _, err := (editFile{}).Execute(context.Background(), args); err == nil {
|
||||
t.Fatal("expected not-unique error")
|
||||
}
|
||||
if b, _ := os.ReadFile(f); string(b) != "x x x" {
|
||||
t.Fatalf("file modified despite error: %q", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiEdit(t *testing.T) {
|
||||
f := filepath.Join(t.TempDir(), "src.go")
|
||||
body := "package old\n\nfunc old() {\n\told()\n}\n"
|
||||
os.WriteFile(f, []byte(body), 0o644)
|
||||
|
||||
// Two edits: rename the package (unique) then sweep every old → new.
|
||||
out := runTool(t, multiEdit{}, map[string]any{
|
||||
"path": f,
|
||||
"edits": []map[string]any{
|
||||
{"old_string": "package old", "new_string": "package new"},
|
||||
{"old_string": "old", "new_string": "reasonix", "replace_all": true},
|
||||
},
|
||||
})
|
||||
if !strings.Contains(out, "multi_edit") || !strings.Contains(out, "2 edits applied") {
|
||||
t.Errorf("summary unexpected: %q", out)
|
||||
}
|
||||
got, _ := os.ReadFile(f)
|
||||
want := "package new\n\nfunc reasonix() {\n\treasonix()\n}\n"
|
||||
if string(got) != want {
|
||||
t.Errorf("after multi_edit = %q\n want = %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMultiEditAtomicity is the safety guarantee: if any edit fails, the file
|
||||
// stays exactly as it was. A chained sequence of single edit_file calls would
|
||||
// have left a half-written intermediate state.
|
||||
func TestMultiEditAtomicity(t *testing.T) {
|
||||
f := filepath.Join(t.TempDir(), "a.txt")
|
||||
original := "alpha\nbeta\ngamma\n"
|
||||
os.WriteFile(f, []byte(original), 0o644)
|
||||
|
||||
args := argsJSON(t, map[string]any{
|
||||
"path": f,
|
||||
"edits": []map[string]any{
|
||||
{"old_string": "alpha", "new_string": "ALPHA"},
|
||||
{"old_string": "no-such-text", "new_string": "x"},
|
||||
{"old_string": "gamma", "new_string": "GAMMA"},
|
||||
},
|
||||
})
|
||||
if _, err := (multiEdit{}).Execute(context.Background(), args); err == nil {
|
||||
t.Fatal("expected failure on the missing edit")
|
||||
}
|
||||
got, _ := os.ReadFile(f)
|
||||
if string(got) != original {
|
||||
t.Errorf("file was modified despite failure:\n got %q\nwant %q", got, original)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrep(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "a.go"), []byte("package main\nfunc Foo() {}\n"), 0o644)
|
||||
os.WriteFile(filepath.Join(dir, "b.go"), []byte("var x = 1\n"), 0o644)
|
||||
|
||||
out := runTool(t, grepTool{}, map[string]any{"pattern": "func ", "path": dir})
|
||||
if !strings.Contains(out, "Foo") || strings.Contains(out, "var x") {
|
||||
t.Fatalf("grep result = %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebFetchHTML serves a tiny HTML page and checks the reducer keeps the
|
||||
// readable text while removing scripts, styles, and tags.
|
||||
func TestWebFetchHTML(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(`<!doctype html>
|
||||
<html><head><title>T</title><style>body{color:red}</style></head>
|
||||
<body>
|
||||
<h1>Hello & world</h1>
|
||||
<script>alert("bad")</script>
|
||||
<p>Visible text.</p>
|
||||
</body></html>`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
out := runTool(t, webFetch{}, map[string]any{"url": srv.URL})
|
||||
for _, want := range []string{"Hello & world", "Visible text", "text/html"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("missing %q in:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
for _, leak := range []string{"<script", "alert(", "<style", "<h1>", "&"} {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package builtin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
func init() { tool.RegisterBuiltin(webFetch{}) }
|
||||
|
||||
type webFetch struct{}
|
||||
|
||||
const (
|
||||
webFetchTimeout = 15 * time.Second
|
||||
webFetchMaxRead = 1 << 20 // 1 MiB cap before extraction
|
||||
)
|
||||
|
||||
func (webFetch) Name() string { return "web_fetch" }
|
||||
|
||||
func (webFetch) Description() string {
|
||||
return "Fetch a URL over HTTPS/HTTP and return its text content. HTML pages are reduced to readable text (scripts, styles, tags stripped, whitespace collapsed); JSON / plain text / markdown bodies come back verbatim. Use to read documentation pages, API responses, or source files hosted somewhere the local filesystem can't reach."
|
||||
}
|
||||
|
||||
func (webFetch) Schema() json.RawMessage {
|
||||
return json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"url":{"type":"string","description":"Absolute URL beginning with http:// or https://"}
|
||||
},
|
||||
"required":["url"]
|
||||
}`)
|
||||
}
|
||||
|
||||
func (webFetch) ReadOnly() bool { return true }
|
||||
|
||||
func (webFetch) Execute(ctx context.Context, args json.RawMessage) (string, error) {
|
||||
var p struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.Unmarshal(args, &p); err != nil {
|
||||
return "", fmt.Errorf("invalid args: %w", err)
|
||||
}
|
||||
if p.URL == "" {
|
||||
return "", fmt.Errorf("url is required")
|
||||
}
|
||||
u, err := url.Parse(p.URL)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
return "", fmt.Errorf("url must be an absolute http(s) address")
|
||||
}
|
||||
|
||||
reqCtx, cancel := context.WithTimeout(ctx, webFetchTimeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, p.URL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
// A plain UA + Accept tip the server toward returning text/HTML rather
|
||||
// than minified asset bundles or binary content.
|
||||
req.Header.Set("User-Agent", "reasonix-web-fetch/1.0")
|
||||
req.Header.Set("Accept", "text/html,text/plain,text/markdown,application/json,*/*;q=0.5")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fetch %s: %w", p.URL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, webFetchMaxRead))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
|
||||
ct := strings.ToLower(resp.Header.Get("Content-Type"))
|
||||
out := string(body)
|
||||
if strings.Contains(ct, "text/html") || looksLikeHTML(out) {
|
||||
out = htmlToText(out)
|
||||
}
|
||||
out = strings.TrimSpace(out)
|
||||
if out == "" {
|
||||
return fmt.Sprintf("(empty body — status %s)", resp.Status), nil
|
||||
}
|
||||
header := fmt.Sprintf("status %s · %s · %d bytes\n\n", resp.Status, contentTypeShort(ct), len(body))
|
||||
return header + out, nil
|
||||
}
|
||||
|
||||
// looksLikeHTML lets servers that misreport Content-Type still hit the HTML
|
||||
// reducer — GitHub raw pages and many docs sites lie about content type.
|
||||
func looksLikeHTML(s string) bool {
|
||||
head := s
|
||||
if len(head) > 512 {
|
||||
head = head[:512]
|
||||
}
|
||||
low := strings.ToLower(head)
|
||||
return strings.Contains(low, "<!doctype html") || strings.Contains(low, "<html")
|
||||
}
|
||||
|
||||
var (
|
||||
scriptStyle = regexp.MustCompile(`(?is)<(script|style)[^>]*>.*?</(?:script|style)>`)
|
||||
htmlComment = regexp.MustCompile(`(?s)<!--.*?-->`)
|
||||
anyTag = regexp.MustCompile(`(?s)<[^>]+>`)
|
||||
multiBlank = regexp.MustCompile(`\n[\t ]*\n([\t ]*\n)+`)
|
||||
trailingWS = regexp.MustCompile(`[\t ]+\n`)
|
||||
)
|
||||
|
||||
// htmlToText strips <script>/<style> blocks, HTML comments, and every other
|
||||
// tag, then unescapes the common entities and collapses runs of blank lines.
|
||||
// It is intentionally lossy — we want to give the model readable text rather
|
||||
// than preserve structure for re-rendering.
|
||||
func htmlToText(s string) string {
|
||||
s = scriptStyle.ReplaceAllString(s, "")
|
||||
s = htmlComment.ReplaceAllString(s, "")
|
||||
s = anyTag.ReplaceAllString(s, "")
|
||||
|
||||
// Unescape the entities the model is most likely to encounter. Avoids
|
||||
// pulling in html.UnescapeString just to handle five characters.
|
||||
repl := strings.NewReplacer(
|
||||
"&", "&",
|
||||
"<", "<",
|
||||
">", ">",
|
||||
""", `"`,
|
||||
"'", "'",
|
||||
"'", "'",
|
||||
" ", " ",
|
||||
)
|
||||
s = repl.Replace(s)
|
||||
|
||||
s = trailingWS.ReplaceAllString(s, "\n")
|
||||
s = multiBlank.ReplaceAllString(s, "\n\n")
|
||||
return s
|
||||
}
|
||||
|
||||
func contentTypeShort(ct string) string {
|
||||
if i := strings.IndexByte(ct, ';'); i >= 0 {
|
||||
ct = ct[:i]
|
||||
}
|
||||
return strings.TrimSpace(ct)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package builtin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
func init() { tool.RegisterBuiltin(writeFile{}) }
|
||||
|
||||
// writeFile writes a file. roots, when non-empty, confines the target to the
|
||||
// workspace (see confine); the zero value registered at init is unconfined and
|
||||
// is overridden per run by ConfineWriters.
|
||||
type writeFile struct{ roots []string }
|
||||
|
||||
func (writeFile) Name() string { return "write_file" }
|
||||
|
||||
func (writeFile) Description() string {
|
||||
return "Write content to a file at the given path (overwriting existing content). Creates parent directories as needed."
|
||||
}
|
||||
|
||||
func (writeFile) Schema() json.RawMessage {
|
||||
return json.RawMessage(`{"type":"object","properties":{"path":{"type":"string","description":"File path"},"content":{"type":"string","description":"Full content to write"}},"required":["path","content"]}`)
|
||||
}
|
||||
|
||||
func (writeFile) ReadOnly() bool { return false }
|
||||
|
||||
func (w writeFile) Execute(ctx context.Context, args json.RawMessage) (string, error) {
|
||||
var p struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal(args, &p); err != nil {
|
||||
return "", fmt.Errorf("invalid args: %w", err)
|
||||
}
|
||||
if p.Path == "" {
|
||||
return "", fmt.Errorf("path is required")
|
||||
}
|
||||
if err := confine(w.roots, p.Path); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if dir := filepath.Dir(p.Path); dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", fmt.Errorf("mkdir %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(p.Path, []byte(p.Content), 0o644); err != nil {
|
||||
return "", fmt.Errorf("write %s: %w", p.Path, err)
|
||||
}
|
||||
return fmt.Sprintf("wrote %d bytes to %s", len(p.Content), p.Path), nil
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// Package tool defines the Tool abstraction and a Registry. Built-in tools live
|
||||
// in tool/builtin and self-register via init(); plugin-provided tools are added
|
||||
// to a runtime Registry alongside the enabled built-ins. The agent sees only a
|
||||
// *Registry, never the global built-in set directly.
|
||||
package tool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
|
||||
// Tool is a capability the model can invoke.
|
||||
type Tool interface {
|
||||
Name() string
|
||||
Description() string
|
||||
// Schema returns the JSON Schema for the tool's parameters.
|
||||
Schema() json.RawMessage
|
||||
// Execute parses the model-generated raw JSON args and returns result text
|
||||
// to feed back to the model.
|
||||
Execute(ctx context.Context, args json.RawMessage) (string, error)
|
||||
// ReadOnly reports whether the tool has no observable side effects on the
|
||||
// host. The agent parallelises a batch of tool calls only when every call
|
||||
// in the batch is ReadOnly; mixed batches stay sequential so write/read
|
||||
// ordering is preserved. bash and plugin tools must return false because
|
||||
// their effects can't be inferred statically from args.
|
||||
ReadOnly() bool
|
||||
}
|
||||
|
||||
// --- process-global built-in set (populated by builtin subpackage init) ---
|
||||
|
||||
var builtins = map[string]Tool{}
|
||||
|
||||
// RegisterBuiltin registers a compile-time built-in tool. Intended for init().
|
||||
// It panics on a duplicate name, which is a compile-time wiring mistake.
|
||||
func RegisterBuiltin(t Tool) {
|
||||
name := t.Name()
|
||||
if _, dup := builtins[name]; dup {
|
||||
panic("tool: duplicate built-in " + name)
|
||||
}
|
||||
builtins[name] = t
|
||||
}
|
||||
|
||||
// Builtins returns all registered built-in tools, sorted by name.
|
||||
func Builtins() []Tool {
|
||||
names := make([]string, 0, len(builtins))
|
||||
for n := range builtins {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
out := make([]Tool, 0, len(names))
|
||||
for _, n := range names {
|
||||
out = append(out, builtins[n])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// LookupBuiltin returns a registered built-in by name.
|
||||
func LookupBuiltin(name string) (Tool, bool) {
|
||||
t, ok := builtins[name]
|
||||
return t, ok
|
||||
}
|
||||
|
||||
// --- per-run registry instance ---
|
||||
|
||||
// Registry is a per-run set of tools: enabled built-ins plus plugin tools.
|
||||
type Registry struct {
|
||||
tools map[string]Tool
|
||||
order []string
|
||||
}
|
||||
|
||||
// NewRegistry returns an empty registry.
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{tools: map[string]Tool{}}
|
||||
}
|
||||
|
||||
// Add inserts (or replaces) a tool, preserving first-seen order.
|
||||
func (r *Registry) Add(t Tool) {
|
||||
name := t.Name()
|
||||
if _, ok := r.tools[name]; !ok {
|
||||
r.order = append(r.order, name)
|
||||
}
|
||||
r.tools[name] = t
|
||||
}
|
||||
|
||||
// Get looks up a tool by name.
|
||||
func (r *Registry) Get(name string) (Tool, bool) {
|
||||
t, ok := r.tools[name]
|
||||
return t, ok
|
||||
}
|
||||
|
||||
// Len returns the number of registered tools.
|
||||
func (r *Registry) Len() int { return len(r.order) }
|
||||
|
||||
// Names returns the registered tool names in insertion order.
|
||||
func (r *Registry) Names() []string {
|
||||
out := make([]string, len(r.order))
|
||||
copy(out, r.order)
|
||||
return out
|
||||
}
|
||||
|
||||
// Schemas exports tool definitions in insertion order for the provider.
|
||||
func (r *Registry) Schemas() []provider.ToolSchema {
|
||||
out := make([]provider.ToolSchema, 0, len(r.order))
|
||||
for _, name := range r.order {
|
||||
t := r.tools[name]
|
||||
out = append(out, provider.ToolSchema{
|
||||
Name: t.Name(),
|
||||
Description: t.Description(),
|
||||
Parameters: t.Schema(),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
# Reasonix configuration.
|
||||
# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.
|
||||
# Secrets come from the environment via api_key_env; never put keys here.
|
||||
|
||||
default_model = "deepseek-flash"
|
||||
# language = "zh" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG
|
||||
|
||||
[agent]
|
||||
system_prompt = """
|
||||
You are Reasonix, a coding agent focused on executing code tasks.
|
||||
Use the provided tools to read and write files and run shell commands.
|
||||
Principles: understand the request before acting; verify with tools instead of
|
||||
guessing; keep changes minimal and correct; briefly summarize what you did."""
|
||||
# system_prompt_file = "prompts/system.md" # overrides system_prompt when set
|
||||
max_steps = 25
|
||||
temperature = 0.0
|
||||
# planner_model = "mimo-pro" # optional: enable two-model collaboration
|
||||
|
||||
[[providers]]
|
||||
name = "deepseek-flash"
|
||||
kind = "openai"
|
||||
base_url = "https://api.deepseek.com"
|
||||
model = "deepseek-v4-flash"
|
||||
api_key_env = "DEEPSEEK_API_KEY"
|
||||
price = { cache_hit = 0.02, input = 1, output = 2, currency = "¥" } # per 1M tokens
|
||||
|
||||
[[providers]]
|
||||
name = "deepseek-pro"
|
||||
kind = "openai"
|
||||
base_url = "https://api.deepseek.com"
|
||||
model = "deepseek-v4-pro"
|
||||
api_key_env = "DEEPSEEK_API_KEY"
|
||||
price = { cache_hit = 0.025, input = 3, output = 6, currency = "¥" } # per 1M tokens
|
||||
|
||||
[[providers]]
|
||||
name = "mimo-pro"
|
||||
kind = "openai"
|
||||
base_url = "https://api.xiaomimimo.com/v1"
|
||||
model = "mimo-v2.5-pro" # thinking-mode on by default; reasoning_content rendered dim above the answer
|
||||
api_key_env = "MIMO_API_KEY"
|
||||
context_window = 1000000
|
||||
|
||||
[[providers]]
|
||||
name = "mimo-flash"
|
||||
kind = "openai"
|
||||
base_url = "https://api.xiaomimimo.com/v1"
|
||||
model = "mimo-v2-flash" # thinking-mode off by default; cheaper / faster
|
||||
api_key_env = "MIMO_API_KEY"
|
||||
context_window = 65536
|
||||
|
||||
[tools]
|
||||
enabled = [] # empty = all built-in tools
|
||||
|
||||
# External stdio plugins (MCP-compatible); each is a standalone executable.
|
||||
# [[plugins]]
|
||||
# name = "example"
|
||||
# command = "reasonix-plugin-example"
|
||||
Reference in new issue
Block a user