Initial commit on Forgejo
Fresh repository history for elektrine/tarakan-client hosted at https://git.elektrine.com/elektrine/tarakan-client.
This commit is contained in:
commit
58b29a7f84
70 changed files with 12994 additions and 0 deletions
280
internal/agent/agent.go
Normal file
280
internal/agent/agent.go
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue