mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
39 files changed
+1452
-22
No files matched your search
@@ -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.
|
||||
@@ -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`。
|
||||
@@ -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.
|
||||
|
||||
@@ -73,6 +73,7 @@ func HostControlToolNames() []string {
|
||||
"update_goal",
|
||||
"todo_write",
|
||||
"complete_step",
|
||||
"setting",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -199,6 +199,7 @@ var Chinese = Messages{
|
||||
SlashHintMessages: slashHintZH,
|
||||
QueueMessages: queueZH,
|
||||
GoalMessages: goalZH,
|
||||
SettingMessages: settingZH,
|
||||
ApprovalNeededFmt: "需要审批:%s",
|
||||
ApprovalNeededWithSubjectFmt: "需要审批:%s %s",
|
||||
AnswerNeededFmt: "需要回答:%s",
|
||||
|
||||
@@ -198,6 +198,7 @@ var ChineseTraditional = Messages{
|
||||
SlashHintMessages: slashHintZHTW,
|
||||
QueueMessages: queueZHTW,
|
||||
GoalMessages: goalZHTW,
|
||||
SettingMessages: settingZHTW,
|
||||
ApprovalNeededFmt: "需要核准:%s",
|
||||
ApprovalNeededWithSubjectFmt: "需要核准:%s %s",
|
||||
AnswerNeededFmt: "需要回答:%s",
|
||||
|
||||
@@ -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: "重新啟動後生效。",
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user