Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 683432ebf3 | |||
| 820e0a4df2 | |||
| 83e628e135 | |||
| a5a3a89c06 | |||
| 24fcf5916d |
@@ -4,7 +4,7 @@
|
||||
|
||||
### The open-source AI agent framework and meta-harness for all your AI agents.
|
||||
|
||||
Omnigent is an open-source **AI agent framework** and meta-harness that gives you a common orchestration layer over Claude Code, Codex, Cursor, Pi, and the agents you write yourself: swap or combine harnesses without rewriting, enforce policies and sandboxing, and collaborate in real time from any device.
|
||||
Omnigent is an open-source **AI agent framework** and meta-harness that gives you a common orchestration layer over Claude Code, Codex, Cursor, Kimi Code, Pi, and the agents you write yourself: swap or combine harnesses without rewriting, enforce policies and sandboxing, and collaborate in real time from any device.
|
||||
|
||||
[](https://github.com/omnigent-ai/omnigent/blob/main/LICENSE)
|
||||

|
||||
@@ -161,6 +161,7 @@ Or launch a specific agent runtime, or your own agent:
|
||||
```bash
|
||||
omnigent claude # Claude Code, in a session your team can join
|
||||
omnigent codex # Codex
|
||||
omnigent kimi # Kimi Code (https://kimi.com), headless
|
||||
omnigent run path/to/agent.yaml # your own agent (see "Write your own agent")
|
||||
```
|
||||
|
||||
@@ -367,7 +368,7 @@ name: my_agent
|
||||
prompt: You are a helpful data analyst.
|
||||
|
||||
executor:
|
||||
harness: claude-sdk # or: claude-native, codex, codex-native, cursor, cursor-native, openai-agents, pi, pi-native, antigravity, qwen
|
||||
harness: claude-sdk # or: claude-native, codex, codex-native, cursor, cursor-native, openai-agents, pi, pi-native, antigravity, qwen, kimi
|
||||
|
||||
tools:
|
||||
# A local Python function (schema auto-generated from the signature)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BotIcon } from "lucide-react";
|
||||
import { ClaudeIcon } from "@/components/icons/ClaudeIcon";
|
||||
import { CodexIcon } from "@/components/icons/CodexIcon";
|
||||
import { CursorIcon } from "@/components/icons/CursorIcon";
|
||||
import { KimiIcon } from "@/components/icons/KimiIcon";
|
||||
import { NessieIcon } from "@/components/icons/NessieIcon";
|
||||
import { PiIcon } from "@/components/icons/PiIcon";
|
||||
import type { ComponentType, SVGProps } from "react";
|
||||
@@ -28,11 +29,14 @@ function iconForAgent(agent: AvailableAgent): ComponentType<SVGProps<SVGSVGEleme
|
||||
if (nativeAgent?.iconKind === "codex") return CodexIcon;
|
||||
if (nativeAgent?.iconKind === "pi") return PiIcon;
|
||||
if (nativeAgent?.iconKind === "cursor") return CursorIcon;
|
||||
if (nativeAgent?.iconKind === "kimi") return KimiIcon;
|
||||
// A null harness (spec couldn't load) flows through to the bot fallback.
|
||||
if (agent.harness?.includes("codex")) return CodexIcon;
|
||||
if (agent.harness?.includes("claude")) return ClaudeIcon;
|
||||
// Both the SDK "cursor" harness and "cursor-native" get the Cursor glyph.
|
||||
if (agent.harness?.includes("cursor")) return CursorIcon;
|
||||
// Both the SDK "kimi"/"kimi-code" harness and "kimi-native" get the Kimi glyph.
|
||||
if (agent.harness?.includes("kimi")) return KimiIcon;
|
||||
// qwen falls back to generic BotIcon for now; see docs/QWEN_FOLLOWUPS.md
|
||||
// Exact match — a substring check would false-match e.g. "openapi".
|
||||
if (agent.harness === "pi") return PiIcon;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// Import the Mono glyph directly instead of the package index. The Kimi index
|
||||
// barrel pulls in a `Color` component whose transitive `@lobehub/fluent-emoji`
|
||||
// dependency uses an ESM directory import that vitest can't resolve (it breaks
|
||||
// AgentCard.test collection). `Mono` is the monochrome `currentColor` glyph the
|
||||
// other harness icons (Cursor/Claude/…) render anyway, and it only depends on
|
||||
// React. See node_modules/@lobehub/icons/es/Kimi/index.d.ts.
|
||||
import Kimi from "@lobehub/icons/es/Kimi/components/Mono";
|
||||
|
||||
export const KimiIcon = Kimi;
|
||||
@@ -483,6 +483,12 @@ describe("inventoryTerminals", () => {
|
||||
session: "main",
|
||||
running: true,
|
||||
};
|
||||
const kimiPane: TerminalInfo = {
|
||||
id: "terminal_kimi_main",
|
||||
name: "kimi",
|
||||
session: "main",
|
||||
running: true,
|
||||
};
|
||||
const bash: TerminalInfo = {
|
||||
id: "terminal_bash_s1",
|
||||
name: "bash",
|
||||
@@ -504,6 +510,13 @@ describe("inventoryTerminals", () => {
|
||||
expect(inventoryTerminals([cursorPane, bash], true)).toEqual([bash]);
|
||||
});
|
||||
|
||||
it("drops the kimi vendor pane for native Kimi sessions", () => {
|
||||
// Regression: terminal_kimi_main was missing from AGENT_TERMINAL_IDS,
|
||||
// same failure mode as the pi/cursor panes above — leaked into Shells
|
||||
// and hid the Chat/Terminal pill in Terminal view.
|
||||
expect(inventoryTerminals([kimiPane, bash], true)).toEqual([bash]);
|
||||
});
|
||||
|
||||
it("drops the embedded REPL terminal for terminal-first SDK sessions", () => {
|
||||
// The REPL terminal backs the pill's Terminal view; listing it in
|
||||
// the rail reads as a phantom "main" terminal on agents that don't
|
||||
|
||||
@@ -50,7 +50,8 @@ export const PANEL_NO_TERMINAL_KEY = "";
|
||||
* connection pill's Terminal view, runner-created per session shape:
|
||||
* the embedded Omnigent REPL (``tui``/``main``) for SDK sessions,
|
||||
* and the vendor pane (``claude``/``main``, ``codex``/``main``,
|
||||
* ``pi``/``main``, or ``cursor``/``main``) for native-wrapper sessions.
|
||||
* ``pi``/``main``, ``cursor``/``main``, or ``kimi``/``main``) for
|
||||
* native-wrapper sessions.
|
||||
* These are plumbing, not
|
||||
* part of the session's shell inventory, and at most one exists per session.
|
||||
*
|
||||
@@ -65,6 +66,7 @@ export const AGENT_TERMINAL_IDS: ReadonlySet<string> = new Set([
|
||||
"terminal_codex_main",
|
||||
"terminal_pi_main",
|
||||
"terminal_cursor_main",
|
||||
"terminal_kimi_main",
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,7 @@ export const WRAPPER_LABEL_KEY = "omnigent.wrapper";
|
||||
export const UI_MODE_LABEL_KEY = "omnigent.ui";
|
||||
export const UI_MODE_TERMINAL_VALUE = "terminal";
|
||||
|
||||
export type NativeCodingAgentIconKind = "claude" | "codex" | "pi" | "cursor";
|
||||
export type NativeCodingAgentIconKind = "claude" | "codex" | "pi" | "cursor" | "kimi";
|
||||
export type NativeCodingAgentCapability = "permissionMode" | "approvalMode";
|
||||
|
||||
export interface NativeCodingAgentSpec {
|
||||
@@ -57,6 +57,15 @@ export const NATIVE_CODING_AGENTS = [
|
||||
iconKind: "pi",
|
||||
sortRank: 40,
|
||||
},
|
||||
{
|
||||
key: "kimi",
|
||||
agentName: "kimi-native-ui",
|
||||
harness: "kimi-native",
|
||||
wrapperLabel: "kimi-native-ui",
|
||||
displayName: "Kimi",
|
||||
iconKind: "kimi",
|
||||
sortRank: 50,
|
||||
},
|
||||
] as const satisfies readonly NativeCodingAgentSpec[];
|
||||
|
||||
const BY_AGENT_NAME: Map<string, NativeCodingAgentSpec> = new Map(
|
||||
@@ -75,6 +84,7 @@ const BY_WRAPPER: Map<string, NativeCodingAgentSpec> = new Map(
|
||||
const HARNESS_ALIASES: Record<string, string> = {
|
||||
"native-pi": "pi-native",
|
||||
"native-cursor": "cursor-native",
|
||||
"native-kimi": "kimi-native",
|
||||
};
|
||||
|
||||
export function nativeCodingAgentForAgentName(
|
||||
|
||||
@@ -71,7 +71,7 @@ import { AgentRowTooltip } from "@/components/AgentHoverCard";
|
||||
// returns agents newest-registered first (agent_store.list sorts by
|
||||
// created_at desc), so pin the order users expect; any agent not listed
|
||||
// here falls after, in server order.
|
||||
const AGENT_DISPLAY_ORDER = ["Claude Code", "Codex", "Cursor", "Pi", "Polly", "Debby"];
|
||||
const AGENT_DISPLAY_ORDER = ["Claude Code", "Codex", "Cursor", "Pi", "Kimi", "Polly", "Debby"];
|
||||
|
||||
// Built-in agents (by name slug) — the long-lived agents the server
|
||||
// ships out of the box. The picker groups these first, then a divider,
|
||||
@@ -82,13 +82,15 @@ const BUILTIN_AGENTS = new Set([
|
||||
"codex-native-ui", // Codex
|
||||
"pi-native-ui", // Pi
|
||||
"cursor-native-ui", // Cursor
|
||||
"kimi-native-ui", // Kimi
|
||||
"polly",
|
||||
"debby",
|
||||
]);
|
||||
|
||||
// Hidden on the new-session picker only (superseded by polly; older
|
||||
// deployments still carry a seeded nessie row this filter keeps out).
|
||||
const NEW_SESSION_HIDDEN_AGENTS = new Set(["nessie"]);
|
||||
// Hidden from the new-session picker only. `nessie` is superseded by polly.
|
||||
// `kimi` / `kimi-code` are the headless SDK harness (kept for sub-agent / `run
|
||||
// --harness kimi` use) — the picker offers only the native TUI (`kimi-native-ui`).
|
||||
const NEW_SESSION_HIDDEN_AGENTS = new Set(["nessie", "kimi", "kimi-code"]);
|
||||
|
||||
// Short picker-row blurbs — the spec descriptions are long paragraphs that
|
||||
// truncate badly in the dropdown; other dialogs keep the server values.
|
||||
|
||||
@@ -33,6 +33,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { ClaudeIcon } from "@/components/icons/ClaudeIcon";
|
||||
import { CodexIcon } from "@/components/icons/CodexIcon";
|
||||
import { CursorIcon } from "@/components/icons/CursorIcon";
|
||||
import { KimiIcon } from "@/components/icons/KimiIcon";
|
||||
import { NessieIcon } from "@/components/icons/NessieIcon";
|
||||
import { OttoIcon } from "@/components/icons/OttoIcon";
|
||||
import { PiIcon } from "@/components/icons/PiIcon";
|
||||
@@ -306,6 +307,7 @@ function brandChildIcon(child: ChildSessionInfo): AgentRowIcon | null {
|
||||
if (nativeAgent?.iconKind === "codex") return CodexIcon;
|
||||
if (nativeAgent?.iconKind === "pi") return PiIcon;
|
||||
if (nativeAgent?.iconKind === "cursor") return CursorIcon;
|
||||
if (nativeAgent?.iconKind === "kimi") return KimiIcon;
|
||||
// Exact match — substring checks would false-match names like "pipeline".
|
||||
if (child.tool === PI_AGENT_NAME) return PiIcon;
|
||||
return null;
|
||||
@@ -466,9 +468,11 @@ function MainRow({ rootSessionId, isActive }: { rootSessionId: string; isActive:
|
||||
? PiIcon
|
||||
: nativeAgent?.iconKind === "cursor"
|
||||
? CursorIcon
|
||||
: isNessie
|
||||
? NessieIcon
|
||||
: BotIcon;
|
||||
: nativeAgent?.iconKind === "kimi"
|
||||
? KimiIcon
|
||||
: isNessie
|
||||
? NessieIcon
|
||||
: BotIcon;
|
||||
// Native wrappers show the product name (mirroring the sidebar) instead
|
||||
// of the spec's YAML name (e.g. "claude-native-ui"); other agents show
|
||||
// their agent name, with "main" only while the session loads or when it
|
||||
|
||||
@@ -21,7 +21,14 @@ export const CLAUDE_NATIVE_DEFAULT_LABEL = "Claude Code";
|
||||
export const CODEX_NATIVE_DEFAULT_LABEL = "Codex";
|
||||
export const PI_NATIVE_DEFAULT_LABEL = "Pi";
|
||||
|
||||
export type ConversationIconKind = "claude" | "codex" | "pi" | "cursor" | "nessie" | null;
|
||||
export type ConversationIconKind =
|
||||
| "claude"
|
||||
| "codex"
|
||||
| "pi"
|
||||
| "cursor"
|
||||
| "kimi"
|
||||
| "nessie"
|
||||
| null;
|
||||
|
||||
// Display label for a session with no title and no native-wrapper name —
|
||||
// shown in the sidebar row and as the browser tab title fallback.
|
||||
|
||||
+22
-1
@@ -49,7 +49,7 @@ resolved from the YAML file's directory.
|
||||
|
||||
```yaml
|
||||
executor:
|
||||
harness: claude-sdk # claude-sdk, openai-agents, codex, cursor, pi, antigravity, qwen, ...
|
||||
harness: claude-sdk # claude-sdk, openai-agents, codex, cursor, pi, antigravity, qwen, kimi, ...
|
||||
model: databricks-claude-opus-4-7
|
||||
auth:
|
||||
type: databricks
|
||||
@@ -89,6 +89,27 @@ To route through OpenRouter / a gateway, declare a key/gateway provider in
|
||||
or set `auth.base_url` to the OpenAI-compatible endpoint alongside the key.
|
||||
For Databricks, use `auth: {type: databricks, profile: …}`.
|
||||
|
||||
### Kimi Code
|
||||
|
||||
`harness: kimi` runs the agent through Moonshot AI's
|
||||
[Kimi Code CLI](https://github.com/MoonshotAI/Kimi-Code) headlessly via
|
||||
`kimi --print --output-format stream-json` per turn. Install the binary
|
||||
with `curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash`
|
||||
and authenticate once with `kimi login` (OAuth or a Moonshot API key).
|
||||
|
||||
```yaml
|
||||
executor:
|
||||
harness: kimi # alias: kimi-code
|
||||
model: kimi-k2-turbo
|
||||
```
|
||||
|
||||
By default Kimi authenticates against Moonshot AI's backend — Omnigent
|
||||
declares no `executor.auth` block. To route through a gateway, either set
|
||||
`HARNESS_KIMI_GATEWAY_BASE_URL` + `HARNESS_KIMI_GATEWAY_API_KEY` in the
|
||||
shell, declare a key/gateway provider in `~/.omnigent/config.yaml`, or use
|
||||
`executor.auth: {type: databricks, profile: …}` and let Omnigent resolve
|
||||
the workspace.
|
||||
|
||||
CLI flags such as `--harness` and `--model` can override or supply missing
|
||||
executor values for a run. Databricks credentials come from the spec's
|
||||
`executor.auth` block or your `omnigent setup` provider config — there is
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
# Kimi Code harness — known follow-ups
|
||||
|
||||
The Kimi Code CLI harness landed in #271 with the runtime, CLI,
|
||||
onboarding, frontend, and gateway-routing wiring complete. This file
|
||||
tracks the gaps deliberately deferred so they don't get lost. Each item
|
||||
lists what the gap is, why it was deferred, and a concrete starting
|
||||
point for whoever picks it up.
|
||||
|
||||
## 1. Omnigent-side provider injection (MCP + provider routing)
|
||||
|
||||
**Gap.** Two related gaps for the same reason — upstream Kimi Code CLI
|
||||
has no per-spawn config override flag (no `--config-file`, no
|
||||
`--mcp-config-file`):
|
||||
|
||||
- **Tools.** Spec-declared tools (`tools:` block in the agent YAML) are
|
||||
not exposed to the kimi subprocess. `KimiExecutor.run_turn` accepts
|
||||
the `tools` argument for ABI parity and logs a one-time warning per
|
||||
session.
|
||||
- **Providers.** A spec that declares
|
||||
`executor.auth: {type: provider, name: X}` or
|
||||
`{type: databricks, profile: P}` cannot be threaded through to kimi.
|
||||
`configure_agent_harness_with_provider` for `harness_type="kimi"`
|
||||
raises rather than silently routing through whatever default kimi
|
||||
already had — so users understand why their auth didn't take effect.
|
||||
|
||||
For v1, both are managed out-of-band:
|
||||
|
||||
- Tools: not exposed.
|
||||
- Providers: configure via `kimi provider add` in
|
||||
`~/.kimi/config.toml`, then pin the resulting model id in the agent
|
||||
spec.
|
||||
|
||||
**Why deferred.** Two paths exist, both substantial:
|
||||
|
||||
1. **MCP-via-config-file.** Wait for upstream kimi to grow a
|
||||
`--mcp-config-file` (or equivalent stdin-injection mechanism); then
|
||||
boot a FastMCP server bound to `127.0.0.1:0` per session and inject
|
||||
its URL via env. Plumbing-heavy (~300–500 lines, see the Codex
|
||||
`dynamicTools` analogue at ~2300 lines total in
|
||||
`omnigent/inner/codex_executor.py`).
|
||||
2. **ACP-server long-lived process.** Switch off the per-turn
|
||||
subprocess to a single `kimi acp` server speaking the Agent Client
|
||||
Protocol (https://agentclientprotocol.com/) over stdio. ACP has
|
||||
first-class tool registration + cancellation + new-message
|
||||
injection. This is the right long-term shape — it also unlocks
|
||||
mid-turn interrupt + live message queue (follow-up #5) — but is a
|
||||
substantial rewrite.
|
||||
|
||||
**Starting point.** Read kimi's `kimi acp` reference; mirror the Codex
|
||||
App-Server JSONL bridge structure but speak ACP instead. The ACP spec
|
||||
is at https://agentclientprotocol.com/ and kimi documents its server
|
||||
under `kimi acp --help`.
|
||||
|
||||
## 2. Native TUI launch (tmux-pane parity with `omnigent claude`)
|
||||
|
||||
**Gap.** `omnigent kimi` is a discoverability shortcut for
|
||||
`omnigent run --harness kimi` — it runs Kimi headlessly behind the
|
||||
standard Omnigent REPL, not the kimi TUI in a tmux pane. There is no
|
||||
`kimi-native` harness analogous to `claude-native` / `codex-native`.
|
||||
|
||||
**Why deferred.** Native TUI integration is a separate piece of work
|
||||
(~300–500 lines): a `tmux`-based pane manager, a `KimiNativeExecutor`
|
||||
that wraps the subprocess and bridges input/output through Omnigent's
|
||||
terminal layer, and the equivalent of `omnigent/claude_native_*.py` /
|
||||
`omnigent/codex_native_*.py`. Kimi's CLI already supports `kimi acp`
|
||||
(Agent Client Protocol over stdio), which Zed and JetBrains use for IDE
|
||||
integration, so the implementation would be easier than
|
||||
Claude/Codex's were.
|
||||
|
||||
**Starting point.** Model on `omnigent/codex_native_executor.py` +
|
||||
`omnigent/codex_native_harness.py`. Adding `kimi-native` to
|
||||
`OMNIGENT_HARNESSES`, `_HARNESS_MODULES`, `NATIVE_HARNESSES`, and the
|
||||
`omnigent.kimi_native_*` analogue file set is the bulk of the work. The
|
||||
`kimi acp` flag means most of the TUI-rendering plumbing already lives
|
||||
in Kimi itself.
|
||||
|
||||
## 3. Dedicated Kimi glyph
|
||||
|
||||
**Gap.** `ap-web/src/components/AgentCard.tsx` falls through to
|
||||
`BotIcon` for kimi agents. The other CLI harnesses each have their own
|
||||
SVG glyph under `ap-web/src/components/icons/`.
|
||||
|
||||
**Why deferred.** No canonical SVG to copy from Kimi's repo yet.
|
||||
Trivially small once an asset lands — add a `KimiIcon.tsx`, import it
|
||||
in `AgentCard.tsx`, and switch the fallback comment to a real branch.
|
||||
|
||||
## 4. Multimodal input (incl. video)
|
||||
|
||||
**Gap.** Image / file / audio blocks (`input_image`, `input_file`,
|
||||
`input_audio`) on a user message are dropped with a warning per
|
||||
`_latest_user_text` in `omnigent/inner/kimi_executor.py`. Only text
|
||||
content reaches kimi.
|
||||
|
||||
**Why deferred.** Kimi advertises **video input** as a first-class
|
||||
feature (drop a screen recording or demo clip into the chat) — so the
|
||||
multimodal story here is richer than for the other harnesses, but
|
||||
plumbing it from Omnigent specs into kimi's CLI needs file
|
||||
materialisation (write each input block to a temp file, pass the path,
|
||||
clean up afterward) and a decision on the API surface. Out of scope
|
||||
for the initial harness wrap.
|
||||
|
||||
**Starting point.** Kimi's CLI accepts the file as a positional
|
||||
argument after the prompt (see kimi's `kimi-command` reference).
|
||||
Extend `_latest_user_text` to also return a list of materialised file
|
||||
paths; thread them into `_build_argv`; clean them up in the `finally`
|
||||
block of `run_turn`.
|
||||
|
||||
## 5. Mid-turn interrupt + live message queue
|
||||
|
||||
**Gap.** `KimiExecutor.interrupt_session` terminates the active process
|
||||
but doesn't preserve a queued message; `enqueue_session_message` always
|
||||
returns `False`. Cancellation works via the standard async-gen close
|
||||
path (the runtime cancels the wrapping HTTP request, `run_turn`'s
|
||||
`finally` block terminates the subprocess), but there's no way to
|
||||
inject a new user message mid-turn.
|
||||
|
||||
**Why deferred.** The per-turn-subprocess design genuinely lacks this
|
||||
surface. Kimi's `kimi acp` long-lived path supports the Agent Client
|
||||
Protocol — a proper bidirectional stdio protocol with cancel /
|
||||
new-prompt messages — and would unlock both features, but switching off
|
||||
the per-turn subprocess model is the substantial rewrite mentioned in
|
||||
the executor docstring ("HTTP / SSE transport is a natural follow-up
|
||||
once the contract is firm").
|
||||
|
||||
**Starting point.** Spawn `kimi acp` once per Omnigent session; cache
|
||||
the stdio handles. Replace the per-turn `kimi --print` with a sequence
|
||||
of ACP `prompt` / `cancel` messages. Drive the event stream off ACP's
|
||||
`session/update` events instead of parsing JSONL from stdout. Wire
|
||||
`interrupt_session` and `enqueue_session_message` against the ACP
|
||||
cancel / prompt endpoints. The ACP spec is at
|
||||
https://agentclientprotocol.com/.
|
||||
|
||||
## 6. Token usage / cost reporting
|
||||
|
||||
**Gap.** `TurnComplete.usage` is set to `None`. Kimi's `step_finish`
|
||||
events (if present in stream-json output) carry token counts that we
|
||||
currently drop.
|
||||
|
||||
**Why deferred.** Easy follow-up; deferred only because the
|
||||
stream-json output schema is still settling and the kimi-cli docs
|
||||
don't yet pin the field names. The cost-advisor already integrates
|
||||
with other executors that report usage.
|
||||
|
||||
**Starting point.** Inspect stream-json output via
|
||||
`kimi --print --output-format stream-json --debug` against a real
|
||||
session and capture the usage field names. Update `_translate_event`
|
||||
in `omnigent/inner/kimi_executor.py` to accumulate them on the
|
||||
executor instance; pass the totals into `TurnComplete(usage=...)` at
|
||||
end of turn.
|
||||
|
||||
## 7. Plan mode + thinking-mode controls in the spec
|
||||
|
||||
**Gap.** `KimiExecutor` honours `HARNESS_KIMI_PLAN` and
|
||||
`HARNESS_KIMI_THINKING` env vars, but there is no spec-level field
|
||||
that surfaces them on an Omnigent agent YAML. A user wanting to pin
|
||||
plan mode for a research agent has to export the env var rather than
|
||||
declare it inline.
|
||||
|
||||
**Why deferred.** Adding spec-level fields means updating the
|
||||
`ExecutorSpec` parser, the workflow spawn-env builder, and the
|
||||
single-file launcher generator — a larger surface for a relatively
|
||||
niche feature.
|
||||
|
||||
**Starting point.** Add an optional `executor.config.kimi.{plan,
|
||||
thinking}` block (parsed in `omnigent/spec/parser.py`), thread it
|
||||
through `_build_kimi_spawn_env` into the env vars.
|
||||
|
||||
## 8. Built-in agent specs (`okabe` and friends)
|
||||
|
||||
**Gap.** Kimi ships built-in agent specs (`--agent default` /
|
||||
`--agent okabe`) that customise the system prompt + tool set.
|
||||
`KimiExecutor` honours `HARNESS_KIMI_AGENT` and `HARNESS_KIMI_AGENT_FILE`,
|
||||
but there is no surface to select them from an Omnigent spec — users
|
||||
must export the env var.
|
||||
|
||||
**Why deferred.** Same shape as #7 — needs spec parser + workflow
|
||||
plumbing. Doc-only follow-up until the built-in agent surface is more
|
||||
broadly used.
|
||||
|
||||
**Starting point.** Same pattern as #7 above, with
|
||||
`executor.config.kimi.agent` / `executor.config.kimi.agent_file`.
|
||||
|
||||
## 9. Test coverage gaps
|
||||
|
||||
- **Live web-UI verification.** The `configured_harness_map()` daemon
|
||||
hello frame includes `kimi`, but I never started `omnigent server`
|
||||
+ `omnigent host` + `npm run dev` to visually confirm Kimi shows up
|
||||
in the new-session picker. Probably works; would be cheap to verify.
|
||||
- **Workflow `_apply_databricks_profile_to_kimi` real-creds test.**
|
||||
The unit test path doesn't exercise the Databricks resolver. A
|
||||
Databricks-creds-required test would catch real-world drift in the
|
||||
gateway endpoint shape (`/serving-endpoints` vs
|
||||
`/serving-endpoints/anthropic`).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Subscription-style auth detection** (the
|
||||
`_SUBSCRIPTION_AUTH_HARNESSES` set in `omnigent/spec/omnigent.py`).
|
||||
Kimi's `kimi login` is OAuth or a single Moonshot API key, not a
|
||||
multi-vendor subscription, so it doesn't fit that mental model.
|
||||
- **`ucode` integration** (`_UCODE_HARNESS_CONFIGS` in
|
||||
`omnigent/runtime/workflow.py`). The ucode path pre-caches gateway
|
||||
state for SDK-wrapping harnesses; Kimi reads its config per-spawn
|
||||
from `HARNESS_KIMI_CONFIG_CONTENT` (synthesised into a temp
|
||||
`--config-file`), so the ucode cache layer is genuinely redundant
|
||||
for it.
|
||||
@@ -0,0 +1,48 @@
|
||||
name: kimi-hello
|
||||
description: >-
|
||||
Smallest possible Kimi Code agent — single-file launcher YAML that hands
|
||||
every turn to Moonshot AI's Kimi Code CLI (https://github.com/MoonshotAI/Kimi-Code)
|
||||
running headlessly behind the standard Omnigent REPL. Useful as a sanity
|
||||
check that the harness wires up end-to-end on a fresh machine, and as a
|
||||
starting point for a real Kimi-backed agent.
|
||||
|
||||
# Single-file launcher shape — matches what ``omnigent run --harness kimi``
|
||||
# generates internally. For multi-agent bundles, prefer the directory layout
|
||||
# under ``examples/polly/`` / ``examples/debby/``.
|
||||
executor:
|
||||
harness: kimi
|
||||
# Override per-invocation via ``-m kimi-k2-turbo`` or ``/model`` in the REPL.
|
||||
# With no model pinned, Kimi picks the default model set in its config.
|
||||
model: kimi-k2-turbo
|
||||
|
||||
prompt: |
|
||||
You are Kimi Code, running headlessly inside Omnigent. Help the user with
|
||||
software engineering tasks — read files, edit code, run tests, and explain
|
||||
your reasoning. Keep responses concise and prefer showing the user diffs /
|
||||
commands over describing them in prose.
|
||||
|
||||
# Kimi Code is a multi-provider coding agent (Moonshot AI's Kimi models by
|
||||
# default; also OpenAI / Anthropic / OpenRouter via its config). Credentials
|
||||
# live inside Kimi's own ``kimi login`` flow (OAuth or a Moonshot API key),
|
||||
# NOT in Omnigent's provider config — so this spec declares no ``executor.auth``
|
||||
# block.
|
||||
#
|
||||
# To route through a gateway (Databricks AI gateway / vendor-neutral proxy)
|
||||
# instead, either set ``HARNESS_KIMI_GATEWAY_BASE_URL`` +
|
||||
# ``HARNESS_KIMI_GATEWAY_API_KEY`` in the shell, or declare a
|
||||
# ``executor.auth: {type: databricks, profile: …}`` block and let Omnigent
|
||||
# resolve the workspace.
|
||||
#
|
||||
# Kimi owns its own file/shell tools (bash, edit, read, …) and runs them
|
||||
# inside its own loop. The harness therefore advertises
|
||||
# ``handles_tools_internally=True`` and does NOT need an ``os_env`` block to
|
||||
# inject ``sys_os_*`` tools — adding one would just duplicate every operation
|
||||
# against Omnigent's dispatch path.
|
||||
|
||||
# Try it:
|
||||
# omnigent run examples/kimi_hello.yaml
|
||||
# omnigent run examples/kimi_hello.yaml -p "summarise the README"
|
||||
# omnigent run examples/kimi_hello.yaml -m kimi-k2-turbo
|
||||
#
|
||||
# Or as a shortcut for any kimi-harness run:
|
||||
# omnigent kimi -p "list the files in the current directory"
|
||||
@@ -53,3 +53,7 @@ PI_NATIVE_WRAPPER_VALUE = "pi-native-ui"
|
||||
# Value the ``omnigent cursor`` wrapper writes into
|
||||
# ``conversations.labels[WRAPPER_LABEL_KEY]``.
|
||||
CURSOR_NATIVE_WRAPPER_VALUE = "cursor-native-ui"
|
||||
|
||||
# Value the ``omnigent kimi`` wrapper writes into
|
||||
# ``conversations.labels[WRAPPER_LABEL_KEY]``.
|
||||
KIMI_NATIVE_WRAPPER_VALUE = "kimi-native-ui"
|
||||
|
||||
@@ -1057,6 +1057,14 @@ def _redirect_native_resume_if_needed(
|
||||
progress=progress,
|
||||
)
|
||||
return True
|
||||
if native_agent.key == "kimi":
|
||||
_run_kimi_native_resume_redirect(
|
||||
base_url=base_url,
|
||||
conversation_id=conversation_id,
|
||||
auto_open_conversation=auto_open_conversation,
|
||||
progress=progress,
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -1228,6 +1236,44 @@ def _run_cursor_native_resume_redirect(
|
||||
)
|
||||
|
||||
|
||||
def _run_kimi_native_resume_redirect(
|
||||
*,
|
||||
base_url: str,
|
||||
conversation_id: str,
|
||||
auto_open_conversation: bool,
|
||||
progress: RunnerStartupProgress | None,
|
||||
) -> None:
|
||||
"""
|
||||
Hand a kimi-native conversation back to ``omnigent kimi``.
|
||||
|
||||
The kimi-native session is driven by the ``kimi`` TUI in a runner-owned
|
||||
tmux pane. Resuming through the Omnigent REPL would run an Omnigent turn
|
||||
per message instead of attaching to the live TUI; redirecting to
|
||||
``omnigent kimi``'s direct tmux attach keeps the TUI the single source of
|
||||
turns. Mirrors :func:`_run_cursor_native_resume_redirect`.
|
||||
|
||||
:param base_url: Omnigent server base URL.
|
||||
:param conversation_id: Omnigent conversation id.
|
||||
:param auto_open_conversation: Browser-open preference for the wrapper.
|
||||
:param progress: Optional Omnigent startup spinner to finish before redirect.
|
||||
:returns: None.
|
||||
"""
|
||||
_finish_native_redirect_progress(
|
||||
progress=progress,
|
||||
conversation_id=conversation_id,
|
||||
wrapper_name="kimi-native",
|
||||
native_command="kimi",
|
||||
)
|
||||
from omnigent.kimi_native import run_kimi_native
|
||||
|
||||
run_kimi_native(
|
||||
server=base_url,
|
||||
session_id=conversation_id,
|
||||
kimi_args=(),
|
||||
auto_open_conversation=auto_open_conversation,
|
||||
)
|
||||
|
||||
|
||||
def _wrapper_label_for_conversation(
|
||||
*,
|
||||
base_url: str,
|
||||
|
||||
+247
-2
@@ -480,6 +480,15 @@ def _pick_first_run_harness() -> _FirstRunPlan | None:
|
||||
return _FirstRunPlan(harness="codex", agent=None)
|
||||
if default_provider_for_harness(config, "pi") is not None:
|
||||
return _FirstRunPlan(harness="pi", agent=None)
|
||||
# Kimi authenticates against its own backend (``kimi login`` OAuth or a
|
||||
# Moonshot API key) rather than the ambient-detected provider config, so
|
||||
# ``default_provider_for_harness`` can't gate it. Fall back to "binary
|
||||
# installed" as the readiness proxy: the executor will fail loud at the
|
||||
# first turn if no provider is actually configured.
|
||||
from omnigent.onboarding.harness_install import KIMI_KEY, harness_cli_installed
|
||||
|
||||
if harness_cli_installed(KIMI_KEY):
|
||||
return _FirstRunPlan(harness="kimi", agent=None)
|
||||
return None
|
||||
|
||||
|
||||
@@ -1168,6 +1177,7 @@ _CLICK_SUBCOMMANDS: frozenset[str] = frozenset(
|
||||
"debby",
|
||||
"debug",
|
||||
"host",
|
||||
"kimi",
|
||||
"lakebox",
|
||||
"login",
|
||||
"pane-picker",
|
||||
@@ -4656,6 +4666,95 @@ def debby(run_args: tuple[str, ...]) -> None:
|
||||
_run_bundled_agent("debby", run_args)
|
||||
|
||||
|
||||
@cli.command(
|
||||
context_settings={
|
||||
"ignore_unknown_options": True,
|
||||
"allow_extra_args": True,
|
||||
}
|
||||
)
|
||||
@click.option(
|
||||
"--server",
|
||||
default=None,
|
||||
help=(
|
||||
"Remote omnigent URL. Ensures the host daemon, asks the "
|
||||
"daemon-spawned runner to launch the Kimi TUI, and attaches this TTY. "
|
||||
'Pass --server "" to auto-spawn a persistent local server in the '
|
||||
"background and use that instead of a remote one."
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"-r",
|
||||
"--resume",
|
||||
"resume",
|
||||
is_flag=False,
|
||||
flag_value=_RESUME_PICKER_SENTINEL,
|
||||
default=None,
|
||||
help=(
|
||||
"Resume a prior Omnigent conversation. With a conversation id "
|
||||
"(e.g. ``--resume conv_abc123``) attaches directly; with no value "
|
||||
"opens an interactive picker scoped to kimi-native sessions."
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--session",
|
||||
"session_id",
|
||||
metavar="SESSION_ID",
|
||||
default=None,
|
||||
hidden=True,
|
||||
help="Deprecated alias for ``--resume <id>``; kept for one release.",
|
||||
)
|
||||
@click.argument("kimi_args", nargs=-1, type=click.UNPROCESSED)
|
||||
def kimi(
|
||||
server: str | None,
|
||||
resume: str | None,
|
||||
session_id: str | None,
|
||||
kimi_args: tuple[str, ...],
|
||||
) -> None:
|
||||
"""Launch the Kimi Code TUI in an Omnigent terminal.
|
||||
|
||||
Boots Moonshot AI's interactive ``kimi`` TUI
|
||||
(https://github.com/MoonshotAI/Kimi-Code) in a runner-owned terminal and
|
||||
attaches your TTY — the native experience, embedded in the Omnigent web
|
||||
UI. No Omnigent provider config is needed: kimi authenticates against its
|
||||
own backend (``kimi login`` for OAuth, or a Moonshot API key).
|
||||
|
||||
For the headless SDK harness (per-turn ``kimi -p`` behind the Omnigent
|
||||
REPL) use ``omnigent run --harness kimi`` instead.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
omnigent kimi
|
||||
omnigent kimi --resume conv_abc123
|
||||
omnigent kimi --resume # interactive picker
|
||||
"""
|
||||
choice = _split_resume_value(resume)
|
||||
if session_id is not None and (choice.picker or choice.conversation_id is not None):
|
||||
raise click.UsageError(
|
||||
"--session and --resume are mutually exclusive; "
|
||||
"prefer --resume (--session is deprecated).",
|
||||
)
|
||||
|
||||
from omnigent.kimi_native import run_kimi_native
|
||||
|
||||
cfg = _load_effective_config()
|
||||
if server is None:
|
||||
server = cfg.get("server")
|
||||
auto_open_conversation = _resolve_auto_open_conversation_from_config(cfg)
|
||||
|
||||
server = _ensure_backend(server)
|
||||
resolved_session_id = (
|
||||
choice.conversation_id if choice.conversation_id is not None else session_id
|
||||
)
|
||||
|
||||
run_kimi_native(
|
||||
server=server,
|
||||
session_id=resolved_session_id,
|
||||
resume_picker=choice.picker,
|
||||
kimi_args=kimi_args,
|
||||
auto_open_conversation=auto_open_conversation,
|
||||
)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("target", required=False, metavar="[CONV_ID]")
|
||||
@click.option(
|
||||
@@ -4715,7 +4814,7 @@ def resume(
|
||||
# into a materialized copy of the spec before the server starts.
|
||||
_HARNESS_CHOICES_HELP = (
|
||||
"'claude' (alias for 'claude-sdk'), 'claude-sdk', 'codex', "
|
||||
"'cursor', "
|
||||
"'cursor', 'kimi', "
|
||||
"'openai-agents', 'open-responses', 'pi', 'antigravity', or 'qwen'"
|
||||
)
|
||||
_HARNESS_HELP = f"Harness to use for a local agent: {_HARNESS_CHOICES_HELP}."
|
||||
@@ -4748,6 +4847,10 @@ _DEFAULT_HARNESS_PROMPTS = {
|
||||
"cursor": (
|
||||
"You are Cursor, running through Omnigent. Help the user with software engineering tasks."
|
||||
),
|
||||
"kimi": (
|
||||
"You are Kimi Code, running through Omnigent. "
|
||||
"Help the user with software engineering tasks."
|
||||
),
|
||||
"qwen": (
|
||||
"You are Qwen Code, running through Omnigent. "
|
||||
"Help the user with software engineering tasks."
|
||||
@@ -5069,6 +5172,10 @@ def _dispatch_native_terminal_harness(
|
||||
from omnigent.cursor_native import run_cursor_native
|
||||
|
||||
run_cursor_native(cursor_args=passthrough, **common)
|
||||
elif native_agent.key == "kimi":
|
||||
from omnigent.kimi_native import run_kimi_native
|
||||
|
||||
run_kimi_native(kimi_args=passthrough, **common)
|
||||
else: # pragma: no cover - new native agent added without a dispatch arm
|
||||
raise click.ClickException(f"No native terminal launcher wired for harness {harness!r}.")
|
||||
return True
|
||||
@@ -9088,6 +9195,111 @@ def _manage_qwen_harness() -> None:
|
||||
status = None
|
||||
|
||||
|
||||
def _print_kimi_auth_help() -> None:
|
||||
"""Print Kimi Code's authentication options.
|
||||
|
||||
Kimi authenticates against Moonshot AI's backend rather than an Omnigent
|
||||
credential: ``kimi login`` (OAuth or a Moonshot API key) for the default
|
||||
provider, and ``kimi provider add`` to register any other provider (an
|
||||
OpenAI-compatible endpoint, a Databricks gateway, …) in
|
||||
``~/.kimi/config.toml``. Omnigent has no per-spawn provider override for
|
||||
upstream kimi, so all of this lives in the kimi CLI's own config — see
|
||||
``docs/KIMI_FOLLOWUPS.md`` for the deferred Omnigent-side injection work.
|
||||
"""
|
||||
from omnigent.onboarding.interactive import console
|
||||
|
||||
console.print(
|
||||
"\n [bold]Authenticate Kimi Code[/bold] (kimi manages its own config in "
|
||||
"~/.kimi/config.toml):\n"
|
||||
" • Default provider: run [bold]kimi login[/bold] "
|
||||
"(Moonshot OAuth, or paste a Moonshot API key)\n"
|
||||
" • Other providers: run [bold]kimi provider add[/bold] "
|
||||
"(OpenAI-compatible endpoint, gateway, …), then pin that model id in "
|
||||
"the agent spec\n"
|
||||
" • Omnigent stores no kimi credential and cannot thread one per "
|
||||
"spawn — configure it once in the kimi CLI\n"
|
||||
)
|
||||
|
||||
|
||||
def _manage_kimi_harness() -> None:
|
||||
"""Run the level-2 loop for Kimi Code: install the CLI and drive ``kimi login``.
|
||||
|
||||
Unlike Qwen (which has no ``login`` subcommand), Kimi ships a real
|
||||
``kimi login`` (Moonshot OAuth or API key) and ``kimi logout``, so this
|
||||
drill-in offers sign-in / sign-out directly. Kimi has no first-class
|
||||
"am I logged in?" probe (its install spec sets ``status_args=None``), so
|
||||
:func:`~omnigent.onboarding.harness_install.harness_cli_logged_in` always
|
||||
reports ``False`` for it — meaning ``harness_login`` runs ``kimi login``
|
||||
every time it is asked (the interactive flow lets the user cancel if
|
||||
already authenticated) and its boolean return is not a reliable success
|
||||
signal. We therefore treat login / logout as best-effort side effects and
|
||||
report that the flow finished rather than asserting an auth state.
|
||||
|
||||
Like the other CLI-backed harnesses, a missing CLI gates the drill-in —
|
||||
there is nothing to configure for a harness you can't run.
|
||||
|
||||
:returns: None. Side effects: may install the kimi CLI and run
|
||||
``kimi login`` / ``kimi logout`` in the foreground.
|
||||
"""
|
||||
from omnigent.onboarding.harness_install import (
|
||||
KIMI_KEY,
|
||||
harness_cli_installed,
|
||||
harness_install_spec,
|
||||
harness_login,
|
||||
harness_logout,
|
||||
)
|
||||
from omnigent.onboarding.interactive import console, select
|
||||
|
||||
# Gate on the CLI. Kimi ships a single binary via a curl installer (not
|
||||
# npm), so there's no in-process auto-install — name the command and let
|
||||
# the user run it, then re-open. Mirrors how ``harness_setup_hint`` treats
|
||||
# the other curl-installed CLI (cursor-agent).
|
||||
if not harness_cli_installed(KIMI_KEY):
|
||||
spec = harness_install_spec(KIMI_KEY)
|
||||
hint = (spec.install_hint if spec else None) or "see Kimi Code docs"
|
||||
console.print(
|
||||
" Kimi Code's CLI isn't installed. Install it with:\n"
|
||||
f" [bold]{hint}[/bold]\n"
|
||||
" then re-open this menu to sign in."
|
||||
)
|
||||
return
|
||||
|
||||
# Carry the prior action's confirmation as a transient status line.
|
||||
status: str | None = None
|
||||
while True:
|
||||
rows: list[_HarnessMenuRow] = [
|
||||
_HarnessMenuRow("Sign in (kimi login)", action="login"),
|
||||
_HarnessMenuRow("Sign out (kimi logout)", action="logout"),
|
||||
_HarnessMenuRow("Show auth options", action="help"),
|
||||
_HarnessMenuRow("← Back", action="back"),
|
||||
]
|
||||
idx = select(
|
||||
"Kimi Code — authentication is managed by the kimi CLI",
|
||||
[r.label for r in rows],
|
||||
clear_on_exit=True,
|
||||
status=status,
|
||||
)
|
||||
if idx < 0: # Esc / q
|
||||
return
|
||||
action = rows[idx].action
|
||||
if action == "back":
|
||||
return
|
||||
if action == "login":
|
||||
# ``kimi login`` runs in the foreground (OAuth / API-key prompt);
|
||||
# its boolean return is unreliable for kimi (no status probe), so
|
||||
# don't assert success — just confirm the flow finished.
|
||||
console.print(" [dim]Signing in to Kimi (its login will open)…[/dim]")
|
||||
harness_login(KIMI_KEY)
|
||||
status = "kimi login flow finished — kimi stores its own credentials"
|
||||
elif action == "logout":
|
||||
console.print(" [dim]Signing out of Kimi…[/dim]")
|
||||
harness_logout(KIMI_KEY)
|
||||
status = "kimi logout flow finished"
|
||||
elif action == "help":
|
||||
_print_kimi_auth_help()
|
||||
status = None
|
||||
|
||||
|
||||
def _manage_credential(provider: str, family: str) -> str | None:
|
||||
"""Run the level-3 loop for one credential: make default / remove.
|
||||
|
||||
@@ -9360,7 +9572,7 @@ def _run_configure_harnesses_interactive() -> None:
|
||||
provider and adopts any ambient-detected credential — announcing the
|
||||
newly auto-configured machine credentials in a callout — then loops on
|
||||
the level-1 harness overview (Claude / Codex / Pi / Cursor / Antigravity /
|
||||
Qwen Code / Quit) until the user quits or presses Esc.
|
||||
Qwen Code / Kimi Code / Quit) until the user quits or presses Esc.
|
||||
|
||||
:returns: None. Side effect: may write ``~/.omnigent/config.yaml`` via
|
||||
the backfill/adopt steps and any add/set-default/remove the user
|
||||
@@ -9380,9 +9592,11 @@ def _run_configure_harnesses_interactive() -> None:
|
||||
)
|
||||
from omnigent.onboarding.harness_install import (
|
||||
CURSOR_KEY,
|
||||
KIMI_KEY,
|
||||
QWEN_KEY,
|
||||
harness_cli_installed,
|
||||
harness_install_command,
|
||||
harness_install_spec,
|
||||
)
|
||||
from omnigent.onboarding.interactive import select
|
||||
from omnigent.onboarding.provider_config import (
|
||||
@@ -9426,6 +9640,11 @@ def _run_configure_harnesses_interactive() -> None:
|
||||
# provider family (its v1 auth is the CLI's own env vars / ``/auth`` flow,
|
||||
# not an Omnigent credential), so it dispatches to its own drill-in.
|
||||
_QWEN = "\x00qwen"
|
||||
# Sentinel marking the Kimi Code row — like Cursor/Antigravity/Qwen it is
|
||||
# not a provider family. Auth lives entirely in the kimi CLI (``kimi login``
|
||||
# / ``kimi provider add`` → ~/.kimi/config.toml), so it dispatches to its
|
||||
# own drill-in rather than ``_manage_harness_providers``.
|
||||
_KIMI = "\x00kimi"
|
||||
families = [ANTHROPIC_FAMILY, OPENAI_FAMILY, PI_SURFACE]
|
||||
while True:
|
||||
config = _load_global_config()
|
||||
@@ -9556,6 +9775,30 @@ def _run_configure_harnesses_interactive() -> None:
|
||||
options.append(f" {qwen_sub}")
|
||||
selectable.append(False)
|
||||
row_target.append(None)
|
||||
# Kimi Code (Moonshot AI's multi-provider CLI, no provider family — like
|
||||
# Cursor / Antigravity / Qwen). Auth lives entirely in the kimi CLI and
|
||||
# Omnigent stores no kimi credential, so "ready" is just whether the
|
||||
# binary is installed; the drill-in runs install + ``kimi login``. Kimi
|
||||
# has no status probe, so the overview can't claim "signed in" — it only
|
||||
# distinguishes installed vs. not.
|
||||
kimi_installed = harness_cli_installed(KIMI_KEY)
|
||||
options.append(f"{' ' if kimi_installed else '[red]✗[/] '}Kimi Code")
|
||||
selectable.append(True)
|
||||
row_target.append(_KIMI)
|
||||
if not kimi_installed:
|
||||
from rich.markup import escape as _rich_escape
|
||||
|
||||
# Kimi is curl-installed (package=None), so use its install_hint —
|
||||
# ``harness_install_command`` raises ValueError for non-npm specs.
|
||||
_kimi_spec = harness_install_spec(KIMI_KEY)
|
||||
kimi_hint = (_kimi_spec.install_hint if _kimi_spec else None) or "see Kimi Code docs"
|
||||
kimi_cmd = _rich_escape(kimi_hint)
|
||||
kimi_sub = f"[dim]not installed — open to install ({kimi_cmd})[/]"
|
||||
else:
|
||||
kimi_sub = "[dim]installed — open to sign in (kimi login)[/]"
|
||||
options.append(f" {kimi_sub}")
|
||||
selectable.append(False)
|
||||
row_target.append(None)
|
||||
options.append("Quit")
|
||||
selectable.append(True)
|
||||
row_target.append(_QUIT)
|
||||
@@ -9576,6 +9819,8 @@ def _run_configure_harnesses_interactive() -> None:
|
||||
_manage_antigravity_harness()
|
||||
elif target == _QWEN:
|
||||
_manage_qwen_harness()
|
||||
elif target == _KIMI:
|
||||
_manage_kimi_harness()
|
||||
else: # Quit row (or, defensively, a non-family row)
|
||||
return
|
||||
|
||||
|
||||
@@ -15,6 +15,12 @@ HARNESS_ALIASES: dict[str, str] = {
|
||||
# canonical id is "antigravity" (matches the registry / workflow type).
|
||||
"agy": "antigravity",
|
||||
"google-antigravity": "antigravity",
|
||||
# User-facing spelling for Moonshot AI's Kimi Code CLI; the canonical id
|
||||
# is "kimi" (matches the binary and the registry / workflow type).
|
||||
"kimi-code": "kimi",
|
||||
# Reversed spelling for the native Kimi Code TUI harness; canonical id is
|
||||
# "kimi-native" (the SDK/headless harness keeps the bare "kimi" id).
|
||||
"native-kimi": "kimi-native",
|
||||
# Qwen Code harness alias.
|
||||
"qwen-code": "qwen",
|
||||
}
|
||||
@@ -35,6 +41,8 @@ NATIVE_HARNESSES: frozenset[str] = frozenset(
|
||||
"native-pi",
|
||||
"cursor-native",
|
||||
"native-cursor",
|
||||
"kimi-native",
|
||||
"native-kimi",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
"""Kimi Code CLI executor.
|
||||
|
||||
Drives Moonshot AI's upstream ``kimi`` CLI from
|
||||
https://github.com/MoonshotAI/Kimi-Code (the curl-installed
|
||||
single-binary build at https://code.kimi.com/kimi-code/install.sh).
|
||||
The legacy pypi ``kimi-cli`` package is **not** supported — its
|
||||
command-line surface (``--print``, list-of-blocks content, etc.) is
|
||||
incompatible with the upstream binary the issue (#271) targets.
|
||||
|
||||
One ``kimi -p <prompt> --output-format stream-json`` subprocess per
|
||||
Omnigent turn:
|
||||
|
||||
- parses each JSONL line on stdout into one or more
|
||||
:class:`ExecutorEvent` (assistant text, tool-call request, tool-call
|
||||
result, session metadata),
|
||||
- captures the kimi session id from the ``role:"meta"`` /
|
||||
``type:"session.resume_hint"`` line for resume on the next turn,
|
||||
- uses the subprocess's ``cwd=`` for the working directory (upstream
|
||||
has no ``--work-dir`` flag).
|
||||
|
||||
Kimi runs its own agent loop and its own tools (Bash, edit, read, web,
|
||||
…) — Omnigent does not re-execute them. The executor advertises
|
||||
``handles_tools_internally=True`` and forwards ``tool_calls`` /
|
||||
``role:"tool"`` events from kimi's transcript as informational
|
||||
:class:`ToolCallRequest` / :class:`ToolCallComplete` so the Omnigent
|
||||
UI can render them, but the Session layer does not dispatch them.
|
||||
|
||||
Env-var contract (read once at construction by
|
||||
:mod:`omnigent.inner.kimi_harness`):
|
||||
|
||||
- ``HARNESS_KIMI_MODEL``: Kimi-side model id, e.g. ``"kimi-k2-turbo"``.
|
||||
``None`` lets the kimi config's ``default_model`` win.
|
||||
- ``HARNESS_KIMI_CWD``: working directory the kimi subprocess runs in.
|
||||
Upstream has no ``--work-dir`` flag so this is threaded through
|
||||
``cwd=`` on the subprocess. ``None`` falls back to the runner's cwd.
|
||||
- ``HARNESS_KIMI_PATH``: explicit path to the ``kimi`` binary, e.g.
|
||||
``"/Users/x/.kimi-code/bin/kimi"``. Defaults to ``"kimi"`` looked up
|
||||
on ``PATH``.
|
||||
- ``HARNESS_KIMI_PLAN``: truthy → ``--plan`` (read-only plan mode).
|
||||
- ``HARNESS_KIMI_CONTINUE_LAST``: truthy → ``--continue`` (resume the
|
||||
most recent session for the working directory). Mutually exclusive
|
||||
with ``HARNESS_KIMI_SESSION_ID``; the explicit session id wins.
|
||||
- ``HARNESS_KIMI_SKILLS_DIRS``: JSON list of paths forwarded as one
|
||||
``--skills-dir <path>`` per entry. Empty / unset = use kimi's
|
||||
default skill discovery (user + project dirs).
|
||||
|
||||
Per-invocation provider routing (``--config-file`` / ``--mcp-config-file``
|
||||
/ gateway env vars) is **not** wired: upstream kimi has no per-spawn
|
||||
config override. Provider configuration lives in ``~/.kimi/config.toml``
|
||||
and is managed out-of-band via ``kimi provider add``. See
|
||||
``docs/KIMI_FOLLOWUPS.md`` for the deferred provider-injection follow-up.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from omnigent.inner.datamodel import OSEnvSpec
|
||||
from omnigent.inner.executor import (
|
||||
EnqueuedContent,
|
||||
Executor,
|
||||
ExecutorConfig,
|
||||
ExecutorError,
|
||||
ExecutorEvent,
|
||||
Message,
|
||||
TextChunk,
|
||||
ToolArgs,
|
||||
ToolCallComplete,
|
||||
ToolCallRequest,
|
||||
ToolCallStatus,
|
||||
ToolSpec,
|
||||
TurnComplete,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Matches the resume hint kimi also prints to stderr / stdout (best-effort
|
||||
# fallback for when the ``role:"meta"`` JSON event isn't seen). The session
|
||||
# id format is ``session_<hex-uuid>`` — we accept the broader ``\S+`` to
|
||||
# survive minor format drift.
|
||||
_SESSION_RESUME_RE = re.compile(
|
||||
r"To resume this session:\s+\S+\s+-r\s+(\S+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _parse_truthy(value: str | None) -> bool:
|
||||
"""Return True for "1"/"true"/"yes"/"on" (case-insensitive)."""
|
||||
if value is None:
|
||||
return False
|
||||
return value.strip().lower() in {"1", "true", "yes", "on", "y"}
|
||||
|
||||
|
||||
def _resolve_kimi_binary() -> str:
|
||||
"""Resolve the ``kimi`` binary path.
|
||||
|
||||
``HARNESS_KIMI_PATH`` wins (lets users point at a custom build or a
|
||||
non-standard install location). Otherwise default to ``"kimi"`` and
|
||||
rely on ``shutil.which`` so a missing binary surfaces clearly at
|
||||
``run_turn``.
|
||||
|
||||
The legacy pypi ``kimi-cli`` package is intentionally NOT detected —
|
||||
its command-line surface is incompatible with the upstream binary
|
||||
Omnigent supports.
|
||||
"""
|
||||
explicit = os.environ.get("HARNESS_KIMI_PATH", "").strip()
|
||||
if explicit:
|
||||
return explicit
|
||||
return "kimi"
|
||||
|
||||
|
||||
def _latest_user_text(messages: list[Message]) -> str:
|
||||
"""Extract the most recent user message's text.
|
||||
|
||||
Kimi receives the conversation history via ``--session <id>``, not
|
||||
via stdin, so we only need the most recent user turn to drive
|
||||
``-p <text>``. Image / file / audio content blocks are dropped with
|
||||
a single warning per turn (multimodal input is deferred — see
|
||||
``docs/KIMI_FOLLOWUPS.md``).
|
||||
"""
|
||||
dropped_blocks = 0
|
||||
for message in reversed(messages):
|
||||
if message.get("role") != "user":
|
||||
continue
|
||||
content = message.get("content")
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
text_parts: list[str] = []
|
||||
for block in content:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
block_type = block.get("type")
|
||||
if block_type in ("text", "input_text") and isinstance(block.get("text"), str):
|
||||
text_parts.append(block["text"])
|
||||
elif block_type in ("input_image", "input_file", "input_audio"):
|
||||
dropped_blocks += 1
|
||||
if dropped_blocks:
|
||||
_logger.warning(
|
||||
"kimi harness: dropped %d non-text content block(s) on the "
|
||||
"latest user message (multimodal input not yet wired — see "
|
||||
"docs/KIMI_FOLLOWUPS.md)",
|
||||
dropped_blocks,
|
||||
)
|
||||
return "".join(text_parts)
|
||||
return ""
|
||||
|
||||
|
||||
def _resolve_skills_dirs(raw: str | None) -> list[str]:
|
||||
"""Parse ``HARNESS_KIMI_SKILLS_DIRS`` (JSON list of paths) into a list.
|
||||
|
||||
Returns ``[]`` when unset / malformed so kimi falls back to its
|
||||
default discovery (user + project skill dirs).
|
||||
"""
|
||||
if not raw or not raw.strip():
|
||||
return []
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
_logger.warning("HARNESS_KIMI_SKILLS_DIRS is not valid JSON (%s); ignoring", exc)
|
||||
return []
|
||||
if not isinstance(parsed, list) or not all(isinstance(p, str) for p in parsed):
|
||||
_logger.warning(
|
||||
"HARNESS_KIMI_SKILLS_DIRS must be a JSON array of strings; got %r; ignoring",
|
||||
parsed,
|
||||
)
|
||||
return []
|
||||
return list(parsed)
|
||||
|
||||
|
||||
class KimiExecutor(Executor):
|
||||
"""Drive ``kimi -p`` per Omnigent turn.
|
||||
|
||||
See module docstring for env-var contract and lifecycle.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
cwd: str | None = None,
|
||||
os_env: OSEnvSpec | None = None,
|
||||
model: str | None = None,
|
||||
binary_path: str | None = None,
|
||||
plan: bool = False,
|
||||
continue_last_session: bool = False,
|
||||
skills_dirs: list[str] | None = None,
|
||||
) -> None:
|
||||
self._cwd = cwd
|
||||
self._os_env = os_env
|
||||
self._model = model
|
||||
self._binary_path = binary_path or _resolve_kimi_binary()
|
||||
self._plan = plan
|
||||
self._continue_last_session = continue_last_session
|
||||
self._skills_dirs = list(skills_dirs or [])
|
||||
|
||||
# Per-session state: kimi session id captured from the prior turn's
|
||||
# ``role:"meta"`` event, fed to ``-S <id>`` on the next turn.
|
||||
self._session_id: str | None = None
|
||||
# Tracks whether we've already warned this session about tools
|
||||
# being declared without a provider-injection bridge (one warning
|
||||
# per session — see docs/KIMI_FOLLOWUPS.md item 1).
|
||||
self._warned_tools_without_bridge = False
|
||||
# Active subprocess handle, captured so interrupt can target it.
|
||||
self._active_process: asyncio.subprocess.Process | None = None
|
||||
|
||||
# -- capabilities --------------------------------------------------------
|
||||
|
||||
def handles_tools_internally(self) -> bool:
|
||||
return True
|
||||
|
||||
def supports_streaming(self) -> bool:
|
||||
return True
|
||||
|
||||
def supports_tool_calling(self) -> bool:
|
||||
return True
|
||||
|
||||
# -- helpers -------------------------------------------------------------
|
||||
|
||||
def _build_spawn_env(self) -> dict[str, str]:
|
||||
"""The env handed to the kimi subprocess.
|
||||
|
||||
Inherits the harness wrap's own env (so ``KIMI_*`` auth vars
|
||||
the user exported reach the subprocess) and adds nothing — all
|
||||
``HARNESS_KIMI_*`` knobs are read on the wrap side and
|
||||
translated into CLI flags.
|
||||
"""
|
||||
return dict(os.environ)
|
||||
|
||||
def _build_argv(self, *, prompt_text: str) -> list[str]:
|
||||
"""Assemble the kimi argv for one turn.
|
||||
|
||||
Upstream ``-p <text>`` is the headless print mode (mutually
|
||||
exclusive with ``--yolo`` because ``-p`` already auto-approves).
|
||||
``--output-format stream-json`` makes stdout a JSONL transcript.
|
||||
``-S`` resumes a session; ``-C`` continues the last session for
|
||||
this cwd. The explicit session id wins when both are set.
|
||||
"""
|
||||
argv: list[str] = [
|
||||
self._binary_path,
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
]
|
||||
|
||||
if self._model:
|
||||
argv.extend(["-m", self._model])
|
||||
|
||||
if self._plan:
|
||||
argv.append("--plan")
|
||||
|
||||
for skills_dir in self._skills_dirs:
|
||||
argv.extend(["--skills-dir", skills_dir])
|
||||
|
||||
if self._session_id:
|
||||
argv.extend(["-S", self._session_id])
|
||||
elif self._continue_last_session:
|
||||
argv.append("-C")
|
||||
|
||||
# ``-p`` must come last because it consumes a single argument; placing
|
||||
# other flags after it would be parsed as part of the prompt.
|
||||
argv.extend(["-p", prompt_text])
|
||||
return argv
|
||||
|
||||
def _translate_event(self, payload: dict[str, Any]) -> list[ExecutorEvent]:
|
||||
"""Translate one kimi stream-json line into Omnigent events.
|
||||
|
||||
Upstream emits whole messages (not deltas). Roles seen:
|
||||
|
||||
- ``"assistant"``: may carry ``content`` (a plain string with the
|
||||
assistant's reply) and/or ``tool_calls`` (the model invoking
|
||||
one of kimi's internal tools).
|
||||
- ``"tool"``: kimi's own tool execution result delivered back to
|
||||
its loop. Surfaced as a ``ToolCallComplete`` so the Omnigent
|
||||
UI can render it; the Session layer does not re-execute
|
||||
(``handles_tools_internally=True``).
|
||||
- ``"meta"`` with ``type:"session.resume_hint"``: carries the
|
||||
kimi session id we capture for resume on the next turn.
|
||||
|
||||
Unknown roles / types are silently ignored — kimi may grow new
|
||||
event types in future versions.
|
||||
"""
|
||||
events: list[ExecutorEvent] = []
|
||||
role = payload.get("role")
|
||||
|
||||
if role == "assistant":
|
||||
content = payload.get("content")
|
||||
if isinstance(content, str) and content:
|
||||
events.append(TextChunk(text=content))
|
||||
tool_calls = payload.get("tool_calls") or []
|
||||
if isinstance(tool_calls, list):
|
||||
for call in tool_calls:
|
||||
if not isinstance(call, dict):
|
||||
continue
|
||||
fn = call.get("function") or {}
|
||||
if not isinstance(fn, dict):
|
||||
continue
|
||||
name = fn.get("name") or ""
|
||||
raw_args = fn.get("arguments")
|
||||
args: ToolArgs = {}
|
||||
if isinstance(raw_args, str):
|
||||
with contextlib.suppress(json.JSONDecodeError):
|
||||
parsed = json.loads(raw_args)
|
||||
if isinstance(parsed, dict):
|
||||
args = parsed
|
||||
elif isinstance(raw_args, dict):
|
||||
args = raw_args
|
||||
call_id = call.get("id") or ""
|
||||
if name:
|
||||
events.append(
|
||||
ToolCallRequest(
|
||||
name=name,
|
||||
args=args,
|
||||
metadata={"call_id": call_id} if call_id else {},
|
||||
)
|
||||
)
|
||||
elif role == "tool":
|
||||
# Kimi has already executed the tool. Emit a synthetic completion
|
||||
# so the Omnigent UI can render the result. The Session layer
|
||||
# will not double-execute (handles_tools_internally=True).
|
||||
result = payload.get("content")
|
||||
call_id = payload.get("tool_call_id") or ""
|
||||
events.append(
|
||||
ToolCallComplete(
|
||||
name="", # kimi doesn't repeat the name in tool results
|
||||
status=ToolCallStatus.SUCCESS,
|
||||
result=result,
|
||||
metadata={"call_id": call_id} if call_id else {},
|
||||
)
|
||||
)
|
||||
elif role == "meta" and payload.get("type") == "session.resume_hint":
|
||||
captured = payload.get("session_id")
|
||||
if isinstance(captured, str) and captured:
|
||||
self._session_id = captured
|
||||
# Anything else: ignore silently.
|
||||
return events
|
||||
|
||||
# -- main entrypoint -----------------------------------------------------
|
||||
|
||||
async def run_turn(
|
||||
self,
|
||||
messages: list[Message],
|
||||
tools: list[ToolSpec],
|
||||
system_prompt: str, # noqa: ARG002 — kimi's own agent spec carries instructions
|
||||
config: ExecutorConfig | None = None, # noqa: ARG002 — per-turn override not yet plumbed
|
||||
) -> AsyncIterator[ExecutorEvent]:
|
||||
if tools and not self._warned_tools_without_bridge:
|
||||
_logger.warning(
|
||||
"kimi executor received %d declared tool(s) but Omnigent has no "
|
||||
"tool-injection bridge for the upstream kimi binary yet (no "
|
||||
"per-spawn --mcp-config-file). The tools will not be exposed to "
|
||||
"kimi for this session. See docs/KIMI_FOLLOWUPS.md for the MCP "
|
||||
"follow-up.",
|
||||
len(tools),
|
||||
)
|
||||
self._warned_tools_without_bridge = True
|
||||
|
||||
if shutil.which(self._binary_path) is None and not Path(self._binary_path).exists():
|
||||
yield ExecutorError(
|
||||
message=(
|
||||
f"kimi harness: binary {self._binary_path!r} not found on PATH. "
|
||||
"Install via `curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash` "
|
||||
"or set HARNESS_KIMI_PATH to its absolute location."
|
||||
),
|
||||
retryable=False,
|
||||
)
|
||||
return
|
||||
|
||||
prompt_text = _latest_user_text(messages)
|
||||
if not prompt_text:
|
||||
yield TurnComplete(response=None)
|
||||
return
|
||||
|
||||
argv = self._build_argv(prompt_text=prompt_text)
|
||||
env = self._build_spawn_env()
|
||||
|
||||
started_at = time.monotonic()
|
||||
process: asyncio.subprocess.Process | None = None
|
||||
stderr_buf = bytearray()
|
||||
any_text_emitted = False
|
||||
final_text_parts: list[str] = []
|
||||
try:
|
||||
process = await _create_subprocess_exec(
|
||||
*argv,
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=self._cwd or None,
|
||||
env=env,
|
||||
)
|
||||
self._active_process = process
|
||||
|
||||
assert process.stdout is not None
|
||||
assert process.stderr is not None
|
||||
|
||||
async def _drain_stderr() -> None:
|
||||
"""Buffer stderr so the resume-hint fallback regex can read it after exit."""
|
||||
assert process is not None and process.stderr is not None
|
||||
while True:
|
||||
chunk = await process.stderr.read(4096)
|
||||
if not chunk:
|
||||
return
|
||||
stderr_buf.extend(chunk)
|
||||
|
||||
stderr_task = asyncio.create_task(_drain_stderr())
|
||||
try:
|
||||
async for raw_line in process.stdout:
|
||||
line = raw_line.decode("utf-8", errors="replace").strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
# Kimi sometimes prints informational lines on stdout
|
||||
# (e.g. ``Shell cwd was reset to ...``). Log at debug
|
||||
# and move on — never crash on non-JSON.
|
||||
_logger.debug("kimi executor: non-JSON stdout line: %s", line[:200])
|
||||
continue
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
for event in self._translate_event(payload):
|
||||
if isinstance(event, TextChunk):
|
||||
any_text_emitted = True
|
||||
final_text_parts.append(event.text)
|
||||
yield event
|
||||
finally:
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await stderr_task
|
||||
except asyncio.CancelledError:
|
||||
if process is not None:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
process.terminate()
|
||||
raise
|
||||
finally:
|
||||
self._active_process = None
|
||||
if process is not None:
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=2.0)
|
||||
except asyncio.TimeoutError:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
process.kill()
|
||||
with contextlib.suppress(Exception):
|
||||
await process.wait()
|
||||
|
||||
# Fallback: if the ``role:"meta"`` JSON event wasn't seen but the
|
||||
# stderr footer carries the resume hint, capture from there. Mostly
|
||||
# belt-and-suspenders against minor stream-json schema drift.
|
||||
if not self._session_id:
|
||||
stderr_text = stderr_buf.decode("utf-8", errors="replace")
|
||||
match = _SESSION_RESUME_RE.search(stderr_text)
|
||||
if match:
|
||||
self._session_id = match.group(1)
|
||||
if not self._session_id:
|
||||
# No id surfaced anywhere — mint one so the next turn at least
|
||||
# has a stable handle. ``-S <unknown>`` will start a fresh
|
||||
# session on kimi's side, which is the desired graceful fallback.
|
||||
self._session_id = uuid.uuid4().hex
|
||||
|
||||
elapsed_ms = (time.monotonic() - started_at) * 1000.0
|
||||
if process is not None and process.returncode not in (None, 0):
|
||||
stderr_text = stderr_buf.decode("utf-8", errors="replace")
|
||||
yield ExecutorError(
|
||||
message=(
|
||||
f"kimi exited with code {process.returncode} after "
|
||||
f"{elapsed_ms:.0f}ms. stderr: {stderr_text.strip()[:500]}"
|
||||
),
|
||||
retryable=False,
|
||||
)
|
||||
return
|
||||
|
||||
yield TurnComplete(
|
||||
response="".join(final_text_parts) if any_text_emitted else None,
|
||||
)
|
||||
|
||||
# -- session lifecycle ---------------------------------------------------
|
||||
|
||||
async def close_session(self, session_key: str) -> None: # noqa: ARG002 — per-session id is the kimi UUID, no extra teardown
|
||||
"""Drop the captured session id so the next turn starts fresh.
|
||||
|
||||
The kimi subprocess is per-turn, so there is no long-lived
|
||||
resource to release. We just forget the cached session id.
|
||||
"""
|
||||
self._session_id = None
|
||||
|
||||
async def interrupt_session(self, session_key: str) -> bool: # noqa: ARG002 — best-effort process terminate
|
||||
"""Terminate the active kimi process, if any.
|
||||
|
||||
Returns True when a process was actually signalled. The next
|
||||
``run_turn`` will start a fresh process (and a fresh ``-S``
|
||||
resume if the cached id is still valid).
|
||||
"""
|
||||
process = self._active_process
|
||||
if process is None or process.returncode is not None:
|
||||
return False
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
process.terminate()
|
||||
return True
|
||||
return False
|
||||
|
||||
async def enqueue_session_message(
|
||||
self,
|
||||
session_key: str, # noqa: ARG002 — per-turn subprocess model; no live queue
|
||||
content: EnqueuedContent, # noqa: ARG002 — per-turn subprocess model; no live queue
|
||||
) -> bool:
|
||||
"""Not supported under the per-turn subprocess model.
|
||||
|
||||
The ``kimi acp`` long-lived path would unlock this — see
|
||||
``docs/KIMI_FOLLOWUPS.md``.
|
||||
"""
|
||||
return False
|
||||
|
||||
|
||||
async def _create_subprocess_exec(
|
||||
*args: Any, # type: ignore[explicit-any]
|
||||
**kwargs: Any, # type: ignore[explicit-any]
|
||||
) -> asyncio.subprocess.Process:
|
||||
"""Indirection point so tests can stub subprocess creation.
|
||||
|
||||
Direct patching of ``asyncio.create_subprocess_exec`` in tests is
|
||||
tricky because asyncio caches the bound method. Tests patch this
|
||||
module-level helper instead.
|
||||
"""
|
||||
return await asyncio.create_subprocess_exec(*args, **kwargs)
|
||||
@@ -0,0 +1,134 @@
|
||||
"""``harness: kimi`` wrap.
|
||||
|
||||
Thin module exposing :func:`create_app` — the entrypoint the shared
|
||||
:mod:`omnigent.runtime.harnesses._runner` invokes after the parent
|
||||
process resolves ``"kimi"`` to this module via
|
||||
:data:`omnigent.runtime.harnesses._HARNESS_MODULES`.
|
||||
|
||||
Wraps a :class:`omnigent.inner.kimi_executor.KimiExecutor` that drives
|
||||
the upstream Moonshot AI ``kimi`` CLI
|
||||
(https://github.com/MoonshotAI/Kimi-Code) headlessly via
|
||||
``kimi -p <prompt> --output-format stream-json`` per turn.
|
||||
|
||||
Env vars read at startup (full contract in
|
||||
``omnigent.inner.kimi_executor``):
|
||||
|
||||
- ``HARNESS_KIMI_MODEL`` — model id (e.g. ``kimi-k2-turbo``); ``None``
|
||||
lets kimi's ``default_model`` from ``~/.kimi/config.toml`` win.
|
||||
- ``HARNESS_KIMI_CWD`` — working directory the kimi subprocess runs in
|
||||
(upstream has no ``--work-dir`` flag, so this is threaded as
|
||||
subprocess ``cwd=``).
|
||||
- ``HARNESS_KIMI_PATH`` — path to the ``kimi`` binary. Default
|
||||
``"kimi"``.
|
||||
- ``HARNESS_KIMI_PLAN`` — truthy → ``--plan`` (read-only plan mode).
|
||||
- ``HARNESS_KIMI_CONTINUE_LAST`` — truthy → ``-C`` (continue the
|
||||
previous session for the working directory). Mutually exclusive with
|
||||
an active resume id; the explicit id wins.
|
||||
- ``HARNESS_KIMI_SKILLS_DIRS`` — JSON array of paths, each forwarded
|
||||
as ``--skills-dir <path>``.
|
||||
- ``HARNESS_KIMI_OS_ENV`` — JSON-encoded :class:`OSEnvSpec`. ``None``
|
||||
falls back to ``caller_process + sandbox=none`` (kimi handles its
|
||||
own sandbox + approval flow internally).
|
||||
|
||||
Provider routing for kimi happens via ``kimi provider add`` / its
|
||||
``~/.kimi/config.toml`` (out-of-band from Omnigent) — upstream kimi
|
||||
has no per-spawn ``--config-file`` or env-var provider override. See
|
||||
``docs/KIMI_FOLLOWUPS.md`` for the deferred Omnigent-side provider
|
||||
injection work.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
|
||||
from omnigent.inner.executor import Executor
|
||||
from omnigent.inner.kimi_executor import KimiExecutor, _resolve_skills_dirs
|
||||
from omnigent.runtime.harnesses._executor_adapter import ExecutorAdapter
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
_ENV_MODEL = "HARNESS_KIMI_MODEL"
|
||||
_ENV_CWD = "HARNESS_KIMI_CWD"
|
||||
_ENV_BIN = "HARNESS_KIMI_PATH"
|
||||
_ENV_PLAN = "HARNESS_KIMI_PLAN"
|
||||
_ENV_CONTINUE_LAST = "HARNESS_KIMI_CONTINUE_LAST"
|
||||
_ENV_SKILLS_DIRS = "HARNESS_KIMI_SKILLS_DIRS"
|
||||
_ENV_OS_ENV = "HARNESS_KIMI_OS_ENV"
|
||||
|
||||
|
||||
def _parse_truthy_with_default(value: str | None, *, default: bool) -> bool:
|
||||
"""Same as ``_parse_truthy`` but with an explicit default for unset/empty."""
|
||||
if value is None or value.strip() == "":
|
||||
return default
|
||||
return value.strip().lower() in {"1", "true", "yes", "on", "y"}
|
||||
|
||||
|
||||
def _resolve_os_env() -> OSEnvSpec:
|
||||
"""Resolve the inner :class:`OSEnvSpec` from :data:`_ENV_OS_ENV`.
|
||||
|
||||
Mirrors the cursor / antigravity wraps' default: when no spec was
|
||||
serialised, fall back to ``caller_process + sandbox=none``. Kimi
|
||||
has its own internal sandbox / approval flow, so Omnigent does not
|
||||
wrap the subprocess in bwrap / seatbelt by default — the user can
|
||||
still set a sandbox via the spec's ``os_env`` block.
|
||||
"""
|
||||
raw = os.environ.get(_ENV_OS_ENV, "").strip()
|
||||
if raw:
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
_logger.warning(
|
||||
"%s is not valid JSON (%s); falling back to default os_env",
|
||||
_ENV_OS_ENV,
|
||||
exc,
|
||||
)
|
||||
payload = None
|
||||
if isinstance(payload, dict):
|
||||
sandbox_payload = payload.get("sandbox")
|
||||
sandbox = (
|
||||
OSEnvSandboxSpec(**sandbox_payload) if isinstance(sandbox_payload, dict) else None
|
||||
)
|
||||
return OSEnvSpec(
|
||||
type=str(payload.get("type", "caller_process")),
|
||||
cwd=payload.get("cwd"),
|
||||
sandbox=sandbox,
|
||||
fork=bool(payload.get("fork", False)),
|
||||
)
|
||||
return OSEnvSpec(
|
||||
type="caller_process",
|
||||
cwd=None,
|
||||
sandbox=OSEnvSandboxSpec(type="none"),
|
||||
fork=False,
|
||||
)
|
||||
|
||||
|
||||
def _build_kimi_executor() -> Executor:
|
||||
"""Construct a :class:`KimiExecutor` from env-var config.
|
||||
|
||||
Called lazily by :class:`ExecutorAdapter` on the first turn, so a
|
||||
missing ``kimi`` binary surfaces as a request-time error (not an
|
||||
app-boot crash) — matching how the cursor / antigravity wraps
|
||||
defer their SDK / binary lookup.
|
||||
"""
|
||||
return KimiExecutor(
|
||||
cwd=os.environ.get(_ENV_CWD) or None,
|
||||
os_env=_resolve_os_env(),
|
||||
model=os.environ.get(_ENV_MODEL) or None,
|
||||
binary_path=os.environ.get(_ENV_BIN) or None,
|
||||
plan=_parse_truthy_with_default(os.environ.get(_ENV_PLAN), default=False),
|
||||
continue_last_session=_parse_truthy_with_default(
|
||||
os.environ.get(_ENV_CONTINUE_LAST), default=False
|
||||
),
|
||||
skills_dirs=_resolve_skills_dirs(os.environ.get(_ENV_SKILLS_DIRS)),
|
||||
)
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""Build the kimi harness's FastAPI app (required entry point)."""
|
||||
adapter = ExecutorAdapter(executor_factory=_build_kimi_executor)
|
||||
return adapter.build()
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Executor that bridges Omnigent web-chat turns into the native Kimi TUI.
|
||||
|
||||
It does not launch ``kimi`` — the ``omnigent kimi`` wrapper already
|
||||
launched the interactive TUI in the session terminal. Each web-UI turn injects
|
||||
the latest user message into that same tmux pane (bracketed paste + Enter), so
|
||||
the message appears in the running Kimi TUI (and, since the web UI embeds the
|
||||
pane, in both surfaces). Output is terminal-originated; the embedded terminal
|
||||
renders it live.
|
||||
|
||||
This is a DIFFERENT executor from the headless :class:`~omnigent.inner.
|
||||
kimi_executor.KimiExecutor`, which shells ``kimi -p … --output-format
|
||||
stream-json`` per turn. This one types into a resident ``kimi`` TUI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from omnigent.inner.executor import (
|
||||
Executor,
|
||||
ExecutorConfig,
|
||||
ExecutorError,
|
||||
ExecutorEvent,
|
||||
Message,
|
||||
ToolSpec,
|
||||
TurnComplete,
|
||||
)
|
||||
from omnigent.kimi_native_bridge import BRIDGE_DIR_ENV_VAR, inject_user_message
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class KimiNativeExecutor(Executor):
|
||||
"""Harness-side executor for ``omnigent kimi`` web-UI turns.
|
||||
|
||||
Injects each web-UI message into the running Kimi TUI's tmux pane. Does not
|
||||
stream output (the embedded terminal shows it); accepts mid-turn steering.
|
||||
|
||||
:param bridge_dir: Optional bridge dir override; ``None`` reads
|
||||
:data:`BRIDGE_DIR_ENV_VAR` from the harness spawn env.
|
||||
"""
|
||||
|
||||
def __init__(self, bridge_dir: Path | None = None) -> None:
|
||||
self._bridge_dir = bridge_dir or _bridge_dir_from_env()
|
||||
# Serializes writes to the shared tmux pane: run_turn (initiating
|
||||
# message) and enqueue_session_message (steering) run concurrently
|
||||
# against one cached executor, and injection is multi-step (clear +
|
||||
# paste + Enter) — without the lock their keystrokes interleave.
|
||||
self._inject_lock = asyncio.Lock()
|
||||
|
||||
def supports_streaming(self) -> bool:
|
||||
""":returns: ``False`` — output is shown by the embedded terminal, not this executor."""
|
||||
return False
|
||||
|
||||
def supports_live_message_queue(self) -> bool:
|
||||
""":returns: ``True`` — messages can be injected mid-turn (steering)."""
|
||||
return True
|
||||
|
||||
async def enqueue_session_message(self, session_key: str, content: Any) -> bool:
|
||||
"""Inject a live steering message into the Kimi terminal."""
|
||||
del session_key
|
||||
text = _content_to_text(content, self._bridge_dir)
|
||||
if not text:
|
||||
return False
|
||||
try:
|
||||
async with self._inject_lock:
|
||||
await asyncio.to_thread(inject_user_message, self._bridge_dir, content=text)
|
||||
except RuntimeError:
|
||||
return False
|
||||
return True
|
||||
|
||||
async def run_turn(
|
||||
self,
|
||||
messages: list[Message],
|
||||
tools: list[ToolSpec],
|
||||
system_prompt: str,
|
||||
config: ExecutorConfig | None = None,
|
||||
) -> AsyncIterator[ExecutorEvent]:
|
||||
"""Inject the latest web-UI user message into the Kimi TUI pane."""
|
||||
del tools, system_prompt, config
|
||||
text = _latest_user_text(messages, self._bridge_dir)
|
||||
if not text:
|
||||
yield ExecutorError(message="kimi native turn had no user text to send")
|
||||
return
|
||||
try:
|
||||
async with self._inject_lock:
|
||||
await asyncio.to_thread(inject_user_message, self._bridge_dir, content=text)
|
||||
except RuntimeError as exc:
|
||||
yield ExecutorError(message=str(exc))
|
||||
return
|
||||
yield TurnComplete(response=None)
|
||||
|
||||
|
||||
def _bridge_dir_from_env() -> Path:
|
||||
"""Resolve the kimi-native bridge dir from the harness spawn env."""
|
||||
raw = os.environ.get(BRIDGE_DIR_ENV_VAR, "").strip()
|
||||
if not raw:
|
||||
raise RuntimeError(f"{BRIDGE_DIR_ENV_VAR} is required for the kimi-native harness")
|
||||
return Path(raw)
|
||||
|
||||
|
||||
def _latest_user_text(messages: list[Message], bridge_dir: Path) -> str:
|
||||
"""Return the latest user message's text (attachments materialized to disk)."""
|
||||
for message in reversed(messages):
|
||||
if message.get("role") == "user":
|
||||
return _content_to_text(message.get("content"), bridge_dir)
|
||||
return ""
|
||||
|
||||
|
||||
def _content_to_text(content: Any, bridge_dir: Path) -> str:
|
||||
"""Normalize executor content into text the Kimi TUI receives.
|
||||
|
||||
Text blocks are extracted directly. Image/file blocks carrying a base64
|
||||
data URI are materialized to the bridge dir and referenced by absolute path
|
||||
(``[Attached: <path>]``) so kimi can open them with its Read tool —
|
||||
otherwise web-UI attachments are silently dropped. Mirrors claude-native.
|
||||
"""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
from omnigent.inner.native_attachments import materialize_attachment
|
||||
|
||||
attachment_lines: list[str] = []
|
||||
text_parts: list[str] = []
|
||||
for block in content:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
block_type = block.get("type", "")
|
||||
if block_type in ("input_text", "text"):
|
||||
text = block.get("text")
|
||||
if isinstance(text, str):
|
||||
text_parts.append(text)
|
||||
elif block_type in ("input_image", "input_file"):
|
||||
path = materialize_attachment(block, bridge_dir)
|
||||
if path is not None:
|
||||
attachment_lines.append(f"[Attached: {path}]")
|
||||
return "\n\n".join(attachment_lines + text_parts)
|
||||
return ""
|
||||
@@ -0,0 +1,43 @@
|
||||
"""``harness: kimi-native`` wrap (the native Kimi Code TUI).
|
||||
|
||||
Thin module exposing :func:`create_app` — the entry point the shared
|
||||
:mod:`omnigent.runtime.harnesses._runner` invokes after the parent process
|
||||
resolves ``"kimi-native"`` to this module via
|
||||
:data:`omnigent.runtime.harnesses._HARNESS_MODULES`.
|
||||
|
||||
Wraps a :class:`omnigent.inner.kimi_native_executor.KimiNativeExecutor`,
|
||||
which injects web-UI messages into the running ``kimi`` TUI (launched by
|
||||
``omnigent kimi`` in the session terminal) via tmux. The bridge dir is read
|
||||
from :data:`~omnigent.kimi_native_bridge.BRIDGE_DIR_ENV_VAR` in the spawn env.
|
||||
|
||||
Tool policies: kimi-native enforces Omnigent's tool deny-policy via a
|
||||
``PreToolUse`` hook (registered in the per-session ``config.toml`` built by
|
||||
:mod:`omnigent.kimi_native_credentials`, dispatched to
|
||||
:mod:`omnigent.kimi_native_hook`). A ``POLICY_ACTION_DENY`` verdict blocks the
|
||||
tool with the policy reason; everything else is "no opinion", so ``kimi``'s own
|
||||
in-TUI approval prompt still runs — the deployment's deny-gate and the user's
|
||||
own consent are kept as two independent gates. A companion ``PermissionRequest``
|
||||
hook surfaces the pending approval in the web UI read-only (the yes/no is
|
||||
answered in the TUI, which Omnigent cannot intercept). Connector/tool ASK
|
||||
policies are not enforced (kimi owns the ask); treat the kimi TUI as the
|
||||
approval surface, with Omnigent able to hard-deny.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from omnigent.inner.executor import Executor
|
||||
from omnigent.inner.kimi_native_executor import KimiNativeExecutor
|
||||
from omnigent.runtime.harnesses._executor_adapter import ExecutorAdapter
|
||||
|
||||
|
||||
def _build_kimi_native_executor() -> Executor:
|
||||
"""Construct a :class:`KimiNativeExecutor` (reads the bridge dir from env)."""
|
||||
return KimiNativeExecutor()
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""Build the kimi-native harness's FastAPI app (required entry point)."""
|
||||
adapter = ExecutorAdapter(executor_factory=_build_kimi_native_executor)
|
||||
return adapter.build()
|
||||
@@ -0,0 +1,637 @@
|
||||
"""Native Kimi TUI wrapper for the Omnigent CLI.
|
||||
|
||||
``omnigent kimi`` launches the Kimi CLI's interactive TUI (``kimi``
|
||||
with no args) inside an Omnigent-runner-owned tmux terminal and attaches the
|
||||
local TTY — the kimi analog of ``omnigent codex`` / ``omnigent pi``. The runner
|
||||
spawns the process (see :func:`omnigent.runner.app._auto_create_kimi_terminal`);
|
||||
this module owns the CLI-side orchestration: session create/resume, daemon
|
||||
runner bind, terminal-ready poll, and the direct tmux attach.
|
||||
|
||||
Auth is the ambient ``kimi login`` (``$HOME/.kimi``); no API key is
|
||||
required. Unlike Pi there is no extension bridge — the runner sets up the
|
||||
terminal environment directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
import httpx
|
||||
import yaml
|
||||
|
||||
from omnigent._native_resume_hint import echo_native_cold_resume_hint, echo_native_resume_hint
|
||||
from omnigent._runner_startup import RunnerStartupProgress, runner_startup_progress
|
||||
from omnigent._wrapper_labels import KIMI_NATIVE_WRAPPER_VALUE as _WRAPPER_LABEL_VALUE
|
||||
from omnigent._wrapper_labels import WRAPPER_LABEL_KEY as _WRAPPER_LABEL_KEY
|
||||
from omnigent.conversation_browser import conversation_url, open_conversation_link_if_enabled
|
||||
from omnigent.entities.session_resources import terminal_resource_id
|
||||
from omnigent.host.daemon_launch import (
|
||||
error_text,
|
||||
launch_or_reuse_daemon_runner,
|
||||
wait_for_host_online,
|
||||
wait_for_runner_online,
|
||||
)
|
||||
from omnigent.native_terminal import (
|
||||
DAEMON_HOST_ONLINE_TIMEOUT_S as _DAEMON_HOST_ONLINE_TIMEOUT_S,
|
||||
)
|
||||
from omnigent.native_terminal import (
|
||||
DAEMON_RUNNER_ONLINE_TIMEOUT_S as _DAEMON_RUNNER_ONLINE_TIMEOUT_S,
|
||||
)
|
||||
from omnigent.native_terminal import (
|
||||
DAEMON_TERMINAL_READY_TIMEOUT_S as _DAEMON_TERMINAL_READY_TIMEOUT_S,
|
||||
)
|
||||
from omnigent.native_terminal import bind_session_runner as _bind_session_runner
|
||||
from omnigent.native_terminal import url_component
|
||||
|
||||
_DEFAULT_KIMI_COMMAND = "kimi"
|
||||
_KIMI_PATH_ENV = "OMNIGENT_KIMI_PATH"
|
||||
_AGENT_NAME = "kimi-native-ui"
|
||||
_TERMINAL_NAME = "kimi"
|
||||
_TERMINAL_SESSION_KEY = "main"
|
||||
_SESSION_LABELS = {
|
||||
"omnigent.ui": "terminal",
|
||||
_WRAPPER_LABEL_KEY: _WRAPPER_LABEL_VALUE,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NativeKimiLaunch:
|
||||
"""Resolved native Kimi process launch."""
|
||||
|
||||
executable: str
|
||||
argv: list[str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LaunchedKimiTerminal:
|
||||
"""Terminal resource returned by the Omnigent runner launch path."""
|
||||
|
||||
terminal_id: str
|
||||
tmux_socket: Path | None
|
||||
tmux_target: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreparedKimiTerminal:
|
||||
"""Prepared native Kimi terminal attachment details.
|
||||
|
||||
:param reattached: ``True`` when an existing, still-running session
|
||||
terminal was reused (the live-reattach path: prior chat is
|
||||
intact).
|
||||
:param cold_resumed: ``True`` when resuming an existing Omnigent
|
||||
session whose terminal had already exited, so a *fresh*
|
||||
``kimi`` TUI was launched with none of the prior turns.
|
||||
Kimi records no resumable chat id, so this is genuinely a new
|
||||
chat - distinct from a brand-new session (``resolved_session_id
|
||||
is None``) and from a live reattach. Drives the honest
|
||||
cold-resume stderr hint. Note: kimi deliberately treats
|
||||
``cold_resumed`` and ``reattached`` as mutually exclusive (the
|
||||
cold-resume path leaves ``reattached`` at its ``False`` default)
|
||||
- unlike ``claude_native`` which models them independently. This
|
||||
is safe because kimi never reads ``reattached`` for teardown
|
||||
ownership; do not "fix" the apparent inconsistency.
|
||||
"""
|
||||
|
||||
session_id: str
|
||||
terminal_id: str
|
||||
tmux_socket: Path | None
|
||||
tmux_target: str | None
|
||||
reattached: bool
|
||||
cold_resumed: bool = False
|
||||
|
||||
|
||||
def _configured_kimi_command(env: Mapping[str, str]) -> str:
|
||||
"""Return the configured kimi executable name/path from *env*."""
|
||||
value = env.get(_KIMI_PATH_ENV, "").strip()
|
||||
return value or _DEFAULT_KIMI_COMMAND
|
||||
|
||||
|
||||
def resolve_kimi_executable(
|
||||
*,
|
||||
env: Mapping[str, str] | None = None,
|
||||
which: Callable[[str], str | None] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Resolve the native Kimi (``kimi``) executable.
|
||||
|
||||
:param env: Environment mapping to inspect. Defaults to ``os.environ``.
|
||||
:param which: Resolver hook for tests; defaults to ``shutil.which``.
|
||||
:returns: Absolute executable path.
|
||||
:raises click.ClickException: If no kimi CLI is available.
|
||||
"""
|
||||
env = os.environ if env is None else env
|
||||
which = shutil.which if which is None else which
|
||||
command = _configured_kimi_command(env)
|
||||
resolved = which(command)
|
||||
if resolved is None:
|
||||
raise click.ClickException(
|
||||
"Native Kimi requires the 'kimi' CLI on PATH. Install it with: "
|
||||
"curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash, then "
|
||||
f"run 'kimi login'. You can also set {_KIMI_PATH_ENV}=/path/to/kimi."
|
||||
)
|
||||
return resolved
|
||||
|
||||
|
||||
def build_kimi_launch(
|
||||
kimi_args: Sequence[str],
|
||||
*,
|
||||
env: Mapping[str, str] | None = None,
|
||||
which: Callable[[str], str | None] | None = None,
|
||||
) -> NativeKimiLaunch:
|
||||
"""Build the argv for a native Kimi process."""
|
||||
executable = resolve_kimi_executable(env=env, which=which)
|
||||
return NativeKimiLaunch(executable=executable, argv=[executable, *kimi_args])
|
||||
|
||||
|
||||
def run_kimi_native(
|
||||
*,
|
||||
server: str | None,
|
||||
session_id: str | None,
|
||||
kimi_args: tuple[str, ...],
|
||||
resume_picker: bool = False,
|
||||
auto_open_conversation: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Launch the Kimi TUI in an Omnigent terminal.
|
||||
|
||||
:param server: Resolved Omnigent server URL.
|
||||
:param session_id: Optional existing Omnigent conversation id.
|
||||
:param kimi_args: Raw kimi CLI args to persist for the runner-owned TUI.
|
||||
:param resume_picker: ``True`` runs the kimi-native picker.
|
||||
:param auto_open_conversation: When ``True``, open the browser
|
||||
conversation URL after launch.
|
||||
:returns: None after the terminal attach session ends.
|
||||
"""
|
||||
_preflight_local_tools()
|
||||
if server is None:
|
||||
raise click.ClickException(
|
||||
"Kimi requires a resolved Omnigent server URL. The CLI should call "
|
||||
"_ensure_backend before run_kimi_native."
|
||||
)
|
||||
with TemporaryDirectory(prefix="omnigent-kimi-native-") as tmpdir:
|
||||
spec_path = _materialize_kimi_agent_spec(Path(tmpdir))
|
||||
_run_with_remote_server(
|
||||
server.rstrip("/"),
|
||||
spec_path,
|
||||
session_id=session_id,
|
||||
resume_picker=resume_picker,
|
||||
kimi_args=kimi_args,
|
||||
auto_open_conversation=auto_open_conversation,
|
||||
)
|
||||
|
||||
|
||||
def _materialize_kimi_agent_spec(tmpdir: Path) -> Path:
|
||||
"""
|
||||
Write the terminal-first agent spec used by ``omnigent kimi``.
|
||||
|
||||
:param tmpdir: Temporary directory for the generated YAML file.
|
||||
:returns: Path to the generated YAML spec.
|
||||
"""
|
||||
yaml_path = tmpdir / "kimi-native-ui.yaml"
|
||||
raw: dict[str, Any] = {
|
||||
"name": _AGENT_NAME,
|
||||
"prompt": (
|
||||
"Kimi is running in the session terminal. The user drives the "
|
||||
"kimi TUI directly."
|
||||
),
|
||||
"executor": {"harness": "kimi-native"},
|
||||
"spawn": True,
|
||||
"os_env": {
|
||||
"type": "caller_process",
|
||||
"cwd": ".",
|
||||
"sandbox": {"type": "none"},
|
||||
},
|
||||
"terminals": {
|
||||
"shell": {
|
||||
"command": "bash",
|
||||
"allow_cwd_override": True,
|
||||
"os_env": {
|
||||
"type": "caller_process",
|
||||
"cwd": ".",
|
||||
"sandbox": {"type": "none"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
yaml_path.write_text(yaml.safe_dump(raw, sort_keys=False), encoding="utf-8")
|
||||
return yaml_path
|
||||
|
||||
|
||||
def _run_with_remote_server(
|
||||
base_url: str,
|
||||
spec_path: Path,
|
||||
*,
|
||||
session_id: str | None,
|
||||
resume_picker: bool,
|
||||
kimi_args: tuple[str, ...],
|
||||
auto_open_conversation: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Launch Kimi on an Omnigent server via a daemon-spawned runner.
|
||||
|
||||
:param base_url: Omnigent server base URL.
|
||||
:param spec_path: Generated Kimi wrapper agent spec.
|
||||
:param session_id: Optional existing Omnigent session id.
|
||||
:param resume_picker: When ``True``, run the kimi-native picker.
|
||||
:param kimi_args: Raw kimi CLI args.
|
||||
:param auto_open_conversation: Whether to open the web conversation URL.
|
||||
"""
|
||||
from omnigent.chat import _bundle_agent, _remote_headers
|
||||
from omnigent.cli import _ensure_host_daemon
|
||||
from omnigent.host.identity import load_or_create_host_identity
|
||||
|
||||
headers = _remote_headers(server_url=base_url)
|
||||
try:
|
||||
resolved_session_id = _resolve_session_id_for_resume(
|
||||
base_url=base_url,
|
||||
headers=headers,
|
||||
session_id=session_id,
|
||||
resume_picker=resume_picker,
|
||||
)
|
||||
if resolved_session_id is None and resume_picker and session_id is None:
|
||||
return
|
||||
|
||||
async def _drive() -> None:
|
||||
with runner_startup_progress(initial_message="Preparing Kimi...") as progress:
|
||||
_update_startup_progress(progress, "Connecting to local daemon...")
|
||||
_ensure_host_daemon(base_url)
|
||||
host_id = load_or_create_host_identity().host_id
|
||||
bundle = None if resolved_session_id is not None else _bundle_agent(spec_path)
|
||||
prepared = await _prepare_kimi_terminal_via_daemon(
|
||||
base_url=base_url,
|
||||
headers=headers,
|
||||
session_id=resolved_session_id,
|
||||
session_bundle=bundle,
|
||||
kimi_args=kimi_args,
|
||||
host_id=host_id,
|
||||
workspace=str(Path.cwd().resolve()),
|
||||
startup_progress=progress,
|
||||
)
|
||||
click.echo(f"Web UI: {conversation_url(base_url, prepared.session_id)}", err=True)
|
||||
open_conversation_link_if_enabled(
|
||||
base_url=base_url,
|
||||
conversation_id=prepared.session_id,
|
||||
enabled=auto_open_conversation,
|
||||
warn=lambda message: click.echo(message, err=True),
|
||||
)
|
||||
if prepared.cold_resumed:
|
||||
echo_native_cold_resume_hint(agent_label="Kimi")
|
||||
await _attach_terminal_resource(prepared)
|
||||
if resolved_session_id is None:
|
||||
echo_native_resume_hint(
|
||||
native_command="kimi",
|
||||
session_id=prepared.session_id,
|
||||
server=base_url,
|
||||
)
|
||||
|
||||
asyncio.run(_drive())
|
||||
except httpx.ConnectError as exc:
|
||||
raise click.ClickException(
|
||||
f"Could not reach the omnigent server at {base_url}. "
|
||||
"Confirm the server is running and reachable from here "
|
||||
f"(e.g. `curl {base_url}/health`), and that --server is correct."
|
||||
) from exc
|
||||
|
||||
|
||||
async def _prepare_kimi_terminal_via_daemon(
|
||||
*,
|
||||
base_url: str,
|
||||
headers: dict[str, str],
|
||||
session_id: str | None,
|
||||
session_bundle: bytes | None,
|
||||
kimi_args: tuple[str, ...],
|
||||
host_id: str,
|
||||
workspace: str,
|
||||
startup_progress: RunnerStartupProgress | None = None,
|
||||
) -> PreparedKimiTerminal:
|
||||
"""
|
||||
Create or resume a kimi-native session through a daemon runner.
|
||||
|
||||
:returns: Prepared terminal details for attaching.
|
||||
"""
|
||||
persist_args = list(kimi_args)
|
||||
timeout = httpx.Timeout(30.0, read=120.0)
|
||||
async with httpx.AsyncClient(base_url=base_url, headers=headers, timeout=timeout) as client:
|
||||
# Resuming an existing session can either reattach to a live
|
||||
# terminal (prior chat intact) or, if that terminal has exited,
|
||||
# cold-start a fresh TUI. We only know which after probing for a
|
||||
# running terminal below, so default both flags off here.
|
||||
reattached = False
|
||||
cold_resumed = False
|
||||
if session_id is None:
|
||||
if session_bundle is None:
|
||||
raise click.ClickException("Creating a Kimi session requires a session bundle.")
|
||||
_update_startup_progress(startup_progress, "Creating Kimi session...")
|
||||
session_id = await _create_kimi_session(
|
||||
client,
|
||||
session_bundle,
|
||||
terminal_launch_args=persist_args or None,
|
||||
)
|
||||
else:
|
||||
_update_startup_progress(startup_progress, "Loading Kimi session...")
|
||||
payload = await _fetch_kimi_session(client, session_id)
|
||||
labels = payload.get("labels") if isinstance(payload, dict) else None
|
||||
if (
|
||||
not isinstance(labels, dict)
|
||||
or labels.get(_WRAPPER_LABEL_KEY) != _WRAPPER_LABEL_VALUE
|
||||
):
|
||||
raise click.ClickException(
|
||||
f"Conversation {session_id!r} is not a kimi-native session."
|
||||
)
|
||||
existing_terminal = await _find_running_kimi_terminal(client, session_id)
|
||||
if existing_terminal is not None:
|
||||
if persist_args:
|
||||
click.echo(
|
||||
"Ignoring Kimi launch args for an already-running terminal; "
|
||||
"restart the session terminal to apply them.",
|
||||
err=True,
|
||||
)
|
||||
_update_startup_progress(startup_progress, "Kimi terminal ready.")
|
||||
return PreparedKimiTerminal(
|
||||
session_id=session_id,
|
||||
terminal_id=existing_terminal.terminal_id,
|
||||
tmux_socket=existing_terminal.tmux_socket,
|
||||
tmux_target=existing_terminal.tmux_target,
|
||||
reattached=True,
|
||||
)
|
||||
# Session exists but its terminal has exited. Kimi records no
|
||||
# resumable chat id, so the launch below starts a fresh TUI with
|
||||
# no prior turns. Flag it so the caller can say so honestly.
|
||||
# Mutually exclusive with the reattach path above: we leave
|
||||
# reattached at False here (unlike claude_native, which treats
|
||||
# cold_resumed/reattached as independent). Safe because kimi
|
||||
# never uses reattached for teardown ownership.
|
||||
cold_resumed = True
|
||||
if persist_args:
|
||||
_update_startup_progress(startup_progress, "Updating Kimi session...")
|
||||
resp = await client.patch(
|
||||
f"/v1/sessions/{url_component(session_id)}",
|
||||
json={"terminal_launch_args": persist_args},
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
raise click.ClickException(
|
||||
f"Kimi session launch config update failed "
|
||||
f"({resp.status_code}): {error_text(resp)}"
|
||||
)
|
||||
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
_update_startup_progress(startup_progress, "Starting runner...")
|
||||
runner_id = await launch_or_reuse_daemon_runner(
|
||||
client,
|
||||
host_id=host_id,
|
||||
session_id=session_id,
|
||||
workspace=workspace,
|
||||
)
|
||||
_update_startup_progress(startup_progress, "Waiting for runner...")
|
||||
await wait_for_runner_online(client, runner_id, timeout_s=_DAEMON_RUNNER_ONLINE_TIMEOUT_S)
|
||||
await _bind_session_runner(client, session_id, runner_id)
|
||||
_update_startup_progress(startup_progress, "Starting Kimi terminal...")
|
||||
await _ensure_kimi_terminal_on_runner(client, session_id)
|
||||
terminal = await _wait_for_kimi_terminal_ready(
|
||||
client,
|
||||
session_id,
|
||||
timeout_s=_DAEMON_TERMINAL_READY_TIMEOUT_S,
|
||||
)
|
||||
_update_startup_progress(startup_progress, "Kimi terminal ready.")
|
||||
return PreparedKimiTerminal(
|
||||
session_id=session_id,
|
||||
terminal_id=terminal.terminal_id,
|
||||
tmux_socket=terminal.tmux_socket,
|
||||
tmux_target=terminal.tmux_target,
|
||||
reattached=reattached,
|
||||
cold_resumed=cold_resumed,
|
||||
)
|
||||
|
||||
|
||||
async def _create_kimi_session(
|
||||
client: httpx.AsyncClient,
|
||||
bundle: bytes,
|
||||
*,
|
||||
terminal_launch_args: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Create a bundled terminal-first kimi-native session."""
|
||||
metadata: dict[str, Any] = {"labels": dict(_SESSION_LABELS)}
|
||||
if terminal_launch_args:
|
||||
metadata["terminal_launch_args"] = terminal_launch_args
|
||||
resp = await client.post(
|
||||
"/v1/sessions",
|
||||
data={"metadata": json.dumps(metadata)},
|
||||
files={"bundle": ("kimi-native-ui.tar.gz", bundle, "application/gzip")},
|
||||
timeout=120.0,
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
raise click.ClickException(
|
||||
f"Kimi session creation failed ({resp.status_code}): {error_text(resp)}"
|
||||
)
|
||||
body = resp.json()
|
||||
new_session_id = body.get("session_id")
|
||||
if not isinstance(new_session_id, str) or not new_session_id:
|
||||
raise click.ClickException("Kimi session creation response did not include session_id.")
|
||||
return new_session_id
|
||||
|
||||
|
||||
async def _fetch_kimi_session(client: httpx.AsyncClient, session_id: str) -> dict[str, Any]:
|
||||
"""Fetch an existing Omnigent session."""
|
||||
resp = await client.get(f"/v1/sessions/{url_component(session_id)}")
|
||||
if resp.status_code == 404:
|
||||
raise click.ClickException(f"Conversation {session_id!r} not found on the server.")
|
||||
if resp.status_code >= 400:
|
||||
raise click.ClickException(
|
||||
f"Failed to fetch conversation {session_id!r} ({resp.status_code}): {error_text(resp)}"
|
||||
)
|
||||
payload = resp.json()
|
||||
if not isinstance(payload, dict):
|
||||
raise click.ClickException("Conversation fetch returned non-object JSON.")
|
||||
return payload
|
||||
|
||||
|
||||
async def _ensure_kimi_terminal_on_runner(client: httpx.AsyncClient, session_id: str) -> None:
|
||||
"""Ask the bound runner to ensure the Kimi terminal exists."""
|
||||
resp = await client.post(
|
||||
f"/v1/sessions/{url_component(session_id)}/resources/terminals",
|
||||
json={
|
||||
"terminal": _TERMINAL_NAME,
|
||||
"session_key": _TERMINAL_SESSION_KEY,
|
||||
"ensure_native_terminal": True,
|
||||
},
|
||||
timeout=60.0,
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
raise click.ClickException(
|
||||
f"Kimi terminal ensure failed ({resp.status_code}): {error_text(resp)}"
|
||||
)
|
||||
|
||||
|
||||
async def _wait_for_kimi_terminal_ready(
|
||||
client: httpx.AsyncClient,
|
||||
session_id: str,
|
||||
*,
|
||||
timeout_s: float,
|
||||
) -> LaunchedKimiTerminal:
|
||||
"""Wait until the runner exposes the Kimi terminal resource."""
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + timeout_s
|
||||
while loop.time() < deadline:
|
||||
terminal = await _find_running_kimi_terminal(client, session_id)
|
||||
if terminal is not None:
|
||||
return terminal
|
||||
await asyncio.sleep(0.2)
|
||||
raise click.ClickException(
|
||||
f"The runner did not create the Kimi terminal for {session_id!r} "
|
||||
f"within {timeout_s:.0f}s."
|
||||
)
|
||||
|
||||
|
||||
async def _find_running_kimi_terminal(
|
||||
client: httpx.AsyncClient,
|
||||
session_id: str,
|
||||
) -> LaunchedKimiTerminal | None:
|
||||
"""Return the existing running Kimi terminal id if present."""
|
||||
terminal_id = kimi_terminal_resource_id()
|
||||
resp = await client.get(
|
||||
f"/v1/sessions/{url_component(session_id)}"
|
||||
f"/resources/terminals/{url_component(terminal_id)}"
|
||||
)
|
||||
if resp.status_code == 404:
|
||||
return None
|
||||
if resp.status_code >= 400:
|
||||
text = error_text(resp)
|
||||
if resp.status_code in {409, 503} and (
|
||||
"not bound to a runner" in text or "offline" in text
|
||||
):
|
||||
return None
|
||||
raise click.ClickException(f"Failed to fetch Kimi terminal ({resp.status_code}): {text}")
|
||||
payload = resp.json()
|
||||
metadata = payload.get("metadata") if isinstance(payload, dict) else None
|
||||
if isinstance(metadata, dict) and metadata.get("running") is False:
|
||||
return None
|
||||
return _launched_kimi_terminal_from_payload(payload)
|
||||
|
||||
|
||||
def _launched_kimi_terminal_from_payload(payload: object) -> LaunchedKimiTerminal:
|
||||
"""Decode terminal launch metadata returned by the runner."""
|
||||
if not isinstance(payload, dict):
|
||||
raise click.ClickException("Kimi terminal launch returned non-object JSON.")
|
||||
terminal_id = payload.get("id")
|
||||
if not isinstance(terminal_id, str) or not terminal_id:
|
||||
raise click.ClickException("Kimi terminal launch response did not include terminal id.")
|
||||
metadata = payload.get("metadata")
|
||||
tmux_socket: Path | None = None
|
||||
tmux_target: str | None = None
|
||||
if isinstance(metadata, dict):
|
||||
raw_socket = metadata.get("tmux_socket")
|
||||
raw_target = metadata.get("tmux_target")
|
||||
if isinstance(raw_socket, str) and raw_socket:
|
||||
tmux_socket = Path(raw_socket)
|
||||
if isinstance(raw_target, str) and raw_target:
|
||||
tmux_target = raw_target
|
||||
return LaunchedKimiTerminal(
|
||||
terminal_id=terminal_id,
|
||||
tmux_socket=tmux_socket,
|
||||
tmux_target=tmux_target,
|
||||
)
|
||||
|
||||
|
||||
async def _attach_terminal_resource(prepared: PreparedKimiTerminal) -> None:
|
||||
"""Attach the current terminal to the prepared Kimi terminal resource."""
|
||||
direct_tmux_error = _direct_tmux_unavailable_reason(prepared)
|
||||
if direct_tmux_error is not None:
|
||||
raise click.ClickException(
|
||||
f"Runner-owned Kimi terminal requires direct tmux attach, but {direct_tmux_error}"
|
||||
)
|
||||
if prepared.tmux_socket is None or prepared.tmux_target is None:
|
||||
raise click.ClickException("Kimi tmux attach metadata was incomplete.")
|
||||
await _attach_direct_tmux(prepared.tmux_socket, prepared.tmux_target)
|
||||
|
||||
|
||||
async def _attach_direct_tmux(socket_path: Path, tmux_target: str) -> None:
|
||||
"""Attach the current terminal directly to the runner-owned tmux pane."""
|
||||
env = dict(os.environ)
|
||||
env.pop("TMUX", None)
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"tmux",
|
||||
"-S",
|
||||
str(socket_path),
|
||||
"-f",
|
||||
os.devnull,
|
||||
"attach",
|
||||
"-t",
|
||||
tmux_target,
|
||||
env=env,
|
||||
)
|
||||
await process.wait()
|
||||
|
||||
|
||||
def _direct_tmux_unavailable_reason(prepared: PreparedKimiTerminal) -> str | None:
|
||||
"""Explain why direct tmux attach is unavailable."""
|
||||
if prepared.tmux_socket is None:
|
||||
return "the terminal resource did not include a tmux socket path."
|
||||
if prepared.tmux_target is None:
|
||||
return "the terminal resource did not include a tmux target."
|
||||
if not prepared.tmux_socket.exists():
|
||||
return f"tmux socket {prepared.tmux_socket} is not reachable from this CLI process."
|
||||
if shutil.which("tmux") is None:
|
||||
return "tmux is not available on PATH."
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_session_id_for_resume(
|
||||
*,
|
||||
base_url: str,
|
||||
headers: dict[str, str],
|
||||
session_id: str | None,
|
||||
resume_picker: bool,
|
||||
) -> str | None:
|
||||
"""Translate resume inputs into a concrete kimi-native session id."""
|
||||
if session_id is not None:
|
||||
return session_id
|
||||
if not resume_picker:
|
||||
return None
|
||||
from omnigent_client import OmnigentClient
|
||||
|
||||
from omnigent.repl._resume_picker import pick_conversation_by_wrapper_label_from_sdk
|
||||
|
||||
async def _drive() -> str | None:
|
||||
async with OmnigentClient(
|
||||
base_url=base_url,
|
||||
headers=headers if headers else None,
|
||||
) as client:
|
||||
return await pick_conversation_by_wrapper_label_from_sdk(
|
||||
client,
|
||||
wrapper_value=_WRAPPER_LABEL_VALUE,
|
||||
agent_name=_AGENT_NAME,
|
||||
)
|
||||
|
||||
return asyncio.run(_drive())
|
||||
|
||||
|
||||
def _update_startup_progress(
|
||||
startup_progress: RunnerStartupProgress | None,
|
||||
message: str,
|
||||
) -> None:
|
||||
"""Show one concise Kimi startup milestone when a renderer is active."""
|
||||
if startup_progress is not None:
|
||||
startup_progress.update(message)
|
||||
|
||||
|
||||
def _preflight_local_tools() -> None:
|
||||
"""Verify local executables required by the native Kimi wrapper."""
|
||||
if shutil.which("tmux") is None:
|
||||
raise click.ClickException(
|
||||
"tmux was not found on local PATH. The native Kimi wrapper "
|
||||
"attaches to the runner-owned Kimi tmux terminal."
|
||||
)
|
||||
|
||||
|
||||
def kimi_terminal_resource_id() -> str:
|
||||
"""Return the deterministic terminal resource id for Kimi."""
|
||||
return terminal_resource_id(_TERMINAL_NAME, _TERMINAL_SESSION_KEY)
|
||||
@@ -0,0 +1,437 @@
|
||||
"""Filesystem bridge + tmux injection for the kimi-native terminal harness.
|
||||
|
||||
The runner launches the ``kimi`` TUI in a private tmux pane and records
|
||||
that pane's socket + target here via :func:`write_tmux_target`. The harness
|
||||
executor then delivers Omnigent web-UI messages into the *same* pane via
|
||||
:func:`inject_user_message` (tmux bracketed paste + Enter) — the kimi analog
|
||||
of claude-native's tmux send-keys bridge. This is what wires the web-UI chat box
|
||||
to the running Kimi TUI (and, since the web UI embeds that pane, the message
|
||||
shows in both surfaces).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
#: Env var carrying the bridge dir into the harness executor process.
|
||||
BRIDGE_DIR_ENV_VAR = "HARNESS_KIMI_NATIVE_BRIDGE_DIR"
|
||||
|
||||
_BRIDGE_ROOT = Path(os.environ.get("TMPDIR", "/tmp")) / f"omnigent-{os.getuid()}" / "kimi-native"
|
||||
_TMUX_FILE = "tmux.json"
|
||||
# Omnigent routing details the kimi hook subprocess reads to reach the server.
|
||||
# Mirrors claude-native's ``permission_hook.json`` (server URL + auth headers +
|
||||
# the active Omnigent session). Written by the runner at terminal-create time;
|
||||
# read by :mod:`omnigent.kimi_native_hook` (PreToolUse deny-gate + the
|
||||
# PermissionRequest read-only surface).
|
||||
_HOOK_CONFIG_FILE = "hook_config.json"
|
||||
_TMUX_READY_TIMEOUT_S = 30.0
|
||||
_TMUX_SEND_TIMEOUT_S = 10.0
|
||||
_POLL_INTERVAL_S = 0.2
|
||||
_PASTE_SETTLE_S = 0.3
|
||||
_PASTE_BUFFER = "omnigent-kimi-paste"
|
||||
# How long to wait for the pasted text to become visible in the pane before
|
||||
# sending Enter — submitting before the TUI commits the paste folds the Enter
|
||||
# into the paste as a newline and the message sits unsent.
|
||||
_PASTE_COMMIT_TIMEOUT_S = 5.0
|
||||
# kimi TUI readiness markers. TODO(kimi-native): these strings are carried
|
||||
# over from cursor-native and are NOT yet verified against a live kimi TUI.
|
||||
# They only gate the pre-paste settle wait in ``_settle_pane`` (idle prompt
|
||||
# vs. first-run trust modal); a wrong marker is non-fatal — the settle simply
|
||||
# falls through on ``_PASTE_COMMIT_TIMEOUT_S`` and the paste still lands. Pin
|
||||
# the real kimi placeholder / trust-prompt strings once verified on a real
|
||||
# session, and drop ``_TRUST_MARKER`` if kimi has no first-run trust modal.
|
||||
_IDLE_MARKERS = ("Plan, search, build", "Add a follow-up")
|
||||
_TRUST_MARKER = "Trust this workspace"
|
||||
|
||||
|
||||
def bridge_dir_for_session_id(session_id: str) -> Path:
|
||||
"""Return the per-session bridge dir, e.g. ``/tmp/omnigent-<uid>/kimi-native/<hash>``."""
|
||||
digest = hashlib.sha256(session_id.encode("utf-8")).hexdigest()[:32]
|
||||
return _BRIDGE_ROOT / digest
|
||||
|
||||
|
||||
def bridge_root() -> Path:
|
||||
"""Return the configured Kimi-native bridge root."""
|
||||
return _BRIDGE_ROOT
|
||||
|
||||
|
||||
def _ensure_dir(path: Path) -> None:
|
||||
"""Create *path* (and parents) with owner-only permissions."""
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
with contextlib.suppress(OSError):
|
||||
os.chmod(path, 0o700)
|
||||
|
||||
|
||||
def build_kimi_native_spawn_env(session_id: str) -> dict[str, str]:
|
||||
"""Build the ``HARNESS_KIMI_NATIVE_*`` env the harness executor reads."""
|
||||
bridge_dir = bridge_dir_for_session_id(session_id)
|
||||
_ensure_dir(bridge_dir)
|
||||
return {
|
||||
BRIDGE_DIR_ENV_VAR: str(bridge_dir),
|
||||
}
|
||||
|
||||
|
||||
|
||||
def write_hook_config(
|
||||
bridge_dir: Path,
|
||||
*,
|
||||
server_url: str,
|
||||
headers: dict[str, str],
|
||||
session_id: str,
|
||||
) -> None:
|
||||
"""Record the Omnigent routing details the kimi hook subprocess reads.
|
||||
|
||||
The PreToolUse / PermissionRequest hook commands receive only
|
||||
``--bridge-dir`` on their command line (no secrets); they read the
|
||||
server URL, auth headers, and active session id from this file. Mirrors
|
||||
:func:`omnigent.claude_native_bridge` ``permission_hook.json`` plumbing.
|
||||
|
||||
:param bridge_dir: The kimi-native bridge dir.
|
||||
:param server_url: Omnigent server base URL, e.g. ``"http://127.0.0.1:8787"``.
|
||||
:param headers: Auth headers to replay on the hook's POSTs (may be empty).
|
||||
:param session_id: The Omnigent session the hook events belong to.
|
||||
"""
|
||||
_ensure_dir(bridge_dir)
|
||||
payload = {
|
||||
"ap_server_url": server_url,
|
||||
"ap_auth_headers": dict(headers),
|
||||
"session_id": session_id,
|
||||
"updated_at": time.time(),
|
||||
}
|
||||
tmp = bridge_dir / (_HOOK_CONFIG_FILE + ".tmp")
|
||||
tmp.write_text(json.dumps(payload), encoding="utf-8")
|
||||
with contextlib.suppress(OSError):
|
||||
os.chmod(tmp, 0o600)
|
||||
os.replace(tmp, bridge_dir / _HOOK_CONFIG_FILE)
|
||||
|
||||
|
||||
def read_hook_config(bridge_dir: Path) -> dict[str, Any]:
|
||||
"""Read Omnigent routing details for the kimi hook subprocess.
|
||||
|
||||
:param bridge_dir: The kimi-native bridge dir.
|
||||
:returns: ``{"ap_server_url", "ap_auth_headers", "session_id"}`` (or an
|
||||
empty dict when the file is absent or malformed).
|
||||
"""
|
||||
try:
|
||||
raw = (bridge_dir / _HOOK_CONFIG_FILE).read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except ValueError:
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def read_active_session_id(bridge_dir: Path) -> str | None:
|
||||
"""Return the Omnigent session id recorded for the hook subprocess.
|
||||
|
||||
:param bridge_dir: The kimi-native bridge dir.
|
||||
:returns: The session id, or ``None`` when unset / malformed.
|
||||
"""
|
||||
session_id = read_hook_config(bridge_dir).get("session_id")
|
||||
return session_id if isinstance(session_id, str) and session_id else None
|
||||
|
||||
|
||||
def write_tmux_target(
|
||||
bridge_dir: Path,
|
||||
*,
|
||||
socket_path: Path,
|
||||
tmux_target: str,
|
||||
pid: int | None = None,
|
||||
) -> None:
|
||||
"""Advertise the tmux socket + target for the running Kimi terminal."""
|
||||
_ensure_dir(bridge_dir)
|
||||
payload: dict[str, Any] = {
|
||||
"socket_path": str(socket_path),
|
||||
"tmux_target": tmux_target,
|
||||
"updated_at": time.time(),
|
||||
}
|
||||
if pid is not None:
|
||||
payload["pid"] = pid
|
||||
tmp = bridge_dir / (_TMUX_FILE + ".tmp")
|
||||
tmp.write_text(json.dumps(payload), encoding="utf-8")
|
||||
os.replace(tmp, bridge_dir / _TMUX_FILE)
|
||||
|
||||
|
||||
def read_tmux_info(bridge_dir: Path) -> dict[str, str] | None:
|
||||
"""Return ``{socket_path, tmux_target}`` from ``tmux.json``, or ``None``."""
|
||||
try:
|
||||
raw = (bridge_dir / _TMUX_FILE).read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
socket_path = data.get("socket_path")
|
||||
tmux_target = data.get("tmux_target")
|
||||
if (
|
||||
isinstance(socket_path, str)
|
||||
and socket_path
|
||||
and isinstance(tmux_target, str)
|
||||
and tmux_target
|
||||
):
|
||||
return {"socket_path": socket_path, "tmux_target": tmux_target}
|
||||
return None
|
||||
|
||||
|
||||
def _wait_for_tmux_info(bridge_dir: Path, *, timeout_s: float) -> dict[str, str]:
|
||||
"""Block until ``tmux.json`` is advertised, or raise on timeout."""
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
info = read_tmux_info(bridge_dir)
|
||||
if info is not None:
|
||||
return info
|
||||
time.sleep(_POLL_INTERVAL_S)
|
||||
raise RuntimeError(f"kimi-native tmux target was not advertised within {timeout_s:.0f}s")
|
||||
|
||||
|
||||
def _run_tmux(socket_path: str, *args: str) -> None:
|
||||
"""Invoke ``tmux -S <socket> <args...>`` and raise on failure."""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["tmux", "-S", socket_path, *args],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_TMUX_SEND_TIMEOUT_S,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise RuntimeError(f"tmux command timed out after {_TMUX_SEND_TIMEOUT_S}s") from exc
|
||||
if proc.returncode != 0:
|
||||
detail = proc.stderr.strip() or proc.stdout.strip() or "<no output>"
|
||||
raise RuntimeError(f"tmux command failed (rc={proc.returncode}): {detail}")
|
||||
|
||||
|
||||
def _capture_pane(socket_path: str, tmux_target: str) -> str:
|
||||
"""Capture the visible pane contents; ``""`` on any failure (treat as not-ready)."""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["tmux", "-S", socket_path, "capture-pane", "-p", "-t", tmux_target],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_TMUX_SEND_TIMEOUT_S,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
return ""
|
||||
return proc.stdout if proc.returncode == 0 else ""
|
||||
|
||||
|
||||
def _paste_payload_bytes(text: str) -> bytes:
|
||||
r"""Encode text for ``tmux load-buffer``: line breaks → CR, tabs kept, other
|
||||
control bytes dropped (a stray ESC would close the bracketed-paste early)."""
|
||||
normalized = text.replace("\r\n", "\n").replace("\r", "\n")
|
||||
body = bytearray()
|
||||
for ch in normalized:
|
||||
if ch == "\n":
|
||||
body.append(0x0D)
|
||||
continue
|
||||
if ch == "\t":
|
||||
body.append(0x09)
|
||||
continue
|
||||
if ord(ch) < 0x20:
|
||||
continue
|
||||
body.extend(ch.encode("utf-8"))
|
||||
return bytes(body)
|
||||
|
||||
|
||||
def _session_alive(socket_path: str, tmux_target: str) -> bool:
|
||||
"""Return whether the tmux session/pane still exists (the TUI is running)."""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["tmux", "-S", socket_path, "has-session", "-t", tmux_target],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_TMUX_SEND_TIMEOUT_S,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
return False
|
||||
return proc.returncode == 0
|
||||
|
||||
|
||||
def _submit_needle(content: str) -> str:
|
||||
"""A stable single-line substring used to confirm the paste rendered in the pane."""
|
||||
for line in content.splitlines():
|
||||
stripped = line.strip()
|
||||
if len(stripped) >= 4:
|
||||
return stripped[:24]
|
||||
stripped = content.strip()
|
||||
return stripped[:24] if len(stripped) >= 4 else ""
|
||||
|
||||
|
||||
def _settle_pane(socket_path: str, tmux_target: str, *, timeout_s: float) -> None:
|
||||
"""Best-effort wait until the Kimi input box is ready to receive a paste.
|
||||
|
||||
Accepts the first-run "Trust this workspace" modal (sends ``a`` at most once)
|
||||
so the input box can mount, then waits for an idle/running input marker. Falls
|
||||
through after the timeout (mid-turn steering has no idle placeholder) rather
|
||||
than raising.
|
||||
"""
|
||||
deadline = time.monotonic() + timeout_s
|
||||
trust_accepted = False
|
||||
while time.monotonic() < deadline:
|
||||
pane = _capture_pane(socket_path, tmux_target)
|
||||
if any(marker in pane for marker in _IDLE_MARKERS):
|
||||
return
|
||||
# One-shot, only when no input marker is up (so a later transcript that
|
||||
# merely echoes the phrase can't spray repeated keystrokes into the TUI).
|
||||
if not trust_accepted and _TRUST_MARKER in pane:
|
||||
trust_accepted = True
|
||||
with contextlib.suppress(RuntimeError):
|
||||
_run_tmux(socket_path, "send-keys", "-t", tmux_target, "a")
|
||||
time.sleep(_POLL_INTERVAL_S)
|
||||
|
||||
|
||||
def inject_user_message(
|
||||
bridge_dir: Path,
|
||||
*,
|
||||
content: str,
|
||||
timeout_s: float = _TMUX_READY_TIMEOUT_S,
|
||||
) -> None:
|
||||
"""Deliver a web-UI user message into the Kimi TUI via a tmux bracketed paste.
|
||||
|
||||
Clears any leftover draft, pastes *content* (multi-line safe via
|
||||
``load-buffer``/``paste-buffer -p`` so interior newlines stay data, not
|
||||
submits), settles, then submits with Enter.
|
||||
|
||||
:param bridge_dir: The kimi-native bridge dir holding ``tmux.json``.
|
||||
:param content: User text (non-empty).
|
||||
:param timeout_s: Per-readiness-gate timeout.
|
||||
:raises RuntimeError: If the tmux target is never advertised or a tmux
|
||||
command fails.
|
||||
"""
|
||||
if not content:
|
||||
raise RuntimeError("kimi-native injection requires non-empty content")
|
||||
info = _wait_for_tmux_info(bridge_dir, timeout_s=timeout_s)
|
||||
socket_path = info["socket_path"]
|
||||
tmux_target = info["tmux_target"]
|
||||
# Fast-fail if the TUI already exited: otherwise _settle_pane polls a dead
|
||||
# pane for the full timeout and the web message is silently lost. A clear
|
||||
# error lets run_turn surface ExecutorError so the UI can say "restart".
|
||||
if not _session_alive(socket_path, tmux_target):
|
||||
raise RuntimeError(
|
||||
"kimi terminal is no longer running (the TUI exited); restart the session"
|
||||
)
|
||||
_settle_pane(socket_path, tmux_target, timeout_s=timeout_s)
|
||||
# Clear any leftover draft: Home (C-a) + kill-to-end (C-k).
|
||||
_run_tmux(socket_path, "send-keys", "-t", tmux_target, "C-a")
|
||||
_run_tmux(socket_path, "send-keys", "-t", tmux_target, "C-k")
|
||||
with tempfile.NamedTemporaryFile(
|
||||
dir=bridge_dir, prefix="paste_", suffix=".bin", delete=False
|
||||
) as paste_file:
|
||||
# Trailing newline absorbs any trailing backslash so it can't escape Enter.
|
||||
paste_file.write(_paste_payload_bytes(content + "\n"))
|
||||
paste_path = paste_file.name
|
||||
try:
|
||||
_run_tmux(socket_path, "load-buffer", "-b", _PASTE_BUFFER, paste_path)
|
||||
_run_tmux(
|
||||
socket_path,
|
||||
"paste-buffer",
|
||||
"-p", # bracketed-paste markers — the TUI keeps newlines as data
|
||||
"-d", # drop the buffer after pasting
|
||||
"-b",
|
||||
_PASTE_BUFFER,
|
||||
"-t",
|
||||
tmux_target,
|
||||
)
|
||||
finally:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(paste_path)
|
||||
# Wait until the paste is visibly committed to the input box before Enter.
|
||||
# Submitting mid-paste folds the Enter in as a newline (the kimi TUI
|
||||
# coalesces rapid stdin bursts), leaving the message unsent. Poll for the
|
||||
# text, then submit; fall through to a blind submit if no needle is usable.
|
||||
needle = _submit_needle(content)
|
||||
if needle:
|
||||
deadline = time.monotonic() + _PASTE_COMMIT_TIMEOUT_S
|
||||
while time.monotonic() < deadline:
|
||||
if needle in _capture_pane(socket_path, tmux_target):
|
||||
break
|
||||
time.sleep(_POLL_INTERVAL_S)
|
||||
time.sleep(_PASTE_SETTLE_S)
|
||||
_run_tmux(socket_path, "send-keys", "-t", tmux_target, "Enter")
|
||||
|
||||
|
||||
def inject_interrupt(bridge_dir: Path, *, timeout_s: float = _TMUX_READY_TIMEOUT_S) -> None:
|
||||
"""Cancel the in-flight Kimi turn by sending ``Escape`` to the pane.
|
||||
|
||||
kimi stops a running turn on a single ``Escape`` (verified live).
|
||||
The harness ``run_turn`` returns right after the paste, so the runner's
|
||||
in-process cancel floor can't reach the turn — this is the analog of
|
||||
:func:`inject_user_message` for the web UI's Stop button.
|
||||
|
||||
:raises RuntimeError: If the tmux target is not advertised or send-keys fails.
|
||||
"""
|
||||
info = _wait_for_tmux_info(bridge_dir, timeout_s=timeout_s)
|
||||
# No ``-l``: tmux must interpret ``Escape`` as a key name.
|
||||
_run_tmux(info["socket_path"], "send-keys", "-t", info["tmux_target"], "Escape")
|
||||
|
||||
|
||||
#: Tool-independent label proving kimi's permission menu is on screen — guards
|
||||
#: against injecting a stray digit once the prompt was already answered.
|
||||
_PERMISSION_PROMPT_MARKER = "Approve once"
|
||||
|
||||
#: Web-UI approve/deny → option digit in kimi's fixed numbered menu
|
||||
#: (1=Approve once, 2=Approve for session, 3=Reject, 4=Reject with feedback).
|
||||
#: "Approve once" re-prompts each call so Omnigent governs every one.
|
||||
APPROVE_KEY = "1"
|
||||
DENY_KEY = "3"
|
||||
|
||||
|
||||
def inject_approval_keystroke(
|
||||
bridge_dir: Path, *, key: str, timeout_s: float = _TMUX_READY_TIMEOUT_S
|
||||
) -> bool:
|
||||
"""Answer kimi's tool-permission menu by typing an option digit + Enter.
|
||||
|
||||
kimi's permission prompt is a numbered select whose footer documents
|
||||
``1/2/3/4 choose · ↵ confirm``; the web-UI Approve/Deny buttons map to
|
||||
:data:`APPROVE_KEY` / :data:`DENY_KEY`. This types *key* then ``Enter``.
|
||||
|
||||
Captures the pane first and injects ONLY when the permission menu is
|
||||
actually showing (:data:`_PERMISSION_PROMPT_MARKER`), so a web verdict that
|
||||
lands after the user already answered in the terminal (or after the prompt
|
||||
closed) is a no-op rather than a stray keystroke leaking into whatever is on
|
||||
screen next.
|
||||
|
||||
:param bridge_dir: The kimi-native bridge dir holding ``tmux.json``.
|
||||
:param key: The option digit to select (e.g. :data:`APPROVE_KEY`).
|
||||
:returns: ``True`` if the keystroke was injected; ``False`` if the
|
||||
permission menu was not present (already answered / closed / TUI gone).
|
||||
:raises RuntimeError: If the tmux target is not advertised or send-keys fails.
|
||||
"""
|
||||
info = _wait_for_tmux_info(bridge_dir, timeout_s=timeout_s)
|
||||
socket_path = info["socket_path"]
|
||||
tmux_target = info["tmux_target"]
|
||||
if not _session_alive(socket_path, tmux_target):
|
||||
return False
|
||||
if _PERMISSION_PROMPT_MARKER not in _capture_pane(socket_path, tmux_target):
|
||||
return False
|
||||
# ``key`` is a single documented option digit; Enter confirms (the footer
|
||||
# lists "choose" and "confirm" separately, so a digit selects and ↵ commits).
|
||||
_run_tmux(socket_path, "send-keys", "-t", tmux_target, key)
|
||||
_run_tmux(socket_path, "send-keys", "-t", tmux_target, "Enter")
|
||||
return True
|
||||
|
||||
|
||||
def kill_session(bridge_dir: Path, *, timeout_s: float = _TMUX_READY_TIMEOUT_S) -> None:
|
||||
"""Hard-stop the Kimi session by killing its tmux session.
|
||||
|
||||
Terminates ``kimi`` and the pane outright — the analog of the
|
||||
user manually exiting the attached TUI, for the web UI's "Stop session"
|
||||
affordance. Mirrors :func:`omnigent.claude_native_bridge.kill_session`.
|
||||
|
||||
:raises RuntimeError: If the tmux target is not advertised or kill-session fails.
|
||||
"""
|
||||
info = _wait_for_tmux_info(bridge_dir, timeout_s=timeout_s)
|
||||
_run_tmux(info["socket_path"], "kill-session", "-t", info["tmux_target"])
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Per-session ``KIMI_CODE_HOME`` builder that injects Omnigent hooks.
|
||||
|
||||
Kimi Code reads a single ``config.toml`` at ``$KIMI_CODE_HOME/config.toml``
|
||||
(default ``~/.kimi-code``) and stores its auth (``oauth/`` + ``credentials/``)
|
||||
relative to the same home — there is no project-level merge for the ``hooks``
|
||||
array. To gate a session's tools without mutating the user's global config, the
|
||||
runner points the launched ``kimi`` process at a session-scoped home that:
|
||||
|
||||
- symlinks every entry of the user's global home (oauth, credentials,
|
||||
sessions, …) so login / providers / history keep working, and
|
||||
- carries a ``config.toml`` that is the user's config text with two Omnigent
|
||||
``[[hooks]]`` appended — a ``PreToolUse`` deny-gate and a ``PermissionRequest``
|
||||
read-only surface, both dispatched to :mod:`omnigent.kimi_native_hook`.
|
||||
|
||||
Appending as text (rather than parsing + re-emitting TOML) keeps the user's
|
||||
config byte-for-byte and needs no TOML writer: a trailing ``[[hooks]]`` table
|
||||
array is always valid regardless of what section preceded it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import shlex
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
#: Env var Kimi Code reads to locate its data dir (config.toml + oauth + …).
|
||||
KIMI_CODE_HOME_ENV_VAR = "KIMI_CODE_HOME"
|
||||
_CONFIG_FILE = "config.toml"
|
||||
|
||||
|
||||
def resolve_user_kimi_home() -> Path:
|
||||
"""Return the user's global Kimi Code home.
|
||||
|
||||
Mirrors kimi's own ``resolveKimiHome``: ``$KIMI_CODE_HOME`` when set, else
|
||||
``~/.kimi-code``.
|
||||
|
||||
:returns: The resolved home path (may not exist if the user never ran kimi).
|
||||
"""
|
||||
env = os.environ.get(KIMI_CODE_HOME_ENV_VAR)
|
||||
if env:
|
||||
return Path(env)
|
||||
return Path.home() / ".kimi-code"
|
||||
|
||||
|
||||
def render_kimi_hooks_toml(*, bridge_dir: Path, python_executable: str | None = None) -> str:
|
||||
"""Render the two Omnigent ``[[hooks]]`` entries as TOML text.
|
||||
|
||||
Both hooks dispatch to :mod:`omnigent.kimi_native_hook` with the bridge
|
||||
dir baked into the command (no secrets on the command line — the hook reads
|
||||
the server URL / auth / session id from the bridge's ``hook_config.json``).
|
||||
|
||||
:param bridge_dir: The kimi-native bridge dir the hook commands read.
|
||||
:param python_executable: Interpreter to run the hook module; ``None`` uses
|
||||
:data:`sys.executable` (the runner's interpreter, which has omnigent).
|
||||
:returns: TOML text starting with a leading newline, safe to append.
|
||||
"""
|
||||
python = python_executable or sys.executable
|
||||
base = f"{shlex.quote(python)} -m omnigent.kimi_native_hook"
|
||||
bridge = shlex.quote(str(bridge_dir))
|
||||
pre = f"{base} evaluate-policy --bridge-dir {bridge}"
|
||||
perm = f"{base} permission-request --bridge-dir {bridge}"
|
||||
# No ``matcher`` → matches every tool. Commands are TOML basic strings;
|
||||
# shlex.quote yields single-quoted POSIX tokens, which contain no double
|
||||
# quotes or backslashes, so they embed in a "..." TOML string verbatim.
|
||||
return (
|
||||
"\n"
|
||||
"# --- Omnigent native hooks (auto-generated; do not edit) ---\n"
|
||||
"[[hooks]]\n"
|
||||
'event = "PreToolUse"\n'
|
||||
f'command = "{pre}"\n'
|
||||
"\n"
|
||||
"[[hooks]]\n"
|
||||
'event = "PermissionRequest"\n'
|
||||
f'command = "{perm}"\n'
|
||||
)
|
||||
|
||||
|
||||
def build_kimi_session_home(
|
||||
session_home: Path,
|
||||
*,
|
||||
bridge_dir: Path,
|
||||
python_executable: str | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Materialize a session-scoped ``KIMI_CODE_HOME`` with Omnigent hooks.
|
||||
|
||||
Symlinks every entry of the user's global kimi home (except
|
||||
``config.toml``) into *session_home*, then writes a ``config.toml`` that is
|
||||
the user's config plus the Omnigent hooks. Best-effort and idempotent:
|
||||
re-running rewrites ``config.toml`` and leaves existing symlinks in place.
|
||||
|
||||
:param session_home: Directory to use as the session's ``KIMI_CODE_HOME``.
|
||||
:param bridge_dir: The kimi-native bridge dir the hook commands read.
|
||||
:param python_executable: Interpreter for the hook commands (see
|
||||
:func:`render_kimi_hooks_toml`).
|
||||
:returns: ``{"KIMI_CODE_HOME": str(session_home)}`` to merge into the
|
||||
launched kimi process env.
|
||||
"""
|
||||
session_home.mkdir(parents=True, exist_ok=True)
|
||||
with contextlib.suppress(OSError):
|
||||
os.chmod(session_home, 0o700)
|
||||
|
||||
user_home = resolve_user_kimi_home()
|
||||
base_config = ""
|
||||
if user_home.is_dir():
|
||||
for entry in user_home.iterdir():
|
||||
if entry.name == _CONFIG_FILE:
|
||||
# config.toml is materialized fresh below (user content + hooks).
|
||||
continue
|
||||
link = session_home / entry.name
|
||||
if link.exists() or link.is_symlink():
|
||||
continue
|
||||
with contextlib.suppress(OSError):
|
||||
link.symlink_to(entry)
|
||||
with contextlib.suppress(OSError):
|
||||
base_config = (user_home / _CONFIG_FILE).read_text(encoding="utf-8")
|
||||
|
||||
hooks = render_kimi_hooks_toml(bridge_dir=bridge_dir, python_executable=python_executable)
|
||||
# Ensure a clean separation if the user's config has no trailing newline.
|
||||
if base_config and not base_config.endswith("\n"):
|
||||
base_config += "\n"
|
||||
(session_home / _CONFIG_FILE).write_text(base_config + hooks, encoding="utf-8")
|
||||
|
||||
return {KIMI_CODE_HOME_ENV_VAR: str(session_home)}
|
||||
@@ -0,0 +1,360 @@
|
||||
"""Mirror a kimi-native TUI session's transcript into the Omnigent web chat.
|
||||
|
||||
The kimi-native harness launches the interactive ``kimi`` TUI in a tmux pane and
|
||||
injects web-UI turns into it (see :mod:`omnigent.kimi_native_bridge`). The TUI's
|
||||
reply renders live in the embedded terminal, but — unlike the SDK ``KimiExecutor``
|
||||
— nothing flows the assistant's response back into Omnigent's conversation
|
||||
transcript (the chat bubbles). This module closes that gap, the kimi analog of
|
||||
:mod:`omnigent.cursor_native_forwarder`.
|
||||
|
||||
Data source: kimi persists each session to an append-only JSONL "wire" log at
|
||||
``$KIMI_CODE_HOME/sessions/<wd_…>/<session_…>/agents/main/wire.jsonl``. The
|
||||
native harness points ``KIMI_CODE_HOME`` at ``<bridge_dir>/kimi-code-home`` whose
|
||||
``sessions/`` is symlinked to the user's global store, so several workspaces'
|
||||
sessions share the tree; we disambiguate by ``workDir`` (via ``session_index.jsonl``)
|
||||
and recency. Relevant wire events:
|
||||
|
||||
- ``{"type": "turn.prompt", "input": [{"type":"text","text":…}], "origin": {"kind":"user"}}``
|
||||
→ a user message.
|
||||
- ``{"type": "context.append_loop_event", "event": {"type": "content.part",
|
||||
"part": {"type": "text", "text": …}, "uuid": …}}`` → an assistant message.
|
||||
(``part.type == "think"`` is reasoning and is skipped for v1; ``tool.call`` /
|
||||
``tool.result`` events are likewise skipped — the embedded terminal shows them.)
|
||||
|
||||
Each mirrored turn is POSTed as an ``external_conversation_item`` to
|
||||
``/v1/sessions/{id}/events`` (the same shape :mod:`omnigent.kimi_native_hook`
|
||||
uses for its read-only approval surface). A per-session line offset is persisted
|
||||
in ``<bridge_dir>/kimi_forwarder.json`` so restarts resume without double-posting.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
#: Poll cadence for new wire-log lines (matches cursor_native_forwarder).
|
||||
_POLL_INTERVAL_S = 0.7
|
||||
#: Persisted forwarder state (discovered wire path + high-water line count).
|
||||
_STATE_FILE = "kimi_forwarder.json"
|
||||
#: Clock-skew tolerance when matching a session created at/after launch.
|
||||
_DISCOVER_SKEW_MS = 10_000
|
||||
#: Supervisor backoff bounds.
|
||||
_BACKOFF_INITIAL_S = 1.0
|
||||
_BACKOFF_MAX_S = 30.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ForwardState:
|
||||
"""Durable cursor for the wire-log tail."""
|
||||
|
||||
wire_path: str
|
||||
last_line: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class _MirrorItem:
|
||||
"""One conversation item to POST, plus the line index it came from."""
|
||||
|
||||
line_no: int
|
||||
role: str
|
||||
text: str
|
||||
response_id: str
|
||||
|
||||
|
||||
def clear_kimi_bridge_state(bridge_dir: Path) -> None:
|
||||
"""Drop any stale forwarder state so a new terminal starts a fresh tail.
|
||||
|
||||
Mirrors ``cursor_native_forwarder.clear_cursor_bridge_state``: without this,
|
||||
a re-created terminal would resume the prior session's line offset against a
|
||||
different wire log.
|
||||
"""
|
||||
with contextlib.suppress(OSError):
|
||||
(bridge_dir / _STATE_FILE).unlink()
|
||||
|
||||
|
||||
def _read_state(bridge_dir: Path) -> _ForwardState | None:
|
||||
try:
|
||||
raw = (bridge_dir / _STATE_FILE).read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
wire_path = data.get("wire_path")
|
||||
last_line = data.get("last_line")
|
||||
if isinstance(wire_path, str) and isinstance(last_line, int):
|
||||
return _ForwardState(wire_path=wire_path, last_line=last_line)
|
||||
return None
|
||||
|
||||
|
||||
def _write_state(bridge_dir: Path, state: _ForwardState) -> None:
|
||||
payload = {"wire_path": state.wire_path, "last_line": state.last_line}
|
||||
tmp = bridge_dir / (_STATE_FILE + ".tmp")
|
||||
with contextlib.suppress(OSError):
|
||||
tmp.write_text(json.dumps(payload), encoding="utf-8")
|
||||
tmp.replace(bridge_dir / _STATE_FILE)
|
||||
|
||||
|
||||
def _workdirs_for_sessions(kimi_home: Path) -> dict[str, str]:
|
||||
"""Map each session dir → its ``workDir`` from ``session_index.jsonl``.
|
||||
|
||||
Returns ``{}`` when the index is absent/unreadable (a brand-new home before
|
||||
kimi has written any session).
|
||||
"""
|
||||
index = kimi_home / "session_index.jsonl"
|
||||
mapping: dict[str, str] = {}
|
||||
try:
|
||||
text = index.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return mapping
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
row = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if isinstance(row, dict):
|
||||
session_dir = row.get("sessionDir")
|
||||
work_dir = row.get("workDir")
|
||||
if isinstance(session_dir, str) and isinstance(work_dir, str):
|
||||
mapping[session_dir] = work_dir
|
||||
return mapping
|
||||
|
||||
|
||||
def _discover_wire(kimi_home: Path, workspace: str, launch_epoch_ms: int) -> Path | None:
|
||||
"""Locate the wire log for *workspace*'s newest session created at/after launch.
|
||||
|
||||
Globs ``sessions/*/session_*/agents/main/wire.jsonl`` under *kimi_home*,
|
||||
keeps only sessions whose ``session_index`` ``workDir`` matches *workspace*
|
||||
(when the index lists them), and returns the most-recently-modified wire log
|
||||
whose mtime is at/after ``launch_epoch_ms`` (minus skew). Returns ``None``
|
||||
until kimi has created the session.
|
||||
"""
|
||||
sessions_root = kimi_home / "sessions"
|
||||
if not sessions_root.exists():
|
||||
return None
|
||||
workdirs = _workdirs_for_sessions(kimi_home)
|
||||
floor_s = (launch_epoch_ms - _DISCOVER_SKEW_MS) / 1000.0
|
||||
best: tuple[float, Path] | None = None
|
||||
for wire in sessions_root.glob("*/session_*/agents/main/wire.jsonl"):
|
||||
# session_index keys on the session dir (…/<wd_…>/<session_…>).
|
||||
session_dir = str(wire.parent.parent.parent)
|
||||
work_dir = workdirs.get(session_dir)
|
||||
# When the index doesn't list it yet, fall back to recency alone — a
|
||||
# freshly created session may not be indexed until its first turn.
|
||||
if work_dir is not None and work_dir != workspace:
|
||||
continue
|
||||
try:
|
||||
mtime = wire.stat().st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
if mtime < floor_s:
|
||||
continue
|
||||
if best is None or mtime > best[0]:
|
||||
best = (mtime, wire)
|
||||
return best[1] if best is not None else None
|
||||
|
||||
|
||||
def _input_text(blocks: object) -> str:
|
||||
"""Concatenate the ``text`` of an ``input`` / ``content`` block list."""
|
||||
if not isinstance(blocks, list):
|
||||
return ""
|
||||
parts: list[str] = []
|
||||
for block in blocks:
|
||||
if isinstance(block, dict) and block.get("type") == "text":
|
||||
text = block.get("text")
|
||||
if isinstance(text, str):
|
||||
parts.append(text)
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _row_to_item(line_no: int, row: dict[str, object]) -> _MirrorItem | None:
|
||||
"""Map one wire-log row to a conversation item, or ``None`` to skip it."""
|
||||
row_type = row.get("type")
|
||||
if row_type == "turn.prompt":
|
||||
origin = row.get("origin")
|
||||
if isinstance(origin, dict) and origin.get("kind") != "user":
|
||||
return None
|
||||
text = _input_text(row.get("input"))
|
||||
if not text:
|
||||
return None
|
||||
return _MirrorItem(
|
||||
line_no=line_no,
|
||||
role="user",
|
||||
text=text,
|
||||
response_id=f"kimi:turn:{line_no}",
|
||||
)
|
||||
if row_type == "context.append_loop_event":
|
||||
event = row.get("event")
|
||||
if not isinstance(event, dict) or event.get("type") != "content.part":
|
||||
return None
|
||||
part = event.get("part")
|
||||
if not isinstance(part, dict) or part.get("type") != "text":
|
||||
return None
|
||||
text = part.get("text")
|
||||
if not isinstance(text, str) or not text:
|
||||
return None
|
||||
uuid = event.get("uuid")
|
||||
response_id = f"kimi:{uuid}" if isinstance(uuid, str) and uuid else f"kimi:line:{line_no}"
|
||||
return _MirrorItem(line_no=line_no, role="assistant", text=text, response_id=response_id)
|
||||
return None
|
||||
|
||||
|
||||
def _read_new_items(wire_path: Path, last_line: int) -> list[_MirrorItem]:
|
||||
"""Parse wire-log lines beyond *last_line* into conversation items.
|
||||
|
||||
The wire log is append-only JSONL, so a line count is a stable high-water
|
||||
mark. Non-JSON / unrecognized lines advance the cursor without emitting.
|
||||
"""
|
||||
try:
|
||||
lines = wire_path.read_text(encoding="utf-8").splitlines()
|
||||
except OSError:
|
||||
return []
|
||||
items: list[_MirrorItem] = []
|
||||
for idx in range(last_line, len(lines)):
|
||||
line = lines[idx].strip()
|
||||
if not line or not line.startswith("{"):
|
||||
continue
|
||||
try:
|
||||
row = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
item = _row_to_item(idx, row)
|
||||
if item is not None:
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
|
||||
async def _post_conversation_item(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
base_url: str,
|
||||
headers: dict[str, str],
|
||||
session_id: str,
|
||||
item: _MirrorItem,
|
||||
agent_name: str,
|
||||
) -> None:
|
||||
"""POST one mirrored turn as an external conversation item."""
|
||||
content_type = "input_text" if item.role == "user" else "output_text"
|
||||
item_data: dict[str, object] = {
|
||||
"role": item.role,
|
||||
"content": [{"type": content_type, "text": item.text}],
|
||||
}
|
||||
if item.role == "assistant":
|
||||
item_data["agent"] = agent_name
|
||||
body = {
|
||||
"type": "external_conversation_item",
|
||||
"data": {
|
||||
"item_type": "message",
|
||||
"item_data": item_data,
|
||||
"response_id": item.response_id,
|
||||
},
|
||||
}
|
||||
url = f"{base_url.rstrip('/')}/v1/sessions/{session_id}/events"
|
||||
resp = await client.post(url, headers=headers, json=body)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
async def forward_kimi_wire_to_session(
|
||||
*,
|
||||
base_url: str,
|
||||
headers: dict[str, str],
|
||||
session_id: str,
|
||||
bridge_dir: Path,
|
||||
kimi_home: Path,
|
||||
workspace: str,
|
||||
launch_epoch_ms: int,
|
||||
agent_name: str = "kimi-native-ui",
|
||||
) -> None:
|
||||
"""Poll the kimi session wire log and mirror new turns into the chat.
|
||||
|
||||
Runs until cancelled. Discovers the wire log lazily (kimi writes it after the
|
||||
first turn), then tails it, POSTing each new user/assistant turn and
|
||||
persisting the line offset after every post.
|
||||
"""
|
||||
state = _read_state(bridge_dir)
|
||||
wire_path = Path(state.wire_path) if state is not None else None
|
||||
last_line = state.last_line if state is not None else 0
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
while True:
|
||||
if wire_path is None or not wire_path.exists():
|
||||
discovered = await asyncio.to_thread(
|
||||
_discover_wire, kimi_home, workspace, launch_epoch_ms
|
||||
)
|
||||
if discovered is not None and discovered != wire_path:
|
||||
wire_path = discovered
|
||||
last_line = 0
|
||||
_write_state(bridge_dir, _ForwardState(str(wire_path), last_line))
|
||||
if wire_path is not None and wire_path.exists():
|
||||
items = await asyncio.to_thread(_read_new_items, wire_path, last_line)
|
||||
for item in items:
|
||||
try:
|
||||
await _post_conversation_item(
|
||||
client,
|
||||
base_url=base_url,
|
||||
headers=headers,
|
||||
session_id=session_id,
|
||||
item=item,
|
||||
agent_name=agent_name,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
_logger.warning("kimi forwarder: POST failed (will retry): %s", exc)
|
||||
break
|
||||
last_line = item.line_no + 1
|
||||
_write_state(bridge_dir, _ForwardState(str(wire_path), last_line))
|
||||
await asyncio.sleep(_POLL_INTERVAL_S)
|
||||
|
||||
|
||||
async def supervise_kimi_forwarder(
|
||||
*,
|
||||
base_url: str,
|
||||
headers: dict[str, str],
|
||||
session_id: str,
|
||||
bridge_dir: Path,
|
||||
kimi_home: Path,
|
||||
workspace: str,
|
||||
launch_epoch_ms: int,
|
||||
agent_name: str = "kimi-native-ui",
|
||||
) -> None:
|
||||
"""Run :func:`forward_kimi_wire_to_session` with restart-on-crash backoff.
|
||||
|
||||
Propagates :class:`asyncio.CancelledError` cleanly (terminal teardown), but
|
||||
restarts on any other exception with exponential backoff — mirrors
|
||||
``cursor_native_forwarder.supervise_cursor_forwarder``.
|
||||
"""
|
||||
backoff = _BACKOFF_INITIAL_S
|
||||
while True:
|
||||
try:
|
||||
await forward_kimi_wire_to_session(
|
||||
base_url=base_url,
|
||||
headers=headers,
|
||||
session_id=session_id,
|
||||
bridge_dir=bridge_dir,
|
||||
kimi_home=kimi_home,
|
||||
workspace=workspace,
|
||||
launch_epoch_ms=launch_epoch_ms,
|
||||
agent_name=agent_name,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
_logger.exception("kimi forwarder crashed for session %s; restarting", session_id)
|
||||
await asyncio.sleep(backoff)
|
||||
backoff = min(backoff * 2, _BACKOFF_MAX_S)
|
||||
else:
|
||||
return
|
||||
@@ -0,0 +1,320 @@
|
||||
"""Kimi Code hook commands for the native Omnigent wrapper.
|
||||
|
||||
Registered into a per-session ``config.toml`` ``[[hooks]]`` array (see
|
||||
:mod:`omnigent.kimi_native_credentials`) so the running ``kimi`` TUI invokes
|
||||
them. Kimi spawns each hook with ``shell: true``, feeds the event JSON on
|
||||
stdin, and reads the decision back from stdout as
|
||||
``{"hookSpecificOutput": {"permissionDecision": ..., "permissionDecisionReason": ...}}``
|
||||
(``permissionDecision == "deny"`` blocks the tool). Two subcommands:
|
||||
|
||||
- ``evaluate-policy`` — the ``PreToolUse`` deny-gate. Mirrors
|
||||
:func:`omnigent.claude_native_hook._main_evaluate_policy`: it converts the
|
||||
Kimi hook payload into an Omnigent ``EvaluationRequest`` (the snake-cased
|
||||
Kimi fields ``tool_name`` / ``tool_input`` / ``hook_event_name`` line up
|
||||
with :func:`omnigent.native_policy_hook.hook_payload_to_evaluation_request`),
|
||||
POSTs to ``/v1/sessions/{id}/policies/evaluate``, and emits a ``deny`` only
|
||||
for a constraining ``POLICY_ACTION_DENY`` verdict. ``ALLOW`` (the engine's
|
||||
no-match default) emits nothing, so kimi's own in-TUI approval prompt still
|
||||
runs — Omnigent enforces its deny-policy without silencing the user's
|
||||
consent. Fails CLOSED (deny) when an already-governed session can't reach a
|
||||
verdict, matching the claude-native gate.
|
||||
|
||||
- ``permission-request`` — the interactive web-UI approval. Kimi fires
|
||||
``PermissionRequest`` fire-and-forget (it does NOT read this hook's output —
|
||||
approval is answered by kimi's own TUI menu), so the hook cannot return an
|
||||
honored decision. Instead it drives a real web-UI Approve/Deny: it POSTs the
|
||||
gated tool to ``/v1/sessions/{id}/hooks/permission-request`` (the same
|
||||
endpoint claude-native uses — the server publishes the approval card and
|
||||
long-polls for the web verdict), then types the answer back into kimi's
|
||||
prompt via ``inject_approval_keystroke`` (option digit + Enter:
|
||||
:data:`~omnigent.kimi_native_bridge.APPROVE_KEY` "Approve once" /
|
||||
:data:`~omnigent.kimi_native_bridge.DENY_KEY` "Reject"). Fail-safe: on no
|
||||
verdict (timeout / unreachable / already answered in the terminal) it injects
|
||||
nothing and kimi's own TUI prompt stands. Never blocks the TUI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import secrets
|
||||
import sys
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from omnigent.kimi_native_bridge import (
|
||||
APPROVE_KEY,
|
||||
DENY_KEY,
|
||||
inject_approval_keystroke,
|
||||
read_active_session_id,
|
||||
read_hook_config,
|
||||
)
|
||||
from omnigent.native_policy_hook import (
|
||||
evaluation_response_to_hook_output,
|
||||
fail_closed_hook_output,
|
||||
hook_payload_to_evaluation_request,
|
||||
post_evaluate_with_retry,
|
||||
)
|
||||
|
||||
# PreToolUse evaluations are normally a quick request/reply. (Unlike
|
||||
# claude-native, a TOOL_CALL ASK does NOT park here — kimi owns the ask via
|
||||
# its own TUI prompt, so the policy layer only ever DENY/ALLOWs for kimi.)
|
||||
_EVALUATE_POLICY_TIMEOUT_S = 70.0
|
||||
# Short timeout for the keystroke-injection tmux round-trip; never delay the TUI.
|
||||
_SURFACE_TIMEOUT_S = 10.0
|
||||
# Long-poll budget for the web approval verdict — the human may take a while.
|
||||
# On timeout the server returns an empty 200 and we fall back to kimi's own TUI
|
||||
# prompt (manual approval in the terminal).
|
||||
_PERMISSION_REQUEST_TIMEOUT_S = 3600.0
|
||||
_HARNESS = "kimi-native"
|
||||
|
||||
|
||||
def _url_component(value: str) -> str:
|
||||
"""Percent-encode one URL path component (slashes escaped)."""
|
||||
return urllib.parse.quote(value, safe="")
|
||||
|
||||
|
||||
def _headers_from_config(config: dict[str, object]) -> dict[str, str]:
|
||||
"""Extract replayable auth headers from the bridge hook config."""
|
||||
raw = config.get("ap_auth_headers")
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
return {str(key): str(value) for key, value in raw.items()}
|
||||
|
||||
|
||||
def _read_stdin_payload() -> dict[str, object] | None:
|
||||
"""Parse the hook event JSON from stdin; ``None`` when unusable."""
|
||||
raw = sys.stdin.read()
|
||||
try:
|
||||
payload = json.loads(raw or "{}")
|
||||
except json.JSONDecodeError as exc:
|
||||
print(f"omnigent kimi hook: malformed JSON: {exc}", file=sys.stderr)
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
print("omnigent kimi hook: expected JSON object", file=sys.stderr)
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
def _main_evaluate_policy(argv: list[str]) -> int:
|
||||
"""Evaluate a kimi ``PreToolUse`` hook against Omnigent policies.
|
||||
|
||||
Reads the hook payload from stdin, POSTs an ``EvaluationRequest`` to
|
||||
``/v1/sessions/{id}/policies/evaluate``, and writes kimi's hook decision
|
||||
to stdout. Only ``POLICY_ACTION_DENY`` produces a ``deny``; everything
|
||||
else emits nothing ("no opinion") so kimi's own approval prompt still
|
||||
fires. An already-governed session that cannot obtain a verdict fails
|
||||
CLOSED with a ``deny`` (this hook is the sole Omnigent enforcement point
|
||||
for kimi tool calls).
|
||||
|
||||
:param argv: CLI argv after the ``evaluate-policy`` subcommand.
|
||||
:returns: Always ``0`` — verdicts are expressed via JSON, not exit codes.
|
||||
"""
|
||||
args = _parse_bridge_dir_args(argv, "evaluate-policy")
|
||||
payload = _read_stdin_payload()
|
||||
if payload is None:
|
||||
return 0
|
||||
bridge_dir = Path(args.bridge_dir)
|
||||
session_id = read_active_session_id(bridge_dir)
|
||||
if not session_id:
|
||||
return 0 # not a governed session — no opinion
|
||||
config = read_hook_config(bridge_dir)
|
||||
ap_server_url = config.get("ap_server_url")
|
||||
if not isinstance(ap_server_url, str) or not ap_server_url:
|
||||
return 0
|
||||
headers = _headers_from_config(config)
|
||||
|
||||
hook_event = payload.get("hook_event_name", "")
|
||||
if not isinstance(hook_event, str):
|
||||
return 0
|
||||
eval_request = hook_payload_to_evaluation_request(hook_event, payload)
|
||||
if eval_request is None:
|
||||
# Unrecognized event or an mcp__omnigent__* tool already gated on the
|
||||
# relay path — no policy to evaluate here.
|
||||
return 0
|
||||
|
||||
# hook_payload_to_evaluation_request always returns an event with a
|
||||
# "context" dict; index it directly (fail loud if that contract changes).
|
||||
context = eval_request["event"]["context"]
|
||||
context["harness"] = _HARNESS
|
||||
|
||||
def _fail_closed() -> int:
|
||||
out = fail_closed_hook_output(hook_event)
|
||||
if out is not None:
|
||||
sys.stdout.write(json.dumps(out))
|
||||
return 0
|
||||
|
||||
url = (
|
||||
f"{ap_server_url.rstrip('/')}"
|
||||
f"/v1/sessions/{_url_component(session_id)}/policies/evaluate"
|
||||
)
|
||||
resp = post_evaluate_with_retry(
|
||||
url, headers, eval_request, _EVALUATE_POLICY_TIMEOUT_S, "kimi evaluate-policy hook"
|
||||
)
|
||||
if resp is None or not resp.content:
|
||||
return _fail_closed()
|
||||
try:
|
||||
eval_response = resp.json()
|
||||
except json.JSONDecodeError:
|
||||
print("omnigent kimi evaluate-policy hook: malformed Omnigent response", file=sys.stderr)
|
||||
return _fail_closed()
|
||||
|
||||
hook_output = evaluation_response_to_hook_output(hook_event, eval_response)
|
||||
if hook_output is not None:
|
||||
sys.stdout.write(json.dumps(hook_output))
|
||||
return 0
|
||||
|
||||
|
||||
def _main_permission_request(argv: list[str]) -> int:
|
||||
"""Mirror a kimi ``PermissionRequest`` to the web UI and inject the verdict.
|
||||
|
||||
Kimi fires this hook **fire-and-forget** — it answers approval in its own
|
||||
TUI and does NOT read the hook's stdout — so we cannot return a decision it
|
||||
honors. Instead we drive an interactive web-UI approval and type the answer
|
||||
back into kimi's prompt:
|
||||
|
||||
1. POST the gated tool to ``/v1/sessions/{id}/hooks/permission-request`` —
|
||||
the server publishes the standard ``response.elicitation_request``
|
||||
approval card and long-polls for the web verdict (the very endpoint
|
||||
claude-native uses).
|
||||
2. On ``allow`` / ``deny``, inject the matching kimi permission-menu option
|
||||
digit + Enter into the TUI pane via :func:`inject_approval_keystroke`
|
||||
(:data:`APPROVE_KEY` "Approve once" / :data:`DENY_KEY` "Reject").
|
||||
|
||||
Fail-safe: on no verdict (timeout / server unreachable / the prompt was
|
||||
already answered in the terminal) it injects nothing and kimi's own TUI
|
||||
prompt stands for manual approval. Always returns 0 (kimi ignores output).
|
||||
|
||||
:param argv: CLI argv after the ``permission-request`` subcommand.
|
||||
:returns: Always ``0``.
|
||||
"""
|
||||
args = _parse_bridge_dir_args(argv, "permission-request")
|
||||
payload = _read_stdin_payload()
|
||||
if payload is None:
|
||||
return 0
|
||||
bridge_dir = Path(args.bridge_dir)
|
||||
session_id = read_active_session_id(bridge_dir)
|
||||
if not session_id:
|
||||
return 0
|
||||
config = read_hook_config(bridge_dir)
|
||||
ap_server_url = config.get("ap_server_url")
|
||||
if not isinstance(ap_server_url, str) or not ap_server_url:
|
||||
return 0
|
||||
headers = _headers_from_config(config)
|
||||
|
||||
tool_name = payload.get("tool_name")
|
||||
if not isinstance(tool_name, str) or not tool_name:
|
||||
return 0
|
||||
body: dict[str, object] = {
|
||||
"tool_name": tool_name,
|
||||
# Stable re-attach id so a severed long-poll re-parks the SAME
|
||||
# elicitation (mirrors the claude permission hook).
|
||||
"_omnigent_elicitation_id": f"elicit_kimi_{secrets.token_hex(16)}",
|
||||
}
|
||||
tool_input = payload.get("tool_input")
|
||||
if isinstance(tool_input, dict):
|
||||
body["tool_input"] = tool_input
|
||||
|
||||
url = (
|
||||
f"{ap_server_url.rstrip('/')}/v1/sessions/"
|
||||
f"{_url_component(session_id)}/hooks/permission-request"
|
||||
)
|
||||
verdict = _request_web_approval(url, headers, body)
|
||||
if verdict is None:
|
||||
# No web verdict: leave kimi's own TUI prompt for manual approval.
|
||||
return 0
|
||||
key = APPROVE_KEY if verdict == "allow" else DENY_KEY
|
||||
try:
|
||||
inject_approval_keystroke(bridge_dir, key=key, timeout_s=_SURFACE_TIMEOUT_S)
|
||||
except RuntimeError as exc:
|
||||
print(
|
||||
f"omnigent kimi permission-request hook: keystroke inject failed: {exc}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def _request_web_approval(
|
||||
url: str, headers: dict[str, str], body: dict[str, object]
|
||||
) -> str | None:
|
||||
"""POST the approval card and long-poll for the web verdict.
|
||||
|
||||
:returns: ``"allow"`` / ``"deny"``, or ``None`` on timeout (server returns
|
||||
an empty 200), transport failure, or an unparseable verdict — all of
|
||||
which fall back to kimi's own TUI prompt.
|
||||
"""
|
||||
timeout = httpx.Timeout(_PERMISSION_REQUEST_TIMEOUT_S, connect=_SURFACE_TIMEOUT_S)
|
||||
try:
|
||||
with httpx.Client(headers=headers, timeout=timeout) as client:
|
||||
resp = client.post(url, json=body)
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPError as exc:
|
||||
print(
|
||||
f"omnigent kimi permission-request hook: approval request failed: {exc}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
if not resp.content:
|
||||
return None
|
||||
try:
|
||||
data = resp.json()
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return _verdict_from_response(data)
|
||||
|
||||
|
||||
def _verdict_from_response(data: object) -> str | None:
|
||||
"""Extract ``"allow"`` / ``"deny"`` from the PermissionRequest hook response.
|
||||
|
||||
The endpoint returns Claude's PermissionRequest contract
|
||||
(``hookSpecificOutput.decision.behavior``), with ``permissionDecision`` as a
|
||||
fallback shape. Any persistent-allow variant (``allow_*``) maps to allow.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
hook_output = data.get("hookSpecificOutput")
|
||||
if not isinstance(hook_output, dict):
|
||||
return None
|
||||
decision = hook_output.get("decision")
|
||||
behavior = decision.get("behavior") if isinstance(decision, dict) else None
|
||||
raw = behavior if isinstance(behavior, str) else hook_output.get("permissionDecision")
|
||||
if not isinstance(raw, str):
|
||||
return None
|
||||
low = raw.lower()
|
||||
if low.startswith("allow") or low in ("approve", "approved", "accept"):
|
||||
return "allow"
|
||||
if low in ("deny", "reject", "rejected", "block"):
|
||||
return "deny"
|
||||
return None
|
||||
|
||||
|
||||
def _parse_bridge_dir_args(argv: list[str], prog: str) -> argparse.Namespace:
|
||||
"""Parse the shared ``--bridge-dir`` argument for a hook subcommand."""
|
||||
parser = argparse.ArgumentParser(prog=f"omnigent.kimi_native_hook {prog}")
|
||||
parser.add_argument("--bridge-dir", required=True)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Dispatch a kimi hook subcommand.
|
||||
|
||||
:param argv: Process argv tail (defaults to ``sys.argv[1:]``).
|
||||
:returns: Process exit code.
|
||||
"""
|
||||
args = list(sys.argv[1:] if argv is None else argv)
|
||||
if not args:
|
||||
print("usage: kimi_native_hook {evaluate-policy|permission-request} ...", file=sys.stderr)
|
||||
return 2
|
||||
subcommand, rest = args[0], args[1:]
|
||||
if subcommand == "evaluate-policy":
|
||||
return _main_evaluate_policy(rest)
|
||||
if subcommand == "permission-request":
|
||||
return _main_permission_request(rest)
|
||||
print(f"omnigent kimi hook: unknown subcommand {subcommand!r}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -99,6 +99,14 @@ _PROVIDER_RESOLUTION_HARNESS: dict[str, str] = {
|
||||
"antigravity": "antigravity",
|
||||
"agy": "antigravity",
|
||||
"google-antigravity": "antigravity",
|
||||
# Kimi Code CLI is multi-provider; it shares no resolution path with an
|
||||
# existing harness. The identity entry keeps callers that iterate this
|
||||
# map (e.g. ``list_models_for_worker``) finding the harness so they
|
||||
# don't fall through to a noisy "unknown harness" branch.
|
||||
"kimi": "kimi",
|
||||
"kimi-code": "kimi",
|
||||
# Native Kimi TUI harness shares the multi-provider kimi resolution path.
|
||||
"kimi-native": "kimi",
|
||||
"qwen": "qwen",
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ _MODEL_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/\[\]-]*$")
|
||||
# SDK harnesses whose model override lands in the spawn env — must stay
|
||||
# in sync with ``_HARNESS_MODEL_ENV_KEY`` in ``omnigent/runner/app.py``.
|
||||
_SDK_MODEL_OVERRIDE_HARNESSES: frozenset[str] = frozenset(
|
||||
{"claude-sdk", "codex", "pi", "openai-agents", "cursor", "antigravity", "qwen"}
|
||||
{"claude-sdk", "codex", "pi", "openai-agents", "cursor", "antigravity", "kimi", "qwen"}
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from omnigent._wrapper_labels import (
|
||||
CLAUDE_NATIVE_WRAPPER_VALUE,
|
||||
CODEX_NATIVE_WRAPPER_VALUE,
|
||||
CURSOR_NATIVE_WRAPPER_VALUE,
|
||||
KIMI_NATIVE_WRAPPER_VALUE,
|
||||
PI_NATIVE_WRAPPER_VALUE,
|
||||
UI_MODE_LABEL_KEY,
|
||||
UI_MODE_TERMINAL_VALUE,
|
||||
@@ -75,11 +76,21 @@ CURSOR_NATIVE_CODING_AGENT = NativeCodingAgent(
|
||||
terminal_name="cursor",
|
||||
)
|
||||
|
||||
KIMI_NATIVE_CODING_AGENT = NativeCodingAgent(
|
||||
key="kimi",
|
||||
display_name="Kimi",
|
||||
agent_name="kimi-native-ui",
|
||||
harness="kimi-native",
|
||||
wrapper_label=KIMI_NATIVE_WRAPPER_VALUE,
|
||||
terminal_name="kimi",
|
||||
)
|
||||
|
||||
NATIVE_CODING_AGENTS: tuple[NativeCodingAgent, ...] = (
|
||||
CLAUDE_NATIVE_CODING_AGENT,
|
||||
CODEX_NATIVE_CODING_AGENT,
|
||||
PI_NATIVE_CODING_AGENT,
|
||||
CURSOR_NATIVE_CODING_AGENT,
|
||||
KIMI_NATIVE_CODING_AGENT,
|
||||
)
|
||||
|
||||
_BY_AGENT_NAME = {agent.agent_name: agent for agent in NATIVE_CODING_AGENTS}
|
||||
|
||||
@@ -56,6 +56,11 @@ QWEN_KEY = "qwen"
|
||||
# installer rather than npm — so it carries an ``install_hint``, not a ``package``.
|
||||
CURSOR_KEY = "cursor"
|
||||
|
||||
# Kimi authenticates against Moonshot AI's backend (``kimi login`` OAuth or a
|
||||
# Moonshot API key), not via the ambient provider config; like Cursor it ships
|
||||
# via a curl installer rather than npm, so it carries an ``install_hint``.
|
||||
KIMI_KEY = "kimi"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HarnessInstallSpec:
|
||||
@@ -141,6 +146,21 @@ _HARNESS_INSTALL: dict[str, HarnessInstallSpec] = {
|
||||
install_hint="curl https://cursor.com/install -fsS | bash",
|
||||
login_status_key="isAuthenticated",
|
||||
),
|
||||
# Kimi Code CLI ships a single-binary ``kimi`` via a curl installer (no
|
||||
# npm). ``kimi login`` is the interactive provider login (OAuth or a
|
||||
# Moonshot API key). ``status_args`` is intentionally ``None``: kimi has
|
||||
# no first-class "am I logged in?" exit-code probe — login state is
|
||||
# only inspected interactively. With ``None`` the login path runs every
|
||||
# time the operator asks for it (interactive, so they can cancel if
|
||||
# already authenticated).
|
||||
KIMI_KEY: HarnessInstallSpec(
|
||||
"Kimi",
|
||||
"kimi",
|
||||
package=None,
|
||||
login_args=("login",),
|
||||
logout_args=("logout",),
|
||||
install_hint="curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -162,8 +182,16 @@ _HARNESS_NAME_TO_KEY: dict[str, str] = {
|
||||
"codex-native": OPENAI_FAMILY,
|
||||
PI_KEY: PI_KEY,
|
||||
"pi-native": PI_KEY,
|
||||
# Kimi is multi-provider but binary-gated: cannot launch without the
|
||||
# ``kimi`` CLI on PATH. Listed here so ``required_cli_for_harness``
|
||||
# returns its install spec and ``missing_harness_cli`` fails loud
|
||||
# before a subagent spawn.
|
||||
KIMI_KEY: KIMI_KEY,
|
||||
"cursor-native": CURSOR_KEY,
|
||||
"native-cursor": CURSOR_KEY,
|
||||
# Native Kimi TUI harness — same binary gate as the bare ``kimi`` surface.
|
||||
"kimi-native": KIMI_KEY,
|
||||
"native-kimi": KIMI_KEY,
|
||||
QWEN_KEY: QWEN_KEY,
|
||||
"qwen-code": QWEN_KEY,
|
||||
}
|
||||
@@ -255,7 +283,7 @@ def harness_cli_installed(key: str) -> bool:
|
||||
``claude-sdk`` harness can run without the ``claude`` CLI.
|
||||
|
||||
:param key: A harness family (``"anthropic"`` / ``"openai"``) or
|
||||
:data:`PI_KEY`.
|
||||
:data:`PI_KEY` / :data:`KIMI_KEY`.
|
||||
:returns: ``True`` when the CLI is on ``PATH``; ``False`` when it isn't or
|
||||
the key has no associated CLI.
|
||||
"""
|
||||
|
||||
@@ -27,7 +27,13 @@ from __future__ import annotations
|
||||
import os
|
||||
|
||||
from omnigent.harness_aliases import HARNESS_ALIASES, canonicalize_harness
|
||||
from omnigent.onboarding.harness_install import CURSOR_KEY, PI_KEY, QWEN_KEY, harness_cli_installed
|
||||
from omnigent.onboarding.harness_install import (
|
||||
CURSOR_KEY,
|
||||
KIMI_KEY,
|
||||
PI_KEY,
|
||||
QWEN_KEY,
|
||||
harness_cli_installed,
|
||||
)
|
||||
from omnigent.onboarding.provider_config import (
|
||||
_EXECUTOR_TYPE_HARNESS_ALIASES,
|
||||
_HARNESS_FAMILY,
|
||||
@@ -50,6 +56,11 @@ _SDK_HARNESSES: frozenset[str] = frozenset(
|
||||
# be gated explicitly or they fail open like an unknown harness.
|
||||
_PI_HARNESSES: frozenset[str] = frozenset({PI_SURFACE, "pi-native"})
|
||||
|
||||
# Surface name for Kimi Code in the readiness map. Mirrors :data:`PI_SURFACE`
|
||||
# — kimi is a CLI-backed harness with its own backend (Moonshot AI's), not a
|
||||
# member of the anthropic/openai families that :data:`_HARNESS_FAMILY` keys.
|
||||
KIMI_SURFACE = "kimi"
|
||||
|
||||
# Native Cursor harnesses. These boot the ``cursor-agent`` TUI (``omni cursor``)
|
||||
# and so, like the other native CLI harnesses, can't launch without that binary
|
||||
# on ``PATH`` — gate them on it. Distinct from the SDK ``cursor`` harness
|
||||
@@ -58,6 +69,12 @@ _PI_HARNESSES: frozenset[str] = frozenset({PI_SURFACE, "pi-native"})
|
||||
# unknown harness, letting a binary-less launch die inside the executor.
|
||||
_CURSOR_NATIVE_HARNESSES: frozenset[str] = frozenset({"cursor-native", "native-cursor"})
|
||||
|
||||
# Native Kimi TUI harnesses (``omnigent kimi``). Like the other native CLIs,
|
||||
# they wrap the resident ``kimi`` binary and can't launch without it on
|
||||
# ``PATH`` — gate on it. Distinct from the bare ``kimi`` SDK surface
|
||||
# (:data:`KIMI_SURFACE`), which gates on the same binary but renders headlessly.
|
||||
_KIMI_NATIVE_HARNESSES: frozenset[str] = frozenset({"kimi-native", "native-kimi"})
|
||||
|
||||
# CLI-wrapping qwen harnesses. Both ``qwen`` and ``qwen-code`` resolve to the
|
||||
# same ``qwen`` binary (canonicalize_harness folds ``qwen-code`` → ``qwen``).
|
||||
# Unlike claude/codex they have no ``_HARNESS_FAMILY`` entry, so they must
|
||||
@@ -86,11 +103,15 @@ def _install_key(canonical: str) -> str:
|
||||
"""Return the install-spec key whose CLI binary *canonical* requires.
|
||||
|
||||
:param canonical: A canonical CLI-wrapping harness id keyed in
|
||||
``_HARNESS_FAMILY`` (e.g. ``"codex-native"``), or ``"pi"``.
|
||||
``_HARNESS_FAMILY`` (e.g. ``"codex-native"``), ``"pi"``, or
|
||||
``"kimi"``.
|
||||
:returns: ``"anthropic"`` / ``"openai"`` for the claude/codex CLIs,
|
||||
:data:`~omnigent.onboarding.harness_install.PI_KEY` for pi, or
|
||||
:data:`~omnigent.onboarding.harness_install.PI_KEY` for pi,
|
||||
:data:`~omnigent.onboarding.harness_install.KIMI_KEY` for kimi, or
|
||||
:data:`~omnigent.onboarding.harness_install.QWEN_KEY` for qwen.
|
||||
"""
|
||||
if canonical == KIMI_SURFACE or canonical in _KIMI_NATIVE_HARNESSES:
|
||||
return KIMI_KEY
|
||||
if canonical in _QWEN_HARNESSES:
|
||||
return QWEN_KEY
|
||||
return _HARNESS_FAMILY.get(canonical) or PI_KEY
|
||||
@@ -143,6 +164,8 @@ def harness_is_configured(harness: str) -> bool:
|
||||
if (
|
||||
canonical not in _HARNESS_FAMILY
|
||||
and canonical not in _PI_HARNESSES
|
||||
and canonical != KIMI_SURFACE
|
||||
and canonical not in _KIMI_NATIVE_HARNESSES
|
||||
and canonical not in _QWEN_HARNESSES
|
||||
):
|
||||
# Unknown harness — the daemon has no install metadata for it, so
|
||||
@@ -170,6 +193,8 @@ def configured_harness_map() -> dict[str, bool]:
|
||||
spellings.update(HARNESS_ALIASES)
|
||||
spellings.update(_PI_HARNESSES)
|
||||
spellings.update(_CURSOR_NATIVE_HARNESSES)
|
||||
spellings.update(_KIMI_NATIVE_HARNESSES)
|
||||
spellings.update(_QWEN_HARNESSES)
|
||||
spellings.add(CURSOR_KEY)
|
||||
spellings.add(KIMI_SURFACE)
|
||||
return {spelling: harness_is_configured(spelling) for spelling in spellings}
|
||||
|
||||
@@ -136,7 +136,11 @@ _HARNESS_FAMILY: dict[str, str] = {
|
||||
# Antigravity is Gemini-native but routes generic-provider traffic over
|
||||
# the OpenAI-compatible wire, so it consumes the ``openai`` family.
|
||||
"antigravity": OPENAI_FAMILY,
|
||||
# Qwen Code uses an OpenAI-compatible provider (like Kimi v1).
|
||||
# NB: ``kimi`` is intentionally absent. Upstream Kimi Code CLI has no
|
||||
# per-spawn provider override flag, so Omnigent cannot thread a generic
|
||||
# provider through. Provider routing for kimi lives in ``~/.kimi/config.toml``
|
||||
# and is managed out-of-band via ``kimi provider add``.
|
||||
# Qwen Code uses an OpenAI-compatible provider.
|
||||
"qwen": OPENAI_FAMILY,
|
||||
}
|
||||
|
||||
|
||||
@@ -252,6 +252,15 @@ def _dispatch_wrapper(
|
||||
cursor_args=(),
|
||||
)
|
||||
return True
|
||||
if native_agent.key == "kimi":
|
||||
from omnigent.kimi_native import run_kimi_native
|
||||
|
||||
run_kimi_native(
|
||||
server=server,
|
||||
session_id=session_id,
|
||||
kimi_args=(),
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
|
||||
+342
-1
@@ -57,6 +57,7 @@ from omnigent.runner.resource_registry import (
|
||||
CLAUDE_NATIVE_TERMINAL_ROLE,
|
||||
CODEX_NATIVE_TERMINAL_ROLE,
|
||||
CURSOR_NATIVE_TERMINAL_ROLE,
|
||||
KIMI_NATIVE_TERMINAL_ROLE,
|
||||
OMNIGENT_REPL_TERMINAL_ROLE,
|
||||
PI_NATIVE_TERMINAL_ROLE,
|
||||
SessionResourceRegistry,
|
||||
@@ -1017,6 +1018,161 @@ async def _auto_create_cursor_terminal(
|
||||
return terminal_view
|
||||
|
||||
|
||||
async def _auto_create_kimi_terminal(
|
||||
session_id: str,
|
||||
resource_registry: SessionResourceRegistry,
|
||||
publish_event: Callable[[str, dict[str, Any]], None],
|
||||
*,
|
||||
server_client: httpx.AsyncClient | None,
|
||||
ensure_comment_relay: Callable[..., Awaitable[None]] | None = None,
|
||||
agent_spec: AgentSpec | ResolvedSpec | None = None,
|
||||
) -> SessionResourceView:
|
||||
"""
|
||||
Auto-create the Kimi TUI terminal for a kimi-native session.
|
||||
|
||||
Launches ``kimi`` (no args → interactive TUI) in a runner-owned tmux pane,
|
||||
then advertises the pane's tmux socket+target so the kimi-native harness
|
||||
executor can inject web-UI turns into the same pane (tmux paste).
|
||||
|
||||
The pane runs with a session-scoped ``KIMI_CODE_HOME`` (built by
|
||||
:func:`omnigent.kimi_native_credentials.build_kimi_session_home`) that
|
||||
mirrors the user's global ``kimi login`` (symlinked ``oauth`` / providers)
|
||||
and adds the Omnigent tool-policy hooks — a ``PreToolUse`` deny-gate and a
|
||||
``PermissionRequest`` read-only surface dispatched to
|
||||
:mod:`omnigent.kimi_native_hook`. The hook subprocess reads its routing
|
||||
from ``hook_config.json`` in the bridge dir.
|
||||
|
||||
A background forwarder (:func:`omnigent.kimi_native_forwarder.
|
||||
supervise_kimi_forwarder`) tails kimi's per-session ``wire.jsonl`` transcript
|
||||
and mirrors each user prompt + assistant reply into the Omnigent chat, so the
|
||||
response shows in the web UI — not only the embedded terminal. Tool calls and
|
||||
reasoning are NOT mirrored (the embedded terminal renders those); see
|
||||
docs/KIMI_FOLLOWUPS.md. NO MCP plumbing (upstream kimi has no per-spawn MCP
|
||||
config).
|
||||
|
||||
:param session_id: Session/conversation identifier.
|
||||
:param resource_registry: Session resource registry for launching the
|
||||
terminal.
|
||||
:param publish_event: Runner session event publisher.
|
||||
:param server_client: Runner Omnigent server client (used only for the
|
||||
workspace snapshot read).
|
||||
:param ensure_comment_relay: Unused; kept for call-site parity with the
|
||||
other native auto-create helpers.
|
||||
:param agent_spec: Unused for now (model pinning via the kimi TUI is a
|
||||
follow-up); kept for call-site parity.
|
||||
:returns: Created terminal resource view.
|
||||
"""
|
||||
del ensure_comment_relay, agent_spec
|
||||
from omnigent.inner.datamodel import OSEnvSpec, TerminalEnvSpec
|
||||
from omnigent.kimi_native import resolve_kimi_executable
|
||||
from omnigent.kimi_native_bridge import (
|
||||
bridge_dir_for_session_id,
|
||||
write_hook_config,
|
||||
write_tmux_target,
|
||||
)
|
||||
from omnigent.kimi_native_credentials import build_kimi_session_home
|
||||
from omnigent.kimi_native_forwarder import clear_kimi_bridge_state, supervise_kimi_forwarder
|
||||
from omnigent.runner._entry import _make_auth_token_factory
|
||||
|
||||
bridge_dir = bridge_dir_for_session_id(session_id)
|
||||
# Stamp launch time before the TUI starts so the forwarder only adopts a kimi
|
||||
# session created for THIS launch. Tear down any prior forwarder + its line
|
||||
# offset so a re-created terminal tails the fresh wire log (mirrors cursor).
|
||||
launch_epoch_ms = int(time.time() * 1000)
|
||||
await _cancel_auto_forwarder_task(session_id)
|
||||
clear_kimi_bridge_state(bridge_dir)
|
||||
|
||||
# ``_pi_native_launch_config`` is a generic session-snapshot reader
|
||||
# (workspace + terminal_launch_args); reused here, not Pi-specific.
|
||||
launch_config = await _pi_native_launch_config(
|
||||
session_id=session_id,
|
||||
server_client=server_client,
|
||||
)
|
||||
workspace = os.path.realpath(str(launch_config.workspace))
|
||||
kimi_command = resolve_kimi_executable()
|
||||
# No subcommand: bare ``kimi`` launches the interactive TUI. Pass-through
|
||||
# launch args (``omnigent kimi -- <args>``) are persisted on the session
|
||||
# snapshot and threaded here.
|
||||
kimi_args = list(launch_config.terminal_launch_args or [])
|
||||
|
||||
# Wire the Omnigent tool-policy hooks: kimi reads a single
|
||||
# ``$KIMI_CODE_HOME/config.toml``, so point it at a session-scoped home that
|
||||
# mirrors the user's global kimi config (symlinked auth) plus a PreToolUse
|
||||
# deny-gate and a PermissionRequest read-only surface, both dispatched to
|
||||
# ``omnigent.kimi_native_hook``. The hook subprocess reads the server URL +
|
||||
# auth + session id from ``hook_config.json`` in the bridge dir, so persist
|
||||
# those first. The hook gets a one-shot token snapshot (a quick
|
||||
# request/reply, like claude-native's permission hook); ``None`` factory is
|
||||
# a safe no-op for local unauthenticated runs.
|
||||
server_url = os.environ.get("RUNNER_SERVER_URL", "http://localhost:6767").rstrip("/")
|
||||
_auth_factory = _make_auth_token_factory()
|
||||
_auth_token = _auth_factory() if _auth_factory is not None else None
|
||||
_runner_headers = {"Authorization": f"Bearer {_auth_token}"} if _auth_token else {}
|
||||
write_hook_config(
|
||||
bridge_dir,
|
||||
server_url=server_url,
|
||||
headers=_runner_headers,
|
||||
session_id=session_id,
|
||||
)
|
||||
kimi_env = build_kimi_session_home(
|
||||
bridge_dir / "kimi-code-home",
|
||||
bridge_dir=bridge_dir,
|
||||
)
|
||||
terminal_view = await resource_registry.launch_required_terminal(
|
||||
session_id=session_id,
|
||||
terminal_name="kimi",
|
||||
session_key="main",
|
||||
resource_role=KIMI_NATIVE_TERMINAL_ROLE,
|
||||
spec=TerminalEnvSpec(
|
||||
os_env=OSEnvSpec(type="caller_process", cwd=workspace),
|
||||
command=kimi_command,
|
||||
args=kimi_args,
|
||||
env=kimi_env,
|
||||
scrollback=100_000,
|
||||
tmux_allow_passthrough=True,
|
||||
tmux_start_on_attach=False,
|
||||
),
|
||||
)
|
||||
# Advertise the tmux socket+target so the kimi-native harness executor can
|
||||
# inject web-UI messages into this same pane (tmux paste), wiring the web
|
||||
# chat box to the running TUI.
|
||||
terminal_registry = resource_registry.terminal_registry
|
||||
if terminal_registry is not None:
|
||||
instance = terminal_registry.get(session_id, "kimi", "main")
|
||||
if instance is not None and instance.running:
|
||||
write_tmux_target(
|
||||
bridge_dir,
|
||||
socket_path=instance.socket_path,
|
||||
tmux_target=instance.tmux_target,
|
||||
)
|
||||
publish_event(
|
||||
session_id,
|
||||
{
|
||||
"type": "session.resource.created",
|
||||
"resource": session_resource_view_to_dict(terminal_view),
|
||||
},
|
||||
)
|
||||
# Mirror the kimi TUI transcript into the Omnigent chat: tail the per-session
|
||||
# wire.jsonl and POST each user/assistant turn, so the reply renders in the
|
||||
# web UI (not just the embedded pane). Reuses the shared auto-forwarder
|
||||
# registry so terminal teardown / stop cancels it.
|
||||
_forwarder_task = asyncio.create_task(
|
||||
supervise_kimi_forwarder(
|
||||
base_url=server_url,
|
||||
headers=_runner_headers,
|
||||
session_id=session_id,
|
||||
bridge_dir=bridge_dir,
|
||||
kimi_home=bridge_dir / "kimi-code-home",
|
||||
workspace=workspace,
|
||||
launch_epoch_ms=launch_epoch_ms,
|
||||
),
|
||||
name=f"kimi-forwarder-{session_id}",
|
||||
)
|
||||
_register_auto_forwarder_task(session_id, _forwarder_task)
|
||||
_logger.info("Auto-created kimi terminal + forwarder for session %s", session_id)
|
||||
return terminal_view
|
||||
|
||||
|
||||
async def _auto_create_codex_terminal(
|
||||
session_id: str,
|
||||
resource_registry: SessionResourceRegistry,
|
||||
@@ -4602,6 +4758,7 @@ def create_runner_app(
|
||||
_codex_terminal_ensure_locks: dict[str, asyncio.Lock] = {}
|
||||
_pi_terminal_ensure_locks: dict[str, asyncio.Lock] = {}
|
||||
_cursor_terminal_ensure_locks: dict[str, asyncio.Lock] = {}
|
||||
_kimi_terminal_ensure_locks: dict[str, asyncio.Lock] = {}
|
||||
# Per-session lock guarding the claude-native terminal auto-create in
|
||||
# ``create_session``. Two ``POST /v1/sessions`` calls can land
|
||||
# concurrently on a host-launched runner — ``_on_runner_connect``
|
||||
@@ -5458,6 +5615,10 @@ def create_runner_app(
|
||||
from omnigent.cursor_native_bridge import build_cursor_native_spawn_env
|
||||
|
||||
spawn_env = build_cursor_native_spawn_env(session_id)
|
||||
if harness_name == "kimi-native" and spawn_env is None:
|
||||
from omnigent.kimi_native_bridge import build_kimi_native_spawn_env
|
||||
|
||||
spawn_env = build_kimi_native_spawn_env(session_id)
|
||||
_session_spec_cache[session_id] = spec_entry
|
||||
from omnigent.llms.context_window import get_model_context_window
|
||||
from omnigent.runtime.workflow import _resolve_spec_model
|
||||
@@ -5815,6 +5976,44 @@ def create_runner_app(
|
||||
finally:
|
||||
_publish_terminal_pending(_publish_event, session_id, False)
|
||||
|
||||
if harness_name == "kimi-native":
|
||||
_kimi_ensure_lock = _kimi_terminal_ensure_locks.setdefault(
|
||||
session_id, asyncio.Lock()
|
||||
)
|
||||
async with _kimi_ensure_lock:
|
||||
_tr = resource_registry.terminal_registry
|
||||
_has_kimi_terminal = (
|
||||
_tr is not None and _tr.get(session_id, "kimi", "main") is not None
|
||||
)
|
||||
if not _has_kimi_terminal:
|
||||
_publish_terminal_pending(_publish_event, session_id, True)
|
||||
try:
|
||||
try:
|
||||
_kimi_spec = await _resolve_session_agent_spec(session_id)
|
||||
except OmnigentError:
|
||||
_kimi_spec = None
|
||||
await _auto_create_kimi_terminal(
|
||||
session_id,
|
||||
resource_registry,
|
||||
_publish_event,
|
||||
server_client=server_client,
|
||||
ensure_comment_relay=_ensure_comment_relay_started,
|
||||
agent_spec=_kimi_spec,
|
||||
)
|
||||
except Exception as exc:
|
||||
_logger.exception(
|
||||
"Failed to auto-create kimi terminal for %s",
|
||||
session_id,
|
||||
)
|
||||
_publish_native_terminal_start_error(
|
||||
_publish_event,
|
||||
session_id,
|
||||
"Kimi",
|
||||
exc,
|
||||
)
|
||||
finally:
|
||||
_publish_terminal_pending(_publish_event, session_id, False)
|
||||
|
||||
# Auto-bootstrap the Omnigent REPL terminal for non-native
|
||||
# (SDK-harness) top-level sessions: host the framework's own TUI
|
||||
# (``omnigent attach``) in a tmux pane so the web UI can embed it
|
||||
@@ -6083,6 +6282,7 @@ def create_runner_app(
|
||||
_claude_terminal_ensure_locks.pop(session_id, None)
|
||||
_pi_terminal_ensure_locks.pop(session_id, None)
|
||||
_cursor_terminal_ensure_locks.pop(session_id, None)
|
||||
_kimi_terminal_ensure_locks.pop(session_id, None)
|
||||
_repl_terminal_ensure_locks.pop(session_id, None)
|
||||
_interrupted_sessions.discard(session_id)
|
||||
|
||||
@@ -6810,7 +7010,12 @@ def create_runner_app(
|
||||
# source — fall through and publish. Suppress only once we positively
|
||||
# know the harness/edge is terminal-owned.
|
||||
harness = _session_harness_name(conv_id)
|
||||
if status != "failed" and harness in {"claude-native", "pi-native", "cursor-native"}:
|
||||
if status != "failed" and harness in {
|
||||
"claude-native",
|
||||
"pi-native",
|
||||
"cursor-native",
|
||||
"kimi-native",
|
||||
}:
|
||||
return
|
||||
if status == "idle" and harness == "codex-native":
|
||||
return
|
||||
@@ -7522,6 +7727,80 @@ def create_runner_app(
|
||||
)
|
||||
return Response(status_code=204)
|
||||
|
||||
async def _handle_kimi_native_interrupt(conv_id: str) -> Response:
|
||||
"""Cancel the in-flight kimi turn by sending ``Escape`` to its TUI pane.
|
||||
|
||||
kimi-native turns run inside the kimi TUI; the runner harness task
|
||||
returns right after the tmux paste, so the in-process cancel floor has
|
||||
nothing to cancel. ``Escape`` stops a running kimi turn.
|
||||
|
||||
:param conv_id: Session/conversation identifier.
|
||||
:returns: 204 when Escape was sent; 503 if the tmux target is unavailable.
|
||||
"""
|
||||
from omnigent.kimi_native_bridge import bridge_dir_for_session_id, inject_interrupt
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
inject_interrupt, bridge_dir_for_session_id(conv_id), timeout_s=1.0
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"error": "kimi_native_interrupt_failed",
|
||||
"detail": _client_safe_error_detail(exc, context="kimi-native interrupt"),
|
||||
},
|
||||
)
|
||||
_wake_parent_after_native_interrupt(conv_id)
|
||||
return Response(status_code=204)
|
||||
|
||||
async def _handle_kimi_native_stop(conv_id: str) -> Response:
|
||||
"""Hard-stop a kimi-native session by killing its tmux session.
|
||||
|
||||
Mirrors :func:`_handle_cursor_native_stop`: kill the pane (ends kimi),
|
||||
cancel the transcript forwarder (the chat store is now frozen — nothing
|
||||
left to mirror), tear the terminal resource down so the web UI stops
|
||||
showing a live terminal, publish ``idle`` so the spinner clears, and
|
||||
reclaim any sub-agent work entry.
|
||||
|
||||
:param conv_id: Session/conversation identifier.
|
||||
:returns: 204 on success; 503 if the tmux target is unavailable.
|
||||
"""
|
||||
from omnigent.kimi_native_bridge import bridge_dir_for_session_id, kill_session
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
kill_session, bridge_dir_for_session_id(conv_id), timeout_s=1.0
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"error": "kimi_native_stop_failed",
|
||||
"detail": _client_safe_error_detail(exc, context="kimi-native stop"),
|
||||
},
|
||||
)
|
||||
await _teardown_session_terminals(conv_id)
|
||||
# Stop mirroring: the wire log is now frozen, so cancel the forwarder so
|
||||
# it isn't left polling a dead session.
|
||||
await _cancel_auto_forwarder_task(conv_id)
|
||||
_publish_event(conv_id, {"type": "session.status", "status": "idle"})
|
||||
delivery_ack = _mark_subagent_terminal_and_wake(
|
||||
conv_id,
|
||||
status="cancelled",
|
||||
output="[System: sub-agent stopped]",
|
||||
)
|
||||
if not delivery_ack.delivered and (
|
||||
delivery_ack.entry is not None or conv_id in _session_sub_agent_names
|
||||
):
|
||||
_logger.warning(
|
||||
"Kimi-native stop succeeded but sub-agent delivery was "
|
||||
"not confirmed; session=%s reason=%s",
|
||||
conv_id,
|
||||
delivery_ack.reason,
|
||||
)
|
||||
return Response(status_code=204)
|
||||
|
||||
async def _handle_claude_native_effort_change(
|
||||
conv_id: str,
|
||||
effort: str | None,
|
||||
@@ -9548,6 +9827,10 @@ def create_runner_app(
|
||||
from omnigent.cursor_native_bridge import build_cursor_native_spawn_env
|
||||
|
||||
spawn_env = build_cursor_native_spawn_env(conv_id)
|
||||
if harness_name == "kimi-native" and spawn_env is None:
|
||||
from omnigent.kimi_native_bridge import build_kimi_native_spawn_env
|
||||
|
||||
spawn_env = build_kimi_native_spawn_env(conv_id)
|
||||
|
||||
agent_version = dispatch.agent_version if dispatch else body.get("agent_version")
|
||||
if agent_version is not None and conv_id in _version_cache:
|
||||
@@ -10464,6 +10747,9 @@ def create_runner_app(
|
||||
if _harness == "cursor-native":
|
||||
# cursor turn lives in the cursor-agent TUI; send Escape to stop it.
|
||||
return await _handle_cursor_native_interrupt(conversation_id)
|
||||
if _harness == "kimi-native":
|
||||
# kimi turn lives in the kimi TUI; send Escape to stop it.
|
||||
return await _handle_kimi_native_interrupt(conversation_id)
|
||||
# In-process harness: mark interrupted, forward an interrupt to the
|
||||
# harness, and force-cancel the runner turn task so the turn ends
|
||||
# promptly even if the harness can't honor the interrupt in time.
|
||||
@@ -10534,6 +10820,9 @@ def create_runner_app(
|
||||
if _harness == "cursor-native":
|
||||
# Hard-kill the cursor-agent tmux pane (the TUI is the runtime).
|
||||
return await _handle_cursor_native_stop(conversation_id)
|
||||
if _harness == "kimi-native":
|
||||
# Hard-kill the kimi tmux pane (the TUI is the runtime).
|
||||
return await _handle_kimi_native_stop(conversation_id)
|
||||
await _cancel_inprocess_turn(conversation_id)
|
||||
return Response(status_code=204)
|
||||
|
||||
@@ -11169,6 +11458,49 @@ def create_runner_app(
|
||||
content=session_resource_view_to_dict(terminal_view),
|
||||
)
|
||||
|
||||
if (
|
||||
body.get("ensure_native_terminal")
|
||||
and terminal_name == "kimi"
|
||||
and session_key == "main"
|
||||
):
|
||||
kimi_terminal_id = terminal_resource_id("kimi", "main")
|
||||
ensure_lock = _kimi_terminal_ensure_locks.setdefault(session_id, asyncio.Lock())
|
||||
async with ensure_lock:
|
||||
existing = await resource_registry.get_terminal_resource(
|
||||
session_id, kimi_terminal_id
|
||||
)
|
||||
if existing is not None:
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content=session_resource_view_to_dict(existing),
|
||||
)
|
||||
try:
|
||||
# The spec only feeds optional model injection (a follow-up),
|
||||
# so a resolution failure must not block launching the
|
||||
# terminal — fall back to None like the cursor/Pi paths.
|
||||
try:
|
||||
kimi_agent_spec = await _resolve_session_agent_spec(session_id)
|
||||
except OmnigentError:
|
||||
kimi_agent_spec = None
|
||||
terminal_view = await _auto_create_kimi_terminal(
|
||||
session_id,
|
||||
resource_registry,
|
||||
_publish_event,
|
||||
server_client=server_client,
|
||||
ensure_comment_relay=_ensure_comment_relay_started,
|
||||
agent_spec=kimi_agent_spec,
|
||||
)
|
||||
except Exception as exc:
|
||||
_logger.exception(
|
||||
"Kimi terminal ensure failed for session=%s",
|
||||
session_id,
|
||||
)
|
||||
return _native_terminal_start_error_response(exc, "Kimi")
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content=session_resource_view_to_dict(terminal_view),
|
||||
)
|
||||
|
||||
from omnigent.inner.datamodel import OSEnvSpec, TerminalEnvSpec
|
||||
|
||||
cwd_override = body.get("cwd")
|
||||
@@ -12644,6 +12976,7 @@ def create_runner_app(
|
||||
_claude_terminal_ensure_locks.pop(session_id, None)
|
||||
_pi_terminal_ensure_locks.pop(session_id, None)
|
||||
_cursor_terminal_ensure_locks.pop(session_id, None)
|
||||
_kimi_terminal_ensure_locks.pop(session_id, None)
|
||||
_repl_terminal_ensure_locks.pop(session_id, None)
|
||||
await resource_registry.cleanup_session(session_id)
|
||||
return JSONResponse(
|
||||
@@ -12697,6 +13030,7 @@ def create_runner_app(
|
||||
_claude_terminal_ensure_locks.pop(session_id, None)
|
||||
_pi_terminal_ensure_locks.pop(session_id, None)
|
||||
_cursor_terminal_ensure_locks.pop(session_id, None)
|
||||
_kimi_terminal_ensure_locks.pop(session_id, None)
|
||||
_repl_terminal_ensure_locks.pop(session_id, None)
|
||||
# Close terminals with ``session.resource.deleted`` events BEFORE
|
||||
# cleanup_session — cleanup_conversation would silently pop them
|
||||
@@ -13466,6 +13800,10 @@ _HARNESS_MODEL_ENV_KEY: dict[str, str] = {
|
||||
# (claude-native, codex-native) it honors the spec model via a launch
|
||||
# ``--model`` arg in _auto_create_cursor_terminal, not via an env var.
|
||||
"antigravity": "HARNESS_ANTIGRAVITY_MODEL",
|
||||
# Kimi reads ``HARNESS_KIMI_MODEL`` in
|
||||
# :mod:`omnigent.inner.kimi_executor`; without this mapping a per-session
|
||||
# ``/model`` override would silently drop on the kimi harness path.
|
||||
"kimi": "HARNESS_KIMI_MODEL",
|
||||
"qwen": "HARNESS_QWEN_MODEL",
|
||||
}
|
||||
|
||||
@@ -13498,6 +13836,7 @@ def _build_spawn_env_from_spec(
|
||||
_build_claude_sdk_spawn_env,
|
||||
_build_codex_spawn_env,
|
||||
_build_cursor_spawn_env,
|
||||
_build_kimi_spawn_env,
|
||||
_build_openai_agents_sdk_spawn_env,
|
||||
_build_pi_spawn_env,
|
||||
_build_qwen_spawn_env,
|
||||
@@ -13515,6 +13854,8 @@ def _build_spawn_env_from_spec(
|
||||
env = _build_cursor_spawn_env(spec, workdir=workdir)
|
||||
elif harness == "antigravity":
|
||||
env = _build_antigravity_spawn_env(spec)
|
||||
elif harness == "kimi":
|
||||
env = _build_kimi_spawn_env(spec, workdir=workdir)
|
||||
elif harness == "qwen":
|
||||
env = _build_qwen_spawn_env(spec, workdir=workdir)
|
||||
else:
|
||||
|
||||
@@ -51,6 +51,7 @@ CODEX_NATIVE_TERMINAL_ROLE = "codex-native"
|
||||
CLAUDE_NATIVE_TERMINAL_ROLE = "claude-native"
|
||||
PI_NATIVE_TERMINAL_ROLE = "pi-native"
|
||||
CURSOR_NATIVE_TERMINAL_ROLE = "cursor-native"
|
||||
KIMI_NATIVE_TERMINAL_ROLE = "kimi-native"
|
||||
# Role marker for the embedded Omnigent REPL terminal auto-created for
|
||||
# runner-hosted SDK sessions (``omnigent attach`` in a tmux pane — the
|
||||
# SDK mirror of the native terminals above). The attach WebSocket uses
|
||||
@@ -961,6 +962,10 @@ class SessionResourceRegistry:
|
||||
# after the paste), so — like pi/claude — the PTY watcher is its only
|
||||
# status source. Without this the web "Working…" badge never clears.
|
||||
CURSOR_NATIVE_TERMINAL_ROLE,
|
||||
# kimi-native also has no forwarder/hook (the injection run_turn
|
||||
# returns right after the tmux paste), so the PTY watcher is its
|
||||
# only running/idle status source — same as cursor/pi/claude.
|
||||
KIMI_NATIVE_TERMINAL_ROLE,
|
||||
}
|
||||
if activity_publisher is None and not emit_status and exit_publisher is None:
|
||||
return
|
||||
|
||||
@@ -62,11 +62,24 @@ _HARNESS_MODULES: dict[str, str] = {
|
||||
# cursor harness wrap (Cursor's ``cursor-agent`` CLI, headless). See
|
||||
# omnigent/inner/cursor_harness.py.
|
||||
"cursor": "omnigent.inner.cursor_harness",
|
||||
# Kimi Code CLI harness wrap (Moonshot AI's ``kimi`` CLI, headless). See
|
||||
# omnigent/inner/kimi_harness.py. Drives ``kimi --print --output-format
|
||||
# stream-json`` per turn; resumes via ``--session <uuid>`` captured from
|
||||
# the prior turn's stderr.
|
||||
"kimi": "omnigent.inner.kimi_harness",
|
||||
# User-facing alias matching the upstream product name ("Kimi Code").
|
||||
"kimi-code": "omnigent.inner.kimi_harness",
|
||||
# cursor-native harness wrap. Drives the resident ``cursor-agent`` TUI by
|
||||
# injecting each web-UI turn into its tmux pane and mirroring the transcript
|
||||
# back — a native-CLI harness like claude/codex/pi-native, so it IS in
|
||||
# ``NATIVE_HARNESSES``. See omnigent/inner/cursor_native_harness.py.
|
||||
"cursor-native": "omnigent.inner.cursor_native_harness",
|
||||
# Native Kimi Code TUI bridge used by ``omnigent kimi``. Drives the resident
|
||||
# ``kimi`` TUI by injecting each web-UI turn into its tmux pane (tmux paste)
|
||||
# — a native-CLI harness like claude/codex/cursor-native, so it IS in
|
||||
# ``NATIVE_HARNESSES``. Distinct from the headless ``kimi`` SDK harness
|
||||
# above. See omnigent/inner/kimi_native_harness.py.
|
||||
"kimi-native": "omnigent.inner.kimi_native_harness",
|
||||
# Google Antigravity SDK harness wrap. See
|
||||
# omnigent/inner/antigravity_harness.py. In-process SDK harness
|
||||
# (``google-antigravity``), like openai-agents — Omnigent spawns no CLI
|
||||
|
||||
@@ -140,7 +140,9 @@ _logger = logging.getLogger(__name__)
|
||||
# (hyphen), e.g. ``"claude_sdk"`` → ``"claude-sdk"`` used by ``_HARNESS_MODULES``.
|
||||
|
||||
|
||||
AgentHarnessType = Literal["claude-sdk", "codex", "pi", "openai-agents-sdk", "antigravity", "qwen"]
|
||||
AgentHarnessType = Literal[
|
||||
"claude-sdk", "codex", "pi", "openai-agents-sdk", "antigravity", "kimi", "qwen"
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -431,6 +433,10 @@ _HARNESS_DATABRICKS_PROFILE: dict[AgentHarnessType, str] = {
|
||||
"openai-agents-sdk": "HARNESS_OPENAI_AGENTS_DATABRICKS_PROFILE",
|
||||
"qwen": "HARNESS_QWEN_DATABRICKS_PROFILE",
|
||||
# NB: no ``antigravity`` — it has no Databricks/gateway path (Gemini-native).
|
||||
# NB: no ``kimi`` — upstream kimi has no per-spawn provider override flag,
|
||||
# so Omnigent cannot thread a Databricks gateway through. Users configure
|
||||
# providers via ``kimi provider add`` in ``~/.kimi/config.toml`` (see
|
||||
# docs/KIMI_FOLLOWUPS.md for the deferred provider-injection follow-up).
|
||||
}
|
||||
|
||||
|
||||
@@ -615,6 +621,21 @@ def configure_agent_harness_with_provider(
|
||||
# the Databricks profile (Databricks-specific, used by the executor
|
||||
# for token refresh), then delegate gateway enrichment to ucode.
|
||||
profile = entry.profile
|
||||
if harness_type == "kimi":
|
||||
# Kimi has no per-spawn provider override (no ``--config-file``
|
||||
# on the upstream binary). Provider routing lives in
|
||||
# ``~/.kimi/config.toml`` and is managed out-of-band via
|
||||
# ``kimi provider add``. Fail loud so the user understands why
|
||||
# their Databricks auth didn't take effect rather than silently
|
||||
# routing through whatever default kimi already had.
|
||||
raise OmnigentError(
|
||||
"The 'kimi' harness does not support per-invocation Databricks "
|
||||
"routing. Run `kimi provider add` once to configure your "
|
||||
"Databricks provider in ~/.kimi/config.toml, then declare the "
|
||||
"kimi-side model in the agent spec. See docs/KIMI_FOLLOWUPS.md "
|
||||
"for the deferred Omnigent-side provider injection work.",
|
||||
code=ErrorCode.INVALID_INPUT,
|
||||
)
|
||||
flag = _HARNESS_GATEWAY_FLAG.get(harness_type)
|
||||
if flag is not None:
|
||||
env[flag] = "true"
|
||||
@@ -627,6 +648,20 @@ def configure_agent_harness_with_provider(
|
||||
if harness_type == "pi":
|
||||
_apply_provider_to_pi(env, entry)
|
||||
return
|
||||
if harness_type == "kimi":
|
||||
# Same reasoning as the Databricks branch above: upstream kimi has no
|
||||
# per-spawn provider override, so an inline-family provider on the
|
||||
# spec cannot be threaded through. Fail loud rather than emit gateway
|
||||
# env vars the executor no longer reads.
|
||||
raise OmnigentError(
|
||||
"The 'kimi' harness does not support per-invocation generic "
|
||||
"providers (kimi has no ``--config-file`` flag). Configure the "
|
||||
"provider once via `kimi provider add` in ~/.kimi/config.toml, "
|
||||
"then pin the resulting model id in the agent spec. See "
|
||||
"docs/KIMI_FOLLOWUPS.md for the deferred Omnigent-side provider "
|
||||
"injection work.",
|
||||
code=ErrorCode.INVALID_INPUT,
|
||||
)
|
||||
family_name = _PROVIDER_HARNESS_FAMILY[harness_type]
|
||||
family = entry.family(family_name)
|
||||
if family is None:
|
||||
@@ -1590,6 +1625,41 @@ def _build_cursor_spawn_env(
|
||||
return env
|
||||
|
||||
|
||||
def _build_kimi_spawn_env(
|
||||
spec: AgentSpec,
|
||||
*,
|
||||
workdir: Path | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Build the env-var dict the kimi harness wrap reads.
|
||||
|
||||
Maps ``spec.executor`` fields → the ``HARNESS_KIMI_*`` env vars
|
||||
defined in :mod:`omnigent.inner.kimi_harness`.
|
||||
|
||||
The upstream Kimi Code CLI has no per-spawn provider override flag
|
||||
(no ``--config-file`` / ``--mcp-config-file``), so this builder
|
||||
only threads the model and working directory. Provider routing for
|
||||
kimi lives in ``~/.kimi/config.toml`` and is managed out-of-band
|
||||
via ``kimi provider add``. A spec that declares an explicit
|
||||
provider / Databricks / api_key auth raises in
|
||||
:func:`configure_agent_harness_with_provider` so the user
|
||||
understands why their auth didn't take effect rather than silently
|
||||
routing through whatever default kimi already had.
|
||||
|
||||
:param spec: The agent spec.
|
||||
:param workdir: The bundle's on-disk path. Threaded as
|
||||
``HARNESS_KIMI_CWD`` so the kimi subprocess runs with its cwd
|
||||
pointed at the bundle (upstream has no ``--work-dir`` flag).
|
||||
:returns: A dict of env-var overrides.
|
||||
"""
|
||||
env: dict[str, str] = {}
|
||||
model = _resolve_spec_model(spec)
|
||||
if model is not None:
|
||||
env["HARNESS_KIMI_MODEL"] = model
|
||||
if workdir is not None:
|
||||
env["HARNESS_KIMI_CWD"] = str(workdir)
|
||||
return env
|
||||
|
||||
|
||||
def _build_antigravity_spawn_env(spec: AgentSpec) -> dict[str, str]:
|
||||
"""
|
||||
Map ``spec.executor`` fields → the ``HARNESS_ANTIGRAVITY_*`` env vars the
|
||||
|
||||
@@ -23,6 +23,7 @@ from omnigent.native_coding_agents import (
|
||||
CLAUDE_NATIVE_CODING_AGENT,
|
||||
CODEX_NATIVE_CODING_AGENT,
|
||||
CURSOR_NATIVE_CODING_AGENT,
|
||||
KIMI_NATIVE_CODING_AGENT,
|
||||
PI_NATIVE_CODING_AGENT,
|
||||
)
|
||||
from omnigent.resources import examples as _examples_resources
|
||||
@@ -78,6 +79,7 @@ _CLAUDE_NATIVE_AGENT_NAME = CLAUDE_NATIVE_CODING_AGENT.agent_name
|
||||
_CODEX_NATIVE_AGENT_NAME = CODEX_NATIVE_CODING_AGENT.agent_name
|
||||
_PI_NATIVE_AGENT_NAME = PI_NATIVE_CODING_AGENT.agent_name
|
||||
_CURSOR_NATIVE_AGENT_NAME = CURSOR_NATIVE_CODING_AGENT.agent_name
|
||||
_KIMI_NATIVE_AGENT_NAME = KIMI_NATIVE_CODING_AGENT.agent_name
|
||||
_DEBBY_AGENT_NAME = "debby"
|
||||
_POLLY_AGENT_NAME = "polly"
|
||||
_UNMATCHED_ROUTE_TEMPLATE = "<unmatched>"
|
||||
@@ -353,6 +355,7 @@ def _ensure_default_agents(
|
||||
_ensure_default_codex_agent(agent_store, artifact_store, agent_cache)
|
||||
_ensure_default_pi_agent(agent_store, artifact_store, agent_cache)
|
||||
_ensure_default_cursor_agent(agent_store, artifact_store, agent_cache)
|
||||
_ensure_default_kimi_native_agent(agent_store, artifact_store, agent_cache)
|
||||
_ensure_default_debby_agent(agent_store, artifact_store, agent_cache)
|
||||
_ensure_default_polly_agent(agent_store, artifact_store, agent_cache)
|
||||
_ensure_extra_builtin_agents(agent_store, artifact_store, agent_cache)
|
||||
@@ -600,6 +603,49 @@ def _ensure_default_cursor_agent(
|
||||
)
|
||||
|
||||
|
||||
def _build_kimi_native_bundle() -> bytes:
|
||||
"""
|
||||
Build a gzipped tarball of the kimi-native-ui agent spec.
|
||||
|
||||
:returns: Gzipped tarball bytes suitable for the artifact store.
|
||||
"""
|
||||
import tempfile
|
||||
|
||||
from omnigent.kimi_native import _materialize_kimi_agent_spec
|
||||
from omnigent.spec import materialize_bundle
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
spec_path = _materialize_kimi_agent_spec(Path(tmpdir))
|
||||
bundle_dir = materialize_bundle(spec_path, Path(tmpdir) / "bundle")
|
||||
return _tar_gz_dir(bundle_dir)
|
||||
|
||||
|
||||
def _ensure_default_kimi_native_agent(
|
||||
agent_store: AgentStore,
|
||||
artifact_store: ArtifactStore,
|
||||
agent_cache: Any,
|
||||
) -> None:
|
||||
"""
|
||||
Register or refresh the kimi-native-ui agent.
|
||||
|
||||
Called during server lifespan startup so the Web UI offers Kimi as a
|
||||
built-in native-terminal agent on every deployment (not only after the
|
||||
``omnigent kimi`` CLI first registers it). Content-aware via
|
||||
:func:`_ensure_builtin_agent`.
|
||||
|
||||
:param agent_store: Store for agent metadata.
|
||||
:param artifact_store: Store for agent bundles.
|
||||
:param agent_cache: Cache for loaded agent specs.
|
||||
"""
|
||||
_ensure_builtin_agent(
|
||||
agent_store,
|
||||
artifact_store,
|
||||
agent_cache,
|
||||
name=_KIMI_NATIVE_AGENT_NAME,
|
||||
bundle_bytes=_build_kimi_native_bundle(),
|
||||
)
|
||||
|
||||
|
||||
def _build_debby_bundle() -> bytes:
|
||||
"""
|
||||
Build a gzipped tarball of the ``examples/debby`` agent bundle.
|
||||
|
||||
@@ -81,6 +81,9 @@ OMNIGENT_HARNESSES = frozenset(
|
||||
"codex",
|
||||
"codex-native",
|
||||
"cursor",
|
||||
"databricks_supervisor",
|
||||
"kimi",
|
||||
"kimi-native",
|
||||
"cursor-native",
|
||||
"openai-agents",
|
||||
"open-responses",
|
||||
@@ -91,7 +94,16 @@ OMNIGENT_HARNESSES = frozenset(
|
||||
)
|
||||
# User-facing aliases accepted in specs and normalized before runtime dispatch.
|
||||
OMNIGENT_HARNESS_ALIASES = frozenset(
|
||||
{"claude", "native-pi", "openai-agents-sdk", "agy", "google-antigravity", "qwen-code"}
|
||||
{
|
||||
"claude",
|
||||
"native-pi",
|
||||
"openai-agents-sdk",
|
||||
"agy",
|
||||
"google-antigravity",
|
||||
"kimi-code",
|
||||
"native-kimi",
|
||||
"qwen-code",
|
||||
}
|
||||
)
|
||||
_OMNIGENT_ACCEPTED_HARNESSES = OMNIGENT_HARNESSES | OMNIGENT_HARNESS_ALIASES
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ from omnigent.cli import (
|
||||
_is_removed_ad_hoc_invocation,
|
||||
_is_run_shorthand,
|
||||
_load_global_config,
|
||||
_manage_kimi_harness,
|
||||
_manage_qwen_harness,
|
||||
_materialize_harness_launcher_file,
|
||||
_node_dependency_problem,
|
||||
@@ -2248,6 +2249,13 @@ def test_run_without_agent_drops_into_configure_when_unconfigured(
|
||||
"omnigent.onboarding.provider_config.default_provider_for_harness",
|
||||
_fake_provider_for(), # nothing configured
|
||||
)
|
||||
# The kimi fallback in ``_pick_first_run_harness`` gates on the ``kimi``
|
||||
# binary being on PATH. Stub it to False so the test stays deterministic
|
||||
# on machines where the developer has kimi installed.
|
||||
monkeypatch.setattr(
|
||||
"omnigent.onboarding.harness_install.harness_cli_installed",
|
||||
lambda _key: False,
|
||||
)
|
||||
# The configure picker would block on a real terminal; stub it.
|
||||
configure = Mock()
|
||||
monkeypatch.setattr("omnigent.cli._run_configure_harnesses_interactive", configure)
|
||||
@@ -4168,6 +4176,13 @@ def test_pick_first_run_harness_none_when_unconfigured(monkeypatch: pytest.Monke
|
||||
"omnigent.onboarding.provider_config.default_provider_for_harness",
|
||||
_fake_provider_for(), # nothing configured
|
||||
)
|
||||
# The kimi fallback in _pick_first_run_harness gates on the ``kimi``
|
||||
# binary being on PATH. Stub it to False so the test stays deterministic
|
||||
# on machines where the developer has kimi installed.
|
||||
monkeypatch.setattr(
|
||||
"omnigent.onboarding.harness_install.harness_cli_installed",
|
||||
lambda _key: False,
|
||||
)
|
||||
assert _pick_first_run_harness() is None
|
||||
|
||||
|
||||
@@ -4250,6 +4265,13 @@ def test_resolve_first_run_plan_drops_into_configure_when_empty(
|
||||
"omnigent.onboarding.provider_config.default_provider_for_harness",
|
||||
_fake_provider_for(), # nothing configured, before and after configure
|
||||
)
|
||||
# The kimi fallback in ``_pick_first_run_harness`` gates on the ``kimi``
|
||||
# binary being on PATH. Stub it to False so the test stays deterministic
|
||||
# regardless of the developer's local install.
|
||||
monkeypatch.setattr(
|
||||
"omnigent.onboarding.harness_install.harness_cli_installed",
|
||||
lambda _key: False,
|
||||
)
|
||||
configure = Mock()
|
||||
monkeypatch.setattr("omnigent.cli._run_configure_harnesses_interactive", configure)
|
||||
|
||||
@@ -4678,3 +4700,82 @@ def test_manage_qwen_harness_back_does_not_launch(
|
||||
_manage_qwen_harness()
|
||||
|
||||
launch.assert_not_called()
|
||||
|
||||
|
||||
# ── omnigent setup: Kimi Code drill-in (_manage_kimi_harness) ────────────
|
||||
|
||||
|
||||
def test_manage_kimi_harness_not_installed_shows_hint_returns(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A missing kimi CLI shows the curl install_hint and returns.
|
||||
|
||||
Kimi is curl-installed (no npm ``package``), so the drill-in can't
|
||||
auto-install it — it must surface the install_hint and bail without
|
||||
touching login / logout.
|
||||
"""
|
||||
import omnigent.onboarding.harness_install as hi
|
||||
import omnigent.onboarding.interactive as it
|
||||
|
||||
monkeypatch.setattr(hi, "harness_cli_installed", lambda key: False)
|
||||
console = Mock()
|
||||
monkeypatch.setattr(it, "console", console)
|
||||
login = Mock()
|
||||
logout = Mock()
|
||||
monkeypatch.setattr(hi, "harness_login", login)
|
||||
monkeypatch.setattr(hi, "harness_logout", logout)
|
||||
# If the drill-in wrongly reached the menu loop, this select would drive it.
|
||||
monkeypatch.setattr(it, "select", lambda *a, **k: 0)
|
||||
|
||||
_manage_kimi_harness()
|
||||
|
||||
login.assert_not_called()
|
||||
logout.assert_not_called()
|
||||
# The curl install command was surfaced to the user.
|
||||
printed = " ".join(str(c.args[0]) for c in console.print.call_args_list if c.args)
|
||||
assert "code.kimi.com/kimi-code/install.sh" in printed
|
||||
|
||||
|
||||
def test_manage_kimi_harness_back_does_not_login(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""With the CLI installed, choosing "← Back" exits without signing in."""
|
||||
import omnigent.onboarding.harness_install as hi
|
||||
import omnigent.onboarding.interactive as it
|
||||
|
||||
monkeypatch.setattr(hi, "harness_cli_installed", lambda key: True)
|
||||
monkeypatch.setattr(it, "console", Mock())
|
||||
login = Mock()
|
||||
logout = Mock()
|
||||
monkeypatch.setattr(hi, "harness_login", login)
|
||||
monkeypatch.setattr(hi, "harness_logout", logout)
|
||||
# rows = [Sign in, Sign out, Show auth options, ← Back]; pick Back (3).
|
||||
monkeypatch.setattr(it, "select", lambda *a, **k: 3)
|
||||
|
||||
_manage_kimi_harness()
|
||||
|
||||
login.assert_not_called()
|
||||
logout.assert_not_called()
|
||||
|
||||
|
||||
def test_manage_kimi_harness_login_runs_kimi_login(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Selecting "Sign in" drives ``harness_login(KIMI_KEY)`` then loops; Back exits."""
|
||||
import omnigent.onboarding.harness_install as hi
|
||||
import omnigent.onboarding.interactive as it
|
||||
|
||||
monkeypatch.setattr(hi, "harness_cli_installed", lambda key: True)
|
||||
monkeypatch.setattr(it, "console", Mock())
|
||||
login = Mock(return_value=False) # kimi has no status probe; return is ignored
|
||||
logout = Mock()
|
||||
monkeypatch.setattr(hi, "harness_login", login)
|
||||
monkeypatch.setattr(hi, "harness_logout", logout)
|
||||
# First iteration: Sign in (0); second: ← Back (3) to exit the loop.
|
||||
choices = iter([0, 3])
|
||||
monkeypatch.setattr(it, "select", lambda *a, **k: next(choices))
|
||||
|
||||
_manage_kimi_harness()
|
||||
|
||||
login.assert_called_once_with(hi.KIMI_KEY)
|
||||
logout.assert_not_called()
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""End-to-end tests for :class:`omnigent.inner.kimi_executor.KimiExecutor`.
|
||||
|
||||
Real-binary tests gated on:
|
||||
|
||||
- ``OMNIGENT_E2E_KIMI=1`` in the environment, and
|
||||
- the ``kimi`` binary (or whichever ``HARNESS_KIMI_PATH`` points at)
|
||||
present on PATH.
|
||||
|
||||
When either gate fails the test is skipped — keeps CI green without the
|
||||
upstream binary while still letting maintainers run the happy path locally
|
||||
with ``OMNIGENT_E2E_KIMI=1 uv run pytest tests/e2e/test_kimi_executor_e2e.py``.
|
||||
|
||||
Mirrors ``tests/e2e/test_cursor_executor_e2e.py`` / ``test_pi_executor_e2e.py``
|
||||
in shape.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from omnigent.inner.executor import TextChunk, TurnComplete
|
||||
from omnigent.inner.kimi_executor import KimiExecutor, _resolve_kimi_binary
|
||||
|
||||
|
||||
def _kimi_e2e_enabled() -> bool:
|
||||
if os.environ.get("OMNIGENT_E2E_KIMI") != "1":
|
||||
return False
|
||||
return shutil.which(_resolve_kimi_binary()) is not None
|
||||
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not _kimi_e2e_enabled(),
|
||||
reason=(
|
||||
"Real-binary e2e: requires OMNIGENT_E2E_KIMI=1 and the ``kimi`` (or "
|
||||
"HARNESS_KIMI_PATH) binary on PATH. Install via "
|
||||
"`curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash` and "
|
||||
"run ``kimi login`` once, then re-run with OMNIGENT_E2E_KIMI=1."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def _collect_events(executor: KimiExecutor, prompt: str) -> list[Any]:
|
||||
out: list[Any] = []
|
||||
async for event in executor.run_turn(
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
tools=[],
|
||||
system_prompt="",
|
||||
):
|
||||
out.append(event)
|
||||
return out
|
||||
|
||||
|
||||
def test_kimi_run_turn_streams_text_against_real_binary() -> None:
|
||||
"""Real kimi-cli driven by KimiExecutor produces a text response.
|
||||
|
||||
Asks for a one-word answer to keep the run fast and the assertion
|
||||
deterministic without relying on exact wording (auth + model
|
||||
variability).
|
||||
"""
|
||||
executor = KimiExecutor()
|
||||
events = asyncio.run(_collect_events(executor, "Reply with the single word: pong"))
|
||||
|
||||
text_chunks = [e for e in events if isinstance(e, TextChunk)]
|
||||
turn_completes = [e for e in events if isinstance(e, TurnComplete)]
|
||||
assert text_chunks, "kimi produced no TextChunk events"
|
||||
assert turn_completes, "kimi did not emit TurnComplete"
|
||||
assert executor._session_id, "kimi did not surface a resume session id on stderr"
|
||||
|
||||
|
||||
def test_kimi_run_turn_session_resume_carries_history() -> None:
|
||||
"""A second run_turn with the same executor should see the prior turn.
|
||||
|
||||
Verifies the executor captured the kimi UUID from the first turn's
|
||||
stderr footer and threaded it via ``--session`` on the next spawn.
|
||||
"""
|
||||
executor = KimiExecutor()
|
||||
asyncio.run(_collect_events(executor, "Remember the word cactus. Reply with: ok."))
|
||||
first_session_id = executor._session_id
|
||||
assert first_session_id, "first turn did not surface a session id"
|
||||
|
||||
events = asyncio.run(_collect_events(executor, "What single word did I ask you to remember?"))
|
||||
|
||||
text_chunks = [e for e in events if isinstance(e, TextChunk)]
|
||||
response = " ".join(c.text for c in text_chunks).lower()
|
||||
assert "cactus" in response, f"second turn lost prior context: {response!r}"
|
||||
# The session id should be the same (resume reused the existing kimi session).
|
||||
assert executor._session_id == first_session_id
|
||||
@@ -0,0 +1,680 @@
|
||||
"""Tests for the ``harness: kimi`` wrap + the inner ``KimiExecutor``.
|
||||
|
||||
Covers the harness registry, FastAPI app shape, env-var-driven
|
||||
construction, and the executor's argv / event-translation / run-turn
|
||||
flows with the upstream ``kimi`` subprocess stubbed out (so the suite
|
||||
passes on machines without the binary).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from omnigent.inner import kimi_executor, kimi_harness
|
||||
from omnigent.inner.executor import (
|
||||
ExecutorError,
|
||||
TextChunk,
|
||||
ToolCallComplete,
|
||||
ToolCallRequest,
|
||||
TurnComplete,
|
||||
)
|
||||
from omnigent.inner.kimi_executor import (
|
||||
_SESSION_RESUME_RE,
|
||||
KimiExecutor,
|
||||
_latest_user_text,
|
||||
_parse_truthy,
|
||||
_resolve_kimi_binary,
|
||||
_resolve_skills_dirs,
|
||||
)
|
||||
from omnigent.runtime.harnesses import _HARNESS_MODULES
|
||||
from omnigent.spec._omnigent_compat import OMNIGENT_HARNESS_ALIASES, OMNIGENT_HARNESSES
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry / allowlist
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_kimi_in_module_registry() -> None:
|
||||
assert _HARNESS_MODULES.get("kimi") == "omnigent.inner.kimi_harness"
|
||||
assert _HARNESS_MODULES.get("kimi-code") == "omnigent.inner.kimi_harness"
|
||||
|
||||
|
||||
def test_kimi_in_omnigent_harnesses_allowlist() -> None:
|
||||
assert "kimi" in OMNIGENT_HARNESSES
|
||||
assert "kimi-code" in OMNIGENT_HARNESS_ALIASES
|
||||
|
||||
|
||||
def test_kimi_canonical_alias_resolution() -> None:
|
||||
from omnigent.harness_aliases import canonicalize_harness
|
||||
|
||||
assert canonicalize_harness("kimi-code") == "kimi"
|
||||
assert canonicalize_harness("kimi") == "kimi"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FastAPI app + factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_app_returns_fastapi_with_required_routes() -> None:
|
||||
app = kimi_harness.create_app()
|
||||
paths = {route.path for route in app.routes} # type: ignore[attr-defined]
|
||||
assert "/health" in paths
|
||||
assert "/v1/sessions/{conversation_id}/events" in paths
|
||||
|
||||
|
||||
def test_executor_factory_reads_env_vars(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("HARNESS_KIMI_MODEL", "kimi-k2-turbo")
|
||||
monkeypatch.setenv("HARNESS_KIMI_CWD", "/tmp/kimi-cwd")
|
||||
monkeypatch.setenv("HARNESS_KIMI_PATH", "/custom/bin/kimi")
|
||||
monkeypatch.setenv("HARNESS_KIMI_PLAN", "yes")
|
||||
monkeypatch.setenv("HARNESS_KIMI_CONTINUE_LAST", "true")
|
||||
monkeypatch.setenv("HARNESS_KIMI_SKILLS_DIRS", json.dumps(["/a", "/b"]))
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def _fake_init(self: Any, **kwargs: Any) -> None:
|
||||
captured.update(kwargs)
|
||||
|
||||
with patch(
|
||||
"omnigent.inner.kimi_harness.KimiExecutor.__init__",
|
||||
_fake_init,
|
||||
):
|
||||
kimi_harness._build_kimi_executor()
|
||||
|
||||
assert captured["model"] == "kimi-k2-turbo"
|
||||
assert captured["cwd"] == "/tmp/kimi-cwd"
|
||||
assert captured["binary_path"] == "/custom/bin/kimi"
|
||||
assert captured["plan"] is True
|
||||
assert captured["continue_last_session"] is True
|
||||
assert captured["skills_dirs"] == ["/a", "/b"]
|
||||
|
||||
|
||||
def test_executor_factory_defaults_when_env_unset(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
for var in (
|
||||
"HARNESS_KIMI_MODEL",
|
||||
"HARNESS_KIMI_CWD",
|
||||
"HARNESS_KIMI_PATH",
|
||||
"HARNESS_KIMI_PLAN",
|
||||
"HARNESS_KIMI_CONTINUE_LAST",
|
||||
"HARNESS_KIMI_SKILLS_DIRS",
|
||||
):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def _fake_init(self: Any, **kwargs: Any) -> None:
|
||||
captured.update(kwargs)
|
||||
|
||||
with patch(
|
||||
"omnigent.inner.kimi_harness.KimiExecutor.__init__",
|
||||
_fake_init,
|
||||
):
|
||||
kimi_harness._build_kimi_executor()
|
||||
|
||||
assert captured["plan"] is False
|
||||
assert captured["continue_last_session"] is False
|
||||
assert captured["binary_path"] is None # passes through; executor resolves
|
||||
assert captured["model"] is None
|
||||
assert captured["cwd"] is None
|
||||
assert captured["skills_dirs"] == []
|
||||
|
||||
|
||||
def test_malformed_os_env_falls_back_to_default(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("HARNESS_KIMI_OS_ENV", "{not-json")
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def _fake_init(self: Any, **kwargs: Any) -> None:
|
||||
captured["os_env"] = kwargs["os_env"]
|
||||
|
||||
with patch(
|
||||
"omnigent.inner.kimi_harness.KimiExecutor.__init__",
|
||||
_fake_init,
|
||||
):
|
||||
kimi_harness._build_kimi_executor()
|
||||
|
||||
assert captured["os_env"].type == "caller_process"
|
||||
assert captured["os_env"].sandbox.type == "none"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value,expected",
|
||||
[
|
||||
("1", True),
|
||||
("true", True),
|
||||
("YES", True),
|
||||
("on", True),
|
||||
("y", True),
|
||||
("0", False),
|
||||
("false", False),
|
||||
("no", False),
|
||||
("", False),
|
||||
(None, False),
|
||||
],
|
||||
)
|
||||
def test_parse_truthy(value: str | None, expected: bool) -> None:
|
||||
assert _parse_truthy(value) is expected
|
||||
|
||||
|
||||
def test_resolve_kimi_binary_default(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("HARNESS_KIMI_PATH", raising=False)
|
||||
assert _resolve_kimi_binary() == "kimi"
|
||||
|
||||
|
||||
def test_resolve_kimi_binary_explicit_override(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("HARNESS_KIMI_PATH", "/opt/bin/kimi")
|
||||
assert _resolve_kimi_binary() == "/opt/bin/kimi"
|
||||
|
||||
|
||||
def test_latest_user_text_string_message() -> None:
|
||||
messages = [
|
||||
{"role": "system", "content": "be helpful"},
|
||||
{"role": "user", "content": "hello"},
|
||||
]
|
||||
assert _latest_user_text(messages) == "hello"
|
||||
|
||||
|
||||
def test_latest_user_text_picks_most_recent_user() -> None:
|
||||
messages = [
|
||||
{"role": "user", "content": "first"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
{"role": "user", "content": "second"},
|
||||
]
|
||||
assert _latest_user_text(messages) == "second"
|
||||
|
||||
|
||||
def test_latest_user_text_concats_text_blocks() -> None:
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "hello "},
|
||||
{"type": "input_text", "text": "world"},
|
||||
],
|
||||
}
|
||||
]
|
||||
assert _latest_user_text(messages) == "hello world"
|
||||
|
||||
|
||||
def test_latest_user_text_drops_image_blocks_with_warning(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
import logging
|
||||
|
||||
caplog.set_level(logging.WARNING, logger="omnigent.inner.kimi_executor")
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "input_image", "image_url": "data:image/png;base64,..."},
|
||||
{"type": "text", "text": "what's in this image?"},
|
||||
],
|
||||
}
|
||||
]
|
||||
assert _latest_user_text(messages) == "what's in this image?"
|
||||
assert any("dropped 1 non-text content block" in rec.message for rec in caplog.records)
|
||||
|
||||
|
||||
def test_latest_user_text_returns_empty_when_no_user_message() -> None:
|
||||
assert _latest_user_text([{"role": "assistant", "content": "hi"}]) == ""
|
||||
|
||||
|
||||
def test_resolve_skills_dirs_valid() -> None:
|
||||
payload = json.dumps(["/x/skills", "/y/skills"])
|
||||
assert _resolve_skills_dirs(payload) == ["/x/skills", "/y/skills"]
|
||||
|
||||
|
||||
def test_resolve_skills_dirs_unset() -> None:
|
||||
assert _resolve_skills_dirs(None) == []
|
||||
assert _resolve_skills_dirs("") == []
|
||||
assert _resolve_skills_dirs(" ") == []
|
||||
|
||||
|
||||
def test_resolve_skills_dirs_invalid_json() -> None:
|
||||
assert _resolve_skills_dirs("{not-json") == []
|
||||
|
||||
|
||||
def test_resolve_skills_dirs_wrong_shape() -> None:
|
||||
assert _resolve_skills_dirs(json.dumps("scalar")) == []
|
||||
assert _resolve_skills_dirs(json.dumps([1, 2])) == []
|
||||
|
||||
|
||||
def test_session_resume_regex_captures_session_id() -> None:
|
||||
line = "To resume this session: kimi -r session_1fac96e7-5223-4021-9bf4-6413bedf38ee"
|
||||
m = _SESSION_RESUME_RE.search(line)
|
||||
assert m is not None
|
||||
assert m.group(1) == "session_1fac96e7-5223-4021-9bf4-6413bedf38ee"
|
||||
|
||||
|
||||
def test_session_resume_regex_no_match() -> None:
|
||||
assert _SESSION_RESUME_RE.search("nothing to see here") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Argv builder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_argv_minimal() -> None:
|
||||
ex = KimiExecutor(binary_path="kimi")
|
||||
argv = ex._build_argv(prompt_text="hi")
|
||||
assert argv[0] == "kimi"
|
||||
assert argv[1:3] == ["--output-format", "stream-json"]
|
||||
# ``-p`` always lands at the tail (it consumes a single argument).
|
||||
assert argv[-2:] == ["-p", "hi"]
|
||||
# No --print, --yolo, --afk, --thinking, --work-dir on the upstream binary.
|
||||
for flag in ("--print", "--yolo", "--afk", "--thinking", "--no-thinking", "--work-dir"):
|
||||
assert flag not in argv
|
||||
|
||||
|
||||
def test_build_argv_threads_model() -> None:
|
||||
ex = KimiExecutor(binary_path="kimi", model="kimi-k2-turbo")
|
||||
argv = ex._build_argv(prompt_text="hi")
|
||||
assert "-m" in argv
|
||||
assert argv[argv.index("-m") + 1] == "kimi-k2-turbo"
|
||||
|
||||
|
||||
def test_build_argv_plan_flag() -> None:
|
||||
ex = KimiExecutor(binary_path="kimi", plan=True)
|
||||
argv = ex._build_argv(prompt_text="hi")
|
||||
assert "--plan" in argv
|
||||
|
||||
|
||||
def test_build_argv_session_resume() -> None:
|
||||
ex = KimiExecutor(binary_path="kimi")
|
||||
ex._session_id = "session_deadbeef-1234-5678-9abc-def012345678"
|
||||
argv = ex._build_argv(prompt_text="next")
|
||||
assert "-S" in argv
|
||||
assert argv[argv.index("-S") + 1] == "session_deadbeef-1234-5678-9abc-def012345678"
|
||||
|
||||
|
||||
def test_build_argv_continue_last_when_no_session_id() -> None:
|
||||
ex = KimiExecutor(binary_path="kimi", continue_last_session=True)
|
||||
argv = ex._build_argv(prompt_text="next")
|
||||
assert "-C" in argv
|
||||
assert "-S" not in argv
|
||||
|
||||
|
||||
def test_build_argv_explicit_session_id_wins_over_continue() -> None:
|
||||
"""``-S <id>`` and ``-C`` are mutually exclusive; the explicit id wins."""
|
||||
ex = KimiExecutor(binary_path="kimi", continue_last_session=True)
|
||||
ex._session_id = "session_abc"
|
||||
argv = ex._build_argv(prompt_text="next")
|
||||
assert "-S" in argv
|
||||
assert "-C" not in argv
|
||||
|
||||
|
||||
def test_build_argv_skills_dirs_repeats_flag() -> None:
|
||||
ex = KimiExecutor(binary_path="kimi", skills_dirs=["/a/skills", "/b/skills"])
|
||||
argv = ex._build_argv(prompt_text="hi")
|
||||
assert argv.count("--skills-dir") == 2
|
||||
skills_positions = [i for i, v in enumerate(argv) if v == "--skills-dir"]
|
||||
assert argv[skills_positions[0] + 1] == "/a/skills"
|
||||
assert argv[skills_positions[1] + 1] == "/b/skills"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Translate event
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_translate_event_assistant_text_as_string() -> None:
|
||||
"""Upstream emits ``content`` as a plain string; emit one TextChunk."""
|
||||
ex = KimiExecutor(binary_path="kimi")
|
||||
events = ex._translate_event({"role": "assistant", "content": "Hi there!"})
|
||||
assert len(events) == 1
|
||||
assert isinstance(events[0], TextChunk)
|
||||
assert events[0].text == "Hi there!"
|
||||
|
||||
|
||||
def test_translate_event_assistant_empty_string_yields_no_events() -> None:
|
||||
ex = KimiExecutor(binary_path="kimi")
|
||||
assert ex._translate_event({"role": "assistant", "content": ""}) == []
|
||||
|
||||
|
||||
def test_translate_event_tool_call() -> None:
|
||||
ex = KimiExecutor(binary_path="kimi")
|
||||
events = ex._translate_event(
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"type": "function",
|
||||
"id": "tool_abc",
|
||||
"function": {
|
||||
"name": "Bash",
|
||||
"arguments": '{"command": "ls -la"}',
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
assert len(events) == 1
|
||||
assert isinstance(events[0], ToolCallRequest)
|
||||
assert events[0].name == "Bash"
|
||||
assert events[0].args == {"command": "ls -la"}
|
||||
assert events[0].metadata == {"call_id": "tool_abc"}
|
||||
|
||||
|
||||
def test_translate_event_tool_result() -> None:
|
||||
ex = KimiExecutor(binary_path="kimi")
|
||||
events = ex._translate_event(
|
||||
{
|
||||
"role": "tool",
|
||||
"content": "total 0\n",
|
||||
"tool_call_id": "tool_abc",
|
||||
}
|
||||
)
|
||||
assert len(events) == 1
|
||||
assert isinstance(events[0], ToolCallComplete)
|
||||
assert events[0].result == "total 0\n"
|
||||
assert events[0].metadata == {"call_id": "tool_abc"}
|
||||
|
||||
|
||||
def test_translate_event_meta_captures_session_id() -> None:
|
||||
"""``role:"meta"`` + ``type:"session.resume_hint"`` updates the executor."""
|
||||
ex = KimiExecutor(binary_path="kimi")
|
||||
events = ex._translate_event(
|
||||
{
|
||||
"role": "meta",
|
||||
"type": "session.resume_hint",
|
||||
"session_id": "session_abc123",
|
||||
"command": "kimi -r session_abc123",
|
||||
}
|
||||
)
|
||||
assert events == [] # meta events yield no Omnigent-visible events
|
||||
assert ex._session_id == "session_abc123"
|
||||
|
||||
|
||||
def test_translate_event_ignores_unknown_role() -> None:
|
||||
ex = KimiExecutor(binary_path="kimi")
|
||||
assert ex._translate_event({"role": "system", "content": "x"}) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Capability flags
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_kimi_executor_capabilities() -> None:
|
||||
ex = KimiExecutor(binary_path="kimi")
|
||||
assert ex.handles_tools_internally() is True
|
||||
assert ex.supports_streaming() is True
|
||||
assert ex.supports_tool_calling() is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# run_turn end-to-end with stubbed subprocess
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeStdout:
|
||||
"""Async-iterable stdout that yields the prepared JSONL lines."""
|
||||
|
||||
def __init__(self, lines: list[str]) -> None:
|
||||
self._lines = [line.encode("utf-8") + b"\n" for line in lines]
|
||||
|
||||
def __aiter__(self) -> _FakeStdout:
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> bytes:
|
||||
if not self._lines:
|
||||
raise StopAsyncIteration
|
||||
return self._lines.pop(0)
|
||||
|
||||
|
||||
class _FakeStderr:
|
||||
"""Reader returning a single buffered stderr blob then EOF."""
|
||||
|
||||
def __init__(self, blob: bytes) -> None:
|
||||
self._blob = blob
|
||||
self._done = False
|
||||
|
||||
async def read(self, _n: int) -> bytes:
|
||||
if self._done:
|
||||
return b""
|
||||
self._done = True
|
||||
return self._blob
|
||||
|
||||
|
||||
class _FakeProcess:
|
||||
"""asyncio.subprocess.Process double the tests inject in place of a real spawn."""
|
||||
|
||||
def __init__(self, stdout_lines: list[str], stderr_blob: bytes, returncode: int = 0) -> None:
|
||||
self.stdout = _FakeStdout(stdout_lines)
|
||||
self.stderr = _FakeStderr(stderr_blob)
|
||||
self._returncode = returncode
|
||||
|
||||
@property
|
||||
def returncode(self) -> int | None:
|
||||
return self._returncode
|
||||
|
||||
async def wait(self) -> int:
|
||||
return self._returncode
|
||||
|
||||
def terminate(self) -> None: # pragma: no cover — happy path doesn't terminate
|
||||
pass
|
||||
|
||||
def kill(self) -> None: # pragma: no cover
|
||||
pass
|
||||
|
||||
|
||||
async def _collect(ex: KimiExecutor, messages: list[dict[str, Any]]) -> list[Any]:
|
||||
out: list[Any] = []
|
||||
async for evt in ex.run_turn(messages=messages, tools=[], system_prompt=""):
|
||||
out.append(evt)
|
||||
return out
|
||||
|
||||
|
||||
def test_run_turn_streams_text_and_emits_turn_complete(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""End-to-end: assistant text + meta resume_hint → TextChunk + session id captured."""
|
||||
stdout_lines = [
|
||||
json.dumps({"role": "assistant", "content": "Hi there!"}),
|
||||
json.dumps(
|
||||
{
|
||||
"role": "meta",
|
||||
"type": "session.resume_hint",
|
||||
"session_id": "session_abc12345-6789",
|
||||
"command": "kimi -r session_abc12345-6789",
|
||||
}
|
||||
),
|
||||
]
|
||||
fake = _FakeProcess(stdout_lines, b"", returncode=0)
|
||||
|
||||
captured_argv: list[str] = []
|
||||
|
||||
async def _fake_spawn(*args: Any, **_kwargs: Any) -> _FakeProcess:
|
||||
captured_argv.extend(args)
|
||||
return fake
|
||||
|
||||
monkeypatch.setattr(kimi_executor, "_create_subprocess_exec", _fake_spawn)
|
||||
monkeypatch.setattr(kimi_executor.shutil, "which", lambda _binary: "/usr/local/bin/kimi")
|
||||
|
||||
ex = KimiExecutor(binary_path="kimi", model="kimi-k2-turbo")
|
||||
events = asyncio.run(_collect(ex, [{"role": "user", "content": "hi"}]))
|
||||
|
||||
text_chunks = [e for e in events if isinstance(e, TextChunk)]
|
||||
turn_completes = [e for e in events if isinstance(e, TurnComplete)]
|
||||
errors = [e for e in events if isinstance(e, ExecutorError)]
|
||||
|
||||
assert errors == []
|
||||
assert [c.text for c in text_chunks] == ["Hi there!"]
|
||||
assert turn_completes and turn_completes[0].response == "Hi there!"
|
||||
assert ex._session_id == "session_abc12345-6789"
|
||||
assert captured_argv[0] == "kimi"
|
||||
# No --print on upstream — make sure we don't reintroduce it.
|
||||
assert "--print" not in captured_argv
|
||||
assert "--output-format" in captured_argv
|
||||
assert "stream-json" in captured_argv
|
||||
|
||||
|
||||
def test_run_turn_uses_session_resume_on_second_turn(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""After the first turn captures a session id, the next spawn passes -S."""
|
||||
fake_first = _FakeProcess(
|
||||
[
|
||||
json.dumps({"role": "assistant", "content": "first"}),
|
||||
json.dumps(
|
||||
{"role": "meta", "type": "session.resume_hint", "session_id": "session_aaaaa"}
|
||||
),
|
||||
],
|
||||
b"",
|
||||
returncode=0,
|
||||
)
|
||||
fake_second = _FakeProcess(
|
||||
[
|
||||
json.dumps({"role": "assistant", "content": "second"}),
|
||||
json.dumps(
|
||||
{"role": "meta", "type": "session.resume_hint", "session_id": "session_aaaaa"}
|
||||
),
|
||||
],
|
||||
b"",
|
||||
returncode=0,
|
||||
)
|
||||
second_argv: list[str] = []
|
||||
calls = {"count": 0}
|
||||
|
||||
async def _fake_spawn(*args: Any, **_kwargs: Any) -> _FakeProcess:
|
||||
calls["count"] += 1
|
||||
if calls["count"] == 1:
|
||||
return fake_first
|
||||
second_argv.extend(args)
|
||||
return fake_second
|
||||
|
||||
monkeypatch.setattr(kimi_executor, "_create_subprocess_exec", _fake_spawn)
|
||||
monkeypatch.setattr(kimi_executor.shutil, "which", lambda _binary: "/usr/local/bin/kimi")
|
||||
|
||||
ex = KimiExecutor(binary_path="kimi")
|
||||
asyncio.run(_collect(ex, [{"role": "user", "content": "first"}]))
|
||||
asyncio.run(_collect(ex, [{"role": "user", "content": "next"}]))
|
||||
|
||||
assert "-S" in second_argv
|
||||
idx = second_argv.index("-S")
|
||||
assert second_argv[idx + 1] == "session_aaaaa"
|
||||
|
||||
|
||||
def test_run_turn_falls_back_to_stderr_regex_for_session_id(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""If the meta JSON event is absent, the stderr footer regex picks up the id."""
|
||||
fake = _FakeProcess(
|
||||
[json.dumps({"role": "assistant", "content": "hi"})],
|
||||
b"To resume this session: kimi -r session_fallback-1234\n",
|
||||
returncode=0,
|
||||
)
|
||||
|
||||
async def _fake_spawn(*_args: Any, **_kwargs: Any) -> _FakeProcess:
|
||||
return fake
|
||||
|
||||
monkeypatch.setattr(kimi_executor, "_create_subprocess_exec", _fake_spawn)
|
||||
monkeypatch.setattr(kimi_executor.shutil, "which", lambda _binary: "/usr/local/bin/kimi")
|
||||
|
||||
ex = KimiExecutor(binary_path="kimi")
|
||||
asyncio.run(_collect(ex, [{"role": "user", "content": "hi"}]))
|
||||
|
||||
assert ex._session_id == "session_fallback-1234"
|
||||
|
||||
|
||||
def test_run_turn_emits_error_when_kimi_binary_missing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(kimi_executor.shutil, "which", lambda _binary: None)
|
||||
monkeypatch.setattr(kimi_executor.Path, "exists", lambda _self: False)
|
||||
|
||||
ex = KimiExecutor(binary_path="kimi")
|
||||
events = asyncio.run(_collect(ex, [{"role": "user", "content": "hi"}]))
|
||||
|
||||
errors = [e for e in events if isinstance(e, ExecutorError)]
|
||||
assert errors and "not found on PATH" in errors[0].message
|
||||
assert errors[0].retryable is False
|
||||
|
||||
|
||||
def test_run_turn_with_empty_user_text_emits_turn_complete_none(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(kimi_executor.shutil, "which", lambda _binary: "/usr/local/bin/kimi")
|
||||
|
||||
async def _never_called(*_args: Any, **_kwargs: Any) -> Any:
|
||||
raise AssertionError("subprocess must not be spawned when prompt is empty")
|
||||
|
||||
monkeypatch.setattr(kimi_executor, "_create_subprocess_exec", _never_called)
|
||||
|
||||
ex = KimiExecutor(binary_path="kimi")
|
||||
events = asyncio.run(_collect(ex, [{"role": "assistant", "content": "no user msg"}]))
|
||||
|
||||
assert len(events) == 1
|
||||
assert isinstance(events[0], TurnComplete)
|
||||
assert events[0].response is None
|
||||
|
||||
|
||||
def test_run_turn_nonzero_exit_yields_executor_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
fake = _FakeProcess([], b"boom\n", returncode=2)
|
||||
|
||||
async def _fake_spawn(*_args: Any, **_kwargs: Any) -> _FakeProcess:
|
||||
return fake
|
||||
|
||||
monkeypatch.setattr(kimi_executor, "_create_subprocess_exec", _fake_spawn)
|
||||
monkeypatch.setattr(kimi_executor.shutil, "which", lambda _binary: "/usr/local/bin/kimi")
|
||||
|
||||
ex = KimiExecutor(binary_path="kimi")
|
||||
events = asyncio.run(_collect(ex, [{"role": "user", "content": "hi"}]))
|
||||
|
||||
errors = [e for e in events if isinstance(e, ExecutorError)]
|
||||
assert errors and "exited with code 2" in errors[0].message
|
||||
|
||||
|
||||
def test_run_turn_warns_once_when_tools_declared(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Tools on the spec are silently dropped (no MCP bridge on upstream kimi
|
||||
yet) — we should warn exactly once per session.
|
||||
"""
|
||||
import logging
|
||||
|
||||
caplog.set_level(logging.WARNING, logger="omnigent.inner.kimi_executor")
|
||||
|
||||
def _make_fake() -> _FakeProcess:
|
||||
return _FakeProcess(
|
||||
[
|
||||
json.dumps({"role": "assistant", "content": "ok"}),
|
||||
json.dumps(
|
||||
{"role": "meta", "type": "session.resume_hint", "session_id": "session_x"}
|
||||
),
|
||||
],
|
||||
b"",
|
||||
returncode=0,
|
||||
)
|
||||
|
||||
fakes = [_make_fake(), _make_fake()]
|
||||
|
||||
async def _fake_spawn(*_args: Any, **_kwargs: Any) -> _FakeProcess:
|
||||
return fakes.pop(0)
|
||||
|
||||
monkeypatch.setattr(kimi_executor, "_create_subprocess_exec", _fake_spawn)
|
||||
monkeypatch.setattr(kimi_executor.shutil, "which", lambda _binary: "/usr/local/bin/kimi")
|
||||
|
||||
ex = KimiExecutor(binary_path="kimi")
|
||||
tools = [{"name": "my_tool", "description": "x", "parameters": {}}]
|
||||
|
||||
async def _two_turns() -> None:
|
||||
async for _ in ex.run_turn(
|
||||
messages=[{"role": "user", "content": "hi"}], tools=tools, system_prompt=""
|
||||
):
|
||||
pass
|
||||
async for _ in ex.run_turn(
|
||||
messages=[{"role": "user", "content": "again"}], tools=tools, system_prompt=""
|
||||
):
|
||||
pass
|
||||
|
||||
asyncio.run(_two_turns())
|
||||
|
||||
warnings = [rec for rec in caplog.records if "tool-injection bridge" in rec.message]
|
||||
assert len(warnings) == 1, "should warn exactly once across both turns"
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Unit tests for the kimi-native (terminal-injection) harness.
|
||||
|
||||
Covers the executor's text extraction + capability flags, the tmux bridge's pure
|
||||
helpers (paste-payload encoding, bridge dir, spawn env, tmux.json round-trip),
|
||||
and harness registration. The live tmux injection is exercised by the e2e gate,
|
||||
not here, so these need no tmux or kimi binary.
|
||||
|
||||
Unlike cursor-native, kimi-native has NO MCP plumbing (upstream kimi has no
|
||||
per-spawn MCP config), so the MCP-config tests have no analogue here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from omnigent import kimi_native_bridge
|
||||
from omnigent.inner.kimi_native_executor import (
|
||||
KimiNativeExecutor,
|
||||
_content_to_text,
|
||||
_latest_user_text,
|
||||
)
|
||||
from omnigent.kimi_native_bridge import (
|
||||
APPROVE_KEY,
|
||||
BRIDGE_DIR_ENV_VAR,
|
||||
DENY_KEY,
|
||||
_paste_payload_bytes,
|
||||
bridge_dir_for_session_id,
|
||||
build_kimi_native_spawn_env,
|
||||
inject_approval_keystroke,
|
||||
read_tmux_info,
|
||||
write_tmux_target,
|
||||
)
|
||||
|
||||
|
||||
class TestContentExtraction:
|
||||
def test_string_content(self, tmp_path: Path) -> None:
|
||||
assert _content_to_text("hello", tmp_path) == "hello"
|
||||
|
||||
def test_input_text_blocks(self, tmp_path: Path) -> None:
|
||||
content = [
|
||||
{"type": "input_text", "text": "one"},
|
||||
{"type": "text", "text": "two"},
|
||||
# invalid data URI -> materialize_attachment returns None -> no line
|
||||
{"type": "input_image", "image_url": "data:..."},
|
||||
]
|
||||
assert _content_to_text(content, tmp_path) == "one\n\ntwo"
|
||||
|
||||
def test_real_image_attachment_materialized(self, tmp_path: Path) -> None:
|
||||
# a tiny valid base64 PNG data URI should be written to disk + referenced
|
||||
png = (
|
||||
"data:image/png;base64,"
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||
)
|
||||
out = _content_to_text([{"type": "input_image", "image_url": png}], tmp_path)
|
||||
assert out.startswith("[Attached: ")
|
||||
assert str(tmp_path) in out
|
||||
|
||||
def test_empty_and_none(self, tmp_path: Path) -> None:
|
||||
assert _content_to_text(None, tmp_path) == ""
|
||||
assert _content_to_text([], tmp_path) == ""
|
||||
|
||||
def test_latest_user_text(self, tmp_path: Path) -> None:
|
||||
messages = [
|
||||
{"role": "user", "content": "first"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
{"role": "user", "content": "second"},
|
||||
]
|
||||
assert _latest_user_text(messages, tmp_path) == "second"
|
||||
assert _latest_user_text([{"role": "assistant", "content": "x"}], tmp_path) == ""
|
||||
|
||||
|
||||
class TestExecutorCapabilities:
|
||||
def test_capability_flags(self, tmp_path: Path) -> None:
|
||||
ex = KimiNativeExecutor(bridge_dir=tmp_path)
|
||||
# Output is shown by the embedded terminal, not streamed by the executor.
|
||||
assert ex.supports_streaming() is False
|
||||
# Web-UI messages can be injected mid-turn (steering).
|
||||
assert ex.supports_live_message_queue() is True
|
||||
|
||||
|
||||
class TestPastePayload:
|
||||
def test_newlines_become_cr(self) -> None:
|
||||
assert _paste_payload_bytes("a\nb") == b"a\rb"
|
||||
assert _paste_payload_bytes("a\r\nb") == b"a\rb"
|
||||
assert _paste_payload_bytes("a\rb") == b"a\rb"
|
||||
|
||||
def test_tab_kept_other_control_dropped(self) -> None:
|
||||
# tab kept (0x09), ESC (0x1b) and BEL (0x07) dropped.
|
||||
assert _paste_payload_bytes("a\tb\x1b\x07c") == b"a\tbc"
|
||||
|
||||
def test_unicode_passthrough(self) -> None:
|
||||
assert _paste_payload_bytes("café") == "café".encode()
|
||||
|
||||
|
||||
class TestBridge:
|
||||
def test_bridge_dir_is_deterministic_and_session_scoped(self) -> None:
|
||||
a1 = bridge_dir_for_session_id("conv_a")
|
||||
a2 = bridge_dir_for_session_id("conv_a")
|
||||
b = bridge_dir_for_session_id("conv_b")
|
||||
assert a1 == a2
|
||||
assert a1 != b
|
||||
assert "kimi-native" in str(a1)
|
||||
|
||||
def test_spawn_env_carries_bridge_dir(self) -> None:
|
||||
env = build_kimi_native_spawn_env("conv_xyz")
|
||||
assert env[BRIDGE_DIR_ENV_VAR] == str(bridge_dir_for_session_id("conv_xyz"))
|
||||
# Only the bridge dir is emitted (no MCP / active-session guard env).
|
||||
assert list(env) == [BRIDGE_DIR_ENV_VAR]
|
||||
|
||||
def test_tmux_target_round_trip(self, tmp_path: Path) -> None:
|
||||
write_tmux_target(tmp_path, socket_path=Path("/tmp/x/tmux.sock"), tmux_target="main")
|
||||
info = read_tmux_info(tmp_path)
|
||||
assert info == {"socket_path": "/tmp/x/tmux.sock", "tmux_target": "main"}
|
||||
|
||||
def test_read_tmux_info_missing(self, tmp_path: Path) -> None:
|
||||
assert read_tmux_info(tmp_path) is None
|
||||
|
||||
|
||||
class TestApprovalKeystroke:
|
||||
"""`inject_approval_keystroke` types the option digit + Enter, guarded by
|
||||
the permission-menu marker so a stray verdict can't leak a keystroke."""
|
||||
|
||||
def _stub_tmux(
|
||||
self, monkeypatch: pytest.MonkeyPatch, *, pane: str, alive: bool = True
|
||||
) -> list[tuple[str, ...]]:
|
||||
sent: list[tuple[str, ...]] = []
|
||||
monkeypatch.setattr(
|
||||
kimi_native_bridge,
|
||||
"_wait_for_tmux_info",
|
||||
lambda bridge_dir, *, timeout_s: {"socket_path": "/s", "tmux_target": "main"},
|
||||
)
|
||||
monkeypatch.setattr(kimi_native_bridge, "_session_alive", lambda s, t: alive)
|
||||
monkeypatch.setattr(kimi_native_bridge, "_capture_pane", lambda s, t: pane)
|
||||
monkeypatch.setattr(
|
||||
kimi_native_bridge,
|
||||
"_run_tmux",
|
||||
lambda socket_path, *args: sent.append(args),
|
||||
)
|
||||
return sent
|
||||
|
||||
def test_injects_digit_and_enter_when_menu_present(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
sent = self._stub_tmux(monkeypatch, pane="▶ 1. Approve once\n 3. Reject")
|
||||
assert inject_approval_keystroke(tmp_path, key=APPROVE_KEY) is True
|
||||
assert sent == [
|
||||
("send-keys", "-t", "main", APPROVE_KEY),
|
||||
("send-keys", "-t", "main", "Enter"),
|
||||
]
|
||||
|
||||
def test_deny_key_selects_reject(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
sent = self._stub_tmux(monkeypatch, pane="▶ 1. Approve once\n 3. Reject")
|
||||
assert inject_approval_keystroke(tmp_path, key=DENY_KEY) is True
|
||||
assert sent[0] == ("send-keys", "-t", "main", DENY_KEY)
|
||||
|
||||
def test_skips_when_menu_absent(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# Prompt already answered in the terminal → marker gone → no keystroke.
|
||||
sent = self._stub_tmux(monkeypatch, pane="● Hello! How can I help?")
|
||||
assert inject_approval_keystroke(tmp_path, key=APPROVE_KEY) is False
|
||||
assert sent == []
|
||||
|
||||
def test_skips_when_tui_exited(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
sent = self._stub_tmux(monkeypatch, pane="▶ 1. Approve once", alive=False)
|
||||
assert inject_approval_keystroke(tmp_path, key=APPROVE_KEY) is False
|
||||
assert sent == []
|
||||
|
||||
|
||||
class TestRegistration:
|
||||
def test_harness_is_registered(self) -> None:
|
||||
from omnigent.runtime.harnesses import _HARNESS_MODULES
|
||||
|
||||
assert _HARNESS_MODULES["kimi-native"] == "omnigent.inner.kimi_native_harness"
|
||||
|
||||
def test_harness_is_allowlisted(self) -> None:
|
||||
from omnigent.spec._omnigent_compat import OMNIGENT_HARNESSES
|
||||
|
||||
assert "kimi-native" in OMNIGENT_HARNESSES
|
||||
|
||||
def test_kimi_native_is_terminal_native(self) -> None:
|
||||
# kimi-native launches the kimi TUI in an omnigent terminal (like
|
||||
# claude/codex/cursor-native), so the runner must treat it as a native
|
||||
# terminal harness.
|
||||
from omnigent.harness_aliases import is_native_harness
|
||||
|
||||
assert is_native_harness("kimi-native") is True
|
||||
assert is_native_harness("native-kimi") is True
|
||||
|
||||
def test_native_coding_agent_record(self) -> None:
|
||||
from omnigent.native_coding_agents import native_coding_agent_for_harness
|
||||
|
||||
agent = native_coding_agent_for_harness("kimi-native")
|
||||
assert agent is not None
|
||||
assert agent.terminal_name == "kimi"
|
||||
assert agent.display_name == "Kimi"
|
||||
|
||||
def test_distinct_from_headless_kimi_harness(self) -> None:
|
||||
# The bare ``kimi`` harness is the headless SDK path; ``kimi-native`` is
|
||||
# the TUI path. They must resolve to different harness modules.
|
||||
from omnigent.runtime.harnesses import _HARNESS_MODULES
|
||||
|
||||
assert _HARNESS_MODULES["kimi"] != _HARNESS_MODULES["kimi-native"]
|
||||
@@ -32,6 +32,53 @@ def test_install_spec_and_command(key: str, binary: str, package: str) -> None:
|
||||
assert hi.harness_install_command(key) == ["npm", "install", "-g", package]
|
||||
|
||||
|
||||
def test_kimi_install_spec_is_login_only_no_npm() -> None:
|
||||
"""Kimi ships via a curl installer (no npm package) and authenticates
|
||||
through its own ``kimi login`` (OAuth or Moonshot API key), so it carries
|
||||
an ``install_hint`` instead of a ``package`` and intentionally has no
|
||||
``status_args`` (no exit-code "am I logged in?" probe to read).
|
||||
"""
|
||||
spec = hi.harness_install_spec(hi.KIMI_KEY)
|
||||
assert spec is not None
|
||||
assert spec.binary == "kimi"
|
||||
assert spec.package is None
|
||||
assert spec.install_hint is not None and "code.kimi.com" in spec.install_hint
|
||||
assert spec.login_args == ("login",)
|
||||
assert spec.logout_args == ("logout",)
|
||||
assert spec.status_args is None
|
||||
|
||||
|
||||
def test_kimi_required_cli_returns_install_spec() -> None:
|
||||
"""The kimi harness is binary-gated: it cannot launch without ``kimi`` on
|
||||
PATH, so the sub-agent dispatch preflight must surface the install spec."""
|
||||
spec = hi.required_cli_for_harness("kimi")
|
||||
assert spec is not None
|
||||
assert spec.binary == "kimi"
|
||||
|
||||
|
||||
def test_kimi_only_upstream_binary_satisfies_readiness(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Only ``kimi`` (the upstream MoonshotAI/Kimi-Code binary) counts as
|
||||
installed. The legacy pypi ``kimi-cli`` package is intentionally NOT
|
||||
accepted — its command-line surface is incompatible with what the
|
||||
executor drives, so falsely reading it as configured would crash at
|
||||
the first turn."""
|
||||
monkeypatch.setattr(
|
||||
hi.shutil,
|
||||
"which",
|
||||
lambda name: "/Users/x/.local/bin/kimi-cli" if name == "kimi-cli" else None,
|
||||
)
|
||||
assert hi.harness_cli_installed(hi.KIMI_KEY) is False
|
||||
|
||||
monkeypatch.setattr(
|
||||
hi.shutil,
|
||||
"which",
|
||||
lambda name: "/Users/x/.kimi-code/bin/kimi" if name == "kimi" else None,
|
||||
)
|
||||
assert hi.harness_cli_installed(hi.KIMI_KEY) is True
|
||||
|
||||
|
||||
def test_cursor_install_spec_is_login_only_no_npm() -> None:
|
||||
"""Cursor ships via a curl installer (no npm package) and authenticates
|
||||
through its own CLI login, so it carries an ``install_hint`` + status JSON
|
||||
|
||||
@@ -142,6 +142,12 @@ def test_configured_harness_map_covers_all_spellings(
|
||||
"antigravity",
|
||||
"agy",
|
||||
"google-antigravity",
|
||||
# Kimi Code CLI + alias.
|
||||
"kimi",
|
||||
"kimi-code",
|
||||
# Native Kimi (``omnigent kimi``) — gates on the kimi CLI.
|
||||
"kimi-native",
|
||||
"native-kimi",
|
||||
# Qwen harnesses
|
||||
"qwen",
|
||||
"qwen-code",
|
||||
@@ -184,6 +190,7 @@ def test_configured_harness_map_gates_only_cli_harnesses(
|
||||
"codex-native",
|
||||
"native-codex",
|
||||
"pi",
|
||||
"kimi",
|
||||
"cursor-native",
|
||||
"native-cursor",
|
||||
"qwen",
|
||||
@@ -206,6 +213,25 @@ def test_configured_harness_map_all_true_with_clis(
|
||||
assert all(result.values())
|
||||
|
||||
|
||||
def test_kimi_readiness_keys_off_binary(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Kimi is configured iff the ``kimi`` binary is on PATH.
|
||||
|
||||
Kimi authenticates against Moonshot AI's backend via ``kimi login`` (OAuth
|
||||
or a Moonshot API key), which the daemon cannot inspect — so readiness
|
||||
keys off binary presence, and the alias ``kimi-code`` resolves to the
|
||||
same verdict via canonicalization.
|
||||
"""
|
||||
_no_clis_installed(monkeypatch)
|
||||
assert harness_is_configured("kimi") is False
|
||||
assert harness_is_configured("kimi-code") is False
|
||||
|
||||
_all_clis_installed(monkeypatch)
|
||||
assert harness_is_configured("kimi") is True
|
||||
assert harness_is_configured("kimi-code") is True
|
||||
|
||||
|
||||
def test_cursor_readiness_keys_off_api_key(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
|
||||
@@ -28,6 +28,7 @@ import yaml as _yaml
|
||||
from omnigent.runtime.workflow import (
|
||||
_build_claude_sdk_spawn_env,
|
||||
_build_codex_spawn_env,
|
||||
_build_kimi_spawn_env,
|
||||
_build_openai_agents_sdk_spawn_env,
|
||||
_build_pi_spawn_env,
|
||||
_build_qwen_spawn_env,
|
||||
@@ -969,3 +970,68 @@ def test_codex_undismissed_config_provider_routes_via_detection(
|
||||
env = _build_codex_spawn_env(spec, workdir=None)
|
||||
|
||||
assert env["HARNESS_CODEX_MODEL_PROVIDER"] == "Databricks"
|
||||
|
||||
|
||||
# ── Kimi Code CLI spawn-env ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_kimi_spawn_env_threads_spec_model_only(config_home: Path) -> None:
|
||||
"""The kimi builder only emits ``HARNESS_KIMI_MODEL`` (when set) and
|
||||
``HARNESS_KIMI_CWD`` (when workdir given). Upstream kimi has no per-spawn
|
||||
provider override, so no HARNESS_KIMI_GATEWAY_* / _DATABRICKS_PROFILE
|
||||
env vars are emitted — provider routing lives in ``~/.kimi/config.toml``."""
|
||||
_write_config(config_home, {"providers": {}})
|
||||
spec = _make_spec(harness="kimi", model="kimi-k2-turbo")
|
||||
|
||||
env = _build_kimi_spawn_env(spec, workdir=None)
|
||||
|
||||
assert env == {"HARNESS_KIMI_MODEL": "kimi-k2-turbo"}
|
||||
|
||||
|
||||
def test_kimi_workdir_threads_through_as_cwd(config_home: Path, tmp_path: Path) -> None:
|
||||
"""``workdir`` lands in ``HARNESS_KIMI_CWD`` so kimi's subprocess runs in
|
||||
the bundle dir (upstream kimi has no ``--work-dir`` flag, so the executor
|
||||
threads this as ``cwd=`` on the subprocess)."""
|
||||
_write_config(config_home, {"providers": {}})
|
||||
spec = _make_spec(harness="kimi")
|
||||
|
||||
env = _build_kimi_spawn_env(spec, workdir=tmp_path)
|
||||
|
||||
assert env["HARNESS_KIMI_CWD"] == str(tmp_path)
|
||||
|
||||
|
||||
def test_kimi_no_provider_emits_no_gateway_vars(config_home: Path) -> None:
|
||||
"""With no provider configured and no spec auth, kimi uses its own
|
||||
``kimi login`` credentials — no HARNESS_KIMI_GATEWAY_* leaks in.
|
||||
|
||||
A regression here would either steal an ambient OPENAI_API_KEY (mis-billing)
|
||||
or point at a stale URL the user never configured. Upstream kimi reads its
|
||||
provider config from ``~/.kimi/config.toml``; Omnigent never injects."""
|
||||
_write_config(config_home, {"providers": {}})
|
||||
spec = _make_spec(harness="kimi")
|
||||
|
||||
env = _build_kimi_spawn_env(spec, workdir=None)
|
||||
|
||||
assert "HARNESS_KIMI_GATEWAY_BASE_URL" not in env
|
||||
assert "HARNESS_KIMI_GATEWAY_API_KEY" not in env
|
||||
assert "HARNESS_KIMI_GATEWAY_PROVIDER" not in env
|
||||
assert "HARNESS_KIMI_DATABRICKS_PROFILE" not in env
|
||||
|
||||
|
||||
def test_kimi_ignores_global_default_provider(config_home: Path) -> None:
|
||||
"""An openai default provider does NOT inject creds into the kimi env.
|
||||
|
||||
Counterpart to the other harnesses: their spawn-env builders adopt the
|
||||
global default. For kimi we DO NOT — upstream has no per-spawn provider
|
||||
override flag, so silently injecting a key the executor can't pass to the
|
||||
subprocess would be misleading (and would mis-bill the user against an
|
||||
OpenAI key when their ``~/.kimi/config.toml`` actually points at
|
||||
Moonshot). The builder emits no gateway vars regardless of what's
|
||||
configured."""
|
||||
_write_config(config_home, _openai_default_config())
|
||||
spec = _make_spec(harness="kimi")
|
||||
|
||||
env = _build_kimi_spawn_env(spec, workdir=None)
|
||||
|
||||
assert "HARNESS_KIMI_GATEWAY_BASE_URL" not in env
|
||||
assert "HARNESS_KIMI_GATEWAY_API_KEY" not in env
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Tests for the kimi-native bridge hook-config helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from omnigent.kimi_native_bridge import (
|
||||
read_active_session_id,
|
||||
read_hook_config,
|
||||
write_hook_config,
|
||||
)
|
||||
|
||||
|
||||
def test_write_then_read_hook_config_round_trips(tmp_path: Path) -> None:
|
||||
bridge_dir = tmp_path / "bridge"
|
||||
bridge_dir.mkdir()
|
||||
write_hook_config(
|
||||
bridge_dir,
|
||||
server_url="http://127.0.0.1:8787",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
session_id="conv_xyz",
|
||||
)
|
||||
config = read_hook_config(bridge_dir)
|
||||
assert config["ap_server_url"] == "http://127.0.0.1:8787"
|
||||
assert config["ap_auth_headers"] == {"Authorization": "Bearer tok"}
|
||||
assert config["session_id"] == "conv_xyz"
|
||||
assert read_active_session_id(bridge_dir) == "conv_xyz"
|
||||
|
||||
|
||||
def test_read_hook_config_absent_is_empty(tmp_path: Path) -> None:
|
||||
bridge_dir = tmp_path / "bridge"
|
||||
bridge_dir.mkdir()
|
||||
assert read_hook_config(bridge_dir) == {}
|
||||
assert read_active_session_id(bridge_dir) is None
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Tests for the per-session KIMI_CODE_HOME builder."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import tomllib
|
||||
|
||||
from omnigent.kimi_native_credentials import (
|
||||
KIMI_CODE_HOME_ENV_VAR,
|
||||
build_kimi_session_home,
|
||||
render_kimi_hooks_toml,
|
||||
)
|
||||
|
||||
|
||||
def _fake_user_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
"""Point ``resolve_user_kimi_home`` at a populated fake global home."""
|
||||
user_home = tmp_path / "user-kimi"
|
||||
user_home.mkdir()
|
||||
(user_home / "config.toml").write_text(
|
||||
'default_model = "kimi-code/x"\n[providers."managed"]\ntype = "kimi"\n', encoding="utf-8"
|
||||
)
|
||||
(user_home / "oauth").mkdir()
|
||||
(user_home / "oauth" / "token").write_text("secret", encoding="utf-8")
|
||||
monkeypatch.setenv(KIMI_CODE_HOME_ENV_VAR, str(user_home))
|
||||
return user_home
|
||||
|
||||
|
||||
def test_render_hooks_toml_is_valid_and_complete() -> None:
|
||||
toml = render_kimi_hooks_toml(bridge_dir=Path("/tmp/b r"), python_executable="/py")
|
||||
parsed = tomllib.loads(toml)
|
||||
events = {h["event"] for h in parsed["hooks"]}
|
||||
assert events == {"PreToolUse", "PermissionRequest"}
|
||||
for hook in parsed["hooks"]:
|
||||
assert "omnigent.kimi_native_hook" in hook["command"]
|
||||
assert "/tmp/b r" in hook["command"] # space-bearing path round-trips
|
||||
|
||||
|
||||
def test_build_session_home_preserves_user_config_and_appends_hooks(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_fake_user_home(tmp_path, monkeypatch)
|
||||
session_home = tmp_path / "session-home"
|
||||
bridge_dir = tmp_path / "bridge"
|
||||
bridge_dir.mkdir()
|
||||
|
||||
env = build_kimi_session_home(session_home, bridge_dir=bridge_dir)
|
||||
|
||||
assert env == {KIMI_CODE_HOME_ENV_VAR: str(session_home)}
|
||||
parsed = tomllib.loads((session_home / "config.toml").read_text(encoding="utf-8"))
|
||||
# User config preserved …
|
||||
assert parsed["default_model"] == "kimi-code/x"
|
||||
assert "managed" in parsed["providers"]
|
||||
# … and the Omnigent hooks appended.
|
||||
assert {h["event"] for h in parsed["hooks"]} == {"PreToolUse", "PermissionRequest"}
|
||||
|
||||
|
||||
def test_build_session_home_symlinks_auth_but_not_config(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_fake_user_home(tmp_path, monkeypatch)
|
||||
session_home = tmp_path / "session-home"
|
||||
bridge_dir = tmp_path / "bridge"
|
||||
bridge_dir.mkdir()
|
||||
|
||||
build_kimi_session_home(session_home, bridge_dir=bridge_dir)
|
||||
|
||||
# oauth is symlinked through to the user's tokens (auth keeps working) …
|
||||
oauth_link = session_home / "oauth"
|
||||
assert oauth_link.is_symlink()
|
||||
assert (oauth_link / "token").read_text(encoding="utf-8") == "secret"
|
||||
# … but config.toml is a real file (we own its content), not a symlink.
|
||||
assert not (session_home / "config.toml").is_symlink()
|
||||
|
||||
|
||||
def test_build_session_home_without_user_home_writes_hooks_only(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv(KIMI_CODE_HOME_ENV_VAR, str(tmp_path / "does-not-exist"))
|
||||
session_home = tmp_path / "session-home"
|
||||
bridge_dir = tmp_path / "bridge"
|
||||
bridge_dir.mkdir()
|
||||
|
||||
build_kimi_session_home(session_home, bridge_dir=bridge_dir)
|
||||
|
||||
parsed = tomllib.loads((session_home / "config.toml").read_text(encoding="utf-8"))
|
||||
assert {h["event"] for h in parsed["hooks"]} == {"PreToolUse", "PermissionRequest"}
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Unit tests for the kimi-native transcript forwarder.
|
||||
|
||||
Covers the pure parsing/discovery helpers against kimi's real ``wire.jsonl``
|
||||
event schema (turn.prompt + content.part), the line-offset state round-trip,
|
||||
and workspace/recency-based session discovery. The live POST loop is exercised
|
||||
by the e2e gate, not here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from omnigent.kimi_native_forwarder import (
|
||||
_discover_wire,
|
||||
_ForwardState,
|
||||
_read_new_items,
|
||||
_read_state,
|
||||
_row_to_item,
|
||||
_write_state,
|
||||
clear_kimi_bridge_state,
|
||||
)
|
||||
|
||||
|
||||
class TestRowToItem:
|
||||
def test_turn_prompt_is_user(self) -> None:
|
||||
row = {
|
||||
"type": "turn.prompt",
|
||||
"input": [{"type": "text", "text": "what is in this repo?"}],
|
||||
"origin": {"kind": "user"},
|
||||
}
|
||||
item = _row_to_item(4, row)
|
||||
assert item is not None
|
||||
assert item.role == "user"
|
||||
assert item.text == "what is in this repo?"
|
||||
assert item.response_id == "kimi:turn:4"
|
||||
|
||||
def test_content_part_text_is_assistant(self) -> None:
|
||||
row = {
|
||||
"type": "context.append_loop_event",
|
||||
"event": {
|
||||
"type": "content.part",
|
||||
"uuid": "67ce67f7",
|
||||
"part": {"type": "text", "text": "This is **Omnigent**."},
|
||||
},
|
||||
}
|
||||
item = _row_to_item(9, row)
|
||||
assert item is not None
|
||||
assert item.role == "assistant"
|
||||
assert item.text == "This is **Omnigent**."
|
||||
assert item.response_id == "kimi:67ce67f7"
|
||||
|
||||
def test_think_part_is_skipped(self) -> None:
|
||||
row = {
|
||||
"type": "context.append_loop_event",
|
||||
"event": {"type": "content.part", "part": {"type": "think", "think": "reasoning"}},
|
||||
}
|
||||
assert _row_to_item(5, row) is None
|
||||
|
||||
def test_tool_call_and_metadata_skipped(self) -> None:
|
||||
for row in (
|
||||
{"type": "context.append_loop_event", "event": {"type": "tool.call", "name": "Read"}},
|
||||
{"type": "metadata", "protocol_version": 1},
|
||||
{"type": "usage.record", "usage": {}},
|
||||
{"type": "context.append_message", "message": {"role": "user", "content": []}},
|
||||
):
|
||||
assert _row_to_item(0, row) is None
|
||||
|
||||
def test_non_user_turn_prompt_skipped(self) -> None:
|
||||
row = {"type": "turn.prompt", "input": [{"type": "text", "text": "x"}],
|
||||
"origin": {"kind": "system"}}
|
||||
assert _row_to_item(0, row) is None
|
||||
|
||||
|
||||
class TestReadNewItems:
|
||||
def _wire(self, tmp_path: Path) -> Path:
|
||||
def _part(uuid: str, part_type: str, text: str) -> dict[str, object]:
|
||||
return {
|
||||
"type": "context.append_loop_event",
|
||||
"event": {"type": "content.part", "uuid": uuid,
|
||||
"part": {"type": part_type, "text": text}},
|
||||
}
|
||||
|
||||
rows = [
|
||||
{"type": "metadata", "protocol_version": 1},
|
||||
{"type": "turn.prompt", "input": [{"type": "text", "text": "hi"}],
|
||||
"origin": {"kind": "user"}},
|
||||
_part("u1", "think", "…"),
|
||||
_part("u2", "text", "hello!"),
|
||||
]
|
||||
p = tmp_path / "wire.jsonl"
|
||||
p.write_text("\n".join(json.dumps(r) for r in rows) + "\n", encoding="utf-8")
|
||||
return p
|
||||
|
||||
def test_parses_user_and_assistant_only(self, tmp_path: Path) -> None:
|
||||
items = _read_new_items(self._wire(tmp_path), 0)
|
||||
assert [(i.role, i.text) for i in items] == [("user", "hi"), ("assistant", "hello!")]
|
||||
|
||||
def test_offset_skips_already_seen(self, tmp_path: Path) -> None:
|
||||
wire = self._wire(tmp_path)
|
||||
# last_line past the user prompt (line 1) → only the assistant text (line 3).
|
||||
items = _read_new_items(wire, 2)
|
||||
assert [(i.role, i.text) for i in items] == [("assistant", "hello!")]
|
||||
assert items[0].line_no == 3
|
||||
|
||||
def test_missing_file_is_empty(self, tmp_path: Path) -> None:
|
||||
assert _read_new_items(tmp_path / "nope.jsonl", 0) == []
|
||||
|
||||
|
||||
class TestState:
|
||||
def test_round_trip_and_clear(self, tmp_path: Path) -> None:
|
||||
assert _read_state(tmp_path) is None
|
||||
_write_state(tmp_path, _ForwardState(wire_path="/x/wire.jsonl", last_line=7))
|
||||
loaded = _read_state(tmp_path)
|
||||
assert loaded is not None
|
||||
assert loaded.wire_path == "/x/wire.jsonl"
|
||||
assert loaded.last_line == 7
|
||||
clear_kimi_bridge_state(tmp_path)
|
||||
assert _read_state(tmp_path) is None
|
||||
|
||||
|
||||
class TestDiscoverWire:
|
||||
def _make_session(
|
||||
self, home: Path, session_dir_name: str, work_dir: str, *, mtime: float
|
||||
) -> Path:
|
||||
wire = home / "sessions" / "wd_x" / session_dir_name / "agents" / "main" / "wire.jsonl"
|
||||
wire.parent.mkdir(parents=True, exist_ok=True)
|
||||
wire.write_text("{}\n", encoding="utf-8")
|
||||
import os
|
||||
|
||||
os.utime(wire, (mtime, mtime))
|
||||
# session_index keys on the session dir (…/<wd_…>/<session_…>).
|
||||
idx = home / "session_index.jsonl"
|
||||
index_row = {"sessionDir": str(wire.parent.parent.parent), "workDir": work_dir}
|
||||
with idx.open("a", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(index_row) + "\n")
|
||||
return wire
|
||||
|
||||
def test_picks_newest_matching_workspace(self, tmp_path: Path) -> None:
|
||||
home = tmp_path / "kimi-code-home"
|
||||
home.mkdir()
|
||||
self._make_session(home, "session_old", "/ws", mtime=1000.0)
|
||||
newest = self._make_session(home, "session_new", "/ws", mtime=2000.0)
|
||||
self._make_session(home, "session_other", "/different", mtime=3000.0)
|
||||
found = _discover_wire(home, "/ws", launch_epoch_ms=0)
|
||||
assert found == newest
|
||||
|
||||
def test_none_before_any_session(self, tmp_path: Path) -> None:
|
||||
home = tmp_path / "kimi-code-home"
|
||||
home.mkdir()
|
||||
assert _discover_wire(home, "/ws", launch_epoch_ms=0) is None
|
||||
|
||||
def test_ignores_sessions_before_launch(self, tmp_path: Path) -> None:
|
||||
home = tmp_path / "kimi-code-home"
|
||||
home.mkdir()
|
||||
self._make_session(home, "session_stale", "/ws", mtime=1000.0)
|
||||
# launch far in the future (ms) → the 1000s-mtime session is below the floor.
|
||||
assert _discover_wire(home, "/ws", launch_epoch_ms=9_000_000_000_000) is None
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Tests for the kimi-native tool-policy hook commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from omnigent import kimi_native_hook
|
||||
from omnigent.kimi_native_bridge import APPROVE_KEY, DENY_KEY, write_hook_config
|
||||
from omnigent.native_policy_hook import _EVAL_UNAVAILABLE_REASON
|
||||
|
||||
|
||||
def _governed_bridge(tmp_path: Path, *, server: str = "http://127.0.0.1:8787") -> Path:
|
||||
"""Make a bridge dir with a hook_config so the session reads as governed."""
|
||||
bridge_dir = tmp_path / "bridge"
|
||||
bridge_dir.mkdir()
|
||||
write_hook_config(
|
||||
bridge_dir,
|
||||
server_url=server,
|
||||
headers={"Authorization": "Bearer t"},
|
||||
session_id="conv_abc",
|
||||
)
|
||||
return bridge_dir
|
||||
|
||||
|
||||
def _feed_stdin(monkeypatch: pytest.MonkeyPatch, payload: dict[str, object]) -> None:
|
||||
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
|
||||
|
||||
|
||||
def test_evaluate_policy_deny_emits_kimi_decision(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""A DENY verdict becomes kimi's ``permissionDecision: deny`` + reason."""
|
||||
bridge_dir = _governed_bridge(tmp_path)
|
||||
_feed_stdin(
|
||||
monkeypatch,
|
||||
{
|
||||
"hook_event_name": "PreToolUse",
|
||||
"tool_name": "Bash",
|
||||
"tool_input": {"command": "rm -rf /"},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
kimi_native_hook,
|
||||
"post_evaluate_with_retry",
|
||||
lambda *a, **k: httpx.Response(
|
||||
200,
|
||||
json={"result": "POLICY_ACTION_DENY", "reason": "blocked by policy"},
|
||||
request=httpx.Request("POST", "http://x"),
|
||||
),
|
||||
)
|
||||
|
||||
exit_code = kimi_native_hook.main(["evaluate-policy", "--bridge-dir", str(bridge_dir)])
|
||||
|
||||
assert exit_code == 0
|
||||
out = json.loads(capsys.readouterr().out)
|
||||
assert out["hookSpecificOutput"]["permissionDecision"] == "deny"
|
||||
assert out["hookSpecificOutput"]["permissionDecisionReason"] == "blocked by policy"
|
||||
|
||||
|
||||
def test_evaluate_policy_allow_emits_nothing(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""ALLOW (engine default) emits no output so kimi's own prompt still runs."""
|
||||
bridge_dir = _governed_bridge(tmp_path)
|
||||
_feed_stdin(
|
||||
monkeypatch,
|
||||
{"hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {}},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
kimi_native_hook,
|
||||
"post_evaluate_with_retry",
|
||||
lambda *a, **k: httpx.Response(
|
||||
200, json={"result": "POLICY_ACTION_ALLOW"}, request=httpx.Request("POST", "http://x")
|
||||
),
|
||||
)
|
||||
|
||||
exit_code = kimi_native_hook.main(["evaluate-policy", "--bridge-dir", str(bridge_dir)])
|
||||
|
||||
assert exit_code == 0
|
||||
assert capsys.readouterr().out == ""
|
||||
|
||||
|
||||
def test_evaluate_policy_ungoverned_session_no_opinion(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""No hook_config (no session/server) → exit 0, no output, no POST."""
|
||||
bridge_dir = tmp_path / "bridge"
|
||||
bridge_dir.mkdir()
|
||||
_feed_stdin(monkeypatch, {"hook_event_name": "PreToolUse", "tool_name": "Bash"})
|
||||
|
||||
def _boom(*_a: object, **_k: object) -> object:
|
||||
raise AssertionError("must not POST for an ungoverned session")
|
||||
|
||||
monkeypatch.setattr(kimi_native_hook, "post_evaluate_with_retry", _boom)
|
||||
|
||||
exit_code = kimi_native_hook.main(["evaluate-policy", "--bridge-dir", str(bridge_dir)])
|
||||
|
||||
assert exit_code == 0
|
||||
assert capsys.readouterr().out == ""
|
||||
|
||||
|
||||
def test_evaluate_policy_fails_closed_when_unreachable(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""A governed PreToolUse with no usable verdict fails CLOSED (deny)."""
|
||||
bridge_dir = _governed_bridge(tmp_path)
|
||||
_feed_stdin(monkeypatch, {"hook_event_name": "PreToolUse", "tool_name": "Bash"})
|
||||
monkeypatch.setattr(kimi_native_hook, "post_evaluate_with_retry", lambda *a, **k: None)
|
||||
|
||||
exit_code = kimi_native_hook.main(["evaluate-policy", "--bridge-dir", str(bridge_dir)])
|
||||
|
||||
assert exit_code == 0
|
||||
out = json.loads(capsys.readouterr().out)
|
||||
assert out["hookSpecificOutput"]["permissionDecision"] == "deny"
|
||||
assert out["hookSpecificOutput"]["permissionDecisionReason"] == _EVAL_UNAVAILABLE_REASON
|
||||
|
||||
|
||||
def _capture_injection(monkeypatch: pytest.MonkeyPatch) -> list[str]:
|
||||
"""Patch ``inject_approval_keystroke`` to record the option keys it gets."""
|
||||
keys: list[str] = []
|
||||
|
||||
def _fake_inject(bridge_dir: Path, *, key: str, timeout_s: float = 0.0) -> bool:
|
||||
del bridge_dir, timeout_s
|
||||
keys.append(key)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(kimi_native_hook, "inject_approval_keystroke", _fake_inject)
|
||||
return keys
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("verdict", "expected_key"),
|
||||
[("allow", APPROVE_KEY), ("deny", DENY_KEY)],
|
||||
)
|
||||
def test_permission_request_injects_keystroke_for_verdict(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
verdict: str,
|
||||
expected_key: str,
|
||||
) -> None:
|
||||
"""A web Approve/Deny verdict is typed into kimi's prompt as the option digit."""
|
||||
bridge_dir = _governed_bridge(tmp_path)
|
||||
_feed_stdin(
|
||||
monkeypatch,
|
||||
{"hook_event_name": "PermissionRequest", "tool_name": "Bash", "tool_call_id": "tc_1"},
|
||||
)
|
||||
posted: list[dict[str, object]] = []
|
||||
monkeypatch.setattr(
|
||||
kimi_native_hook,
|
||||
"_request_web_approval",
|
||||
lambda url, headers, body: posted.append({"url": url, "body": body}) or verdict,
|
||||
)
|
||||
keys = _capture_injection(monkeypatch)
|
||||
|
||||
exit_code = kimi_native_hook.main(["permission-request", "--bridge-dir", str(bridge_dir)])
|
||||
|
||||
assert exit_code == 0
|
||||
# Routed to the shared elicitation endpoint with the gated tool.
|
||||
assert posted[0]["url"].endswith("/v1/sessions/conv_abc/hooks/permission-request")
|
||||
assert posted[0]["body"]["tool_name"] == "Bash"
|
||||
assert keys == [expected_key]
|
||||
|
||||
|
||||
def test_permission_request_no_verdict_injects_nothing(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""No web verdict (timeout/unreachable/answered in terminal) → no keystroke."""
|
||||
bridge_dir = _governed_bridge(tmp_path)
|
||||
_feed_stdin(monkeypatch, {"hook_event_name": "PermissionRequest", "tool_name": "Bash"})
|
||||
monkeypatch.setattr(kimi_native_hook, "_request_web_approval", lambda *a, **k: None)
|
||||
keys = _capture_injection(monkeypatch)
|
||||
|
||||
assert kimi_native_hook.main(["permission-request", "--bridge-dir", str(bridge_dir)]) == 0
|
||||
assert keys == []
|
||||
|
||||
|
||||
def test_permission_request_ungoverned_no_request(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""No hook_config → no approval request and no keystroke (never raises)."""
|
||||
bridge_dir = tmp_path / "bridge"
|
||||
bridge_dir.mkdir()
|
||||
_feed_stdin(monkeypatch, {"hook_event_name": "PermissionRequest", "tool_name": "Bash"})
|
||||
|
||||
def _boom(*_a: object, **_k: object) -> str | None:
|
||||
raise AssertionError("ungoverned session must not request approval")
|
||||
|
||||
monkeypatch.setattr(kimi_native_hook, "_request_web_approval", _boom)
|
||||
keys = _capture_injection(monkeypatch)
|
||||
|
||||
assert kimi_native_hook.main(["permission-request", "--bridge-dir", str(bridge_dir)]) == 0
|
||||
assert keys == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("response", "expected"),
|
||||
[
|
||||
({"hookSpecificOutput": {"decision": {"behavior": "allow"}}}, "allow"),
|
||||
({"hookSpecificOutput": {"decision": {"behavior": "deny"}}}, "deny"),
|
||||
({"hookSpecificOutput": {"permissionDecision": "allow"}}, "allow"),
|
||||
({"hookSpecificOutput": {"permissionDecision": "deny"}}, "deny"),
|
||||
({"hookSpecificOutput": {"decision": {"behavior": "allow_always"}}}, "allow"),
|
||||
({"hookSpecificOutput": {"decision": {"behavior": "reject"}}}, "deny"),
|
||||
({}, None),
|
||||
({"hookSpecificOutput": {}}, None),
|
||||
({"hookSpecificOutput": {"decision": {"behavior": "huh"}}}, None),
|
||||
("not a dict", None),
|
||||
],
|
||||
)
|
||||
def test_verdict_from_response(response: object, expected: str | None) -> None:
|
||||
assert kimi_native_hook._verdict_from_response(response) == expected
|
||||
|
||||
|
||||
def test_unknown_subcommand_returns_2(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
assert kimi_native_hook.main(["bogus", "--bridge-dir", "/tmp/x"]) == 2
|
||||
@@ -33,10 +33,23 @@ def test_native_pi_alias_resolves_like_canonical() -> None:
|
||||
|
||||
def test_canonical_native_harnesses_resolve() -> None:
|
||||
"""The canonical native spellings all resolve to their agents."""
|
||||
for harness in ("claude-native", "codex-native", "pi-native"):
|
||||
for harness in ("claude-native", "codex-native", "pi-native", "cursor-native", "kimi-native"):
|
||||
assert native_coding_agent_for_harness(harness) is not None
|
||||
|
||||
|
||||
def test_native_kimi_alias_resolves_like_canonical() -> None:
|
||||
"""``native-kimi`` resolves to the same native agent as ``kimi-native``.
|
||||
|
||||
Mirrors the ``native-pi`` fold: ``canonicalize_harness`` maps the reversed
|
||||
spelling to the canonical id so a forked/switched kimi-native agent keeps
|
||||
its terminal-first presentation labels.
|
||||
"""
|
||||
agent = native_coding_agent_for_harness("native-kimi")
|
||||
assert agent is not None
|
||||
assert agent is native_coding_agent_for_harness("kimi-native")
|
||||
assert agent.terminal_name == "kimi"
|
||||
|
||||
|
||||
def test_unknown_harness_returns_none() -> None:
|
||||
"""A non-native harness stays unresolved (no terminal presentation)."""
|
||||
assert native_coding_agent_for_harness("claude-sdk") is None
|
||||
|
||||
Reference in New Issue
Block a user