openhorse 0.2.11 → 0.2.13

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 (105) hide show
  1. package/dist/commands/index.d.ts.map +1 -1
  2. package/dist/commands/index.js +681 -6
  3. package/dist/commands/index.js.map +1 -1
  4. package/dist/commands/types.d.ts +3 -1
  5. package/dist/commands/types.d.ts.map +1 -1
  6. package/dist/commands/types.js.map +1 -1
  7. package/dist/core/checkpoint.d.ts +1 -0
  8. package/dist/core/checkpoint.d.ts.map +1 -1
  9. package/dist/core/checkpoint.js +59 -10
  10. package/dist/core/checkpoint.js.map +1 -1
  11. package/dist/core/tool-artifacts.d.ts +10 -0
  12. package/dist/core/tool-artifacts.d.ts.map +1 -1
  13. package/dist/core/tool-artifacts.js +81 -3
  14. package/dist/core/tool-artifacts.js.map +1 -1
  15. package/dist/framework/index.d.ts +2 -2
  16. package/dist/framework/index.d.ts.map +1 -1
  17. package/dist/framework/index.js +5 -1
  18. package/dist/framework/index.js.map +1 -1
  19. package/dist/framework/query.d.ts +104 -1
  20. package/dist/framework/query.d.ts.map +1 -1
  21. package/dist/framework/query.js +454 -18
  22. package/dist/framework/query.js.map +1 -1
  23. package/dist/framework/store.d.ts +5 -0
  24. package/dist/framework/store.d.ts.map +1 -1
  25. package/dist/framework/store.js +4 -0
  26. package/dist/framework/store.js.map +1 -1
  27. package/dist/framework/tool-scheduler.d.ts +9 -0
  28. package/dist/framework/tool-scheduler.d.ts.map +1 -1
  29. package/dist/framework/tool-scheduler.js +34 -0
  30. package/dist/framework/tool-scheduler.js.map +1 -1
  31. package/dist/ink-ui/components/StatusLine.d.ts.map +1 -1
  32. package/dist/ink-ui/components/StatusLine.js +7 -1
  33. package/dist/ink-ui/components/StatusLine.js.map +1 -1
  34. package/dist/runtime/agent-runtime-protocol.d.ts +13 -1
  35. package/dist/runtime/agent-runtime-protocol.d.ts.map +1 -1
  36. package/dist/runtime/agent-runtime-protocol.js +24 -0
  37. package/dist/runtime/agent-runtime-protocol.js.map +1 -1
  38. package/dist/runtime/chat-controller.d.ts +3 -0
  39. package/dist/runtime/chat-controller.d.ts.map +1 -1
  40. package/dist/runtime/chat-controller.js +930 -5
  41. package/dist/runtime/chat-controller.js.map +1 -1
  42. package/dist/runtime/loop-budget.d.ts +5 -0
  43. package/dist/runtime/loop-budget.d.ts.map +1 -0
  44. package/dist/runtime/loop-budget.js +77 -0
  45. package/dist/runtime/loop-budget.js.map +1 -0
  46. package/dist/runtime/ui-events.d.ts +39 -1
  47. package/dist/runtime/ui-events.d.ts.map +1 -1
  48. package/dist/runtime/ui-events.js.map +1 -1
  49. package/dist/services/config-dir.d.ts +2 -0
  50. package/dist/services/config-dir.d.ts.map +1 -1
  51. package/dist/services/config-dir.js +5 -0
  52. package/dist/services/config-dir.js.map +1 -1
  53. package/dist/services/config.d.ts +4 -2
  54. package/dist/services/config.d.ts.map +1 -1
  55. package/dist/services/config.js +19 -0
  56. package/dist/services/config.js.map +1 -1
  57. package/dist/services/doctor.d.ts.map +1 -1
  58. package/dist/services/doctor.js +34 -4
  59. package/dist/services/doctor.js.map +1 -1
  60. package/dist/services/global-config.d.ts +17 -0
  61. package/dist/services/global-config.d.ts.map +1 -1
  62. package/dist/services/global-config.js.map +1 -1
  63. package/dist/services/llm.d.ts +18 -0
  64. package/dist/services/llm.d.ts.map +1 -1
  65. package/dist/services/llm.js +175 -163
  66. package/dist/services/llm.js.map +1 -1
  67. package/dist/services/provider-diagnostics.d.ts +30 -0
  68. package/dist/services/provider-diagnostics.d.ts.map +1 -0
  69. package/dist/services/provider-diagnostics.js +308 -0
  70. package/dist/services/provider-diagnostics.js.map +1 -0
  71. package/dist/services/redaction.d.ts +2 -0
  72. package/dist/services/redaction.d.ts.map +1 -0
  73. package/dist/services/redaction.js +27 -0
  74. package/dist/services/redaction.js.map +1 -0
  75. package/dist/services/session-index.d.ts.map +1 -1
  76. package/dist/services/session-index.js +7 -3
  77. package/dist/services/session-index.js.map +1 -1
  78. package/dist/services/session-storage.d.ts +100 -0
  79. package/dist/services/session-storage.d.ts.map +1 -1
  80. package/dist/services/session-storage.js +154 -3
  81. package/dist/services/session-storage.js.map +1 -1
  82. package/dist/services/verification-profile.d.ts +36 -0
  83. package/dist/services/verification-profile.d.ts.map +1 -0
  84. package/dist/services/verification-profile.js +239 -0
  85. package/dist/services/verification-profile.js.map +1 -0
  86. package/dist/services/workspace-state.d.ts +25 -0
  87. package/dist/services/workspace-state.d.ts.map +1 -0
  88. package/dist/services/workspace-state.js +115 -0
  89. package/dist/services/workspace-state.js.map +1 -0
  90. package/dist/skills/loader.d.ts +5 -1
  91. package/dist/skills/loader.d.ts.map +1 -1
  92. package/dist/skills/loader.js +34 -0
  93. package/dist/skills/loader.js.map +1 -1
  94. package/dist/skills/registry.d.ts +5 -1
  95. package/dist/skills/registry.d.ts.map +1 -1
  96. package/dist/skills/registry.js +23 -0
  97. package/dist/skills/registry.js.map +1 -1
  98. package/dist/skills/runtime.d.ts +6 -0
  99. package/dist/skills/runtime.d.ts.map +1 -1
  100. package/dist/skills/runtime.js +88 -4
  101. package/dist/skills/runtime.js.map +1 -1
  102. package/dist/skills/types.d.ts +19 -0
  103. package/dist/skills/types.d.ts.map +1 -1
  104. package/dist/skills/types.js.map +1 -1
  105. package/package.json +1 -1
