Commit Graph

4 Commits

Author SHA1 Message Date
Truffle
6acca84c28 fix(agent): clear stale session id when a resumed ACP session is gone [MUL-3216] (#4015)
* fix(agent): clear stale session id when a resumed ACP session is gone

When an agent's stored ACP session no longer exists on the runtime side,
session/resume still succeeds — hermes echoes the requested sessionId
back — so the failure only surfaces when session/prompt returns JSON-RPC
-32603 "Session not found". The backend then reported Status=failed with
the stale SessionID still set, which kept the daemon's resume-failure
fallback (gated on SessionID == "") from ever firing. The failed task
never updates the stored session, so every future mention on the same
(agent, issue) dispatched against the same dead id, forever (#4010).

handleResponse now returns a structured acpRPCError instead of a flat
string (rendered text unchanged), and the hermes/kimi/kiro prompt-error
paths clear the session id when the error is session-not-found class on
a resumed session. The daemon's existing retry then re-executes with a
fresh session and stores the replacement id, healing the mapping.

* fix(agent): clear stale session id when set_model hits a dead resumed session

With a model override, session/set_model runs before session/prompt,
so a resumed session that is gone on the agent side surfaces there
instead of at the prompt — and the error branch returned the stale
SessionID, so the daemon's fresh-session retry (gated on
SessionID == "") never fired. Apply the same clear-the-id fix in the
set_model error branch of all three backends.

Also relax isACPSessionNotFound to accept -32602: kimi-cli raises
RequestError.invalid_params({"session_id": "Session not found"}) for
every unknown-session path (src/kimi_cli/acp/server.py), so pinning
-32603 made the fix dead code for kimi. The wording gate keeps
unrelated invalid_params errors (e.g. "model not available") on the
preserve-the-id path.

Regression tests for all three backends: resumed session + model
override + set_model failing with each runtime's observed
session-not-found shape must yield status=failed with an empty
SessionID.
2026-06-11 14:54:56 +08:00
Bohan Jiang
09f9c7e2ce MUL-2764 feat(agent): wire mcp_config through ACP runtimes (Hermes / Kimi / Kiro) (#3439)
* MUL-2764 feat(agent): wire mcp_config through ACP runtimes (Hermes / Kimi / Kiro)

The MCP config Tab (#3419) already lets admins save mcp_config on an
agent, and the daemon plumbs it through to `agent.ExecOptions.McpConfig`
for every runtime. Claude and Codex consume it; the three ACP runtimes
(Hermes / Kimi / Kiro) ignored the field and hardcoded an empty
`mcpServers: []` in their `session/new` requests.

Add `buildACPMcpServers` to translate the Claude-style `{"mcpServers":
{"<name>": {...}}}` object-of-objects into the array shape ACP requires
(`[{name, command, args, env: [{name,value}, ...]}, ...]` for stdio;
`[{type, name, url, headers: [...]}, ...]` for http/sse), then pass the
translated array on `session/new` (all three) and `session/load` (kiro
resume). Malformed JSON fails the launch closed — same contract Codex's
`renderCodexMcpServersBlock` uses — so users see a real error instead of
silently running with no MCP servers. Individual unclassifiable entries
(no command, no url) are skipped with a warning so one bad row can't
take MCP down for the rest of the agent.

Co-authored-by: multica-agent <github@multica.ai>

* MUL-2764 fix(agent): wire mcp_config through ACP resume + gate http/sse on capability

Addresses the two blockers Elon raised on #3439:

1. session/resume now carries mcpServers for Hermes and Kimi (Kiro's
   session/load already did). Per the ACP Session Setup spec the resume
   path re-attaches MCP servers, and without this a resumed task lost
   access to MCP tools that a fresh task on the same agent would have
   had. Pinned with new TestHermesResumeIncludesMcpServers and
   TestKimiResumeIncludesMcpServers integration tests that inspect the
   recorded wire request.

2. Added extractACPMcpCapabilities + filterACPMcpServersByCapability so
   http/sse MCP entries get dropped (with a daemon-log warning naming
   the entry) when the runtime's initialize response doesn't advertise
   mcpCapabilities.http / .sse. Sending those entries to a stdio-only
   runtime is a spec violation and reliably tanks session/new; now they
   get filtered and the rest of the session still starts. Stdio entries
   pass through unconditionally. Both backends wire the filter in right
   after initialize so session/new and session/resume see the same
   filtered list.

Also added TestKiroLoadIncludesMcpServersFromConfig — Elon flagged that
no test pinned "non-empty mcp_config actually reaches the wire" for
Kimi/Kiro, so the wire assertions go in for all three runtimes.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-28 16:29:49 +08:00
Bohan Jiang
7375bda9b5 fix(landing): scope landing route to always-light palette (MUL-1277) (#1537)
* fix(landing): scope landing route to always-light palette

The landing page sections use hardcoded light colors (bg-white / #0a0d12),
but shared components rendered inside — notably CloudWaitlistExpand on
/download — use semantic tokens that flip to dark values under next-themes'
`.dark` class, producing a mismatched dark card on an otherwise light page
when the user's OS is in dark mode.

Add a `.landing-light` class on the landing layout wrapper that re-declares
all color tokens to their light values for the subtree, so nested
token-driven components stay in lockstep with the hardcoded palette.

* test(agent): serialize fake-executable writes to avoid ETXTBSY on CI

TestKimiBackendInvokesACPSubcommand (and its Kimi/Codex siblings) write a
shell script to a per-test TempDir and then fork/exec it. With t.Parallel()
enabled across the package, a concurrent goroutine's fork can inherit the
still-open write fd to another test's new executable; Linux then rejects
the subsequent exec with ETXTBSY (seen as
  fork/exec /tmp/.../kimi: text file busy
on GitHub Actions).

Introduce writeTestExecutable, which holds syscall.ForkLock.RLock across
OpenFile→Write→Close. Fork (which takes ForkLock.Lock) cannot run while we
hold RLock, so no sibling fork inherits our write fd. Ran the three callers
with -count=10 under -p=1 and the full package with no failures.
2026-04-23 01:52:46 +08:00
devv-eve
9e47b83f02 feat(agent): add Kimi CLI as agent runtime (#1400)
* 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>
2026-04-21 02:18:30 +08:00