package runtime import ( "github.com/krow/krow-backend/go-api/internal/config" "github.com/krow/krow-backend/go-api/internal/gateway" "github.com/krow/krow-backend/go-api/internal/knowledge" "github.com/krow/krow-backend/go-api/internal/repo" "github.com/krow/krow-backend/go-api/internal/tools" ) // NewModelEngine builds the production runtime: the loader, the agent loop, a // live model gateway and a Postgres trajectory sink. // // One call, because the alternative is four, and four assembled at a call site // is how a deployment ends up running with a DiscardSink nobody chose. A test // that wants a fake model still reaches for NewEngine with WithAgentExecutor — // this function is the wiring, not a second way to configure the runtime. // // Skills keep the refusing stub. A skill has no executor of its own: the loop // runs agents, and a skill reaches a model only as a capability an agent // carries. Handing SkillExec a model would create a second, unbounded path to // one — which is exactly the shape I3 exists to prevent. func NewModelEngine(db repo.Querier, cfg config.Config) *Engine { gw := gateway.NewAnthropic(gateway.FromConfig(cfg.Model)) retriever := knowledge.NewRetriever(db, NewEmbedder(cfg)) exec := NewModelExecutor(gw, NewPostgresSink(db), DefaultTools(db, retriever)). WithRetriever(retriever) return NewEngine(db, WithAgentExecutor(exec)) } // NewEmbedder picks the embedding provider from configuration. // // Explicit first, then what is configured, then nothing. The order is the whole // design: three providers all return vectors and retrieval works with any of // them, so a deployment running the wrong one looks identical to one running // the right one until somebody phrases a question differently. Naming the // provider is how that stops being a silent condition. // // ollama A model on this machine. Real semantics, no credential, no // per-token cost, no tenant text leaving the host. The default // worth reaching for. // voyage Hosted. Better on subtle retrieval over a large messy corpus, // and the only one that needs a credential. // lexical The deterministic stand-in. NOT semantic — it matches shared // vocabulary and nothing else. Development only; config.validate // refuses it in production. // // Returns nil when nothing is configured, and retrieval then runs keyword-only, // saying so on every result. Nil rather than a hosted client with an empty key: // both end up keyword-only, but nil says "no embedder is configured" once, at // wiring time, instead of failing an HTTP call per query to learn the same // thing. func NewEmbedder(cfg config.Config) knowledge.Embedder { k := cfg.Knowledge provider := k.EmbedProvider if provider == "" { // Nothing named. Infer from what is actually present, preferring the // one that costs nothing and keeps text local. switch { case k.UseLexicalEmbedder: provider = "lexical" case k.EmbedBaseURL != "": provider = "ollama" case k.EmbedAPIKey != "": provider = "voyage" default: return nil } } switch provider { case "ollama": return knowledge.NewOllama(k.EmbedBaseURL, k.EmbedModel, k.EmbedDims) case "voyage": if k.EmbedAPIKey == "" { // Named but unusable. Nil, so retrieval degrades honestly rather // than failing a request per query on a credential nobody set. return nil } model, dims := k.EmbedModel, k.EmbedDims if model == "" { model = knowledge.DefaultVoyageModel } if dims == 0 { dims = knowledge.DefaultVoyageDims } return knowledge.NewVoyage(k.EmbedAPIKey, model, dims) case "lexical": dims := k.EmbedDims if dims == 0 { dims = 256 } e := knowledge.NewLexical(dims) // Told what environment it is in, so its own refusal is the backstop // behind config.validate's. e.Production = cfg.AppEnv == "production" return e } return nil } // DefaultTools is the tool registry this service ships with. // // One function, so "which tools exist" has a single answer that a test and the // server reach the same way. Registration panics on a malformed tool: a // service that booted without a capability its specs name would fail one run // at a time instead of once, loudly, at startup. func DefaultTools(db repo.Querier, retriever *knowledge.Retriever) *tools.Registry { // The confirmation store is Postgres-backed, not in-process. A pending // write is asked about in one request and approved in another, and nothing // guarantees those two reach the same replica — an in-memory store would // refuse a large share of perfectly good approvals, for a reason invisible // to the person clicking. See tools.MemoryStore's own warning. reg := tools.NewRegistryWithStore(tools.NewPostgresStore(db)) for _, t := range []tools.Tool{ // Activity tools.ActivityBreakdown(db), tools.ActivitySignals(db), // Workforce tools.WorkforceAttendance(db), tools.WorkforceOvertime(db), tools.WorkforceCoverage(db), tools.WorkforceTraining(db), // Hiring tools.CandidatesQuality(db), tools.HiresRecent(db), tools.HiresPerformance(db), tools.PositionsRisk(db), tools.TalentPool(db), // Cross-domain tools.WorkspaceSummary(db), tools.OperationsRisk(db), // Assignments: the two lookups that yield ids, and the one write that // consumes them. assign_worker is the only tool here with an effect, // and it cannot run without an approval — see tools/confirm.go. tools.OpenPositions(db), tools.AvailableWorkers(db), tools.AssignWorker(db), // The hiring funnel: the lookup that yields application ids, and the // write that moves somebody through it. Replaces the browser panel's // interview matcher, which was the one capability the old templates had // that the tool layer did not. tools.CandidatesAwaiting(db), tools.MoveApplication(db), // Knowledge. Registered once; which corpora it may read comes from the // running agent's spec by way of the tool Context, so this single // registration serves every agent without any of them being able to // name another's documents. tools.KnowledgeSearch(retriever), } { reg.MustRegister(t) } return reg }