Skip to content
Open
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
6 changes: 1 addition & 5 deletions cmd/san/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,9 @@ var (
httpClient = http.DefaultClient
)

// releaseInfo represents the GitHub API response for a release.
// releaseInfo carries the release fields the updater uses — just the tag.
type releaseInfo struct {
TagName string `json:"tag_name"`
Assets []struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
} `json:"assets"`
}

func runSelfUpdate(ctx context.Context) error {
Expand Down
123 changes: 89 additions & 34 deletions docs/packages/2-feature/selflearn.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ layer: feature

# selflearn

Runs San's background self-learning pass: on cadence, a restricted fork reviews
recent conversation state and writes durable memory or agent-created skills.
Runs San's background self-learning pass: after a turn, a restricted fork
reviews the just-completed conversation and writes durable memory or
agent-created skills. Triggering is model-decided — the main agent calls the
`Evolve` tool when a turn is worth learning from.

## Purpose

Expand All @@ -17,8 +19,43 @@ the Bubble Tea model and exposes concrete types for the pieces the app needs.

This package deliberately writes only to San-managed durable state: project
memory under the encoded project configuration directory and skills marked with
agent-created provenance. User-created skills remain read-only unless the
setting explicitly opts into updates.
agent-created provenance. User-created skills are read-only to the reviewer at
every setting.

## Triggers & flow

Triggering is entirely model-decided — there is no cadence. When self-learning
is active the main agent is given an `Evolve` tool; the per-arm settings are
permission / store config only (skill create/update/delete gates, memory
enable + cap + path, a shared strategy override).

End-to-end when the model calls `Evolve`:

1. The call is auto-permitted (`Evolve` is a safe tool) and returns an ack; the
model keeps working. `model.OnToolResult` sets `evolveRequestedThisTurn`.
2. At `OnTurnEnd`, `Reviewer.Observe(result, skillUsed, evolveRequested)` fires a
review over every enabled arm — memory if enabled, and skills scoped by skill
use (skill-used → update/delete, skill-free → create) — bounded by the
permission gates.
3. A background fork (`RunReview`) reflects on the turn snapshot with only
`skill_manage` + `memory_write`, then writes memory and/or a skill, or does
nothing. The status bar shows `evolving… → evolved · N changes`.

The `Evolve` tool's schema is built by `tool/evolve.Schema` from the enabled
capabilities (`evolve.Capabilities`) so the model is only invited to flag
learnings the review can act on — memory off never mentions memory — and is
injected through the toolset's generic `ExtraTools` hook. The writes affect
future sessions, not the current turn; at most one review runs at a time.
Capability changes are reconciled at the next turn start: `ensureAgentSession`
records what the toolset was built with (`agentEvolveCaps`) and rebuilds the
agent on drift — covering /evolve saves and external settings edits with one
mechanism. `SAN_DISABLE_SELF_LEARN=1` is a hard runtime switch: it suppresses
both the reviewer and the `Evolve` tool regardless of layered settings.

The skills JSON retains an explicit `enabled` compatibility marker. Legacy
empty `skills` objects came from `enabled:false` being omitted and migrate to
all three actions denied; current saves always emit the marker so permissive
permission sets round-trip without ambiguity.

## Contract

Expand All @@ -30,20 +67,26 @@ need.
package selflearn

type Config struct {
Memory Arm
Skills Arm
Perms ActionPermissions
MemoryEnabled bool
MemoryMaxChars int
MemoryPath string
Skills SkillPermissions
Strategy string // learning-strategy override (empty ⇒ built-in)
}

func (c Config) Enabled() bool
func ResolveSettings(s setting.SelfLearnSettings) (Config, error)

type Arm struct {
Enabled bool
Interval int
// SkillPermissions appears at three altitudes with one meaning: the configured
// gates (Config.Skills), the per-pass scope the trigger derives from them, and
// the SkillManager's hard floor at dispatch.
type SkillPermissions struct {
AllowCreate, AllowUpdate, AllowDelete bool
}

func (p SkillPermissions) Any() bool
func AllowAllSkillActions() SkillPermissions

type ReviewKind uint8

const (
Expand All @@ -54,50 +97,44 @@ const (
func (k ReviewKind) Has(x ReviewKind) bool
func (k ReviewKind) String() string

type ReviewFunc func(kinds ReviewKind, snapshot []core.Message)
type ReviewFunc func(kinds ReviewKind, skillPerms SkillPermissions, snapshot []core.Message)

type Reviewer struct { /* unexported */ }

func New(cfg Config, review ReviewFunc) *Reviewer
func (r *Reviewer) SeedTurns(priorUserTurns int)
func (r *Reviewer) Observe(result core.Result)
func (r *Reviewer) Observe(result core.Result, skillUsed, evolveRequested bool)

func DefaultStrategy() string // built-in guidance the /evolve Strategy editor seeds with

type ForkConfig struct {
LLM core.LLM
System *system.System
CWD string
Memory *MemoryStore
Skills *SkillManager
OnEvent func(core.Event)
LLM core.LLM
System *system.System
CWD string
Memory *MemoryStore
Skills *SkillManager // its Perms() also scope the review prompt
Strategy string
OnEvent func(core.Event)
}

func RunReview(ctx context.Context, fc ForkConfig, kinds ReviewKind, snapshot []core.Message) (string, error)

type MemoryStore struct { /* unexported */ }

func NewMemoryStore(cwd string, maxFile int) *MemoryStore
func NewMemoryStore(cwd string, maxCharsPerFile int, dirOverride string) *MemoryStore
func (s *MemoryStore) MaxKB() int
func (s *MemoryStore) SetWriteObserver(fn MemoryWriteObserver)
func (s *MemoryStore) Dir() string
func (s *MemoryStore) Add(file, content, note string) (string, error)
func (s *MemoryStore) Replace(file, oldText, newContent, note string) (string, error)
func (s *MemoryStore) Remove(file, oldText, note string) (string, error)

type ActionPermissions struct {
AllowCreate bool
AllowUpdate bool
AllowDelete bool
AllowUpdateUserCreated bool
}

func DefaultActionPermissions() ActionPermissions

type SkillManager struct { /* unexported */ }

func NewSkillManager(cwd string, perms ActionPermissions) *SkillManager
func (m *SkillManager) Perms() ActionPermissions
func NewSkillManager(cwd string, perms SkillPermissions) *SkillManager
func (m *SkillManager) Perms() SkillPermissions
func (m *SkillManager) SetWriteObserver(fn SkillWriteObserver)
func (m *SkillManager) Inventory() []SkillInfo
func (m *SkillManager) Read(name string) (string, error)
func (m *SkillManager) Create(name, description, body, level, note string) (string, error)
func (m *SkillManager) Edit(name, body, note string) (string, error)
func (m *SkillManager) Patch(name, oldText, newText string, replaceAll bool, note string) (string, error)
Expand All @@ -108,7 +145,7 @@ func (m *SkillManager) Delete(name, note string) (string, error)

## Internals

- `Reviewer` owns cadence counters and the at-most-one-in-flight gate. It
- `Reviewer` owns the at-most-one-in-flight gate. It
observes completed `core.Result` values and launches the injected
`ReviewFunc` on a background goroutine.
- `RunReview` builds a restricted review agent with only `memory_write` and
Expand Down Expand Up @@ -139,8 +176,8 @@ func (m *SkillManager) Delete(name, note string) (string, error)
## Tests

```
internal/selflearn/reviewer_test.go — cadence, single-flight behavior,
seeding, and result filtering.
internal/selflearn/reviewer_test.go — model-request trigger, single-flight,
scoping, and result filtering.
internal/selflearn/concurrency_test.go — snapshot copying and race safety.
internal/selflearn/fork_test.go — fork prompt/tool restrictions and
inherited system state.
Expand All @@ -151,8 +188,26 @@ internal/selflearn/skill_test.go — skill create/update/delete,
internal/selflearn/config_test.go — settings resolution and defaults.
internal/selflearn/fixes_test.go — regression coverage for security
and patch edge cases.
internal/setting/selflearn_test.go — permission schema migration.
internal/app/selflearn_review_fixes_test.go — runtime gates, layered overrides,
and live-workspace stores.
```

### Testing the trigger manually

1. `/evolve` → keep at least one skill permission on (or enable memory), then
**Save**. Saving rebuilds the agent so the `Evolve` tool is injected.
2. Confirm injection: ask the agent to list its tools — `Evolve` appears only
when self-learning is active.
3. Do skill-worthy work, then have the agent call `Evolve` (e.g. reason
"testing the trigger").
4. Watch: the status-bar indicator (`evolving… → evolved` / `nothing`), the
`/evolve → Learned skills` inventory + RECENT recap, and
`~/.san/debug.log` (`grep selflearn`).

Requires an active LLM provider (the fork makes a real call) and
`SAN_DISABLE_SELF_LEARN` unset.

## See Also

- Code: `internal/selflearn/`
Expand Down
6 changes: 6 additions & 0 deletions internal/agent/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ type BuildParams struct {
DisabledTools map[string]bool
MCPTools []core.Tool

// ExtraTools are caller-built conditional tool schemas appended to the
// toolset (e.g. the self-learning Evolve trigger, injected only when
// self-learning is active). Nil ⇒ no extra tools.
ExtraTools []core.ToolSchema

// PermissionRules and PermissionReview are the two stages of the
// pre-execution permission gate: the rules stage applies the static rules
// (permit/reject/prompt); the review stage is the LLM auto-review consulted
Expand Down Expand Up @@ -90,6 +95,7 @@ func buildAgent(p BuildParams) (core.Agent, *PermissionGate, error) {
schemas := (&tool.Set{
Disabled: p.DisabledTools,
AgentDirectory: p.AgentDirectory,
ExtraTools: p.ExtraTools,
}).Tools()
var adaptOpts []tool.AdaptOption
if p.AskUser != nil {
Expand Down
80 changes: 50 additions & 30 deletions internal/app/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,31 +120,16 @@ func (m *model) buildAgentParams() agent.BuildParams {
// hot-swap it mid-session; the closures below Load it per call.
m.rebuildAutopilotReviewer()

// Read stream timeout overrides from settings (low priority) then env vars
// (high priority). Priority: SAN_* env var > settings.json > core default.
// Valid time.Duration strings (e.g. "30s", "3m"); empty/invalid leaves the
// core default (zero value => core picks FirstChunkTimeout=5m, IdleTimeout=60s).
var streamFirstChunk, streamIdle time.Duration
if s := m.services.Setting.StreamFirstChunkTimeout(); s != "" {
if d, err := time.ParseDuration(s); err == nil {
streamFirstChunk = d
}
}
if s := m.services.Setting.StreamIdleTimeout(); s != "" {
if d, err := time.ParseDuration(s); err == nil {
streamIdle = d
}
}
if s := setting.Getenv("STREAM_FIRST_CHUNK_TIMEOUT"); s != "" {
if d, err := time.ParseDuration(s); err == nil {
streamFirstChunk = d
}
}
if s := setting.Getenv("STREAM_IDLE_TIMEOUT"); s != "" {
if d, err := time.ParseDuration(s); err == nil {
streamIdle = d
}
}
// Stream timeout overrides. Priority: SAN_* env var > settings.json > core
// default (zero value => core picks FirstChunkTimeout=5m, IdleTimeout=60s).
streamFirstChunk := firstValidDuration(
setting.Getenv("STREAM_FIRST_CHUNK_TIMEOUT"),
m.services.Setting.StreamFirstChunkTimeout(),
)
streamIdle := firstValidDuration(
setting.Getenv("STREAM_IDLE_TIMEOUT"),
m.services.Setting.StreamIdleTimeout(),
)

return agent.BuildParams{
Provider: m.env.LLMProvider,
Expand All @@ -164,6 +149,10 @@ func (m *model) buildAgentParams() agent.BuildParams {
MCPTools: mcpTools,
HookEngine: m.services.Hook,

// Inject the Evolve trigger tool (tailored to the enabled capabilities)
// so the model can queue its own self-learning reviews.
ExtraTools: m.selfLearnExtraTools(),

AskUser: func(ctx context.Context, req *tool.QuestionRequest) (*tool.QuestionResponse, error) {
return m.conv.AgentToUI.Ask(ctx, 0, req)
},
Expand Down Expand Up @@ -352,6 +341,21 @@ func marshalPermInput(args map[string]any) json.RawMessage {
return data
}

// firstValidDuration returns the first value that parses as a time.Duration
// (e.g. "30s", "3m"), in priority order. Empty/invalid values are skipped;
// zero means "no override" and lets the core default apply.
func firstValidDuration(vals ...string) time.Duration {
for _, v := range vals {
if v == "" {
continue
}
if d, err := time.ParseDuration(v); err == nil {
return d
}
}
return 0
}

// ============================================================
// Agent lifecycle (delegates to services.Agent)
// ============================================================
Expand All @@ -363,7 +367,15 @@ func marshalPermInput(args map[string]any) json.RawMessage {
// the input twice. Pass "" when the caller hasn't yet appended the message.
func (m *model) ensureAgentSession(pendingSend string) (tea.Cmd, error) {
if m.services.Agent.Active() {
return nil, nil
// The Evolve toolset is fixed at agent-build time. If the enabled
// capabilities drifted since the build (a /evolve save, an external
// settings edit), stop the stale session and fall through to rebuild
// with the current toolset — the one choke point every turn passes
// through, so all drift sources are covered uniformly.
if m.selfLearnCapabilities() == m.agentEvolveCaps {
return nil, nil
}
m.services.Agent.Stop()
}

params := m.buildAgentParams()
Expand All @@ -385,11 +397,13 @@ func (m *model) ensureAgentSession(pendingSend string) (tea.Cmd, error) {
return nil, err
}

// Record what the toolset was built with, for the drift check above.
m.agentEvolveCaps = m.selfLearnCapabilities()

// Wire L1 self-learning *after* Agent.Start so the ReviewFunc can capture
// the live Agent + System for its fork. Builds nothing if both arms are
// off (§3.1 zero-overhead guarantee). pendingSend is forwarded so the
// reviewer's cadence seed skips the in-flight user turn.
m.wireSelfLearn(params, pendingSend)
// off (§3.1 zero-overhead guarantee).
m.wireSelfLearn(params)

cmds := []tea.Cmd{
conv.DrainAgentOutbox(m.services.Agent.Outbox()),
Expand Down Expand Up @@ -449,7 +463,13 @@ func (m *model) wireReminderProviders() {
// Kept as its own scope so agent-written entries never mix with the
// user-authored memory above.
m.services.Reminder.Register(reminder.NewProvider(reminder.ProviderMemoryAuto, func() string {
body, ok := system.LoadAutoMemory(m.env.CWD)
// Honor a configured memory storage path so the injected memory matches
// where the reviewer writes.
override := ""
if snap := m.services.Setting.Snapshot(); snap != nil {
override = snap.SelfLearn.Memory.Path
}
body, ok := system.LoadAutoMemoryAt(system.ResolveAutoMemoryDir(m.env.CWD, override))
if !ok {
return ""
}
Expand Down
1 change: 1 addition & 0 deletions internal/app/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ func discoverPlugins(cwd string) {
func (m *model) reloadProjectServices(cwd string) {
setting.Initialize(setting.Options{CWD: cwd})
m.services.Setting = setting.Default()
m.learnedStores.Update(cwd, m.services.Setting)

skill.Initialize(skill.Options{CWD: cwd, PluginSkillPaths: pluginSkillPaths})
m.services.Skill = skill.Default()
Expand Down
2 changes: 1 addition & 1 deletion internal/app/input/config_appearance.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ func (p *appearancePanel) HintLine() string {
return keycap("↑↓") + " navigate " + keycap("enter") + " apply"
}

func (p *appearancePanel) Render(width int) string {
func (p *appearancePanel) Render(width, _ int) string {
var b strings.Builder

prevSection := ""
Expand Down
Loading