@lythos/agent-adapter 0.17.10 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lythos/agent-adapter",
3
- "version": "0.17.10",
3
+ "version": "0.18.0",
4
4
  "main": "./src/index.ts",
5
5
  "exports": {
6
6
  ".": "./src/index.ts",
@@ -1,5 +1,14 @@
1
1
  import { describe, expect, it } from 'bun:test'
2
- import { detectKimiBinary, buildKimiCommand, parseKimiStreamJson } from './kimi'
2
+ import {
3
+ detectKimiBinary,
4
+ buildKimiCommand,
5
+ parseKimiStreamJson,
6
+ parseKimiVersion,
7
+ classifyKimiUpstream,
8
+ satisfiesVersionRange,
9
+ detectKimiProtocolMismatch,
10
+ KIMI_VERSION_RANGE,
11
+ } from './kimi'
3
12
 
4
13
  describe('detectKimiBinary', () => {
5
14
  it('returns a non-empty string when a kimi binary is available', () => {
@@ -19,12 +28,18 @@ describe('buildKimiCommand', () => {
19
28
  expect(cmd).toContain('stream-json')
20
29
  })
21
30
 
22
- it('builds kimi CLI args for kimi binary', () => {
23
- const cmd = buildKimiCommand(undefined, 'kimi')
31
+ it('builds kimi-code args for kimi binary (never --print)', () => {
32
+ const cmd = buildKimiCommand(undefined, 'kimi', 'kimi-code', 'do the thing')
24
33
  expect(cmd[0]).toBe('kimi')
25
- expect(cmd).toContain('--print')
34
+ expect(cmd).toContain('--prompt')
35
+ expect(cmd).toContain('do the thing')
26
36
  expect(cmd).toContain('--output-format')
27
37
  expect(cmd).toContain('stream-json')
38
+ expect(cmd).not.toContain('--print')
39
+ })
40
+
41
+ it('kimi-code requires a prompt argument', () => {
42
+ expect(() => buildKimiCommand(undefined, 'kimi', 'kimi-code')).toThrow('--prompt')
28
43
  })
29
44
 
30
45
  it('does not use shell or redirect (injection-safe)', () => {
@@ -116,3 +131,112 @@ describe('parseKimiStreamJson', () => {
116
131
  expect(result.checkpoints[1].stdout_summary).toBe('agent-stdout.txt\nreport.docx')
117
132
  })
118
133
  })
134
+
135
+ describe('parseKimiVersion', () => {
136
+ it('parses kimi-code bare version output', () => {
137
+ expect(parseKimiVersion('0.38.0')).toEqual({ version: '0.38.0', major: 0 })
138
+ })
139
+
140
+ it('parses kimi-cli self-report ("kimi, version 1.45.0")', () => {
141
+ expect(parseKimiVersion('kimi, version 1.45.0')).toEqual({ version: '1.45.0', major: 1 })
142
+ })
143
+
144
+ it('returns null for unparseable output', () => {
145
+ expect(parseKimiVersion('garbage')).toBeNull()
146
+ expect(parseKimiVersion('')).toBeNull()
147
+ })
148
+ })
149
+
150
+ describe('classifyKimiUpstream', () => {
151
+ it('major 0 → kimi-code', () => {
152
+ expect(classifyKimiUpstream(0)).toBe('kimi-code')
153
+ })
154
+
155
+ it('major 1 → kimi-cli', () => {
156
+ expect(classifyKimiUpstream(1)).toBe('kimi-cli')
157
+ })
158
+
159
+ it('unknown majors fail closed', () => {
160
+ expect(classifyKimiUpstream(2)).toBeNull()
161
+ expect(classifyKimiUpstream(9)).toBeNull()
162
+ })
163
+ })
164
+
165
+ describe('satisfiesVersionRange', () => {
166
+ it('accepts in-range kimi-code and kimi-cli versions', () => {
167
+ expect(satisfiesVersionRange('0.38.0', KIMI_VERSION_RANGE)).toBe(true)
168
+ expect(satisfiesVersionRange('1.45.0', KIMI_VERSION_RANGE)).toBe(true)
169
+ })
170
+
171
+ it('rejects out-of-range versions', () => {
172
+ expect(satisfiesVersionRange('2.0.0', KIMI_VERSION_RANGE)).toBe(false)
173
+ expect(satisfiesVersionRange('0.10.0', KIMI_VERSION_RANGE)).toBe(false)
174
+ expect(satisfiesVersionRange('9.9.9', KIMI_VERSION_RANGE)).toBe(false)
175
+ })
176
+
177
+ it('boundary values follow comparator semantics', () => {
178
+ expect(satisfiesVersionRange('0.30.0', KIMI_VERSION_RANGE)).toBe(true) // >= inclusive
179
+ expect(satisfiesVersionRange('2.0.0', '>=0.30.0 <2.0.0')).toBe(false) // < exclusive
180
+ expect(satisfiesVersionRange('1.0.0', '>1.0.0')).toBe(false)
181
+ expect(satisfiesVersionRange('1.0.1', '>1.0.0')).toBe(true)
182
+ expect(satisfiesVersionRange('1.0.0', '<=1.0.0')).toBe(true)
183
+ })
184
+ })
185
+
186
+ describe('parseKimiStreamJson — real kimi-code capture', () => {
187
+ // Live capture from kimi-code 0.38.0: `kimi -p "Reply with exactly: PROBE_OK" --output-format stream-json`
188
+ const kimiCodeCapture = [
189
+ '{"role":"meta","type":"system.version","version":"0.38.0"}',
190
+ '{"role":"assistant","content":"PROBE_OK"}',
191
+ '{"role":"meta","type":"session.resume_hint","session_id":"session_x","command":"kimi -r session_x","content":"To resume this session: kimi -r session_x"}',
192
+ ].join('\n')
193
+
194
+ it('extracts assistant text and skips meta events', () => {
195
+ const result = parseKimiStreamJson(kimiCodeCapture)
196
+ expect(result.text).toBe('PROBE_OK')
197
+ expect(result.events).toBe(3)
198
+ expect(result.checkpoints).toEqual([])
199
+ })
200
+ })
201
+
202
+ describe('detectKimiProtocolMismatch', () => {
203
+ it('regression: kimi-code invoked with --print must NOT pass silently (the live bug)', () => {
204
+ // Exact live capture: `kimi --print --output-format stream-json` → exit 1, empty stdout
205
+ const msg = detectKimiProtocolMismatch({
206
+ code: 1,
207
+ rawStdout: '',
208
+ stderr: "error: unknown option '--print'\n(Did you mean --prompt?)",
209
+ events: 0,
210
+ })
211
+ expect(msg).not.toBeNull()
212
+ expect(msg).toContain('code 1')
213
+ expect(msg).toContain("unknown option '--print'")
214
+ })
215
+
216
+ it('exit 0 with non-JSON stdout → protocol mismatch', () => {
217
+ const msg = detectKimiProtocolMismatch({
218
+ code: 0,
219
+ rawStdout: 'plain text that is not stream-json',
220
+ stderr: '',
221
+ events: 0,
222
+ })
223
+ expect(msg).not.toBeNull()
224
+ expect(msg).toContain('no parseable stream-json events')
225
+ })
226
+
227
+ it('exit 0 with empty stdout → no-output error', () => {
228
+ const msg = detectKimiProtocolMismatch({ code: 0, rawStdout: '', stderr: '', events: 0 })
229
+ expect(msg).not.toBeNull()
230
+ expect(msg).toContain('no output at all')
231
+ })
232
+
233
+ it('exit 0 with parsed events → OK', () => {
234
+ const msg = detectKimiProtocolMismatch({
235
+ code: 0,
236
+ rawStdout: '{"role":"assistant","content":"hi"}',
237
+ stderr: '',
238
+ events: 1,
239
+ })
240
+ expect(msg).toBeNull()
241
+ })
242
+ })
@@ -7,28 +7,80 @@ import { registerAgent } from '../registry'
7
7
 
8
8
  // ── Pure functions (testable without CLI) ────────────────────────────────────
9
9
 
10
- /** Detect which kimi binary to use. Prefers kimi-cli (legacy, v1.x) over kimi (kimi-code, v0.5+). */
11
- export function detectKimiBinary(): string {
12
- // kimi-cli v1.x supports --print --output-format stream-json
13
- // kimi v0.5.0 (kimi-code) does NOT support --print; it uses --prompt with different semantics
14
- if (Bun.which('kimi-cli')) {
15
- return 'kimi-cli'
16
- }
17
- if (Bun.which('kimi')) {
18
- return 'kimi'
10
+ /** kimi-cli = legacy Python CLI (v1.x, binary `kimi-cli`); kimi-code = TS CLI (v0.x, binary `kimi`). */
11
+ export type KimiUpstream = 'kimi-cli' | 'kimi-code'
12
+
13
+ /** Supported range across both upstreams (ADR-20260828004129233 Option B). */
14
+ export const KIMI_VERSION_RANGE = '>=0.30.0 <2.0.0'
15
+
16
+ /** Detect which kimi binary to use, in the declared preference order (default: kimi-cli legacy over kimi kimi-code). */
17
+ export function detectKimiBinary(binaries: string[] = ['kimi-cli', 'kimi']): string {
18
+ for (const b of binaries) {
19
+ if (Bun.which(b)) return b
19
20
  }
20
21
  return ''
21
22
  }
22
23
 
23
- /** Build kimi CLI command args (no shell wrapper — safe from injection). */
24
+ /**
25
+ * Parse the first semver triple from `<binary> --version` output.
26
+ * Both upstreams self-report as "kimi" — kimi-code prints "0.38.0",
27
+ * kimi-cli prints "kimi, version 1.45.0" — so discriminate on the
28
+ * version, never on the product name.
29
+ */
30
+ export function parseKimiVersion(output: string): { version: string; major: number } | null {
31
+ const m = output.match(/(\d+)\.(\d+)\.(\d+)/)
32
+ if (!m) return null
33
+ return { version: m[0], major: parseInt(m[1], 10) }
34
+ }
35
+
36
+ /** major 0 = kimi-code, major 1 = kimi-cli, anything else = unknown (fail closed). */
37
+ export function classifyKimiUpstream(major: number): KimiUpstream | null {
38
+ if (major === 0) return 'kimi-code'
39
+ if (major === 1) return 'kimi-cli'
40
+ return null
41
+ }
42
+
43
+ /** Minimal semver-triple check against space-separated comparators (>=, >, <=, <). No semver dep. */
44
+ export function satisfiesVersionRange(version: string, range: string): boolean {
45
+ const v = version.split('.').map(n => parseInt(n, 10))
46
+ for (const cmp of range.trim().split(/\s+/)) {
47
+ const m = cmp.match(/^(>=|<=|>|<)(\d+\.\d+\.\d+)$/)
48
+ if (!m) return false
49
+ const t = m[2].split('.').map(n => parseInt(n, 10))
50
+ let d = 0
51
+ for (let i = 0; i < 3; i++) {
52
+ d = (v[i] ?? 0) - (t[i] ?? 0)
53
+ if (d !== 0) break
54
+ }
55
+ if (m[1] === '>=' && d < 0) return false
56
+ if (m[1] === '>' && d <= 0) return false
57
+ if (m[1] === '<=' && d > 0) return false
58
+ if (m[1] === '<' && d >= 0) return false
59
+ }
60
+ return true
61
+ }
62
+
63
+ /**
64
+ * Build kimi CLI command args (no shell wrapper — safe from injection).
65
+ * kimi-cli speaks `--print` (prompt on stdin). kimi-code speaks `--prompt <prompt>`
66
+ * (its `--output-format` only works in prompt mode; `--print` never existed and
67
+ * exits 1 with "unknown option").
68
+ */
24
69
  export function buildKimiCommand(
25
70
  _modelTier?: 'fast' | 'balanced' | 'deep',
26
71
  binary = detectKimiBinary(),
72
+ upstream: KimiUpstream = 'kimi-cli',
73
+ prompt?: string,
27
74
  ): string[] {
28
75
  if (!binary) {
29
76
  throw new Error('No kimi binary found in PATH. Install: https://github.com/MoonshotAI/kimi-cli')
30
77
  }
31
- // Both kimi-cli and kimi (if it ever gains --print support) use the same flag set
78
+ if (upstream === 'kimi-code') {
79
+ if (!prompt) {
80
+ throw new Error('kimi-code requires the prompt as a --prompt argument (no stdin prompt mode)')
81
+ }
82
+ return [binary, '--prompt', prompt, '--output-format', 'stream-json']
83
+ }
32
84
  return [binary, '--print', '--output-format', 'stream-json']
33
85
  }
34
86
 
@@ -36,16 +88,20 @@ export function buildKimiCommand(
36
88
  * Parse kimi stream-json output into plain text + checkpoint trace.
37
89
  * Each line is a JSON event; extracts text from assistant role messages
38
90
  * and tool_calls/tool messages into CheckpointEntry for white-box replay.
91
+ * `events` counts successfully parsed JSON lines — zero events on non-empty
92
+ * stdout means the upstream is speaking a different protocol.
39
93
  */
40
- export function parseKimiStreamJson(raw: string): { text: string; checkpoints: CheckpointEntry[] } {
94
+ export function parseKimiStreamJson(raw: string): { text: string; checkpoints: CheckpointEntry[]; events: number } {
41
95
  const textLines: string[] = []
42
96
  const checkpoints: CheckpointEntry[] = []
43
97
  const pendingTools = new Map<string, { name: string; args: string }>()
98
+ let events = 0
44
99
 
45
100
  for (const line of raw.split('\n')) {
46
101
  if (!line.trim()) continue
47
102
  try {
48
103
  const event = JSON.parse(line)
104
+ events++
49
105
  if (event.role === 'assistant') {
50
106
  const content = event.content
51
107
  if (typeof content === 'string') {
@@ -85,24 +141,103 @@ export function parseKimiStreamJson(raw: string): { text: string; checkpoints: C
85
141
  } catch { /* skip malformed lines */ }
86
142
  }
87
143
 
88
- return { text: textLines.join('\n'), checkpoints }
144
+ return { text: textLines.join('\n'), checkpoints, events }
145
+ }
146
+
147
+ /**
148
+ * Fail-loud check on a finished kimi run. Returns a HATEOAS-style error
149
+ * message, or null when the run produced parseable stream-json output.
150
+ * Replaces the old never-throw passthrough that returned empty stdout as success.
151
+ */
152
+ export function detectKimiProtocolMismatch(result: {
153
+ code: number
154
+ rawStdout: string
155
+ stderr: string
156
+ events: number
157
+ }): string | null {
158
+ const { code, rawStdout, stderr, events } = result
159
+ if (code !== 0) {
160
+ return [
161
+ `kimi exited with code ${code} and produced no usable stream-json output.`,
162
+ stderr.trim() ? `stderr: ${stderr.trim().slice(0, 200)}` : '',
163
+ `Likely cause: flag/protocol mismatch between this adapter and the installed kimi upstream.`,
164
+ `Fix: upgrade kimi-cli (https://github.com/MoonshotAI/kimi-cli) or kimi-code, or pick another --player.`,
165
+ ].filter(Boolean).join('\n ')
166
+ }
167
+ if (!rawStdout.trim()) {
168
+ return [
169
+ `kimi exited 0 but produced no output at all.`,
170
+ `Likely cause: protocol mismatch with the installed kimi upstream.`,
171
+ `Fix: upgrade kimi-cli/kimi-code, or pick another --player.`,
172
+ ].join('\n ')
173
+ }
174
+ if (events === 0) {
175
+ return [
176
+ `kimi exited 0 but stdout had no parseable stream-json events.`,
177
+ `stdout (first 200 chars): ${rawStdout.trim().slice(0, 200)}`,
178
+ `Likely cause: the upstream is speaking a different output protocol.`,
179
+ `Fix: upgrade kimi-cli/kimi-code, or pick another --player.`,
180
+ ].join('\n ')
181
+ }
182
+ return null
89
183
  }
90
184
 
91
185
  // ── Spawn wrapper (IO, tested via BDD / arena integration) ──────────────────
92
186
 
93
- async function spawnKimi(opts: {
94
- brief: string
95
- cwd: string
96
- timeoutMs?: number
97
- }): Promise<AgentRunResult> {
98
- const promptFile = join(tmpdir(), `kimi-prompt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.txt`)
99
- writeFileSync(promptFile, opts.brief, 'utf-8')
187
+ /** Probe `<binary> <probeArgs>`, classify the upstream, enforce the declared version range. Fail closed. */
188
+ function probeKimiUpstream(
189
+ binary: string,
190
+ probeArgs: string[],
191
+ versionRange: string,
192
+ ): { upstream: KimiUpstream; version: string } {
193
+ const probe = Bun.spawnSync([binary, ...probeArgs])
194
+ const output = `${probe.stdout.toString()}\n${probe.stderr.toString()}`.trim()
195
+ const parsed = parseKimiVersion(output)
196
+ const upstream = parsed ? classifyKimiUpstream(parsed.major) : null
197
+ if (!parsed || !upstream || !satisfiesVersionRange(parsed.version, versionRange)) {
198
+ throw new Error([
199
+ `kimi upstream probe failed: "${binary} ${probeArgs.join(' ')}" returned "${output.slice(0, 120) || '(no output)'}"`,
200
+ `Detected: ${parsed ? `version ${parsed.version}` : 'unparseable version output'}`,
201
+ `Supported: kimi-cli 1.x (legacy) / kimi-code 0.x — declared range: ${versionRange}`,
202
+ `Fix: install/upgrade kimi-cli (https://github.com/MoonshotAI/kimi-cli) or kimi-code, or pick another --player.`,
203
+ ].join('\n '))
204
+ }
205
+ return { upstream, version: parsed.version }
206
+ }
207
+
208
+ async function spawnKimi(
209
+ opts: {
210
+ brief: string
211
+ cwd: string
212
+ timeoutMs?: number
213
+ },
214
+ declared?: AgentAdapter['upstream'],
215
+ ): Promise<AgentRunResult> {
216
+ const binary = detectKimiBinary(declared?.binaries)
217
+ if (!binary) {
218
+ throw new Error('kimi not found in PATH. Install: https://github.com/MoonshotAI/kimi-cli')
219
+ }
220
+ // Contract semantics: adapters without an `upstream` declaration stay unprobed (legacy behavior).
221
+ const probed = declared ? probeKimiUpstream(binary, declared.probeArgs, declared.versionRange) : null
222
+ const upstream: KimiUpstream = probed?.upstream ?? 'kimi-cli'
223
+ if (probed) console.error(`ℹ️ kimi upstream: ${probed.upstream} ${probed.version}`)
224
+
225
+ // kimi-cli reads the prompt from stdin (temp file); kimi-code takes it as --prompt argv.
226
+ let promptFile: string | null = null
227
+ let cmd: string[]
228
+ if (upstream === 'kimi-code') {
229
+ cmd = buildKimiCommand(undefined, binary, upstream, opts.brief)
230
+ } else {
231
+ promptFile = join(tmpdir(), `kimi-prompt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.txt`)
232
+ writeFileSync(promptFile, opts.brief, 'utf-8')
233
+ cmd = buildKimiCommand(undefined, binary, upstream)
234
+ }
100
235
 
101
236
  const start = Date.now()
102
237
 
103
- const proc = Bun.spawn(buildKimiCommand(), {
238
+ const proc = Bun.spawn(cmd, {
104
239
  cwd: opts.cwd,
105
- stdin: Bun.file(promptFile),
240
+ stdin: promptFile ? Bun.file(promptFile) : 'ignore',
106
241
  stdout: 'pipe',
107
242
  stderr: 'pipe',
108
243
  })
@@ -111,22 +246,20 @@ async function spawnKimi(opts: {
111
246
  await proc.exited
112
247
  clearTimeout(timeout)
113
248
 
114
- try { unlinkSync(promptFile) } catch {}
249
+ if (promptFile) {
250
+ try { unlinkSync(promptFile) } catch {}
251
+ }
115
252
 
116
253
  const durationMs = Date.now() - start
117
254
  const rawStdout = await new Response(proc.stdout).text()
118
255
  const stderr = await new Response(proc.stderr).text()
119
256
  const code = proc.exitCode ?? 1
120
257
 
121
- let text: string
122
- let streamCheckpoints: CheckpointEntry[]
123
- try {
124
- const parsed = parseKimiStreamJson(rawStdout)
125
- text = parsed.text
126
- streamCheckpoints = parsed.checkpoints
127
- } catch {
128
- text = rawStdout
129
- streamCheckpoints = []
258
+ // parseKimiStreamJson never throws; protocol mismatch is detected explicitly.
259
+ const parsed = parseKimiStreamJson(rawStdout)
260
+ const mismatch = detectKimiProtocolMismatch({ code, rawStdout, stderr, events: parsed.events })
261
+ if (mismatch) {
262
+ throw new Error(mismatch)
130
263
  }
131
264
 
132
265
  // Preserve raw JSONL for post-hoc white-box analysis
@@ -135,20 +268,22 @@ async function spawnKimi(opts: {
135
268
  } catch { /* ignore write failures */ }
136
269
 
137
270
  const fileCheckpoints = readCheckpoints(opts.cwd)
138
- const checkpoints = [...streamCheckpoints, ...fileCheckpoints]
271
+ const checkpoints = [...parsed.checkpoints, ...fileCheckpoints]
139
272
 
140
- return { stdout: text.trim(), stderr, code, durationMs, checkpoints }
273
+ return { stdout: parsed.text.trim(), stderr, code, durationMs, checkpoints }
141
274
  }
142
275
 
143
276
  const kimiAdapter: AgentAdapter = {
144
277
  name: 'kimi',
145
278
 
279
+ upstream: {
280
+ binaries: ['kimi-cli', 'kimi'],
281
+ versionRange: KIMI_VERSION_RANGE,
282
+ probeArgs: ['--version'],
283
+ },
284
+
146
285
  async spawn(opts): Promise<AgentRunResult> {
147
- const binary = detectKimiBinary()
148
- if (!binary) {
149
- throw new Error('kimi not found in PATH. Install: https://github.com/MoonshotAI/kimi-cli')
150
- }
151
- return spawnKimi(opts)
286
+ return spawnKimi(opts, kimiAdapter.upstream)
152
287
  },
153
288
 
154
289
  async invokeTool(_opts): Promise<unknown> {
package/src/types.ts CHANGED
@@ -33,6 +33,22 @@ export interface ToolDefinition {
33
33
 
34
34
  export interface AgentAdapter {
35
35
  name: string
36
+
37
+ /**
38
+ * Optional upstream declaration (ADR-20260828004129233 Option B): which upstream
39
+ * binary(-ies) this adapter drives, the supported version range, and how to probe.
40
+ * Adapters that declare it probe at spawn time and fail closed (loud HATEOAS error)
41
+ * on unknown/out-of-range upstreams. Adapters without it are unprobed (unchanged).
42
+ */
43
+ upstream?: {
44
+ /** Candidate binary names in preference order (e.g. ['kimi-cli', 'kimi']). */
45
+ binaries: string[]
46
+ /** Space-separated comparators, e.g. '>=0.30.0 <2.0.0'. */
47
+ versionRange: string
48
+ /** Args that print the version, e.g. ['--version']. */
49
+ probeArgs: string[]
50
+ }
51
+
36
52
  spawn(opts: {
37
53
  cwd: string
38
54
  brief: string