Skip to content
Closed
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
8 changes: 8 additions & 0 deletions cmd/antares/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,10 @@ func bootstrap(ctx context.Context) (*runtimeServices, error) {
if err != nil {
return nil, err
}
cfg, err = migrateSkillState(cfg)
if err != nil {
return nil, err
}
if err := logx.Setup(cfg.Logging.Level, cfg.Logging.File, cfg.Logging.JSON); err != nil {
return nil, fmt.Errorf("setting up logging: %w", err)
}
Expand Down Expand Up @@ -697,6 +701,10 @@ func (rt *runtimeServices) reload() error {
if err != nil {
return err
}
cfg, err = migrateSkillState(cfg)
if err != nil {
return err
}
cfg, _ = config.Effective(rt.cfg, cfg)
previous := rt.cfg
if err := cfg.Server.ValidateListen(); err != nil {
Expand Down
24 changes: 24 additions & 0 deletions cmd/antares/skills.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package main

import (
"fmt"

"github.com/enowdev/antares/internal/config"
"github.com/enowdev/antares/internal/skills"
)

// migrateSkillState imports only the selected configured sources, once per profile.
func migrateSkillState(cfg *config.Config) (*config.Config, error) {
if cfg.Skills.FrontmatterMigrated {
return cfg, nil
}
manager := skills.NewManager(expandAll(cfg.Skills.Dirs))
if err := manager.Reload(); err != nil {
return nil, fmt.Errorf("migrate skill preferences: %w", err)
}
fresh, err := config.MigrateSkillState(manager.LegacyDisabled())
if err != nil {
return nil, fmt.Errorf("migrate skill preferences: %w", err)
}
return fresh, nil
}
241 changes: 241 additions & 0 deletions cmd/antares/skills_migration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
package main

import (
"bytes"
"os"
"path/filepath"
"reflect"
"strings"
"testing"

"github.com/enowdev/antares/internal/config"
"github.com/enowdev/antares/internal/skills"
)

func skillMigrationConfig(t *testing.T) *config.Config {
t.Helper()
home := t.TempDir()
t.Setenv("ANTARES_HOME", home)
t.Setenv("ANTARES_CONFIG", filepath.Join(home, "config.yaml"))
t.Setenv("ANTARES_PROFILE", "default")
cfg := config.Default()
cfg.Skills.Dirs = []string{filepath.Join(home, "skills")}
if err := os.MkdirAll(cfg.Skills.Dirs[0], 0o700); err != nil {
t.Fatal(err)
}
if err := config.Save(cfg); err != nil {
t.Fatal(err)
}
return cfg
}

func migrationSource(t *testing.T, dir, file, name, enabled string) string {
t.Helper()
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, file)
if err := os.WriteFile(path, []byte("---\nname: "+name+"\ndescription: fixture\nenabled: "+enabled+"\n---\nBODY\n"), 0o600); err != nil {
t.Fatal(err)
}
return path
}

func TestMigrateSkillStateOnceAndSelectedSources(t *testing.T) {
cfg := skillMigrationConfig(t)
cfg.Skills.Disabled = []string{"missing"}
second := t.TempDir()
cfg.Skills.Dirs = append(cfg.Skills.Dirs, second)
path := migrationSource(t, cfg.Skills.Dirs[0], "legacy.md", "legacy", "false")
migrationSource(t, cfg.Skills.Dirs[0], "duplicate.md", "duplicate", "false")
migrationSource(t, second, "duplicate.md", "duplicate", "true")
migrationSource(t, config.Path("security-skills"), "pack.md", "automatic-pack", "false")
before, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if err := config.Save(cfg); err != nil {
t.Fatal(err)
}
cfg, err = migrateSkillState(cfg)
if err != nil {
t.Fatal(err)
}
if !cfg.Skills.FrontmatterMigrated || !reflect.DeepEqual(cfg.Skills.Disabled, []string{"legacy", "missing"}) {
t.Fatalf("migration = %+v", cfg.Skills)
}
after, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(before, after) {
t.Fatal("migration rewrote legacy source")
}
cfg, err = config.SetSkillEnabled("legacy", true)
if err != nil {
t.Fatal(err)
}
migrationSource(t, cfg.Skills.Dirs[0], "later.md", "later", "false")
cfg, err = config.Reload()
if err != nil {
t.Fatal(err)
}
cfg, err = migrateSkillState(cfg)
if err != nil {
t.Fatal(err)
}
m := skills.NewManager(expandAll(cfg.Skills.Dirs))
if err := m.Reload(); err != nil {
t.Fatal(err)
}
m.SetDisabled(cfg.Skills.Disabled)
for _, name := range []string{"legacy", "later"} {
s, ok := m.Get(name)
if !ok || !s.Enabled {
t.Fatalf("%s disabled by stale header after migration: %+v", name, s)
}
}
// The completed marker bypasses even a now-malformed configured source.
if err := os.WriteFile(path, []byte("---\nenabled: [\n---\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := migrateSkillState(cfg); err != nil {
t.Fatalf("completed migration rescanned: %v", err)
}
}

func TestMigrateSkillStateEmptyAndExplicitPack(t *testing.T) {
cfg := skillMigrationConfig(t)
cfg.Skills.Dirs = append(cfg.Skills.Dirs, filepath.Join(t.TempDir(), "missing"))
if err := config.Save(cfg); err != nil {
t.Fatal(err)
}
fresh, err := migrateSkillState(cfg)
if err != nil {
t.Fatal(err)
}
if !fresh.Skills.FrontmatterMigrated || len(fresh.Skills.Disabled) != 0 {
t.Fatalf("empty migration = %+v", fresh.Skills)
}
// Explicitly configured bundled paths are configured sources, not excluded by location.
cfg.Skills.Dirs = []string{config.Path("security-skills")}
migrationSource(t, cfg.Skills.Dirs[0], "explicit.md", "explicit", "false")
if err := config.Save(cfg); err != nil {
t.Fatal(err)
}
fresh, err = migrateSkillState(cfg)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(fresh.Skills.Disabled, []string{"explicit"}) {
t.Fatalf("explicit import = %v", fresh.Skills.Disabled)
}
}

func TestMigrateSkillStateIncompleteRetry(t *testing.T) {
cfg := skillMigrationConfig(t)
migrationSource(t, cfg.Skills.Dirs[0], "good.md", "good", "false")
broken := filepath.Join(cfg.Skills.Dirs[0], "broken.md")
if err := os.WriteFile(broken, []byte("---\nenabled: [\n---\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := migrateSkillState(cfg); err == nil || !strings.Contains(err.Error(), "migrate skill preferences:") {
t.Fatalf("incomplete migration error = %v", err)
}
fresh, err := config.Reload()
if err != nil {
t.Fatal(err)
}
if fresh.Skills.FrontmatterMigrated {
t.Fatal("partial migration marked complete")
}
migrationSource(t, cfg.Skills.Dirs[0], "broken.md", "repaired", "false")
fresh, err = migrateSkillState(fresh)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(fresh.Skills.Disabled, []string{"good", "repaired"}) {
t.Fatalf("retry import = %v", fresh.Skills.Disabled)
}
}

func TestMigrateSkillStateSaveFailureLeavesMarkerUnset(t *testing.T) {
cfg := skillMigrationConfig(t)
migrationSource(t, cfg.Skills.Dirs[0], "legacy.md", "legacy", "false")
// The file remains readable, but atomic replacement needs directory write permission.
original, err := os.ReadFile(config.ConfigFile())
if err != nil {
t.Fatal(err)
}
if err := os.Chmod(filepath.Dir(config.ConfigFile()), 0o500); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chmod(filepath.Dir(config.ConfigFile()), 0o700) })
if os.Geteuid() == 0 {
t.Skip("permission failure requires an unprivileged process; config package covers deterministic write failure")
}
if _, err := migrateSkillState(cfg); err == nil {
t.Fatal("migration succeeded despite unwritable config directory")
}
after, err := os.ReadFile(config.ConfigFile())
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(original, after) || cfg.Skills.FrontmatterMigrated {
t.Fatal("failed migration changed persisted marker")
}
}

func TestMigrateSkillStateUnreadableSourceRetry(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("permission semantics require an unprivileged process")
}
for _, rootUnreadable := range []bool{false, true} {
t.Run(map[bool]string{false: "file", true: "root"}[rootUnreadable], func(t *testing.T) {
cfg := skillMigrationConfig(t)
p := migrationSource(t, cfg.Skills.Dirs[0], "legacy.md", "legacy", "false")
if rootUnreadable {
p = cfg.Skills.Dirs[0]
}
if err := os.Chmod(p, 0); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chmod(p, 0o700) })
if _, err := migrateSkillState(cfg); err == nil {
t.Fatal("unreadable source completed migration")
}
fresh, err := config.Reload()
if err != nil {
t.Fatal(err)
}
if fresh.Skills.FrontmatterMigrated {
t.Fatal("incomplete import marked complete")
}
if err := os.Chmod(p, 0o700); err != nil {
t.Fatal(err)
}
fresh, err = migrateSkillState(fresh)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(fresh.Skills.Disabled, []string{"legacy"}) {
t.Fatalf("retry lost opt-out: %v", fresh.Skills.Disabled)
}
})
}
}

