tarakan-client/internal/agent/agent.go
Maxfield Luke 58b29a7f84
Some checks failed
CI and releases / Test and vet (push) Failing after 1s
CI and releases / Build release binaries (push) Has been skipped
CI and releases / Publish GitHub release (push) Has been skipped
Initial commit on Forgejo
Fresh repository history for elektrine/tarakan-client hosted at
https://git.elektrine.com/elektrine/tarakan-client.
2026-07-29 04:56:36 -04:00

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
}