diff --git a/docs/features/skills/index.md b/docs/features/skills/index.md index 81feb516b..362e305de 100644 --- a/docs/features/skills/index.md +++ b/docs/features/skills/index.md @@ -98,6 +98,7 @@ When asked to create a Dockerfile: | `name` | Yes | Unique skill identifier | | `description` | Yes | Short description shown to the agent for skill matching | | `context` | No | Set to `fork` to run the skill as an isolated sub-agent (see below) | +| `model` | No | Override the model used while running the skill as a sub-agent (fork only) | | `allowed-tools` | No | List of tools the skill needs (YAML list or comma-separated string) | | `license` | No | License identifier (e.g. `Apache-2.0`) | | `compatibility` | No | Free-text compatibility notes | @@ -138,6 +139,45 @@ When the agent encounters a task that matches a `context: fork` skill, it uses t +### Overriding the model for a fork skill + +Fork skills can declare a `model` field in their frontmatter to use a +different model than the parent agent for the duration of the sub-session. +This is useful when a skill is best handled by a faster, cheaper, or more +specialised model — for example a powerful reasoning model for refactors, +or a fast model for routine bookkeeping work. The override only applies +while the skill is running; the parent agent keeps its own model. + +The `model` value accepts either a named model from the agent config or +an inline `provider/model` reference (and the same comma-separated alloy +syntax as the rest of the agent config): + + +```yaml +--- +name: bump-go-dependencies +description: Update Go module dependencies one by one +context: fork +model: openai/gpt-4o-mini +--- + +# Bump Dependencies + +1. ... +``` + +If the model reference cannot be resolved (unknown name, missing +credentials, runtime not configured for model switching, …) the skill +falls back to the agent's currently-active model (its configured +default, or any override the user previously set via the model picker) +and a warning is logged. + +When the skill completes, the agent's previous model is restored — but +only if no one else changed the model in the meantime. If the user +switches the model via the TUI model picker while the fork skill is +running, their choice is preserved (the deferred restore becomes a +no-op). + ## Search Paths Skills are discovered from these locations (later overrides earlier): diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index 154f104fd..8c7bf354f 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -152,7 +152,13 @@ func (a *Agent) Model() provider.Provider { // The override(s) take precedence over the configured models. // For alloy models, multiple providers can be passed and one will be randomly selected. // Pass no arguments or nil providers to clear the override. -func (a *Agent) SetModelOverride(models ...provider.Provider) { +// +// SetModelOverride returns a snapshot of the value that was just stored. +// Callers performing a scoped override (apply now, restore later) should +// keep this snapshot and pass it as `current` to RestoreModelOverride so +// the deferred restore can detect concurrent changes via CAS. Callers +// that only need the side-effect can ignore the return value. +func (a *Agent) SetModelOverride(models ...provider.Provider) ModelOverrideSnapshot { // Filter out nil providers var validModels []provider.Provider for _, m := range models { @@ -161,17 +167,20 @@ func (a *Agent) SetModelOverride(models ...provider.Provider) { } } + var ptr *[]provider.Provider if len(validModels) == 0 { a.modelOverrides.Store(nil) slog.Debug("Cleared model override", "agent", a.name) } else { - a.modelOverrides.Store(&validModels) + ptr = &validModels + a.modelOverrides.Store(ptr) ids := make([]string, len(validModels)) for i, m := range validModels { ids[i] = m.ID() } slog.Debug("Set model override", "agent", a.name, "models", ids) } + return ModelOverrideSnapshot{ptr: ptr} } // HasModelOverride returns true if a model override is currently set. @@ -180,6 +189,39 @@ func (a *Agent) HasModelOverride() bool { return overrides != nil && len(*overrides) > 0 } +// ModelOverrideSnapshot is an opaque token that captures the agent's model +// override at a point in time. Pass it to RestoreModelOverride to undo a +// scoped override safely. +type ModelOverrideSnapshot struct { + // ptr is the raw atomic pointer value at snapshot time. It is used for + // pointer-identity compare-and-swap, never dereferenced by callers. + ptr *[]provider.Provider +} + +// SnapshotModelOverride captures the agent's current model override. The +// returned snapshot is opaque; pass it to RestoreModelOverride later to +// restore the captured value. +func (a *Agent) SnapshotModelOverride() ModelOverrideSnapshot { + return ModelOverrideSnapshot{ptr: a.modelOverrides.Load()} +} + +// RestoreModelOverride atomically restores the override to the value +// captured by `prev`, but only if the current override is still the one +// captured by `current` (pointer identity). If another caller has changed +// the override since `current` was captured, the restore is a no-op so +// that the concurrent change wins. +// +// This is the safe primitive for applying a temporary override around a +// scope (e.g. a skill sub-session) without clobbering changes made by +// concurrent callers such as the TUI model picker. +func (a *Agent) RestoreModelOverride(prev, current ModelOverrideSnapshot) { + if a.modelOverrides.CompareAndSwap(current.ptr, prev.ptr) { + slog.Debug("Restored model override", "agent", a.name) + } else { + slog.Debug("Model override changed concurrently; skipping restore", "agent", a.name) + } +} + // ConfiguredModels returns the originally configured models for this agent. // This is useful for listing available models in the TUI picker. func (a *Agent) ConfiguredModels() []provider.Provider { diff --git a/pkg/agent/agent_test.go b/pkg/agent/agent_test.go index 63790795b..ce5b59f02 100644 --- a/pkg/agent/agent_test.go +++ b/pkg/agent/agent_test.go @@ -145,19 +145,15 @@ func TestModelOverride(t *testing.T) { a := New("root", "test", WithModel(defaultModel)) // Initially should return the default model - model := a.Model() - assert.Equal(t, "openai/gpt-4o", model.ID()) + assert.Equal(t, "openai/gpt-4o", a.Model().ID()) assert.False(t, a.HasModelOverride()) // Set an override a.SetModelOverride(overrideModel) assert.True(t, a.HasModelOverride()) + assert.Equal(t, "anthropic/claude-sonnet-4-0", a.Model().ID()) - // Now Model() should return the override - model = a.Model() - assert.Equal(t, "anthropic/claude-sonnet-4-0", model.ID()) - - // ConfiguredModels should still return the original models + // ConfiguredModels still reflects the originally configured models configuredModels := a.ConfiguredModels() require.Len(t, configuredModels, 1) assert.Equal(t, "openai/gpt-4o", configuredModels[0].ID()) @@ -165,10 +161,131 @@ func TestModelOverride(t *testing.T) { // Clear the override a.SetModelOverride(nil) assert.False(t, a.HasModelOverride()) + assert.Equal(t, "openai/gpt-4o", a.Model().ID()) +} + +func TestSetModelOverride_ReturnsSnapshotOfStoredValue(t *testing.T) { + // SetModelOverride must return a snapshot of the value it just stored, + // not what a subsequent SnapshotModelOverride() would load. This is the + // guarantee that closes the race window for scoped overrides: if a + // concurrent caller stores a different override after our store but + // before we capture our snapshot, our snapshot must still refer to + // what we stored, so the deferred CAS-restore will fail (concurrent + // change wins) instead of incorrectly succeeding. + t.Parallel() + + defaultModel := &mockProvider{id: "default"} + oursModel := &mockProvider{id: "ours"} + othersModel := &mockProvider{id: "others"} + + a := New("root", "test", WithModel(defaultModel)) + + // Capture the snapshot returned by SetModelOverride. + prev := a.SnapshotModelOverride() + oursSnap := a.SetModelOverride(oursModel) + + // Simulate a concurrent caller storing a different override _after_ we + // stored ours but _before_ a hypothetical post-store SnapshotModelOverride. + a.SetModelOverride(othersModel) + require.Equal(t, "others", a.Model().ID()) + + // The deferred restore must be a no-op because oursSnap holds the + // pointer we stored, not the current pointer. + a.RestoreModelOverride(prev, oursSnap) + assert.Equal(t, "others", a.Model().ID(), + "concurrent override must be preserved; the snapshot returned by SetModelOverride captures the stored pointer") +} - // Model() should return the default again - model = a.Model() - assert.Equal(t, "openai/gpt-4o", model.ID()) +func TestSetModelOverride_ClearReturnsZeroSnapshot(t *testing.T) { + t.Parallel() + + a := New("root", "test", WithModel(&mockProvider{id: "default"})) + + // Calling SetModelOverride with no providers (or nil) clears the override. + // The returned snapshot should round-trip cleanly through RestoreModelOverride. + cleared := a.SetModelOverride() + assert.False(t, a.HasModelOverride()) + + // Now set an override and restore using `cleared` as `prev`. + oursSnap := a.SetModelOverride(&mockProvider{id: "ours"}) + require.True(t, a.HasModelOverride()) + + a.RestoreModelOverride(cleared, oursSnap) + assert.False(t, a.HasModelOverride(), "restoring a cleared snapshot must clear the override") +} + +func TestSnapshotAndRestoreModelOverride(t *testing.T) { + t.Parallel() + + defaultModel := &mockProvider{id: "openai/gpt-4o"} + skillModel := &mockProvider{id: "openai/gpt-4o-mini"} + userModel := &mockProvider{id: "anthropic/claude-sonnet-4-0"} + + t.Run("restores when no concurrent change", func(t *testing.T) { + t.Parallel() + a := New("root", "test", WithModel(defaultModel)) + + prev := a.SnapshotModelOverride() + a.SetModelOverride(skillModel) + ours := a.SnapshotModelOverride() + assert.Equal(t, "openai/gpt-4o-mini", a.Model().ID()) + + a.RestoreModelOverride(prev, ours) + assert.False(t, a.HasModelOverride()) + assert.Equal(t, "openai/gpt-4o", a.Model().ID()) + }) + + t.Run("restores back to a pre-existing override", func(t *testing.T) { + t.Parallel() + a := New("root", "test", WithModel(defaultModel)) + a.SetModelOverride(userModel) + + prev := a.SnapshotModelOverride() + a.SetModelOverride(skillModel) + ours := a.SnapshotModelOverride() + assert.Equal(t, "openai/gpt-4o-mini", a.Model().ID()) + + a.RestoreModelOverride(prev, ours) + assert.Equal(t, "anthropic/claude-sonnet-4-0", a.Model().ID()) + }) + + t.Run("keeps a concurrent change instead of restoring", func(t *testing.T) { + // This is the TUI-while-skill-runs scenario: another caller + // changes the override between SnapshotModelOverride and + // RestoreModelOverride. The deferred restore must NOT clobber + // that change. + t.Parallel() + a := New("root", "test", WithModel(defaultModel)) + + prev := a.SnapshotModelOverride() + a.SetModelOverride(skillModel) + ours := a.SnapshotModelOverride() + + // Simulate concurrent TUI model switch. + a.SetModelOverride(userModel) + + a.RestoreModelOverride(prev, ours) + require.True(t, a.HasModelOverride(), "user's model choice must be preserved") + assert.Equal(t, "anthropic/claude-sonnet-4-0", a.Model().ID()) + }) + + t.Run("keeps a concurrent clear instead of restoring", func(t *testing.T) { + // Same as above but the concurrent caller clears the override + // (e.g. user revert via TUI). The restore must respect that. + t.Parallel() + a := New("root", "test", WithModel(defaultModel)) + a.SetModelOverride(userModel) + + prev := a.SnapshotModelOverride() + a.SetModelOverride(skillModel) + ours := a.SnapshotModelOverride() + + // Simulate concurrent TUI revert. + a.SetModelOverride() + + a.RestoreModelOverride(prev, ours) + assert.False(t, a.HasModelOverride(), "user's revert must be preserved") + }) } func TestModel_LogsSelection(t *testing.T) { diff --git a/pkg/runtime/model_switcher.go b/pkg/runtime/model_switcher.go index 88fad7bc1..43578bbd7 100644 --- a/pkg/runtime/model_switcher.go +++ b/pkg/runtime/model_switcher.go @@ -8,6 +8,7 @@ import ( "slices" "strings" + "github.com/docker/docker-agent/pkg/agent" "github.com/docker/docker-agent/pkg/config/latest" "github.com/docker/docker-agent/pkg/environment" "github.com/docker/docker-agent/pkg/model/provider" @@ -93,20 +94,34 @@ type ModelSwitcherConfig struct { // SetAgentModel implements ModelSwitcher for LocalRuntime. func (r *LocalRuntime) SetAgentModel(ctx context.Context, agentName, modelRef string) error { + _, err := r.setAgentModelInternal(ctx, agentName, modelRef) + return err +} + +// setAgentModelInternal applies modelRef as the agent's model override and +// returns a snapshot of the value that was just stored. The snapshot is +// captured atomically with the store (it is the pointer returned by +// SetModelOverride itself), so there is no window where another caller +// could intervene and the snapshot would refer to a different value. +// +// SetAgentModel is a thin wrapper that discards the snapshot; callers that +// want to do a CAS-based restore (see WithAgentModel) use this method +// directly to keep the snapshot. +func (r *LocalRuntime) setAgentModelInternal(ctx context.Context, agentName, modelRef string) (agent.ModelOverrideSnapshot, error) { if r.modelSwitcherCfg == nil { - return errors.New("model switching not configured for this runtime") + return agent.ModelOverrideSnapshot{}, errors.New("model switching not configured for this runtime") } a, err := r.team.Agent(agentName) if err != nil { - return fmt.Errorf("agent not found: %w", err) + return agent.ModelOverrideSnapshot{}, fmt.Errorf("agent not found: %w", err) } // Empty modelRef means clear the override (use agent's default) if modelRef == "" { - a.SetModelOverride() + snap := a.SetModelOverride() slog.Info("Cleared agent model override (using default)", "agent", agentName) - return nil + return snap, nil } // Check if modelRef is a named model from config @@ -116,20 +131,20 @@ func (r *LocalRuntime) SetAgentModel(ctx context.Context, agentName, modelRef st if isAlloyModelConfig(modelConfig) { providers, err := r.resolveModelRefs(ctx, modelConfig.Model) if err != nil { - return fmt.Errorf("failed to create alloy model from config: %w", err) + return agent.ModelOverrideSnapshot{}, fmt.Errorf("failed to create alloy model from config: %w", err) } - a.SetModelOverride(providers...) + snap := a.SetModelOverride(providers...) slog.Info("Set agent model override (alloy)", "agent", agentName, "config_name", modelRef, "model_count", len(providers)) - return nil + return snap, nil } prov, err := r.createProviderFromConfig(ctx, &modelConfig) if err != nil { - return fmt.Errorf("failed to create model from config: %w", err) + return agent.ModelOverrideSnapshot{}, fmt.Errorf("failed to create model from config: %w", err) } - a.SetModelOverride(prov) + snap := a.SetModelOverride(prov) slog.Info("Set agent model override", "agent", agentName, "model", prov.ID(), "config_name", modelRef) - return nil + return snap, nil } // Check if this is an inline alloy spec (comma-separated provider/model specs) @@ -137,21 +152,47 @@ func (r *LocalRuntime) SetAgentModel(ctx context.Context, agentName, modelRef st if isInlineAlloySpec(modelRef) { providers, err := r.resolveModelRefs(ctx, modelRef) if err != nil { - return fmt.Errorf("failed to create inline alloy model: %w", err) + return agent.ModelOverrideSnapshot{}, fmt.Errorf("failed to create inline alloy model: %w", err) } - a.SetModelOverride(providers...) + snap := a.SetModelOverride(providers...) slog.Info("Set agent model override (inline alloy)", "agent", agentName, "model_count", len(providers)) - return nil + return snap, nil } // Try single inline spec (provider/model) prov, err := r.resolveModelRef(ctx, modelRef) if err != nil { - return fmt.Errorf("failed to resolve model %q: %w", modelRef, err) + return agent.ModelOverrideSnapshot{}, fmt.Errorf("failed to resolve model %q: %w", modelRef, err) } - a.SetModelOverride(prov) + snap := a.SetModelOverride(prov) slog.Info("Set agent model override (inline)", "agent", agentName, "model", prov.ID()) - return nil + return snap, nil +} + +// WithAgentModel applies modelRef as a model override on the named agent +// and returns a function that restores the previous override safely. +// +// The returned restore func is always non-nil. On success it uses +// pointer-identity compare-and-swap on the agent's override, so a +// concurrent change made between the apply and the restore (e.g. by the +// TUI model picker) is preserved instead of being clobbered. The post- +// apply snapshot is captured atomically with the store inside +// SetModelOverride, so there is no window where a concurrent change +// could be misattributed to this scope. On error the agent is left +// untouched and restore is a no-op, so callers can always defer it +// without nil-checking. +func (r *LocalRuntime) WithAgentModel(ctx context.Context, agentName, modelRef string) (restore func(), err error) { + noop := func() {} + a, err := r.team.Agent(agentName) + if err != nil { + return noop, fmt.Errorf("agent not found: %w", err) + } + prev := a.SnapshotModelOverride() + ours, err := r.setAgentModelInternal(ctx, agentName, modelRef) + if err != nil { + return noop, err + } + return func() { a.RestoreModelOverride(prev, ours) }, nil } // resolveModelRef resolves a model reference to a single provider. diff --git a/pkg/runtime/skill_runner.go b/pkg/runtime/skill_runner.go index d900e010b..99de35b3b 100644 --- a/pkg/runtime/skill_runner.go +++ b/pkg/runtime/skill_runner.go @@ -67,6 +67,24 @@ func (r *LocalRuntime) handleRunSkill(ctx context.Context, sess *session.Session "task", params.Task, ) + // If the skill declares a model override, apply it for the duration of + // the sub-session. WithAgentModel handles every accepted form (named + // model, alloy, inline provider/model, inline alloy) and returns a + // CAS-safe restore func that is always non-nil; on failure we log a + // warning and fall back to the agent's currently-active model. + if skill.Model != "" { + restore, err := r.WithAgentModel(ctx, ca, skill.Model) + defer restore() + if err != nil { + slog.Warn("Failed to apply skill model override; using current model", + "agent", ca, + "skill", params.Name, + "model", skill.Model, + "error", err, + ) + } + } + cfg := SubSessionConfig{ Task: params.Task, SystemMessage: skillContent, diff --git a/pkg/runtime/with_agent_model_test.go b/pkg/runtime/with_agent_model_test.go new file mode 100644 index 000000000..7b4ca44d1 --- /dev/null +++ b/pkg/runtime/with_agent_model_test.go @@ -0,0 +1,145 @@ +package runtime + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/agent" + "github.com/docker/docker-agent/pkg/team" +) + +// TestWithAgentModel covers the LocalRuntime.WithAgentModel helper. The +// helper is the public entry point for "apply a temporary model override +// for a scope, then CAS-restore it"; it composes resolution (SetAgentModel) +// with the agent-level snapshot/restore primitives. +func TestWithAgentModel(t *testing.T) { + t.Parallel() + + t.Run("agent not found returns no-op restore and error", func(t *testing.T) { + t.Parallel() + tm := team.New(team.WithAgents(agent.New("root", "test"))) + r := &LocalRuntime{ + team: tm, + modelSwitcherCfg: &ModelSwitcherConfig{}, + } + + restore, err := r.WithAgentModel(t.Context(), "missing", "openai/gpt-4o") + require.Error(t, err) + assert.Contains(t, err.Error(), "agent not found") + require.NotNil(t, restore, "restore must always be non-nil") + assert.NotPanics(t, restore, "restore on error must be a safe no-op") + }) + + t.Run("nil modelSwitcherCfg returns no-op restore and error", func(t *testing.T) { + t.Parallel() + root := agent.New("root", "test") + tm := team.New(team.WithAgents(root)) + r := &LocalRuntime{team: tm} // modelSwitcherCfg is nil + + restore, err := r.WithAgentModel(t.Context(), "root", "openai/gpt-4o") + require.Error(t, err) + require.NotNil(t, restore) + assert.NotPanics(t, restore) + assert.False(t, root.HasModelOverride(), "agent state must not be touched on error") + }) + + t.Run("invalid model ref returns no-op restore and error", func(t *testing.T) { + t.Parallel() + root := agent.New("root", "test") + tm := team.New(team.WithAgents(root)) + r := &LocalRuntime{ + team: tm, + modelSwitcherCfg: &ModelSwitcherConfig{}, + } + + // "invalid" has no slash → not an inline spec, and no named config + // matches → SetAgentModel returns an error. + restore, err := r.WithAgentModel(t.Context(), "root", "invalid") + require.Error(t, err) + require.NotNil(t, restore) + assert.NotPanics(t, restore) + assert.False(t, root.HasModelOverride(), "agent state must not be touched on error") + }) + + t.Run("apply clears existing override; restore puts it back", func(t *testing.T) { + t.Parallel() + // Pre-existing override (e.g. set by the user via the model picker + // before the skill ran). + userPick := &mockProvider{id: "user/pick"} + root := agent.New("root", "test", agent.WithModel(&mockProvider{id: "default/model"})) + root.SetModelOverride(userPick) + require.Equal(t, "user/pick", root.Model().ID()) + + tm := team.New(team.WithAgents(root)) + r := &LocalRuntime{ + team: tm, + modelSwitcherCfg: &ModelSwitcherConfig{}, + } + + // Empty modelRef clears the override (handled inside SetAgentModel + // without requiring any provider resolution). + restore, err := r.WithAgentModel(t.Context(), "root", "") + require.NoError(t, err) + require.NotNil(t, restore) + + // Inside the scope: override is cleared. + assert.False(t, root.HasModelOverride()) + assert.Equal(t, "default/model", root.Model().ID()) + + // After restore: user's pick is back. + restore() + assert.True(t, root.HasModelOverride()) + assert.Equal(t, "user/pick", root.Model().ID()) + }) + + t.Run("restore is idempotent", func(t *testing.T) { + t.Parallel() + root := agent.New("root", "test", agent.WithModel(&mockProvider{id: "default/model"})) + userPick := &mockProvider{id: "user/pick"} + root.SetModelOverride(userPick) + + tm := team.New(team.WithAgents(root)) + r := &LocalRuntime{ + team: tm, + modelSwitcherCfg: &ModelSwitcherConfig{}, + } + + restore, err := r.WithAgentModel(t.Context(), "root", "") + require.NoError(t, err) + + restore() + assert.Equal(t, "user/pick", root.Model().ID()) + // Second call is a CAS no-op (the state is already restored). + assert.NotPanics(t, restore) + assert.Equal(t, "user/pick", root.Model().ID()) + }) + + t.Run("concurrent change is preserved by restore", func(t *testing.T) { + t.Parallel() + // This is the TUI-while-skill-runs scenario at the runtime layer: + // after the skill applies its override, another caller (e.g. the + // model picker) sets a different override before the deferred + // restore runs. The restore must NOT clobber that change. + root := agent.New("root", "test", agent.WithModel(&mockProvider{id: "default/model"})) + tm := team.New(team.WithAgents(root)) + r := &LocalRuntime{ + team: tm, + modelSwitcherCfg: &ModelSwitcherConfig{}, + } + + // Apply: clears any override (none was set). + restore, err := r.WithAgentModel(t.Context(), "root", "") + require.NoError(t, err) + + // Concurrent caller wins between apply and restore. + userPick := &mockProvider{id: "user/pick"} + root.SetModelOverride(userPick) + + // Restore must be a no-op because the override changed. + restore() + require.True(t, root.HasModelOverride(), "concurrent change must be preserved") + assert.Equal(t, "user/pick", root.Model().ID()) + }) +} diff --git a/pkg/skills/skills.go b/pkg/skills/skills.go index 4d61179aa..264a7fc6c 100644 --- a/pkg/skills/skills.go +++ b/pkg/skills/skills.go @@ -26,6 +26,11 @@ type Skill struct { Metadata map[string]string AllowedTools []string Context string // "fork" to run the skill as an isolated sub-agent + // Model is an optional model override applied while the skill runs as + // a sub-agent (context: fork). It accepts either a named model from the + // agent config or an inline "provider/model" reference (e.g. + // "openai/gpt-4o-mini"). It is ignored for non-fork skills. + Model string } // IsFork returns true when the skill should be executed in an isolated @@ -358,6 +363,8 @@ func parseFrontmatter(content string) (Skill, bool) { skill.Compatibility = unquote(value) case "context": skill.Context = unquote(value) + case "model": + skill.Model = unquote(value) case "metadata": currentKey = "metadata" case "allowed-tools": diff --git a/pkg/skills/skills_test.go b/pkg/skills/skills_test.go index 289f7b387..6de0734e4 100644 --- a/pkg/skills/skills_test.go +++ b/pkg/skills/skills_test.go @@ -159,6 +159,42 @@ Body`, }, wantOK: true, }, + { + name: "model override (named)", + content: `--- +name: model-skill +description: A skill that overrides the model +context: fork +model: my_fast_model +--- + +Body`, + want: Skill{ + Name: "model-skill", + Description: "A skill that overrides the model", + Context: "fork", + Model: "my_fast_model", + }, + wantOK: true, + }, + { + name: "model override (inline provider/model)", + content: `--- +name: inline-model-skill +description: Skill with inline provider/model override +context: fork +model: openai/gpt-4o-mini +--- + +Body`, + want: Skill{ + Name: "inline-model-skill", + Description: "Skill with inline provider/model override", + Context: "fork", + Model: "openai/gpt-4o-mini", + }, + wantOK: true, + }, { name: "allowed-tools list with quoted items", content: "---\nname: quoted-tools\ndescription: Skill with quoted tool items\nallowed-tools:\n - \"Bash(git:*)\"\n - 'Read'\n---\n\nBody", @@ -192,6 +228,7 @@ Body`, assert.Equal(t, tt.want.Metadata, got.Metadata) assert.Equal(t, tt.want.AllowedTools, got.AllowedTools) assert.Equal(t, tt.want.Context, got.Context) + assert.Equal(t, tt.want.Model, got.Model) } }) }