Files
magpie/internal/gateway/decide.go
T

337 lines
11 KiB
Go

package gateway
// A group whose classifier is a decision provider's model (Jev, from
// TypeSafe) asks it in one call, as a user's turn begins, what the
// classifier model would be asked — which of the rules' intents the
// message is — and, for a group with Effort "auto", how hard the turn is
// to think about. Jev answers each with how likely every option was
// rather than with words: an intent it isn't sure of counts as none, and
// the effort is the level the likelihoods weigh out to. The turn's
// requests then ask their model for that effort, where the agent asked
// for reasoning at all.
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"strings"
"sync"
"time"
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/usage"
)
// jevSure is how confident Jev must be of an intent for it to count.
// Confidence runs from 0, every option as likely, to 1, one certain.
const jevSure = 0.4
// noIntent is the option Jev picks for a message that is none of the
// intents.
const noIntent = "none of these"
// jevLevels are the efforts Jev chooses among, with what each is for,
// lowest first.
var jevLevels = []struct{ effort, what string }{
{"low", "Little to think about: a greeting, a question answered from what is already known, a mechanical or one-line change, running a command"},
{"medium", "Some thought: an ordinary bug fix or a small feature in code already understood"},
{"high", "Careful thought: a change across several files, a bug whose cause is not known yet, a design choice with trade-offs"},
{"xhigh", "Deep thought: a subtle bug (concurrency, performance, security), an architecture or algorithm to design, a long multi-step plan"},
}
// jevBody is the System One request asking which of intents text is
// (when there are any) and, when effort, how hard it is. What was said of
// the turn before (prev) is in the state: a message that only carries on
// from it is of its kind and wants its reasoning.
func jevBody(model string, intents []string, prev before, effort bool, text string) []byte {
qs := map[string]any{}
if len(intents) > 0 {
// Kinds may be topics, which a message can be none of, or levels
// (how hard or big a request is), which every message has one
// of. Jev takes "none of these" over any level for a greeting,
// and without it puts a poem in some topic, sure of it; so it is
// asked both ways, and whether the kinds are levels (jevLevelled)
// says which answer counts (readJev).
criteria := map[string]any{noIntent: "The message is none of the other kinds"}
levels := map[string]any{}
for _, in := range intents {
criteria[in], levels[in] = nil, nil
}
qs["intent"] = map[string]any{"type": "choice", "instructions": intentAsk(prev), "criteria": criteria}
qs["level"] = map[string]any{"type": "choice", "instructions": intentAsk(prev), "criteria": levels}
}
if effort {
levels := make([]string, len(jevLevels))
for i, l := range jevLevels {
levels[i] = l.what
}
qs["effort"] = map[string]any{
"type": "score",
"instructions": effortAsk(prev),
"criteria": levels,
}
}
b, _ := json.Marshal(map[string]any{
"model": model,
"state": jevState(prev, effort, text),
"questions": qs,
})
return b
}
func jevState(prev before, effort bool, text string) map[string]string {
st := map[string]string{"message": text}
if prev.Intent != "" {
st["previous_message_kind"] = prev.Intent
}
if effort && prev.Effort != "" {
st["previous_message_reasoning"] = prev.Effort
}
return st
}
func intentAsk(prev before) string {
q := "The `message` is what a user asked a coding assistant. Which kind of request fits it best?"
if prev.Intent != "" {
q += " A message that only carries on from the user's message before it (go on, yes, do it, fix that) is of `previous_message_kind`; one that asks for something of its own is of the kind that fits it."
}
return q
}
func effortAsk(prev before) string {
q := "How much reasoning does a coding assistant need to handle the `message` well?"
if prev.Effort != "" {
q += " A message that only carries on from the user's message before it (go on, yes, do it) needs what that one did, `previous_message_reasoning` (low, medium, high or xhigh)."
}
return q
}
// readJev is the verdict in a System One reply to jevBody, the answer
// without "none of these" counting when the intents are levels.
func readJev(b []byte, intents []string, levels bool, prev before) (verdict, error) {
var out struct {
Model string `json:"model"`
Answers map[string]struct {
Choice string `json:"choice"`
Score float64 `json:"score"`
Confidence float64 `json:"confidence"`
} `json:"answers"`
Usage struct {
Input int `json:"input_tokens"`
Output int `json:"output_tokens"`
} `json:"usage"`
}
if err := json.Unmarshal(b, &out); err != nil {
return verdict{}, answerError{fmt.Errorf("not a System One answer")}
}
var v verdict
if a, ok := out.Answers["intent"]; ok && len(intents) > 0 && !levels {
v.Sure = a.Confidence
if a.Choice != noIntent && a.Confidence >= jevSure {
v.Intent = intentNamed(intents, a.Choice)
}
}
if a, ok := out.Answers["level"]; ok && len(intents) > 0 && levels {
// every message is at one of them, however unsure Jev is: when it
// is torn, the conversation stays at the level of the turn before
// (继续,再加一个基准测试 after a 复杂任务 came out 0.54 to 0.46),
// and a turn with none before takes the likelier
v.Sure = a.Confidence
v.Intent = intentNamed(intents, a.Choice)
if was := intentNamed(intents, prev.Intent); a.Confidence < jevSure && was != "" {
v.Intent = was
}
}
if a, ok := out.Answers["effort"]; ok {
i := int(math.Round(a.Score))
i = max(0, min(i, len(jevLevels)-1))
v.Effort, v.Score = jevLevels[i].effort, a.Score
}
return v, nil
}
// intentNamed is the one of intents named, or "".
func intentNamed(intents []string, name string) string {
for _, in := range intents {
if in == name {
return in
}
}
return ""
}
// levelled is what Jev said of each set of intents: whether they are
// levels every message is at (jevLevelled). It holds as long as the
// intents do.
var levelled = struct {
sync.Mutex
m map[string]bool
}{m: map[string]bool{}}
// jevLevelled asks Jev whether intents are levels of one scale — how hard
// or big a request is — rather than topics a message may be none of,
// once for each set of them.
func (s *Server) jevLevelled(ctx context.Context, p provider.Provider, model string, intents []string) (bool, error) {
key := model + "\x00" + strings.ToLower(strings.Join(intents, "\x00"))
levelled.Lock()
l, ok := levelled.m[key]
levelled.Unlock()
if ok {
return l, nil
}
body, _ := json.Marshal(map[string]any{
"model": model,
"state": map[string]any{"kinds": intents},
"questions": map[string]any{"levels": map[string]any{"type": "noul",
"instructions": "Are these `kinds` levels of one scale that every request to a coding assistant is at, such as how hard, how big or how urgent it is — rather than topics or tasks that a request may be none of?"}},
})
b, err := s.systemOne(ctx, p, model, body)
if err != nil {
return false, err
}
var out struct {
Answers struct {
Levels *struct {
Noul float64 `json:"noul"`
} `json:"levels"`
} `json:"answers"`
}
if json.Unmarshal(b, &out) != nil || out.Answers.Levels == nil {
return false, answerError{fmt.Errorf("not a System One answer")}
}
l = out.Answers.Levels.Noul >= 0.5
levelled.Lock()
levelled.m[key] = l
levelled.Unlock()
return l, nil
}
// askJev asks a decision provider's model through its System One API.
func (s *Server) askJev(p provider.Provider, model string, intents []string, prev before, effort bool, text string) (verdict, error) {
ctx, cancel := context.WithTimeout(context.Background(), classifyTimeout)
defer cancel()
if model == "" {
model = provider.JevLatest
}
levels := false
if len(intents) > 0 {
var err error
if levels, err = s.jevLevelled(ctx, p, model, intents); err != nil {
return verdict{}, err
}
}
b, err := s.systemOne(ctx, p, model, jevBody(model, intents, prev, effort, text))
if err != nil {
return verdict{}, err
}
return readJev(b, intents, levels, prev)
}
// systemOne posts body to p's System One API and returns the answer,
// counting it in the usage as magpie's own.
func (s *Server) systemOne(ctx context.Context, p provider.Provider, model string, body []byte) ([]byte, error) {
start := time.Now()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.Decide+"/systemone", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", RouterAgent)
if err := p.Sign(ctx, req, provider.Chat, body); err != nil {
return nil, err
}
res, err := s.client.Do(req)
if err != nil {
if ctx.Err() != nil {
return nil, fmt.Errorf("%s gave no answer in %s", p.Name, classifyTimeout)
}
return nil, fmt.Errorf("%s: %v", p.Name, err)
}
defer res.Body.Close()
b, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if res.StatusCode >= 300 {
return nil, fmt.Errorf("%s: %s", p.Name, provider.APIError(b, res.Status))
}
var u struct {
Usage struct {
Input int `json:"input_tokens"`
Output int `json:"output_tokens"`
} `json:"usage"`
}
_ = json.Unmarshal(b, &u)
usage.Append(usage.Record{Time: start, Agent: usage.AgentOf(RouterAgent), Provider: p.ID, Host: p.Where(), Model: model,
Input: u.Usage.Input, Output: u.Usage.Output, Millis: time.Since(start).Milliseconds(), Status: res.StatusCode})
return b, nil
}
// withEffort asks a request, in the client's own API, for reasoning at
// effort, in place of what the agent asked. Only a request that asked for
// reasoning is changed: one that didn't (a session title) stays without.
func withEffort(proto provider.Protocol, body []byte, effort string) []byte {
var v struct {
ReasoningEffort string `json:"reasoning_effort"`
Reasoning *struct {
Effort string `json:"effort"`
Summary string `json:"summary"`
} `json:"reasoning"`
Thinking *struct {
Type string `json:"type"`
} `json:"thinking"`
OutputConfig map[string]any `json:"output_config"`
MaxTokens int `json:"max_tokens"`
}
if effort == "" || json.Unmarshal(body, &v) != nil {
return body
}
switch proto {
case provider.Chat:
if v.ReasoningEffort == "" || v.ReasoningEffort == "none" {
return body
}
return withFields(body, map[string]any{"reasoning_effort": effort})
case provider.Responses:
if v.Reasoning == nil || v.Reasoning.Effort == "" || v.Reasoning.Effort == "none" {
return body
}
r := map[string]any{"effort": effort}
if v.Reasoning.Summary != "" {
r["summary"] = v.Reasoning.Summary
}
return withFields(body, map[string]any{"reasoning": r})
case provider.Anthropic:
if v.Thinking == nil {
return body
}
switch v.Thinking.Type {
case "adaptive":
oc := v.OutputConfig
if oc == nil {
oc = map[string]any{}
}
oc["effort"] = effort
return withFields(body, map[string]any{"output_config": oc})
case "enabled":
// the budget must leave room for the answer
budget := budgetOf(effort)
if v.MaxTokens > 0 {
budget = min(budget, v.MaxTokens-1)
}
if budget < 1024 {
return body
}
fields := map[string]any{"thinking": map[string]any{"type": "enabled", "budget_tokens": budget}}
if oc := v.OutputConfig; oc != nil && oc["effort"] != nil {
oc["effort"] = effort
fields["output_config"] = oc
}
return withFields(body, fields)
}
}
return body
}