| 1 | package boot |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "io" |
| 7 | "os" |
| 8 | |
| 9 | "reasonix/internal/ablation" |
| 10 | "reasonix/internal/agent" |
| 11 | "reasonix/internal/config" |
| 12 | "reasonix/internal/control" |
| 13 | "reasonix/internal/event" |
| 14 | "reasonix/internal/hook" |
| 15 | "reasonix/internal/lsp" |
| 16 | "reasonix/internal/provider" |
| 17 | "reasonix/internal/sandbox" |
| 18 | "reasonix/internal/skill" |
| 19 | "reasonix/internal/tool" |
| 20 | "reasonix/internal/workspacelease" |
| 21 | ) |
| 22 | |
| 23 | func newSubagentSkillOptionsFactory( |
| 24 | cfg config.AgentConfig, |
| 25 | quoteCtx *event.QuoteContext, |
| 26 | gate agent.Gate, |
| 27 | keepPolicy agent.KeepPolicy, |
| 28 | maxDepth int, |
| 29 | ablationSet ablation.Set, |
| 30 | lease *workspacelease.Owner, |
| 31 | writeRoots *sandbox.WritableRootSet, |
| 32 | hookRunner *hook.Runner, |
| 33 | imageRoutes ...childImageRouting, |
| 34 | ) func(context.Context, int, *provider.Pricing, int, int) agent.Options { |
| 35 | home, stateRoot := userHomeDir(), config.MemoryUserDir() |
| 36 | return func(ctx context.Context, steps int, price *provider.Pricing, ctxWin, childDepth int) agent.Options { |
| 37 | opts := agent.Options{ |
| 38 | MaxSteps: steps, Temperature: cfg.Temperature, Pricing: price, QuoteContext: quoteCtx, UsageSource: event.UsageSourceSubagent, |
| 39 | Gate: gate, ContextWindow: ctxWin, RecentKeep: cfg.RecentKeep, |
| 40 | SoftCompactRatio: cfg.SoftCompactRatio, ToolResultSnipRatio: cfg.ToolResultSnipRatio, |
| 41 | CompactRatio: cfg.CompactRatio, CompactForceRatio: cfg.CompactForceRatio, ContextEditing: cfg.ContextEditing, |
| 42 | ArchiveDir: config.ArchiveDir(), KeepPolicy: keepPolicy, |
| 43 | ResponseLanguage: agent.ResponseLanguageFromContext(ctx), ReasoningLanguage: agent.ReasoningLanguageFromContext(ctx), |
| 44 | SubagentDepth: childDepth, MaxSubagentDepth: maxDepth, |
| 45 | Ablation: ablationSet, WorkspaceLease: lease, WriteRoots: writeRoots, |
| 46 | DisableWriteAccessExpand: true, HomeDir: home, StateRoot: stateRoot, |
| 47 | } |
| 48 | if len(imageRoutes) > 0 { |
| 49 | opts.ImageRequestResolver = imageRoutes[0].controller() |
| 50 | opts.ImageInput = imageRoutes[0].config |
| 51 | } |
| 52 | callID, _, _, _ := agent.CallContext(ctx) |
| 53 | opts.Hooks = hookRunner.ForSession("subagent:" + callID) |
| 54 | return opts |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | func newBootHookRunner(resolved []hook.ResolvedHook, root string, shell sandbox.Shell, sink event.Sink) *hook.Runner { |
| 59 | if held, pending := hook.PendingProjectHooks(hook.LoadOptions{ProjectRoot: root}); pending { |
| 60 | sink.Emit(projectHooksHeldEvent(held)) |
| 61 | } |
| 62 | return newHookRunner(resolved, root, shell, |
| 63 | func(msg string) { sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: msg}) }) |
| 64 | } |
| 65 | |
| 66 | // NewCommandHookRunner resolves hooks for a command that drives an agent |
| 67 | // without Build, from the sources load names; hooks run in load.ProjectRoot. |
| 68 | // Resolving shellCfg may execute its path, so it must come from a source the |
| 69 | // command trusts as much as the hooks themselves. Warnings go to warn. |
| 70 | func NewCommandHookRunner(shellCfg config.ShellConfig, load hook.LoadOptions, warn io.Writer) *hook.Runner { |
| 71 | shell := sandbox.ResolveShell(shellCfg.Prefer, shellCfg.Path, warn) |
| 72 | return newHookRunner(hook.Load(load), load.ProjectRoot, shell, |
| 73 | func(msg string) { _, _ = fmt.Fprintln(warn, msg) }) |
| 74 | } |
| 75 | |
| 76 | func newHookRunner(resolved []hook.ResolvedHook, root string, shell sandbox.Shell, notify func(string)) *hook.Runner { |
| 77 | runtime := hook.RuntimeOptions{} |
| 78 | if shell.Kind == sandbox.ShellBash { |
| 79 | runtime.BashPath = shell.Path |
| 80 | } |
| 81 | return hook.NewRunner(resolved, root, hook.NewDefaultSpawner(runtime), notify) |
| 82 | } |
| 83 | |
| 84 | func reviewSubagentSkillOptions( |
| 85 | ctx context.Context, |
| 86 | profile, task string, |
| 87 | steps int, |
| 88 | price *provider.Pricing, |
| 89 | ctxWin, childDepth int, |
| 90 | factory func(context.Context, int, *provider.Pricing, int, int) agent.Options, |
| 91 | ) (string, agent.Options) { |
| 92 | reviewTokens := 0 |
| 93 | if reviewTask, tokens, ok := agent.PrepareReviewSubagentContext(ctx, profile, task); ok { |
| 94 | task, reviewTokens = reviewTask, tokens |
| 95 | } |
| 96 | opts := factory(ctx, steps, price, ctxWin, childDepth) |
| 97 | if reviewTokens > 0 { |
| 98 | opts.MaxOutputTokens = reviewTokens |
| 99 | } |
| 100 | return task, opts |
| 101 | } |
| 102 | |
| 103 | func skillSubagentRegistry( |
| 104 | sk skill.Skill, |
| 105 | parent *tool.Registry, |
| 106 | childDepth, maxDepth int, |
| 107 | runtime *agent.MCPCapabilityRuntime, |
| 108 | writeRoots *sandbox.WritableRootSet, |
| 109 | ) (*tool.Registry, *sandbox.WritableRootSet) { |
| 110 | if sk.ReadOnly { |
| 111 | return agent.ReadOnlySubagentToolRegistryForDepthWithRuntime(parent, sk.AllowedTools, childDepth, maxDepth, runtime), nil |
| 112 | } |
| 113 | reg := agent.SubagentToolRegistryForDepthWithRuntime(parent, sk.AllowedTools, childDepth, maxDepth, runtime) |
| 114 | return agent.BindChildWriteRoots(reg, writeRoots, agent.WritePathSet{}) |
| 115 | } |
| 116 | |
| 117 | func projectWriteAccessPersister(root string) control.PersistWriteAccessFunc { |
| 118 | return func(dirs []string, permRule string) error { |
| 119 | return config.PersistWorkspaceWriteAccess(config.ReasonixHomeDir(), root, dirs, permRule) |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | func userHomeDir() string { |
| 124 | home, _ := os.UserHomeDir() |
| 125 | return home |
| 126 | } |
| 127 | |
| 128 | // projectHooksHeldEvent says which project hooks did not load and how to let them. |
| 129 | func projectHooksHeldEvent(p config.ProjectProgram) event.Event { |
| 130 | return event.Event{ |
| 131 | Kind: event.Notice, |
| 132 | Level: event.LevelWarn, |
| 133 | Text: "This project's hooks are off until you approve them.", |
| 134 | Detail: fmt.Sprintf("%s declares: %s\nRun `reasonix trust` in this workspace to review and approve them; any later change needs approval again.", p.Name, p.Detail), |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | // verifiedLSPSpecs checks a workspace-declared server against its approval |
| 139 | // before every start, so a file it names that changed mid-session is refused. |
| 140 | func verifiedLSPSpecs(cfg *config.Config) map[string]lsp.ServerSpec { |
| 141 | specs := LSPSpecs(cfg.LSP) |
| 142 | for lang, spec := range specs { |
| 143 | spec.Verify = cfg.ProjectProgramVerifier(config.ProjectProgramLSP, lang) |
| 144 | specs[lang] = spec |
| 145 | } |
| 146 | return specs |
| 147 | } |
| 148 |