@lqc123qwe/car-runtime 1.0.0 → 1.1.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/README.md +24 -1
- package/package.json +1 -1
- package/src/cli.ts +195 -17
- package/src/dx/doctor.ts +54 -0
- package/src/kernel/context.ts +15 -0
- package/src/load/config.ts +129 -0
- package/src/load/sign.ts +60 -0
- package/src/runtime-core/chatStep.ts +121 -0
- package/src/runtime-core/credentials.ts +135 -0
- package/src/runtime-core/errors.ts +47 -0
- package/src/runtime-core/llm.ts +304 -0
- package/src/runtime-core/redaction.ts +94 -0
- package/src/runtime-core/telemetry.ts +243 -0
- package/src/runtime-core/types.ts +79 -0
- package/test/s18.spec.ts +9 -0
- package/test/s25-m8-runtime-core.spec.ts +599 -0
- package/test/s26-signature-closeout.spec.ts +349 -0
- package/test/s27-host-load-wiring.spec.ts +134 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M8 · 脱敏钩子(redact ——《系统设计》§3.2.M8.2 脱敏行 / SQ-07 输出敏感扫描 / O-14)
|
|
3
|
+
*
|
|
4
|
+
* 口径:
|
|
5
|
+
* - 检测器单一权威:本层只做 M8 面(RedactionResult 形态 + 流式 hold-back),
|
|
6
|
+
* 模式库/三层检测/遮蔽口径全部委托 F12 scanSecrets(security/secrets.ts)——
|
|
7
|
+
* 双实现必然漂移,S15 已确立「回填前强制 redact」先例;
|
|
8
|
+
* - 遮蔽口径(§3.2.M8.3 字段表):API Key 形如 sk-****(保留首尾 ≤4 字符)= F12 maskToken;
|
|
9
|
+
* - 流式扫描(StreamRedactor):delta 直发会造成跨 chunk 命中逃逸——hold-back 窗口
|
|
10
|
+
* 「从最早疑似锚点起驻留」,命中补齐即遮蔽放行,finishReason 收口时 flush 全量扫描;
|
|
11
|
+
* 结构标记类模式(GCP service_account 等)的匹配起点先于锚点,流式模式不保证完整遮蔽
|
|
12
|
+
* (登记为流式已知边界;非流式 redact() 为权威口径)。
|
|
13
|
+
*/
|
|
14
|
+
import { scanSecrets, type ScanHit } from '../security/secrets.ts'
|
|
15
|
+
|
|
16
|
+
export interface RedactionResult {
|
|
17
|
+
/** 脱敏后文本(§3.2.M8.3 字段表口径:RedactionResult.redacted) */
|
|
18
|
+
redacted: string
|
|
19
|
+
/** 命中遮蔽次数 */
|
|
20
|
+
redactedCount: number
|
|
21
|
+
/** 命中明细(审计留痕用;不含原文) */
|
|
22
|
+
hits: Array<Pick<ScanHit, 'category' | 'name' | 'start' | 'end' | 'masked' | 'score'>>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 非流式脱敏钩子(§3.2.M8.2:redact(text): RedactionResult,天然幂等) */
|
|
26
|
+
export function redact(text: string): RedactionResult {
|
|
27
|
+
const { hits } = scanSecrets(text)
|
|
28
|
+
let out = ''
|
|
29
|
+
let cursor = 0
|
|
30
|
+
for (const h of hits) {
|
|
31
|
+
out += text.slice(cursor, h.start) + h.masked
|
|
32
|
+
cursor = h.end
|
|
33
|
+
}
|
|
34
|
+
out += text.slice(cursor)
|
|
35
|
+
return { redacted: out, redactedCount: hits.length, hits }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 疑似锚点:所有模式匹配串的起始字面量(大小写不敏感)。
|
|
40
|
+
* 命中检测必然先经过锚点——锚点之后的字节才需要驻留等待命中补齐。
|
|
41
|
+
*/
|
|
42
|
+
const ANCHOR_RE = /(sk[-_]|gh[pousr]_|xox|akia|rk_|npm_|-----begin|postgres|mysql|mongodb|redis|jdbc:|aws|service_account|accountkey|eyJ|authorization|bearer|api[_-]?key|client[_-]?secret|access[_-]?token|password)/i
|
|
43
|
+
|
|
44
|
+
/** 无锚点时的尾部驻留长度(≥ 最长锚点 15,防锚点跨 chunk 拼合逃逸) */
|
|
45
|
+
const TRAILING_HOLD = 16
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 流式脱敏器:push() 返回「当前可安全放行」的文本(已遮蔽),flush() 收口全量扫描。
|
|
49
|
+
* 语义:不泄漏(任何已放行字节不可能成为命中的一部分)、不误遮(锚点未补齐成命中时
|
|
50
|
+
* 原文放行)、粒度换正确性(疑似锚点驻留直到命中补齐或 flush——长行含假锚点时
|
|
51
|
+
* 流式粒度退化到收口时点,登记为已接受折衷)。
|
|
52
|
+
*/
|
|
53
|
+
export class StreamRedactor {
|
|
54
|
+
#pending = ''
|
|
55
|
+
|
|
56
|
+
/** 追加 delta,返回可安全放行的文本(可能为空串) */
|
|
57
|
+
push(text: string): string {
|
|
58
|
+
this.#pending += text
|
|
59
|
+
let out = ''
|
|
60
|
+
// 1) 命中补齐:从前往后逐个遮蔽(遮蔽后重扫——遮蔽串不会再命中)
|
|
61
|
+
for (;;) {
|
|
62
|
+
const { hits } = scanSecrets(this.#pending)
|
|
63
|
+
const h = hits[0]
|
|
64
|
+
if (!h) break
|
|
65
|
+
out += this.#pending.slice(0, h.start) + h.masked
|
|
66
|
+
this.#pending = this.#pending.slice(h.end)
|
|
67
|
+
}
|
|
68
|
+
// 2) 无完整命中:从最早疑似锚点起驻留
|
|
69
|
+
const m = ANCHOR_RE.exec(this.#pending)
|
|
70
|
+
if (m && m.index !== undefined) {
|
|
71
|
+
out += this.#pending.slice(0, m.index)
|
|
72
|
+
this.#pending = this.#pending.slice(m.index)
|
|
73
|
+
return out
|
|
74
|
+
}
|
|
75
|
+
// 3) 无锚点:放行除尾部驻留窗外的全部(尾窗防锚点跨 chunk 拼合)
|
|
76
|
+
if (this.#pending.length > TRAILING_HOLD) {
|
|
77
|
+
out += this.#pending.slice(0, this.#pending.length - TRAILING_HOLD)
|
|
78
|
+
this.#pending = this.#pending.slice(-TRAILING_HOLD)
|
|
79
|
+
}
|
|
80
|
+
return out
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 收口(finishReason 时点):全量扫描遮蔽剩余驻留 */
|
|
84
|
+
flush(): string {
|
|
85
|
+
const r = redact(this.#pending)
|
|
86
|
+
this.#pending = ''
|
|
87
|
+
return r.redacted
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 已遮蔽计数口径(测试/审计观察面):flush 前的累计放行不含命中原文 */
|
|
91
|
+
get pendingLength(): number {
|
|
92
|
+
return this.#pending.length
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M8 · 遥测门面(getTracer/getMeter ——《系统设计》§3.2.M8.2 遥测行 / §3.2.M8.5 隐私三原则)
|
|
3
|
+
*
|
|
4
|
+
* 口径(§3.2.M8.5 关键约束,逐条落地):
|
|
5
|
+
* - 默认关:无端点配置时不初始化任何 exporter——无配置 = noop 句柄 + 零出站(可机器断言);
|
|
6
|
+
* - 显式开:用户配置端点即视为开启(CAR_OTEL_ENDPOINT 或显式 config 传入);
|
|
7
|
+
* - 端点归用户:数据只发往用户自配 E-05;OTLP/HTTP JSON(fetch),导出超时 5s、0 重试、
|
|
8
|
+
* 端点不可达静默丢弃(BD-05——不抛错、不重试、不落审计);
|
|
9
|
+
* - 严格解耦:本文件零 import 自 session/*(遥测与审计日志禁止混流)——测试做静态断言;
|
|
10
|
+
* - S17 零内容计数器(telemetry/metrics.ts)是登记表兜底通道,与本门面并存互补:
|
|
11
|
+
* 本面是 OTel 形态的出站通道,不承接 S17 的零内容红线语义(内容治理属调用方);
|
|
12
|
+
* - 零依赖:OTel「标准句柄形态」自研(startSpan/attributes/end、createCounter/add),
|
|
13
|
+
* 不引入 @opentelemetry/* 依赖(项目零依赖红线,README engines 纪律)。
|
|
14
|
+
*/
|
|
15
|
+
import { randomBytes } from 'node:crypto'
|
|
16
|
+
|
|
17
|
+
export interface TelemetryConfig {
|
|
18
|
+
/** 用户自配 OTLP/HTTP 端点(如 https://otel.example.internal)——无值 = 默认关 */
|
|
19
|
+
endpoint?: string
|
|
20
|
+
serviceName?: string
|
|
21
|
+
headers?: Record<string, string>
|
|
22
|
+
/** 导出超时(§3.5.4 基线 5s) */
|
|
23
|
+
exportTimeoutMs?: number
|
|
24
|
+
/** 自动导出间隔 ms;0(缺省)= 仅手动 flush(测试/收口时点调用) */
|
|
25
|
+
intervalMs?: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TelemetrySpan {
|
|
29
|
+
setAttribute(key: string, value: string | number | boolean): this
|
|
30
|
+
addEvent(name: string, attrs?: Record<string, string | number | boolean>): this
|
|
31
|
+
recordException(err: unknown): this
|
|
32
|
+
end(): void
|
|
33
|
+
readonly traceId: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface TelemetryTracer {
|
|
37
|
+
startSpan(name: string, opts?: { attributes?: Record<string, string | number | boolean> }): TelemetrySpan
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface TelemetryCounter {
|
|
41
|
+
add(value: number, labels?: Record<string, string>): void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface TelemetryMeter {
|
|
45
|
+
createCounter(name: string): TelemetryCounter
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface TelemetryStats {
|
|
49
|
+
spansEnded: number
|
|
50
|
+
tracesExported: number
|
|
51
|
+
metricsExported: number
|
|
52
|
+
droppedExports: number
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface TelemetryFacade {
|
|
56
|
+
/** 默认关 = false(无端点配置时) */
|
|
57
|
+
readonly enabled: boolean
|
|
58
|
+
/** 幂等句柄(§3.2.M8.2:天然幂等) */
|
|
59
|
+
getTracer(): TelemetryTracer
|
|
60
|
+
getMeter(): TelemetryMeter
|
|
61
|
+
/** 手动导出(BD-05:端点不可达静默丢弃——resolve 不 reject) */
|
|
62
|
+
flush(): Promise<void>
|
|
63
|
+
shutdown(): Promise<void>
|
|
64
|
+
/** 观察面(测试/收口;不含内容字段) */
|
|
65
|
+
stats(): TelemetryStats
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ==================== noop 句柄(默认关路径:零出站、零积累) ====================
|
|
69
|
+
|
|
70
|
+
const noopSpan = (): TelemetrySpan => ({
|
|
71
|
+
setAttribute() { return noopSpan() },
|
|
72
|
+
addEvent() { return noopSpan() },
|
|
73
|
+
recordException() { return noopSpan() },
|
|
74
|
+
end() {},
|
|
75
|
+
traceId: '',
|
|
76
|
+
})
|
|
77
|
+
const NOOP_TRACER: TelemetryTracer = { startSpan: () => noopSpan() }
|
|
78
|
+
const NOOP_METER: TelemetryMeter = { createCounter: () => ({ add() {} }) }
|
|
79
|
+
|
|
80
|
+
// ==================== 活动门面(显式开路径) ====================
|
|
81
|
+
|
|
82
|
+
interface SpanRecord {
|
|
83
|
+
traceId: string
|
|
84
|
+
spanId: string
|
|
85
|
+
parentSpanId: string | null
|
|
86
|
+
name: string
|
|
87
|
+
startMs: number
|
|
88
|
+
endMs: number
|
|
89
|
+
attributes: Record<string, string | number | boolean>
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const attrValue = (v: string | number | boolean) =>
|
|
93
|
+
typeof v === 'string' ? { stringValue: v } : typeof v === 'boolean' ? { boolValue: v } : { doubleValue: v }
|
|
94
|
+
|
|
95
|
+
export function createTelemetryFacade(
|
|
96
|
+
config: TelemetryConfig | null | undefined,
|
|
97
|
+
opts: { fetchImpl?: typeof fetch; now?: () => number } = {},
|
|
98
|
+
): TelemetryFacade {
|
|
99
|
+
const endpoint = config?.endpoint?.replace(/\/+$/, '')
|
|
100
|
+
if (!endpoint) {
|
|
101
|
+
return {
|
|
102
|
+
enabled: false,
|
|
103
|
+
getTracer: () => NOOP_TRACER,
|
|
104
|
+
getMeter: () => NOOP_METER,
|
|
105
|
+
flush: async () => {},
|
|
106
|
+
shutdown: async () => {},
|
|
107
|
+
stats: () => ({ spansEnded: 0, tracesExported: 0, metricsExported: 0, droppedExports: 0 }),
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const fetchImpl = opts.fetchImpl ?? fetch
|
|
112
|
+
const now = opts.now ?? Date.now
|
|
113
|
+
const timeoutMs = config?.exportTimeoutMs ?? 5_000
|
|
114
|
+
const baseHeaders = { 'content-type': 'application/json', ...(config?.headers ?? {}) }
|
|
115
|
+
const serviceName = config?.serviceName ?? 'car-runtime'
|
|
116
|
+
|
|
117
|
+
const spans: SpanRecord[] = []
|
|
118
|
+
const counters = new Map<string, { labels: Record<string, string>; value: number }>()
|
|
119
|
+
const stats: TelemetryStats = { spansEnded: 0, tracesExported: 0, metricsExported: 0, droppedExports: 0 }
|
|
120
|
+
let tracer: TelemetryTracer | null = null
|
|
121
|
+
let meter: TelemetryMeter | null = null
|
|
122
|
+
let timer: ReturnType<typeof setInterval> | null = null
|
|
123
|
+
let flushing = false
|
|
124
|
+
|
|
125
|
+
const exportOtlp = async (path: string, body: unknown): Promise<boolean> => {
|
|
126
|
+
try {
|
|
127
|
+
// 单次尝试 + 有界超时(§3.5.4:OTel 导出 5s / 0 重试);失败静默丢弃(BD-05)
|
|
128
|
+
await fetchImpl(endpoint + path, {
|
|
129
|
+
method: 'POST',
|
|
130
|
+
headers: baseHeaders,
|
|
131
|
+
body: JSON.stringify(body),
|
|
132
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
133
|
+
})
|
|
134
|
+
return true
|
|
135
|
+
} catch {
|
|
136
|
+
return false
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const flush = async (): Promise<void> => {
|
|
141
|
+
if (flushing) return
|
|
142
|
+
flushing = true
|
|
143
|
+
try {
|
|
144
|
+
const spanBatch = spans.splice(0, spans.length)
|
|
145
|
+
if (spanBatch.length) {
|
|
146
|
+
const body = {
|
|
147
|
+
resource: { attributes: [{ key: 'service.name', value: { stringValue: serviceName } }] },
|
|
148
|
+
scopeSpans: [{
|
|
149
|
+
scope: { name: 'car-runtime' },
|
|
150
|
+
spans: spanBatch.map(s => ({
|
|
151
|
+
traceId: s.traceId,
|
|
152
|
+
spanId: s.spanId,
|
|
153
|
+
...(s.parentSpanId ? { parentSpanId: s.parentSpanId } : {}),
|
|
154
|
+
name: s.name,
|
|
155
|
+
kind: 'SPAN_KIND_INTERNAL',
|
|
156
|
+
startTimeUnixNano: String(s.startMs * 1e6),
|
|
157
|
+
endTimeUnixNano: String(s.endMs * 1e6),
|
|
158
|
+
attributes: Object.entries(s.attributes).map(([key, v]) => ({ key, value: attrValue(v) })),
|
|
159
|
+
status: { code: 'STATUS_CODE_UNSET' },
|
|
160
|
+
})),
|
|
161
|
+
}],
|
|
162
|
+
}
|
|
163
|
+
if (await exportOtlp('/v1/traces', body)) stats.tracesExported++
|
|
164
|
+
else stats.droppedExports++
|
|
165
|
+
}
|
|
166
|
+
if (counters.size) {
|
|
167
|
+
const batch = [...counters.entries()]
|
|
168
|
+
counters.clear()
|
|
169
|
+
const body = {
|
|
170
|
+
resource: { attributes: [{ key: 'service.name', value: { stringValue: serviceName } }] },
|
|
171
|
+
scopeMetrics: [{
|
|
172
|
+
scope: { name: 'car-runtime' },
|
|
173
|
+
metrics: batch.map(([name, c]) => ({
|
|
174
|
+
name,
|
|
175
|
+
sum: {
|
|
176
|
+
aggregationTemporality: 'AGGREGATION_TEMPORALITY_CUMULATIVE',
|
|
177
|
+
dataPoints: Object.entries(c.labels).length
|
|
178
|
+
? Object.entries(c.labels).map(([k, v]) => ({ asInt: c.value, attributes: [{ key: k, value: { stringValue: v } }] }))
|
|
179
|
+
: [{ asInt: c.value }],
|
|
180
|
+
},
|
|
181
|
+
})),
|
|
182
|
+
}],
|
|
183
|
+
}
|
|
184
|
+
if (await exportOtlp('/v1/metrics', body)) stats.metricsExported++
|
|
185
|
+
else stats.droppedExports++
|
|
186
|
+
}
|
|
187
|
+
} finally {
|
|
188
|
+
flushing = false
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if ((config?.intervalMs ?? 0) > 0) timer = setInterval(() => { void flush() }, config!.intervalMs!)
|
|
193
|
+
|
|
194
|
+
const activeTracer: TelemetryTracer = {
|
|
195
|
+
startSpan(name, startOpts) {
|
|
196
|
+
const record: SpanRecord = {
|
|
197
|
+
traceId: randomBytes(16).toString('hex'),
|
|
198
|
+
spanId: randomBytes(8).toString('hex'),
|
|
199
|
+
parentSpanId: null,
|
|
200
|
+
name,
|
|
201
|
+
startMs: now(),
|
|
202
|
+
endMs: 0,
|
|
203
|
+
attributes: { ...startOpts?.attributes },
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
get traceId() { return record.traceId },
|
|
207
|
+
setAttribute(key, value) { record.attributes[key] = value; return this },
|
|
208
|
+
addEvent(evtName, attrs) { record.attributes[`event.${evtName}`] = attrs ? JSON.stringify(attrs) : evtName; return this },
|
|
209
|
+
recordException(err) { record.attributes['exception.message'] = String((err as Error)?.message ?? err); return this },
|
|
210
|
+
end() {
|
|
211
|
+
record.endMs = now()
|
|
212
|
+
spans.push(record)
|
|
213
|
+
stats.spansEnded++
|
|
214
|
+
},
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const activeMeter: TelemetryMeter = {
|
|
220
|
+
createCounter(name) {
|
|
221
|
+
return {
|
|
222
|
+
add(value, labels = {}) {
|
|
223
|
+
const key = name + JSON.stringify(labels)
|
|
224
|
+
const cur = counters.get(key) ?? { labels, value: 0 }
|
|
225
|
+
cur.value += value
|
|
226
|
+
counters.set(key, cur)
|
|
227
|
+
},
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
enabled: true,
|
|
234
|
+
getTracer() { tracer ??= activeTracer; return tracer },
|
|
235
|
+
getMeter() { meter ??= activeMeter; return meter },
|
|
236
|
+
flush,
|
|
237
|
+
async shutdown() {
|
|
238
|
+
if (timer) { clearInterval(timer); timer = null }
|
|
239
|
+
await flush()
|
|
240
|
+
},
|
|
241
|
+
stats: () => ({ ...stats }),
|
|
242
|
+
}
|
|
243
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M8 · 运行时底座共享 DTO(《系统设计》§3.2.M8.3 / §3.3.1 O-10/O-11/O-13/O-14)
|
|
3
|
+
*
|
|
4
|
+
* 口径:
|
|
5
|
+
* - LlmChunk.finishReason 为不可变透传(readonly 语义,ADR-001;vLLM #53269 教训 SR-17)——
|
|
6
|
+
* 唯一信号源,禁止中间层改写/吞没;llm.ts 的流包装层对此做防御性断言;
|
|
7
|
+
* - LlmRequest.tools 含 declaredSideEffect(权限门依据;readonly 是 ADR-001 补错重试白名单前置);
|
|
8
|
+
* - ToolDefinition 为 M8 共享 DTO(O-10):M3 授权门/M5 MCP 网关/M8 模型请求三方复用;
|
|
9
|
+
* 与 loop/stop.ts 的 ToolDef 分工——ToolDefinition 是声明面(进模型 schema),ToolDef 是执行面;
|
|
10
|
+
* - CredentialRef 不落明文(O-13):ref 只含来源定位,值经 credentials.reveal 按需取用;
|
|
11
|
+
* - 规格外扩展字段(adapterId / error)以加法演进登记——与 hostRaw 同纪律,不改动规格最小集语义。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** 模型请求(Provider 无关) */
|
|
15
|
+
export interface LlmRequest {
|
|
16
|
+
model: string
|
|
17
|
+
messages: Array<{ role: 'system' | 'user' | 'assistant' | 'toolResult'; content: unknown }>
|
|
18
|
+
/** 工具 schema(缓存失效因素:工具集合/模型切换/compaction,D3 §14.6) */
|
|
19
|
+
tools: ToolDefinition[]
|
|
20
|
+
maxTokens?: number
|
|
21
|
+
metadata?: { sessionId: string; turnId: string; traceId: string }
|
|
22
|
+
// —— 规格外加法扩展(登记:M8 收口文档 §4)——
|
|
23
|
+
/** 多适配器在册时显式路由;缺省用默认适配器 */
|
|
24
|
+
adapterId?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 工具调用流式增量(跨 chunk 聚合出完整 ToolCall) */
|
|
28
|
+
export interface ToolCallDelta {
|
|
29
|
+
index?: number
|
|
30
|
+
id?: string
|
|
31
|
+
name?: string
|
|
32
|
+
/** JSON 增量片段,聚合后整体 JSON.parse(半截 JSON 不解析——ADR-001 同纪律) */
|
|
33
|
+
argumentsDelta?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 流式块(finishReason 一经产生不可被任何中间层改写) */
|
|
37
|
+
export interface LlmChunk {
|
|
38
|
+
delta?: string
|
|
39
|
+
toolCallDelta?: ToolCallDelta
|
|
40
|
+
finishReason?: 'stop' | 'length' | 'toolUse' | 'error' | 'aborted'
|
|
41
|
+
// —— 规格外加法扩展(登记:M8 收口文档 §4)——
|
|
42
|
+
/** finishReason='error' 时的结构化原因(消费方落 turnEnd meta,不进模型消息流) */
|
|
43
|
+
error?: { code: string; message: string }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Provider 适配器(ACL,C-02):统一签名,消解 Provider 差异 */
|
|
47
|
+
export interface LlmAdapter {
|
|
48
|
+
/** 如 openai-compat / anthropic */
|
|
49
|
+
id: string
|
|
50
|
+
chat(req: LlmRequest): AsyncIterable<LlmChunk>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 可逆注册句柄(dispose = Effect 回卷) */
|
|
54
|
+
export interface Disposable {
|
|
55
|
+
dispose(): void
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 共享工具声明 DTO(O-10;注册时构造,进模型请求 schema) */
|
|
59
|
+
export interface ToolDefinition {
|
|
60
|
+
name: string
|
|
61
|
+
description?: string
|
|
62
|
+
/** JSON Schema 形态参数声明 */
|
|
63
|
+
parameters?: unknown
|
|
64
|
+
/** 副作用声明(必填——M5 网关 T-22 强制点同一口径:缺声明即 write) */
|
|
65
|
+
declaredSideEffect: 'readonly' | 'write'
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 凭据来源(§3.2.M8.3 字段表:env 需用户显式开启 fallback) */
|
|
69
|
+
export type CredentialSource = 'keychain' | 'env'
|
|
70
|
+
|
|
71
|
+
/** 凭据引用(O-13):只含来源定位与掩码提示,值永不入此结构 */
|
|
72
|
+
export interface CredentialRef {
|
|
73
|
+
provider: string
|
|
74
|
+
source: CredentialSource
|
|
75
|
+
/** 来源标识:keychain 服务名 / env 变量名 */
|
|
76
|
+
origin: string
|
|
77
|
+
/** keychain 通道不可用等降级留痕(降级必须显式,禁静默) */
|
|
78
|
+
degraded?: string
|
|
79
|
+
}
|
package/test/s18.spec.ts
CHANGED
|
@@ -50,6 +50,15 @@ test('S18: 采集窗口——会话结束 stderr 输出零内容快照(登记
|
|
|
50
50
|
assert.ok('car_registry_decision|source=registry' in snap)
|
|
51
51
|
})
|
|
52
52
|
|
|
53
|
+
test('S18+: 1.1-S4 边界——未指定 --plugin 时装载计数不出现(装载接线不改变缺省协议面与快照键集)', async () => {
|
|
54
|
+
const { stderr } = await talk('claude-code', [
|
|
55
|
+
JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'session_start', arguments: { hostSessionId: 's' } } }),
|
|
56
|
+
])
|
|
57
|
+
const snap = JSON.parse(/snapshot=(\{.*?\}) zeroContent/.exec(stderr)![1])
|
|
58
|
+
assert.ok(!('car_load_total|result=ok' in snap), '无 --plugin = 无装载计数(1.1 装载接线为显式 opt-in)')
|
|
59
|
+
assert.ok(!('car_unsigned_confirmed|confirmed=no' in snap))
|
|
60
|
+
})
|
|
61
|
+
|
|
53
62
|
test('S18: codex 宿主接入(同一入口不同 hostId)', async () => {
|
|
54
63
|
const { outs } = await talk('codex', [
|
|
55
64
|
JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'session_start', arguments: { hostSessionId: 'cx-e2e' } } }),
|