返回 DeepSeek-Reasonix
runner.go
根目录 / internal / hook / runner.go
1 package hook
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "maps"
9 "strings"
10 "sync"
11 )
12
13 // Runner binds a set of resolved hooks to a session: a working directory, the
14 // spawner, and a notify callback that surfaces non-blocking hook messages to the
15 // user. It is the single object the agent (tool events) and the controller
16 // (prompt/stop events) fire hooks through, so neither has to know how hooks load
17 // or run. A nil *Runner is a valid no-op (no hooks configured).
18 type Runner struct {
19 hooks []ResolvedHook
20 cwd string
21 spawner Spawner
22 notify func(string) // surface a non-blocking (warn/error) hook message; may be nil
23 mu sync.RWMutex
24 sessionID string
25 parent *Runner // set by ForRole; the session ID is then derived from it
26 role string
27 }
28
29 // SetSessionID updates the Claude-compatible session identifier used in hook
30 // payloads. It is safe to call when a controller rotates sessions.
31 func (r *Runner) SetSessionID(id string) {
32 if r == nil {
33 return
34 }
35 r.mu.Lock()
36 r.sessionID = id
37 r.mu.Unlock()
38 }
39
40 func (r *Runner) payload(event Event) Payload {
41 return Payload{Event: event, Cwd: r.cwd, SessionID: r.currentSessionID()}
42 }
43
44 func (r *Runner) currentSessionID() string {
45 if r.parent != nil {
46 if id := r.parent.currentSessionID(); id != "" {
47 return id + ":" + r.role
48 }
49 return r.role
50 }
51 r.mu.RLock()
52 defer r.mu.RUnlock()
53 return r.sessionID
54 }
55
56 // NewRunner builds a Runner. spawner nil uses DefaultSpawner; notify nil drops
57 // non-blocking messages.
58 func NewRunner(hooks []ResolvedHook, cwd string, spawner Spawner, notify func(string)) *Runner {
59 return &Runner{hooks: hooks, cwd: cwd, spawner: spawner, notify: notify}
60 }
61
62 // ForSession gives a child session the same resolved rules and execution
63 // environment without sharing mutable session identity with its parent.
64 func (r *Runner) ForSession(id string) *Runner {
65 if r == nil {
66 return nil
67 }
68 child := NewRunner(r.hooks, r.cwd, r.spawner, r.notify)
69 child.SetSessionID(id)
70 return child
71 }
72
73 // ForRole gives an agent that works for this runner's session, such as the
74 // planner, the same rules under session "<parent session>:<role>". The parent's
75 // ID is read each time a hook fires, so a later SetSessionID on it reaches the role.
76 func (r *Runner) ForRole(role string) *Runner {
77 if r == nil {
78 return nil
79 }
80 child := NewRunner(r.hooks, r.cwd, r.spawner, r.notify)
81 child.parent, child.role = r, role
82 return child
83 }
84
85 // NoCwdCommandSearchEnv is honoured by cmd.exe and CreateProcess: when set,
86 // a bare command name is not resolved against the current directory.
87 const NoCwdCommandSearchEnv = "NoDefaultCurrentDirectoryInExePath"
88
89 // WithoutCwdCommandSearch gives hooks the same cwd but sets
90 // NoCwdCommandSearchEnv, so on Windows a bare `python` in a hook command can
91 // never resolve to a python.exe that the cwd ships. It is inherited by
92 // ForSession and ForRole, and is harmless on other platforms.
93 func (r *Runner) WithoutCwdCommandSearch() *Runner {
94 if r == nil {
95 return nil
96 }
97 base := r.spawner
98 if base == nil {
99 base = DefaultSpawner
100 }
101 child := r.ForSession(r.currentSessionID())
102 child.spawner = func(ctx context.Context, in SpawnInput) SpawnResult {
103 env := make(map[string]string, len(in.Env)+1)
104 maps.Copy(env, in.Env)
105 env[NoCwdCommandSearchEnv] = "1"
106 in.Env = env
107 return base(ctx, in)
108 }
109 return child
110 }
111
112 // Hooks returns the resolved hooks (for `/hooks` listing).
113 func (r *Runner) Hooks() []ResolvedHook {
114 if r == nil {
115 return nil
116 }
117 return r.hooks
118 }
119
120 // Enabled reports whether any hooks are configured.
121 func (r *Runner) Enabled() bool { return r != nil && len(r.hooks) > 0 }
122
123 // Has reports whether any configured hook listens for the given event. Callers
124 // use it to skip work that only matters when a specific hook exists (e.g. the
125 // agent buffers reasoning for transform only when a PostLLMCall hook is set).
126 func (r *Runner) Has(event Event) bool {
127 if r == nil {
128 return false
129 }
130 for _, h := range r.hooks {
131 if h.Event == event {
132 return true
133 }
134 }
135 return false
136 }
137
138 // HasPostLLMCall reports whether a PostLLMCall hook is configured, so the agent
139 // keeps streaming reasoning live unless a transform is actually wired up.
140 func (r *Runner) HasPostLLMCall() bool { return r.Has(PostLLMCall) }
141
142 // ToolMutationHooksEnabled reports whether any hook runs around a tool call.
143 // These hooks execute user shell code and may mutate paths that the tool itself
144 // does not declare, so checkpoint coverage must account for them.
145 func (r *Runner) ToolMutationHooksEnabled() bool {
146 return r.Has(PreToolUse) || r.Has(PostToolUse) || r.Has(PostToolUseFailure)
147 }
148
149 // PreToolUse fires before a tool call. block=true means the call must be
150 // refused; message is the reason (fed back to the model and shown to the user).
151 func (r *Runner) PreToolUse(ctx context.Context, name string, args json.RawMessage) (block bool, message string) {
152 if !r.Enabled() {
153 return false, ""
154 }
155 p := r.payload(PreToolUse)
156 p.ToolName, p.ToolArgs = name, args
157 rep := Run(ctx, p, r.hooks, r.spawner)
158 return r.handle(rep)
159 }
160
161 // PostToolUse fires after a tool call. It can't block; non-pass outcomes are
162 // surfaced to the user via notify.
163 func (r *Runner) PostToolUse(ctx context.Context, name string, args json.RawMessage, result string) {
164 if !r.Enabled() {
165 return
166 }
167 p := r.payload(PostToolUse)
168 p.ToolName, p.ToolArgs, p.ToolResult = name, args, result
169 rep := Run(ctx, p, r.hooks, r.spawner)
170 r.handle(rep)
171 }
172
173 // PostToolUseFailure fires when a tool invocation returns an error.
174 func (r *Runner) PostToolUseFailure(ctx context.Context, name string, args json.RawMessage, result string, err error) {
175 if !r.Enabled() {
176 return
177 }
178 p := r.payload(PostToolUseFailure)
179 p.ToolName, p.ToolArgs, p.ToolResult = name, args, result
180 if err != nil {
181 p.Error = err.Error()
182 p.IsInterrupt = errors.Is(err, context.Canceled)
183 }
184 r.handle(Run(ctx, p, r.hooks, r.spawner))
185 // Native Reasonix PostToolUse historically observed both success and
186 // failure. Preserve that contract while Claude hooks use the distinct event.
187 legacy := r.nativeHooks(PostToolUse)
188 if len(legacy) > 0 {
189 p.Event = PostToolUse
190 r.handle(Run(ctx, p, legacy, r.spawner))
191 }
192 }
193
194 // PermissionRequest fires before a tool approval prompt is shown. A native
195 // Reasonix hook here can't answer the dialog (non-pass outcomes are surfaced
196 // via notify only); a Claude-imported hook (PayloadFormat "claude") can
197 // answer it on the user's behalf via exit 2 or a JSON decision, matching
198 // Claude's own contract. decision == nil means "no opinion, show the prompt
199 // normally"; a non-nil decision means the caller should skip the prompt and
200 // treat it as denied (false) or auto-approved (true).
201 func (r *Runner) PermissionRequest(ctx context.Context, name, subject string, args json.RawMessage) (decision *bool, message string) {
202 if !r.Enabled() {
203 return nil, ""
204 }
205 p := r.payload(PermissionRequest)
206 p.ToolName, p.ToolArgs, p.Subject = name, args, subject
207 rep := Run(ctx, p, r.hooks, r.spawner)
208 block, msg := r.handle(rep)
209 switch {
210 case block:
211 deny := false
212 return &deny, msg
213 case rep.Allowed:
214 allow := true
215 return &allow, msg
216 default:
217 return nil, msg
218 }
219 }
220
221 // PromptSubmit fires before a turn starts. block=true aborts the turn; message
222 // is the reason.
223 func (r *Runner) PromptSubmit(ctx context.Context, prompt string, turn int) (block bool, message string) {
224 if !r.Enabled() {
225 return false, ""
226 }
227 p := r.payload(UserPromptSubmit)
228 p.Prompt, p.Turn = prompt, turn
229 rep := Run(ctx, p, r.hooks, r.spawner)
230 return r.handle(rep)
231 }
232
233 // Stop fires after a turn finishes. It can't block.
234 func (r *Runner) Stop(ctx context.Context, lastAssistant string, turn int) {
235 if !r.Enabled() {
236 return
237 }
238 p := r.payload(Stop)
239 p.LastAssistant, p.Turn = lastAssistant, turn
240 rep := Run(ctx, p, r.hooks, r.spawner)
241 r.handle(rep)
242 }
243
244 // StopResult emits Stop on success and StopFailure when the turn failed.
245 func (r *Runner) StopResult(ctx context.Context, lastAssistant string, turn int, err error) {
246 if err == nil {
247 r.Stop(ctx, lastAssistant, turn)
248 return
249 }
250 if !r.Enabled() {
251 return
252 }
253 p := r.payload(StopFailure)
254 p.LastAssistant, p.Turn, p.Error = lastAssistant, turn, err.Error()
255 p.IsInterrupt = errors.Is(err, context.Canceled)
256 r.handle(Run(ctx, p, r.hooks, r.spawner))
257 legacy := r.nativeHooks(Stop)
258 if len(legacy) > 0 {
259 p.Event = Stop
260 r.handle(Run(ctx, p, legacy, r.spawner))
261 }
262 }
263
264 func (r *Runner) nativeHooks(event Event) []ResolvedHook {
265 var out []ResolvedHook
266 for _, h := range r.hooks {
267 if h.Event == event && h.PayloadFormat != "claude" {
268 out = append(out, h)
269 }
270 }
271 return out
272 }
273
274 // SessionStart fires when a session becomes active. It can't block; successful
275 // stdout may contribute one-shot context for the next model request.
276 func (r *Runner) SessionStart(ctx context.Context, source ...string) []string {
277 if !r.Enabled() {
278 return nil
279 }
280 p := r.payload(SessionStart)
281 p.Source = "startup"
282 if len(source) > 0 && strings.TrimSpace(source[0]) != "" {
283 p.Source = strings.TrimSpace(source[0])
284 }
285 rep := Run(ctx, p, r.hooks, r.spawner)
286 r.handle(rep)
287 return r.additionalContexts(rep)
288 }
289
290 // SessionEnd fires when a session is closed or rotated (/new). It can't block.
291 func (r *Runner) SessionEnd(ctx context.Context, reason ...string) {
292 if !r.Enabled() {
293 return
294 }
295 p := r.payload(SessionEnd)
296 p.Reason = "other"
297 if len(reason) > 0 && strings.TrimSpace(reason[0]) != "" {
298 p.Reason = strings.TrimSpace(reason[0])
299 }
300 r.handle(Run(ctx, p, r.hooks, r.spawner))
301 }
302
303 // SubagentStop fires when a `task` sub-agent finishes. It can't block; last is
304 // the sub-agent's final answer.
305 func (r *Runner) SubagentStop(ctx context.Context, last string) {
306 if !r.Enabled() {
307 return
308 }
309 p := r.payload(SubagentStop)
310 p.LastAssistant = last
311 r.handle(Run(ctx, p, r.hooks, r.spawner))
312 }
313
314 // Notification fires when the agent needs the user's attention (e.g. a pending
315 // approval). It can't block; message describes what's waiting.
316 func (r *Runner) Notification(ctx context.Context, message string, notificationType ...string) {
317 if !r.Enabled() {
318 return
319 }
320 p := r.payload(Notification)
321 p.Message = message
322 if len(notificationType) > 0 {
323 p.NotificationType = strings.TrimSpace(notificationType[0])
324 }
325 r.handle(Run(ctx, p, r.hooks, r.spawner))
326 }
327
328 // PostLLMCall fires after every model turn completes but before the
329 // reasoning_content is stored in the session. It returns the hook's stdout as
330 // the new reasoning text, or the original reasoning if the hook passes with
331 // empty stdout / doesn't exist / fails. A non-pass outcome is surfaced via
332 // notify but doesn't block.
333 func (r *Runner) PostLLMCall(ctx context.Context, reasoning string, turn int) string {
334 if !r.Has(PostLLMCall) {
335 return reasoning
336 }
337 p := r.payload(PostLLMCall)
338 p.Reasoning, p.Turn = reasoning, turn
339 rep := Run(ctx, p, r.hooks, r.spawner)
340 r.handle(rep)
341 for _, o := range rep.Outcomes {
342 if o.Decision == DecisionPass {
343 if s := strings.TrimSpace(o.Stdout); s != "" {
344 return s
345 }
346 }
347 }
348 return reasoning
349 }
350
351 // PreCompact fires just before a compaction pass and returns the concatenated
352 // stdout of its hooks as extra summary guidance, so a hook can steer what the
353 // summary keeps. Non-pass outcomes are surfaced via notify.
354 func (r *Runner) PreCompact(ctx context.Context, trigger string) string {
355 if !r.Enabled() {
356 return ""
357 }
358 p := r.payload(PreCompact)
359 p.Trigger = trigger
360 rep := Run(ctx, p, r.hooks, r.spawner)
361 r.handle(rep)
362 var b strings.Builder
363 for _, o := range rep.Outcomes {
364 if s := strings.TrimSpace(o.Stdout); s != "" {
365 if b.Len() > 0 {
366 b.WriteString("\n")
367 }
368 b.WriteString(s)
369 }
370 }
371 return b.String()
372 }
373
374 func (r *Runner) additionalContexts(rep Report) []string {
375 var contexts []string
376 for _, o := range rep.Outcomes {
377 if o.Decision != DecisionPass {
378 continue
379 }
380 out, warnings := ParseOutput(rep.Event, o.Stdout)
381 for _, warning := range warnings {
382 if r.notify != nil {
383 r.notify(FormatOutcome(Outcome{
384 Hook: o.Hook,
385 Decision: DecisionWarn,
386 Stdout: warning,
387 }))
388 }
389 }
390 if out.AdditionalContext != "" {
391 contexts = append(contexts, out.AdditionalContext)
392 }
393 }
394 return contexts
395 }
396
397 // handle surfaces every non-pass outcome to the user (notify) and returns the
398 // block decision plus the blocking hook's message.
399 func (r *Runner) handle(rep Report) (bool, string) {
400 var blockMsg string
401 for _, o := range rep.Outcomes {
402 if o.Decision == DecisionPass {
403 continue
404 }
405 msg := FormatOutcome(o)
406 if r.notify != nil {
407 r.notify(msg)
408 }
409 if o.Decision == DecisionBlock {
410 blockMsg = msg
411 }
412 }
413 return rep.Blocked, blockMsg
414 }
415
416 // FormatOutcome renders a non-pass outcome as a one-line human message.
417 func FormatOutcome(o Outcome) string {
418 detail := strings.TrimSpace(o.Stderr)
419 if detail == "" {
420 detail = strings.TrimSpace(o.Stdout)
421 }
422 tag := string(o.Hook.Scope) + "/" + string(o.Hook.Event)
423 cmd := o.Hook.Command
424 if cmd == "" && o.Hook.ContextFile != "" {
425 cmd = "context:" + o.Hook.ContextFile
426 }
427 cmd = clipRunes(cmd, 60)
428 trunc := ""
429 if o.Truncated {
430 trunc = " (output truncated)"
431 }
432 head := fmt.Sprintf("hook [%s] %s — %s%s", tag, cmd, o.Decision, trunc)
433 if detail != "" {
434 return head + ": " + detail
435 }
436 return head
437 }
438
439 func clipRunes(s string, max int) string {
440 r := []rune(s)
441 if len(r) <= max {
442 return s
443 }
444 if max < 1 {
445 return ""
446 }
447 return string(r[:max]) + "…"
448 }
449
449 lines GO