@@ -10,12 +10,17 @@
10
10
  * chunks directly to stdout via the callback.
11
11
  */
12
12
  Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.QueryLoopError = exports.DEFAULT_LOOP_BUDGET = exports.DEFAULT_MAX_MODEL_VISIBLE_TOOL_RESULT_BYTES = void 0;
14
+ exports.createLocalFastPathLoopStats = createLocalFastPathLoopStats;
15
+ exports.createFailedLoopStats = createFailedLoopStats;
13
16
  exports.query = query;
14
17
  const tool_1 = require("./tool");
15
18
  const strategy_tracker_1 = require("../core/strategy-tracker");
16
19
  const auto_compact_1 = require("../services/compact/auto-compact");
17
20
  const tool_scheduler_1 = require("./tool-scheduler");
18
21
  const token_estimate_1 = require("../utils/token-estimate");
22
+ const tool_serializer_1 = require("./tool-serializer");
23
+ exports.DEFAULT_MAX_MODEL_VISIBLE_TOOL_RESULT_BYTES = 4096;
19
24
  function isAborted(signal) {
20
25
  return signal?.aborted === true;
21
26
  }
@@ -26,6 +31,14 @@ function cancelledEvent(llm) {
26
31
  model: llm.getModel(),
27
32
  };
28
33
  }
34
+ function cancelledCompleteEvent(llm, stats) {
35
+ return {
36
+ type: 'complete',
37
+ content: 'Operation cancelled.',
38
+ model: llm.getModel(),
39
+ stats: cloneLoopStats(stats, 'cancelled'),
40
+ };
41
+ }
29
42
  function isRecord(value) {
30
43
  return typeof value === 'object' && value !== null && !Array.isArray(value);
31
44
  }
@@ -65,6 +78,326 @@ function parseBatchReadEvidenceSteps(result) {
65
78
  }];
66
79
  });
67
80
  }
