Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cmd/aiscan/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
7 changes: 6 additions & 1 deletion core/config/config_gen.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down
3 changes: 3 additions & 0 deletions core/config/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
1 change: 1 addition & 0 deletions core/config/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
12 changes: 12 additions & 0 deletions core/config/loader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package config
import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/chainreactors/aiscan/pkg/telemetry"
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion core/config/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Expand Down Expand Up @@ -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"`
Expand Down
220 changes: 220 additions & 0 deletions core/config/output.go
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading