// Package agent wires real LLM-driven planner and work agents (on top of the // agent-core SDK) to the dual SQLite graph. See docs/ARTEX-架构设计.md // §4.3 (planner) and §4.4 (work agent). // // Provider configuration is read from the environment so the system runs with // any Anthropic- or OpenAI-format endpoint. If no key is configured, FromEnv // returns ok=false and the exploration engine stays idle (an LLM is required). package agent import ( "bytes" "context" "fmt" "io" "net/http" "net/url" "os" "regexp" "strings" "time" "github.com/Autumn-27/norma/agentcore" "github.com/Autumn-27/norma/compaction" "github.com/Autumn-27/norma/llm" acperm "github.com/Autumn-27/norma/permission" ) // Config describes the LLM backend resolved from the environment. type Config struct { Format llm.Format BaseURL string APIKey string Model string // Proxy routes all LLM requests through the given proxy URL (http/https/socks5). // Empty falls back to the standard *_PROXY environment variables. Proxy string // RatePerSecond / RatePerMinute cap the shared request rate across ALL agents // using the provider (0 = that window unlimited). RatePerSecond float64 RatePerMinute float64 // ContextWindowK is the model's context window in K tokens (user-configured), // used to size compaction thresholds. 0 = default; see CompactionWindow. ContextWindowK int // ThinkingType 独立控制思考「开关」字段(thinking.type): // "" = 不发送(默认,兼容不支持该字段的模型); "disabled" = 显式关闭; // "enabled" = 开启. 与 ReasoningEffort 完全解耦——有些接口没有 thinking 字段、 // 只靠强度参数就能激活思考,故两者可各自单独设置. ThinkingType string // ReasoningEffort 独立控制思考「强度」字段: // "" = 不发送(默认); "low"/"medium"/"high"/"xhigh"/"max" = 对应强度. // OpenAI 映射为顶层 reasoning_effort;Anthropic 映射为 output_config.effort. ReasoningEffort string } // compaction window resolution bounds (in K tokens). Below the floor the // threshold math (window − summary reserve − buffer) would go non-positive and // compaction would fire every turn; above the cap it would never fire. const ( defaultWindowK = 200 // unset → assume a 200K window (Claude default) minWindowK = 32 // floor so effectiveWindow stays comfortably positive maxWindowK = 1000 // cap at 1M tokens (user request) ) // CompactionWindow returns the model context window in TOKENS for compaction // thresholds, resolved from the user-configured size (ContextWindowK). 0/unset → // a 200K default; otherwise clamped to [32K, 1M] so compaction stays effective. func (c Config) CompactionWindow() int { k := c.ContextWindowK if k <= 0 { k = defaultWindowK } if k < minWindowK { k = minWindowK } if k > maxWindowK { k = maxWindowK } return k * 1000 } // compactionConfig builds the agent-core compaction config for a context window // in tokens. agentcore.NewSession wires the summarizer (same provider) when this // is set on Options.Compaction. func compactionConfig(windowTokens int) *compaction.Config { if windowTokens <= 0 { windowTokens = defaultWindowK * 1000 } return &compaction.Config{ContextWindow: windowTokens} } // FromEnv resolves the LLM provider config: // // ARTEX_LLM_PROVIDER = anthropic|openai (default: inferred from keys) // ARTEX_LLM_MODEL = model id (default: per provider) // ARTEX_LLM_BASE_URL = endpoint (optional) // ARTEX_LLM_PROXY = proxy URL (optional; http/https/socks5) // ANTHROPIC_API_KEY / OPENAI_API_KEY = credentials func FromEnv() (Config, bool) { prov := os.Getenv("ARTEX_LLM_PROVIDER") anthKey := os.Getenv("ANTHROPIC_API_KEY") oaiKey := os.Getenv("OPENAI_API_KEY") if prov == "" { switch { case anthKey != "": prov = "anthropic" case oaiKey != "": prov = "openai" default: return Config{}, false } } c := Config{ BaseURL: os.Getenv("ARTEX_LLM_BASE_URL"), Model: os.Getenv("ARTEX_LLM_MODEL"), Proxy: strings.TrimSpace(os.Getenv("ARTEX_LLM_PROXY")), } switch prov { case "openai": c.Format = llm.FormatOpenAI c.APIKey = oaiKey if c.Model == "" { c.Model = "gpt-4o" } default: c.Format = llm.FormatAnthropic c.APIKey = anthKey if c.Model == "" { c.Model = "claude-opus-4-8" } } if c.APIKey == "" { return Config{}, false } return c, true } // ConfigFrom builds a Config from UI-provided strings (provider defaults to // anthropic; model defaults per provider). Inputs are trimmed and the base URL // is normalized to the API base the provider expects (the provider appends the // endpoint path itself), so a full endpoint URL is tolerated. func ConfigFrom(provider, model, baseURL, apiKey, proxy string) Config { c := Config{ Model: strings.TrimSpace(model), BaseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"), APIKey: strings.TrimSpace(apiKey), Proxy: strings.TrimSpace(proxy), } switch strings.TrimSpace(provider) { case "openai": c.Format = llm.FormatOpenAI // provider appends "/chat/completions"; tolerate a full endpoint URL. c.BaseURL = strings.TrimRight(strings.TrimSuffix(c.BaseURL, "/chat/completions"), "/") if c.Model == "" { c.Model = "gpt-4o" } default: c.Format = llm.FormatAnthropic // provider appends "/v1/messages". c.BaseURL = strings.TrimRight(strings.TrimSuffix(c.BaseURL, "/v1/messages"), "/") if c.Model == "" { c.Model = "claude-opus-4-8" } } return c } // Provider returns the short provider name ("anthropic"/"openai"). func (c Config) Provider() string { if c.Format == llm.FormatOpenAI { return "openai" } return "anthropic" } // NewProvider builds an llm.Provider from the config. When a rate is set, the // limiter lives on the single provider instance — so planner + all workers + // main agent (which share this provider) are bounded by one shared rate limit. func (c Config) NewProvider() (llm.Provider, error) { client, err := quotaAwareHTTPClient(c.Proxy) if err != nil { return nil, err } lc := llm.Config{ Format: c.Format, BaseURL: c.BaseURL, APIKey: c.APIKey, Model: c.Model, HTTPClient: client, } // 思考开关与强度两个字段各自透传(空 = 该字段不发送)。二者解耦: // 可只发 thinking.type、只发 effort、都发、或都不发。 lc.ThinkingType = c.ThinkingType lc.ReasoningEffort = c.ReasoningEffort if c.RatePerSecond > 0 || c.RatePerMinute > 0 { lc.RateLimit = &llm.RateLimit{PerSecond: c.RatePerSecond, PerMinute: c.RatePerMinute} } return llm.NewProvider(lc) } // IsQuotaExhaustedMessage deliberately recognizes only explicit balance, // billing, credit, or quota-exhaustion signals. Generic 429/rate-limit text, // authentication failures, network errors, and server failures are excluded. var nonFailoverHTTPStatus = regexp.MustCompile(`(?:status(?:\s+code)?|http(?:\s+status)?)\s*[=:]?\s*(?:401|403|5\d\d)\b`) var transientQuotaLimit = regexp.MustCompile(`(?i)(?:\b(?:rpm|tpm|rpd|qps)\b|quota[_\s-]*metric|rate[_\s-]*limit|too many requests|(?:requests?|tokens?)\s+(?:per|/)\s*(?:second|minute)|(?:per|/)\s*(?:second|minute)\s+(?:requests?|tokens?)|generate[_\s-]*requests[_\s-]*per[_\s-]*(?:minute|second)|tokens?[_\s-]*per[_\s-]*(?:minute|second))`) func IsQuotaExhaustedMessage(message string) bool { message = strings.ToLower(message) // Authentication/authorization and provider-side 5xx failures never rotate, // even when a gateway happens to echo a quota-looking phrase in the body. if nonFailoverHTTPStatus.MatchString(message) { return false } // Provider APIs frequently describe an ordinary rate limit as "quota // exceeded", especially Google-style responses containing a quota metric. // These limits recover with time and must stay on the current provider. if transientQuotaLimit.MatchString(message) { return false } markers := []string{ "insufficient_quota", "quota_exceeded", "quota exceeded", "quota exhausted", "exceeded your current quota", "billing_hard_limit_reached", "billing hard limit", "billing_not_active", "credit balance", "insufficient credit", "insufficient balance", "balance is too low", "payment required", "status 402", "余额不足", "额度不足", "额度已用尽", "欠费", } for _, marker := range markers { if strings.Contains(message, marker) { return true } } // gRPC RESOURCE_EXHAUSTED is overloaded for both account quota and ordinary // request-rate limiting. Preserve it as an explicit exhaustion signal only // when the same error does not identify a transient rate limit. return strings.Contains(message, "resource_exhausted") && !strings.Contains(message, "rate limit") && !strings.Contains(message, "too many requests") } // quotaAwareTransport preserves Norma's normal retry behavior except for a 429 // whose body explicitly says the account quota/balance is exhausted. Norma's // retry loop treats every 429 as transient; normalizing only that response to // 402 lets a task router fail over immediately while retaining the original // response body for provider-specific classification and audit logs. type quotaAwareTransport struct{ base http.RoundTripper } func (t quotaAwareTransport) RoundTrip(req *http.Request) (*http.Response, error) { resp, err := t.base.RoundTrip(req) if err != nil || resp == nil || resp.StatusCode != http.StatusTooManyRequests { return resp, err } body, readErr := io.ReadAll(resp.Body) _ = resp.Body.Close() resp.Body = io.NopCloser(bytes.NewReader(body)) resp.ContentLength = int64(len(body)) if readErr != nil { return resp, nil } if IsQuotaExhaustedMessage(string(body)) { resp.StatusCode = http.StatusPaymentRequired resp.Status = "402 Payment Required" } return resp, nil } func quotaAwareHTTPClient(proxy string) (*http.Client, error) { transport := http.DefaultTransport.(*http.Transport).Clone() proxy = strings.TrimSpace(proxy) if proxy == "" { transport.Proxy = http.ProxyFromEnvironment } else { proxyURL, err := url.Parse(proxy) if err != nil { return nil, fmt.Errorf("llm: invalid proxy %q: %w", proxy, err) } switch proxyURL.Scheme { case "http", "https", "socks5": case "": return nil, fmt.Errorf("llm: proxy %q missing scheme (use http://, https:// or socks5://)", proxy) default: return nil, fmt.Errorf("llm: unsupported proxy scheme %q (use http, https or socks5)", proxyURL.Scheme) } transport.Proxy = http.ProxyURL(proxyURL) } return &http.Client{Transport: quotaAwareTransport{base: transport}}, nil } // TestConnection makes a minimal real completion to verify the provider/model/ // endpoint/key actually work. Returns the round-trip latency. func TestConnection(ctx context.Context, c Config) (time.Duration, error) { prov, err := c.NewProvider() if err != nil { return 0, err } ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() start := time.Now() // MaxTokens 要给足:推理模型(如 deepseek-v4-pro)在给出答案前会先产出一大段 // 思考(实测对一句 "ping" 也能烧 ~2900 token)。若只给 32,模型会一直卡在"思考阶段" // 就撞到输出上限(finish=length)、被截断,连接测试虽仍算通(err=nil)但显示成 // "已中断/length/resume" 一团糟。给足预算让它把 OK 干净吐完(finish=stop)。 // EscalateMaxTokens 保持 false:不因截断而抬额重试,避免 resume 循环空烧。 _, err = agentcore.Run(ctx, agentcore.Options{ Provider: prov, SystemPrompt: []string{"你是连接测试。直接输出两个字符 OK 即可,不要思考、不要解释、不要别的。"}, PermissionMode: acperm.ModeBypass, MaxTurns: 1, MaxTokens: 8192, }, "ping") return time.Since(start), err }