mixdog 0.9.86 → 0.9.88
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +14 -8
- package/scripts/tool-failures.mjs +60 -24
- package/src/agents/heavy-worker/AGENT.md +3 -4
- package/src/agents/worker/AGENT.md +1 -2
- package/src/cli.mjs +8 -0
- package/src/defaults/cycle3-review-prompt.md +4 -4
- package/src/rules/agent/00-core.md +3 -0
- package/src/rules/agent/42-cycle3-agent.md +5 -6
- package/src/rules/lead/01-general.md +2 -0
- package/src/rules/shared/01-tool.md +5 -1
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +3 -51
- package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +59 -0
- package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +2 -2
- package/src/runtime/agent/orchestrator/agent-runtime/title-completion.mjs +67 -0
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +59 -4
- package/src/runtime/agent/orchestrator/agent-trace-io.mjs +2 -3
- package/src/runtime/agent/orchestrator/config.mjs +29 -11
- package/src/runtime/agent/orchestrator/context/collect.mjs +1 -1
- package/src/runtime/agent/orchestrator/internal-agents.mjs +1 -1
- package/src/runtime/agent/orchestrator/mcp/client.mjs +0 -24
- package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +3 -3
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +25 -17
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +321 -22
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +35 -22
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +28 -23
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +10 -2
- package/src/runtime/agent/orchestrator/providers/grok-oauth-login.mjs +0 -1
- package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +0 -1
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +2 -5
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-native-blocks.mjs +27 -0
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +23 -0
- package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +329 -0
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +147 -16
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +107 -20
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +4 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +124 -12
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +19 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +110 -75
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +96 -113
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +144 -10
- package/src/runtime/agent/orchestrator/session/lifecycle-scan.mjs +147 -117
- package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +88 -0
- package/src/runtime/agent/orchestrator/session/loop/termination.mjs +25 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +47 -6
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +485 -24
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +100 -2
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +28 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +80 -14
- package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +142 -2
- package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +128 -32
- package/src/runtime/agent/orchestrator/session/manager.mjs +8 -1
- package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +77 -4
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +70 -39
- package/src/runtime/agent/orchestrator/session/store/fs-probe.mjs +91 -0
- package/src/runtime/agent/orchestrator/session/store/listing.mjs +141 -64
- package/src/runtime/agent/orchestrator/session/store/live-state.mjs +253 -0
- package/src/runtime/agent/orchestrator/session/store/load-cache.mjs +211 -28
- package/src/runtime/agent/orchestrator/session/store/save-fault.mjs +313 -0
- package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +622 -53
- package/src/runtime/agent/orchestrator/session/store/serialize.mjs +43 -10
- package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +53 -8
- package/src/runtime/agent/orchestrator/session/store/summary-rebuild-worker.mjs +20 -0
- package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +113 -9
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +476 -52
- package/src/runtime/agent/orchestrator/session/store.mjs +721 -196
- package/src/runtime/agent/orchestrator/session/token-native.mjs +2 -4
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +19 -0
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +4 -6
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +9 -2
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +146 -90
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +6 -0
- package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +93 -17
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +7 -7
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +2 -0
- package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +52 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +54 -51
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -15
- package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +8 -0
- package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +132 -8
- package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +67 -3
- package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +6 -1
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +203 -38
- package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +13 -1
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +73 -19
- package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +12 -8
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +12 -2
- package/src/runtime/agent/orchestrator/tools/token-manifest.json +23 -2
- package/src/runtime/media/lanes.mjs +2 -2
- package/src/runtime/media/renditions.mjs +101 -17
- package/src/runtime/media/renditions.test.mjs +54 -0
- package/src/runtime/media/store.mjs +111 -43
- package/src/runtime/media/store.test.mjs +53 -11
- package/src/runtime/memory/index.mjs +26 -50
- package/src/runtime/memory/lib/core-memory-candidates.mjs +2 -7
- package/src/runtime/memory/lib/core-memory-store.mjs +2 -9
- package/src/runtime/memory/lib/cycle-scheduler.mjs +10 -0
- package/src/runtime/memory/lib/embedding-provider.mjs +3 -3
- package/src/runtime/memory/lib/embedding-worker.mjs +60 -15
- package/src/runtime/memory/lib/http-router.mjs +4 -0
- package/src/runtime/memory/lib/ko-morph.mjs +49 -6
- package/src/runtime/memory/lib/memory-action-handlers.mjs +25 -19
- package/src/runtime/memory/lib/memory-cycle2.mjs +3 -3
- package/src/runtime/memory/lib/memory-cycle3.mjs +1 -3
- package/src/runtime/memory/lib/memory-recall-store.mjs +24 -0
- package/src/runtime/memory/lib/query-handlers.mjs +26 -28
- package/src/runtime/memory/tool-defs.mjs +22 -6
- package/src/runtime/shared/atomic-file.mjs +5 -1
- package/src/runtime/shared/child-guardian.mjs +52 -2
- package/src/runtime/shared/provider-api-key.mjs +22 -5
- package/src/runtime/shared/tool-execution-contract.mjs +53 -0
- package/src/session-runtime/config-helpers.mjs +6 -5
- package/src/session-runtime/lifecycle-api.mjs +27 -0
- package/src/session-runtime/media-api.mjs +31 -16
- package/src/session-runtime/prewarm.mjs +2 -24
- package/src/session-runtime/provider-auth-api.mjs +8 -0
- package/src/session-runtime/provider-usage.mjs +30 -5
- package/src/session-runtime/runtime-core.mjs +298 -494
- package/src/session-runtime/runtime-tunables.mjs +0 -3
- package/src/session-runtime/session-lifecycle.mjs +372 -0
- package/src/session-runtime/session-title.mjs +228 -0
- package/src/session-runtime/session-turn-api.mjs +11 -7
- package/src/session-runtime/workflow-agents-api.mjs +16 -0
- package/src/session-runtime/workflow.mjs +7 -5
- package/src/standalone/agent-tool/job-views.mjs +383 -0
- package/src/standalone/agent-tool/spawn-flow.mjs +438 -0
- package/src/standalone/agent-tool/tag-registry.mjs +340 -0
- package/src/standalone/agent-tool/worker-index.mjs +8 -0
- package/src/standalone/agent-tool/worker-rows.mjs +2 -1
- package/src/standalone/agent-tool.mjs +115 -1166
- package/src/standalone/agent-watchdog-registry.mjs +19 -2
- package/src/standalone/channel-daemon.mjs +8 -0
- package/src/standalone/memory-runtime-proxy.mjs +4 -7
- package/src/standalone/projects.mjs +4 -1
- package/src/standalone/usage-dashboard.mjs +47 -3
- package/src/tui/App.jsx +367 -2579
- package/src/tui/app/app-view.jsx +504 -0
- package/src/tui/app/create-app-pickers.mjs +313 -0
- package/src/tui/app/prompt-submit.mjs +501 -0
- package/src/tui/app/resume-picker.mjs +7 -2
- package/src/tui/app/shell-layout.mjs +563 -0
- package/src/tui/app/slash-dispatch.mjs +13 -3
- package/src/tui/app/transcript-row-estimate.mjs +1 -1
- package/src/tui/app/transcript-window.mjs +17 -93
- package/src/tui/app/usage-context-panels.mjs +288 -0
- package/src/tui/app/use-copy-selection.mjs +56 -0
- package/src/tui/app/use-global-key-input.mjs +151 -0
- package/src/tui/app/use-pasted-buffers.mjs +115 -0
- package/src/tui/app/use-prompt-draft-flow.mjs +167 -0
- package/src/tui/app/use-prompt-hint.mjs +58 -0
- package/src/tui/app/use-prompt-queue-history.mjs +100 -0
- package/src/tui/app/use-terminal-chrome.mjs +72 -0
- package/src/tui/app/use-transcript-activity.mjs +131 -0
- package/src/tui/app/use-transcript-scroll.mjs +32 -2
- package/src/tui/app/use-transcript-window.mjs +64 -114
- package/src/tui/app/use-welcome-prompt-hint.mjs +96 -0
- package/src/tui/components/Markdown.jsx +19 -2
- package/src/tui/components/Message.jsx +8 -3
- package/src/tui/components/ToolExecution.jsx +7 -3
- package/src/tui/dist/index.mjs +9889 -8999
- package/src/tui/engine/live-share.mjs +15 -5
- package/src/tui/engine/render-timing.mjs +2 -2
- package/src/tui/engine/session-api-ext.mjs +97 -4
- package/src/tui/engine/session-flow.mjs +3 -1
- package/src/tui/engine.mjs +2 -2
- package/src/tui/hooks/useSharedTick.mjs +0 -2
- package/src/tui/index.jsx +5 -5
- package/src/ui/statusline-agents.mjs +44 -11
- package/src/vendor/statusline/bin/statusline-route.mjs +26 -3
- package/src/workflows/default/WORKFLOW.md +3 -3
- package/src/workflows/solo/WORKFLOW.md +1 -1
- package/src/runtime/media/index.mjs +0 -18
- package/src/runtime/memory/lib/embedding-warmup.mjs +0 -68
- package/src/standalone/agent-shard/shard-child.mjs +0 -300
- package/src/standalone/agent-shard/shard-pool.mjs +0 -442
|
@@ -4,17 +4,17 @@
|
|
|
4
4
|
* Single source of truth across every provider (openai-oauth-ws, openai-oauth,
|
|
5
5
|
* anthropic-oauth, anthropic, gemini, openai-ws, openai-compat).
|
|
6
6
|
*
|
|
7
|
-
* Goal: when a provider returns a transient server-side condition we
|
|
8
|
-
* retry; when it returns a deterministic refusal (auth, permission,
|
|
9
|
-
* we should fail fast.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
7
|
+
* Goal: when a provider returns a TYPED transient server-side condition we
|
|
8
|
+
* should retry; when it returns a deterministic refusal (auth, permission,
|
|
9
|
+
* quota) we should fail fast. Evidence is structural only — HTTP status,
|
|
10
|
+
* Node errno, SDK error type, WS close code, or a typed field on a wire event.
|
|
11
|
+
* Error MESSAGE TEXT is never parsed into a status/transience/auth verdict:
|
|
12
|
+
* an untyped failure stays 'unknown' and is surfaced, not retried.
|
|
13
13
|
*
|
|
14
14
|
* Usage:
|
|
15
|
-
* import { classifyError,
|
|
15
|
+
* import { classifyError, typedStatusFrom } from './retry-classifier.mjs'
|
|
16
16
|
* const kind = classifyError(err) // 'auth' | 'permanent' | 'transient' | 'unknown'
|
|
17
|
-
*
|
|
17
|
+
* typedStatusFrom(event.response?.error, event) // structured status, or 0
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import {
|
|
@@ -24,6 +24,9 @@ import {
|
|
|
24
24
|
PROVIDER_RETRY_MAX_ATTEMPTS,
|
|
25
25
|
createTimeoutSignal,
|
|
26
26
|
} from '../stall-policy.mjs'
|
|
27
|
+
import { readStreamOutcome } from './lib/stream-outcome.mjs'
|
|
28
|
+
|
|
29
|
+
export { readStreamOutcome, stampStreamOutcome, isReplaySafe, isReplayUnsafe, canPromoteToSuccess, hasObservedOutput, hasDispatchedToolCalls, STREAM_TRANSPORTS } from './lib/stream-outcome.mjs'
|
|
27
30
|
|
|
28
31
|
// HTTP statuses considered transient — safe to retry with backoff.
|
|
29
32
|
// 408 — request timeout
|
|
@@ -39,47 +42,23 @@ const TRANSIENT_STATUSES = new Set([408, 409])
|
|
|
39
42
|
const AUTH_STATUSES = new Set([401, 403])
|
|
40
43
|
const PERMANENT_STATUSES = new Set([400, 404, 405, 410, 415, 422])
|
|
41
44
|
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
// message text plus a request ID, with no HTTP status on the WS event.
|
|
48
|
-
{ regex: /(?:an error occurred while processing your request|please include the request id)/i, status: 503 },
|
|
49
|
-
// Overload / transient 5xx — server is asking us to back off. The `\b`
|
|
50
|
-
// anchor is intentionally OMITTED on the trailing side of `overload` so
|
|
51
|
-
// "overloaded" / "overloading" both match (inflected forms are common in
|
|
52
|
-
// server error text).
|
|
53
|
-
{ regex: /(?:overload(?:ed|ing)?|temporarily unavailable|try again later|service unavailable|bad gateway|gateway timeout)/i, status: 503 },
|
|
54
|
-
// Explicit 5xx mention.
|
|
55
|
-
{ regex: /\b(?:5\d\d|http 5\d\d)\b/i, status: 503 },
|
|
56
|
-
// Rate limit / quota — same retry posture as 429 but treat as permanent
|
|
57
|
-
// by classifyError because per-call retry won't change the answer (the
|
|
58
|
-
// window must elapse). Providers that want time-bounded retry should
|
|
59
|
-
// honor Retry-After on the original response, not loop here.
|
|
60
|
-
{ regex: /(?:rate[_ -]?limit|quota|too many requests|resource exhausted|insufficient_quota|quota_exceeded)/i, status: 429 },
|
|
61
|
-
// Auth — never retryable from our side.
|
|
62
|
-
{ regex: /\b(?:unauthorized|unauthorised|authentication|not authenticated|token expired|access token|invalid api key)\b/i, status: 401 },
|
|
63
|
-
{ regex: /\b(?:forbidden|permission denied|policy violation)\b/i, status: 403 },
|
|
64
|
-
]
|
|
45
|
+
// Structured status fields a provider error / wire event may carry. A value is
|
|
46
|
+
// accepted only when it is a real numeric HTTP status; string codes
|
|
47
|
+
// ('server_error', 'forbidden', ...) and free text are ignored, so nothing is
|
|
48
|
+
// ever synthesized from a message.
|
|
49
|
+
const TYPED_STATUS_KEYS = ['httpStatus', 'http_status', 'status', 'statusCode', 'status_code', 'code']
|
|
65
50
|
|
|
66
51
|
/**
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
* Returns the resolved httpStatus (existing or newly assigned), or 0 when
|
|
72
|
-
* nothing matched.
|
|
52
|
+
* Read the first TYPED HTTP status carried by any of `sources` (error object,
|
|
53
|
+
* wire event, event.response.error payload, ...). Returns 0 when none of them
|
|
54
|
+
* declares one. Never inspects message text.
|
|
73
55
|
*/
|
|
74
|
-
export function
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (regex.test(text)) {
|
|
81
|
-
err.httpStatus = status
|
|
82
|
-
return status
|
|
56
|
+
export function typedStatusFrom(...sources) {
|
|
57
|
+
for (const source of sources) {
|
|
58
|
+
if (!source || typeof source !== 'object') continue
|
|
59
|
+
for (const key of TYPED_STATUS_KEYS) {
|
|
60
|
+
const n = Number(source[key])
|
|
61
|
+
if (Number.isFinite(n) && n >= 100 && n <= 599) return Math.floor(n)
|
|
83
62
|
}
|
|
84
63
|
}
|
|
85
64
|
return 0
|
|
@@ -98,12 +77,13 @@ export function populateHttpStatusFromMessage(err, msg = null) {
|
|
|
98
77
|
*/
|
|
99
78
|
export function classifyError(err) {
|
|
100
79
|
if (!err) return 'unknown'
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
80
|
+
// Canonical stream-outcome contract owns replay safety: exposed
|
|
81
|
+
// text/reasoning or a dispatched/ambiguous complete tool call makes
|
|
82
|
+
// re-issuing the same turn unsafe (double side effect / output
|
|
83
|
+
// concatenation) → 'permanent'. Everything else stays classifiable by the
|
|
84
|
+
// typed rules below; an unknown/untyped failure ends as 'unknown', never a
|
|
85
|
+
// blanket retry.
|
|
86
|
+
if (readStreamOutcome(err).replayUnsafe === true) return 'permanent'
|
|
107
87
|
// Cancellation is a caller decision, never a transport symptom. Anthropic's
|
|
108
88
|
// APIUserAbortError inherits Error without overriding `name`, so recognize
|
|
109
89
|
// only exact SDK constructor/type markers (plus standard AbortError markers)
|
|
@@ -112,7 +92,7 @@ export function classifyError(err) {
|
|
|
112
92
|
if (chain.some(isExplicitUserAbortError)) return 'permanent'
|
|
113
93
|
|
|
114
94
|
// Current typed HTTP status outranks stale stream/connection annotations.
|
|
115
|
-
const status = Number(err.httpStatus || err.status || err.response?.status || 0) ||
|
|
95
|
+
const status = Number(err.httpStatus || err.status || err.response?.status || 0) || 0
|
|
116
96
|
if (AUTH_STATUSES.has(status)) return 'auth'
|
|
117
97
|
if (status === 429) return 'permanent'
|
|
118
98
|
if (PERMANENT_STATUSES.has(status)
|
|
@@ -129,14 +109,10 @@ export function classifyError(err) {
|
|
|
129
109
|
// transient: we can retry the same request and may succeed.
|
|
130
110
|
if (chain.some((item) => TRANSIENT_ERROR_CODES.has(String(item?.code || '')))) return 'transient'
|
|
131
111
|
// The Anthropic SDK uses APIConnectionError for transport failures which
|
|
132
|
-
// may not carry a Node errno. Native fetch
|
|
133
|
-
//
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
if (name === 'APIConnectionError'
|
|
137
|
-
|| (name === 'TypeError' && /fetch failed|network error/i.test(message))) {
|
|
138
|
-
return 'transient'
|
|
139
|
-
}
|
|
112
|
+
// may not carry a Node errno. Native fetch wraps its errno in cause.code,
|
|
113
|
+
// which the bounded chain check above already covers — a bare
|
|
114
|
+
// TypeError('fetch failed') with no errno stays 'unknown'.
|
|
115
|
+
if (String(err.name || '') === 'APIConnectionError') return 'transient'
|
|
140
116
|
|
|
141
117
|
return 'unknown'
|
|
142
118
|
}
|
|
@@ -261,8 +237,6 @@ function isPermanentQuotaError(err) {
|
|
|
261
237
|
for (const item of boundedCauseChain(err)) {
|
|
262
238
|
const codes = [item?.code, item?.error?.code]
|
|
263
239
|
if (codes.some((code) => permanentCodes.has(String(code || '').toLowerCase()))) return true
|
|
264
|
-
const text = `${String(item?.message || '')} ${String(item?.error?.message || '')}`
|
|
265
|
-
if (/insufficient_quota|quota[_ -]?exceeded|resource exhausted/i.test(text)) return true
|
|
266
240
|
}
|
|
267
241
|
return false
|
|
268
242
|
}
|
|
@@ -389,12 +363,13 @@ export function classifyMidstreamError(err, signals, policy = {}) {
|
|
|
389
363
|
|
|
390
364
|
// WebSocket classification consumes the provider's stream-state signals.
|
|
391
365
|
function _classifyMidstreamWs(err, state, attemptIndex, policy) {
|
|
392
|
-
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
|
|
397
|
-
if (
|
|
366
|
+
// Terminal + replay gate. A mid-stream retry re-issues the turn, so it is
|
|
367
|
+
// denied once visible output was relayed or a tool call was dispatched
|
|
368
|
+
// (including a nominal close-1000 before response.completed: the tool may be
|
|
369
|
+
// executing). Everything below is the typed transient classification.
|
|
370
|
+
const outcome = readStreamOutcome(err, state)
|
|
371
|
+
if (outcome.terminalObserved) return null
|
|
372
|
+
if (outcome.replaySafe !== true) return null
|
|
398
373
|
if (err?.wsFrameTooLarge || state.wsFrameTooLarge) {
|
|
399
374
|
return _allowMidstream('ws_frame_too_large', attemptIndex, policy)
|
|
400
375
|
}
|
|
@@ -418,8 +393,7 @@ function _classifyMidstreamWs(err, state, attemptIndex, policy) {
|
|
|
418
393
|
if (name === 'StreamStalledAbortError' || name === 'StreamStalledError'
|
|
419
394
|
|| err?.code === 'ESTREAMSTALL' || err?.streamStalled === true
|
|
420
395
|
|| state.watchdogAbort === 'StreamStalledAbortError') {
|
|
421
|
-
// A stall
|
|
422
|
-
if (err?.unsafeToRetry === true) return null
|
|
396
|
+
// A stall after any exposure was already denied by the outcome gate.
|
|
423
397
|
return _allowMidstream('stream_stalled', attemptIndex, policy)
|
|
424
398
|
}
|
|
425
399
|
if (closeCode === 4000) return _allowMidstream('ws_4000', attemptIndex, policy)
|
|
@@ -444,9 +418,6 @@ function _classifyMidstreamWs(err, state, attemptIndex, policy) {
|
|
|
444
418
|
const name = err?.name || ''
|
|
445
419
|
if (name === 'AgentStallAbortError') return _allowMidstream('agent_stall', attemptIndex, policy)
|
|
446
420
|
if (name === 'StreamStalledAbortError' || name === 'StreamStalledError' || err?.code === 'ESTREAMSTALL' || err?.streamStalled === true) {
|
|
447
|
-
// A stall that fired AFTER a tool call was emitted is unsafe to replay
|
|
448
|
-
// (double side-effect); surface it as terminal so the turn is not retried.
|
|
449
|
-
if (err?.unsafeToRetry === true) return null
|
|
450
421
|
return _allowMidstream('stream_stalled', attemptIndex, policy)
|
|
451
422
|
}
|
|
452
423
|
|
|
@@ -463,23 +434,42 @@ function _classifyMidstreamWs(err, state, attemptIndex, policy) {
|
|
|
463
434
|
|
|
464
435
|
const failed = err?.responseFailed || state.responseFailedPayload
|
|
465
436
|
if (failed) {
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
437
|
+
// STRUCTURED evidence only: the failure's own numeric status and its
|
|
438
|
+
// explicit error code/type field. The payload is never stringified and
|
|
439
|
+
// searched — a message/body that merely CONTAINS "network_error",
|
|
440
|
+
// "stream_disconnected" or "auth context expired" is not a typed retry
|
|
441
|
+
// signal and leaves the turn terminal.
|
|
442
|
+
const detail = failed?.response?.error || failed?.error || failed
|
|
443
|
+
const failedStatus = typedStatusFrom(detail, failed?.response, failed)
|
|
444
|
+
if (failedStatus >= 500 && failedStatus < 600) {
|
|
445
|
+
return _allowMidstream(`http_${failedStatus}`, attemptIndex, policy)
|
|
446
|
+
}
|
|
447
|
+
for (const field of [detail?.code, detail?.type]) {
|
|
448
|
+
const key = typeof field === 'string' ? field.trim().toLowerCase() : ''
|
|
449
|
+
const classifier = RESPONSE_FAILED_CODE_CLASSIFIERS.get(key)
|
|
450
|
+
if (classifier) return _allowMidstream(classifier, attemptIndex, policy)
|
|
451
|
+
}
|
|
472
452
|
}
|
|
473
453
|
|
|
474
454
|
return null
|
|
475
455
|
}
|
|
476
456
|
|
|
457
|
+
// Explicit `response.failed` error codes/types that describe a transport-level
|
|
458
|
+
// interruption (Codex retries these); every other code is terminal.
|
|
459
|
+
const RESPONSE_FAILED_CODE_CLASSIFIERS = new Map([
|
|
460
|
+
['stream_disconnected', 'response_failed_disconnected'],
|
|
461
|
+
['network_error', 'response_failed_network'],
|
|
462
|
+
['auth_context_expired', 'response_failed_auth_expired'],
|
|
463
|
+
['auth_expired', 'response_failed_auth_expired'],
|
|
464
|
+
])
|
|
465
|
+
|
|
477
466
|
// SSE classification consumes the provider's stream-state signals.
|
|
478
467
|
function _classifyMidstreamSse(err, state, attemptIndex, policy) {
|
|
479
468
|
if (attemptIndex >= policy.defaultRetries) return null
|
|
480
|
-
|
|
469
|
+
const outcome = readStreamOutcome(err, state)
|
|
470
|
+
if (outcome.terminalObserved) return null
|
|
481
471
|
if (state.userAbort) return null
|
|
482
|
-
if (
|
|
472
|
+
if (outcome.replaySafe !== true) return null
|
|
483
473
|
|
|
484
474
|
if (!err) return null
|
|
485
475
|
const status = Number(err?.httpStatus || err?.status || err?.response?.status || 0)
|
|
@@ -490,9 +480,6 @@ function _classifyMidstreamSse(err, state, attemptIndex, policy) {
|
|
|
490
480
|
const name = err?.name || ''
|
|
491
481
|
if (name === 'AgentStallAbortError') return 'agent_stall'
|
|
492
482
|
if (name === 'StreamStalledAbortError' || name === 'StreamStalledError' || err?.code === 'ESTREAMSTALL' || err?.streamStalled === true) {
|
|
493
|
-
// A stall AFTER a tool emit is unsafe to replay (double side-effect) →
|
|
494
|
-
// terminal (null), no mid-stream retry. Otherwise route to stream_stalled.
|
|
495
|
-
if (err?.unsafeToRetry === true) return null
|
|
496
483
|
return 'stream_stalled'
|
|
497
484
|
}
|
|
498
485
|
if (state.watchdogAbort === 'AgentStallAbortError') return 'agent_stall'
|
|
@@ -503,10 +490,6 @@ function _classifyMidstreamSse(err, state, attemptIndex, policy) {
|
|
|
503
490
|
if (code === 'ETIMEDOUT' || code === 'ESOCKETTIMEDOUT') return 'timeout'
|
|
504
491
|
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN' || code === 'EAI_NODATA') return 'dns'
|
|
505
492
|
|
|
506
|
-
const msg = String(err?.message || '').toLowerCase()
|
|
507
|
-
if (msg.includes('stream timed out after') && msg.includes('of inactivity')) return 'sse_idle_timeout'
|
|
508
|
-
if (msg.includes('body stream') && msg.includes('terminated')) return 'stream_terminated'
|
|
509
|
-
if (msg.includes('fetch failed')) return 'fetch_failed'
|
|
510
493
|
if (classifyError(err) === 'transient') return 'connection'
|
|
511
494
|
|
|
512
495
|
return null
|
|
@@ -530,23 +513,25 @@ const TRANSPORT_FALLBACK_ERRNO = new Set([
|
|
|
530
513
|
export function shouldFallbackTransport(err, { signal, enabled = true } = {}) {
|
|
531
514
|
if (!enabled) return false
|
|
532
515
|
if (signal?.aborted) return false
|
|
533
|
-
|
|
534
|
-
|
|
516
|
+
// Transport fallback re-issues the request on another transport: it is a
|
|
517
|
+
// replay, so the exposure deny applies. Eligibility itself stays typed
|
|
518
|
+
// (status / errno / classifier), matching Codex's WS→HTTPS switch.
|
|
519
|
+
if (readStreamOutcome(err).replaySafe !== true) return false
|
|
535
520
|
const status = Number(err?.httpStatus || err?.status || 0)
|
|
536
|
-
// 401 is auth recovery, never transport fallback.
|
|
537
|
-
//
|
|
538
|
-
//
|
|
521
|
+
// 401 is auth recovery, never transport fallback. 426 is the explicit
|
|
522
|
+
// immediate WS→HTTPS switch; every other status must be TYPED transient
|
|
523
|
+
// (408/409/5xx) — an arbitrary nonzero status is not fallback evidence.
|
|
539
524
|
if (status === 401) return false
|
|
540
525
|
if (status === 426) return true
|
|
541
|
-
if (status
|
|
526
|
+
if (TRANSIENT_STATUSES.has(status) || (status >= 500 && status < 600)) return true
|
|
527
|
+
if (status > 0) return false
|
|
542
528
|
const code = String(err?.code || '')
|
|
543
529
|
if (TRANSPORT_FALLBACK_ERRNO.has(code)) return true
|
|
544
530
|
const classifier = String(err?.retryClassifier || err?.midstreamClassifier || '')
|
|
545
531
|
if (TRANSPORT_FALLBACK_CLASSIFIERS.has(classifier)) return true
|
|
546
532
|
if (/^http_5\d\d$/.test(classifier)) return true
|
|
547
533
|
if (err?.firstByteTimeout) return true
|
|
548
|
-
|
|
549
|
-
return /opening handshake has timed out|socket hang up|acquire timed out|no first server event|no meaningful output/i.test(msg)
|
|
534
|
+
return false
|
|
550
535
|
}
|
|
551
536
|
|
|
552
537
|
// C) Stream-safety stamp latches. Mirrors openai-oauth-ws's _stampLiveText /
|
|
@@ -645,12 +630,17 @@ function _sleepChunkWithAbort(ms, signal, sleepFn, abortMessage) {
|
|
|
645
630
|
export function classifyHandshakeError(err, { retry429 = true } = {}) {
|
|
646
631
|
if (!err) return null
|
|
647
632
|
const code = err.code || ''
|
|
648
|
-
const msg = String(err.message || '')
|
|
649
633
|
const status = Number(err.httpStatus || 0)
|
|
650
634
|
|
|
651
635
|
if (status === 401 || status === 426 || (status === 429 && !retry429)) return null
|
|
652
636
|
if (status > 0) {
|
|
653
|
-
|
|
637
|
+
// Typed transient handshake statuses only. A 403/404/4xx upgrade refusal
|
|
638
|
+
// is a deterministic decision: spending the retry budget on it cannot
|
|
639
|
+
// change the answer.
|
|
640
|
+
if (status === 429 || TRANSIENT_STATUSES.has(status) || (status >= 500 && status < 600)) {
|
|
641
|
+
return `http_${status}`
|
|
642
|
+
}
|
|
643
|
+
return null
|
|
654
644
|
}
|
|
655
645
|
|
|
656
646
|
if (code === 'ECONNRESET') return 'reset'
|
|
@@ -660,9 +650,6 @@ export function classifyHandshakeError(err, { retry429 = true } = {}) {
|
|
|
660
650
|
if (code === 'EWSACQUIRETIMEOUT') return 'acquire_timeout'
|
|
661
651
|
if (code === 'ENETUNREACH' || code === 'EHOSTUNREACH' || code === 'EPIPE') return 'network'
|
|
662
652
|
|
|
663
|
-
if (/opening handshake has timed out/i.test(msg)) return 'timeout'
|
|
664
|
-
if (/socket hang up/i.test(msg)) return 'reset'
|
|
665
|
-
|
|
666
653
|
return null
|
|
667
654
|
}
|
|
668
655
|
|
|
@@ -677,9 +664,8 @@ export function classifyHandshakeError(err, { retry429 = true } = {}) {
|
|
|
677
664
|
* the provider's own responsibility via its native abort plumbing.
|
|
678
665
|
* - Uses classifyError() to decide retry. 'transient' → retry,
|
|
679
666
|
* 'auth' / 'permanent' / 'unknown' → throw immediately.
|
|
680
|
-
* -
|
|
681
|
-
*
|
|
682
|
-
* resolve to httpStatus before classification.
|
|
667
|
+
* - Classification is typed-only: an error with no status/errno/SDK type is
|
|
668
|
+
* 'unknown' and is surfaced immediately instead of being replayed.
|
|
683
669
|
*
|
|
684
670
|
* Returns whatever `fn()` resolves to. Throws the last error if every retry
|
|
685
671
|
* is exhausted, or the first error if it's classified non-transient.
|
|
@@ -736,16 +722,13 @@ export async function withRetry(fn, opts = {}) {
|
|
|
736
722
|
throw reason instanceof Error ? reason : new Error('withRetry: aborted')
|
|
737
723
|
}
|
|
738
724
|
lastErr = caught
|
|
739
|
-
populateHttpStatusFromMessage(caught)
|
|
740
725
|
const status = Number(caught?.httpStatus || caught?.status || caught?.response?.status || 0)
|
|
741
726
|
const kind = classifyError(caught)
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|| caught?.emittedThinking === true
|
|
748
|
-
if (outputWasExposed || caught?.unsafeToRetry === true) throw caught
|
|
727
|
+
// Hard replay boundary: a retry RE-ISSUES the request, so it is denied
|
|
728
|
+
// once visible output was relayed or a tool call was dispatched. Whether
|
|
729
|
+
// an eligible failure is actually retried remains the typed question
|
|
730
|
+
// resolved by classifyError()/status below.
|
|
731
|
+
if (readStreamOutcome(caught).replaySafe !== true) throw caught
|
|
749
732
|
// Claude Code treats x-should-retry:false as an explicit server veto
|
|
750
733
|
// (except an internal-only 5xx override that Mixdog does not have).
|
|
751
734
|
// Keep this ahead of status defaults, including the request-local 429 path.
|
|
@@ -50,6 +50,7 @@ import {
|
|
|
50
50
|
crossTurnDedupStub,
|
|
51
51
|
ITERATION_CAP_REFUSAL_STUB,
|
|
52
52
|
} from './loop/completion-guards.mjs';
|
|
53
|
+
import { STOP_HOOK_SOURCE, createToolFailureStopHook } from './loop/stop-hooks.mjs';
|
|
53
54
|
import { isEditProgressTool } from './loop/completion-guards.mjs';
|
|
54
55
|
import { agentContextOverflowError } from './loop/context-overflow.mjs';
|
|
55
56
|
import { positiveTokenInt } from './loop/env.mjs';
|
|
@@ -83,7 +84,12 @@ import {
|
|
|
83
84
|
restoreToolCallBodyForId,
|
|
84
85
|
} from './loop/stored-tool-args.mjs';
|
|
85
86
|
import { repairTranscriptBeforeProviderSend } from './loop/transcript-repair.mjs';
|
|
86
|
-
import {
|
|
87
|
+
import {
|
|
88
|
+
classifyTerminationReason,
|
|
89
|
+
INCOMPLETE_STOP_REASONS,
|
|
90
|
+
isOutputLimitStopReason,
|
|
91
|
+
providerContinuationSignal,
|
|
92
|
+
} from './loop/termination.mjs';
|
|
87
93
|
import { createSteeringLadder } from './loop/steering-ladder.mjs';
|
|
88
94
|
import { runPreSendCompactPass } from './pre-send-compact.mjs';
|
|
89
95
|
import { createEagerDispatcher } from './eager-dispatch.mjs';
|
|
@@ -210,14 +216,26 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
210
216
|
// Reasoning/thinking deltas, tool calls, and the final answer are kept.
|
|
211
217
|
const suppressMidTurnText = isAgentOwner(sessionRef);
|
|
212
218
|
if (suppressMidTurnText) opts.onTextDelta = undefined;
|
|
219
|
+
// ---- Codex turn stop hook (refs/codex core/src/session/turn.rs:372-404) --
|
|
220
|
+
// A no-tool assistant message is TERMINAL. Only a structured provider
|
|
221
|
+
// follow-up signal (end_turn=false / pause_turn), pending input, tool
|
|
222
|
+
// calls/results, or a stop hook that blocks with a continuation prompt keep
|
|
223
|
+
// sampling alive. The single hook below is structural: after an unresolved
|
|
224
|
+
// real tool failure it blocks the first terminal message ONCE, records the
|
|
225
|
+
// continuation prompt, and then stays inactive for the rest of the turn.
|
|
226
|
+
const _toolFailureStopHook = createToolFailureStopHook();
|
|
213
227
|
const pushToolResultMessage = (message) => {
|
|
214
228
|
messages.push(message);
|
|
229
|
+
_toolFailureStopHook.observeToolResult(message);
|
|
215
230
|
try { opts.onToolResult?.(message); } catch {}
|
|
216
231
|
};
|
|
217
232
|
const drainSteeringIntoMessages = (stage = 'mid-turn', options = {}) => {
|
|
218
233
|
if (typeof opts.drainSteering !== 'function') return false;
|
|
219
234
|
let steerMsgs = [];
|
|
220
|
-
|
|
235
|
+
// The stage rides along so the host can scope which queues a drain may
|
|
236
|
+
// consume (mid-turn tool-batch boundary vs the terminal pending-input
|
|
237
|
+
// check that precedes the stop hooks).
|
|
238
|
+
try { steerMsgs = opts.drainSteering(sessionId, { ...options, stage }) || []; }
|
|
221
239
|
catch { steerMsgs = []; }
|
|
222
240
|
const mergedMessages = [];
|
|
223
241
|
for (const entry of Array.isArray(steerMsgs) ? steerMsgs : []) {
|
|
@@ -288,13 +306,34 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
288
306
|
const providerMetadata = resp.providerMetadata && typeof resp.providerMetadata === 'object'
|
|
289
307
|
? resp.providerMetadata
|
|
290
308
|
: null;
|
|
291
|
-
|
|
309
|
+
// Anthropic native server-tool turns (web search / code execution /
|
|
310
|
+
// native MCP) carry `server_tool_use` + `*_tool_result` blocks that
|
|
311
|
+
// exist ONLY in this ordered verbatim list — they cannot be rebuilt
|
|
312
|
+
// from content/thinkingBlocks/toolCalls, and dropping them breaks the
|
|
313
|
+
// resumed turn (a result block is only valid right after its call
|
|
314
|
+
// block). Attach them so Anthropic lowering replays the turn as-is.
|
|
315
|
+
// Guarded on no client tool calls: this committer only handles
|
|
316
|
+
// continuation/intermediate turns. A MIXED turn (native blocks +
|
|
317
|
+
// client tool_use) is committed by the tool-call branch below, which
|
|
318
|
+
// keeps both the compacted toolCalls and these blocks.
|
|
319
|
+
const assistantBlocks = Array.isArray(resp.assistantBlocks)
|
|
320
|
+
&& resp.assistantBlocks.length
|
|
321
|
+
&& !resp.toolCalls?.length
|
|
322
|
+
? resp.assistantBlocks
|
|
323
|
+
: null;
|
|
324
|
+
// A native-only turn (server tool blocks, no flattened text) is real
|
|
325
|
+
// assistant output and must stay committable — not treated as empty.
|
|
326
|
+
if (!content && !reasoningContent && !reasoningItems && !thinkingBlocks && !assistantBlocks) return false;
|
|
292
327
|
const message = attachAssistantTranscriptMetadata({
|
|
293
328
|
role: 'assistant',
|
|
294
329
|
content,
|
|
330
|
+
// assistantBlocks already contains the thinking blocks verbatim in
|
|
331
|
+
// stream order, so thinkingBlocks is redundant (and would be
|
|
332
|
+
// double-counted by the context estimator) when it is present.
|
|
333
|
+
...(assistantBlocks ? { assistantBlocks } : {}),
|
|
295
334
|
// Anthropic adaptive-thinking signatures must be replayed verbatim
|
|
296
335
|
// before the continuation turn, just like tool-call trajectories.
|
|
297
|
-
...(thinkingBlocks ? { thinkingBlocks } : {}),
|
|
336
|
+
...(thinkingBlocks && !assistantBlocks ? { thinkingBlocks } : {}),
|
|
298
337
|
...(reasoningItems ? { reasoningItems } : {}),
|
|
299
338
|
...(reasoningContent ? { reasoningContent } : {}),
|
|
300
339
|
...(providerMetadata ? { providerMetadata } : {}),
|
|
@@ -334,6 +373,11 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
334
373
|
let _refusalRetryUsed = false;
|
|
335
374
|
let _maxOutputRecoveryCount = 0;
|
|
336
375
|
const _maxOutputContentParts = [];
|
|
376
|
+
// Count of structured provider continuation signals honored this turn
|
|
377
|
+
// (endTurn === false / stopReason === 'pause_turn'). Diagnostic only; the
|
|
378
|
+
// hard iteration cap remains the sole bound on how long a provider may
|
|
379
|
+
// keep declaring "not done" inside one user turn.
|
|
380
|
+
let _providerContinuationCount = 0;
|
|
337
381
|
// Claude Code parity: queued prompt/task notifications are attached after a
|
|
338
382
|
// tool batch, before the continuation provider send. Normal batches drain
|
|
339
383
|
// up to 'next'; a Sleep-like tool grants a 'later' flush.
|
|
@@ -760,6 +804,42 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
760
804
|
});
|
|
761
805
|
continue;
|
|
762
806
|
}
|
|
807
|
+
// Structured provider continuation signal. The provider declared
|
|
808
|
+
// the assistant turn unfinished, so this text is mid-turn output,
|
|
809
|
+
// not a final answer: commit it EXACTLY ONCE to history/UI (single
|
|
810
|
+
// pushIntermediateAssistantResponse → onAssistantMessageCommitted)
|
|
811
|
+
// and immediately resume sampling in the same user turn. Output
|
|
812
|
+
// limit stops keep the bounded max-output recovery ladder above and
|
|
813
|
+
// refusals keep the bounded refusal retry; both own their own
|
|
814
|
+
// continuation semantics. No lexical/progress-text heuristic.
|
|
815
|
+
const continuationSignal = !isOutputLimitStop && stopReason !== 'refusal'
|
|
816
|
+
? providerContinuationSignal(response)
|
|
817
|
+
: null;
|
|
818
|
+
if (continuationSignal && pushIntermediateAssistantResponse(response)) {
|
|
819
|
+
if (hasContent && !suppressMidTurnText) {
|
|
820
|
+
try { opts.onAssistantText?.(response.content); } catch { /* best-effort */ }
|
|
821
|
+
}
|
|
822
|
+
_providerContinuationCount += 1;
|
|
823
|
+
_emptyNudgeStreak = 0;
|
|
824
|
+
try {
|
|
825
|
+
appendAgentTrace({
|
|
826
|
+
sessionId,
|
|
827
|
+
iteration: iterations,
|
|
828
|
+
kind: 'provider_continuation',
|
|
829
|
+
payload: {
|
|
830
|
+
signal: continuationSignal,
|
|
831
|
+
stop_reason: stopReason,
|
|
832
|
+
count: _providerContinuationCount,
|
|
833
|
+
content_len: typeof response.content === 'string' ? response.content.length : 0,
|
|
834
|
+
},
|
|
835
|
+
agent: sessionAgent || null,
|
|
836
|
+
});
|
|
837
|
+
} catch { /* best-effort */ }
|
|
838
|
+
continue;
|
|
839
|
+
}
|
|
840
|
+
// A continuation signal with nothing committable (no text, no
|
|
841
|
+
// reasoning) falls through to the bounded empty-turn handling
|
|
842
|
+
// below: re-sending an unchanged transcript would livelock.
|
|
763
843
|
if (!hasContent && !isHidden) {
|
|
764
844
|
_emptyNudgeStreak += 1;
|
|
765
845
|
if (_emptyNudgeStreak > EMPTY_NUDGE_MAX) {
|
|
@@ -774,14 +854,49 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
774
854
|
if (isIncompleteStop) {
|
|
775
855
|
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.`;
|
|
776
856
|
} else {
|
|
777
|
-
// Vary the nudge per attempt — a byte-identical repeat
|
|
778
|
-
// reinforces the empty-completion pattern it is trying to
|
|
779
|
-
// break (the request context stays effectively constant).
|
|
780
857
|
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.`;
|
|
781
858
|
}
|
|
782
859
|
messages.push({ role: 'user', content: nudgeMsg });
|
|
783
860
|
continue;
|
|
784
861
|
}
|
|
862
|
+
// Codex `has_pending_input` (turn.rs:304-318): queued user input is
|
|
863
|
+
// folded into needs_follow_up and evaluated BEFORE the stop hooks,
|
|
864
|
+
// so real steering always wins over a synthetic continuation prompt.
|
|
865
|
+
// Commit the terminal text first (beforeAppend), then resume.
|
|
866
|
+
if (!_capFinalToolsDisabled && drainSteeringIntoMessages('terminal', {
|
|
867
|
+
maxPriority: 'next',
|
|
868
|
+
beforeAppend: () => { pushIntermediateAssistantResponse(response); },
|
|
869
|
+
})) {
|
|
870
|
+
_emptyNudgeStreak = 0;
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
// Codex parity (turn.rs:372-404): this no-tool message ends the turn
|
|
874
|
+
// unless a stop hook blocks it. The unresolved-tool-failure hook may
|
|
875
|
+
// block exactly once — commit the assistant text, record the
|
|
876
|
+
// structural continuation prompt, resume sampling. Skipped on the
|
|
877
|
+
// hard-cap final turn (tool use is already forbidden) and for hidden
|
|
878
|
+
// roles, whose own role rules define a text-only terminal contract.
|
|
879
|
+
if (!_capFinalToolsDisabled && !isHidden) {
|
|
880
|
+
const _hookPrompt = _toolFailureStopHook.takeContinuationPrompt();
|
|
881
|
+
if (_hookPrompt) {
|
|
882
|
+
pushIntermediateAssistantResponse(response);
|
|
883
|
+
messages.push({
|
|
884
|
+
role: 'user',
|
|
885
|
+
content: _hookPrompt,
|
|
886
|
+
meta: { source: STOP_HOOK_SOURCE, tool: _toolFailureStopHook.lastFailedTool || null },
|
|
887
|
+
});
|
|
888
|
+
try {
|
|
889
|
+
appendAgentTrace({
|
|
890
|
+
sessionId,
|
|
891
|
+
iteration: iterations,
|
|
892
|
+
kind: 'steer',
|
|
893
|
+
payload: { tag: 'tool_failure_stop_hook', tool: _toolFailureStopHook.lastFailedTool || null },
|
|
894
|
+
agent: sessionAgent || null,
|
|
895
|
+
});
|
|
896
|
+
} catch { /* best-effort */ }
|
|
897
|
+
continue;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
785
900
|
if (_maxOutputContentParts.length > 0) {
|
|
786
901
|
const terminalSegment = typeof response.content === 'string' ? response.content : '';
|
|
787
902
|
response = {
|
|
@@ -824,12 +939,27 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
824
939
|
// turn). Blank it so it never accumulates as input tokens.
|
|
825
940
|
content: suppressMidTurnText ? '' : (response.content || ''),
|
|
826
941
|
toolCalls: compactToolCallsForHistory(calls),
|
|
942
|
+
// MIXED Anthropic turn (native server tools + client tool_use):
|
|
943
|
+
// the ordered `server_tool_use` / `*_tool_result` blocks exist ONLY
|
|
944
|
+
// in this verbatim list and are order-bound (a result block is
|
|
945
|
+
// valid only right after its call block), so they must ride the
|
|
946
|
+
// tool-call history message too. toolCalls above stays authoritative
|
|
947
|
+
// for execution/recovery/interruption; both Anthropic lowerers
|
|
948
|
+
// prefer assistantBlocks verbatim (which already contains the
|
|
949
|
+
// text/thinking/tool_use blocks), so nothing is emitted twice and
|
|
950
|
+
// non-Anthropic providers simply ignore the field.
|
|
951
|
+
...(Array.isArray(response.assistantBlocks) && response.assistantBlocks.length
|
|
952
|
+
? { assistantBlocks: response.assistantBlocks }
|
|
953
|
+
: {}),
|
|
827
954
|
// Anthropic adaptive thinking: prior-turn thinking blocks must be
|
|
828
955
|
// returned verbatim (signature intact; empty thinking allowed) and
|
|
829
956
|
// are REQUIRED back before tool_use blocks on tool-continuation
|
|
830
957
|
// turns. Store them so toAnthropicMessages can build assistantBlocks
|
|
831
|
-
// = [...thinking, tool_use...].
|
|
958
|
+
// = [...thinking, tool_use...]. Skipped when assistantBlocks is
|
|
959
|
+
// present: those blocks already carry the thinking verbatim, and a
|
|
960
|
+
// second copy would double-count in the context estimator.
|
|
832
961
|
...(Array.isArray(response.thinkingBlocks) && response.thinkingBlocks.length
|
|
962
|
+
&& !(Array.isArray(response.assistantBlocks) && response.assistantBlocks.length)
|
|
833
963
|
? { thinkingBlocks: response.thinkingBlocks }
|
|
834
964
|
: {}),
|
|
835
965
|
...(Array.isArray(response.reasoningItems) && response.reasoningItems.length
|
|
@@ -844,6 +974,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
844
974
|
}, opts);
|
|
845
975
|
messages.push(_assistantTurnMsg);
|
|
846
976
|
try { opts.onAssistantMessageCommitted?.(_assistantTurnMsg); } catch {}
|
|
977
|
+
const _callsToExecute = calls;
|
|
847
978
|
// Hard-cap final turn: tools are disabled but the model still emitted
|
|
848
979
|
// tool calls. Do NOT execute them — push a refusal stub for each.
|
|
849
980
|
if (_capFinalToolsDisabled) {
|
|
@@ -860,7 +991,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
860
991
|
}
|
|
861
992
|
try { opts.onToolPhaseStarted?.(); } catch {}
|
|
862
993
|
({ dedupStubTotal: _dedupStubTotal, editCount: _editCount } = await processToolBatch({
|
|
863
|
-
calls, messages, tools, cwd, sessionId, sessionRef, signal, opts,
|
|
994
|
+
calls: _callsToExecute, messages, tools, cwd, sessionId, sessionRef, signal, opts,
|
|
864
995
|
iterations, assistantTurnMsg: _assistantTurnMsg,
|
|
865
996
|
pending: eager.pending, epoch: eager.epoch, startEagerRun: eager.startEagerRun,
|
|
866
997
|
crossTurnCalls: _crossTurnCalls, crossTurnCap: _CROSS_TURN_CAP,
|
|
@@ -869,8 +1000,10 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
869
1000
|
pushToolResultMessage, throwIfAborted,
|
|
870
1001
|
repeatFailLimit: REPEAT_FAIL_LIMIT,
|
|
871
1002
|
}));
|
|
1003
|
+
// Settle the stop hook on the batch that actually executed.
|
|
1004
|
+
_toolFailureStopHook.endBatch(_callsToExecute);
|
|
872
1005
|
_toolBatchJustCompleted = true;
|
|
873
|
-
_lastToolBatchHadSleep =
|
|
1006
|
+
_lastToolBatchHadSleep = _callsToExecute.some(isSleepLikeToolCall);
|
|
874
1007
|
}
|
|
875
1008
|
// Classify WHY the loop ended so agent-tool can promote an empty/abnormal
|
|
876
1009
|
// finish to an explicit Lead-facing error instead of a silent empty
|
|
@@ -891,5 +1024,6 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
891
1024
|
providerStateUpdated,
|
|
892
1025
|
terminationReason,
|
|
893
1026
|
maxLoopIterations,
|
|
1027
|
+
providerContinuations: _providerContinuationCount,
|
|
894
1028
|
};
|
|
895
1029
|
}
|