oh-my-knowledge 0.51.0 → 0.51.2

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.
Files changed (33) hide show
  1. package/dist/assets/agent-skills/omk/SKILL.md +2 -0
  2. package/dist/assets/agent-skills/omk/references/commands.md +2 -2
  3. package/dist/authoring/generator.d.ts +15 -5
  4. package/dist/authoring/generator.js +181 -18
  5. package/dist/authoring/sample-fixer.d.ts +2 -0
  6. package/dist/authoring/sample-fixer.js +19 -5
  7. package/dist/cli/commands/sample.js +11 -4
  8. package/dist/eval-core/dependency-checker.js +30 -18
  9. package/dist/eval-core/evaluation-execution.js +11 -2
  10. package/dist/eval-core/fact-checker.d.ts +15 -2
  11. package/dist/eval-core/fact-checker.js +75 -20
  12. package/dist/eval-core/mock-hook.cjs +40 -1
  13. package/dist/eval-core/mocks-runtime.js +39 -2
  14. package/dist/eval-core/task-planner.d.ts +2 -2
  15. package/dist/eval-core/task-planner.js +7 -7
  16. package/dist/eval-workflows/evaluation-pipeline.js +2 -0
  17. package/dist/eval-workflows/run-evaluation.js +2 -1
  18. package/dist/executors/capabilities.d.ts +15 -0
  19. package/dist/executors/capabilities.js +64 -0
  20. package/dist/executors/index.d.ts +1 -0
  21. package/dist/executors/index.js +4 -1
  22. package/dist/observability/codex-exec-command.d.ts +7 -0
  23. package/dist/observability/codex-exec-command.js +134 -0
  24. package/dist/observability/codex-trace-adapter.js +16 -6
  25. package/dist/observability/trace-attribution.js +11 -107
  26. package/dist/server/skill-insights.js +1 -1
  27. package/dist/shared/sample-contract.d.ts +1 -0
  28. package/dist/shared/sample-contract.js +35 -0
  29. package/dist/shared/tool-identity.d.ts +8 -0
  30. package/dist/shared/tool-identity.js +13 -0
  31. package/dist/types/eval.d.ts +7 -6
  32. package/dist/types/executor.d.ts +3 -2
  33. package/package.json +4 -4
@@ -123,6 +123,8 @@ omk sample skills/my-skill/SKILL.md --focus "重点覆盖搜索失败 / 权限
123
123
  omk sample --batch
