minimal-agent 0.11.0 → 0.12.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.
Files changed (128) hide show
  1. package/package.json +4 -1
  2. package/plugins/workflow-runner/plugin.js +27 -15
  3. package/plugins/workflow-runner/plugin.ts +34 -20
  4. package/plugins/workflow-runner/src/agentCommon.js +13 -12
  5. package/plugins/workflow-runner/src/agentCommon.ts +22 -13
  6. package/plugins/workflow-runner/src/index.js +5 -3
  7. package/plugins/workflow-runner/src/index.ts +31 -13
  8. package/plugins/workflow-runner/src/multimodal.js +2 -1
  9. package/plugins/workflow-runner/src/multimodal.ts +2 -1
  10. package/plugins/workflow-runner/src/stepExecutors/skill.js +5 -6
  11. package/plugins/workflow-runner/src/stepExecutors/skill.ts +12 -7
  12. package/plugins/workflow-runner/src/stepExecutors/tool.js +68 -57
  13. package/plugins/workflow-runner/src/stepExecutors/tool.ts +88 -66
  14. package/plugins/workflow-runner/src/toolRuntime.js +48 -0
  15. package/plugins/workflow-runner/src/toolRuntime.ts +64 -0
  16. package/plugins/workflow-runner/src/types.ts +26 -7
  17. package/plugins/workflow-runner/test/context-files.test.ts +47 -16
  18. package/plugins/workflow-runner/test/hostCapability.test.ts +9 -3
  19. package/plugins/workflow-runner/test/multimodal.test.ts +61 -10
  20. package/plugins/workflow-runner/test/toolRuntimeIntegration.test.ts +168 -0
  21. package/src/cli/print.js +19 -4
  22. package/src/cli/print.ts +17 -4
  23. package/src/cli/streamJson.js +19 -4
  24. package/src/cli/streamJson.ts +17 -4
  25. package/src/config/configDefaults.js +25 -0
  26. package/src/config/configDefaults.ts +33 -0
  27. package/src/config/configFile.js +2 -21
  28. package/src/config/configFile.ts +11 -24
  29. package/src/config/configManagement.js +1 -1
  30. package/src/config/configManagement.ts +2 -2
  31. package/src/context/compact.js +4 -0
  32. package/src/context/compact.ts +4 -0
  33. package/src/context/contextBundle.js +38 -13
  34. package/src/context/contextBundle.ts +47 -13
  35. package/src/harness/stallGuard.js +19 -4
  36. package/src/harness/stallGuard.ts +30 -7
  37. package/src/loop.js +95 -11
  38. package/src/loop.ts +125 -16
  39. package/src/main.js +8 -1
  40. package/src/main.tsx +7 -1
  41. package/src/memory/constants.js +10 -0
  42. package/src/memory/constants.ts +13 -0
  43. package/src/memory/episodicMemory.js +274 -2
  44. package/src/memory/episodicMemory.ts +306 -1
  45. package/src/memory/fileStore.js +24 -4
  46. package/src/memory/fileStore.ts +22 -2
  47. package/src/memory/memoryService.js +79 -12
  48. package/src/memory/memoryService.ts +108 -9
  49. package/src/memory/repository.js +415 -85
  50. package/src/memory/repository.ts +437 -86
  51. package/src/memory/turnJournal.js +95 -0
  52. package/src/memory/turnJournal.ts +120 -0
  53. package/src/memory/types.ts +16 -0
  54. package/src/plugins/pluginApi.ts +30 -6
  55. package/src/plugins/pluginRunner.js +6 -2
  56. package/src/plugins/pluginRunner.ts +12 -7
  57. package/src/prompts/policyRegistry.js +27 -0
  58. package/src/prompts/policyRegistry.ts +27 -0
  59. package/src/prompts/promptAssembly.js +39 -7
  60. package/src/prompts/promptAssembly.ts +145 -59
  61. package/src/session/runAgentTurn.js +43 -21
  62. package/src/session/runAgentTurn.ts +68 -26
  63. package/src/session/runTurn.js +11 -46
  64. package/src/session/runTurn.ts +12 -46
  65. package/src/session/runtimeStore.js +149 -0
  66. package/src/session/runtimeStore.ts +197 -0
  67. package/src/session/sessionState.js +5 -0
  68. package/src/session/sessionState.ts +14 -0
  69. package/src/tools/agent/agent.js +143 -14
  70. package/src/tools/agent/agent.ts +149 -14
  71. package/src/tools/agent/agentTaskService.js +60 -0
  72. package/src/tools/agent/agentTaskService.ts +113 -0
  73. package/src/tools/agent/agentTypes.js +5 -0
  74. package/src/tools/agent/agentTypes.ts +5 -0
  75. package/src/tools/bash/bash.js +31 -11
  76. package/src/tools/bash/bash.ts +29 -10
  77. package/src/tools/capabilities.js +5 -0
  78. package/src/tools/capabilities.ts +11 -1
  79. package/src/tools/edit/edit.js +7 -4
  80. package/src/tools/edit/edit.ts +7 -4
  81. package/src/tools/edit/multi-edit.js +4 -2
  82. package/src/tools/edit/multi-edit.ts +4 -2
  83. package/src/tools/glob/glob.js +156 -40
  84. package/src/tools/glob/glob.ts +185 -46
  85. package/src/tools/index.js +35 -17
  86. package/src/tools/index.ts +81 -20
  87. package/src/tools/lsp/lsp.js +165 -22
  88. package/src/tools/lsp/lsp.ts +206 -36
  89. package/src/tools/powershell/powershell.js +116 -26
  90. package/src/tools/powershell/powershell.ts +155 -30
  91. package/src/tools/presentresult/presentresult.js +10 -0
  92. package/src/tools/presentresult/presentresult.ts +10 -0
  93. package/src/tools/read/read.js +244 -29
  94. package/src/tools/read/read.ts +391 -59
  95. package/src/tools/shared/diffSummary.js +44 -0
  96. package/src/tools/shared/diffSummary.ts +63 -0
  97. package/src/tools/shared/fileActivity.js +2 -1
  98. package/src/tools/shared/fileActivity.ts +3 -0
  99. package/src/tools/shared/permanentDeleteGuard.js +26 -0
  100. package/src/tools/shared/permanentDeleteGuard.ts +34 -0
  101. package/src/tools/shared/toolOutputStore.js +97 -0
  102. package/src/tools/shared/toolOutputStore.ts +110 -0
  103. package/src/tools/skill/skill.js +86 -0
  104. package/src/tools/skill/skill.ts +94 -0
  105. package/src/tools/snip/snip.js +67 -0
  106. package/src/tools/snip/snip.ts +80 -0
  107. package/src/tools/tasks/backgroundTaskService.js +79 -28
  108. package/src/tools/tasks/backgroundTaskService.ts +165 -49
  109. package/src/tools/tasks/taskManagement.js +163 -0
  110. package/src/tools/tasks/taskManagement.ts +180 -0
  111. package/src/tools/tasks/taskOutput.js +64 -14
  112. package/src/tools/tasks/taskOutput.ts +102 -23
  113. package/src/tools/tasks/taskStop.js +48 -8
  114. package/src/tools/tasks/taskStop.ts +69 -9
  115. package/src/tools/tasks/taskStore.js +46 -0
  116. package/src/tools/tasks/taskStore.ts +77 -0
  117. package/src/tools/terminal/terminalCapture.js +167 -0
  118. package/src/tools/terminal/terminalCapture.ts +179 -0
  119. package/src/tools/trash/trash.js +233 -0
  120. package/src/tools/trash/trash.ts +283 -0
  121. package/src/tools/worktree/sessionWorktree.js +128 -0
  122. package/src/tools/worktree/sessionWorktree.ts +158 -0
  123. package/src/tools/write/write.js +6 -2
  124. package/src/tools/write/write.ts +6 -2
  125. package/src/types.ts +12 -0
  126. package/src/ui/hooks/useChat.js +31 -11
  127. package/src/ui/hooks/useChat.ts +31 -13
  128. package/src/web/protocol.ts +78 -3
