| 1 | package boot |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "sort" |
| 8 | "strings" |
| 9 | "sync" |
| 10 | |
| 11 | "reasonix/internal/tool" |
| 12 | ) |
| 13 | |
| 14 | const ( |
| 15 | TokenModeFull = "full" |
| 16 | TokenModeEconomy = "economy" |
| 17 | TokenModeDelivery = "delivery" |
| 18 | ) |
| 19 | |
| 20 | const tokenEconomyPrompt = `Economy mode is on. Keep work direct and use connect_tool_source only when the task needs a capability absent from the core file and shell tools.` |
| 21 | |
| 22 | const tokenDeliveryPrompt = `<delivery-profile> |
| 23 | Prioritize a verified, complete result over minimizing model calls or tokens. |
| 24 | For action requests: establish acceptance criteria; reproduce bugs when practical; |
| 25 | inspect the relevant code and project rules; fix the root cause; run focused |
| 26 | verification; review the resulting diff and adjacent behavior; and continue until |
| 27 | the request is complete or a genuine blocker remains. Do not claim success without |
| 28 | evidence. State any unverified result or assumption explicitly. |
| 29 | </delivery-profile>` |
| 30 | |
| 31 | var tokenEconomyCoreBuiltins = []string{ |
| 32 | "bash", |
| 33 | "bash_output", |
| 34 | "edit_file", |
| 35 | "kill_shell", |
| 36 | "read_file", |
| 37 | "update_goal", |
| 38 | "wait", |
| 39 | "write_file", |
| 40 | } |
| 41 | |
| 42 | func NormalizeTokenMode(mode string) string { |
| 43 | switch strings.ToLower(strings.TrimSpace(mode)) { |
| 44 | case TokenModeEconomy, "eco", "save", "saving", "low", "lite", "minimal": |
| 45 | return TokenModeEconomy |
| 46 | case TokenModeDelivery, "deliver", "quality", "performance": |
| 47 | return TokenModeDelivery |
| 48 | default: |
| 49 | return TokenModeFull |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | func tokenEconomyBuiltins(configured []string) []string { |
| 54 | if len(configured) == 0 { |
| 55 | return append([]string(nil), tokenEconomyCoreBuiltins...) |
| 56 | } |
| 57 | core := map[string]bool{} |
| 58 | for _, name := range tokenEconomyCoreBuiltins { |
| 59 | core[name] = true |
| 60 | } |
| 61 | out := make([]string, 0, len(configured)) |
| 62 | seen := map[string]bool{} |
| 63 | for _, name := range configured { |
| 64 | name = strings.TrimSpace(name) |
| 65 | if name == "" || !core[name] || seen[name] { |
| 66 | continue |
| 67 | } |
| 68 | seen[name] = true |
| 69 | out = append(out, name) |
| 70 | } |
| 71 | return out |
| 72 | } |
| 73 | |
| 74 | type toolSourceConnector struct { |
| 75 | mu sync.Mutex |
| 76 | |
| 77 | skills func(context.Context) (string, error) |
| 78 | readOnlySkill func(context.Context) (string, error) |
| 79 | task func(context.Context) (string, error) |
| 80 | readOnlyTask func(context.Context) (string, error) |
| 81 | install func(context.Context) (string, error) |
| 82 | webFetch func(context.Context) (string, error) |
| 83 | lsp func(context.Context) (string, error) |
| 84 | docs func(context.Context) (string, error) |
| 85 | sessions func(context.Context) (string, error) |
| 86 | memory func(context.Context) (string, error) |
| 87 | commands func(context.Context) (string, error) |
| 88 | search func(context.Context) (string, error) |
| 89 | files func(context.Context) (string, error) |
| 90 | workflow func(context.Context) (string, error) |
| 91 | mcp func(context.Context, string) (string, error) |
| 92 | mcpNames []string |
| 93 | } |
| 94 | |
| 95 | func (*toolSourceConnector) Name() string { return "connect_tool_source" } |
| 96 | |
| 97 | func (*toolSourceConnector) Description() string { |
| 98 | return "Economy mode only: enable optional tools for the current task, including embedded Reasonix docs. For mcp, pass a configured server name or omit it to list servers. Enabled tools are available on the next model request." |
| 99 | } |
| 100 | |
| 101 | func (*toolSourceConnector) ReadOnly() bool { return true } |
| 102 | |
| 103 | func (*toolSourceConnector) Schema() json.RawMessage { |
| 104 | return json.RawMessage(`{ |
| 105 | "type":"object", |
| 106 | "properties":{ |
| 107 | "source":{"type":"string","description":"Tool source to enable: docs, search, files, workflow, sessions, memory, commands, skills, read_only_skill, mcp, lsp, web_fetch, install_source, task, or read_only_task."}, |
| 108 | "name":{"type":"string","description":"For source=mcp, the configured server name. Omit to list configured MCP servers without connecting them."} |
| 109 | }, |
| 110 | "required":["source"] |
| 111 | }`) |
| 112 | } |
| 113 | |
| 114 | func (t *toolSourceConnector) Execute(ctx context.Context, args json.RawMessage) (string, error) { |
| 115 | var p struct { |
| 116 | Source string `json:"source"` |
| 117 | Name string `json:"name"` |
| 118 | } |
| 119 | if err := json.Unmarshal(args, &p); err != nil { |
| 120 | return "", fmt.Errorf("invalid args: %w", err) |
| 121 | } |
| 122 | source := normalizeToolSource(p.Source) |
| 123 | if source == "" { |
| 124 | return "", fmt.Errorf("unknown tool source %q; available: %s", p.Source, strings.Join(t.availableSources(), ", ")) |
| 125 | } |
| 126 | name := strings.TrimSpace(p.Name) |
| 127 | |
| 128 | out, mcpConnect, err := t.executeLocked(ctx, source, name, p.Source) |
| 129 | if mcpConnect == nil { |
| 130 | return out, err |
| 131 | } |
| 132 | // Connecting an MCP server spawns its subprocess and blocks until the |
| 133 | // handshake finishes (seconds, or until ctx expires), so it runs outside |
| 134 | // t.mu: concurrent connect_tool_source calls for fast sources must not |
| 135 | // queue behind it. No re-locking is needed afterwards: the callback itself |
| 136 | // merges the server's tools into the registry (which has its own lock), |
| 137 | // and Execute keeps no per-server state. Concurrent connects racing on the |
| 138 | // same server are deduplicated inside the callback via the plugin host |
| 139 | // (ErrServerAlreadyConnected / ErrSpawningInFlight fall back to the |
| 140 | // already-connected server's tools), so the loser still idempotently |
| 141 | // reports the enabled tools instead of failing. |
| 142 | return mcpConnect(ctx, name) |
| 143 | } |
| 144 | |
| 145 | // executeLocked dispatches a connect_tool_source call under t.mu. Fast sources |
| 146 | // (registry-only mutations) run to completion while the lock is held. For an |
| 147 | // MCP connect with a server name it performs only the quick pre-checks |
| 148 | // (callback availability and source arguments) and returns the connect callback as |
| 149 | // mcpConnect; the caller invokes it after releasing t.mu. When mcpConnect is |
| 150 | // nil, out/err are the final result. |
| 151 | func (t *toolSourceConnector) executeLocked(ctx context.Context, source, name, rawSource string) (out string, mcpConnect func(context.Context, string) (string, error), err error) { |
| 152 | t.mu.Lock() |
| 153 | defer t.mu.Unlock() |
| 154 | |
| 155 | switch source { |
| 156 | case "skills": |
| 157 | out, err = runSourceInstaller(ctx, "skills", t.skills) |
| 158 | case "read_only_skill": |
| 159 | out, err = runSourceInstaller(ctx, "read_only_skill", t.readOnlySkill) |
| 160 | case "task": |
| 161 | out, err = runSourceInstaller(ctx, "task", t.task) |
| 162 | case "read_only_task": |
| 163 | out, err = runSourceInstaller(ctx, "read_only_task", t.readOnlyTask) |
| 164 | case "install_source": |
| 165 | out, err = runSourceInstaller(ctx, "install_source", t.install) |
| 166 | case "web_fetch": |
| 167 | out, err = runSourceInstaller(ctx, "web_fetch", t.webFetch) |
| 168 | case "lsp": |
| 169 | out, err = runSourceInstaller(ctx, "lsp", t.lsp) |
| 170 | case "docs": |
| 171 | out, err = runSourceInstaller(ctx, "docs", t.docs) |
| 172 | case "sessions": |
| 173 | out, err = runSourceInstaller(ctx, "sessions", t.sessions) |
| 174 | case "memory": |
| 175 | out, err = runSourceInstaller(ctx, "memory", t.memory) |
| 176 | case "commands": |
| 177 | out, err = runSourceInstaller(ctx, "commands", t.commands) |
| 178 | case "search": |
| 179 | out, err = runSourceInstaller(ctx, "search", t.search) |
| 180 | case "files": |
| 181 | out, err = runSourceInstaller(ctx, "files", t.files) |
| 182 | case "workflow": |
| 183 | out, err = runSourceInstaller(ctx, "workflow", t.workflow) |
| 184 | case "mcp": |
| 185 | if name == "" { |
| 186 | if len(t.mcpNames) == 0 { |
| 187 | return "No configured MCP servers are available in this session.", nil, nil |
| 188 | } |
| 189 | names := append([]string(nil), t.mcpNames...) |
| 190 | sort.Strings(names) |
| 191 | return "Configured MCP servers: " + strings.Join(names, ", ") + ". Call connect_tool_source again with source=\"mcp\" and name set to connect one server.", nil, nil |
| 192 | } |
| 193 | if t.mcp == nil { |
| 194 | return "", nil, fmt.Errorf("MCP source is unavailable in this session") |
| 195 | } |
| 196 | return "", t.mcp, nil |
| 197 | default: |
| 198 | return "", nil, fmt.Errorf("unknown tool source %q", rawSource) |
| 199 | } |
| 200 | return out, nil, err |
| 201 | } |
| 202 | |
| 203 | func normalizeToolSource(source string) string { |
| 204 | switch strings.ToLower(strings.TrimSpace(source)) { |
| 205 | case "skill", "skills": |
| 206 | return "skills" |
| 207 | case "read_only_skill", "readonly_skill", "read-only-skill", "read_only_skills", "readonly_skills", "read-only-skills": |
| 208 | return "read_only_skill" |
| 209 | case "mcp", "plugin", "plugins", "server", "servers": |
| 210 | return "mcp" |
| 211 | case "lsp", "language_server", "language-servers": |
| 212 | return "lsp" |
| 213 | case "web", "web_fetch", "webfetch", "fetch": |
| 214 | return "web_fetch" |
| 215 | case "install", "install_source", "installer": |
| 216 | return "install_source" |
| 217 | case "session", "sessions", "history", "conversation", "conversations": |
| 218 | return "sessions" |
| 219 | case "doc", "docs", "documentation", "help", "manual": |
| 220 | return "docs" |
| 221 | case "memory", "memories", "remember": |
| 222 | return "memory" |
| 223 | case "command", "commands", "slash", "slash_command", "slash-command": |
| 224 | return "commands" |
| 225 | case "search", "searches", "find", "grep": |
| 226 | return "search" |
| 227 | case "file", "files", "file_ops", "file-ops", "file_operations", "file-operations": |
| 228 | return "files" |
| 229 | case "workflow", "workflows", "todo", "todos": |
| 230 | return "workflow" |
| 231 | case "read_only_task", "readonly_task", "read-only-task", "read_only_subagent", "readonly_subagent", "read-only-subagent", "research_task", "research-subagent": |
| 232 | return "read_only_task" |
| 233 | case "task", "subagent", "subagents": |
| 234 | return "task" |
| 235 | default: |
| 236 | return "" |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | func (t *toolSourceConnector) availableSources() []string { |
| 241 | var out []string |
| 242 | if t.skills != nil { |
| 243 | out = append(out, "skills") |
| 244 | } |
| 245 | if t.readOnlySkill != nil { |
| 246 | out = append(out, "read_only_skill") |
| 247 | } |
| 248 | if t.mcp != nil || len(t.mcpNames) > 0 { |
| 249 | out = append(out, "mcp") |
| 250 | } |
| 251 | if t.lsp != nil { |
| 252 | out = append(out, "lsp") |
| 253 | } |
| 254 | if t.docs != nil { |
| 255 | out = append(out, "docs") |
| 256 | } |
| 257 | if t.sessions != nil { |
| 258 | out = append(out, "sessions") |
| 259 | } |
| 260 | if t.memory != nil { |
| 261 | out = append(out, "memory") |
| 262 | } |
| 263 | if t.commands != nil { |
| 264 | out = append(out, "commands") |
| 265 | } |
| 266 | if t.search != nil { |
| 267 | out = append(out, "search") |
| 268 | } |
| 269 | if t.files != nil { |
| 270 | out = append(out, "files") |
| 271 | } |
| 272 | if t.workflow != nil { |
| 273 | out = append(out, "workflow") |
| 274 | } |
| 275 | if t.webFetch != nil { |
| 276 | out = append(out, "web_fetch") |
| 277 | } |
| 278 | if t.install != nil { |
| 279 | out = append(out, "install_source") |
| 280 | } |
| 281 | if t.task != nil { |
| 282 | out = append(out, "task") |
| 283 | } |
| 284 | if t.readOnlyTask != nil { |
| 285 | out = append(out, "read_only_task") |
| 286 | } |
| 287 | sort.Strings(out) |
| 288 | return out |
| 289 | } |
| 290 | |
| 291 | func runSourceInstaller(ctx context.Context, name string, fn func(context.Context) (string, error)) (string, error) { |
| 292 | if fn == nil { |
| 293 | return "", fmt.Errorf("%s source is unavailable in this session", name) |
| 294 | } |
| 295 | return fn(ctx) |
| 296 | } |
| 297 | |
| 298 | func addTools(reg *tool.Registry, tools []tool.Tool) []string { |
| 299 | names := make([]string, 0, len(tools)) |
| 300 | for _, t := range tools { |
| 301 | reg.Add(t) |
| 302 | names = append(names, t.Name()) |
| 303 | } |
| 304 | sort.Strings(names) |
| 305 | return names |
| 306 | } |
| 307 |