81
+ exports.DEFAULT_LOOP_BUDGET = {
82
+ maxLlmRequestsPerUserTurn: 24,
83
+ maxToolCallsPerUserTurn: 120,
84
+ maxReadOnlyFragmentation: 3,
85
+ maxModelVisibleToolBytes: 64 * 1024,
86
+ profile: 'default',
87
+ baseProfile: 'default',
88
+ configOverride: false,
89
+ };
90
+ const COMPLEX_LOOP_BUDGET = {
91
+ maxLlmRequestsPerUserTurn: 48,
92
+ maxToolCallsPerUserTurn: 180,
93
+ maxModelVisibleToolBytes: 96 * 1024,
94
+ };
95
+ class QueryLoopError extends Error {
96
+ constructor(error, stats) {
97
+ super(error instanceof Error ? error.message : String(error));
98
+ this.name = 'QueryLoopError';
99
+ this.originalError = error;
100
+ this.stats = stats;
101
+ }
102
+ }
103
+ exports.QueryLoopError = QueryLoopError;
104
+ function createLoopStats() {
105
+ return {
106
+ turnsStarted: 0,
107
+ llmRequests: 0,
108
+ toolCalls: 0,
109
+ readOnlyToolCalls: 0,
110
+ unsafeToolCalls: 0,
111
+ toolResultBytes: 0,
112
+ modelVisibleToolBytes: 0,
113
+ summarizedBytes: 0,
114
+ finishReason: 'running',
115
+ providerRetryCount: 0,
116
+ providerRetryDelayMs: 0,
117
+ providerRetryErrorTypes: [],
118
+ providerFallbackCount: 0,
119
+ providerUsingFallback: false,
120
+ singleReadOnlyStreak: 0,
121
+ batchReadSuggestionCount: 0,
122
+ localFastPathUsed: false,
123
+ };
124
+ }
125
+ function cloneLoopStats(stats, finishReason) {
126
+ return {
127
+ ...stats,
128
+ finishReason: finishReason ?? stats.finishReason,
129
+ };
130
+ }
131
+ function byteLength(text) {
132
+ return Buffer.byteLength(text, 'utf8');
133
+ }
134
+ function createLocalFastPathLoopStats(overrides = {}) {
135
+ return {
136
+ ...createLoopStats(),
137
+ turnsStarted: 1,
138
+ finishReason: 'completed',
139
+ localFastPathUsed: true,
140
+ ...overrides,
141
+ };
142
+ }
143
+ function createFailedLoopStats(options = {}) {
144
+ const { loopBudget, diagnostics, ...overrides } = options;
145
+ const stats = {
146
+ ...createLoopStats(),
147
+ turnsStarted: 1,
148
+ llmRequests: 1,
149
+ finishReason: 'failed',
150
+ ...overrides,
151
+ };
152
+ applyLoopBudgetStats(stats, resolveLoopBudget(loopBudget));
153
+ applyLlmRequestDiagnostics(stats, diagnostics);
154
+ return stats;
155
+ }
156
+ function isLoopBudgetSource(value) {
157
+ return value === 'default' || value === 'complex' || value === 'release' || value === 'config';
158
+ }
159
+ function isLoopBudgetBaseProfile(value) {
160
+ return value === 'default' || value === 'complex' || value === 'release';
161
+ }
162
+ function resolveLoopBudget(partial) {
163
+ const numericOverrides = Object.fromEntries(Object.entries(partial ?? {}).filter(([, value]) => typeof value === 'number' && Number.isFinite(value) && value > 0));
164
+ const hasNumericOverrides = Object.keys(numericOverrides).length > 0;
165
+ const hasExplicitProfile = partial?.profile !== undefined || partial?.baseProfile !== undefined;
166
+ const inferredConfigOverride = hasNumericOverrides && !hasExplicitProfile;
167
+ const configOverride = partial?.configOverride === true || inferredConfigOverride;
168
+ return {
169
+ ...exports.DEFAULT_LOOP_BUDGET,
170
+ ...numericOverrides,
171
+ profile: isLoopBudgetSource(partial?.profile)
172
+ ? partial.profile
173
+ : configOverride
174
+ ? 'config'
175
+ : exports.DEFAULT_LOOP_BUDGET.profile,
176
+ baseProfile: isLoopBudgetBaseProfile(partial?.baseProfile) ? partial.baseProfile : exports.DEFAULT_LOOP_BUDGET.baseProfile,
177
+ configOverride,
178
+ };
179
+ }
180
+ function applyLoopBudgetStats(stats, budget) {
181
+ stats.loopBudgetSource = budget.profile ?? 'default';
182
+ stats.loopBudgetBaseProfile = budget.baseProfile ?? (budget.profile === 'config' ? 'default' : budget.profile ?? 'default');
183
+ stats.loopBudgetMaxLlmRequests = budget.maxLlmRequestsPerUserTurn;
184
+ stats.loopBudgetMaxToolCalls = budget.maxToolCallsPerUserTurn;
185
+ stats.loopBudgetMaxReadOnlyFragmentation = budget.maxReadOnlyFragmentation;
186
+ stats.loopBudgetMaxModelVisibleBytes = budget.maxModelVisibleToolBytes;
187
+ stats.loopBudgetConfigOverride = budget.configOverride === true;
188
+ }
189
+ function shouldPromoteDefaultBudget(stats, budget) {
190
+ if (budget.configOverride === true || budget.profile !== 'default')
191
+ return false;
192
+ if (stats.llmRequests < budget.maxLlmRequestsPerUserTurn)
193
+ return false;
194
+ return stats.toolCalls >= 8
195
+ || stats.readOnlyToolCalls >= 6
196
+ || stats.modelVisibleToolBytes >= 16 * 1024;
197
+ }
198
+ function promoteDefaultBudgetToComplex(budget) {
199
+ return {
200
+ ...budget,
201
+ maxLlmRequestsPerUserTurn: Math.max(budget.maxLlmRequestsPerUserTurn, COMPLEX_LOOP_BUDGET.maxLlmRequestsPerUserTurn),
202
+ maxToolCallsPerUserTurn: Math.max(budget.maxToolCallsPerUserTurn, COMPLEX_LOOP_BUDGET.maxToolCallsPerUserTurn),
203
+ maxModelVisibleToolBytes: Math.max(budget.maxModelVisibleToolBytes, COMPLEX_LOOP_BUDGET.maxModelVisibleToolBytes),
204
+ profile: 'complex',
205
+ baseProfile: 'complex',
206
+ configOverride: false,
207
+ };
208
+ }
209
+ function getLlmRequestDiagnostics(llm) {
210
+ const diagnosticsReader = llm.getLastRequestDiagnostics;
211
+ return typeof diagnosticsReader === 'function' ? diagnosticsReader.call(llm) : undefined;
212
+ }
213
+ function applyLlmRequestDiagnostics(stats, diagnostics) {
214
+ if (!diagnostics)
215
+ return;
216
+ const existingTypes = stats.providerRetryErrorTypes ?? [];
217
+ const retryTypes = diagnostics.retryErrorTypes.filter((type, index, values) => !existingTypes.includes(type) && values.indexOf(type) === index);
218
+ stats.providerRetryCount = (stats.providerRetryCount ?? 0) + diagnostics.retryCount;
219
+ stats.providerRetryDelayMs = (stats.providerRetryDelayMs ?? 0) + diagnostics.retryDelayMs;
220
+ stats.providerRetryErrorTypes = [...existingTypes, ...retryTypes];
221
+ stats.providerLastRetryErrorType = diagnostics.lastRetryErrorType ?? stats.providerLastRetryErrorType;
222
+ stats.providerLastRetryStatus = diagnostics.lastRetryStatus ?? stats.providerLastRetryStatus;
223
+ stats.providerFinalModel = diagnostics.finalModel;
224
+ stats.providerUsingFallback = diagnostics.usingFallback;
225
+ if (diagnostics.fallbackTriggered) {
226
+ stats.providerFallbackCount = (stats.providerFallbackCount ?? 0) + 1;
227
+ stats.providerFallbackFromModel = diagnostics.fallbackFromModel;
228
+ stats.providerFallbackToModel = diagnostics.fallbackToModel;
229
+ }
230
+ }
231
+ function budgetExceededEvent(llm, stats, reason) {
232
+ const continuationActions = [
233
+ 'reply_continue',
234
+ 'narrow_instruction',
235
+ 'inspect_loop_stats',
236
+ 'raise_budget',
237
+ ];
238
+ const continuationHint = 'Reply `继续` to continue the same objective, give a narrower next step, inspect /loop-stats, or raise agentLoop.budget for intentional long work.';
239
+ return {
240
+ type: 'complete',
241
+ content: [
242
+ `Agent loop budget reached: ${reason}.`,
243
+ 'I stopped this turn to avoid unnecessary model requests and preserved the current session state.',
244
+ 'To continue the same objective, reply `继续` or provide the next concrete step.',
245
+ 'Use /loop-stats to inspect request/tool counts. For intentional long work, raise agentLoop.budget in openhorse.json.',
246
+ ].join('\n'),
247
+ model: llm.getModel(),
248
+ stats: cloneLoopStats({
249
+ ...stats,
250
+ budgetExceededReason: reason,
251
+ continuationActions,
252
+ continuationHint,
253
+ }, 'budget_exceeded'),
254
+ };
255
+ }
256
+ function permissionBlockedEvent(llm, stats, toolName, error, source) {
257
+ const detail = error ? ` ${error}` : '';
258
+ const sourceText = source ? ` (${source})` : '';
259
+ return {
260
+ type: 'complete',
261
+ content: `Tool ${toolName} was not executed because permission was denied${sourceText}.${detail}`.trim(),
262
+ model: llm.getModel(),
263
+ stats: cloneLoopStats(stats, 'blocked'),
264
+ };
265
+ }
266
+ function compactPromptEvidence(items, maxItems = 12) {
267
+ return items.slice(0, maxItems).map(item => `${item.id}:${item.kind}:score=${item.score}:tokens=${item.tokens}`);
268
+ }
269
+ function takeUtf8Prefix(text, maxBytes) {
270
+ if (maxBytes <= 0)
271
+ return '';
272
+ let result = '';
273
+ let bytes = 0;
274
+ for (const char of text) {
275
+ const nextBytes = byteLength(char);
276
+ if (bytes + nextBytes > maxBytes)
277
+ break;
278
+ result += char;
279
+ bytes += nextBytes;
280
+ }
281
+ return result;
282
+ }
283
+ function takeUtf8Suffix(text, maxBytes) {
284
+ if (maxBytes <= 0)
285
+ return '';
286
+ let result = '';
287
+ let bytes = 0;
288
+ const chars = Array.from(text);
289
+ for (let index = chars.length - 1; index >= 0; index--) {
290
+ const char = chars[index];
291
+ const nextBytes = byteLength(char);
292
+ if (bytes + nextBytes > maxBytes)
293
+ break;
294
+ result = char + result;
295
+ bytes += nextBytes;
296
+ }
297
+ return result;
298
+ }
299
+ function truncateForModel(text, maxBytes) {
300
+ if (byteLength(text) <= maxBytes)
301
+ return text;
302
+ const marker = '\n...[truncated]';
303
+ if (maxBytes <= 128) {
304
+ return `${takeUtf8Prefix(text, Math.max(0, maxBytes - byteLength(marker)))}${marker}`;
305
+ }
306
+ const middle = `\n...[truncated ${byteLength(text)}B output for model context]...\n`;
307
+ const contentBudget = Math.max(0, maxBytes - byteLength(middle));
308
+ const headBytes = Math.floor(contentBudget * 0.65);
309
+ const tailBytes = contentBudget - headBytes;
310
+ return [
311
+ takeUtf8Prefix(text, headBytes),
312
+ middle,
313
+ takeUtf8Suffix(text, tailBytes),
314
+ ].join('');
315
+ }
316
+ function summarizeModelVisibleToolResult(executed, maxBytes) {
317
+ const rawBytes = byteLength(executed.result);
318
+ const fullOutputBytes = executed.outputBytes ?? rawBytes;
319
+ if (rawBytes <= maxBytes && !executed.artifactRef) {
320
+ return { result: executed.result, bytes: rawBytes, summarizedBytes: 0 };
321
+ }
322
+ const envelope = (0, tool_serializer_1.parseToolResultEnvelope)(executed.result);
323
+ const output = typeof envelope.output === 'string' ? envelope.output : executed.result;
324
+ const summary = executed.summary || envelope.summary;
325
+ const compactSummary = summary ? truncateForModel(summary, 192) : undefined;
326
+ const rawError = executed.error ?? envelope.error;
327
+ const compactError = rawError ? truncateForModel(rawError, 192) : undefined;
328
+ const artifactText = executed.artifactRef
329
+ ? ` Full output is available as artifact ${executed.artifactRef.id} (${executed.artifactRef.outputBytes}B).`
330
+ : '';
331
+ const serializeCompact = (compactOutput) => (0, tool_serializer_1.serializeToolResult)({
332
+ success: executed.success,
333
+ output: compactOutput,
334
+ error: compactError,
335
+ summary: compactSummary,
336
+ outputBytes: fullOutputBytes,
337
+ artifactRef: executed.artifactRef ?? envelope.artifactRef,
338
+ metadata: {
339
+ ...(envelope.metadata ?? {}),
340
+ modelVisibleCompressed: true,
341
+ originalResultBytes: rawBytes,
342
+ },
343
+ });
344
+ let outputBudget = Math.max(128, maxBytes - 768);
345
+ let compact = '';
346
+ for (let attempt = 0; attempt < 6; attempt++) {
347
+ compact = serializeCompact([
348
+ compactSummary ? `Summary: ${compactSummary}` : 'Tool output was summarized for model context.',
349
+ artifactText.trim(),
350
+ truncateForModel(output, outputBudget),
351
+ ].filter(Boolean).join('\n'));
352
+ const compactBytes = byteLength(compact);
353
+ if (compactBytes <= maxBytes || outputBudget <= 128)
354
+ break;
355
+ outputBudget = Math.max(128, outputBudget - Math.max(compactBytes - maxBytes, 128));
356
+ }
357
+ if (byteLength(compact) > maxBytes) {
358
+ compact = serializeCompact([
359
+ compactSummary ? `Summary: ${compactSummary}` : 'Tool output was summarized for model context.',
360
+ artifactText.trim(),
361
+ `Output body omitted from model context (${fullOutputBytes}B total).`,
362
+ ].filter(Boolean).join('\n'));
363
+ }
364
+ if (byteLength(compact) > maxBytes) {
365
+ compact = (0, tool_serializer_1.serializeToolResult)({
366
+ success: executed.success,
367
+ output: `Tool result omitted from model context (${fullOutputBytes}B total).`,
368
+ outputBytes: fullOutputBytes,
369
+ metadata: {
370
+ modelVisibleCompressed: true,
371
+ originalResultBytes: rawBytes,
372
+ },
373
+ });
374
+ }
375
+ const bytes = byteLength(compact);
376
+ return {
377
+ result: compact,
378
+ bytes,
379
+ summarizedBytes: Math.max(0, fullOutputBytes - bytes),
380
+ };
381
+ }
382
+ function omitModelVisibleToolResult(executed, maxAggregateBytes) {
383
+ const fullBytes = executed.outputBytes ?? byteLength(executed.result);
384
+ const compact = (0, tool_serializer_1.serializeToolResult)({
385
+ success: executed.success,
386
+ output: `Tool result omitted from model context because the per-turn model-visible tool budget (${maxAggregateBytes}B) was reached.`,
387
+ outputBytes: fullBytes,
388
+ artifactRef: executed.artifactRef,
389
+ metadata: {
390
+ modelVisibleCompressed: true,
391
+ modelVisibleBudgetExceeded: true,
392
+ },
393
+ });
394
+ const bytes = byteLength(compact);
395
+ return {
396
+ result: compact,
397
+ bytes,
398
+ summarizedBytes: Math.max(0, fullBytes - bytes),
399
+ };
400
+ }
68
401
  // ============================================================================
