Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ default_model = "deepseek-flash" # executor; set [agent].planner_model to add
max_steps = 0 # user/global only; executor tool-call rounds; 0 = no limit
planner_max_steps = 0 # user/global only; planner read-only tool-call rounds; 0 = no limit
reasoning_language = "auto" # visible reasoning text: auto|zh|en
# plan_mode_allowed_tools = ["custom_reader"] # extra read-only custom tools only;
# # does not unlock blocked tools or unsafe bash
# planner_model = "deepseek-pro" # optional low-frequency planner
# subagent_model = "deepseek-pro" # optional default for runAs=subagent skills
# subagent_models = { review = "deepseek-pro", security_review = "deepseek-pro" }
Expand Down Expand Up @@ -100,6 +102,15 @@ command = "reasonix-plugin-example"

For the full schema and every field's contract, see [`SPEC.md` §5](./SPEC.md#5-configuration-toml).

`[agent].plan_mode_allowed_tools` is an extra read-only declaration for custom or
external tools Reasonix cannot classify itself — it is also the escape valve for
MCP/plugin tools whose read-only flag comes from an untrusted server
`readOnlyHint`, which plan mode does not trust and so fails closed on until
declared here (first-party `ReadOnlyToolNames` overrides and built-ins stay
trusted). It never unlocks known blocked plan-mode tools such as `bash`, `task`,
writers, installers, or memory mutation tools, and it never bypasses bash's
plan-mode safety checks.

## Serve web frontend

`reasonix serve` starts the same local engine behind a browser UI. Use it when
Expand Down Expand Up @@ -451,6 +462,15 @@ Subagent skills inherit the executor model by default. Set `subagent_model` to
run them on another configured model, or use `subagent_models` to override only
specific skills such as `review` or `security_review`.

Use `read_only_task` when planning needs isolated, deeper research without
granting write-capable delegation. Use `read_only_skill` when the same need is
best expressed through an existing skill. Both run ephemeral read-only
subagents with only read-only research tools plus safe foreground bash, return
only the final answer, and do not create resumable subagent transcripts. In
token economy mode, connect only this narrow surface with
`connect_tool_source(source="read_only_skill")`; the full `skills` source still
enables writer-capable skill tools and remains blocked in plan mode.

For interactive frontends, plan mode is manual by default. Set
`agent.auto_plan = "on"` to make complex-looking tasks enter plan mode
automatically: Reasonix first drafts a read-only plan, then waits for approval
Expand Down
14 changes: 14 additions & 0 deletions docs/GUIDE.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ default_model = "deepseek-flash" # 执行器;设 [agent].planner_model 可
max_steps = 0 # 仅用户/全局;执行器工具调用轮数;0 表示不限
planner_max_steps = 0 # 仅用户/全局;规划器只读工具调用轮数;0 表示不限
reasoning_language = "auto" # 可见思考过程语言:auto|zh|en
# plan_mode_allowed_tools = ["custom_reader"] # 仅声明额外只读自定义工具;
# # 不能解锁被计划模式阻断的工具或 unsafe bash
# planner_model = "deepseek-pro" # 可选的低频规划器
# subagent_model = "deepseek-pro" # runAs=subagent skill 的默认模型
# subagent_models = { review = "deepseek-pro", security_review = "deepseek-pro" }
Expand Down Expand Up @@ -92,6 +94,11 @@ command = "reasonix-plugin-example"

