Files
gnoma/internal/engine/engine.go
T
vikingowl c4fde583f5 chore(lint): gofmt sweep + errcheck cleanups in router discovery
Apply gofmt -w across the codebase (struct field comment realignment
only — no semantic changes) and silence two errcheck warnings on
fmt.Sscanf / fmt.Fprintf return values in internal/router/discovery
with explicit `_, _ =` discards. Required so `make check` is green
before tagging v0.1.0.
2026-05-20 03:13:05 +02:00

422 lines
12 KiB
Go

package engine
import (
"context"
"fmt"
"log/slog"
"sync"
gnomactx "somegit.dev/Owlibou/gnoma/internal/context"
"somegit.dev/Owlibou/gnoma/internal/hook"
"somegit.dev/Owlibou/gnoma/internal/message"
"somegit.dev/Owlibou/gnoma/internal/permission"
"somegit.dev/Owlibou/gnoma/internal/provider"
"somegit.dev/Owlibou/gnoma/internal/router"
"somegit.dev/Owlibou/gnoma/internal/security"
"somegit.dev/Owlibou/gnoma/internal/tool"
"somegit.dev/Owlibou/gnoma/internal/tool/persist"
)
// Config holds engine configuration.
type Config struct {
Provider router.SecureProvider // direct provider (used if Router is nil)
Router *router.Router // nil = use Provider directly
Classifier router.TaskClassifier // nil = HeuristicClassifier
Tools *tool.Registry
Firewall *security.Firewall // nil = no scanning
Permissions *permission.Checker // nil = allow all
Context *gnomactx.Window // nil = no compaction
System string // system prompt
Model string // override model (empty = provider default)
Temperature *float64 // nil = provider default
MaxTurns int // safety limit on tool loops (0 = unlimited)
Store *persist.Store // nil = no result persistence
Hooks *hook.Dispatcher // nil = no hooks
Logger *slog.Logger
// ForceTwoStageTools forces the two-stage tool-routing path on the forced
// arm regardless of its context window. When false, two-stage is enabled
// automatically for local arms with ContextWindow <= twoStageContextLimit.
ForceTwoStageTools bool
}
// twoStageContextLimit is the upper bound on arm context window (in tokens)
// under which two-stage tool routing kicks in automatically. Models bigger
// than this can afford the full tool catalogue in every request.
const twoStageContextLimit = 16384
func (c Config) validate() error {
if c.Provider == nil {
return fmt.Errorf("engine: provider required")
}
if c.Tools == nil {
return fmt.Errorf("engine: tool registry required")
}
return nil
}
// Turn is the result of a complete agentic turn (may span multiple API calls).
type Turn struct {
Messages []message.Message // all messages produced (assistant + tool results)
Usage message.Usage // cumulative for all API calls in this turn
Rounds int // number of API round-trips
}
// TurnOptions carries per-turn overrides that apply for a single Submit call.
type TurnOptions struct {
ToolChoice provider.ToolChoiceMode // "" = use provider default
AllowedTools []string // if non-nil, only these tools are sent (matched by name)
AllowedPaths []string // if non-nil, tool filesystem access is restricted to these paths
}
// Engine orchestrates the conversation.
//
// Mutable state (history, usage, activatedTools, modelCaps, turnOpts, and the
// hot fields of cfg — Provider/Model) is guarded by mu. The lock is released
// across blocking provider.Stream calls so external setters can interleave.
type Engine struct {
mu sync.Mutex
cfg Config
history []message.Message
usage message.Usage
logger *slog.Logger
modelCaps *provider.Capabilities
modelCapsFor string
activatedTools map[string]bool
turnOpts TurnOptions
// selectedCategory is set when the model picks a category via the
// synthetic select_category tool under two-stage routing. Empty string
// means "round 1 of two-stage" (or two-stage inactive). Reset at the end
// of each turn together with turnOpts.
selectedCategory tool.Category
}
// ToolsAvailable reports whether the current model supports tool calling.
func (e *Engine) ToolsAvailable() bool {
return e.forcedArmSupportsTools()
}
// forcedArmSupportsTools returns true if tool definitions should be included
// in the request. When the router has a forced arm, checks its ToolUse
// capability. Returns true for multi-arm routing (feasibility filter handles it)
// or when no router is configured.
func (e *Engine) forcedArmSupportsTools() bool {
if e.cfg.Router == nil {
return true
}
id := e.cfg.Router.ForcedArm()
if id == "" {
return true // multi-arm routing: router handles feasibility
}
arm, ok := e.cfg.Router.LookupArm(id)
if !ok {
if e.logger != nil {
e.logger.Debug("forced arm not found in router, assuming tool support", "arm", id)
}
return true
}
if e.logger != nil {
e.logger.Debug("forced arm tool support check",
"arm", id,
"tool_use", arm.Capabilities.ToolUse,
)
}
return arm.Capabilities.ToolUse
}
// isLocalArm returns true if the forced arm is a local provider (Ollama, llama.cpp).
func (e *Engine) isLocalArm() bool {
if e.cfg.Router == nil {
return false
}
id := e.cfg.Router.ForcedArm()
if id == "" {
return false
}
arm, ok := e.cfg.Router.LookupArm(id)
if !ok {
return false
}
return arm.IsLocal
}
// useTwoStageTools reports whether the current turn should use the two-stage
// tool-routing path. True when:
// - cfg.ForceTwoStageTools is set, OR
// - a forced arm exists, is local, and its ContextWindow is small enough
// that the full tool catalogue would burn a non-trivial fraction of the
// prompt budget.
//
// Multi-arm routing (no forced arm) and cloud arms do not trigger two-stage.
func (e *Engine) useTwoStageTools() bool {
if e.cfg.ForceTwoStageTools {
return true
}
if e.cfg.Router == nil {
return false
}
id := e.cfg.Router.ForcedArm()
if id == "" {
return false
}
arm, ok := e.cfg.Router.LookupArm(id)
if !ok {
return false
}
if !arm.IsLocal {
return false
}
cw := arm.Capabilities.ContextWindow
if cw <= 0 {
// Unknown context window on a local arm — assume small.
return true
}
return cw <= twoStageContextLimit
}
// New creates an engine.
func New(cfg Config) (*Engine, error) {
if err := cfg.validate(); err != nil {
return nil, err
}
logger := cfg.Logger
if logger == nil {
logger = slog.Default()
}
return &Engine{
cfg: cfg,
logger: logger,
activatedTools: make(map[string]bool),
}, nil
}
// resolveCapabilities returns the capabilities for the active model.
// Caches the result — re-resolves if the model changes.
func (e *Engine) resolveCapabilities(ctx context.Context) *provider.Capabilities {
e.mu.Lock()
model := e.cfg.Model
if model == "" {
model = e.cfg.Provider.DefaultModel()
}
if e.modelCaps != nil && e.modelCapsFor == model {
caps := e.modelCaps
e.mu.Unlock()
return caps
}
prov := e.cfg.Provider
e.mu.Unlock()
models, err := prov.Models(ctx)
if err != nil {
e.logger.Debug("failed to fetch model capabilities", "error", err)
return nil
}
for _, m := range models {
if m.ID == model {
e.mu.Lock()
e.modelCaps = &m.Capabilities
e.modelCapsFor = model
caps := e.modelCaps
e.mu.Unlock()
return caps
}
}
e.logger.Debug("model not found in provider model list", "model", model)
return nil
}
// History returns a snapshot copy of the conversation.
func (e *Engine) History() []message.Message {
e.mu.Lock()
defer e.mu.Unlock()
out := make([]message.Message, len(e.history))
copy(out, e.history)
return out
}
// ContextWindow returns the context window (may be nil).
func (e *Engine) ContextWindow() *gnomactx.Window {
return e.cfg.Context
}
// InjectMessage appends a message to conversation history without triggering a turn.
// Used for system notifications (permission mode changes, incognito toggles) that
// the model should see as context in subsequent turns.
func (e *Engine) InjectMessage(msg message.Message) {
e.mu.Lock()
e.history = append(e.history, msg)
e.mu.Unlock()
if e.cfg.Context != nil {
e.cfg.Context.AppendMessage(msg)
}
}
// Usage returns cumulative token usage.
func (e *Engine) Usage() message.Usage {
e.mu.Lock()
defer e.mu.Unlock()
return e.usage
}
// SetProvider swaps the active provider (for dynamic switching).
//
// Callers must pass a provider that has already been wrapped with
// security.WrapProvider — the engine's buildRequest scans inline today,
// but the boundary contract is "every Stream call routes through a
// SafeProvider." Passing a raw provider here would silently open a
// firewall bypass for any engine path that calls Provider.Stream
// without going through buildRequest.
// SetProvider changes the provider for the engine.
func (e *Engine) SetProvider(p router.SecureProvider) {
e.mu.Lock()
e.cfg.Provider = p
e.mu.Unlock()
}
// SetModel changes the model within the current provider.
func (e *Engine) SetModel(model string) {
e.mu.Lock()
e.cfg.Model = model
e.mu.Unlock()
}
// SetHistory replaces the conversation history (for session restore).
// Also syncs the context window and re-estimates the tracker's token count.
func (e *Engine) SetHistory(msgs []message.Message) {
e.mu.Lock()
e.history = msgs
e.mu.Unlock()
if e.cfg.Context != nil {
e.cfg.Context.SetMessages(msgs)
e.cfg.Context.Tracker().Set(e.cfg.Context.Tracker().CountMessages(msgs))
}
}
// SetUsage sets cumulative token usage (for session restore).
func (e *Engine) SetUsage(u message.Usage) {
e.mu.Lock()
e.usage = u
e.mu.Unlock()
}
// SetActivatedTools restores the set of activated deferred tools (for session restore).
func (e *Engine) SetActivatedTools(tools map[string]bool) {
e.mu.Lock()
e.activatedTools = tools
e.mu.Unlock()
}
// classify returns a Task for the given prompt using the configured classifier.
// Falls back to HeuristicClassifier if none is configured or if classification fails.
func (e *Engine) classify(ctx context.Context, prompt string) router.Task {
cls := e.cfg.Classifier
if cls == nil {
cls = router.HeuristicClassifier{}
}
e.mu.Lock()
histSnap := make([]message.Message, len(e.history))
copy(histSnap, e.history)
e.mu.Unlock()
task, err := cls.Classify(ctx, prompt, histSnap)
if err != nil {
e.logger.Debug("classifier error, falling back to heuristic", "error", err)
return router.ClassifyTask(prompt)
}
return task
}
// latestUserPrompt returns the text of the most recent user message.
func (e *Engine) latestUserPrompt() string {
e.mu.Lock()
defer e.mu.Unlock()
for i := len(e.history) - 1; i >= 0; i-- {
if e.history[i].Role == message.RoleUser {
return e.history[i].TextContent()
}
}
return ""
}
// historySnapshot returns a copy of the current history slice.
func (e *Engine) historySnapshot() []message.Message {
e.mu.Lock()
defer e.mu.Unlock()
out := make([]message.Message, len(e.history))
copy(out, e.history)
return out
}
// appendHistory appends a message under the lock.
func (e *Engine) appendHistory(msg message.Message) {
e.mu.Lock()
e.history = append(e.history, msg)
e.mu.Unlock()
}
// replaceHistory swaps the history slice (used after context compaction).
func (e *Engine) replaceHistory(msgs []message.Message) {
e.mu.Lock()
e.history = msgs
e.mu.Unlock()
}
// addUsage accumulates token usage.
func (e *Engine) addUsage(u message.Usage) {
e.mu.Lock()
e.usage.Add(u)
e.mu.Unlock()
}
// activeProvider returns the current provider under lock.
func (e *Engine) activeProvider() provider.Provider {
e.mu.Lock()
defer e.mu.Unlock()
return e.cfg.Provider
}
// activeModel returns the configured model name under lock.
func (e *Engine) activeModel() string {
e.mu.Lock()
defer e.mu.Unlock()
return e.cfg.Model
}
// snapshotTurnOpts returns a copy of the current per-turn options.
func (e *Engine) snapshotTurnOpts() TurnOptions {
e.mu.Lock()
defer e.mu.Unlock()
return e.turnOpts
}
// markToolActivated records that a deferred tool has been requested.
func (e *Engine) markToolActivated(name string) {
e.mu.Lock()
e.activatedTools[name] = true
e.mu.Unlock()
}
// isToolActivated reports whether a deferred tool has been activated.
func (e *Engine) isToolActivated(name string) bool {
e.mu.Lock()
defer e.mu.Unlock()
return e.activatedTools[name]
}
// Reset clears conversation history and usage.
func (e *Engine) Reset() {
e.mu.Lock()
e.history = nil
e.usage = message.Usage{}
e.activatedTools = make(map[string]bool)
e.mu.Unlock()
if e.cfg.Context != nil {
e.cfg.Context.Reset()
}
}