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