@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
package/src/load/config.ts
CHANGED
|
@@ -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}"
|
|
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 已定义即为显式意见(压过配置),未定义落配置层。
|
package/src/load/loader.ts
CHANGED
|
@@ -85,24 +85,38 @@ export function parseManifest(raw: unknown, file = '<manifest>'): Manifest {
|
|
|
85
85
|
return m
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
export interface ToolReg {
|
|
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
|
-
|
|
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
|
+
}
|
package/src/ptc/runCode.ts
CHANGED
|
@@ -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
|
|
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(适配器契约违规)')
|
package/src/runtime-core/llm.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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?: {
|
|
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
|
-
|
|
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:
|
|
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
|
|
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) */
|