mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40:28 -10:00
cli: --reasoning-effort notice evaluates the EFFECTIVE model (works without --model) (MED); unsafe warning fires for --auto high too (MED); --auto value validated even with --skip-permissions-unsafe (LOW); DefaultRegistry built once per exec, not 3x (LOW); --profile kept as intentional legacy-compat accept-and-ignore (contested finding resolved, doc-commented). modelregistry: removed dead FallbackFor. tools: removed dead TaskNonInteractiveMessage + dead OnSandboxDecision hook (no setter anywhere); fixed the now-stale loop.go comment. TDD where behavioral; build/vet/full-suite green. Refs #102 Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
613 lines
21 KiB
Go
613 lines
21 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Gitlawb/zero/internal/agent"
|
|
"github.com/Gitlawb/zero/internal/config"
|
|
"github.com/Gitlawb/zero/internal/modelregistry"
|
|
"github.com/Gitlawb/zero/internal/providers"
|
|
"github.com/Gitlawb/zero/internal/sandbox"
|
|
"github.com/Gitlawb/zero/internal/sessions"
|
|
"github.com/Gitlawb/zero/internal/streamjson"
|
|
"github.com/Gitlawb/zero/internal/worktrees"
|
|
)
|
|
|
|
const (
|
|
exitSuccess = 0
|
|
exitCrash = 1
|
|
exitUsage = 2
|
|
exitProvider = 3
|
|
)
|
|
|
|
type execOutputFormat string
|
|
type execInputFormat string
|
|
|
|
const (
|
|
execOutputText execOutputFormat = "text"
|
|
execOutputJSON execOutputFormat = "json"
|
|
execOutputStreamJSON execOutputFormat = "stream-json"
|
|
execInputText execInputFormat = "text"
|
|
execInputStreamJSON execInputFormat = "stream-json"
|
|
)
|
|
|
|
type execOptions struct {
|
|
promptParts []string
|
|
file string
|
|
mode string
|
|
model string
|
|
// modelProfile captures the legacy --profile flag. It is accepted for
|
|
// backward compatibility (so old invocations do not error) but is
|
|
// intentionally inert: nothing consumes it. Model selection is driven by
|
|
// --model / --mode instead. See writeExecHelp ("Accept legacy model profile
|
|
// selection") and TestRunExecAcceptsLegacyModelProfileFlags.
|
|
modelProfile string
|
|
reasoningEffort string
|
|
maxTurns int
|
|
cwd string
|
|
inputFormat execInputFormat
|
|
outputFormat execOutputFormat
|
|
autonomy string
|
|
enabledTools []string
|
|
disabledTools []string
|
|
listTools bool
|
|
resume string
|
|
resumeLatest bool
|
|
fork string
|
|
worktree bool
|
|
worktreeName string
|
|
worktreeDir string
|
|
skipPermissionsUnsafe bool
|
|
}
|
|
|
|
type execUsageError struct {
|
|
message string
|
|
}
|
|
|
|
func (err execUsageError) Error() string {
|
|
return err.message
|
|
}
|
|
|
|
func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
|
options, help, err := parseExecArgs(args)
|
|
if err != nil {
|
|
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
|
|
}
|
|
if help {
|
|
if err := writeExecHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
// A mode seeds model/effort/max-turns/tool filters as a preset. Expand it up
|
|
// front — before tool-filter validation and the --list-tools branch — so a
|
|
// mode-injected tool filter is validated and reflected in --list-tools, and a
|
|
// mode-supplied model flows through the same resolution (and deprecation
|
|
// notice) path as an explicit --model. Explicit flags still win: applyExecMode
|
|
// only fills fields the caller left unset.
|
|
if err := applyExecMode(&options); err != nil {
|
|
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
|
|
}
|
|
|
|
workspaceRoot, err := resolveWorkspaceRoot(options.cwd, deps)
|
|
if err != nil {
|
|
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
|
|
}
|
|
if options.worktree {
|
|
preparedWorktree, err := deps.prepareWorktree(context.Background(), worktrees.Options{
|
|
Cwd: workspaceRoot,
|
|
Name: options.worktreeName,
|
|
BaseDir: options.worktreeDir,
|
|
Now: deps.now,
|
|
})
|
|
if err != nil {
|
|
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
|
|
}
|
|
workspaceRoot = preparedWorktree.Path
|
|
}
|
|
|
|
registry := newCoreRegistry(workspaceRoot)
|
|
permissionMode, err := resolveExecPermissionMode(options)
|
|
if err != nil {
|
|
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
|
|
}
|
|
mcpRuntime, err := registerMCPToolsForWorkspace(context.Background(), workspaceRoot, registry, deps, execMCPAutonomy(options))
|
|
if err != nil {
|
|
return writeExecProviderError(stdout, stderr, options.outputFormat, "mcp_error", err.Error())
|
|
}
|
|
defer closeMCPRuntime(stderr, mcpRuntime)
|
|
if err := validateExecToolFilters(options, registry); err != nil {
|
|
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
|
|
}
|
|
if options.listTools {
|
|
if options.outputFormat == execOutputStreamJSON {
|
|
return writeExecStreamJSONFinal(stdout, workspaceRoot, execRunMetadata{}, permissionMode, formatExecToolList(registry, options, permissionMode), exitSuccess)
|
|
}
|
|
if err := writeExecToolList(stdout, registry, options, permissionMode); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if err := preflightExecSession(options); err != nil {
|
|
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
|
|
}
|
|
sandboxEngine, err := buildExecSandboxEngine(workspaceRoot, deps)
|
|
if err != nil {
|
|
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", err.Error())
|
|
}
|
|
|
|
prompt, err := resolveExecPrompt(options, workspaceRoot, deps.stdin)
|
|
if err != nil {
|
|
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
|
|
}
|
|
|
|
// Build the model registry once and reuse it across every model-aware
|
|
// lookup in this exec run (model resolution, reasoning-effort advisory, and
|
|
// context-window sizing). DefaultRegistry builds the full catalog and
|
|
// compiles its match patterns, so rebuilding it per lookup is wasteful.
|
|
// modelRegistry is the zero Registry when the catalog fails to build; the
|
|
// helpers below degrade to safe no-op behavior in that case.
|
|
modelRegistry, _ := modelregistry.DefaultRegistry()
|
|
|
|
overrides := config.Overrides{}
|
|
if options.model != "" {
|
|
resolvedModel, notice := resolveSelectedModel(modelRegistry, options.model)
|
|
overrides.Provider.Model = resolvedModel
|
|
if notice != "" {
|
|
fmt.Fprintln(stderr, notice)
|
|
}
|
|
}
|
|
if options.maxTurns > 0 {
|
|
overrides.MaxTurns = options.maxTurns
|
|
}
|
|
resolved, err := deps.resolveConfig(workspaceRoot, overrides)
|
|
if err != nil {
|
|
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", err.Error())
|
|
}
|
|
if resolved.Provider == (config.ProviderProfile{}) {
|
|
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", "No provider configured. Set OPENAI_MODEL/OPENAI_API_KEY or add .zero/config.json.")
|
|
}
|
|
// Evaluate the --reasoning-effort advisory against the EFFECTIVE resolved
|
|
// model (resolved.Provider.Model), not the override. Without an explicit
|
|
// --model the override model is empty, so checking it here would silently
|
|
// skip the advisory even though the run uses a concrete effective model.
|
|
if options.reasoningEffort != "" {
|
|
if notice := reasoningEffortNotice(modelRegistry, resolved.Provider.Model, options.reasoningEffort); notice != "" {
|
|
fmt.Fprintln(stderr, notice)
|
|
}
|
|
}
|
|
|
|
provider, err := buildProvider(resolved, deps)
|
|
if err != nil {
|
|
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", err.Error())
|
|
}
|
|
runMetadata, err := resolveExecRunMetadata(resolved.Provider)
|
|
if err != nil {
|
|
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", err.Error())
|
|
}
|
|
|
|
preparedSession := sessions.PreparedExec{}
|
|
agentPrompt := prompt
|
|
if shouldUseExecSession(options) {
|
|
preparedSession, err = sessions.PrepareExec(sessions.PrepareExecOptions{
|
|
Title: createSessionTitle(prompt),
|
|
Cwd: workspaceRoot,
|
|
ModelID: resolved.Provider.Model,
|
|
Provider: runMetadata.Provider,
|
|
Resume: options.resume,
|
|
ResumeLatest: options.resumeLatest,
|
|
Fork: options.fork,
|
|
})
|
|
if err != nil {
|
|
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
|
|
}
|
|
agentPrompt = sessions.FormatExecPrompt(prompt, preparedSession)
|
|
}
|
|
runID, err := streamjson.CreateRunID(time.Now())
|
|
if err != nil {
|
|
return writeAppError(stderr, "failed to create run id: "+err.Error(), exitCrash)
|
|
}
|
|
writer := execEventWriter{
|
|
stdout: stdout,
|
|
stderr: stderr,
|
|
format: options.outputFormat,
|
|
runID: runID,
|
|
sessionID: preparedSession.Session.SessionID,
|
|
streamedText: &strings.Builder{},
|
|
}
|
|
writer.runStart(workspaceRoot, runMetadata, permissionMode)
|
|
if writer.err != nil {
|
|
return exitCrash
|
|
}
|
|
// Surface the unsafe-permissions warning whenever the run resolves to unsafe
|
|
// mode, covering BOTH --skip-permissions-unsafe and --auto high (which also
|
|
// resolves to PermissionModeUnsafe). Previously only the explicit flag path
|
|
// warned, so --auto high silently ran without notice.
|
|
if permissionMode == agent.PermissionModeUnsafe {
|
|
reason := "--auto high"
|
|
if options.skipPermissionsUnsafe {
|
|
reason = "--skip-permissions-unsafe"
|
|
}
|
|
writer.warning(fmt.Sprintf("Unsafe permissions are active for this run because %s was passed.", reason))
|
|
if writer.err != nil {
|
|
return exitCrash
|
|
}
|
|
}
|
|
|
|
sessionRecorder := execSessionRecorder{prepared: preparedSession}
|
|
sessionRecorder.append(sessions.EventMessage, map[string]any{
|
|
"role": "user",
|
|
"content": prompt,
|
|
})
|
|
|
|
// OnAskUser is intentionally left unset: headless runs have no interactive
|
|
// user, so ask_user degrades to a "proceed with your best assumption" result
|
|
// rather than blocking. (Future enhancement: collect answers over stream-json
|
|
// input when a controlling client is attached.)
|
|
result, err := agent.Run(context.Background(), agentPrompt, provider, agent.Options{
|
|
MaxTurns: resolved.MaxTurns,
|
|
ContextWindow: modelContextWindow(modelRegistry, resolved.Provider.Model),
|
|
Registry: registry,
|
|
PermissionMode: permissionMode,
|
|
Autonomy: options.autonomy,
|
|
Sandbox: sandboxEngine,
|
|
EnabledTools: options.enabledTools,
|
|
DisabledTools: options.disabledTools,
|
|
OnText: writer.text,
|
|
OnToolCall: func(call agent.ToolCall) {
|
|
writer.toolCall(call, registry)
|
|
sessionRecorder.append(sessions.EventToolCall, map[string]any{
|
|
"id": call.ID,
|
|
"name": call.Name,
|
|
"arguments": call.Arguments,
|
|
})
|
|
// Snapshot before-state of files this call will mutate (safe rewind).
|
|
if checkpoint, ok := sessionRecorder.captureCheckpoint(workspaceRoot, call); ok {
|
|
writer.checkpoint(checkpoint)
|
|
}
|
|
},
|
|
OnPermission: func(event agent.PermissionEvent) {
|
|
writer.permission(event)
|
|
sessionRecorder.append(sessionPermissionEventType(event), event)
|
|
},
|
|
OnToolResult: func(result agent.ToolResult) {
|
|
writer.toolResult(result)
|
|
payload := map[string]any{
|
|
"toolCallId": result.ToolCallID,
|
|
"name": result.Name,
|
|
"status": string(result.Status),
|
|
"output": result.Output,
|
|
}
|
|
if len(result.Meta) > 0 {
|
|
payload["meta"] = result.Meta
|
|
}
|
|
if result.Redacted {
|
|
payload["redacted"] = true
|
|
}
|
|
if len(result.ChangedFiles) > 0 {
|
|
payload["changedFiles"] = result.ChangedFiles
|
|
}
|
|
sessionRecorder.append(sessions.EventToolResult, payload)
|
|
},
|
|
OnUsage: func(usage agent.Usage) {
|
|
writer.usage(usage)
|
|
sessionRecorder.append(sessions.EventUsage, map[string]any{
|
|
"promptTokens": usage.EffectiveInputTokens(),
|
|
"completionTokens": usage.EffectiveOutputTokens(),
|
|
"totalTokens": usage.TotalTokens(),
|
|
})
|
|
},
|
|
})
|
|
if writer.err != nil {
|
|
return exitCrash
|
|
}
|
|
if err != nil {
|
|
sessionRecorder.append(sessions.EventError, map[string]any{"message": err.Error()})
|
|
if options.outputFormat == execOutputStreamJSON {
|
|
writer.errorEvent("provider_error", err.Error(), false)
|
|
writer.runEnd("error", exitProvider)
|
|
if writer.err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitProvider
|
|
}
|
|
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", err.Error())
|
|
}
|
|
sessionRecorder.append(sessions.EventMessage, map[string]any{
|
|
"role": "assistant",
|
|
"content": result.FinalAnswer,
|
|
})
|
|
|
|
writer.final(result.FinalAnswer)
|
|
writer.runEnd("success", exitSuccess)
|
|
if writer.err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
func buildExecSandboxEngine(workspaceRoot string, deps appDeps) (*sandbox.Engine, error) {
|
|
store, err := deps.newSandboxStore()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
policy := sandbox.DefaultPolicy()
|
|
backend := deps.selectSandboxBackend(sandbox.BackendOptions{})
|
|
return sandbox.NewEngine(sandbox.EngineOptions{
|
|
WorkspaceRoot: workspaceRoot,
|
|
Policy: policy,
|
|
Store: store,
|
|
Backend: backend,
|
|
}), nil
|
|
}
|
|
|
|
func resolveWorkspaceRoot(cwd string, deps appDeps) (string, error) {
|
|
current, err := deps.getwd()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to resolve workspace: %w", err)
|
|
}
|
|
|
|
workspaceRoot := strings.TrimSpace(cwd)
|
|
if workspaceRoot == "" {
|
|
workspaceRoot = current
|
|
} else if !filepath.IsAbs(workspaceRoot) {
|
|
workspaceRoot = filepath.Join(current, workspaceRoot)
|
|
}
|
|
workspaceRoot = filepath.Clean(workspaceRoot)
|
|
|
|
info, err := os.Stat(workspaceRoot)
|
|
if err != nil || !info.IsDir() {
|
|
return "", execUsageError{fmt.Sprintf("cwd must be an existing directory: %s", workspaceRoot)}
|
|
}
|
|
return workspaceRoot, nil
|
|
}
|
|
|
|
func resolveExecPrompt(options execOptions, workspaceRoot string, stdin io.Reader) (string, error) {
|
|
if options.inputFormat == execInputStreamJSON {
|
|
input := ""
|
|
if options.file != "" {
|
|
promptPath := options.file
|
|
if !filepath.IsAbs(promptPath) {
|
|
promptPath = filepath.Join(workspaceRoot, promptPath)
|
|
}
|
|
data, err := os.ReadFile(promptPath)
|
|
if err != nil {
|
|
return "", execUsageError{fmt.Sprintf("prompt file not found: %s", promptPath)}
|
|
}
|
|
input = string(data)
|
|
} else {
|
|
data, err := io.ReadAll(stdin)
|
|
if err != nil {
|
|
return "", execUsageError{fmt.Sprintf("failed to read stream-json input: %v", err)}
|
|
}
|
|
input = string(data)
|
|
}
|
|
prompt, err := streamjson.ParsePrompt(input)
|
|
if err != nil {
|
|
return "", execUsageError{err.Error()}
|
|
}
|
|
return prompt, nil
|
|
}
|
|
|
|
parts := []string{}
|
|
inlinePrompt := strings.TrimSpace(strings.Join(options.promptParts, " "))
|
|
if inlinePrompt != "" {
|
|
parts = append(parts, inlinePrompt)
|
|
}
|
|
|
|
if options.file != "" {
|
|
promptPath := options.file
|
|
if !filepath.IsAbs(promptPath) {
|
|
promptPath = filepath.Join(workspaceRoot, promptPath)
|
|
}
|
|
data, err := os.ReadFile(promptPath)
|
|
if err != nil {
|
|
return "", execUsageError{fmt.Sprintf("prompt file not found: %s", promptPath)}
|
|
}
|
|
filePrompt := strings.TrimSpace(string(data))
|
|
if filePrompt == "" {
|
|
return "", execUsageError{fmt.Sprintf("prompt file is empty: %s", promptPath)}
|
|
}
|
|
parts = append(parts, filePrompt)
|
|
}
|
|
|
|
prompt := strings.TrimSpace(strings.Join(parts, "\n\n"))
|
|
if prompt == "" {
|
|
return "", execUsageError{"Prompt required. Use `zero exec \"prompt\"` or `zero exec --file prompt.txt`."}
|
|
}
|
|
return prompt, nil
|
|
}
|
|
|
|
func writeExecUsageError(stderr io.Writer, message string) int {
|
|
if _, err := fmt.Fprintf(stderr, "[zero] %s\n", message); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitUsage
|
|
}
|
|
|
|
func writeExecFormatUsageError(stdout io.Writer, stderr io.Writer, format execOutputFormat, message string) int {
|
|
if format == execOutputStreamJSON {
|
|
return writeStreamJSONError(stdout, "usage_error", message, false, exitUsage)
|
|
}
|
|
return writeExecUsageError(stderr, message)
|
|
}
|
|
|
|
func writeExecProviderError(stdout io.Writer, stderr io.Writer, format execOutputFormat, code string, message string) int {
|
|
if format == execOutputStreamJSON {
|
|
return writeStreamJSONError(stdout, code, message, false, exitProvider)
|
|
}
|
|
if format == execOutputJSON {
|
|
if err := writeJSONLine(stdout, map[string]any{
|
|
"type": "error",
|
|
"code": code,
|
|
"message": message,
|
|
}); err != nil {
|
|
return exitCrash
|
|
}
|
|
if err := writeJSONLine(stdout, map[string]any{
|
|
"type": "done",
|
|
"exit_code": exitProvider,
|
|
}); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitProvider
|
|
}
|
|
if _, err := fmt.Fprintf(stderr, "[zero] %s\n", message); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitProvider
|
|
}
|
|
|
|
// applyExecMode expands a --mode preset onto the exec options. The preset only
|
|
// fills fields the caller left unset, so an explicit --model / --reasoning-effort
|
|
// / --max-turns / tool filter always wins over the mode. The mode's model is left
|
|
// as the preset's raw id/alias so the shared --model resolution path resolves it
|
|
// through the registry (canonical ids/deprecation fallbacks) AND surfaces any
|
|
// deprecation notice on stderr, exactly like an explicit --model. An unknown mode
|
|
// is a usage error listing the valid presets.
|
|
func applyExecMode(options *execOptions) error {
|
|
name := strings.TrimSpace(options.mode)
|
|
if name == "" {
|
|
return nil
|
|
}
|
|
mode, ok := modelregistry.LookupMode(name)
|
|
if !ok {
|
|
return execUsageError{fmt.Sprintf("unknown mode %q. Valid modes: %s.", options.mode, strings.Join(modelregistry.ModeNames(), ", "))}
|
|
}
|
|
if options.model == "" && mode.Model != "" {
|
|
options.model = mode.Model
|
|
}
|
|
if options.reasoningEffort == "" && mode.Effort != "" {
|
|
options.reasoningEffort = string(mode.Effort)
|
|
}
|
|
if options.maxTurns == 0 && mode.MaxTurns > 0 {
|
|
options.maxTurns = mode.MaxTurns
|
|
}
|
|
if len(options.enabledTools) == 0 && len(mode.EnabledTools) > 0 {
|
|
options.enabledTools = append([]string{}, mode.EnabledTools...)
|
|
}
|
|
if len(options.disabledTools) == 0 && len(mode.DisabledTools) > 0 {
|
|
options.disabledTools = append([]string{}, mode.DisabledTools...)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// resolveSelectedModel routes a user-supplied --model value through the model
|
|
// registry so that fuzzy aliases (e.g. "sonnet 4.5") resolve to canonical ids
|
|
// and deprecated models auto-redirect to their fallback. It returns the model id
|
|
// to use plus a non-empty notice when a deprecation redirect or warning applies.
|
|
// Inputs that the registry does not recognize (e.g. custom openai-compatible
|
|
// model names) are returned unchanged so provider passthrough still works.
|
|
func resolveSelectedModel(registry modelregistry.Registry, input string) (string, string) {
|
|
trimmed := strings.TrimSpace(input)
|
|
if trimmed == "" {
|
|
return input, ""
|
|
}
|
|
entry, notice, ok := registry.ResolveWithFallback(trimmed)
|
|
if !ok {
|
|
return input, ""
|
|
}
|
|
return entry.ID, notice
|
|
}
|
|
|
|
// modelContextWindow returns the resolved model's context window (max input
|
|
// tokens) from the model registry, used to enable agent-loop compaction. An
|
|
// unknown model (e.g. a custom openai-compatible name) returns 0, which leaves
|
|
// compaction DISABLED — a safe default that never compacts unexpectedly.
|
|
func modelContextWindow(registry modelregistry.Registry, modelID string) int {
|
|
trimmed := strings.TrimSpace(modelID)
|
|
if trimmed == "" {
|
|
return 0
|
|
}
|
|
entry, ok := registry.Resolve(trimmed)
|
|
if !ok {
|
|
return 0
|
|
}
|
|
return entry.ContextLimits.ContextWindow
|
|
}
|
|
|
|
// reasoningEffortNotice resolves the requested --reasoning-effort against the
|
|
// selected model's supported efforts via EffectiveReasoningEffort and returns a
|
|
// short advisory when the requested value is unsupported (and was coerced to the
|
|
// model default).
|
|
//
|
|
// NOTE: the effective effort is not yet forwarded to the provider request — the
|
|
// zeroruntime.CompletionRequest / provider wire schemas carry no effort field.
|
|
// Full provider-request propagation is deferred (see slice-3 report).
|
|
func reasoningEffortNotice(registry modelregistry.Registry, modelID string, requested string) string {
|
|
trimmed := strings.TrimSpace(modelID)
|
|
if trimmed == "" {
|
|
return ""
|
|
}
|
|
entry, ok := registry.Get(trimmed)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
want := modelregistry.ReasoningEffort(strings.TrimSpace(strings.ToLower(requested)))
|
|
effective := modelregistry.EffectiveReasoningEffort(entry, want)
|
|
if effective == modelregistry.ReasoningEffortNone {
|
|
return fmt.Sprintf("%s does not support reasoning effort; ignoring --reasoning-effort %s", entry.ID, requested)
|
|
}
|
|
if want != "" && effective != want {
|
|
return fmt.Sprintf("reasoning effort %q is not supported by %s; using %s instead", requested, entry.ID, effective)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func resolveExecRunMetadata(profile config.ProviderProfile) (execRunMetadata, error) {
|
|
metadata, err := providers.ResolveRuntimeMetadata(profile, providers.Options{})
|
|
if err != nil {
|
|
return execRunMetadata{}, err
|
|
}
|
|
provider := strings.TrimSpace(string(metadata.ProviderKind))
|
|
if provider == "" {
|
|
provider = strings.TrimSpace(profile.Name)
|
|
}
|
|
apiModel := strings.TrimSpace(metadata.APIModel)
|
|
if apiModel == "" {
|
|
apiModel = strings.TrimSpace(profile.Model)
|
|
}
|
|
return execRunMetadata{
|
|
Provider: provider,
|
|
Model: strings.TrimSpace(profile.Model),
|
|
APIModel: apiModel,
|
|
}, nil
|
|
}
|
|
|
|
func writeExecStreamJSONFinal(stdout io.Writer, cwd string, metadata execRunMetadata, permissionMode agent.PermissionMode, text string, exitCode int) int {
|
|
runID, err := streamjson.CreateRunID(time.Now())
|
|
if err != nil {
|
|
return exitCrash
|
|
}
|
|
writer := execEventWriter{
|
|
stdout: stdout,
|
|
format: execOutputStreamJSON,
|
|
runID: runID,
|
|
streamedText: &strings.Builder{},
|
|
}
|
|
writer.runStart(cwd, metadata, permissionMode)
|
|
writer.final(text)
|
|
writer.runEnd("success", exitCode)
|
|
if writer.err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitCode
|
|
}
|
|
|
|
func sessionPermissionEventType(event agent.PermissionEvent) sessions.EventType {
|
|
if event.Action == agent.PermissionActionPrompt {
|
|
return sessions.EventPermissionRequest
|
|
}
|
|
if event.Action == agent.PermissionActionAllow || event.Action == agent.PermissionActionDeny {
|
|
return sessions.EventPermissionDecision
|
|
}
|
|
return sessions.EventPermission
|
|
}
|