mocode-ai 1.4.2 → 1.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +13 -1
  2. package/dist/agent/core.js +14 -936
  3. package/dist/agent/index.js +37 -13
  4. package/dist/agent/model-turn.js +218 -0
  5. package/dist/agent/pipeline.js +18 -0
  6. package/dist/agent/run-contracts.js +1 -0
  7. package/dist/agent/run-coordinator.js +758 -0
  8. package/dist/agent/runtime-context.js +118 -24
  9. package/dist/agent/spawn.js +11 -7
  10. package/dist/agent/stages/context-trimmer.js +63 -0
  11. package/dist/agent/stages/contracts.js +12 -0
  12. package/dist/agent/stages/history-manager.js +178 -0
  13. package/dist/agent/stages/legacy-adapters.js +19 -0
  14. package/dist/agent/stages/model-runner.js +29 -0
  15. package/dist/agent/stages/run-policy.js +73 -0
  16. package/dist/agent/stages/tool-dispatcher.js +341 -0
  17. package/dist/agent/tool-helpers.js +12 -12
  18. package/dist/agent/tool-turn.js +87 -0
  19. package/dist/agent/trace-state.js +97 -101
  20. package/dist/agent/turn-lifecycle.js +110 -0
  21. package/dist/config/index.js +14 -0
  22. package/dist/host/stdio.js +101 -40
  23. package/dist/llm/index.js +51 -35
  24. package/dist/llm/providers/anthropic.js +16 -10
  25. package/dist/llm/runtime.js +1 -0
  26. package/dist/permissions/index.js +21 -5
  27. package/dist/repl/commands/compact.js +2 -2
  28. package/dist/repl/commands/session.js +3 -12
  29. package/dist/repl/message-format.js +5 -0
  30. package/dist/repl/runtime.js +95 -55
  31. package/dist/rollback/index.js +29 -624
  32. package/dist/rollback/store.js +593 -0
  33. package/dist/runtime/index.js +1 -0
  34. package/dist/runtime/runtime.js +307 -0
  35. package/dist/session/compact.js +22 -14
  36. package/dist/session/index.js +1 -0
  37. package/dist/session/persist.js +10 -146
  38. package/dist/session/scheduler.js +28 -16
  39. package/dist/session/state.js +16 -12
  40. package/dist/session/store.js +218 -0
  41. package/dist/session/trace.js +5 -15
  42. package/dist/tools/policy.js +19 -15
  43. package/dist/tools/registry.js +21 -229
  44. package/dist/tools/router.js +5 -3
  45. package/dist/tools/tool-runtime.js +267 -0
  46. package/dist/ui/layout-internal/content-write.js +4 -0
  47. package/package.json +7 -3