@@ -33,7 +33,9 @@ export type { AskAnswer } from '../askBridge.js';
33
33
  import { normalizeImageDetail } from '../multimodal.js';
34
34
  import type { ImageInputSpec, Message, Provider } from '../types.js';
35
35
  import { runWithPlugins } from '../plugins/pluginRunner.js';
36
- import { buildTransientPromptBundle } from '../prompts/system.js';
36
+ import { buildBaseSystemPrompt, buildTransientPromptBundle } from '../prompts/system.js';
37
+ import { getOrCreateSessionState, sessionStateKey } from '../session/sessionState.js';
38
+ import { hydrateSessionWorktree } from '../tools/worktree/sessionWorktree.js';
37
39
  import { ALL_TOOLS } from '../tools/index.js';
38
40
  import { createPromptCacheContext } from '../llm/promptCache.js';
39
41
  import {
@@ -344,10 +346,16 @@ export async function runStreamJsonMode(
344
346
  });
345
347
 
346
348
  try {
347
- const cwd = options.workspace?.cwd ?? getWorkingDir();
348
- const workspace = await workspaceIndexService.findByPath(cwd).catch(() => null);
349
+ const workspaceRoot = options.workspace?.cwd ?? getWorkingDir();
350
+ const workspace = await workspaceIndexService.findByPath(workspaceRoot).catch(() => null);
349
351
  const sessionId = options.workspace?.sessionId ?? 'default';
350
- const conversationKey = `workspace:${workspace?.id ?? cwd}:session:${sessionId}`;
352
+ const sessionState = getOrCreateSessionState(sessionStateKey({
353
+ surface: 'headless', workspaceId: workspace?.id,
354
+ cwd: workspace?.id ? undefined : workspaceRoot, sessionId,
355
+ }));
356
+ await hydrateSessionWorktree({ sessionState, workspaceId: workspace?.id, sessionId, cwd: workspaceRoot });
357
+ const cwd = sessionState.activeWorktree?.path ?? workspaceRoot;
358
+ const conversationKey = `workspace:${workspace?.id ?? workspaceRoot}:session:${sessionId}`;
351
359
  const computerRun = options.computerUseMode && options.computerUseMode !== 'disabled'
352
360
  ? createCliComputerRun({
353
361
  mode: options.computerUseMode,
@@ -371,6 +379,9 @@ export async function runStreamJsonMode(
371
379
  computer: computerRun,
372
380
  });
373
381
  const turnTools = filterToolsForRuntime(ALL_TOOLS, runtimeContext);
382
+ const stablePrompt = await buildBaseSystemPrompt(cwd, turnTools);
383
+ if (history[0]?.role === 'system') history[0] = { ...history[0], content: stablePrompt };
384
+ else history.unshift({ role: 'system', content: stablePrompt });
374
385
  const transientBundle = await buildTransientPromptBundle({
375
386
  cwd,
376
387
  tools: turnTools,
@@ -378,6 +389,7 @@ export async function runStreamJsonMode(
378
389
  surface: 'headless',
379
390
  cwd,
380
391
  workspaceId: workspace?.id,
392
+ sessionId,
381
393
  interactiveAsk: control,
382
394
  memoryAccess: runtimeContext.memoryAccess,
383
395
  currentUserInput: prompt,
@@ -393,6 +405,7 @@ export async function runStreamJsonMode(
393
405
  imageDetail: cliImageOptions.imageDetail,
394
406
  imageMaxLongSidePixel: cliImageOptions.imageMaxLongSidePixel,
395
407
  runtimeContext,
408
+ sessionState,
396
409
  allowedTools: turnTools.map((tool) => tool.name),
397
410
  ...(transientBundle ? { transientBundle } : {}),
398
411
  promptCache: createPromptCacheContext(
@@ -0,0 +1,25 @@
1
+ /**
2
+ * 浏览器与 Node/Bun 都可安全加载的纯配置默认值。
3
+ * 不得在此模块引入 node:*、文件系统或运行时路径逻辑。
4
+ */
5
+ export const DEFAULT_SUBAGENTS_CONFIG = {
6
+ enabled: true,
7
+ maxCallsPerTurn: 6,
8
+ maxConcurrent: 3,
9
+ defaultWriteIsolation: 'none',
10
+ timeoutMs: 1_800_000,
11
+ exploreMaxTurns: 50,
12
+ generalMaxTurns: 100,
13
+ };
14
+ export const DEFAULT_BROWSER_CONFIG = {
15
+ enabled: true,
16
+ profileId: 'default',
17
+ developerMode: false,
18
+ allowedCdpOrigins: [],
19
+ };
20
+ export const DEFAULT_COMPUTER_USE_CONFIG = {
21
+ enabled: true,
22
+ imagePolicy: 'single-adaptive',
23
+ maxImagePixels: 1_600_000,
24
+ observationFrames: 1,
25
+ };
@@ -0,0 +1,33 @@
1
+ import type {
2
+ BrowserConfig,
3
+ ComputerUseConfig,
4
+ SubagentsConfig,
5
+ } from './configFile.js';
6
+
7
+ /**
8
+ * 浏览器与 Node/Bun 都可安全加载的纯配置默认值。
9
+ * 不得在此模块引入 node:*、文件系统或运行时路径逻辑。
10
+ */
11
+ export const DEFAULT_SUBAGENTS_CONFIG: SubagentsConfig = {
12
+ enabled: true,
13
+ maxCallsPerTurn: 6,
14
+ maxConcurrent: 3,
15
+ defaultWriteIsolation: 'none',
16
+ timeoutMs: 1_800_000,
17
+ exploreMaxTurns: 50,
18
+ generalMaxTurns: 100,
19
+ };
20
+
21
+ export const DEFAULT_BROWSER_CONFIG: BrowserConfig = {
22
+ enabled: true,
23
+ profileId: 'default',
24
+ developerMode: false,
25
+ allowedCdpOrigins: [],
26
+ };
27
+
28
+ export const DEFAULT_COMPUTER_USE_CONFIG: ComputerUseConfig = {
29
+ enabled: true,
30
+ imagePolicy: 'single-adaptive',
31
+ maxImagePixels: 1_600_000,
32
+ observationFrames: 1,
33
+ };
@@ -5,27 +5,8 @@ import { dirname, join } from 'node:path';
5
5
  import { execSiblingDir } from '../utils/greenRoot.js';
6
6
  import { normalizePromptCacheMode } from '../llm/promptCache.js';
7
7
  import { CONFIG_LIMITS, normalizeLimited } from './numericLimits.js';
8
- export const DEFAULT_SUBAGENTS_CONFIG = {
9
- enabled: true,
10
- maxCallsPerTurn: 6,
11
- maxConcurrent: 3,
12
- defaultWriteIsolation: 'none',
13
- timeoutMs: 1_800_000,
14
- exploreMaxTurns: 50,
15
- generalMaxTurns: 100,
16
- };
17
- export const DEFAULT_BROWSER_CONFIG = {
18
- enabled: true,
19
- profileId: 'default',
20
- developerMode: false,
21
- allowedCdpOrigins: [],
22
- };
23
- export const DEFAULT_COMPUTER_USE_CONFIG = {
24
- enabled: true,
25
- imagePolicy: 'single-adaptive',
26
- maxImagePixels: 1_600_000,
27
- observationFrames: 1,
28
- };
8
+ import { DEFAULT_BROWSER_CONFIG, DEFAULT_COMPUTER_USE_CONFIG, DEFAULT_SUBAGENTS_CONFIG, } from './configDefaults.js';
9
+ export { DEFAULT_BROWSER_CONFIG, DEFAULT_COMPUTER_USE_CONFIG, DEFAULT_SUBAGENTS_CONFIG, } from './configDefaults.js';
29
10
  const DEFAULT_CONTEXT_WINDOW = 128_000;
30
11
  const LOCK_STALE_MS = 120_000;
31
12
  const LOCK_WAIT_MS = 15_000;
@@ -7,6 +7,17 @@ import { execSiblingDir } from '../utils/greenRoot.js';
7
7
  import type { PromptCacheConfig } from '../types.js';
8
8
  import { normalizePromptCacheMode } from '../llm/promptCache.js';
9
9
  import { CONFIG_LIMITS, normalizeLimited } from './numericLimits.js';
10
+ import {
11
+ DEFAULT_BROWSER_CONFIG,
12
+ DEFAULT_COMPUTER_USE_CONFIG,
13
+ DEFAULT_SUBAGENTS_CONFIG,
14
+ } from './configDefaults.js';
15
+
16
+ export {
17
+ DEFAULT_BROWSER_CONFIG,
18
+ DEFAULT_COMPUTER_USE_CONFIG,
19
+ DEFAULT_SUBAGENTS_CONFIG,
20
+ } from './configDefaults.js';
10
21
 
11
22
  export type ImageProtocol = 'auto' | 'chat' | 'images';
12
23
  export type ApiFormat = 'openai' | 'anthropic';
@@ -95,30 +106,6 @@ export interface MinimalAgentConfig {
95
106
  savedAt: number;
96
107
  }
97
108
 
98
- export const DEFAULT_SUBAGENTS_CONFIG: SubagentsConfig = {
99
- enabled: true,
100
- maxCallsPerTurn: 6,
101
- maxConcurrent: 3,
102
- defaultWriteIsolation: 'none',
103
- timeoutMs: 1_800_000,
104
- exploreMaxTurns: 50,
105
- generalMaxTurns: 100,
106
- };
107
-
108
- export const DEFAULT_BROWSER_CONFIG: BrowserConfig = {
109
- enabled: true,
110
- profileId: 'default',
111
- developerMode: false,
112
- allowedCdpOrigins: [],
113
- };
114
-
115
- export const DEFAULT_COMPUTER_USE_CONFIG: ComputerUseConfig = {
116
- enabled: true,
117
- imagePolicy: 'single-adaptive',
118
- maxImagePixels: 1_600_000,
119
- observationFrames: 1,
120
- };
121
-
122
109
  /**
123
110
  * 加模型是提交式弹窗、顾问改字段都是有界枚举/数字——不会出现「输入中途非法值」,
124
111
  * 所以 v3 时代的 provider/advisor 草稿桶随 schema 一起删除,只剩 tools(imageGeneration/
@@ -1,4 +1,4 @@
1
- import { DEFAULT_BROWSER_CONFIG, DEFAULT_COMPUTER_USE_CONFIG, } from './configFile.js';
1
+ import { DEFAULT_BROWSER_CONFIG, DEFAULT_COMPUTER_USE_CONFIG, } from './configDefaults.js';
2
2
  /**
3
3
  * CLI 和 Desktop 共用的脱敏配置视图。这里不返回任何凭据,也不读取 Runtime
4
4
  * 状态;它只是把 V4 权威配置稳定地投影成可展示、可比较的管理模型。
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  DEFAULT_BROWSER_CONFIG,
3
3
  DEFAULT_COMPUTER_USE_CONFIG,
4
- type ModelConfig,
5
- } from './configFile.js';
4
+ } from './configDefaults.js';
5
+ import type { ModelConfig } from './configFile.js';
6
6
  import type { ConfigSnapshot } from './configService.js';
7
7
 
8
8
  export type ManagementStatus = 'unconfigured' | 'configured' | 'incomplete';
@@ -379,6 +379,10 @@ async function runCompaction(messages, provider, signal, chatFn = chat, promptCa
379
379
  ...(systemMsg ? [systemMsg] : []),
380
380
  {
381
381
  role: 'user',
382
+ // ⚠️ 这里**故意不带** provider 原始报错:本条消息会进 history,既落盘也会在
383
+ // 后续轮次重新发给模型,错误体里可能夹带 URL / 请求体 / 凭据。
384
+ // 排障线索走 `compact_failed` 事件(只到 stderr / UI,不进 history)。
385
+ // `compact.test.ts::reports snip fallback without exposing the raw LLM error` 锁定此约定。
382
386
  content: '(LLM 摘要压缩失败,已使用规则式截断继续会话)'
383
387
  + (failureLedger
384
388
  ? `\n\n<user_messages>\n以下是被截掉的那部分对话里**用户的原话**(程序逐字抽取)。摘要不存在,这里就是唯一残留的历史要求。\n${failureLedger}\n</user_messages>`
@@ -491,6 +491,10 @@ async function runCompaction(
491
491
  ...(systemMsg ? [systemMsg] : []),
492
492
  {
493
493
  role: 'user',
494
+ // ⚠️ 这里**故意不带** provider 原始报错:本条消息会进 history,既落盘也会在
495
+ // 后续轮次重新发给模型,错误体里可能夹带 URL / 请求体 / 凭据。
496
+ // 排障线索走 `compact_failed` 事件(只到 stderr / UI,不进 history)。
497
+ // `compact.test.ts::reports snip fallback without exposing the raw LLM error` 锁定此约定。
494
498
  content: '(LLM 摘要压缩失败,已使用规则式截断继续会话)'
495
499
  + (failureLedger
496
500
  ? `\n\n<user_messages>\n以下是被截掉的那部分对话里**用户的原话**(程序逐字抽取)。摘要不存在,这里就是唯一残留的历史要求。\n${failureLedger}\n</user_messages>`
@@ -8,10 +8,24 @@ export function contextFragment(input) {
8
8
  estimatedTokens: input.estimatedTokens ?? countTextTokens(content),
9
9
  };
10
10
  }
11
- function clipToTokenBudget(content, budgetTokens) {
11
+ const CLIP_MARKER = '\n[上下文片段因预算截断]';
12
+ /**
13
+ * 按**渲染后**的 token 数收敛,而不是裸内容。
14
+ *
15
+ * ⚠️ 曾经的实现拿裸内容 token 判预算、外层却用 `renderedTokens()`(JSON 转义 + 信封)验收,
16
+ * 于是 `裸 token ≤ 预算 < 渲染 token` 这个窗口里 clip 会「认为放得下」原样返回,验收再不通过,
17
+ * 片段就被整段丢进 `omittedIds` —— `clippable` 形同虚设(`clippedIds` 恒空)。
18
+ * 窗口宽度 = 转义膨胀量,所以 **Windows 上尤其致命**:技能目录里每个 `\` 都要转义成 `\\`,
19
+ * 27 条技能的窗口从 POSIX 的 ~25 token 涨到 ~92 token,本仓库实测整份 `skills.catalog`
20
+ * (2643 token)在还剩 1900+ token 预算时被整个扔掉,模型完全看不到任何 Skill。
21
+ *
22
+ * 判据必须与验收同口径:这里直接对 `measure(候选内容)` 二分,measure 由调用方传入,
23
+ * 保证「算的就是最终要花的」。
24
+ */
25
+ function clipToRenderedBudget(content, budgetTokens, measure) {
12
26
  if (budgetTokens <= 0)
13
27
  return '';
14
- if (countTextTokens(content) <= budgetTokens)
28
+ if (measure(content) <= budgetTokens)
15
29
  return content;
16
30
  // Token counter is deliberately dependency-free. Binary search preserves its
17
31
  // exact mixed Chinese/ASCII estimate instead of falling back to chars/4.
@@ -19,12 +33,15 @@ function clipToTokenBudget(content, budgetTokens) {
19
33
  let high = content.length;
20
34
  while (low < high) {
21
35
  const mid = Math.ceil((low + high) / 2);
22
- if (countTextTokens(content.slice(0, mid)) <= Math.max(1, budgetTokens - 12))
36
+ const candidate = `${content.slice(0, mid).trimEnd()}${CLIP_MARKER}`;
37
+ if (measure(candidate) <= budgetTokens)
23
38
  low = mid;
24
39
  else
25
40
  high = mid - 1;
26
41
  }
27
- return `${content.slice(0, low).trimEnd()}\n[上下文片段因预算截断]`;
42
+ if (low <= 0)
43
+ return '';
44
+ return `${content.slice(0, low).trimEnd()}${CLIP_MARKER}`;
28
45
  }
29
46
  export function renderContextFragment(fragment) {
30
47
  const encoded = JSON.stringify(fragment.content)
@@ -36,6 +53,9 @@ export function renderContextFragment(fragment) {
36
53
  function renderedTokens(fragment) {
37
54
  return countTextTokens(renderContextFragment(fragment));
38
55
  }
56
+ /** 片段之间的分隔符——记账时必须与最终 join 用的是同一个常量。 */
57
+ const FRAGMENT_SEPARATOR = '\n\n';
58
+ const SEPARATOR_TOKENS = countTextTokens(FRAGMENT_SEPARATOR);
39
59
  /**
40
60
  * Deterministic context budgeting. Required host facts are kept first; optional
41
61
  * fragments compete by priority, then original order. No LLM call is involved.
@@ -52,21 +72,26 @@ export function assembleContextBundle(input, budgetTokens = DEFAULT_TRANSIENT_BU
52
72
  let remaining = Math.max(0, budgetTokens);
53
73
  for (const item of ordered) {
54
74
  const fragment = item.fragment;
75
+ // 片段之间要用 SEPARATOR 拼接,那几个 token 也得记账,否则最终 text 会超出预算
76
+ // (usedTokens > budgetTokens 会让 loop.ts::transientFor 的余量判断失真)。
77
+ const separator = included.length > 0 ? SEPARATOR_TOKENS : 0;
78
+ const budgetForItem = remaining - separator;
55
79
  const fullTokens = renderedTokens(fragment);
56
- if (fullTokens <= remaining) {
80
+ if (fullTokens <= budgetForItem) {
57
81
  included.push(item);
58
- remaining -= fullTokens;
82
+ remaining -= fullTokens + separator;
59
83
  continue;
60
84
  }
61
- if ((fragment.required || fragment.clippable) && remaining > 24) {
62
- const wrapperTokens = renderedTokens({ ...fragment, content: '' });
63
- const content = clipToTokenBudget(fragment.content, Math.max(0, remaining - wrapperTokens));
85
+ if ((fragment.required || fragment.clippable) && budgetForItem > 24) {
86
+ // 同口径:二分时就按「这段内容渲染进本片段信封后要花多少 token」来量。
87
+ const content = clipToRenderedBudget(fragment.content, budgetForItem, (candidate) => renderedTokens({ ...fragment, content: candidate }));
64
88
  const clipped = contextFragment({ ...fragment, content });
65
89
  const clippedTokens = renderedTokens(clipped);
66
- if (clipped.content && clippedTokens <= remaining) {
90
+ if (clipped.content && clippedTokens <= budgetForItem) {
67
91
  included.push({ fragment: clipped, index: item.index });
68
- clippedIds.push(fragment.id);
69
- remaining -= clippedTokens;
92
+ if (clipped.content !== fragment.content)
93
+ clippedIds.push(fragment.id);
94
+ remaining -= clippedTokens + separator;
70
95
  continue;
71
96
  }
72
97
  }
@@ -75,7 +100,7 @@ export function assembleContextBundle(input, budgetTokens = DEFAULT_TRANSIENT_BU
75
100
  // Rendering follows semantic input order, not selection priority.
76
101
  included.sort((a, b) => a.index - b.index);
77
102
  const selected = included.map((item) => item.fragment);
78
- const text = selected.map(renderContextFragment).join('\n\n');
103
+ const text = selected.map(renderContextFragment).join(FRAGMENT_SEPARATOR);
79
104
  const usedTokens = countTextTokens(text);
80
105
  return {
81
106
  fragments: selected,
@@ -8,6 +8,7 @@ export type ContextFragmentKind =
8
8
  | 'project'
9
9
  | 'skill'
10
10
  | 'plugin'
11
+ | 'task'
11
12
  | 'diagnostic';
12
13
 
13
14
  export type ContextAuthority = 'host' | 'constraint' | 'user-evidence' | 'untrusted-data';
@@ -50,19 +51,40 @@ export function contextFragment(
50
51
  };
51
52
  }
52
53
 
53
- function clipToTokenBudget(content: string, budgetTokens: number): string {
54
+ const CLIP_MARKER = '\n[上下文片段因预算截断]';
55
+
56
+ /**
57
+ * 按**渲染后**的 token 数收敛,而不是裸内容。
58
+ *
59
+ * ⚠️ 曾经的实现拿裸内容 token 判预算、外层却用 `renderedTokens()`(JSON 转义 + 信封)验收,
60
+ * 于是 `裸 token ≤ 预算 < 渲染 token` 这个窗口里 clip 会「认为放得下」原样返回,验收再不通过,
61
+ * 片段就被整段丢进 `omittedIds` —— `clippable` 形同虚设(`clippedIds` 恒空)。
62
+ * 窗口宽度 = 转义膨胀量,所以 **Windows 上尤其致命**:技能目录里每个 `\` 都要转义成 `\\`,
63
+ * 27 条技能的窗口从 POSIX 的 ~25 token 涨到 ~92 token,本仓库实测整份 `skills.catalog`
64
+ * (2643 token)在还剩 1900+ token 预算时被整个扔掉,模型完全看不到任何 Skill。
65
+ *
66
+ * 判据必须与验收同口径:这里直接对 `measure(候选内容)` 二分,measure 由调用方传入,
67
+ * 保证「算的就是最终要花的」。
68
+ */
69
+ function clipToRenderedBudget(
70
+ content: string,
71
+ budgetTokens: number,
72
+ measure: (candidate: string) => number,
73
+ ): string {
54
74
  if (budgetTokens <= 0) return '';
55
- if (countTextTokens(content) <= budgetTokens) return content;
75
+ if (measure(content) <= budgetTokens) return content;
56
76
  // Token counter is deliberately dependency-free. Binary search preserves its
57
77
  // exact mixed Chinese/ASCII estimate instead of falling back to chars/4.
58
78
  let low = 0;
59
79
  let high = content.length;
60
80
  while (low < high) {
61
81
  const mid = Math.ceil((low + high) / 2);
62
- if (countTextTokens(content.slice(0, mid)) <= Math.max(1, budgetTokens - 12)) low = mid;
82
+ const candidate = `${content.slice(0, mid).trimEnd()}${CLIP_MARKER}`;
83
+ if (measure(candidate) <= budgetTokens) low = mid;
63
84
  else high = mid - 1;
64
85
  }
65
- return `${content.slice(0, low).trimEnd()}\n[上下文片段因预算截断]`;
86
+ if (low <= 0) return '';
87
+ return `${content.slice(0, low).trimEnd()}${CLIP_MARKER}`;
66
88
  }
67
89
 
68
90
  export function renderContextFragment(fragment: ContextFragment): string {
@@ -77,6 +99,10 @@ function renderedTokens(fragment: ContextFragment): number {
77
99
  return countTextTokens(renderContextFragment(fragment));
78
100
  }
79
101
 
102
+ /** 片段之间的分隔符——记账时必须与最终 join 用的是同一个常量。 */
103
+ const FRAGMENT_SEPARATOR = '\n\n';
104
+ const SEPARATOR_TOKENS = countTextTokens(FRAGMENT_SEPARATOR);
105
+
80
106
  /**
81
107
  * Deterministic context budgeting. Required host facts are kept first; optional
82
108
  * fragments compete by priority, then original order. No LLM call is involved.
@@ -98,21 +124,29 @@ export function assembleContextBundle(
98
124
 
99
125
  for (const item of ordered) {
100
126
  const fragment = item.fragment;
127
+ // 片段之间要用 SEPARATOR 拼接,那几个 token 也得记账,否则最终 text 会超出预算
128
+ // (usedTokens > budgetTokens 会让 loop.ts::transientFor 的余量判断失真)。
129
+ const separator = included.length > 0 ? SEPARATOR_TOKENS : 0;
130
+ const budgetForItem = remaining - separator;
101
131
  const fullTokens = renderedTokens(fragment);
102
- if (fullTokens <= remaining) {
132
+ if (fullTokens <= budgetForItem) {
103
133
  included.push(item);
104
- remaining -= fullTokens;
134
+ remaining -= fullTokens + separator;
105
135
  continue;
106
136
  }
107
- if ((fragment.required || fragment.clippable) && remaining > 24) {
108
- const wrapperTokens = renderedTokens({ ...fragment, content: '' });
109
- const content = clipToTokenBudget(fragment.content, Math.max(0, remaining - wrapperTokens));
137
+ if ((fragment.required || fragment.clippable) && budgetForItem > 24) {
138
+ // 同口径:二分时就按「这段内容渲染进本片段信封后要花多少 token」来量。
139
+ const content = clipToRenderedBudget(
140
+ fragment.content,
141
+ budgetForItem,
142
+ (candidate) => renderedTokens({ ...fragment, content: candidate }),
143
+ );
110
144
  const clipped = contextFragment({ ...fragment, content });
111
145
  const clippedTokens = renderedTokens(clipped);
112
- if (clipped.content && clippedTokens <= remaining) {
146
+ if (clipped.content && clippedTokens <= budgetForItem) {
113
147
  included.push({ fragment: clipped, index: item.index });
114
- clippedIds.push(fragment.id);
115
- remaining -= clippedTokens;
148
+ if (clipped.content !== fragment.content) clippedIds.push(fragment.id);
149
+ remaining -= clippedTokens + separator;
116
150
  continue;
117
151
  }
118
152
  }
@@ -122,7 +156,7 @@ export function assembleContextBundle(
122
156
  // Rendering follows semantic input order, not selection priority.
123
157
  included.sort((a, b) => a.index - b.index);
124
158
  const selected = included.map((item) => item.fragment);
125
- const text = selected.map(renderContextFragment).join('\n\n');
159
+ const text = selected.map(renderContextFragment).join(FRAGMENT_SEPARATOR);
126
160
  const usedTokens = countTextTokens(text);
127
161
  return {
128
162
  fragments: selected,
@@ -5,33 +5,48 @@ function hash(value) {
5
5
  /** Run-local deterministic guard. It never persists arguments or result text. */
6
6
  export class StallGuard {
7
7
  #lastFailure = '';
8
+ #lastFailureCall = '';
8
9
  #failureCount = 0;
9
10
  #lastBatch = '';
10
11
  #batchCount = 0;
12
+ shouldReject(call) {
13
+ return (this.#failureCount >= 2 &&
14
+ this.#lastFailureCall ===
15
+ hash(`${call.function.name}\0${call.function.arguments}`));
16
+ }
11
17
  observe(calls, results, assistantText) {
12
18
  for (let index = 0; index < calls.length; index += 1) {
13
19
  const call = calls[index];
14
20
  const result = results[index];
15
21
  if (result.ok) {
16
22
  this.#lastFailure = '';
23
+ this.#lastFailureCall = '';
17
24
  this.#failureCount = 0;
18
25
  continue;
19
26
  }
20
- const signature = hash(`${call.function.name}\0${call.function.arguments}\0${result.error}`);
27
+ const callSignature = hash(`${call.function.name}\0${call.function.arguments}`);
28
+ const signature = hash(`${callSignature}\0${result.error}`);
21
29
  if (signature === this.#lastFailure)
22
30
  this.#failureCount += 1;
23
31
  else {
24
32
  this.#lastFailure = signature;
33
+ this.#lastFailureCall = callSignature;
25
34
  this.#failureCount = 1;
26
35
  }
27
36
  if (this.#failureCount >= 3) {
28
- return { stalled: true, reason: 'repeated_failure', signature: signature.slice(0, 16) };
37
+ return {
38
+ stalled: true,
39
+ reason: 'repeated_failure',
40
+ signature: signature.slice(0, 16),
41
+ };
29
42
  }
30
43
  }
31
- const batch = hash(calls.map((call, index) => {
44
+ const batch = hash(calls
45
+ .map((call, index) => {
32
46
  const result = results[index];
33
47
  return `${call.function.name}\0${call.function.arguments}\0${result.ok ? result.content : result.error}`;
34
- }).join('\x01'));
48
+ })
49
+ .join('\x01'));
35
50
  if (assistantText.trim().length === 0 && batch === this.#lastBatch)
36
51
  this.#batchCount += 1;
37
52
  else {
@@ -14,10 +14,19 @@ export interface StallObservation {
14
14
  /** Run-local deterministic guard. It never persists arguments or result text. */
15
15
  export class StallGuard {
16
16
  #lastFailure = '';
17
+ #lastFailureCall = '';
17
18
  #failureCount = 0;
18
19
  #lastBatch = '';
19
20
  #batchCount = 0;
20
21
 
22
+ shouldReject(call: ToolCall): boolean {
23
+ return (
24
+ this.#failureCount >= 2 &&
25
+ this.#lastFailureCall ===
26
+ hash(`${call.function.name}\0${call.function.arguments}`)
27
+ );
28
+ }
29
+
21
30
  observe(
22
31
  calls: readonly ToolCall[],
23
32
  results: readonly ToolResult[],
@@ -28,25 +37,39 @@ export class StallGuard {
28
37
  const result = results[index]!;
29
38
  if (result.ok) {
30
39
  this.#lastFailure = '';
40
+ this.#lastFailureCall = '';
31
41
  this.#failureCount = 0;
32
42
  continue;
33
43
  }
34
- const signature = hash(`${call.function.name}\0${call.function.arguments}\0${result.error}`);
44
+ const callSignature = hash(
45
+ `${call.function.name}\0${call.function.arguments}`,
46
+ );
47
+ const signature = hash(`${callSignature}\0${result.error}`);
35
48
  if (signature === this.#lastFailure) this.#failureCount += 1;
36
49
  else {
37
50
  this.#lastFailure = signature;
51
+ this.#lastFailureCall = callSignature;
38
52
  this.#failureCount = 1;
39
53
  }
40
54
  if (this.#failureCount >= 3) {
41
- return { stalled: true, reason: 'repeated_failure', signature: signature.slice(0, 16) };
55
+ return {
56
+ stalled: true,
57
+ reason: 'repeated_failure',
58
+ signature: signature.slice(0, 16),
59
+ };
42
60
  }
43
61
  }
44
62
 
45
- const batch = hash(calls.map((call, index) => {
46
- const result = results[index]!;
47
- return `${call.function.name}\0${call.function.arguments}\0${result.ok ? result.content : result.error}`;
48
- }).join('\x01'));
49
- if (assistantText.trim().length === 0 && batch === this.#lastBatch) this.#batchCount += 1;
63
+ const batch = hash(
64
+ calls
65
+ .map((call, index) => {
66
+ const result = results[index]!;
67
+ return `${call.function.name}\0${call.function.arguments}\0${result.ok ? result.content : result.error}`;
68
+ })
69
+ .join('\x01'),
70
+ );
71
+ if (assistantText.trim().length === 0 && batch === this.#lastBatch)
72
+ this.#batchCount += 1;
50
73
  else {
51
74
  this.#lastBatch = batch;
52
75
  this.#batchCount = assistantText.trim().length === 0 ? 1 : 0;