1package core
2
3import (
4 "errors"
5 "strings"
6
7 "github.com/bnema/zut/packages/provider"
8)
9
10// ErrIncompleteTurn reports that a normal turn ended without a final answer
11// twice in one top-level invocation, even after one bounded recovery
12// continuation. Hosts surface it as a visible incomplete outcome, never as
13// success. It matches with errors.Is.
14var ErrIncompleteTurn = errors.New("agent turn ended without a final answer")
15
16// Bounded normal-turn recovery reasons emitted on EvTurnRecovery.
17const (
18 TurnRecoveryReasonMissingAnswer = "missing_answer"
19 TurnRecoveryReasonCommentaryOnly = "commentary_only"
20)
21
22// turnRecoveryMetaKey marks the synthetic user-context message appended for
23// a recovery continuation, following the existing goal synthetic-context
24// pattern.
25const turnRecoveryMetaKey = "zut_turn_recovery"
26
27// turnRecoveryPrompt is the fixed bounded continuation appended on an
28// eligible incomplete normal stop.
29const turnRecoveryPrompt = "The request ended without a final answer. Continue the authorized work if it remains actionable. Otherwise provide the result, the concrete blocker, or the specific decision required. Do not merely promise to continue. Respect the user's scope, tool permissions, required confirmations, and any pending required work."
30
31// classifyTerminalAssistant classifies the current terminal assistant
32// message of a successful normal stop. It returns the recovery reason and
33// true when the message carries no final answer: empty, whitespace-only, or
34// reasoning-only content yields missing_answer; nonempty text exclusively
35// marked commentary yields commentary_only.
36//
37// A non-whitespace text block with empty, unknown, or final phase is an
38// answer, as is any user-facing non-text output such as an image. Reasoning
39// and tool-call blocks are never answers. A nonempty refusal, clarification
40// question, or short final answer arrives as ordinary text and therefore
41// counts as complete; no prose sniffing is performed here.
42func classifyTerminalAssistant(msg provider.Message) (reason string, incomplete bool) {
43 commentaryOnly := false
44 for _, c := range msg.Content {
45 switch b := c.(type) {
46 case provider.TextBlock:
47 if strings.TrimSpace(b.Text) == "" {
48 continue
49 }
50 if b.Phase == provider.TextPhaseCommentary {
51 commentaryOnly = true
52 continue
53 }
54 // Empty, unknown, and final phases all retain compatibility
55 // behavior: the text counts as an answer.
56 return "", false
57 case provider.ImageBlock:
58 return "", false
59 default:
60 // Reasoning, tool-call, and tool-result blocks are not
61 // user-facing answers.
62 continue
63 }
64 }
65 if commentaryOnly {
66 return TurnRecoveryReasonCommentaryOnly, true
67 }
68 return TurnRecoveryReasonMissingAnswer, true
69}
70
71// ToolDeniedError marks a tool execution refusal (permission scope,
72// allowlist, jail confinement, or admission gate) as distinct from an
73// ordinary tool failure. Tools return it through Execute; runOneTool
74// records the refusal so a later incomplete normal stop does not gain a
75// recovery prompt urging more actions. The Reason text is shown to the
76// model as the tool result, unchanged from the untyped errors it replaces.
77type ToolDeniedError struct {
78 Reason string
79}
80
81func (e *ToolDeniedError) Error() string {
82 if e == nil || e.Reason == "" {
83 return "tool call denied"
84 }
85 return e.Reason
86}
87
88// asToolDeniedError reports whether err chains to a ToolDeniedError.
89func asToolDeniedError(err error) bool {
90 var denied *ToolDeniedError
91 return errors.As(err, &denied)
92}
93
94// markDeniedToolCall records that a tool invocation was refused at the
95// execution boundary (guard or confirmation denial, or a denied tool
96// execution). A denied invocation must not gain a recovery prompt urging
97// more actions.
98func (a *Agent) markDeniedToolCall() {
99 a.mu.Lock()
100 a.deniedToolCall = true
101 a.mu.Unlock()
102}
103
104// sawDeniedToolCall reports whether any tool invocation was denied during
105// the current top-level runLoop invocation.
106func (a *Agent) sawDeniedToolCall() bool {
107 a.mu.Lock()
108 defer a.mu.Unlock()
109 return a.deniedToolCall
110}
111
112// resetDeniedToolCalls clears the execution-local denial flag at the start
113// of each top-level Prompt/Continue invocation.
114func (a *Agent) resetDeniedToolCalls() {
115 a.mu.Lock()
116 a.deniedToolCall = false
117 a.mu.Unlock()
118}