func TestRuntimeReloadAbortsIncompleteSkillMigration(t *testing.T) {
cfg := skillMigrationConfig(t)
if err := os.WriteFile(filepath.Join(cfg.Skills.Dirs[0], "bad.md"), []byte("---\nenabled: [\n---\n"), 0o600); err != nil {
t.Fatal(err)
}
rt := &runtimeServices{cfg: cfg.Clone()}
rt.cfg.Skills.FrontmatterMigrated = true
if err := rt.reload(); err == nil || !strings.Contains(err.Error(), "migrate skill preferences:") {
t.Fatalf("reload migration error = %v", err)
}
if !rt.cfg.Skills.FrontmatterMigrated {
t.Fatal("failed migration published replacement config")
}
}
6 changes: 6 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,13 +234,19 @@ which would leave the model reading a call with no answer.
skills:
enabled: true
dirs: [~/.antares/skills]
disabled: []
auto_create: true
creation_nudge_interval: 20
```

`dirs` is searched in order and later directories win, so a personal copy can
override a shared one. See [Skills](skills.md).

`disabled` contains exact, case-sensitive names turned off for this profile,
including names whose files are temporarily absent. Dashboard switches update
this list without changing source files. Legacy configured opt-outs are imported
once; see [Managing skills](skills.md#managing-them).

## Server

```yaml
Expand Down
16 changes: 15 additions & 1 deletion docs/skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ Port 8787 already in use usually means the old process did not exit. Check
| `description` | **The most important line.** How the agent decides whether this is relevant |
| `tags` | For your own browsing |
| `triggers` | Words that make it more likely to surface |
| `enabled` | `false` keeps it on disk but out of the prompt |

