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
28 changes: 16 additions & 12 deletions docs/design/agent-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,20 +36,25 @@ the first validation of a delegated local login.

## Current resolution order

The current runner path resolves values in these stages:
The runner path resolves values in these stages:

1. Load the eval YAML and apply `--engine` and `--model` overrides.
1. Load the eval YAML, apply `--engine`, and retain the raw `--model` value.
2. Load `~/.skill-up/credentials.yaml` and provider-scoped environment values.
3. Resolve provider-scoped `MODEL`, `API_KEY`, and `BASE_URL` values. A
3. Build one role-aware `ResolvedAgentConfig`. Legacy slash disambiguation is
performed once at this point instead of mutating and later repairing the
loaded eval config.
4. Resolve provider-scoped `MODEL`, `API_KEY`, and `BASE_URL` values. A
provider-scoped model environment variable currently overrides the YAML
model; provider environment credentials override the credential file.
4. Apply explicit CLI `--model` and `--api-key` last.
5. Let the selected adapter normalize unsupported values and construct its
5. Preserve explicit CLI `--model` and `--api-key` precedence.
6. Pass the resolved value to the selected adapter, which constructs its
command and environment.

This explains why requested and effective values can differ today. A later
phase should retain both instead of reconstructing effective configuration from
the eval YAML in reports.
Runner and judge roles use the same resolution flow. Until an explicit judge
engine schema is introduced, the judge inherits the runner engine lifecycle and
kwargs, while resolving its provider/model and credentials as a separate role.
Reports use the resolved runner engine/model identity rather than reconstructing
it from a CLI-mutated eval config.

## Legacy slashed model compatibility

Expand Down Expand Up @@ -88,14 +93,13 @@ These translations are covered by `action/main_test.py`. Any future explicit

## Known gaps for later phases

- Provider, protocol, credential source, and effective model are not yet held
in one immutable resolved configuration.
- Protocol and adapter capabilities are not yet declared on the resolved value.
- Nested provider endpoints are flattened before the adapter protocol is known.
- Provider-scoped `MODEL` currently overrides an explicit YAML model.
- Adapters may ignore unsupported explicit values rather than failing before
case execution.
- Reports do not consistently distinguish requested configuration from the
effective adapter configuration.
- Reports use the resolved runner identity but do not yet distinguish every
requested value from the adapter's effective configuration.

