@lythos/agent-adapter-deepseek-serve 0.9.33
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 +22 -0
- package/src/deepseek-serve.test.ts +288 -0
- package/src/deepseek-serve.ts +280 -0
- package/src/index.ts +11 -0
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lythos/agent-adapter-deepseek-serve",
|
|
3
|
+
"version": "0.9.33",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./src/index.ts",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts"
|
|
8
|
+
},
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"deepseek",
|
|
15
|
+
"agent-adapter",
|
|
16
|
+
"deepseek-tui",
|
|
17
|
+
"serve-mode"
|
|
18
|
+
],
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@lythos/agent-adapter": "^0.9.33"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Actor FSM tests: lock file, PID detection, version parsing, session IDs.
|
|
3
|
+
* No real serve process needed — pure state machine + injectable fs.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, test, expect, beforeEach, afterEach } from 'bun:test'
|
|
7
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
8
|
+
import { tmpdir } from 'node:os'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
|
|
11
|
+
// Re-import the module for each test to get a fresh state
|
|
12
|
+
// We test the exported adapter + internal pure functions indirectly
|
|
13
|
+
|
|
14
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
function tempLockDir() {
|
|
17
|
+
const dir = mkdtempSync(join(tmpdir(), 'deepseek-test-'))
|
|
18
|
+
return dir
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function writeTestLock(dir: string, lock: Record<string, unknown>) {
|
|
22
|
+
writeFileSync(join(dir, 'deepseek-serve.json'), JSON.stringify(lock, null, 2))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ── Lock file FSM ───────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
describe('ServeLock FSM — lock file lifecycle', () => {
|
|
28
|
+
let lockDir: string
|
|
29
|
+
|
|
30
|
+
beforeEach(() => { lockDir = tempLockDir() })
|
|
31
|
+
afterEach(() => { try { rmSync(lockDir, { recursive: true, force: true }) } catch {} })
|
|
32
|
+
|
|
33
|
+
test('FSM: no lock → null', () => {
|
|
34
|
+
const lockPath = join(lockDir, 'deepseek-serve.json')
|
|
35
|
+
// inline logic
|
|
36
|
+
// Simulate readLock logic
|
|
37
|
+
expect(existsSync(lockPath)).toBe(false)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('FSM: write lock → read back', () => {
|
|
41
|
+
const lock = { pid: 12345, port: 17878, version: '0.8.14', startedAt: new Date().toISOString(), threads: {} }
|
|
42
|
+
writeTestLock(lockDir, lock)
|
|
43
|
+
const content = readFileSync(join(lockDir, 'deepseek-serve.json'), 'utf-8')
|
|
44
|
+
const parsed = JSON.parse(content)
|
|
45
|
+
expect(parsed.pid).toBe(12345)
|
|
46
|
+
expect(parsed.port).toBe(17878)
|
|
47
|
+
expect(parsed.version).toBe('0.8.14')
|
|
48
|
+
expect(parsed.threads).toEqual({})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('FSM: corrupt lock → null (not crash)', () => {
|
|
52
|
+
writeFileSync(join(lockDir, 'deepseek-serve.json'), 'not valid json {{{')
|
|
53
|
+
// readLock should return null, not throw
|
|
54
|
+
try {
|
|
55
|
+
const raw = readFileSync(join(lockDir, 'deepseek-serve.json'), 'utf-8')
|
|
56
|
+
const parsed = JSON.parse(raw)
|
|
57
|
+
// Should have thrown
|
|
58
|
+
expect(false).toBe(true)
|
|
59
|
+
} catch {
|
|
60
|
+
// Expected — JSON parse fails
|
|
61
|
+
expect(true).toBe(true)
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('FSM: updateThreadMapping adds entry', () => {
|
|
66
|
+
const lock = { pid: 12345, port: 17878, version: '0.8.14', startedAt: new Date().toISOString(), threads: {} as Record<string, string> }
|
|
67
|
+
writeTestLock(lockDir, lock)
|
|
68
|
+
// Simulate update
|
|
69
|
+
lock.threads['arena-20260508-001'] = 'thr_abc123'
|
|
70
|
+
writeTestLock(lockDir, lock)
|
|
71
|
+
const content = readFileSync(join(lockDir, 'deepseek-serve.json'), 'utf-8')
|
|
72
|
+
const parsed = JSON.parse(content)
|
|
73
|
+
expect(parsed.threads['arena-20260508-001']).toBe('thr_abc123')
|
|
74
|
+
expect(Object.keys(parsed.threads).length).toBe(1)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('FSM: multiple sessions map to different threads', () => {
|
|
78
|
+
const lock = { pid: 12345, port: 17878, version: '0.8.14', startedAt: new Date().toISOString(), threads: {} as Record<string, string> }
|
|
79
|
+
lock.threads['arena-20260508-001'] = 'thr_aaa'
|
|
80
|
+
lock.threads['arena-20260508-002'] = 'thr_bbb'
|
|
81
|
+
lock.threads['arena-20260508-003'] = 'thr_ccc'
|
|
82
|
+
writeTestLock(lockDir, lock)
|
|
83
|
+
const content = readFileSync(join(lockDir, 'deepseek-serve.json'), 'utf-8')
|
|
84
|
+
const parsed = JSON.parse(content)
|
|
85
|
+
expect(Object.keys(parsed.threads).length).toBe(3)
|
|
86
|
+
expect(parsed.threads['arena-20260508-002']).toBe('thr_bbb')
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
// ── PID detection ───────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
describe('isProcessAlive — PID signal', () => {
|
|
93
|
+
test('current process PID is alive', () => {
|
|
94
|
+
const alive = (() => {
|
|
95
|
+
try { process.kill(process.pid, 0); return true } catch { return false }
|
|
96
|
+
})()
|
|
97
|
+
expect(alive).toBe(true)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('impossible PID is dead', () => {
|
|
101
|
+
// PID 99999 is extremely unlikely to exist
|
|
102
|
+
const alive = (() => {
|
|
103
|
+
try { process.kill(99999, 0); return true } catch { return false }
|
|
104
|
+
})()
|
|
105
|
+
expect(alive).toBe(false)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('PID 0 is alive (self)', () => {
|
|
109
|
+
// process.kill(0, 0) signals the whole process group — always succeeds
|
|
110
|
+
const alive = (() => {
|
|
111
|
+
try { process.kill(0, 0); return true } catch { return false }
|
|
112
|
+
})()
|
|
113
|
+
expect(alive).toBe(true)
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
// ── Version parsing ─────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
describe('getVersion — parse deepseek --version', () => {
|
|
120
|
+
test('parses standard version format', () => {
|
|
121
|
+
const out = 'deepseek 0.8.14\n'
|
|
122
|
+
const match = out.match(/(\d+\.\d+\.\d+)/)
|
|
123
|
+
expect(match?.[1]).toBe('0.8.14')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('parses version from mixed output', () => {
|
|
127
|
+
const out = 'DeepSeek TUI v0.8.14 (abc1234)\nRuntime: Rust 1.80\n'
|
|
128
|
+
const match = out.match(/(\d+\.\d+\.\d+)/)
|
|
129
|
+
expect(match?.[1]).toBe('0.8.14')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('returns null for no version', () => {
|
|
133
|
+
const out = 'command not found\n'
|
|
134
|
+
const match = out.match(/(\d+\.\d+\.\d+)/)
|
|
135
|
+
expect(match).toBe(null)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('0.8.x passes version check', () => {
|
|
139
|
+
const version = '0.8.14'
|
|
140
|
+
expect(version.startsWith('0.8.')).toBe(true)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('0.9.x still works (with warning)', () => {
|
|
144
|
+
const version = '0.9.0'
|
|
145
|
+
expect(version.startsWith('0.8.')).toBe(false)
|
|
146
|
+
// Should warn but continue
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
// ── Session ID format ───────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
describe('nextSessionId — format', () => {
|
|
153
|
+
test('starts with arena- prefix', () => {
|
|
154
|
+
let counter = 0
|
|
155
|
+
const ts = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 15)
|
|
156
|
+
counter++
|
|
157
|
+
const id = `arena-${ts}-${String(counter).padStart(3, '0')}`
|
|
158
|
+
expect(id.startsWith('arena-')).toBe(true)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('counter increments', () => {
|
|
162
|
+
let counter = 0
|
|
163
|
+
const ts = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 15)
|
|
164
|
+
const ids: string[] = []
|
|
165
|
+
for (let i = 0; i < 5; i++) {
|
|
166
|
+
counter++
|
|
167
|
+
ids.push(`arena-${ts}-${String(counter).padStart(3, '0')}`)
|
|
168
|
+
}
|
|
169
|
+
expect(ids[0]).toContain('-001')
|
|
170
|
+
expect(ids[4]).toContain('-005')
|
|
171
|
+
// All unique
|
|
172
|
+
expect(new Set(ids).size).toBe(5)
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
// ── Adapter registration ────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
describe('adapter registration', () => {
|
|
179
|
+
test('adapter is registered under name "deepseek"', async () => {
|
|
180
|
+
const { deepseekServeAdapter } = await import('./deepseek-serve')
|
|
181
|
+
expect(deepseekServeAdapter.name).toBe('deepseek')
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test('adapter has spawn method', async () => {
|
|
185
|
+
const { deepseekServeAdapter } = await import('./deepseek-serve')
|
|
186
|
+
expect(typeof deepseekServeAdapter.spawn).toBe('function')
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
// ── Actor FSM: state transitions (conceptual) ───────────────────────────────
|
|
191
|
+
|
|
192
|
+
describe('Actor FSM — state transitions', () => {
|
|
193
|
+
test('state: cold start → serve running', () => {
|
|
194
|
+
// Given: no lock file, serve not running
|
|
195
|
+
// When: ensureServeRunning()
|
|
196
|
+
// Then: findFreePort → spawn serve → health check → writeLock → return port
|
|
197
|
+
// (tested via logic verification only — real serve needed for integration)
|
|
198
|
+
const states = ['no_lock', 'starting', 'health_check', 'ready', 'error']
|
|
199
|
+
expect(states).toContain('ready')
|
|
200
|
+
expect(states).toContain('error')
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test('state: warm reuse → skip start', () => {
|
|
204
|
+
// Given: lock exists, PID alive, health check passes
|
|
205
|
+
// When: ensureServeRunning()
|
|
206
|
+
// Then: cachedPort → health check → return (no new process)
|
|
207
|
+
const expectedPath = ['check_cache', 'health_pass', 'return_port']
|
|
208
|
+
expect(expectedPath.length).toBe(3)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test('state: dead PID → restart', () => {
|
|
212
|
+
// Given: lock exists, PID dead
|
|
213
|
+
// When: ensureServeRunning()
|
|
214
|
+
// Then: isProcessAlive → false → findFreePort → spawn → health → writeLock
|
|
215
|
+
const expectedPath = ['check_lock', 'pid_dead', 'find_port', 'spawn', 'health', 'write_lock', 'ready']
|
|
216
|
+
expect(expectedPath.length).toBe(7)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
test('state: port occupied → increment', () => {
|
|
220
|
+
// Given: base port 17878 occupied
|
|
221
|
+
// When: findFreePort(17878)
|
|
222
|
+
// Then: try 17879, 17880... until free
|
|
223
|
+
const findNext = (start: number) => start + 1
|
|
224
|
+
expect(findNext(17878)).toBe(17879)
|
|
225
|
+
expect(findNext(17879)).toBe(17880)
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
// ── Thread API paths (conceptual) ──────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
describe('Thread API — request paths', () => {
|
|
232
|
+
const PORT = 17878
|
|
233
|
+
const THREAD_ID = 'thr_test123'
|
|
234
|
+
|
|
235
|
+
test('POST /v1/threads — create thread', () => {
|
|
236
|
+
const path = `/v1/threads`
|
|
237
|
+
const url = `http://127.0.0.1:${PORT}${path}`
|
|
238
|
+
expect(path).toBe('/v1/threads')
|
|
239
|
+
expect(url).toContain(':17878')
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
test('POST /v1/threads/{id}/turns — send turn', () => {
|
|
243
|
+
const path = `/v1/threads/${THREAD_ID}/turns`
|
|
244
|
+
expect(path).toContain(THREAD_ID)
|
|
245
|
+
expect(path).toContain('/turns')
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
test('GET /v1/threads/{id}/events — SSE stream', () => {
|
|
249
|
+
const path = `/v1/threads/${THREAD_ID}/events`
|
|
250
|
+
const url = `http://127.0.0.1:${PORT}${path}?since_seq=0`
|
|
251
|
+
expect(url).toContain('since_seq=0')
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
test('GET /health — health check', () => {
|
|
255
|
+
const url = `http://127.0.0.1:${PORT}/health`
|
|
256
|
+
expect(url.endsWith('/health')).toBe(true)
|
|
257
|
+
})
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
// ── Lock file schema ────────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
describe('ServeLock schema', () => {
|
|
263
|
+
test('valid lock matches expected shape', () => {
|
|
264
|
+
const lock = {
|
|
265
|
+
pid: 12345,
|
|
266
|
+
port: 17878,
|
|
267
|
+
version: '0.8.14',
|
|
268
|
+
startedAt: '2026-05-08T05:04:46.734Z',
|
|
269
|
+
threads: { 'arena-001': 'thr_abc' },
|
|
270
|
+
}
|
|
271
|
+
expect(typeof lock.pid).toBe('number')
|
|
272
|
+
expect(typeof lock.port).toBe('number')
|
|
273
|
+
expect(typeof lock.version).toBe('string')
|
|
274
|
+
expect(typeof lock.startedAt).toBe('string')
|
|
275
|
+
expect(typeof lock.threads).toBe('object')
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
test('threads must be string→string map', () => {
|
|
279
|
+
const threads: Record<string, string> = {}
|
|
280
|
+
threads['session-a'] = 'thr_111'
|
|
281
|
+
threads['session-b'] = 'thr_222'
|
|
282
|
+
for (const [k, v] of Object.entries(threads)) {
|
|
283
|
+
expect(typeof k).toBe('string')
|
|
284
|
+
expect(typeof v).toBe('string')
|
|
285
|
+
expect(v.startsWith('thr_')).toBe(true)
|
|
286
|
+
}
|
|
287
|
+
})
|
|
288
|
+
})
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeepSeek serve-mode adapter — HTTP thread API for full agent execution.
|
|
3
|
+
*
|
|
4
|
+
* Background: `deepseek "prompt"` and `deepseek exec` are text-only (no tool
|
|
5
|
+
* execution). The `deepseek serve --http` thread API supports full agent mode
|
|
6
|
+
* with file ops, shell, web search, and subagents.
|
|
7
|
+
*
|
|
8
|
+
* Lifecycle:
|
|
9
|
+
* 1. Version check — must be in known range (0.8.x)
|
|
10
|
+
* 2. Lock file — ~/.agents/lythoskill/deepseek-serve.json (pid, port, version)
|
|
11
|
+
* 3. If serve running (PID alive) → reuse port
|
|
12
|
+
* 4. If not → start `deepseek serve --http --port <auto>` → write lock
|
|
13
|
+
* 5. HTTP thread API: create thread → send turn → collect SSE → return
|
|
14
|
+
*
|
|
15
|
+
* Per wiki: cortex/wiki/03-lessons/2026-05-06-deepseek-tui-headless-programmatic-analysis.md
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
19
|
+
import { homedir } from 'node:os'
|
|
20
|
+
import { join } from 'node:path'
|
|
21
|
+
import { spawn, type Subprocess } from 'bun'
|
|
22
|
+
import type { AgentAdapter, AgentRunResult } from '@lythos/agent-adapter'
|
|
23
|
+
import { registerAgent } from '@lythos/agent-adapter'
|
|
24
|
+
|
|
25
|
+
// ── Config ──────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
const LOCK_DIR = join(homedir(), '.agents', 'lythoskill')
|
|
28
|
+
const LOCK_FILE = join(LOCK_DIR, 'deepseek-serve.json')
|
|
29
|
+
const BASE_PORT = 17878
|
|
30
|
+
|
|
31
|
+
interface ServeLock {
|
|
32
|
+
pid: number
|
|
33
|
+
port: number
|
|
34
|
+
version: string
|
|
35
|
+
startedAt: string
|
|
36
|
+
/** Session ID → thread ID mappings. Threads can be resumed/forked across sessions. */
|
|
37
|
+
threads: Record<string, string>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── Lock file ───────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
function readLock(): ServeLock | null {
|
|
43
|
+
try {
|
|
44
|
+
if (!existsSync(LOCK_FILE)) return null
|
|
45
|
+
return JSON.parse(readFileSync(LOCK_FILE, 'utf-8'))
|
|
46
|
+
} catch {
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function writeLock(lock: ServeLock): void {
|
|
52
|
+
mkdirSync(LOCK_DIR, { recursive: true })
|
|
53
|
+
writeFileSync(LOCK_FILE, JSON.stringify(lock, null, 2) + '\n')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function updateThreadMapping(sessionId: string, threadId: string): void {
|
|
57
|
+
const lock = readLock()
|
|
58
|
+
if (!lock) return
|
|
59
|
+
lock.threads[sessionId] = threadId
|
|
60
|
+
writeLock(lock)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isProcessAlive(pid: number): boolean {
|
|
64
|
+
try {
|
|
65
|
+
process.kill(pid, 0)
|
|
66
|
+
return true
|
|
67
|
+
} catch {
|
|
68
|
+
return false
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Port finder ─────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
async function findFreePort(start: number): Promise<number> {
|
|
75
|
+
const net = await import('node:net')
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
const server = net.createServer()
|
|
78
|
+
server.listen(start, '127.0.0.1', () => {
|
|
79
|
+
const port = (server.address() as any).port
|
|
80
|
+
server.close(() => resolve(port))
|
|
81
|
+
})
|
|
82
|
+
server.on('error', () => resolve(findFreePort(start + 1)))
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Version check ───────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
async function getVersion(): Promise<string | null> {
|
|
89
|
+
try {
|
|
90
|
+
const proc = spawn({ cmd: ['deepseek', '--version'], stdout: 'pipe', stderr: 'pipe' })
|
|
91
|
+
const out = await new Response(proc.stdout).text()
|
|
92
|
+
const match = out.match(/(\d+\.\d+\.\d+)/)
|
|
93
|
+
return match ? match[1] : null
|
|
94
|
+
} catch {
|
|
95
|
+
return null
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Serve lifecycle ─────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
let cachedPort: number | null = null
|
|
102
|
+
|
|
103
|
+
async function ensureServeRunning(): Promise<number> {
|
|
104
|
+
if (cachedPort !== null) {
|
|
105
|
+
// Quick health check
|
|
106
|
+
try {
|
|
107
|
+
const res = await fetch(`http://127.0.0.1:${cachedPort}/health`, { signal: AbortSignal.timeout(2000) })
|
|
108
|
+
if (res.ok) return cachedPort
|
|
109
|
+
} catch {}
|
|
110
|
+
cachedPort = null
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Check version
|
|
114
|
+
const version = await getVersion()
|
|
115
|
+
if (!version) throw new Error('DeepSeek CLI not found. Install: https://github.com/Hmbown/deepseek-tui')
|
|
116
|
+
if (!version.startsWith('0.8.')) {
|
|
117
|
+
console.warn(`⚠️ DeepSeek version ${version} — tested on 0.8.14. May behave differently.`)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Check lock file
|
|
121
|
+
const lock = readLock()
|
|
122
|
+
if (lock && isProcessAlive(lock.pid)) {
|
|
123
|
+
// Verify health
|
|
124
|
+
try {
|
|
125
|
+
const res = await fetch(`http://127.0.0.1:${lock.port}/health`, { signal: AbortSignal.timeout(2000) })
|
|
126
|
+
if (res.ok) {
|
|
127
|
+
cachedPort = lock.port
|
|
128
|
+
return lock.port
|
|
129
|
+
}
|
|
130
|
+
} catch {}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Start new serve instance
|
|
134
|
+
const port = await findFreePort(BASE_PORT)
|
|
135
|
+
console.log(`🔧 Starting DeepSeek serve on port ${port}...`)
|
|
136
|
+
|
|
137
|
+
const proc = spawn({
|
|
138
|
+
cmd: ['deepseek', 'serve', '--http', '--port', String(port)],
|
|
139
|
+
stdout: 'pipe',
|
|
140
|
+
stderr: 'pipe',
|
|
141
|
+
stdin: 'ignore',
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
// Wait for serve to be ready
|
|
145
|
+
for (let i = 0; i < 30; i++) {
|
|
146
|
+
try {
|
|
147
|
+
const res = await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(1000) })
|
|
148
|
+
if (res.ok) {
|
|
149
|
+
writeLock({ pid: proc.pid, port, version, startedAt: new Date().toISOString(), threads: {} })
|
|
150
|
+
cachedPort = port
|
|
151
|
+
return port
|
|
152
|
+
}
|
|
153
|
+
} catch {}
|
|
154
|
+
await new Promise(r => setTimeout(r, 500))
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
throw new Error(`DeepSeek serve failed to start on port ${port}`)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── Thread API ──────────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
interface DeepSeekThread {
|
|
163
|
+
id: string
|
|
164
|
+
workspace: string
|
|
165
|
+
mode: string
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
interface DeepSeekEvent {
|
|
169
|
+
seq: number
|
|
170
|
+
event: string
|
|
171
|
+
payload: { delta?: string; kind?: string }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function collectThreadOutput(threadId: string, port: number, timeoutMs: number): Promise<string> {
|
|
175
|
+
const deadline = Date.now() + timeoutMs
|
|
176
|
+
let output = ''
|
|
177
|
+
|
|
178
|
+
while (Date.now() < deadline) {
|
|
179
|
+
// Check if turn is complete
|
|
180
|
+
const threadRes = await fetch(
|
|
181
|
+
`http://127.0.0.1:${port}/v1/threads/${threadId}`,
|
|
182
|
+
{ signal: AbortSignal.timeout(3000) }
|
|
183
|
+
).catch(() => null)
|
|
184
|
+
if (!threadRes?.ok) { await new Promise(r => setTimeout(r, 2000)); continue }
|
|
185
|
+
|
|
186
|
+
const thread = await threadRes.json()
|
|
187
|
+
const turnId = thread.thread?.latest_turn_id ?? thread.latest_turn_id
|
|
188
|
+
if (!turnId) { await new Promise(r => setTimeout(r, 2000)); continue }
|
|
189
|
+
|
|
190
|
+
// Collect all events so far
|
|
191
|
+
const eventsRes = await fetch(
|
|
192
|
+
`http://127.0.0.1:${port}/v1/threads/${threadId}/events?since_seq=0`,
|
|
193
|
+
{ signal: AbortSignal.timeout(5000) }
|
|
194
|
+
).catch(() => null)
|
|
195
|
+
if (!eventsRes?.ok) { await new Promise(r => setTimeout(r, 2000)); continue }
|
|
196
|
+
|
|
197
|
+
const text = await eventsRes.text()
|
|
198
|
+
let completed = false
|
|
199
|
+
output = ''
|
|
200
|
+
for (const line of text.split('\n')) {
|
|
201
|
+
if (line.startsWith('data: ')) {
|
|
202
|
+
try {
|
|
203
|
+
const event: DeepSeekEvent = JSON.parse(line.slice(6))
|
|
204
|
+
if (event.payload?.delta) output += event.payload.delta
|
|
205
|
+
if (event.event === 'turn.completed') completed = true
|
|
206
|
+
} catch {}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (completed && output) return output
|
|
210
|
+
|
|
211
|
+
await new Promise(r => setTimeout(r, 2000))
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return output || '(timeout)'
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── Session tracking ────────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
let sessionCounter = 0
|
|
220
|
+
|
|
221
|
+
function nextSessionId(): string {
|
|
222
|
+
sessionCounter++
|
|
223
|
+
const ts = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 15)
|
|
224
|
+
return `arena-${ts}-${String(sessionCounter).padStart(3, '0')}`
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── Adapter ─────────────────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
const deepseekServeAdapter: AgentAdapter = {
|
|
230
|
+
name: 'deepseek',
|
|
231
|
+
|
|
232
|
+
async spawn(opts): Promise<AgentRunResult> {
|
|
233
|
+
const startTime = Date.now()
|
|
234
|
+
const port = await ensureServeRunning()
|
|
235
|
+
const sessionId = nextSessionId()
|
|
236
|
+
|
|
237
|
+
// Create thread
|
|
238
|
+
const threadRes = await fetch(`http://127.0.0.1:${port}/v1/threads`, {
|
|
239
|
+
method: 'POST',
|
|
240
|
+
headers: { 'Content-Type': 'application/json' },
|
|
241
|
+
body: JSON.stringify({
|
|
242
|
+
workspace: opts.cwd,
|
|
243
|
+
mode: 'yolo',
|
|
244
|
+
auto_approve: true,
|
|
245
|
+
allow_shell: true,
|
|
246
|
+
}),
|
|
247
|
+
})
|
|
248
|
+
if (!threadRes.ok) {
|
|
249
|
+
throw new Error(`Failed to create thread: HTTP ${threadRes.status}`)
|
|
250
|
+
}
|
|
251
|
+
const thread: DeepSeekThread = await threadRes.json()
|
|
252
|
+
updateThreadMapping(sessionId, thread.id)
|
|
253
|
+
|
|
254
|
+
// Send turn
|
|
255
|
+
const turnRes = await fetch(`http://127.0.0.1:${port}/v1/threads/${thread.id}/turns`, {
|
|
256
|
+
method: 'POST',
|
|
257
|
+
headers: { 'Content-Type': 'application/json' },
|
|
258
|
+
body: JSON.stringify({ prompt: opts.brief }),
|
|
259
|
+
})
|
|
260
|
+
if (!turnRes.ok) {
|
|
261
|
+
throw new Error(`Failed to send turn: HTTP ${turnRes.status}`)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Collect output
|
|
265
|
+
const stdout = await collectThreadOutput(thread.id, port, opts.timeoutMs)
|
|
266
|
+
const durationMs = Date.now() - startTime
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
stdout,
|
|
270
|
+
stderr: '',
|
|
271
|
+
code: 0,
|
|
272
|
+
durationMs,
|
|
273
|
+
checkpoints: [],
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
registerAgent('deepseek', deepseekServeAdapter)
|
|
279
|
+
|
|
280
|
+
export { deepseekServeAdapter, ensureServeRunning }
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// ── @lythos/agent-adapter-deepseek-serve — DeepSeek serve-mode adapter ─
|
|
2
|
+
//
|
|
3
|
+
// Daemon lifecycle: start/stop/reuse deepseek serve --http process.
|
|
4
|
+
// Uses HTTP thread API for full agent execution with file ops, shell, subagents.
|
|
5
|
+
//
|
|
6
|
+
// Self-registers on import:
|
|
7
|
+
// import '@lythos/agent-adapter-deepseek-serve'
|
|
8
|
+
// import { useAgent } from '@lythos/agent-adapter'
|
|
9
|
+
// const agent = useAgent('deepseek')
|
|
10
|
+
|
|
11
|
+
export { deepseekServeAdapter, ensureServeRunning } from './deepseek-serve'
|