a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
350 lines
12 KiB
Go
350 lines
12 KiB
Go
//go:build llama
|
|
|
|
// bench: run a fixed battery of agent questions across multiple GGUF
|
|
// models and print a comparison table. Reuses the agent package and
|
|
// the same mock tools as agentdemo so the only variable is the model.
|
|
//
|
|
// go build -tags llama -o /tmp/llmbench ./internal/llm/cmd/bench
|
|
// /tmp/llmbench
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/zzet/gortex/internal/llm"
|
|
"github.com/zzet/gortex/internal/llm/agent"
|
|
"github.com/zzet/gortex/internal/llm/provider"
|
|
)
|
|
|
|
type modelSpec struct {
|
|
Label string
|
|
Path string
|
|
Template string
|
|
}
|
|
|
|
type question struct {
|
|
Name string
|
|
Text string
|
|
Scope llm.Scope // per-question override; empty fields fall back to the run-level scope
|
|
}
|
|
|
|
// effectiveScope returns the per-question scope merged with the
|
|
// run-level scope. Question-level fields win when set.
|
|
func (q question) effectiveScope(run llm.Scope) llm.Scope {
|
|
s := run
|
|
if q.Scope.Repo != "" {
|
|
s.Repo = q.Scope.Repo
|
|
}
|
|
if q.Scope.Project != "" {
|
|
s.Project = q.Scope.Project
|
|
}
|
|
if q.Scope.Ref != "" {
|
|
s.Ref = q.Scope.Ref
|
|
}
|
|
return s
|
|
}
|
|
|
|
type result struct {
|
|
Question string
|
|
Steps int
|
|
Answer string
|
|
Err string
|
|
Elapsed time.Duration
|
|
}
|
|
|
|
// Prompt variants. P0 is the empty baseline (no extras). P1 adds the
|
|
// process rules we expect to fix the failures observed in the model
|
|
// bench. P2 adds a one-shot worked example on top of P1.
|
|
const promptP1 = `RULES (follow these exactly):
|
|
- If the user gives you only a bare name (not a path-qualified id like "pkg/x.Foo"), you MUST first call search_symbols to resolve it to an id before calling get_callers.
|
|
- For search_symbols, pass ONLY the bare symbol name as "query" — no prepositions, no package qualifiers, no extra words. Do not write "Foo in pkg/bar" — write just "Foo".
|
|
- search_symbols returns ranked matches; the FIRST few are best. Do not walk every match — pick at most the top 1-3 that look like functions or methods and ignore the rest (fields, params, strings).
|
|
- Make at least one real tool call before final_answer.
|
|
- Never call the same tool with the same args twice in a row. If a tool result is empty, try a DIFFERENT tool or DIFFERENT args.
|
|
- When you have gathered enough information, call final_answer summarising what you found.`
|
|
|
|
const promptP2 = promptP1 + `
|
|
|
|
EXAMPLE — answer "who calls Foo?":
|
|
Step 1 emit: {"tool":"search_symbols","args":{"query":"Foo"}}
|
|
Step 1 result: {"matches":[{"id":"pkg/x.Foo","kind":"function","path":"pkg/x/foo.go"}]}
|
|
Step 2 emit: {"tool":"get_callers","args":{"id":"pkg/x.Foo"}}
|
|
Step 2 result: {"callers":[{"id":"pkg/y.bar","file":"pkg/y/bar.go"}]}
|
|
Step 3 emit: {"tool":"final_answer","args":{"text":"Foo is called by pkg/y.bar in pkg/y/bar.go."}}`
|
|
|
|
const promptChain = `RULES (follow these exactly):
|
|
- You are tracing a cross-system call chain. Output one tool call per turn.
|
|
- DIRECTION MATTERS. Only these tools are correct in chain mode:
|
|
* contracts — find producer↔consumer pairs across repos
|
|
* get_dependencies — FORWARD direction: what does this symbol call/import?
|
|
* final_answer — emit the chain
|
|
Do NOT use get_callers. get_callers walks BACKWARDS (who calls X), which is
|
|
the WRONG direction for chain tracing and will lead you astray.
|
|
- For search_symbols and contracts, pass clean values (no extra words).
|
|
- Typical flow for "trace request X across systems":
|
|
1) contracts({"role":"consumer","path":"<path>"}) — find the caller side.
|
|
2) contracts({"role":"provider","path":"<path>"}) — find the handler.
|
|
3) get_dependencies({"id":"<provider symbol_id>"}) — see what the handler calls.
|
|
4) For deeper hops, call get_dependencies AGAIN on the most interesting result's id.
|
|
Repeat until you reach a symbol in a third repo OR you have enough to answer.
|
|
5) Look for deps whose repo prefix differs from the handler's repo —
|
|
those are the cross-repo downstream calls.
|
|
6) Call final_answer with the chain as numbered steps.
|
|
- Never call the same tool with the same args twice in a row.
|
|
- final_answer.text should list each system hop with its symbol id and repo.`
|
|
|
|
// defaultQuestions returns the canonical bench question set for the
|
|
// given backend. The mock backend uses synthetic LoadConfig/RunServer
|
|
// questions; the daemon backend uses real cross-repo questions whose
|
|
// ground truth lives in the actual graph.
|
|
func defaultQuestions(backend string, chain bool) []question {
|
|
if chain {
|
|
return []question{
|
|
{
|
|
Name: "C1-chain-v1-stats",
|
|
Text: `A TypeScript client in the web repo calls GET /v1/stats. Trace the chain: find the consumer symbol in the web repo, the provider handler in another repo via the contracts tool, and any cross-repo function the handler reaches via its dependencies. List the full chain as numbered steps.`,
|
|
},
|
|
}
|
|
}
|
|
if backend == "daemon" {
|
|
return []question{
|
|
{
|
|
Name: "Q1-newserver",
|
|
Text: "Who calls NewServer in the mcp package?",
|
|
Scope: llm.Scope{Repo: "gortex"},
|
|
},
|
|
{
|
|
Name: "Q2-dialto",
|
|
Text: "Who calls DialTo?",
|
|
Scope: llm.Scope{Repo: "gortex"},
|
|
},
|
|
{
|
|
Name: "Q3-recordwebhook",
|
|
Text: "Who calls RecordWebhookEvent?",
|
|
Scope: llm.Scope{Repo: "gortex-cloud"},
|
|
},
|
|
{
|
|
Name: "Q4-encodeany",
|
|
Text: "Who calls EncodeAny?",
|
|
Scope: llm.Scope{Repo: "gcx-go"},
|
|
},
|
|
}
|
|
}
|
|
return []question{
|
|
{Name: "Q1-loadcfg", Text: "who calls the function called LoadConfig?"},
|
|
{Name: "Q2-runserver", Text: "Find every caller of RunServer and tell me which file each lives in."},
|
|
{Name: "Q3-nonexistent", Text: "Who calls FooBarBazNonexistent?"},
|
|
}
|
|
}
|
|
|
|
func promptByName(name string) (string, error) {
|
|
switch name {
|
|
case "", "p0":
|
|
return "", nil
|
|
case "p1":
|
|
return promptP1, nil
|
|
case "p2":
|
|
return promptP2, nil
|
|
case "chain":
|
|
return promptChain, nil
|
|
}
|
|
return "", fmt.Errorf("unknown prompt variant %q (use p0|p1|p2|chain)", name)
|
|
}
|
|
|
|
func runOne(spec modelSpec, qs []question, ctxSize int, systemExtras string, backend llm.Backend, runScope llm.Scope, chain bool) []result {
|
|
results := make([]result, 0, len(qs))
|
|
|
|
cfg := llm.Config{
|
|
Provider: "local",
|
|
Local: llm.LocalConfig{
|
|
Model: spec.Path,
|
|
Ctx: ctxSize,
|
|
GPULayers: 999,
|
|
Template: spec.Template,
|
|
},
|
|
}.ApplyDefaults()
|
|
prov, err := provider.New(cfg)
|
|
if err != nil {
|
|
for _, q := range qs {
|
|
results = append(results, result{Question: q.Name, Err: "provider: " + err.Error()})
|
|
}
|
|
return results
|
|
}
|
|
defer prov.Close()
|
|
|
|
for _, q := range qs {
|
|
// Rebuild tools per question so the scope can differ across
|
|
// the cross-repo set. Chain mode adds contracts +
|
|
// get_dependencies for cross-system tracing.
|
|
var tools []agent.Tool
|
|
if chain {
|
|
tools = agent.GortexChainTools(backend, q.effectiveScope(runScope))
|
|
} else {
|
|
tools = agent.GortexTools(backend, q.effectiveScope(runScope))
|
|
}
|
|
ag, err := agent.New(prov, tools)
|
|
if err != nil {
|
|
results = append(results, result{Question: q.Name, Err: "agent: " + err.Error()})
|
|
continue
|
|
}
|
|
|
|
maxSteps := 16
|
|
if chain {
|
|
maxSteps = 20
|
|
}
|
|
t0 := time.Now()
|
|
ans, transcript, runErr := ag.Run(context.Background(), systemExtras, q.Text, maxSteps)
|
|
elapsed := time.Since(t0)
|
|
|
|
r := result{Question: q.Name, Steps: stepCount(transcript), Answer: ans, Elapsed: elapsed}
|
|
if runErr != nil {
|
|
r.Err = runErr.Error()
|
|
}
|
|
results = append(results, r)
|
|
}
|
|
return results
|
|
}
|
|
|
|
func stepCount(steps []agent.Step) int {
|
|
n := 0
|
|
for _, s := range steps {
|
|
if s.Kind == "call" || s.Kind == "final" {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
func main() {
|
|
modelsDir := flag.String("models-dir", os.ExpandEnv("$HOME/models"), "directory containing .gguf files")
|
|
ctxSize := flag.Int("ctx", 4096, "context size")
|
|
promptName := flag.String("prompt", "p0", "prompt variant: p0 | p1 | p2")
|
|
only := flag.String("only", "", "substring filter on model label; empty = all")
|
|
backendName := flag.String("backend", "mock", "backend: mock | daemon")
|
|
gortexBin := flag.String("gortex-bin", "", "path to gortex binary (default: gortex on PATH)")
|
|
repo := flag.String("repo", "", "restrict queries to this repo prefix (cross-repo experiment)")
|
|
project := flag.String("project", "", "restrict queries to this project")
|
|
ref := flag.String("ref", "", "restrict queries to this ref tag")
|
|
chain := flag.Bool("chain", false, "register contract+dependency tools, swap to cross-system chain question set")
|
|
flag.Parse()
|
|
if *chain && *promptName == "p0" {
|
|
*promptName = "chain"
|
|
}
|
|
|
|
systemExtras, err := promptByName(*promptName)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(2)
|
|
}
|
|
|
|
var backend llm.Backend
|
|
switch *backendName {
|
|
case "mock":
|
|
backend = llm.MockBackend{}
|
|
case "daemon":
|
|
db, err := llm.NewDaemonBackend(*gortexBin, nil)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "daemon backend: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer db.Close()
|
|
backend = db
|
|
fmt.Fprintf(os.Stderr, "[daemon backend: gortex mcp subprocess]\n")
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "unknown -backend %q (use mock | daemon)\n", *backendName)
|
|
os.Exit(2)
|
|
}
|
|
scope := llm.Scope{Repo: *repo, Project: *project, Ref: *ref}
|
|
fmt.Fprintf(os.Stderr, "[prompt=%s backend=%s scope=%+v]\n", *promptName, *backendName, scope)
|
|
|
|
specs := []modelSpec{
|
|
{Label: "Qwen2.5-1.5B-Instruct (baseline)",
|
|
Path: filepath.Join(*modelsDir, "qwen2.5-1.5b-instruct-q4_k_m.gguf"),
|
|
Template: "chatml"},
|
|
{Label: "Qwen2.5-3B-Instruct (baseline)",
|
|
Path: filepath.Join(*modelsDir, "qwen2.5-3b-instruct-q4_k_m.gguf"),
|
|
Template: "chatml"},
|
|
{Label: "Qwen2.5-Coder-3B-Instruct",
|
|
Path: filepath.Join(*modelsDir, "qwen2.5-coder-3b-instruct-q4_k_m.gguf"),
|
|
Template: "chatml"},
|
|
{Label: "Hammer2.1-1.5b (tool-use specialist)",
|
|
Path: filepath.Join(*modelsDir, "Hammer2.1-1.5b.Q4_K_M.gguf"),
|
|
Template: "chatml"},
|
|
{Label: "Hermes-3-Llama-3.2-3B",
|
|
Path: filepath.Join(*modelsDir, "Hermes-3-Llama-3.2-3B.Q4_K_M.gguf"),
|
|
Template: "chatml"},
|
|
{Label: "Qwen2.5-Coder-7B-Instruct",
|
|
Path: filepath.Join(*modelsDir, "qwen2.5-coder-7b-instruct-q4_k_m.gguf"),
|
|
Template: "chatml"},
|
|
}
|
|
|
|
questions := defaultQuestions(*backendName, *chain)
|
|
|
|
all := make(map[string][]result)
|
|
for _, s := range specs {
|
|
if *only != "" && !strings.Contains(s.Label, *only) {
|
|
continue
|
|
}
|
|
if _, err := os.Stat(s.Path); err != nil {
|
|
fmt.Fprintf(os.Stderr, "[skip %s: %v]\n", s.Label, err)
|
|
continue
|
|
}
|
|
fmt.Fprintf(os.Stderr, "\n=== %s ===\n", s.Label)
|
|
rs := runOne(s, questions, *ctxSize, systemExtras, backend, scope, *chain)
|
|
all[s.Label] = rs
|
|
for _, r := range rs {
|
|
fmt.Fprintf(os.Stderr, " %s steps=%d %s\n", r.Question, r.Steps, r.Elapsed.Round(time.Millisecond))
|
|
if r.Err != "" {
|
|
fmt.Fprintf(os.Stderr, " ERROR: %s\n", r.Err)
|
|
} else {
|
|
fmt.Fprintf(os.Stderr, " ANSWER: %s\n", trunc(r.Answer, 200))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Final summary table on stdout — column-count adapts to the
|
|
// question set so 3-question (mock) and 4-question (daemon) sets
|
|
// both render correctly.
|
|
fmt.Println()
|
|
header := []string{"MODEL"}
|
|
sep := []string{"------"}
|
|
for _, q := range questions {
|
|
header = append(header, q.Name+" steps/time")
|
|
sep = append(sep, "---------------")
|
|
}
|
|
fmt.Println(strings.Join(header, " | "))
|
|
fmt.Println(strings.Join(sep, "-|-"))
|
|
for _, s := range specs {
|
|
if *only != "" && !strings.Contains(s.Label, *only) {
|
|
continue
|
|
}
|
|
rs, ok := all[s.Label]
|
|
if !ok {
|
|
continue
|
|
}
|
|
cells := []string{s.Label}
|
|
for _, r := range rs {
|
|
if r.Err != "" {
|
|
cells = append(cells, "ERR")
|
|
} else {
|
|
cells = append(cells, fmt.Sprintf("%d / %s", r.Steps, r.Elapsed.Round(time.Millisecond)))
|
|
}
|
|
}
|
|
fmt.Println(strings.Join(cells, " | "))
|
|
}
|
|
}
|
|
|
|
func trunc(s string, n int) string {
|
|
s = strings.ReplaceAll(s, "\n", " ")
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
return s[:n] + "…"
|
|
}
|