124
124
  ```
125
125
 
126
+ 目标执行器不支持工具拦截时,`omk sample` 会自动生成无 mock 用例。当前 `codex` / `codex-sdk` 属于这种情况;不要手工补 `mocks` 或 `mock_hit`。已有 mocks 用例会被 `omk eval` 在模型调用前拒绝,避免把执行器能力缺口误判成模型失败。`environment.files_available` 只提供题设上下文,不会在 `cwd` 物化文件。
127
+
126
128
  输出位置:目录 skill(`<skill>/SKILL.md`)→ `<skill>/.omk/samples.json`(标准);扁平 `.md` 单次生成 → 当前目录 `eval-samples.json`(项目级兜底);扁平 `.md` 的 `--batch` 兼容生成 `<skill-dir>/<name>.eval-samples.json`。
127
129
 
128
130
  ### 观测真实使用
@@ -602,7 +602,7 @@ omk sample [skillPath] [flags]
602
602
  - `--from-traces` `boolean`:from-traces 模式:从 observe inbox 的失败信号回流生成评测用例草稿(provenance: production-trace),落草稿待人工 review。
603
603
  - `--lang` `option` (默认 `zh`):输出语言 zh|en,优先级 CLI > OMK_LANG env > zh。
604
604
  - `--model` `option`:生成 LLM model 名。Codex 自动读取本机配置;也可用 OMK_MODEL 设置环境偏好。
605
- - `--no-mock` `boolean`:不生成 mocks,eval 时所有工具调用真实执行。
605
+ - `--no-mock` `boolean`:不生成 mocks。执行器不支持工具拦截时会自动启用,避免产生必然失败的 mock_hit。
606
606
  - `--observations-dir` `option`:observe inbox 目录(from-traces 模式用),默认项目 .omk/observe-inbox。
607
607
  - `--reports-dir` `option`:报告目录(fix 模式用),默认 ~/.oh-my-knowledge/reports。
608
608
  - `--skill` `option`:仅从指定 skill 的 observe inbox 信号生成草稿(仅 from-traces 模式用)。
@@ -689,7 +689,7 @@ omk studio --port 8080 --no-open
689
689
  | `construct` | 否 | 测的是什么构念 |
690
690
  | `provenance` | 否 | 用例来源(`omk sample` 自动打) |
691
691
  | `mocks` | 否 | 工具调用 mock 返回(sandbox 评测) |
692
- | `environment` | 否 | 评测环境前置「已就绪」声明 |
692
+ | `environment` | 否 | 题设环境声明(仅注入 prompt,不物化) |
693
693
  | `tripwire` | 否 | 标记为「故意诱错」样本,failed 时 diagnostic 不建议改 skill |
694
694
 
695
695
  完整 schema 见 [docs/specs/sample-design-spec.md](https://github.com/lizhiyao/oh-my-knowledge/blob/main/docs/specs/sample-design-spec.md)。
@@ -1,6 +1,7 @@
1
- import type { Sample, ExecutorFn } from '../types/index.js';
1
+ import type { ExecutorFn, Sample } from '../types/index.js';
2
2
  import type { ObservationInboxItem } from '../types/observability.js';
3
- interface GenerateSamplesOptions {
3
+ export declare function sampleGenerationUsesMocks(executorName: string | undefined, noMock?: boolean): boolean;
4
+ export interface GenerateSamplesOptions {
4
5
  skillContent: string;
5
6
  count?: number;
6
7
  model: string;
@@ -12,6 +13,8 @@ interface GenerateSamplesOptions {
12
13
  focus?: string;
13
14
  /** 不生成 mocks/mocksStrict,eval 时真实执行所有工具调用。 */
14
15
  noMock?: boolean;
16
+ /** Injectable executor for tests. Defaults to createExecutor(executorName). */
17
+ executor?: ExecutorFn;
15
18
  }
16
19
  /**
17
20
  * 拼出送给 LLM 的 user prompt。抽出来便于单测验证 focus 是否真的注入了。
@@ -26,7 +29,7 @@ export declare function buildSamplesPrompt({ skillContent, count, focus, noMock
26
29
  focus?: string;
27
30
  noMock?: boolean;
28
31
  }): string;
29
- export declare function generateSamples({ skillContent, count, model, executorName, focus, noMock }: GenerateSamplesOptions): Promise<{
32
+ export declare function generateSamples({ skillContent, count, model, executorName, focus, noMock, executor: injectedExecutor, }: GenerateSamplesOptions): Promise<{
30
33
  samples: Sample[];
31
34
  costUSD: number;
32
35
  }>;
@@ -62,12 +65,16 @@ export declare function stratifyTraceSignals(items: TraceSignalItem[]): Stratifi
62
65
  * production failures. The trace text feeds the *generator* only — never the
63
66
  * judge prompt — so judge-prompt isolation is unaffected.
64
67
  */
65
- export declare function buildSamplesFromTracesPrompt(items: TraceSignalItem[], count?: number): string;
68
+ export declare function buildSamplesFromTracesPrompt(items: TraceSignalItem[], count?: number, options?: {
69
+ noMock?: boolean;
70
+ }): string;
66
71
  export interface GenerateSamplesFromTracesOptions {
67
72
  items: TraceSignalItem[];
68
73
  count?: number;
69
74
  model: string;
70
75
  executorName?: string;
76
+ /** 不生成 mocks。目标 executor 不支持时会自动启用。 */
77
+ noMock?: boolean;
71
78
  /** Injectable executor (tests). Defaults to createExecutor(executorName). */
72
79
  executor?: ExecutorFn;
73
80
  }
@@ -77,12 +84,15 @@ export interface GenerateSamplesFromTracesOptions {
77
84
  * stamps `provenance: 'production-trace'`. Output is meant to land in a review draft,
78
85
  * not the live dataset (the CLI enforces that).
79
86
  */
80
- export declare function generateSamplesFromTraces({ items, count, model, executorName, executor: injectedExecutor, }: GenerateSamplesFromTracesOptions): Promise<{
87
+ export declare function generateSamplesFromTraces({ items, count, model, executorName, noMock, executor: injectedExecutor, }: GenerateSamplesFromTracesOptions): Promise<{
81
88
  samples: Sample[];
82
89
  costUSD: number;
83
90
  }>;
84
91
  export declare function sanitizeGeneratedSamples(samples: Sample[], opts?: {
85
92
  skillContent?: string;
93
+ mockless?: boolean;
94
+ migrateMocklessEnvironment?: boolean;
95
+ preserveMocklessEnvironment?: boolean;
86
96
  }): {
87
97
  stripped: string[];
88
98
  };
@@ -1,5 +1,7 @@
1
1
  import { createExecutor } from '../executors/index.js';
2
+ import { executorSupportsSampleMocks } from '../executors/capabilities.js';
2
3
  import { DEFAULT_GATE_THRESHOLD } from '../eval-core/verdict.js';
4
+ import { sampleMockReferenceKeys } from '../shared/sample-contract.js';
3
5
  const SYSTEM_PROMPT = `你是一个评测用例生成器。你的任务是根据用户提供的 skill(系统提示词)内容,生成高质量的评测用例。
4
6
 
5
7
  样本结构决策(必须先做):先扫一遍 skill 内容判断它属于哪一类,按对应配比和数量生成。
