diff --git a/cmd/aiscan/cli_test.go b/cmd/aiscan/cli_test.go index 2bdd298e..631101ea 100644 --- a/cmd/aiscan/cli_test.go +++ b/cmd/aiscan/cli_test.go @@ -227,6 +227,9 @@ func TestAgentHelpRendersAgentOptionsWithoutRootCatalog(t *testing.T) { for _, wants := range [][]string{ {"agent [OPTIONS]"}, {"Agent Options:"}, + {"-v", "--verbose"}, + {"thinking and tool previews"}, + {"full tool results"}, {"--prompt", "/prompt"}, {"--transport", "/transport"}, {"--server-url", "/server-url"}, diff --git a/core/config/config_gen.go b/core/config/config_gen.go index 6c59f1ec..1ce485c1 100644 --- a/core/config/config_gen.go +++ b/core/config/config_gen.go @@ -74,6 +74,7 @@ func generateFromStruct(t reflect.Type, v reflect.Value, indent int) string { groupTag := field.Tag.Get("group") descTag := field.Tag.Get("description") defaultTag := field.Tag.Get("default") + optionalTag := field.Tag.Get("config_optional") == "true" fieldType := field.Type if fieldType.Kind() == reflect.Pointer { @@ -108,7 +109,11 @@ func generateFromStruct(t reflect.Type, v reflect.Value, indent int) string { b.WriteString(fmt.Sprintf("%s# %s\n", prefix, descTag)) } val := formatValue(fieldType.Kind(), defaultTag) - b.WriteString(fmt.Sprintf("%s%s: %s\n", prefix, configTag, val)) + if optionalTag { + b.WriteString(fmt.Sprintf("%s# %s: %s\n", prefix, configTag, val)) + } else { + b.WriteString(fmt.Sprintf("%s%s: %s\n", prefix, configTag, val)) + } } } return b.String() diff --git a/core/config/env.go b/core/config/env.go index 1daebff5..709d1198 100644 --- a/core/config/env.go +++ b/core/config/env.go @@ -17,6 +17,9 @@ func ResolveRuntimeConfig(option *Option) (string, error) { } applyEnvironment(option, explicit, os.LookupEnv) ApplyDefaults(option) + if _, err := ResolveOutputPolicy(option); err != nil { + return configPath, err + } if strings.TrimSpace(option.DataDir) != "" { SetDataDir(option.DataDir) } diff --git a/core/config/loader.go b/core/config/loader.go index 3d6ae671..6729703f 100644 --- a/core/config/loader.go +++ b/core/config/loader.go @@ -148,6 +148,7 @@ func mergeOption(dst, src *Option) { if !dst.SaveSession && src.SaveSession { dst.SaveSession = true } + mergeOutputOptions(&dst.OutputOptions, &src.OutputOptions) dst.DataDir = ResolveString(dst.DataDir, src.DataDir) } diff --git a/core/config/loader_test.go b/core/config/loader_test.go index 3d100656..a4ff5e19 100644 --- a/core/config/loader_test.go +++ b/core/config/loader_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "strings" "testing" "github.com/chainreactors/aiscan/pkg/telemetry" @@ -439,6 +440,17 @@ func TestInitDefaultConfig(t *testing.T) { if err := LoadConfig(path, &opt); err != nil { t.Errorf("generated config should be parseable: %v", err) } + for _, want := range []string{ + "output:", + "preset: \"default\"", + "# reasoning: \"hidden\"", + "# tool_results: \"hidden\"", + "# live_status: true", + } { + if !strings.Contains(content, want) { + t.Errorf("generated config missing %q", want) + } + } } func TestFullPriorityChain(t *testing.T) { diff --git a/core/config/options.go b/core/config/options.go index fefa656a..fd280825 100644 --- a/core/config/options.go +++ b/core/config/options.go @@ -17,6 +17,7 @@ type Option struct { AgentOptions `group:"Agent Options" config:"agent"` IOAOptions `group:"Server Options" config:"ioa"` ReconOptions `group:"Recon Options" config:"recon"` + OutputOptions `group:"Agent Output Options" config:"output"` MiscOptions `group:"Miscellaneous Options" config:"misc"` ScanConfig ScanConfigOptions `no-flag:"true" config:"scan"` } @@ -125,7 +126,7 @@ type MiscOptions struct { ViewFormat string `short:"o" long:"output" description:"Output format for -F: terminal (default), markdown" default:"terminal"` ViewOutput string `short:"f" long:"file" description:"Write -F output to file instead of stdout"` Debug bool `long:"debug" config:"debug" description:"Enable debug logging"` - Verbose []bool `short:"v" long:"verbose" description:"Increase verbosity (-v tools, -vv thinking)"` + Verbose []bool `short:"v" long:"verbose" description:"Increase verbosity (-v thinking and tool previews, -vv full tool results)"` Quiet bool `short:"q" long:"quiet" config:"quiet" description:"Quiet mode — only show final result"` NoColor bool `long:"no-color" config:"no_color" description:"Disable ANSI colors in scanner output"` Version bool `long:"version" description:"Print version and exit"` diff --git a/core/config/output.go b/core/config/output.go new file mode 100644 index 00000000..7d9029a5 --- /dev/null +++ b/core/config/output.go @@ -0,0 +1,220 @@ +package config + +import ( + "fmt" + "strings" +) + +type OutputOptions struct { + Preset string `config:"preset" default:"default" description:"Output preset: default, verbose, or full"` + Reasoning string `config:"reasoning" default:"hidden" config_optional:"true" description:"Reasoning output: hidden or full"` + ToolCalls string `config:"tool_calls" default:"compact" config_optional:"true" description:"Tool call output: hidden or compact"` + ToolArguments string `config:"tool_arguments" default:"hidden" config_optional:"true" description:"Tool argument output: hidden, preview, or full"` + ToolResults string `config:"tool_results" default:"hidden" config_optional:"true" description:"Tool result output: hidden, preview, or full"` + LiveStatus *bool `config:"live_status" default:"true" config_optional:"true" description:"Show the transient thinking/tooling/talking status"` + Usage *bool `config:"usage" default:"true" config_optional:"true" description:"Show token and context usage in the live status"` +} + +type OutputDetail string + +const ( + OutputDetailHidden OutputDetail = "hidden" + OutputDetailPreview OutputDetail = "preview" + OutputDetailFull OutputDetail = "full" +) + +type OutputCalls string + +const ( + OutputCallsHidden OutputCalls = "hidden" + OutputCallsCompact OutputCalls = "compact" +) + +type OutputPreset string + +const ( + OutputPresetDefault OutputPreset = "default" + OutputPresetVerbose OutputPreset = "verbose" + OutputPresetFull OutputPreset = "full" + OutputPresetQuiet OutputPreset = "quiet" +) + +type OutputPolicy struct { + Preset OutputPreset + Reasoning OutputDetail + ToolCalls OutputCalls + ToolArguments OutputDetail + ToolResults OutputDetail + LiveStatus bool + Usage bool + Custom bool +} + +func (p OutputPolicy) Quiet() bool { + return p.Preset == OutputPresetQuiet +} + +func (p OutputPolicy) ShowReasoning() bool { + return !p.Quiet() && p.Reasoning == OutputDetailFull +} + +func OutputPolicyForPreset(preset OutputPreset) OutputPolicy { + switch preset { + case OutputPresetVerbose: + return OutputPolicy{ + Preset: preset, Reasoning: OutputDetailFull, ToolCalls: OutputCallsCompact, + ToolArguments: OutputDetailPreview, ToolResults: OutputDetailPreview, + LiveStatus: true, Usage: true, + } + case OutputPresetFull: + return OutputPolicy{ + Preset: preset, Reasoning: OutputDetailFull, ToolCalls: OutputCallsCompact, + ToolArguments: OutputDetailPreview, ToolResults: OutputDetailFull, + LiveStatus: true, Usage: true, + } + case OutputPresetQuiet: + return OutputPolicy{ + Preset: preset, Reasoning: OutputDetailHidden, ToolCalls: OutputCallsHidden, + ToolArguments: OutputDetailHidden, ToolResults: OutputDetailHidden, + } + default: + return OutputPolicy{ + Preset: OutputPresetDefault, Reasoning: OutputDetailHidden, ToolCalls: OutputCallsCompact, + ToolArguments: OutputDetailHidden, ToolResults: OutputDetailHidden, + LiveStatus: true, Usage: true, + } + } +} + +func OutputPolicyForLevel(level int) OutputPolicy { + switch { + case level < 0: + return OutputPolicyForPreset(OutputPresetQuiet) + case level == 1: + return OutputPolicyForPreset(OutputPresetVerbose) + case level >= 2: + return OutputPolicyForPreset(OutputPresetFull) + default: + return OutputPolicyForPreset(OutputPresetDefault) + } +} + +func ResolveOutputPolicy(option *Option) (OutputPolicy, error) { + if option != nil { + if option.Quiet { + return OutputPolicyForPreset(OutputPresetQuiet), nil + } + if len(option.Verbose) > 0 { + return OutputPolicyForLevel(len(option.Verbose)), nil + } + } + + opts := OutputOptions{} + if option != nil { + opts = option.OutputOptions + } + preset, err := parseOutputPreset(opts.Preset) + if err != nil { + return OutputPolicy{}, err + } + base := OutputPolicyForPreset(preset) + policy := base + + if opts.Reasoning != "" { + policy.Reasoning, err = parseOutputDetail("reasoning", opts.Reasoning, false) + if err != nil { + return OutputPolicy{}, err + } + } + if opts.ToolCalls != "" { + policy.ToolCalls, err = parseOutputCalls(opts.ToolCalls) + if err != nil { + return OutputPolicy{}, err + } + } + if opts.ToolArguments != "" { + policy.ToolArguments, err = parseOutputDetail("tool_arguments", opts.ToolArguments, true) + if err != nil { + return OutputPolicy{}, err + } + } + if opts.ToolResults != "" { + policy.ToolResults, err = parseOutputDetail("tool_results", opts.ToolResults, true) + if err != nil { + return OutputPolicy{}, err + } + } + if opts.LiveStatus != nil { + policy.LiveStatus = *opts.LiveStatus + } + if opts.Usage != nil { + policy.Usage = *opts.Usage + } + policy.Custom = !outputPoliciesEqual(policy, base) + return policy, nil +} + +func parseOutputPreset(value string) (OutputPreset, error) { + switch preset := OutputPreset(strings.ToLower(strings.TrimSpace(value))); preset { + case "", OutputPresetDefault: + return OutputPresetDefault, nil + case OutputPresetVerbose, OutputPresetFull: + return preset, nil + default: + return "", fmt.Errorf("output.preset must be default, verbose, or full, got %q", value) + } +} + +func parseOutputDetail(field, value string, preview bool) (OutputDetail, error) { + detail := OutputDetail(strings.ToLower(strings.TrimSpace(value))) + if detail == OutputDetailHidden || detail == OutputDetailFull || (preview && detail == OutputDetailPreview) { + return detail, nil + } + allowed := "hidden or full" + if preview { + allowed = "hidden, preview, or full" + } + return "", fmt.Errorf("output.%s must be %s, got %q", field, allowed, value) +} + +func parseOutputCalls(value string) (OutputCalls, error) { + calls := OutputCalls(strings.ToLower(strings.TrimSpace(value))) + if calls == OutputCallsHidden || calls == OutputCallsCompact { + return calls, nil + } + return "", fmt.Errorf("output.tool_calls must be hidden or compact, got %q", value) +} + +func outputPoliciesEqual(a, b OutputPolicy) bool { + return a.Preset == b.Preset && + a.Reasoning == b.Reasoning && + a.ToolCalls == b.ToolCalls && + a.ToolArguments == b.ToolArguments && + a.ToolResults == b.ToolResults && + a.LiveStatus == b.LiveStatus && + a.Usage == b.Usage +} + +func mergeOutputOptions(dst, src *OutputOptions) { + if dst.Preset == "" { + dst.Preset = src.Preset + } + if dst.Reasoning == "" { + dst.Reasoning = src.Reasoning + } + if dst.ToolCalls == "" { + dst.ToolCalls = src.ToolCalls + } + if dst.ToolArguments == "" { + dst.ToolArguments = src.ToolArguments + } + if dst.ToolResults == "" { + dst.ToolResults = src.ToolResults + } + if dst.LiveStatus == nil { + dst.LiveStatus = src.LiveStatus + } + if dst.Usage == nil { + dst.Usage = src.Usage + } +} diff --git a/core/config/output_test.go b/core/config/output_test.go new file mode 100644 index 00000000..9e78df89 --- /dev/null +++ b/core/config/output_test.go @@ -0,0 +1,146 @@ +package config + +import "testing" + +func boolPtr(value bool) *bool { return &value } + +func TestOutputPresetPolicies(t *testing.T) { + tests := []struct { + name string + option Option + want OutputPolicy + }{ + {name: "default", want: OutputPolicyForPreset(OutputPresetDefault)}, + { + name: "verbose CLI", + option: Option{MiscOptions: MiscOptions{Verbose: []bool{true}}}, + want: OutputPolicyForPreset(OutputPresetVerbose), + }, + { + name: "full CLI", + option: Option{MiscOptions: MiscOptions{Verbose: []bool{true, true}}}, + want: OutputPolicyForPreset(OutputPresetFull), + }, + { + name: "quiet CLI", + option: Option{MiscOptions: MiscOptions{Quiet: true, Verbose: []bool{true, true}}}, + want: OutputPolicyForPreset(OutputPresetQuiet), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := ResolveOutputPolicy(&tc.option) + if err != nil { + t.Fatal(err) + } + if !outputPoliciesEqual(got, tc.want) || got.Custom != tc.want.Custom { + t.Fatalf("policy = %#v, want %#v", got, tc.want) + } + }) + } +} + +func TestOutputConfigOverridesPreset(t *testing.T) { + option := Option{OutputOptions: OutputOptions{ + Preset: "verbose", + Reasoning: "hidden", + ToolArguments: "full", + ToolResults: "hidden", + LiveStatus: boolPtr(false), + Usage: boolPtr(false), + }} + + got, err := ResolveOutputPolicy(&option) + if err != nil { + t.Fatal(err) + } + if got.Reasoning != OutputDetailHidden || got.ToolArguments != OutputDetailFull || + got.ToolResults != OutputDetailHidden || got.LiveStatus || got.Usage || !got.Custom { + t.Fatalf("custom policy = %#v", got) + } +} + +func TestOutputCLIOverridesEntireConfig(t *testing.T) { + for _, tc := range []struct { + name string + verbose []bool + preset OutputPreset + }{ + {name: "verbose", verbose: []bool{true}, preset: OutputPresetVerbose}, + {name: "full", verbose: []bool{true, true}, preset: OutputPresetFull}, + } { + t.Run(tc.name, func(t *testing.T) { + option := Option{ + OutputOptions: OutputOptions{ + Preset: "default", Reasoning: "hidden", ToolCalls: "hidden", + ToolArguments: "full", ToolResults: "hidden", + LiveStatus: boolPtr(false), Usage: boolPtr(false), + }, + MiscOptions: MiscOptions{Verbose: tc.verbose}, + } + + got, err := ResolveOutputPolicy(&option) + if err != nil { + t.Fatal(err) + } + want := OutputPolicyForPreset(tc.preset) + if !outputPoliciesEqual(got, want) || got.Custom { + t.Fatalf("CLI policy = %#v, want %#v", got, want) + } + }) + } +} + +func TestLoadedOutputPresetKeepsUnspecifiedPresetValues(t *testing.T) { + path := writeTestConfig(t, t.TempDir(), ` +output: + preset: verbose +`) + var option Option + if err := LoadConfig(path, &option); err != nil { + t.Fatal(err) + } + + got, err := ResolveOutputPolicy(&option) + if err != nil { + t.Fatal(err) + } + want := OutputPolicyForPreset(OutputPresetVerbose) + if !outputPoliciesEqual(got, want) || got.Custom { + t.Fatalf("loaded preset policy = %#v, want %#v", got, want) + } +} + +func TestOutputPolicyRejectsInvalidValues(t *testing.T) { + tests := []OutputOptions{ + {Preset: "debug"}, + {Reasoning: "preview"}, + {ToolCalls: "full"}, + {ToolArguments: "compact"}, + {ToolResults: "compact"}, + } + for _, opts := range tests { + if _, err := ResolveOutputPolicy(&Option{OutputOptions: opts}); err == nil { + t.Fatalf("ResolveOutputPolicy(%#v) succeeded", opts) + } + } +} + +func TestMergeOutputOptionsKeepsLocalValues(t *testing.T) { + dst := OutputOptions{Preset: "full", ToolResults: "hidden", LiveStatus: boolPtr(false)} + src := OutputOptions{ + Preset: "verbose", Reasoning: "full", ToolCalls: "compact", + ToolArguments: "preview", ToolResults: "full", + LiveStatus: boolPtr(true), Usage: boolPtr(true), + } + mergeOutputOptions(&dst, &src) + + if dst.Preset != "full" || dst.ToolResults != "hidden" || dst.LiveStatus == nil || *dst.LiveStatus { + t.Fatalf("local output values were overwritten: %#v", dst) + } + if dst.Reasoning != "full" || dst.ToolCalls != "compact" || + dst.ToolArguments != "preview" || dst.Usage == nil || !*dst.Usage { + t.Fatalf("config output values were not merged: %#v", dst) + } +} diff --git a/docs/reference.md b/docs/reference.md index cd9b0eec..9d429180 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -89,6 +89,16 @@ ioa: node_name: "" space: "" +# Agent 交互输出 +output: + preset: "default" # default、verbose 或 full + # reasoning: "hidden" # hidden 或 full + # tool_calls: "compact" # hidden 或 compact + # tool_arguments: "hidden" # hidden、preview 或 full + # tool_results: "hidden" # hidden、preview 或 full + # live_status: true # thinking/tooling/talking 瞬时状态 + # usage: true # 瞬时状态中的 token/上下文用量 + # 扫描默认值 scan: verify: "" # auto, off, low, medium, high, critical @@ -101,6 +111,25 @@ misc: no_color: false ``` +### Agent 输出 + +`output.preset` 提供三组基线;未注释的细粒度字段会覆盖所选 preset: + +| 输出项 | `default` | `verbose` / `-v` | `full` / `-vv` | +| --- | --- | --- | --- | +| reasoning | hidden | full | full | +| tool_calls | compact | compact | compact | +| tool_arguments | hidden | preview | preview | +| tool_results | hidden | preview | full | +| live_status | true | true | true | +| usage | true | true | true | + +默认输出只保留紧凑的工具调用摘要,不显示 reasoning、结构化参数或工具结果。`tool_calls: hidden` 是总开关,同时隐藏工具参数和结果。`live_status: false` 只关闭动态状态,仍按策略输出静态工具摘要;`usage: false` 只隐藏动态状态中的 token 和上下文用量,不产生或删除永久统计行。 + +输出优先级为 `-q > -vv > -v > output 配置 > default`。`-q` 只显示最终回答;`-v` 和 `-vv` 会完整覆盖 `output` 中的 preset 和细粒度字段。此配置只影响 Agent 交互输出,不改变 scanner 输出或 `--debug` 日志,也不改变最终回答的 stdout 输出。 + +交互模式下 `Ctrl+O` 按 `default → thinking → full → default` 循环固定 preset。当前为自定义细粒度配置时,第一次按键先切换到 `default`,之后再继续循环。 + --- ## 全局参数 @@ -158,7 +187,8 @@ misc: | 参数 | 说明 | | --- | --- | | `--debug` | 输出调试日志 | -| `-q, --quiet` | 减少日志输出 | +| `-v, --verbose` | 显示完整 reasoning 和预览后的工具参数/结果;重复为 `-vv`,显示完整工具结果 | +| `-q, --quiet` | 只显示最终回答(优先于 `-v/-vv`) | | `--no-color` | 禁用 ANSI 颜色 | | `--version` | 输出版本号并退出 | diff --git a/pkg/commands/bash_test.go b/pkg/commands/bash_test.go index e447d65c..cdd41c81 100644 --- a/pkg/commands/bash_test.go +++ b/pkg/commands/bash_test.go @@ -194,7 +194,9 @@ func TestBashProxyEnvInjection(t *testing.T) { proxy := "socks5://127.0.0.1:1080" bash := commands.NewBashTool(t.TempDir(), 5).WithScannerProxy(proxy) - res, err := bash.Execute(context.Background(), bashArgs("env")) + res, err := bash.Execute(context.Background(), bashArgs( + `env | grep -E '^(ALL_PROXY|all_proxy|HTTP_PROXY|http_proxy|HTTPS_PROXY|https_proxy)='`, + )) if err != nil { t.Fatalf("bash env: %v", err) } diff --git a/pkg/tui/keybindings.go b/pkg/tui/keybindings.go index d9a7f3b3..4087dd98 100644 --- a/pkg/tui/keybindings.go +++ b/pkg/tui/keybindings.go @@ -128,10 +128,7 @@ func (r *AgentConsole) handleToggleVerbosity() { if out == nil { return } - current := out.VerbosityLevel() - next := (current + 1) % 3 - out.SetVerbosity(next) - label := out.VerbosityLabel() + label := out.CycleOutputPreset() if out.color.Enabled { fmt.Fprintf(r.stderr, "\n%s %s\n", out.dim("verbosity:"), diff --git a/pkg/tui/live.go b/pkg/tui/live.go index 33af5a92..20735502 100644 --- a/pkg/tui/live.go +++ b/pkg/tui/live.go @@ -43,6 +43,7 @@ type LiveStatus struct { outputEstimate int contextTokens int contextWindow int + showUsage bool tools map[string]*toolEvent order []string @@ -52,6 +53,12 @@ type LiveStatus struct { renderToolLine func(*toolEvent) string } +func (l *LiveStatus) SetUsageVisible(visible bool) { + if l != nil { + l.showUsage = visible + } +} + func NewLiveStatus(view *LiveView, dim func(string) string, renderToolLine func(*toolEvent) string) *LiveStatus { if dim == nil { dim = func(s string) string { return s } @@ -62,6 +69,7 @@ func NewLiveStatus(view *LiveView, dim func(string) string, renderToolLine func( return &LiveStatus{ view: view, status: liveStatusThinking, + showUsage: true, tools: make(map[string]*toolEvent), dim: dim, renderToolLine: renderToolLine, @@ -367,17 +375,19 @@ func (l *LiveStatus) formatTurnDetails() string { if l.turnToolCalls > 0 { parts = append(parts, fmt.Sprintf("tools=%d", l.turnToolCalls)) } - contextTokens := l.contextTokens - if l.turnUsage != nil { - parts = append(parts, formatTokenUsage(l.turnUsage)) - if l.turnUsage.PromptTokens > 0 { - contextTokens = l.turnUsage.PromptTokens + if l.showUsage { + contextTokens := l.contextTokens + if l.turnUsage != nil { + parts = append(parts, formatTokenUsage(l.turnUsage)) + if l.turnUsage.PromptTokens > 0 { + contextTokens = l.turnUsage.PromptTokens + } + } else if l.outputEstimate > 0 { + parts = append(parts, outputTokenMarker+"≈"+util.FormatNumber(l.outputEstimate)) + } + if context := l.ContextUsage(contextTokens); context != "" { + parts = append(parts, context) } - } else if l.outputEstimate > 0 { - parts = append(parts, outputTokenMarker+"≈"+util.FormatNumber(l.outputEstimate)) - } - if context := l.ContextUsage(contextTokens); context != "" { - parts = append(parts, context) } parts = append(parts, elapsedSentinel) return "[" + strings.Join(parts, " | ") + "]" diff --git a/pkg/tui/output.go b/pkg/tui/output.go index 19c832d8..654a826a 100644 --- a/pkg/tui/output.go +++ b/pkg/tui/output.go @@ -48,6 +48,7 @@ type AgentOutput struct { color output.Color debug bool verbosity int + policy cfg.OutputPolicy stream *StreamWriter aborted bool @@ -111,20 +112,20 @@ func newAgentOutputWithWriters(option *cfg.Option, stdout, stderr io.Writer, ter func newAgentOutput(option *cfg.Option, stdout, stderr io.Writer, stdoutTTY, stderrTTY bool, mode RenderMode) *AgentOutput { debug := false - verbosity := 0 noColor := false model := "" contextWindow := 0 if option != nil { debug = option.Debug - verbosity = len(option.Verbose) - if option.Quiet { - verbosity = -1 - } noColor = option.NoColor model = option.Model contextWindow = option.ContextWindow } + policy, err := cfg.ResolveOutputPolicy(option) + if err != nil { + policy = cfg.OutputPolicyForPreset(cfg.OutputPresetDefault) + } + verbosity := outputPolicyLevel(policy) useColor := !noColor && stderrTTY color := output.NewColor(useColor) lv := NewLiveView(stderr, color.Code(output.ANSICyan)) @@ -132,12 +133,14 @@ func newAgentOutput(option *cfg.Option, stdout, stderr io.Writer, stdoutTTY, std color: color, debug: debug, verbosity: verbosity, - stream: NewStreamWriter(stdout, stderr, stdoutTTY, !noColor && stdoutTTY, color, verbosity), + policy: policy, + stream: NewStreamWriter(stdout, stderr, stdoutTTY, !noColor && stdoutTTY, color, policy.ShowReasoning()), mode: mode, tty: stderrTTY, deltas: make(map[string]*deltaAccumulator), } o.live = NewLiveStatus(lv, o.dim, o.renderToolLine) + o.live.SetUsageVisible(policy.Usage) if contextWindow <= 0 { contextWindow = agent.ModelContextWindow(model) } @@ -188,8 +191,21 @@ func (o *AgentOutput) SetVerbosity(level int) { } o.mu.Lock() defer o.mu.Unlock() - o.verbosity = level - o.stream.verbosity = level + o.applyOutputPolicyLocked(cfg.OutputPolicyForLevel(level)) +} + +func (o *AgentOutput) CycleOutputPreset() string { + if o == nil { + return "default" + } + o.mu.Lock() + defer o.mu.Unlock() + next := 0 + if !o.policy.Custom { + next = (o.verbosity + 1) % 3 + } + o.applyOutputPolicyLocked(cfg.OutputPolicyForLevel(next)) + return o.outputLabelLocked() } func (o *AgentOutput) VerbosityLevel() int { @@ -202,18 +218,54 @@ func (o *AgentOutput) VerbosityLevel() int { } func (o *AgentOutput) VerbosityLabel() string { - switch o.VerbosityLevel() { + if o == nil { + return "default" + } + o.mu.Lock() + defer o.mu.Unlock() + return o.outputLabelLocked() +} + +func (o *AgentOutput) outputLabelLocked() string { + if o.policy.Custom { + return "custom" + } + switch o.verbosity { case -1: return "quiet" case 0: return "default" case 1: - return "tools" - default: return "thinking" + default: + return "full" } } +func (o *AgentOutput) applyOutputPolicyLocked(policy cfg.OutputPolicy) { + o.policy = policy + o.verbosity = outputPolicyLevel(policy) + o.stream.SetReasoning(policy.ShowReasoning()) + o.live.SetUsageVisible(policy.Usage) +} + +func outputPolicyLevel(policy cfg.OutputPolicy) int { + switch policy.Preset { + case cfg.OutputPresetQuiet: + return -1 + case cfg.OutputPresetVerbose: + return 1 + case cfg.OutputPresetFull: + return 2 + default: + return 0 + } +} + +func (o *AgentOutput) quiet() bool { + return o == nil || o.policy.Quiet() +} + // --------------------------------------------------------------------------- // Lifecycle // --------------------------------------------------------------------------- @@ -227,7 +279,7 @@ func (o *AgentOutput) Start(label, text string) { o.stopLive() o.stream.Flush() o.beginRun() - if o.verbosity < 0 { + if o.quiet() { return } label = strings.TrimSpace(label) @@ -250,7 +302,7 @@ func (o *AgentOutput) Start(label, text string) { } func (o *AgentOutput) Empty() { - if o == nil || o.verbosity < 0 { + if o == nil || o.quiet() { return } o.mu.Lock() @@ -296,7 +348,7 @@ func (o *AgentOutput) SetInbox(items []string) { } func (o *AgentOutput) Stopping() { - if o == nil || o.verbosity < 0 { + if o == nil || o.quiet() { return } o.mu.Lock() @@ -306,7 +358,7 @@ func (o *AgentOutput) Stopping() { } func (o *AgentOutput) Stopped() { - if o == nil || o.verbosity < 0 { + if o == nil || o.quiet() { return } o.mu.Lock() @@ -410,7 +462,7 @@ func (o *AgentOutput) HandleEvent(event aop.Event) { o.live.SetOutputEstimate(estimateStreamTokens(acc.text, acc.reasoning)) contentDelta := o.stream.WouldPrintContentDelta(&acc.text) visible := o.stream.WouldPrintDelta(&acc.text, &acc.reasoning) - if o.verbosity >= 0 { + if !o.quiet() { writeDelta := func() { o.stream.Delta(&acc.text, &acc.reasoning) } @@ -457,6 +509,9 @@ func (o *AgentOutput) HandleEvent(event aop.Event) { } o.turnToolCalls++ o.live.SetTurnToolCalls(o.turnToolCalls) + if o.policy.ToolCalls == cfg.OutputCallsHidden || o.quiet() { + return + } ev := &toolEvent{ id: data.ToolCallID, name: data.ToolName, @@ -472,14 +527,14 @@ func (o *AgentOutput) HandleEvent(event aop.Event) { } else { o.live.Stop() o.stream.Flush() - if o.verbosity >= 0 { + if !o.quiet() { name := toolNameOrDefault(ev) w := o.Stderr() fmt.Fprintln(w) fmt.Fprintf(w, "%s%s\n", toolBlockIndent, o.color.Wrap("▸", output.ANSICyan)+" "+o.bold(name)+" "+ o.dim(truncate.Clip(summarizeToolArguments(name, ev.args), 80))) - if o.verbosity >= 1 { + if o.policy.ToolArguments != cfg.OutputDetailHidden { o.printToolArgBlock(w, name, ev.args) } if o.debug { @@ -499,6 +554,9 @@ func (o *AgentOutput) HandleEvent(event aop.Event) { if data.IsError { o.toolErrorCount++ } + if o.policy.ToolCalls == cfg.OutputCallsHidden || o.quiet() { + return + } ev := &toolEvent{ id: data.ToolCallID, name: data.ToolName, @@ -513,11 +571,11 @@ func (o *AgentOutput) HandleEvent(event aop.Event) { } } else { o.stopLive() - if o.verbosity >= 0 { + if !o.quiet() { w := o.Stderr() fmt.Fprintln(w) fmt.Fprintln(w, o.renderToolLine(ev)) - if o.verbosity >= 1 { + if o.policy.ToolResults != cfg.OutputDetailHidden { o.printToolDetail(w, ev) } } @@ -541,7 +599,9 @@ func (o *AgentOutput) HandleEvent(event aop.Event) { o.totalUsage.TotalTokens += usage.TotalTokens o.totalUsage.CacheReadTokens += usage.CacheReadTokens o.totalUsage.CacheWriteTokens += usage.CacheWriteTokens - o.live.SetTurnUsage(usage) + if o.policy.Usage { + o.live.SetTurnUsage(usage) + } if o.canAnimate() { o.live.Render() } @@ -606,7 +666,7 @@ func estimateStreamTokens(parts ...string) int { // --------------------------------------------------------------------------- func (o *AgentOutput) canAnimate() bool { - if o == nil || o.mode != ModeInteractive || !o.tty || o.verbosity < 0 { + if o == nil || o.mode != ModeInteractive || !o.tty || o.quiet() || !o.policy.LiveStatus { return false } if o.readline { @@ -649,8 +709,11 @@ func (o *AgentOutput) printToolDetail(w io.Writer, ev *toolEvent) { name := toolNameOrDefault(ev) if ev.isError { if errText := strings.TrimSpace(ev.result); errText != "" { + if o.policy.ToolResults != cfg.OutputDetailFull { + errText = truncate.Clip(errText, agentStatusPreviewLimit) + } fmt.Fprintf(w, "%s%s\n", toolResultIndent, - o.color.Wrap(truncate.Clip(errText, agentStatusPreviewLimit), output.ANSIRed)) + o.color.Wrap(errText, output.ANSIRed)) } return } @@ -659,7 +722,7 @@ func (o *AgentOutput) printToolDetail(w io.Writer, ev *toolEvent) { return } var preview toolResultPreview - if o.verbosity >= 2 { + if o.policy.ToolResults == cfg.OutputDetailFull { preview = toolResultPreview{lines: normalizeToolResultLines(result)} } else { preview = buildToolResultPreview(name, result, o.debug) @@ -687,6 +750,10 @@ func (o *AgentOutput) printToolDetail(w io.Writer, ev *toolEvent) { } func (o *AgentOutput) printToolArgBlock(w io.Writer, name, arguments string) { + if o.policy.ToolArguments == cfg.OutputDetailFull { + o.printFullToolArguments(w, arguments) + return + } lines := formatToolArguments(name, arguments) if len(lines) == 0 { return @@ -703,6 +770,22 @@ func (o *AgentOutput) printToolArgBlock(w io.Writer, name, arguments string) { } } +func (o *AgentOutput) printFullToolArguments(w io.Writer, arguments string) { + var decoded any + if err := json.Unmarshal([]byte(arguments), &decoded); err != nil { + fmt.Fprintf(w, "%s%s\n", toolArgIndent, arguments) + return + } + pretty, err := json.MarshalIndent(decoded, "", " ") + if err != nil { + fmt.Fprintf(w, "%s%s\n", toolArgIndent, arguments) + return + } + for _, line := range strings.Split(string(pretty), "\n") { + fmt.Fprintf(w, "%s%s\n", toolArgIndent, line) + } +} + func (o *AgentOutput) printPermanentTools(events []*toolEvent) { if len(events) == 0 { return @@ -711,7 +794,10 @@ func (o *AgentOutput) printPermanentTools(events []*toolEvent) { fmt.Fprintln(w) for _, event := range events { fmt.Fprintln(w, o.renderToolLine(event)) - if o.verbosity >= 1 { + if o.policy.ToolArguments != cfg.OutputDetailHidden { + o.printToolArgBlock(w, toolNameOrDefault(event), event.args) + } + if o.policy.ToolResults != cfg.OutputDetailHidden { o.printToolDetail(w, event) } } @@ -764,13 +850,13 @@ func (o *AgentOutput) coloredElapsed(started time.Time) string { // --------------------------------------------------------------------------- func (o *AgentOutput) turnEnd(turn int) { - if o.verbosity < 0 { + if o.quiet() { return } o.stream.Flush() w := o.Stderr() - if o.verbosity >= 2 && o.stream.ReasoningPrinted() == 0 { + if o.policy.ShowReasoning() && o.stream.ReasoningPrinted() == 0 { if reasoning := strings.TrimSpace(messagePartText(o.lastAssistant, aop.PartReasoning)); reasoning != "" { o.renderThinkingBlock(w, reasoning) } diff --git a/pkg/tui/output_test.go b/pkg/tui/output_test.go index 553518e1..5889e3f8 100644 --- a/pkg/tui/output_test.go +++ b/pkg/tui/output_test.go @@ -118,14 +118,17 @@ func usageEvent(input, outputTok, total int) aop.Event { func testOutput(stderr io.Writer, verbosity int, debug bool) *AgentOutput { stdout := &bytes.Buffer{} color := output.NewColor(false) + policy := cfg.OutputPolicyForLevel(verbosity) o := &AgentOutput{ color: color, debug: debug, verbosity: verbosity, - stream: NewStreamWriter(stdout, stderr, true, false, color, verbosity), + policy: policy, + stream: NewStreamWriter(stdout, stderr, true, false, color, policy.ShowReasoning()), deltas: make(map[string]*deltaAccumulator), } o.live = NewLiveStatus(NewLiveView(stderr, ""), o.dim, o.renderToolLine) + o.live.SetUsageVisible(policy.Usage) return o } @@ -157,7 +160,8 @@ func TestAgentOutputFinalWritesPlainMarkdownWithoutWrapper(t *testing.T) { color := output.NewColor(false) o := &AgentOutput{ color: color, - stream: NewStreamWriter(&stdout, &bytes.Buffer{}, true, false, color, 0), + policy: cfg.OutputPolicyForPreset(cfg.OutputPresetDefault), + stream: NewStreamWriter(&stdout, &bytes.Buffer{}, true, false, color, false), deltas: make(map[string]*deltaAccumulator), } o.live = NewLiveStatus(NewLiveView(&bytes.Buffer{}, ""), o.dim, o.renderToolLine) @@ -304,6 +308,31 @@ func TestThinkingLineShowsTurnUsage(t *testing.T) { } } +func TestLiveStatusCanHideUsageDetails(t *testing.T) { + var stdout bytes.Buffer + var stderr syncedBuffer + showUsage := false + o := NewAgentOutputWithWriters(&cfg.Option{ + LLMOptions: cfg.LLMOptions{Model: "gpt-4"}, + OutputOptions: cfg.OutputOptions{Usage: &showUsage}, + }, &stdout, &stderr, true) + defer o.live.Stop() + + o.HandleEvent(turnStartEvent(1)) + o.HandleEvent(usageEvent(4096, 50, 4146)) + o.HandleEvent(textDeltaEvent("m-1", "12345678")) + + got := stripANSI(stderr.String()) + if !strings.Contains(got, "thinking") || !strings.Contains(got, "turn 1") { + t.Fatalf("live status itself was hidden: %q", got) + } + for _, hidden := range []string{"↑", "↓", "◐", "4,096/8,192"} { + if strings.Contains(got, hidden) { + t.Fatalf("usage detail %q leaked into live status: %q", hidden, got) + } + } +} + func TestThinkingLineShowsChangingStreamTokenEstimate(t *testing.T) { var stdout bytes.Buffer var stderr syncedBuffer @@ -578,7 +607,7 @@ func TestThinkingVerboseStreamsReasoningWithoutTags(t *testing.T) { var stdout bytes.Buffer var stderr syncedBuffer o := NewAgentOutputWithWriters(&cfg.Option{ - MiscOptions: cfg.MiscOptions{Verbose: []bool{true, true}}, + MiscOptions: cfg.MiscOptions{Verbose: []bool{true}}, }, &stdout, &stderr, true) defer o.live.Stop() @@ -605,7 +634,7 @@ func TestThinkingVerboseStreamsOnlyReasoningDelta(t *testing.T) { var stdout bytes.Buffer var stderr syncedBuffer o := NewAgentOutputWithWriters(&cfg.Option{ - MiscOptions: cfg.MiscOptions{Verbose: []bool{true, true}}, + MiscOptions: cfg.MiscOptions{Verbose: []bool{true}}, }, &stdout, &stderr, true) defer o.live.Stop() @@ -636,7 +665,7 @@ func TestReadlineThinkingAppendsWithoutSyntheticNewlines(t *testing.T) { redraw: func() {}, } o := NewAgentOutputWithWriters(&cfg.Option{ - MiscOptions: cfg.MiscOptions{Verbose: []bool{true, true}}, + MiscOptions: cfg.MiscOptions{Verbose: []bool{true}}, }, &stdout, &stderr, true) o.SetReadlineMode(bridge, bridge.UpdateStatus) defer o.live.Stop() @@ -751,7 +780,7 @@ func TestReadlineCommitsFinalTextForImageResponse(t *testing.T) { func TestThinkingBlockFinalRenderingHasNoTags(t *testing.T) { var stderr syncedBuffer - o := testOutput(&stderr, 2, false) + o := testOutput(&stderr, 1, false) reasoning := "checking target scope\nprobing admin route" o.HandleEvent(turnStartEvent(1)) @@ -860,6 +889,151 @@ func TestAgentOutputMultiLineResult(t *testing.T) { } } +func TestAgentOutputFullResultIsNotTruncated(t *testing.T) { + var stderr syncedBuffer + o := testOutput(&stderr, 2, false) + result := strings.Join([]string{ + "line1", "line2", "line3", "line4", "line5", "line6", "line7", "line8", "line9", "line10", + "line11", "line12", "line13", "line14", "line15", "line16", "line17", "line18", "line19", "line20", + }, "\n") + + o.HandleEvent(toolResultEvent("call-1", "bash", result, false)) + got := stripANSI(stderr.String()) + if !strings.Contains(got, "line20") || strings.Contains(got, "lines hidden") { + t.Fatalf("full result was truncated: %q", got) + } +} + +func TestAgentOutputDefaultKeepsToolOutputCompact(t *testing.T) { + var stderr syncedBuffer + o := testOutput(&stderr, 0, false) + + o.HandleEvent(toolCallEvent("call-1", "bash", `{"command":"echo compact"}`)) + o.HandleEvent(toolResultEvent("call-1", "bash", "sensitive result body", false)) + got := stripANSI(stderr.String()) + if !strings.Contains(got, "bash") || !strings.Contains(got, "echo compact") { + t.Fatalf("compact summary missing: %q", got) + } + if strings.Contains(got, "command ") || strings.Contains(got, "sensitive result body") { + t.Fatalf("default output leaked tool detail: %q", got) + } +} + +func TestAgentOutputWithoutLiveStatusKeepsStaticToolSummaries(t *testing.T) { + var stdout bytes.Buffer + var stderr syncedBuffer + showLive := false + o := NewAgentOutputWithWriters(&cfg.Option{OutputOptions: cfg.OutputOptions{ + LiveStatus: &showLive, + }}, &stdout, &stderr, true) + + o.HandleEvent(turnStartEvent(1)) + o.HandleEvent(toolCallEvent("call-1", "bash", `{"command":"echo compact"}`)) + o.HandleEvent(toolResultEvent("call-1", "bash", "hidden result body", false)) + + got := stripANSI(stderr.String()) + if o.canAnimate() || liveRunning(o.live) { + t.Fatal("live status remained active after output.live_status=false") + } + if !strings.Contains(got, "bash") || !strings.Contains(got, "echo compact") || !strings.Contains(got, "✓") { + t.Fatalf("static compact tool summary missing: %q", got) + } + if strings.Contains(got, "thinking") || strings.Contains(got, "hidden result body") { + t.Fatalf("disabled live status rendered transient or detailed output: %q", got) + } +} + +func TestAgentOutputSeparatesReasoningAndFinalAnswerStreams(t *testing.T) { + var stdout bytes.Buffer + var stderr syncedBuffer + o := NewAgentOutputWithWriters(&cfg.Option{ + MiscOptions: cfg.MiscOptions{Verbose: []bool{true}}, + }, &stdout, &stderr, true) + defer o.live.Stop() + + reasoning := "reasoning-stream-only" + answer := "final-answer-stream-only" + o.HandleEvent(turnStartEvent(1)) + o.HandleEvent(reasoningDeltaEvent("m-1", reasoning)) + o.HandleEvent(textDeltaEvent("m-1", answer+"\n\n")) + o.HandleEvent(messageEvent("m-1", "assistant", + aop.MessagePart{Type: aop.PartReasoning, Text: reasoning}, + aop.MessagePart{Type: aop.PartText, Text: answer}, + )) + o.HandleEvent(turnEndEvent(1, 0)) + + stdoutText := stripANSI(stdout.String()) + stderrText := stripANSI(stderr.String()) + if !strings.Contains(stdoutText, answer) || strings.Contains(stdoutText, reasoning) { + t.Fatalf("stdout mixed agent streams: %q", stdoutText) + } + if !strings.Contains(stderrText, reasoning) || strings.Contains(stderrText, answer) { + t.Fatalf("stderr mixed agent streams: %q", stderrText) + } +} + +func TestAgentOutputCustomPolicyControlsEachToolSection(t *testing.T) { + var stdout bytes.Buffer + var stderr syncedBuffer + show := false + o := NewAgentOutputWithWriters(&cfg.Option{OutputOptions: cfg.OutputOptions{ + Reasoning: "full", + ToolCalls: "compact", + ToolArguments: "full", + ToolResults: "hidden", + LiveStatus: &show, + Usage: &show, + }}, &stdout, &stderr, true) + + o.HandleEvent(turnStartEvent(1)) + o.HandleEvent(reasoningDeltaEvent("m-1", "custom reasoning")) + o.HandleEvent(toolCallEvent("call-1", "bash", `{"command":"echo a very long custom command"}`)) + o.HandleEvent(toolResultEvent("call-1", "bash", "hidden result", false)) + + got := stripANSI(stderr.String()) + for _, want := range []string{"custom reasoning", "echo a very long custom command"} { + if !strings.Contains(got, want) { + t.Fatalf("custom output missing %q: %q", want, got) + } + } + if strings.Contains(got, "hidden result") { + t.Fatalf("custom output included hidden result: %q", got) + } + if o.VerbosityLabel() != "custom" || o.canAnimate() { + t.Fatalf("custom state label=%q animate=%v", o.VerbosityLabel(), o.canAnimate()) + } +} + +func TestAgentOutputHiddenToolsSuppressesArgumentsAndResults(t *testing.T) { + var stdout bytes.Buffer + var stderr syncedBuffer + o := NewAgentOutputWithWriters(&cfg.Option{OutputOptions: cfg.OutputOptions{ + ToolCalls: "hidden", ToolArguments: "full", ToolResults: "full", + }}, &stdout, &stderr, false) + + o.HandleEvent(toolCallEvent("call-1", "bash", `{"command":"echo hidden"}`)) + o.HandleEvent(toolResultEvent("call-1", "bash", "hidden result", false)) + if got := stripANSI(stderr.String()); strings.Contains(got, "echo hidden") || strings.Contains(got, "hidden result") { + t.Fatalf("hidden tool output was rendered: %q", got) + } +} + +func TestAgentOutputCustomPresetCycleStartsAtDefault(t *testing.T) { + show := false + o := NewAgentOutputWithWriters(&cfg.Option{OutputOptions: cfg.OutputOptions{ + Reasoning: "full", LiveStatus: &show, + }}, &bytes.Buffer{}, &bytes.Buffer{}, false) + + if got := o.VerbosityLabel(); got != "custom" { + t.Fatalf("initial label = %q, want custom", got) + } + for _, want := range []string{"default", "thinking", "full", "default"} { + if got := o.CycleOutputPreset(); got != want { + t.Fatalf("cycle label = %q, want %q", got, want) + } + } +} + func TestFormatToolArguments(t *testing.T) { tests := []struct { name string diff --git a/pkg/tui/stream.go b/pkg/tui/stream.go index 5bb7c232..173c39c0 100644 --- a/pkg/tui/stream.go +++ b/pkg/tui/stream.go @@ -17,7 +17,7 @@ type StreamWriter struct { enabled bool markdown bool color output.Color - verbosity int + reasoning bool printed int // content bytes flushed buf string // paragraph buffer @@ -29,14 +29,20 @@ type StreamWriter struct { streamed bool // any content was streamed this turn } -func NewStreamWriter(stdout, stderr io.Writer, enabled, markdown bool, color output.Color, verbosity int) *StreamWriter { +func NewStreamWriter(stdout, stderr io.Writer, enabled, markdown bool, color output.Color, reasoning bool) *StreamWriter { return &StreamWriter{ stdout: stdout, stderr: stderr, enabled: enabled, markdown: markdown, color: color, - verbosity: verbosity, + reasoning: reasoning, + } +} + +func (w *StreamWriter) SetReasoning(enabled bool) { + if w != nil { + w.reasoning = enabled } } @@ -49,7 +55,7 @@ func (w *StreamWriter) Delta(content, reasoning *string) { // Reasoning: stream incrementally to stderr in dim. This avoids repainting // long wrapped lines in the live view, which terminals cannot erase reliably // without width-aware row accounting. - if w.verbosity >= 2 && reasoning != nil { + if w.reasoning && reasoning != nil { w.reasonFull = *reasoning if len(w.reasonFull) > w.reasonPrt { if !w.reasonOpen { @@ -96,7 +102,7 @@ func (w *StreamWriter) WouldPrintDelta(content, reasoning *string) bool { if w == nil || !w.enabled || w.stdout == nil { return false } - if w.verbosity >= 2 && reasoning != nil { + if w.reasoning && reasoning != nil { if len(*reasoning) > w.reasonPrt { return true } diff --git a/pkg/webagent/agent_test.go b/pkg/webagent/agent_test.go index defcdf1d..630acc9b 100644 --- a/pkg/webagent/agent_test.go +++ b/pkg/webagent/agent_test.go @@ -423,7 +423,7 @@ func TestRunConnectionPushesPTYSessionsOnManagerEvents(t *testing.T) { })) defer srv.Close() - ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() reg := commands.NewRegistry()