完整 schema 与每个字段的契约见 [`SPEC.md` §5](./SPEC.md#5-configuration-toml)。

`[agent].plan_mode_allowed_tools` 用于把 Reasonix 无法自动分类的自定义/外部工具声明为额外只读工具,
它也是 MCP/plugin 工具的逃生阀——当 MCP 工具的只读标志来自服务器自报的 `readOnlyHint`(不可信)时,
计划模式不信任它、默认 fail-closed,需在此显式声明才能使用(first-party `ReadOnlyToolNames` 覆盖与 builtin 仍可信)。
它不再解锁 `bash`、`task`、写文件工具、安装器、记忆变更工具等计划模式已知阻断项,也不会绕过 bash 在计划模式下的安全检查。

## Serve Web 前端

`reasonix serve` 会用同一个本地 Reasonix 引擎启动浏览器 UI。适合不安装桌面端但想用可视化界面、
Expand Down Expand Up @@ -387,6 +394,13 @@ Planner 会看到已加载的 `REASONIX.md` / `AGENTS.md` 记忆,并拿到一
Subagent skills 默认继承执行器模型。设置 `subagent_model` 可让它们统一走另一个已配置
模型;设置 `subagent_models` 则只覆盖 `review`、`security_review` 等指定 skill。

当计划阶段需要隔离上下文做更深的调研时,用 `read_only_task`,而不是放开可写的
`task`。如果这类调研更适合复用已有 skill,用 `read_only_skill`。两者都会启动
ephemeral 只读 subagent,只暴露只读研究工具和安全前台 bash,只返回最终答案,不创建
可续接的 subagent transcript。在 token economy 模式下,只用
`connect_tool_source(source="read_only_skill")` 连接这条窄入口;完整的 `skills`
source 仍会启用可写 skill 工具,plan mode 下继续阻断。

交互式前端中,计划模式默认手动开启。设置 `agent.auto_plan = "on"` 后,看起来复杂
的任务会自动进入 plan mode:Reasonix 先只读生成计划,待用户批准后才
编辑文件或执行有副作用的命令。`auto_plan_classifier` 可以指定便宜的 provider,例如
Expand Down
13 changes: 13 additions & 0 deletions docs/MIGRATING.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,19 @@ and DeepSeek prefix-cache–oriented design.
search + tree-sitter symbol index is not bundled in v2 yet, and CodeGraph is no
longer shipped as an internal MCP server.
- **Plan mode** + `complete_step` (evidence-backed step sign-off).
- **Plan-mode tool overrides are narrower, and plan mode is fail-closed for
external tools**: `[agent].plan_mode_allowed_tools` now only declares extra
read-only custom/external tools. It no longer unlocks known blocked plan-mode
tools such as `bash`, `task`, writers, installers, or memory mutation tools, and
unsafe bash commands still remain blocked. An MCP/plugin tool whose read-only
status comes from the server's untrusted `readOnlyHint` is not trusted by plan
mode; declare it here to use it while planning — otherwise plan mode fails closed
on it. First-party `ReadOnlyToolNames` overrides and built-ins stay trusted.
- **Read-only subagent research**: use `read_only_task` for generic isolated
research in plan mode, or `read_only_skill` when the work should follow an
existing skill. Both expose only read-only tools and safe foreground bash, do
not write resumable transcripts, and keep writer-capable `task` / `run_skill`
blocked until after plan approval.
- **No web dashboard** — the v2 line is terminal + desktop (Wails), by design.
- Some granular v1 tools are intentionally consolidated (e.g. file-management ops
go through `bash`); a few v1 tools are not yet ported (tracked on Discussions).
Expand Down
19 changes: 17 additions & 2 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,8 +284,21 @@ func (p Policy) Decide(toolName string, readOnly bool, args json.RawMessage) Dec
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.
checked before the permission layer. Its boundary is fail-closed for untrusted
tools: while planning, a tool runs only if it reports a *trustworthy*
`ReadOnly()==true` — a built-in or a first-party MCP `ReadOnlyToolNames`
override — or self-reports plan-safe via `tool.PlanModeClassifier`. An MCP
tool's `ReadOnly()` may instead come from the server's self-reported
`readOnlyHint`, which plan mode does not trust (`tool.PlanModeUntrustedReadOnly`):
such a tool is gated like a writer. Writers, installers, memory mutation, process
control, and `complete_step` (read-only yet post-approval only, so it
self-reports plan-unsafe) are refused; the enforced invariant is
PlanSafe ⇒ ReadOnly. An untrusted read-only MCP/plugin tool is therefore blocked
until declared in `[agent].plan_mode_allowed_tools`, and is likewise excluded
from read-only research sub-agents. Plan mode still allows `read_only_task` and
`read_only_skill`, whose sub-agents receive only read-only research tools and
safe foreground bash; writer-capable `task` delegation and full skill execution
remain blocked.
- **User decisions are separate from tool approvals.** Runtime tool approval has
three user-facing postures: `ask` ("需要批准"), `auto` ("自动批准"), and
`yolo` ("Yolo批准"). `auto` lets the permission policy auto-approve the writer
Expand Down Expand Up @@ -470,6 +483,8 @@ max_steps = 0 # user/global only; executor tool-call rounds; 0 = no l
planner_max_steps = 0 # user/global only; planner read-only tool-call rounds; 0 = no limit
temperature = 0.0
reasoning_language = "auto" # visible reasoning text: auto|zh|en
# plan_mode_allowed_tools = ["custom_reader"] # extra read-only declarations for custom tools;
# # cannot unlock known blocked tools or unsafe bash
# planner_model = "deepseek-pro" # optional: two-model collaboration (low-frequency planner)
# subagent_model = "deepseek-pro" # optional default for runAs=subagent skills
# subagent_models = { review = "deepseek-pro", security_review = "deepseek-pro" }
Expand Down
208 changes: 35 additions & 173 deletions internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import (
"sync"
"sync/atomic"
"time"
"unicode"
"unicode/utf8"

"reasonix/internal/diff"
Expand All @@ -19,6 +18,7 @@ import (
"reasonix/internal/jobs"
"reasonix/internal/memory"
"reasonix/internal/nilutil"
"reasonix/internal/planmode"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
Expand All @@ -29,56 +29,6 @@ import (
// window before the next compaction runs.
const maxToolOutputBytes = 32 * 1024

// planModeDeniedTools lists tools that are unconditionally denied in plan mode.
// These are never shown to the LLM and cannot be called even if the agent
// somehow references them. The write_file, edit_file, and multi_edit tools are
// the canonical file-writing tools; apply_patch is a structured write variant.
var planModeDeniedTools = map[string]bool{
"write_file": true,
"edit_file": true,
"multi_edit": true,
"apply_patch": true,
}

// planModeBashMetachars defines shell metacharacters that indicate command
// chaining, redirection, or substitution. When any of these appear in a bash
// command during plan mode, the command is blocked — even if the command prefix
// matches a safe read-only entry — because chaining can introduce side effects
// after an otherwise safe prefix.
var planModeBashMetachars = []string{"&&", "||", ">>", "<<", "$(", "\x60", ";", "|", ">", "<", "&", "\n", "\r"}

// planModeSafeBashCommands are bash command prefixes that are safe to run in
// plan mode. Each entry is matched as a prefix against the trimmed, lowercased
// command string. The match requires a shell-argument boundary after the prefix:
// whitespace or end-of-string — so "echop" never matches "echo".
var planModeSafeBashCommands = []string{
"git status", "git diff", "git log", "git show",
"git ls-files", "git grep", "git blame",
"ls", "cat", "grep", "find", "head", "tail", "pwd",
"echo", "wc", "which", "type", "uname", "hostname",
"go version", "go list", "go doc", "go vet",
"node -v", "npm list", "python --version",
}

var planModeFindWriteArgs = map[string]bool{
"-delete": true,
"-exec": true,
"-execdir": true,
"-ok": true,
"-okdir": true,
"-fprint": true,
"-fprintf": true,
"-fls": true,
}

var planModeGoWriteOrExecArgs = map[string]bool{
"-fix": true,
"-mod": true,
"-modfile": true,
"-toolexec": true,
"-vettool": true,
}

const maxFinalReadinessBlocks = 3
const maxEmptyFinalBlocks = 3
const maxStreamRecoveries = 3
Expand Down Expand Up @@ -310,10 +260,10 @@ type Agent struct {
// session without touching the cache-stable prefix. Set via SetMemoryQueue.
memQueue memory.Queue

// planModeAllowedTools is the set of tool names that are exempt from the
// plan-mode gate. When non-empty, these tools bypass the read-only check.
// planModeAllowedTools declares extra custom tools that the centralized
// plan-mode policy may treat as read-only. Known blocked tools still lose.
// Populated from Options.PlanModeAllowedTools during construction.
planModeAllowedTools map[string]bool
planModeAllowedTools []string

// Context management: when a turn's prompt nears contextWindow, the older
// middle of the session is summarized away, keeping a token-bounded recent
Expand Down Expand Up @@ -578,25 +528,11 @@ type Options struct {
// user-turn context. Empty/auto injects nothing.
ReasoningLanguage string

// PlanModeAllowedTools names tools that bypass the plan-mode read-only gate.
// When a tool named here is called while planMode is true, it executes
// without the "plan mode is read-only" block — even if its ReadOnly contract
// returns false. Use sparingly; the caller is responsible for ensuring the
// tool invocation is safe in a read-only context (e.g. bash for git status).
// PlanModeAllowedTools names extra custom tools the plan-mode policy may treat
// as read-only. It cannot unlock known blocked tools or unsafe bash commands.
PlanModeAllowedTools []string
}

func stringSet(ss []string) map[string]bool {
if len(ss) == 0 {
return nil
}
m := make(map[string]bool, len(ss))
for _, s := range ss {
m[s] = true
}
return m
}

// New constructs an Agent. MaxSteps <= 0 means no cap — the run loop continues
// until the model gives a final answer, the context is cancelled, or the
// provider errors (compaction keeps the context bounded). A nil sink is replaced
Expand Down Expand Up @@ -651,7 +587,7 @@ func New(prov provider.Provider, tools *tool.Registry, session *Session, opts Op
recentKeep: opts.RecentKeep,
archiveDir: opts.ArchiveDir,
keepPolicy: opts.KeepPolicy,
planModeAllowedTools: stringSet(opts.PlanModeAllowedTools),
planModeAllowedTools: append([]string(nil), opts.PlanModeAllowedTools...),
}
a.SetReasoningLanguage(opts.ReasoningLanguage)
return a
Expand Down Expand Up @@ -1725,7 +1661,23 @@ func (a *Agent) executeOne(ctx context.Context, call provider.ToolCall) toolOutc
}
}
if a.planMode.Load() {
if blocked, msg := a.planModeBlocked(call.Name, t.ReadOnly(), json.RawMessage(call.Arguments)); blocked {
// Translate the tool's optional plan-mode self-report into the policy's
// tri-state. Mirrors the t.(tool.Previewer) assertion precedent below.
safety := planmode.PlanSafetyUnknown
if c, ok := t.(tool.PlanModeClassifier); ok {
if c.PlanModeSafe() {
safety = planmode.PlanSafetySafe
} else {
safety = planmode.PlanSafetyUnsafe
}
}
// External tools (MCP) whose ReadOnly() is only a server-reported
// readOnlyHint are not trusted by plan mode's read-only fast path.
untrusted := false
if u, ok := t.(tool.PlanModeUntrustedReadOnly); ok {
untrusted = u.PlanModeUntrustedReadOnly()
}
if blocked, msg := a.planModeBlocked(call.Name, t.ReadOnly(), untrusted, safety, json.RawMessage(call.Arguments)); blocked {
return toolOutcome{
output: msg,
blocked: true,
Expand Down Expand Up @@ -1842,110 +1794,20 @@ func (a *Agent) executeOne(ctx context.Context, call provider.ToolCall) toolOutc
return toolOutcome{output: body, truncated: truncMsg != "", truncMsg: truncMsg}
}

func (a *Agent) planModeBlocked(toolName string, readOnly bool, args json.RawMessage) (blocked bool, message string) {
if readOnly {
return false, ""
}
if planModeDeniedTools[toolName] {
return true, fmt.Sprintf("blocked: %q is not available in plan mode. Keep exploring with read-only tools — the user will be asked to approve the plan before any changes are made.", toolName)
}
if a.planModeAllowedTools != nil && a.planModeAllowedTools[toolName] {
return false, ""
}
if toolName == "bash" {
if blocked, msg := planModeBashBlocked(args); blocked {
return true, msg
}
return false, ""
}
return true, fmt.Sprintf("blocked: %q is a writer tool and plan mode is read-only. Keep exploring with read-only tools, then write your plan as your reply — the user will be asked to approve it before any changes are made.", toolName)
func (a *Agent) planModeBlocked(toolName string, readOnly, untrusted bool, safety planmode.PlanSafety, args json.RawMessage) (blocked bool, message string) {
decision := planmode.Policy{AllowedTools: a.planModeAllowedTools}.Decide(planmode.Call{
Name: toolName,
ReadOnly: readOnly,
Untrusted: untrusted,
Safety: safety,
Args: args,
})
return decision.Blocked, decision.Message
}

func planModeBashBlocked(args json.RawMessage) (bool, string) {
var p struct {
Command string `json:"command"`
}
if err := json.Unmarshal(args, &p); err != nil || p.Command == "" {
return false, ""
}
cmd := strings.TrimSpace(p.Command)
lower := strings.ToLower(cmd)

// Reject commands containing shell metacharacters — chaining, piping,
// redirection, or command substitution can introduce side effects after
// an otherwise safe prefix.
for _, mc := range planModeBashMetachars {
if strings.Contains(lower, mc) {
return true, fmt.Sprintf("blocked: bash command in plan mode must not contain shell operators (%q). Use separate calls for chained commands.", mc)
}
}

// Check the command prefix against the safe read-only whitelist. Require a
// shell-argument boundary after the match to avoid prefix collisions.
for _, safe := range planModeSafeBashCommands {
if !planModeBashMatchesSafePrefix(lower, safe) {
continue
}
if arg := planModeUnsafeSafeCommandArg(cmd, safe); arg != "" {
return true, fmt.Sprintf("blocked: bash command in plan mode uses a write-capable argument (%q). Use a read-only command while planning.", arg)
}
return false, ""
}

return true, fmt.Sprintf("blocked: bash commands in plan mode must be read-only. %q is not in the safe command list. Use read-only tools for exploration, then exit plan mode to run this command.", cmd)
}

func planModeBashMatchesSafePrefix(lower, safe string) bool {
if !strings.HasPrefix(lower, safe) {
return false
}
if len(lower) == len(safe) {
return true
}
r, _ := utf8.DecodeRuneInString(lower[len(safe):])
return unicode.IsSpace(r)
}

func planModeUnsafeSafeCommandArg(cmd, safe string) string {
fields := strings.Fields(cmd)
base := strings.Fields(safe)
if len(fields) <= len(base) {
return ""
}
args := fields[len(base):]
lowerArgs := make([]string, len(args))
for i, arg := range args {
lowerArgs[i] = strings.ToLower(arg)
}
if strings.HasPrefix(safe, "git ") {
for _, arg := range lowerArgs {
if arg == "--output" || strings.HasPrefix(arg, "--output=") || arg == "--ext-diff" {
return arg
}
}
}
switch safe {
case "git grep":
for i, arg := range args {
lowerArg := lowerArgs[i]
if arg == "-O" || strings.HasPrefix(arg, "-O") || strings.HasPrefix(lowerArg, "--open-files-in-pager") {
return arg
}
}
case "find":
for _, arg := range lowerArgs {
if planModeFindWriteArgs[arg] {
return arg
}
}
case "go list", "go vet":
for _, arg := range lowerArgs {
if planModeGoWriteOrExecArgs[arg] || strings.HasPrefix(arg, "-mod=mod") || strings.HasPrefix(arg, "-modfile=") || strings.HasPrefix(arg, "-toolexec=") || strings.HasPrefix(arg, "-vettool=") {
return arg
}
}
}
return ""
decision := planmode.Policy{}.Decide(planmode.Call{Name: "bash", Args: args})
return decision.Blocked, decision.Message
}

func (a *Agent) repeatedSuccessBlock(call provider.ToolCall, t tool.Tool) (string, bool) {
Expand Down
Loading
Loading