agent build
This commit is contained in:
@@ -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 == "" {
|
||||
|
||||
Reference in New Issue
Block a user