69
402
  // query() — async generator
70
403
  // ============================================================================
@@ -89,25 +422,40 @@ async function* query(params) {
89
422
  harness, input, maxParallelToolCalls = 6, } = params;
90
423
  const openaiTools = (0, tool_1.toOpenAITools)(tools);
91
424
  let turn = 0;
425
+ const stats = createLoopStats();
426
+ let loopBudget = resolveLoopBudget(params.loopBudget);
427
+ applyLoopBudgetStats(stats, loopBudget);
428
+ const maxModelVisibleToolResultBytes = Math.max(512, params.maxModelVisibleToolResultBytes ?? exports.DEFAULT_MAX_MODEL_VISIBLE_TOOL_RESULT_BYTES);
429
+ const fragmentedReadOnlyTools = new Set(['read_file', 'glob', 'grep', 'list_files', 'git_status']);
92
430
  // 无限循环,依赖安全机制停止
93
431
  while (true) {
94
432
  turn++;
95
433
  // Check abort
96
434
  if (isAborted(abortSignal)) {
97
- yield cancelledEvent(llm);
435
+ yield cancelledCompleteEvent(llm, stats);
98
436
  return;
99
437
  }
100
- // Request start
101
- yield { type: 'request_start', model: llm.getModel(), turn };
102
438
  // Safety valve: check maxTurns if specified (optional)
103
439
  if (maxTurns && turn > maxTurns) {
104
440
  yield {
105
441
  type: 'complete',
106
442
  content: `Reached maximum turns (${maxTurns}). Task may be incomplete.`,
107
443
  model: llm.getModel(),
444
+ stats: cloneLoopStats(stats, 'max_turns'),
108
445
  };
109
446
  return;
110
447
  }
448
+ if (shouldPromoteDefaultBudget(stats, loopBudget)) {
449
+ loopBudget = promoteDefaultBudgetToComplex(loopBudget);
450
+ applyLoopBudgetStats(stats, loopBudget);
451
+ }
452
+ if (stats.llmRequests >= loopBudget.maxLlmRequestsPerUserTurn) {
453
+ yield budgetExceededEvent(llm, stats, `LLM request budget ${loopBudget.maxLlmRequestsPerUserTurn} reached`);
454
+ return;
455
+ }
456
+ // Request start
457
+ yield { type: 'request_start', model: llm.getModel(), turn };
458
+ stats.turnsStarted = turn;
111
459
  const autoCompact = (0, auto_compact_1.getAutoCompact)({
112
460
  modelId: llm.getModel(),
113
461
  getContextCapsule: harness ? () => harness.getCapsule() : undefined,
@@ -122,34 +470,64 @@ async function* query(params) {
122
470
  const predictedTokens = (0, token_estimate_1.estimateMessagesTokens)(requestMessages);
123
471
  const preCompacted = await autoCompact.checkPredictiveAndCompact(messages, predictedTokens);
124
472
  if (preCompacted !== messages) {
473
+ stats.compactTrigger = 'pre_turn';
125
474
  messages.length = 0;
126
475
  messages.push(...preCompacted);
127
476
  requestMessages = harness
128
477
  ? harness.assembleMessages(messages, { input, tools: tools.map(tool => ({ name: tool.name, description: tool.description })) })
129
478
  : messages;
130
479
  }
480
+ const assemblyStats = harness?.toJSON().promptAssemblyStats;
481
+ if (assemblyStats) {
482
+ yield {
483
+ type: 'prompt_assembly',
484
+ modelId: assemblyStats.modelId,
485
+ estimatedTokens: assemblyStats.estimatedTokens,
486
+ budgetTokens: assemblyStats.budgetTokens,
487
+ coreTokens: assemblyStats.coreTokens,
488
+ evidenceBudgetTokens: assemblyStats.evidenceBudgetTokens,
489
+ recentTurnBudgetTokens: assemblyStats.recentTurnBudgetTokens,
490
+ sections: assemblyStats.sections.slice(0, 12),
491
+ includedEvidence: compactPromptEvidence(assemblyStats.includedEvidence),
492
+ omittedEvidence: compactPromptEvidence(assemblyStats.omittedEvidence, 8),
493
+ includedEvidenceCount: assemblyStats.includedEvidence.length,
494
+ omittedEvidenceCount: assemblyStats.omittedEvidence.length,
495
+ };
496
+ }
131
497
  if (isAborted(abortSignal)) {
132
- yield cancelledEvent(llm);
498
+ yield cancelledCompleteEvent(llm, stats);
133
499
  return;
134
500
  }
135
- const response = await llm.chatStream(requestMessages, streamCallbacks, openaiTools, { abortSignal });
501
+ let response;
502
+ try {
503
+ stats.llmRequests++;
504
+ response = await llm.chatStream(requestMessages, streamCallbacks, openaiTools, { abortSignal });
505
+ applyLlmRequestDiagnostics(stats, getLlmRequestDiagnostics(llm));
506
+ }
507
+ catch (error) {
508
+ applyLlmRequestDiagnostics(stats, getLlmRequestDiagnostics(llm));
509
+ throw new QueryLoopError(error, cloneLoopStats(stats, 'failed'));
510
+ }
136
511
  if (isAborted(abortSignal)) {
137
- yield cancelledEvent(llm);
512
+ yield cancelledCompleteEvent(llm, stats);
138
513
  return;
139
514
  }
140
- // Save assistant message to history
141
- const assistantMsg = {
142
- role: 'assistant',
143
- content: response.content,
144
- };
145
- if (response.toolCalls) {
146
- assistantMsg.tool_calls = response.toolCalls;
147
- }
148
- messages.push(assistantMsg);
149
- harness?.recordAssistantResponse(response);
150
515
  // Handle tool calls
151
516
  if (response.toolCalls && response.toolCalls.length > 0) {
152
517
  const toolCalls = response.toolCalls;
518
+ if (stats.toolCalls + toolCalls.length > loopBudget.maxToolCallsPerUserTurn) {
519
+ yield budgetExceededEvent(llm, stats, `tool call budget ${loopBudget.maxToolCallsPerUserTurn} would be exceeded by ${toolCalls.length} requested tools`);
520
+ return;
521
+ }
522
+ // Save assistant tool-call message only after budget checks, so future
523
+ // requests never inherit unresolved tool calls if this turn is stopped.
524
+ const assistantMsg = {
525
+ role: 'assistant',
526
+ content: response.content,
527
+ tool_calls: toolCalls,
528
+ };
529
+ messages.push(assistantMsg);
530
+ harness?.recordAssistantResponse(response);
153
531
  yield {
154
532
  type: 'assistant_tool_calls',
155
533
  content: response.content,
@@ -169,6 +547,21 @@ async function* query(params) {
169
547
  harnessDriftCheck: harness ? ({ name, args }) => harness.beforeToolUse({ name, args }) : undefined,
170
548
  harnessBlockedResult: harness ? (drift) => harness.asToolBlockedResult(drift) : undefined,
171
549
  });
550
+ stats.toolCalls += preparedCalls.length;
551
+ for (const prepared of preparedCalls) {
552
+ if (prepared.tool?.isReadOnly?.(prepared.args) === true) {
553
+ stats.readOnlyToolCalls++;
554
+ }
555
+ else {
556
+ stats.unsafeToolCalls++;
557
+ }
558
+ }
559
+ const singleFragmentedReadOnlyCall = preparedCalls.length === 1
560
+ && preparedCalls[0].tool?.isReadOnly?.(preparedCalls[0].args) === true
561
+ && fragmentedReadOnlyTools.has(preparedCalls[0].tc.function.name);
562
+ stats.singleReadOnlyStreak = singleFragmentedReadOnlyCall
563
+ ? stats.singleReadOnlyStreak + 1
564
+ : 0;
172
565
  for (const prepared of preparedCalls) {
173
566
  yield {
174
567
  type: 'tool_call',
@@ -189,11 +582,18 @@ async function* query(params) {
189
582
  maxParallelToolCalls,
190
583
  })) {
191
584
  if (isAborted(abortSignal)) {
192
- yield cancelledEvent(llm);
585
+ yield cancelledCompleteEvent(llm, stats);
193
586
  return;
194
587
  }
195
588
  const { prepared } = executed;
196
589
  const { tc, args, attemptId } = prepared;
590
+ const remainingModelVisibleBytes = loopBudget.maxModelVisibleToolBytes - stats.modelVisibleToolBytes;
591
+ const modelVisible = remainingModelVisibleBytes < 512
592
+ ? omitModelVisibleToolResult(executed, loopBudget.maxModelVisibleToolBytes)
593
+ : summarizeModelVisibleToolResult(executed, Math.min(maxModelVisibleToolResultBytes, remainingModelVisibleBytes));
594
+ stats.toolResultBytes += executed.outputBytes ?? byteLength(executed.result);
595
+ stats.modelVisibleToolBytes += modelVisible.bytes;
596
+ stats.summarizedBytes += modelVisible.summarizedBytes;
197
597
  strategyTracker.recordResult(attemptId, executed.strategyResult, executed.strategyError, executed.duration);
198
598
  harness?.recordToolResult({
199
599
  name: tc.function.name,
@@ -222,12 +622,24 @@ async function* query(params) {
222
622
  });
223
623
  }
224
624
  }
625
+ if (executed.permissionDecision) {
626
+ yield {
627
+ type: 'permission_decision',
628
+ name: tc.function.name,
629
+ args,
630
+ callId: tc.id,
631
+ decision: executed.permissionDecision,
632
+ batchCount: toolCalls.length,
633
+ batchIndex: prepared.index,
634
+ };
635
+ }
225
636
  yield {
226
637
  type: 'tool_result',
227
638
  name: tc.function.name,
228
639
  args,
229
640
  callId: tc.id,
230
641
  result: executed.result,
642
+ modelVisibleResult: modelVisible.result,
231
643
  duration: executed.duration,
232
644
  success: executed.success,
233
645
  artifactRef: executed.artifactRef,
@@ -239,9 +651,13 @@ async function* query(params) {
239
651
  };
240
652
  messages.push({
241
653
  role: 'tool',
242
- content: executed.result,
654
+ content: modelVisible.result,
243
655
  tool_call_id: tc.id,
244
656
  });
657
+ if (executed.permissionDecision?.approved === false) {
658
+ yield permissionBlockedEvent(llm, stats, tc.function.name, executed.error, executed.permissionDecision.source);
659
+ return;
660
+ }
245
661
  if (strategyTracker.isExhausted()) {
246
662
  const suggestion = strategyTracker.suggestAlternative();
247
663
  if (suggestion) {
@@ -254,13 +670,31 @@ async function* query(params) {
254
670
  }
255
671
  }
256
672
  }
673
+ if (stats.singleReadOnlyStreak >= loopBudget.maxReadOnlyFragmentation) {
674
+ messages.push({
675
+ role: 'system',
676
+ content: [
677
+ '[OpenHorse loop hint]',
678
+ `You have made ${stats.singleReadOnlyStreak} consecutive turns with a single read-only local tool call.`,
679
+ 'For independent local exploration, prefer batch_read with up to 8 steps using git_status, list_files, glob, grep, and read_file.',
680
+ ].join(' '),
681
+ });
682
+ stats.batchReadSuggestionCount++;
683
+ }
257
684
  // Continue to next turn
258
685
  continue;
259
686
  }
687
+ const assistantMsg = {
688
+ role: 'assistant',
689
+ content: response.content,
690
+ };
691
+ messages.push(assistantMsg);
692
+ harness?.recordAssistantResponse(response);
260
693
  // No tool calls — done, unless the harness requires one more pass.
261
694
  const completionGate = harness?.beforeComplete();
262
695
  if (completionGate && !completionGate.canComplete) {
263
696
  messages.push(harness.asCompletionBlockedMessage(completionGate));
697
+ stats.finishReason = 'completion_gate';
264
698
  continue;
265
699
  }
266
700
  yield { type: 'message', role: 'assistant', content: response.content };
@@ -278,6 +712,7 @@ async function* query(params) {
278
712
  });
279
713
  const compacted = await autoCompact.checkAndCompact(messages, totalTokens);
280
714
  if (compacted !== messages) {
715
+ stats.compactTrigger = stats.compactTrigger ?? 'post_turn';
281
716
  messages.length = 0;
282
717
  messages.push(...compacted);
283
718
  }
@@ -286,6 +721,7 @@ async function* query(params) {
286
721
  content: response.content,
287
722
  usage: response.usage,
288
723
  model: response.model,
724
+ stats: cloneLoopStats(stats, 'completed'),
289
725
  };
290
726
  return;
291
727
  }