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
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { dirname, resolve } from 'node:path';
|
|
2
|
+
import { countTextTokens } from '../context/tokenCounter.js';
|
|
3
|
+
import { loadProjectPromptInstructions } from '../prompts/projectInstructions.js';
|
|
4
|
+
import { acquireSubagentLease, normalizeWriteScopes } from '../tools/agent/coordinator.js';
|
|
5
|
+
import { isReadOnlyShellCommand } from '../tools/bash/semantics.js';
|
|
6
|
+
import { capabilityForToolName } from '../tools/capabilities.js';
|
|
7
|
+
import { getToolGateDetails } from '../tools/index.js';
|
|
8
|
+
import { resolveToolPath } from '../tools/shared/fileUtils.js';
|
|
9
|
+
import { advisorSemanticsFor } from './toolSemantics.js';
|
|
10
|
+
/** One batch's execution boundary. Scheduling and history writes belong to callers. */
|
|
11
|
+
export function createToolExecutor(input) {
|
|
12
|
+
const { options, runtimeContext, toolCallContext, currentEffectiveToolSet, advisorCoordinator, stallGuard, modelEnvironmentCwd, observedRuleScopes, pendingRuleScopes, currentUserMessageId, executeToolFn, } = input;
|
|
13
|
+
const { signal, sessionState } = options;
|
|
14
|
+
// Convert tool execution failures/aborts into results; host hook failures are
|
|
15
|
+
// not a successful tool outcome and may propagate to the batch boundary.
|
|
16
|
+
const failureReviews = [];
|
|
17
|
+
const runOneImpl = async (tc, reportProgress, advisorConsultation) => {
|
|
18
|
+
let started = false;
|
|
19
|
+
try {
|
|
20
|
+
const handoff = options.agentHandoff?.current();
|
|
21
|
+
if (handoff && handoff.kind !== 'help')
|
|
22
|
+
return {
|
|
23
|
+
tc,
|
|
24
|
+
result: {
|
|
25
|
+
ok: false,
|
|
26
|
+
disposition: 'deferred',
|
|
27
|
+
error: '子任务已经提交交接,本批后续调用未执行',
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
if (signal?.aborted || options.hasQueuedInput?.()) {
|
|
31
|
+
return {
|
|
32
|
+
tc,
|
|
33
|
+
result: { ok: false, disposition: 'deferred', error: 'aborted' },
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const advisorAvailable = currentEffectiveToolSet.names.has('Advisor');
|
|
37
|
+
if (!advisorAvailable && stallGuard.shouldReject(tc)) {
|
|
38
|
+
return {
|
|
39
|
+
tc,
|
|
40
|
+
result: {
|
|
41
|
+
ok: false,
|
|
42
|
+
disposition: 'deferred',
|
|
43
|
+
error: '检测到同一工具和参数已连续两次返回相同失败;第三次调用已在执行前拒绝,请改变输入或改用其他路径。',
|
|
44
|
+
recovery: 'change-input',
|
|
45
|
+
sensitivity: 'public',
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
// The model-visible catalog and the execution boundary are the same set.
|
|
50
|
+
// A provider may still emit a stale or fabricated tool call; return a
|
|
51
|
+
// recoverable result so the model can choose an available path.
|
|
52
|
+
if (!currentEffectiveToolSet.names.has(tc.function.name)) {
|
|
53
|
+
const boundary = options.toolBoundaryLabel ?? '当前宿主授权';
|
|
54
|
+
const visible = [...currentEffectiveToolSet.names];
|
|
55
|
+
const availableHint = visible.length
|
|
56
|
+
? `(可用:${visible.slice(0, 12).join('、')}${visible.length > 12 ? '…' : ''})`
|
|
57
|
+
: '(本轮没有可用工具)';
|
|
58
|
+
const deniedReason = tc.function.name === 'Agent'
|
|
59
|
+
? '当前上下文不允许调用 Agent;请直接完成子任务或改用已提供的工具'
|
|
60
|
+
: `工具 ${tc.function.name} 不在${boundary}的有效工具集中${availableHint},请改用已提供的工具继续任务`;
|
|
61
|
+
return {
|
|
62
|
+
tc,
|
|
63
|
+
result: {
|
|
64
|
+
ok: false,
|
|
65
|
+
error: deniedReason,
|
|
66
|
+
recovery: 'change-input',
|
|
67
|
+
sensitivity: 'public',
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const checkpoint = options.advisorCheckpoints === false
|
|
72
|
+
? { allow: true }
|
|
73
|
+
: advisorCoordinator.beforeTool(tc, {
|
|
74
|
+
...advisorSemanticsFor(tc, toolCallContext, currentEffectiveToolSet.tools.find((tool) => tool.name === tc.function.name), { treatVerificationAsReadOnly: true }),
|
|
75
|
+
retriesFailure: stallGuard.shouldReject(tc),
|
|
76
|
+
});
|
|
77
|
+
if (!checkpoint.allow) {
|
|
78
|
+
return { tc, result: checkpoint.deferred };
|
|
79
|
+
}
|
|
80
|
+
// 工具执行闸门:宿主注入后覆盖所有工具,包括只读工具。deny → 把 reason 当
|
|
81
|
+
// tool_result 回填,模型据此改道/如实报告,不抛异常不中断整轮。宿主异常一律 deny。
|
|
82
|
+
// Keep the host gate's side-effect classification identical to the
|
|
83
|
+
// Advisor classification. Shell tools are generic in their schema but
|
|
84
|
+
// individual commands can be deterministic read-only diagnostics.
|
|
85
|
+
let isReadOnly = currentEffectiveToolSet.tools.find((tool) => tool.name === tc.function.name)?.isReadOnly === true;
|
|
86
|
+
if (tc.function.name === 'Bash' || tc.function.name === 'PowerShell') {
|
|
87
|
+
try {
|
|
88
|
+
const input = JSON.parse(tc.function.arguments || '{}');
|
|
89
|
+
if (typeof input.command === 'string')
|
|
90
|
+
isReadOnly = isReadOnlyShellCommand(input.command);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
isReadOnly = false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const effectiveToolGate = runtimeContext.toolGate ?? options.toolGate;
|
|
97
|
+
if (effectiveToolGate) {
|
|
98
|
+
let decision;
|
|
99
|
+
try {
|
|
100
|
+
const gateDetails = getToolGateDetails(tc.function.name, tc.function.arguments, {
|
|
101
|
+
...toolCallContext,
|
|
102
|
+
cwd: sessionState?.activeWorktree?.path ?? toolCallContext.cwd,
|
|
103
|
+
toolCallId: tc.id,
|
|
104
|
+
reportProgress,
|
|
105
|
+
});
|
|
106
|
+
decision = await effectiveToolGate({
|
|
107
|
+
name: tc.function.name,
|
|
108
|
+
argsJson: tc.function.arguments,
|
|
109
|
+
isReadOnly,
|
|
110
|
+
toolCallId: tc.id,
|
|
111
|
+
...gateDetails,
|
|
112
|
+
}, signal);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
decision = {
|
|
116
|
+
allow: false,
|
|
117
|
+
reason: '工具执行审批失败(宿主异常)',
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
if (!decision.allow) {
|
|
121
|
+
return { tc, result: { ok: false, error: decision.reason } };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (signal?.aborted || options.hasQueuedInput?.()) {
|
|
125
|
+
return {
|
|
126
|
+
tc,
|
|
127
|
+
result: { ok: false, disposition: 'deferred', error: 'aborted' },
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
// 浅拷贝填入本次调用的 toolCallId:进度按调用实例路由(并行同名工具不串扰)。
|
|
131
|
+
// advisorCalls/agentCalls 是共享对象引用,计数仍全轮共享,不受拷贝影响。
|
|
132
|
+
const executionCwd = sessionState?.activeWorktree?.path ?? toolCallContext.cwd;
|
|
133
|
+
if (executionCwd !== modelEnvironmentCwd)
|
|
134
|
+
return {
|
|
135
|
+
tc,
|
|
136
|
+
result: {
|
|
137
|
+
ok: false,
|
|
138
|
+
disposition: 'deferred',
|
|
139
|
+
error: '执行环境已改变,本批调用依据旧目录生成,尚未执行。请等待刷新环境和项目规则后重新生成调用。',
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
// Load path-scoped rules before a direct file mutation. Returning a
|
|
143
|
+
// deferred result gives the model a boundary to reconsider its edit.
|
|
144
|
+
if (['Edit', 'MultiEdit', 'Write', 'Download', 'NotebookEdit', 'Trash'].includes(tc.function.name)) {
|
|
145
|
+
try {
|
|
146
|
+
const args = JSON.parse(tc.function.arguments);
|
|
147
|
+
const targets = tc.function.name === 'Trash' ? args.paths : [args.file_path ?? args.path ?? args.notebook_path];
|
|
148
|
+
for (const target of Array.isArray(targets) ? targets : []) {
|
|
149
|
+
if (typeof target !== 'string')
|
|
150
|
+
continue;
|
|
151
|
+
// Download/Trash use literal path.resolve; editing tools expand ~.
|
|
152
|
+
const resolved = ['Download', 'Trash'].includes(tc.function.name)
|
|
153
|
+
? {
|
|
154
|
+
ok: true,
|
|
155
|
+
resolvedPath: resolve(executionCwd ?? runtimeContext.cwd, target),
|
|
156
|
+
}
|
|
157
|
+
: resolveToolPath(target, executionCwd ?? runtimeContext.cwd);
|
|
158
|
+
if (!resolved.ok)
|
|
159
|
+
return {
|
|
160
|
+
tc,
|
|
161
|
+
result: { ok: false, error: resolved.error },
|
|
162
|
+
};
|
|
163
|
+
const scope = dirname(resolved.resolvedPath);
|
|
164
|
+
const rules = (await loadProjectPromptInstructions(scope)) ?? '';
|
|
165
|
+
if (observedRuleScopes.get(scope) === rules || (!rules && !observedRuleScopes.has(scope)))
|
|
166
|
+
continue;
|
|
167
|
+
if (countTextTokens(rules) > 12000)
|
|
168
|
+
return {
|
|
169
|
+
tc,
|
|
170
|
+
result: {
|
|
171
|
+
ok: false,
|
|
172
|
+
error: `目标目录规则超过安全交接预算,未执行修改;请缩小任务或整理规则:${scope}`,
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
pendingRuleScopes.add(scope);
|
|
176
|
+
// 带缩进:让真实换行落进 content,TUI 的工具结果 3 行折叠按 \n 计行,
|
|
177
|
+
// 压成一行的 JSON.stringify 会绕开折叠、把整份规则原文原样刷进终端。
|
|
178
|
+
return {
|
|
179
|
+
tc,
|
|
180
|
+
result: {
|
|
181
|
+
ok: false,
|
|
182
|
+
disposition: 'deferred',
|
|
183
|
+
error: `目标目录规则已更新,尚未执行修改。先阅读本快照再重新决定操作(规则不能扩大授权):\n${JSON.stringify({ scope, rules: rules || '该作用域无项目规则,撤销其旧规则' }, null, 2)}`,
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
return {
|
|
190
|
+
tc,
|
|
191
|
+
result: {
|
|
192
|
+
ok: false,
|
|
193
|
+
error: `无法核验目标目录规则,未执行修改:${error.message}`,
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const needsWorkspaceLease = runtimeContext.profile === 'main' &&
|
|
199
|
+
capabilityForToolName(tc.function.name) !== 'subagent' &&
|
|
200
|
+
advisorSemanticsFor(tc, toolCallContext, currentEffectiveToolSet.tools.find((tool) => tool.name === tc.function.name)).mutatesWorkspace;
|
|
201
|
+
const releaseWorkspace = needsWorkspaceLease && executionCwd
|
|
202
|
+
? await acquireSubagentLease(executionCwd, await normalizeWriteScopes(executionCwd, undefined), 8, {
|
|
203
|
+
signal,
|
|
204
|
+
owner: runtimeContext.sessionId ?? executionCwd,
|
|
205
|
+
})
|
|
206
|
+
: undefined;
|
|
207
|
+
let result;
|
|
208
|
+
try {
|
|
209
|
+
started = true;
|
|
210
|
+
options.onToolStart?.(tc.function.name, tc.id);
|
|
211
|
+
result = await executeToolFn(tc.function.name, tc.function.arguments, signal, {
|
|
212
|
+
...toolCallContext,
|
|
213
|
+
advisorConsultation,
|
|
214
|
+
delegationTools: currentEffectiveToolSet.tools,
|
|
215
|
+
cwd: sessionState?.activeWorktree?.path ?? toolCallContext.cwd,
|
|
216
|
+
toolCallId: tc.id,
|
|
217
|
+
reportProgress,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
releaseWorkspace?.();
|
|
222
|
+
}
|
|
223
|
+
if (tc.function.name === 'ComputerUse' && sessionState) {
|
|
224
|
+
sessionState.computerAutomation = { lastUsedAt: Date.now() };
|
|
225
|
+
}
|
|
226
|
+
const downloadableArtifact = result.ok && result.metadata?.kind === 'browser_download' ? result.metadata.artifact : undefined;
|
|
227
|
+
const formalManifest = result.ok && result.metadata?.kind === 'result_manifest' ? result.metadata.manifest : downloadableArtifact;
|
|
228
|
+
if (formalManifest &&
|
|
229
|
+
(tc.function.name === 'PresentResult' || downloadableArtifact) &&
|
|
230
|
+
options.commitFormalDelivery) {
|
|
231
|
+
try {
|
|
232
|
+
await options.commitFormalDelivery({
|
|
233
|
+
toolCallId: tc.id,
|
|
234
|
+
turnId: currentUserMessageId,
|
|
235
|
+
manifest: formalManifest,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
result = {
|
|
240
|
+
ok: false,
|
|
241
|
+
error: `正式交付未能可靠保存:${error.message}`,
|
|
242
|
+
recovery: 'retry',
|
|
243
|
+
sensitivity: 'local',
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (tc.function.name === 'Advisor' || tc.function.name === 'AdvisorDecision') {
|
|
248
|
+
// 串行批次内下一项可能就是 AdvisorDecision/受保护写入,必须立即推进协调器状态。
|
|
249
|
+
advisorCoordinator.observeToolResult(tc, result);
|
|
250
|
+
}
|
|
251
|
+
started = false;
|
|
252
|
+
options.onToolEnd?.(tc.id, false);
|
|
253
|
+
return { tc, result };
|
|
254
|
+
}
|
|
255
|
+
catch (e) {
|
|
256
|
+
const wasAborted = e.name === 'AbortError';
|
|
257
|
+
if (started)
|
|
258
|
+
options.onToolEnd?.(tc.id, wasAborted);
|
|
259
|
+
if (wasAborted) {
|
|
260
|
+
return { tc, result: { ok: false, error: 'aborted' } };
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
tc,
|
|
264
|
+
result: { ok: false, error: e.message },
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
const runOne = async (tc, reportProgress) => {
|
|
269
|
+
// Also settle denied/invalid/unavailable calls that never reached the
|
|
270
|
+
// provider adapter. This records a real attempt, not an ignored reminder.
|
|
271
|
+
let consultation;
|
|
272
|
+
if (tc.function.name === 'Advisor') {
|
|
273
|
+
let reason;
|
|
274
|
+
try {
|
|
275
|
+
reason = JSON.parse(tc.function.arguments).reason;
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
/* validation handles malformed input */
|
|
279
|
+
}
|
|
280
|
+
consultation = advisorCoordinator.beginConsultation(reason);
|
|
281
|
+
}
|
|
282
|
+
const outcome = await runOneImpl(tc, reportProgress, consultation);
|
|
283
|
+
if (consultation &&
|
|
284
|
+
outcome.result.metadata?.kind !== 'advisor_consultation' &&
|
|
285
|
+
!(!outcome.result.ok && outcome.result.error === 'aborted')) {
|
|
286
|
+
outcome.result = {
|
|
287
|
+
...outcome.result,
|
|
288
|
+
metadata: advisorCoordinator.finishConsultation(consultation, outcome.result.ok),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
const metadata = outcome.result.metadata;
|
|
292
|
+
if ((metadata?.kind === 'advisor_decision' && outcome.result.ok) || metadata?.kind === 'advisor_consultation') {
|
|
293
|
+
if (metadata.checkpoint === 'stalled')
|
|
294
|
+
stallGuard.completeReview(metadata.checkpointKey.split(':').at(-1));
|
|
295
|
+
}
|
|
296
|
+
const failure = stallGuard.observeAttempt(tc, outcome.result);
|
|
297
|
+
if (failure.stalled && failure.signature) {
|
|
298
|
+
failureReviews.push(failure);
|
|
299
|
+
advisorCoordinator.requireFailureReview(failure.signature, failure.attempts ?? []);
|
|
300
|
+
}
|
|
301
|
+
return outcome;
|
|
302
|
+
};
|
|
303
|
+
return { runOne, failureReviews };
|
|
304
|
+
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { dirname, resolve } from 'node:path';
|
|
2
|
+
import type { AdvisorCoordinator } from '../advisor/advisorCoordinator.js';
|
|
3
|
+
import { countTextTokens } from '../context/tokenCounter.js';
|
|
4
|
+
import { StallGuard, type StallObservation } from '../harness/stallGuard.js';
|
|
5
|
+
import { loadProjectPromptInstructions } from '../prompts/projectInstructions.js';
|
|
6
|
+
import type { AgentRuntimeContext } from '../runtime/agentRuntimeContext.js';
|
|
7
|
+
import { acquireSubagentLease, normalizeWriteScopes } from '../tools/agent/coordinator.js';
|
|
8
|
+
import { isReadOnlyShellCommand } from '../tools/bash/semantics.js';
|
|
9
|
+
import { capabilityForToolName } from '../tools/capabilities.js';
|
|
10
|
+
import type { EffectiveToolSet } from '../tools/effectiveToolSet.js';
|
|
11
|
+
import { getToolGateDetails } from '../tools/index.js';
|
|
12
|
+
import { resolveToolPath } from '../tools/shared/fileUtils.js';
|
|
13
|
+
import type { ToolCall, ToolCallContext, ToolResult } from '../types.js';
|
|
14
|
+
import type { RunQueryOptions } from './options.js';
|
|
15
|
+
import { advisorSemanticsFor } from './toolSemantics.js';
|
|
16
|
+
export type WorkerOutcome = {
|
|
17
|
+
tc: ToolCall;
|
|
18
|
+
result: ToolResult;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
interface ToolExecutorInput {
|
|
22
|
+
options: Pick<RunQueryOptions,
|
|
23
|
+
'signal' | 'sessionState' | 'agentHandoff' | 'hasQueuedInput' |
|
|
24
|
+
'advisorCheckpoints' | 'toolBoundaryLabel' | 'toolGate' |
|
|
25
|
+
'onToolStart' | 'onToolEnd' | 'commitFormalDelivery'>;
|
|
26
|
+
runtimeContext: AgentRuntimeContext;
|
|
27
|
+
toolCallContext: ToolCallContext;
|
|
28
|
+
currentEffectiveToolSet: EffectiveToolSet;
|
|
29
|
+
advisorCoordinator: AdvisorCoordinator;
|
|
30
|
+
stallGuard: StallGuard;
|
|
31
|
+
modelEnvironmentCwd: string;
|
|
32
|
+
observedRuleScopes: Map<string, string>;
|
|
33
|
+
pendingRuleScopes: Set<string>;
|
|
34
|
+
currentUserMessageId: string;
|
|
35
|
+
executeToolFn: NonNullable<RunQueryOptions['executeTool']>;
|
|
36
|
+
}
|
|
37
|
+
/** One batch's execution boundary. Scheduling and history writes belong to callers. */
|
|
38
|
+
export function createToolExecutor(input: ToolExecutorInput) {
|
|
39
|
+
const {
|
|
40
|
+
options,
|
|
41
|
+
runtimeContext,
|
|
42
|
+
toolCallContext,
|
|
43
|
+
currentEffectiveToolSet,
|
|
44
|
+
advisorCoordinator,
|
|
45
|
+
stallGuard,
|
|
46
|
+
modelEnvironmentCwd,
|
|
47
|
+
observedRuleScopes,
|
|
48
|
+
pendingRuleScopes,
|
|
49
|
+
currentUserMessageId,
|
|
50
|
+
executeToolFn,
|
|
51
|
+
} = input;
|
|
52
|
+
const { signal, sessionState } = options;
|
|
53
|
+
// Convert tool execution failures/aborts into results; host hook failures are
|
|
54
|
+
// not a successful tool outcome and may propagate to the batch boundary.
|
|
55
|
+
const failureReviews: StallObservation[] = [];
|
|
56
|
+
const runOneImpl = async (
|
|
57
|
+
tc: ToolCall,
|
|
58
|
+
reportProgress?: (message: string, detail?: import('../types.js').ToolProgressDetail) => void,
|
|
59
|
+
advisorConsultation?: import('../types.js').AdvisorConsultationMetadata,
|
|
60
|
+
): Promise<WorkerOutcome> => {
|
|
61
|
+
let started = false;
|
|
62
|
+
try {
|
|
63
|
+
const handoff = options.agentHandoff?.current();
|
|
64
|
+
if (handoff && handoff.kind !== 'help')
|
|
65
|
+
return {
|
|
66
|
+
tc,
|
|
67
|
+
result: {
|
|
68
|
+
ok: false,
|
|
69
|
+
disposition: 'deferred',
|
|
70
|
+
error: '子任务已经提交交接,本批后续调用未执行',
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
if (signal?.aborted || options.hasQueuedInput?.()) {
|
|
74
|
+
return {
|
|
75
|
+
tc,
|
|
76
|
+
result: { ok: false, disposition: 'deferred', error: 'aborted' },
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const advisorAvailable = currentEffectiveToolSet.names.has('Advisor');
|
|
80
|
+
if (!advisorAvailable && stallGuard.shouldReject(tc)) {
|
|
81
|
+
return {
|
|
82
|
+
tc,
|
|
83
|
+
result: {
|
|
84
|
+
ok: false as const,
|
|
85
|
+
disposition: 'deferred',
|
|
86
|
+
error: '检测到同一工具和参数已连续两次返回相同失败;第三次调用已在执行前拒绝,请改变输入或改用其他路径。',
|
|
87
|
+
recovery: 'change-input',
|
|
88
|
+
sensitivity: 'public',
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
// The model-visible catalog and the execution boundary are the same set.
|
|
93
|
+
// A provider may still emit a stale or fabricated tool call; return a
|
|
94
|
+
// recoverable result so the model can choose an available path.
|
|
95
|
+
if (!currentEffectiveToolSet.names.has(tc.function.name)) {
|
|
96
|
+
const boundary = options.toolBoundaryLabel ?? '当前宿主授权';
|
|
97
|
+
const visible = [...currentEffectiveToolSet.names];
|
|
98
|
+
const availableHint = visible.length
|
|
99
|
+
? `(可用:${visible.slice(0, 12).join('、')}${visible.length > 12 ? '…' : ''})`
|
|
100
|
+
: '(本轮没有可用工具)';
|
|
101
|
+
const deniedReason =
|
|
102
|
+
tc.function.name === 'Agent'
|
|
103
|
+
? '当前上下文不允许调用 Agent;请直接完成子任务或改用已提供的工具'
|
|
104
|
+
: `工具 ${tc.function.name} 不在${boundary}的有效工具集中${availableHint},请改用已提供的工具继续任务`;
|
|
105
|
+
return {
|
|
106
|
+
tc,
|
|
107
|
+
result: {
|
|
108
|
+
ok: false as const,
|
|
109
|
+
error: deniedReason,
|
|
110
|
+
recovery: 'change-input',
|
|
111
|
+
sensitivity: 'public',
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
const checkpoint =
|
|
116
|
+
options.advisorCheckpoints === false
|
|
117
|
+
? { allow: true as const }
|
|
118
|
+
: advisorCoordinator.beforeTool(tc, {
|
|
119
|
+
...advisorSemanticsFor(
|
|
120
|
+
tc,
|
|
121
|
+
toolCallContext,
|
|
122
|
+
currentEffectiveToolSet.tools.find((tool) => tool.name === tc.function.name),
|
|
123
|
+
{ treatVerificationAsReadOnly: true },
|
|
124
|
+
),
|
|
125
|
+
retriesFailure: stallGuard.shouldReject(tc),
|
|
126
|
+
});
|
|
127
|
+
if (!checkpoint.allow) {
|
|
128
|
+
return { tc, result: checkpoint.deferred! };
|
|
129
|
+
}
|
|
130
|
+
// 工具执行闸门:宿主注入后覆盖所有工具,包括只读工具。deny → 把 reason 当
|
|
131
|
+
// tool_result 回填,模型据此改道/如实报告,不抛异常不中断整轮。宿主异常一律 deny。
|
|
132
|
+
// Keep the host gate's side-effect classification identical to the
|
|
133
|
+
// Advisor classification. Shell tools are generic in their schema but
|
|
134
|
+
// individual commands can be deterministic read-only diagnostics.
|
|
135
|
+
let isReadOnly =
|
|
136
|
+
currentEffectiveToolSet.tools.find((tool) => tool.name === tc.function.name)?.isReadOnly === true;
|
|
137
|
+
if (tc.function.name === 'Bash' || tc.function.name === 'PowerShell') {
|
|
138
|
+
try {
|
|
139
|
+
const input = JSON.parse(tc.function.arguments || '{}') as {
|
|
140
|
+
command?: unknown;
|
|
141
|
+
};
|
|
142
|
+
if (typeof input.command === 'string') isReadOnly = isReadOnlyShellCommand(input.command);
|
|
143
|
+
} catch {
|
|
144
|
+
isReadOnly = false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const effectiveToolGate = runtimeContext.toolGate ?? options.toolGate;
|
|
148
|
+
if (effectiveToolGate) {
|
|
149
|
+
let decision;
|
|
150
|
+
try {
|
|
151
|
+
const gateDetails = getToolGateDetails(tc.function.name, tc.function.arguments, {
|
|
152
|
+
...toolCallContext,
|
|
153
|
+
cwd: sessionState?.activeWorktree?.path ?? toolCallContext.cwd,
|
|
154
|
+
toolCallId: tc.id,
|
|
155
|
+
reportProgress,
|
|
156
|
+
});
|
|
157
|
+
decision = await effectiveToolGate(
|
|
158
|
+
{
|
|
159
|
+
name: tc.function.name,
|
|
160
|
+
argsJson: tc.function.arguments,
|
|
161
|
+
isReadOnly,
|
|
162
|
+
toolCallId: tc.id,
|
|
163
|
+
...gateDetails,
|
|
164
|
+
},
|
|
165
|
+
signal,
|
|
166
|
+
);
|
|
167
|
+
} catch {
|
|
168
|
+
decision = {
|
|
169
|
+
allow: false as const,
|
|
170
|
+
reason: '工具执行审批失败(宿主异常)',
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
if (!decision.allow) {
|
|
174
|
+
return { tc, result: { ok: false as const, error: decision.reason } };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (signal?.aborted || options.hasQueuedInput?.()) {
|
|
178
|
+
return {
|
|
179
|
+
tc,
|
|
180
|
+
result: { ok: false, disposition: 'deferred', error: 'aborted' },
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
// 浅拷贝填入本次调用的 toolCallId:进度按调用实例路由(并行同名工具不串扰)。
|
|
184
|
+
// advisorCalls/agentCalls 是共享对象引用,计数仍全轮共享,不受拷贝影响。
|
|
185
|
+
const executionCwd = sessionState?.activeWorktree?.path ?? toolCallContext.cwd;
|
|
186
|
+
if (executionCwd !== modelEnvironmentCwd)
|
|
187
|
+
return {
|
|
188
|
+
tc,
|
|
189
|
+
result: {
|
|
190
|
+
ok: false as const,
|
|
191
|
+
disposition: 'deferred' as const,
|
|
192
|
+
error: '执行环境已改变,本批调用依据旧目录生成,尚未执行。请等待刷新环境和项目规则后重新生成调用。',
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
// Load path-scoped rules before a direct file mutation. Returning a
|
|
196
|
+
// deferred result gives the model a boundary to reconsider its edit.
|
|
197
|
+
if (['Edit', 'MultiEdit', 'Write', 'Download', 'NotebookEdit', 'Trash'].includes(tc.function.name)) {
|
|
198
|
+
try {
|
|
199
|
+
const args = JSON.parse(tc.function.arguments);
|
|
200
|
+
const targets =
|
|
201
|
+
tc.function.name === 'Trash' ? args.paths : [args.file_path ?? args.path ?? args.notebook_path];
|
|
202
|
+
for (const target of Array.isArray(targets) ? targets : []) {
|
|
203
|
+
if (typeof target !== 'string') continue;
|
|
204
|
+
// Download/Trash use literal path.resolve; editing tools expand ~.
|
|
205
|
+
const resolved = ['Download', 'Trash'].includes(tc.function.name)
|
|
206
|
+
? {
|
|
207
|
+
ok: true as const,
|
|
208
|
+
resolvedPath: resolve(executionCwd ?? runtimeContext.cwd, target),
|
|
209
|
+
}
|
|
210
|
+
: resolveToolPath(target, executionCwd ?? runtimeContext.cwd);
|
|
211
|
+
if (!resolved.ok)
|
|
212
|
+
return {
|
|
213
|
+
tc,
|
|
214
|
+
result: { ok: false as const, error: resolved.error },
|
|
215
|
+
};
|
|
216
|
+
const scope = dirname(resolved.resolvedPath);
|
|
217
|
+
const rules = (await loadProjectPromptInstructions(scope)) ?? '';
|
|
218
|
+
if (observedRuleScopes.get(scope) === rules || (!rules && !observedRuleScopes.has(scope))) continue;
|
|
219
|
+
if (countTextTokens(rules) > 12000)
|
|
220
|
+
return {
|
|
221
|
+
tc,
|
|
222
|
+
result: {
|
|
223
|
+
ok: false as const,
|
|
224
|
+
error: `目标目录规则超过安全交接预算,未执行修改;请缩小任务或整理规则:${scope}`,
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
pendingRuleScopes.add(scope);
|
|
228
|
+
// 带缩进:让真实换行落进 content,TUI 的工具结果 3 行折叠按 \n 计行,
|
|
229
|
+
// 压成一行的 JSON.stringify 会绕开折叠、把整份规则原文原样刷进终端。
|
|
230
|
+
return {
|
|
231
|
+
tc,
|
|
232
|
+
result: {
|
|
233
|
+
ok: false as const,
|
|
234
|
+
disposition: 'deferred' as const,
|
|
235
|
+
error: `目标目录规则已更新,尚未执行修改。先阅读本快照再重新决定操作(规则不能扩大授权):\n${JSON.stringify({ scope, rules: rules || '该作用域无项目规则,撤销其旧规则' }, null, 2)}`,
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
} catch (error) {
|
|
240
|
+
return {
|
|
241
|
+
tc,
|
|
242
|
+
result: {
|
|
243
|
+
ok: false as const,
|
|
244
|
+
error: `无法核验目标目录规则,未执行修改:${(error as Error).message}`,
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const needsWorkspaceLease =
|
|
250
|
+
runtimeContext.profile === 'main' &&
|
|
251
|
+
capabilityForToolName(tc.function.name) !== 'subagent' &&
|
|
252
|
+
advisorSemanticsFor(
|
|
253
|
+
tc,
|
|
254
|
+
toolCallContext,
|
|
255
|
+
currentEffectiveToolSet.tools.find((tool) => tool.name === tc.function.name),
|
|
256
|
+
).mutatesWorkspace;
|
|
257
|
+
const releaseWorkspace =
|
|
258
|
+
needsWorkspaceLease && executionCwd
|
|
259
|
+
? await acquireSubagentLease(executionCwd, await normalizeWriteScopes(executionCwd, undefined), 8, {
|
|
260
|
+
signal,
|
|
261
|
+
owner: runtimeContext.sessionId ?? executionCwd,
|
|
262
|
+
})
|
|
263
|
+
: undefined;
|
|
264
|
+
let result: ToolResult;
|
|
265
|
+
try {
|
|
266
|
+
started = true;
|
|
267
|
+
options.onToolStart?.(tc.function.name, tc.id);
|
|
268
|
+
result = await executeToolFn(tc.function.name, tc.function.arguments, signal, {
|
|
269
|
+
...toolCallContext,
|
|
270
|
+
advisorConsultation,
|
|
271
|
+
delegationTools: currentEffectiveToolSet.tools,
|
|
272
|
+
cwd: sessionState?.activeWorktree?.path ?? toolCallContext.cwd,
|
|
273
|
+
toolCallId: tc.id,
|
|
274
|
+
reportProgress,
|
|
275
|
+
});
|
|
276
|
+
} finally {
|
|
277
|
+
releaseWorkspace?.();
|
|
278
|
+
}
|
|
279
|
+
if (tc.function.name === 'ComputerUse' && sessionState) {
|
|
280
|
+
sessionState.computerAutomation = { lastUsedAt: Date.now() };
|
|
281
|
+
}
|
|
282
|
+
const downloadableArtifact =
|
|
283
|
+
result.ok && result.metadata?.kind === 'browser_download' ? result.metadata.artifact : undefined;
|
|
284
|
+
const formalManifest =
|
|
285
|
+
result.ok && result.metadata?.kind === 'result_manifest' ? result.metadata.manifest : downloadableArtifact;
|
|
286
|
+
if (
|
|
287
|
+
formalManifest &&
|
|
288
|
+
(tc.function.name === 'PresentResult' || downloadableArtifact) &&
|
|
289
|
+
options.commitFormalDelivery
|
|
290
|
+
) {
|
|
291
|
+
try {
|
|
292
|
+
await options.commitFormalDelivery({
|
|
293
|
+
toolCallId: tc.id,
|
|
294
|
+
turnId: currentUserMessageId,
|
|
295
|
+
manifest: formalManifest,
|
|
296
|
+
});
|
|
297
|
+
} catch (error) {
|
|
298
|
+
result = {
|
|
299
|
+
ok: false,
|
|
300
|
+
error: `正式交付未能可靠保存:${(error as Error).message}`,
|
|
301
|
+
recovery: 'retry',
|
|
302
|
+
sensitivity: 'local',
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (tc.function.name === 'Advisor' || tc.function.name === 'AdvisorDecision') {
|
|
307
|
+
// 串行批次内下一项可能就是 AdvisorDecision/受保护写入,必须立即推进协调器状态。
|
|
308
|
+
advisorCoordinator.observeToolResult(tc, result);
|
|
309
|
+
}
|
|
310
|
+
started = false;
|
|
311
|
+
options.onToolEnd?.(tc.id, false);
|
|
312
|
+
return { tc, result };
|
|
313
|
+
} catch (e) {
|
|
314
|
+
const wasAborted = (e as Error).name === 'AbortError';
|
|
315
|
+
if (started) options.onToolEnd?.(tc.id, wasAborted);
|
|
316
|
+
if (wasAborted) {
|
|
317
|
+
return { tc, result: { ok: false as const, error: 'aborted' } };
|
|
318
|
+
}
|
|
319
|
+
return {
|
|
320
|
+
tc,
|
|
321
|
+
result: { ok: false as const, error: (e as Error).message },
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const runOne = async (tc: ToolCall, reportProgress?: Parameters<typeof runOneImpl>[1]): Promise<WorkerOutcome> => {
|
|
327
|
+
// Also settle denied/invalid/unavailable calls that never reached the
|
|
328
|
+
// provider adapter. This records a real attempt, not an ignored reminder.
|
|
329
|
+
let consultation;
|
|
330
|
+
if (tc.function.name === 'Advisor') {
|
|
331
|
+
let reason: import('../types.js').AdvisorReason | undefined;
|
|
332
|
+
try {
|
|
333
|
+
reason = JSON.parse(tc.function.arguments).reason;
|
|
334
|
+
} catch {
|
|
335
|
+
/* validation handles malformed input */
|
|
336
|
+
}
|
|
337
|
+
consultation = advisorCoordinator.beginConsultation(reason);
|
|
338
|
+
}
|
|
339
|
+
const outcome = await runOneImpl(tc, reportProgress, consultation);
|
|
340
|
+
if (
|
|
341
|
+
consultation &&
|
|
342
|
+
outcome.result.metadata?.kind !== 'advisor_consultation' &&
|
|
343
|
+
!(!outcome.result.ok && outcome.result.error === 'aborted')
|
|
344
|
+
) {
|
|
345
|
+
outcome.result = {
|
|
346
|
+
...outcome.result,
|
|
347
|
+
metadata: advisorCoordinator.finishConsultation(consultation, outcome.result.ok),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
const metadata = outcome.result.metadata;
|
|
351
|
+
if ((metadata?.kind === 'advisor_decision' && outcome.result.ok) || metadata?.kind === 'advisor_consultation') {
|
|
352
|
+
if (metadata.checkpoint === 'stalled') stallGuard.completeReview(metadata.checkpointKey.split(':').at(-1)!);
|
|
353
|
+
}
|
|
354
|
+
const failure = stallGuard.observeAttempt(tc, outcome.result);
|
|
355
|
+
if (failure.stalled && failure.signature) {
|
|
356
|
+
failureReviews.push(failure);
|
|
357
|
+
advisorCoordinator.requireFailureReview(failure.signature, failure.attempts ?? []);
|
|
358
|
+
}
|
|
359
|
+
return outcome;
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
return { runOne, failureReviews };
|
|
363
|
+
}
|