See [Issue #196](https://github.com/alibaba/skill-up/issues/196) for the staged
cleanup plan.
2 changes: 1 addition & 1 deletion internal/agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ Both are parsed by **`parseSessionFile`** in `internal/agent/claude_code.go` (`c
```
internal/agent/
├── agent.go # Core interface definitions: Agent, SessionResult, BaseAgent
├── factory.go # DetectAgent / DetectAgentWithInitParams factory functions
├── factory.go # DetectAgent / DetectAgentWithResolvedConfig factory functions
├── claude_code.go # ClaudeCodeAgent implementation
├── qodercli.go # QoderCLIAgent implementation
├── codex.go # CodexAgent implementation (OpenAI Codex CLI)
Expand Down
80 changes: 45 additions & 35 deletions internal/agent/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -538,17 +538,18 @@ func TestProbeAndMergePATH_SkipsMergeOnEmptyStdout(t *testing.T) {
}
}

func TestDetectAgentWithInitParams_SetsTypedCredentialFields(t *testing.T) {
func TestDetectAgentWithResolvedConfig_SetsTypedCredentialFields(t *testing.T) {
t.Parallel()

ag, err := DetectAgentWithInitParams("codex", credential.AgentInitParams{
ag, err := DetectAgentWithResolvedConfig(credential.ResolvedAgentConfig{
Engine: "codex",
Provider: "openai",
Model: "gpt-5.4",
APIKey: "openai-test-token",
BaseURL: "https://openai.example.com/v1",
}, nil)
})
if err != nil {
t.Fatalf("DetectAgentWithInitParams failed: %v", err)
t.Fatalf("DetectAgentWithResolvedConfig failed: %v", err)
}

codexAgent, ok := ag.(*CodexAgent)
Expand All @@ -569,16 +570,17 @@ func TestDetectAgentWithInitParams_SetsTypedCredentialFields(t *testing.T) {
}
}

func TestDetectAgentWithInitParams_QoderMapsAPIKeyToRuntimeEnv(t *testing.T) {
func TestDetectAgentWithResolvedConfig_QoderMapsAPIKeyToRuntimeEnv(t *testing.T) {
token := "qoder-runtime-token" //nolint:gosec // test credential, not real
t.Setenv(credential.EnvQoderPersonalAccessToken, token)

ag, err := DetectAgentWithInitParams("qoder-cli", credential.AgentInitParams{
ag, err := DetectAgentWithResolvedConfig(credential.ResolvedAgentConfig{
Engine: "qoder-cli",
Provider: "qoder",
Model: "auto",
}, nil)
})
if err != nil {
t.Fatalf("DetectAgentWithInitParams failed: %v", err)
t.Fatalf("DetectAgentWithResolvedConfig failed: %v", err)
}

qoderAgent, ok := ag.(*QoderCLIAgent)
Expand All @@ -590,17 +592,19 @@ func TestDetectAgentWithInitParams_QoderMapsAPIKeyToRuntimeEnv(t *testing.T) {
}
}

func TestDetectAgentWithInitParams_QoderCNMapsKeychainAliasToOfficialEnv(t *testing.T) {
func TestDetectAgentWithResolvedConfig_QoderCNMapsKeychainAliasToOfficialEnv(t *testing.T) {
token := "qoder-cn-runtime-token" //nolint:gosec // test credential, not real
t.Setenv(credential.EnvQoderCNAccessToken, token)
t.Setenv(credential.EnvQoderCNPersonalAccessToken, "")

ag, err := DetectAgentWithInitParams("qoder-cli", credential.AgentInitParams{
ag, err := DetectAgentWithResolvedConfig(credential.ResolvedAgentConfig{
Engine: "qoder-cli",
Provider: "qoder",
Model: "auto",
}, map[string]string{KwargEdition: qoderEditionCN})
Kwargs: map[string]string{KwargEdition: qoderEditionCN},
})
if err != nil {
t.Fatalf("DetectAgentWithInitParams failed: %v", err)
t.Fatalf("DetectAgentWithResolvedConfig failed: %v", err)
}

qoderAgent, ok := ag.(*QoderCLIAgent)
Expand All @@ -615,15 +619,16 @@ func TestDetectAgentWithInitParams_QoderCNMapsKeychainAliasToOfficialEnv(t *test
}
}

func TestDetectAgentWithInitParams_QoderCNPrefersOfficialEnv(t *testing.T) {
func TestDetectAgentWithResolvedConfig_QoderCNPrefersOfficialEnv(t *testing.T) {
t.Setenv(credential.EnvQoderCNPersonalAccessToken, "official-token")
t.Setenv(credential.EnvQoderCNAccessToken, "alias-token")

ag, err := DetectAgentWithInitParams("qoder-cli", credential.AgentInitParams{}, map[string]string{
KwargEdition: qoderEditionCN,
ag, err := DetectAgentWithResolvedConfig(credential.ResolvedAgentConfig{
Engine: "qoder-cli",
Kwargs: map[string]string{KwargEdition: qoderEditionCN},
})
if err != nil {
t.Fatalf("DetectAgentWithInitParams failed: %v", err)
t.Fatalf("DetectAgentWithResolvedConfig failed: %v", err)
}
qoderAgent, ok := ag.(*QoderCLIAgent)
if !ok {
Expand All @@ -634,16 +639,17 @@ func TestDetectAgentWithInitParams_QoderCNPrefersOfficialEnv(t *testing.T) {
}
}

func TestDetectAgentWithInitParams_QoderIgnoresParamsAPIKey(t *testing.T) {
func TestDetectAgentWithResolvedConfig_QoderIgnoresParamsAPIKey(t *testing.T) {
t.Parallel()

ag, err := DetectAgentWithInitParams("qoder-cli", credential.AgentInitParams{ //nolint:gosec // test dummy key
ag, err := DetectAgentWithResolvedConfig(credential.ResolvedAgentConfig{ //nolint:gosec // test dummy key
Engine: "qoder-cli",
Provider: "anthropic",
Model: "auto",
APIKey: "sk-ant-should-not-appear",
}, nil)
})
if err != nil {
t.Fatalf("DetectAgentWithInitParams failed: %v", err)
t.Fatalf("DetectAgentWithResolvedConfig failed: %v", err)
}

qoderAgent, ok := ag.(*QoderCLIAgent)
Expand Down Expand Up @@ -678,33 +684,35 @@ func TestUnsupportedAgentError(t *testing.T) {
}
}

func TestDetectAgentWithInitParams_StripsAutoForNonQoderEngines(t *testing.T) {
func TestDetectAgentWithResolvedConfig_UsesResolvedModelWithoutNormalization(t *testing.T) {
t.Parallel()

ag, err := DetectAgentWithInitParams("claude-code", credential.AgentInitParams{
Model: "auto",
}, nil)
ag, err := DetectAgentWithResolvedConfig(credential.ResolvedAgentConfig{
Engine: "claude-code",
Model: "",
})
if err != nil {
t.Fatalf("DetectAgentWithInitParams failed: %v", err)
t.Fatalf("DetectAgentWithResolvedConfig failed: %v", err)
}

ccAgent, ok := ag.(*ClaudeCodeAgent)
if !ok {
t.Fatalf("expected *ClaudeCodeAgent, got %T", ag)
}
if got := ccAgent.Cfg.ModelName; got != "" {
t.Fatalf("ModelName = %q, want empty (auto should be stripped for claude-code)", got)
t.Fatalf("ModelName = %q, want the already-resolved empty value", got)
}
}

func TestDetectAgentWithInitParams_PreservesAutoForQoderCLI(t *testing.T) {
func TestDetectAgentWithResolvedConfig_PreservesAutoForQoderCLI(t *testing.T) {
t.Parallel()

ag, err := DetectAgentWithInitParams("qoder-cli", credential.AgentInitParams{
Model: "auto",
}, nil)
ag, err := DetectAgentWithResolvedConfig(credential.ResolvedAgentConfig{
Engine: "qoder-cli",
Model: "auto",
})
if err != nil {
t.Fatalf("DetectAgentWithInitParams failed: %v", err)
t.Fatalf("DetectAgentWithResolvedConfig failed: %v", err)
}

qoderAgent, ok := ag.(*QoderCLIAgent)
Expand All @@ -716,16 +724,18 @@ func TestDetectAgentWithInitParams_PreservesAutoForQoderCLI(t *testing.T) {
}
}

func TestDetectAgentWithInitParams_ForwardsKwargs(t *testing.T) {
func TestDetectAgentWithResolvedConfig_ForwardsKwargs(t *testing.T) {
t.Parallel()

kwargs := map[string]string{KwargBypassSandbox: "true", "future_key": "x"}
ag, err := DetectAgentWithInitParams("codex", credential.AgentInitParams{
ag, err := DetectAgentWithResolvedConfig(credential.ResolvedAgentConfig{
Engine: "codex",
Provider: "openai",
Model: "gpt-5.4",
}, kwargs)
Kwargs: kwargs,
})
if err != nil {
t.Fatalf("DetectAgentWithInitParams failed: %v", err)
t.Fatalf("DetectAgentWithResolvedConfig failed: %v", err)
}

codexAgent, ok := ag.(*CodexAgent)
Expand Down
9 changes: 5 additions & 4 deletions internal/agent/custom_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -882,18 +882,19 @@ func TestDetectAgent_NonBuiltinWithoutCustom(t *testing.T) {
}
}

func TestDetectAgentWithInitParams_KeepsAutoModelForCustom(t *testing.T) {
func TestDetectAgentWithResolvedConfig_KeepsAutoModelForCustom(t *testing.T) {
t.Parallel()
custom := &config.CustomEngineConfig{
Transport: "local",
Local: &config.CustomLocalConfig{Command: "/opt/agent"},
}
ag, err := DetectAgentWithInitParams("my-agent", credential.AgentInitParams{
ag, err := DetectAgentWithResolvedConfig(credential.ResolvedAgentConfig{
Engine: "my-agent",
Model: modelAuto,
Custom: custom,
}, nil)
})
if err != nil {
t.Fatalf("DetectAgentWithInitParams: %v", err)
t.Fatalf("DetectAgentWithResolvedConfig: %v", err)
}
ca, ok := ag.(*CustomAgent)
if !ok {
Expand Down
38 changes: 10 additions & 28 deletions internal/agent/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,38 +33,29 @@ func DetectAgent(engineName string, cfg Config) (Agent, error) {
}
}

// DetectAgentWithInitParams maps resolved init params into an engine-specific agent config.
// kwargs carries engine.kwargs from eval.yaml (or --engine-kwarg overrides) and
// is forwarded as-is to the agent; each agent reads only the keys it understands.
func DetectAgentWithInitParams(engineName string, params credential.AgentInitParams, kwargs map[string]string) (Agent, error) {
model := params.Model
// "auto" is a QoderCLI-specific model tier; strip it for other built-in
// engines so they don't need to hard-code awareness of it. A custom engine
// keeps the user's configured value — it is exposed verbatim via ${model}
// and SessionInput.model.
if model == "auto" && !isQoderCLIEngine(engineName) && params.Custom == nil {
model = ""
}

// DetectAgentWithResolvedConfig maps a resolved role configuration into the
// selected adapter without reinterpreting raw YAML or CLI values.
func DetectAgentWithResolvedConfig(params credential.ResolvedAgentConfig) (Agent, error) {
engineName := params.Engine
cfg := Config{
Name: engineName,
ModelName: model,
ModelName: params.Model,
ModelProvider: params.Provider,
APIKey: params.APIKey,
BaseURL: params.BaseURL,
EnvVars: make(map[string]string),
Kwargs: kwargs,
Kwargs: params.Kwargs,
Custom: params.Custom,
}
logUnknownEngineKwargs(engineName, kwargs)
logUnknownEngineKwargs(engineName, params.Kwargs)

switch engineName {
case agentkind.QoderCLIAlias, agentkind.QoderAlias, agentkind.QoderCLI:
// The selected edition's PAT is qodercli's own auth credential, independent of the
// underlying model provider (e.g. anthropic). params.APIKey may hold a provider-scoped
// key (e.g. ANTHROPIC_API_KEY) which must not be forwarded as the qodercli token.
// See docs/bugfix/Bug_ QODER_PERSONAL_ACCESS_TOKEN is invalid.md for details.
profile := qoderProfileForKwargs(kwargs)
profile := qoderProfileForKwargs(params.Kwargs)
sourceEnv := profile.credentialEnv
token := os.Getenv(sourceEnv)
if token == "" && profile.edition == qoderEditionCN {
Expand All @@ -75,22 +66,13 @@ func DetectAgentWithInitParams(engineName string, params credential.AgentInitPar
cfg.EnvVars[profile.credentialEnv] = token
logging.Debugf(
"AGENT_CONFIG kind=%s engine=%s edition=%s auth_env=%s source.auth=process_env source.env=%s",
params.Kind, engineName, profile.edition, profile.credentialEnv, sourceEnv,
params.Role, engineName, profile.edition, profile.credentialEnv, sourceEnv,
)
}
if params.BaseURL != "" {
logging.Debugf("AGENT_CONFIG kind=%s engine=%s ignored.base_url reason=unsupported_by_agent", params.Kind, engineName)
logging.Debugf("AGENT_CONFIG kind=%s engine=%s ignored.base_url reason=unsupported_by_agent", params.Role, engineName)
}
}

return DetectAgent(engineName, cfg)
}

func isQoderCLIEngine(name string) bool {
switch name {
case agentkind.QoderCLIAlias, agentkind.QoderAlias, agentkind.QoderCLI:
return true
default:
return false
}
}
Loading
Loading