@@ -0,0 +1,341 @@
1
+ import { ADD_TOOL_GROUPS_TOOL_NAME } from '../../config/profiles.js';
2
+ import { t } from '../../i18n/index.js';
3
+ import { checkPermission as defaultCheckPermission } from '../../permissions/index.js';
4
+ import { jailResolve as defaultJailResolve } from '../../sandbox/index.js';
5
+ import { summarizeToolArguments } from '../../session/index.js';
6
+ import { getPlanDisabledTools } from '../../tools/constants.js';
7
+ import { defaultToolRuntime } from '../../tools/registry.js';
8
+ import { validateToolArguments } from '../../tools/validation.js';
9
+ import { deniedOutcome, isParallelTool, isResourceLockedCall, parseArgs, readDiffContext } from '../tool-helpers.js';
10
+ const DEFAULT_DEPENDENCIES = {
11
+ toolRuntime: defaultToolRuntime,
12
+ checkPermission: defaultCheckPermission,
13
+ jailResolve: defaultJailResolve,
14
+ };
15
+ class LegacyCompatibleToolDispatcher {
16
+ implementation;
17
+ dependencies;
18
+ constructor(implementation, dependencies = {}) {
19
+ this.implementation = implementation;
20
+ this.dependencies = { ...DEFAULT_DEPENDENCIES, ...dependencies };
21
+ }
22
+ async dispatch(request) {
23
+ const { toolRuntime, checkPermission, jailResolve } = this.dependencies;
24
+ const calls = request.calls;
25
+ const argumentSummaries = calls.map((call) => summarizeToolArguments(call.arguments));
26
+ const orderedResults = new Array(calls.length);
27
+ const modelAttachments = [];
28
+ const changedFiles = new Set();
29
+ const record = (index, outcome) => {
30
+ orderedResults[index] = { call: calls[index], outcome };
31
+ if (outcome.status === 'success' && outcome.modelAttachments?.length) {
32
+ modelAttachments.push(...outcome.modelAttachments);
33
+ }
34
+ for (const file of outcome.changedFiles ?? [])
35
+ changedFiles.add(file);
36
+ };
37
+ const traceEnd = (index, outcome) => request.onEvent({
38
+ type: 'trace_end',
39
+ call: calls[index],
40
+ callIndex: index,
41
+ argumentSummary: argumentSummaries[index],
42
+ outcome,
43
+ });
44
+ const execute = (call, hint, onLockAcquired) => toolRuntime.executeToolOutcome(call.name, call.arguments, request.signal, {
45
+ callId: call.id,
46
+ allowedToolNames: request.currentAllowedToolNames(),
47
+ delegation: request.delegation(),
48
+ ...(hint ? { argumentErrorHint: hint } : {}),
49
+ ...(onLockAcquired ? { onLockAcquired } : {}),
50
+ });
51
+ const executionEvents = (index, parsed, outcome) => {
52
+ request.onEvent({ type: 'usage', usage: outcome.usage });
53
+ request.onEvent({ type: 'host_outcome', call: calls[index], parsed: parsed ?? {}, outcome });
54
+ traceEnd(index, outcome);
55
+ };
56
+ const resultEvent = (index, outcome, parsed, diff = { preWriteOld: null, editStartLine: 1 }, includeContextState = true) => request.onEvent({
57
+ type: 'result',
58
+ call: calls[index],
59
+ outcome,
60
+ parsed,
61
+ diff,
62
+ succeeded: outcome.status === 'success',
63
+ includeContextState,
64
+ });
65
+ const invalidate = (outcome) => {
66
+ const files = [...new Set([...(outcome.changedFiles ?? []), ...(outcome.staleFiles ?? [])])];
67
+ if (files.length > 0)
68
+ request.onEvent({ type: 'invalidate', files });
69
+ };
70
+ for (let index = 0; index < calls.length; index++) {
71
+ request.onEvent({
72
+ type: 'call_start',
73
+ call: calls[index],
74
+ callIndex: index,
75
+ argumentSummary: argumentSummaries[index],
76
+ });
77
+ }
78
+ const hasRouteBarrier = calls.some((call) => call.name === ADD_TOOL_GROUPS_TOOL_NAME);
79
+ if (hasRouteBarrier) {
80
+ const mixedCall = calls.length !== 1;
81
+ for (let index = 0; index < calls.length; index++) {
82
+ const call = calls[index];
83
+ request.onEvent({ type: 'header', call });
84
+ const parsed = parseArgs(call.arguments);
85
+ let outcome;
86
+ if (mixedCall) {
87
+ const isControl = call.name === ADD_TOOL_GROUPS_TOOL_NAME;
88
+ outcome = {
89
+ status: 'denied',
90
+ code: isControl ? 'INVALID_ARGUMENTS' : 'TOOL_DISABLED',
91
+ retryable: false,
92
+ output: isControl
93
+ ? '错误:add_tool_groups 必须在一个独立的 model step 中单独调用;本次没有扩容。'
94
+ : `错误:同一响应包含 add_tool_groups,工具 ${call.name} 未执行。请等待扩容结果后在下一 step 重试。`,
95
+ changedFiles: [],
96
+ durationMs: 0,
97
+ };
98
+ }
99
+ else if (request.isDenied(call.name)) {
100
+ outcome = {
101
+ status: 'denied',
102
+ code: 'TOOL_DISABLED',
103
+ retryable: false,
104
+ output: `错误:当前 tool policy snapshot 不允许调用 ${call.name}。`,
105
+ changedFiles: [],
106
+ durationMs: 0,
107
+ };
108
+ }
109
+ else if (!request.expandToolGroups) {
110
+ outcome = {
111
+ status: 'denied',
112
+ code: 'TOOL_DISABLED',
113
+ retryable: false,
114
+ output: '错误:当前 Agent 未启用动态工具策略,无法调用 add_tool_groups。',
115
+ changedFiles: [],
116
+ durationMs: 0,
117
+ };
118
+ }
119
+ else if (!parsed ||
120
+ !Array.isArray(parsed.groups) ||
121
+ parsed.groups.length === 0 ||
122
+ typeof parsed.reason !== 'string' ||
123
+ !parsed.reason.trim()) {
124
+ outcome = {
125
+ status: 'error',
126
+ code: 'INVALID_ARGUMENTS',
127
+ retryable: false,
128
+ output: '错误:add_tool_groups 需要非空 groups 数组和非空 reason。',
129
+ changedFiles: [],
130
+ durationMs: 0,
131
+ };
132
+ }
133
+ else {
134
+ const expansion = request.expandToolGroups(parsed.groups, parsed.reason);
135
+ const succeeded = expansion.added.length > 0;
136
+ const details = [
137
+ succeeded
138
+ ? `Tool policy expanded to v${expansion.snapshot.version}; added groups: ${expansion.added.join(', ')}.`
139
+ : `Tool policy was not expanded (still v${expansion.snapshot.version}).`,
140
+ expansion.rejected.length > 0 ? `Rejected: ${expansion.rejected.join('; ')}.` : '',
141
+ succeeded ? 'The added tool schemas become available on the next model step.' : '',
142
+ ]
143
+ .filter(Boolean)
144
+ .join('\n');
145
+ outcome = {
146
+ status: succeeded ? 'success' : 'error',
147
+ code: succeeded ? 'OK' : 'INVALID_ARGUMENTS',
148
+ retryable: false,
149
+ output: details,
150
+ changedFiles: [],
151
+ durationMs: 0,
152
+ };
153
+ request.onEvent({
154
+ type: 'route_expand',
155
+ fromVersion: request.policy.toolPolicy?.version,
156
+ expansion,
157
+ requestedGroups: parsed.groups,
158
+ reason: parsed.reason,
159
+ status: outcome.status,
160
+ });
161
+ }
162
+ record(index, outcome);
163
+ request.onEvent({ type: 'host_outcome', call, parsed: parsed ?? {}, outcome });
164
+ resultEvent(index, outcome, null);
165
+ traceEnd(index, outcome);
166
+ }
167
+ }
168
+ let index = hasRouteBarrier ? calls.length : 0;
169
+ while (index < calls.length) {
170
+ const current = calls[index];
171
+ if (request.isDenied(current.name)) {
172
+ request.onEvent({ type: 'header', call: current });
173
+ const output = t('task.disabled');
174
+ const outcome = {
175
+ status: 'denied',
176
+ code: 'TOOL_DISABLED',
177
+ retryable: false,
178
+ output,
179
+ changedFiles: [],
180
+ durationMs: 0,
181
+ };
182
+ record(index, outcome);
183
+ resultEvent(index, outcome, null);
184
+ traceEnd(index, outcome);
185
+ index++;
186
+ continue;
187
+ }
188
+ if (isParallelTool(current.name, toolRuntime)) {
189
+ let end = index;
190
+ while (end < calls.length && isParallelTool(calls[end].name, toolRuntime) && !request.isDenied(calls[end].name))
191
+ end++;
192
+ const batch = calls.slice(index, end);
193
+ for (const call of batch)
194
+ request.onEvent({ type: 'header', call });
195
+ request.onEvent({ type: 'start', tool: batch[0].name });
196
+ const started = batch.map((call) => execute(call));
197
+ for (let offset = 0; offset < batch.length; offset++) {
198
+ const callIndex = index + offset;
199
+ const outcome = await started[offset];
200
+ record(callIndex, outcome);
201
+ executionEvents(callIndex, parseArgs(batch[offset].arguments), outcome);
202
+ resultEvent(callIndex, outcome, null);
203
+ }
204
+ request.onEvent({ type: 'done' });
205
+ index = end;
206
+ continue;
207
+ }
208
+ if (isResourceLockedCall(current, toolRuntime) &&
209
+ !(request.policy.mode === 'plan' && getPlanDisabledTools().has(current.name))) {
210
+ let end = index;
211
+ while (end < calls.length &&
212
+ isResourceLockedCall(calls[end], toolRuntime) &&
213
+ !request.isDenied(calls[end].name) &&
214
+ !(request.policy.mode === 'plan' && getPlanDisabledTools().has(calls[end].name)))
215
+ end++;
216
+ const batch = calls.slice(index, end);
217
+ const entries = [];
218
+ for (let offset = 0; offset < batch.length; offset++) {
219
+ const call = batch[offset];
220
+ const parsed = parseArgs(call.arguments);
221
+ const tool = toolRuntime.findTool(call.name);
222
+ const argumentsValid = tool && parsed !== null ? validateToolArguments(tool, parsed).valid : false;
223
+ let denied;
224
+ if (tool && argumentsValid) {
225
+ const decision = await checkPermission(tool, parsed ?? {}, request.signal, {
226
+ prompt: request.permissionPrompt,
227
+ });
228
+ request.onEvent({ type: 'permission', call, callIndex: index + offset, decision });
229
+ if (decision === 'deny')
230
+ denied = deniedOutcome(call.name);
231
+ }
232
+ entries.push({
233
+ call,
234
+ parsed,
235
+ diff: { preWriteOld: null, editStartLine: 1 },
236
+ ...(denied ? { denied } : {}),
237
+ });
238
+ }
239
+ for (const entry of entries)
240
+ request.onEvent({ type: 'header', call: entry.call });
241
+ const firstAllowed = entries.find((entry) => !entry.denied);
242
+ if (firstAllowed)
243
+ request.onEvent({ type: 'start', tool: firstAllowed.call.name });
244
+ const started = entries.map((entry) => {
245
+ if (entry.denied)
246
+ return Promise.resolve(entry.denied);
247
+ return execute(entry.call, request.argumentErrorHint(entry.call.name), (lockedArgs) => {
248
+ entry.diff = readDiffContext(entry.call, lockedArgs, jailResolve);
249
+ });
250
+ });
251
+ for (let offset = 0; offset < entries.length; offset++) {
252
+ const callIndex = index + offset;
253
+ const entry = entries[offset];
254
+ const outcome = await started[offset];
255
+ record(callIndex, outcome);
256
+ executionEvents(callIndex, entry.parsed, outcome);
257
+ resultEvent(callIndex, outcome, entry.denied ? null : entry.parsed, entry.diff);
258
+ invalidate(outcome);
259
+ }
260
+ if (firstAllowed)
261
+ request.onEvent({ type: 'done' });
262
+ index = end;
263
+ continue;
264
+ }
265
+ const call = calls[index];
266
+ if (request.policy.mode === 'plan' && getPlanDisabledTools().has(call.name)) {
267
+ request.onEvent({ type: 'header', call });
268
+ const output = `错误:计划模式下禁用工具 ${call.name}(仅读探查,不改动文件 / 不跑命令)`;
269
+ const outcome = {
270
+ status: 'denied',
271
+ code: 'MODE_DENIED',
272
+ retryable: false,
273
+ output,
274
+ changedFiles: [],
275
+ durationMs: 0,
276
+ };
277
+ record(index, outcome);
278
+ resultEvent(index, outcome, null, undefined, false);
279
+ traceEnd(index, outcome);
280
+ index++;
281
+ continue;
282
+ }
283
+ const parsed = parseArgs(call.arguments);
284
+ const tool = toolRuntime.findTool(call.name);
285
+ const argumentsValid = tool && parsed !== null ? validateToolArguments(tool, parsed).valid : false;
286
+ if (tool && argumentsValid) {
287
+ const decision = await checkPermission(tool, parsed ?? {}, request.signal, {
288
+ prompt: request.permissionPrompt,
289
+ });
290
+ request.onEvent({ type: 'permission', call, callIndex: index, decision });
291
+ if (decision === 'deny') {
292
+ request.onEvent({ type: 'header', call });
293
+ const outcome = deniedOutcome(call.name);
294
+ record(index, outcome);
295
+ resultEvent(index, outcome, null);
296
+ traceEnd(index, outcome);
297
+ index++;
298
+ continue;
299
+ }
300
+ }
301
+ request.onEvent({ type: 'header', call });
302
+ const mutationParsed = toolRuntime.isFileMutationTool(call.name) ? parsed : null;
303
+ let diff = readDiffContext(call, mutationParsed, jailResolve);
304
+ request.onEvent({ type: 'start', tool: call.name });
305
+ const outcome = await execute(call, request.argumentErrorHint(call.name), (lockedArgs) => {
306
+ if (mutationParsed)
307
+ diff = readDiffContext(call, lockedArgs, jailResolve);
308
+ });
309
+ record(index, outcome);
310
+ executionEvents(index, parsed, outcome);
311
+ request.onEvent({ type: 'done' });
312
+ resultEvent(index, outcome, mutationParsed, diff);
313
+ invalidate(outcome);
314
+ index++;
315
+ }
316
+ if (orderedResults.some((result) => !result)) {
317
+ throw new Error('Tool dispatcher did not settle every provider tool call.');
318
+ }
319
+ return {
320
+ orderedResults: orderedResults,
321
+ changedFiles: [...changedFiles],
322
+ modelAttachments,
323
+ };
324
+ }
325
+ }
326
+ class LegacyToolDispatcher extends LegacyCompatibleToolDispatcher {
327
+ constructor(dependencies = {}) {
328
+ super('legacy', dependencies);
329
+ }
330
+ }
331
+ class StagedToolDispatcher extends LegacyCompatibleToolDispatcher {
332
+ constructor(dependencies = {}) {
333
+ super('staged', dependencies);
334
+ }
335
+ }
336
+ export function createLegacyToolDispatcher(dependencies = {}) {
337
+ return new LegacyToolDispatcher(dependencies);
338
+ }
339
+ export function createStagedToolDispatcher(dependencies = {}) {
340
+ return new StagedToolDispatcher(dependencies);
341
+ }
@@ -9,7 +9,7 @@
9
9
  * pushToolResult 依赖 context/relevance/lifecycle/scheduler 的 metadata 登记,不改写历史正文。
