mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-12 08:41:32 -10:00
* feat(tui): tabbed ask_user questionnaire rendered in the composer
Redesign the interactive ask_user prompt to match the requested mockup: when a
question is raised the composer text box becomes the questionnaire.
- Multi-question prompts render as a row of TABS (one per question + a trailing
Confirm tab). Tab / Shift+Tab switch questions, answers are given in any order,
and Enter on Confirm submits them all. A single-question prompt submits on
answer (no Confirm step) and shows no tab row.
- Each option can carry a one-line DESCRIPTION (contract: per-option
optionDescriptions, or option objects {label, description}); options render
numbered with the description dimmer underneath and the recommended one tagged.
- Per-question optional `header` gives each tab a short title (falls back to a
trimmed question).
- Rendered in the composer/footer region (footerView), replacing the text box
while active, instead of a scrolling transcript card.
- Keys: Tab move · ↑↓ select · Enter confirm · Esc dismiss; a printable key in
the picker drops into free-text; Esc from "type your own" returns to the picker;
multi-select questions are free-text with the options shown as suggestions.
- Headless unaffected (zero exec never wires OnAskUser).
Removed the old single-question sequential state (cursor/typing/index,
renderFocusedAskUserPrompt, resolveAskUser). Tests rewritten for the tabbed flow
(picker default, type-your-own, multi-question tabs + Confirm, Esc dismiss,
descriptions render). make build / go vet / go test ./... -race / make lint green.
* fix(tui): ask_user questionnaire renders on the black terminal canvas
It was painting a gray panel background (styledBlockFill with zeroTheme.panel +
onPanel-wrapped foregrounds). Since the questionnaire replaces the composer, it
should match it: bare foregrounds on the terminal canvas (black) with the
composer's lineStrong border and no fill. The lime cursor/badge highlights and
the recommended tag stay.
* fix(tui): address PR review — schema/parser alignment, single-question Tab
- ask_user schema: options.items is declared as string, so advertise only the
string form (descriptions go via the parallel optionDescriptions array). The
parser stays object-tolerant defensively, but the schema no longer tells
strict-schema providers to send objects it would reject.
- moveAskUserTab no-ops for single-question prompts (no tab strip / Confirm tab),
so Tab/Shift+Tab can't move the prompt into the hidden Confirm state. Test added.
333 lines
16 KiB
Go
333 lines
16 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/Gitlawb/zero/internal/hooks"
|
|
"github.com/Gitlawb/zero/internal/sandbox"
|
|
"github.com/Gitlawb/zero/internal/streamjson"
|
|
"github.com/Gitlawb/zero/internal/tools"
|
|
"github.com/Gitlawb/zero/internal/zeroruntime"
|
|
)
|
|
|
|
type Message = zeroruntime.Message
|
|
type Provider = zeroruntime.Provider
|
|
type ToolCall = zeroruntime.ToolCall
|
|
type Usage = zeroruntime.Usage
|
|
|
|
type PermissionMode string
|
|
type PermissionAction string
|
|
type PermissionDecisionAction string
|
|
|
|
const (
|
|
PermissionModeAuto PermissionMode = "auto"
|
|
PermissionModeAsk PermissionMode = "ask"
|
|
PermissionModeUnsafe PermissionMode = "unsafe"
|
|
PermissionModeSpecDraft PermissionMode = "spec-draft"
|
|
// PermissionModeMemberAuto is a headless mode for swarm/specialist MEMBERS: it
|
|
// advertises the in-workspace mutators a member needs to build (write/edit +
|
|
// shell) on top of the Auto set, while the sandbox engine still gates them at
|
|
// call time — in-workspace writes and sandbox-backed shell auto-allow, but
|
|
// out-of-workspace writes, network, and destructive commands still prompt (and
|
|
// a headless member has no approver, so they are denied). It normalizes to Auto
|
|
// everywhere except ToolAdvertised, so authority is never widened beyond what an
|
|
// interactive auto agent already has inside the sandbox.
|
|
PermissionModeMemberAuto PermissionMode = "member-auto"
|
|
)
|
|
|
|
type StopReason string
|
|
|
|
const (
|
|
StopReasonSpecReviewRequired StopReason = "spec_review_required"
|
|
)
|
|
|
|
const (
|
|
PermissionActionAllow PermissionAction = "allow"
|
|
PermissionActionPrompt PermissionAction = "prompt"
|
|
PermissionActionDeny PermissionAction = "deny"
|
|
PermissionActionCancel PermissionAction = "cancel"
|
|
)
|
|
|
|
const (
|
|
PermissionDecisionAllow PermissionDecisionAction = "allow"
|
|
PermissionDecisionAllowStrict PermissionDecisionAction = "allow_with_strict_auto_review"
|
|
PermissionDecisionAllowForSession PermissionDecisionAction = "allow_for_session"
|
|
PermissionDecisionAllowPrefix PermissionDecisionAction = "allow_prefix_for_session"
|
|
PermissionDecisionAlwaysAllowPrefix PermissionDecisionAction = "always_allow_prefix"
|
|
PermissionDecisionDeny PermissionDecisionAction = "deny"
|
|
PermissionDecisionAlwaysAllow PermissionDecisionAction = "always_allow"
|
|
PermissionDecisionCancel PermissionDecisionAction = "cancel"
|
|
)
|
|
|
|
type ToolResult struct {
|
|
ToolCallID string
|
|
Name string
|
|
Status tools.Status
|
|
Output string
|
|
Meta map[string]string
|
|
Redacted bool
|
|
ChangedFiles []string
|
|
Display tools.Display
|
|
// DenialReason categorizes why a tool call was blocked (empty when it ran).
|
|
// It lets a surface distinguish the cause precisely instead of parsing Output.
|
|
DenialReason DenialCategory
|
|
// LoadedTools carries the deferred-tool names a tool_search call asked the
|
|
// loop to expose next turn (lifted from Meta["load_tools"]). nil for every
|
|
// ordinary tool result; only tool_search populates it.
|
|
LoadedTools []string
|
|
// RequestedModel is the model id a tool asked the loop to switch to for the
|
|
// rest of the run (lifted from the tool's Meta["escalate_to_model"]). Empty
|
|
// for every normal tool result; the Run loop performs the switch when it is
|
|
// set and Options.ModelSwitcher is wired.
|
|
RequestedModel string
|
|
}
|
|
|
|
// DenialCategory classifies why a tool call was blocked before it executed.
|
|
type DenialCategory string
|
|
|
|
const (
|
|
DenialNone DenialCategory = ""
|
|
DenialFiltered DenialCategory = "filtered" // tool not enabled for this run
|
|
DenialPermissionDenied DenialCategory = "permission_denied" // approval declined
|
|
DenialApprovalCanceled DenialCategory = "approval_canceled" // approval canceled and run aborted
|
|
DenialSandboxBlock DenialCategory = "sandbox_block" // blocked by the sandbox
|
|
DenialHookBlocked DenialCategory = "hook_blocked" // vetoed by a beforeTool hook
|
|
)
|
|
|
|
type PermissionRequest struct {
|
|
ToolCallID string `json:"toolCallId"`
|
|
ToolName string `json:"name"`
|
|
Action PermissionAction `json:"action"`
|
|
Permission string `json:"permission"`
|
|
PermissionMode PermissionMode `json:"permissionMode"`
|
|
Autonomy string `json:"autonomy,omitempty"`
|
|
SideEffect string `json:"sideEffect"`
|
|
Reason string `json:"reason,omitempty"`
|
|
Scope string `json:"scope,omitempty"`
|
|
Risk sandbox.Risk `json:"risk"`
|
|
Args map[string]any `json:"args,omitempty"`
|
|
Block *sandbox.Block `json:"block,omitempty"`
|
|
GrantMatched bool `json:"grantMatched,omitempty"`
|
|
Grant *sandbox.Grant `json:"grant,omitempty"`
|
|
CommandPrefix []string `json:"commandPrefix,omitempty"`
|
|
AvailableDecisions []PermissionDecisionAction `json:"availableDecisions,omitempty"`
|
|
}
|
|
|
|
type PermissionDecision struct {
|
|
Action PermissionDecisionAction `json:"action"`
|
|
Reason string `json:"reason,omitempty"`
|
|
}
|
|
|
|
type PermissionEvent struct {
|
|
ToolCallID string `json:"toolCallId"`
|
|
ToolName string `json:"name"`
|
|
Action PermissionAction `json:"action"`
|
|
DecisionAction PermissionDecisionAction `json:"decisionAction,omitempty"`
|
|
Permission string `json:"permission"`
|
|
PermissionGranted bool `json:"permissionGranted,omitempty"`
|
|
PermissionMode PermissionMode `json:"permissionMode"`
|
|
Autonomy string `json:"autonomy,omitempty"`
|
|
SideEffect string `json:"sideEffect"`
|
|
Reason string `json:"reason,omitempty"`
|
|
Scope string `json:"scope,omitempty"`
|
|
DecisionReason string `json:"decisionReason,omitempty"`
|
|
Risk sandbox.Risk `json:"risk"`
|
|
Block *sandbox.Block `json:"block,omitempty"`
|
|
GrantMatched bool `json:"grantMatched,omitempty"`
|
|
Grant *sandbox.Grant `json:"grant,omitempty"`
|
|
CommandPrefix []string `json:"commandPrefix,omitempty"`
|
|
}
|
|
|
|
// AskUserQuestion is one clarifying question the agent wants answered. Options are
|
|
// optional suggested answers an interactive front-end can render as a picker;
|
|
// Recommended (when set) is the suggested default — it should match one of Options.
|
|
// Header is an optional short tab title for a multi-question prompt (falls back to
|
|
// the question text). OptionDescriptions, when present, holds a one-line description
|
|
// per option aligned by index to Options (empty string = no description).
|
|
type AskUserQuestion struct {
|
|
Question string `json:"question"`
|
|
Header string `json:"header,omitempty"`
|
|
Options []string `json:"options,omitempty"`
|
|
OptionDescriptions []string `json:"optionDescriptions,omitempty"`
|
|
Recommended string `json:"recommended,omitempty"`
|
|
MultiSelect bool `json:"multiSelect,omitempty"`
|
|
}
|
|
|
|
// AskUserRequest is handed to OnAskUser when the model invokes the ask_user tool.
|
|
type AskUserRequest struct {
|
|
ToolCallID string `json:"toolCallId"`
|
|
Header string `json:"header,omitempty"`
|
|
Questions []AskUserQuestion `json:"questions"`
|
|
}
|
|
|
|
// AskUserResponse carries the user's answers back to the loop, one per question.
|
|
type AskUserResponse struct {
|
|
Answers []string `json:"answers"`
|
|
}
|
|
|
|
// SpecialistInfo is a one-line summary of a delegatable sub-agent (its name and
|
|
// when-to-use description) surfaced to the orchestrator's system prompt so it can
|
|
// route work to the right specialist. It is plain data so the agent package needs
|
|
// no dependency on internal/specialist.
|
|
type SpecialistInfo struct {
|
|
Name string
|
|
WhenToUse string
|
|
}
|
|
|
|
type Options struct {
|
|
MaxTurns int
|
|
// DeferThreshold activates deferred MCP-tool loading: when the number of
|
|
// deferred-eligible visible tools is >= this value (and it is > 0), their
|
|
// full schemas are withheld and advertised as compact lines via tool_search.
|
|
// 0 (or below the eligible count) keeps every tool eager — byte-identical to
|
|
// the pre-deferral behavior.
|
|
DeferThreshold int
|
|
// Specialists lists the sub-agents the orchestrator may delegate to via the
|
|
// Task tool; when non-empty the system prompt gains a delegation section that
|
|
// names them and nudges the model to offload read-heavy work (search,
|
|
// exploration) so verbose tool output stays out of the main context. It is
|
|
// populated only where the Task tool is actually registered, so an empty slice
|
|
// (the default) reproduces the previous prompt byte-for-byte.
|
|
Specialists []SpecialistInfo
|
|
// Specialist/sub-agent metadata is carried through exec now and consumed by
|
|
// the specialist runtime in later slices.
|
|
SessionID string
|
|
CallingSessionID string
|
|
CallingToolUseID string
|
|
Tag string
|
|
Depth int
|
|
SessionTitle string
|
|
ProviderName string
|
|
Model string
|
|
ReasoningEffort string
|
|
Cwd string
|
|
SystemPrompt string
|
|
// ResponseStyle is the operator-selected reply style from the TUI /style
|
|
// command (e.g. "concise", "explanatory", "review"). It is rendered into the
|
|
// system prompt as a short directive. Empty or "balanced" adds nothing — the
|
|
// prompt is then byte-identical to the pre-style behavior.
|
|
ResponseStyle string
|
|
// Images are optional image attachments to seed onto the initial user turn.
|
|
// nil for text-only runs (the seeded message then carries no images, exactly
|
|
// as before).
|
|
Images []zeroruntime.ImageBlock
|
|
// ContextWindow is the model's maximum input token budget. When > 0 the agent
|
|
// loop compacts long conversations once the estimated size crosses a fraction
|
|
// of this window. 0 DISABLES compaction entirely (every existing caller/test
|
|
// behaves identically).
|
|
ContextWindow int
|
|
// CompactionPreserveLast is how many trailing messages compaction keeps
|
|
// verbatim. <= 0 falls back to defaultCompactionPreserveLast.
|
|
CompactionPreserveLast int
|
|
Registry *tools.Registry
|
|
PermissionMode PermissionMode
|
|
Autonomy string
|
|
Sandbox *sandbox.Engine
|
|
// FileTracker records per-session file read/write versions so the write tools
|
|
// can detect a file changed on disk outside Zero since it was last read. nil
|
|
// disables the check. Created once per session and threaded into every tool run.
|
|
FileTracker *tools.FileTracker
|
|
// Hooks, when set, runs configured beforeTool (blocking) and afterTool
|
|
// (advisory) commands around each tool call. nil disables hooks entirely; a
|
|
// dispatcher built from an empty config is also a safe no-op.
|
|
Hooks *hooks.Dispatcher
|
|
EnabledTools []string
|
|
DisabledTools []string
|
|
OnText func(string)
|
|
OnReasoning func(string)
|
|
OnToolCall func(ToolCall)
|
|
// OnToolCallStart / OnToolCallDelta stream a tool call's arguments LIVE as the
|
|
// model generates them — OnToolCallStart on open (id, tool name), then
|
|
// OnToolCallDelta for each argument fragment. A surface can render the
|
|
// in-progress call (e.g. a file being written) instead of waiting for
|
|
// OnToolCall, which only fires once the whole call has accumulated. nil no-ops.
|
|
OnToolCallStart func(id, name string)
|
|
OnToolCallDelta func(id, fragment string)
|
|
OnPermissionRequest func(context.Context, PermissionRequest) (PermissionDecision, error)
|
|
OnPermission func(PermissionEvent)
|
|
OnAskUser func(context.Context, AskUserRequest) (AskUserResponse, error)
|
|
OnToolResult func(ToolResult)
|
|
OnUsage func(Usage)
|
|
// OnToolProgress, when set, is called with each stream-json event a
|
|
// specialist child process emits while running. The toolCallID identifies
|
|
// which Task tool call the progress belongs to. nil is a no-op.
|
|
OnToolProgress func(toolCallID string, event streamjson.Event)
|
|
// OnContext, when set, is called once per turn with the per-category context
|
|
// budget of the request about to be sent, so a surface (TUI/CLI) can show
|
|
// context utilization. Opt-in like the other callbacks; nil is a no-op.
|
|
OnContext func(ContextBreakdown)
|
|
// ModelSwitcher, when set, lets a tool escalate the run to a stronger model
|
|
// mid-run: the loop calls it with the requested model id and, on success,
|
|
// swaps the active provider and updates Options.Model for the rest of the
|
|
// run. nil DISABLES escalation entirely (the loop ignores any switch
|
|
// request), so every existing caller is unaffected. A returned error is
|
|
// non-fatal: the run continues on the current model.
|
|
ModelSwitcher func(ctx context.Context, modelID string) (Provider, error)
|
|
// SelfCorrect, when set, runs a post-edit verify-and-correct cycle after a
|
|
// mutating tool call: it verifies the changed files (LSP diagnostics + project
|
|
// tests) and feeds failures back to the model to fix, bounded by an attempt
|
|
// ceiling and the autonomy gate. nil disables it entirely (the loop is
|
|
// byte-identical to before). One instance per run — it holds attempt state.
|
|
SelfCorrect *SelfCorrector
|
|
|
|
// RequireCompletionSignal gates run completion for HEADLESS exec. Without it,
|
|
// any assistant turn that produces text but no tool call is accepted as the
|
|
// final answer. With it, a no-tool-call turn is NOT treated as "done" while
|
|
// work clearly remains — pending update_plan items, or a message that ends on a
|
|
// continuation cue ("…Let me check the config:"). The loop then nudges the
|
|
// model to continue instead, bounded by maxContinueNudges (and still by
|
|
// MaxTurns and the run deadline); if the model keeps stalling, the run
|
|
// finalizes as INCOMPLETE (Result.Incomplete) rather than success. Default
|
|
// false leaves the loop byte-identical, so the interactive TUI is unaffected.
|
|
RequireCompletionSignal bool
|
|
|
|
runPermissions *permissionRunState
|
|
}
|
|
|
|
type Result struct {
|
|
FinalAnswer string
|
|
Turns int
|
|
Messages []Message
|
|
StopReason StopReason
|
|
// FinishReason is the provider's normalized terminal stop reason for the turn
|
|
// that produced FinalAnswer: zeroruntime.FinishReasonLength when the output
|
|
// hit the token cap, FinishReasonContentFilter when it was filtered. Empty for
|
|
// a normal completion.
|
|
FinishReason string
|
|
// Incomplete reports that a headless run (RequireCompletionSignal) stopped with
|
|
// work clearly unfinished: the model ended a turn with no tool call while plan
|
|
// items were pending or the message ended mid-step, the model admitted it
|
|
// guessed / could not meet the objective, and/or it failed a task-grounded
|
|
// acceptance check. Callers map it to a non-success terminal status / exit
|
|
// code. False for every normal completion.
|
|
Incomplete bool
|
|
// IncompleteReason is a short, model-derived explanation of why the run was
|
|
// marked Incomplete (e.g. "pending plan items remain"). Empty when Incomplete
|
|
// is false. Surfaced in logs / run_end so an abandoned run is debuggable.
|
|
IncompleteReason string
|
|
}
|
|
|
|
// Truncated reports whether the final response ended abnormally (cut off at the
|
|
// output token cap or withheld by a content filter) rather than completing
|
|
// naturally. Callers can use it to warn the user that FinalAnswer is incomplete.
|
|
func (result Result) Truncated() bool {
|
|
return result.FinishReason != ""
|
|
}
|
|
|
|
// TruncationNotice returns a user-facing warning when the final response was
|
|
// truncated, or "" for a normal completion. Shared by the CLI and TUI so the
|
|
// wording stays consistent.
|
|
func (result Result) TruncationNotice() string {
|
|
switch result.FinishReason {
|
|
case zeroruntime.FinishReasonLength:
|
|
return "Response was cut off at the output token limit and may be incomplete. " +
|
|
"Raise the model's max output tokens or ask zero to continue."
|
|
case zeroruntime.FinishReasonContentFilter:
|
|
return "Response was withheld or cut off by the provider's content filter and may be incomplete."
|
|
case "":
|
|
return ""
|
|
default:
|
|
return "Response ended early (" + result.FinishReason + ") and may be incomplete."
|
|
}
|
|
}
|