@@ -195,14 +197,14 @@ const SYSTEM_PROMPT = `你是一个评测用例生成器。你的任务是根据
195
197
  tool_input_not_contains 或 tools_not_called。
196
198
  - { "type": "regex", "pattern": "...", "weight": 1 }
197
199
  ↑ 同 contains 限制:只用在固定格式字面量(如 SHA / UUID / 路径模板)。
198
- - environment: 可选,对象。**评测环境的"已就绪"声明**,LLM 看到后跳过环境探测直接进工作流。
200
+ - environment: 可选,对象。**仅作 prompt 上下文的题设环境声明**,不会修改 PATH、创建文件或物化 fixture。
199
201
  字段:
200
202
  - cli_available: string[],已在 PATH 上的 CLI(如 ["node", "git", "code-host"])
201
203
  - files_available: string[],已存在的文件/脚本(如 ["~/.req-tool-api.json", "$SKILL_DIR/scripts/x.js"])
202
204
  - notes: string,自由文本兜底(如"DevAPI 凭证有效,工号 testuser001")
203
205
  原则:
204
- 凡是 skill 跑起来需要的环境(凭证文件 / 业务 CLI / 自带脚本 / API token 等),
205
- 都写到这里,而不是在 mock mock 它们的探测命令。这让 mock 只关注业务调用本身。
206
+ 只有用例明确把某项环境能力作为题设前提时才写。需要读取真实内容的文件不能放在
207
+ files_available 里冒充 fixture:应放进 sample.cwd 下的真实 fixture,或由 mock 返回内容。
206
208
  - mocksStrict: **必填且必须设为 true**(只要 sample 配了 mocks)。
207
209
  原因:mocksStrict=false 时,LLM 调到没匹配 mock 的命令会**透传到真 shell**,
208
210
  既可能真调外部接口产生副作用,也可能因二进制不存在(如 mcporter)报噪声错误污染评测信号。
@@ -223,8 +225,8 @@ const SYSTEM_PROMPT = `你是一个评测用例生成器。你的任务是根据
223
225
  - 对(✅):写一条宽 mock:\`{tool:"Bash", match:{command_glob:"ls *"},
224
226
  return:{stdout:"<模拟目录列表>", exit:0}}\` — \`command_glob\` 用 \`*\` 兜底各种
225
227
  ls 参数变体(\`ls\` / \`ls -la\` / \`ls -d\` / \`ls /xx\` 全命中)
226
- (c) 单纯"已就绪"声明(凭证文件 / 业务 CLI 是否安装)还是走 \`environment\` 字段,
227
- 不需要 LLM 真调命令检查 — environment 字段就是告诉 LLM "这些不用检查"
228
+ (c) 题设明确声明可用的凭证 / CLI 可写进 \`environment\`,让 LLM 不做可用性探测。
229
+ 该字段只进 prompt,不会真的安装 CLI、创建凭证文件或改变 runtime
228
230
  (d) **intent-level mock(文件搜索/读取类操作)** — LLM 搜代码时会自由选择 Bash grep、
229
231
  Grep 工具、Glob+Read 组合、甚至 Agent 子代理,逐个枚举工具写 mock 不可持续。
230
232
  正确做法:用 \`tool: "*"\` + \`input_contains: "关键词"\` 按意图匹配:
@@ -367,6 +369,37 @@ const SYSTEM_PROMPT = `你是一个评测用例生成器。你的任务是根据
367
369
  - 字符串字段(prompt / rubric / capability 等)内部如需引号,**必须用全角「」**而不是半角 \`""\`,避免漏转义破坏 JSON 解析
368
370
  - 例:错 → \`"prompt": "查询"Daily"标签..."\`(内部 \`"\` 未转义,JSON 解析失败)
369
371
  对 → \`"prompt": "查询「Daily」标签..."\`(全角引号,无转义压力)`;
