mixdog 0.9.19 → 0.9.21
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/LICENSE +21 -0
- package/README.md +100 -34
- package/package.json +3 -2
- package/scripts/build-tui.mjs +6 -0
- package/scripts/hook-bus-test.mjs +8 -0
- package/scripts/log-writer-guard-smoke.mjs +131 -0
- package/scripts/reactive-compact-persist-smoke.mjs +22 -6
- package/scripts/recall-bench-cases.json +0 -1
- package/scripts/recall-quality-cases.json +1 -2
- package/scripts/session-ingest-compaction-smoke.mjs +241 -0
- package/scripts/tool-smoke.mjs +150 -45
- package/src/defaults/skills/setup/SKILL.md +327 -0
- package/src/help.mjs +2 -5
- package/src/lib/mixdog-debug.cjs +13 -0
- package/src/mixdog-session-runtime.mjs +7 -3328
- package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -2
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -10
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +40 -3
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +73 -3
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +663 -0
- package/src/runtime/agent/orchestrator/session/compact/engine.mjs +6 -0
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +153 -0
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +49 -0
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -1
- package/src/runtime/agent/orchestrator/session/loop.mjs +8 -1860
- package/src/runtime/agent/orchestrator/session/manager/agent-runtime-singleton.mjs +29 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +686 -0
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -12
- package/src/runtime/agent/orchestrator/session/manager/env-utils.mjs +8 -0
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +159 -0
- package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +143 -0
- package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +268 -0
- package/src/runtime/agent/orchestrator/session/manager/provider-cache-key.mjs +22 -0
- package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +26 -0
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +124 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +258 -0
- package/src/runtime/agent/orchestrator/session/manager/session-errors.mjs +20 -0
- package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +9 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +475 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +23 -0
- package/src/runtime/agent/orchestrator/session/manager.mjs +88 -2285
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +440 -0
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +153 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +4 -1
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +642 -0
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +23 -3
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +108 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +15 -3
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +24 -2
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +72 -8
- package/src/runtime/agent/orchestrator/tools/shell-policy-danger-target.mjs +5 -1
- package/src/runtime/channels/index.mjs +6 -2183
- package/src/runtime/channels/lib/inbound-handler.mjs +328 -0
- package/src/runtime/channels/lib/interaction-handlers.mjs +260 -0
- package/src/runtime/channels/lib/network-retry.mjs +23 -0
- package/src/runtime/channels/lib/owned-runtime.mjs +627 -0
- package/src/runtime/channels/lib/runtime-paths.mjs +20 -9
- package/src/runtime/channels/lib/transcript-binding.mjs +336 -0
- package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +39 -2
- package/src/runtime/channels/lib/worker-bootstrap.mjs +97 -0
- package/src/runtime/channels/lib/worker-ipc.mjs +201 -0
- package/src/runtime/channels/lib/worker-main.mjs +777 -0
- package/src/runtime/memory/index.mjs +163 -1725
- package/src/runtime/memory/lib/embedding-provider.mjs +4 -2
- package/src/runtime/memory/lib/embedding-worker.mjs +47 -6
- package/src/runtime/memory/lib/http-router.mjs +811 -0
- package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +6 -0
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +46 -9
- package/src/runtime/memory/lib/memory-cycle2.mjs +87 -11
- package/src/runtime/memory/lib/memory-embed.mjs +28 -7
- package/src/runtime/memory/lib/memory-recall-store.mjs +4 -4
- package/src/runtime/memory/lib/memory.mjs +39 -0
- package/src/runtime/memory/lib/query-handlers.mjs +2 -2
- package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
- package/src/runtime/shared/atomic-file.mjs +138 -80
- package/src/runtime/shared/child-guardian.mjs +61 -3
- package/src/session-runtime/boot-profile.mjs +36 -0
- package/src/session-runtime/channel-config-api.mjs +70 -0
- package/src/session-runtime/context-status.mjs +181 -0
- package/src/session-runtime/env.mjs +17 -0
- package/src/session-runtime/lifecycle-api.mjs +242 -0
- package/src/session-runtime/model-route-api.mjs +198 -0
- package/src/session-runtime/provider-auth-api.mjs +135 -0
- package/src/session-runtime/resource-api.mjs +282 -0
- package/src/session-runtime/runtime-core.mjs +2104 -0
- package/src/session-runtime/session-turn-api.mjs +274 -0
- package/src/session-runtime/tool-catalog.mjs +18 -264
- package/src/session-runtime/tool-defs.mjs +2 -2
- package/src/session-runtime/workflow-agents-api.mjs +238 -0
- package/src/standalone/agent-tool.mjs +2 -2
- package/src/standalone/channel-worker.mjs +67 -7
- package/src/standalone/folder-dialog.mjs +4 -1
- package/src/standalone/memory-runtime-proxy.mjs +154 -17
- package/src/standalone/seeds.mjs +28 -1
- package/src/tui/App.jsx +40 -28
- package/src/tui/app/core-memory-picker.mjs +1 -1
- package/src/tui/app/doctor.mjs +175 -0
- package/src/tui/app/slash-commands.mjs +1 -0
- package/src/tui/app/slash-dispatch.mjs +9 -0
- package/src/tui/app/use-mouse-input.mjs +6 -0
- package/src/tui/app/use-transcript-scroll.mjs +77 -3
- package/src/tui/dist/index.mjs +2851 -2162
- package/src/tui/engine/context-state.mjs +145 -0
- package/src/tui/engine/prompt-history.mjs +27 -0
- package/src/tui/engine/session-api-ext.mjs +478 -0
- package/src/tui/engine/session-api.mjs +564 -0
- package/src/tui/engine/session-flow.mjs +485 -0
- package/src/tui/engine/turn.mjs +1078 -0
- package/src/tui/engine.mjs +68 -2620
- package/src/tui/index.jsx +7 -0
- package/vendor/ink/build/ink.js +16 -1
- package/vendor/ink/build/output.js +30 -4
- package/vendor/ink/build/render.js +5 -0
- package/src/workflows/sequential/WORKFLOW.md +0 -51
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
import { classifyResultKind } from './result-classification.mjs';
|
|
2
|
+
import { canonicalizeBuiltinToolName, executeBuiltinTool, isBuiltinTool } from '../tools/builtin.mjs';
|
|
3
|
+
import { takeApplyPatchUiDiff } from '../tools/patch.mjs';
|
|
4
|
+
import { executeInternalTool, isInternalTool } from '../internal-tools.mjs';
|
|
5
|
+
import { normalizeToolEnvelope } from './tool-envelope.mjs';
|
|
6
|
+
import { traceAgentLoop, traceAgentTool, traceAgentToolFailure, traceAgentCompact, estimateProviderPayloadBytes, messagePrefixHash, appendAgentTrace } from '../agent-trace.mjs';
|
|
7
|
+
import { resolveSessionMaxLoopIterations } from '../agent-runtime/agent-loop-policy.mjs';
|
|
8
|
+
import { isAgentOwner } from '../agent-owner.mjs';
|
|
9
|
+
import { markSessionToolCall, updateSessionStage, SessionClosedError, bumpUsageMetricsEpoch } from './manager.mjs';
|
|
10
|
+
import {
|
|
11
|
+
pruneToolOutputs,
|
|
12
|
+
pruneToolOutputsUnanchored,
|
|
13
|
+
semanticCompactMessages,
|
|
14
|
+
effectiveBudget as compactEffectiveBudget,
|
|
15
|
+
DEFAULT_COMPACT_TYPE,
|
|
16
|
+
} from './compact.mjs';
|
|
17
|
+
import { isContextOverflowError } from '../providers/retry-classifier.mjs';
|
|
18
|
+
import { stripSoftWarns } from '../tool-loop-guard.mjs';
|
|
19
|
+
import { maybeOffloadToolResult } from './tool-result-offload.mjs';
|
|
20
|
+
import { tryReadCached, setReadCached, invalidatePathForSession, markPostEdit, consumePostEditMark, clearReadDedupSession, extractTouchedPathsFromPatch, tryScopedToolCached, setScopedToolCached, clearScopedToolsForSession, clearScopedToolsForSessionPaths, invalidatePrefetchCache } from './read-dedup.mjs';
|
|
21
|
+
import { isInvalidToolArgsMarker, formatInvalidToolArgsResult } from '../providers/openai-compat-stream.mjs';
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
_stripMcpPrefix,
|
|
25
|
+
_isReadTool,
|
|
26
|
+
_isMutationTool,
|
|
27
|
+
_isScopedCacheableTool,
|
|
28
|
+
_isShellTool,
|
|
29
|
+
_intraTurnSig,
|
|
30
|
+
} from './loop/tool-classify.mjs';
|
|
31
|
+
import { preDispatchDenyForSession } from './loop/pre-dispatch-deny.mjs';
|
|
32
|
+
import { runRecallFastTrackCompact } from './loop/recall-fasttrack.mjs';
|
|
33
|
+
import { executeTool, _scopedCacheOutcomeForCall } from './loop/tool-exec.mjs';
|
|
34
|
+
|
|
35
|
+
// classifyResultKind is imported from result-classification.mjs at the top of
|
|
36
|
+
// this file; import it from there directly rather than via this module.
|
|
37
|
+
import { compressToolResult, recordToolBatch } from '../tools/result-compression.mjs';
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
import { resolve as resolvePath, isAbsolute } from 'path';
|
|
41
|
+
import {
|
|
42
|
+
estimateMessagesTokensSafe,
|
|
43
|
+
compactDiagnosticError,
|
|
44
|
+
compactByteLength,
|
|
45
|
+
compactDebugLog,
|
|
46
|
+
} from './loop/compact-debug.mjs';
|
|
47
|
+
import { mergeSteeringEntries, steeringContentText } from './loop/steering.mjs';
|
|
48
|
+
import {
|
|
49
|
+
crossTurnSignature,
|
|
50
|
+
crossTurnDedupStub,
|
|
51
|
+
ITERATION_CAP_REFUSAL_STUB,
|
|
52
|
+
} from './loop/completion-guards.mjs';
|
|
53
|
+
import { isEditProgressTool } from './loop/completion-guards.mjs';
|
|
54
|
+
import { agentContextOverflowError } from './loop/context-overflow.mjs';
|
|
55
|
+
import { positiveTokenInt } from './loop/env.mjs';
|
|
56
|
+
import { normalizeUsage, addUsage } from './loop/usage.mjs';
|
|
57
|
+
import { HIDDEN_AGENT_NAMES } from './loop/hidden-agents.mjs';
|
|
58
|
+
import {
|
|
59
|
+
resolveWorkerCompactPolicy,
|
|
60
|
+
compactionTelemetryPressureTokens,
|
|
61
|
+
compactTargetBudget,
|
|
62
|
+
shouldCompactForSession,
|
|
63
|
+
countPrunedToolOutputs,
|
|
64
|
+
rememberCompactTelemetry,
|
|
65
|
+
emitCompactEvent,
|
|
66
|
+
compactEventType,
|
|
67
|
+
} from './loop/compact-policy.mjs';
|
|
68
|
+
import {
|
|
69
|
+
isEagerDispatchable,
|
|
70
|
+
messagesArrayChanged,
|
|
71
|
+
getToolKind,
|
|
72
|
+
normalizeHookUpdatedToolOutput,
|
|
73
|
+
resolveToolResultAfterHook,
|
|
74
|
+
parseNativeToolSearchPayload,
|
|
75
|
+
buildAgentBashSessionArgs,
|
|
76
|
+
formatMissingToolApprovalUiDenial,
|
|
77
|
+
resolvePreToolAskApproval,
|
|
78
|
+
approvalGranted,
|
|
79
|
+
approvalReason,
|
|
80
|
+
} from './loop/tool-helpers.mjs';
|
|
81
|
+
import {
|
|
82
|
+
compactToolCallsForHistory,
|
|
83
|
+
restoreToolCallBodyForId,
|
|
84
|
+
} from './loop/stored-tool-args.mjs';
|
|
85
|
+
import { repairTranscriptBeforeProviderSend } from './loop/transcript-repair.mjs';
|
|
86
|
+
import { classifyTerminationReason, INCOMPLETE_STOP_REASONS } from './loop/termination.mjs';
|
|
87
|
+
import { createSteeringLadder } from './loop/steering-ladder.mjs';
|
|
88
|
+
import { runPreSendCompactPass } from './pre-send-compact.mjs';
|
|
89
|
+
import { createEagerDispatcher } from './eager-dispatch.mjs';
|
|
90
|
+
import { sendWithRecovery } from './send-with-recovery.mjs';
|
|
91
|
+
import { processToolBatch } from './tool-batch.mjs';
|
|
92
|
+
|
|
93
|
+
// Facade re-exports: these symbols moved to split modules under ./loop/ but
|
|
94
|
+
// remain part of loop.mjs's public surface (imported by scripts/tests and other
|
|
95
|
+
// runtime modules). Re-export the already-imported local bindings so every
|
|
96
|
+
// existing import path keeps working (no duplicate module binding).
|
|
97
|
+
export {
|
|
98
|
+
preDispatchDenyForSession,
|
|
99
|
+
repairTranscriptBeforeProviderSend,
|
|
100
|
+
normalizeHookUpdatedToolOutput,
|
|
101
|
+
resolveToolResultAfterHook,
|
|
102
|
+
buildAgentBashSessionArgs,
|
|
103
|
+
formatMissingToolApprovalUiDenial,
|
|
104
|
+
resolvePreToolAskApproval,
|
|
105
|
+
approvalGranted,
|
|
106
|
+
approvalReason,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// Hard iteration ceiling for every agent loop. Reset to 0 whenever the
|
|
110
|
+
// transcript is compacted (see the trim block below): a long task that keeps
|
|
111
|
+
// compacting can proceed past this count, while a tight NON-compacting loop
|
|
112
|
+
// still stops here and returns the accumulated transcript.
|
|
113
|
+
// Consecutive identical-AND-failing tool calls (same name+args, error result)
|
|
114
|
+
// tolerated across iterations before the loop refuses to re-execute and steers
|
|
115
|
+
// the model to change approach. Distinct from the hard iteration cap above:
|
|
116
|
+
// this catches tight deterministic-failure loops (e.g. a command that errors
|
|
117
|
+
// the same way every time) far earlier than 100 iterations.
|
|
118
|
+
const REPEAT_FAIL_LIMIT = 3;
|
|
119
|
+
// _scopedCacheOutcomeForCall and executeTool moved to ./loop/tool-exec.mjs
|
|
120
|
+
// (imported above).
|
|
121
|
+
/**
|
|
122
|
+
* Agent loop: send → tool_call → execute → re-send → repeat until text.
|
|
123
|
+
* sendOpts may include:
|
|
124
|
+
* - `effort` (provider-specific)
|
|
125
|
+
* - `fast` (boolean)
|
|
126
|
+
* - `sessionId` — enables runtime liveness markers (optional)
|
|
127
|
+
* - `signal` — AbortSignal; checked at each iteration boundary and after each
|
|
128
|
+
* tool. When aborted, throws SessionClosedError so the ask
|
|
129
|
+
* wrapper can propagate a clean cancellation.
|
|
130
|
+
* - `onStageChange(stage)` / `onStreamDelta()` — forwarded to provider.send for heartbeats
|
|
131
|
+
*/
|
|
132
|
+
// Stop reasons that signal the turn was cut short mid-synthesis (token cap,
|
|
133
|
+
// provider pause). Empty content + one of these reasons means the worker
|
|
134
|
+
// was not done — re-prompt instead of accepting empty as final.
|
|
135
|
+
// Covers Anthropic (pause_turn, max_tokens), OpenAI (length), Gemini
|
|
136
|
+
// (MAX_TOKENS, OTHER), and case variants.
|
|
137
|
+
export async function agentLoop(provider, messages, model, tools, onToolCall, cwd, sendOpts) {
|
|
138
|
+
let iterations = 0;
|
|
139
|
+
let toolCallsTotal = 0;
|
|
140
|
+
let lastUsage;
|
|
141
|
+
let firstTurnUsage;
|
|
142
|
+
let response;
|
|
143
|
+
let contextOverflowRetryUsed = false;
|
|
144
|
+
// Set when the hard iteration-cap break below fires. Consumed at the final
|
|
145
|
+
// return to tag terminationReason='iteration_cap' so a worker that exhausts
|
|
146
|
+
// the loop without a final answer surfaces to Lead as an explicit error
|
|
147
|
+
// instead of a silent empty "completed".
|
|
148
|
+
let terminatedByCap = false;
|
|
149
|
+
// Set when a provider context-overflow refusal triggers the in-turn
|
|
150
|
+
// reactive compact retry below; consumed by the next pre-send compact pass
|
|
151
|
+
// so its telemetry/events carry trigger:'reactive' (distinct from the
|
|
152
|
+
// proactive pre-send pressure trigger). Cleared after that pass reads it.
|
|
153
|
+
let reactiveOverflowRetryPending = false;
|
|
154
|
+
const opts = sendOpts || {};
|
|
155
|
+
const sessionId = opts.sessionId || null;
|
|
156
|
+
const signal = opts.signal || null;
|
|
157
|
+
const sessionAgent = opts.session?.agent;
|
|
158
|
+
const forcedFirstTool = opts.forcedFirstTool ?? null;
|
|
159
|
+
const forcedFirstToolDef = forcedFirstTool
|
|
160
|
+
? tools.find(tool => tool?.name === forcedFirstTool)
|
|
161
|
+
: null;
|
|
162
|
+
// Opaque providerState passthrough. The loop never inspects provider-native
|
|
163
|
+
// payloads; the originating provider owns them. Stateful Responses
|
|
164
|
+
// providers may use it for continuation anchors.
|
|
165
|
+
let providerState = opts.providerState ?? undefined;
|
|
166
|
+
const throwIfAborted = () => {
|
|
167
|
+
if (signal?.aborted) {
|
|
168
|
+
const reason = signal.reason instanceof Error ? signal.reason : null;
|
|
169
|
+
// Preserve any structured abort reason (SessionClosedError,
|
|
170
|
+
// StreamStalledAbortError, etc.). Fallback to SessionClosedError
|
|
171
|
+
// when the reason is not an Error instance.
|
|
172
|
+
if (reason) throw reason;
|
|
173
|
+
throw new SessionClosedError(sessionId || 'unknown', 'agent loop aborted');
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
const sessionRef = opts.session || null;
|
|
177
|
+
const loopUsageMetricsEpoch = () => Number(sessionRef?.usageMetricsEpoch) || 0;
|
|
178
|
+
const loopUsageMetricsTurnId = () => Number(sessionRef?.usageMetricsTurnId) || 0;
|
|
179
|
+
// Sub-agent (worker/heavy-worker/reviewer/debugger/explore/…) sessions
|
|
180
|
+
// drop mid-turn assistant preamble text outright. Only the final
|
|
181
|
+
// <final-answer> reply is consumed by Lead, so any "Now let me…" prose
|
|
182
|
+
// that precedes a tool call is pure noise — both for live surfacing AND
|
|
183
|
+
// for the agent's own history (where it re-enters context as input
|
|
184
|
+
// tokens on every later turn). Drop it at the runtime, no model-side rule:
|
|
185
|
+
// - streaming : opts.onTextDelta suppressed (token-by-token preamble)
|
|
186
|
+
// - buffered : opts.onAssistantText skipped (response.content below)
|
|
187
|
+
// - history : tool-call turn content blanked before messages.push
|
|
188
|
+
// Reasoning/thinking deltas, tool calls, and the final answer are kept.
|
|
189
|
+
const suppressMidTurnText = isAgentOwner(sessionRef);
|
|
190
|
+
if (suppressMidTurnText) opts.onTextDelta = undefined;
|
|
191
|
+
const pushToolResultMessage = (message) => {
|
|
192
|
+
messages.push(message);
|
|
193
|
+
try { opts.onToolResult?.(message); } catch {}
|
|
194
|
+
};
|
|
195
|
+
const drainSteeringIntoMessages = (stage = 'mid-turn', options = {}) => {
|
|
196
|
+
if (typeof opts.drainSteering !== 'function') return false;
|
|
197
|
+
let steerMsgs = [];
|
|
198
|
+
try { steerMsgs = opts.drainSteering(sessionId) || []; }
|
|
199
|
+
catch { steerMsgs = []; }
|
|
200
|
+
const merged = mergeSteeringEntries(steerMsgs);
|
|
201
|
+
if (!merged) return false;
|
|
202
|
+
if (typeof options.beforeAppend === 'function') {
|
|
203
|
+
try { options.beforeAppend(); } catch { /* best-effort hook */ }
|
|
204
|
+
}
|
|
205
|
+
messages.push({ role: 'user', content: merged.content });
|
|
206
|
+
try { opts.onSteerMessage?.(merged.text || steeringContentText(merged.content)); } catch {}
|
|
207
|
+
if (sessionId) {
|
|
208
|
+
try { process.stderr.write(`[steer] sess=${sessionId} injected ${stage} user message (merged=${merged.count} len=${String(merged.text || '').length})\n`); } catch {}
|
|
209
|
+
}
|
|
210
|
+
return true;
|
|
211
|
+
};
|
|
212
|
+
const pushIntermediateAssistantResponse = (resp) => {
|
|
213
|
+
if (!resp) return false;
|
|
214
|
+
const content = typeof resp.content === 'string' ? resp.content : (resp.content == null ? '' : String(resp.content));
|
|
215
|
+
const reasoningContent = typeof resp.reasoningContent === 'string' && resp.reasoningContent
|
|
216
|
+
? resp.reasoningContent
|
|
217
|
+
: '';
|
|
218
|
+
const reasoningItems = Array.isArray(resp.reasoningItems) && resp.reasoningItems.length
|
|
219
|
+
? resp.reasoningItems
|
|
220
|
+
: null;
|
|
221
|
+
if (!content && !reasoningContent && !reasoningItems) return false;
|
|
222
|
+
messages.push({
|
|
223
|
+
role: 'assistant',
|
|
224
|
+
content,
|
|
225
|
+
...(reasoningItems ? { reasoningItems } : {}),
|
|
226
|
+
...(reasoningContent ? { reasoningContent } : {}),
|
|
227
|
+
});
|
|
228
|
+
return true;
|
|
229
|
+
};
|
|
230
|
+
const maxLoopIterations = resolveSessionMaxLoopIterations(sessionRef);
|
|
231
|
+
// ---- Completion-first loop guards (worker runaway prevention) ----
|
|
232
|
+
// Step 1 (escalation ladder) + the missed-parallelism / serial-rewording
|
|
233
|
+
// steering hints live in the createSteeringLadder controller below; it owns
|
|
234
|
+
// their cumulative counters and emits at most one hint per turn.
|
|
235
|
+
// _editCount counts any executed tool call whose def lacks readOnlyHint
|
|
236
|
+
// (i.e. edit/progress: apply_patch, bash, MCP writes, skills, ...).
|
|
237
|
+
let _editCount = 0;
|
|
238
|
+
// Step 2: cross-turn identical read-only call dedup. Map keyed by
|
|
239
|
+
// signature(name + stableStringify(args)) → { count, firstIteration }.
|
|
240
|
+
// Populated only for SUCCESSFUL isEagerDispatchable (read-only) calls.
|
|
241
|
+
// Bounded to 500 entries (drop-oldest / insertion order).
|
|
242
|
+
const _crossTurnCalls = new Map();
|
|
243
|
+
const _CROSS_TURN_CAP = 500;
|
|
244
|
+
let _dedupStubTotal = 0;
|
|
245
|
+
// Hard-cap final-answer turn: one tool-less wrap-up turn granted when the
|
|
246
|
+
// hard iteration cap fires, so the session ends with text, not empty.
|
|
247
|
+
let _capFinalTurnUsed = false;
|
|
248
|
+
// True while the granted hard-cap final turn is active (no tool defs).
|
|
249
|
+
let _capFinalToolsDisabled = false;
|
|
250
|
+
// Consecutive empty-turn contract nudges. A model that answers the same
|
|
251
|
+
// nudge with another empty turn is in a deterministic livelock (same
|
|
252
|
+
// context in → same empty completion out); re-sending an identical nudge
|
|
253
|
+
// 199× just burns the iteration budget (observed: sess_10400…9dfdc436,
|
|
254
|
+
// 199 identical nudges to the 200-iteration cap). Cap the streak and end
|
|
255
|
+
// the loop as an explicit empty termination instead.
|
|
256
|
+
let _emptyNudgeStreak = 0;
|
|
257
|
+
const EMPTY_NUDGE_MAX = 3;
|
|
258
|
+
// Completion-first steering ladder controller. Owns the (cumulative) level-1
|
|
259
|
+
// fire count, the all-read-only / serial-single / same-file-grep streaks,
|
|
260
|
+
// and the level-2 latch. Threaded via live getters so it reads the loop's
|
|
261
|
+
// current `iterations` / `_editCount` on every call (no stale snapshots).
|
|
262
|
+
const _steeringLadder = createSteeringLadder({
|
|
263
|
+
sessionId,
|
|
264
|
+
sessionAgent,
|
|
265
|
+
tools,
|
|
266
|
+
getIterations: () => iterations,
|
|
267
|
+
getEditCount: () => _editCount,
|
|
268
|
+
readOnlyRole: String(sessionRef?.permission || sessionRef?.toolPermission || '') === 'read',
|
|
269
|
+
pushUserMessage: (msg) => messages.push(msg),
|
|
270
|
+
pushSystemReminder: (text) => messages.push({ role: 'user', content: `<system-reminder>\n${text}\n</system-reminder>`, meta: 'hook' }),
|
|
271
|
+
});
|
|
272
|
+
// Tool execution must use the session cwd even when the caller omitted the
|
|
273
|
+
// legacy positional cwd argument. Agent workers always carry their cwd on
|
|
274
|
+
// sessionRef; falling through to pwd()/process.cwd() resolves relatives
|
|
275
|
+
// against the host/plugin root instead of the worker workspace.
|
|
276
|
+
cwd = cwd || sessionRef?.cwd || undefined;
|
|
277
|
+
// Staged pre-cap warnings + one true hard stop. The ONLY count-based
|
|
278
|
+
// forced termination is the hard cap at maxLoopIterations (default 200):
|
|
279
|
+
// a genuine runaway guard. Before it, staged warnings fire at 50%/75%/90%
|
|
280
|
+
// of the cap steering the model to converge — warnings only, nothing is
|
|
281
|
+
// cut off early. Other runaway protection is behavior-based (steering
|
|
282
|
+
// ladder hints, REPEAT_FAIL_LIMIT), never a lower iteration count.
|
|
283
|
+
let _iterWarnStage = 0;
|
|
284
|
+
const _iterWarnAt = [
|
|
285
|
+
Math.floor(maxLoopIterations * 0.5),
|
|
286
|
+
Math.floor(maxLoopIterations * 0.75),
|
|
287
|
+
Math.floor(maxLoopIterations * 0.9),
|
|
288
|
+
];
|
|
289
|
+
while (true) {
|
|
290
|
+
throwIfAborted();
|
|
291
|
+
if (iterations >= maxLoopIterations) {
|
|
292
|
+
// Final-answer turn: instead of breaking mid-transcript (which
|
|
293
|
+
// yields an empty final for locator-style agents that never got to
|
|
294
|
+
// answer), give the model ONE tool-less text turn to wrap up, then
|
|
295
|
+
// stop (empty sendTools + refusal stubs if tools are requested).
|
|
296
|
+
if (_capFinalTurnUsed) {
|
|
297
|
+
process.stderr.write(`[loop] hard iteration cap ${maxLoopIterations} reached (sess=${sessionId || 'unknown'}); stopping loop.\n`);
|
|
298
|
+
terminatedByCap = true;
|
|
299
|
+
// The granted final turn produced no text (model kept emitting
|
|
300
|
+
// tool calls into refusal stubs, or thinking-only). Synthesize a
|
|
301
|
+
// non-empty final so callers never see an empty response.
|
|
302
|
+
if (response && !String(response.content || '').trim()) {
|
|
303
|
+
response.content = sessionAgent === 'explorer'
|
|
304
|
+
? 'EXPLORATION_FAILED'
|
|
305
|
+
: '[iteration cap reached before final text]';
|
|
306
|
+
if (Array.isArray(response.toolCalls)) response.toolCalls = [];
|
|
307
|
+
}
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
_capFinalTurnUsed = true;
|
|
311
|
+
_capFinalToolsDisabled = true;
|
|
312
|
+
messages.push({ role: 'user', content: '<system-reminder>\nIteration cap reached — tools disabled; answer with your best result from context.\n</system-reminder>', meta: 'hook' });
|
|
313
|
+
process.stderr.write(`[loop] hard iteration cap ${maxLoopIterations} reached (sess=${sessionId || 'unknown'}); forcing final text turn.\n`);
|
|
314
|
+
}
|
|
315
|
+
if (_iterWarnStage < _iterWarnAt.length && iterations >= _iterWarnAt[_iterWarnStage]) {
|
|
316
|
+
_iterWarnStage += 1;
|
|
317
|
+
const warnAt = _iterWarnAt[_iterWarnStage - 1];
|
|
318
|
+
const stageMsg = _iterWarnStage === 1
|
|
319
|
+
? `Iteration budget notice: ${warnAt} of ${maxLoopIterations} iterations used. Converge on a conclusion: prefer finishing the current objective over opening new exploration.`
|
|
320
|
+
: `Iteration budget warning (stage ${_iterWarnStage}): ${warnAt} of ${maxLoopIterations} iterations used — the loop hard-stops at ${maxLoopIterations}. Wrap up now: summarize progress, state what remains, and finish with your best current result.`;
|
|
321
|
+
messages.push({ role: 'user', content: `<system-reminder>\n${stageMsg}\n</system-reminder>`, meta: 'hook' });
|
|
322
|
+
process.stderr.write(`[loop] iteration warning stage ${_iterWarnStage} at ${iterations} (sess=${sessionId || 'unknown'}); continuing with steer.\n`);
|
|
323
|
+
try {
|
|
324
|
+
appendAgentTrace({
|
|
325
|
+
sessionId,
|
|
326
|
+
iteration: iterations,
|
|
327
|
+
kind: 'steer',
|
|
328
|
+
payload: { tag: 'iteration_warning', stage: _iterWarnStage, at: iterations, unit: maxLoopIterations },
|
|
329
|
+
agent: sessionAgent || null,
|
|
330
|
+
});
|
|
331
|
+
} catch { /* best-effort */ }
|
|
332
|
+
}
|
|
333
|
+
// Drain queued steering/prompts BEFORE the
|
|
334
|
+
// pre-send compact check. The compact decision must see the exact
|
|
335
|
+
// message set that the next provider.send would receive, including
|
|
336
|
+
// tool results plus any queued user input/notifications.
|
|
337
|
+
drainSteeringIntoMessages('pre-send');
|
|
338
|
+
({
|
|
339
|
+
iterations,
|
|
340
|
+
lastUsage,
|
|
341
|
+
firstTurnUsage,
|
|
342
|
+
providerState,
|
|
343
|
+
reactiveOverflowRetryPending,
|
|
344
|
+
} = await runPreSendCompactPass({
|
|
345
|
+
provider,
|
|
346
|
+
messages,
|
|
347
|
+
model,
|
|
348
|
+
tools,
|
|
349
|
+
sessionRef,
|
|
350
|
+
sessionId,
|
|
351
|
+
cwd,
|
|
352
|
+
opts,
|
|
353
|
+
signal,
|
|
354
|
+
iterations,
|
|
355
|
+
lastUsage,
|
|
356
|
+
firstTurnUsage,
|
|
357
|
+
providerState,
|
|
358
|
+
reactiveOverflowRetryPending,
|
|
359
|
+
loopUsageMetricsTurnId,
|
|
360
|
+
loopUsageMetricsEpoch,
|
|
361
|
+
}));
|
|
362
|
+
const nextIteration = iterations + 1;
|
|
363
|
+
opts.iteration = nextIteration;
|
|
364
|
+
opts.providerState = providerState;
|
|
365
|
+
if (forcedFirstTool && toolCallsTotal === 0) {
|
|
366
|
+
opts.toolChoice = 'required';
|
|
367
|
+
} else {
|
|
368
|
+
delete opts.toolChoice;
|
|
369
|
+
}
|
|
370
|
+
// Hard-cap final turn: send NO tool definitions so the provider can
|
|
371
|
+
// only emit text. Overrides the forced-first-tool path.
|
|
372
|
+
const sendTools = _capFinalToolsDisabled
|
|
373
|
+
? []
|
|
374
|
+
: (forcedFirstToolDef && toolCallsTotal === 0 ? [forcedFirstToolDef] : tools);
|
|
375
|
+
// Eager-dispatch queue: when the provider streams a tool-call event,
|
|
376
|
+
// start read-only tools immediately so execution overlaps with the
|
|
377
|
+
// remaining SSE parse. Writes and unknown tools wait until send()
|
|
378
|
+
// returns and run serially in the call-order loop below.
|
|
379
|
+
// Eager-dispatch queue (see ./eager-dispatch.mjs): read-only tools
|
|
380
|
+
// start the instant the provider streams a tool-call event; the
|
|
381
|
+
// dispatcher owns pending, the intra-turn sig set, and the mutation
|
|
382
|
+
// epoch, all fresh per turn.
|
|
383
|
+
const eager = createEagerDispatcher({
|
|
384
|
+
tools, cwd, sessionId, sessionRef, signal, opts,
|
|
385
|
+
crossTurnCalls: _crossTurnCalls,
|
|
386
|
+
getIterations: () => iterations,
|
|
387
|
+
getNextIteration: () => nextIteration,
|
|
388
|
+
repeatFailLimit: REPEAT_FAIL_LIMIT,
|
|
389
|
+
});
|
|
390
|
+
opts.onToolCall = eager.onToolCall;
|
|
391
|
+
// Reattach separated tool results, then drop only truly dangling
|
|
392
|
+
// assistant/orphan pairs before the provider sees the transcript.
|
|
393
|
+
repairTranscriptBeforeProviderSend(messages, sessionId);
|
|
394
|
+
// Strip soft-warn markers from prior tool results before the next
|
|
395
|
+
// send. Marker bytes (Tool-budget(xN), Same-file reads(xN), etc.)
|
|
396
|
+
// mutate every turn with dynamic counters, so leaving them in the
|
|
397
|
+
// transcript breaks server-side prefix cache lookup on later turns.
|
|
398
|
+
// The current turn's marker (if any) is appended AFTER this strip,
|
|
399
|
+
// so the model still sees the self-correct hint on its own iteration.
|
|
400
|
+
for (let _i = 0; _i < messages.length; _i++) {
|
|
401
|
+
const _m = messages[_i];
|
|
402
|
+
if (_m && _m.role === 'tool' && typeof _m.content === 'string' && _m.content.includes('⚠')) {
|
|
403
|
+
const _stripped = stripSoftWarns(_m.content);
|
|
404
|
+
if (_stripped !== _m.content) _m.content = _stripped;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
const sendStartedAt = Date.now();
|
|
408
|
+
const _sendResult = await sendWithRecovery({
|
|
409
|
+
provider, messages, model, sendTools, tools, opts,
|
|
410
|
+
sessionId, sessionRef, nextIteration, contextOverflowRetryUsed,
|
|
411
|
+
});
|
|
412
|
+
if (_sendResult.action === 'retry') {
|
|
413
|
+
contextOverflowRetryUsed = true;
|
|
414
|
+
reactiveOverflowRetryPending = true;
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
response = _sendResult.response;
|
|
418
|
+
opts.onToolCall = undefined;
|
|
419
|
+
contextOverflowRetryUsed = false;
|
|
420
|
+
// Capture opaque state for the next turn (may be undefined — that's
|
|
421
|
+
// the stateless contract for providers that don't use continuation).
|
|
422
|
+
providerState = response?.providerState ?? undefined;
|
|
423
|
+
iterations = nextIteration;
|
|
424
|
+
// Payload byte estimate serializes the FULL messages+tools array —
|
|
425
|
+
// only pay that cost when verbose loop tracing is actually enabled
|
|
426
|
+
// (traceAgentLoop is a no-op otherwise).
|
|
427
|
+
if (process.env.MIXDOG_AGENT_TRACE_VERBOSE === '1') {
|
|
428
|
+
traceAgentLoop({
|
|
429
|
+
sessionId,
|
|
430
|
+
iteration: iterations,
|
|
431
|
+
sendMs: Date.now() - sendStartedAt,
|
|
432
|
+
messageCount: Array.isArray(messages) ? messages.length : 0,
|
|
433
|
+
bodyBytesEst: estimateProviderPayloadBytes(messages, model, sendTools),
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
// Accumulate usage across iterations — every billable slot, not just
|
|
437
|
+
// input/output. Anthropic cache_read/cache_write typically stay 0 on
|
|
438
|
+
// the first iteration and surge on later ones (warm prefix reuse),
|
|
439
|
+
// so aggregating only the head would silently drop most of the
|
|
440
|
+
// cache-side tokens.
|
|
441
|
+
if (response.usage) {
|
|
442
|
+
const hadUsage = !!lastUsage;
|
|
443
|
+
lastUsage = addUsage(lastUsage, response.usage);
|
|
444
|
+
if (!hadUsage) {
|
|
445
|
+
// Snapshot the first turn separately so callers can show
|
|
446
|
+
// iter1 vs final cache-hit ratios — first iter is the
|
|
447
|
+
// warm-prefix signal, final iter is the steady-state
|
|
448
|
+
// efficiency signal after tool-result accumulation.
|
|
449
|
+
firstTurnUsage = { ...lastUsage };
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
// Provider may have returned despite an abort (SDKs that don't honour
|
|
453
|
+
// signal) — bail before processing any of its output.
|
|
454
|
+
throwIfAborted();
|
|
455
|
+
// P1 audit fix (Step4): a text-only turn truncated by the provider's
|
|
456
|
+
// max-output limit (response.truncated, set by the provider layer
|
|
457
|
+
// when stopReason==='length' AND content is non-empty) used to look
|
|
458
|
+
// identical to a clean completion — the model's answer could be
|
|
459
|
+
// silently cut mid-sentence with zero signal to the operator. Surface
|
|
460
|
+
// it as a one-line stderr warning + trace event WITHOUT failing the
|
|
461
|
+
// turn (the partial content is still usable and the loop's own
|
|
462
|
+
// isIncompleteStop nudge below already re-prompts when content is
|
|
463
|
+
// empty).
|
|
464
|
+
if (response?.truncated === true) {
|
|
465
|
+
try {
|
|
466
|
+
process.stderr.write(
|
|
467
|
+
`[loop] provider output truncated at max-output limit (sess=${sessionId || 'unknown'} `
|
|
468
|
+
+ `iter=${iterations} stopReason=${response.stopReason ?? response.stop_reason ?? 'length'} `
|
|
469
|
+
+ `contentLen=${typeof response.content === 'string' ? response.content.length : 0}); `
|
|
470
|
+
+ `answer may be cut off mid-sentence.\n`,
|
|
471
|
+
);
|
|
472
|
+
} catch { /* best-effort */ }
|
|
473
|
+
try {
|
|
474
|
+
appendAgentTrace({
|
|
475
|
+
sessionId,
|
|
476
|
+
iteration: iterations,
|
|
477
|
+
kind: 'output_truncated',
|
|
478
|
+
payload: {
|
|
479
|
+
stop_reason: response.stopReason ?? response.stop_reason ?? 'length',
|
|
480
|
+
content_len: typeof response.content === 'string' ? response.content.length : 0,
|
|
481
|
+
agent: sessionAgent || null,
|
|
482
|
+
},
|
|
483
|
+
});
|
|
484
|
+
} catch { /* best-effort */ }
|
|
485
|
+
}
|
|
486
|
+
// Incremental metric persistence (fix A): push per-iteration token delta
|
|
487
|
+
// immediately so watchdog / agent type=list sees live totals mid-turn.
|
|
488
|
+
if (sessionId && opts.onUsageDelta && response.usage) {
|
|
489
|
+
try {
|
|
490
|
+
opts.onUsageDelta({
|
|
491
|
+
sessionId,
|
|
492
|
+
iterationIndex: iterations,
|
|
493
|
+
usageMetricsTurnId: loopUsageMetricsTurnId(),
|
|
494
|
+
source: 'provider_send',
|
|
495
|
+
usageMetricsEpoch: loopUsageMetricsEpoch(),
|
|
496
|
+
deltaInput: response.usage.inputTokens || 0,
|
|
497
|
+
deltaOutput: response.usage.outputTokens || 0,
|
|
498
|
+
deltaPrompt: response.usage.promptTokens || 0,
|
|
499
|
+
// Cache delta carried alongside input/output so live metrics
|
|
500
|
+
// reflect the same token classes the terminal aggregate adds;
|
|
501
|
+
// additive — callers that ignore these fields keep working.
|
|
502
|
+
deltaCachedRead: response.usage.cachedTokens || 0,
|
|
503
|
+
deltaCacheWrite: response.usage.cacheWriteTokens || 0,
|
|
504
|
+
ts: Date.now(),
|
|
505
|
+
});
|
|
506
|
+
} catch { /* best-effort — never break the loop */ }
|
|
507
|
+
}
|
|
508
|
+
// No tool calls. For PUBLIC agents, the agent contract
|
|
509
|
+
// (rules/agent/00-core.md) requires either a tool call or a final
|
|
510
|
+
// handoff text (fragments).
|
|
511
|
+
// A text-only turn without those tags violates the contract (e.g.
|
|
512
|
+
// Opus 4.6 emits 'Now I'll polish…' preamble before its first tool
|
|
513
|
+
// call) and used to leave the session idle until the idle sweep
|
|
514
|
+
// collected it. Re-prompt the worker with a contract reminder on each
|
|
515
|
+
// empty turn (hard iteration cap bounds total turns). Hidden roles
|
|
516
|
+
// (cycle1-agent / cycle2-agent / explorer /
|
|
517
|
+
// scheduler-task / webhook-handler) are exempt:
|
|
518
|
+
// their own role rules define a different output contract (pipe-
|
|
519
|
+
// separated chunker output, structured pipe-format, etc.) and a
|
|
520
|
+
// text-only terminal turn is the correct shape — nudging them
|
|
521
|
+
// produces a contradictory user message that traps the model in a
|
|
522
|
+
// tool-call-blocked vs contract-required oscillation.
|
|
523
|
+
if (!response.toolCalls?.length) {
|
|
524
|
+
// No tool calls. Decide between final-answer accept vs nudge.
|
|
525
|
+
// Reviewer fix: a zero-tool turn (final-pre-send steering drain or
|
|
526
|
+
// contract nudge `continue`) must not bridge the all-read-only
|
|
527
|
+
// streak across non-tool turns — that would fire level-2 early on
|
|
528
|
+
// a worker that paused to synthesize text mid-run.
|
|
529
|
+
_steeringLadder.resetAllReadOnlyStreak();
|
|
530
|
+
// - has content + non-hidden role → valid final, break.
|
|
531
|
+
// - empty content + hidden role → contract allows text-only
|
|
532
|
+
// terminal turn, break.
|
|
533
|
+
// - empty content + non-hidden role → contract nudge, continue.
|
|
534
|
+
const hasContent = typeof response.content === 'string' && response.content.trim().length > 0;
|
|
535
|
+
const isHidden = HIDDEN_AGENT_NAMES.has(sessionAgent);
|
|
536
|
+
const stopReason = response.stopReason ?? response.stop_reason ?? null;
|
|
537
|
+
const isIncompleteStop = stopReason && INCOMPLETE_STOP_REASONS.has(stopReason);
|
|
538
|
+
// A user/schedule notification can arrive while provider.send() is
|
|
539
|
+
// returning a terminal no-tool response. Drain once before accepting
|
|
540
|
+
// it as final so the queued input is handled in the same active turn
|
|
541
|
+
// instead of waiting for post-turn TUI drain. If the model already
|
|
542
|
+
// produced assistant text, persist that as an intermediate assistant
|
|
543
|
+
// message before appending the steered user message.
|
|
544
|
+
if (drainSteeringIntoMessages('final-pre-send', {
|
|
545
|
+
beforeAppend: () => pushIntermediateAssistantResponse(response),
|
|
546
|
+
})) {
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
if (!hasContent && !isHidden) {
|
|
550
|
+
_emptyNudgeStreak += 1;
|
|
551
|
+
if (_emptyNudgeStreak > EMPTY_NUDGE_MAX) {
|
|
552
|
+
// Livelock: identical nudges keep producing identical empty
|
|
553
|
+
// completions. Stop re-prompting; classifyTerminationReason
|
|
554
|
+
// tags this final empty response as 'empty' so the caller
|
|
555
|
+
// surfaces an explicit error instead of a silent finish.
|
|
556
|
+
process.stderr.write(`[loop] empty-turn nudge cap ${EMPTY_NUDGE_MAX} reached (sess=${sessionId || 'unknown'}); ending loop as empty termination.\n`);
|
|
557
|
+
break;
|
|
558
|
+
}
|
|
559
|
+
let nudgeMsg;
|
|
560
|
+
if (isIncompleteStop) {
|
|
561
|
+
nudgeMsg = `[mixdog-runtime] Previous turn ended mid-synthesis (stopReason=${stopReason}) with empty content. Continue — emit your final handoff (fragments, file:line) with your synthesis so far, or call more tools to finish.`;
|
|
562
|
+
} else {
|
|
563
|
+
// Vary the nudge per attempt — a byte-identical repeat
|
|
564
|
+
// reinforces the empty-completion pattern it is trying to
|
|
565
|
+
// break (the request context stays effectively constant).
|
|
566
|
+
nudgeMsg = `[mixdog-runtime] Your previous response was empty (no handoff text and no tool call) — attempt ${_emptyNudgeStreak}/${EMPTY_NUDGE_MAX}. Either emit your final handoff text now, or continue with tool calls. Do not return an empty turn.`;
|
|
567
|
+
}
|
|
568
|
+
messages.push({ role: 'user', content: nudgeMsg });
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
break;
|
|
572
|
+
}
|
|
573
|
+
_emptyNudgeStreak = 0;
|
|
574
|
+
const calls = response.toolCalls;
|
|
575
|
+
toolCallsTotal += calls.length;
|
|
576
|
+
// Surface any mid-turn assistant text (preamble that precedes a tool
|
|
577
|
+
// call) to the UI. Providers that stream text via onTextDelta already
|
|
578
|
+
// rendered it; providers that return the text only in response.content
|
|
579
|
+
// (no deltas) would otherwise show nothing before the tool card. The
|
|
580
|
+
// engine de-dups against already-streamed text, so emitting here is
|
|
581
|
+
// safe for both paths. Sub-agent sessions suppress it entirely
|
|
582
|
+
// (suppressMidTurnText) — Lead only consumes the final answer.
|
|
583
|
+
if (!suppressMidTurnText && typeof response.content === 'string' && response.content.trim()) {
|
|
584
|
+
try { opts.onAssistantText?.(response.content); } catch { /* best-effort */ }
|
|
585
|
+
}
|
|
586
|
+
// Per-turn batch shape — one row per assistant turn so trace
|
|
587
|
+
// consumers can derive multi-tool adoption ratio without scanning
|
|
588
|
+
// every assistant message body.
|
|
589
|
+
recordToolBatch(sessionId, calls.length);
|
|
590
|
+
await Promise.resolve(onToolCall?.(iterations, calls));
|
|
591
|
+
// Append assistant message with tool calls. reasoningItems is the
|
|
592
|
+
// OpenAI Responses API replay payload (encrypted_content blobs);
|
|
593
|
+
// providers that ignore it just see an extra field and drop it,
|
|
594
|
+
// openai-oauth.convertMessagesToResponsesInput emits matching
|
|
595
|
+
// type:'reasoning' input items on the next turn to keep the openai-oauth
|
|
596
|
+
// server-side cache prefix stable.
|
|
597
|
+
const _assistantTurnMsg = {
|
|
598
|
+
role: 'assistant',
|
|
599
|
+
// Sub-agent tool-call turns carry only mid-turn preamble in
|
|
600
|
+
// response.content (the real result rides the later final-answer
|
|
601
|
+
// turn). Blank it so it never accumulates as input tokens.
|
|
602
|
+
content: suppressMidTurnText ? '' : (response.content || ''),
|
|
603
|
+
toolCalls: compactToolCallsForHistory(calls),
|
|
604
|
+
// Anthropic adaptive thinking: prior-turn thinking blocks must be
|
|
605
|
+
// returned verbatim (signature intact; empty thinking allowed) and
|
|
606
|
+
// are REQUIRED back before tool_use blocks on tool-continuation
|
|
607
|
+
// turns. Store them so toAnthropicMessages can build assistantBlocks
|
|
608
|
+
// = [...thinking, tool_use...]. Other providers ignore this field.
|
|
609
|
+
...(Array.isArray(response.thinkingBlocks) && response.thinkingBlocks.length
|
|
610
|
+
? { thinkingBlocks: response.thinkingBlocks }
|
|
611
|
+
: {}),
|
|
612
|
+
...(Array.isArray(response.reasoningItems) && response.reasoningItems.length
|
|
613
|
+
? { reasoningItems: response.reasoningItems }
|
|
614
|
+
: {}),
|
|
615
|
+
...(typeof response.reasoningContent === 'string' && response.reasoningContent
|
|
616
|
+
? { reasoningContent: response.reasoningContent }
|
|
617
|
+
: {}),
|
|
618
|
+
};
|
|
619
|
+
messages.push(_assistantTurnMsg);
|
|
620
|
+
// Hard-cap final turn: tools are disabled but the model still emitted
|
|
621
|
+
// tool calls. Do NOT execute them — push a refusal stub for each.
|
|
622
|
+
if (_capFinalToolsDisabled) {
|
|
623
|
+
for (const _c of calls) {
|
|
624
|
+
pushToolResultMessage({
|
|
625
|
+
role: 'tool',
|
|
626
|
+
content: ITERATION_CAP_REFUSAL_STUB,
|
|
627
|
+
toolCallId: _c.id,
|
|
628
|
+
toolKind: 'error',
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
if (sessionId) updateSessionStage(sessionId, 'connecting');
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
({ dedupStubTotal: _dedupStubTotal, editCount: _editCount } = await processToolBatch({
|
|
635
|
+
calls, messages, tools, cwd, sessionId, sessionRef, signal, opts,
|
|
636
|
+
iterations, assistantTurnMsg: _assistantTurnMsg,
|
|
637
|
+
pending: eager.pending, epoch: eager.epoch, startEagerRun: eager.startEagerRun,
|
|
638
|
+
crossTurnCalls: _crossTurnCalls, crossTurnCap: _CROSS_TURN_CAP,
|
|
639
|
+
dedupStubTotal: _dedupStubTotal, editCount: _editCount,
|
|
640
|
+
sessionAgent, steeringLadder: _steeringLadder,
|
|
641
|
+
pushToolResultMessage, throwIfAborted,
|
|
642
|
+
repeatFailLimit: REPEAT_FAIL_LIMIT,
|
|
643
|
+
}));
|
|
644
|
+
}
|
|
645
|
+
// Classify WHY the loop ended so agent-tool can promote an empty/abnormal
|
|
646
|
+
// finish to an explicit Lead-facing error instead of a silent empty
|
|
647
|
+
// "completed" (see classifyTerminationReason in ./loop/termination.mjs).
|
|
648
|
+
const terminationReason = classifyTerminationReason(response, {
|
|
649
|
+
terminatedByCap,
|
|
650
|
+
sessionAgent,
|
|
651
|
+
});
|
|
652
|
+
return {
|
|
653
|
+
...response,
|
|
654
|
+
usage: lastUsage || response.usage,
|
|
655
|
+
lastTurnUsage: response.usage,
|
|
656
|
+
firstTurnUsage: firstTurnUsage || response.usage,
|
|
657
|
+
iterations,
|
|
658
|
+
toolCallsTotal,
|
|
659
|
+
providerState,
|
|
660
|
+
terminationReason,
|
|
661
|
+
maxLoopIterations,
|
|
662
|
+
};
|
|
663
|
+
}
|