@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.
@@ -27,8 +27,27 @@ export interface SandboxSigConfig {
27
27
  sigTrustRoot?: string
28
28
  }
29
29
 
30
+ export interface LlmConfig {
31
+ baseUrl?: string
32
+ model?: string
33
+ adapterId?: string
34
+ maxTokens?: number
35
+ allowEnvFallback?: boolean
36
+ }
37
+
38
+ export interface McpServerSpec {
39
+ command: string
40
+ args?: string[]
41
+ /** 启动环境变量(禁明文凭据——gateway.containsPlaintextCredential 注册时强制检查) */
42
+ env?: Record<string, string>
43
+ }
44
+
30
45
  export interface CarConfig {
31
46
  sandbox?: SandboxSigConfig
47
+ /** 1.4-S3(D-16):模型接入配置键位空间;凭据禁入(CR-05)——只放端点/模型名/开关 */
48
+ llm?: LlmConfig
49
+ /** 1.4-S7(D-18):MCP server 登记面(--mcp <id> 显式启用;serverId → stdio 启动规格) */
50
+ mcp?: { servers: Record<string, McpServerSpec> }
32
51
  }
33
52
 
34
53
  export interface ConfigLoadResult {
@@ -68,8 +87,77 @@ function validate(raw: unknown): { config: CarConfig; error?: string } {
68
87
  }
69
88
  const out: CarConfig = {}
70
89
  for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
71
- if (k !== 'sandbox') {
72
- return { config: {}, error: `CAR-E-CONFIG: 未知顶层键 "${k}"(1.1 登记键位空间仅 sandbox.*)` }
90
+ if (k !== 'sandbox' && k !== 'llm' && k !== 'mcp') {
91
+ return { config: {}, error: `CAR-E-CONFIG: 未知顶层键 "${k}"(登记键位空间:sandbox.* / llm.* / mcp.*)` }
92
+ }
93
+ if (k === 'mcp') {
94
+ if (typeof v !== 'object' || v === null || Array.isArray(v)) {
95
+ return { config: {}, error: 'CAR-E-CONFIG: mcp 必须为对象' }
96
+ }
97
+ const top = v as Record<string, unknown>
98
+ if (Object.keys(top).some(x => x !== 'servers')) {
99
+ return { config: {}, error: `CAR-E-CONFIG: 未知键 mcp.${Object.keys(top).find(x => x !== 'servers')}(登记键位:mcp.servers)` }
100
+ }
101
+ const serversRaw = top.servers
102
+ if (typeof serversRaw !== 'object' || serversRaw === null || Array.isArray(serversRaw)) {
103
+ return { config: {}, error: 'CAR-E-CONFIG: mcp.servers 必须为对象(serverId → 启动规格)' }
104
+ }
105
+ const servers: Record<string, McpServerSpec> = {}
106
+ for (const [sid, sv] of Object.entries(serversRaw as Record<string, unknown>)) {
107
+ if (typeof sv !== 'object' || sv === null || Array.isArray(sv)) {
108
+ return { config: {}, error: `CAR-E-CONFIG: mcp.servers.${sid} 必须为对象` }
109
+ }
110
+ const spec = sv as Record<string, unknown>
111
+ if (typeof spec.command !== 'string' || !spec.command.trim()) {
112
+ return { config: {}, error: `CAR-E-CONFIG: mcp.servers.${sid}.command 必须为非空字符串` }
113
+ }
114
+ const parsed: McpServerSpec = { command: spec.command.trim() }
115
+ if (spec.args !== undefined) {
116
+ if (!Array.isArray(spec.args) || spec.args.some(a => typeof a !== 'string')) {
117
+ return { config: {}, error: `CAR-E-CONFIG: mcp.servers.${sid}.args 必须为字符串数组` }
118
+ }
119
+ parsed.args = spec.args as string[]
120
+ }
121
+ if (spec.env !== undefined) {
122
+ if (typeof spec.env !== 'object' || spec.env === null || Array.isArray(spec.env) || Object.values(spec.env).some(x => typeof x !== 'string')) {
123
+ return { config: {}, error: `CAR-E-CONFIG: mcp.servers.${sid}.env 必须为字符串映射` }
124
+ }
125
+ parsed.env = spec.env as Record<string, string>
126
+ }
127
+ const unknownKey = Object.keys(spec).find(x => x !== 'command' && x !== 'args' && x !== 'env')
128
+ if (unknownKey) {
129
+ return { config: {}, error: `CAR-E-CONFIG: 未知键 mcp.servers.${sid}.${unknownKey}(登记键位:command / args / env)` }
130
+ }
131
+ servers[sid] = parsed
132
+ }
133
+ out.mcp = { servers }
134
+ continue
135
+ }
136
+ if (k === 'llm') {
137
+ if (typeof v !== 'object' || v === null || Array.isArray(v)) {
138
+ return { config: {}, error: 'CAR-E-CONFIG: llm 必须为对象' }
139
+ }
140
+ const llm: LlmConfig = {}
141
+ for (const [lk, lv] of Object.entries(v as Record<string, unknown>)) {
142
+ if (lk === 'baseUrl' || lk === 'model' || lk === 'adapterId') {
143
+ if (typeof lv !== 'string' || !lv.trim()) return { config: {}, error: `CAR-E-CONFIG: llm.${lk} 必须为非空字符串` }
144
+ if (lk === 'baseUrl') llm.baseUrl = lv.trim()
145
+ else if (lk === 'model') llm.model = lv.trim()
146
+ else llm.adapterId = lv.trim()
147
+ } else if (lk === 'maxTokens') {
148
+ if (typeof lv !== 'number' || !Number.isFinite(lv) || lv <= 0 || !Number.isInteger(lv)) {
149
+ return { config: {}, error: 'CAR-E-CONFIG: llm.maxTokens 必须为正整数' }
150
+ }
151
+ llm.maxTokens = lv
152
+ } else if (lk === 'allowEnvFallback') {
153
+ if (typeof lv !== 'boolean') return { config: {}, error: 'CAR-E-CONFIG: llm.allowEnvFallback 必须为布尔' }
154
+ llm.allowEnvFallback = lv
155
+ } else {
156
+ return { config: {}, error: `CAR-E-CONFIG: 未知键 llm.${lk}(登记键位:llm.baseUrl / llm.model / llm.adapterId / llm.maxTokens / llm.allowEnvFallback)` }
157
+ }
158
+ }
159
+ out.llm = llm
160
+ continue
73
161
  }
74
162
  if (typeof v !== 'object' || v === null || Array.isArray(v)) {
75
163
  return { config: {}, error: 'CAR-E-CONFIG: sandbox 必须为对象' }
@@ -112,6 +200,34 @@ export interface ResolvedSignatureGate {
112
200
  unsignedAllow: boolean
113
201
  }
114
202
 
203
+ /**
204
+ * 1.4-S3(D-16):模型接入配置合并(flag > env > 配置 > 缺省)。
205
+ * env 已定义为显式意见(压过配置),未定义落配置层——与 mergeSignatureGate 同纪律。
206
+ * 凭据不在此层(CR-05):走 CredentialService(keychain → env OPENAI_API_KEY/CAR_LLM_API_KEY,
207
+ * env fallback 需显式开启 = CAR_ALLOW_ENV_CREDENTIALS=1 或 llm.allowEnvFallback)。
208
+ */
209
+ export function mergeLlmConfig(env: NodeJS.ProcessEnv, cfg: CarConfig, flags: { baseUrl?: string; model?: string; adapterId?: string; maxTokens?: number } = {}): LlmConfig {
210
+ let baseUrl = cfg.llm?.baseUrl
211
+ if (env.CAR_LLM_BASE_URL) baseUrl = env.CAR_LLM_BASE_URL
212
+ if (flags.baseUrl) baseUrl = flags.baseUrl
213
+ let model = cfg.llm?.model
214
+ if (env.CAR_LLM_MODEL) model = env.CAR_LLM_MODEL
215
+ if (flags.model) model = flags.model
216
+ let adapterId = cfg.llm?.adapterId
217
+ if (flags.adapterId) adapterId = flags.adapterId
218
+ let maxTokens = cfg.llm?.maxTokens
219
+ if (flags.maxTokens !== undefined) maxTokens = flags.maxTokens
220
+ let allowEnvFallback = cfg.llm?.allowEnvFallback === true
221
+ if (env.CAR_ALLOW_ENV_CREDENTIALS !== undefined) allowEnvFallback = env.CAR_ALLOW_ENV_CREDENTIALS === '1'
222
+ return {
223
+ ...(baseUrl ? { baseUrl } : {}),
224
+ ...(model ? { model } : {}),
225
+ ...(adapterId ? { adapterId } : {}),
226
+ ...(maxTokens !== undefined ? { maxTokens } : {}),
227
+ allowEnvFallback,
228
+ }
229
+ }
230
+
115
231
  /**
116
232
  * 优先级合并(flag > env > 配置 > 缺省):产出装载签名门选项。
117
233
  * flagEnforce = CLI --sig-enforce;env 已定义即为显式意见(压过配置),未定义落配置层。
@@ -85,24 +85,38 @@ export function parseManifest(raw: unknown, file = '<manifest>'): Manifest {
85
85
  return m
86
86
  }
87
87
 
88
- export interface ToolReg { name: string; run: (args: any) => Promise<unknown> }
88
+ export interface ToolReg {
89
+ name: string
90
+ run: (args: any) => Promise<unknown>
91
+ // —— 1.4-S1 声明面加法(进模型 ToolDefinition;缺省收口见 runtime-core/tools.ts T-22)——
92
+ /** 工具描述(进模型 function.description) */
93
+ description?: string
94
+ /** 入参 JSON Schema(进模型 function.parameters) */
95
+ parameters?: unknown
96
+ /** 副作用声明(缺省 write 收口——T-22 同款:未声明按最高约束) */
97
+ declaredSideEffect?: 'readonly' | 'write'
98
+ }
89
99
  export interface CommandReg { name: string; run: (args: string[]) => Promise<void> }
90
100
  export interface PluginApi {
91
101
  registerTool(tool: ToolReg): void
92
102
  registerCommand(cmd: CommandReg): void
93
103
  getRegisteredTools(): ToolReg[]
104
+ /** 1.4-S2:插件设定 system prompt(session 级;last-wins;bindCore 时冲刷进宿主收集器,落 'system' 事件) */
105
+ setSystemPrompt(text: string): void
94
106
  }
95
107
 
96
108
  interface HostBinding {
97
109
  registerTool?(tool: ToolReg): void
98
110
  registerCommand?(cmd: CommandReg): void
99
111
  getRegisteredTools(): ToolReg[]
112
+ setSystemPrompt?(text: string): void
100
113
  }
101
114
 
102
115
  class ApiImpl {
103
116
  #host: HostBinding | undefined
104
117
  #pendingTools: ToolReg[] = []
105
118
  #pendingCommands: CommandReg[] = []
119
+ #pendingSystemPrompt: string | null = null
106
120
  registerTool(tool: ToolReg): void {
107
121
  if (this.#host?.registerTool) { this.#host.registerTool(tool); return }
108
122
  this.#pendingTools.push(tool) // 注册类动作始终可用:Stub 期收集,bind 后冲刷
@@ -111,6 +125,10 @@ class ApiImpl {
111
125
  if (this.#host?.registerCommand) { this.#host.registerCommand(cmd); return }
112
126
  this.#pendingCommands.push(cmd)
113
127
  }
128
+ setSystemPrompt(text: string): void {
129
+ if (this.#host?.setSystemPrompt) { this.#host.setSystemPrompt(text); return }
130
+ this.#pendingSystemPrompt = text // last-wins:多次设定取最后一次
131
+ }
114
132
  getRegisteredTools(): ToolReg[] {
115
133
  // 依赖宿主绑定的能力查询:bindCore 前一调用即抛错(Stub 显式失败)
116
134
  if (!this.#host) throw new Error('CAR-STUB: "getRegisteredTools" called before bindCore(启动期显式失败换运行期时序正确)')
@@ -120,8 +138,10 @@ class ApiImpl {
120
138
  this.#host = host
121
139
  for (const t of this.#pendingTools) host.registerTool?.(t)
122
140
  for (const c of this.#pendingCommands) host.registerCommand?.(c)
141
+ if (this.#pendingSystemPrompt !== null) host.setSystemPrompt?.(this.#pendingSystemPrompt)
123
142
  this.#pendingTools = []
124
143
  this.#pendingCommands = []
144
+ this.#pendingSystemPrompt = null
125
145
  }
126
146
  }
127
147
 
package/src/loop/stop.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  * - 取消:未派发调用补记成对事件(toolCall + 合成错误 toolResult),日志无缺口
13
13
  */
14
14
  import type { SessionLog } from '../session/log.ts'
15
+ import { CarM8Error } from '../runtime-core/errors.ts'
15
16
 
16
17
  export type StepOutcome = 'completed' | 'max-tokens' | 'null'
17
18
  export type TurnEndReason = 'completed' | 'max-tokens' | 'aborted' | 'error' | 'blocked' | 'interrupted'
@@ -23,6 +24,9 @@ export interface ToolDef {
23
24
  concludesTurn?: boolean
24
25
  terminate?: boolean
25
26
  run(args: Record<string, unknown>): Promise<unknown>
27
+ // —— 1.4-S1 声明面加法(进模型 ToolDefinition;执行面与声明面同一对象——声明面=授权面红线)——
28
+ description?: string
29
+ parameters?: unknown
26
30
  }
27
31
  /** 模型单步响应(provider 形态;stopReason 为不可变透传——ADR-001) */
28
32
  export interface ModelStep {
@@ -193,7 +197,14 @@ export async function runTurn(opts: {
193
197
  trail.push({ seq, reason: 'aborted' })
194
198
  return { reason: 'aborted', steps, endReasonTrail: trail }
195
199
  }
196
- const seq = log.append('runtime', 'turnEnd', turnId, null, { reason: 'error', detail: String(e) }).seq
200
+ // 1.4-S4:CarM8Error 保形——code/userHint/retryable 进 turnEnd(A080001 引导文案「请运行
201
+ // car doctor」可达用户;非 CarM8Error 维持 String(e) 口径不变)
202
+ const m8 = e instanceof CarM8Error
203
+ const detail = m8 ? `${e.slug}: ${e.message}` : String(e)
204
+ const seq = log.append('runtime', 'turnEnd', turnId, null, {
205
+ reason: 'error', detail,
206
+ ...(m8 ? { code: e.code, userHint: e.userHint, retryable: e.retryable } : {}),
207
+ }).seq
197
208
  trail.push({ seq, reason: 'error' })
198
209
  return { reason: 'error', steps, endReasonTrail: trail }
199
210
  }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * 1.4-S7 · MCP stdio 客户端传输(W1 MCP 工具进 turn):spawn 子进程 + JSON-RPC 换行分隔 JSON
3
+ *
4
+ * 口径(1.4 规划 W1 MCP 块 / D-18):
5
+ * - CAR → 外部 MCP Server 方向(gateway.ts ClientTransport 的真实通道实装——此前仅测试替身);
6
+ * server 侧对偶(宿主 → CAR)见 src/host/stdio.ts;
7
+ * - 行协议:请求一行 JSON 写入 server stdin;响应按 id 匹配 pending 表(行解析容错跳过非 JSON 行);
8
+ * - send 内建 30s 超时(fail-visible——gateway.register 的 tools/list 无外层 race,防挂死);
9
+ * - stderr 不透传(server 诊断输出与协议通道隔离);close = kill(car run 收口调用)。
10
+ */
11
+ import { spawn, type ChildProcess } from 'node:child_process'
12
+ import type { ClientTransport, JsonRpcResponse } from './gateway.ts'
13
+
14
+ export interface StdioServerSpec {
15
+ command: string
16
+ args?: string[]
17
+ }
18
+
19
+ export function createStdioClientTransport(spec: StdioServerSpec): ClientTransport & { readonly alive: () => boolean } {
20
+ const child: ChildProcess = spawn(spec.command, spec.args ?? [], { stdio: ['pipe', 'pipe', 'pipe'] })
21
+ const pending = new Map<number, (res: JsonRpcResponse) => void>()
22
+ let buffer = ''
23
+ let exited = false
24
+ child.on('exit', () => { exited = true })
25
+ child.stdout!.on('data', chunk => {
26
+ buffer += chunk.toString('utf-8')
27
+ let nl: number
28
+ while ((nl = buffer.indexOf('\n')) >= 0) {
29
+ const line = buffer.slice(0, nl).trim()
30
+ buffer = buffer.slice(nl + 1)
31
+ if (!line) continue
32
+ try {
33
+ const res = JSON.parse(line) as JsonRpcResponse
34
+ const settle = pending.get(res.id as number)
35
+ if (settle) {
36
+ pending.delete(res.id as number)
37
+ settle(res)
38
+ }
39
+ } catch { /* 非 JSON 行容错跳过(server 诊断污染 stdin 协议通道时不断链) */ }
40
+ }
41
+ })
42
+ child.stderr!.on('data', () => { /* 隔离(见头注释) */ })
43
+ child.on('error', () => { exited = true })
44
+
45
+ const transport: ClientTransport & { readonly alive: () => boolean } = {
46
+ send(req) {
47
+ return new Promise((resolve, reject) => {
48
+ if (!transport.alive()) {
49
+ reject(new Error('CAR-E-MCP: stdio transport not alive(server 进程已退出)'))
50
+ return
51
+ }
52
+ const timer = setTimeout(() => {
53
+ pending.delete(req.id as number)
54
+ reject(new Error('CAR-E-MCP: stdio send timeout (30s)'))
55
+ }, 30_000)
56
+ pending.set(req.id as number, res => {
57
+ clearTimeout(timer)
58
+ resolve(res)
59
+ })
60
+ try {
61
+ child.stdin!.write(JSON.stringify(req) + '\n')
62
+ } catch (e) {
63
+ clearTimeout(timer)
64
+ pending.delete(req.id as number)
65
+ reject(e)
66
+ }
67
+ })
68
+ },
69
+ alive: () => !exited && child.exitCode === null && !child.killed,
70
+ close: () => {
71
+ try { child.kill() } catch { /* 已退出 */ }
72
+ },
73
+ }
74
+ return transport
75
+ }
@@ -116,6 +116,16 @@ export function makePtcToolDefinition(opts: { tools: Map<string, ToolBridge>; au
116
116
  return {
117
117
  name: 'run_code',
118
118
  description: '执行一段 erasable TypeScript 程序(PTC)——程序体内经 tools.<name>(args) 调用已注册工具',
119
+ // 1.4-S7:run_code 入参 schema(进模型声明面——此前无 parameters,模型看不到 code/description 形状)
120
+ parameters: {
121
+ type: 'object',
122
+ properties: {
123
+ code: { type: 'string', description: 'erasable TS 程序体(async 函数体;经 tools.<name>(args) 调用已注册工具)' },
124
+ description: { type: 'string', description: '本段程序的一句话描述(审计留痕)' },
125
+ },
126
+ required: ['code', 'description'],
127
+ additionalProperties: false,
128
+ },
119
129
  // T-22 语义:PTC 与 bash 同信任级别 → 强制 write 最高约束(不可声明为 readonly)
120
130
  declaredSideEffect: 'write' as const,
121
131
  run: async (args: { code: string; description: string; toolCallId?: string }) => {
@@ -15,7 +15,7 @@
15
15
  * 由 runTurn 落 turnEnd);工具调用事件由 runTurn executeBatch 落(本层不重复)。
16
16
  */
17
17
  import type { SessionLog } from '../session/log.ts'
18
- import type { ModelStep, ToolCall } from '../loop/stop.ts'
18
+ import { TurnAborted, type ModelStep, type ToolCall } from '../loop/stop.ts'
19
19
  import type { RuntimeCore } from './llm.ts'
20
20
  import type { ToolDefinition } from './types.ts'
21
21
  import { StreamRedactor } from './redaction.ts'
@@ -32,6 +32,8 @@ export interface ChatStepOptions {
32
32
  tools: ToolDefinition[]
33
33
  maxTokens?: number
34
34
  adapterId?: string
35
+ /** 1.4-S4(D-19):turn 取消信号——chunk 间隙与流结束后(finish 检查前)检查,命中抛 TurnAborted */
36
+ signal?: { aborted: boolean }
35
37
  }
36
38
 
37
39
  /** ModelStep 加法扩展:脱敏计数留痕(S15 secretsRedacted 同名口径) */
@@ -49,7 +51,7 @@ export async function chatStep(opts: ChatStepOptions): Promise<ChatStepResult> {
49
51
  // 2. 请求消息 = 投影(角色系统:user/assistant/toolResult 与 LlmRequest 对齐;
50
52
  // assistant 工具调用以 { toolCall } content 形态原样透传——deriveMessages 投影即模型可见流)
51
53
  const messages = log.deriveMessages().map(m => ({
52
- role: m.role as 'user' | 'assistant' | 'toolResult',
54
+ role: m.role as 'system' | 'user' | 'assistant' | 'toolResult',
53
55
  content: m.content,
54
56
  }))
55
57
 
@@ -66,9 +68,12 @@ export async function chatStep(opts: ChatStepOptions): Promise<ChatStepResult> {
66
68
  tools,
67
69
  ...(maxTokens != null ? { maxTokens } : {}),
68
70
  ...(adapterId ? { adapterId } : {}),
71
+ ...(opts.signal ? { signal: opts.signal } : {}),
69
72
  ...(opts.log.sessionId ? { metadata: { sessionId: opts.log.sessionId, turnId, traceId: `${opts.log.sessionId}:${turnId}` } } : {}),
70
73
  })
71
74
  for await (const chunk of stream) {
75
+ // 1.4-S4(D-19):chunk 间隙取消——此刻 assistant 尚未落链(流完才 append),无半截消息
76
+ if (opts.signal?.aborted) throw new TurnAborted()
72
77
  if (chunk.delta !== undefined) text += redactor.push(chunk.delta)
73
78
  if (chunk.toolCallDelta) {
74
79
  const idx = chunk.toolCallDelta.index ?? 0
@@ -81,6 +86,9 @@ export async function chatStep(opts: ChatStepOptions): Promise<ChatStepResult> {
81
86
  if (chunk.error) errorDetail = chunk.error.message
82
87
  if (chunk.finishReason !== undefined) stopReason = chunk.finishReason === 'aborted' ? 'error' : chunk.finishReason
83
88
  }
89
+ // 1.4-S4(D-19):finish 检查前先查取消(适配器取消路径静默收口无 finishReason——
90
+ // 必须先于此处的契约兜底,否则取消被误判为适配器违规)
91
+ if (opts.signal?.aborted) throw new TurnAborted()
84
92
  if (stopReason === undefined) {
85
93
  // 适配器层已保证首 finishReason(AL-05);防御性兜底显式化
86
94
  throw new Error('CAR-E-LLM-FINISH: 流结束无 finishReason(适配器契约违规)')
@@ -199,7 +199,13 @@ export function createOpenAICompatAdapter(opts: OpenAICompatOptions): LlmAdapter
199
199
  const id = opts.id ?? 'openai-compat'
200
200
  const baseUrl = opts.baseUrl.replace(/\/+$/, '')
201
201
  if (!baseUrl.startsWith('https://')) {
202
- throw new Error(`CAR-E-LLM-TLS: baseUrl 必须 https(TLS 强制,§3.2.M8.4;本地明文部署请经 TLS 代理或自建适配器)`)
202
+ // 1.4-S4(D-12a,1.2 规划期裁决):http 仅豁免 loopback——本地模型服务(Ollama 等)与
203
+ // 本地 E2E;非回环地址维持 TLS 强制(§3.2.M8.4)。豁免集显式列举,不扩大到局域网。
204
+ let loopback = false
205
+ try { const host = new URL(baseUrl).hostname.replace(/^\[|\]$/g, ''); loopback = host === '127.0.0.1' || host === 'localhost' || host === '::1' } catch { /* 解析失败 = 非法 URL,按非回环拒绝 */ }
206
+ if (!loopback) {
207
+ throw new Error(`CAR-E-LLM-TLS: baseUrl 必须 https(TLS 强制,§3.2.M8.4);http 仅豁免 loopback(127.0.0.1/[::1]/localhost,D-12a)——远程明文部署请经 TLS 代理或自建适配器`)
208
+ }
203
209
  }
204
210
  const url = `${baseUrl}/chat/completions`
205
211
  const fetchImpl = opts.fetchImpl ?? fetch
@@ -210,6 +216,9 @@ export function createOpenAICompatAdapter(opts: OpenAICompatOptions): LlmAdapter
210
216
  const backoffMs = opts.backoffMs ?? [1_000, 2_000]
211
217
 
212
218
  const toOpenAI = (req: LlmRequest, apiKey: string) => ({
219
+ // 1.4-BUG-1:method: 'POST' 缺失——M8 最小实现潜伏缺陷(注入式 fetch 的单测忽略 method,
220
+ // 真 undici 对 GET+body 直接拒绝);car run 真路径接线时暴露(生产调用点核查产出)
221
+ method: 'POST',
213
222
  headers: {
214
223
  'content-type': 'application/json',
215
224
  authorization: `Bearer ${apiKey}`,
@@ -250,6 +259,8 @@ export function createOpenAICompatAdapter(opts: OpenAICompatOptions): LlmAdapter
250
259
  const payload = toOpenAI(req, apiKey)
251
260
 
252
261
  for (let attempt = 0; ; attempt++) {
262
+ // 1.4-S4(D-19):外部取消 ≠ 失败——attempt 前检查,静默收口(无 chunk、无 retry)
263
+ if (req.signal?.aborted) return
253
264
  const controller = new AbortController()
254
265
  const started = Date.now()
255
266
  let firstByteSeen = false
@@ -267,6 +278,9 @@ export function createOpenAICompatAdapter(opts: OpenAICompatOptions): LlmAdapter
267
278
  if (!res.body) throw new Retryable('响应无 body 流')
268
279
  let sawFinish = false
269
280
  for await (const chunk of parseSseStream(res.body)) {
281
+ // 1.4-S4(D-19):chunk 间隙检查取消——abort 连接并静默收口(不产 error chunk,
282
+ // 不违反 finishReason 不可变;上层 chatStep 以自身 signal 检查抛 TurnAborted)
283
+ if (req.signal?.aborted) { controller.abort(); return }
270
284
  if (!firstByteSeen) { firstByteSeen = true; clearTimeout(firstByteTimer) }
271
285
  if (chunk.finishReason !== undefined) sawFinish = true
272
286
  yield chunk
@@ -279,6 +293,8 @@ export function createOpenAICompatAdapter(opts: OpenAICompatOptions): LlmAdapter
279
293
  }
280
294
  return
281
295
  } catch (e) {
296
+ // 1.4-S4(D-19):外部取消(abort 连锁的 fetch 异常)≠ 失败——静默收口,不走 retry
297
+ if (req.signal?.aborted) return
282
298
  const beforeFirstByte = !firstByteSeen
283
299
  clearTimeout(firstByteTimer)
284
300
  // 首块前失败 = 网络错误/429/5xx/首字节超时(§3.5.4 重试条件全集)——未消费任何
@@ -4,7 +4,8 @@
4
4
  * 口径(§3.2.M8.5 关键约束,逐条落地):
5
5
  * - 默认关:无端点配置时不初始化任何 exporter——无配置 = noop 句柄 + 零出站(可机器断言);
6
6
  * - 显式开:用户配置端点即视为开启(CAR_OTEL_ENDPOINT 或显式 config 传入);
7
- * - 端点归用户:数据只发往用户自配 E-05;OTLP/HTTP JSON(fetch),导出超时 5s;
7
+ * - 端点归用户:数据只发往用户自配 E-05;OTLP/HTTP JSON(node:http(s) 出站——1.4-BUG-2:全局
8
+ * fetch/undici 在 win32 进程退出时触发 libuv 断言崩溃),导出超时 5s;
8
9
  * 1.2-S3 生产级策略(D-12b 口径修订,2026-09-27):**0 重试 → 有界重试 ≤2**(仅 429/5xx/
9
10
  * 网络错误,退避 1s/2s;4xx 业务错不重试)——重试耗尽静默丢弃 + droppedExports 计数,
10
11
  * 永不抛错(BD-05「不抛错、不落审计」收紧保持);隐私三原则不变(数据仍只发用户自配端点);
@@ -22,6 +23,8 @@
22
23
  * 不引入 @opentelemetry/* 依赖(项目零依赖红线,README engines 纪律)。
23
24
  */
24
25
  import { randomBytes } from 'node:crypto'
26
+ import { request as httpRequest } from 'node:http'
27
+ import { request as httpsRequest } from 'node:https'
25
28
 
26
29
  export type SamplingSpec = 'always_on' | 'always_off' | { ratio: number }
27
30
 
@@ -55,10 +58,16 @@ export interface TelemetrySpan {
55
58
  recordException(err: unknown): this
56
59
  end(): void
57
60
  readonly traceId: string
61
+ /** 1.4-S6:子 span 挂链需要父 spanId(TurnTracer turn→step 层级) */
62
+ readonly spanId: string
58
63
  }
59
64
 
60
65
  export interface TelemetryTracer {
61
- startSpan(name: string, opts?: { attributes?: Record<string, string | number | boolean> }): TelemetrySpan
66
+ startSpan(name: string, opts?: {
67
+ attributes?: Record<string, string | number | boolean>
68
+ /** 1.4-S6:trace 透传(traceId 复用 + parentSpanId 挂链);缺省 = 新独立 trace */
69
+ trace?: { traceId: string; parentSpanId?: string }
70
+ }): TelemetrySpan
62
71
  }
63
72
 
64
73
  export interface TelemetryCounter {
@@ -102,6 +111,7 @@ const noopSpan = (): TelemetrySpan => ({
102
111
  recordException() { return noopSpan() },
103
112
  end() {},
104
113
  traceId: '',
114
+ spanId: '',
105
115
  })
106
116
  const NOOP_TRACER: TelemetryTracer = { startSpan: () => noopSpan() }
107
117
  const NOOP_METER: TelemetryMeter = { createCounter: () => ({ add() {} }) }
@@ -164,7 +174,25 @@ export function createTelemetryFacade(
164
174
  }
165
175
  }
166
176
 
167
- const fetchImpl = opts.fetchImpl ?? fetch
177
+ // 1.4-BUG-2:出站默认走 node:http(s)(非全局 fetch/undici)——win32 上 undici socket +
178
+ // 进程退出触发 libuv 断言崩溃(UV_HANDLE_CLOSING, 0xC0000409,Node 23.5 实测、22 不复现);
179
+ // 测试仍可注入 fetchImpl(缝保留)。res.resume() 排空响应体(连接干净归还)。
180
+ const fetchImpl = opts.fetchImpl ?? ((url: string | URL, init?: RequestInit): Promise<{ ok: boolean; status: number }> => new Promise(resolve => {
181
+ try {
182
+ const u = String(url)
183
+ const mod = u.startsWith('https:') ? httpsRequest : httpRequest
184
+ const req = mod(u, { method: init?.method ?? 'GET', headers: init?.headers as Record<string, string> | undefined }, res => {
185
+ res.resume()
186
+ res.on('end', () => {
187
+ const st = res.statusCode ?? 0
188
+ resolve({ ok: st >= 200 && st < 300, status: st })
189
+ })
190
+ })
191
+ req.setTimeout(timeoutMs, () => req.destroy(new Error('timeout')))
192
+ req.on('error', () => resolve({ ok: false, status: 599 })) // 网络/超时错误 → 599 可重试类
193
+ req.end(init?.body ?? null)
194
+ } catch { resolve({ ok: false, status: 599 }) }
195
+ }))
168
196
  const now = opts.now ?? Date.now
169
197
  const sleep = config?.sleep ?? (ms => new Promise<void>(r => setTimeout(r, ms)))
170
198
  const timeoutMs = config?.exportTimeoutMs ?? 5_000
@@ -194,12 +222,16 @@ export function createTelemetryFacade(
194
222
  */
195
223
  const exportOtlp = async (path: string, body: unknown): Promise<boolean> => {
196
224
  for (let attempt = 0; ; attempt++) {
225
+ // 1.4-BUG-2:AbortSignal.timeout 的内部定时器在进程退出时触发 libuv win32 断言崩溃
226
+ // (UV_HANDLE_CLOSING, 0xC0000409)——改手动 AbortController + clearTimeout 确定性清理
227
+ const controller = new AbortController()
228
+ const abortTimer = setTimeout(() => controller.abort(), timeoutMs)
197
229
  try {
198
230
  const res = await fetchImpl(endpoint + path, {
199
231
  method: 'POST',
200
232
  headers: baseHeaders,
201
233
  body: JSON.stringify(body),
202
- signal: AbortSignal.timeout(timeoutMs),
234
+ signal: controller.signal,
203
235
  })
204
236
  if (res.ok) return true
205
237
  if ((res.status === 429 || res.status >= 500) && attempt < retryLimit) {
@@ -214,6 +246,8 @@ export function createTelemetryFacade(
214
246
  continue
215
247
  }
216
248
  return false
249
+ } finally {
250
+ clearTimeout(abortTimer)
217
251
  }
218
252
  }
219
253
  }
@@ -277,9 +311,10 @@ export function createTelemetryFacade(
277
311
  const activeTracer: TelemetryTracer = {
278
312
  startSpan(name, startOpts) {
279
313
  const record: SpanRecord = {
280
- traceId: randomBytes(16).toString('hex'),
314
+ // 1.4-S6:trace 透传(traceId 复用 + parentSpanId 挂链)——缺省仍为独立随机 trace(1.1 行为不变)
315
+ traceId: startOpts?.trace?.traceId ?? randomBytes(16).toString('hex'),
281
316
  spanId: randomBytes(8).toString('hex'),
282
- parentSpanId: null,
317
+ parentSpanId: startOpts?.trace?.parentSpanId ?? null,
283
318
  name,
284
319
  startMs: now(),
285
320
  endMs: 0,
@@ -287,6 +322,7 @@ export function createTelemetryFacade(
287
322
  }
288
323
  return {
289
324
  get traceId() { return record.traceId },
325
+ get spanId() { return record.spanId },
290
326
  setAttribute(key, value) { record.attributes[key] = value; return this },
291
327
  addEvent(evtName, attrs) { record.attributes[`event.${evtName}`] = attrs ? JSON.stringify(attrs) : evtName; return this },
292
328
  recordException(err) { record.attributes['exception.message'] = String((err as Error)?.message ?? err); return this },
@@ -0,0 +1,27 @@
1
+ /**
2
+ * 1.4-S1 · 工具声明面转换(执行面 Map → 模型声明面 ToolDefinition[])
3
+ *
4
+ * 口径(1.4 规划 W1-3 / D-18 / T-22 / 声明面=授权面红线):
5
+ * - **执行面与声明面同一对象**:ToolDef(loop/stop.ts,declaredSideEffect+run)即声明来源——
6
+ * 插件注册的 ToolReg 经 bindCore 收集后直接进本转换器,模型看到的工具集合 = 授权门放行的集合
7
+ * (多渲一个 = 无授权执行面,少渲一个 = 授权白给——PTC SDK 同款红线);
8
+ * - T-22 收口:declaredSideEffect 缺省 write(未声明按最高约束——mcp/gateway.ts 注册点同款);
9
+ * - 跨源同名显式报错(禁静默覆盖):Map 键即名字,插件/MCP 工具合并由调用方先行去重——
10
+ * 本转换器对重名(Map 键重复不可能,但 ToolDef.name 与键不一致时)显式报错;
11
+ * - declaredSideEffect 不出站(llm.ts 出站映射只带 name/description/parameters——权限面只进权限门)。
12
+ */
13
+ import type { ToolDef } from '../loop/stop.ts'
14
+ import type { ToolDefinition } from './types.ts'
15
+
16
+ export function toToolDefinitions(tools: Map<string, ToolDef>): ToolDefinition[] {
17
+ const out: ToolDefinition[] = []
18
+ for (const [name, t] of tools) {
19
+ out.push({
20
+ name,
21
+ ...(t.description !== undefined ? { description: t.description } : {}),
22
+ ...(t.parameters !== undefined ? { parameters: t.parameters } : {}),
23
+ declaredSideEffect: t.declaredSideEffect ?? 'write',
24
+ })
25
+ }
26
+ return out
27
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * 1.4-S6 · Turn 级 trace 面(W1 遥测未入链收尾):turn span 包全程、step span 挂每次 model() 调用
3
+ *
4
+ * 口径(1.4 规划 W1 遥测块 / telemetry.ts 头注释):
5
+ * - 层级:turn(根 span,traceId 在此生成)→ step(parentSpanId = turn.spanId)——同一 turn 共享 trace;
6
+ * - **挂点在装配层**(car run 的 model 回调闭包),loop 层零改动——遥测治理属调用方(telemetry.ts
7
+ * 「内容治理属调用方」口径);设计微调登记:规划原文「runTurn opts.telemetry 注入面」改为装配层
8
+ * 闭包挂点,语义等价(turn/step 层级与出站面不变),loop 层保持纯停止语义;
9
+ * - attributes 零内容面:turn_id/step 序号/outcome 枚举——无会话内容/提示词/路径。
10
+ */
11
+ import { randomBytes } from 'node:crypto'
12
+ import type { TelemetryFacade } from './telemetry.ts'
13
+
14
+ export interface StepSpanHandle {
15
+ end(outcome: string): void
16
+ recordException(e: unknown): void
17
+ }
18
+
19
+ export interface TurnSpanHandle {
20
+ traceId: string
21
+ spanId: string
22
+ end(outcome: string): void
23
+ stepSpan(step: number): StepSpanHandle
24
+ }
25
+
26
+ export class TurnTracer {
27
+ readonly #facade: TelemetryFacade | null
28
+
29
+ constructor(facade: TelemetryFacade | null | undefined) {
30
+ // 默认关(无端点)= noop 句柄(零出站零积累——telemetry 三原则)
31
+ this.#facade = facade?.enabled ? facade : null
32
+ }
33
+
34
+ startTurn(turnId: string): TurnSpanHandle {
35
+ if (!this.#facade) {
36
+ return { traceId: '', spanId: '', end() {}, stepSpan: () => ({ end() {}, recordException() {} }) }
37
+ }
38
+ const tracer = this.#facade.getTracer()
39
+ const traceId = randomBytes(16).toString('hex')
40
+ const turn = tracer.startSpan('car.turn', { attributes: { 'car.turn_id': turnId }, trace: { traceId } })
41
+ return {
42
+ traceId,
43
+ spanId: turn.spanId,
44
+ end(outcome) {
45
+ turn.setAttribute('car.outcome', outcome)
46
+ turn.end()
47
+ },
48
+ stepSpan(step: number): StepSpanHandle {
49
+ const s = tracer.startSpan('car.step', {
50
+ attributes: { 'car.turn_id': turnId, 'car.step': step },
51
+ trace: { traceId, parentSpanId: turn.spanId },
52
+ })
53
+ return {
54
+ end(outcome) {
55
+ s.setAttribute('car.outcome', outcome)
56
+ s.end()
57
+ },
58
+ recordException(e) { s.recordException(e) },
59
+ }
60
+ },
61
+ }
62
+ }
63
+ }
@@ -19,9 +19,12 @@ export interface LlmRequest {
19
19
  tools: ToolDefinition[]
20
20
  maxTokens?: number
21
21
  metadata?: { sessionId: string; turnId: string; traceId: string }
22
- // —— 规格外加法扩展(登记:M8 收口文档 §4)——
22
+ // —— 规格外加法扩展(登记:M8 收口文档 §4 / 1.4-S4)——
23
23
  /** 多适配器在册时显式路由;缺省用默认适配器 */
24
24
  adapterId?: string
25
+ /** 1.4-S4(D-19):turn 取消信号(runTurn 的 {aborted} 形态透传)——适配器在 attempt 前 /
26
+ * chunk 间隙检查;外部取消 ≠ 失败(不 retry、不产 error chunk,静默收口由 chatStep 抛 TurnAborted) */
27
+ signal?: { aborted: boolean }
25
28
  }
26
29
 
27
30
  /** 工具调用流式增量(跨 chunk 聚合出完整 ToolCall) */