package evolve import ( "context" "errors" "fmt" "strings" "git.flytoex.net/yuanwei/flyto-agent/internal/transport/retry" "git.flytoex.net/yuanwei/flyto-agent/pkg/flyto" ) // FlytoLLMClient wraps a flyto.ModelProvider as an evolve.LLMClient. // // Direction of dependency: // evolve consumes flyto.ModelProvider; providers/ subpackages define concrete // providers. The direction is evolve -> flyto, so keeping the adapter in the // evolve package is the natural home. Placing it under providers/ would // require providers to know about evolve, inverting the boundary. // // Sampling-knob translation (Temperature / TopP): // flyto.Request is the cross-provider greatest common denominator. As of // L683 (commit 1/3), it carries Temperature *float64 and TopP *float64 with // passthrough semantics across all 7 providers. The adapter therefore // translates LLMCallOpts.{Temperature, TopP} (float64, zero-as-unset) into // flyto.Request.{Temperature, TopP} (*float64, nil-as-unset): non-zero // values become flyto.Float(v), zero leaves the field nil so the upstream // provider applies its own default. Anthropic + extended thinking enforces // temperature == 1.0 server-side; the anthropic provider pre-handles that // override and emits a parameter_overridden WarningEvent through the // stream, which the adapter ignores by design (only TextEvent / TextDeltaEvent // drive candidate text -- WarningEvent surfaces to engine observers). // // Sampling 旋钮翻译 (Temperature / TopP): // flyto.Request 是跨 provider 最大公约数. L683 (commit 1/3) 之后, 它带 // Temperature *float64 与 TopP *float64, 7 provider 全 passthrough. adapter // 把 LLMCallOpts.{Temperature, TopP} (float64, 零=未设) 翻译为 // flyto.Request.{Temperature, TopP} (*float64, nil=未设): 非零值经 // flyto.Float(v) 装为指针, 零值保持 nil 让上游 provider 用自己默认. // Anthropic + extended thinking 服务端强制 temperature == 1.0, anthropic // provider 会预拦覆盖并发 parameter_overridden WarningEvent, adapter 按 // 设计忽略 (只用 TextEvent/TextDeltaEvent 驱动候选文本, WarningEvent // 由 engine observer 路径消费). // // Event filtering (candidate generation only needs text): // - Aggregated: TextEvent (authoritative), TextDeltaEvent (fallback) // - Error routed: ErrorEvent -> wraps ErrLLMFailed // - Ignored: ToolUse*, Thinking*, Usage, Done, Turn*, SessionInfo, // Permission*, Warning, Compact, Checkpoint*, and any // other non-text event // // Aggregation precedence (CLEVER): // anthropic provider (and others that honor the full event contract) emit // both TextDeltaEvent (streaming increments) and TextEvent (the complete // text block on block_stop). A naive "sum both" strategy double-counts. We // take TextEvent as authoritative: on each TextEvent arrival we append the // final text and reset the delta accumulator. If the stream closes without // any TextEvent (providers that skip the complete-block emission), we fall // back to the delta accumulator so no text is lost. type FlytoLLMClient struct { provider flyto.ModelProvider defaultModel string systemPrompt string } // NewFlytoLLMClient builds a FlytoLLMClient. provider is required; it is the // underlying flyto.ModelProvider (anthropic / openai / minimax / ...) the // adapter will drive. defaultModel is used whenever LLMCallOpts.Model is // empty. systemPrompt is injected verbatim into flyto.Request.System on // every Complete call; pass "" to omit. func NewFlytoLLMClient(provider flyto.ModelProvider, defaultModel, systemPrompt string) (*FlytoLLMClient, error) { if provider == nil { return nil, errors.New("evolve: NewFlytoLLMClient requires non-nil provider") } return &FlytoLLMClient{ provider: provider, defaultModel: defaultModel, systemPrompt: systemPrompt, }, nil } // Complete implements LLMClient by invoking provider.Stream with a single // user message and draining the event channel into a response string. // See FlytoLLMClient godoc for event filtering and aggregation rules. func (c *FlytoLLMClient) Complete(ctx context.Context, prompt string, opts LLMCallOpts) (string, error) { model := opts.Model if model == "" { model = c.defaultModel } req := &flyto.Request{ Model: model, System: c.systemPrompt, Messages: []flyto.Message{flyto.UserText(prompt)}, MaxTokens: opts.MaxTokens, } // Translate per-call sampling knobs from LLMCallOpts (float64, zero = // unset) to flyto.Request (*float64, nil = unset). Non-zero becomes a // pointer; zero leaves the field nil so the upstream provider uses its // own default. See LLMCallOpts godoc for the deterministic-zero caveat. // // 把 per-call sampling 旋钮从 LLMCallOpts (float64, 零=未设) 翻译为 // flyto.Request (*float64, nil=未设). 非零变指针; 零保留 nil 让 // 上游 provider 用自己默认. deterministic-0 的取舍见 LLMCallOpts godoc. if opts.Temperature != 0 { req.Temperature = flyto.Float(opts.Temperature) } if opts.TopP != 0 { req.TopP = flyto.Float(opts.TopP) } // Evolve: self-improvement / generator LLM calls are background; label // so retry failures here don't alias with main-thread user failures. // Free-form label "evolve" (QuerySource is not an enum per godoc). // // Evolve: 自我改进 / generator LLM 调用为后台; 标记让此处重试失败 // 不与主线程用户失败混淆. 自由格式标签 "evolve" (QuerySource 按 // godoc 非枚举). ctx = retry.WithQuerySource(ctx, "evolve") ch, err := c.provider.Stream(ctx, req) if err != nil { return "", fmt.Errorf("%w: %v", ErrLLMFailed, err) } var textBlocks []string var deltaBuf strings.Builder for { select { case <-ctx.Done(): return "", ctx.Err() case evt, ok := <-ch: if !ok { if len(textBlocks) > 0 { return strings.Join(textBlocks, ""), nil } return deltaBuf.String(), nil } switch e := evt.(type) { case *flyto.TextEvent: textBlocks = append(textBlocks, e.Text) deltaBuf.Reset() case *flyto.TextDeltaEvent: deltaBuf.WriteString(e.Text) case *flyto.ErrorEvent: return "", fmt.Errorf("%w: %v", ErrLLMFailed, e.Err) default: // Ignore ToolUse / Thinking / Usage / Done / Turn* / etc. } } } }