v0.42.0 82578de

turn_recovery.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close