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.
- package/package.json +4 -1
- package/plugins/workflow-runner/plugin.js +27 -15
- package/plugins/workflow-runner/plugin.ts +34 -20
- package/plugins/workflow-runner/src/agentCommon.js +13 -12
- package/plugins/workflow-runner/src/agentCommon.ts +22 -13
- package/plugins/workflow-runner/src/index.js +5 -3
- package/plugins/workflow-runner/src/index.ts +31 -13
- package/plugins/workflow-runner/src/multimodal.js +2 -1
- package/plugins/workflow-runner/src/multimodal.ts +2 -1
- package/plugins/workflow-runner/src/stepExecutors/skill.js +5 -6
- package/plugins/workflow-runner/src/stepExecutors/skill.ts +12 -7
- package/plugins/workflow-runner/src/stepExecutors/tool.js +68 -57
- package/plugins/workflow-runner/src/stepExecutors/tool.ts +88 -66
- package/plugins/workflow-runner/src/toolRuntime.js +48 -0
- package/plugins/workflow-runner/src/toolRuntime.ts +64 -0
- package/plugins/workflow-runner/src/types.ts +26 -7
- package/plugins/workflow-runner/test/context-files.test.ts +47 -16
- package/plugins/workflow-runner/test/hostCapability.test.ts +9 -3
- package/plugins/workflow-runner/test/multimodal.test.ts +61 -10
- package/plugins/workflow-runner/test/toolRuntimeIntegration.test.ts +168 -0
- package/src/cli/print.js +19 -4
- package/src/cli/print.ts +17 -4
- package/src/cli/streamJson.js +19 -4
- package/src/cli/streamJson.ts +17 -4
- package/src/config/configDefaults.js +25 -0
- package/src/config/configDefaults.ts +33 -0
- package/src/config/configFile.js +2 -21
- package/src/config/configFile.ts +11 -24
- package/src/config/configManagement.js +1 -1
- package/src/config/configManagement.ts +2 -2
- package/src/context/compact.js +4 -0
- package/src/context/compact.ts +4 -0
- package/src/context/contextBundle.js +38 -13
- package/src/context/contextBundle.ts +47 -13
- package/src/harness/stallGuard.js +19 -4
- package/src/harness/stallGuard.ts +30 -7
- package/src/loop.js +95 -11
- package/src/loop.ts +125 -16
- package/src/main.js +8 -1
- package/src/main.tsx +7 -1
- package/src/memory/constants.js +10 -0
- package/src/memory/constants.ts +13 -0
- package/src/memory/episodicMemory.js +274 -2
- package/src/memory/episodicMemory.ts +306 -1
- package/src/memory/fileStore.js +24 -4
- package/src/memory/fileStore.ts +22 -2
- package/src/memory/memoryService.js +79 -12
- package/src/memory/memoryService.ts +108 -9
- package/src/memory/repository.js +415 -85
- package/src/memory/repository.ts +437 -86
- package/src/memory/turnJournal.js +95 -0
- package/src/memory/turnJournal.ts +120 -0
- package/src/memory/types.ts +16 -0
- package/src/plugins/pluginApi.ts +30 -6
- package/src/plugins/pluginRunner.js +6 -2
- package/src/plugins/pluginRunner.ts +12 -7
- package/src/prompts/policyRegistry.js +27 -0
- package/src/prompts/policyRegistry.ts +27 -0
- package/src/prompts/promptAssembly.js +39 -7
- package/src/prompts/promptAssembly.ts +145 -59
- package/src/session/runAgentTurn.js +43 -21
- package/src/session/runAgentTurn.ts +68 -26
- package/src/session/runTurn.js +11 -46
- package/src/session/runTurn.ts +12 -46
- package/src/session/runtimeStore.js +149 -0
- package/src/session/runtimeStore.ts +197 -0
- package/src/session/sessionState.js +5 -0
- package/src/session/sessionState.ts +14 -0
- package/src/tools/agent/agent.js +143 -14
- package/src/tools/agent/agent.ts +149 -14
- package/src/tools/agent/agentTaskService.js +60 -0
- package/src/tools/agent/agentTaskService.ts +113 -0
- package/src/tools/agent/agentTypes.js +5 -0
- package/src/tools/agent/agentTypes.ts +5 -0
- package/src/tools/bash/bash.js +31 -11
- package/src/tools/bash/bash.ts +29 -10
- package/src/tools/capabilities.js +5 -0
- package/src/tools/capabilities.ts +11 -1
- package/src/tools/edit/edit.js +7 -4
- package/src/tools/edit/edit.ts +7 -4
- package/src/tools/edit/multi-edit.js +4 -2
- package/src/tools/edit/multi-edit.ts +4 -2
- package/src/tools/glob/glob.js +156 -40
- package/src/tools/glob/glob.ts +185 -46
- package/src/tools/index.js +35 -17
- package/src/tools/index.ts +81 -20
- package/src/tools/lsp/lsp.js +165 -22
- package/src/tools/lsp/lsp.ts +206 -36
- package/src/tools/powershell/powershell.js +116 -26
- package/src/tools/powershell/powershell.ts +155 -30
- package/src/tools/presentresult/presentresult.js +10 -0
- package/src/tools/presentresult/presentresult.ts +10 -0
- package/src/tools/read/read.js +244 -29
- package/src/tools/read/read.ts +391 -59
- package/src/tools/shared/diffSummary.js +44 -0
- package/src/tools/shared/diffSummary.ts +63 -0
- package/src/tools/shared/fileActivity.js +2 -1
- package/src/tools/shared/fileActivity.ts +3 -0
- package/src/tools/shared/permanentDeleteGuard.js +26 -0
- package/src/tools/shared/permanentDeleteGuard.ts +34 -0
- package/src/tools/shared/toolOutputStore.js +97 -0
- package/src/tools/shared/toolOutputStore.ts +110 -0
- package/src/tools/skill/skill.js +86 -0
- package/src/tools/skill/skill.ts +94 -0
- package/src/tools/snip/snip.js +67 -0
- package/src/tools/snip/snip.ts +80 -0
- package/src/tools/tasks/backgroundTaskService.js +79 -28
- package/src/tools/tasks/backgroundTaskService.ts +165 -49
- package/src/tools/tasks/taskManagement.js +163 -0
- package/src/tools/tasks/taskManagement.ts +180 -0
- package/src/tools/tasks/taskOutput.js +64 -14
- package/src/tools/tasks/taskOutput.ts +102 -23
- package/src/tools/tasks/taskStop.js +48 -8
- package/src/tools/tasks/taskStop.ts +69 -9
- package/src/tools/tasks/taskStore.js +46 -0
- package/src/tools/tasks/taskStore.ts +77 -0
- package/src/tools/terminal/terminalCapture.js +167 -0
- package/src/tools/terminal/terminalCapture.ts +179 -0
- package/src/tools/trash/trash.js +233 -0
- package/src/tools/trash/trash.ts +283 -0
- package/src/tools/worktree/sessionWorktree.js +128 -0
- package/src/tools/worktree/sessionWorktree.ts +158 -0
- package/src/tools/write/write.js +6 -2
- package/src/tools/write/write.ts +6 -2
- package/src/types.ts +12 -0
- package/src/ui/hooks/useChat.js +31 -11
- package/src/ui/hooks/useChat.ts +31 -13
- package/src/web/protocol.ts +78 -3
package/src/cli/streamJson.ts
CHANGED
|
@@ -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
|
|
348
|
-
const workspace = await workspaceIndexService.findByPath(
|
|
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
|
|
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
|
+
};
|
package/src/config/configFile.js
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
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;
|
package/src/config/configFile.ts
CHANGED
|
@@ -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 './
|
|
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
|
-
|
|
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';
|
package/src/context/compact.js
CHANGED
|
@@ -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>`
|
package/src/context/compact.ts
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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 <=
|
|
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) &&
|
|
62
|
-
|
|
63
|
-
const content =
|
|
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 <=
|
|
90
|
+
if (clipped.content && clippedTokens <= budgetForItem) {
|
|
67
91
|
included.push({ fragment: clipped, index: item.index });
|
|
68
|
-
|
|
69
|
-
|
|
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(
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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 <=
|
|
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) &&
|
|
108
|
-
|
|
109
|
-
const content =
|
|
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 <=
|
|
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(
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
})
|
|
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
|
|
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 {
|
|
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(
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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;
|