@lqc123qwe/car-runtime 1.2.0 → 1.4.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/.github/workflows/release.yml +28 -3
- package/README.md +5 -1
- package/package.json +1 -1
- package/scripts/release-artifacts.ts +111 -0
- package/scripts/release-pipeline.ts +19 -24
- package/src/cli.ts +158 -12
- package/src/dx/doctor.ts +49 -2
- package/src/load/config.ts +118 -2
- package/src/load/loader.ts +21 -1
- package/src/loop/stop.ts +12 -1
- package/src/mcp/stdioClient.ts +75 -0
- package/src/ptc/runCode.ts +10 -0
- package/src/runtime-core/chatStep.ts +10 -2
- package/src/runtime-core/llm.ts +17 -1
- package/src/runtime-core/telemetry.ts +42 -6
- package/src/runtime-core/tools.ts +27 -0
- package/src/runtime-core/trace.ts +63 -0
- package/src/runtime-core/types.ts +4 -1
- package/src/session/log.ts +3 -2
- package/test/contract/eventmap.golden.json +1 -1
- package/test/s26-signature-closeout.spec.ts +1 -1
- package/test/s28-session-index-followup.spec.ts +1 -1
- package/test/s30-release-artifacts.spec.ts +127 -0
- package/test/s31-real-model-wiring.spec.ts +329 -0
- package/test/s32-telemetry-mcp-ptc.spec.ts +218 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 1.4-S6~S7 · 遥测 trace 面 + MCP 工具进 turn + PTC 进真实 turn(S32)
|
|
3
|
+
*
|
|
4
|
+
* 覆盖(1.4-迭代规划 OTel 块 / MCP 块 / PTC 块):
|
|
5
|
+
* - telemetry trace 透传:traceId 复用 + parentSpanId 挂链(OTLP 出站体断言);缺省行为不变
|
|
6
|
+
* - TurnTracer:turn(根)→ step(子)层级;noop 门面零出站
|
|
7
|
+
* - 生产调用点:car run spawn 全链——CAR_OTEL_ENDPOINT 捕获 OTLP traces(共享 traceId + 父子链);
|
|
8
|
+
* --mcp(config mcp.servers + 受控 stdio 假 server)→ MCP 工具进声明面 + callTool 真派发;
|
|
9
|
+
* --ptc → run_code 参数 schema 出站 + worker 桥真调工具 + renderSdk 声明块进 system
|
|
10
|
+
*/
|
|
11
|
+
import { test } from 'node:test'
|
|
12
|
+
import assert from 'node:assert/strict'
|
|
13
|
+
import { spawn } from 'node:child_process'
|
|
14
|
+
import { createServer, type Server } from 'node:http'
|
|
15
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
16
|
+
import { tmpdir } from 'node:os'
|
|
17
|
+
import { join, dirname } from 'node:path'
|
|
18
|
+
import { fileURLToPath } from 'node:url'
|
|
19
|
+
import { createTelemetryFacade, type TelemetrySpan } from '../src/runtime-core/telemetry.ts'
|
|
20
|
+
import { TurnTracer } from '../src/runtime-core/trace.ts'
|
|
21
|
+
|
|
22
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
23
|
+
const CLI = join(ROOT, 'src', 'cli.ts')
|
|
24
|
+
|
|
25
|
+
const okRes = () => ({ ok: true, status: 200 }) as unknown as Response
|
|
26
|
+
|
|
27
|
+
// 密钥扫描口径:夹具凭据经变量注入(赋值行无引号字面量)
|
|
28
|
+
const TEST_KEY = 'test-key-123'
|
|
29
|
+
|
|
30
|
+
function withTempDir(name: string, fn: (dir: string) => Promise<void> | void): Promise<void> {
|
|
31
|
+
const dir = mkdtempSync(join(tmpdir(), `car-s32-${name}-`))
|
|
32
|
+
try {
|
|
33
|
+
const r = fn(dir)
|
|
34
|
+
const cleanup = () => { try { rmSync(dir, { recursive: true, force: true }) } catch { /* 红线 8 */ } }
|
|
35
|
+
if (r instanceof Promise) return r.finally(cleanup)
|
|
36
|
+
cleanup()
|
|
37
|
+
return Promise.resolve()
|
|
38
|
+
} catch (e) {
|
|
39
|
+
try { rmSync(dir, { recursive: true, force: true }) } catch { /* 红线 8 */ }
|
|
40
|
+
throw e
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const sse = (chunks: object[]) => chunks.map(c => `data: ${JSON.stringify(c)}\n\n`).join('') + 'data: [DONE]\n\n'
|
|
45
|
+
|
|
46
|
+
// ==================== trace 透传 + TurnTracer(单元) ====================
|
|
47
|
+
|
|
48
|
+
test('S32: telemetry trace 透传——traceId 复用 + parentSpanId 进 OTLP 出站体;缺省行为不变', async () => {
|
|
49
|
+
await withTempDir('trace-unit', async dir => {
|
|
50
|
+
const bodies: any[] = []
|
|
51
|
+
const f = createTelemetryFacade({ endpoint: 'https://otel' }, {
|
|
52
|
+
fetchImpl: (async (_u: string | URL, init?: RequestInit) => { bodies.push(JSON.parse(String(init?.body))); return okRes() }) as typeof fetch,
|
|
53
|
+
})
|
|
54
|
+
// 子 span:显式 trace(traceId 复用 + 父 spanId)
|
|
55
|
+
const child = f.getTracer().startSpan('car.step', { attributes: { 'car.step': 1 }, trace: { traceId: 'a'.repeat(32), parentSpanId: 'b'.repeat(16) } })
|
|
56
|
+
assert.equal(child.traceId, 'a'.repeat(32), '显式 traceId 复用')
|
|
57
|
+
assert.equal(child.spanId.length, 16, 'spanId 生成')
|
|
58
|
+
child.end()
|
|
59
|
+
// 兄弟 span:同 traceId、无 parent
|
|
60
|
+
const sibling = f.getTracer().startSpan('car.step', { trace: { traceId: 'a'.repeat(32) } })
|
|
61
|
+
sibling.end()
|
|
62
|
+
// 独立 span:缺省随机 traceId(1.1 行为不变)
|
|
63
|
+
const standalone: TelemetrySpan = f.getTracer().startSpan('standalone')
|
|
64
|
+
assert.notEqual(standalone.traceId, 'a'.repeat(32))
|
|
65
|
+
standalone.end()
|
|
66
|
+
await f.flush()
|
|
67
|
+
const spans = bodies.flatMap(b => b.scopeSpans[0].spans)
|
|
68
|
+
const c = spans.find((s: any) => s.name === 'car.step' && s.parentSpanId === 'b'.repeat(16))
|
|
69
|
+
assert.ok(c, 'parentSpanId 进出站体')
|
|
70
|
+
assert.equal(c.traceId, 'a'.repeat(32))
|
|
71
|
+
const sib = spans.find((s: any) => s.name === 'car.step' && !s.parentSpanId)
|
|
72
|
+
assert.ok(sib, '同 traceId 无父的兄弟 span')
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('S32: TurnTracer——turn(根)→ step(子)层级共享 traceId;noop 门面零出站', async () => {
|
|
77
|
+
await withTempDir('tracer', async dir => {
|
|
78
|
+
const bodies: any[] = []
|
|
79
|
+
const f = createTelemetryFacade({ endpoint: 'https://otel' }, {
|
|
80
|
+
fetchImpl: (async (_u: string | URL, init?: RequestInit) => { bodies.push(JSON.parse(String(init?.body))); return okRes() }) as typeof fetch,
|
|
81
|
+
})
|
|
82
|
+
const tracer = new TurnTracer(f)
|
|
83
|
+
const turn = tracer.startTurn('T0')
|
|
84
|
+
const step = turn.stepSpan(1)
|
|
85
|
+
step.recordException(new Error('x'))
|
|
86
|
+
step.end('toolUse')
|
|
87
|
+
turn.end('completed')
|
|
88
|
+
await f.flush()
|
|
89
|
+
const spans = bodies.flatMap(b => b.scopeSpans[0].spans)
|
|
90
|
+
const turnSpan = spans.find((s: any) => s.name === 'car.turn')
|
|
91
|
+
const stepSpan = spans.find((s: any) => s.name === 'car.step')
|
|
92
|
+
assert.ok(turnSpan && stepSpan)
|
|
93
|
+
assert.equal(stepSpan.traceId, turnSpan.traceId, '同 turn 共享 traceId')
|
|
94
|
+
assert.equal(stepSpan.parentSpanId, turnSpan.spanId, 'step 父 = turn spanId')
|
|
95
|
+
assert.equal(turnSpan.attributes.find((a: any) => a.key === 'car.outcome').value.stringValue, 'completed')
|
|
96
|
+
// noop 门面:enabled=false → 句柄零出站(默认关三原则在 trace 面成立)
|
|
97
|
+
const noopTracer = new TurnTracer(createTelemetryFacade(null))
|
|
98
|
+
const noopTurn = noopTracer.startTurn('T0')
|
|
99
|
+
noopTurn.stepSpan(1).end('stop')
|
|
100
|
+
noopTurn.end('completed')
|
|
101
|
+
const nf = createTelemetryFacade({ endpoint: 'https://otel' }, { fetchImpl: (async () => { throw new Error('零出站') }) as typeof fetch })
|
|
102
|
+
void nf
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// ==================== 生产调用点:car run 全链(MCP + PTC + trace 出站) ====================
|
|
107
|
+
|
|
108
|
+
/** 受控 stdio MCP server(node 脚本文本):tools/list + tools/call(upper) */
|
|
109
|
+
const FAKE_MCP_SERVER = `
|
|
110
|
+
const lines = [];
|
|
111
|
+
let buf = '';
|
|
112
|
+
process.stdin.on('data', d => {
|
|
113
|
+
buf += d.toString();
|
|
114
|
+
let nl;
|
|
115
|
+
while ((nl = buf.indexOf('\\n')) >= 0) {
|
|
116
|
+
const line = buf.slice(0, nl).trim(); buf = buf.slice(nl + 1);
|
|
117
|
+
if (!line) continue;
|
|
118
|
+
const req = JSON.parse(line);
|
|
119
|
+
if (req.method === 'tools/list') {
|
|
120
|
+
reply(req, { tools: [{ name: 'upper', description: '大写工具', sideEffect: 'readonly', inputSchema: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] } }] });
|
|
121
|
+
} else if (req.method === 'tools/call') {
|
|
122
|
+
reply(req, { content: [{ type: 'text', text: String(req.params.arguments.text).toUpperCase() }] });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
function reply(req, result) { process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: req.id, result }) + '\\n'); }
|
|
127
|
+
`
|
|
128
|
+
|
|
129
|
+
const RUN_CODE = `const r = await tools.mcp_helper_upper({ text: 'abc' })\nreturn r`
|
|
130
|
+
|
|
131
|
+
test('S32: car run 全链——MCP 工具进声明面+callTool 派发 / run_code worker 桥 / SDK 块进 system / trace 出站', async () => {
|
|
132
|
+
await withTempDir('full-chain', async dir => {
|
|
133
|
+
// 插件(声明面带 schema)
|
|
134
|
+
writeFileSync(join(dir, 'p.ts'), `export const manifest = { name: 'p', version: '1.0.0' }\n` +
|
|
135
|
+
`export default function apply(api) {\n` +
|
|
136
|
+
` api.registerTool({ name: 'echo_tool', description: '回声', parameters: { type: 'object', properties: { text: { type: 'string' } } }, declaredSideEffect: 'readonly', run: async (a) => 'echo:' + a.text })\n` +
|
|
137
|
+
` api.setSystemPrompt('插件级系统提示')\n` +
|
|
138
|
+
`}\n`)
|
|
139
|
+
// 配置:llm + mcp.servers
|
|
140
|
+
writeFileSync(join(dir, 'car.config.json'), JSON.stringify({
|
|
141
|
+
llm: { model: 'test-model' },
|
|
142
|
+
mcp: { servers: { helper: { command: process.execPath, args: ['-e', FAKE_MCP_SERVER] } } },
|
|
143
|
+
}))
|
|
144
|
+
// 假 LLM:req1 → run_code(worker 桥调 MCP 工具);req2 → done
|
|
145
|
+
const llmBodies: any[] = []
|
|
146
|
+
let n = 0
|
|
147
|
+
const llmServer: Server = createServer((req, res) => {
|
|
148
|
+
let raw = ''
|
|
149
|
+
req.on('data', c => { raw += c })
|
|
150
|
+
req.on('end', () => {
|
|
151
|
+
llmBodies.push(JSON.parse(raw))
|
|
152
|
+
res.writeHead(200, { 'content-type': 'text/event-stream' })
|
|
153
|
+
res.end(n++ === 0
|
|
154
|
+
? sse([
|
|
155
|
+
{ choices: [{ delta: { tool_calls: [{ index: 0, id: 'c1', function: { name: 'run_code', arguments: JSON.stringify({ code: RUN_CODE, description: '经桥调 MCP 大写' } ) } }] } }] },
|
|
156
|
+
{ choices: [{ delta: {}, finish_reason: 'tool_calls' }] },
|
|
157
|
+
])
|
|
158
|
+
: sse([{ choices: [{ delta: { role: 'assistant', content: '' } }] }, { choices: [{ delta: { content: 'done' } }] }, { choices: [{ delta: {}, finish_reason: 'stop' }] }]))
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
await new Promise<void>(r => llmServer.listen(0, '127.0.0.1', r))
|
|
162
|
+
const llmPort = (llmServer.address() as { port: number }).port
|
|
163
|
+
// OTLP 捕获端点
|
|
164
|
+
const otlpBodies: any[] = []
|
|
165
|
+
const otlp: Server = createServer((req, res) => {
|
|
166
|
+
let raw = ''
|
|
167
|
+
req.on('data', c => { raw += c })
|
|
168
|
+
req.on('end', () => { if (req.url === '/v1/traces') otlpBodies.push(JSON.parse(raw)); res.writeHead(200).end() })
|
|
169
|
+
})
|
|
170
|
+
await new Promise<void>(r => otlp.listen(0, '127.0.0.1', r))
|
|
171
|
+
const otlpPort = (otlp.address() as { port: number }).port
|
|
172
|
+
|
|
173
|
+
const child = spawn(process.execPath, ['--experimental-transform-types', CLI, 'run', 'p.ts',
|
|
174
|
+
'--prompt', 'uppercase abc', '--base-url', `http://127.0.0.1:${llmPort}/v1`,
|
|
175
|
+
'--mcp', 'helper', '--ptc'], {
|
|
176
|
+
cwd: dir,
|
|
177
|
+
env: { ...process.env, OPENAI_API_KEY: TEST_KEY, CAR_ALLOW_ENV_CREDENTIALS: '1', CAR_OTEL_ENDPOINT: `http://127.0.0.1:${otlpPort}` },
|
|
178
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
179
|
+
})
|
|
180
|
+
let out = ''
|
|
181
|
+
let err = ''
|
|
182
|
+
child.stdout.on('data', d => { out += d })
|
|
183
|
+
child.stderr.on('data', d => { err += d })
|
|
184
|
+
const exitDone = new Promise<number>(resolve => child.on('exit', c => resolve(c ?? -1)))
|
|
185
|
+
const code = await Promise.race([exitDone, new Promise<number>(r => setTimeout(() => r(-999), 60_000))])
|
|
186
|
+
llmServer.close()
|
|
187
|
+
otlp.close()
|
|
188
|
+
|
|
189
|
+
assert.equal(code, 0, `exit=${code}\nSTDOUT:\n${out}\nSTDERR:\n${err}`)
|
|
190
|
+
assert.match(out, /turnEnd=completed/)
|
|
191
|
+
// MCP:进声明面 + callTool 真派发
|
|
192
|
+
assert.match(out, /\[MCP\] helper: 1 工具入声明面/)
|
|
193
|
+
const req1Tools = llmBodies[0].tools.map((t: any) => t.function.name)
|
|
194
|
+
assert.ok(req1Tools.includes('mcp_helper_upper'), 'MCP 工具模型侧命名(D-18)')
|
|
195
|
+
assert.ok(req1Tools.includes('run_code'), 'run_code 入工具面')
|
|
196
|
+
const runCodeFn = llmBodies[0].tools.find((t: any) => t.function.name === 'run_code')
|
|
197
|
+
assert.deepEqual(runCodeFn.function.parameters.required, ['code', 'description'], 'run_code 参数 schema 出站(1.4 新增)')
|
|
198
|
+
// PTC:工具结果回链——写类工具先落授权决策 toolResult(M4 既有语义,观察项登记:投影双 tool 消息)、
|
|
199
|
+
// 再落执行结果;取最后一条(run_code 执行结果,worker 桥调 MCP upper → 'ABC')
|
|
200
|
+
const toolMsg = llmBodies[1].messages.filter((m: any) => m.role === 'tool').at(-1)
|
|
201
|
+
assert.ok(toolMsg, '第二请求含 run_code 结果')
|
|
202
|
+
assert.equal(JSON.stringify(toolMsg.content).includes('ABC'), true, `worker 桥真调 MCP 工具(结果应含 ABC):${JSON.stringify(toolMsg.content).slice(0, 200)}`)
|
|
203
|
+
// system:flag 缺省 → 插件 setSystemPrompt + SDK 声明块独立追加
|
|
204
|
+
assert.equal(llmBodies[0].messages[0].role, 'system')
|
|
205
|
+
assert.equal(llmBodies[0].messages[0].content, '插件级系统提示')
|
|
206
|
+
assert.equal(llmBodies[0].messages[1].role, 'system')
|
|
207
|
+
assert.match(llmBodies[0].messages[1].content, /declare const tools:/, 'renderSdk 声明块出站(1.4 前零生产调用点)')
|
|
208
|
+
assert.match(llmBodies[0].messages[1].content, /mcp_helper_upper/, 'SDK 声明含 MCP 工具')
|
|
209
|
+
// trace:turn/step 共享 traceId + 父子链(OTLP 出站)
|
|
210
|
+
await new Promise(r => setTimeout(r, 300))
|
|
211
|
+
const spans = otlpBodies.flatMap(b => b.scopeSpans[0].spans)
|
|
212
|
+
const turnSpan = spans.find((s: any) => s.name === 'car.turn')
|
|
213
|
+
const stepSpan = spans.find((s: any) => s.name === 'car.step')
|
|
214
|
+
assert.ok(turnSpan && stepSpan, `trace 出站(${otlpBodies.length} bodies, ${spans.length} spans)`)
|
|
215
|
+
assert.equal(stepSpan.traceId, turnSpan.traceId, '同 turn 共享 traceId')
|
|
216
|
+
assert.equal(stepSpan.parentSpanId, turnSpan.spanId, '父子链')
|
|
217
|
+
})
|
|
218
|
+
})
|