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

425 lines
15 KiB
Go

package main
import (
"context"
"fmt"
"io"
"strings"
"time"
"reasonix/internal/agent"
"reasonix/internal/boot"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/permission"
"reasonix/internal/sandbox"
"reasonix/internal/skill"
"reasonix/internal/tool"
"reasonix/internal/tool/builtin"
)
// SubagentProfileInput is the desktop-bound shape for authoring a subagent
// profile. Named SubagentProfile* rather than bare Subagent* to stay distinct
// from internal/agent's Subagent* run-transcript types: this is a saved
// authoring profile (a skill file), not a runtime record of one execution.
//
// A profile is always written with runAs=subagent and invocation=manual — it
// stays invocable by name (/<name> <task>, run_skill) but never enters the pinned
// Skills index the model scans for candidates to call on its own initiative
// (see internal/skill/index.go). This is deliberate: a profile authored
// through a settings form has no triggers/auto-use tuning, so nothing about
// it signals the model should discover it unprompted.
type SubagentProfileInput struct {
Name string `json:"name"`
Description string `json:"description"`
SystemPrompt string `json:"systemPrompt"`
Color string `json:"color"`
Model string `json:"model"`
Effort string `json:"effort"`
AllowedTools []string `json:"allowedTools"`
// Scope is "project" or "global" (empty defaults to global on create).
Scope string `json:"scope"`
}
func createSubagentProfileScope(raw string) (skill.Scope, error) {
if strings.TrimSpace(raw) == "" {
return skill.ScopeGlobal, nil
}
return editableSubagentProfileScope(raw)
}
func editableSubagentProfileScope(raw string) (skill.Scope, error) {
switch strings.TrimSpace(raw) {
case "project":
return skill.ScopeProject, nil
case "global":
return skill.ScopeGlobal, nil
default:
return "", fmt.Errorf("unsupported subagent profile scope %q — manage custom-path skills from the Skills page", raw)
}
}
// CreateSubagentProfile writes a new user-authored subagent profile and
// returns its file path. Refuses a name that collides with a built-in
// subagent skill (explore/research/review/security-review) — Store.List's
// dedup rules let a same-named user file silently shadow the built-in
// everywhere, including the dedicated top-level explore/review tools, so this
// must be caught here rather than left to the generic CreateWithContent
// same-scope-only overwrite check.
func (a *App) CreateSubagentProfile(input SubagentProfileInput) (string, error) {
name := strings.TrimSpace(input.Name)
desc := strings.TrimSpace(input.Description)
if desc == "" {
return "", fmt.Errorf("description is required")
}
prompt := strings.TrimSpace(input.SystemPrompt)
if prompt == "" {
return "", fmt.Errorf("system prompt is required")
}
scope, err := createSubagentProfileScope(input.Scope)
if err != nil {
return "", err
}
_, ctrl := a.activeTabAndCtrl()
if ctrl == nil {
return "", fmt.Errorf("no active session")
}
// Refuse before writing anything: the post-save RefreshSkills rebuild is
// rejected while the controller has a running turn, pending prompt, or
// background jobs, and a profile file already written by then would strand
// the UI — the save reports failure, the list never refreshes, and a retry
// hits "already exists". Same precheck order as applyConfigChange.
if err := a.ensureActiveTabRebuildAllowed("subagents"); err != nil {
return "", err
}
occupied := make([]string, 0)
for _, existing := range ctrl.AllSkills() {
occupied = append(occupied, existing.Name, existing.SlashName())
}
for _, command := range ctrl.Commands() {
occupied = append(occupied, command.Name)
}
if host := ctrl.Host(); host != nil {
for _, prompt := range host.Prompts() {
occupied = append(occupied, prompt.Name)
}
}
if err := skill.ValidateSubagentProfileName(name, occupied); err != nil {
return "", err
}
content := skill.RenderSkillFile(skill.SkillFileOptions{
Name: name,
Description: desc,
Body: prompt,
RunAs: skill.RunSubagent,
Model: strings.TrimSpace(input.Model),
Effort: strings.TrimSpace(input.Effort),
AllowedTools: input.AllowedTools,
Color: strings.TrimSpace(input.Color),
Invocation: "manual",
})
path, err := ctrl.CreateSkill(name, scope, content)
if err != nil {
return "", err
}
// Mirrors RefreshSkills/SetSkillEnabled: degrade a lease-held rebuild to a
// deferred warning (the file is already saved), fail hard on a real error.
if err := a.RefreshSkills(); err != nil {
return "", err
}
return path, nil
}
// UpdateSubagentProfile overwrites an existing user-authored subagent
// profile's content in place. name and scope are the profile's identity and
// are not editable through this call — the frontend keeps them read-only in
// the edit form, since renaming or re-scoping would mean moving the file
// (delete-then-create), a separate operation this repo doesn't support yet.
// input.Name/input.Scope are ignored in favor of the name/scope params.
//
// Only profiles this page could have written are editable — see
// editableSubagentProfile. This is the backend enforcement of the same rule
// the frontend applies by filtering its list to invocation=manual.
func (a *App) UpdateSubagentProfile(name, scope string, input SubagentProfileInput) error {
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("name is required")
}
desc := strings.TrimSpace(input.Description)
if desc == "" {
return fmt.Errorf("description is required")
}
prompt := strings.TrimSpace(input.SystemPrompt)
if prompt == "" {
return fmt.Errorf("system prompt is required")
}
targetScope, err := editableSubagentProfileScope(scope)
if err != nil {
return err
}
_, ctrl := a.activeTabAndCtrl()
if ctrl == nil {
return fmt.Errorf("no active session")
}
// See CreateSubagentProfile: refuse before writing so a busy-rejected
// rebuild can't leave the file changed while the UI reports failure.
if err := a.ensureActiveTabRebuildAllowed("subagents"); err != nil {
return err
}
found := false
for _, sk := range ctrl.AllSkills() {
if config.SkillNameKey(sk.Name) != config.SkillNameKey(name) {
continue
}
found = true
if sk.Scope != targetScope {
return fmt.Errorf("%q scope mismatch: requested %q, current scope is %q", name, targetScope, sk.Scope)
}
if err := skill.ValidateEditableSubagentProfile(sk); err != nil {
return err
}
break
}
if !found {
return fmt.Errorf("%q not found", name)
}
content := skill.RenderSkillFile(skill.SkillFileOptions{
Name: name,
Description: desc,
Body: prompt,
RunAs: skill.RunSubagent,
Model: strings.TrimSpace(input.Model),
Effort: strings.TrimSpace(input.Effort),
AllowedTools: input.AllowedTools,
Color: strings.TrimSpace(input.Color),
Invocation: "manual",
})
if err := ctrl.UpdateSkill(name, targetScope, content); err != nil {
return err
}
if err := a.RefreshSkills(); err != nil {
return err
}
return nil
}
// DeleteSubagentProfile removes a user-authored subagent profile. scope must
// match what the caller most recently saw for this name (SkillView.Scope) —
// Store.Delete refuses a scope mismatch rather than guessing, so a stale
// client-side scope fails safely instead of deleting the wrong file.
func (a *App) DeleteSubagentProfile(name, scope string) error {
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("name is required")
}
targetScope, err := editableSubagentProfileScope(scope)
if err != nil {
return err
}
_, ctrl := a.activeTabAndCtrl()
if ctrl == nil {
return fmt.Errorf("no active session")
}
// See CreateSubagentProfile: refuse before deleting so a busy-rejected
// rebuild can't remove the file while the UI reports failure and keeps
// listing the profile.
if err := a.ensureActiveTabRebuildAllowed("subagents"); err != nil {
return err
}
// Re-resolve the target and apply the full profile-identity check before
// deleting: the generic DeleteSkill removes any user skill matching
// name+scope, so a stale UI list (the file changed after load) or a direct
// bridge call could otherwise delete an unrelated hand-authored skill this
// page never owned.
found := false
for _, sk := range ctrl.AllSkills() {
if config.SkillNameKey(sk.Name) != config.SkillNameKey(name) {
continue
}
found = true
if sk.Scope != targetScope {
return fmt.Errorf("%q scope mismatch: requested %q, current scope is %q", name, targetScope, sk.Scope)
}
if err := skill.ValidateEditableSubagentProfile(sk); err != nil {
return err
}
break
}
if !found {
return fmt.Errorf("%q not found", name)
}
if err := ctrl.DeleteSkill(name, targetScope); err != nil {
return err
}
if err := a.RefreshSkills(); err != nil {
return err
}
return nil
}
// TrySubagentProfile runs a subagent profile once, synchronously, fully
// isolated from any live session — it builds its own provider and tool
// registry straight from config, like the standalone `reasonix review` CLI
// command (internal/cli/review.go), and never touches Controller.RunSkill or
// any part of the Chat Runtime critical path. Because it needs nothing saved
// to disk, it runs directly against the caller's current form values (input),
// so a profile can be tried before Save.
//
// A try run is deliberately READ-ONLY regardless of the profile's tool scope:
// it is a settings-page preview, not a real work session, and it has no UI to
// answer approval prompts. ReadOnlySubagentToolRegistry strips writer tools
// and wraps bash in the plan-mode safe command policy; the confined reader/
// search/fetch instances below enforce the same workspace boundaries the real
// boot path installs (boot.go addBuiltins), and the headless permission gate
// applies the user's configured deny rules.
func (a *App) TrySubagentProfile(input SubagentProfileInput, task string) (string, error) {
task = strings.TrimSpace(task)
if task == "" {
return "", fmt.Errorf("task is required")
}
prompt := strings.TrimSpace(input.SystemPrompt)
if prompt == "" {
return "", fmt.Errorf("system prompt is required")
}
// One try run at a time, cancellable from the settings page and aborted
// with the app context on shutdown — a runaway model loop must not burn
// through all 12 steps with no way to stop it.
base := a.ctx
if base == nil {
base = context.Background()
}
runCtx, cancel := context.WithCancel(base)
a.tryRunMu.Lock()
if a.tryRunCancel != nil {
a.tryRunMu.Unlock()
cancel()
return "", fmt.Errorf("another try run is still in progress — cancel it or wait for it to finish")
}
a.tryRunCancel = cancel
a.tryRunMu.Unlock()
defer func() {
a.tryRunMu.Lock()
a.tryRunCancel = nil
a.tryRunMu.Unlock()
cancel()
}()
// Resolve config against the active tab's workspace, not the desktop
// process's CWD — project-level reasonix.toml (sandbox roots, permissions)
// must apply to the try run exactly as it would to a real session there.
// Snapshot under the lock: WorkspaceRoot is rewritten under a.mu (spelling
// normalization, session-binding redirects) and must not be read bare.
root := ""
a.mu.RLock()
if tab := a.activeTabLocked(); tab != nil {
root = tab.WorkspaceRoot
}
a.mu.RUnlock()
cfg, err := config.LoadForRoot(root)
if err != nil {
return "", err
}
modelRef := strings.TrimSpace(input.Model)
if modelRef == "" {
modelRef = strings.TrimSpace(cfg.Agent.SubagentModel)
}
if modelRef == "" {
modelRef = cfg.DefaultModel
}
entry, ok := cfg.ResolveModel(modelRef)
if !ok {
return "", fmt.Errorf("unknown model %q", modelRef)
}
me := *entry
if effort := strings.TrimSpace(input.Effort); effort != "" {
normalized, err := config.NormalizeEffort(&me, effort)
if err != nil {
return "", err
}
me.Effort = normalized
if me.Kind == "anthropic" && me.Effort != "" && strings.TrimSpace(me.Thinking) == "" {
me.Thinking = "adaptive"
}
}
prov, err := boot.NewProviderWithProxy(&me, cfg.NetworkProxySpec())
if err != nil {
return "", err
}
reg := trySubagentToolRegistry(cfg, root, input.AllowedTools)
// The headless gate enforces the user's configured permission rules (deny
// hard-blocks; ask resolves to allow, as for any subagent, which has no UI
// to answer a prompt).
policy := permission.New(cfg.Permissions.Mode, cfg.Permissions.Allow, cfg.Permissions.Ask, cfg.Permissions.Deny)
result, err := agent.RunSubAgentWithSession(runCtx, prov, reg, agent.NewSession(prompt), task, agent.Options{
MaxSteps: 12,
Temperature: cfg.Agent.Temperature,
Pricing: me.Price,
ContextWindow: me.ContextWindow,
Gate: control.NewHeadlessPermissionGate(policy),
}, event.Discard)
if err != nil {
return "", err
}
return result, nil
}
// CancelTrySubagentProfile aborts the in-flight settings-page try run, if
// any. The pending TrySubagentProfile call returns its context error.
func (a *App) CancelTrySubagentProfile() {
a.tryRunMu.Lock()
cancel := a.tryRunCancel
a.tryRunMu.Unlock()
if cancel != nil {
cancel()
}
}
// trySubagentToolRegistry builds the read-only, workspace-rooted tool set a
// try run may use. The parent set comes from builtin.Workspace — the same
// per-workspace assembly boot uses for desktop tabs — so every tool both
// enforces the configured confinement AND resolves relative paths against the
// active tab's root, not the desktop process CWD (which, in a multi-workspace
// session, would let a try run read or search a different project than the
// one on screen). Writers are stripped by the read-only registry — Workspace
// wiring them anyway keeps this byte-comparable with boot's assembly and safe
// if the read-only posture is ever relaxed.
func trySubagentToolRegistry(cfg *config.Config, root string, allowedTools []string) *tool.Registry {
writeRoots := cfg.WriteRootsForRoot(root)
forbidReadRoots := cfg.ForbidReadRootsForRoot(root)
bashSpec := sandbox.Spec{
Mode: cfg.BashMode(),
WriteRoots: writeRoots,
ForbidReadRoots: forbidReadRoots,
Network: cfg.Sandbox.Network,
}
ws := builtin.Workspace{
Dir: root,
WriteRoots: writeRoots,
ForbidReadRoots: forbidReadRoots,
Bash: bashSpec,
BashTimeout: time.Duration(cfg.BashTimeoutSeconds()) * time.Second,
Search: builtin.ResolveSearch(cfg.Tools.Search.Engine, cfg.Tools.Search.RgPath, io.Discard),
ProxySpec: cfg.NetworkProxySpec(),
ReadPaths: builtin.NewPathResolver(),
SessionGuard: builtin.NewSessionDataGuard(config.MemoryUserDir(), cfg.AllowWriteRoots()),
ManagedConfig: builtin.NewManagedConfigPaths(config.ReasonixManagedConfigPaths()),
}
parentReg := tool.NewRegistry()
for _, tl := range ws.Tools() {
parentReg.Add(tl)
}
// ReadOnlySubagentToolRegistry treats an empty allowedTools as "all" (the
// "default all permissions" tool-scope option) and then keeps only
// read-only tools plus policy-wrapped bash.
return agent.ReadOnlySubagentToolRegistry(parentReg, allowedTools)
}