initial public release
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
package eval
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// maxOutputBytes caps Claude CLI stdout. A normal A-G eval is well under 100KB
|
||||
// of markdown; 5MB leaves headroom for streaming tool output while bounding
|
||||
// any runaway subprocess.
|
||||
const maxOutputBytes = 5 * 1024 * 1024
|
||||
|
||||
// Client wraps the Claude CLI subprocess. Safe to reuse across evals.
|
||||
type Client struct {
|
||||
CLIPath string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// NewClient returns a Client bound to the system `claude` binary with a
|
||||
// generous default timeout suited to A-G evaluations (WebSearch calls for
|
||||
// Block D can push total runtime past the 2-minute mark).
|
||||
func NewClient() *Client {
|
||||
return &Client{
|
||||
CLIPath: "claude",
|
||||
Timeout: 10 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// ChunkHandler receives raw text chunks as they stream from the subprocess.
|
||||
// Returning false cancels the eval. Use to implement Block-G-first ghost-job
|
||||
// cutoff from the caller side without coupling this package to policy.
|
||||
type ChunkHandler func(text string) bool
|
||||
|
||||
// Evaluate runs the Claude CLI against the given prompt and returns the
|
||||
// parsed Evaluation. The prompt is piped via stdin (never argv) so that:
|
||||
// - arbitrarily long prompts don't hit ARG_MAX,
|
||||
// - prompt contents don't leak to /proc or `ps` output,
|
||||
// - shell metacharacters in the prompt are inert (no bash -c).
|
||||
//
|
||||
// onChunk is optional; nil means buffer-only. Returning false from onChunk
|
||||
// cancels the context and terminates the subprocess.
|
||||
func (c *Client) Evaluate(ctx context.Context, prompt string, onChunk ChunkHandler) (Evaluation, error) {
|
||||
if c.CLIPath == "" {
|
||||
return Evaluation{}, errors.New("claude cli path not configured")
|
||||
}
|
||||
if strings.TrimSpace(prompt) == "" {
|
||||
return Evaluation{}, errors.New("empty prompt")
|
||||
}
|
||||
|
||||
runCtx, cancel := context.WithTimeout(ctx, c.Timeout)
|
||||
defer cancel()
|
||||
|
||||
// --print: non-interactive mode. Prompt is read from stdin when no
|
||||
// positional prompt arg is supplied. --output-format text keeps stdout
|
||||
// as plain markdown, which is what parser.go consumes.
|
||||
cmd := exec.CommandContext(runCtx, c.CLIPath, "--print", "--output-format", "text")
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return Evaluation{}, fmt.Errorf("stdin pipe: %w", err)
|
||||
}
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return Evaluation{}, fmt.Errorf("stdout pipe: %w", err)
|
||||
}
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
started := time.Now()
|
||||
if err := cmd.Start(); err != nil {
|
||||
return Evaluation{}, fmt.Errorf("start claude: %w", err)
|
||||
}
|
||||
|
||||
// Feed the prompt on stdin in a goroutine so we can start reading stdout
|
||||
// immediately. Close stdin when done so claude knows the input is complete.
|
||||
go func() {
|
||||
defer stdin.Close()
|
||||
_, _ = io.WriteString(stdin, prompt)
|
||||
}()
|
||||
|
||||
// Read stdout line-by-line, buffering the full output while also routing
|
||||
// chunks to the caller. LimitReader enforces the output cap regardless of
|
||||
// subprocess behavior.
|
||||
var buf bytes.Buffer
|
||||
reader := bufio.NewReader(io.LimitReader(stdout, maxOutputBytes))
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if len(line) > 0 {
|
||||
buf.WriteString(line)
|
||||
if onChunk != nil && !onChunk(line) {
|
||||
cancel()
|
||||
break
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
// Drain any remaining bytes before surfacing the error — this
|
||||
// ensures stderr is captured even when the stream breaks mid-way.
|
||||
_ = cmd.Wait()
|
||||
return Evaluation{}, fmt.Errorf("read stdout: %w (stderr: %s)", err, stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
waitErr := cmd.Wait()
|
||||
elapsed := time.Since(started).Milliseconds()
|
||||
|
||||
if waitErr != nil && runCtx.Err() == nil {
|
||||
return Evaluation{}, fmt.Errorf("claude exit: %w (stderr: %s)", waitErr, stderr.String())
|
||||
}
|
||||
if runCtx.Err() == context.DeadlineExceeded {
|
||||
return Evaluation{}, fmt.Errorf("claude timeout after %s", c.Timeout)
|
||||
}
|
||||
|
||||
eval, err := ParseEvaluation(buf.String())
|
||||
if err != nil {
|
||||
return Evaluation{Raw: buf.String(), ElapsedMS: elapsed, ReceivedAt: time.Now()},
|
||||
fmt.Errorf("parse: %w", err)
|
||||
}
|
||||
eval.Raw = buf.String()
|
||||
eval.ElapsedMS = elapsed
|
||||
eval.ReceivedAt = time.Now()
|
||||
return eval, nil
|
||||
}
|
||||
|
||||
// Available verifies the Claude CLI is on PATH. Called at startup to fail
|
||||
// loudly rather than at first eval.
|
||||
func (c *Client) Available() error {
|
||||
path := c.CLIPath
|
||||
if path == "" {
|
||||
path = "claude"
|
||||
}
|
||||
if _, err := exec.LookPath(path); err != nil {
|
||||
return fmt.Errorf("claude cli not found on PATH: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user