Files
2026-07-13 13:00:08 +08:00

297 lines
10 KiB
Go

package boot
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"sync"
"reasonix/internal/agent"
"reasonix/internal/planmode"
"reasonix/internal/plugin"
"reasonix/internal/tool"
)
const (
TokenModeFull = "full"
TokenModeEconomy = "economy"
TokenModeDelivery = "delivery"
)
const tokenEconomyPrompt = `Token economy mode is on. Keep the default tool surface lean. Optional sources are hidden behind connect_tool_source; enable skills, read_only_skill, MCP servers, LSP, web_fetch, install_source, task, or read_only_task only when the current request actually needs them.`
const tokenDeliveryPrompt = `<delivery-profile>
Prioritize a verified, complete result over minimizing model calls or tokens.
For action requests: establish acceptance criteria; reproduce bugs when practical;
inspect the relevant code and project rules; fix the root cause; run focused
verification; review the resulting diff and adjacent behavior; and continue until
the request is complete or a genuine blocker remains. Do not claim success without
evidence. State any unverified result or assumption explicitly.
</delivery-profile>`
var tokenEconomyCoreBuiltins = []string{
"bash",
"bash_output",
"code_index",
"complete_step",
"edit_file",
"glob",
"grep",
"kill_shell",
"ls",
"move_file",
"multi_edit",
"read_file",
"todo_write",
"wait",
"write_file",
}
func NormalizeTokenMode(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case TokenModeEconomy, "eco", "save", "saving", "low", "lite", "minimal":
return TokenModeEconomy
case TokenModeDelivery, "deliver", "quality", "performance":
return TokenModeDelivery
default:
return TokenModeFull
}
}
func tokenEconomyBuiltins(configured []string) []string {
if len(configured) == 0 {
return append([]string(nil), tokenEconomyCoreBuiltins...)
}
core := map[string]bool{}
for _, name := range tokenEconomyCoreBuiltins {
core[name] = true
}
out := make([]string, 0, len(configured))
seen := map[string]bool{}
for _, name := range configured {
name = strings.TrimSpace(name)
if name == "" || !core[name] || seen[name] {
continue
}
seen[name] = true
out = append(out, name)
}
return out
}
type toolSourceConnector struct {
mu sync.Mutex
skills func(context.Context) (string, error)
readOnlySkill func(context.Context) (string, error)
task func(context.Context) (string, error)
readOnlyTask func(context.Context) (string, error)
install func(context.Context) (string, error)
webFetch func(context.Context) (string, error)
lsp func(context.Context) (string, error)
mcp func(context.Context, string) (string, error)
mcpNames []string
planModeAllowedTools []string
planModeTrustedMCPServer map[string]bool
}
func (*toolSourceConnector) Name() string { return "connect_tool_source" }
func (*toolSourceConnector) Description() string {
return "Token economy mode only: enable an optional tool source when the task needs it. Sources: skills, read_only_skill, mcp, lsp, web_fetch, install_source, task, read_only_task. For mcp, pass the configured server name; omit name to list servers. Newly enabled tools are available on the next model request."
}
func (*toolSourceConnector) ReadOnly() bool { return true }
func (*toolSourceConnector) Schema() json.RawMessage {
return json.RawMessage(`{
"type":"object",
"properties":{
"source":{"type":"string","description":"Tool source to enable: skills, read_only_skill, mcp, lsp, web_fetch, install_source, task, or read_only_task."},
"name":{"type":"string","description":"For source=mcp, the configured server name. Omit to list configured MCP servers without connecting them."}
},
"required":["source"]
}`)
}
func (t *toolSourceConnector) Execute(ctx context.Context, args json.RawMessage) (string, error) {
var p struct {
Source string `json:"source"`
Name string `json:"name"`
}
if err := json.Unmarshal(args, &p); err != nil {
return "", fmt.Errorf("invalid args: %w", err)
}
source := normalizeToolSource(p.Source)
if source == "" {
return "", fmt.Errorf("unknown tool source %q; available: %s", p.Source, strings.Join(t.availableSources(), ", "))
}
name := strings.TrimSpace(p.Name)
out, mcpConnect, err := t.executeLocked(ctx, source, name, p.Source)
if mcpConnect == nil {
return out, err
}
// Connecting an MCP server spawns its subprocess and blocks until the
// handshake finishes (seconds, or until ctx expires), so it runs outside
// t.mu: concurrent connect_tool_source calls for fast sources must not
// queue behind it. No re-locking is needed afterwards: the callback itself
// merges the server's tools into the registry (which has its own lock),
// and Execute keeps no per-server state. Concurrent connects racing on the
// same server are deduplicated inside the callback via the plugin host
// (ErrServerAlreadyConnected / ErrSpawningInFlight fall back to the
// already-connected server's tools), so the loser still idempotently
// reports the enabled tools instead of failing.
return mcpConnect(ctx, name)
}
// executeLocked dispatches a connect_tool_source call under t.mu. Fast sources
// (registry-only mutations) run to completion while the lock is held. For an
// MCP connect with a server name it performs only the quick pre-checks
// (plan-mode gate, callback availability) and returns the connect callback as
// mcpConnect; the caller invokes it after releasing t.mu. When mcpConnect is
// nil, out/err are the final result.
func (t *toolSourceConnector) executeLocked(ctx context.Context, source, name, rawSource string) (out string, mcpConnect func(context.Context, string) (string, error), err error) {
t.mu.Lock()
defer t.mu.Unlock()
if blocked, msg := t.planModeSourceBlocked(ctx, source, name); blocked {
return msg, nil, nil
}
switch source {
case "skills":
out, err = runSourceInstaller(ctx, "skills", t.skills)
case "read_only_skill":
out, err = runSourceInstaller(ctx, "read_only_skill", t.readOnlySkill)
case "task":
out, err = runSourceInstaller(ctx, "task", t.task)
case "read_only_task":
out, err = runSourceInstaller(ctx, "read_only_task", t.readOnlyTask)
case "install_source":
out, err = runSourceInstaller(ctx, "install_source", t.install)
case "web_fetch":
out, err = runSourceInstaller(ctx, "web_fetch", t.webFetch)
case "lsp":
out, err = runSourceInstaller(ctx, "lsp", t.lsp)
case "mcp":
if name == "" {
if len(t.mcpNames) == 0 {
return "No configured MCP servers are available in this session.", nil, nil
}
names := append([]string(nil), t.mcpNames...)
sort.Strings(names)
return "Configured MCP servers: " + strings.Join(names, ", ") + ". Call connect_tool_source again with source=\"mcp\" and name set to connect one server.", nil, nil
}
if t.mcp == nil {
return "", nil, fmt.Errorf("MCP source is unavailable in this session")
}
return "", t.mcp, nil
default:
return "", nil, fmt.Errorf("unknown tool source %q", rawSource)
}
return out, nil, err
}
func (t *toolSourceConnector) planModeSourceBlocked(ctx context.Context, source, name string) (bool, string) {
if !agent.PlanModeFromContext(ctx) {
return false, ""
}
if source == "mcp" {
if name == "" || planModeAllowsMCPServer(t.planModeAllowedTools, name) || t.planModeTrustedMCPServer[name] {
return false, ""
}
return true, fmt.Sprintf("blocked: MCP source %q is not available in plan mode until at least one concrete tool is trusted. Connect it outside plan mode, choose always allow from the read-only trust prompt, pre-seed trusted_read_only_tools, or declare a concrete %q tool in plan_mode_allowed_tools. Keep exploring with read-only tools, then write your plan for approval before using this MCP server.", name, plugin.ToolPrefix(name))
}
// Sources are read-only iff they expose only read-only research surfaces; the
// moderate plan-mode gate then trusts that ReadOnly flag (step 6), while any
// other source stays non-read-only and is fail-closed by the policy.
readOnlySource := source == "web_fetch" || source == "lsp" || source == "read_only_task" || source == "read_only_skill"
decision := planmode.Policy{}.Decide(planmode.Call{Name: source, ReadOnly: readOnlySource})
return decision.Blocked, decision.Message
}
func planModeAllowsMCPServer(allowedTools []string, server string) bool {
prefix := plugin.ToolPrefix(server)
for _, name := range allowedTools {
name = strings.TrimSpace(name)
if strings.HasPrefix(name, prefix) && len(name) > len(prefix) {
return true
}
}
return false
}
func normalizeToolSource(source string) string {
switch strings.ToLower(strings.TrimSpace(source)) {
case "skill", "skills":
return "skills"
case "read_only_skill", "readonly_skill", "read-only-skill", "read_only_skills", "readonly_skills", "read-only-skills":
return "read_only_skill"
case "mcp", "plugin", "plugins", "server", "servers":
return "mcp"
case "lsp", "language_server", "language-servers":
return "lsp"
case "web", "web_fetch", "webfetch", "fetch":
return "web_fetch"
case "install", "install_source", "installer":
return "install_source"
case "read_only_task", "readonly_task", "read-only-task", "read_only_subagent", "readonly_subagent", "read-only-subagent", "research_task", "research-subagent":
return "read_only_task"
case "task", "subagent", "subagents":
return "task"
default:
return ""
}
}
func (t *toolSourceConnector) availableSources() []string {
var out []string
if t.skills != nil {
out = append(out, "skills")
}
if t.readOnlySkill != nil {
out = append(out, "read_only_skill")
}
if t.mcp != nil || len(t.mcpNames) > 0 {
out = append(out, "mcp")
}
if t.lsp != nil {
out = append(out, "lsp")
}
if t.webFetch != nil {
out = append(out, "web_fetch")
}
if t.install != nil {
out = append(out, "install_source")
}
if t.task != nil {
out = append(out, "task")
}
if t.readOnlyTask != nil {
out = append(out, "read_only_task")
}
sort.Strings(out)
return out
}
func runSourceInstaller(ctx context.Context, name string, fn func(context.Context) (string, error)) (string, error) {
if fn == nil {
return "", fmt.Errorf("%s source is unavailable in this session", name)
}
return fn(ctx)
}
func addTools(reg *tool.Registry, tools []tool.Tool) []string {
names := make([]string, 0, len(tools))
for _, t := range tools {
reg.Add(t)
names = append(names, t.Name())
}
sort.Strings(names)
return names
}