-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathdecision.go
More file actions
438 lines (353 loc) · 15.3 KB
/
Copy pathdecision.go
File metadata and controls
438 lines (353 loc) · 15.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
package agenthooks
import "fmt"
// Decisions carry *intent*; the provider codec translates intent into that
// provider's mechanism (JSON dialect, exit code, thrown error). Consumers
// never see the wire.
// DecisionKind identifies the outcome a decision carries. The values are
// plain ints with append-only ordering: new kinds are only ever added at the
// end, so persisted values never change meaning.
type DecisionKind int
const (
// DecisionNoDecision defers to the provider's normal flow (NEVER a
// forced allow). It is the zero value: a zero-value decision is neutral.
DecisionNoDecision DecisionKind = iota
DecisionAllow
DecisionDeny
DecisionAsk
DecisionAcceptPrompt
DecisionBlockPrompt
DecisionFinish
DecisionContinue
DecisionObserved
DecisionFlagOutput
DecisionReplaceOutput
DecisionContinueSession
)
// String returns a stable lower-case name for logs.
func (k DecisionKind) String() string {
switch k {
case DecisionNoDecision:
return "no-decision"
case DecisionAllow:
return "allow"
case DecisionDeny:
return "deny"
case DecisionAsk:
return "ask"
case DecisionAcceptPrompt:
return "accept-prompt"
case DecisionBlockPrompt:
return "block-prompt"
case DecisionFinish:
return "finish"
case DecisionContinue:
return "continue"
case DecisionObserved:
return "observed"
case DecisionFlagOutput:
return "flag-output"
case DecisionReplaceOutput:
return "replace-output"
case DecisionContinueSession:
return "continue-session"
}
return fmt.Sprintf("decision-kind(%d)", int(k))
}
// Decision is the read-only view every decision type satisfies — the common
// surface Runner.Decide and middleware Next return. Consumers type-assert to
// the concrete decision type (ToolPreDecision, StopDecision, ...) when they
// need kind-specific fields such as Instruction or UpdatedInput. The
// interface is sealed: only decision values built by this package implement
// it, so every Decision can be carried back through the pipeline losslessly.
type Decision interface {
Kind() DecisionKind
Reason() string
SystemMessage() string
Context() []string
// Blocks reports whether the decision prevents the gated action: true
// exactly for the kinds whose intent is "the action is prevented" —
// DecisionDeny and DecisionBlockPrompt today. An ask is not blocking
// (it defers to a human), and every observe/continue kind is false.
// Consumers branch on this instead of enumerating kinds; a blocking
// kind added later will return true here without call-site changes.
Blocks() bool
// StopsAgent reports whether the decision requests the agent stop (the
// StopAgent modifier) and, if so, the reason it carries. ok is false for
// decisions that did not request a stop, in which case reason is empty.
// The modifier changes edge behavior, so Runner.Decide callers need it to
// reconstruct the full intent.
StopsAgent() (reason string, ok bool)
decCore() decisionCore
}
type decisionCore struct {
kind DecisionKind
reason string
instruction string // ContinueWith payload
context []string
systemMessage string
stopAgent bool
stopReason string
updatedInput any
hasUpdatedInput bool
replacedOutput any
hasReplacedOutput bool
}
// firstNonEmpty picks the first candidate that is not the empty string,
// reporting whether one was found. It backs the WithBlockReason builders:
// candidates are ordered fallbacks and only "" is skipped — values are not
// trimmed, so a whitespace-only candidate wins its slot.
func firstNonEmpty(candidates []string) (string, bool) {
for _, c := range candidates {
if c != "" {
return c, true
}
}
return "", false
}
// blocks is the single source of truth for Decision.Blocks: only the kinds
// whose intent is "the action is prevented" are blocking.
func (c decisionCore) blocks() bool {
switch c.kind {
case DecisionDeny, DecisionBlockPrompt:
return true
default:
return false
}
}
// stopsAgent is the single source of truth for Decision.StopsAgent.
func (c decisionCore) stopsAgent() (string, bool) {
return c.stopReason, c.stopAgent
}
func (c decisionCore) withContext(s string) decisionCore {
c.context = append(append([]string(nil), c.context...), s)
return c
}
// contextCopy returns the context strings without aliasing the internal
// slice, so read accessors can't be used to mutate a decision.
func (c decisionCore) contextCopy() []string {
if len(c.context) == 0 {
return nil
}
return append([]string(nil), c.context...)
}
// ToolPreDecision gates tool.pre and permission.request events.
type ToolPreDecision struct{ core decisionCore }
// NoDecision defers to the provider's normal permission flow. It is NEVER a
// forced allow: the codecs emit each provider's correct "no opinion" form.
func NoDecision() ToolPreDecision { return ToolPreDecision{decisionCore{kind: DecisionNoDecision}} }
// Allow skips the permission prompt where supported. It never loosens policy:
// provider-side deny rules still apply.
func Allow() ToolPreDecision { return ToolPreDecision{decisionCore{kind: DecisionAllow}} }
// Deny blocks the tool call with feedback for the model.
func Deny(reason string) ToolPreDecision {
return ToolPreDecision{decisionCore{kind: DecisionDeny, reason: reason}}
}
// AskUser forces a confirmation prompt where the provider supports one.
// Behavior on providers without ask is governed by Policy.AskFallback.
func AskUser(reason string) ToolPreDecision {
return ToolPreDecision{decisionCore{kind: DecisionAsk, reason: reason}}
}
// WithUpdatedInput rewrites the tool arguments before execution.
func (d ToolPreDecision) WithUpdatedInput(v any) ToolPreDecision {
d.core.updatedInput = v
d.core.hasUpdatedInput = true
return d
}
// WithContext injects context for the model where supported.
func (d ToolPreDecision) WithContext(s string) ToolPreDecision {
d.core = d.core.withContext(s)
return d
}
// WithSystemMessage attaches a user-facing note where supported.
func (d ToolPreDecision) WithSystemMessage(s string) ToolPreDecision {
d.core.systemMessage = s
return d
}
// WithBlockReason attaches the user-facing message for a blocking decision,
// chosen as the first non-empty candidate. Callers pass ordered fallbacks —
// e.g. a policy's custom user-facing message followed by the audit reason the
// decision was constructed with — and the library picks the one to surface.
// Candidates are compared verbatim: only the empty string is skipped, so a
// whitespace-only candidate counts as present. When every candidate is empty
// the decision is returned unchanged. The chosen message occupies the same
// channel as WithSystemMessage and is read back with SystemMessage.
func (d ToolPreDecision) WithBlockReason(candidates ...string) ToolPreDecision {
if reason, ok := firstNonEmpty(candidates); ok {
d.core.systemMessage = reason
}
return d
}
// StopAgent requests continue:false where supported.
func (d ToolPreDecision) StopAgent(reason string) ToolPreDecision {
d.core.stopAgent = true
d.core.stopReason = reason
return d
}
// Kind reports the decision outcome.
func (d ToolPreDecision) Kind() DecisionKind { return d.core.kind }
// Reason reports the reason the decision was constructed with.
func (d ToolPreDecision) Reason() string { return d.core.reason }
// SystemMessage reports the note attached with WithSystemMessage.
func (d ToolPreDecision) SystemMessage() string { return d.core.systemMessage }
// Context reports the context strings attached with WithContext.
func (d ToolPreDecision) Context() []string { return d.core.contextCopy() }
// Blocks reports whether the decision prevents the gated action.
func (d ToolPreDecision) Blocks() bool { return d.core.blocks() }
// StopsAgent reports whether StopAgent was requested and the reason.
func (d ToolPreDecision) StopsAgent() (string, bool) { return d.core.stopsAgent() }
// UpdatedInput reports the rewritten tool arguments and whether a rewrite
// was attached with WithUpdatedInput.
func (d ToolPreDecision) UpdatedInput() (any, bool) {
return d.core.updatedInput, d.core.hasUpdatedInput
}
func (d ToolPreDecision) decCore() decisionCore { return d.core }
// PromptDecision gates prompt.submitted events.
type PromptDecision struct{ core decisionCore }
func AcceptPrompt() PromptDecision {
return PromptDecision{decisionCore{kind: DecisionAcceptPrompt}}
}
func BlockPrompt(reason string) PromptDecision {
return PromptDecision{decisionCore{kind: DecisionBlockPrompt, reason: reason}}
}
func (d PromptDecision) WithContext(s string) PromptDecision {
d.core = d.core.withContext(s)
return d
}
func (d PromptDecision) WithSystemMessage(s string) PromptDecision {
d.core.systemMessage = s
return d
}
// WithBlockReason attaches the user-facing message for a blocking decision,
// chosen as the first non-empty candidate; see
// ToolPreDecision.WithBlockReason for the full semantics.
func (d PromptDecision) WithBlockReason(candidates ...string) PromptDecision {
if reason, ok := firstNonEmpty(candidates); ok {
d.core.systemMessage = reason
}
return d
}
func (d PromptDecision) StopAgent(reason string) PromptDecision {
d.core.stopAgent = true
d.core.stopReason = reason
return d
}
// Kind reports the decision outcome.
func (d PromptDecision) Kind() DecisionKind { return d.core.kind }
// Reason reports the reason the decision was constructed with.
func (d PromptDecision) Reason() string { return d.core.reason }
// SystemMessage reports the note attached with WithSystemMessage.
func (d PromptDecision) SystemMessage() string { return d.core.systemMessage }
// Context reports the context strings attached with WithContext.
func (d PromptDecision) Context() []string { return d.core.contextCopy() }
// Blocks reports whether the decision prevents the gated action.
func (d PromptDecision) Blocks() bool { return d.core.blocks() }
// StopsAgent reports whether StopAgent was requested and the reason.
func (d PromptDecision) StopsAgent() (string, bool) { return d.core.stopsAgent() }
func (d PromptDecision) decCore() decisionCore { return d.core }
// StopDecision responds to agent.stop / subagent.stop.
type StopDecision struct{ core decisionCore }
// Finish lets the agent stop.
func Finish() StopDecision { return StopDecision{decisionCore{kind: DecisionFinish}} }
// ContinueWith keeps the agent working: claude decision:block+reason, cursor
// followup_message, codex continuation prompt, gemini retry. The runner
// enforces Policy.ContinuationCap so consumers can't build infinite loops on
// providers without native caps.
func ContinueWith(instruction string) StopDecision {
return StopDecision{decisionCore{kind: DecisionContinue, instruction: instruction}}
}
func (d StopDecision) WithSystemMessage(s string) StopDecision {
d.core.systemMessage = s
return d
}
// Kind reports the decision outcome.
func (d StopDecision) Kind() DecisionKind { return d.core.kind }
// Reason reports the reason the decision was constructed with.
func (d StopDecision) Reason() string { return d.core.reason }
// SystemMessage reports the note attached with WithSystemMessage.
func (d StopDecision) SystemMessage() string { return d.core.systemMessage }
// Context reports the context strings attached to the decision.
func (d StopDecision) Context() []string { return d.core.contextCopy() }
// Blocks reports whether the decision prevents the gated action.
func (d StopDecision) Blocks() bool { return d.core.blocks() }
// StopsAgent reports whether StopAgent was requested and the reason. Stop
// decisions never set the modifier, so ok is always false.
func (d StopDecision) StopsAgent() (string, bool) { return d.core.stopsAgent() }
// Instruction reports the ContinueWith payload.
func (d StopDecision) Instruction() string { return d.core.instruction }
func (d StopDecision) decCore() decisionCore { return d.core }
// ToolPostDecision responds to tool.post / tool.error.
type ToolPostDecision struct{ core decisionCore }
// Observed acknowledges the event with no opinion.
func Observed() ToolPostDecision { return ToolPostDecision{decisionCore{kind: DecisionObserved}} }
// FlagOutput sends feedback about the tool output to the model.
func FlagOutput(reason string) ToolPostDecision {
return ToolPostDecision{decisionCore{kind: DecisionFlagOutput, reason: reason}}
}
// ReplaceOutput substitutes the tool output where supported: claude
// updatedToolOutput, cursor updated_mcp_tool_output (MCP only), gemini
// tool_output, opencode output mutation.
func ReplaceOutput(v any) ToolPostDecision {
return ToolPostDecision{decisionCore{
kind: DecisionReplaceOutput,
replacedOutput: v,
hasReplacedOutput: true,
}}
}
func (d ToolPostDecision) WithContext(s string) ToolPostDecision {
d.core = d.core.withContext(s)
return d
}
func (d ToolPostDecision) WithSystemMessage(s string) ToolPostDecision {
d.core.systemMessage = s
return d
}
func (d ToolPostDecision) StopAgent(reason string) ToolPostDecision {
d.core.stopAgent = true
d.core.stopReason = reason
return d
}
// Kind reports the decision outcome.
func (d ToolPostDecision) Kind() DecisionKind { return d.core.kind }
// Reason reports the reason the decision was constructed with.
func (d ToolPostDecision) Reason() string { return d.core.reason }
// SystemMessage reports the note attached with WithSystemMessage.
func (d ToolPostDecision) SystemMessage() string { return d.core.systemMessage }
// Context reports the context strings attached with WithContext.
func (d ToolPostDecision) Context() []string { return d.core.contextCopy() }
// Blocks reports whether the decision prevents the gated action.
func (d ToolPostDecision) Blocks() bool { return d.core.blocks() }
// StopsAgent reports whether StopAgent was requested and the reason.
func (d ToolPostDecision) StopsAgent() (string, bool) { return d.core.stopsAgent() }
// ReplacedOutput reports the substituted tool output and whether the
// decision was constructed with ReplaceOutput.
func (d ToolPostDecision) ReplacedOutput() (any, bool) {
return d.core.replacedOutput, d.core.hasReplacedOutput
}
func (d ToolPostDecision) decCore() decisionCore { return d.core }
// SessionStartDecision responds to session.start.
type SessionStartDecision struct{ core decisionCore }
func ContinueSession() SessionStartDecision {
return SessionStartDecision{decisionCore{kind: DecisionContinueSession}}
}
func (d SessionStartDecision) WithContext(s string) SessionStartDecision {
d.core = d.core.withContext(s)
return d
}
func (d SessionStartDecision) WithSystemMessage(s string) SessionStartDecision {
d.core.systemMessage = s
return d
}
// Kind reports the decision outcome.
func (d SessionStartDecision) Kind() DecisionKind { return d.core.kind }
// Reason reports the reason the decision was constructed with.
func (d SessionStartDecision) Reason() string { return d.core.reason }
// SystemMessage reports the note attached with WithSystemMessage.
func (d SessionStartDecision) SystemMessage() string { return d.core.systemMessage }
// Context reports the context strings attached with WithContext.
func (d SessionStartDecision) Context() []string { return d.core.contextCopy() }
// Blocks reports whether the decision prevents the gated action.
func (d SessionStartDecision) Blocks() bool { return d.core.blocks() }
// StopsAgent reports whether StopAgent was requested and the reason. Session
// start decisions never set the modifier, so ok is always false.
func (d SessionStartDecision) StopsAgent() (string, bool) { return d.core.stopsAgent() }
func (d SessionStartDecision) decCore() decisionCore { return d.core }