agent-orchestrator/backend/internal/adapters/agent/kimi/kimi.go

186 lines
6.6 KiB
Go

// Package kimi implements the Kimi CLI (Moonshot AI) agent adapter: launching
// new non-interactive sessions and resuming sessions when a native Kimi session
// id is known.
//
// Kimi CLI (binary "kimi") is Moonshot AI's terminal-native agentic coding
// agent. A new task is run non-interactively with `kimi -p <prompt>`, which
// streams the assistant output to stdout without opening the TUI. Sessions are
// resumed by id with `kimi --session <id>`.
//
// Kimi exposes no native lifecycle/hook system and is not documented as
// Claude Code hook-compatible, so this is a Tier C adapter: hook installation
// and SessionInfo are intentionally no-ops, and activity is left to the
// lifecycle reaper. There is also no documented system-prompt flag, so AO's
// system prompt is not injected. Both should be upgraded if/when Kimi adds the
// corresponding CLI surface.
package kimi
import (
"context"
"strings"
"sync"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
const adapterID = "kimi"
// Plugin is the Kimi CLI agent adapter. It is safe for concurrent use; the
// binary path is resolved once and cached under binaryMu.
type Plugin struct {
agentbase.Base
binaryMu sync.Mutex
resolvedBinary string
}
// New returns a ready-to-register Kimi adapter.
func New() *Plugin {
return &Plugin{}
}
var _ adapters.Adapter = (*Plugin)(nil)
var _ ports.Agent = (*Plugin)(nil)
// Manifest returns the adapter's static self-description.
func (p *Plugin) Manifest() adapters.Manifest {
return adapters.Manifest{
ID: adapterID,
Name: "Kimi",
Description: "Run Kimi CLI (Moonshot AI) worker sessions.",
Version: "0.0.1",
Capabilities: []adapters.Capability{
adapters.CapabilityAgent,
},
}
}
// GetLaunchCommand builds the argv to start a new Kimi session:
//
// kimi -p <prompt> (non-interactive, default)
// kimi [--yolo|--auto] (interactive, no prompt)
//
// When a prompt is supplied, it is delivered via `-p` (in command), which runs
// a single prompt without opening the TUI. Per Kimi docs, `--prompt` cannot be
// combined with `--yolo`, `--auto`, or `--plan` -- non-interactive mode already
// uses the `auto` permission policy by default, so approval flags would be
// rejected at startup. They are only emitted on the (interactive) path with no
// prompt. Kimi has no documented system-prompt flag, so cfg.SystemPrompt /
// cfg.SystemPromptFile are not injected.
func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) {
binary, err := p.kimiBinary(ctx)
if err != nil {
return nil, err
}
cmd = []string{binary}
if cfg.Prompt != "" {
cmd = append(cmd, "-p", cfg.Prompt)
return cmd, nil
}
appendApprovalFlags(&cmd, cfg.Permissions)
return cmd, nil
}
// GetRestoreCommand rebuilds the argv that continues an existing Kimi session
// when a native Kimi session id is known:
//
// kimi --session <agentSessionId>
//
// ok is false when no native session id has been captured, so callers fall back
// to fresh launch behavior. Per Kimi docs, `--yolo` and `--auto` cannot be
// combined with `--session` (or `--continue`) -- resumed sessions inherit the
// approval settings of the original session -- so cfg.Permissions is
// intentionally ignored here. Kimi has no lifecycle hook for AO to capture the
// native session id from yet, so in practice this returns ok=false today.
func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) {
if err := ctx.Err(); err != nil {
return nil, false, err
}
agentSessionID := strings.TrimSpace(cfg.Session.Metadata[ports.MetadataKeyAgentSessionID])
if agentSessionID == "" {
return nil, false, nil
}
binary, err := p.kimiBinary(ctx)
if err != nil {
return nil, false, err
}
cmd = []string{binary, "--session", agentSessionID}
return cmd, true, nil
}
// appendApprovalFlags maps AO's permission modes onto Kimi's approval flags
// for interactive launches. Per Kimi docs these flags cannot be combined with
// `--prompt`, `--session`, or `--continue`, so callers on those paths must
// skip this mapping.
//
// - Default: no flag, deferring to the user's Kimi config/default behavior.
// - AcceptEdits / Auto: `--auto` (auto permission mode; approvals handled
// automatically).
// - BypassPermissions: `-y` (yolo; auto-approve regular tool calls including
// file writes and shell execution).
func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
switch normalizePermissionMode(permissions) {
case ports.PermissionModeDefault:
// No flag: defer to the user's Kimi config/default behavior.
case ports.PermissionModeAcceptEdits, ports.PermissionModeAuto:
*cmd = append(*cmd, "--auto")
case ports.PermissionModeBypassPermissions:
*cmd = append(*cmd, "-y")
}
}
func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode {
switch mode {
case ports.PermissionModeDefault,
ports.PermissionModeAcceptEdits,
ports.PermissionModeAuto,
ports.PermissionModeBypassPermissions:
return mode
default:
return ports.PermissionModeDefault
}
}
var kimiBinarySpec = binaryutil.BinarySpec{
Label: "kimi",
Names: []string{"kimi"},
WinNames: []string{"kimi.cmd", "kimi.exe", "kimi"},
UnixPaths: []string{"/usr/local/bin/kimi", "/opt/homebrew/bin/kimi"},
UnixHomePaths: [][]string{{".local", "bin", "kimi"}, {".cargo", "bin", "kimi"}},
WinPaths: []binaryutil.WinPath{
{Base: binaryutil.WinAppData, Parts: []string{"npm", "kimi.cmd"}},
{Base: binaryutil.WinAppData, Parts: []string{"npm", "kimi.exe"}},
{Base: binaryutil.WinHome, Parts: []string{".local", "bin", "kimi.exe"}},
},
}
// ResolveKimiBinary finds the `kimi` binary, searching PATH then common install
// locations (the uv tool/curl installer drops it in ~/.local/bin, plus Homebrew
// and ~/.cargo/bin). It returns "kimi" as a last resort so callers get the
// shell's normal command-not-found behavior if Kimi is absent.
func ResolveKimiBinary(ctx context.Context) (string, error) {
return binaryutil.ResolveBinary(ctx, kimiBinarySpec)
}
func (p *Plugin) kimiBinary(ctx context.Context) (string, error) {
p.binaryMu.Lock()
defer p.binaryMu.Unlock()
if p.resolvedBinary != "" {
return p.resolvedBinary, nil
}
binary, err := ResolveKimiBinary(ctx)
if err != nil {
return "", err
}
p.resolvedBinary = binary
return binary, nil
}