mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 18:08:31 -10:00
* perf(agent): execution profiles, --exec-profile, bench passthrough, TUI /profile (PR10b+10c) * fix(execprofile): harden profiles against review findings - fast escalates on critical-risk mutations, not high: the sandbox marks every shell command high before command analysis, so a high trigger spent the one-shot on the first go test of any task - escalation can now restore the provider-default effort a profile displaced (PostureEscalation.RestoreDefaultEffort; a plain empty target means leave untouched) - TUI /turns while a profile is active pins the budget: disarms the escalation turn target (mirroring exec's explicit --max-turns rule) and profile reverts use touched bits instead of value equality, so explicit choices survive even when they coincide with profile values - spec-draft runs get the same escalation policy as the main run instead of the displaced budget with no safety net - bench validates and canonicalizes --exec-profile before spawning: an unknown name previously produced an exit-0 report full of millisecond usage-error walls - turn summary prints exec-profile alongside mode; zero exec rejects an inline-empty --exec-profile=; balanced trace label documented and pinned as deliberate attribution * fix(execprofile): explicit effort choices disarm the escalation effort restore An explicit effort set AFTER profile selection could be cleared by a mid-run escalation (the effort analog of the /turns pin, introduced by RestoreDefaultEffort): TUI /effort and Ctrl+T now disarm the restore on a policy clone, and the spec-draft path only arms it when no explicit --spec-reasoning-effort governs the draft. * fix(execprofile): address review findings on the profile surfaces - --use-spec projects away a profile's self-correct knob instead of silently arming an option the draft runner ignores (the explicit --self-correct --use-spec combination is already a parse-time error) - RunTurnBench canonicalizes and validates ExecProfile at the library boundary, so a direct caller's casing/whitespace cannot fork report labels and an unknown name fails before anything runs - reverting a profile whose displaced budget was zero clears ZERO_MAX_TURNS (SetMaxTurnsEnv ignores zero, so sub-agents would have kept the removed profile's budget) - the TUI effort-disarm test fails instead of skipping when the catalog stops filling low effort * fix(tui): reconcile an active profile's effort fill on model switch The fill is per-model, not a session preference: fast selected on a model without low fills nothing, and switching to a model that supports it should behave like selecting the profile there; the reverse switch must not keep sending a level the destination does not support. Both switch paths (the /model command and the cross-provider picker) now re-derive the fill, leaving explicitly touched efforts alone and keeping the escalation's effort restore in step. Also: the revert-env test asserts key removal via LookupEnv, and applyProfileTurnBudget's pinned-budget parameter is named and documented as covering mode presets, not just the flag. * fix(tui): void profile effort bookkeeping when the model-switch drop erases it The /model path's pre-existing unsupported-effort drop ran before the profile reconciliation and left contradictory state behind: a dropped touched override kept blocking the profile from governing, and a dropped profile fill left a stale applied value with the effort restore still armed. The drop itself is load-bearing (the TUI run path forwards the effort to the provider unfiltered), so instead of preserving a known-unsupported level, the voided choice now resets the profile's effort bookkeeping and the reconcile re-derives from scratch. The whole sequence lives in one ring-parameterized helper driven by the switch target's authoritative ring, unit-tested against synthetic rings for both the void-then-refill arc and the supported-override survival. * fix(tui): only drop an explicit effort when the target ring is authoritative A nil ring meant both a catalog model with no effort controls and a live-discovered custom model the catalog cannot vouch for. The drop now requires an authoritative ring (a resolved catalog entry, whose ring may legitimately be empty); an unknown ring preserves an explicit preference, matching the cross-provider picker's behavior, while the profile's own fill still applies only where support is known.
474 lines
18 KiB
Go
474 lines
18 KiB
Go
package perfbench
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// TaskSchemaVersion is the schema version of a published task-benchmark result.
|
|
// Bump it whenever the TaskRunResult shape changes so consumers (docs/BENCHMARK.md
|
|
// tooling, dashboards) can detect format drift.
|
|
const TaskSchemaVersion = 1
|
|
|
|
// TaskSet is a reproducible benchmark task list. It mirrors the shape of a
|
|
// Terminal-Bench task manifest: each task is an isolated workspace plus a prompt
|
|
// ZERO must satisfy. The set is recorded by ID with every result so a published
|
|
// number is traceable to the exact tasks that produced it.
|
|
type TaskSet struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name,omitempty"`
|
|
Description string `json:"description,omitempty"`
|
|
Tasks []BenchTask `json:"tasks"`
|
|
// BuildOnlyClasses lists task classes whose verificationCommand is a
|
|
// non-positive build check (e.g. refactor's `go build ./...`): it proves the
|
|
// edit compiles, not that the refactor achieved its goal. The turn benchmark
|
|
// reports these separately from correctness oracles so a build-pass cannot be
|
|
// misread as a correctness pass. Classes with a verificationCommand that are
|
|
// NOT listed here are treated as correctness classes; classes whose tasks
|
|
// carry no verificationCommand are latency-only regardless of this list.
|
|
BuildOnlyClasses []string `json:"buildOnlyClasses,omitempty"`
|
|
}
|
|
|
|
// BenchTask is one benchmark task. WorkspaceFixture is the relative path of the
|
|
// task's starting workspace; VerificationCommand (optional) is the command the
|
|
// default runner executes to decide pass/fail after ZERO finishes. Class groups
|
|
// the task for the turn-benchmark's per-group latency breakdown (e.g. "nav",
|
|
// "edit", "fix"); it is optional and ignored by the pass/fail runner.
|
|
type BenchTask struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name,omitempty"`
|
|
Class string `json:"class,omitempty"`
|
|
Prompt string `json:"prompt"`
|
|
WorkspaceFixture string `json:"workspaceFixture,omitempty"`
|
|
VerificationCommand []string `json:"verificationCommand,omitempty"`
|
|
// OracleTest is Go source stamped into <fixtureCopy>/oracle_test.go AFTER
|
|
// the agent run (so it can't interfere with the agent's own go build/test
|
|
// during the task and can't be pre-seen). The verificationCommand then runs
|
|
// `go test ./...`, making the Go compiler the structural verifier: a
|
|
// compile-time reference (e.g. `var _ = Config{}.Label`) or a behavioral call
|
|
// fails to compile/run when the task wasn't actually done. Empty for tasks
|
|
// whose verificationCommand is a plain grep/build with no stamped test.
|
|
OracleTest string `json:"oracleTest,omitempty"`
|
|
}
|
|
|
|
// TaskConfig is the recorded configuration of a benchmark run. The honest
|
|
// framing matters: the model, mode, and self-correct flag are captured because
|
|
// the score is largely model-bounded — the point of the benchmark is to show the
|
|
// self-correct loop's contribution (the with/without delta), not the raw digit.
|
|
type TaskConfig struct {
|
|
Model string
|
|
Mode string
|
|
SelfCorrect bool
|
|
Version string
|
|
Commit string
|
|
// Now overrides the clock for the recorded date (tests inject a fixed time).
|
|
Now func() time.Time
|
|
// Runner executes one task and reports the outcome. Required. The default
|
|
// production runner (NewExecRunner) invokes headless `zero exec`.
|
|
Runner TaskRunner
|
|
}
|
|
|
|
// RunContext is the immutable per-run context handed to the runner for each
|
|
// task, so a runner records the same model/mode/self-correct values the result
|
|
// is stamped with.
|
|
type RunContext struct {
|
|
Model string
|
|
Mode string
|
|
SelfCorrect bool
|
|
// ExecProfile, when non-empty, is forwarded to the agent invocation as
|
|
// --exec-profile (turn benchmark runner; the task benchmark leaves it
|
|
// empty).
|
|
ExecProfile string
|
|
}
|
|
|
|
// TaskRunner runs a single benchmark task and returns its outcome.
|
|
type TaskRunner func(ctx context.Context, task BenchTask, rc RunContext) TaskOutcome
|
|
|
|
// TaskOutcome is what a runner reports for one task. Err is reserved for the run
|
|
// failing to execute (e.g. the agent process crashed); Passed reports whether
|
|
// the task's verification succeeded. A non-nil Err is always a non-pass.
|
|
type TaskOutcome struct {
|
|
Passed bool
|
|
Detail string
|
|
Err error
|
|
}
|
|
|
|
// TaskResult is the per-task entry in a TaskRunResult.
|
|
type TaskResult struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name,omitempty"`
|
|
Passed bool `json:"passed"`
|
|
Errored bool `json:"errored,omitempty"`
|
|
Detail string `json:"detail,omitempty"`
|
|
}
|
|
|
|
// TaskRunResult is the publishable benchmark record. It is intentionally
|
|
// self-describing: model + version + commit + self-correct flag + date are all
|
|
// embedded so a copy of this JSON is reproducible and auditable on its own.
|
|
type TaskRunResult struct {
|
|
SchemaVersion int `json:"schemaVersion"`
|
|
Suite string `json:"suite"`
|
|
SuiteName string `json:"suiteName,omitempty"`
|
|
Model string `json:"model"`
|
|
Mode string `json:"mode,omitempty"`
|
|
SelfCorrect bool `json:"selfCorrect"`
|
|
Version string `json:"version,omitempty"`
|
|
Commit string `json:"commit,omitempty"`
|
|
Date string `json:"date"`
|
|
TasksAttempted int `json:"tasksAttempted"`
|
|
TasksPassed int `json:"tasksPassed"`
|
|
Errors int `json:"errors"`
|
|
PassRate float64 `json:"passRate"`
|
|
Tasks []TaskResult `json:"tasks"`
|
|
}
|
|
|
|
// LoadTaskSet reads and validates a benchmark task set from a JSON file. Unknown
|
|
// fields are rejected so a malformed manifest fails loudly rather than silently
|
|
// dropping tasks.
|
|
func LoadTaskSet(path string) (TaskSet, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return TaskSet{}, fmt.Errorf("load task set: %w", err)
|
|
}
|
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
|
decoder.DisallowUnknownFields()
|
|
var set TaskSet
|
|
if err := decoder.Decode(&set); err != nil {
|
|
return TaskSet{}, fmt.Errorf("parse task set: %w", err)
|
|
}
|
|
// DisallowUnknownFields only validates the first top-level value, so a file
|
|
// like `{...}\nnot-json` would still load. Reject any trailing content so a
|
|
// malformed manifest fails loudly rather than silently using only the prefix.
|
|
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
|
return TaskSet{}, errors.New("parse task set: unexpected trailing content")
|
|
}
|
|
if strings.TrimSpace(set.ID) == "" {
|
|
return TaskSet{}, errors.New("parse task set: id is required")
|
|
}
|
|
if len(set.Tasks) == 0 {
|
|
return TaskSet{}, errors.New("parse task set: tasks must not be empty")
|
|
}
|
|
// Resolve relative workspace fixtures against the manifest's directory so the
|
|
// runner's cmd.Dir does not depend on the caller's cwd (the documented sample
|
|
// uses paths like ./hello relative to the manifest, not the repo root).
|
|
base := filepath.Dir(path)
|
|
for index, task := range set.Tasks {
|
|
if strings.TrimSpace(task.ID) == "" {
|
|
return TaskSet{}, fmt.Errorf("parse task set: tasks[%d] id is required", index)
|
|
}
|
|
if strings.TrimSpace(task.Prompt) == "" {
|
|
return TaskSet{}, fmt.Errorf("parse task set: tasks[%d] prompt is required", index)
|
|
}
|
|
if fixture := strings.TrimSpace(task.WorkspaceFixture); fixture != "" && !filepath.IsAbs(fixture) {
|
|
set.Tasks[index].WorkspaceFixture = filepath.Clean(filepath.Join(base, fixture))
|
|
}
|
|
}
|
|
return set, nil
|
|
}
|
|
|
|
// SkipRunner is a runner that performs no work and records every task as skipped
|
|
// (not passed, no error). It backs the harness's --dry-run mode, which exercises
|
|
// the full record path without invoking an agent or a model.
|
|
func SkipRunner(context.Context, BenchTask, RunContext) TaskOutcome {
|
|
return TaskOutcome{Passed: false, Detail: "skipped (dry run)"}
|
|
}
|
|
|
|
// RunTasks executes every task in the set with the configured runner and returns
|
|
// a self-describing result record. It never aborts on a single task failure or
|
|
// runner error — every task is attempted and recorded — so one bad task can't
|
|
// hide the rest of the run. The context cancels the whole run.
|
|
func RunTasks(ctx context.Context, set TaskSet, config TaskConfig) (TaskRunResult, error) {
|
|
if len(set.Tasks) == 0 {
|
|
return TaskRunResult{}, errors.New("task set has no tasks")
|
|
}
|
|
if strings.TrimSpace(config.Model) == "" {
|
|
return TaskRunResult{}, errors.New("benchmark requires a model")
|
|
}
|
|
if config.Runner == nil {
|
|
return TaskRunResult{}, errors.New("benchmark requires a task runner")
|
|
}
|
|
now := config.Now
|
|
if now == nil {
|
|
now = time.Now
|
|
}
|
|
rc := RunContext{Model: config.Model, Mode: config.Mode, SelfCorrect: config.SelfCorrect}
|
|
|
|
result := TaskRunResult{
|
|
SchemaVersion: TaskSchemaVersion,
|
|
Suite: strings.TrimSpace(set.ID),
|
|
SuiteName: strings.TrimSpace(set.Name),
|
|
Model: strings.TrimSpace(config.Model),
|
|
Mode: strings.TrimSpace(config.Mode),
|
|
SelfCorrect: config.SelfCorrect,
|
|
Version: strings.TrimSpace(config.Version),
|
|
Commit: strings.TrimSpace(config.Commit),
|
|
Date: now().UTC().Format(time.RFC3339),
|
|
Tasks: make([]TaskResult, 0, len(set.Tasks)),
|
|
}
|
|
|
|
for _, task := range set.Tasks {
|
|
if err := ctx.Err(); err != nil {
|
|
return result, err
|
|
}
|
|
outcome := config.Runner(ctx, task, rc)
|
|
entry := TaskResult{ID: task.ID, Name: task.Name}
|
|
result.TasksAttempted++
|
|
switch {
|
|
case outcome.Err != nil:
|
|
entry.Errored = true
|
|
entry.Detail = outcome.Err.Error()
|
|
result.Errors++
|
|
case outcome.Passed:
|
|
entry.Passed = true
|
|
entry.Detail = strings.TrimSpace(outcome.Detail)
|
|
result.TasksPassed++
|
|
default:
|
|
entry.Detail = strings.TrimSpace(outcome.Detail)
|
|
}
|
|
result.Tasks = append(result.Tasks, entry)
|
|
}
|
|
if result.TasksAttempted > 0 {
|
|
result.PassRate = RoundMetric(float64(result.TasksPassed) / float64(result.TasksAttempted))
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// FormatTaskSummary renders a human-readable summary that foregrounds the honest
|
|
// framing: the model and the self-correct setting are printed alongside the
|
|
// score, because the number is model-bounded.
|
|
func FormatTaskSummary(result TaskRunResult) string {
|
|
selfCorrect := "off"
|
|
if result.SelfCorrect {
|
|
selfCorrect = "on"
|
|
}
|
|
build := strings.TrimSpace(result.Version)
|
|
if commit := strings.TrimSpace(result.Commit); commit != "" {
|
|
if build != "" {
|
|
build += " (commit " + commit + ")"
|
|
} else {
|
|
build = "commit " + commit
|
|
}
|
|
}
|
|
lines := []string{
|
|
"Zero task benchmark: " + displayOrUnknown(result.Suite),
|
|
"model: " + displayOrUnknown(result.Model),
|
|
}
|
|
if mode := strings.TrimSpace(result.Mode); mode != "" {
|
|
lines = append(lines, "mode: "+mode)
|
|
}
|
|
lines = append(lines, "self-correct: "+selfCorrect)
|
|
if build != "" {
|
|
lines = append(lines, "build: "+build)
|
|
}
|
|
lines = append(lines, fmt.Sprintf("passed: %d/%d (%.0f%%)", result.TasksPassed, result.TasksAttempted, result.PassRate*100))
|
|
if result.Errors > 0 {
|
|
lines = append(lines, fmt.Sprintf("errors: %d", result.Errors))
|
|
}
|
|
for _, task := range result.Tasks {
|
|
lines = append(lines, "- "+formatTaskLine(task))
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
// WriteTaskJSON writes the indented JSON form of a benchmark result.
|
|
func WriteTaskJSON(w io.Writer, result TaskRunResult) error {
|
|
encoder := json.NewEncoder(w)
|
|
encoder.SetIndent("", " ")
|
|
return encoder.Encode(result)
|
|
}
|
|
|
|
func formatTaskLine(task TaskResult) string {
|
|
status := "pass"
|
|
if task.Errored {
|
|
status = "error"
|
|
} else if !task.Passed {
|
|
status = "fail"
|
|
}
|
|
label := strings.TrimSpace(task.Name)
|
|
if label == "" {
|
|
label = task.ID
|
|
} else {
|
|
label = task.ID + " " + label
|
|
}
|
|
line := fmt.Sprintf("[%s] %s", status, label)
|
|
if detail := strings.TrimSpace(task.Detail); detail != "" {
|
|
line += ": " + detail
|
|
}
|
|
return line
|
|
}
|
|
|
|
func displayOrUnknown(value string) string {
|
|
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
|
return trimmed
|
|
}
|
|
return "unknown"
|
|
}
|
|
|
|
// NewExecRunner builds the production runner: it invokes headless `zero exec`
|
|
// with stream-json output for each task and decides pass/fail. binary is the path
|
|
// to the `zero` binary; extraArgs are appended to every invocation (e.g. sandbox
|
|
// flags). The self-correct flag from RunContext is translated into the exec
|
|
// invocation so the recorded config matches what actually ran.
|
|
//
|
|
// Pass/fail is decided from the stream-json run_end event's exit code: a zero
|
|
// exit means the agent completed the task without error. When a task carries a
|
|
// VerificationCommand, that command is run after the agent and its exit status
|
|
// is authoritative (this mirrors Terminal-Bench's external verifier model).
|
|
func NewExecRunner(binary string, extraArgs ...string) TaskRunner {
|
|
return func(ctx context.Context, task BenchTask, rc RunContext) TaskOutcome {
|
|
args := buildExecArgs(task, rc, extraArgs)
|
|
cmd := exec.CommandContext(ctx, binary, args...)
|
|
cmd.Env = appendNoColor(os.Environ())
|
|
if dir := strings.TrimSpace(task.WorkspaceFixture); dir != "" {
|
|
cmd.Dir = dir
|
|
}
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
runErr := cmd.Run()
|
|
|
|
// The terminal run_end exit code is authoritative for pass/fail: a non-zero
|
|
// agent exit is a normal task failure, not a harness error, even though
|
|
// cmd.Run() reports any non-zero process exit as an *exec.ExitError. Only when
|
|
// no terminal run_end event was parsed do we fall back to treating the run as
|
|
// a harness error — failing closed so a missing terminal event never reads as
|
|
// a pass.
|
|
exitCode, haveExit := streamJSONExitCode(stdout.Bytes())
|
|
if haveExit && exitCode != 0 {
|
|
return TaskOutcome{Passed: false, Detail: fmt.Sprintf("agent run_end exit code %d", exitCode)}
|
|
}
|
|
if !haveExit {
|
|
detail := strings.TrimSpace(stderr.String())
|
|
if detail == "" && runErr != nil {
|
|
detail = runErr.Error()
|
|
}
|
|
if detail == "" {
|
|
detail = "missing terminal run_end event"
|
|
}
|
|
return TaskOutcome{Err: fmt.Errorf("zero exec failed: %s", detail)}
|
|
}
|
|
|
|
if len(task.VerificationCommand) > 0 {
|
|
return runVerification(ctx, task)
|
|
}
|
|
return TaskOutcome{Passed: true}
|
|
}
|
|
}
|
|
|
|
func buildExecArgs(task BenchTask, rc RunContext, extraArgs []string) []string {
|
|
args := []string{"exec", "--output-format", "stream-json"}
|
|
if model := strings.TrimSpace(rc.Model); model != "" {
|
|
args = append(args, "--model", model)
|
|
}
|
|
if mode := strings.TrimSpace(rc.Mode); mode != "" {
|
|
args = append(args, "--mode", mode)
|
|
}
|
|
if rc.SelfCorrect {
|
|
args = append(args, "--self-correct")
|
|
}
|
|
args = append(args, extraArgs...)
|
|
args = append(args, task.Prompt)
|
|
return args
|
|
}
|
|
|
|
func runVerification(ctx context.Context, task BenchTask) TaskOutcome {
|
|
cmd := exec.CommandContext(ctx, task.VerificationCommand[0], task.VerificationCommand[1:]...)
|
|
// Inherit the environment so the verifier sees PATH, HOME, language toolchain
|
|
// vars, etc. — the same surface a maintainer gets running the command by hand
|
|
// (matching the agent run above). NO_COLOR is appended for stable output.
|
|
cmd.Env = appendNoColor(os.Environ())
|
|
if dir := strings.TrimSpace(task.WorkspaceFixture); dir != "" {
|
|
cmd.Dir = dir
|
|
}
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
detail := strings.TrimSpace(string(output))
|
|
if detail == "" {
|
|
detail = err.Error()
|
|
}
|
|
return TaskOutcome{Passed: false, Detail: "verification failed: " + firstLine(detail)}
|
|
}
|
|
return TaskOutcome{Passed: true}
|
|
}
|
|
|
|
// streamJSONResult holds the terminal events parsed from one pass over a
|
|
// stream-json output: the last run_end exit code (if any) and the last final
|
|
// event's text (the agent's answer, if any). streamJSONExitCode and
|
|
// streamJSONFinalText both consume this so the scanner limits and
|
|
// malformed-event handling live in exactly one place.
|
|
type streamJSONResult struct {
|
|
exitCode int
|
|
haveExit bool
|
|
finalText string
|
|
}
|
|
|
|
// parseStreamJSON scans stream-json output once and returns the terminal
|
|
// run_end exit code and the final event's text. ZERO emits one JSON object per
|
|
// line; the last run_end carries the exit code and the last final carries the
|
|
// answer. Lines that don't parse are skipped. The scanner buffer is raised so a
|
|
// large single-line event (e.g. a big final answer) doesn't error out.
|
|
func parseStreamJSON(output []byte) streamJSONResult {
|
|
scanner := bufio.NewScanner(bytes.NewReader(output))
|
|
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
|
var r streamJSONResult
|
|
for scanner.Scan() {
|
|
line := bytes.TrimSpace(scanner.Bytes())
|
|
if len(line) == 0 || line[0] != '{' {
|
|
continue
|
|
}
|
|
var event struct {
|
|
Type string `json:"type"`
|
|
ExitCode *int `json:"exitCode"`
|
|
Text string `json:"text"`
|
|
}
|
|
if err := json.Unmarshal(line, &event); err != nil {
|
|
continue
|
|
}
|
|
if event.Type == "run_end" && event.ExitCode != nil {
|
|
r.exitCode = *event.ExitCode
|
|
r.haveExit = true
|
|
}
|
|
if event.Type == "final" {
|
|
r.finalText = event.Text
|
|
}
|
|
}
|
|
return r
|
|
}
|
|
|
|
// streamJSONExitCode scans stream-json output for the terminal run_end event and
|
|
// returns its exit code. The last run_end event carries the exit code. Lines
|
|
// that don't parse are skipped.
|
|
func streamJSONExitCode(output []byte) (int, bool) {
|
|
r := parseStreamJSON(output)
|
|
return r.exitCode, r.haveExit
|
|
}
|
|
|
|
// streamJSONFinalText scans stream-json output for the terminal "final" event
|
|
// and returns its text (the agent's final answer). ZERO emits one final event
|
|
// per run on both the success and incomplete paths (exec.go writer.final), so
|
|
// the last one wins. The turn benchmark writes this to .zero-answer.txt in the
|
|
// fixture copy so nav answer-oracles can grep the captured answer rather than
|
|
// the raw stream. Returns "" when no final event was emitted (e.g. a provider
|
|
// error path) — the caller writes an empty file and a nav grep then fails,
|
|
// which is the correct outcome for a run that produced no answer.
|
|
func streamJSONFinalText(output []byte) string {
|
|
return parseStreamJSON(output).finalText
|
|
}
|
|
|
|
func firstLine(text string) string {
|
|
if index := strings.IndexByte(text, '\n'); index >= 0 {
|
|
return strings.TrimSpace(text[:index])
|
|
}
|
|
return text
|
|
}
|