Build image / build-and-push (push) Successful in 17s
- Remove config.AgentConfig.Ready() — dead after the refactor (no non-test
callers), and its doc comment ("route registration and capabilities gate on
this") was now false. EffectiveAgent.Ready() carries the same logic and IS
used, see below.
- agentHolder now uses eff.Ready() and eff.APIKey instead of an inline check and
a redundant apiKey field it held separately. This makes EffectiveAgent.APIKey/
Ready() production-used rather than test-only, drops the duplicated readiness
check, and simplifies newAgentHolder's signature.
- settingsPayload no longer swallows an EffectiveAgent error into a misleading
empty "effective" view (which would read as "nothing configured"). It returns
the error; the handlers surface it as a 500. EffectiveAgent re-reads the row
GetInstanceSettings just returned, so a failure there is a real DB fault.
- Frontend streamChat handles 503 (assistant disabled at runtime) distinctly
from 404 (endpoint absent) — the backend returns 503 AGENT_DISABLED now, which
the old code mislabelled.
- Fixed the api.go comment that still said a disabled request gets a 404 — it's
a 503.
- updateSettings uses the shared parseNullable[bool] instead of a bespoke
parseNullableBool (now removed).
- NewRunner drops its empty-spec guard; agentmodel.Resolve already owns that
check, so one place decides what a valid spec is.
- rebuild-on-resolve-error now documents WHY it keeps the current Runner rather
than tearing down a working assistant on a transient DB blip: the state is
persisted, the next rebuild reconciles, and killing a live assistant on a read
hiccup is worse than a brief stale window.
Not changed: the store's version-conflict fallback returning GetInstanceSettings'
error instead of ErrVersionConflict when that read also fails. That's the exact
pattern every other version-guarded update uses (gardens/objects/plants); making
only this one differ would be the inconsistency. A DB read failing immediately
after the guarded UPDATE on the same local SQLite file is a disk-fault edge case,
and surfacing that error is defensible.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
90 lines
3.7 KiB
Go
90 lines
3.7 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"sync"
|
|
"sync/atomic"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agent"
|
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
|
)
|
|
|
|
// agentHolder owns the live assistant Runner and lets an admin swap it at
|
|
// runtime when the model settings change (#79).
|
|
//
|
|
// The Runner used to be a plain handlers field set once at boot, with the chat
|
|
// routes registered only when it existed. That made the assistant permanently
|
|
// whatever the environment said at startup. Now the routes are always
|
|
// registered and the Runner lives behind an atomic pointer, so a settings change
|
|
// can turn the assistant on, off, or onto a different model without a restart —
|
|
// and without a data race against in-flight requests reading the pointer.
|
|
//
|
|
// A nil pointer means "no assistant right now"; handlers nil-check get() rather
|
|
// than assuming a Runner is present.
|
|
type agentHolder struct {
|
|
svc *service.Service
|
|
|
|
ptr atomic.Pointer[agent.Runner]
|
|
// rebuildMu serializes rebuilds so two concurrent settings saves can't
|
|
// interleave into a torn "resolve A, resolve B, store A, store B" swap. The
|
|
// read path (get) stays lock-free on the atomic pointer.
|
|
rebuildMu sync.Mutex
|
|
}
|
|
|
|
// newAgentHolder builds the holder and resolves the initial Runner from whatever
|
|
// the settings + environment currently say. A resolution failure is logged and
|
|
// left as "no assistant", never fatal: a garden planner must still boot.
|
|
func newAgentHolder(ctx context.Context, svc *service.Service) *agentHolder {
|
|
h := &agentHolder{svc: svc}
|
|
h.rebuild(ctx)
|
|
return h
|
|
}
|
|
|
|
// get returns the current Runner, or nil if the assistant is off.
|
|
func (h *agentHolder) get() *agent.Runner { return h.ptr.Load() }
|
|
|
|
// rebuild resolves the effective agent configuration and swaps the Runner to
|
|
// match: a new one when the assistant should be on, nil when it shouldn't. It is
|
|
// safe to call at boot and from a settings save; concurrent calls serialize.
|
|
//
|
|
// It logs what it did rather than returning an error, because every caller wants
|
|
// the same thing — best-effort apply, keep serving either way — and a settings
|
|
// save must not fail just because the new model won't resolve. The save already
|
|
// validated the spec; a rebuild failure here means the environment changed under
|
|
// it, and "assistant off, with a reason in the log" is the right outcome.
|
|
func (h *agentHolder) rebuild(ctx context.Context) {
|
|
h.rebuildMu.Lock()
|
|
defer h.rebuildMu.Unlock()
|
|
|
|
eff, err := h.svc.EffectiveAgent(ctx)
|
|
if err != nil {
|
|
// EffectiveAgent errors only if the settings row can't be read — a DB fault,
|
|
// and a very transient one when it happens right after a settings write. We
|
|
// keep the current Runner rather than tear down a working assistant on a
|
|
// blip: the state is already persisted, so the next rebuild (any later save,
|
|
// or a restart) reconciles it. Loud, because a persistent failure here means
|
|
// the live assistant no longer matches stored settings.
|
|
slog.Error("api: could not resolve agent settings; leaving the assistant as-is", "error", err)
|
|
return
|
|
}
|
|
if !eff.Ready() {
|
|
if h.ptr.Swap(nil) != nil {
|
|
slog.Info("api: garden assistant turned off",
|
|
"enabled", eff.Enabled, "hasKey", eff.APIKey != "", "hasModel", eff.Model != "")
|
|
}
|
|
return
|
|
}
|
|
runner, err := agent.NewRunner(h.svc, eff.APIKey, eff.Model)
|
|
if err != nil {
|
|
// Configured but unusable. Turn the assistant off rather than leaving a
|
|
// stale Runner on the old model — an admin who just pointed it at a broken
|
|
// spec should see it stop, not silently keep answering on the previous one.
|
|
slog.Error("api: garden assistant disabled (model won't resolve)", "error", err, "model", eff.Model)
|
|
h.ptr.Store(nil)
|
|
return
|
|
}
|
|
h.ptr.Store(runner)
|
|
slog.Info("api: garden assistant ready", "model", eff.Model)
|
|
}
|