Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

581 lines
20 KiB
Go

// Package svc is the runner layer that ties an llm.Provider to the
// agent tool-loop (package llm/agent) and the search-assist passes
// (assist.go). It lives in its own package to break the import cycle
// that would otherwise exist between `llm` and `llm/agent`.
//
// svc is pure Go: the `-tags llama` build-tag split is contained
// entirely within the provider packages. The daemon links the same
// Service whether or not the tag is set — without it only the `local`
// provider is unavailable; the HTTP providers still work, and a
// disabled service degrades cleanly (Enabled() reports false).
package svc
import (
"context"
"errors"
"strings"
"sync"
"time"
"go.uber.org/zap"
"github.com/zzet/gortex/internal/llm"
"github.com/zzet/gortex/internal/llm/agent"
"github.com/zzet/gortex/internal/llm/conversationlog"
"github.com/zzet/gortex/internal/llm/provider"
"github.com/zzet/gortex/internal/savings"
)
// loadable is implemented by a provider whose backing model is loaded
// lazily and can be unloaded when idle (the local llama provider). The
// service consults it via ModelLoaded so the search-assist gate can
// avoid triggering an expensive cold load while enrichment is running.
// Providers with no cold-load cost (the HTTP backends) do not implement
// it and are always treated as ready.
type loadable interface {
Loaded() bool
}
// loggerConfigurable is implemented by a provider that emits lifecycle
// diagnostics (model load / idle unload) to a structured logger the
// service supplies after construction.
type loggerConfigurable interface {
SetLogger(*zap.Logger)
}
// ServiceOption customises a Service at construction. Options are
// applied before the provider is built so a WithLogger logger can be
// propagated into a lifecycle-aware provider.
type ServiceOption func(*Service)
// WithBusyPredicate installs the enrichment-busy predicate the
// search-assist gate consults: when it reports true and the local model
// is not yet loaded, an implicit assist request defers its cold load
// rather than contend with the in-flight enrichment pass. A nil
// predicate (the default) always reads as "not busy".
func WithBusyPredicate(fn func() bool) ServiceOption {
return func(s *Service) { s.busy = fn }
}
// WithLogger attaches the structured logger the service propagates into
// a lifecycle-aware provider (the local model's load / idle-unload
// diagnostics).
func WithLogger(l *zap.Logger) ServiceOption {
return func(s *Service) { s.logger = l }
}
// errServiceUnavailable is returned by operational methods when no
// provider could be constructed (disabled config, build without
// `-tags llama` for the local provider, missing API key, ...).
var errServiceUnavailable = errors.New("llm: service unavailable — no provider configured")
// Service is the reusable LLM access point. It wraps a constructed
// llm.Provider plus a Backend (typically an InProcessBackend pointing
// at the daemon's *query.Engine). Three consumption shapes:
//
// - Generate: one-shot prompt → text. Freeform completion.
// - RunAgent: the grammar/schema-constrained tool-calling loop that
// uses the Backend's tools to navigate the graph. Backs the MCP
// `ask` tool.
// - ExpandQuery / RerankSymbols / VerifyRelevance: the search-assist
// passes — short structured completions backing the `search_symbols`
// `assist` argument (see assist.go).
//
// The active provider is chosen by llm.Config.Provider. The prompt
// tier (profile) is derived from the provider's Name() so the assist
// passes prompt small local models and hosted frontier models
// differently — see llm.ProfileForProvider.
type Service struct {
cfg llm.Config
backend llm.Backend
provider llm.Provider
providerErr error
profile llm.PromptProfile
expandCache *assistCache
rerankCache *assistCache
verifyCache *assistCache
// routedProviders caches one provider per distinct model id when
// llm.routing is enabled — see providerForModel. Guarded by
// routedMu; closed by Close.
routedMu sync.Mutex
routedProviders map[string]llm.Provider
// convLog records the exact request/response of each completion as
// JSONL when a conversation-log directory is configured. Opt-in
// (records raw LLM I/O): a nil/disabled logger is a no-op.
convLog *conversationlog.Logger
// busy, when set, reports whether a background enrichment pass is in
// flight — the signal the search-assist gate uses to defer a local
// model cold load. Nil reads as "not busy". Installed via
// WithBusyPredicate.
busy func() bool
// logger receives the provider's lifecycle diagnostics (model load /
// idle unload). Installed via WithLogger; nil degrades to a no-op.
logger *zap.Logger
}
// NewService constructs the service and its provider. Provider
// construction is cheap for every backend — the local provider only
// validates its config here and defers the model mmap to the first
// call. A disabled or misconfigured config yields a Service whose
// Enabled() reports false; the construction error is retained and
// surfaced via ProviderErr.
func NewService(cfg llm.Config, backend llm.Backend, opts ...ServiceOption) *Service {
cfg = cfg.ApplyDefaults()
s := &Service{
cfg: cfg,
backend: backend,
expandCache: newAssistCache(256),
rerankCache: newAssistCache(256),
verifyCache: newAssistCache(256),
routedProviders: map[string]llm.Provider{},
convLog: conversationlog.New(conversationlog.DirFromEnv()),
}
for _, opt := range opts {
opt(s)
}
if cfg.IsEnabled() && backend != nil {
p, err := provider.New(cfg)
if err != nil {
s.providerErr = err
} else {
s.provider = p
s.profile = llm.ProfileForProvider(p.Name())
s.configureProvider(p)
}
}
return s
}
// configureProvider propagates the service's logger into a
// lifecycle-aware provider (the local model). A provider without the
// SetLogger method, or a nil logger, is left untouched.
func (s *Service) configureProvider(p llm.Provider) {
if s.logger == nil {
return
}
if lc, ok := p.(loggerConfigurable); ok {
lc.SetLogger(s.logger)
}
}
// ModelLoaded reports whether the active provider's backing model is
// resident. A provider with no cold-load cost (the HTTP backends), or no
// provider at all, always reports true — there is nothing to load.
func (s *Service) ModelLoaded() bool {
if s == nil || s.provider == nil {
return true
}
if l, ok := s.provider.(loadable); ok {
return l.Loaded()
}
return true
}
// EnrichmentBusy reports whether the configured busy predicate fires —
// i.e. a background enrichment pass is currently running. A nil
// predicate (never configured) reports false.
func (s *Service) EnrichmentBusy() bool {
if s == nil || s.busy == nil {
return false
}
return s.busy()
}
// SetConversationDir enables (or disables) the conversation-log sink at
// runtime. A non-empty dir turns recording on; "" turns it off. This is
// the opt-in wiring point used by the daemon/server when the operator
// configures a conversation-log location.
func (s *Service) SetConversationDir(dir string) {
if s == nil {
return
}
if s.convLog != nil {
_ = s.convLog.Close()
}
s.convLog = conversationlog.New(dir)
}
// ConversationDir returns the active conversation-log directory ("" when
// the sink is off).
func (s *Service) ConversationDir() string {
if s == nil || s.convLog == nil {
return ""
}
return s.convLog.Dir()
}
// recordConversation appends one Record to the conversation-log sink
// when it is enabled. The labels (session/repo/file/phase) ride on the
// context via conversationlog.WithMeta. A nil/disabled logger is a
// no-op; recording never disturbs the completion.
func (s *Service) recordConversation(ctx context.Context, req []llm.Message, resp string, usage llm.TokenUsage, model string, elapsedMs int64, callErr error) {
if s == nil || s.convLog == nil || !s.convLog.Enabled() {
return
}
meta := conversationlog.MetaFromContext(ctx)
rec := conversationlog.Record{
Session: meta.Session,
Repo: meta.Repo,
File: meta.File,
Phase: meta.Phase,
Provider: s.ProviderName(),
Model: model,
Request: req,
Response: resp,
ElapsedMs: elapsedMs,
}
if usage.IsZero() {
// No provider usage available — estimate from char counts so the
// inspector still shows a magnitude, flagged as an estimate.
rec.InputTokens = estimateTokens(req)
rec.OutputTokens = len(resp) / 4
rec.Estimated = true
} else {
rec.InputTokens = usage.InputTokens
rec.OutputTokens = usage.OutputTokens
}
if callErr != nil {
rec.Error = callErr.Error()
}
s.convLog.Record(rec)
}
// estimateTokens approximates the prompt token count from message
// content lengths (char/4) when no provider usage is available.
func estimateTokens(msgs []llm.Message) int {
n := 0
for _, m := range msgs {
n += len(m.Content)
}
return n / 4
}
// Enabled reports whether the service can do real work — a provider
// was constructed and a backend is wired. Callers gate feature /
// tool registration on this.
func (s *Service) Enabled() bool {
return s != nil && s.provider != nil && s.backend != nil
}
// Provider returns the underlying LLM provider, or nil when none was
// constructed. Lets callers that need raw completion (e.g. the wiki
// narrative enhancer) reuse the service's already-built provider instead
// of constructing a second one.
func (s *Service) Provider() llm.Provider {
if s == nil {
return nil
}
return s.provider
}
// ProviderErr returns the error from provider construction, if any.
// Enabled() is false whenever this is non-nil; the daemon entrypoint
// surfaces it as a startup warning so a misconfigured `llm:` block
// (unset API key, model file missing) isn't silently ignored.
func (s *Service) ProviderErr() error {
if s == nil {
return nil
}
return s.providerErr
}
// ProviderName returns the active provider's name, or "" when no
// provider was constructed.
func (s *Service) ProviderName() string {
if s == nil || s.provider == nil {
return ""
}
return s.provider.Name()
}
// Close releases the provider's resources (model weights, idle HTTP
// connections), including any providers constructed for model routing.
// Safe to call multiple times and on a disabled service.
func (s *Service) Close() error {
if s == nil {
return nil
}
s.routedMu.Lock()
for _, p := range s.routedProviders {
_ = p.Close()
}
s.routedProviders = map[string]llm.Provider{}
s.routedMu.Unlock()
if s.convLog != nil {
_ = s.convLog.Close()
}
if s.provider == nil {
return nil
}
return s.provider.Close()
}
// providerForModel returns the llm.Provider for the given model id,
// constructing and caching one provider per distinct model. An empty
// model id — or one equal to the active provider's configured model —
// returns the base provider untouched. Routed providers are closed by
// Close. Used by model routing (see RunAgent).
func (s *Service) providerForModel(model string) (llm.Provider, error) {
model = strings.TrimSpace(model)
if model == "" || model == strings.TrimSpace(s.cfg.ActiveModel()) {
return s.provider, nil
}
s.routedMu.Lock()
defer s.routedMu.Unlock()
if p, ok := s.routedProviders[model]; ok {
return p, nil
}
p, err := provider.New(s.cfg.WithModel(model))
if err != nil {
return nil, err
}
s.configureProvider(p)
s.routedProviders[model] = p
return p, nil
}
// repoCount reports how many repos the backend currently exposes — the
// graph-breadth signal Classify uses for routing. Returns 0 when the
// backend is unavailable or the lookup fails.
func (s *Service) repoCount(ctx context.Context) int {
if s.backend == nil {
return 0
}
repos, err := s.backend.ListRepos(ctx)
if err != nil {
return 0
}
return len(repos)
}
// Generate runs one-shot freeform inference: prompt in, generated text
// out. No agent loop, no tools. maxTokens caps generation length; 0
// falls back to a sensible default.
func (s *Service) Generate(ctx context.Context, prompt string, maxTokens int) (string, error) {
if s.provider == nil {
return "", errServiceUnavailable
}
if maxTokens <= 0 {
maxTokens = 1024
}
messages := []llm.Message{{Role: llm.RoleUser, Content: prompt}}
t0 := time.Now()
resp, err := s.provider.Complete(ctx, llm.CompletionRequest{
Messages: messages,
MaxTokens: maxTokens,
Shape: llm.ShapeFreeform,
})
s.recordConversation(ctx, messages, resp.Text, resp.Usage, s.cfg.ActiveModel(), time.Since(t0).Milliseconds(), err)
if err != nil {
return "", err
}
return resp.Text, nil
}
// GenerateWithUsage is the usage-aware variant of Generate: it returns
// the provider's token accounting for the call alongside the generated
// text. The review flow threads this so its CostBreakdown reflects a real
// per-call token split; a provider that does not surface usage yields a
// zero TokenUsage (the caller's cost block is then zero / not Estimated).
func (s *Service) GenerateWithUsage(ctx context.Context, prompt string, maxTokens int) (string, llm.TokenUsage, error) {
if s.provider == nil {
return "", llm.TokenUsage{}, errServiceUnavailable
}
if maxTokens <= 0 {
maxTokens = 1024
}
messages := []llm.Message{{Role: llm.RoleUser, Content: prompt}}
t0 := time.Now()
resp, err := s.provider.Complete(ctx, llm.CompletionRequest{
Messages: messages,
MaxTokens: maxTokens,
Shape: llm.ShapeFreeform,
})
s.recordConversation(ctx, messages, resp.Text, resp.Usage, s.cfg.ActiveModel(), time.Since(t0).Milliseconds(), err)
if err != nil {
return "", llm.TokenUsage{}, err
}
return resp.Text, resp.Usage, nil
}
// Pricing returns the active provider's USD-per-1M-token rate card so a
// usage-aware caller (the review cost block) can price its token usage. A
// user-registered custom provider carries its own pricing; for a built-in
// provider the active model is matched against the savings rate table (a
// single per-model input rate, applied to both the input and output
// fields since the table carries no separate output rate). An unknown
// model or a disabled service yields a zero rate card — the cost block is
// still emitted, just with a zero USD estimate.
func (s *Service) Pricing() llm.ProviderPricing {
if s == nil || s.provider == nil {
return llm.ProviderPricing{}
}
if cp, ok := s.cfg.Custom[s.cfg.ProviderName()]; ok {
return cp.Pricing
}
rate := savings.ModelRate(s.cfg.ActiveModel())
if rate <= 0 {
return llm.ProviderPricing{}
}
return llm.ProviderPricing{Input: rate, Output: rate}
}
// RunAgent runs the structured tool-calling agent loop. The agent
// issues tool calls against the configured Backend and synthesizes a
// final answer via the final_answer tool.
//
// The returned AgentAnswer always has at least Answer/Error populated
// — non-nil even on error paths.
func (s *Service) RunAgent(ctx context.Context, opts llm.RunAgentOptions) (*llm.AgentAnswer, error) {
answer := &llm.AgentAnswer{Scope: opts.Scope, ChainMode: opts.Chain}
if s.provider == nil {
answer.Error = errServiceUnavailable.Error()
return answer, errServiceUnavailable
}
if strings.TrimSpace(opts.Question) == "" {
err := errors.New("llm: question is empty")
answer.Error = err.Error()
return answer, err
}
systemExtras := opts.SystemExtras
if systemExtras == "" {
if opts.Chain {
systemExtras = promptChain
} else {
systemExtras = promptSimple
}
}
var tools []agent.Tool
if opts.Chain {
tools = agent.GortexChainTools(s.backend, opts.Scope)
} else {
tools = agent.GortexTools(s.backend, opts.Scope)
}
// Model routing: when enabled, classify the run by graph-derived
// task complexity and dispatch it to the cheaper or the more
// capable model. A routing failure (a tier model that won't
// construct) degrades cleanly to the base provider.
activeProvider := s.provider
answer.Model = s.cfg.ActiveModel()
if s.cfg.Routing.Enabled {
complexity := llm.Classify(llm.ComplexitySignals{
Question: opts.Question,
Chain: opts.Chain,
Scoped: opts.Scope.Repo != "" || opts.Scope.Project != "" || opts.Scope.Ref != "",
RepoCount: s.repoCount(ctx),
})
answer.Complexity = complexity.String()
routedModel := s.cfg.Routing.SimpleModel
if complexity == llm.ComplexityComplex {
routedModel = s.cfg.Routing.ComplexModel
}
if p, perr := s.providerForModel(routedModel); perr == nil && p != nil {
activeProvider = p
if m := strings.TrimSpace(routedModel); m != "" {
answer.Model = m
}
}
}
ag, err := agent.New(activeProvider, tools)
if err != nil {
answer.Error = err.Error()
return answer, err
}
t0 := time.Now()
answerText, transcript, runErr := ag.Run(ctx, systemExtras, opts.Question, s.cfg.MaxSteps)
answer.ElapsedMs = time.Since(t0).Milliseconds()
answer.Answer = answerText
// Token accounting: the agent summed per-step provider usage over the
// loop. Stamp it on the answer and price it against the answer's
// model. Zero/Estimated:false for providers that don't report usage.
answer.Usage = ag.LastUsage()
answer.Cost = estimateRunCost(answer.Usage, answer.Model)
// Record the agent turn to the conversation-log sink when enabled:
// the framed prompt (system extras + question) in, the final answer
// out, with the run's summed token usage.
s.recordConversation(ctx,
[]llm.Message{
{Role: llm.RoleSystem, Content: systemExtras},
{Role: llm.RoleUser, Content: opts.Question},
},
answerText, answer.Usage, answer.Model, answer.ElapsedMs, runErr)
steps := 0
for _, st := range transcript {
if st.Kind == "call" || st.Kind == "final" {
steps++
}
}
answer.Steps = steps
if opts.IncludeTranscript {
answer.Transcript = make([]llm.TranscriptStep, 0, len(transcript))
for _, st := range transcript {
answer.Transcript = append(answer.Transcript, llm.TranscriptStep{
Kind: st.Kind, Raw: st.Raw, Tool: st.Tool,
})
}
}
if runErr != nil {
answer.Error = runErr.Error()
}
return answer, runErr
}
// estimateRunCost prices a run's token usage against the savings pricing
// table for the given model. Input and output tokens are both billed at
// the model's listed input rate (the table carries a single per-model
// rate); an unknown model or zero usage yields zero. The cost is an
// estimate — the table is a list-price approximation, not a billed total.
func estimateRunCost(u llm.TokenUsage, model string) float64 {
if model == "" || u.IsZero() {
return 0
}
billable := int64(u.InputTokens + u.OutputTokens)
return savings.CostAvoided(billable, model)
}
// promptSimple — tight system-prompt extras for single-hop /
// cross-repo lookups.
const promptSimple = `RULES (follow these exactly):
- If the user gives you only a bare name (not a path-qualified id like "pkg/x.Foo"), you MUST first call search_symbols to resolve it to an id before calling get_callers.
- For search_symbols, pass ONLY the bare symbol name as "query" — no prepositions, no package qualifiers, no extra words.
- search_symbols returns ranked matches; the FIRST few are best. Pick at most the top 1-3 that look like functions or methods.
- Make at least one real tool call before final_answer.
- Never call the same tool with the same args twice in a row.
- When you have enough information, call final_answer summarising what you found.`
// promptChain — chain-mode extras with the explicit "no get_callers"
// direction warning.
const promptChain = `RULES (follow these exactly):
- You are tracing a cross-system call chain. Output one tool call per turn.
- DIRECTION MATTERS. Only these tools are correct in chain mode:
* contracts — find producer↔consumer pairs across repos
* get_dependencies — FORWARD direction: what does this symbol call/import?
* final_answer — emit the chain
Do NOT use get_callers. get_callers walks BACKWARDS (who calls X), which is
the WRONG direction for chain tracing and will lead you astray.
- For search_symbols and contracts, pass clean values (no extra words).
- Typical flow for "trace request X across systems":
1) contracts({"role":"consumer","path":"<path>"}) — find the caller side.
2) contracts({"role":"provider","path":"<path>"}) — find the handler.
3) get_dependencies({"id":"<provider symbol_id>"}) — see what the handler calls.
4) For deeper hops, call get_dependencies AGAIN on the most interesting result's id.
5) Look for deps whose repo prefix differs from the handler's repo —
those are the cross-repo downstream calls.
6) Call final_answer with the chain as numbered steps.
- Never call the same tool with the same args twice in a row.
- final_answer.text should list each system hop with its symbol id and repo.`