mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 18:06:45 +08:00
* feat(agent): add Kimi CLI as agent runtime Adds support for Moonshot AI's Kimi Code CLI (https://github.com/MoonshotAI/kimi-cli) as a new agent runtime, alongside Claude, Codex, OpenCode, OpenClaw, Hermes, Gemini, Pi, Cursor and Copilot. Kimi Code CLI implements the standard Agent Client Protocol (ACP) via the `kimi acp` subcommand, so the new `kimiBackend` reuses the existing hermesClient JSON-RPC transport in the agent package — only the binary, client identity, log prefix, and tool-name extraction differ. Wiring: - server/pkg/agent: new kimiBackend + kimi_test.go; registered in New(), LaunchHeader map, and the supported-types coverage test. - server/internal/daemon/config.go: probes `kimi` (overridable via MULTICA_KIMI_PATH / MULTICA_KIMI_MODEL). - server/internal/daemon/execenv: writes AGENTS.md as the runtime context file (Kimi reads AGENTS.md natively via /init), and writes skills under `.kimi/skills/` so they are auto-discovered by the project-level skill loader. - packages/views/runtimes: ProviderLogo gains a Kimi mark. Co-authored-by: Copilot <[email protected]> * feat(agent/kimi): support per-agent model selection via ACP set_model Wire Kimi into the model dropdown introduced in #1399: - ListModels gets a 'kimi' case that drives the same ACP initialize + session/new handshake as Hermes; both share a new discoverACPModels helper and parseACPSessionNewModels parser so future ACP backends only need a small provider entry. - kimiBackend now issues session/set_model after session/new when opts.Model is non-empty, mirroring the Hermes flow. Failures fail the task instead of silently falling back to Kimi's default model — silent fallback would hide that the dropdown pick wasn't honoured. Verified: go build ./..., go test ./pkg/agent/... ./internal/daemon/... ./internal/handler/..., pnpm typecheck and pnpm test (138 passed). Co-authored-by: Copilot <[email protected]> * refactor(agent): address code review feedback on Kimi runtime - Share ACP provider-error sniffer between hermes and kimi. Previously only hermes promoted stderr-observed 4xx/5xx into a failed task; kimi would report "completed + empty output" when the Moonshot upstream rejected a request (expired token, rate limit, …). Rename hermesProviderErrorSniffer → acpProviderErrorSniffer and parameterise the provider name; wire it into kimiBackend.Execute the same way. - Rename extractHermesSessionID → extractACPSessionID (shared by all ACP backends) so the name matches parseACPSessionNewModels. - Drop the redundant second argument to kimiToolNameFromTitle; the Message struct has only one relevant field (Tool), so passing it twice was a dead fallback. Document that the function normalises residual capitalised kimi titles not caught by hermesToolNameFromTitle. - Remove kimi-only cmd.WaitDelay override; the hermes baseline is fine for both and divergence adds noise. - Add TestKimiBackendSetModelFailureFailsTask: fake `kimi acp` binary that returns a JSON-RPC error for session/set_model, asserts that the task result surfaces status=failed with the model name + upstream message and preserves the session id. - Fix stale agent listings in agent.go / daemon/config.go doc comments (missing cursor, gemini, copilot). All: `go build ./...`, `go vet ./...`, `go test ./pkg/agent/... ./internal/daemon/... ./internal/handler/...` green. * fix(agent/kimi): pass --yolo so Shell tools don't hang on approval Kimi's default config has `default_yolo = false`. Every Shell/file-mutating tool call causes kimi acp to send a `session/request_permission` request and block (up to 300s) waiting for a response. The daemon's hermesClient only handles `session/update` notifications — permission requests go unanswered, the tool call times out, and the UI loop eventually dies ("UI loop timed out"). Observed with the first real kimi task: agent sat as Live for ~7 minutes before the daemon killed it. The fix mirrors hermes' HERMES_YOLO_MODE=1 override: pass `--yolo` to `kimi` so it auto-approves everything. `--yolo` is a top-level flag on the `kimi` CLI (not a flag on `kimi acp`), so it must come before the `acp` subcommand in argv. Added to kimiBlockedArgs so user custom_args can't strip it. While here, fix a related bug that made kimi tool names show up empty in the daemon log ("tool #1: "): hermesToolNameFromTitle's fallback returned `kind` when neither title-with-colon nor kind matched a known tool. Kimi's ACP `tool_call` emits bare titles like "Shell" or "Read file" with no `kind` at all, so we'd drop the title on the floor before kimiToolNameFromTitle ever got a chance to map it. Now: preserve the title when kind is unclassified; hermes titles always carry a colon so this branch never fires for hermes. Tests: - TestKimiBackendPassesYoloFlag — fake binary that records its argv, asserts --yolo comes before acp. - TestHermesToolNameFromTitle rows for bare kimi-style titles. - Existing suite green: go build, go vet, full pkg/agent + daemon + handler test packages. * fix(agent/acp): auto-approve session/request_permission from agent The previous attempt (`kimi --yolo acp`) was a no-op. Inspected the kimi-cli source: the `acp` Typer subcommand takes no parameters, so flags on the root `kimi` command are dropped before `acp_main()` runs — it's impossible to opt into YOLO mode through CLI flags for ACP. The real fix is on our side: respond to session/request_permission. ACP is bidirectional. When kimi runs a Shell or file-write tool, it sends `session/request_permission` (agent → client, JSON-RPC request with id + method) and waits up to 300s for a response. Our existing hermesClient.handleLine only dispatched: (id + result/error) → handleResponse, and (no id + method) → handleNotification. A request with BOTH id and method fell through and got silently dropped — kimi timed out, UI loop died, task sat stuck for 7 minutes. Add handleAgentRequest: for session/request_permission, echo the id and respond with outcome=selected, optionId=approve_for_session. The daemon is headless; there's no user to prompt. `approve_for_session` lets the agent remember the action so subsequent identical calls (every Shell, every file write) skip the round-trip entirely. For any other agent → client method, reply with standard -32601 method-not- found so the agent doesn't block. Also: - Add writeMu so request() (main goroutine) and handleAgentRequest (reader goroutine) don't interleave JSON frames on stdin. - Revert the `--yolo acp` flag — it's a no-op, and carrying it in kimiBlockedArgs gives the wrong impression that it does something. Comment in kimi.go now points at handleAgentRequest as the real fix. Tests: - TestHermesClientAutoApprovesPermissionRequest: inject a session/request_permission, assert the reply echoes the id and carries {outcome: selected, optionId: approve_for_session}. - TestHermesClientReplesMethodNotFoundForUnknownAgentRequest: confirm unknown agent → client methods get JSON-RPC -32601 instead of silence. - TestKimiBackendInvokesACPSubcommand replaces the yolo-flag assertion with a negative assertion: no dead --yolo / --auto-approve / -y on argv, since they'd pretend to do something they can't. All: go build ./..., go vet ./..., go test ./pkg/agent/... green. * fix(agent/acp): surface kimi tool input/output via content blocks Kimi-cli emits tool_call and tool_call_update ACP frames with the input/output inside a `content` array of ContentToolCallContent blocks (shape: {type:"content", content:{type:"text", text:"..."}}), not in the hermes-style `rawInput` map / `rawOutput` string. Our parser only looked at rawInput/rawOutput, so the daemon recorded empty Input and Output for every kimi tool — the execution-history UI showed blank terminal panels even for commands that ran fine. Add extractACPToolCallText() and a fallback in handleToolCallStart / handleToolCallUpdate: when rawInput is nil / rawOutput is empty, pull the text out of the content blocks. rawInput / rawOutput still take precedence so hermes' behaviour is untouched. Terminal / FileEditToolCallContent blocks are skipped (we have nothing to render them as — kimi only emits TerminalToolCallContent when the client advertises terminal capability, which we don't). Tests: - TestHermesClientHandleToolCallStartKimiContent — content array → Input.text populated. - TestHermesClientHandleToolCallCompleteKimiContent — multi-block content → Output concatenated with newline separator. - TestHermesClientHandleToolCallRawOutputTakesPrecedence — hermes rawOutput still wins when both are present. - TestExtractACPToolCallText — unit coverage for the helper (single/multiple text blocks, terminal-block skip, empty input). * fix(agent/acp): buffer streaming tool args so Input isn't empty in UI kimi-cli streams tool args token-by-token via tool_call_update frames — the initial tool_call carries an empty content block and each subsequent in_progress update carries the cumulative JSON so far (`{`, `{"comma`, `{"command": "echo`, …). The final completed update then carries the tool's stdout, not the args. Observed per kimi-cli acp/session.py::_send_tool_call{,_part,_result} and confirmed by driving a real Shell call end-to-end: 10 in_progress frames, last with `{"command": "echo hello world"}`, then completed with `hello world\n`. Our previous handleToolCallStart emitted MessageToolUse on the first tool_call frame, capturing the empty content — so every kimi tool appeared in the execution-history UI with a blank input. Output was correct (fix 4335c198) but command was missing. Changes: - hermesClient now tracks pending tool calls per toolCallId. Hermes path is unchanged — rawInput is present at tool_call time, so emit-immediately-then-flag-emitted still fires on the initial frame. - kimi path defers MessageToolUse until status=completed / failed. tool_call_update in_progress frames update the buffered argsText (cumulative, so overwrite); on completion we parse the accumulated JSON into Message.Input. Malformed JSON falls back to `{"text": …}` so non-JSON tool args still render. - Orphan completion frames (no matching tool_call seen — e.g. daemon restarted mid-task) synthesise ToolUse from the update's own title/kind/rawInput so the UI still gets a header. - extractACPToolCallText now also renders FileEditToolCallContent blocks as a compact header ("--- path / +++ path / (edited: N → M bytes)"). kimi emits these for Write / StrReplaceFile / Patch when the tool's display block is a DiffDisplayBlock. Tests: - TestHermesClientKimiStreamingToolCall: empty tool_call + 5 streaming in_progress + completed. Asserts no emission until complete, then [ToolUse(Input.command="echo hi"), ToolResult(Output="hi\n")]. - TestHermesClientKimiMalformedArgsFallback: non-JSON argsText → falls back to Input.text. - TestHermesClientHandleToolCallCompleteOrphan: completed frame without a start → ToolUse synthesised from update's rawInput. - TestExtractACPToolCallText: diff + new-file-diff cases. All agent / daemon / handler test packages green. --------- Co-authored-by: Eve <[email protected]> Co-authored-by: Copilot <[email protected]> Co-authored-by: Eve <[email protected]> Co-authored-by: Lambda <[email protected]>
161 lines
5.5 KiB
Go
161 lines
5.5 KiB
Go
package execenv
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// writeContextFiles renders and writes .agent_context/issue_context.md and
|
|
// skills into the appropriate provider-native location.
|
|
//
|
|
// Claude: skills → {workDir}/.claude/skills/{name}/SKILL.md (native discovery)
|
|
// Codex: skills → handled separately in Prepare via codex-home
|
|
// Copilot: skills → {workDir}/.github/skills/{name}/SKILL.md (native project-level discovery)
|
|
// OpenCode: skills → {workDir}/.config/opencode/skills/{name}/SKILL.md (native discovery)
|
|
// Pi: skills → {workDir}/.pi/agent/skills/{name}/SKILL.md (native discovery)
|
|
// Cursor: skills → {workDir}/.cursor/skills/{name}/SKILL.md (native discovery)
|
|
// Kimi: skills → {workDir}/.kimi/skills/{name}/SKILL.md (native discovery)
|
|
// Default: skills → {workDir}/.agent_context/skills/{name}/SKILL.md
|
|
func writeContextFiles(workDir, provider string, ctx TaskContextForEnv) error {
|
|
contextDir := filepath.Join(workDir, ".agent_context")
|
|
if err := os.MkdirAll(contextDir, 0o755); err != nil {
|
|
return fmt.Errorf("create .agent_context dir: %w", err)
|
|
}
|
|
|
|
content := renderIssueContext(provider, ctx)
|
|
path := filepath.Join(contextDir, "issue_context.md")
|
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|
return fmt.Errorf("write issue_context.md: %w", err)
|
|
}
|
|
|
|
if len(ctx.AgentSkills) > 0 {
|
|
skillsDir, err := resolveSkillsDir(workDir, provider)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve skills dir: %w", err)
|
|
}
|
|
// Codex skills are written to codex-home in Prepare; skip here.
|
|
if provider != "codex" {
|
|
if err := writeSkillFiles(skillsDir, ctx.AgentSkills); err != nil {
|
|
return fmt.Errorf("write skill files: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// resolveSkillsDir returns the directory where skills should be written
|
|
// based on the agent provider.
|
|
func resolveSkillsDir(workDir, provider string) (string, error) {
|
|
var skillsDir string
|
|
switch provider {
|
|
case "claude":
|
|
// Claude Code natively discovers skills from .claude/skills/ in the workdir.
|
|
skillsDir = filepath.Join(workDir, ".claude", "skills")
|
|
case "copilot":
|
|
// GitHub Copilot CLI natively discovers project-level skills from
|
|
// .github/skills/<name>/SKILL.md (takes precedence over user-level
|
|
// skills in ~/.copilot/skills/).
|
|
// See: https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-config-dir-reference
|
|
skillsDir = filepath.Join(workDir, ".github", "skills")
|
|
case "opencode":
|
|
// OpenCode natively discovers skills from .config/opencode/skills/ in the workdir.
|
|
skillsDir = filepath.Join(workDir, ".config", "opencode", "skills")
|
|
case "pi":
|
|
// Pi natively discovers skills from .pi/agent/skills/ in the workdir.
|
|
skillsDir = filepath.Join(workDir, ".pi", "agent", "skills")
|
|
case "cursor":
|
|
// Cursor natively discovers skills from .cursor/skills/ in the workdir.
|
|
skillsDir = filepath.Join(workDir, ".cursor", "skills")
|
|
case "kimi":
|
|
// Kimi Code CLI auto-discovers project-level skills from .kimi/skills/
|
|
// in the workdir. See https://moonshotai.github.io/kimi-cli/en/customization/skills.html
|
|
skillsDir = filepath.Join(workDir, ".kimi", "skills")
|
|
default:
|
|
// Fallback: write to .agent_context/skills/ (referenced by meta config).
|
|
skillsDir = filepath.Join(workDir, ".agent_context", "skills")
|
|
}
|
|
if err := os.MkdirAll(skillsDir, 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
return skillsDir, nil
|
|
}
|
|
|
|
var nonAlphaNum = regexp.MustCompile(`[^a-z0-9]+`)
|
|
|
|
// sanitizeSkillName converts a skill name to a safe directory name.
|
|
func sanitizeSkillName(name string) string {
|
|
s := strings.ToLower(strings.TrimSpace(name))
|
|
s = nonAlphaNum.ReplaceAllString(s, "-")
|
|
s = strings.Trim(s, "-")
|
|
if s == "" {
|
|
s = "skill"
|
|
}
|
|
return s
|
|
}
|
|
|
|
// writeSkillFiles writes skill directories into the given parent directory.
|
|
// Each skill gets its own subdirectory containing SKILL.md and supporting files.
|
|
func writeSkillFiles(skillsDir string, skills []SkillContextForEnv) error {
|
|
if err := os.MkdirAll(skillsDir, 0o755); err != nil {
|
|
return fmt.Errorf("create skills dir: %w", err)
|
|
}
|
|
|
|
for _, skill := range skills {
|
|
dir := filepath.Join(skillsDir, sanitizeSkillName(skill.Name))
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Write main SKILL.md
|
|
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(skill.Content), 0o644); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Write supporting files
|
|
for _, f := range skill.Files {
|
|
fpath := filepath.Join(dir, f.Path)
|
|
if err := os.MkdirAll(filepath.Dir(fpath), 0o755); err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(fpath, []byte(f.Content), 0o644); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// renderIssueContext builds the markdown content for issue_context.md.
|
|
func renderIssueContext(provider string, ctx TaskContextForEnv) string {
|
|
var b strings.Builder
|
|
|
|
b.WriteString("# Task Assignment\n\n")
|
|
fmt.Fprintf(&b, "**Issue ID:** %s\n\n", ctx.IssueID)
|
|
|
|
if ctx.TriggerCommentID != "" {
|
|
b.WriteString("**Trigger:** Comment Reply\n")
|
|
b.WriteString("**Triggering comment ID:** `" + ctx.TriggerCommentID + "`\n\n")
|
|
} else {
|
|
b.WriteString("**Trigger:** New Assignment\n\n")
|
|
}
|
|
|
|
b.WriteString("## Quick Start\n\n")
|
|
fmt.Fprintf(&b, "Run `multica issue get %s --output json` to fetch the full issue details.\n\n", ctx.IssueID)
|
|
|
|
if len(ctx.AgentSkills) > 0 {
|
|
b.WriteString("## Agent Skills\n\n")
|
|
b.WriteString("The following skills are available to you:\n\n")
|
|
for _, skill := range ctx.AgentSkills {
|
|
fmt.Fprintf(&b, "- **%s**\n", skill.Name)
|
|
}
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
return b.String()
|
|
}
|