minimal-agent 0.18.9 → 0.18.11
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 +1 -1
- package/src/advisor/advisorCoordinator.js +30 -6
- package/src/advisor/advisorCoordinator.ts +23 -6
- package/src/advisor/deliveryReview.js +23 -0
- package/src/advisor/deliveryReview.ts +22 -0
- package/src/advisor/presentation.js +3 -1
- package/src/advisor/presentation.ts +4 -1
- package/src/loop/README.md +44 -0
- package/src/loop/deliveryIntent.js +22 -0
- package/src/loop/deliveryIntent.ts +39 -0
- package/src/loop/messages.js +146 -0
- package/src/loop/messages.ts +166 -0
- package/src/loop/options.js +1 -0
- package/src/loop/options.ts +155 -0
- package/src/loop/progressStream.js +40 -0
- package/src/loop/progressStream.ts +44 -0
- package/src/loop/publication.js +94 -0
- package/src/loop/publication.ts +124 -0
- package/src/loop/requestPreparation.js +180 -0
- package/src/loop/requestPreparation.ts +226 -0
- package/src/loop/toolBatch.js +99 -0
- package/src/loop/toolBatch.ts +123 -0
- package/src/loop/toolExecutor.js +304 -0
- package/src/loop/toolExecutor.ts +363 -0
- package/src/loop/toolPresentation.js +135 -0
- package/src/loop/toolPresentation.ts +134 -0
- package/src/loop/toolSemantics.js +45 -0
- package/src/loop/toolSemantics.ts +51 -0
- package/src/loop.js +368 -1111
- package/src/loop.ts +480 -1378
- package/src/tools/advisor/advisor.js +5 -2
- package/src/tools/advisor/advisor.ts +6 -2
- package/src/tools/applypatch/applyPatch.js +1 -1
- package/src/tools/applypatch/applyPatch.ts +1 -1
- package/src/tools/bash/semantics.js +33 -1
- package/src/tools/bash/semantics.ts +27 -1
- package/src/tools/edit/edit.js +11 -15
- package/src/tools/edit/edit.ts +10 -15
- package/src/tools/edit/multi-edit.js +14 -4
- package/src/tools/edit/multi-edit.ts +10 -4
- package/src/tools/read/read.js +91 -47
- package/src/tools/read/read.ts +77 -46
- package/src/tools/shared/diffSummary.js +25 -3
- package/src/tools/shared/diffSummary.ts +23 -3
- package/src/tools/shared/editTransaction.js +45 -0
- package/src/tools/shared/editTransaction.ts +39 -0
- package/src/tools/shared/fileActivity.js +10 -2
- package/src/tools/shared/fileActivity.ts +11 -2
- package/src/tools/shared/fileState.js +2 -0
- package/src/tools/shared/fileState.ts +4 -0
- package/src/tools/shared/readBudget.js +12 -0
- package/src/tools/shared/readBudget.ts +12 -0
- package/src/tools/toolPromptGuides.js +2 -2
- package/src/tools/toolPromptGuides.ts +2 -2
- package/src/tools/types.js +2 -6
- package/src/tools/types.ts +2 -6
- package/src/types.ts +9 -2
package/src/loop.ts
CHANGED
|
@@ -1,418 +1,122 @@
|
|
|
1
|
+
import { DELIVERY_CORRECTION_REMINDER, expectsFormalDelivery } from './loop/deliveryIntent.js';
|
|
2
|
+
import {
|
|
3
|
+
buildModelMessages,
|
|
4
|
+
evictPriorVisionShots,
|
|
5
|
+
injectVisionFollowups,
|
|
6
|
+
mergeToolCallDelta,
|
|
7
|
+
pushManualInterruptMarker,
|
|
8
|
+
pushQueuedInputMarker,
|
|
9
|
+
} from './loop/messages.js';
|
|
10
|
+
import type { RunQueryOptions } from './loop/options.js';
|
|
11
|
+
import { publishFinalAnswer } from './loop/publication.js';
|
|
12
|
+
import { prepareModelRequest } from './loop/requestPreparation.js';
|
|
13
|
+
import { executeToolBatch } from './loop/toolBatch.js';
|
|
14
|
+
import { createToolExecutor, type WorkerOutcome } from './loop/toolExecutor.js';
|
|
15
|
+
import { advisorSemanticsFor } from './loop/toolSemantics.js';
|
|
16
|
+
export { expectsFormalDelivery } from './loop/deliveryIntent.js';
|
|
17
|
+
export {
|
|
18
|
+
buildModelMessages,
|
|
19
|
+
SYSTEM_REMINDER_CLOSE,
|
|
20
|
+
SYSTEM_REMINDER_OPEN,
|
|
21
|
+
VISION_SHOT_MARKER,
|
|
22
|
+
} from './loop/messages.js';
|
|
23
|
+
export type { RunQueryOptions } from './loop/options.js';
|
|
24
|
+
export { friendlyToolDescription } from './loop/toolPresentation.js';
|
|
25
|
+
export { advisorSemanticsFor } from './loop/toolSemantics.js';
|
|
1
26
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* 用户说话 → 调 LLM → LLM 想调工具就执行工具 → 把结果喂回去 → 继续调 LLM →
|
|
8
|
-
* 直到 LLM 不再调工具,输出最终回答。
|
|
9
|
-
*
|
|
10
|
-
* 和 kakadeai 主仓库 query.ts 用同样的 AsyncGenerator 模式:
|
|
11
|
-
* - 函数是 async function*,可以 yield 各种事件给 UI
|
|
12
|
-
* - UI 通过 for await 拿到事件,把它们映射到 React state
|
|
13
|
-
*
|
|
14
|
-
* 循环每轮(turn)做的事:
|
|
15
|
-
* a. 自动压缩检查(防止 context 撑爆)
|
|
16
|
-
* b. 调 LLM,把流式响应组装成完整的 assistant message
|
|
17
|
-
* c. 如果 assistant 不调工具 → 结束,return
|
|
18
|
-
* d. 否则并行执行所有工具调用(OpenAI 协议要求 tool 消息按 tool_calls 顺序回填)
|
|
19
|
-
* e. 进入下一轮(让模型看到 tool_result 后继续推理)
|
|
20
|
-
*
|
|
21
|
-
* 失控保护:maxTurns(默认 200)。
|
|
22
|
-
* 中断支持:AbortSignal 透传到 chat() 和 tool.call()。
|
|
23
|
-
*
|
|
24
|
-
* "工作过程显示"事件:
|
|
25
|
-
* - stage_change:thinking / streaming / tool_executing / compacting
|
|
26
|
-
* - reasoning:思维链流式增量(仅字符串型 field)
|
|
27
|
-
* ============================================================
|
|
27
|
+
* Agent turn orchestrator. Owns conversation state and continuation decisions.
|
|
28
|
+
* Request preparation, tool execution/scheduling and final publication live in
|
|
29
|
+
* ./loop/. Host options and LoopEvent remain the public compatibility boundary.
|
|
30
|
+
* See ./loop/README.md for phase ownership and invariants.
|
|
28
31
|
*/
|
|
29
32
|
|
|
30
33
|
import crypto from 'node:crypto';
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
37
|
-
import {
|
|
38
|
-
import { loadProjectPromptInstructions } from './prompts/projectInstructions.js';
|
|
39
|
-
import { resolveToolPath } from './tools/shared/fileUtils.js';
|
|
40
|
-
import { invocationInstructions, invocationToolAllowed, type InvocationPolicy } from './runtime/invocationPolicy.js';
|
|
41
|
-
import { needsInteractiveQuestion } from './tools/ask/routing.js';
|
|
42
|
-
import { autoCompactIfNeeded, getCompactThreshold, shouldAutoCompact } from './context/compact.js';
|
|
43
|
-
import { createConversationOptimizer } from './context/optimizeConversation.js';
|
|
34
|
+
import { resolve } from 'node:path';
|
|
35
|
+
import { AdvisorCoordinator } from './advisor/advisorCoordinator.js';
|
|
36
|
+
import { collectReviewContext } from './advisor/reviewContext.js';
|
|
37
|
+
import { configService } from './config/configService.js';
|
|
38
|
+
import { buildContextBudgetPlan } from './context/budgetPlan.js';
|
|
39
|
+
import { getCompactThreshold } from './context/compact.js';
|
|
40
|
+
import { assembleContextBundle, contextFragment, resizeContextBundle } from './context/contextBundle.js';
|
|
44
41
|
import {
|
|
45
|
-
microCompact,
|
|
46
|
-
incrementTurn,
|
|
47
42
|
expireOldEntries,
|
|
43
|
+
getToolResultPolicy,
|
|
44
|
+
incrementTurn,
|
|
45
|
+
microCompact,
|
|
48
46
|
reclaimOldToolResults,
|
|
49
47
|
resetCacheEdits,
|
|
50
|
-
getToolResultPolicy,
|
|
51
48
|
} from './context/microCompactLite.js';
|
|
52
|
-
import {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
} from './context/reactiveCompact.js';
|
|
56
|
-
import { snipCompactIfNeeded } from './context/snipCompact.js';
|
|
49
|
+
import { createConversationOptimizer } from './context/optimizeConversation.js';
|
|
50
|
+
import { isPromptTooLongError, reactiveCompactIfApplicable } from './context/reactiveCompact.js';
|
|
51
|
+
import { countMessagesTokens, estimateToolsTokens } from './context/tokenCounter.js';
|
|
57
52
|
import { archiveToolResult } from './context/toolResultArchive.js';
|
|
58
|
-
import {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
estimateToolsTokens,
|
|
62
|
-
} from './context/tokenCounter.js';
|
|
63
|
-
import { buildContextBudgetPlan, fitToolsForEmergency } from './context/budgetPlan.js';
|
|
64
|
-
import { assembleContextBundle, contextFragment, resizeContextBundle, type ContextBundle } from './context/contextBundle.js';
|
|
53
|
+
import { validateFinalClaims } from './harness/claimValidation.js';
|
|
54
|
+
import { EvidenceLedger } from './harness/evidenceLedger.js';
|
|
55
|
+
import { StallGuard } from './harness/stallGuard.js';
|
|
65
56
|
import { chat as defaultChat } from './llm/client.js';
|
|
66
57
|
import { buildUserContent } from './multimodal.js';
|
|
67
|
-
import
|
|
68
|
-
import {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
} from './
|
|
74
|
-
import
|
|
75
|
-
import
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
LoopEvent,
|
|
80
|
-
Message,
|
|
81
|
-
Provider,
|
|
82
|
-
Tool,
|
|
83
|
-
ToolCall,
|
|
84
|
-
ToolCallContext,
|
|
85
|
-
ToolResult,
|
|
86
|
-
TurnResourceUsage,
|
|
87
|
-
} from './types.js';
|
|
88
|
-
import { configService } from './config/configService.js';
|
|
58
|
+
import { delegationRuntimeGuidance } from './prompts/delegation.js';
|
|
59
|
+
import { capabilitiesForTools, enabledPolicyIds } from './prompts/policyRegistry.js';
|
|
60
|
+
import { loadProjectPromptInstructions } from './prompts/projectInstructions.js';
|
|
61
|
+
import { needsDiagramPreparation, visualPresentation } from './prompts/visualPresentation.js';
|
|
62
|
+
import { createAgentRuntimeContext } from './runtime/agentRuntimeContext.js';
|
|
63
|
+
import { answerPreparationNotice } from './runtime/answerPreparation.js';
|
|
64
|
+
import { invocationInstructions, invocationToolAllowed } from './runtime/invocationPolicy.js';
|
|
65
|
+
import { createLifecycleTrace } from './runtime/lifecycleTrace.js';
|
|
66
|
+
import { resolveOutputContract, toolsForResponsePhase } from './runtime/outputContract.js';
|
|
67
|
+
import { filterToolsForProvider } from './team/toolPolicy.js';
|
|
68
|
+
import { agentCoordinationContext, takeAgentNotifications } from './tools/agent/agentTaskService.js';
|
|
69
|
+
import { needsInteractiveQuestion } from './tools/ask/routing.js';
|
|
89
70
|
import { capabilityForToolName } from './tools/capabilities.js';
|
|
90
|
-
import { createEffectiveToolSet } from './tools/effectiveToolSet.js';
|
|
91
71
|
import { classifyComputerAutomationIntent } from './tools/computeruse/computerIntent.js';
|
|
92
72
|
import { recordComputerUseExposure } from './tools/computeruse/telemetryLog.js';
|
|
93
|
-
import {
|
|
73
|
+
import { createEffectiveToolSet } from './tools/effectiveToolSet.js';
|
|
74
|
+
import { ALL_TOOLS, executeTool as defaultExecuteTool } from './tools/index.js';
|
|
75
|
+
import { createReadBudget } from './tools/shared/readBudget.js';
|
|
94
76
|
import { hydrateSessionWorktree } from './tools/worktree/sessionWorktree.js';
|
|
95
|
-
import {
|
|
96
|
-
capabilitiesForTools,
|
|
97
|
-
enabledPolicyIds,
|
|
98
|
-
} from './prompts/policyRegistry.js';
|
|
99
|
-
import { EvidenceLedger } from './harness/evidenceLedger.js';
|
|
100
|
-
import { StallGuard, type StallObservation } from './harness/stallGuard.js';
|
|
101
|
-
import { validateFinalClaims } from './harness/claimValidation.js';
|
|
102
|
-
import {
|
|
103
|
-
AdvisorCoordinator,
|
|
104
|
-
type AdvisorToolSemantics,
|
|
105
|
-
} from './advisor/advisorCoordinator.js';
|
|
106
|
-
import { toolEffectFor } from './tools/capabilities.js';
|
|
107
|
-
import { isReadOnlyShellCommand, isVerificationShellCommand } from './tools/bash/semantics.js';
|
|
108
|
-
import { collectReviewContext } from './advisor/reviewContext.js';
|
|
109
|
-
import {
|
|
110
|
-
createAgentRuntimeContext,
|
|
111
|
-
type AgentRuntimeContext,
|
|
112
|
-
} from './runtime/agentRuntimeContext.js';
|
|
77
|
+
import type { LoopEvent, Message, Tool, ToolCall, ToolCallContext, TurnResourceUsage } from './types.js';
|
|
113
78
|
import { stripThinkBlocks } from './utils/stripThink.js';
|
|
114
|
-
import { orderedPool } from './runtime/orderedPool.js';
|
|
115
|
-
import { takeAgentNotifications, agentCoordinationContext } from './tools/agent/agentTaskService.js';
|
|
116
|
-
import { delegationRuntimeGuidance } from './prompts/delegation.js';
|
|
117
|
-
import { executionSegments } from './tools/executionSegments.js';
|
|
118
|
-
import { acquireSubagentLease, normalizeWriteScopes } from './tools/agent/coordinator.js';
|
|
119
|
-
|
|
120
|
-
const DELIVERY_ARTIFACT = '(?:文件|合同|协议|方案|文档|表格|报告|图片|图像|海报|幻灯片|PPT|PDF|代码|脚本|压缩包|网页|页面|HTML|DOCX|XLSX|CSV|ZIP)';
|
|
121
|
-
const DELIVERY_ACTION = '(?:发送|(?<![开研])发|交付|提交|(?<!宣)传(?!统)|制作|生成|创建|导出|输出|修改|更新|完成|写|起草|做|设计|整理)';
|
|
122
|
-
const ENGLISH_DELIVERY_ARTIFACT = '(?:file|contract|document|report|spreadsheet|workbook|slides?|presentation|pdf|image|code|script|archive|zip|html|webpage|website)';
|
|
123
|
-
const ENGLISH_DELIVERY_ACTION = '(?:send|deliver|submit|provide|create|generate|export|update|modify|finish|produce|draft|write|make|build)';
|
|
124
|
-
|
|
125
|
-
/** Whether this turn explicitly asks for a concrete artifact that should be formally delivered. */
|
|
126
|
-
export function expectsFormalDelivery(input: string): boolean {
|
|
127
|
-
// Questions about producing artifacts are not instructions to produce them.
|
|
128
|
-
// Evaluate clauses independently so “解释原因,然后生成报告” still delivers.
|
|
129
|
-
const clauses = input.split(/[。!?!?;;\n]|然后|随后|并且/).filter(Boolean);
|
|
130
|
-
return clauses.some(expectsDeliveryClause);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function expectsDeliveryClause(input: string): boolean {
|
|
134
|
-
if (/为什么|为何|如何|怎么|怎样|是否|能否|能不能|不要|不必|无需|只(?:需|要)?(?:解释|分析|评估|讨论)|\b(?:why|how|whether|explain|analy[sz]e|discuss|do not|don't)\b/i.test(input)) return false;
|
|
135
|
-
const compact = input.replace(/\s+/g, '');
|
|
136
|
-
const chinese = new RegExp(
|
|
137
|
-
`${DELIVERY_ACTION}(?:给)?我?.{0,24}${DELIVERY_ARTIFACT}|${DELIVERY_ARTIFACT}.{0,24}${DELIVERY_ACTION}(?:给)?我?|给我.{0,24}${DELIVERY_ARTIFACT}|${DELIVERY_ARTIFACT}.{0,24}给我`,
|
|
138
|
-
'i',
|
|
139
|
-
);
|
|
140
|
-
const english = new RegExp(
|
|
141
|
-
`\\b${ENGLISH_DELIVERY_ACTION}\\b.{0,80}\\b${ENGLISH_DELIVERY_ARTIFACT}\\b|\\b${ENGLISH_DELIVERY_ARTIFACT}\\b.{0,80}\\b${ENGLISH_DELIVERY_ACTION}\\b`,
|
|
142
|
-
'i',
|
|
143
|
-
);
|
|
144
|
-
return chinese.test(compact) || english.test(input);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
const DELIVERY_CORRECTION_REMINDER = `The user requested a concrete final deliverable, but no successful PresentResult call has been made.
|
|
148
|
-
Do not repeat the answer or mention a path only. Call PresentResult now with the actual final files and/or final http(s) links.
|
|
149
|
-
Submit only final outputs, never temporary scripts, unpacked assets, caches, or reference sources. If no valid result exists, explain that honestly without inventing one.`;
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* 每轮 LLM 请求都会带上工具 schema,但 countMessagesTokens 只数历史消息、
|
|
153
|
-
* 漏了这部分 → 系统性低估。autoCompact 判定阈值时加上它才准。
|
|
154
|
-
*
|
|
155
|
-
* ⚠️ 必须懒求值而非模块顶层求值:Agent 工具引入了 tools/index → agent → loop →
|
|
156
|
-
* tools/index 的模块环,若从 tools/index 一侧先进入,顶层求值 ALL_TOOLS 时它还在
|
|
157
|
-
* TDZ(临时死区),启动即 ReferenceError。函数体内访问时模块图早已初始化完毕。
|
|
158
|
-
*/
|
|
159
|
-
/**
|
|
160
|
-
* 本轮**真正发出去的**工具 schema 估算 token 数。
|
|
161
|
-
*
|
|
162
|
-
* 曾经这里数的是 `ALL_TOOLS`,那是错的:每回合发给模型的是 `activeTools`,
|
|
163
|
-
* 会被 `allowedTools`(工作流步骤 / 子 agent 收窄)、`subagents.enabled`、
|
|
164
|
-
* `computerRun` 是否注入等条件过滤掉一部分。ComputerUse 单个工具的 schema
|
|
165
|
-
* 实测就有 ~5800 token(全量 ~18000 的三分之一),在没暴露它的会话里照样
|
|
166
|
-
* 被计入压缩阈值 → **压缩提前触发**;被 `allowed_tools` 收窄到两三个工具的
|
|
167
|
-
* 子 agent 偏差更大。
|
|
168
|
-
*
|
|
169
|
-
* 缓存必须按工具集**键控**:模块级单值缓存配上按回合变化的工具集,会让所有
|
|
170
|
-
* 后续回合都拿到第一回合那个数字 —— 比不缓存更糟,因为它错得很稳定。
|
|
171
|
-
*/
|
|
172
|
-
const toolsSchemaTokensCache = new Map<string, number>();
|
|
173
|
-
function getToolsSchemaTokens(tools: readonly Tool[], signature?: string): number {
|
|
174
|
-
const key = signature ?? tools.map((t) => t.name).join(',');
|
|
175
|
-
let cached = toolsSchemaTokensCache.get(key);
|
|
176
|
-
if (cached === undefined) {
|
|
177
|
-
cached = estimateToolsTokens(tools as Tool[]);
|
|
178
|
-
toolsSchemaTokensCache.set(key, cached);
|
|
179
|
-
}
|
|
180
|
-
return cached;
|
|
181
|
-
}
|
|
182
79
|
|
|
183
80
|
/** 主工具循环的默认轮数上限。 */
|
|
184
81
|
export const DEFAULT_MAX_TURNS = 200;
|
|
185
82
|
|
|
186
|
-
export function advisorSemanticsFor(
|
|
187
|
-
call: ToolCall,
|
|
188
|
-
context?: ToolCallContext,
|
|
189
|
-
tool?: Tool,
|
|
190
|
-
// 默认 false:只有 advisor 检查点/记账(beforeTool、observeToolResult、
|
|
191
|
-
// 持久化的 advisorMutatesWorkspace 标注)才传 true。工作区租约判定
|
|
192
|
-
// (needsWorkspaceLease)必须保持严格定义不受影响——验证类命令仍可能写
|
|
193
|
-
// 构建产物/缓存,并发场景下让它绕开租约会重新引入竞态。
|
|
194
|
-
opts?: { treatVerificationAsReadOnly?: boolean },
|
|
195
|
-
): AdvisorToolSemantics {
|
|
196
|
-
const definition = tool ?? getToolByName(call.function.name);
|
|
197
|
-
const gate = context ? getToolGateDetails(call.function.name, call.function.arguments, context) : undefined;
|
|
198
|
-
// Reuse the tool's side-effect contract, including shell/scripts, Trash and
|
|
199
|
-
// generation tools. Shell tools refine their generic writable declaration
|
|
200
|
-
// using the actual command; the same result is also used by the host gate.
|
|
201
|
-
let isReadOnly = gate?.isReadOnly ?? definition?.isReadOnly ?? false;
|
|
202
|
-
if (call.function.name === 'Agent') {
|
|
203
|
-
try {
|
|
204
|
-
const args = JSON.parse(call.function.arguments || '{}');
|
|
205
|
-
isReadOnly = !args.resume_agent_id && (args.agent_type ?? 'explore') === 'explore';
|
|
206
|
-
} catch { isReadOnly = false; }
|
|
207
|
-
}
|
|
208
|
-
if (call.function.name === 'Bash' || call.function.name === 'PowerShell') {
|
|
209
|
-
try {
|
|
210
|
-
const input = JSON.parse(call.function.arguments || '{}') as { command?: unknown };
|
|
211
|
-
if (typeof input.command === 'string') {
|
|
212
|
-
// 跑测试/构建/lint 只是核对现状,不该被当成"开始实施改动"而白白烧掉
|
|
213
|
-
// pre-execution 检查点——即使它们可能写构建产物/缓存,不满足
|
|
214
|
-
// isReadOnlyShellCommand 的并发安全定义(见 opts 注释,那个场景仍要严格)。
|
|
215
|
-
isReadOnly =
|
|
216
|
-
isReadOnlyShellCommand(input.command) ||
|
|
217
|
-
(opts?.treatVerificationAsReadOnly === true &&
|
|
218
|
-
isVerificationShellCommand(input.command));
|
|
219
|
-
}
|
|
220
|
-
} catch {
|
|
221
|
-
// Malformed input is left writable; normal tool validation reports it.
|
|
222
|
-
isReadOnly = false;
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
const mutatesWorkspace = toolEffectFor(call.function.name, isReadOnly) === 'execution';
|
|
226
|
-
return { mutatesWorkspace };
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
export interface RunQueryOptions {
|
|
230
|
-
agentHandoff?: ToolCallContext['agentHandoff'];
|
|
231
|
-
/** Generic embedded-task policy; omitted for ordinary conversation turns. */
|
|
232
|
-
invocationPolicy?: InvocationPolicy;
|
|
233
|
-
executionServices?: ToolCallContext['executionServices'];
|
|
234
|
-
provider: Provider;
|
|
235
|
-
/** Canonical host-issued trust context. Legacy fields below remain adapters. */
|
|
236
|
-
runtimeContext?: AgentRuntimeContext;
|
|
237
|
-
/** 历史会被 mutate(追加新消息、压缩时整体替换内容);调用方持同一引用 */
|
|
238
|
-
history: Message[];
|
|
239
|
-
/** Host mailbox: consume at model boundaries, never mutate an in-flight request. */
|
|
240
|
-
takePendingMessages?: () => Message[];
|
|
241
|
-
/** 最多多少轮(防止失控调工具) */
|
|
242
|
-
maxTurns?: number;
|
|
243
|
-
/**
|
|
244
|
-
* 工作流 agent/llm 步的工具白名单:只把这些工具下发给模型并允许执行。
|
|
245
|
-
* 缺省 = 当前宿主工具全集;空数组 = 无工具。
|
|
246
|
-
*/
|
|
247
|
-
allowedTools?: string[];
|
|
248
|
-
/** 调用方工具边界名称,仅用于准确解释越界调用;缺省表示宿主能力边界。 */
|
|
249
|
-
toolBoundaryLabel?: string;
|
|
250
|
-
/** Disable Advisor checkpoints for an explicitly read-only embedded node. */
|
|
251
|
-
advisorCheckpoints?: boolean;
|
|
252
|
-
/** Tools the host/workflow explicitly requires; emergency schema reduction preserves them. */
|
|
253
|
-
requiredTools?: string[];
|
|
254
|
-
/**
|
|
255
|
-
* provider 原生结构化输出(`response_format.json_schema`),原样透传给 `chat()`。
|
|
256
|
-
*
|
|
257
|
-
* ⚠️ **只在本轮没有工具时才该传**:response_format 会强制**每一条** assistant
|
|
258
|
-
* 消息都是 JSON,而工具调用轮的 assistant 消息带的是 tool_calls —— 两者互斥,
|
|
259
|
-
* 同时给会让带工具的循环直接崩。工作流引擎因此只在 `llm()`(零工具)和
|
|
260
|
-
* schema 修复轮(零工具)上传它;`agent()` 的结构化输出走「跑完再修复」那条路。
|
|
261
|
-
*
|
|
262
|
-
* Anthropic 格式的 provider 不支持,`chat()` 会在发请求前抛错 —— 所以它是
|
|
263
|
-
* **加成**而不是地基,地基是引擎侧的「校验 + 有界修复」。
|
|
264
|
-
*/
|
|
265
|
-
responseFormat?: {
|
|
266
|
-
type: 'json_schema';
|
|
267
|
-
json_schema: {
|
|
268
|
-
name: string;
|
|
269
|
-
schema: Record<string, unknown>;
|
|
270
|
-
strict?: boolean;
|
|
271
|
-
};
|
|
272
|
-
};
|
|
273
|
-
/**
|
|
274
|
-
* Host-provided tool catalog. Production hosts normally omit this and use ALL_TOOLS;
|
|
275
|
-
* tests and embedders with custom executors must provide matching schemas so the
|
|
276
|
-
* model-visible catalog and execution boundary remain identical.
|
|
277
|
-
*/
|
|
278
|
-
availableTools?: readonly Tool[];
|
|
279
|
-
/** 用户取消 */
|
|
280
|
-
signal?: AbortSignal;
|
|
281
|
-
/**
|
|
282
|
-
* 回调:宿主检查是否有排队输入需要在安全点处理。
|
|
283
|
-
* loop只在安全检查点调用:每轮while开始时、工具全部执行完成后。
|
|
284
|
-
* 返回true表示应该结束当前turn(自然return,不抛异常)。
|
|
285
|
-
*/
|
|
286
|
-
hasQueuedInput?: () => boolean;
|
|
287
|
-
/** Concurrent-safe tool throughput; defaults to 8, host may tune upward. */
|
|
288
|
-
toolConcurrency?: number;
|
|
289
|
-
/**
|
|
290
|
-
* 回调:工具开始/结束执行,用于宿主跟踪运行中工具状态。
|
|
291
|
-
*/
|
|
292
|
-
onToolStart?: (toolName: string, toolCallId: string) => void;
|
|
293
|
-
onToolEnd?: (toolCallId: string, wasAborted: boolean) => void;
|
|
294
|
-
/**
|
|
295
|
-
* 多租户场景下注入隔离的 session 级状态;缺省走模块单例。
|
|
296
|
-
* TUI / print(单进程单 session)不传即可;HTTP server per-request 必须传。
|
|
297
|
-
*/
|
|
298
|
-
sessionState?: SessionState;
|
|
299
|
-
/**
|
|
300
|
-
* 注入 chat 实现(仅测试用)。
|
|
301
|
-
*
|
|
302
|
-
* 之前用 bun:test 的 mock.module 替换 llm/client.js 会进程全局污染所有跨文件
|
|
303
|
-
* import;改用显式 DI 后,测试传 stub 进来即可,零污染。生产代码不需要传。
|
|
304
|
-
*/
|
|
305
|
-
chat?: typeof defaultChat;
|
|
306
|
-
/**
|
|
307
|
-
* 注入 executeTool 实现(仅测试用)。同上,DI 而非 mock.module,零污染。
|
|
308
|
-
*/
|
|
309
|
-
executeTool?: (
|
|
310
|
-
name: string,
|
|
311
|
-
rawArgsJson: string,
|
|
312
|
-
signal?: AbortSignal,
|
|
313
|
-
ctx?: ToolCallContext,
|
|
314
|
-
) => Promise<ToolResult>;
|
|
315
|
-
/**
|
|
316
|
-
* Trusted host persistence boundary for formal results. It runs after
|
|
317
|
-
* PresentResult validates the files but before tool_end is published or the
|
|
318
|
-
* result is appended to history. Throwing turns the tool call into a failure,
|
|
319
|
-
* so a UI can never display an uncommitted delivery as durable.
|
|
320
|
-
*/
|
|
321
|
-
commitFormalDelivery?: (input: {
|
|
322
|
-
toolCallId: string;
|
|
323
|
-
turnId: string;
|
|
324
|
-
manifest: DeliverableManifest;
|
|
325
|
-
}) => Promise<void>;
|
|
326
|
-
/**
|
|
327
|
-
* 工具执行闸门(通用机制,见 src/toolGate.ts):loop 在执行每个工具前
|
|
328
|
-
* await 它拿放行/拒绝;拒绝时把 reason 当 tool_result 回填。内核零策略——审批语义
|
|
329
|
-
* 全在宿主(如 gateway 跨机指令同意闸门)。缺省不传 = 全放行,行为不变。
|
|
330
|
-
*/
|
|
331
|
-
toolGate?: ToolGate;
|
|
332
|
-
/**
|
|
333
|
-
* 仅对本次回合可见的系统上下文。它会随每次模型调用注入,但不会写入 history、
|
|
334
|
-
* 会话文件或压缩摘要,适合项目事实、远程委托和附件概要等外部上下文。
|
|
335
|
-
*
|
|
336
|
-
* 注入到本轮用户消息的模型副本,不改变 UI 或持久化历史。
|
|
337
|
-
*/
|
|
338
|
-
transientContext?: string;
|
|
339
|
-
/** Structured transient fragments allow deterministic request-time degradation. */
|
|
340
|
-
transientBundle?: ContextBundle;
|
|
341
|
-
/** Provider cache routing for this conversation; never persisted. */
|
|
342
|
-
promptCache?: import('./types.js').PromptCacheContext;
|
|
343
|
-
/** 仅供宿主 UI 恢复附件卡片;不会发送给 LLM。 */
|
|
344
|
-
userAttachments?: import('./types.js').MessageAttachmentRef[];
|
|
345
|
-
/** Host-authorized files for typed workflow inputs; runtime-only paths. */
|
|
346
|
-
inputResources?: readonly import('./types.js').InvocationResource[];
|
|
347
|
-
/** Stable host-provided identity for the user message of this turn. */
|
|
348
|
-
userMessageId?: string;
|
|
349
|
-
/** Trusted local origin for every message created by this run. */
|
|
350
|
-
messageProvenance?: import('./types.js').ConversationProvenance;
|
|
351
|
-
/** Browser element chips are persisted for UI but stripped before provider calls. */
|
|
352
|
-
userBrowserReferences?: import('./types.js').MessageBrowserReference[];
|
|
353
|
-
/** 显式传入的图片附件(-p --image / stream-json images)。 */
|
|
354
|
-
imageInputs?: ImageInputSpec[];
|
|
355
|
-
/** 是否解析用户文本里的 @./image.png / 图片 URL。TUI 默认开启。 */
|
|
356
|
-
parseInlineImages?: boolean;
|
|
357
|
-
/** 图片 detail 默认值:low/default/high。 */
|
|
358
|
-
imageDetail?: ImageDetail;
|
|
359
|
-
/** OpenAI 兼容扩展:图片最长边像素。 */
|
|
360
|
-
imageMaxLongSidePixel?: number;
|
|
361
|
-
/** Host-provided browser ownership scope; never exposed to the model. */
|
|
362
|
-
browserRun?: ToolCallContext['browserRun'];
|
|
363
|
-
/** Host-provided native desktop-control scope; absent means ComputerUse is not exposed. */
|
|
364
|
-
computerRun?: ToolCallContext['computerRun'];
|
|
365
|
-
/** Trusted host-only human handoff bridge for native Computer Use. */
|
|
366
|
-
computerIntervention?: ToolCallContext['computerIntervention'];
|
|
367
|
-
memoryContext?: {
|
|
368
|
-
access: import('./runtime/agentRuntimeContext.js').MemoryAccessInput;
|
|
369
|
-
workspaceId?: string;
|
|
370
|
-
sessionId?: string;
|
|
371
|
-
};
|
|
372
|
-
}
|
|
373
|
-
|
|
374
83
|
/**
|
|
375
84
|
* 执行一次"用户输入 → 模型回答完成"的完整流程。
|
|
376
85
|
*
|
|
377
86
|
* yield 出来的 LoopEvent 会被 UI 用来更新界面。
|
|
378
87
|
*/
|
|
379
|
-
export async function* runQuery(
|
|
380
|
-
userInput: string,
|
|
381
|
-
options: RunQueryOptions,
|
|
382
|
-
): AsyncGenerator<LoopEvent, void, void> {
|
|
88
|
+
export async function* runQuery(userInput: string, options: RunQueryOptions): AsyncGenerator<LoopEvent, void, void> {
|
|
383
89
|
const { provider, history, signal, sessionState } = options;
|
|
384
90
|
// Tool screenshots are transient evidence for the tool continuation that produced them.
|
|
385
91
|
// Never carry a prior turn's base64 image into a new user request.
|
|
386
92
|
evictPriorVisionShots(history);
|
|
387
93
|
const maxTurns = options.maxTurns ?? DEFAULT_MAX_TURNS;
|
|
388
|
-
const inferredDesktop = options.browserRun?.supervised === true ||
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
94
|
+
const inferredDesktop = options.browserRun?.supervised === true || options.browserRun?.viewer === 'desktop';
|
|
95
|
+
const baseRuntimeContext =
|
|
96
|
+
options.runtimeContext ??
|
|
97
|
+
createAgentRuntimeContext({
|
|
98
|
+
surface: inferredDesktop ? 'desktop' : 'headless',
|
|
99
|
+
...(inferredDesktop ? { host: 'desktop' as const } : {}),
|
|
100
|
+
profile: 'main',
|
|
101
|
+
cwd: process.cwd(),
|
|
102
|
+
workspaceId: options.memoryContext?.workspaceId,
|
|
103
|
+
sessionId: options.memoryContext?.sessionId,
|
|
104
|
+
owner: options.memoryContext?.access !== 'denied' ? 'local-owner' : 'untrusted',
|
|
105
|
+
memoryAccess: options.memoryContext?.access ?? 'denied',
|
|
106
|
+
interactiveAsk: false,
|
|
107
|
+
browser: options.browserRun,
|
|
108
|
+
computer: options.computerRun,
|
|
109
|
+
computerIntervention: options.computerIntervention,
|
|
110
|
+
toolGate: options.toolGate,
|
|
111
|
+
});
|
|
112
|
+
const computerIntent =
|
|
113
|
+
baseRuntimeContext.computerIntent ?? classifyComputerAutomationIntent(userInput, sessionState?.computerAutomation);
|
|
407
114
|
const runtimeContext = createAgentRuntimeContext({
|
|
408
115
|
...baseRuntimeContext,
|
|
409
116
|
computerIntent,
|
|
410
117
|
});
|
|
411
118
|
if (runtimeContext.computer) {
|
|
412
|
-
void recordComputerUseExposure(
|
|
413
|
-
computerIntent.needed,
|
|
414
|
-
computerIntent.source,
|
|
415
|
-
);
|
|
119
|
+
void recordComputerUseExposure(computerIntent.needed, computerIntent.source);
|
|
416
120
|
}
|
|
417
121
|
if (!computerIntent.needed && sessionState) delete sessionState.computerAutomation;
|
|
418
122
|
// DI fallback:测试可传 stub,生产走真实模块单例
|
|
@@ -428,7 +132,9 @@ export async function* runQuery(
|
|
|
428
132
|
const observedRuleScopes = new Map<string, string>();
|
|
429
133
|
const pendingRuleScopes = new Set<string>();
|
|
430
134
|
const transientFor = (tools: readonly Tool[]): string => {
|
|
431
|
-
const opaque = [options.transientContext?.trim(), invocationInstructions(options.invocationPolicy)]
|
|
135
|
+
const opaque = [options.transientContext?.trim(), invocationInstructions(options.invocationPolicy)]
|
|
136
|
+
.filter(Boolean)
|
|
137
|
+
.join('\n\n');
|
|
432
138
|
const bundle = currentTransientBundle;
|
|
433
139
|
if (!bundle) return opaque;
|
|
434
140
|
const basePlan = buildContextBudgetPlan({
|
|
@@ -441,9 +147,8 @@ export async function* runQuery(
|
|
|
441
147
|
0,
|
|
442
148
|
basePlan.inputLimit - basePlan.historyTokens - basePlan.toolTokens - basePlan.transientTokens,
|
|
443
149
|
);
|
|
444
|
-
const selected =
|
|
445
|
-
? bundle
|
|
446
|
-
: resizeContextBundle(bundle, availableForBundle);
|
|
150
|
+
const selected =
|
|
151
|
+
availableForBundle >= bundle.diagnostics.usedTokens ? bundle : resizeContextBundle(bundle, availableForBundle);
|
|
447
152
|
advisorContextBundle = selected;
|
|
448
153
|
return [selected.text, opaque].filter(Boolean).join('\n\n');
|
|
449
154
|
};
|
|
@@ -465,18 +170,43 @@ export async function* runQuery(
|
|
|
465
170
|
const toolCallContext: ToolCallContext = {
|
|
466
171
|
output,
|
|
467
172
|
availableToolNames: () => currentEffectiveToolSet.names,
|
|
468
|
-
executionServices: {
|
|
173
|
+
executionServices: {
|
|
174
|
+
...options.executionServices,
|
|
175
|
+
executeTool: executeToolFn,
|
|
176
|
+
chat: chatFn,
|
|
177
|
+
invocationPolicy: options.invocationPolicy,
|
|
178
|
+
},
|
|
469
179
|
history,
|
|
470
180
|
sessionState,
|
|
471
181
|
advisorReviewContext: () => ({
|
|
472
182
|
...collectReviewContext(advisorContextBundle),
|
|
473
|
-
constraints: [
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
183
|
+
constraints: [
|
|
184
|
+
...collectReviewContext(advisorContextBundle).constraints,
|
|
185
|
+
{
|
|
186
|
+
id: 'execution.boundary',
|
|
187
|
+
authority: 'host',
|
|
188
|
+
content: JSON.stringify({
|
|
189
|
+
cwd: sessionState?.activeWorktree?.path ?? toolCallContext.cwd,
|
|
190
|
+
conversationMode: toolCallContext.conversationMode,
|
|
191
|
+
profile: runtimeContext.profile,
|
|
192
|
+
availableTools: [...currentEffectiveToolSet.names],
|
|
193
|
+
invocation: invocationInstructions(options.invocationPolicy),
|
|
194
|
+
}),
|
|
195
|
+
},
|
|
196
|
+
],
|
|
197
|
+
omittedIds: [
|
|
198
|
+
...new Set([
|
|
199
|
+
...(currentTransientBundle?.diagnostics.omittedIds ?? []),
|
|
200
|
+
...(advisorContextBundle?.diagnostics.omittedIds ?? []),
|
|
201
|
+
]),
|
|
202
|
+
...(options.transientContext ? ['legacy.transient-context:not-forwarded'] : []),
|
|
203
|
+
],
|
|
204
|
+
clippedIds: [
|
|
205
|
+
...new Set([
|
|
206
|
+
...(currentTransientBundle?.diagnostics.clippedIds ?? []),
|
|
207
|
+
...(advisorContextBundle?.diagnostics.clippedIds ?? []),
|
|
208
|
+
]),
|
|
209
|
+
],
|
|
480
210
|
}),
|
|
481
211
|
// Statistics only; consultations have no per-turn quota.
|
|
482
212
|
advisorCalls: { used: 0 },
|
|
@@ -503,16 +233,17 @@ export async function* runQuery(
|
|
|
503
233
|
promptCache: options.promptCache,
|
|
504
234
|
};
|
|
505
235
|
|
|
506
|
-
|
|
507
236
|
// 实际下发给模型的工具集:allowedTools 未传时给全量;
|
|
508
237
|
// 传入数组时严格按数组过滤,[] 表示本轮不下发任何工具。
|
|
509
238
|
// 同时约束模型可见工具和本循环实际执行的工具。
|
|
510
239
|
const hostTools = filterToolsForProvider(options.availableTools ?? ALL_TOOLS, provider);
|
|
511
|
-
const availableTools = hostTools.filter(
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
240
|
+
const availableTools = hostTools.filter(
|
|
241
|
+
(tool) =>
|
|
242
|
+
invocationToolAllowed(tool.name, options.invocationPolicy) &&
|
|
243
|
+
(tool.name !== 'Agent' || subagents.enabled) &&
|
|
244
|
+
(tool.name !== 'ComputerUse' ||
|
|
245
|
+
(runtimeContext.computer !== undefined && runtimeContext.computerIntent?.needed === true)),
|
|
246
|
+
);
|
|
516
247
|
const effectiveToolSet = createEffectiveToolSet({
|
|
517
248
|
availableTools,
|
|
518
249
|
runtime: runtimeContext,
|
|
@@ -520,10 +251,11 @@ export async function* runQuery(
|
|
|
520
251
|
requiredNames: options.requiredTools,
|
|
521
252
|
});
|
|
522
253
|
const activeTools = effectiveToolSet.tools;
|
|
523
|
-
const formalDeliveryRequired =
|
|
524
|
-
&&
|
|
525
|
-
|
|
526
|
-
|
|
254
|
+
const formalDeliveryRequired =
|
|
255
|
+
explicitFileDeliveryRequested &&
|
|
256
|
+
runtimeContext.profile === 'main' &&
|
|
257
|
+
options.invocationPolicy?.delivery !== 'caller' &&
|
|
258
|
+
effectiveToolSet.names.has('PresentResult');
|
|
527
259
|
let currentEffectiveToolSet = effectiveToolSet;
|
|
528
260
|
const advisorCoordinator = new AdvisorCoordinator({
|
|
529
261
|
history,
|
|
@@ -542,30 +274,39 @@ export async function* runQuery(
|
|
|
542
274
|
const parts = [
|
|
543
275
|
transientFor(activeTools),
|
|
544
276
|
coordinationText,
|
|
545
|
-
delegationRuntimeGuidance({
|
|
546
|
-
|
|
547
|
-
|
|
277
|
+
delegationRuntimeGuidance({
|
|
278
|
+
available: runtimeContext.profile === 'main' && currentEffectiveToolSet.names.has('Agent'),
|
|
279
|
+
maxConcurrent: subagents.maxConcurrent,
|
|
280
|
+
callsUsed: toolCallContext.agentCalls?.used ?? 0,
|
|
281
|
+
maxCalls: toolCallContext.agentCalls?.max ?? 0,
|
|
282
|
+
}),
|
|
548
283
|
diagramPreparationAttempted ? visualPresentation(output) : '',
|
|
549
|
-
delegationCorrectionAttempted
|
|
550
|
-
|
|
551
|
-
|
|
284
|
+
delegationCorrectionAttempted
|
|
285
|
+
? '本轮必要子任务尚未验收或接管完成。先读取结果、等待运行任务或完成接力,使用 AgentResolve 记录判断;若确实无法完成,明确报告缺口,不声称全部完成。'
|
|
286
|
+
: '',
|
|
287
|
+
askCorrectionAttempted
|
|
288
|
+
? '当前交互宿主支持 AskUserQuestion。若仍需用户选择或补充,请调用该工具一次只问一个问题,等待答案后继续;不要仅在正文列问后停止。若没有待决事项则正常回答,不必提问。'
|
|
289
|
+
: '',
|
|
290
|
+
emptyResponseRecoveryAttempted
|
|
291
|
+
? `上一次模型响应没有可交付正文(${emptyResponseRecoveryReason})。请基于已有历史和工具结果继续完成任务:信息充分则给出实质结论,不充分则继续必要操作或明确缺口。不要重复已经完成的调查或有副作用的操作;不要把未完成的任务说成完成。已有顾问意见继续有效,无需仅因恢复而再次咨询。`
|
|
292
|
+
: '',
|
|
552
293
|
deliveryCorrectionAttempted && !resultPresented ? DELIVERY_CORRECTION_REMINDER : '',
|
|
553
294
|
advisorAvailable ? advisorCoordinator.transientReminder() : '',
|
|
554
|
-
advisorAvailable
|
|
555
|
-
? advisorCoordinator.usageLine(toolCallContext.advisorCalls!.used)
|
|
556
|
-
: '',
|
|
295
|
+
advisorAvailable ? advisorCoordinator.usageLine(toolCallContext.advisorCalls!.used) : '',
|
|
557
296
|
];
|
|
558
297
|
return parts.filter(Boolean).join('\n\n');
|
|
559
298
|
};
|
|
560
299
|
let lastRequestTransientContext = [options.transientBundle?.text, options.transientContext]
|
|
561
|
-
.filter(Boolean)
|
|
300
|
+
.filter(Boolean)
|
|
301
|
+
.join('\n\n');
|
|
562
302
|
const evidenceLedger = new EvidenceLedger();
|
|
563
303
|
const stallGuard = new StallGuard();
|
|
564
304
|
const harnessDiagnostics = (
|
|
565
305
|
stopReason: import('./types.js').HarnessDiagnostics['stopReason'],
|
|
566
306
|
): import('./types.js').HarnessDiagnostics => ({
|
|
567
307
|
promptVersion: 'minimal-agent:prompt:v2',
|
|
568
|
-
basePromptHash: crypto
|
|
308
|
+
basePromptHash: crypto
|
|
309
|
+
.createHash('sha256')
|
|
569
310
|
.update(history[0]?.role === 'system' ? history[0].content : '')
|
|
570
311
|
.digest('hex'),
|
|
571
312
|
enabledPolicyIds: enabledPolicyIds({
|
|
@@ -575,9 +316,13 @@ export async function* runQuery(
|
|
|
575
316
|
capabilities: capabilitiesForTools(currentEffectiveToolSet.tools),
|
|
576
317
|
toolNames: currentEffectiveToolSet.names,
|
|
577
318
|
}),
|
|
578
|
-
enabledCapabilities: [
|
|
579
|
-
|
|
580
|
-
|
|
319
|
+
enabledCapabilities: [
|
|
320
|
+
...new Set(
|
|
321
|
+
currentEffectiveToolSet.tools
|
|
322
|
+
.map((tool) => capabilityForToolName(tool.name))
|
|
323
|
+
.filter((capability): capability is NonNullable<typeof capability> => Boolean(capability)),
|
|
324
|
+
),
|
|
325
|
+
],
|
|
581
326
|
effectiveToolSetSignature: currentEffectiveToolSet.signature,
|
|
582
327
|
tokenBudget: buildContextBudgetPlan({
|
|
583
328
|
provider,
|
|
@@ -618,8 +363,8 @@ export async function* runQuery(
|
|
|
618
363
|
message: `未作为图片附加:${skipped.source}(${skipped.reason}),已按普通文本处理。`,
|
|
619
364
|
};
|
|
620
365
|
}
|
|
621
|
-
let visionRequiredThisTurn =
|
|
622
|
-
&& userContent.some((part) => part.type === 'image_url' || part.type === 'video_url');
|
|
366
|
+
let visionRequiredThisTurn =
|
|
367
|
+
Array.isArray(userContent) && userContent.some((part) => part.type === 'image_url' || part.type === 'video_url');
|
|
623
368
|
if (visionRequiredThisTurn && provider.supportsVision === false) {
|
|
624
369
|
yield {
|
|
625
370
|
type: 'error',
|
|
@@ -682,14 +427,17 @@ export async function* runQuery(
|
|
|
682
427
|
};
|
|
683
428
|
const optimizationEvents: LoopEvent[] = [];
|
|
684
429
|
toolCallContext.optimizeConversation = createConversationOptimizer({
|
|
685
|
-
history,
|
|
430
|
+
history,
|
|
431
|
+
provider,
|
|
686
432
|
// This additional model call must contribute to the turn's resource ledger,
|
|
687
433
|
// without replacing the main model's context/cache usage measurements.
|
|
688
434
|
chat: async function* (args) {
|
|
689
435
|
turnUsage.totalCalls += 1;
|
|
690
436
|
for await (const event of chatFn(args)) {
|
|
691
|
-
if (
|
|
692
|
-
|
|
437
|
+
if (
|
|
438
|
+
event.type === 'done' &&
|
|
439
|
+
(typeof event.promptTokens === 'number' || typeof event.outputTokens === 'number')
|
|
440
|
+
) {
|
|
693
441
|
turnUsage.reportedCalls += 1;
|
|
694
442
|
turnUsage.inputTokens += event.promptTokens ?? 0;
|
|
695
443
|
turnUsage.outputTokens += event.outputTokens ?? 0;
|
|
@@ -698,8 +446,9 @@ export async function* runQuery(
|
|
|
698
446
|
}
|
|
699
447
|
},
|
|
700
448
|
promptCache: options.promptCache,
|
|
701
|
-
replaceHistory,
|
|
702
|
-
|
|
449
|
+
replaceHistory,
|
|
450
|
+
hasQueuedInput: options.hasQueuedInput,
|
|
451
|
+
onCompacted: (event) => optimizationEvents.push(event),
|
|
703
452
|
});
|
|
704
453
|
const interruption = (): Extract<LoopEvent, { type: 'interrupted' }> => {
|
|
705
454
|
if (options.hasQueuedInput?.()) {
|
|
@@ -722,19 +471,45 @@ export async function* runQuery(
|
|
|
722
471
|
}
|
|
723
472
|
modelEnvironmentCwd = nextEnvironmentCwd;
|
|
724
473
|
try {
|
|
725
|
-
const rules = await loadProjectPromptInstructions(modelEnvironmentCwd) ?? '';
|
|
474
|
+
const rules = (await loadProjectPromptInstructions(modelEnvironmentCwd)) ?? '';
|
|
726
475
|
observedRuleScopes.set(resolve(modelEnvironmentCwd), rules);
|
|
727
476
|
for (const scope of new Set([...observedRuleScopes.keys(), ...pendingRuleScopes])) {
|
|
728
|
-
if (scope !== resolve(modelEnvironmentCwd))
|
|
477
|
+
if (scope !== resolve(modelEnvironmentCwd))
|
|
478
|
+
observedRuleScopes.set(scope, (await loadProjectPromptInstructions(scope)) ?? '');
|
|
729
479
|
}
|
|
730
480
|
pendingRuleScopes.clear();
|
|
731
|
-
const mergedRules =
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
481
|
+
const mergedRules =
|
|
482
|
+
(await loadProjectPromptInstructions(modelEnvironmentCwd, [...observedRuleScopes.keys()])) ?? '';
|
|
483
|
+
currentTransientBundle = assembleContextBundle(
|
|
484
|
+
[
|
|
485
|
+
...(options.transientBundle?.fragments ?? []).filter(
|
|
486
|
+
(f) => f.id !== 'project.instructions' && f.id !== 'runtime.execution-environment',
|
|
487
|
+
),
|
|
488
|
+
contextFragment({
|
|
489
|
+
id: 'runtime.execution-environment',
|
|
490
|
+
kind: 'runtime',
|
|
491
|
+
authority: 'host',
|
|
492
|
+
priority: 100,
|
|
493
|
+
required: true,
|
|
494
|
+
atomic: true,
|
|
495
|
+
content: `当前有效执行目录:${modelEnvironmentCwd}。此宿主快照替换旧执行目录;项目规则以本次快照为准,旧历史中的版本不再生效。${rules ? '' : '当前目录及父目录没有适用的项目规则文件,旧规则不得恢复。'}工具权限仍以本次有效工具集和宿主闸门为准。`,
|
|
496
|
+
}),
|
|
497
|
+
...(mergedRules
|
|
498
|
+
? [
|
|
499
|
+
contextFragment({
|
|
500
|
+
id: 'project.instructions',
|
|
501
|
+
kind: 'project',
|
|
502
|
+
authority: 'constraint',
|
|
503
|
+
priority: 90,
|
|
504
|
+
required: true,
|
|
505
|
+
atomic: true,
|
|
506
|
+
content: mergedRules,
|
|
507
|
+
}),
|
|
508
|
+
]
|
|
509
|
+
: []),
|
|
510
|
+
],
|
|
511
|
+
options.transientBundle?.diagnostics.budgetTokens,
|
|
512
|
+
);
|
|
738
513
|
} catch (error) {
|
|
739
514
|
yield { type: 'error', error: `无法加载完整项目规则,停止本轮:${(error as Error).message}` };
|
|
740
515
|
yield { type: 'turn_done', completionStatus: 'delivery_incomplete' };
|
|
@@ -742,12 +517,18 @@ export async function* runQuery(
|
|
|
742
517
|
}
|
|
743
518
|
const handoff = options.agentHandoff?.current();
|
|
744
519
|
if (handoff && handoff.kind !== 'help') {
|
|
745
|
-
yield {
|
|
520
|
+
yield {
|
|
521
|
+
type: 'turn_done',
|
|
522
|
+
completionStatus: handoff.kind === 'result' ? 'completed' : 'delivery_incomplete',
|
|
746
523
|
warning: handoff.kind === 'blocked' ? '子任务已交回主 Agent 接力' : undefined,
|
|
747
|
-
usage: {
|
|
524
|
+
usage: {
|
|
525
|
+
...turnUsage,
|
|
526
|
+
trustworthy: turnUsage.totalCalls > 0 && turnUsage.reportedCalls === turnUsage.totalCalls,
|
|
527
|
+
},
|
|
528
|
+
};
|
|
748
529
|
return;
|
|
749
530
|
}
|
|
750
|
-
history.push(...(options.takePendingMessages?.() ?? []), ...await takeAgentNotifications(toolCallContext));
|
|
531
|
+
history.push(...(options.takePendingMessages?.() ?? []), ...(await takeAgentNotifications(toolCallContext)));
|
|
751
532
|
if (runtimeContext.profile === 'main') coordinationText = (await agentCoordinationContext(toolCallContext)).text;
|
|
752
533
|
incrementTurn(sessionState?.microCompact);
|
|
753
534
|
expireOldEntries(sessionState?.microCompact);
|
|
@@ -759,11 +540,7 @@ export async function* runQuery(
|
|
|
759
540
|
// 本地估算的虚高部分(服务端已删内容)由 autoCompactIfNeeded 的 serverDeletedTokens
|
|
760
541
|
// 校正,判据不会因此过早触发压缩。
|
|
761
542
|
const cacheEditsMode = provider.apiFormat === 'anthropic' && provider.cacheEdits === true;
|
|
762
|
-
reclaimOldToolResults(
|
|
763
|
-
history,
|
|
764
|
-
sessionState?.microCompact,
|
|
765
|
-
cacheEditsMode ? 'cacheEdits' : 'inline',
|
|
766
|
-
);
|
|
543
|
+
reclaimOldToolResults(history, sessionState?.microCompact, cacheEditsMode ? 'cacheEdits' : 'inline');
|
|
767
544
|
|
|
768
545
|
if (signal?.aborted) {
|
|
769
546
|
pushManualInterruptMarker(history);
|
|
@@ -785,177 +562,54 @@ export async function* runQuery(
|
|
|
785
562
|
return;
|
|
786
563
|
}
|
|
787
564
|
|
|
788
|
-
|
|
789
|
-
// 阈值 provider 无关、按 contextWindow × compactRatio 比例触发)
|
|
790
|
-
let requestTransientContext = buildRequestTransientContext();
|
|
791
|
-
lastRequestTransientContext = requestTransientContext;
|
|
792
|
-
const preCompactBudget = buildContextBudgetPlan({
|
|
793
|
-
provider,
|
|
794
|
-
history,
|
|
795
|
-
tools: activeTools,
|
|
796
|
-
transientContext: requestTransientContext,
|
|
797
|
-
});
|
|
798
|
-
if (preCompactBudget.overBudget || lastPromptTokens !== undefined) {
|
|
799
|
-
try {
|
|
800
|
-
const compactOptions = {
|
|
801
|
-
actualPromptTokens: lastPromptTokens,
|
|
802
|
-
toolsTokens: getToolsSchemaTokens(activeTools, effectiveToolSet.signature),
|
|
803
|
-
transientTokens: countTextTokens(requestTransientContext),
|
|
804
|
-
// cacheEdits 判据校正:本地 history 未瘦身,扣掉服务端已删部分的估算,
|
|
805
|
-
// 防止虚高估算劫持 Math.max 导致压缩过早触发(压缩会重建整个前缀缓存)。
|
|
806
|
-
...(cacheEditsMode && sessionState?.microCompact
|
|
807
|
-
? { serverDeletedTokens: sessionState.microCompact.cacheEdits.activeDeletedTokens }
|
|
808
|
-
: {}),
|
|
809
|
-
signal,
|
|
810
|
-
promptCache: options.promptCache,
|
|
811
|
-
chat: chatFn,
|
|
812
|
-
};
|
|
813
|
-
if (shouldAutoCompact(history, provider, compactOptions)) {
|
|
814
|
-
yield { type: 'compact_start' };
|
|
815
|
-
yield { type: 'stage_change', stage: 'compacting' };
|
|
816
|
-
}
|
|
817
|
-
const compact = await autoCompactIfNeeded(history, provider, compactOptions);
|
|
818
|
-
if (compact.compacted) {
|
|
819
|
-
replaceHistory(compact.messages);
|
|
820
|
-
yield {
|
|
821
|
-
type: 'compact_done',
|
|
822
|
-
before: compact.before,
|
|
823
|
-
after: compact.after,
|
|
824
|
-
trigger: 'threshold',
|
|
825
|
-
mode: compact.mode,
|
|
826
|
-
degraded: compact.degraded,
|
|
827
|
-
...(compact.fallbackReason ? { fallbackReason: compact.fallbackReason } : {}),
|
|
828
|
-
...(compact.messagesRemoved !== undefined ? { messagesRemoved: compact.messagesRemoved } : {}),
|
|
829
|
-
...(compact.tokensFreed !== undefined ? { tokensFreed: compact.tokensFreed } : {}),
|
|
830
|
-
};
|
|
831
|
-
yield { type: 'stage_change', stage: 'thinking' };
|
|
832
|
-
}
|
|
833
|
-
} catch (e) {
|
|
834
|
-
if (signal?.aborted || (e as Error).name === 'AbortError') {
|
|
835
|
-
yield interruption();
|
|
836
|
-
return;
|
|
837
|
-
}
|
|
838
|
-
// 压缩失败不要让整个对话挂掉:先 snip 砍最老消息释放空间再继续。
|
|
839
|
-
let recovered = false;
|
|
840
|
-
try {
|
|
841
|
-
const before = countMessagesTokens(history);
|
|
842
|
-
const snipped = snipCompactIfNeeded(history, { force: true, snipPercent: 0.3 });
|
|
843
|
-
if (snipped.messagesRemoved > 0) {
|
|
844
|
-
replaceHistory(snipped.messages);
|
|
845
|
-
recovered = true;
|
|
846
|
-
yield {
|
|
847
|
-
type: 'compact_done',
|
|
848
|
-
before,
|
|
849
|
-
after: countMessagesTokens(history),
|
|
850
|
-
trigger: 'threshold',
|
|
851
|
-
mode: 'snip-fallback',
|
|
852
|
-
degraded: true,
|
|
853
|
-
fallbackReason: 'llm-error',
|
|
854
|
-
messagesRemoved: snipped.messagesRemoved,
|
|
855
|
-
tokensFreed: snipped.tokensFreed,
|
|
856
|
-
};
|
|
857
|
-
}
|
|
858
|
-
} catch {
|
|
859
|
-
/* snip 也失败 → 真的只能硬上 */
|
|
860
|
-
}
|
|
861
|
-
if (!recovered) {
|
|
862
|
-
// 事件是 provider 原始报错唯一该去的地方:只到 stderr / UI,不进 history、
|
|
863
|
-
// 不落盘、不会在后续轮次重新发给模型(compact.ts 里那条降级消息因此保持无错误体)。
|
|
864
|
-
yield {
|
|
865
|
-
type: 'error',
|
|
866
|
-
error: `自动压缩失败,规则式截断也未能释放空间;已继续尝试回答:${(e as Error).message}`,
|
|
867
|
-
code: 'compact_failed',
|
|
868
|
-
};
|
|
869
|
-
}
|
|
870
|
-
}
|
|
871
|
-
}
|
|
872
|
-
|
|
873
|
-
// Re-open transient capacity after compaction. If the compacted tail is still
|
|
874
|
-
// too large, trim old history before sacrificing any model-visible ability.
|
|
875
|
-
requestTransientContext = buildRequestTransientContext();
|
|
876
|
-
lastRequestTransientContext = requestTransientContext;
|
|
877
|
-
let postCompactBudget = buildContextBudgetPlan({
|
|
878
|
-
provider,
|
|
565
|
+
const prepared = yield* prepareModelRequest({
|
|
879
566
|
history,
|
|
880
|
-
tools: activeTools,
|
|
881
|
-
transientContext: requestTransientContext,
|
|
882
|
-
});
|
|
883
|
-
if (postCompactBudget.overBudget) {
|
|
884
|
-
const before = countMessagesTokens(history);
|
|
885
|
-
const snipped = snipCompactIfNeeded(history, { force: true, snipPercent: 0.3 });
|
|
886
|
-
if (snipped.messagesRemoved > 0) {
|
|
887
|
-
replaceHistory(snipped.messages);
|
|
888
|
-
yield {
|
|
889
|
-
type: 'compact_done',
|
|
890
|
-
before,
|
|
891
|
-
after: countMessagesTokens(history),
|
|
892
|
-
trigger: 'post-budget',
|
|
893
|
-
mode: 'snip-fallback',
|
|
894
|
-
degraded: true,
|
|
895
|
-
fallbackReason: 'still-over-budget',
|
|
896
|
-
messagesRemoved: snipped.messagesRemoved,
|
|
897
|
-
tokensFreed: snipped.tokensFreed,
|
|
898
|
-
};
|
|
899
|
-
requestTransientContext = buildRequestTransientContext();
|
|
900
|
-
lastRequestTransientContext = requestTransientContext;
|
|
901
|
-
postCompactBudget = buildContextBudgetPlan({
|
|
902
|
-
provider,
|
|
903
|
-
history,
|
|
904
|
-
tools: activeTools,
|
|
905
|
-
transientContext: requestTransientContext,
|
|
906
|
-
});
|
|
907
|
-
}
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
const emergencyFit = fitToolsForEmergency({
|
|
911
567
|
provider,
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
// guidance against the actual request boundary, then account for its size.
|
|
929
|
-
requestTransientContext = buildRequestTransientContext();
|
|
930
|
-
lastRequestTransientContext = requestTransientContext;
|
|
931
|
-
const finalRequestBudget = buildContextBudgetPlan({
|
|
932
|
-
provider, history, tools: currentEffectiveToolSet.tools,
|
|
933
|
-
transientContext: requestTransientContext,
|
|
568
|
+
activeTools,
|
|
569
|
+
effectiveToolSet,
|
|
570
|
+
runtimeContext,
|
|
571
|
+
sessionState,
|
|
572
|
+
signal,
|
|
573
|
+
lastPromptTokens,
|
|
574
|
+
cacheEditsMode,
|
|
575
|
+
options,
|
|
576
|
+
chatFn,
|
|
577
|
+
replaceHistory,
|
|
578
|
+
buildRequestTransientContext: (tools) => {
|
|
579
|
+
currentEffectiveToolSet = tools;
|
|
580
|
+
advisorCoordinator.setAvailable(tools.names.has('Advisor'));
|
|
581
|
+
lastRequestTransientContext = buildRequestTransientContext();
|
|
582
|
+
return lastRequestTransientContext;
|
|
583
|
+
},
|
|
934
584
|
});
|
|
935
|
-
if (
|
|
936
|
-
yield
|
|
937
|
-
|
|
938
|
-
message: `当前模型窗口压力过高,本次请求临时省略工具 Schema:${emergencyFit.removedNames.join(', ')};下一轮会重新评估并恢复。`,
|
|
939
|
-
};
|
|
585
|
+
if (prepared.kind === 'interrupted') {
|
|
586
|
+
yield interruption();
|
|
587
|
+
return;
|
|
940
588
|
}
|
|
941
|
-
if (
|
|
942
|
-
yield {
|
|
943
|
-
type: 'error',
|
|
944
|
-
error: `当前请求即使经过上下文降级仍需要约 ${finalRequestBudget.totalInputTokens} tokens,超过模型输入上限 ${finalRequestBudget.inputLimit}。已停止发送,避免 Provider 接收必然失败的超限请求。`,
|
|
945
|
-
code: 'context_overflow',
|
|
946
|
-
};
|
|
589
|
+
if (prepared.kind === 'overflow') {
|
|
947
590
|
yield { type: 'harness_diagnostics', diagnostics: harnessDiagnostics('error') };
|
|
948
591
|
return;
|
|
949
592
|
}
|
|
593
|
+
const requestTransientContext = prepared.requestTransientContext;
|
|
594
|
+
currentEffectiveToolSet = prepared.currentEffectiveToolSet;
|
|
595
|
+
toolCallContext.reserveReadChars = createReadBudget(prepared.remainingTokens);
|
|
950
596
|
|
|
951
597
|
// 3. 调 LLM 并组装 assistant 消息
|
|
952
598
|
trace('model_start');
|
|
953
599
|
let assistantText = '';
|
|
954
600
|
// Candidate text is private until the delivery gates allow publication.
|
|
955
601
|
// Keep the compatibility event as a phase notice, never as a text retraction.
|
|
956
|
-
function* retireDraft(
|
|
957
|
-
|
|
958
|
-
|
|
602
|
+
function* retireDraft(
|
|
603
|
+
reason: Extract<LoopEvent, { type: 'assistant_draft_discarded' }>['reason'] | 'interrupted',
|
|
604
|
+
): Generator<LoopEvent> {
|
|
605
|
+
const message: Message = {
|
|
606
|
+
role: 'system',
|
|
607
|
+
content: answerPreparationNotice(reason),
|
|
608
|
+
id: crypto.randomUUID(),
|
|
609
|
+
timestamp: Date.now(),
|
|
610
|
+
_ephemeral: true,
|
|
611
|
+
_uiProgress: true,
|
|
612
|
+
};
|
|
959
613
|
history.push(message);
|
|
960
614
|
yield { type: 'injected_message', message };
|
|
961
615
|
if (reason !== 'interrupted') yield { type: 'assistant_draft_discarded', reason, textLength: 0 };
|
|
@@ -975,8 +629,7 @@ export async function* runQuery(
|
|
|
975
629
|
* 'length' = 被 provider 因 max_tokens 截断 —— 收尾时据此给 warning,
|
|
976
630
|
* 避免「半截答案」被静默当成正常 end_turn。
|
|
977
631
|
*/
|
|
978
|
-
let lastFinishReason: 'stop' | 'tool_calls' | 'length' | 'unknown' | 'aborted' =
|
|
979
|
-
'unknown';
|
|
632
|
+
let lastFinishReason: 'stop' | 'tool_calls' | 'length' | 'unknown' | 'aborted' = 'unknown';
|
|
980
633
|
|
|
981
634
|
// 进入 LLM 流前:等首 token / reasoning,本质是 thinking
|
|
982
635
|
yield { type: 'stage_change', stage: 'thinking' };
|
|
@@ -994,12 +647,8 @@ export async function* runQuery(
|
|
|
994
647
|
promptCache: options.promptCache,
|
|
995
648
|
visionPolicy: visionRequiredThisTurn ? 'required' : 'optional-history',
|
|
996
649
|
// cacheEdits 回收状态(同一会话级对象跨轮复用;compact 后由 resetCacheEdits 清空)
|
|
997
|
-
...(cacheEditsMode && sessionState?.microCompact
|
|
998
|
-
|
|
999
|
-
: {}),
|
|
1000
|
-
...(options.responseFormat
|
|
1001
|
-
? { responseFormat: options.responseFormat }
|
|
1002
|
-
: {}),
|
|
650
|
+
...(cacheEditsMode && sessionState?.microCompact ? { cacheEdits: sessionState.microCompact.cacheEdits } : {}),
|
|
651
|
+
...(options.responseFormat ? { responseFormat: options.responseFormat } : {}),
|
|
1003
652
|
})) {
|
|
1004
653
|
if (ev.type === 'text_delta') {
|
|
1005
654
|
assistantText += ev.delta;
|
|
@@ -1136,14 +785,29 @@ export async function* runQuery(
|
|
|
1136
785
|
// A pending review must run before another solving/publishing attempt.
|
|
1137
786
|
// Read-only diagnosis and human questions remain available while stalled.
|
|
1138
787
|
const pendingReview = options.advisorCheckpoints === false ? undefined : advisorCoordinator.pendingReview();
|
|
1139
|
-
if (
|
|
1140
|
-
&&
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
788
|
+
if (
|
|
789
|
+
pendingReview &&
|
|
790
|
+
turn < maxTurns &&
|
|
791
|
+
toolCallsByIndex.length > 0 &&
|
|
792
|
+
!toolCallsByIndex.some((tc) => ['Advisor', 'AskUserQuestion'].includes(tc.function.name)) &&
|
|
793
|
+
toolCallsByIndex.some(
|
|
794
|
+
(tc) =>
|
|
795
|
+
tc.function.name === 'PresentResult' ||
|
|
796
|
+
advisorSemanticsFor(
|
|
797
|
+
tc,
|
|
798
|
+
toolCallContext,
|
|
799
|
+
currentEffectiveToolSet.tools.find((t) => t.name === tc.function.name),
|
|
800
|
+
{ treatVerificationAsReadOnly: true },
|
|
801
|
+
).mutatesWorkspace ||
|
|
802
|
+
stallGuard.shouldReject(tc),
|
|
803
|
+
)
|
|
804
|
+
) {
|
|
1144
805
|
yield* retireDraft('advisor_checkpoint');
|
|
1145
|
-
toolCallsByIndex.splice(0, toolCallsByIndex.length, {
|
|
1146
|
-
|
|
806
|
+
toolCallsByIndex.splice(0, toolCallsByIndex.length, {
|
|
807
|
+
id: crypto.randomUUID(),
|
|
808
|
+
type: 'function',
|
|
809
|
+
function: { name: 'Advisor', arguments: JSON.stringify(pendingReview) },
|
|
810
|
+
});
|
|
1147
811
|
assistantText = '';
|
|
1148
812
|
}
|
|
1149
813
|
// Count the calls that survived review scheduling, including failed loads.
|
|
@@ -1151,7 +815,9 @@ export async function* runQuery(
|
|
|
1151
815
|
if (call.function.name !== 'Skill') continue;
|
|
1152
816
|
try {
|
|
1153
817
|
if (JSON.parse(call.function.arguments).name === 'dynamic-ui') diagramPreparationAttempted = true;
|
|
1154
|
-
} catch {
|
|
818
|
+
} catch {
|
|
819
|
+
/* Ordinary tool validation reports malformed arguments. */
|
|
820
|
+
}
|
|
1155
821
|
}
|
|
1156
822
|
const assistantMsg: Message = {
|
|
1157
823
|
role: 'assistant',
|
|
@@ -1168,35 +834,54 @@ export async function* runQuery(
|
|
|
1168
834
|
finalResponse: if (toolCallsByIndex.length === 0) {
|
|
1169
835
|
// Repair only private preparation, through the ordinary tool pipeline.
|
|
1170
836
|
// One attempt per turn, including missing/failed skills; never rewind a reply.
|
|
1171
|
-
if (
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
837
|
+
if (
|
|
838
|
+
!diagramPreparationAttempted &&
|
|
839
|
+
!options.responseFormat &&
|
|
840
|
+
runtimeContext.profile === 'main' &&
|
|
841
|
+
output.diagrams.length > 0 &&
|
|
842
|
+
currentEffectiveToolSet.names.has('Skill') &&
|
|
843
|
+
turn < maxTurns &&
|
|
844
|
+
needsDiagramPreparation(assistantText, userInput)
|
|
845
|
+
) {
|
|
1175
846
|
diagramPreparationAttempted = true;
|
|
1176
|
-
toolCallsByIndex.push({
|
|
1177
|
-
|
|
1178
|
-
|
|
847
|
+
toolCallsByIndex.push({
|
|
848
|
+
id: crypto.randomUUID(),
|
|
849
|
+
type: 'function',
|
|
850
|
+
function: {
|
|
851
|
+
name: 'Skill',
|
|
852
|
+
arguments: JSON.stringify({ name: 'dynamic-ui' }),
|
|
853
|
+
},
|
|
854
|
+
});
|
|
1179
855
|
assistantMsg.content = null;
|
|
1180
856
|
assistantMsg.tool_calls = toolCallsByIndex;
|
|
1181
857
|
break finalResponse;
|
|
1182
858
|
}
|
|
1183
|
-
if (
|
|
1184
|
-
&&
|
|
1185
|
-
|
|
859
|
+
if (
|
|
860
|
+
!askCorrectionAttempted &&
|
|
861
|
+
runtimeContext.interactiveAsk &&
|
|
862
|
+
currentEffectiveToolSet.names.has('AskUserQuestion') &&
|
|
863
|
+
needsInteractiveQuestion(stripThinkBlocks(assistantText)) &&
|
|
864
|
+
turn < maxTurns
|
|
865
|
+
) {
|
|
1186
866
|
askCorrectionAttempted = true;
|
|
1187
867
|
yield* retireDraft('interactive_question');
|
|
1188
868
|
continue;
|
|
1189
869
|
}
|
|
1190
870
|
const emptyResponse = stripThinkBlocks(assistantText).length === 0;
|
|
1191
|
-
const coordination =
|
|
1192
|
-
|
|
871
|
+
const coordination =
|
|
872
|
+
runtimeContext.profile === 'main' ? await agentCoordinationContext(toolCallContext) : undefined;
|
|
873
|
+
const pendingDelegations =
|
|
874
|
+
coordination?.unresolved.filter((record) => record.parentMessageId === currentUserMessageId) ?? [];
|
|
1193
875
|
if (pendingDelegations.length && !delegationCorrectionAttempted && turn < maxTurns) {
|
|
1194
876
|
delegationCorrectionAttempted = true;
|
|
1195
877
|
coordinationText = coordination!.text;
|
|
1196
878
|
yield* retireDraft('formal_delivery');
|
|
1197
879
|
continue;
|
|
1198
880
|
}
|
|
1199
|
-
const pendingMessages = [
|
|
881
|
+
const pendingMessages = [
|
|
882
|
+
...(options.takePendingMessages?.() ?? []),
|
|
883
|
+
...(await takeAgentNotifications(toolCallContext)),
|
|
884
|
+
];
|
|
1200
885
|
if (pendingMessages.length) {
|
|
1201
886
|
history.push(...pendingMessages);
|
|
1202
887
|
continue;
|
|
@@ -1205,27 +890,36 @@ export async function* runQuery(
|
|
|
1205
890
|
// presenting it. Give it one model-only corrective pass; the reminder is
|
|
1206
891
|
// transient and therefore never pollutes persisted conversation history.
|
|
1207
892
|
if (
|
|
1208
|
-
!emptyResponse &&
|
|
1209
|
-
&&
|
|
1210
|
-
&&
|
|
1211
|
-
&&
|
|
893
|
+
!emptyResponse &&
|
|
894
|
+
formalDeliveryRequired &&
|
|
895
|
+
!resultPresented &&
|
|
896
|
+
!deliveryCorrectionAttempted &&
|
|
897
|
+
turn < maxTurns
|
|
1212
898
|
) {
|
|
1213
899
|
deliveryCorrectionAttempted = true;
|
|
1214
900
|
yield* retireDraft('formal_delivery');
|
|
1215
901
|
continue;
|
|
1216
902
|
}
|
|
1217
|
-
const deliveryCheckpoint =
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
903
|
+
const deliveryCheckpoint =
|
|
904
|
+
emptyResponse || options.advisorCheckpoints === false
|
|
905
|
+
? { allow: true as const }
|
|
906
|
+
: advisorCoordinator.beforeDelivery(assistantText);
|
|
907
|
+
const fallbackReview =
|
|
908
|
+
!emptyResponse && options.advisorCheckpoints !== false
|
|
909
|
+
? advisorCoordinator.pendingReview(assistantText)
|
|
910
|
+
: undefined;
|
|
1222
911
|
if (fallbackReview && turn < maxTurns) {
|
|
1223
912
|
yield* retireDraft('advisor_checkpoint');
|
|
1224
913
|
// Reuse the effective tool boundary, permissions, cancellation, events and
|
|
1225
914
|
// persistence. Never call a provider behind the host's tool pipeline.
|
|
1226
|
-
toolCallsByIndex.push({
|
|
1227
|
-
|
|
1228
|
-
|
|
915
|
+
toolCallsByIndex.push({
|
|
916
|
+
id: crypto.randomUUID(),
|
|
917
|
+
type: 'function',
|
|
918
|
+
function: {
|
|
919
|
+
name: 'Advisor',
|
|
920
|
+
arguments: JSON.stringify(fallbackReview),
|
|
921
|
+
},
|
|
922
|
+
});
|
|
1229
923
|
assistantMsg.content = null;
|
|
1230
924
|
assistantMsg.tool_calls = toolCallsByIndex;
|
|
1231
925
|
break finalResponse;
|
|
@@ -1236,18 +930,28 @@ export async function* runQuery(
|
|
|
1236
930
|
}
|
|
1237
931
|
if (!emptyResponse) {
|
|
1238
932
|
for (const message of advisorCoordinator.deliveryWarnings()) yield { type: 'warning', message };
|
|
1239
|
-
if (!deliveryCheckpoint.allow)
|
|
933
|
+
if (!deliveryCheckpoint.allow)
|
|
934
|
+
yield { type: 'warning', message: '已达到本轮执行上限,顾问交付检查尚未完成;不能把此次输出视为已复核。' };
|
|
1240
935
|
}
|
|
1241
936
|
if (emptyResponse) {
|
|
1242
937
|
// Recover once within the existing turn budget, retaining tool receipts and
|
|
1243
938
|
// advisor state. Never replay tools or expose an empty candidate as an answer.
|
|
1244
939
|
if (!resultPresented && !emptyResponseRecoveryAttempted && turn < maxTurns) {
|
|
1245
940
|
emptyResponseRecoveryAttempted = true;
|
|
1246
|
-
emptyResponseRecoveryReason =
|
|
1247
|
-
|
|
1248
|
-
? '
|
|
1249
|
-
|
|
1250
|
-
|
|
941
|
+
emptyResponseRecoveryReason =
|
|
942
|
+
lastFinishReason === 'length'
|
|
943
|
+
? '输出额度耗尽'
|
|
944
|
+
: lastFinishReason === 'unknown'
|
|
945
|
+
? '响应流缺少正常结束状态'
|
|
946
|
+
: '响应为空或仅含思考内容';
|
|
947
|
+
const message: Message = {
|
|
948
|
+
role: 'system',
|
|
949
|
+
content: '正在恢复回答,已保留调查结果与工作进展。',
|
|
950
|
+
id: crypto.randomUUID(),
|
|
951
|
+
timestamp: Date.now(),
|
|
952
|
+
_ephemeral: true,
|
|
953
|
+
_uiProgress: true,
|
|
954
|
+
};
|
|
1251
955
|
history.push(message);
|
|
1252
956
|
yield { type: 'injected_message', message };
|
|
1253
957
|
continue;
|
|
@@ -1259,81 +963,49 @@ export async function* runQuery(
|
|
|
1259
963
|
let publicationError: unknown;
|
|
1260
964
|
let publicationInterrupted = false;
|
|
1261
965
|
let streamed = false;
|
|
1262
|
-
if (!options.responseFormat && runtimeContext.profile === 'main'
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
turnUsage.totalCalls += 1;
|
|
1278
|
-
for await (const event of chatFn({
|
|
1279
|
-
provider,
|
|
1280
|
-
messages: publicationMessages,
|
|
1281
|
-
tools: toolsForResponsePhase(currentEffectiveToolSet.tools, 'publication'), signal,
|
|
1282
|
-
conversationMode: runtimeContext.conversationMode ?? 'private',
|
|
1283
|
-
promptCache: options.promptCache,
|
|
1284
|
-
})) {
|
|
1285
|
-
if (event.type === 'done' && !publicationDone) {
|
|
1286
|
-
publicationDone = true;
|
|
1287
|
-
if (typeof event.promptTokens === 'number' || typeof event.outputTokens === 'number') {
|
|
1288
|
-
turnUsage.reportedCalls += 1;
|
|
1289
|
-
turnUsage.inputTokens += event.promptTokens ?? 0;
|
|
1290
|
-
turnUsage.outputTokens += event.outputTokens ?? 0;
|
|
1291
|
-
}
|
|
1292
|
-
}
|
|
1293
|
-
if (signal?.aborted || options.hasQueuedInput?.()) {
|
|
1294
|
-
publicationInterrupted = true;
|
|
1295
|
-
break;
|
|
1296
|
-
}
|
|
1297
|
-
if (event.type === 'text_delta') {
|
|
1298
|
-
// Final expression is live generation, not an exact-copy replay.
|
|
1299
|
-
// Once visible, text is never replaced or joined with a different draft.
|
|
1300
|
-
publishedText += event.delta;
|
|
1301
|
-
streamed = true;
|
|
1302
|
-
yield { type: 'text', delta: event.delta };
|
|
1303
|
-
} else if (event.type === 'tool_call_delta') {
|
|
1304
|
-
throw new Error('最终表达阶段返回了非预期工具调用,未执行该工具。');
|
|
1305
|
-
} else if (event.type === 'done') {
|
|
1306
|
-
publicationDone = true;
|
|
1307
|
-
lastFinishReason = event.stopReason;
|
|
1308
|
-
if (event.stopReason === 'aborted') publicationInterrupted = true;
|
|
1309
|
-
}
|
|
1310
|
-
}
|
|
1311
|
-
if (!publicationDone && !publicationInterrupted) throw new Error('最终答案流未正常结束。');
|
|
1312
|
-
if (!publicationInterrupted && lastFinishReason !== 'stop' && lastFinishReason !== 'length') throw new Error('最终答案流缺少有效的结束状态。');
|
|
1313
|
-
if (!publishedText.trim() && !publicationInterrupted) throw new Error('最终表达未返回正文。');
|
|
1314
|
-
} catch (error) {
|
|
1315
|
-
publicationInterrupted ||= signal?.aborted === true || options.hasQueuedInput?.() === true || (error as Error)?.name === 'AbortError';
|
|
1316
|
-
publicationError = error;
|
|
1317
|
-
}
|
|
1318
|
-
if (publicationError && !publicationInterrupted && !publishedText) {
|
|
1319
|
-
yield { type: 'warning', message: '最终回答流未能开始,已一次性显示内部准备的答案;未进行新的事实核验。' };
|
|
1320
|
-
yield { type: 'text', delta: candidate };
|
|
1321
|
-
streamed = true;
|
|
1322
|
-
publishedText = candidate;
|
|
1323
|
-
lastFinishReason = candidateFinishReason;
|
|
1324
|
-
publicationError = undefined;
|
|
1325
|
-
}
|
|
966
|
+
if (!options.responseFormat && runtimeContext.profile === 'main' && output.finalDelivery === 'stream') {
|
|
967
|
+
const publication = yield* publishFinalAnswer({
|
|
968
|
+
provider,
|
|
969
|
+
messages: buildModelMessages(history, requestTransientContext, userMessage.id),
|
|
970
|
+
candidate: assistantText,
|
|
971
|
+
candidateFinishReason: lastFinishReason,
|
|
972
|
+
output,
|
|
973
|
+
chatFn,
|
|
974
|
+
options,
|
|
975
|
+
signal,
|
|
976
|
+
conversationMode: runtimeContext.conversationMode ?? 'private',
|
|
977
|
+
turnUsage,
|
|
978
|
+
});
|
|
979
|
+
const publishedText = publication.text;
|
|
980
|
+
({ publicationError, publicationInterrupted, streamed, lastFinishReason } = publication);
|
|
1326
981
|
assistantText = publishedText;
|
|
1327
982
|
assistantMsg.content = publishedText || null;
|
|
1328
983
|
if (!publishedText) {
|
|
1329
|
-
if (publicationInterrupted)
|
|
1330
|
-
|
|
984
|
+
if (publicationInterrupted)
|
|
985
|
+
yield {
|
|
986
|
+
...interruption(),
|
|
987
|
+
usage: {
|
|
988
|
+
...turnUsage,
|
|
989
|
+
trustworthy: turnUsage.totalCalls > 0 && turnUsage.reportedCalls === turnUsage.totalCalls,
|
|
990
|
+
},
|
|
991
|
+
};
|
|
992
|
+
else
|
|
993
|
+
yield {
|
|
994
|
+
type: 'error',
|
|
995
|
+
code: 'llm_error',
|
|
996
|
+
error: `最终答案生成失败:${String(publicationError)}`,
|
|
997
|
+
usage: {
|
|
998
|
+
...turnUsage,
|
|
999
|
+
trustworthy: turnUsage.totalCalls > 0 && turnUsage.reportedCalls === turnUsage.totalCalls,
|
|
1000
|
+
},
|
|
1001
|
+
};
|
|
1331
1002
|
return;
|
|
1332
1003
|
}
|
|
1333
1004
|
}
|
|
1334
1005
|
assistantMsg.presentation = 'final';
|
|
1335
1006
|
trace('final_published');
|
|
1336
|
-
if (assistantMsg.role === 'assistant')
|
|
1007
|
+
if (assistantMsg.role === 'assistant')
|
|
1008
|
+
assistantMsg.advisorTaskVersion = advisorCoordinator.versions.taskVersion;
|
|
1337
1009
|
assistantMsg.advisorCheckpoints = advisorCoordinator.drainDegradations();
|
|
1338
1010
|
history.push(assistantMsg);
|
|
1339
1011
|
// Non-interactive consumers retain atomic output. Interactive hosts
|
|
@@ -1345,8 +1017,24 @@ export async function* runQuery(
|
|
|
1345
1017
|
};
|
|
1346
1018
|
if (publicationInterrupted || publicationError) {
|
|
1347
1019
|
yield { type: 'warning', message: '最终答案未完整生成,已保留已输出内容;未撤回或重新生成。' };
|
|
1348
|
-
if (publicationInterrupted)
|
|
1349
|
-
|
|
1020
|
+
if (publicationInterrupted)
|
|
1021
|
+
yield {
|
|
1022
|
+
...interruption(),
|
|
1023
|
+
usage: {
|
|
1024
|
+
...turnUsage,
|
|
1025
|
+
trustworthy: turnUsage.totalCalls > 0 && turnUsage.reportedCalls === turnUsage.totalCalls,
|
|
1026
|
+
},
|
|
1027
|
+
};
|
|
1028
|
+
else
|
|
1029
|
+
yield {
|
|
1030
|
+
type: 'error',
|
|
1031
|
+
code: 'llm_error',
|
|
1032
|
+
error: `最终答案流中断:${String(publicationError)}`,
|
|
1033
|
+
usage: {
|
|
1034
|
+
...turnUsage,
|
|
1035
|
+
trustworthy: turnUsage.totalCalls > 0 && turnUsage.reportedCalls === turnUsage.totalCalls,
|
|
1036
|
+
},
|
|
1037
|
+
};
|
|
1350
1038
|
return;
|
|
1351
1039
|
}
|
|
1352
1040
|
}
|
|
@@ -1364,11 +1052,8 @@ export async function* runQuery(
|
|
|
1364
1052
|
// The final answer has already consumed the latest screenshot. Persist only a compact
|
|
1365
1053
|
// marker so the next turn does not resend a large screen image.
|
|
1366
1054
|
evictPriorVisionShots(history);
|
|
1367
|
-
const estimatedTokens = countMessagesTokens(history) +
|
|
1368
|
-
const usedTokens = Math.min(
|
|
1369
|
-
provider.contextWindow,
|
|
1370
|
-
Math.max(lastPromptTokens ?? 0, estimatedTokens),
|
|
1371
|
-
);
|
|
1055
|
+
const estimatedTokens = countMessagesTokens(history) + estimateToolsTokens(activeTools);
|
|
1056
|
+
const usedTokens = Math.min(provider.contextWindow, Math.max(lastPromptTokens ?? 0, estimatedTokens));
|
|
1372
1057
|
yield { type: 'harness_diagnostics', diagnostics: harnessDiagnostics('complete') };
|
|
1373
1058
|
const claimValidation = validateFinalClaims(assistantText, evidenceLedger.snapshot());
|
|
1374
1059
|
for (const warning of claimValidation.warnings) yield { type: 'warning', message: warning };
|
|
@@ -1426,8 +1111,12 @@ export async function* runQuery(
|
|
|
1426
1111
|
usage,
|
|
1427
1112
|
};
|
|
1428
1113
|
} else {
|
|
1429
|
-
yield {
|
|
1430
|
-
|
|
1114
|
+
yield {
|
|
1115
|
+
type: 'turn_done',
|
|
1116
|
+
completionStatus: 'completed',
|
|
1117
|
+
usage,
|
|
1118
|
+
...(emptyResponse ? { warning: '成果已提交,未生成文字总结;请查看已提交的成果。' } : {}),
|
|
1119
|
+
};
|
|
1431
1120
|
}
|
|
1432
1121
|
return;
|
|
1433
1122
|
}
|
|
@@ -1452,269 +1141,18 @@ export async function* runQuery(
|
|
|
1452
1141
|
// (OpenAI 协议要求 tool 消息与上一条 assistant 的 tool_calls 一一对应)。
|
|
1453
1142
|
yield { type: 'stage_change', stage: 'tool_executing' };
|
|
1454
1143
|
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
const handoff = options.agentHandoff?.current();
|
|
1468
|
-
if (handoff && handoff.kind !== 'help') return { tc, result: { ok: false, disposition: 'deferred', error: '子任务已经提交交接,本批后续调用未执行' } };
|
|
1469
|
-
if (signal?.aborted || options.hasQueuedInput?.()) {
|
|
1470
|
-
return { tc, result: { ok: false, disposition: 'deferred', error: 'aborted' } };
|
|
1471
|
-
}
|
|
1472
|
-
const advisorAvailable = currentEffectiveToolSet.names.has('Advisor');
|
|
1473
|
-
if (!advisorAvailable && stallGuard.shouldReject(tc)) {
|
|
1474
|
-
return {
|
|
1475
|
-
tc,
|
|
1476
|
-
result: {
|
|
1477
|
-
ok: false as const,
|
|
1478
|
-
disposition: 'deferred',
|
|
1479
|
-
error:
|
|
1480
|
-
'检测到同一工具和参数已连续两次返回相同失败;第三次调用已在执行前拒绝,请改变输入或改用其他路径。',
|
|
1481
|
-
recovery: 'change-input',
|
|
1482
|
-
sensitivity: 'public',
|
|
1483
|
-
},
|
|
1484
|
-
};
|
|
1485
|
-
}
|
|
1486
|
-
// The model-visible catalog and the execution boundary are the same set.
|
|
1487
|
-
// A provider may still emit a stale or fabricated tool call; return a
|
|
1488
|
-
// recoverable result so the model can choose an available path.
|
|
1489
|
-
if (!currentEffectiveToolSet.names.has(tc.function.name)) {
|
|
1490
|
-
const boundary = options.toolBoundaryLabel ?? '当前宿主授权';
|
|
1491
|
-
const visible = [...currentEffectiveToolSet.names];
|
|
1492
|
-
const availableHint = visible.length
|
|
1493
|
-
? `(可用:${visible.slice(0, 12).join('、')}${visible.length > 12 ? '…' : ''})`
|
|
1494
|
-
: '(本轮没有可用工具)';
|
|
1495
|
-
const deniedReason = tc.function.name === 'Agent'
|
|
1496
|
-
? '当前上下文不允许调用 Agent;请直接完成子任务或改用已提供的工具'
|
|
1497
|
-
: `工具 ${tc.function.name} 不在${boundary}的有效工具集中${availableHint},请改用已提供的工具继续任务`;
|
|
1498
|
-
return {
|
|
1499
|
-
tc,
|
|
1500
|
-
result: {
|
|
1501
|
-
ok: false as const,
|
|
1502
|
-
error: deniedReason,
|
|
1503
|
-
recovery: 'change-input',
|
|
1504
|
-
sensitivity: 'public',
|
|
1505
|
-
},
|
|
1506
|
-
};
|
|
1507
|
-
}
|
|
1508
|
-
const checkpoint = options.advisorCheckpoints === false
|
|
1509
|
-
? { allow: true as const }
|
|
1510
|
-
: advisorCoordinator.beforeTool(tc, {
|
|
1511
|
-
...advisorSemanticsFor(tc, toolCallContext, currentEffectiveToolSet.tools.find((tool) => tool.name === tc.function.name), { treatVerificationAsReadOnly: true }),
|
|
1512
|
-
retriesFailure: stallGuard.shouldReject(tc),
|
|
1513
|
-
});
|
|
1514
|
-
if (!checkpoint.allow) {
|
|
1515
|
-
return { tc, result: checkpoint.deferred! };
|
|
1516
|
-
}
|
|
1517
|
-
// 工具执行闸门:宿主注入后覆盖所有工具,包括只读工具。deny → 把 reason 当
|
|
1518
|
-
// tool_result 回填,模型据此改道/如实报告,不抛异常不中断整轮。宿主异常一律 deny。
|
|
1519
|
-
// Keep the host gate's side-effect classification identical to the
|
|
1520
|
-
// Advisor classification. Shell tools are generic in their schema but
|
|
1521
|
-
// individual commands can be deterministic read-only diagnostics.
|
|
1522
|
-
let isReadOnly = currentEffectiveToolSet.tools.find(tool => tool.name === tc.function.name)?.isReadOnly === true;
|
|
1523
|
-
if (tc.function.name === 'Bash' || tc.function.name === 'PowerShell') {
|
|
1524
|
-
try {
|
|
1525
|
-
const input = JSON.parse(tc.function.arguments || '{}') as { command?: unknown };
|
|
1526
|
-
if (typeof input.command === 'string') isReadOnly = isReadOnlyShellCommand(input.command);
|
|
1527
|
-
} catch { isReadOnly = false; }
|
|
1528
|
-
}
|
|
1529
|
-
const effectiveToolGate = runtimeContext.toolGate ?? options.toolGate;
|
|
1530
|
-
if (effectiveToolGate) {
|
|
1531
|
-
let decision;
|
|
1532
|
-
try {
|
|
1533
|
-
const gateDetails = getToolGateDetails(
|
|
1534
|
-
tc.function.name,
|
|
1535
|
-
tc.function.arguments,
|
|
1536
|
-
{ ...toolCallContext, cwd: sessionState?.activeWorktree?.path ?? toolCallContext.cwd, toolCallId: tc.id, reportProgress },
|
|
1537
|
-
);
|
|
1538
|
-
decision = await effectiveToolGate(
|
|
1539
|
-
{
|
|
1540
|
-
name: tc.function.name,
|
|
1541
|
-
argsJson: tc.function.arguments,
|
|
1542
|
-
isReadOnly,
|
|
1543
|
-
toolCallId: tc.id,
|
|
1544
|
-
...gateDetails,
|
|
1545
|
-
},
|
|
1546
|
-
signal,
|
|
1547
|
-
);
|
|
1548
|
-
} catch {
|
|
1549
|
-
decision = { allow: false as const, reason: '工具执行审批失败(宿主异常)' };
|
|
1550
|
-
}
|
|
1551
|
-
if (!decision.allow) {
|
|
1552
|
-
return { tc, result: { ok: false as const, error: decision.reason } };
|
|
1553
|
-
}
|
|
1554
|
-
}
|
|
1555
|
-
if (signal?.aborted || options.hasQueuedInput?.()) {
|
|
1556
|
-
return { tc, result: { ok: false, disposition: 'deferred', error: 'aborted' } };
|
|
1557
|
-
}
|
|
1558
|
-
options.onToolStart?.(tc.function.name, tc.id);
|
|
1559
|
-
// 浅拷贝填入本次调用的 toolCallId:进度按调用实例路由(并行同名工具不串扰)。
|
|
1560
|
-
// advisorCalls/agentCalls 是共享对象引用,计数仍全轮共享,不受拷贝影响。
|
|
1561
|
-
const executionCwd = sessionState?.activeWorktree?.path ?? toolCallContext.cwd;
|
|
1562
|
-
if (executionCwd !== modelEnvironmentCwd) return { tc, result: {
|
|
1563
|
-
ok: false as const, disposition: 'deferred' as const,
|
|
1564
|
-
error: '执行环境已改变,本批调用依据旧目录生成,尚未执行。请等待刷新环境和项目规则后重新生成调用。',
|
|
1565
|
-
} };
|
|
1566
|
-
// Load path-scoped rules before a direct file mutation. Returning a
|
|
1567
|
-
// deferred result gives the model a boundary to reconsider its edit.
|
|
1568
|
-
if (['Edit', 'MultiEdit', 'Write', 'Download', 'NotebookEdit', 'Trash'].includes(tc.function.name)) {
|
|
1569
|
-
try {
|
|
1570
|
-
const args = JSON.parse(tc.function.arguments);
|
|
1571
|
-
const targets = tc.function.name === 'Trash' ? args.paths : [args.file_path ?? args.path ?? args.notebook_path];
|
|
1572
|
-
for (const target of Array.isArray(targets) ? targets : []) {
|
|
1573
|
-
if (typeof target !== 'string') continue;
|
|
1574
|
-
// Download/Trash use literal path.resolve; editing tools expand ~.
|
|
1575
|
-
const resolved = ['Download', 'Trash'].includes(tc.function.name)
|
|
1576
|
-
? { ok: true as const, resolvedPath: resolve(executionCwd ?? runtimeContext.cwd, target) }
|
|
1577
|
-
: resolveToolPath(target, executionCwd ?? runtimeContext.cwd);
|
|
1578
|
-
if (!resolved.ok) return { tc, result: { ok: false as const, error: resolved.error } };
|
|
1579
|
-
const scope = dirname(resolved.resolvedPath);
|
|
1580
|
-
const rules = await loadProjectPromptInstructions(scope) ?? '';
|
|
1581
|
-
if (observedRuleScopes.get(scope) === rules || (!rules && !observedRuleScopes.has(scope))) continue;
|
|
1582
|
-
if (countTextTokens(rules) > 12000) return { tc, result: { ok: false as const, error: `目标目录规则超过安全交接预算,未执行修改;请缩小任务或整理规则:${scope}` } };
|
|
1583
|
-
pendingRuleScopes.add(scope);
|
|
1584
|
-
// 带缩进:让真实换行落进 content,TUI 的工具结果 3 行折叠按 \n 计行,
|
|
1585
|
-
// 压成一行的 JSON.stringify 会绕开折叠、把整份规则原文原样刷进终端。
|
|
1586
|
-
return { tc, result: { ok: false as const, disposition: 'deferred' as const,
|
|
1587
|
-
error: `目标目录规则已更新,尚未执行修改。先阅读本快照再重新决定操作(规则不能扩大授权):\n${JSON.stringify({ scope, rules: rules || '该作用域无项目规则,撤销其旧规则' }, null, 2)}` } };
|
|
1588
|
-
}
|
|
1589
|
-
} catch (error) {
|
|
1590
|
-
return { tc, result: { ok: false as const, error: `无法核验目标目录规则,未执行修改:${(error as Error).message}` } };
|
|
1591
|
-
}
|
|
1592
|
-
}
|
|
1593
|
-
const needsWorkspaceLease = runtimeContext.profile === 'main'
|
|
1594
|
-
&& capabilityForToolName(tc.function.name) !== 'subagent'
|
|
1595
|
-
&& advisorSemanticsFor(tc, toolCallContext, currentEffectiveToolSet.tools.find((tool) => tool.name === tc.function.name)).mutatesWorkspace;
|
|
1596
|
-
const releaseWorkspace = needsWorkspaceLease && executionCwd
|
|
1597
|
-
? await acquireSubagentLease(executionCwd, await normalizeWriteScopes(executionCwd, undefined), 8,
|
|
1598
|
-
{ signal, owner: runtimeContext.sessionId ?? executionCwd }) : undefined;
|
|
1599
|
-
let result: ToolResult;
|
|
1600
|
-
try {
|
|
1601
|
-
result = await executeToolFn(
|
|
1602
|
-
tc.function.name,
|
|
1603
|
-
tc.function.arguments,
|
|
1604
|
-
signal,
|
|
1605
|
-
{
|
|
1606
|
-
...toolCallContext,
|
|
1607
|
-
delegationTools: currentEffectiveToolSet.tools,
|
|
1608
|
-
cwd:
|
|
1609
|
-
sessionState?.activeWorktree?.path ?? toolCallContext.cwd,
|
|
1610
|
-
toolCallId: tc.id,
|
|
1611
|
-
reportProgress,
|
|
1612
|
-
},
|
|
1613
|
-
);
|
|
1614
|
-
} finally { releaseWorkspace?.(); }
|
|
1615
|
-
if (tc.function.name === 'ComputerUse' && sessionState) {
|
|
1616
|
-
sessionState.computerAutomation = { lastUsedAt: Date.now() };
|
|
1617
|
-
}
|
|
1618
|
-
const downloadableArtifact =
|
|
1619
|
-
result.ok && result.metadata?.kind === 'browser_download'
|
|
1620
|
-
? result.metadata.artifact
|
|
1621
|
-
: undefined;
|
|
1622
|
-
const formalManifest =
|
|
1623
|
-
result.ok && result.metadata?.kind === 'result_manifest'
|
|
1624
|
-
? result.metadata.manifest
|
|
1625
|
-
: downloadableArtifact;
|
|
1626
|
-
if (
|
|
1627
|
-
formalManifest &&
|
|
1628
|
-
(tc.function.name === 'PresentResult' || downloadableArtifact) &&
|
|
1629
|
-
options.commitFormalDelivery
|
|
1630
|
-
) {
|
|
1631
|
-
try {
|
|
1632
|
-
await options.commitFormalDelivery({
|
|
1633
|
-
toolCallId: tc.id,
|
|
1634
|
-
turnId: userMessage.id ?? options.userMessageId ?? 'turn:unknown',
|
|
1635
|
-
manifest: formalManifest,
|
|
1636
|
-
});
|
|
1637
|
-
} catch (error) {
|
|
1638
|
-
result = {
|
|
1639
|
-
ok: false,
|
|
1640
|
-
error: `正式交付未能可靠保存:${(error as Error).message}`,
|
|
1641
|
-
recovery: 'retry',
|
|
1642
|
-
sensitivity: 'local',
|
|
1643
|
-
};
|
|
1644
|
-
}
|
|
1645
|
-
}
|
|
1646
|
-
if (tc.function.name === 'Advisor' || tc.function.name === 'AdvisorDecision') {
|
|
1647
|
-
// 串行批次内下一项可能就是 AdvisorDecision/受保护写入,必须立即推进协调器状态。
|
|
1648
|
-
advisorCoordinator.observeToolResult(tc, result);
|
|
1649
|
-
}
|
|
1650
|
-
options.onToolEnd?.(tc.id, false);
|
|
1651
|
-
return { tc, result };
|
|
1652
|
-
} catch (e) {
|
|
1653
|
-
const wasAborted = (e as Error).name === 'AbortError';
|
|
1654
|
-
options.onToolEnd?.(tc.id, wasAborted);
|
|
1655
|
-
if (wasAborted) {
|
|
1656
|
-
return { tc, result: { ok: false as const, error: 'aborted' } };
|
|
1657
|
-
}
|
|
1658
|
-
return { tc, result: { ok: false as const, error: (e as Error).message } };
|
|
1659
|
-
}
|
|
1660
|
-
};
|
|
1661
|
-
|
|
1662
|
-
const runOne: typeof runOneImpl = async (tc, reportProgress) => {
|
|
1663
|
-
// Also settle denied/invalid/unavailable calls that never reached the
|
|
1664
|
-
// provider adapter. This records a real attempt, not an ignored reminder.
|
|
1665
|
-
let consultation;
|
|
1666
|
-
if (tc.function.name === 'Advisor') {
|
|
1667
|
-
let reason: import('./types.js').AdvisorReason | undefined;
|
|
1668
|
-
try { reason = JSON.parse(tc.function.arguments).reason; } catch { /* validation handles malformed input */ }
|
|
1669
|
-
consultation = advisorCoordinator.beginConsultation(reason);
|
|
1670
|
-
}
|
|
1671
|
-
const outcome = await runOneImpl(tc, reportProgress);
|
|
1672
|
-
if (consultation && outcome.result.metadata?.kind !== 'advisor_consultation'
|
|
1673
|
-
&& !( !outcome.result.ok && outcome.result.error === 'aborted')) {
|
|
1674
|
-
outcome.result = { ...outcome.result, metadata: advisorCoordinator.finishConsultation(consultation, outcome.result.ok) };
|
|
1675
|
-
}
|
|
1676
|
-
const metadata = outcome.result.metadata;
|
|
1677
|
-
if (metadata?.kind === 'advisor_decision' && outcome.result.ok
|
|
1678
|
-
|| metadata?.kind === 'advisor_consultation') {
|
|
1679
|
-
if (metadata.checkpoint === 'stalled') stallGuard.completeReview(metadata.checkpointKey.split(':').at(-1)!);
|
|
1680
|
-
}
|
|
1681
|
-
const failure = stallGuard.observeAttempt(tc, outcome.result);
|
|
1682
|
-
if (failure.stalled && failure.signature) {
|
|
1683
|
-
failureReviews.push(failure);
|
|
1684
|
-
advisorCoordinator.requireFailureReview(failure.signature, failure.attempts ?? []);
|
|
1685
|
-
}
|
|
1686
|
-
return outcome;
|
|
1687
|
-
};
|
|
1688
|
-
|
|
1689
|
-
const startEvent = (
|
|
1690
|
-
tc: ToolCall,
|
|
1691
|
-
): Extract<LoopEvent, { type: 'tool_start' }> => ({
|
|
1692
|
-
type: 'tool_start',
|
|
1693
|
-
toolCallId: tc.id,
|
|
1694
|
-
toolName: tc.function.name,
|
|
1695
|
-
argsPreview: previewArgs(tc.function.arguments),
|
|
1696
|
-
argsFriendly: friendlyToolDescription(tc.function.name, tc.function.arguments),
|
|
1697
|
-
argsConcise: friendlyToolDescription(tc.function.name, tc.function.arguments, conciseToolPath),
|
|
1698
|
-
});
|
|
1699
|
-
const endEvent = (
|
|
1700
|
-
o: WorkerOutcome,
|
|
1701
|
-
): Extract<LoopEvent, { type: 'tool_end' }> => ({
|
|
1702
|
-
type: 'tool_end',
|
|
1703
|
-
toolCallId: o.tc.id,
|
|
1704
|
-
toolName: o.tc.function.name,
|
|
1705
|
-
ok: o.result.ok,
|
|
1706
|
-
content: o.result.ok
|
|
1707
|
-
? o.result.content
|
|
1708
|
-
: o.result.error === 'aborted'
|
|
1709
|
-
? '(已中断)'
|
|
1710
|
-
: `Error: ${o.result.error}`,
|
|
1711
|
-
metadata: o.result.metadata,
|
|
1712
|
-
});
|
|
1713
|
-
|
|
1714
|
-
const segments = executionSegments(toolCallsByIndex, (tc) => {
|
|
1715
|
-
const tool = currentEffectiveToolSet.tools.find((tool) => tool.name === tc.function.name);
|
|
1716
|
-
return !['AskUserQuestion', 'Advisor', 'AdvisorDecision', 'OptimizeConversation', 'AgentReport', 'AgentResolve'].includes(tc.function.name)
|
|
1717
|
-
&& (tool?.isConcurrencySafe ?? getToolByName(tc.function.name)?.isConcurrencySafe) === true;
|
|
1144
|
+
const { runOne, failureReviews } = createToolExecutor({
|
|
1145
|
+
options,
|
|
1146
|
+
runtimeContext,
|
|
1147
|
+
toolCallContext,
|
|
1148
|
+
currentEffectiveToolSet,
|
|
1149
|
+
advisorCoordinator,
|
|
1150
|
+
stallGuard,
|
|
1151
|
+
modelEnvironmentCwd,
|
|
1152
|
+
observedRuleScopes,
|
|
1153
|
+
pendingRuleScopes,
|
|
1154
|
+
currentUserMessageId: userMessage.id!,
|
|
1155
|
+
executeToolFn,
|
|
1718
1156
|
});
|
|
1719
1157
|
|
|
1720
1158
|
let anyAborted = false;
|
|
@@ -1723,49 +1161,51 @@ export async function* runQuery(
|
|
|
1723
1161
|
// settled. Both paths use this single writer, avoiding duplicate observation.
|
|
1724
1162
|
const commitOutcome = async function* ({ tc, result }: WorkerOutcome): AsyncGenerator<LoopEvent> {
|
|
1725
1163
|
if (tc.function.name !== 'Advisor' && tc.function.name !== 'AdvisorDecision') {
|
|
1726
|
-
advisorCoordinator.observeToolResult(
|
|
1164
|
+
advisorCoordinator.observeToolResult(
|
|
1165
|
+
tc,
|
|
1166
|
+
result,
|
|
1167
|
+
advisorSemanticsFor(
|
|
1168
|
+
tc,
|
|
1169
|
+
toolCallContext,
|
|
1170
|
+
currentEffectiveToolSet.tools.find((tool) => tool.name === tc.function.name),
|
|
1171
|
+
{ treatVerificationAsReadOnly: true },
|
|
1172
|
+
),
|
|
1173
|
+
);
|
|
1727
1174
|
}
|
|
1728
1175
|
if (result.ok || result.disposition !== 'deferred') {
|
|
1729
1176
|
evidenceLedger.record(tc.function.name, tc.function.arguments, result);
|
|
1730
1177
|
}
|
|
1731
1178
|
if (!result.ok && result.error === 'aborted') anyAborted = true;
|
|
1732
1179
|
const browserArtifact =
|
|
1733
|
-
result.ok && result.metadata?.kind === 'browser_download'
|
|
1734
|
-
? result.metadata.artifact
|
|
1735
|
-
: undefined;
|
|
1180
|
+
result.ok && result.metadata?.kind === 'browser_download' ? result.metadata.artifact : undefined;
|
|
1736
1181
|
if (
|
|
1737
1182
|
result.ok &&
|
|
1738
|
-
((tc.function.name === 'PresentResult' &&
|
|
1739
|
-
result.metadata?.kind === 'result_manifest') ||
|
|
1740
|
-
browserArtifact)
|
|
1183
|
+
((tc.function.name === 'PresentResult' && result.metadata?.kind === 'result_manifest') || browserArtifact)
|
|
1741
1184
|
) {
|
|
1742
1185
|
resultPresented = true;
|
|
1743
1186
|
// `not_run` only says that no optional, format-specific validator was
|
|
1744
1187
|
// invoked. The PresentResult boundary has still checked the file and
|
|
1745
1188
|
// persisted its fingerprint, so it must not downgrade a normal delivery.
|
|
1746
|
-
const manifest =
|
|
1747
|
-
|
|
1748
|
-
? result.metadata.manifest
|
|
1749
|
-
: browserArtifact;
|
|
1750
|
-
presentedResultUnverified ||=
|
|
1751
|
-
manifest?.files.some((file) => file.verification === 'failed') ??
|
|
1752
|
-
false;
|
|
1189
|
+
const manifest = result.metadata?.kind === 'result_manifest' ? result.metadata.manifest : browserArtifact;
|
|
1190
|
+
presentedResultUnverified ||= manifest?.files.some((file) => file.verification === 'failed') ?? false;
|
|
1753
1191
|
}
|
|
1754
1192
|
const rawContent = result.ok ? result.content : `Error: ${result.error}`;
|
|
1755
1193
|
const basePolicy = result.policy ?? getToolResultPolicy(tc.function.name);
|
|
1756
1194
|
// 超大结果先归档再压缩:归档失败时保留原文,绝不因为磁盘问题破坏上下文。
|
|
1757
|
-
const archived =
|
|
1758
|
-
|
|
1759
|
-
|
|
1195
|
+
const archived =
|
|
1196
|
+
result.ok && sessionState
|
|
1197
|
+
? await archiveToolResult(rawContent, sessionState.microCompact.archiveKey, tc.function.name)
|
|
1198
|
+
: { content: rawContent, originalChars: rawContent.length };
|
|
1760
1199
|
// 归档退化必须发声:撞会话上限后每条超长结果都退回原文塞进上下文,
|
|
1761
1200
|
// 也就是这个机制本来要解决的问题原样回来了 —— 静默的话没人会发现。
|
|
1762
1201
|
if (archived.degraded && !archiveDegradedWarned) {
|
|
1763
1202
|
archiveDegradedWarned = true;
|
|
1764
1203
|
yield {
|
|
1765
1204
|
type: 'warning',
|
|
1766
|
-
message:
|
|
1767
|
-
|
|
1768
|
-
|
|
1205
|
+
message:
|
|
1206
|
+
archived.degraded === 'session-limit'
|
|
1207
|
+
? '工具结果归档已达本会话上限,后续超长结果将直接进入上下文(归档不再生效)。建议 /new 开新会话。'
|
|
1208
|
+
: '工具结果归档写盘失败,后续超长结果将直接进入上下文。请检查磁盘空间与权限。',
|
|
1769
1209
|
};
|
|
1770
1210
|
}
|
|
1771
1211
|
const policy = archived.archiveRef
|
|
@@ -1783,9 +1223,12 @@ export async function* runQuery(
|
|
|
1783
1223
|
tool_result_policy: policy,
|
|
1784
1224
|
...(!result.ok && result.disposition === 'deferred' ? { toolResultDisposition: 'deferred' as const } : {}),
|
|
1785
1225
|
...(tc === toolCallsByIndex.at(-1) ? { advisorCheckpoints: advisorCoordinator.drainDegradations() } : {}),
|
|
1786
|
-
advisorMutatesWorkspace: advisorSemanticsFor(
|
|
1787
|
-
|
|
1788
|
-
|
|
1226
|
+
advisorMutatesWorkspace: advisorSemanticsFor(
|
|
1227
|
+
tc,
|
|
1228
|
+
toolCallContext,
|
|
1229
|
+
currentEffectiveToolSet.tools.find((tool) => tool.name === tc.function.name),
|
|
1230
|
+
{ treatVerificationAsReadOnly: true },
|
|
1231
|
+
).mutatesWorkspace,
|
|
1789
1232
|
...(result.metadata ? { display_metadata: result.metadata } : {}),
|
|
1790
1233
|
timestamp: Date.now(),
|
|
1791
1234
|
id: crypto.randomUUID(),
|
|
@@ -1793,137 +1236,47 @@ export async function* runQuery(
|
|
|
1793
1236
|
});
|
|
1794
1237
|
};
|
|
1795
1238
|
|
|
1796
|
-
const settled
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
const queue: LoopEvent[] = [];
|
|
1804
|
-
let signalNew: (() => void) | null = null;
|
|
1805
|
-
const enqueue = (ev: LoopEvent) => {
|
|
1806
|
-
queue.push(ev);
|
|
1807
|
-
signalNew?.();
|
|
1808
|
-
signalNew = null;
|
|
1809
|
-
};
|
|
1810
|
-
|
|
1811
|
-
// 默认并发 = 本轮工具调用数,即不设人为上限(orderedPool 内部本就会
|
|
1812
|
-
// Math.min(concurrency, items.length))——这批工具已经过 allConcurrencySafe
|
|
1813
|
-
// 判定全部只读/并发安全,能同时跑多少纯是吞吐量问题,不是正确性问题;宿主仍可显式传
|
|
1814
|
-
// options.toolConcurrency 收紧("并发安全"不等于"内存/子进程/provider 配额无限",
|
|
1815
|
-
// 真有这类顾虑应由宿主按自己的资源预算显式传值,而不是内核替所有宿主悄悄封顶)。
|
|
1816
|
-
// ⚠️ 这条默认值曾被 9ce9be61 那次子Agent/浏览器重构悄悄改回 8——那次改动是照着一份
|
|
1817
|
-
// 更早的 loop.ts 打的 diff,没带上这里的注释,属于历史真空期的意外覆盖,不是刻意
|
|
1818
|
-
// 重新评估后的决定;发现后原样恢复。
|
|
1819
|
-
const allDone = orderedPool(segment, options.toolConcurrency ?? segment.length, async (tc) => {
|
|
1820
|
-
enqueue(startEvent(tc));
|
|
1821
|
-
const o = await runOne(tc, (message, detail) => enqueue({
|
|
1822
|
-
type: 'tool_progress',
|
|
1823
|
-
toolCallId: tc.id,
|
|
1824
|
-
toolName: tc.function.name,
|
|
1825
|
-
message,
|
|
1826
|
-
detail,
|
|
1827
|
-
}));
|
|
1828
|
-
enqueue(endEvent(o));
|
|
1829
|
-
return o;
|
|
1830
|
-
});
|
|
1831
|
-
|
|
1832
|
-
let finished = false;
|
|
1833
|
-
const wakeDone = () => {
|
|
1834
|
-
finished = true;
|
|
1835
|
-
signalNew?.();
|
|
1836
|
-
signalNew = null;
|
|
1837
|
-
};
|
|
1838
|
-
void allDone.then(wakeDone, wakeDone);
|
|
1839
|
-
|
|
1840
|
-
while (!finished || queue.length > 0) {
|
|
1841
|
-
while (queue.length > 0) yield queue.shift()!;
|
|
1842
|
-
if (finished) break;
|
|
1843
|
-
await new Promise<void>((r) => {
|
|
1844
|
-
signalNew = r;
|
|
1845
|
-
});
|
|
1846
|
-
}
|
|
1847
|
-
|
|
1848
|
-
const outcomes = await allDone;
|
|
1849
|
-
settled.push(...outcomes);
|
|
1850
|
-
for (const outcome of outcomes) yield* commitOutcome(outcome);
|
|
1851
|
-
} else {
|
|
1852
|
-
// —— 串行:逐个执行(含写类工具,或只有一个工具)。事件直接顺序 yield,无需队列。
|
|
1853
|
-
for (const tc of segment) {
|
|
1854
|
-
if (questionEncountered || advisorEncountered) {
|
|
1855
|
-
const outcome = { tc, result: { ok: false as const, disposition: 'deferred' as const,
|
|
1856
|
-
error: questionEncountered
|
|
1857
|
-
? '此调用在用户回答前已生成,尚未执行。请先阅读提问结果并重新规划;仅在仍必要时重新调用。'
|
|
1858
|
-
: '此调用在顾问返回前已生成,尚未执行。请先阅读顾问结果,自主决定采纳、部分采纳或拒绝,再重新生成必要的工具调用。咨询失败则自行判断继续,不必重复咨询。' } };
|
|
1859
|
-
yield startEvent(tc);
|
|
1860
|
-
yield endEvent(outcome);
|
|
1861
|
-
settled.push(outcome);
|
|
1862
|
-
yield* commitOutcome(outcome);
|
|
1863
|
-
continue;
|
|
1864
|
-
}
|
|
1865
|
-
questionEncountered = tc.function.name === 'AskUserQuestion';
|
|
1866
|
-
yield startEvent(tc);
|
|
1867
|
-
const queue: LoopEvent[] = [];
|
|
1868
|
-
let wake: (() => void) | null = null;
|
|
1869
|
-
let finished = false;
|
|
1870
|
-
const worker = runOne(tc, (message, detail) => {
|
|
1871
|
-
queue.push({
|
|
1872
|
-
type: 'tool_progress',
|
|
1873
|
-
toolCallId: tc.id,
|
|
1874
|
-
toolName: tc.function.name,
|
|
1875
|
-
message,
|
|
1876
|
-
detail,
|
|
1877
|
-
});
|
|
1878
|
-
wake?.();
|
|
1879
|
-
wake = null;
|
|
1880
|
-
}).finally(() => {
|
|
1881
|
-
finished = true;
|
|
1882
|
-
wake?.();
|
|
1883
|
-
wake = null;
|
|
1884
|
-
});
|
|
1885
|
-
while (!finished || queue.length > 0) {
|
|
1886
|
-
while (queue.length > 0) yield queue.shift()!;
|
|
1887
|
-
if (finished) break;
|
|
1888
|
-
await new Promise<void>((resolve) => { wake = resolve; });
|
|
1889
|
-
}
|
|
1890
|
-
const o = await worker;
|
|
1891
|
-
// Every later call was planned without this second opinion, including
|
|
1892
|
-
// decision records and further consultations. Return control to the LLM.
|
|
1893
|
-
advisorEncountered = tc.function.name === 'Advisor';
|
|
1894
|
-
yield endEvent(o);
|
|
1895
|
-
settled.push(o);
|
|
1896
|
-
yield* commitOutcome(o);
|
|
1897
|
-
}
|
|
1898
|
-
}
|
|
1899
|
-
}
|
|
1900
|
-
|
|
1901
|
-
|
|
1239
|
+
const settled = yield* executeToolBatch({
|
|
1240
|
+
toolCallsByIndex,
|
|
1241
|
+
currentEffectiveToolSet,
|
|
1242
|
+
options,
|
|
1243
|
+
runOne,
|
|
1244
|
+
commitOutcome,
|
|
1245
|
+
});
|
|
1902
1246
|
|
|
1903
1247
|
// Publish after every pending tool result has been appended, so hosts see a
|
|
1904
1248
|
// complete protocol chain when persisting the compacted history.
|
|
1905
1249
|
while (optimizationEvents.length > 0) yield optimizationEvents.shift()!;
|
|
1906
1250
|
const childHandoff = options.agentHandoff?.current();
|
|
1907
1251
|
if (childHandoff && childHandoff.kind !== 'help') {
|
|
1908
|
-
yield {
|
|
1252
|
+
yield {
|
|
1253
|
+
type: 'turn_done',
|
|
1254
|
+
completionStatus: childHandoff.kind === 'result' ? 'completed' : 'delivery_incomplete',
|
|
1909
1255
|
warning: childHandoff.kind === 'blocked' ? '子任务已交回主 Agent 接力' : undefined,
|
|
1910
|
-
usage: {
|
|
1256
|
+
usage: {
|
|
1257
|
+
...turnUsage,
|
|
1258
|
+
trustworthy: turnUsage.totalCalls > 0 && turnUsage.reportedCalls === turnUsage.totalCalls,
|
|
1259
|
+
},
|
|
1260
|
+
};
|
|
1911
1261
|
return;
|
|
1912
1262
|
}
|
|
1913
1263
|
|
|
1914
1264
|
// Failures are recorded at execution time; batch observation now supplements
|
|
1915
1265
|
// them with the existing no-progress signal without counting attempts twice.
|
|
1916
|
-
const stall =
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1266
|
+
const stall =
|
|
1267
|
+
failureReviews.at(-1) ??
|
|
1268
|
+
(turn + 1 < maxTurns
|
|
1269
|
+
? stallGuard.observe(
|
|
1270
|
+
settled.map((item) => item.tc),
|
|
1271
|
+
settled.map((item) => item.result),
|
|
1272
|
+
assistantText,
|
|
1273
|
+
)
|
|
1274
|
+
: { stalled: false as const });
|
|
1923
1275
|
if (stall.stalled) {
|
|
1924
|
-
const message =
|
|
1925
|
-
|
|
1926
|
-
|
|
1276
|
+
const message =
|
|
1277
|
+
stall.reason === 'repeated_failure'
|
|
1278
|
+
? '同一问题两次尝试失败,请停止原样重试;顾问可用时,继续解决或交付前先咨询,由主 Agent 自行核验和决策。只读诊断仍可继续。'
|
|
1279
|
+
: '检测到连续工具轮次没有产生新文本或新结果;请切换路径或咨询 Advisor。';
|
|
1927
1280
|
if (stall.reason !== 'repeated_failure') advisorCoordinator.notifyStalled(stall.signature ?? stall.reason);
|
|
1928
1281
|
yield { type: 'warning', message };
|
|
1929
1282
|
}
|
|
@@ -1934,7 +1287,9 @@ export async function* runQuery(
|
|
|
1934
1287
|
let injectedShot: Message | null = null;
|
|
1935
1288
|
try {
|
|
1936
1289
|
injectedShot = await injectVisionFollowups(history, settled, provider);
|
|
1937
|
-
} catch {
|
|
1290
|
+
} catch {
|
|
1291
|
+
/* 截图回填失败不影响主流程 */
|
|
1292
|
+
}
|
|
1938
1293
|
// The history copy is intentionally downgraded after the turn. Emit a detached snapshot
|
|
1939
1294
|
// so TUI/Desktop stores do not see their already-rendered image mutate underneath them.
|
|
1940
1295
|
if (injectedShot) {
|
|
@@ -1970,256 +1325,3 @@ export async function* runQuery(
|
|
|
1970
1325
|
code: 'max_turns',
|
|
1971
1326
|
};
|
|
1972
1327
|
}
|
|
1973
|
-
|
|
1974
|
-
// ---------------- 辅助函数 ----------------
|
|
1975
|
-
|
|
1976
|
-
/**
|
|
1977
|
-
* 易变上下文只存在于 provider 请求副本中,不会进入会话历史。
|
|
1978
|
-
*/
|
|
1979
|
-
export const SYSTEM_REMINDER_OPEN = '<system-reminder>';
|
|
1980
|
-
export const SYSTEM_REMINDER_CLOSE = '</system-reminder>';
|
|
1981
|
-
|
|
1982
|
-
/**
|
|
1983
|
-
* Attach transient context to the current turn's user message in a model-only
|
|
1984
|
-
* clone. Later tool-loop requests reuse the same decorated message, making each
|
|
1985
|
-
* serialized request a strict prefix extension of the previous one.
|
|
1986
|
-
*/
|
|
1987
|
-
export function buildModelMessages(
|
|
1988
|
-
history: Message[],
|
|
1989
|
-
transientContext?: string,
|
|
1990
|
-
currentUserMessageId?: string,
|
|
1991
|
-
): Message[] {
|
|
1992
|
-
history = history.filter(message => !(message.role === 'system' && message._ephemeral));
|
|
1993
|
-
const transient = transientContext?.trim();
|
|
1994
|
-
if (!transient) return history;
|
|
1995
|
-
let index = currentUserMessageId
|
|
1996
|
-
? history.findIndex((message) => message.role === 'user' && message.id === currentUserMessageId)
|
|
1997
|
-
: -1;
|
|
1998
|
-
if (index < 0) {
|
|
1999
|
-
for (let cursor = history.length - 1; cursor >= 0; cursor--) {
|
|
2000
|
-
if (history[cursor]?.role === 'user') {
|
|
2001
|
-
index = cursor;
|
|
2002
|
-
break;
|
|
2003
|
-
}
|
|
2004
|
-
}
|
|
2005
|
-
}
|
|
2006
|
-
if (index < 0) return history;
|
|
2007
|
-
const target = history[index];
|
|
2008
|
-
if (target.role !== 'user') return history;
|
|
2009
|
-
const reminder = `${SYSTEM_REMINDER_OPEN}\n${transient}\n${SYSTEM_REMINDER_CLOSE}`;
|
|
2010
|
-
const content = typeof target.content === 'string'
|
|
2011
|
-
? `${target.content}\n\n${reminder}`
|
|
2012
|
-
: [...target.content, { type: 'text' as const, text: reminder }];
|
|
2013
|
-
const next = [...history];
|
|
2014
|
-
next[index] = { ...target, content };
|
|
2015
|
-
return next;
|
|
2016
|
-
}
|
|
2017
|
-
|
|
2018
|
-
// 关键节点图像核验的合成消息标记:既是给模型的提示前缀,也是 evictPriorVisionShots 识别旧图消息的锚。
|
|
2019
|
-
export const VISION_SHOT_MARKER = '【实况图像】';
|
|
2020
|
-
|
|
2021
|
-
/**
|
|
2022
|
-
* 历史标记。这套机制最初只有 WebBrowser 用得上,文案写死了「浏览器」;
|
|
2023
|
-
* 现在 Bash 的 ATTACH_IMAGE sentinel 也能产图(屏幕截图 / 生成的产物),文案必须中性。
|
|
2024
|
-
* 但**持久化会话里仍存着旧前缀的消息**,淘汰逻辑必须继续认它 ——
|
|
2025
|
-
* 否则老会话里的旧截图永远不会被降级,图片一路累积撑爆上下文。
|
|
2026
|
-
*/
|
|
2027
|
-
const LEGACY_VISION_SHOT_MARKERS = ['【浏览器实况截图】'] as const;
|
|
2028
|
-
|
|
2029
|
-
const VISION_SHOT_PROMPT = `${VISION_SHOT_MARKER}上面是刚才工具执行后附带的真实图像(浏览器页面 / 屏幕截图 / 生成的产物)。请仔细看图核对:是否真的到达/呈现了你预期的状态?若与预期不符(空白/报错/弹窗遮挡/验证拦截/内容不对),不要假设成功,如实说明并调整下一步。`;
|
|
2030
|
-
|
|
2031
|
-
/** 一条 user 消息的文本是否是「实况图像」合成消息(含历史前缀)。 */
|
|
2032
|
-
function isVisionShotText(text: string): boolean {
|
|
2033
|
-
return (
|
|
2034
|
-
text.startsWith(VISION_SHOT_MARKER) ||
|
|
2035
|
-
LEGACY_VISION_SHOT_MARKERS.some((marker) => text.startsWith(marker))
|
|
2036
|
-
);
|
|
2037
|
-
}
|
|
2038
|
-
|
|
2039
|
-
/**
|
|
2040
|
-
* 把本轮工具附带的实况截图(ToolResult.visionImages)组装成一条带图 user 消息喂给模型自我核验。
|
|
2041
|
-
* - 仅在模型明确支持识图时注入(provider.supportsVision === true);否则丢弃(不假装核验、也不给非视觉端点发图触发 400)。
|
|
2042
|
-
* - 注入前先把历史里旧的截图消息降级为纯文字占位(evictPriorVisionShots),保证上下文里最多一张实况图,
|
|
2043
|
-
* 避免图片累积撑爆上下文(呼应浏览器 ADR「高频帧只存内存、只持久化最终结果」)。
|
|
2044
|
-
* - **失败的工具结果同样回填**:出错现场那张图往往比成功时更有信息量(权限弹窗、
|
|
2045
|
-
* 验证拦截、意外的前台窗口)。此前这里要求 `ok === true`,逼得想附图的工具只能
|
|
2046
|
-
* 把失败伪装成成功——见 types.ts 里 ToolResult 那段注释。
|
|
2047
|
-
*/
|
|
2048
|
-
async function injectVisionFollowups(
|
|
2049
|
-
history: Message[],
|
|
2050
|
-
settled: { result: ToolResult }[],
|
|
2051
|
-
provider: Provider,
|
|
2052
|
-
): Promise<Message | null> {
|
|
2053
|
-
if (provider.supportsVision === false) return null;
|
|
2054
|
-
const images = settled.flatMap((s) => s.result.visionImages ?? []);
|
|
2055
|
-
if (images.length === 0) return null;
|
|
2056
|
-
evictPriorVisionShots(history);
|
|
2057
|
-
// 工具可以给自己这批图配一句说明(visionNote)。内核只负责把它拼在通用提示之后,
|
|
2058
|
-
// 不解读内容——「这是一组 1Hz 连续帧」这类语义只有产图的工具知道。
|
|
2059
|
-
const notes = settled
|
|
2060
|
-
.flatMap((s) => (s.result.visionNote ? [s.result.visionNote] : []))
|
|
2061
|
-
.filter((note, index, all) => all.indexOf(note) === index);
|
|
2062
|
-
const text = notes.length > 0
|
|
2063
|
-
? `${VISION_SHOT_PROMPT}\n\n${notes.join('\n\n')}`
|
|
2064
|
-
: VISION_SHOT_PROMPT;
|
|
2065
|
-
const content = await buildUserContent({ text, images });
|
|
2066
|
-
const msg: Message = { role: 'user', content, timestamp: Date.now(), id: crypto.randomUUID() };
|
|
2067
|
-
history.push(msg);
|
|
2068
|
-
return msg;
|
|
2069
|
-
}
|
|
2070
|
-
|
|
2071
|
-
/** 把历史里旧的实况图像 user 消息降级为纯文字占位、剔除其图片,释放上下文(只保留最近一张真图)。 */
|
|
2072
|
-
function evictPriorVisionShots(history: Message[]): void {
|
|
2073
|
-
for (const msg of history) {
|
|
2074
|
-
if (msg.role !== 'user' || !Array.isArray(msg.content)) continue;
|
|
2075
|
-
const textPart = msg.content.find((p) => p.type === 'text');
|
|
2076
|
-
if (textPart?.type === 'text' && isVisionShotText(textPart.text)) {
|
|
2077
|
-
msg.content = `${VISION_SHOT_MARKER}(此前的实况图像已从上下文移除以节省空间。)`;
|
|
2078
|
-
}
|
|
2079
|
-
}
|
|
2080
|
-
}
|
|
2081
|
-
|
|
2082
|
-
/**
|
|
2083
|
-
* 用户按ESC手动中断
|
|
2084
|
-
*/
|
|
2085
|
-
function pushManualInterruptMarker(history: Message[]): void {
|
|
2086
|
-
history.push({
|
|
2087
|
-
role: 'user',
|
|
2088
|
-
content: '(用户按下了 ESC/Ctrl+C 中断了任务。等待用户新指令。)',
|
|
2089
|
-
timestamp: Date.now(),
|
|
2090
|
-
id: crypto.randomUUID(),
|
|
2091
|
-
});
|
|
2092
|
-
}
|
|
2093
|
-
|
|
2094
|
-
/**
|
|
2095
|
-
* 有新的用户输入在队列中,当前安全停靠
|
|
2096
|
-
*/
|
|
2097
|
-
function pushQueuedInputMarker(history: Message[]): void {
|
|
2098
|
-
history.push({
|
|
2099
|
-
role: 'user',
|
|
2100
|
-
content: [
|
|
2101
|
-
'(系统提示:用户在工作过程中发送了新的消息。',
|
|
2102
|
-
'你现在看到的是上一轮工作在被打断前已完成的部分。',
|
|
2103
|
-
'请阅读用户的新消息,判断:',
|
|
2104
|
-
'1. 如果是不相关的新任务:简要确认已收到,转向新任务,可在新任务完成后询问是否回到之前工作',
|
|
2105
|
-
'2. 如果是补充信息或前置依赖:把新信息纳入考虑,判断是否需要调整已做的工作,然后继续',
|
|
2106
|
-
'3. 如果是改变方向或取消:评估已完成的部分,说明你对变更的理解,按新方向推进)',
|
|
2107
|
-
].join('\n'),
|
|
2108
|
-
timestamp: Date.now(),
|
|
2109
|
-
id: crypto.randomUUID(),
|
|
2110
|
-
});
|
|
2111
|
-
}
|
|
2112
|
-
|
|
2113
|
-
/** 把一条 tool_call_delta 合并到累积数组上 */
|
|
2114
|
-
function mergeToolCallDelta(
|
|
2115
|
-
acc: ToolCall[],
|
|
2116
|
-
ev: {
|
|
2117
|
-
index: number;
|
|
2118
|
-
id?: string;
|
|
2119
|
-
name?: string;
|
|
2120
|
-
argumentsDelta?: string;
|
|
2121
|
-
},
|
|
2122
|
-
): void {
|
|
2123
|
-
let slot = acc[ev.index];
|
|
2124
|
-
if (!slot) {
|
|
2125
|
-
slot = {
|
|
2126
|
-
id: '',
|
|
2127
|
-
type: 'function',
|
|
2128
|
-
function: { name: '', arguments: '' },
|
|
2129
|
-
};
|
|
2130
|
-
acc[ev.index] = slot;
|
|
2131
|
-
}
|
|
2132
|
-
if (ev.id) slot.id = ev.id;
|
|
2133
|
-
if (ev.name) slot.function.name += ev.name;
|
|
2134
|
-
if (ev.argumentsDelta) slot.function.arguments += ev.argumentsDelta;
|
|
2135
|
-
}
|
|
2136
|
-
|
|
2137
|
-
/** 给 UI 展示用的参数预览(只取前 60 字符) */
|
|
2138
|
-
function previewArgs(rawJson: string): string {
|
|
2139
|
-
const oneLine = rawJson.replace(/\s+/g, ' ').trim();
|
|
2140
|
-
if (oneLine.length <= 60) return oneLine;
|
|
2141
|
-
return oneLine.slice(0, 60) + '...';
|
|
2142
|
-
}
|
|
2143
|
-
|
|
2144
|
-
/**
|
|
2145
|
-
* 把工具名 + raw arguments JSON 转换成人话描述。
|
|
2146
|
-
* UI 优先用这个显示在 spinner 行(如 "Reading src/foo.ts")。
|
|
2147
|
-
* 已知工具走 switch;未知工具或 JSON parse 失败 fallback 到 `${toolName}(...)`。
|
|
2148
|
-
*/
|
|
2149
|
-
function friendlyToolDescription(toolName: string, rawArgsJson: string, fileLabel?: (path: string) => string): string {
|
|
2150
|
-
let args: Record<string, unknown> = {};
|
|
2151
|
-
try {
|
|
2152
|
-
args = JSON.parse(rawArgsJson) ?? {};
|
|
2153
|
-
} catch {
|
|
2154
|
-
return `${toolName}(...)`;
|
|
2155
|
-
}
|
|
2156
|
-
const truncate = (s: unknown, n: number): string => {
|
|
2157
|
-
if (typeof s !== 'string') return '';
|
|
2158
|
-
return s.length > n ? s.slice(0, n) + '…' : s;
|
|
2159
|
-
};
|
|
2160
|
-
const file = (value: unknown): string => {
|
|
2161
|
-
if (typeof value !== 'string') return '';
|
|
2162
|
-
if (fileLabel) return fileLabel(value);
|
|
2163
|
-
return value.length > 64 ? `…${value.slice(-63)}` : value;
|
|
2164
|
-
};
|
|
2165
|
-
switch (toolName) {
|
|
2166
|
-
case 'Skill': return `技能 · ${truncate(args.name, 60)}`;
|
|
2167
|
-
case 'SearchSkills': return `查找技能 · ${truncate(args.query, 60)}`;
|
|
2168
|
-
case 'Advisor': {
|
|
2169
|
-
const reasons: Record<string, string> = { plan_review: '执行前评审', delivery_review: '交付前核验', stalled: '分析重复失败', uncertainty: '核查不确定问题', user_dissatisfaction: '复核用户反馈', evidence_conflict: '核查证据冲突' };
|
|
2170
|
-
return `咨询顾问 · ${reasons[String(args.reason)] ?? '独立第二意见'}`;
|
|
2171
|
-
}
|
|
2172
|
-
case 'AdvisorDecision': return `顾问建议 · ${truncate(args.decision ?? args.action, 40) || '记录核验与采纳结论'}`;
|
|
2173
|
-
case 'AskUserQuestion': return '询问关键决策';
|
|
2174
|
-
case 'PresentResult': return '提交成果与验证结果';
|
|
2175
|
-
case 'PowerShell': return `运行 PowerShell · ${truncate(args.description ?? args.command, 60)}`;
|
|
2176
|
-
case 'OptimizeConversation': return '优化对话';
|
|
2177
|
-
case 'Read': return `Reading ${file(args.file_path)}`;
|
|
2178
|
-
case 'Edit': return `Editing ${file(args.file_path)}`;
|
|
2179
|
-
case 'MultiEdit': {
|
|
2180
|
-
const n = Array.isArray(args.edits) ? args.edits.length : 0;
|
|
2181
|
-
return `Editing ${file(args.file_path)}${n ? ` (${n} 处)` : ''}`;
|
|
2182
|
-
}
|
|
2183
|
-
case 'Write': return `Writing ${file(args.file_path)}`;
|
|
2184
|
-
case 'Trash': return `Moving to Trash ${file(args.file_path ?? args.path)}`;
|
|
2185
|
-
case 'ApplyPatch': {
|
|
2186
|
-
const patch = typeof args.patch === 'string' ? args.patch : typeof args.input === 'string' ? args.input : '';
|
|
2187
|
-
const files = [...patch.matchAll(/^\*\*\* (Add|Update|Delete) File: (.+)$/gm)].map(m => `${file(m[2])}(${({ Add: '新增', Update: '修改', Delete: '删除' } as Record<string, string>)[m[1]]})`);
|
|
2188
|
-
if (!files.length) files.push(...[...patch.matchAll(/^\+\+\+ [ab]\/([^\n]+)$/gm)].map(m => file(m[1])));
|
|
2189
|
-
return files.length > 0 ? `Editing ${files.join(', ')}` : 'Editing patch';
|
|
2190
|
-
}
|
|
2191
|
-
case 'NotebookEdit': return `Editing ${file(args.notebook_path ?? args.file_path)}`;
|
|
2192
|
-
case 'Bash': return typeof args.description === 'string' && args.description.trim()
|
|
2193
|
-
? `运行 ${truncate(args.description, 70)}` : `Running \`${truncate(args.command, 40)}\``;
|
|
2194
|
-
case 'Grep': {
|
|
2195
|
-
const p = truncate(args.pattern, 30);
|
|
2196
|
-
const g = typeof args.glob === 'string' && args.glob.length > 0 ? ` in ${truncate(args.glob, 25)}` : '';
|
|
2197
|
-
return `Searching \`${p}\`${g}`;
|
|
2198
|
-
}
|
|
2199
|
-
case 'Glob': return `Globbing \`${truncate(args.pattern, 50)}\``;
|
|
2200
|
-
case 'WebBrowser': {
|
|
2201
|
-
const actions: Record<string, string> = { start: '启动', navigate: '打开页面', screenshot: '截图', snapshot: '读取页面', click: '点击', fill: '填写', evaluate: '执行页面脚本', close: '关闭', stop: '停止', console: '查看控制台', network: '查看网络请求' };
|
|
2202
|
-
let target = '';
|
|
2203
|
-
try { target = new URL(String(args.url)).host; } catch { /* no URL for local actions */ }
|
|
2204
|
-
return `浏览器 · ${actions[String(args.action)] ?? (truncate(args.action, 32) || '操作页面')}${target ? ` · ${target}` : ''}`;
|
|
2205
|
-
}
|
|
2206
|
-
case 'WebFetch': {
|
|
2207
|
-
try {
|
|
2208
|
-
return `Fetching ${new URL(String(args.url)).host}`;
|
|
2209
|
-
} catch {
|
|
2210
|
-
return `Fetching ...`;
|
|
2211
|
-
}
|
|
2212
|
-
}
|
|
2213
|
-
case 'BrowserBatch': return `浏览器 · ${truncate(args.action, 30) || '批量操作'}(${Array.isArray(args.steps) ? args.steps.length : 0} 步)`;
|
|
2214
|
-
case 'BrowserCDP': return `浏览器调试 · ${truncate(String(args.method ?? args.operation ?? ''), 45)}`;
|
|
2215
|
-
case 'WebSearch': return `Searching \`${truncate(args.query, 50)}\``;
|
|
2216
|
-
case 'Agent': {
|
|
2217
|
-
const t = typeof args.agent_type === 'string' ? args.agent_type : 'explore';
|
|
2218
|
-
return `子任务[${t}] ${truncate(args.description, 50)}`;
|
|
2219
|
-
}
|
|
2220
|
-
default: return `${toolName} · ${truncate(args.description ?? args.action ?? args.operation, 60) || '执行工具操作'}`;
|
|
2221
|
-
}
|
|
2222
|
-
}
|
|
2223
|
-
|
|
2224
|
-
// 导出供测试用(friendlyToolDescription 是 module-private 纯函数)
|
|
2225
|
-
export { friendlyToolDescription };
|