10
10
  */
11
11
  import { readFileSync } from 'node:fs';
12
- import { findTool, getToolCapabilities } from '../tools/registry.js';
12
+ import { defaultToolRuntime } from '../tools/registry.js';
13
13
  import { jailResolve } from '../sandbox/index.js';
14
14
  import { contextState } from '../session/compact.js';
15
15
  import { capToolResultForHistory } from '../session/compact.js';
@@ -46,19 +46,19 @@ export function isToolResultsNoise(content) {
46
46
  return /^(?:\s*Tool results:\s*)+$/i.test(content.trim());
47
47
  }
48
48
  /** 只有显式声明 parallel 且无需权限确认的工具才进入普通并发组。 */
49
- export function isParallelTool(name) {
50
- const tool = findTool(name);
51
- return !!tool && (tool.risk ?? 'safe') === 'safe' && getToolCapabilities(tool).concurrency === 'parallel';
49
+ export function isParallelTool(name, toolRuntime = defaultToolRuntime) {
50
+ const tool = toolRuntime.findTool(name);
51
+ return !!tool && (tool.risk ?? 'safe') === 'safe' && toolRuntime.getToolCapabilities(tool).concurrency === 'parallel';
52
52
  }
53
53
  /** resource-locked 工具先顺序完成权限预检,再依赖 canonical resource lock 并发执行。 */
54
- export function isResourceLockedTool(name) {
55
- const tool = findTool(name);
56
- return !!tool && getToolCapabilities(tool).concurrency === 'resource-locked';
54
+ export function isResourceLockedTool(name, toolRuntime = defaultToolRuntime) {
55
+ const tool = toolRuntime.findTool(name);
56
+ return !!tool && toolRuntime.getToolCapabilities(tool).concurrency === 'resource-locked';
57
57
  }
58
- export function isResourceLockedCall(call) {
58
+ export function isResourceLockedCall(call, toolRuntime = defaultToolRuntime) {
59
59
  // sub-agent 是长时全域操作(嵌套 agent 与主 agent 同权,可写任意文件/跑任意命令),
60
60
  // 不进 mutation 并发批:逐个串行执行,避免两个子 agent 同时改工作区。
61
- return call.name !== 'sub-agent' && isResourceLockedTool(call.name);
61
+ return call.name !== 'sub-agent' && isResourceLockedTool(call.name, toolRuntime);
62
62
  }
63
63
  /** 权限拒绝时的结构化 ToolOutcome(供调度器统一回灌,不抛错中断循环)。 */
64
64
  export function deniedOutcome(name) {
@@ -72,7 +72,7 @@ export function deniedOutcome(name) {
72
72
  /** mutation 执行前读旧内容供 diff:write_file 取整文件旧内容(不存在→null=新建),
73
73
  * edit_file 取 old_string 起始行号(供 diff 显示真实文件行号)。读不到则 diff 退化为相对行号。
74
74
  * 非 mutation 或参数非法返 { preWriteOld: null, editStartLine: 1 }。失败不阻断。 */
75
- export function readDiffContext(tc, parsed) {
75
+ export function readDiffContext(tc, parsed, resolvePath = jailResolve) {
76
76
  if (!parsed)
77
77
  return { preWriteOld: null, editStartLine: 1 };
78
78
  const p = String(parsed.path ?? '');
@@ -81,7 +81,7 @@ export function readDiffContext(tc, parsed) {
81
81
  if (tc.name === 'write_file') {
82
82
  try {
83
83
  // jailResolve:沙箱越界(../../、绝对外圈、软链出圈)抛错 → catch 兜底返 null,不泄露牢外内容(TOCTOU)
84
- return { preWriteOld: readFileSync(jailResolve(p), 'utf8'), editStartLine: 1 };
84
+ return { preWriteOld: readFileSync(resolvePath(p), 'utf8'), editStartLine: 1 };
85
85
  }
86
86
  catch {
87
87
  return { preWriteOld: null, editStartLine: 1 }; // 文件不存在(新建)、不可读 或 沙箱越界(不泄露)
@@ -95,7 +95,7 @@ export function readDiffContext(tc, parsed) {
95
95
  .replace(/\r/g, '\n');
96
96
  try {
97
97
  // jailResolve:同上,沙箱越界抛错 → catch 兜底,不泄露牢外内容
98
- const raw = readFileSync(jailResolve(p), 'utf8');
98
+ const raw = readFileSync(resolvePath(p), 'utf8');
99
99
  const data = raw.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
100
100
  const idx = oldStr ? data.indexOf(oldStr) : -1;
101
101
  return {
@@ -0,0 +1,87 @@
1
+ import { isToolResultsNoise } from './tool-helpers.js';
2
+ const PLAN_NAG_THRESHOLD = 3;
3
+ const PLAN_NAG_TEXT = '[mocode] Reminder: you have an active plan in notes.md but have not updated it recently. ' +
4
+ 'If you finished a step, call plan_update to check it off (keep at most one in_progress); ' +
5
+ 'if the whole plan is done, let plan_update settle it to ## Done:. If the plan changed scope, update it to match reality.';
6
+ /** Owns tool-turn history publication, transaction settlement, plan nag, attachments and checkpoint ordering. */
7
+ export async function runToolTurn(input) {
8
+ const { opts, ctx, historyManager, result, stream, step, maxSteps, planState, turnLifecycle, cancellationLifecycle, terminationPolicy, rebuildHistoryIndexes, dispatch, } = input;
9
+ const { hooks, signal } = opts;
10
+ turnLifecycle.addToolCalls(result.toolCalls.length);
11
+ if (result.content && isToolResultsNoise(result.content)) {
12
+ result.content = null;
13
+ stream.mode = 'idle';
14
+ stream.gotText = false;
15
+ stream.lastChar = '';
16
+ }
17
+ if (stream.mode !== 'idle' && stream.lastChar !== '\n')
18
+ hooks.onTextEnd?.();
19
+ historyManager.appendAssistantTurn({ content: result.content, toolCalls: result.toolCalls });
20
+ const toolBatch = historyManager.beginToolBatch(result.toolCalls);
21
+ const workingHistory = toolBatch.workingMessages;
22
+ try {
23
+ const toolResultStartIdx = workingHistory.length;
24
+ const notesMtimeBefore = ctx.getNotesMtime();
25
+ const narration = result.content?.trim() ?? '';
26
+ if (narration) {
27
+ turnLifecycle.emitTrace('narration', {
28
+ chars: [...narration].length,
29
+ toolCalls: result.toolCalls.length,
30
+ step,
31
+ });
32
+ }
33
+ const modelAttachments = [];
34
+ await dispatch(workingHistory, modelAttachments);
35
+ const notesMtimeAfter = ctx.getNotesMtime();
36
+ if (notesMtimeAfter !== notesMtimeBefore) {
37
+ planState.stepsSincePlanTouch = 0;
38
+ }
39
+ else {
40
+ planState.stepsSincePlanTouch += 1;
41
+ if (planState.stepsSincePlanTouch >= PLAN_NAG_THRESHOLD) {
42
+ const activePlan = ctx.extractActivePlanSection();
43
+ const firstToolMsg = workingHistory[toolResultStartIdx];
44
+ if (activePlan && firstToolMsg && firstToolMsg.role === 'tool' && typeof firstToolMsg.content === 'string') {
45
+ firstToolMsg.content = `${PLAN_NAG_TEXT}\n\n${firstToolMsg.content}`;
46
+ }
47
+ planState.stepsSincePlanTouch = 0;
48
+ }
49
+ }
50
+ let attachmentMessage;
51
+ if (modelAttachments.length > 0) {
52
+ const names = modelAttachments.map((attachment) => attachment.name).join(', ');
53
+ const content = [
54
+ {
55
+ type: 'text',
56
+ text: `The view_image tool loaded the following visual input: ${names}. Analyze the attached image content directly.`,
57
+ },
58
+ ...modelAttachments.map((attachment) => ({
59
+ type: 'image_url',
60
+ image_url: {
61
+ url: attachment.dataUrl,
62
+ ...(attachment.detail === 'low' || attachment.detail === 'high' ? { detail: attachment.detail } : {}),
63
+ },
64
+ })),
65
+ ];
66
+ attachmentMessage = { role: 'user', content };
67
+ }
68
+ toolBatch.commit(attachmentMessage);
69
+ }
70
+ catch (error) {
71
+ toolBatch.rollback();
72
+ rebuildHistoryIndexes();
73
+ throw error;
74
+ }
75
+ hooks.onToolBatchEnd?.();
76
+ cancellationLifecycle.checkpoint();
77
+ const batchDecision = terminationPolicy.decide({
78
+ phase: 'tool_batch_committed',
79
+ step,
80
+ maxSteps,
81
+ aborted: signal?.aborted === true,
82
+ modelResult: result,
83
+ });
84
+ if (batchDecision.kind !== 'continue') {
85
+ throw new Error(`Unexpected termination after committed tool batch: ${batchDecision.kind}.`);
86
+ }
87
+ }