Fresh repository history for elektrine/tarakan-client hosted at https://git.elektrine.com/elektrine/tarakan-client.
280 lines
7.9 KiB
Go
280 lines
7.9 KiB
Go
package agent
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
var ErrUnavailable = errors.New("agent is unavailable")
|
|
|
|
// lookPath is indirected so provider detection can be exercised in tests
|
|
// without depending on what is installed on the host.
|
|
var lookPath = exec.LookPath
|
|
|
|
// Kind distinguishes an agentic CLI (which reads the repository itself) from
|
|
// an HTTP model endpoint (which needs the repository packed into the prompt).
|
|
const (
|
|
KindCLI = "cli"
|
|
KindHTTP = "http"
|
|
)
|
|
|
|
type Provider struct {
|
|
Name string `json:"name"`
|
|
Kind string `json:"kind,omitempty"`
|
|
Command string `json:"command,omitempty"`
|
|
Description string `json:"description"`
|
|
Path string `json:"path,omitempty"`
|
|
|
|
// HTTP providers only.
|
|
BaseURL string `json:"base_url,omitempty"`
|
|
Model string `json:"model,omitempty"`
|
|
APIKeyEnv string `json:"api_key_env,omitempty"`
|
|
}
|
|
|
|
type Request struct {
|
|
Prompt string
|
|
Directory string
|
|
// Progress, if set, is called with status lines (and agent stderr when streaming).
|
|
// Must be safe to call from a background goroutine.
|
|
Progress func(string)
|
|
}
|
|
|
|
type Registry struct {
|
|
providers []Provider
|
|
}
|
|
|
|
// Detect discovers every review backend available in this environment: the
|
|
// agentic CLIs on $PATH, plus the HTTP model endpoints (Ollama, OpenRouter)
|
|
// that are configured and reachable.
|
|
func Detect() Registry {
|
|
// Order is preference: Default() takes the first one that is installed, and
|
|
// the TUI lists them in this order. Kimi leads deliberately.
|
|
known := []Provider{
|
|
{Name: "kimi", Kind: KindCLI, Command: "kimi", Description: "Kimi Code"},
|
|
{Name: "claude", Kind: KindCLI, Command: "claude", Description: "Claude Code"},
|
|
{Name: "codex", Kind: KindCLI, Command: "codex", Description: "OpenAI Codex"},
|
|
{Name: "grok", Kind: KindCLI, Command: "grok", Description: "Grok Build"},
|
|
}
|
|
|
|
available := make([]Provider, 0, len(known)+2)
|
|
for _, provider := range known {
|
|
if path, err := exec.LookPath(provider.Command); err == nil {
|
|
provider.Path = path
|
|
available = append(available, provider)
|
|
}
|
|
}
|
|
|
|
available = append(available, detectHTTPProviders(os.Getenv)...)
|
|
return Registry{providers: available}
|
|
}
|
|
|
|
func (r Registry) Providers() []Provider {
|
|
return append([]Provider(nil), r.providers...)
|
|
}
|
|
|
|
func (r Registry) Find(name string) (Provider, bool) {
|
|
name = strings.ToLower(strings.TrimSpace(name))
|
|
for _, provider := range r.providers {
|
|
if provider.Name == name {
|
|
return provider, true
|
|
}
|
|
}
|
|
// --agent kimi prefers the CLI; fall back to Moonshot HTTP when only the API key is set.
|
|
if name == "kimi" {
|
|
for _, provider := range r.providers {
|
|
if provider.Name == "kimi-http" {
|
|
return provider, true
|
|
}
|
|
}
|
|
}
|
|
return Provider{}, false
|
|
}
|
|
|
|
func (r Registry) Default() (Provider, bool) {
|
|
if len(r.providers) == 0 {
|
|
return Provider{}, false
|
|
}
|
|
return r.providers[0], true
|
|
}
|
|
|
|
// ModelIdentifier is the model string recorded on a submitted review. HTTP
|
|
// providers know their exact model; CLI agents report the tool name because
|
|
// the underlying model is the CLI's own concern.
|
|
func (p Provider) ModelIdentifier() string {
|
|
if p.Kind == KindHTTP {
|
|
return p.Model
|
|
}
|
|
return p.Name
|
|
}
|
|
|
|
// WithModel returns a copy of the provider using a caller-supplied model. It
|
|
// only affects HTTP providers; CLI agents choose their own model.
|
|
func (p Provider) WithModel(model string) Provider {
|
|
if model == "" || p.Kind != KindHTTP {
|
|
return p
|
|
}
|
|
p.Model = model
|
|
return p
|
|
}
|
|
|
|
func Run(ctx context.Context, provider Provider, request Request) (string, error) {
|
|
switch provider.Kind {
|
|
case KindHTTP:
|
|
return runHTTP(ctx, provider, request)
|
|
default:
|
|
return runCLI(ctx, provider, request)
|
|
}
|
|
}
|
|
|
|
func runCLI(ctx context.Context, provider Provider, request Request) (string, error) {
|
|
if provider.Path == "" {
|
|
return "", ErrUnavailable
|
|
}
|
|
|
|
// CLI agents that expose structured event streams get live tool activity
|
|
// in Progress (same transcript UX for grok / claude / codex / kimi).
|
|
switch provider.Name {
|
|
case "grok":
|
|
return runGrok(ctx, provider, request)
|
|
case "claude":
|
|
return runClaude(ctx, provider, request)
|
|
case "codex":
|
|
return runCodex(ctx, provider, request)
|
|
case "kimi":
|
|
return runKimi(ctx, provider, request)
|
|
}
|
|
|
|
args, err := arguments(provider.Name, securityPrompt(request.Prompt))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
command := exec.CommandContext(ctx, provider.Path, args...)
|
|
command.Dir = request.Directory
|
|
command.Env = subprocessEnvironment(os.Environ())
|
|
|
|
if request.Progress == nil {
|
|
output, err := command.CombinedOutput()
|
|
if err != nil {
|
|
return string(output), fmt.Errorf("%s failed: %w", provider.Description, err)
|
|
}
|
|
return strings.TrimSpace(string(output)), nil
|
|
}
|
|
|
|
return runCLIStreaming(command, provider.Description, request.Progress)
|
|
}
|
|
|
|
// runCLIStreaming tees stdout+stderr into the returned buffer while forwarding
|
|
// non-empty lines to progress (prefixed) so the TUI can show agent activity.
|
|
func runCLIStreaming(command *exec.Cmd, description string, progress func(string)) (string, error) {
|
|
stdout, err := command.StdoutPipe()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
stderr, err := command.StderrPipe()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := command.Start(); err != nil {
|
|
return "", fmt.Errorf("%s failed to start: %w", description, err)
|
|
}
|
|
|
|
var (
|
|
buf bytes.Buffer
|
|
mu sync.Mutex
|
|
wg sync.WaitGroup
|
|
)
|
|
scan := func(r io.Reader, isErr bool) {
|
|
defer wg.Done()
|
|
scanner := bufio.NewScanner(r)
|
|
// Agents can emit long JSON lines.
|
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
mu.Lock()
|
|
buf.WriteString(line)
|
|
buf.WriteByte('\n')
|
|
mu.Unlock()
|
|
trimmed := strings.TrimSpace(line)
|
|
if trimmed == "" || progress == nil {
|
|
continue
|
|
}
|
|
// Skip huge JSON blobs in the status line; keep short activity.
|
|
if len(trimmed) > 200 || strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") {
|
|
if isErr {
|
|
progress(description + " … (working)")
|
|
}
|
|
continue
|
|
}
|
|
if isErr {
|
|
progress(description + ": " + trimmed)
|
|
} else {
|
|
progress(description + ": " + trimmed)
|
|
}
|
|
}
|
|
}
|
|
wg.Add(2)
|
|
go scan(stdout, false)
|
|
go scan(stderr, true)
|
|
wg.Wait()
|
|
waitErr := command.Wait()
|
|
output := strings.TrimSpace(buf.String())
|
|
if waitErr != nil {
|
|
return output, fmt.Errorf("%s failed: %w", description, waitErr)
|
|
}
|
|
return output, nil
|
|
}
|
|
|
|
// subprocessEnvironment prevents the agent process-and therefore untrusted
|
|
// repository instructions-from reading credentials for the Tarakan service.
|
|
// Provider CLIs keep their normal environment and their own authentication.
|
|
func subprocessEnvironment(environment []string) []string {
|
|
filtered := make([]string, 0, len(environment))
|
|
for _, entry := range environment {
|
|
name, _, _ := strings.Cut(entry, "=")
|
|
if strings.HasPrefix(strings.ToUpper(name), "TARAKAN_") {
|
|
continue
|
|
}
|
|
filtered = append(filtered, entry)
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func arguments(provider, prompt string) ([]string, error) {
|
|
switch provider {
|
|
case "claude":
|
|
return []string{"-p", prompt}, nil
|
|
case "codex":
|
|
return []string{"exec", prompt}, nil
|
|
case "grok":
|
|
return []string{"-p", prompt}, nil
|
|
case "kimi":
|
|
// Print mode: non-interactive, auto-approves tools for this invocation.
|
|
return []string{
|
|
"--print",
|
|
"--prompt", prompt,
|
|
"--yolo",
|
|
"--final-message-only",
|
|
}, nil
|
|
default:
|
|
return nil, fmt.Errorf("unknown agent provider %q", provider)
|
|
}
|
|
}
|
|
|
|
// reviewInstruction is the read-only security-review directive shared by every
|
|
// backend. CLI agents receive it inline; HTTP models receive it as the system
|
|
// message.
|
|
const reviewInstruction = "You are contributing a read-only security review of the current repository. " +
|
|
"Do not edit files, commit changes, access secrets, or interact with external systems. " +
|
|
"Clearly separate verified findings from hypotheses and include file paths and evidence."
|
|
|
|
func securityPrompt(userPrompt string) string {
|
|
return reviewInstruction + "\n\nUser request:\n" + userPrompt
|
|
}
|