512 lines
16 KiB
Go
512 lines
16 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
|
|
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/platform"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/exec"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/projectindex"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/vector"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration/audit"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/persist"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/task"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/automations"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/qq_obv11"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/telegram"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/webhook"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink"
|
|
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/basic"
|
|
)
|
|
|
|
// defaultOwnerPath and defaultPersonasDir point at the on-disk persona/owner
|
|
// definitions shipped with the repo; they are relative to the working directory.
|
|
const (
|
|
defaultOwnerPath = "configs/users/owner.yaml"
|
|
defaultPersonasDir = "configs/personas"
|
|
defaultSQLitePath = "./data/cyrene.db"
|
|
fallbackInstruction = "You are Cyrene, a warm and attentive companion."
|
|
|
|
// Bound/knob constants. Compile-time so no bare magic number appears in the
|
|
// runtime path (project rule §10).
|
|
defaultMaxRounds = 8
|
|
defaultMaxParallel = 4
|
|
defaultAgentID = "orchestrator"
|
|
defaultToolTimeout = 30 * time.Second
|
|
)
|
|
|
|
// app holds every subsystem the single Cyrene binary assembles. It is built by
|
|
// newApp and torn down by close on shutdown.
|
|
type app struct {
|
|
cfg *config.Config
|
|
log *slog.Logger
|
|
db *sql.DB
|
|
bus bus.Bus
|
|
personas *personareg.Registry
|
|
caps *registry.Registry
|
|
dispatcher *tool.Dispatcher
|
|
memStore *memory.Store
|
|
retriever *memory.Retriever
|
|
loop *loop.Loop
|
|
scheduler *orchestration.Scheduler
|
|
tasks *task.Manager
|
|
iot *iot.Manager
|
|
sessions *session.SessionManager
|
|
automation *automations.Service
|
|
backthink *backthink.Backthink
|
|
vindex vector.Index
|
|
owner *user.OwnerProfile
|
|
gw *gateway.Server
|
|
}
|
|
|
|
// newApp assembles the full Cyrene runtime from cfg and log. It opens the
|
|
// database, migrates the schema, wires the persona registry, model adapter, tool
|
|
// dispatcher, memory stack, agent loop, orchestration, execution, IoT, channel
|
|
// sessions/adapters and the gateway, and returns the ready app.
|
|
//
|
|
// Fatal wiring failures (database open/migrate, required auth, gateway) are
|
|
// returned as errors; optional subsystems degrade gracefully so the binary can
|
|
// boot even when a model key or external channel is not configured.
|
|
func newApp(ctx context.Context, cfg *config.Config, log *slog.Logger) (*app, error) {
|
|
dataDir := resolveDataDir(cfg)
|
|
databasePath := resolveSQLitePath(cfg, dataDir)
|
|
dbCfg := cfg.DB
|
|
dbCfg.SQLitePath = databasePath
|
|
|
|
sqlDB, err := db.Open(ctx, databasePath, log)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open database: %w", err)
|
|
}
|
|
if err := db.MigrateUp(ctx, sqlDB, log); err != nil {
|
|
_ = sqlDB.Close()
|
|
return nil, fmt.Errorf("migrate database: %w", err)
|
|
}
|
|
|
|
eventBus := bus.New()
|
|
a := &app{
|
|
cfg: cfg,
|
|
log: log,
|
|
db: sqlDB,
|
|
bus: eventBus,
|
|
caps: registry.New(),
|
|
}
|
|
|
|
// Release already-opened resources if any later wiring step fails, without
|
|
// the full-service shutdown log (a partial boot is not a clean shutdown).
|
|
completed := false
|
|
defer func() {
|
|
if completed {
|
|
return
|
|
}
|
|
if a.vindex != nil {
|
|
_ = a.vindex.Close()
|
|
}
|
|
if a.tasks != nil {
|
|
_ = a.tasks.Close()
|
|
}
|
|
if a.bus != nil {
|
|
_ = a.bus.Close()
|
|
}
|
|
if a.db != nil {
|
|
_ = a.db.Close()
|
|
}
|
|
}()
|
|
|
|
if err := a.buildPersonas(); err != nil {
|
|
return a, err
|
|
}
|
|
if err := a.wireMemory(ctx); err != nil {
|
|
return a, err
|
|
}
|
|
if err := a.wireRuntime(); err != nil {
|
|
return a, err
|
|
}
|
|
if err := a.wireAuxiliary(); err != nil {
|
|
return a, err
|
|
}
|
|
if err := a.wireChannels(); err != nil {
|
|
return a, err
|
|
}
|
|
if err := a.wireFrontend(); err != nil {
|
|
return a, err
|
|
}
|
|
completed = true
|
|
return a, nil
|
|
}
|
|
|
|
// buildPersonas loads the persona definitions and registers them, plus the
|
|
// default character and the owner profile used by channel identity resolution.
|
|
func (a *app) buildPersonas() error {
|
|
a.personas = personareg.New()
|
|
|
|
dirPersonas, err := loader.LoadDir(defaultPersonasDir)
|
|
if err != nil {
|
|
a.log.Warn("persona dir load skipped", "error", err)
|
|
} else {
|
|
for _, p := range dirPersonas {
|
|
if rerr := a.personas.RegisterPersona(p); rerr != nil {
|
|
a.log.Warn("persona register skipped", "persona", p.ID, "error", rerr)
|
|
}
|
|
}
|
|
}
|
|
|
|
if owner, oerr := user.LoadOwnerProfile(defaultOwnerPath); oerr == nil {
|
|
a.owner = owner
|
|
} else {
|
|
a.log.Warn("owner profile load skipped", "error", oerr)
|
|
}
|
|
|
|
// Load the default persona explicitly if it was not under the personas dir.
|
|
defaultID := "cyrene"
|
|
if a.owner != nil && a.owner.DefaultPersona != "" {
|
|
defaultID = a.owner.DefaultPersona
|
|
}
|
|
if _, ok := a.personas.Persona(defaultID); !ok {
|
|
if p, lerr := loader.Load(filepath.Join(defaultPersonasDir, defaultID+".yaml")); lerr == nil {
|
|
_ = a.personas.RegisterPersona(p)
|
|
} else {
|
|
a.log.Warn("default persona load skipped", "persona", defaultID, "error", lerr)
|
|
}
|
|
}
|
|
if p, ok := a.personas.Persona(defaultID); ok {
|
|
char := &personareg.Character{ID: p.ID, Name: p.Name, Default: true, PersonaID: p.ID}
|
|
if cerr := a.personas.RegisterCharacter(char); cerr != nil {
|
|
a.log.Warn("default character register skipped", "error", cerr)
|
|
}
|
|
}
|
|
a.log.Info("personas ready", "count", a.personas.CountPersonas(), "default", defaultID)
|
|
return nil
|
|
}
|
|
|
|
// wireMemory builds the long-term memory stack: store, embedding manager, vector
|
|
// index, project index and the retriever wired into the io knowledge tool.
|
|
func (a *app) wireMemory(ctx context.Context) error {
|
|
store, err := memory.NewStore(ctx, a.db, a.log)
|
|
if err != nil {
|
|
return fmt.Errorf("memory store: %w", err)
|
|
}
|
|
a.memStore = store
|
|
|
|
embedManager := embedding.New(a.cfg.Embedding)
|
|
embedder := embedManager.Embedder()
|
|
|
|
index, err := vector.New(a.dbCfgForEmbed(), embedder, a.log)
|
|
if err != nil {
|
|
a.log.Warn("vector index unavailable, falling back to in-memory semantic", "error", err)
|
|
} else {
|
|
a.vindex = index
|
|
}
|
|
|
|
projStore, err := projectindex.New(ctx, a.db, a.log)
|
|
if err != nil {
|
|
a.log.Warn("project index unavailable", "error", err)
|
|
projStore = nil
|
|
}
|
|
|
|
opts := []memory.RetrieverOption{memory.WithEmbedder(embedder)}
|
|
if a.vindex != nil {
|
|
opts = append(opts, memory.WithVectorIndex(a.vindex))
|
|
}
|
|
if projStore != nil {
|
|
opts = append(opts, memory.WithProjectIndex(projStore))
|
|
}
|
|
a.retriever = memory.NewRetriever(store, opts...)
|
|
return nil
|
|
}
|
|
|
|
// wireRuntime builds the model adapter, tool dispatcher, guardrails, the agent
|
|
// and its loop, tracing, persistence and the orchestration scheduler.
|
|
func (a *app) wireRuntime() error {
|
|
adapter, aerr := model.NewChatChain(a.cfg.LLM)
|
|
if aerr != nil {
|
|
a.log.Warn("llm adapter unavailable", "error", aerr)
|
|
adapter = nil
|
|
}
|
|
router := model.NewRouter(model.DefaultModelForProvider(a.cfg.LLM))
|
|
|
|
dispatcher, derr := tool.New(a.caps, tool.WithDefaultTimeout(defaultToolTimeout))
|
|
if derr != nil {
|
|
return fmt.Errorf("tool dispatcher: %w", derr)
|
|
}
|
|
a.dispatcher = dispatcher
|
|
|
|
if err := basic.Register(a.caps); err != nil {
|
|
return fmt.Errorf("register basic tools: %w", err)
|
|
}
|
|
ioTools, ioErr := iotools.New(a.cfg, iotools.WithKnowledgeRetriever(a.retriever), iotools.WithLogger(a.log))
|
|
if ioErr != nil {
|
|
return fmt.Errorf("build io tools: %w", ioErr)
|
|
}
|
|
for _, t := range ioTools {
|
|
if rerr := a.dispatcher.Register(t); rerr != nil {
|
|
a.log.Warn("io tool register skipped", "tool", t.Name(), "error", rerr)
|
|
}
|
|
}
|
|
|
|
guards := guardrail.New()
|
|
instructions := a.defaultInstructions()
|
|
ag, aerr := agent.New(instructions, a.dispatcher, guards,
|
|
agent.WithModel(adapter, router),
|
|
agent.WithOptions(agent.Options{MaxRounds: defaultMaxRounds, Purpose: "chat"}))
|
|
if aerr != nil {
|
|
return fmt.Errorf("agent: %w", aerr)
|
|
}
|
|
|
|
personaID := a.defaultPersonaID()
|
|
store := a.memStore
|
|
|
|
tracer := trace.New(trace.NewJSONRecorder(os.Stderr))
|
|
checkpoint, perr := persist.New(context.Background(), a.db, a.log)
|
|
if perr != nil {
|
|
return fmt.Errorf("checkpoint store: %w", perr)
|
|
}
|
|
|
|
a.loop = loop.New(loop.Options{
|
|
Agent: ag,
|
|
Tools: a.dispatcher,
|
|
Memory: store,
|
|
Retriever: a.retriever,
|
|
Extractor: memory.NewManualExtractor(personaID, "owner"),
|
|
Checkpoint: checkpoint,
|
|
Tracer: tracer,
|
|
Guards: guards,
|
|
PersonaID: personaID,
|
|
MaxRounds: defaultMaxRounds,
|
|
WorkingMemory: memory.NewWorkingMemory(0),
|
|
Log: logger.WithSource(a.log, "runtime/loop"),
|
|
})
|
|
|
|
a.scheduler = orchestration.New(orchestration.Options{
|
|
MaxParallel: defaultMaxParallel,
|
|
Loop: a.loop,
|
|
Bus: a.bus,
|
|
Memory: store,
|
|
Tracer: tracer,
|
|
Approver: audit.NewApprover(a.log),
|
|
Logger: logger.WithSource(a.log, "orchestration"),
|
|
AgentID: defaultAgentID,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// wireAuxiliary builds the execution provider, task manager, IoT manager,
|
|
// channel session manager, automations and background thinking services.
|
|
func (a *app) wireAuxiliary() error {
|
|
provider, err := exec.New(a.cfg.ExecSandbox, a.log)
|
|
if err != nil {
|
|
return fmt.Errorf("exec provider: %w", err)
|
|
}
|
|
tmgr, err := task.New(a.cfg, provider, a.log, task.WithBus(a.bus))
|
|
if err != nil {
|
|
return fmt.Errorf("task manager: %w", err)
|
|
}
|
|
a.tasks = tmgr
|
|
|
|
iotMgr, err := iot.New(a.cfg.IoT, a.log)
|
|
if err != nil {
|
|
return fmt.Errorf("iot: %w", err)
|
|
}
|
|
a.iot = iotMgr
|
|
|
|
sess, err := session.New(context.Background(), a.db, a.log)
|
|
if err != nil {
|
|
return fmt.Errorf("session manager: %w", err)
|
|
}
|
|
a.sessions = sess
|
|
|
|
automation, err := automations.NewService(automations.Config{}, automations.Deps{
|
|
Bus: a.bus,
|
|
Loop: a.loop,
|
|
Log: logger.WithSource(a.log, "services/automations"),
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("automations: %w", err)
|
|
}
|
|
a.automation = automation
|
|
|
|
bt, err := backthink.New(backthink.ConfigFromBackground(a.cfg.Background), backthink.Deps{
|
|
Bus: a.bus,
|
|
Thinker: backthink.NewLoopThinker(a.loop),
|
|
State: backthink.NewIoTStateProvider(iotMgr, a.log),
|
|
DB: a.db,
|
|
Log: logger.WithSource(a.log, "services/backthink"),
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("backthink: %w", err)
|
|
}
|
|
a.backthink = bt
|
|
return nil
|
|
}
|
|
|
|
// wireChannels constructs and registers the four IM channel adapters so the
|
|
// admin API can enumerate them. They are registered but not connected: real
|
|
// platform credentials are supplied per-deployment, and connecting with empty
|
|
// config would dial external networks.
|
|
func (a *app) wireChannels() error {
|
|
channels := []struct {
|
|
name string
|
|
value any
|
|
policy registry.ToolPolicy
|
|
}{
|
|
{"qq-obv11", qq_obv11.NewAdapter(qq_obv11.Config{Logger: a.log}), registry.ToolPolicy{}},
|
|
{"weixin-ilink", mustWeixin(a.log), registry.ToolPolicy{}},
|
|
{"telegram", telegram.New(telegram.Config{}, telegram.WithLogger(a.log)), registry.ToolPolicy{}},
|
|
{"webhook", webhook.New(webhook.WithLogger(a.log)), registry.ToolPolicy{}},
|
|
}
|
|
for _, ch := range channels {
|
|
capp := registry.Capability{
|
|
Name: ch.name,
|
|
Kind: registry.KindChannel,
|
|
Description: ch.name,
|
|
Policy: ch.policy,
|
|
Value: ch.value,
|
|
}
|
|
if err := a.caps.Register(capp); err != nil {
|
|
a.log.Warn("channel register skipped", "channel", ch.name, "error", err)
|
|
}
|
|
}
|
|
a.log.Info("channel adapters registered", "count", len(channels))
|
|
return nil
|
|
}
|
|
|
|
// wireFrontend builds the gateway with the embedded/external SPA filesystem and
|
|
// the JWT/RBAC authenticator.
|
|
func (a *app) wireFrontend() error {
|
|
jwt := auth.New(a.cfg.Auth)
|
|
gw, err := gateway.New(a.cfg, gateway.Deps{
|
|
Log: logger.WithSource(a.log, "gateway"),
|
|
Auth: jwt,
|
|
Bus: a.bus,
|
|
Registry: a.caps,
|
|
Loop: a.loop,
|
|
IoT: a.iot,
|
|
Frontend: resolveFrontendFS(a.log),
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("gateway: %w", err)
|
|
}
|
|
a.gw = gw
|
|
return nil
|
|
}
|
|
|
|
// dbCfgForEmbed returns the DB config used by the vector index, which opens the
|
|
// same SQLite file as the primary pool.
|
|
func (a *app) dbCfgForEmbed() config.DBConfig {
|
|
cfg := a.cfg.DB
|
|
cfg.SQLitePath = resolveSQLitePath(a.cfg, resolveDataDir(a.cfg))
|
|
return cfg
|
|
}
|
|
|
|
// defaultPersonaID returns the id of the default persona, falling back to
|
|
// "cyrene".
|
|
func (a *app) defaultPersonaID() string {
|
|
if c, ok := a.personas.DefaultCharacter(); ok {
|
|
return c.PersonaID
|
|
}
|
|
return "cyrene"
|
|
}
|
|
|
|
// defaultInstructions returns the default persona's system prompt, falling back
|
|
// to its role text or a minimal instruction when neither is set.
|
|
func (a *app) defaultInstructions() string {
|
|
if c, ok := a.personas.DefaultCharacter(); ok {
|
|
if p, ok := a.personas.Persona(c.PersonaID); ok {
|
|
if p.Identity.SystemPrompt != "" {
|
|
return p.Identity.SystemPrompt
|
|
}
|
|
if p.Identity.Role != "" {
|
|
return p.Identity.Role
|
|
}
|
|
}
|
|
}
|
|
return fallbackInstruction
|
|
}
|
|
|
|
// close shuts down every subsystem in dependency order and releases resources.
|
|
func (a *app) close(ctx context.Context) {
|
|
if a.backthink != nil {
|
|
_ = a.backthink.Stop(ctx)
|
|
}
|
|
if a.automation != nil {
|
|
_ = a.automation.Stop(ctx)
|
|
}
|
|
if a.scheduler != nil {
|
|
_ = a.scheduler.CancelAll(ctx)
|
|
}
|
|
if a.gw != nil {
|
|
a.gw.Close()
|
|
}
|
|
if a.tasks != nil {
|
|
_ = a.tasks.Close()
|
|
}
|
|
if a.vindex != nil {
|
|
_ = a.vindex.Close()
|
|
}
|
|
if a.bus != nil {
|
|
_ = a.bus.Close()
|
|
}
|
|
if a.db != nil {
|
|
_ = a.db.Close()
|
|
}
|
|
a.log.Info("cyrene shutdown complete")
|
|
}
|
|
|
|
// resolveDataDir returns the configured data directory (from .env / env, via
|
|
// cfg.DataDir), falling back to the OS-appropriate default only when no
|
|
// directory is configured.
|
|
func resolveDataDir(cfg *config.Config) string {
|
|
if cfg.DataDir != "" {
|
|
return cfg.DataDir
|
|
}
|
|
return platform.DefaultDataDir()
|
|
}
|
|
|
|
// resolveSQLitePath returns the SQLite path to use, deriving it under the data
|
|
// directory when the configured default is left untouched.
|
|
func resolveSQLitePath(cfg *config.Config, dataDir string) string {
|
|
p := cfg.DB.SQLitePath
|
|
if strings.TrimSpace(p) == "" || p == defaultSQLitePath {
|
|
return filepath.Join(dataDir, "cyrene.db")
|
|
}
|
|
return p
|
|
}
|
|
|
|
// mustWeixin builds the weixin-ilink adapter, panicking on an invalid config
|
|
// (which should never happen with defaults).
|
|
func mustWeixin(log *slog.Logger) *weixin_ilink.Adapter {
|
|
adapter, err := weixin_ilink.New(weixin_ilink.Config{}, nil, log)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("weixin-ilink adapter: %v", err))
|
|
}
|
|
return adapter
|
|
}
|