mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-13 13:18:56 +02: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 <223556219+Copilot@users.noreply.github.com>
* 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 <223556219+Copilot@users.noreply.github.com>
* 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 <8b0578a3-cf72-4394-9e38-b328eca92463@users.noreply.multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: Lambda <f252c2c5-7d1d-4f3c-b394-a61abfe673fc@users.noreply.multica.ai>
792 lines
25 KiB
Go
792 lines
25 KiB
Go
package agent
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Model describes a single LLM model exposed by an agent provider.
|
|
// The dropdown groups by Provider when the ID uses the
|
|
// `provider/model` form (e.g. "openai/gpt-4o" from opencode).
|
|
// Default is a *display* hint: the UI badges the entry the
|
|
// runtime advertises as its preferred pick (e.g. Claude Code's
|
|
// shipped default, or hermes' currentModelId). It has no effect
|
|
// at execution time — when agent.model is empty the daemon passes
|
|
// "" to the backend so each provider's own CLI resolves its own
|
|
// default, which is always closer to what the user's account /
|
|
// environment actually supports than a static guess here.
|
|
type Model struct {
|
|
ID string `json:"id"`
|
|
Label string `json:"label"`
|
|
Provider string `json:"provider,omitempty"`
|
|
Default bool `json:"default,omitempty"`
|
|
}
|
|
|
|
// modelCache memoizes dynamic discovery calls so repeated UI loads
|
|
// don't re-shell the agent CLI. Entries expire after cacheTTL.
|
|
type modelCacheEntry struct {
|
|
models []Model
|
|
expiresAt time.Time
|
|
}
|
|
|
|
var (
|
|
modelCacheMu sync.Mutex
|
|
modelCache = map[string]modelCacheEntry{}
|
|
)
|
|
|
|
const modelCacheTTL = 60 * time.Second
|
|
|
|
// ListModels returns the models supported by the given agent provider.
|
|
// For providers with a known static catalog it returns the baked-in
|
|
// list; for providers with a CLI discovery mechanism (opencode, pi,
|
|
// openclaw) it shells out with caching and falls back to the static
|
|
// list on failure.
|
|
//
|
|
// executablePath lets the caller point at a non-default binary; pass
|
|
// "" to use the provider's default name on PATH.
|
|
func ListModels(ctx context.Context, providerType, executablePath string) ([]Model, error) {
|
|
switch providerType {
|
|
case "claude":
|
|
return claudeStaticModels(), nil
|
|
case "codex":
|
|
return codexStaticModels(), nil
|
|
case "gemini":
|
|
return geminiStaticModels(), nil
|
|
case "cursor":
|
|
return cachedDiscovery(providerType, func() ([]Model, error) {
|
|
return discoverCursorModels(ctx, executablePath)
|
|
})
|
|
case "copilot":
|
|
return copilotStaticModels(), nil
|
|
case "hermes":
|
|
return cachedDiscovery(providerType, func() ([]Model, error) {
|
|
return discoverHermesModels(ctx, executablePath)
|
|
})
|
|
case "kimi":
|
|
return cachedDiscovery(providerType, func() ([]Model, error) {
|
|
return discoverKimiModels(ctx, executablePath)
|
|
})
|
|
case "opencode":
|
|
return cachedDiscovery(providerType, func() ([]Model, error) {
|
|
return discoverOpenCodeModels(ctx, executablePath)
|
|
})
|
|
case "pi":
|
|
return cachedDiscovery(providerType, func() ([]Model, error) {
|
|
return discoverPiModels(ctx, executablePath)
|
|
})
|
|
case "openclaw":
|
|
return cachedDiscovery(providerType, func() ([]Model, error) {
|
|
return discoverOpenclawAgents(ctx, executablePath)
|
|
})
|
|
default:
|
|
return nil, fmt.Errorf("unknown agent type: %q", providerType)
|
|
}
|
|
}
|
|
|
|
// ModelSelectionSupported reports whether setting `agent.model` has
|
|
// any effect for the given provider. Today every provider in the
|
|
// registry honours `opts.Model` end-to-end: Hermes routes it through
|
|
// the ACP `session/set_model` RPC before each prompt, which means
|
|
// the UI's dropdown choice is carried all the way down to the LLM
|
|
// call. The helper is retained so we can add a `return false` branch
|
|
// the next time a provider legitimately ignores model selection.
|
|
func ModelSelectionSupported(providerType string) bool {
|
|
_ = providerType
|
|
return true
|
|
}
|
|
|
|
// cachedDiscovery invokes fn and caches the result for modelCacheTTL.
|
|
// The cache is keyed on providerType only; callers that need to
|
|
// distinguish discovery by host/user should include that in the key
|
|
// if we ever introduce such a mode.
|
|
func cachedDiscovery(key string, fn func() ([]Model, error)) ([]Model, error) {
|
|
modelCacheMu.Lock()
|
|
if entry, ok := modelCache[key]; ok && time.Now().Before(entry.expiresAt) {
|
|
out := entry.models
|
|
modelCacheMu.Unlock()
|
|
return out, nil
|
|
}
|
|
modelCacheMu.Unlock()
|
|
|
|
models, err := fn()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
modelCacheMu.Lock()
|
|
modelCache[key] = modelCacheEntry{models: models, expiresAt: time.Now().Add(modelCacheTTL)}
|
|
modelCacheMu.Unlock()
|
|
return models, nil
|
|
}
|
|
|
|
// ── Static catalogs ──
|
|
|
|
// claudeStaticModels reflects the Claude Code CLI's accepted --model
|
|
// values. Keep this list short and current; stale entries here
|
|
// mislead users more than they help. Default = Sonnet because it's
|
|
// the everyday workhorse (Opus is reserved for advisor-style flows).
|
|
func claudeStaticModels() []Model {
|
|
return []Model{
|
|
{ID: "claude-sonnet-4-6", Label: "Claude Sonnet 4.6", Provider: "anthropic", Default: true},
|
|
{ID: "claude-opus-4-7", Label: "Claude Opus 4.7", Provider: "anthropic"},
|
|
{ID: "claude-haiku-4-5-20251001", Label: "Claude Haiku 4.5", Provider: "anthropic"},
|
|
{ID: "claude-opus-4-6", Label: "Claude Opus 4.6", Provider: "anthropic"},
|
|
{ID: "claude-sonnet-4-5", Label: "Claude Sonnet 4.5", Provider: "anthropic"},
|
|
}
|
|
}
|
|
|
|
func codexStaticModels() []Model {
|
|
return []Model{
|
|
{ID: "gpt-5.4", Label: "GPT-5.4", Provider: "openai", Default: true},
|
|
{ID: "gpt-5.4-mini", Label: "GPT-5.4 mini", Provider: "openai"},
|
|
{ID: "gpt-5.3-codex", Label: "GPT-5.3 Codex", Provider: "openai"},
|
|
{ID: "gpt-5", Label: "GPT-5", Provider: "openai"},
|
|
{ID: "o3", Label: "o3", Provider: "openai"},
|
|
{ID: "o3-mini", Label: "o3-mini", Provider: "openai"},
|
|
}
|
|
}
|
|
|
|
func geminiStaticModels() []Model {
|
|
return []Model{
|
|
{ID: "gemini-2.5-pro", Label: "Gemini 2.5 Pro", Provider: "google", Default: true},
|
|
{ID: "gemini-2.5-flash", Label: "Gemini 2.5 Flash", Provider: "google"},
|
|
{ID: "gemini-2.0-flash", Label: "Gemini 2.0 Flash", Provider: "google"},
|
|
}
|
|
}
|
|
|
|
// cursorStaticModels is a minimal fallback used when
|
|
// `cursor-agent --list-models` isn't available (binary missing,
|
|
// offline, etc). The real catalog is fetched dynamically because
|
|
// Cursor's model IDs shift (e.g. `composer-2-fast`,
|
|
// `claude-4.6-sonnet-medium`, `gemini-3.1-pro`) and any static
|
|
// list we ship goes stale fast.
|
|
func cursorStaticModels() []Model {
|
|
return []Model{
|
|
{ID: "auto", Label: "Auto", Provider: "cursor", Default: true},
|
|
}
|
|
}
|
|
|
|
// copilotStaticModels — GitHub Copilot CLI resolves models via the
|
|
// user's GitHub account, not via CLI args. We deliberately mark no
|
|
// Default: the right model is whatever GitHub routes the request
|
|
// to, and forcing one here would override that.
|
|
func copilotStaticModels() []Model {
|
|
return []Model{
|
|
{ID: "gpt-5.4", Label: "GPT-5.4", Provider: "openai"},
|
|
{ID: "claude-sonnet-4-6", Label: "Claude Sonnet 4.6", Provider: "anthropic"},
|
|
}
|
|
}
|
|
|
|
// ── Dynamic discovery ──
|
|
|
|
// discoverOpenCodeModels runs `opencode models` and parses its tabular
|
|
// output. The CLI prints `provider/model` rows; we emit them verbatim
|
|
// as IDs so what the user sees matches what `--model` accepts.
|
|
// On any failure (CLI missing, parse error, timeout) we fall back to
|
|
// an empty list so the creatable UI still works.
|
|
func discoverOpenCodeModels(ctx context.Context, executablePath string) ([]Model, error) {
|
|
if executablePath == "" {
|
|
executablePath = "opencode"
|
|
}
|
|
if _, err := exec.LookPath(executablePath); err != nil {
|
|
return []Model{}, nil
|
|
}
|
|
runCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(runCtx, executablePath, "models")
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return []Model{}, nil
|
|
}
|
|
return parseOpenCodeModels(string(out)), nil
|
|
}
|
|
|
|
// parseOpenCodeModels accepts the `opencode models` text output and
|
|
// extracts IDs. Output format (v0.x): a header row followed by rows
|
|
// whose first whitespace-delimited field is `provider/model`.
|
|
func parseOpenCodeModels(output string) []Model {
|
|
scanner := bufio.NewScanner(strings.NewReader(output))
|
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
|
var models []Model
|
|
seen := map[string]bool{}
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" {
|
|
continue
|
|
}
|
|
first := strings.Fields(line)
|
|
if len(first) == 0 {
|
|
continue
|
|
}
|
|
id := first[0]
|
|
if !strings.Contains(id, "/") {
|
|
continue
|
|
}
|
|
// Skip the header row (opencode prints e.g. PROVIDER/MODEL in caps).
|
|
if id == strings.ToUpper(id) {
|
|
continue
|
|
}
|
|
if seen[id] {
|
|
continue
|
|
}
|
|
seen[id] = true
|
|
provider := ""
|
|
if i := strings.Index(id, "/"); i > 0 {
|
|
provider = id[:i]
|
|
}
|
|
models = append(models, Model{ID: id, Label: id, Provider: provider})
|
|
}
|
|
return models
|
|
}
|
|
|
|
// discoverPiModels runs `pi --list-models` and parses its output.
|
|
// Older pi versions print the list to stderr; newer versions use
|
|
// stdout. We capture both and parse whichever is non-empty.
|
|
func discoverPiModels(ctx context.Context, executablePath string) ([]Model, error) {
|
|
if executablePath == "" {
|
|
executablePath = "pi"
|
|
}
|
|
if _, err := exec.LookPath(executablePath); err != nil {
|
|
return []Model{}, nil
|
|
}
|
|
runCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(runCtx, executablePath, "--list-models")
|
|
var stderr strings.Builder
|
|
cmd.Stderr = &stderr
|
|
stdout, err := cmd.Output()
|
|
if err != nil {
|
|
return []Model{}, nil
|
|
}
|
|
text := string(stdout)
|
|
if strings.TrimSpace(text) == "" {
|
|
text = stderr.String()
|
|
}
|
|
return parsePiModels(text), nil
|
|
}
|
|
|
|
// parsePiModels accepts the `pi --list-models` output and extracts
|
|
// model IDs. Pi's format uses `provider:model` rows; we normalize to
|
|
// the same `provider/model` form as opencode for UI consistency.
|
|
func parsePiModels(output string) []Model {
|
|
scanner := bufio.NewScanner(strings.NewReader(output))
|
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
|
var models []Model
|
|
seen := map[string]bool{}
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" {
|
|
continue
|
|
}
|
|
first := strings.Fields(line)
|
|
if len(first) == 0 {
|
|
continue
|
|
}
|
|
id := first[0]
|
|
if !strings.ContainsAny(id, ":/") {
|
|
continue
|
|
}
|
|
// Normalize ":" to "/" since pi uses colon but opencode/UI uses slash.
|
|
id = strings.Replace(id, ":", "/", 1)
|
|
if seen[id] {
|
|
continue
|
|
}
|
|
seen[id] = true
|
|
provider := ""
|
|
if i := strings.Index(id, "/"); i > 0 {
|
|
provider = id[:i]
|
|
}
|
|
models = append(models, Model{ID: id, Label: id, Provider: provider})
|
|
}
|
|
return models
|
|
}
|
|
|
|
// discoverHermesModels spins up a throwaway `hermes acp` process,
|
|
// drives just enough of the protocol to receive the model list
|
|
// advertised in the `session/new` response, and shuts it down. The
|
|
// list and the `current` flag both come from hermes' own
|
|
// `_build_model_state` so whatever ~/.hermes/config.yaml resolves
|
|
// to at runtime is exactly what the UI shows.
|
|
//
|
|
// Failure modes (hermes missing, no credentials, config resolution
|
|
// error) all return an empty list so the UI falls back to the
|
|
// creatable manual-entry input instead of blocking the form.
|
|
func discoverHermesModels(ctx context.Context, executablePath string) ([]Model, error) {
|
|
return discoverACPModels(ctx, executablePath, acpDiscoveryProvider{
|
|
defaultBin: "hermes",
|
|
clientName: "multica-model-discovery",
|
|
extraEnv: []string{"HERMES_YOLO_MODE=1"},
|
|
tmpdirPrefix: "multica-hermes-discovery-",
|
|
})
|
|
}
|
|
|
|
// discoverKimiModels spins up a throwaway `kimi acp` process and
|
|
// drives the same minimal ACP handshake as Hermes to surface the
|
|
// model catalog advertised by Kimi's `session/new` response. Kimi's
|
|
// ACPServer.new_session returns a `models` block of the same shape
|
|
// (`availableModels`/`currentModelId`) so the parsing path is shared.
|
|
//
|
|
// Failure modes (kimi missing, not logged in, config error) all
|
|
// return an empty list so the UI falls back to manual entry.
|
|
func discoverKimiModels(ctx context.Context, executablePath string) ([]Model, error) {
|
|
return discoverACPModels(ctx, executablePath, acpDiscoveryProvider{
|
|
defaultBin: "kimi",
|
|
clientName: "multica-model-discovery",
|
|
tmpdirPrefix: "multica-kimi-discovery-",
|
|
})
|
|
}
|
|
|
|
// acpDiscoveryProvider configures how discoverACPModels launches an
|
|
// ACP-speaking agent CLI. The shared helper drives every CLI in
|
|
// the same way (initialize → session/new → parse models block) — the
|
|
// per-provider differences are which binary to spawn, which env
|
|
// vars suppress interactive prompts during init, and what to label
|
|
// temporary work directories so they're easy to identify in logs.
|
|
type acpDiscoveryProvider struct {
|
|
defaultBin string
|
|
clientName string
|
|
extraEnv []string
|
|
tmpdirPrefix string
|
|
}
|
|
|
|
// discoverACPModels runs the ACP handshake for any agent CLI that
|
|
// implements the standard `initialize` + `session/new` flow and
|
|
// advertises its model catalog in the response under
|
|
// `models.availableModels` / `models.currentModelId`. This covers
|
|
// Hermes and Kimi today; future ACP backends can plug in by adding
|
|
// an acpDiscoveryProvider entry instead of duplicating the loop.
|
|
func discoverACPModels(ctx context.Context, executablePath string, p acpDiscoveryProvider) ([]Model, error) {
|
|
if executablePath == "" {
|
|
executablePath = p.defaultBin
|
|
}
|
|
if _, err := exec.LookPath(executablePath); err != nil {
|
|
return []Model{}, nil
|
|
}
|
|
runCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
|
defer cancel()
|
|
|
|
cmd := exec.CommandContext(runCtx, executablePath, "acp")
|
|
if len(p.extraEnv) > 0 {
|
|
cmd.Env = append(os.Environ(), p.extraEnv...)
|
|
}
|
|
stdin, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
return []Model{}, nil
|
|
}
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
stdin.Close()
|
|
return []Model{}, nil
|
|
}
|
|
// Discard stderr; noisy logs here don't help us and we don't
|
|
// want them bleeding into the daemon log every 60s.
|
|
cmd.Stderr = io.Discard
|
|
if err := cmd.Start(); err != nil {
|
|
return []Model{}, nil
|
|
}
|
|
// Ensure the child process is always reaped.
|
|
defer func() {
|
|
_ = stdin.Close()
|
|
_ = cmd.Process.Kill()
|
|
_, _ = cmd.Process.Wait()
|
|
}()
|
|
|
|
writeACP := func(id int, method string, params map[string]any) error {
|
|
msg := map[string]any{
|
|
"jsonrpc": "2.0",
|
|
"id": id,
|
|
"method": method,
|
|
"params": params,
|
|
}
|
|
data, err := json.Marshal(msg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data = append(data, '\n')
|
|
_, err = stdin.Write(data)
|
|
return err
|
|
}
|
|
|
|
// Send initialize + session/new.
|
|
if err := writeACP(1, "initialize", map[string]any{
|
|
"protocolVersion": 1,
|
|
"clientInfo": map[string]any{"name": p.clientName, "version": "0.1.0"},
|
|
"clientCapabilities": map[string]any{},
|
|
}); err != nil {
|
|
return []Model{}, nil
|
|
}
|
|
|
|
// session/new requires a valid cwd — use a temp directory we
|
|
// clean up afterwards, not the daemon's workdir (which might
|
|
// be in the middle of another task's worktree).
|
|
tmp, err := os.MkdirTemp("", p.tmpdirPrefix)
|
|
if err != nil {
|
|
return []Model{}, nil
|
|
}
|
|
defer os.RemoveAll(tmp)
|
|
|
|
if err := writeACP(2, "session/new", map[string]any{
|
|
"cwd": tmp,
|
|
"mcpServers": []any{},
|
|
}); err != nil {
|
|
return []Model{}, nil
|
|
}
|
|
|
|
// Read responses until we see the one for id=2 (session/new).
|
|
scanner := bufio.NewScanner(stdout)
|
|
scanner.Buffer(make([]byte, 0, 1024*1024), 4*1024*1024)
|
|
deadline := time.After(12 * time.Second)
|
|
done := make(chan []Model, 1)
|
|
go func() {
|
|
defer close(done)
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" {
|
|
continue
|
|
}
|
|
var env struct {
|
|
ID json.Number `json:"id"`
|
|
Result json.RawMessage `json:"result"`
|
|
}
|
|
if err := json.Unmarshal([]byte(line), &env); err != nil {
|
|
continue
|
|
}
|
|
if env.ID.String() != "2" || len(env.Result) == 0 {
|
|
continue
|
|
}
|
|
done <- parseACPSessionNewModels(env.Result)
|
|
return
|
|
}
|
|
}()
|
|
|
|
select {
|
|
case models := <-done:
|
|
if models == nil {
|
|
return []Model{}, nil
|
|
}
|
|
return models, nil
|
|
case <-deadline:
|
|
return []Model{}, nil
|
|
case <-runCtx.Done():
|
|
return []Model{}, nil
|
|
}
|
|
}
|
|
|
|
// parseACPSessionNewModels extracts the model catalog from an ACP
|
|
// `session/new` response. Both Hermes and Kimi (and any other ACP
|
|
// agent that follows the standard schema) emit:
|
|
//
|
|
// {
|
|
// "sessionId": "...",
|
|
// "models": {
|
|
// "availableModels": [
|
|
// {"modelId": "...", "name": "...", "description": "..."}
|
|
// ],
|
|
// "currentModelId": "..."
|
|
// }
|
|
// }
|
|
//
|
|
// Returns nil (not an empty slice) when the payload is missing so
|
|
// the caller can distinguish "parsed with no models" (valid but
|
|
// empty catalog) from "couldn't find the structure at all".
|
|
func parseACPSessionNewModels(raw json.RawMessage) []Model {
|
|
var resp struct {
|
|
Models struct {
|
|
AvailableModels []struct {
|
|
ModelID string `json:"modelId"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
} `json:"availableModels"`
|
|
CurrentModelID string `json:"currentModelId"`
|
|
} `json:"models"`
|
|
}
|
|
if err := json.Unmarshal(raw, &resp); err != nil {
|
|
return nil
|
|
}
|
|
models := make([]Model, 0, len(resp.Models.AvailableModels))
|
|
seen := map[string]bool{}
|
|
for _, m := range resp.Models.AvailableModels {
|
|
if m.ModelID == "" || seen[m.ModelID] {
|
|
continue
|
|
}
|
|
seen[m.ModelID] = true
|
|
label := m.Name
|
|
if label == "" {
|
|
label = m.ModelID
|
|
}
|
|
provider := ""
|
|
if idx := strings.Index(m.ModelID, ":"); idx > 0 {
|
|
provider = m.ModelID[:idx]
|
|
}
|
|
models = append(models, Model{
|
|
ID: m.ModelID,
|
|
Label: label,
|
|
Provider: provider,
|
|
Default: m.ModelID == resp.Models.CurrentModelID,
|
|
})
|
|
}
|
|
return models
|
|
}
|
|
|
|
// discoverCursorModels runs `cursor-agent --list-models` and parses
|
|
// the `id - Label` rows. Cursor's catalog changes often and ships
|
|
// many variants of the same base model (thinking / fast / max
|
|
// suffixes) — static baking would be obsolete within weeks. On any
|
|
// failure we fall back to the minimal static catalog so the UI
|
|
// stays usable when cursor-agent isn't installed on the daemon host.
|
|
func discoverCursorModels(ctx context.Context, executablePath string) ([]Model, error) {
|
|
if executablePath == "" {
|
|
executablePath = "cursor-agent"
|
|
}
|
|
if _, err := exec.LookPath(executablePath); err != nil {
|
|
return cursorStaticModels(), nil
|
|
}
|
|
runCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(runCtx, executablePath, "--list-models")
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return cursorStaticModels(), nil
|
|
}
|
|
models := parseCursorModels(string(out))
|
|
if len(models) == 0 {
|
|
return cursorStaticModels(), nil
|
|
}
|
|
return models, nil
|
|
}
|
|
|
|
// parseCursorModels extracts model IDs from `cursor-agent --list-models`.
|
|
// Output format (as of cursor-agent 2026.04):
|
|
//
|
|
// Available models
|
|
// <blank>
|
|
// auto - Auto
|
|
// composer-2-fast - Composer 2 Fast (current, default)
|
|
// composer-2 - Composer 2
|
|
// …
|
|
//
|
|
// The model tagged `(default)` is surfaced as Default=true so the
|
|
// UI badge points at cursor's own recommendation rather than a
|
|
// hard-coded guess from our catalog.
|
|
func parseCursorModels(output string) []Model {
|
|
scanner := bufio.NewScanner(strings.NewReader(output))
|
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
|
var models []Model
|
|
seen := map[string]bool{}
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" {
|
|
continue
|
|
}
|
|
// Row format: "<id> - <label>". Skip the "Available models" header.
|
|
idx := strings.Index(line, " - ")
|
|
if idx <= 0 {
|
|
continue
|
|
}
|
|
id := strings.TrimSpace(line[:idx])
|
|
label := strings.TrimSpace(line[idx+3:])
|
|
if !isOpenclawIdentifier(id) {
|
|
// Reuse the identifier guard — cursor IDs are in the
|
|
// same character set (alnum + `-./_`), so anything
|
|
// that fails it is either malformed or a header line.
|
|
continue
|
|
}
|
|
if seen[id] {
|
|
continue
|
|
}
|
|
seen[id] = true
|
|
isDefault := strings.Contains(label, "default")
|
|
// Strip the "(current, default)" suffix from the display
|
|
// label since we surface that through the Default flag.
|
|
if paren := strings.Index(label, "("); paren > 0 {
|
|
label = strings.TrimSpace(label[:paren])
|
|
}
|
|
if label == "" {
|
|
label = id
|
|
}
|
|
models = append(models, Model{
|
|
ID: id,
|
|
Label: label,
|
|
Provider: "cursor",
|
|
Default: isDefault,
|
|
})
|
|
}
|
|
return models
|
|
}
|
|
|
|
// discoverOpenclawAgents enumerates the pre-registered OpenClaw
|
|
// agents (which is where model selection actually lives in the
|
|
// OpenClaw world — each agent is bound to a model at `agents add`
|
|
// time). It tries structured JSON output first, falling back to a
|
|
// conservative text parser that rejects TUI decoration and section
|
|
// headers. On any ambiguity we return an empty list and let the
|
|
// creatable dropdown handle manual entry — a silently-wrong
|
|
// enumeration would be worse than none.
|
|
func discoverOpenclawAgents(ctx context.Context, executablePath string) ([]Model, error) {
|
|
if executablePath == "" {
|
|
executablePath = "openclaw"
|
|
}
|
|
if _, err := exec.LookPath(executablePath); err != nil {
|
|
return []Model{}, nil
|
|
}
|
|
runCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer cancel()
|
|
|
|
// Try JSON modes first. Different openclaw builds expose the
|
|
// flag under different names; trying a couple is cheap.
|
|
for _, jsonArgs := range [][]string{
|
|
{"agents", "list", "--json"},
|
|
{"agents", "list", "--output", "json"},
|
|
{"agents", "list", "-o", "json"},
|
|
} {
|
|
cmd := exec.CommandContext(runCtx, executablePath, jsonArgs...)
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if models, ok := parseOpenclawAgentsJSON(out); ok {
|
|
return models, nil
|
|
}
|
|
}
|
|
|
|
// Text fallback. Be strict — the default output is a decorated
|
|
// banner with box-drawing and section headers, and picking up
|
|
// the wrong tokens produces nonsense entries like "Identity:".
|
|
cmd := exec.CommandContext(runCtx, executablePath, "agents", "list")
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return []Model{}, nil
|
|
}
|
|
return parseOpenclawAgents(string(out)), nil
|
|
}
|
|
|
|
// openclawAgentEntry is the shape parseOpenclawAgentsJSON expects
|
|
// from `openclaw agents list --json`. Both `name` and `id` are
|
|
// accepted as the identifier (different openclaw versions ship
|
|
// different field names); `model` is optional and only used to
|
|
// enrich the dropdown label.
|
|
type openclawAgentEntry struct {
|
|
Name string `json:"name"`
|
|
ID string `json:"id"`
|
|
Model string `json:"model"`
|
|
}
|
|
|
|
// parseOpenclawAgentsJSON accepts `openclaw agents list --json`-style
|
|
// output. It handles two common shapes: a top-level array, or an
|
|
// object with an `agents` key whose value is an array. Returns
|
|
// ok=false if the input isn't valid JSON in either shape.
|
|
func parseOpenclawAgentsJSON(raw []byte) ([]Model, bool) {
|
|
raw = bytes.TrimSpace(raw)
|
|
if len(raw) == 0 {
|
|
return nil, false
|
|
}
|
|
|
|
var flat []openclawAgentEntry
|
|
if err := json.Unmarshal(raw, &flat); err == nil {
|
|
return openclawEntriesToModels(flat), true
|
|
}
|
|
|
|
var wrapped struct {
|
|
Agents []openclawAgentEntry `json:"agents"`
|
|
}
|
|
if err := json.Unmarshal(raw, &wrapped); err == nil && wrapped.Agents != nil {
|
|
return openclawEntriesToModels(wrapped.Agents), true
|
|
}
|
|
|
|
return nil, false
|
|
}
|
|
|
|
func openclawEntriesToModels(entries []openclawAgentEntry) []Model {
|
|
models := make([]Model, 0, len(entries))
|
|
seen := map[string]bool{}
|
|
for _, e := range entries {
|
|
name := e.Name
|
|
if name == "" {
|
|
name = e.ID
|
|
}
|
|
if name == "" || seen[name] {
|
|
continue
|
|
}
|
|
seen[name] = true
|
|
label := name
|
|
if e.Model != "" {
|
|
label = name + " (" + e.Model + ")"
|
|
}
|
|
models = append(models, Model{ID: name, Label: label, Provider: "openclaw"})
|
|
}
|
|
return models
|
|
}
|
|
|
|
// parseOpenclawAgents extracts agent names from the text output of
|
|
// `openclaw agents list`. The default CLI output is a decorated
|
|
// banner — section headers ending in `:`, box-drawing characters,
|
|
// and single-character icons — so we only accept lines that look
|
|
// like a proper `<name> <model>` row: at least two whitespace-
|
|
// separated tokens, both made of safe identifier characters, and
|
|
// neither ending in `:`. Anything else is discarded to avoid
|
|
// surfacing "Identity:" or `◇` as selectable models.
|
|
func parseOpenclawAgents(output string) []Model {
|
|
scanner := bufio.NewScanner(strings.NewReader(output))
|
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
|
var models []Model
|
|
seen := map[string]bool{}
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" {
|
|
continue
|
|
}
|
|
fields := strings.Fields(line)
|
|
if len(fields) < 2 {
|
|
continue
|
|
}
|
|
name, model := fields[0], fields[1]
|
|
if !isOpenclawIdentifier(name) || !isOpenclawIdentifier(model) {
|
|
continue
|
|
}
|
|
if seen[name] {
|
|
continue
|
|
}
|
|
seen[name] = true
|
|
models = append(models, Model{
|
|
ID: name,
|
|
Label: name + " (" + model + ")",
|
|
Provider: "openclaw",
|
|
})
|
|
}
|
|
return models
|
|
}
|
|
|
|
// isOpenclawIdentifier reports whether s looks like a valid
|
|
// agent-name or model-id token: starts with a letter, contains only
|
|
// identifier-safe characters, and isn't a section header
|
|
// (trailing colon). Rejects TUI decoration like `│`, `╭`, `◇`, `|`.
|
|
func isOpenclawIdentifier(s string) bool {
|
|
if s == "" || strings.HasSuffix(s, ":") {
|
|
return false
|
|
}
|
|
first := s[0]
|
|
if !((first >= 'a' && first <= 'z') || (first >= 'A' && first <= 'Z')) {
|
|
return false
|
|
}
|
|
for _, r := range s {
|
|
switch {
|
|
case r >= 'a' && r <= 'z':
|
|
case r >= 'A' && r <= 'Z':
|
|
case r >= '0' && r <= '9':
|
|
case r == '-' || r == '_' || r == '.' || r == '/':
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|