mirror of
https://github.com/zzhzz/dsh-ask-peer.git
synced 2026-09-29 17:08:04 +08:00
239 lines
8.3 KiB
JavaScript
239 lines
8.3 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Minimal DeepSeek-compatible streaming chat-completions mock for keyless
|
|
* dsh-ask-peer smoke tests. Not a general-purpose test server.
|
|
*
|
|
* Roles (MOCK_ROLE):
|
|
* answerer (default) — answers with MOCK_ANSWER text. With
|
|
* MOCK_ATTEMPT_WRITE=1, the first real turn per session
|
|
* emits a `bash` call that tries to write a file, so
|
|
* the test can verify the answering agent's read-only
|
|
* sandbox denies it. With MOCK_ASK_LOOP=1 it also emits
|
|
* an `ask_peer` call, so the test can verify answering
|
|
* agents are denied ask tools (no recursive asks).
|
|
* asker — drives a full model round trip: peers_list, then
|
|
* ask_peers (peers from MOCK_ASK_PEERS), then a final
|
|
* text summarizing both tool results.
|
|
*/
|
|
|
|
import { createServer } from 'node:http'
|
|
|
|
const PORT = Number(process.env.MOCK_PORT ?? 9001)
|
|
const ROLE = process.env.MOCK_ROLE ?? 'answerer'
|
|
const ANSWER =
|
|
process.env.MOCK_ANSWER ??
|
|
'Answer: run `make dev` with NODE_ENV=development to stand up the environment.'
|
|
const ASK_PEERS = (process.env.MOCK_ASK_PEERS ?? 'bob,carol').split(',').map((s) => s.trim())
|
|
const RECOMMEND_PEER = process.env.MOCK_RECOMMEND_PEER ?? 'carol'
|
|
const RECOMMEND_TOPIC = process.env.MOCK_RECOMMEND_TOPIC ?? 'dev environment'
|
|
const ASKER_PAUSE_MS = Number(process.env.MOCK_ASKER_PAUSE_MS ?? 6000)
|
|
const DEFAULT_QUESTION = process.env.MOCK_ASK_QUESTION ?? 'How do I stand up the dev environment?'
|
|
const ATTEMPT_WRITE = process.env.MOCK_ATTEMPT_WRITE === '1'
|
|
const ASK_LOOP = process.env.MOCK_ASK_LOOP === '1'
|
|
const MAX_BODY = 4 * 1024 * 1024
|
|
|
|
const attemptedWrites = new Set()
|
|
const askerSteps = new Map()
|
|
|
|
function readBody(req) {
|
|
return new Promise((resolve, reject) => {
|
|
const chunks = []
|
|
let size = 0
|
|
req.on('data', (chunk) => {
|
|
size += chunk.length
|
|
if (size > MAX_BODY) {
|
|
reject(new Error('mock: request body too large'))
|
|
req.destroy()
|
|
return
|
|
}
|
|
chunks.push(chunk)
|
|
})
|
|
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
req.on('error', reject)
|
|
})
|
|
}
|
|
|
|
function ssePayload(res, chunk) {
|
|
res.write(`data: ${JSON.stringify(chunk)}\n\n`)
|
|
}
|
|
|
|
function usageChunk() {
|
|
return {
|
|
usage: {
|
|
prompt_tokens: 12,
|
|
completion_tokens: 8,
|
|
total_tokens: 20,
|
|
prompt_cache_hit_tokens: 0,
|
|
prompt_cache_miss_tokens: 12,
|
|
},
|
|
}
|
|
}
|
|
|
|
function finish(res) {
|
|
res.write('data: [DONE]\n\n')
|
|
res.end()
|
|
}
|
|
|
|
function respondText(res, text, thinking) {
|
|
ssePayload(res, {
|
|
choices: [{
|
|
delta: { role: 'assistant', content: '', ...(thinking ? { reasoning_content: '' } : {}) },
|
|
}],
|
|
})
|
|
if (thinking) {
|
|
ssePayload(res, { choices: [{ delta: { reasoning_content: 'I will answer this question directly. ' } }] })
|
|
}
|
|
ssePayload(res, { choices: [{ delta: { content: text } }] })
|
|
ssePayload(res, { choices: [{ delta: {}, finish_reason: 'stop' }] })
|
|
ssePayload(res, usageChunk())
|
|
finish(res)
|
|
}
|
|
|
|
function respondToolCall(res, toolName, args, thinking) {
|
|
respondToolCalls(res, [{ name: toolName, args }], thinking)
|
|
}
|
|
|
|
function respondToolCalls(res, calls, thinking) {
|
|
ssePayload(res, {
|
|
choices: [{
|
|
delta: { role: 'assistant', content: '', ...(thinking ? { reasoning_content: '' } : {}) },
|
|
}],
|
|
})
|
|
if (thinking) {
|
|
ssePayload(res, {
|
|
choices: [{ delta: { reasoning_content: 'I should use tools for this. ' } }],
|
|
})
|
|
}
|
|
const firsts = calls.map((call, index) => {
|
|
const mid = Math.floor(call.args.length / 2)
|
|
return {
|
|
index,
|
|
id: `call_mock_${index}`,
|
|
type: 'function',
|
|
function: { name: call.name, arguments: call.args.slice(0, mid) },
|
|
}
|
|
})
|
|
const seconds = calls.map((call, index) => {
|
|
const mid = Math.floor(call.args.length / 2)
|
|
return { index, function: { arguments: call.args.slice(mid) } }
|
|
})
|
|
ssePayload(res, {
|
|
choices: [{
|
|
delta: { content: '', tool_calls: firsts },
|
|
}],
|
|
})
|
|
ssePayload(res, { choices: [{ delta: { tool_calls: seconds } }] })
|
|
ssePayload(res, { choices: [{ delta: {}, finish_reason: 'tool_calls' }] })
|
|
ssePayload(res, usageChunk())
|
|
finish(res)
|
|
}
|
|
|
|
const server = createServer((req, res) => {
|
|
void (async () => {
|
|
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
if (req.method !== 'POST' || !url.pathname.endsWith('/chat/completions')) {
|
|
res.writeHead(404, { 'content-type': 'application/json' })
|
|
res.end(JSON.stringify({ error: { message: `mock: unknown path ${url.pathname}` } }))
|
|
return
|
|
}
|
|
|
|
let body
|
|
try {
|
|
body = JSON.parse(await readBody(req))
|
|
} catch (error) {
|
|
res.writeHead(400, { 'content-type': 'application/json' })
|
|
res.end(JSON.stringify({ error: { message: `mock: bad request: ${String(error)}` } }))
|
|
return
|
|
}
|
|
|
|
const session = req.headers['x-deepseek-harness-session-id'] ?? 'anon'
|
|
const thinking = body.thinking?.type === 'enabled'
|
|
const messages = Array.isArray(body.messages) ? body.messages : []
|
|
const hasTools = Array.isArray(body.tools) && body.tools.length > 0
|
|
const hasAskTools =
|
|
hasTools &&
|
|
body.tools.some(
|
|
(tool) =>
|
|
tool.function?.name === 'ask_peer' ||
|
|
tool.function?.name === 'ask_peers' ||
|
|
tool.function?.name === 'peers_list',
|
|
)
|
|
|
|
res.writeHead(200, {
|
|
'content-type': 'text/event-stream',
|
|
'cache-control': 'no-cache',
|
|
connection: 'keep-alive',
|
|
})
|
|
|
|
// Asker role: peers_list -> recommend_peer -> ask_peers -> summarize.
|
|
if (ROLE === 'asker' && hasAskTools) {
|
|
const step = askerSteps.get(session) ?? 0
|
|
askerSteps.set(session, step + 1)
|
|
const lastUser = [...messages].reverse().find((message) => message.role === 'user')
|
|
const question =
|
|
typeof lastUser?.content === 'string' && lastUser.content.trim().length > 0
|
|
? lastUser.content.trim()
|
|
: DEFAULT_QUESTION
|
|
if (step === 0) {
|
|
respondToolCall(res, 'peers_list', '{}', thinking)
|
|
return
|
|
}
|
|
if (step === 1) {
|
|
respondToolCall(
|
|
res,
|
|
'recommend_peer',
|
|
JSON.stringify({ peer: RECOMMEND_PEER, topic: RECOMMEND_TOPIC }),
|
|
thinking,
|
|
)
|
|
return
|
|
}
|
|
if (step === 2) {
|
|
// Keep the asker's process alive briefly so the test can observe its
|
|
// pending recommendation and run the decision flow mid-task.
|
|
await new Promise((resolve) => setTimeout(resolve, ASKER_PAUSE_MS))
|
|
respondToolCall(res, 'ask_peers', JSON.stringify({ peers: ASK_PEERS, question }), thinking)
|
|
return
|
|
}
|
|
const toolResults = messages.filter((message) => message.role === 'tool').map((message) => message.content)
|
|
respondText(
|
|
res,
|
|
`Roster from peers_list:\n${toolResults[0] ?? '(no roster)'}\n\nRecommendation:\n${
|
|
toolResults[1] ?? '(no recommendation)'
|
|
}\n\nAnswers from ask_peers:\n${toolResults[2] ?? toolResults[1] ?? '(no answers)'}`,
|
|
thinking,
|
|
)
|
|
return
|
|
}
|
|
|
|
// Answerer role: the first real turn may attempt a workspace write (proves
|
|
// the read-only sandbox) and/or a recursive ask_peer (proves ask tools are
|
|
// denied on answering agents).
|
|
if (ROLE === 'answerer' && hasTools && !attemptedWrites.has(session)) {
|
|
attemptedWrites.add(session)
|
|
const calls = []
|
|
if (ATTEMPT_WRITE) {
|
|
calls.push({ name: 'bash', args: JSON.stringify({ command: 'echo pwned > pwned.txt' }) })
|
|
}
|
|
if (ASK_LOOP) {
|
|
calls.push({ name: 'ask_peer', args: JSON.stringify({ peer: 'ada', question: 'recursion probe' }) })
|
|
calls.push({ name: 'ask_peer_async', args: JSON.stringify({ peer: 'ada', question: 'recursion probe async' }) })
|
|
}
|
|
if (calls.length > 0) {
|
|
respondToolCalls(res, calls, thinking)
|
|
return
|
|
}
|
|
}
|
|
|
|
const question = [...messages].reverse().find((message) => message.role === 'user')?.content ?? ''
|
|
respondText(res, `Answer to "${question}": ${ANSWER}`, thinking)
|
|
})().catch((error) => {
|
|
console.error('[mock-llm]', error)
|
|
if (!res.headersSent) res.writeHead(500, { 'content-type': 'application/json' })
|
|
if (!res.writableEnded) res.end()
|
|
})
|
|
})
|
|
|
|
server.listen(PORT, '127.0.0.1', () => {
|
|
console.log(`[mock-llm] role=${ROLE} listening on 127.0.0.1:${PORT}`)
|
|
})
|