agent build

This commit is contained in:
2026-08-28 12:21:44 +05:30
parent b6f8655909
commit f7df96c973
138 changed files with 24164 additions and 207 deletions

View File

@@ -19,13 +19,80 @@ import (
"time"
)
// defaultModel is what every reasoning tier routes to until a deployment says
// otherwise. Named once here so the three tiers cannot drift apart by accident.
const defaultModel = "claude-opus-5"
// Config is the whole of the Phase 1 configuration surface.
type Config struct {
AppEnv string
Log LogConfig
HTTP HTTPConfig
DB DBConfig
Seed SeedConfig
AppEnv string
Log LogConfig
HTTP HTTPConfig
DB DBConfig
Seed SeedConfig
Model ModelConfig
Knowledge KnowledgeConfig
}
// KnowledgeConfig routes the retrieval layer's embedding provider.
//
// Anthropic does not serve embeddings, so the dense half of hybrid retrieval
// needs a separate credential. Voyage is the documented partner and the default.
//
// An empty key is legitimate: this service boots and serves without one, and
// retrieval degrades to keyword-only rather than failing — reported on every
// result, never silently. What is NOT legitimate is production running on the
// lexical stand-in, which is why that is a separate, deliberate opt-in rather
// than something an empty key falls back to.
type KnowledgeConfig struct {
// EmbedProvider names which embedder to use: "voyage", "ollama",
// "lexical", or "" to pick from what is configured.
//
// Explicit beats inferred here. The three differ in a way that is invisible
// from the outside — all of them return vectors and retrieval works with
// any of them — so a deployment silently running the stand-in would look
// exactly like one running a real model, right up until somebody phrased a
// question differently. Naming the provider makes the choice reviewable.
EmbedProvider string
// EmbedAPIKey is the hosted provider's credential (Voyage).
EmbedAPIKey string
// EmbedBaseURL is where a local model answers. Ollama's default is
// http://localhost:11434.
EmbedBaseURL string
EmbedModel string
EmbedDims int
// UseLexicalEmbedder swaps in the deterministic stand-in. Development only:
// it is not semantic, and a corpus indexed with it retrieves on word overlap
// alone. Load() refuses it outside development rather than trusting the
// operator to have read the comment.
//
// Kept alongside EmbedProvider for the deployments that already set it.
UseLexicalEmbedder bool
}
// ModelConfig routes an agent spec's reasoning tier to a model.
//
// A spec declares `reasoning: fast | balanced | deep`, never a model id, so the
// mapping is a deployment decision and changes without editing a definition.
// All three default to the same model: the tiers differ by *effort*, which the
// gateway owns, and a deployment that wants a cheaper model on the fast tier
// says so explicitly rather than inheriting a downgrade nobody chose.
//
// The API key may legitimately be empty outside production. This service has to
// boot without model credentials — migrations, seeding and every endpoint that
// is not an agent run work fine without one — so the failure belongs at the
// first model call, as a structured gateway.not_configured a run can end with,
// not at startup as a refusal to boot.
type ModelConfig struct {
APIKey string
Fast string
Balanced string
Deep string
MaxOutputTokens int
}
// SeedConfig locates the demo fixture. The file is generated from the frontend
@@ -158,11 +225,36 @@ func Load() (*Config, error) {
IdleTimeout: durationDefault("HTTP_IDLE_TIMEOUT", 60*time.Second),
ShutdownTimeout: durationDefault("HTTP_SHUTDOWN_TIMEOUT", 10*time.Second),
CORSOrigins: corsOrigins(withDefault("APP_ENV", "development")),
CookieSameSite: strings.ToLower(withDefault("HTTP_COOKIE_SAMESITE", "lax")),
// Empty when unset, which is NOT the same as "lax": unset means "let
// the server derive it from the CORS posture", and an explicit value
// overrides that derivation. See Server.sessionSameSite.
CookieSameSite: strings.ToLower(strings.TrimSpace(os.Getenv("HTTP_COOKIE_SAMESITE"))),
},
Seed: SeedConfig{
FixturePath: withDefault("SEED_FIXTURE_PATH", "./seed/fixtures/seed.json"),
},
Knowledge: KnowledgeConfig{
EmbedProvider: strings.ToLower(strings.TrimSpace(os.Getenv("EMBED_PROVIDER"))),
EmbedAPIKey: strings.TrimSpace(os.Getenv("VOYAGE_API_KEY")),
EmbedBaseURL: strings.TrimSpace(os.Getenv("EMBED_BASE_URL")),
// No default model or width here: they differ per provider, and one
// shared default would silently hand Ollama's dimensions to Voyage.
// Resolved where the provider is chosen — see runtime.NewEmbedder.
EmbedModel: strings.TrimSpace(os.Getenv("EMBED_MODEL")),
EmbedDims: intDefault("EMBED_DIMENSIONS", 0),
UseLexicalEmbedder: boolDefault("EMBED_USE_LEXICAL", false),
},
Model: ModelConfig{
APIKey: strings.TrimSpace(os.Getenv("ANTHROPIC_API_KEY")),
Fast: withDefault("MODEL_FAST", defaultModel),
Balanced: withDefault("MODEL_BALANCED", defaultModel),
Deep: withDefault("MODEL_DEEP", defaultModel),
// 16k keeps a non-streaming response inside the SDK's HTTP
// timeout. The loop raises it and switches to streaming when it
// needs a long answer; this is the ceiling for a single
// unstreamed call, not the run's budget.
MaxOutputTokens: intDefault("MODEL_MAX_OUTPUT_TOKENS", 16000),
},
DB: DBConfig{
Host: required("DATABASE_HOST"),
Port: intDefault("DATABASE_PORT", 5432),
@@ -216,7 +308,52 @@ func (c *Config) validate() error {
if c.AppEnv == "production" && c.DB.SSLMode == "disable" {
return fmt.Errorf("DATABASE_SSLMODE=disable is not allowed when APP_ENV=production")
}
// A production deployment with no model credentials would accept agent runs
// and fail every one of them at the gateway. That is a boot-time
// misconfiguration wearing a runtime error's clothes, so it is caught here.
// Development is left alone deliberately: working on migrations or the
// definitions API must not require a key.
if c.AppEnv == "production" && c.Model.APIKey == "" {
return fmt.Errorf("ANTHROPIC_API_KEY is required when APP_ENV=production; " +
"without it every agent run fails at the model gateway")
}
if c.Model.MaxOutputTokens < 1 {
return fmt.Errorf("MODEL_MAX_OUTPUT_TOKENS must be at least 1, got %d", c.Model.MaxOutputTokens)
}
// The lexical embedder is a development stand-in that hashes words into a
// vector. It is not semantic, so a production corpus indexed with it would
// retrieve on word overlap alone — which looks like working retrieval and is
// not. Refused here rather than trusted to an operator's reading of a
// comment, because the failure is invisible from the outside: results come
// back, they are just the wrong ones.
switch c.Knowledge.EmbedProvider {
case "", "voyage", "ollama", "lexical":
default:
return fmt.Errorf("EMBED_PROVIDER must be voyage, ollama or lexical, got %q",
c.Knowledge.EmbedProvider)
}
if c.AppEnv == "production" &&
(c.Knowledge.UseLexicalEmbedder || c.Knowledge.EmbedProvider == "lexical") {
return fmt.Errorf("EMBED_USE_LEXICAL is a development stand-in and is not allowed when " +
"APP_ENV=production; it is not a semantic embedder and a corpus indexed with it " +
"retrieves on word overlap alone")
}
// Zero means "the provider's own default", resolved where the provider is
// chosen. Only a negative value is a mistake.
if c.Knowledge.EmbedDims < 0 {
return fmt.Errorf("EMBED_DIMENSIONS cannot be negative, got %d", c.Knowledge.EmbedDims)
}
for name, model := range map[string]string{
"MODEL_FAST": c.Model.Fast, "MODEL_BALANCED": c.Model.Balanced, "MODEL_DEEP": c.Model.Deep,
} {
if strings.TrimSpace(model) == "" {
return fmt.Errorf("%s must name a model", name)
}
}
switch c.HTTP.CookieSameSite {
// Unset. The server derives the mode from whether a CORS allowlist is
// configured; there is nothing to validate.
case "":
case "lax", "strict":
case "none":
// SameSite=None without Secure is ignored — and in current browsers,
@@ -315,6 +452,25 @@ func intDefault(key string, fallback int) int {
return n
}
// boolDefault reads a boolean flag.
//
// An unparseable value falls back rather than erroring, matching intDefault.
// The one asymmetry worth knowing: only the explicit true spellings turn a flag
// on, so a typo'd "yes" leaves a feature off rather than on — the safe
// direction for every flag this file currently carries.
func boolDefault(key string, fallback bool) bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
case "":
return fallback
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
return fallback
}
}
func durationDefault(key string, fallback time.Duration) time.Duration {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {