Fresh repository history for elektrine/tarakan-client hosted at https://git.elektrine.com/elektrine/tarakan-client.
219 lines
5.7 KiB
Go
219 lines
5.7 KiB
Go
package agent
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// runKimi runs Kimi Code headless in print mode.
|
|
//
|
|
// With --final-message-only the CLI emits only the assistant answer (best for
|
|
// Review Format JSON). When stream-json is available and Progress is set, we
|
|
// use streaming so the TUI can show tool activity.
|
|
func runKimi(ctx context.Context, provider Provider, request Request) (string, error) {
|
|
if provider.Path == "" {
|
|
return "", ErrUnavailable
|
|
}
|
|
|
|
prompt := securityPrompt(request.Prompt)
|
|
cwd := request.Directory
|
|
if cwd == "" {
|
|
cwd, _ = os.Getwd()
|
|
}
|
|
|
|
// Prefer stream-json when the UI wants progress; otherwise quiet text.
|
|
if request.Progress != nil {
|
|
return runKimiStreaming(ctx, provider, prompt, cwd, request.Progress)
|
|
}
|
|
return runKimiFinal(ctx, provider, prompt, cwd)
|
|
}
|
|
|
|
func runKimiFinal(ctx context.Context, provider Provider, prompt, cwd string) (string, error) {
|
|
args := []string{
|
|
"--print",
|
|
"--prompt", prompt,
|
|
"--yolo",
|
|
"--final-message-only",
|
|
"--work-dir", cwd,
|
|
}
|
|
command := exec.CommandContext(ctx, provider.Path, args...)
|
|
command.Dir = cwd
|
|
command.Env = subprocessEnvironment(os.Environ())
|
|
|
|
output, err := command.CombinedOutput()
|
|
if err != nil {
|
|
return string(output), fmt.Errorf("%s failed: %w\n%s", provider.Description, err, strings.TrimSpace(string(output)))
|
|
}
|
|
return strings.TrimSpace(string(output)), nil
|
|
}
|
|
|
|
func runKimiStreaming(ctx context.Context, provider Provider, prompt, cwd string, progress func(string)) (string, error) {
|
|
args := []string{
|
|
"--print",
|
|
"--prompt", prompt,
|
|
"--yolo",
|
|
"--output-format", "stream-json",
|
|
"--work-dir", cwd,
|
|
}
|
|
command := exec.CommandContext(ctx, provider.Path, args...)
|
|
command.Dir = cwd
|
|
command.Env = subprocessEnvironment(os.Environ())
|
|
|
|
stdout, err := command.StdoutPipe()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var stderrBuf bytes.Buffer
|
|
command.Stderr = &stderrBuf
|
|
|
|
if err := command.Start(); err != nil {
|
|
return "", fmt.Errorf("%s failed to start: %w", provider.Description, err)
|
|
}
|
|
|
|
report := func(line string) {
|
|
if progress == nil {
|
|
return
|
|
}
|
|
if line = strings.TrimSpace(line); line != "" {
|
|
progress(line)
|
|
}
|
|
}
|
|
|
|
var (
|
|
textBuf strings.Builder
|
|
mu sync.Mutex
|
|
final string
|
|
)
|
|
|
|
scanner := bufio.NewScanner(stdout)
|
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
ev := parseKimiStreamLine(line)
|
|
if !ev.ok {
|
|
continue
|
|
}
|
|
if ev.activity != "" {
|
|
report(ev.activity)
|
|
}
|
|
if ev.text != "" {
|
|
mu.Lock()
|
|
textBuf.WriteString(ev.text)
|
|
mu.Unlock()
|
|
}
|
|
if ev.final != "" {
|
|
final = ev.final
|
|
}
|
|
}
|
|
|
|
waitErr := command.Wait()
|
|
out := strings.TrimSpace(final)
|
|
if out == "" {
|
|
mu.Lock()
|
|
out = strings.TrimSpace(textBuf.String())
|
|
mu.Unlock()
|
|
}
|
|
if waitErr != nil {
|
|
detail := strings.TrimSpace(stderrBuf.String())
|
|
if detail != "" {
|
|
return out, fmt.Errorf("%s failed: %w\n%s", provider.Description, waitErr, detail)
|
|
}
|
|
return out, fmt.Errorf("%s failed: %w", provider.Description, waitErr)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
type kimiStreamEvent struct {
|
|
ok bool
|
|
activity string
|
|
text string
|
|
final string
|
|
}
|
|
|
|
// parseKimiStreamLine maps Kimi print stream-json lines into progress + text.
|
|
// The wire format is evolving; we accept common role/type shapes and ignore the rest.
|
|
func parseKimiStreamLine(line string) kimiStreamEvent {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || !strings.HasPrefix(line, "{") {
|
|
return kimiStreamEvent{}
|
|
}
|
|
|
|
var raw map[string]any
|
|
if err := json.Unmarshal([]byte(line), &raw); err != nil {
|
|
return kimiStreamEvent{}
|
|
}
|
|
|
|
// OpenAI-ish chat chunk: {"choices":[{"delta":{"content":"..."}}]}
|
|
if choices, ok := raw["choices"].([]any); ok && len(choices) > 0 {
|
|
if choice, ok := choices[0].(map[string]any); ok {
|
|
if delta, ok := choice["delta"].(map[string]any); ok {
|
|
if content, ok := delta["content"].(string); ok && content != "" {
|
|
return kimiStreamEvent{ok: true, text: content}
|
|
}
|
|
}
|
|
if msg, ok := choice["message"].(map[string]any); ok {
|
|
if content, ok := msg["content"].(string); ok && content != "" {
|
|
return kimiStreamEvent{ok: true, text: content, final: content}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// role/content messages
|
|
if role, _ := raw["role"].(string); role == "assistant" {
|
|
if content, ok := raw["content"].(string); ok && content != "" {
|
|
return kimiStreamEvent{ok: true, text: content, final: content}
|
|
}
|
|
}
|
|
|
|
// type-tagged events (tool use, assistant text)
|
|
typ, _ := raw["type"].(string)
|
|
switch typ {
|
|
case "assistant", "message", "agent_message", "text":
|
|
if content := kimiFirstString(raw, "text", "content", "message"); content != "" {
|
|
return kimiStreamEvent{ok: true, text: content, final: content}
|
|
}
|
|
case "tool_use", "tool_call", "tool":
|
|
name := kimiFirstString(raw, "name", "tool", "tool_name")
|
|
if name == "" {
|
|
if input, ok := raw["input"].(map[string]any); ok {
|
|
name = kimiFirstString(input, "name", "command", "path")
|
|
}
|
|
}
|
|
if name != "" {
|
|
return kimiStreamEvent{ok: true, activity: "Kimi: " + name}
|
|
}
|
|
return kimiStreamEvent{ok: true, activity: "Kimi … (tool)"}
|
|
case "result", "final", "final_message":
|
|
if content := kimiFirstString(raw, "result", "text", "content", "message"); content != "" {
|
|
return kimiStreamEvent{ok: true, text: content, final: content}
|
|
}
|
|
}
|
|
|
|
// Tool name at top level without type
|
|
if name := kimiFirstString(raw, "tool_name", "name"); name != "" && raw["type"] == nil {
|
|
if _, isMsg := raw["role"]; !isMsg {
|
|
return kimiStreamEvent{ok: true, activity: "Kimi: " + name}
|
|
}
|
|
}
|
|
|
|
return kimiStreamEvent{}
|
|
}
|
|
|
|
func kimiFirstString(m map[string]any, keys ...string) string {
|
|
for _, key := range keys {
|
|
if v, ok := m[key].(string); ok {
|
|
if s := strings.TrimSpace(v); s != "" {
|
|
return s
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|