Merge pull request #13690 from esengine/a5/settings-tool

feat(settings): the model can propose a setting change and the user confirms it
This commit is contained in:
YHH authored and GitHub committed 2026-10-17 20:03:48 -07:00
commit 4840df9825
39 files changed
+1452 -22

No files matched your search

+14
View File
@@ -30,6 +30,7 @@
- [Embedded documentation retrieval](#embedded-documentation-retrieval)
- [@ references](#-references)
- [Two-model collaboration](#two-model-collaboration)
- [Letting the model change a setting](#letting-the-model-change-a-setting)
## Configuration
@@ -2112,3 +2113,16 @@ Not built, and why:
- Gemini: its video input is in the native Gemini API (inline data, the Files API, YouTube URLs); the OpenAI-compatible page does not list a video content part, and Reasonix reaches Gemini through that endpoint.
- Zhipu GLM: the documentation shows `video_url` with a public HTTPS address only and says nothing about base64 or limits, so a local file cannot be sent without Reasonix uploading it somewhere.
## Letting the model change a setting
The model has a `setting` tool. When something it needs is switched off, such as handing a task to another project, it can ask you to turn the switch on instead of sending you to Settings.
A refusal that a setting would lift ends with `(fix: setting ...)`. The tool can also list settings by the words of their id.
- Nothing changes until you answer a card. It names the setting, the old and the new value, and when the change takes effect. It is worded by Reasonix; the model's own reason is only quoted under it.
- Every setting has a class in the code. A preference (a font, a retry count) can be agreed once for the rest of the session. An elevated setting (messaging between sessions, dispatching to another project, a protection the model could otherwise loosen) is asked every time, in every approval mode including YOLO.
- Boundary settings (approval mode, sandbox, network, rules, hooks, provider keys and addresses, safety limits, and the switch for this tool) are never changed by the model. It can only point you at them (`op=open`).
- The card shows the whole new value; a value too long to show is refused instead of cut. The change is written to your user file, a project file cannot grant it, and values are checked with the same rules as the Settings screen.
- At most three proposals are put to you per turn, and none while plan mode is on (the model can still look at settings and point you at one).
- To remove the tool, set `[tools] setting_tool = false`, or leave `setting` out of an explicit `[tools] enabled` list.
+12
View File
@@ -30,6 +30,7 @@
- [内置文档检索](#内置文档检索)
- [@ 引用](#-引用)
- [双模型协同](#双模型协同)
- [让模型修改设置](#让模型修改设置)
## 配置
@@ -1667,3 +1668,14 @@ video_protocol = "inline_data_url" # 或 "moonshot_file"
- Gemini:视频输入在原生 Gemini 接口里(内联数据、Files API、YouTube 链接);OpenAI 兼容页没有列视频内容块,而 Reasonix 是通过那个端点接 Gemini 的。
- 智谱 GLM:文档只给了公网 HTTPS 地址的 `video_url` 示例,没写 base64 与限制,所以不能在不替用户把文件传到别处的前提下发送本地文件。
## 让模型修改设置
模型有一个 `setting` 工具。需要的功能被关着时(比如把任务交给另一个项目),它可以请你把开关打开,而不是让你自己去设置里找。能被某项设置解除的拒绝会以 `(fix: setting ...)` 结尾,工具也可以按设置 id 里的词列出设置。
- 在你回答卡片之前什么都不会改。卡片写明设置名、旧值、新值和生效时间,文字由 Reasonix 生成,模型自己的理由只作为引用附在下面。
- 每项设置在代码里都有类别。偏好(字号、重试次数等)可以同意一次后本会话内不再问;提高权限的设置(会话间消息、向其他项目派发、模型本可借此放松的保护)每次都问,包括 YOLO 在内的任何审批模式都不会代答。
- 边界类设置(审批模式、沙盒、网络、规则、hooks、模型服务的密钥与地址、安全上限,以及这个工具自己的开关)模型永远不会改,只能把你带到那里(`op=open`)。
- 卡片显示完整的新值;长到显示不下的值会被拒绝,而不是截断。改动写入你的用户级文件,项目文件不能授予,取值按设置页同一套规则校验。
- 每回合最多向你提出三次;计划模式下不提出(模型仍可查看设置、把你带到某一项)。
- 想去掉这个工具:在用户文件里设 `[tools] setting_tool = false`,或在显式的 `[tools] enabled` 列表里不写 `setting`。
+2
View File
@@ -127,6 +127,8 @@ coding tools, background-shell lifecycle tools, and the stable capability proxy:
The host-control tools ride the same surface at every setting: `todo_write` and `complete_step` for the task list, and `ask` and `await_user` for the turns that end on the model's terms.
`setting` finds the user's settings and asks the user to confirm a change. A refusal that a setting would lift ends with `(fix: setting ...)`.
`conclude_blocked` is registered but not yet on this surface, so it is reachable only through `use_capability` even where the host names it as a way out of an unfinished turn.
Optional tools (`glob`, `grep`, `ls`, `web_fetch`, MCP, skills, subagents, docs, session history, memory mutation, workflow, and so on) remain in the host registry for dispatch. The model lists, inspects, calls, or declines them via `use_capability` without changing the provider tool list.
+1
View File
@@ -73,6 +73,7 @@ func HostControlToolNames() []string {
"update_goal",
"todo_write",
"complete_step",
"setting",
}
}
+3
View File
@@ -82,6 +82,7 @@ type toolStage struct {
lsp *lsp.Manager
browser *browser.Session
peerLink *tool.PeerLink
settingLink *tool.SettingsLink
peerTools func(enabled, dispatch bool)
maxSteps int
policy permission.Policy
@@ -339,6 +340,7 @@ func (b *builder) wireTools() error {
addViewImage(t.reg, cfg, b.model.entry, env, root)
bindFileViews(t.reg, cfg.Tools.ChangedFilesProtected())
t.peerLink, t.peerTools = peerTools(t.reg, cfg)
t.settingLink = addSettingTool(t.reg, cfg)
addSystemOne(t.reg, cfg.Tools.Enabled, cfg, b.keyedClient)
addAdvisor(t.reg, cfg, b.proxy, b.sink)
addWebSearch(t.reg, cfg, b.proxy, b.sink)
@@ -565,6 +567,7 @@ func (b *builder) controllerOptions(runner agent.Runner, executor *agent.Agent,
SystemPrompt: b.prompt.prompt,
SessionDir: b.session.dir,
Peers: peersOptions(cfg, t.peerLink, t.peerTools),
Settings: t.settingLink,
Host: t.host,
Commands: t.cmds,
Skills: b.prompt.skills,
@@ -50,13 +50,13 @@ recent_keep = 2
name = "model-a"
kind = "boot-compaction-switch"
model = "a"
context_window = 32000
context_window = 32500
[[providers]]
name = "model-b"
kind = "boot-compaction-switch"
model = "b"
context_window = 32000
context_window = 32500
`)
approveWorkspaceAt(t, home, dir)
@@ -81,7 +81,7 @@ compact_ratio = 0.5
name = "test-model"
kind = "`+kind+`"
model = "x"
context_window = 32000
context_window = 32500
effort = "high"
`)
approveWorkspace(t, dir)
@@ -0,0 +1,183 @@
package boot
import (
"context"
"encoding/json"
"fmt"
"reflect"
"slices"
"strings"
"sync"
"testing"
"time"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/config"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/session/control"
)
// settingProvider asks for one setting change on its first request and then
// ends the turn, recording what each request carried.
type settingProvider struct {
mu sync.Mutex
reqs []provider.Request
id string
val string
}
func (p *settingProvider) Name() string { return "boot-setting-tool" }
func (p *settingProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
p.mu.Lock()
p.reqs = append(p.reqs, req)
first := len(p.reqs) == 1
p.mu.Unlock()
ch := make(chan provider.Chunk, 3)
if first {
args, _ := json.Marshal(map[string]any{"op": "propose", "id": p.id, "value": json.RawMessage(p.val), "reason": "the task needs it"})
ch <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-set", Name: "setting", Arguments: string(args)}}
} else {
ch <- provider.Chunk{Type: provider.ChunkText, Text: "done"}
}
ch <- provider.Chunk{Type: provider.ChunkDone}
close(ch)
return ch, nil
}
func (p *settingProvider) toolResult() string {
p.mu.Lock()
defer p.mu.Unlock()
if len(p.reqs) < 2 {
return ""
}
for _, m := range p.reqs[1].Messages {
if m.Role == provider.RoleTool && m.Name == "setting" {
return m.Content
}
}
return ""
}
func (p *settingProvider) toolNames(i int) []string {
p.mu.Lock()
defer p.mu.Unlock()
var names []string
for _, t := range p.reqs[i].Tools {
names = append(names, t.Name)
}
return names
}
func settingSession(t *testing.T, id, val string) (*settingProvider, *control.Controller, string, chan event.Approval) {
t.Helper()
home := statedBootHome(t)
dir := testenv.TempDir(t)
rec := &settingProvider{id: id, val: val}
kind := "boot-setting-tool-" + strings.ReplaceAll(t.Name(), "/", "-")
provider.Register(kind, func(provider.Config) (provider.Provider, error) { return rec, nil })
writeFile(t, dir, "reasonix.toml", fmt.Sprintf("default_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[[providers]]\nname = \"test-model\"\nkind = %q\nmodel = \"x\"\n", kind))
approveWorkspaceAt(t, home, dir)
approvals := make(chan event.Approval, 4)
sink := event.FuncSink(func(e event.Event) {
if e.Kind == event.ApprovalRequest {
approvals <- e.Approval
}
})
ctrl, err := Build(context.Background(), Options{Home: home, WorkspaceRoot: dir, Sink: sink})
if err != nil {
t.Fatalf("Build: %v", err)
}
t.Cleanup(ctrl.Close)
if err := ctrl.NewSession(); err != nil {
t.Fatal(err)
}
ctrl.SetToolApprovalMode(control.ToolApprovalYolo)
ctrl.EnableInteractiveApproval()
path := config.UserConfigPath()
cfg := config.LoadForEdit(path)
cfg.Peers.Enabled, cfg.Permissions.Mode = false, ""
if err := cfg.SaveTo(path); err != nil {
t.Fatal(err)
}
return rec, ctrl, path, approvals
}
func runSetting(ctrl *control.Controller) chan error {
done := make(chan error, 1)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
done <- ctrl.Run(ctx, "get the other project to do it")
}()
return done
}
// A model that asks to loosen a switch gets a card even in YOLO, nothing is
// written until the click, and the next request carries the same tool block.
// The process's user config is shared, so these run one after another.
func TestEffectTheModelProposesASettingAndTheUserClickDecidesIt(t *testing.T) {
for _, allow := range []bool{true, false} {
t.Run(fmt.Sprintf("allow=%v", allow), func(t *testing.T) {
rec, ctrl, userFile, approvals := settingSession(t, "peers.enabled", "true")
done := runSetting(ctrl)
var card event.Approval
select {
case card = <-approvals:
case <-time.After(20 * time.Second):
t.Fatal("no card for a proposed setting")
}
if card.Tool != control.SettingElevatedApprovalTool || !card.Fresh || card.AllowsSession {
t.Fatalf("card = %+v", card)
}
if config.LoadForEdit(userFile).Peers.Enabled {
t.Fatal("the file changed before the click")
}
ctrl.Approve(card.ID, allow, false, false)
if err := <-done; err != nil {
t.Fatalf("Run: %v", err)
}
if got := config.LoadForEdit(userFile).Peers.Enabled; got != allow {
t.Fatalf("peers.enabled = %v after allow=%v", got, allow)
}
result := rec.toolResult()
want := `"via":"click"`
if !allow {
want = "setting.declined"
}
if !strings.Contains(result, want) {
t.Fatalf("tool result = %q, want %q", result, want)
}
if !reflect.DeepEqual(rec.toolNames(0), rec.toolNames(1)) {
t.Fatalf("the tool block moved between requests:\n%v\n%v", rec.toolNames(0), rec.toolNames(1))
}
if !slices.Contains(rec.toolNames(0), "setting") {
t.Fatalf("setting is not offered: %v", rec.toolNames(0))
}
})
}
}
// A boundary setting is never proposed: no card, nothing written, and the
// refusal tells the model the one thing it may do instead.
func TestEffectTheModelCannotProposeAGuardedSetting(t *testing.T) {
rec, ctrl, userFile, approvals := settingSession(t, "permissions.mode", `"yolo"`)
if err := <-runSetting(ctrl); err != nil {
t.Fatalf("Run: %v", err)
}
select {
case a := <-approvals:
t.Fatalf("a card for a boundary setting: %+v", a)
default:
}
result := rec.toolResult()
for _, want := range []string{"setting.guarded", "(fix: setting op=open id=permissions.mode)"} {
if !strings.Contains(result, want) {
t.Fatalf("tool result = %q, want %q", result, want)
}
}
if config.LoadForEdit(userFile).Permissions.Mode == "yolo" {
t.Fatal("a guarded setting was written")
}
}
+21
View File
@@ -0,0 +1,21 @@
package boot
import (
"slices"
"reasonix/internal/contract/config"
"reasonix/internal/contract/tool"
"reasonix/internal/tools/builtin"
)
// addSettingTool offers the model the setting tool unless the user turned it off
// or left it out of an explicit tool list. The link reaches the controller once
// it exists.
func addSettingTool(reg *tool.Registry, cfg *config.Config) *tool.SettingsLink {
link := &tool.SettingsLink{}
if !cfg.Tools.SettingToolEnabled() || (len(cfg.Tools.Enabled) > 0 && !slices.Contains(cfg.Tools.Enabled, "setting")) {
return link
}
reg.Add(builtin.SettingTool(link))
return link
}
@@ -52,7 +52,7 @@ recent_keep = 2
name = "test-model"
kind = "boot-superseded-effect"
model = "x"
context_window = 32000
context_window = 32500
`)
approveWorkspaceAt(t, home, dir)
+3 -3
View File
@@ -1,7 +1,7 @@
{
"SystemHash": "bc08dbc97c837bba",
"ToolsHash": "7af98c4b6e697acf",
"PrefixHash": "6b8bc3fdc3efc02e",
"ToolsHash": "c45c085eee19072e",
"PrefixHash": "6826388583ce18f3",
"LogRewriteVersion": 0,
"ToolSchemaTokens": 5556
"ToolSchemaTokens": 5820
}
@@ -559,6 +559,42 @@
"type": "object"
}
},
{
"name": "setting",
"description": "Find and change the user's own settings. op=list finds settings by words of their id (for example \"peers dispatch\"). op=propose asks the user to confirm one change: give the id, the new value and a short reason; nothing changes until the user answers, and the result says what changed or that they declined. op=open points the user at a setting that only they may change. Use it when a refusal ends with (fix: setting ...), or when a tool you need is not offered because a switch is off. Do not change a setting the task did not call for.",
"parameters": {
"additionalProperties": false,
"properties": {
"id": {
"description": "The setting's id, for propose and open",
"type": "string"
},
"op": {
"enum": [
"list",
"propose",
"open"
],
"type": "string"
},
"query": {
"description": "Words of the setting's id, for op=list",
"type": "string"
},
"reason": {
"description": "Why the change helps the user, shown on the card, for op=propose",
"type": "string"
},
"value": {
"description": "The new value as JSON, for op=propose"
}
},
"required": [
"op"
],
"type": "object"
}
},
{
"name": "todo_write",
"description": "Record and update a structured task list for the current work. Send the COMPLETE list every call — it replaces the previous one. Use it to plan multi-step work and show progress: keep exactly one item in_progress at a time, and flip an item to completed the moment it's done (don't batch completions). Skip it for trivial single-step tasks. The list is two-level: a `level` 0 item is a PHASE (a milestone) and the `level` 1 items after it are its concrete sub-steps. COPY `step_id` VERBATIM for every item that already has one: it is how a completion stays attached to its step when you retitle it, insert a step above it, or reorder the list. Give a new item a fresh unique id (e.g. \"plan_step_07\"); never reuse or renumber an existing one.",
@@ -543,6 +543,43 @@
"type": "object"
}
},
{
"Name": "setting",
"Description": "Find and change the user's own settings. op=list finds settings by words of their id (for example \"peers dispatch\"). op=propose asks the user to confirm one change: give the id, the new value and a short reason; nothing changes until the user answers, and the result says what changed or that they declined. op=open points the user at a setting that only they may change. Use it when a refusal ends with (fix: setting ...), or when a tool you need is not offered because a switch is off. Do not change a setting the task did not call for.",
"ReadOnly": true,
"Schema": {
"additionalProperties": false,
"properties": {
"id": {
"description": "The setting's id, for propose and open",
"type": "string"
},
"op": {
"enum": [
"list",
"propose",
"open"
],
"type": "string"
},
"query": {
"description": "Words of the setting's id, for op=list",
"type": "string"
},
"reason": {
"description": "Why the change helps the user, shown on the card, for op=propose",
"type": "string"
},
"value": {
"description": "The new value as JSON, for op=propose"
}
},
"required": [
"op"
],
"type": "object"
}
},
{
"Name": "todo_write",
"Description": "Record and update a structured task list for the current work. Send the COMPLETE list every call — it replaces the previous one. Use it to plan multi-step work and show progress: keep exactly one item in_progress at a time, and flip an item to completed the moment it's done (don't batch completions). Skip it for trivial single-step tasks. The list is two-level: a `level` 0 item is a PHASE (a milestone) and the `level` 1 items after it are its concrete sub-steps. COPY `step_id` VERBATIM for every item that already has one: it is how a completion stays attached to its step when you retitle it, insert a step above it, or reorder the list. Give a new item a fresh unique id (e.g. \"plan_step_07\"); never reuse or renumber an existing one.",
@@ -26,6 +26,7 @@ func stableBootToolNames() []string {
"edit_file",
"read_file",
"recall",
"setting",
"todo_write",
"use_capability",
"write_file",
+1
View File
@@ -172,6 +172,7 @@ type Messages struct {
SlashHintMessages
QueueMessages
GoalMessages
SettingMessages
ApprovalNeededFmt string // notification text for a pending approval, tool only
ApprovalNeededWithSubjectFmt string // notification text for a pending approval with subject
AnswerNeededFmt string // notification text for a pending ask question
+1
View File
@@ -199,6 +199,7 @@ var English = Messages{
SlashHintMessages: slashHintEN,
QueueMessages: queueEN,
GoalMessages: goalEN,
SettingMessages: settingEN,
ApprovalNeededFmt: "approval needed: %s",
ApprovalNeededWithSubjectFmt: "approval needed: %s %s",
AnswerNeededFmt: "answer needed: %s",
+1
View File
@@ -199,6 +199,7 @@ var Chinese = Messages{
SlashHintMessages: slashHintZH,
QueueMessages: queueZH,
GoalMessages: goalZH,
SettingMessages: settingZH,
ApprovalNeededFmt: "需要审批:%s",
ApprovalNeededWithSubjectFmt: "需要审批:%s %s",
AnswerNeededFmt: "需要回答:%s",
+1
View File
@@ -198,6 +198,7 @@ var ChineseTraditional = Messages{
SlashHintMessages: slashHintZHTW,
QueueMessages: queueZHTW,
GoalMessages: goalZHTW,
SettingMessages: settingZHTW,
ApprovalNeededFmt: "需要核准:%s",
ApprovalNeededWithSubjectFmt: "需要核准:%s %s",
AnswerNeededFmt: "需要回答:%s",
+34
View File
@@ -0,0 +1,34 @@
package i18n
var settingEN = SettingMessages{
SettingApprovalFmt: "The assistant asks to change the setting %[1]s from %[2]s to %[3]s. %[4]s",
SettingReasonFmt: "Its reason: %s",
SettingChangedFmt: "Setting %[1]s changed from %[2]s to %[3]s. %[4]s",
SettingOpenFmt: "The assistant needs %s changed, which only you can do. It is highlighted in Settings.",
SettingEffectImmediate: "Takes effect right away.",
SettingEffectNextTurn: "Takes effect from the next turn.",
SettingEffectOnOpen: "Takes effect in sessions opened afterwards.",
SettingEffectRestart: "Takes effect after a restart.",
}
var settingZH = SettingMessages{
SettingApprovalFmt: "助手请求把设置 %[1]s 从 %[2]s 改为 %[3]s。%[4]s",
SettingReasonFmt: "它给出的理由:%s",
SettingChangedFmt: "设置 %[1]s 已从 %[2]s 改为 %[3]s。%[4]s",
SettingOpenFmt: "助手需要改动 %s,只有你本人能改。已在设置中高亮。",
SettingEffectImmediate: "立即生效。",
SettingEffectNextTurn: "从下一回合起生效。",
SettingEffectOnOpen: "之后新打开的会话生效。",
SettingEffectRestart: "重启后生效。",
}
var settingZHTW = SettingMessages{
SettingApprovalFmt: "助手請求把設定 %[1]s 從 %[2]s 改為 %[3]s。%[4]s",
SettingReasonFmt: "它給出的理由:%s",
SettingChangedFmt: "設定 %[1]s 已從 %[2]s 改為 %[3]s。%[4]s",
SettingOpenFmt: "助手需要改動 %s,只有你本人能改。已在設定中標示。",
SettingEffectImmediate: "立即生效。",
SettingEffectNextTurn: "從下一回合起生效。",
SettingEffectOnOpen: "之後新開啟的工作階段生效。",
SettingEffectRestart: "重新啟動後生效。",
}
+15
View File
@@ -0,0 +1,15 @@
package i18n
// SettingMessages is how the host words a model's request to change a setting.
// The card is built from these and the setting's declaration, never from text
// the model wrote; Messages embeds it.
type SettingMessages struct {
SettingApprovalFmt string // %[1]s id, %[2]s old value, %[3]s new value, %[4]s when it applies
SettingReasonFmt string // %s the model's own reason, quoted after the card text
SettingChangedFmt string // %[1]s id, %[2]s old value, %[3]s new value, %[4]s when it applies
SettingOpenFmt string // %s id
SettingEffectImmediate string
SettingEffectNextTurn string
SettingEffectOnOpen string
SettingEffectRestart string
}
+3 -4
View File
@@ -1162,12 +1162,11 @@ type ToolsConfig struct {
SystemOne SystemOneConfig `toml:"system_one"`
ProtectChangedFiles *bool `toml:"protect_changed_files"`
BrowserTools *bool `toml:"browser_tools"`
// MCPResources offers the model a tool that reads a resource a connected
// server declares. The user file only; off by default.
SettingTool *bool `toml:"setting_tool"`
// MCPResources offers a tool that reads a connected server's resource; user file only, off by default.
MCPResources bool `toml:"mcp_resources"`
WebAccessSettings
// VideoNativeMaxBytes lowers the size of file read_video sends natively below
// what the endpoint documents; 0 keeps the documented limit. The user file only.
// VideoNativeMaxBytes lowers the size read_video sends natively below the endpoint's limit; 0 keeps it. User file only.
VideoNativeMaxBytes int64 `toml:"video_native_max_bytes"`
// MCPLoad maps a server name to "always" or "deferred", overriding what
// the server's own declaration says.
@@ -0,0 +1,7 @@
package config
// SettingToolEnabled reports whether the model is offered the setting tool.
// Unset means on: the tool only asks, and nothing changes without the user's answer.
func (t ToolsConfig) SettingToolEnabled() bool {
return t.SettingTool == nil || *t.SettingTool
}
+1
View File
@@ -35,6 +35,7 @@ const (
NoticeCodeSessionRecoveryDepthCap = "session_recovery_depth_cap"
NoticeCodeSessionShutdownRecoveryForked = "session_shutdown_recovery_forked"
NoticeCodeVerificationStalled = "verification_stalled"
NoticeCodeSettingChanged, NoticeCodeSettingOpen = "setting_changed", "setting_open"
NoticeCodeAskTimeoutInvalid = "ask_timeout_invalid"
NoticeCodeDecisionReceipt, NoticeCodeContextEditingFallback = "decision_receipt", "context_editing_fallback"
// A reported lease wait always arrives as a pair: one of the two below
+2 -1
View File
@@ -140,6 +140,8 @@ var table = []Entry{
{ID: "tools.background_jobs.stalled_warning_seconds", Class: Preference, Effect: OnOpen},
{ID: "tools.search.engine", Class: Preference, Effect: OnOpen},
{ID: "tools.search.rg_path", Class: Guarded, Effect: OnOpen},
{ID: "attachments.max_stream_bytes", Class: Guarded, Effect: OnOpen},
{ID: "tools.setting_tool", Class: Guarded, Effect: OnOpen},
{ID: "tools.shell.prefer", Class: Guarded, Effect: OnOpen},
{ID: "tools.shell.path", Class: Guarded, Effect: OnOpen},
{ID: "tools.shell.args", Class: Guarded, Effect: OnOpen},
@@ -211,7 +213,6 @@ var table = []Entry{
{ID: "secrets.protect_credential_files", Class: Guarded, Effect: OnOpen},
{ID: "remote.import_ssh_config", Class: Guarded, Effect: OnOpen},
{ID: "remote.hosts", Class: Guarded, Effect: OnOpen},
{ID: "attachments.max_stream_bytes", Class: Guarded, Effect: OnOpen},
{ID: "auto_submit", Class: Preference, Effect: OnOpen},
{ID: "peers.enabled", Class: Elevated, Effect: NextTurn},
{ID: "peers.dispatch", Class: Elevated, Effect: NextTurn},
+51
View File
@@ -0,0 +1,51 @@
package settings
import (
"encoding/json"
"reasonix/internal/contract/tool"
)
// RemedyFor is the fix a refusal points at for a setting and the value that
// would lift it. The operation follows the key's class, so a producer never
// decides it: a Guarded key can only be opened, never proposed.
func RemedyFor(id string, value any) *tool.Remedy {
e, ok := Lookup(id)
if !ok {
return nil
}
if e.Class == Guarded {
return &tool.Remedy{Op: tool.RemedyOpen, Setting: id}
}
raw, err := json.Marshal(value)
if err != nil {
return nil
}
return &tool.Remedy{Op: tool.RemedyPropose, Setting: id, Value: raw}
}
func (c Class) String() string {
switch c {
case Preference:
return "preference"
case Elevated:
return "elevated"
case Guarded:
return "guarded"
}
return ""
}
func (e Effect) String() string {
switch e {
case Immediate:
return "immediate"
case NextTurn:
return "next_turn"
case OnOpen:
return "on_open"
case Restart:
return "restart"
}
return ""
}
+3
View File
@@ -85,6 +85,9 @@ type Error struct {
func (e *Error) Error() string { return e.Code + ": " + e.ID + ": " + e.msg }
// Message is the sentence without the code, for a caller that carries the code itself.
func (e *Error) Message() string { return e.msg }
// CodeOf is the code of a settings refusal, or "" for any other error.
func CodeOf(err error) string {
var e *Error
@@ -219,3 +219,23 @@ func TestTheConfigsOwnSetterJudgesTheValue(t *testing.T) {
t.Fatalf("to=%v err=%v", to, err)
}
}
func TestARemedyFollowsTheKeysClass(t *testing.T) {
if r := RemedyFor("peers.dispatch", true); r == nil || r.Op != "propose" || string(r.Value) != "true" {
t.Fatalf("elevated remedy = %+v", r)
}
if r := RemedyFor("permissions.mode", "yolo"); r == nil || r.Op != "open" || len(r.Value) != 0 {
t.Fatalf("guarded remedy = %+v, want open without a value", r)
}
if RemedyFor("no.such.key", 1) != nil {
t.Fatal("a remedy for a key nobody declared")
}
}
func TestClassAndEffectHaveWireNames(t *testing.T) {
for _, e := range All() {
if e.Class.String() == "" || e.Effect.String() == "" {
t.Fatalf("%s has no wire name", e.ID)
}
}
}
+11 -5
View File
@@ -17,6 +17,8 @@ type RefusalIdentity interface {
type Refusal struct {
Code string
Message string
// Remedy, when set, is the setting whose change would lift this refusal.
Remedy *Remedy
}
// Error lets one value serve both refusal paths a contextual tool has: the
@@ -29,13 +31,17 @@ func (r Refusal) Error() string { return r.String() }
// because an identity the model never sees is one it cannot cite back.
func (r Refusal) String() string {
msg := strings.TrimSpace(r.Message)
if r.Code == "" {
return msg
switch {
case r.Code == "":
case msg == "":
msg = "(refusal: " + r.Code + ")"
default:
msg += " (refusal: " + r.Code + ")"
}
if msg == "" {
return "(refusal: " + r.Code + ")"
if r.Remedy != nil {
msg += " (fix: " + r.Remedy.String() + ")"
}
return msg + " (refusal: " + r.Code + ")"
return strings.TrimSpace(msg)
}
// Empty reports a refusal carrying neither identity nor words, which is what a
+27
View File
@@ -0,0 +1,27 @@
package tool
import (
"encoding/json"
"testing"
)
func TestARefusalRendersTheSettingThatWouldLiftIt(t *testing.T) {
r := Refusal{Code: "peer.disabled", Message: "Messages between sessions are off.", Remedy: &Remedy{Op: RemedyPropose, Setting: "peers.enabled", Value: json.RawMessage(`true`)}}
want := "Messages between sessions are off. (refusal: peer.disabled) (fix: setting op=propose id=peers.enabled value=true)"
if got := r.String(); got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestARemedyThatOnlyOpensCarriesNoValue(t *testing.T) {
r := Refusal{Code: "x.y", Remedy: &Remedy{Op: RemedyOpen, Setting: "permissions.mode", Value: json.RawMessage(`"yolo"`)}}
if got, want := r.String(), "(refusal: x.y) (fix: setting op=open id=permissions.mode)"; got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestARefusalWithoutARemedyReadsAsBefore(t *testing.T) {
if got := (Refusal{Code: "a.b", Message: "no"}).String(); got != "no (refusal: a.b)" {
t.Fatalf("got %q", got)
}
}
+80
View File
@@ -0,0 +1,80 @@
package tool
import (
"context"
"encoding/json"
"fmt"
"sync"
)
// Remedy names the setting whose change would lift a refusal. The producer that
// knows why the call cannot proceed sets it; the model reads it rendered by the
// host and never parses it out of a sentence.
type Remedy struct {
// Op is RemedyPropose where the model may ask for the change, RemedyOpen where
// only the user may make it.
Op string
Setting string
Value json.RawMessage
}
const (
RemedyPropose = "propose"
RemedyOpen = "open"
)
func (r Remedy) String() string {
if r.Op == RemedyPropose && len(r.Value) > 0 {
return fmt.Sprintf("setting op=%s id=%s value=%s", r.Op, r.Setting, r.Value)
}
return fmt.Sprintf("setting op=%s id=%s", r.Op, r.Setting)
}
// SettingInfo is one row of what the model may learn about a setting.
type SettingInfo struct {
ID string `json:"id"`
Class string `json:"class"`
Effect string `json:"effect"`
// Value is absent for a secret or a setting edited in its own screen.
Value any `json:"value,omitempty"`
}
// SettingReceipt is what a confirmed change did.
type SettingReceipt struct {
ID string `json:"id"`
From any `json:"from"`
To any `json:"to"`
Effect string `json:"effect"`
// Via is "click" for a fresh answer and "session_grant" for one the user
// gave earlier in the session for this setting.
Via string `json:"via"`
}
// SettingsHost is the controller side of the setting tool.
type SettingsHost interface {
SettingsList(query string) []SettingInfo
ProposeSetting(ctx context.Context, id string, value json.RawMessage, reason string) (SettingReceipt, error)
OpenSetting(id string) error
}
// SettingsLink joins the setting tool, built with the registry, to the
// controller that exists only afterwards.
type SettingsLink struct {
mu sync.RWMutex
host SettingsHost
}
func (l *SettingsLink) Bind(h SettingsHost) {
l.mu.Lock()
l.host = h
l.mu.Unlock()
}
func (l *SettingsLink) Host() (SettingsHost, bool) {
if l == nil {
return nil, false
}
l.mu.RLock()
defer l.mu.RUnlock()
return l.host, l.host != nil
}
+2 -2
View File
@@ -633,7 +633,7 @@ func ParseToolApprovalMode(mode string) (string, bool) {
// narrow operation in interactive or headless mode.
func RequiresFreshHumanApprovalTool(tool string) bool {
switch tool {
case planApprovalTool, memoryRememberTool, memoryForgetTool, SandboxEscapeApprovalTool, ManagedConfigWriteApprovalTool, NetworkEgressApprovalTool, PaidSearchApprovalTool, MCPSignInApprovalTool:
case planApprovalTool, memoryRememberTool, memoryForgetTool, SandboxEscapeApprovalTool, ManagedConfigWriteApprovalTool, NetworkEgressApprovalTool, PaidSearchApprovalTool, MCPSignInApprovalTool, SettingApprovalTool, SettingElevatedApprovalTool:
return true
default:
return false
@@ -671,7 +671,7 @@ func approvalGrantsForRequest(tool string, fresh, requireHuman bool) (session, p
func allowsFreshSessionGrantTool(tool string) bool {
switch tool {
case SandboxEscapeApprovalTool, ManagedConfigWriteApprovalTool, NetworkEgressApprovalTool, PaidSearchApprovalTool, MCPSignInApprovalTool:
case SandboxEscapeApprovalTool, ManagedConfigWriteApprovalTool, NetworkEgressApprovalTool, PaidSearchApprovalTool, MCPSignInApprovalTool, SettingApprovalTool:
return true
default:
return false
+6 -1
View File
@@ -196,6 +196,8 @@ type Controller struct {
inbox inboxState
// peers is this controller's side of messages between sessions; nil-safe off.
peers *peerMail
// settings counts the setting proposals of the current turn.
settings settingsState
}
type approvalReply struct {
@@ -315,7 +317,9 @@ type Options struct {
SessionDir string
SessionPath string
// Peers opens this session to messages from other sessions; zero is off.
Peers PeersOptions
Peers PeersOptions
// Settings, when set, is bound to this controller so the setting tool can reach it.
Settings *tool.SettingsLink
Host *plugin.Host
Commands []command.Command
Skills []skill.Skill
@@ -487,6 +491,7 @@ func New(opts Options) *Controller {
c.peers = newPeerMail(opts.Peers)
c.rebindInbox()
c.startPeers()
c.startSettings(opts.Settings)
// Observe Steer / unapplied-steer for durable inbox state transitions.
// Must wrap both the controller sink and the executor sink: agent.Steer
// emits on the executor path, TurnDone on the controller path.
+1
View File
@@ -14,6 +14,7 @@ import (
// the only thing a delegation opened mid-turn can be recorded against.
func (c *Controller) beginTurn(ctx context.Context, startMessageIndex int, preserveUser bool) (context.Context, sessionstore.InFlightTurnMeta) {
c.applyPeerSwitches()
c.settings.proposals.Store(0)
marker := c.markInFlightTurn(startMessageIndex, preserveUser)
return delegation.WithTurnIdentity(ctx, marker.ID), marker
}
+23 -2
View File
@@ -4,6 +4,8 @@ import (
"errors"
"fmt"
"reasonix/internal/contract/config"
"reasonix/internal/contract/settings"
"reasonix/internal/contract/tool"
)
@@ -28,8 +30,27 @@ func (d peerDirectory) SendPeerMessage(to, text string) (tool.PeerReceipt, error
return tool.PeerReceipt{ID: r.ID, To: tool.PeerView{Key: r.To.Key, Title: r.To.Title, Project: r.To.Project}}, nil
}
// fmtPeerError leads with the error's identity, as the send path does.
func fmtPeerError(err error) error { return fmt.Errorf("%s: %w", peerErrorCode(err), err) }
// fmtPeerError is the refusal a peer tool reports: the error's identity, and
// the setting whose change would lift it where one would.
func fmtPeerError(err error) error {
code := peerErrorCode(err)
return tool.Refusal{Code: code, Message: err.Error(), Remedy: peerRemedy(code)}
}
// peerRemedy points at the switch a refusal names: messages between sessions
// when they are off, else the dispatch switch.
func peerRemedy(code string) *tool.Remedy {
switch code {
case "peer.disabled":
return settings.RemedyFor("peers.enabled", true)
case "dispatch.disabled":
if !config.UserPeers().Enabled {
return settings.RemedyFor("peers.enabled", true)
}
return settings.RemedyFor("peers.dispatch", true)
}
return nil
}
func peerErrorCode(err error) string {
for code, sentinel := range map[string]error{
+276
View File
@@ -0,0 +1,276 @@
package control
import (
"context"
"encoding/json"
"errors"
"fmt"
"reflect"
"strings"
"sync"
"sync/atomic"
"unicode/utf8"
"reasonix/internal/base/i18n"
"reasonix/internal/contract/config"
"reasonix/internal/contract/event"
"reasonix/internal/contract/settings"
"reasonix/internal/contract/tool"
)
const (
// SettingApprovalTool is the card for a Preference setting; a session grant
// covers that one setting.
SettingApprovalTool = "setting_change"
// SettingElevatedApprovalTool is the card for an Elevated setting. It offers
// no session grant, so every change of it is a fresh click.
SettingElevatedApprovalTool = "setting_change_elevated"
settingProposalsPerTurn = 3
settingReasonRunes = 160
settingListRows = 60
// settingValueRunes bounds the new value so the card can show all of it: what
// the user reads is exactly what is written. settingRawBytes bounds what is
// parsed at all.
settingValueRunes = 400
settingRawBytes = 4 << 10
)
// Setting refusal codes the controller adds to the declaration's own.
const (
CodeSettingDeclined = "setting.declined"
CodeSettingNoAnswer = "setting.no_approver"
CodeSettingLimit = "setting.proposal_limit"
CodeSettingUnwritable = "setting.unwritable"
CodeSettingTooLong = "setting.value_too_long"
CodeSettingPlanMode = "setting.plan_mode"
)
// settingsState holds the per-turn count of proposals, so a model cannot turn
// the card into a stream the user answers to get on with their work, and the
// settings the user agreed to for the rest of the session. The grants are kept
// here and not in the approval manager's, whose rule for a tool it does not
// know covers the whole tool: one agreed setting would cover them all.
type settingsState struct {
proposals atomic.Int32
mu sync.Mutex
granted map[string]bool
}
func (s *settingsState) grant(id string) {
s.mu.Lock()
defer s.mu.Unlock()
if s.granted == nil {
s.granted = map[string]bool{}
}
s.granted[id] = true
}
func (s *settingsState) granting(id string) bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.granted[id]
}
func (c *Controller) startSettings(link *tool.SettingsLink) {
if link != nil {
link.Bind(c)
}
}
// SettingsList finds declared settings whose id holds every word of query, with
// the value the user file holds now for those whose value may be shown.
func (c *Controller) SettingsList(query string) []tool.SettingInfo {
words := strings.Fields(strings.ToLower(query))
cfg := config.LoadForEdit(config.UserConfigPath())
var out []tool.SettingInfo
for _, e := range settings.All() {
id := strings.ToLower(e.ID)
match := true
for _, w := range words {
match = match && strings.Contains(id, w)
}
if !match {
continue
}
row := tool.SettingInfo{ID: e.ID, Class: e.Class.String(), Effect: e.Effect.String()}
if e.Class != settings.Guarded {
if v, err := settings.Read(cfg, e.ID); err == nil {
row.Value = v
}
}
out = append(out, row)
if len(out) > settingListRows {
break
}
}
return out
}
// OpenSetting asks the frontend to bring the user to a setting. It changes
// nothing, which is why it is the one operation a Guarded setting allows.
func (c *Controller) OpenSetting(id string) error {
if _, ok := settings.Lookup(id); !ok {
return settingRefusal(&settings.Error{Code: settings.CodeUnknown, ID: id})
}
c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Code: event.NoticeCodeSettingOpen,
Text: fmt.Sprintf(i18n.M.SettingOpenFmt, id), Detail: id})
return nil
}
// ProposeSetting asks the user to confirm a change and applies it only after
// their answer. The card is worded from the declaration; the model's reason is
// quoted beneath it.
func (c *Controller) ProposeSetting(ctx context.Context, id string, raw json.RawMessage, reason string) (tool.SettingReceipt, error) {
e, ok := settings.Lookup(id)
if !ok {
return tool.SettingReceipt{}, settingRefusal(&settings.Error{Code: settings.CodeUnknown, ID: id})
}
if e.Class == settings.Guarded {
return tool.SettingReceipt{}, tool.Refusal{Code: settings.CodeGuarded, Remedy: settings.RemedyFor(id, nil),
Message: id + " is a boundary setting; only the user changes it."}
}
if c.PlanMode() {
return tool.SettingReceipt{}, tool.Refusal{Code: CodeSettingPlanMode, Message: "Settings are not changed while plan mode is on: use op=open to point the user at it, or ask again once the plan is approved."}
}
if len(raw) > settingRawBytes {
return tool.SettingReceipt{}, tooLong(id)
}
probe := config.LoadForEdit(config.UserConfigPath())
from, to, err := settings.Write(probe, id, raw)
if err != nil {
return tool.SettingReceipt{}, settingRefusal(err)
}
if utf8.RuneCountInString(whole(to)) > settingValueRunes {
return tool.SettingReceipt{}, tooLong(id)
}
receipt := tool.SettingReceipt{ID: id, From: from, To: to, Effect: e.Effect.String()}
if reflect.DeepEqual(from, to) {
receipt.Via = "unchanged"
return receipt, nil
}
if c.settings.proposals.Add(1) > settingProposalsPerTurn {
return tool.SettingReceipt{}, tool.Refusal{Code: CodeSettingLimit,
Message: "Enough settings have been proposed this turn; carry on and ask again next turn if it still matters."}
}
via, err := c.confirmSetting(ctx, e, receipt, reason)
if err != nil {
return tool.SettingReceipt{}, err
}
receipt.Via = via
from, to, err = c.writeSetting(id, raw)
if err != nil {
return tool.SettingReceipt{}, err
}
receipt.From, receipt.To = from, to
c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Code: event.NoticeCodeSettingChanged,
Text: fmt.Sprintf(i18n.M.SettingChangedFmt, id, shown(from), whole(to), effectSentence(e.Effect)), Detail: id})
return receipt, nil
}
// confirmSetting returns how the change was agreed. A grant the user gave
// earlier for this one Preference setting counts; nothing else answers.
func (c *Controller) confirmSetting(ctx context.Context, e settings.Entry, r tool.SettingReceipt, reason string) (string, error) {
card := SettingApprovalTool
if e.Class == settings.Elevated {
card = SettingElevatedApprovalTool
}
args, _ := json.Marshal(map[string]any{"id": e.ID, "from": r.From, "to": r.To, "effect": r.Effect})
text := fmt.Sprintf(i18n.M.SettingApprovalFmt, e.ID, shown(r.From), whole(r.To), effectSentence(e.Effect))
if reason = tidyReason(reason); reason != "" {
text += "\n" + fmt.Sprintf(i18n.M.SettingReasonFmt, reason)
}
if e.Class == settings.Preference && c.settings.granting(e.ID) {
return "session_grant", nil
}
reply, err := c.requestApprovalDecision(ctx, approvalRequest{tool: card, subject: e.ID, args: args, reason: text, fresh: true})
switch {
case err != nil && ctx.Err() != nil:
return "", ctx.Err()
case err != nil:
return "", tool.Refusal{Code: CodeSettingNoAnswer, Message: "Nobody answered the request to change " + e.ID + "."}
case !reply.allow:
return "", tool.Refusal{Code: CodeSettingDeclined, Message: "The user declined changing " + e.ID + "; leave it as it is."}
}
if reply.session && e.Class == settings.Preference {
c.settings.grant(e.ID)
}
return "click", nil
}
// writeSetting applies the change to the user file. It re-reads the file under
// the edit lock and refuses to write over one it cannot read.
func (c *Controller) writeSetting(id string, raw json.RawMessage) (from, to any, err error) {
unlock := config.LockUserConfigEdits()
defer unlock()
path := config.UserConfigPath()
cfg, err := config.LoadForEditReadOnlyStrict(path)
if err != nil {
return nil, nil, tool.Refusal{Code: CodeSettingUnwritable, Message: "The user config could not be read, so nothing was changed."}
}
if from, to, err = settings.Write(cfg, id, raw); err != nil {
return nil, nil, settingRefusal(err)
}
if err := cfg.SaveTo(path); err != nil {
return nil, nil, tool.Refusal{Code: CodeSettingUnwritable, Message: "The user config could not be saved, so nothing was changed."}
}
return from, to, nil
}
func settingRefusal(err error) error {
var se *settings.Error
if !errors.As(err, &se) {
return err
}
r := tool.Refusal{Code: se.Code, Message: se.ID + ": " + se.Message()}
if se.Code == settings.CodeShape || se.Code == settings.CodeSecret {
r.Remedy = &tool.Remedy{Op: tool.RemedyOpen, Setting: se.ID}
}
return r
}
func effectSentence(e settings.Effect) string {
switch e {
case settings.Immediate:
return i18n.M.SettingEffectImmediate
case settings.NextTurn:
return i18n.M.SettingEffectNextTurn
case settings.Restart:
return i18n.M.SettingEffectRestart
}
return i18n.M.SettingEffectOnOpen
}
func tooLong(id string) error {
return tool.Refusal{Code: CodeSettingTooLong, Message: fmt.Sprintf("The value for %s is longer than the confirmation card can show whole (%d characters); propose a shorter one.", id, settingValueRunes)}
}
// whole is a value as the card prints it. A value that is written is never cut.
func whole(v any) string {
b, err := json.Marshal(v)
if err != nil {
return "?"
}
return string(b)
}
// shown is a value that is only being replaced, so it may be shortened.
func shown(v any) string {
b, err := json.Marshal(v)
if err != nil {
return "?"
}
s := string(b)
if utf8.RuneCountInString(s) > 80 {
s = string([]rune(s)[:80]) + "…"
}
return s
}
func tidyReason(s string) string {
s = strings.Join(strings.Fields(s), " ")
if utf8.RuneCountInString(s) > settingReasonRunes {
s = string([]rune(s)[:settingReasonRunes]) + "…"
}
return s
}
@@ -0,0 +1,381 @@
package control
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"time"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/config"
"reasonix/internal/contract/event"
"reasonix/internal/contract/tool"
)
type proposal struct {
receipt tool.SettingReceipt
err error
}
func settingController(t *testing.T) (*Controller, chan event.Approval, chan event.Event) {
t.Helper()
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
requests := make(chan event.Approval, 8)
notices := make(chan event.Event, 8)
c := New(Options{Sink: event.FuncSink(func(e event.Event) {
switch e.Kind {
case event.ApprovalRequest:
requests <- e.Approval
case event.Notice:
notices <- e
}
})})
return c, requests, notices
}
func propose(c *Controller, id, value, reason string) chan proposal {
done := make(chan proposal, 1)
go func() {
r, err := c.ProposeSetting(context.Background(), id, json.RawMessage(value), reason)
done <- proposal{r, err}
}()
return done
}
func userFile() *config.Config { return config.LoadForEdit(config.UserConfigPath()) }
func asRefusal(err error) (tool.Refusal, bool) {
var r tool.Refusal
ok := errors.As(err, &r)
return r, ok
}
func refusalCode(err error) string {
r, _ := asRefusal(err)
return r.Code
}
func idle(t *testing.T, done chan proposal, what string) {
t.Helper()
select {
case got := <-done:
t.Fatalf("%s: answered without a person: %+v", what, got)
case <-time.After(50 * time.Millisecond):
}
}
func TestAPreferenceChangeWaitsForAPersonEvenInYoloAndWritesOnTheClick(t *testing.T) {
c, requests, notices := settingController(t)
c.SetAutoApproveTools(true)
was := userFile().UI.ShowReceipt
done := propose(c, "ui.show_receipt", jsonBool(!was), "the receipt card is in the way")
a := nextApproval(t, requests)
if a.Tool != SettingApprovalTool || a.Subject != "ui.show_receipt" || !a.Fresh || !a.AllowsSession || a.AllowsPersist {
t.Fatalf("approval = %+v", a)
}
for _, want := range []string{"ui.show_receipt", "in the way"} {
if !strings.Contains(a.Reason, want) {
t.Errorf("the card does not say %q: %q", want, a.Reason)
}
}
idle(t, done, "yolo")
if userFile().UI.ShowReceipt != was {
t.Fatal("the file changed before the click")
}
c.Approve(a.ID, true, false, false)
got := <-done
if got.err != nil || got.receipt.Via != "click" || got.receipt.From != was || got.receipt.To != !was {
t.Fatalf("receipt = %+v err=%v", got.receipt, got.err)
}
if userFile().UI.ShowReceipt == was {
t.Fatal("the click did not change the file")
}
select {
case n := <-notices:
if n.Code != event.NoticeCodeSettingChanged || n.Detail != "ui.show_receipt" || !strings.Contains(n.Text, "ui.show_receipt") {
t.Fatalf("notice = %+v", n)
}
case <-time.After(time.Second):
t.Fatal("no receipt notice")
}
}
func jsonBool(b bool) string {
if b {
return "true"
}
return "false"
}
func TestAnElevatedChangeOffersNoSessionGrantAndAsksEveryTime(t *testing.T) {
c, requests, _ := settingController(t)
done := propose(c, "peers.enabled", "true", "")
a := nextApproval(t, requests)
if a.Tool != SettingElevatedApprovalTool || a.AllowsSession {
t.Fatalf("approval = %+v, want the elevated card with no session grant", a)
}
c.Approve(a.ID, true, true, false)
if got := <-done; got.err != nil || got.receipt.Via != "click" {
t.Fatalf("receipt = %+v err=%v", got.receipt, got.err)
}
c.settings.proposals.Store(0)
again := propose(c, "peers.enabled", "false", "")
b := nextApproval(t, requests)
if b.Tool != SettingElevatedApprovalTool {
t.Fatalf("second card = %+v", b)
}
c.Approve(b.ID, false, false, false)
<-again
}
func TestASessionGrantCoversThatOnePreferenceAndNoOther(t *testing.T) {
c, requests, _ := settingController(t)
was := userFile().UI.ShowReceipt
first := propose(c, "ui.show_receipt", jsonBool(!was), "")
a := nextApproval(t, requests)
c.Approve(a.ID, true, true, false)
<-first
second := <-propose(c, "ui.show_receipt", jsonBool(was), "")
if second.err != nil || second.receipt.Via != "session_grant" {
t.Fatalf("second = %+v err=%v, want it applied by the grant", second.receipt, second.err)
}
other := propose(c, "ui.show_changed_files", jsonBool(!userFile().UI.ShowChangedFiles), "")
card := nextApproval(t, requests)
idle(t, other, "a different setting")
c.Approve(card.ID, false, false, false)
<-other
}
func TestAGuardedSettingIsRefusedWithAnOpenRemedyAndNoCard(t *testing.T) {
c, requests, _ := settingController(t)
before := userFile().Permissions.Mode
_, err := c.ProposeSetting(context.Background(), "permissions.mode", json.RawMessage(`"yolo"`), "faster")
r, ok := asRefusal(err)
if !ok || r.Code != "setting.guarded" || r.Remedy == nil || r.Remedy.Op != tool.RemedyOpen || r.Remedy.Setting != "permissions.mode" {
t.Fatalf("err = %#v", err)
}
select {
case a := <-requests:
t.Fatalf("a card was shown for a boundary setting: %+v", a)
default:
}
if userFile().Permissions.Mode != before {
t.Fatal("a guarded setting was written")
}
}
func TestADeclinedChangeLeavesTheFileAndSaysSo(t *testing.T) {
c, requests, _ := settingController(t)
was := userFile().UI.ShowReceipt
done := propose(c, "ui.show_receipt", jsonBool(!was), "")
a := nextApproval(t, requests)
c.Approve(a.ID, false, false, false)
got := <-done
if refusalCode(got.err) != CodeSettingDeclined || userFile().UI.ShowReceipt != was {
t.Fatalf("err = %v, show = %v", got.err, userFile().UI.ShowReceipt)
}
}
func TestAValueTheKeyCannotHoldIsRefusedBeforeAnyCard(t *testing.T) {
c, requests, _ := settingController(t)
_, err := c.ProposeSetting(context.Background(), "ui.show_receipt", json.RawMessage(`"maybe"`), "")
if refusalCode(err) != "setting.invalid_value" {
t.Fatalf("err = %v", err)
}
select {
case a := <-requests:
t.Fatalf("a card for an unusable value: %+v", a)
default:
}
}
func TestAChangeToTheValueAlreadyHeldAsksNothing(t *testing.T) {
c, requests, _ := settingController(t)
got, err := c.ProposeSetting(context.Background(), "ui.show_receipt", json.RawMessage(jsonBool(userFile().UI.ShowReceipt)), "")
if err != nil || got.Via != "unchanged" {
t.Fatalf("got = %+v err=%v", got, err)
}
select {
case a := <-requests:
t.Fatalf("a card for no change: %+v", a)
default:
}
}
func TestOnlyThreeProposalsAreAskedPerTurn(t *testing.T) {
c, requests, _ := settingController(t)
for range settingProposalsPerTurn {
d := propose(c, "notifications.sound", "true", "")
a := nextApproval(t, requests)
c.Approve(a.ID, false, false, false)
<-d
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_, err := c.ProposeSetting(ctx, "notifications.sound", json.RawMessage(`true`), "")
if refusalCode(err) != CodeSettingLimit {
t.Fatalf("fourth proposal: %v", err)
}
}
func TestTheModelsReasonIsQuotedTidyAndBounded(t *testing.T) {
c, requests, _ := settingController(t)
d := propose(c, "ui.show_receipt", jsonBool(!userFile().UI.ShowReceipt), "line one\n\nline two "+strings.Repeat("x", 400))
a := nextApproval(t, requests)
if strings.Contains(a.Reason, "\n\n") || strings.Contains(a.Reason, strings.Repeat("x", 200)) {
t.Fatalf("the reason was not tidied or bounded: %q", a.Reason)
}
c.Approve(a.ID, false, false, false)
<-d
}
func TestSettingsListFindsByWordsAndNeverShowsASecret(t *testing.T) {
c, _, _ := settingController(t)
rows := c.SettingsList("peers dispatch")
if len(rows) != 1 || rows[0].ID != "peers.dispatch" || rows[0].Class != "elevated" {
t.Fatalf("rows = %+v", rows)
}
for _, r := range c.SettingsList("serve token") {
if r.ID == "serve.token" && r.Value != nil {
t.Fatalf("a secret's value was listed: %+v", r)
}
}
}
func TestOpeningASettingEmitsANoticeAndChangesNothing(t *testing.T) {
c, _, notices := settingController(t)
before := userFile().Permissions.Mode
if err := c.OpenSetting("permissions.mode"); err != nil {
t.Fatal(err)
}
n := <-notices
if n.Code != event.NoticeCodeSettingOpen || n.Detail != "permissions.mode" || userFile().Permissions.Mode != before {
t.Fatalf("notice = %+v", n)
}
if refusalCode(c.OpenSetting("no.such")) != "setting.unknown" {
t.Fatal("an undeclared id was opened")
}
}
func TestADisabledDispatchNamesTheSwitchThatWouldLiftIt(t *testing.T) {
c, requests, _ := settingController(t)
_, err := peerDirectory{c}.DispatchToProject(context.Background(), "other", "do it")
r, ok := asRefusal(err)
if !ok || r.Code != "dispatch.disabled" || r.Remedy == nil || r.Remedy.Setting != "peers.enabled" || r.Remedy.Op != tool.RemedyPropose {
t.Fatalf("err = %#v, want the refusal to name peers.enabled", err)
}
done := propose(c, r.Remedy.Setting, string(r.Remedy.Value), "hand a task to the other project")
c.Approve(nextApproval(t, requests).ID, true, false, false)
if got := <-done; got.err != nil || !userFile().Peers.Enabled {
t.Fatalf("applying the remedy: %+v err=%v enabled=%v", got.receipt, got.err, userFile().Peers.Enabled)
}
c.settings.proposals.Store(0)
_, err = peerDirectory{c}.DispatchToProject(context.Background(), "other", "do it")
if r, ok = asRefusal(err); !ok || r.Remedy == nil || r.Remedy.Setting != "peers.dispatch" {
t.Fatalf("with messaging on, err = %#v, want the dispatch switch next", err)
}
}
func listOf(prefix string, n int) string {
var b strings.Builder
b.WriteString("[")
for i := range n {
if i > 0 {
b.WriteString(",")
}
fmt.Fprintf(&b, `"%s%03d"`, prefix, i)
}
b.WriteString("]")
return b.String()
}
// What the card shows is what is written: a list whose harmless head fits the
// card and whose last element is the one that matters is refused outright.
func TestAValueTooLongForTheCardToShowIsRefusedBeforeAnyCardOrWrite(t *testing.T) {
c, requests, _ := settingController(t)
value := strings.TrimSuffix(listOf("skill-", 80), "]") + `,"the-one-that-matters"]`
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_, err := c.ProposeSetting(ctx, "skills.disabled_skills", json.RawMessage(value), "")
if refusalCode(err) != CodeSettingTooLong {
t.Fatalf("err = %v, want %s", err, CodeSettingTooLong)
}
select {
case a := <-requests:
t.Fatalf("a card for a value it cannot show: %+v", a)
default:
}
if len(userFile().Skills.DisabledSkills) != 0 {
t.Fatal("the long value was written")
}
}
func TestAValueTheCardCanShowIsShownWhole(t *testing.T) {
c, requests, _ := settingController(t)
done := propose(c, "skills.disabled_skills", `["a","b","the-one-that-matters"]`, "")
a := nextApproval(t, requests)
if !strings.Contains(a.Reason, "the-one-that-matters") {
t.Fatalf("the card hides the tail of the value: %q", a.Reason)
}
c.Approve(a.ID, false, false, false)
<-done
}
func TestARawValueBeyondTheParseCapIsRefusedUnread(t *testing.T) {
c, _, _ := settingController(t)
huge := `"` + strings.Repeat("x", settingRawBytes) + `"`
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_, err := c.ProposeSetting(ctx, "agent.output_style", json.RawMessage(huge), "")
if refusalCode(err) != CodeSettingTooLong {
t.Fatalf("err = %v", err)
}
}
func TestARawValueIsCappedBeforeItIsParsedWhateverItHolds(t *testing.T) {
c, _, _ := settingController(t)
padded := strings.Repeat(" ", settingRawBytes) + jsonBool(!userFile().UI.ShowReceipt)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if _, err := c.ProposeSetting(ctx, "ui.show_receipt", json.RawMessage(padded), ""); refusalCode(err) != CodeSettingTooLong {
t.Fatalf("err = %v", err)
}
}
// A click authorises the change, not the mode: in plan mode nothing is proposed,
// while reading and pointing stay open.
func TestPlanModeRefusesAProposalButNotListOrOpen(t *testing.T) {
c, requests, _ := settingController(t)
c.SetPlanMode(true)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_, err := c.ProposeSetting(ctx, "ui.show_receipt", json.RawMessage(jsonBool(!userFile().UI.ShowReceipt)), "")
if refusalCode(err) != CodeSettingPlanMode {
t.Fatalf("err = %v, want %s", err, CodeSettingPlanMode)
}
select {
case a := <-requests:
t.Fatalf("a card in plan mode: %+v", a)
default:
}
if len(c.SettingsList("receipt")) == 0 || c.OpenSetting("permissions.mode") != nil {
t.Fatal("list and open must stay available in plan mode")
}
}
func TestListShowsNoValueForABoundarySetting(t *testing.T) {
c, _, _ := settingController(t)
cfg := userFile()
cfg.Agent.SystemPrompt = "secret instructions"
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatal(err)
}
for _, r := range c.SettingsList("agent.system_prompt") {
if r.ID == "agent.system_prompt" && (r.Value != nil || r.Class != "guarded") {
t.Fatalf("row = %+v, want the row without its value", r)
}
}
}
+92
View File
@@ -0,0 +1,92 @@
package builtin
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"reasonix/internal/contract/tool"
)
// SettingTool lets the model look up the user's settings and ask for a change.
// A change is only ever applied after the user's own answer to a card the host
// words; the tool never writes on the model's say-so.
func SettingTool(link *tool.SettingsLink) tool.Tool { return &settingTool{link: link} }
type settingTool struct{ link *tool.SettingsLink }
func (*settingTool) Name() string { return "setting" }
func (*settingTool) Description() string {
return "Find and change the user's own settings. op=list finds settings by words of their id (for example \"peers dispatch\"). op=propose asks the user to confirm one change: give the id, the new value and a short reason; nothing changes until the user answers, and the result says what changed or that they declined. op=open points the user at a setting that only they may change. Use it when a refusal ends with (fix: setting ...), or when a tool you need is not offered because a switch is off. Do not change a setting the task did not call for."
}
func (*settingTool) Schema() json.RawMessage {
return json.RawMessage(`{"type":"object","properties":{"op":{"type":"string","enum":["list","propose","open"]},"query":{"type":"string","description":"Words of the setting's id, for op=list"},"id":{"type":"string","description":"The setting's id, for propose and open"},"value":{"description":"The new value as JSON, for op=propose"},"reason":{"type":"string","description":"Why the change helps the user, shown on the card, for op=propose"}},"required":["op"],"additionalProperties":false}`)
}
// ReadOnly is true because the only write waits on the user's own answer to a
// host-worded card, which is the approval this tool would otherwise ask for.
func (*settingTool) ReadOnly() bool { return true }
// ReadOnlyExecutionHostMutation keeps the tool out of a read-only child, whose
// parent would otherwise answer for a change it did not read.
func (*settingTool) ReadOnlyExecutionHostMutation() bool { return true }
func (*settingTool) DecisionBarrierCall(args json.RawMessage) bool {
var in struct {
Op string `json:"op"`
}
return json.Unmarshal(args, &in) == nil && in.Op == "propose"
}
func (t *settingTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
var in struct {
Op string `json:"op"`
Query string `json:"query"`
ID string `json:"id"`
Value json.RawMessage `json:"value"`
Reason string `json:"reason"`
}
if err := json.Unmarshal(args, &in); err != nil {
return "", fmt.Errorf("setting needs {\"op\": list|propose|open, ...}: %w", err)
}
host, ok := t.link.Host()
if !ok {
return "", tool.Refusal{Code: "setting.unavailable", Message: "Settings cannot be changed from this session."}
}
switch in.Op {
case "list":
return listSettings(host.SettingsList(in.Query))
case "propose":
if strings.TrimSpace(in.ID) == "" || len(in.Value) == 0 {
return "", errors.New("setting.invalid_value: op=propose needs an id and a value")
}
r, err := host.ProposeSetting(ctx, in.ID, in.Value, in.Reason)
if err != nil {
return "", err
}
out, _ := json.Marshal(r)
return string(out), nil
case "open":
if err := host.OpenSetting(in.ID); err != nil {
return "", err
}
return fmt.Sprintf("Pointed the user at %s. Only they can change it; carry on without it and say what it blocks.", in.ID), nil
}
return "", errors.New("setting needs op list, propose or open")
}
const settingListMax = 40
func listSettings(rows []tool.SettingInfo) (string, error) {
truncated := len(rows) > settingListMax
if truncated {
rows = rows[:settingListMax]
}
out, err := json.Marshal(struct {
Settings []tool.SettingInfo `json:"settings"`
Truncated bool `json:"truncated,omitempty"`
}{rows, truncated})
return string(out), err
}
+94
View File
@@ -0,0 +1,94 @@
package builtin
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"reasonix/internal/contract/tool"
)
type fakeSettings struct {
proposed []string
opened []string
rows []tool.SettingInfo
}
func (f *fakeSettings) SettingsList(string) []tool.SettingInfo { return f.rows }
func (f *fakeSettings) ProposeSetting(_ context.Context, id string, v json.RawMessage, _ string) (tool.SettingReceipt, error) {
f.proposed = append(f.proposed, id+"="+string(v))
return tool.SettingReceipt{ID: id, From: false, To: true, Effect: "next_turn", Via: "click"}, nil
}
func (f *fakeSettings) OpenSetting(id string) error { f.opened = append(f.opened, id); return nil }
func settingToolWith(h tool.SettingsHost) tool.Tool {
link := &tool.SettingsLink{}
if h != nil {
link.Bind(h)
}
return SettingTool(link)
}
func TestSettingProposeReachesTheHostAndReturnsTheReceipt(t *testing.T) {
h := &fakeSettings{}
out, err := settingToolWith(h).Execute(context.Background(), json.RawMessage(`{"op":"propose","id":"peers.dispatch","value":true,"reason":"hand a task over"}`))
if err != nil || len(h.proposed) != 1 || h.proposed[0] != "peers.dispatch=true" {
t.Fatalf("proposed=%v err=%v", h.proposed, err)
}
if !strings.Contains(out, `"via":"click"`) || !strings.Contains(out, `"effect":"next_turn"`) {
t.Fatalf("receipt = %s", out)
}
}
func TestSettingNeedsAValueToPropose(t *testing.T) {
h := &fakeSettings{}
if _, err := settingToolWith(h).Execute(context.Background(), json.RawMessage(`{"op":"propose","id":"peers.dispatch"}`)); err == nil || len(h.proposed) != 0 {
t.Fatalf("proposed %v with no value, err=%v", h.proposed, err)
}
}
func TestSettingListIsBoundedAndSaysSo(t *testing.T) {
h := &fakeSettings{}
for range settingListMax + 5 {
h.rows = append(h.rows, tool.SettingInfo{ID: "a.b", Class: "preference", Effect: "immediate"})
}
out, err := settingToolWith(h).Execute(context.Background(), json.RawMessage(`{"op":"list","query":"a"}`))
var got struct {
Settings []tool.SettingInfo
Truncated bool
}
if err != nil || json.Unmarshal([]byte(out), &got) != nil || len(got.Settings) != settingListMax || !got.Truncated {
t.Fatalf("n=%d truncated=%v err=%v", len(got.Settings), got.Truncated, err)
}
}
func TestSettingOpenNeverProposes(t *testing.T) {
h := &fakeSettings{}
if _, err := settingToolWith(h).Execute(context.Background(), json.RawMessage(`{"op":"open","id":"permissions.mode","value":"yolo"}`)); err != nil || len(h.proposed) != 0 || len(h.opened) != 1 {
t.Fatalf("proposed=%v opened=%v err=%v", h.proposed, h.opened, err)
}
}
func TestSettingWithoutAHostRefusesByCode(t *testing.T) {
_, err := settingToolWith(nil).Execute(context.Background(), json.RawMessage(`{"op":"list"}`))
var r tool.Refusal
if !errors.As(err, &r) || r.Code != "setting.unavailable" {
t.Fatalf("err = %v", err)
}
}
func TestOnlyAProposalIsADecisionBarrier(t *testing.T) {
st := settingToolWith(nil)
if !tool.IsDecisionBarrierCall(st, json.RawMessage(`{"op":"propose"}`)) || tool.IsDecisionBarrierCall(st, json.RawMessage(`{"op":"list"}`)) {
t.Fatal("barrier follows the op wrongly")
}
}
func TestTheSettingToolStaysOutOfAReadOnlyChild(t *testing.T) {
m, ok := settingToolWith(nil).(tool.ReadOnlyExecutionHostMutation)
if !ok || !m.ReadOnlyExecutionHostMutation() {
t.Fatal("a read-only child could be handed the setting tool")
}
}
@@ -0,0 +1,2 @@
- 新增:模型现在可以请你打开它需要的开关(比如派发到其他项目),你点一下确认才生效;安全边界类设置它永远改不了。关联 #13678。
- Added: The model can now ask you to turn on a switch it needs (such as dispatching to another project), applied only when you confirm; boundary settings it can never change. Refs #13678.