372
+ const MOCKLESS_SYSTEM_OVERRIDE = `
373
+
374
+ ## 目标执行器能力约束(最高优先级)
375
+
376
+ 目标执行器不支持可靠的工具调用拦截。本节覆盖上文所有要求 mocks、mock_hit、
377
+ files_available 或正向工具调用断言的规则:
378
+
379
+ - 不要输出 mocks、mocksStrict、environment 字段。
380
+ - 不要输出 mock_hit、tools_called、tools_count_min、tool_input_contains、tool_output_contains。
381
+ - 可以保留 tools_not_called、tools_count_max、tool_input_not_contains 这类负向安全约束。
382
+ - 把必要的输入事实直接写进 context,把可判分结果写进 rubric 或输出内容断言。
383
+ - 不要假装某个文件、CLI 或工具调用已经由 fixture 物化。`;
384
+ function generationSystemPrompt(mockless) {
385
+ return mockless ? `${SYSTEM_PROMPT}${MOCKLESS_SYSTEM_OVERRIDE}` : SYSTEM_PROMPT;
386
+ }
387
+ const warnedAutoMocklessExecutors = new Set();
388
+ function warnAutoMockless(executorName, noMock) {
389
+ if (noMock
390
+ || !executorName
391
+ || executorSupportsSampleMocks(executorName)
392
+ || warnedAutoMocklessExecutors.has(executorName))
393
+ return;
394
+ warnedAutoMocklessExecutors.add(executorName);
395
+ process.stderr.write(`[omk sample] 执行器「${executorName}」不支持工具调用拦截,`
396
+ + '已自动切换为无 mocks 用例。\n');
397
+ }
398
+ export function sampleGenerationUsesMocks(executorName, noMock = false) {
399
+ if (noMock)
400
+ return false;
401
+ return executorName ? executorSupportsSampleMocks(executorName) : true;
402
+ }
370
403
  /**
371
404
  * 拼出送给 LLM 的 user prompt。抽出来便于单测验证 focus 是否真的注入了。
372
405
  *
@@ -390,9 +423,12 @@ ${skillContent}
390
423
 
391
424
  ${countLine}直接输出 JSON 数组。${focusBlock}${noMockBlock}`;
392
425
  }
393
- export async function generateSamples({ skillContent, count, model, executorName, focus, noMock }) {
394
- const executor = createExecutor(executorName);
395
- const prompt = buildSamplesPrompt({ skillContent, count, focus, noMock });
426
+ export async function generateSamples({ skillContent, count, model, executorName, focus, noMock, executor: injectedExecutor, }) {
427
+ const executor = injectedExecutor ?? createExecutor(executorName);
428
+ const mockless = !sampleGenerationUsesMocks(executorName, noMock);
429
+ warnAutoMockless(executorName, noMock);
430
+ const prompt = buildSamplesPrompt({ skillContent, count, focus, noMock: mockless });
431
+ const system = generationSystemPrompt(mockless);
396
432
  // 生成场景比单次 eval 调用更重(LLM 要思考结构 + 输出大段 JSON),
397
433
  // 默认 120s 对长 skill + count >= 8 经常不够,这里用 5 分钟兜底。
398
434
  // lean=true 关掉 agent 工具循环 / skill 发现 — 生成只需要纯文本,不需要 Bash / Read 等工具。
@@ -405,7 +441,7 @@ export async function generateSamples({ skillContent, count, model, executorName
405
441
  const attemptPrompt = attempt === 1
406
442
  ? prompt
407
443
  : `${prompt}\n\n上一次输出解析失败:${lastErr}\n请严格按 JSON 规范输出(字符串内部用「」全角引号),只输出数组,不要包含其他文字。`;
408
- const result = await executor({ model, system: SYSTEM_PROMPT, prompt: attemptPrompt, timeoutMs: 300_000, lean: true });
444
+ const result = await executor({ model, system, prompt: attemptPrompt, timeoutMs: 300_000, lean: true });
409
445
  totalCost += result.costUSD || 0;
410
446
  if (!result.ok) {
411
447
  lastErr = result.error || 'unknown error';
@@ -435,12 +471,15 @@ export async function generateSamples({ skillContent, count, model, executorName
435
471
  continue;
436
472
  }
437
473
  // 通过校验,跳出循环继续后续 sanitize
438
- return await finalizeSamples(samples, totalCost, skillContent);
474
+ return await finalizeSamples(samples, totalCost, {
475
+ skillContent,
476
+ mockless,
477
+ });
439
478
  }
440
479
  // 不可达 (循环里所有出口都 throw 或 return),保留是为了 TS 类型推断
441
480
  throw new Error('unreachable');
442
481
  }
443
- async function finalizeSamples(samples, costUSD, skillContent) {
482
+ async function finalizeSamples(samples, costUSD, options) {
444
483
  // Validate required fields + sanitize metadata enums *at generator boundary*
445
484
  // (see sanitizeGeneratedSamples). skillContent is passed so the function can
446
485
  // strip "脑补"-style fact assertions whose tool name has no literal mention
@@ -448,7 +487,10 @@ async function finalizeSamples(samples, costUSD, skillContent) {
448
487
  // the data-security-review v1-v5 regen series (generator kept producing
449
488
  // tool_input_contains "WebFetch:语雀URL" even after 7 prompt iterations,
450
489
  // because LLM's "URL → fetch" training prior overrides instructional text).
451
- const { stripped } = sanitizeGeneratedSamples(samples, { skillContent });
490
+ const { stripped } = sanitizeGeneratedSamples(samples, {
491
+ ...options,
492
+ migrateMocklessEnvironment: true,
493
+ });
452
494
  if (stripped.length > 0) {
453
495
  process.stderr.write(`[omk sample] LLM-output 含 ${stripped.length} 个非法元数据/断言字段,已剥离避免污染:\n - ${stripped.join('\n - ')}\n`);
454
496
  }
@@ -492,7 +534,7 @@ export function stratifyTraceSignals(items) {
492
534
  * production failures. The trace text feeds the *generator* only — never the
493
535
  * judge prompt — so judge-prompt isolation is unaffected.
494
536
  */