The description does the work. "Deployment stuff" will not get picked; "Deploy
this project to the home server. Use when asked to deploy, ship, or release."
Expand All @@ -47,6 +46,11 @@ will.
Only names and descriptions go into the system prompt — the catalogue. Bodies
are fetched on demand with the `skill` tool.


Disabled names are omitted from new prompts and from the skill tool's list,
search, read, and chain results. Re-enabling restores access. Already-sent model
context cannot be retracted, and this preference does not restrict generic
filesystem tools. Saving skill content does not enable a disabled name.
Twenty skills therefore cost a few hundred tokens per turn rather than tens of
thousands, and adding more does not degrade the conversation.

Expand Down Expand Up @@ -100,6 +104,16 @@ learned it says so and writes nothing.
The dashboard's Skills page lists them with a switch each, shows the body
inline, and has a Browse button for the hub.

Switches save exact, case-sensitive skill names in `skills.disabled` in the active
profile's configuration; they never rewrite skill files. Preferences remain when
a file is removed or reinstalled. `skills.enabled` is the separate global gate.

On the first startup with this setting, Antares imports `enabled: false` from
selected files in configured skill directories. It records completion in
`skills.frontmatter_migrated`. Later header changes do not affect enablement.
An unreadable or malformed configured source aborts that initial import; repair
the source and restart to retry. The dashboard and `/skills` still show off entries.

```yaml
skills:
auto_create: true
Expand Down
8 changes: 8 additions & 0 deletions internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,8 +248,13 @@ func (a *Agent) SetConfig(cfg *config.Config) {
if cfg == nil {
return
}
a.servicesMu.Lock()
if a.skills != nil {
a.skills.SetDisabled(cfg.Skills.Disabled)
}
prev := a.cfg.Load()
a.cfg.Store(cfg)
a.servicesMu.Unlock()
// A raised MaxConcurrentSessions makes room for parked RunQueued
// waiters immediately; without a wake here they would sit on the old
// channel until an unrelated turn ended.
Expand All @@ -274,6 +279,9 @@ func (a *Agent) SetRAG(p tools.RAGProvider) {
// SetSkills attaches the skill library. Publishes under servicesMu.
func (a *Agent) SetSkills(m *skills.Manager) {
a.servicesMu.Lock()
if cfg := a.cfg.Load(); m != nil && cfg != nil {
m.SetDisabled(cfg.Skills.Disabled)
}
a.skills = m
a.servicesMu.Unlock()
}
Expand Down
Loading
Loading