495
- export function buildSamplesFromTracesPrompt(items, count) {
537
+ export function buildSamplesFromTracesPrompt(items, count, options = {}) {
496
538
  // 先按频次分层(合并重复 + 算占比 + 降序),让模型按「占比」分配配额,而非每信号一刀切。
497
539
  const stratified = stratifyTraceSignals(items);
498
540
  const sections = stratified.map((it, i) => {
@@ -515,13 +557,16 @@ export function buildSamplesFromTracesPrompt(items, count) {
515
557
  const countLine = typeof count === 'number'
516
558
  ? `共生成约 ${count} 条评测用例,按各信号的「占比」分配配额(高频多、低频少),覆盖整体失败分布。`
517
559
  : '按各信号「占比」分配:高频信号多生成、低频少生成,覆盖整体失败分布。';
560
+ const mocklessBlock = options.noMock
561
+ ? '\n\n目标执行器不支持工具调用拦截:不要生成 mocks、mocksStrict、environment 或正向工具调用断言;把 trace 证据写入 context 和 rubric。'
562
+ : '';
518
563
  return `${TRACE_GEN_INSTRUCTIONS}
519
564
 
520
565
  ## 观测到的失败信号(共 ${stratified.length} 个,已按出现频次降序)
521
566
 
522
567
  ${sections}
523
568
 
524
- ${countLine}直接输出 JSON 数组。`;
569
+ ${countLine}直接输出 JSON 数组。${mocklessBlock}`;
525
570
  }
526
571
  /** Build a sanitize context string from trace evidence so finalizeSamples keeps
527
572
  * tool-name assertions that reference tools actually seen in the traces (an empty
@@ -539,14 +584,17 @@ function traceSanitizeContext(items) {
539
584
  * stamps `provenance: 'production-trace'`. Output is meant to land in a review draft,
540
585
  * not the live dataset (the CLI enforces that).
541
586
  */
542
- export async function generateSamplesFromTraces({ items, count, model, executorName, executor: injectedExecutor, }) {
587
+ export async function generateSamplesFromTraces({ items, count, model, executorName, noMock, executor: injectedExecutor, }) {
543
588
  if (items.length === 0)
544
589
  return { samples: [], costUSD: 0 };
545
590
  if (!injectedExecutor && !executorName) {
546
591
  throw new Error('executorName is required when no executor is injected');
547
592
  }
548
593
  const executor = injectedExecutor ?? createExecutor(executorName);
549
- const prompt = buildSamplesFromTracesPrompt(items, count);
594
+ const mockless = !sampleGenerationUsesMocks(executorName, noMock);
595
+ warnAutoMockless(executorName, noMock);
596
+ const prompt = buildSamplesFromTracesPrompt(items, count, { noMock: mockless });
597
+ const system = generationSystemPrompt(mockless);
550
598
  const sanitizeContext = traceSanitizeContext(items);
551
599
  const PROVENANCE = 'production-trace';
552
600
  const MAX_ATTEMPTS = 3;
@@ -556,7 +604,7 @@ export async function generateSamplesFromTraces({ items, count, model, executorN
556
604
  const attemptPrompt = attempt === 1
557
605
  ? prompt
558
606
  : `${prompt}\n\n上一次输出解析失败:${lastErr}\n请严格按 JSON 规范输出(字符串内部用「」全角引号),只输出数组,不要包含其他文字。`;
559
- const result = await executor({ model, system: SYSTEM_PROMPT, prompt: attemptPrompt, timeoutMs: 300_000, lean: true });
607
+ const result = await executor({ model, system, prompt: attemptPrompt, timeoutMs: 300_000, lean: true });
560
608
  totalCost += result.costUSD || 0;
561
609
  if (!result.ok) {
562
610
  lastErr = result.error || 'unknown error';
@@ -593,7 +641,10 @@ export async function generateSamplesFromTraces({ items, count, model, executorN
593
641
  // Stamp provenance before sanitize so it survives (it's a valid enum value).
594
642
  for (const s of samples)
595
643
  s.provenance = PROVENANCE;
596
- return await finalizeSamples(samples, totalCost, sanitizeContext);
644
+ return await finalizeSamples(samples, totalCost, {
645
+ skillContent: sanitizeContext,
646
+ mockless,
647
+ });
597
648
  }
598
649
  throw new Error('unreachable');
599
650
  }
@@ -656,6 +707,78 @@ const TEXT_VALUE_TYPES = new Set([
656
707
  const TOOL_POSITIVE_TYPES = new Set([
657
708
  'tool_input_contains', 'tool_output_contains', 'mock_hit',
658
709
  ]);
710
+ const MOCKLESS_POSITIVE_TOOL_TYPES = new Set([
711
+ 'mock_hit',
712
+ 'tools_called',
713
+ 'tools_count_min',
714
+ 'tool_input_contains',
715
+ 'tool_output_contains',
716
+ ]);
717
+ function stripMocklessPositiveToolAssertions(assertions, label, stripped) {
718
+ const kept = [];
719
+ for (const [index, assertion] of assertions.entries()) {
720
+ const assertionLabel = `${label}[${index}]`;
721
+ if (assertion?.type === 'assert-set' && Array.isArray(assertion.children)) {
722
+ const children = stripMocklessPositiveToolAssertions(assertion.children, `${assertionLabel}.children`, stripped);
723
+ if (children.length === 0) {
724
+ stripped.push(`${assertionLabel}.assert-set(无 mock 模式下没有可执行子断言)`);
725
+ continue;
726
+ }
727
+ assertion.children = children;
728
+ kept.push(assertion);
729
+ continue;
730
+ }
731
+ if (MOCKLESS_POSITIVE_TOOL_TYPES.has(assertion?.type)) {
732
+ stripped.push(`${assertionLabel}.${assertion.type}(目标执行器不支持 mocks,正向工具证据不可复现)`);
733
+ continue;
734
+ }
735
+ kept.push(assertion);
736
+ }
737
+ return kept;
738
+ }
739
+ function stripInvalidMockHitAssertions(assertions, mockKeys, label, stripped) {
740
+ const kept = [];
741
+ for (const [index, assertion] of assertions.entries()) {
742
+ const assertionLabel = `${label}[${index}]`;
743
+ if (assertion?.type === 'assert-set' && Array.isArray(assertion.children)) {
744
+ const children = stripInvalidMockHitAssertions(assertion.children, mockKeys, `${assertionLabel}.children`, stripped);
745
+ if (children.length === 0) {
746
+ stripped.push(`${assertionLabel}.assert-set(没有可执行子断言)`);
747
+ continue;
748
+ }
749
+ assertion.children = children;
750
+ kept.push(assertion);
751
+ continue;
752
+ }
753
+ if (assertion?.type === 'mock_hit'
754
+ && typeof assertion.value === 'string'
755
+ && !mockKeys.has(assertion.value)) {
756
+ stripped.push(`${assertionLabel}.mock_hit(未引用实际 mock:${assertion.value})`);
757
+ continue;
758
+ }
759
+ kept.push(assertion);
760
+ }
761
+ return kept;
762
+ }
763
+ function environmentAsPromptContext(environment) {
764
+ if (!environment || typeof environment !== 'object' || Array.isArray(environment)) {
765
+ return null;
766
+ }
767
+ const env = environment;
768
+ const lines = ['题设环境声明(仅作上下文,不会在 cwd 物化):'];
769
+ if (Array.isArray(env.cli_available)
770
+ && env.cli_available.every((item) => typeof item === 'string' && item.length > 0)) {
771
+ lines.push(`- 可用 CLI:${env.cli_available.join('、')}`);
772
+ }
773
+ if (Array.isArray(env.files_available)
774
+ && env.files_available.every((item) => typeof item === 'string' && item.length > 0)) {
775
+ lines.push(`- 题设引用路径(未物化):${env.files_available.join('、')}`);
776
+ }
777
+ if (typeof env.notes === 'string' && env.notes.trim()) {
778
+ lines.push(`- 说明:${env.notes.trim()}`);
779
+ }
780
+ return lines.length > 1 ? lines.join('\n') : null;
781
+ }
659
782
  function isAsciiTokenLike(v) {
660
783
  if (typeof v !== 'string')
661
784
  return false;
@@ -719,6 +842,40 @@ export function sanitizeGeneratedSamples(samples, opts = {}) {
719
842
  stripped.push(`samples[${i}].tripwire (${typeof s.tripwire})`);
720
843
  delete s.tripwire;
721
844
  }
845
+ if (opts.mockless) {
846
+ if (Array.isArray(s.mocks) && s.mocks.length > 0) {
847
+ stripped.push(`samples[${i}].mocks(目标执行器不支持工具调用拦截)`);
848
+ }
849
+ delete s.mocks;
850
+ if (s.mocksStrict !== undefined) {
851
+ stripped.push(`samples[${i}].mocksStrict(无 mocks)`);
852
+ }
853
+ delete s.mocksStrict;
854
+ if (s.environment !== undefined && !opts.preserveMocklessEnvironment) {
855
+ const environmentContext = opts.migrateMocklessEnvironment
856
+ ? environmentAsPromptContext(s.environment)
857
+ : null;
858
+ if (environmentContext) {
859
+ const existingContext = typeof s.context === 'string' ? s.context.trim() : '';
860
+ s.context = existingContext
861
+ ? `${existingContext}\n\n${environmentContext}`
862
+ : environmentContext;
863
+ stripped.push(`samples[${i}].environment(已迁移到题设 context,未物化)`);
864
+ }
865
+ else {
866
+ stripped.push(`samples[${i}].environment(未物化的环境声明不能充当 fixture)`);
867
+ }
868
+ }
869
+ if (!opts.preserveMocklessEnvironment) {
870
+ delete s.environment;
871
+ }
872
+ if (Array.isArray(s.assertions)) {
873
+ s.assertions = stripMocklessPositiveToolAssertions(s.assertions, `samples[${i}].assertions`, stripped);
874
+ if (s.assertions.length === 0) {
875
+ delete s.assertions;
876
+ }
877
+ }
878
+ }
722
879
  // assertions 校验:loader 会拒掉两类无效断言 — 在 generator boundary 提前 strip,
723
880
  // 避免落盘的 sample 跑不动:
724
881
  // 1. tools_called / tools_not_called 的 values 必须非空
@@ -843,6 +1000,12 @@ export function sanitizeGeneratedSamples(samples, opts = {}) {
843
1000
  delete s.mocks;
844
1001
  }
845
1002
  }
1003
+ if (Array.isArray(s.assertions)) {
1004
+ s.assertions = stripInvalidMockHitAssertions(s.assertions, sampleMockReferenceKeys(s.mocks), `samples[${i}].assertions`, stripped);
1005
+ if (s.assertions.length === 0) {
1006
+ delete s.assertions;
1007
+ }
1008
+ }
846
1009
  // mocksStrict 兜底:有 mocks 时强制 true。
847
1010
  // SYSTEM_PROMPT 已要求 LLM 必填,但偶尔 LLM 漏填 — 在 generator boundary 修掉,
848
1011
  // 避免运行时 mock 未命中透传到真 shell(报 mcporter not found 等噪声错误)。
@@ -34,6 +34,8 @@ export interface FixSamplesOptions {
34
34
  }>;
35
35
  model: string;
36
36
  maxAttemptsPerSample?: number;
37
+ /** Target executor cannot intercept tool calls; strip mocks and dependent positive evidence. */
38
+ mockless?: boolean;
37
39
  }
38
40
  export interface FixSamplesResult {
39
41
  samples: Record<string, unknown>[];
@@ -20,8 +20,17 @@ const FIX_SYSTEM_PROMPT = `你是一个评测用例修复专家。根据诊断
20
20
  输出一个 **JSON 数组**,包含所有待修复 sample(修改过的和原样保留的都要包含)。
21
21
  第一字符 \`[\`,最后 \`]\`,不要用 \`\`\`json\`\`\` 围栏,不要寒暄。
22
22
  如果判断某条是 LLM 行为问题不需要改,也原样放进数组。`;
23
- function sanitizeFixedSamples(samples, skillContent) {
24
- sanitizeGeneratedSamples(samples, { skillContent });
23
+ const MOCKLESS_FIX_OVERRIDE = `
24
+
25
+ 目标执行器不支持工具调用拦截。不得新增或保留 mocks、mocksStrict、
26
+ mock_hit、tools_called、tools_count_min、tool_input_contains、tool_output_contains。
27
+ environment 仅表示题设上下文,不得当作已物化 fixture;负向安全断言可以保留。`;
28
+ function sanitizeFixedSamples(samples, skillContent, mockless) {
29
+ sanitizeGeneratedSamples(samples, {
30
+ skillContent,
31
+ mockless,
32
+ preserveMocklessEnvironment: true,
33
+ });
25
34
  return samples;
26
35
  }
27
36
  const FIXABLE_SAMPLE_FIELDS = new Set([
@@ -97,7 +106,7 @@ function parseFixedSamples(text) {
97
106
  return null;
98
107
  }
99
108
  export async function fixSamples(options) {
100
- const { skillContent, samples, report, treatmentKey, executor, model, maxAttemptsPerSample = 2 } = options;
109
+ const { skillContent, samples, report, treatmentKey, executor, model, maxAttemptsPerSample = 2, mockless = false, } = options;
101
110
  const sampleMap = new Map(samples.map((s) => [s.sample_id, s]));
102
111
  // Collect all fixable samples into one batch
103
112
  const fixContexts = [];
@@ -185,7 +194,12 @@ ${sampleSections}
185
194
  let incurredCostUSD = 0;
186
195
  let incurredCostReported = false;
187
196
  try {
188
- const result = await executor({ model, system: FIX_SYSTEM_PROMPT, prompt, timeoutMs: 300_000 });
197
+ const result = await executor({
198
+ model,
199
+ system: mockless ? `${FIX_SYSTEM_PROMPT}${MOCKLESS_FIX_OVERRIDE}` : FIX_SYSTEM_PROMPT,
200
+ prompt,
201
+ timeoutMs: 300_000,
202
+ });
189
203
  incurredCostUSD = result.costUSD;
190
204
  incurredCostReported = result.costReported !== false;
191
205
  if (!result.ok) {
@@ -226,7 +240,7 @@ ${sampleSections}
226
240
  })),
227
241
  };
228
242
  }
229
- const sanitizedFixedArr = sanitizeFixedSamples(fixedArr, skillContent);
243
+ const sanitizedFixedArr = sanitizeFixedSamples(fixedArr, skillContent, mockless);
230
244
  const outOfScope = sanitizedFixedArr.find((fixed) => {
231
245
  const sid = fixed.sample_id;
232
246
  const original = sampleMap.get(sid);
@@ -274,7 +274,7 @@ async function runSampleFix(args, flags, lang) {
274
274
  throw new CliExit(1);
275
275
  }
276
276
  process.stderr.write(lang === 'zh' ? `🔧 发现 ${sampleDesignCount} 条 sample_design 失败,开始修复...\n` : `🔧 Found ${sampleDesignCount} sample_design failure(s), fixing...\n`);
277
- const { createExecutor } = await import('../../executors/index.js');
277
+ const { createExecutor, executorSupportsSampleMocks, } = await import('../../executors/index.js');
278
278
  const exec = createExecutor(executorName);
279
279
  const executorFn = async (opts) => {
280
280
  const result = await exec({
@@ -298,6 +298,7 @@ async function runSampleFix(args, flags, lang) {
298
298
  treatmentKey: treatmentName,
299
299
  executor: executorFn,
300
300
  model,
301
+ mockless: !executorSupportsSampleMocks(executorName),
301
302
  });
302
303
  let writtenFiles = [];
303
304
  if (result.fixedCount > 0) {
@@ -363,7 +364,13 @@ export async function runSampleFromTraces(flags, lang) {
363
364
  ? `🔭 发现 ${items.length} 个${flags.skill ? ` ${flags.skill} 的` : ''}失败信号,正在生成评测用例草稿...\n`
364
365
  : `🔭 Found ${items.length}${flags.skill ? ` ${flags.skill}` : ''} failure signal(s); generating regression-sample drafts...\n`);
365
366
  try {
366
- const { samples, costUSD } = await generateSamplesFromTraces({ items, count, model, executorName });
367
+ const { samples, costUSD } = await generateSamplesFromTraces({
368
+ items,
369
+ count,
370
+ model,
371
+ executorName,
372
+ noMock: flags['no-mock'],
373
+ });
367
374
  const cost = costUSD > 0 ? ` $${costUSD.toFixed(4)}` : '';
368
375
  if (samples.length === 0) {
369
376
  // The model conservatively skipped every signal (noise / unreproducible). That's a
@@ -663,8 +670,8 @@ export default class Sample extends BaseCommand {
663
670
  }),
664
671
  'no-mock': Flags.boolean({
665
672
  description: bilingual({
666
- zh: '不生成 mocks,eval 时所有工具调用真实执行。',
667
- en: 'Skip mock generation; all tool calls execute for real during eval.',
673
+ zh: '不生成 mocks。执行器不支持工具拦截时会自动启用,避免产生必然失败的 mock_hit。',
674
+ en: 'Skip mocks. Automatically enabled when the executor cannot intercept tools, preventing impossible mock_hit assertions.',
668
675
  }),
669
676
  default: false,
670
677
  }),
@@ -25,7 +25,14 @@ const SYSTEM_ENV_VARS = new Set([
25
25
  'TMPDIR', 'EDITOR', 'VISUAL', 'HOSTNAME', 'LOGNAME', 'DISPLAY',
26
26
  'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_CACHE_HOME',
27
27
  'NODE_ENV', 'NODE_PATH', 'NODE_OPTIONS', 'NPM_CONFIG_PREFIX',
28
+ // Agent Skills resolves this placeholder to the active skill directory.
29
+ // It is not a user-provided environment prerequisite.
30
+ 'SKILL_ROOT',
28
31
  ]);
32
+ // A shell probe that explicitly tolerates failure is discovering the target
33
+ // project shape. Candidate paths on that line are not hard dependencies of the
34
+ // skill itself.
35
+ const OPTIONAL_PROBE_LINE_REGEX = /(?:2>\s*\/dev\/null|\|\|\s*(?:true|:)(?:\s|$))/;
29
36
  function extractFromText(text) {
30
37
  const tools = new Set();
31
38
  const files = new Set();
@@ -42,25 +49,30 @@ function extractFromText(text) {
42
49
  tools.add(cmd);
43
50
  }
44
51
  }
45
- // File paths
46
- for (const match of text.matchAll(FILE_PATH_REGEX)) {
47
- const path = match[1];
48
- // Skip paths that look like URLs, package names, or version strings
49
- if (path.startsWith('http') || path.startsWith('node_modules') || /^\d/.test(path))
52
+ // File paths. Keep line context so optional discovery commands do not turn
53
+ // every candidate project entry point into a fatal preflight requirement.
54
+ for (const line of text.split(/\r?\n/)) {
55
+ if (OPTIONAL_PROBE_LINE_REGEX.test(line))
50
56
  continue;
51
- // Skip very short paths that are likely not real files
52
- if (path.length < 5)
53
- continue;
54
- // Skip extension-mention patterns(`.d.ts` / `.tsx` 这种以点开头的"扩展名讨论"
55
- // 不是真路径,SKILL.md 里"查看 .d.ts 文件"会被误识别)
56
- if (path.startsWith('.'))
57
- continue;
58
- // Skip bare filenames without a directory segment(`index.ts` / `package.json`
59
- // 这种通用文件名几乎都是示例性提及,真依赖会带路径段。要声明 bare 文件
60
- // 走显式 requires)
61
- if (!path.includes('/'))
62
- continue;
63
- files.add(path);
57
+ for (const match of line.matchAll(FILE_PATH_REGEX)) {
58
+ const path = match[1];
59
+ // Skip paths that look like URLs, package names, or version strings
60
+ if (path.startsWith('http') || path.startsWith('node_modules') || /^\d/.test(path))
61
+ continue;
62
+ // Skip very short paths that are likely not real files
63
+ if (path.length < 5)
64
+ continue;
65
+ // Skip extension-mention patterns(`.d.ts` / `.tsx` 这种以点开头的"扩展名讨论"
66
+ // 不是真路径,SKILL.md 里"查看 .d.ts 文件"会被误识别)
67
+ if (path.startsWith('.'))
68
+ continue;
69
+ // Skip bare filenames without a directory segment(`index.ts` / `package.json`
70
+ // 这种通用文件名几乎都是示例性提及,真依赖会带路径段。要声明 bare 文件
71
+ // 走显式 requires)
72
+ if (!path.includes('/'))
73
+ continue;
74
+ files.add(path);
75
+ }
64
76
  }
65
77
  // Environment variables
66
78
  for (const match of text.matchAll(ENV_VAR_REGEX)) {
@@ -258,8 +258,17 @@ export async function executeTasks({ tasks, executor, executorName, model, noJud
258
258
  }
259
259
  }
260
260
  let factCheck;
261
- if (execResult.ok && execResult.output && task.cwd) {
262
- factCheck = checkFacts(execResult.output, resolve(task.cwd));
261
+ if (execResult.ok && execResult.output) {
262
+ const sharedEvidence = {
263
+ ...(task._sample.context && { context: task._sample.context }),
264
+ ...(task._sample.environment?.files_available?.length && {
265
+ declaredFiles: task._sample.environment.files_available,
266
+ }),
267
+ // Resolve exactly like the executor's cwd. samplesBaseDir is for bundle
268
+ // assets such as mocks, not for changing Sample.cwd path semantics.
269
+ ...(task._sample.cwd && { cwd: resolve(task._sample.cwd) }),
270
+ };
271
+ factCheck = checkFacts(execResult.output, sharedEvidence);
263
272
  }
264
273
  const sampleResults = ownRecordValue(results, task.sample_id)
265
274
  ?? setOwnRecordValue(results, task.sample_id, {});
@@ -14,11 +14,24 @@ export interface FactCheckResult {
14
14
  totalCount: number;
15
15
  verifiedRate: number;
16
16
  }
17
+ export interface FactCheckEvidence {
18
+ /** Shared sample fixture root. Arm-specific execution directories must not be used. */
19
+ cwd?: string;
20
+ /** Facts supplied to both arms in the sample context. */
21
+ context?: string;
22
+ /** Declarative fixture paths from sample.environment.files_available. */
23
+ declaredFiles?: string[];
24
+ }
17
25
  /**
18
26
  * Extract file path claims from agent output text.
19
27
  */
20
28
  export declare function extractPathClaims(output: string): string[];
21
29
  /**
22
- * Check facts in agent output by verifying file paths exist in cwd.
30
+ * Check file-path facts against sample-level evidence shared by every arm.
31
+ *
32
+ * A string keeps the legacy direct-filesystem API for callers outside the
33
+ * evaluation pipeline. The pipeline passes structured evidence and deliberately
34
+ * excludes each artifact's execution cwd, because that directory is not a
35
+ * comparable source of truth across control and treatment arms.
23
36
  */
24
- export declare function checkFacts(output: string, cwd: string): FactCheckResult;
37
+ export declare function checkFacts(output: string, evidence: string | FactCheckEvidence): FactCheckResult;