muonroi-cli 1.8.5 → 1.9.0
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/dist/packages/agent-harness-core/src/event-filter.js +11 -0
- package/dist/packages/agent-harness-core/src/event-redact.js +7 -0
- package/dist/packages/agent-harness-core/src/event-tee.d.ts +20 -4
- package/dist/packages/agent-harness-core/src/event-tee.js +32 -5
- package/dist/packages/agent-harness-core/src/mcp-server.d.ts +14 -0
- package/dist/packages/agent-harness-core/src/mcp-server.js +86 -10
- package/dist/packages/agent-harness-core/src/predicate.d.ts +1 -1
- package/dist/packages/agent-harness-core/src/protocol.d.ts +24 -2
- package/dist/src/agent-harness/mock-model.d.ts +10 -0
- package/dist/src/agent-harness/mock-model.js +6 -2
- package/dist/src/chat/chat-keychain.d.ts +7 -12
- package/dist/src/chat/chat-keychain.js +19 -86
- package/dist/src/cli/keys.d.ts +8 -45
- package/dist/src/cli/keys.js +18 -324
- package/dist/src/council/clarifier.d.ts +4 -2
- package/dist/src/council/clarifier.js +158 -36
- package/dist/src/council/debate-planner.js +3 -2
- package/dist/src/council/debate.js +59 -5
- package/dist/src/council/index.d.ts +23 -0
- package/dist/src/council/index.js +37 -2
- package/dist/src/council/llm.d.ts +62 -0
- package/dist/src/council/llm.js +123 -23
- package/dist/src/council/panel-select.js +13 -3
- package/dist/src/council/planner.js +17 -0
- package/dist/src/council/preflight.d.ts +10 -0
- package/dist/src/council/preflight.js +36 -0
- package/dist/src/council/prompts.d.ts +9 -1
- package/dist/src/council/prompts.js +23 -6
- package/dist/src/council/types.d.ts +10 -0
- package/dist/src/ee/auth.d.ts +19 -0
- package/dist/src/ee/auth.js +39 -0
- package/dist/src/ee/client.js +28 -3
- package/dist/src/ee/ee-onboarding.js +6 -26
- package/dist/src/flow/compaction/compress.d.ts +2 -2
- package/dist/src/flow/compaction/compress.js +21 -8
- package/dist/src/flow/compaction/extract.d.ts +3 -3
- package/dist/src/flow/compaction/extract.js +6 -6
- package/dist/src/flow/compaction/index.d.ts +2 -1
- package/dist/src/flow/compaction/index.js +29 -3
- package/dist/src/flow/compaction/progress.d.ts +35 -0
- package/dist/src/flow/compaction/progress.js +35 -0
- package/dist/src/generated/version.d.ts +1 -1
- package/dist/src/generated/version.js +1 -1
- package/dist/src/gsd/flags.d.ts +11 -0
- package/dist/src/gsd/flags.js +19 -0
- package/dist/src/gsd/plan-council.js +104 -72
- package/dist/src/gsd/verdict-schema.d.ts +1 -1
- package/dist/src/headless/council-answers.js +4 -0
- package/dist/src/index.js +129 -260
- package/dist/src/lsp/builtins.js +3 -1
- package/dist/src/lsp/manager.d.ts +5 -1
- package/dist/src/lsp/manager.js +249 -3
- package/dist/src/lsp/npm-cache.d.ts +11 -1
- package/dist/src/lsp/npm-cache.js +17 -1
- package/dist/src/lsp/runtime.d.ts +6 -1
- package/dist/src/lsp/runtime.js +17 -1
- package/dist/src/lsp/types.d.ts +83 -1
- package/dist/src/lsp/types.js +10 -0
- package/dist/src/mcp/client-pool.js +43 -15
- package/dist/src/mcp/lsp-tools.d.ts +5 -1
- package/dist/src/mcp/lsp-tools.js +93 -2
- package/dist/src/mcp/mcp-keychain.d.ts +3 -5
- package/dist/src/mcp/mcp-keychain.js +9 -49
- package/dist/src/mcp/setup-guide-text.d.ts +1 -1
- package/dist/src/mcp/setup-guide-text.js +22 -2
- package/dist/src/mcp/tools-server.d.ts +10 -0
- package/dist/src/mcp/tools-server.js +10 -2
- package/dist/src/models/catalog.json +19 -19
- package/dist/src/orchestrator/ask-user.d.ts +61 -0
- package/dist/src/orchestrator/ask-user.js +65 -0
- package/dist/src/orchestrator/compaction.d.ts +2 -3
- package/dist/src/orchestrator/compaction.js +8 -8
- package/dist/src/orchestrator/council-manager.js +9 -8
- package/dist/src/orchestrator/council-request.d.ts +49 -0
- package/dist/src/orchestrator/council-request.js +62 -0
- package/dist/src/orchestrator/interactive-pause.d.ts +26 -0
- package/dist/src/orchestrator/interactive-pause.js +36 -0
- package/dist/src/orchestrator/message-processor.d.ts +4 -0
- package/dist/src/orchestrator/message-processor.js +26 -8
- package/dist/src/orchestrator/orchestrator.d.ts +25 -0
- package/dist/src/orchestrator/orchestrator.js +204 -50
- package/dist/src/orchestrator/preprocessor.js +2 -2
- package/dist/src/orchestrator/safety-askcard.d.ts +1 -1
- package/dist/src/orchestrator/safety-askcard.js +5 -2
- package/dist/src/orchestrator/safety-intercept.d.ts +5 -0
- package/dist/src/orchestrator/safety-intercept.js +7 -0
- package/dist/src/orchestrator/stall-watchdog.d.ts +8 -1
- package/dist/src/orchestrator/stall-watchdog.js +24 -3
- package/dist/src/orchestrator/stream-runner.d.ts +13 -3
- package/dist/src/orchestrator/stream-runner.js +54 -21
- package/dist/src/orchestrator/tool-engine.d.ts +19 -0
- package/dist/src/orchestrator/tool-engine.js +241 -25
- package/dist/src/orchestrator/turn-watchdog.d.ts +7 -0
- package/dist/src/orchestrator/turn-watchdog.js +38 -9
- package/dist/src/pil/agent-operating-contract.d.ts +1 -1
- package/dist/src/pil/agent-operating-contract.js +6 -4
- package/dist/src/pil/discovery.d.ts +1 -1
- package/dist/src/pil/discovery.js +2 -2
- package/dist/src/pil/layer1_5-complexity-size.d.ts +7 -0
- package/dist/src/pil/layer1_5-complexity-size.js +31 -5
- package/dist/src/pil/llm-classify.d.ts +78 -3
- package/dist/src/pil/llm-classify.js +351 -111
- package/dist/src/pil/native-capabilities-workbook.d.ts +1 -1
- package/dist/src/pil/native-capabilities-workbook.js +7 -0
- package/dist/src/pil/pipeline.js +2 -0
- package/dist/src/pil/repo-grounding-probe.d.ts +15 -0
- package/dist/src/pil/repo-grounding-probe.js +136 -0
- package/dist/src/pil/repo-structure-hints.d.ts +7 -0
- package/dist/src/pil/repo-structure-hints.js +45 -0
- package/dist/src/product-loop/artifact-io.js +4 -0
- package/dist/src/product-loop/criteria-seed.d.ts +51 -0
- package/dist/src/product-loop/criteria-seed.js +200 -0
- package/dist/src/product-loop/discovery-interview.d.ts +9 -0
- package/dist/src/product-loop/discovery-interview.js +37 -18
- package/dist/src/product-loop/discovery-recommender.js +2 -1
- package/dist/src/product-loop/discovery-schema.js +14 -1
- package/dist/src/product-loop/discovery-triage.d.ts +23 -0
- package/dist/src/product-loop/discovery-triage.js +109 -0
- package/dist/src/product-loop/gather.js +150 -2
- package/dist/src/product-loop/index.js +7 -0
- package/dist/src/product-loop/loop-driver.js +21 -8
- package/dist/src/product-loop/phase-plan.d.ts +16 -0
- package/dist/src/product-loop/phase-plan.js +42 -4
- package/dist/src/product-loop/phase-rituals.d.ts +3 -0
- package/dist/src/product-loop/phase-rituals.js +8 -3
- package/dist/src/product-loop/phase-runner.js +30 -11
- package/dist/src/product-loop/plan-adherence-review.d.ts +26 -0
- package/dist/src/product-loop/plan-adherence-review.js +144 -0
- package/dist/src/product-loop/sprint-runner.d.ts +62 -0
- package/dist/src/product-loop/sprint-runner.js +309 -8
- package/dist/src/product-loop/types.d.ts +25 -0
- package/dist/src/providers/anthropic.d.ts +9 -8
- package/dist/src/providers/anthropic.js +13 -47
- package/dist/src/providers/auth/grok-oauth.d.ts +1 -0
- package/dist/src/providers/auth/grok-oauth.js +30 -5
- package/dist/src/providers/auth/openai-oauth.d.ts +1 -0
- package/dist/src/providers/auth/openai-oauth.js +14 -0
- package/dist/src/providers/auth/token-store.d.ts +9 -9
- package/dist/src/providers/auth/token-store.js +8 -70
- package/dist/src/providers/auth/types.d.ts +8 -0
- package/dist/src/providers/env-store.d.ts +17 -0
- package/dist/src/providers/env-store.js +228 -0
- package/dist/src/providers/keychain.d.ts +21 -17
- package/dist/src/providers/keychain.js +124 -135
- package/dist/src/providers/runtime.d.ts +24 -9
- package/dist/src/providers/runtime.js +48 -37
- package/dist/src/providers/strategies/thinking-mode.js +9 -1
- package/dist/src/providers/strategies/xai.strategy.js +27 -0
- package/dist/src/providers/warm.d.ts +65 -0
- package/dist/src/providers/warm.js +145 -0
- package/dist/src/self-qa/agentic-loop.js +3 -2
- package/dist/src/storage/transcript.js +56 -2
- package/dist/src/tools/git-safety.d.ts +19 -0
- package/dist/src/tools/git-safety.js +168 -0
- package/dist/src/tools/native-tools.d.ts +1 -1
- package/dist/src/tools/native-tools.js +76 -1
- package/dist/src/tools/registry.d.ts +17 -0
- package/dist/src/tools/registry.js +116 -1
- package/dist/src/types/index.d.ts +29 -1
- package/dist/src/ui/app.js +67 -4
- package/dist/src/ui/components/agent-rail-activities.d.ts +26 -0
- package/dist/src/ui/components/agent-rail-activities.js +47 -0
- package/dist/src/ui/components/compact-progress-card.d.ts +24 -0
- package/dist/src/ui/components/compact-progress-card.js +42 -0
- package/dist/src/ui/components/council-phase-timeline.js +17 -2
- package/dist/src/ui/components/council-question-card.js +1 -0
- package/dist/src/ui/components/message-view.d.ts +15 -0
- package/dist/src/ui/components/message-view.js +50 -1
- package/dist/src/ui/components/tool-group.d.ts +15 -3
- package/dist/src/ui/components/tool-group.js +69 -11
- package/dist/src/ui/containers/modals-layer.d.ts +2 -1
- package/dist/src/ui/containers/modals-layer.js +2 -2
- package/dist/src/ui/council-harness-event.d.ts +57 -0
- package/dist/src/ui/council-harness-event.js +46 -0
- package/dist/src/ui/heartbeat-debug.d.ts +29 -0
- package/dist/src/ui/heartbeat-debug.js +45 -0
- package/dist/src/ui/modals/api-key-modal.js +1 -1
- package/dist/src/ui/modals/model-picker-modal.d.ts +8 -18
- package/dist/src/ui/modals/model-picker-modal.js +8 -10
- package/dist/src/ui/slash/ee.js +81 -0
- package/dist/src/ui/slash/menu-items.js +11 -2
- package/dist/src/ui/use-app-logic.js +354 -224
- package/dist/src/ui/utils/agent-activities.d.ts +39 -0
- package/dist/src/ui/utils/agent-activities.js +96 -0
- package/dist/src/ui/utils/group-tool-entries.d.ts +26 -0
- package/dist/src/ui/utils/group-tool-entries.js +111 -0
- package/dist/src/ui/utils/tool-summary.d.ts +21 -0
- package/dist/src/ui/utils/tool-summary.js +91 -0
- package/dist/src/utils/event-loop-monitor.d.ts +85 -0
- package/dist/src/utils/event-loop-monitor.js +107 -0
- package/dist/src/utils/llm-deadline.d.ts +14 -0
- package/dist/src/utils/llm-deadline.js +19 -0
- package/dist/src/utils/loop-profiler.d.ts +102 -0
- package/dist/src/utils/loop-profiler.js +202 -0
- package/dist/src/utils/settings.d.ts +27 -0
- package/dist/src/utils/settings.js +38 -2
- package/dist/src/utils/side-question.d.ts +1 -2
- package/dist/src/utils/side-question.js +2 -2
- package/dist/src/verify/entrypoint.js +51 -16
- package/dist/src/verify/orchestrator.d.ts +1 -1
- package/dist/src/verify/orchestrator.js +20 -3
- package/package.json +1 -2
- package/dist/src/cli/bw-vault.d.ts +0 -55
- package/dist/src/cli/bw-vault.js +0 -133
- package/dist/src/mcp/ee-tools.d.ts +0 -46
- package/dist/src/mcp/ee-tools.js +0 -194
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
// - C3 (cross-turn dedup wrap) — wrapToolSetWithDedup
|
|
27
27
|
// - F1 (sub-agent cumulative cap) — wrapToolSetWithCap
|
|
28
28
|
// - reasoning-strip (provider quirk) — taskCaps.sanitizeHistory
|
|
29
|
+
import { appendFileSync } from "node:fs";
|
|
29
30
|
import { stepCountIs, streamText } from "ai";
|
|
30
31
|
import { recordArtifact } from "../ee/artifact-cache.js";
|
|
31
32
|
import { getDefaultEEClient } from "../ee/intercept.js";
|
|
@@ -36,7 +37,7 @@ import { injectCheapModelWorkbook, shouldInjectCheapModelWorkbook, subagentTaskT
|
|
|
36
37
|
import { getProviderCapabilities } from "../providers/capabilities.js";
|
|
37
38
|
import { getVisionGuidanceForTextOnly } from "../providers/mcp-vision-bridge.js";
|
|
38
39
|
import { captureToolSchemas } from "../providers/patch-zod-schema.js";
|
|
39
|
-
import { buildTurnProviderOptions, requireRuntimeProvider, resolveModelRuntime, resolveTemperatureParam, shouldDropParam, } from "../providers/runtime.js";
|
|
40
|
+
import { buildTurnProviderOptions, factoryForModel, requireRuntimeProvider, resolveModelRuntime, resolveTemperatureParam, shouldDropParam, } from "../providers/runtime.js";
|
|
40
41
|
import { needsVisionProxy } from "../providers/vision-proxy.js";
|
|
41
42
|
import { wireDebug } from "../providers/wire-debug.js";
|
|
42
43
|
import { statusBarStore } from "../state/status-bar-store.js";
|
|
@@ -60,6 +61,35 @@ import { createStallWatchdog, STALL_ERROR_MESSAGE } from "./stall-watchdog.js";
|
|
|
60
61
|
import { wrapToolSetWithCap } from "./sub-agent-cap.js";
|
|
61
62
|
import { applyAnthropicPromptCaching, compactSubAgentMessages } from "./subagent-compactor.js";
|
|
62
63
|
import { combineAbortSignals, firstLine, formatSubagentActivity } from "./tool-utils.js";
|
|
64
|
+
/**
|
|
65
|
+
* Emit one sub-agent diagnostic line (MUONROI_DEBUG_SUBAGENT=1).
|
|
66
|
+
*
|
|
67
|
+
* Writes to the file named by MUONROI_SUBAGENT_DEBUG_LOG when set, else stderr.
|
|
68
|
+
* The file sink exists because stderr is a dead end for the case this
|
|
69
|
+
* diagnostic was written for: under the MCP harness the TUI runs as a child
|
|
70
|
+
* whose stderr nobody reads (opentui-spawn.ts consumes only the fd3/named-pipe
|
|
71
|
+
* stream), so every line went into a pipe no one drains. G1 — "Task failed: No
|
|
72
|
+
* output generated" — was named, given this flag, and never diagnosed, because
|
|
73
|
+
* the diagnostic could not reach whoever turned it on. Mirrors the working
|
|
74
|
+
* MUONROI_COUNCIL_DEBUG_LOG sink in council/llm.ts.
|
|
75
|
+
*/
|
|
76
|
+
export function writeSubagentDebug(enabled, line) {
|
|
77
|
+
if (!enabled)
|
|
78
|
+
return;
|
|
79
|
+
const text = `[subagent] ${line}\n`;
|
|
80
|
+
const path = process.env.MUONROI_SUBAGENT_DEBUG_LOG?.trim();
|
|
81
|
+
if (!path) {
|
|
82
|
+
process.stderr.write(text);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
appendFileSync(path, text);
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
// Never let diagnostics become the failure. Fall back to stderr and say why.
|
|
90
|
+
process.stderr.write(`[subagent] debug-log append failed (path=${path}): ${err instanceof Error ? err.message : String(err)}\n${text}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
63
93
|
/**
|
|
64
94
|
* StreamRunner — extracted sub-agent stream lifecycle.
|
|
65
95
|
*
|
|
@@ -81,7 +111,6 @@ export class StreamRunner {
|
|
|
81
111
|
* Returns either a prepared call or a short-circuit ToolResult.
|
|
82
112
|
*/
|
|
83
113
|
async setup(request, onActivity, signal) {
|
|
84
|
-
const provider = this.deps.getProvider();
|
|
85
114
|
const agentKey = String(request.agent);
|
|
86
115
|
const isExplore = agentKey === "explore";
|
|
87
116
|
const isGeneral = agentKey === "general";
|
|
@@ -189,12 +218,18 @@ export class StreamRunner {
|
|
|
189
218
|
if (childModelId !== topModelId) {
|
|
190
219
|
statusBarStore.setState({ routed_from: topModelId, model: childModelId });
|
|
191
220
|
}
|
|
221
|
+
// The vision branch must build its model from the CHILD model's own factory.
|
|
222
|
+
// It previously called the PARENT session's factory directly, so a sub-agent
|
|
223
|
+
// routed to another provider had its id POSTed to the parent's endpoint.
|
|
192
224
|
const childRuntime = isVision
|
|
193
|
-
? {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
225
|
+
? (() => {
|
|
226
|
+
const childFactory = factoryForModel(childModelId);
|
|
227
|
+
return {
|
|
228
|
+
...resolveModelRuntime(childModelId),
|
|
229
|
+
model: childFactory.responses?.(childModelId) ?? childFactory(childModelId),
|
|
230
|
+
};
|
|
231
|
+
})()
|
|
232
|
+
: resolveModelRuntime(childModelId);
|
|
198
233
|
const taskCaps = getProviderCapabilities(requireRuntimeProvider(childRuntime));
|
|
199
234
|
if (isComputer && !taskCaps.supportsClientTools(childRuntime.modelInfo)) {
|
|
200
235
|
return {
|
|
@@ -321,10 +356,7 @@ export class StreamRunner {
|
|
|
321
356
|
// diagnose "No output generated" / silent task failures (e.g. with
|
|
322
357
|
// gpt-5.4 reasoning models). Disabled by default — zero cost.
|
|
323
358
|
const debugSubagent = process.env.MUONROI_DEBUG_SUBAGENT === "1";
|
|
324
|
-
const debugLog = (
|
|
325
|
-
if (debugSubagent)
|
|
326
|
-
process.stderr.write(`[subagent] ${line}\n`);
|
|
327
|
-
};
|
|
359
|
+
const debugLog = writeSubagentDebug.bind(null, debugSubagent);
|
|
328
360
|
if (debugSubagent) {
|
|
329
361
|
const mi = childRuntime.modelInfo;
|
|
330
362
|
debugLog(`start: model=${childRuntime.modelId} provider=${mi?.provider} reasoning=${mi?.reasoning} thinkingType=${mi?.thinkingType} supportsClientTools=${mi?.supportsClientTools} supportsMaxOutputTokens=${mi?.supportsMaxOutputTokens} agent=${prepared.request.agent}`);
|
|
@@ -789,30 +821,31 @@ export class StreamRunner {
|
|
|
789
821
|
// MUONROI_DEBUG_SUBAGENT=1 so we can see whether `msg` is "No output
|
|
790
822
|
// generated" (AI SDK validation failure on empty assistant response)
|
|
791
823
|
// or a deeper provider error.
|
|
792
|
-
|
|
824
|
+
const debugCatch = process.env.MUONROI_DEBUG_SUBAGENT === "1";
|
|
825
|
+
if (debugCatch) {
|
|
793
826
|
const e = err;
|
|
794
|
-
|
|
827
|
+
writeSubagentDebug(true, `catch: name=${e.name ?? "?"} statusCode=${e.statusCode ?? "?"} message=${(e.message ?? "").slice(0, 400)}`);
|
|
795
828
|
if (e.cause !== undefined) {
|
|
796
829
|
try {
|
|
797
|
-
|
|
830
|
+
writeSubagentDebug(true, `catch.cause=${JSON.stringify(e.cause, Object.getOwnPropertyNames(e.cause)).slice(0, 600)}`);
|
|
798
831
|
}
|
|
799
|
-
catch {
|
|
800
|
-
|
|
832
|
+
catch (jsonErr) {
|
|
833
|
+
writeSubagentDebug(true, `catch.cause(string)=${String(e.cause).slice(0, 400)} [stringify failed: ${jsonErr instanceof Error ? jsonErr.message : String(jsonErr)}]`);
|
|
801
834
|
}
|
|
802
835
|
}
|
|
803
836
|
if (e.responseBody !== undefined) {
|
|
804
|
-
|
|
837
|
+
writeSubagentDebug(true, `catch.responseBody=${String(e.responseBody).slice(0, 600)}`);
|
|
805
838
|
}
|
|
806
839
|
if (e.data !== undefined) {
|
|
807
840
|
try {
|
|
808
|
-
|
|
841
|
+
writeSubagentDebug(true, `catch.data=${JSON.stringify(e.data).slice(0, 400)}`);
|
|
809
842
|
}
|
|
810
|
-
catch {
|
|
811
|
-
|
|
843
|
+
catch (jsonErr) {
|
|
844
|
+
writeSubagentDebug(true, `catch.data(unserializable): ${jsonErr instanceof Error ? jsonErr.message : String(jsonErr)}`);
|
|
812
845
|
}
|
|
813
846
|
}
|
|
814
847
|
if (e.stack)
|
|
815
|
-
|
|
848
|
+
writeSubagentDebug(true, `stack: ${e.stack.split("\n").slice(0, 6).join(" | ")}`);
|
|
816
849
|
}
|
|
817
850
|
const output = `Task failed: ${msg}`;
|
|
818
851
|
return {
|
|
@@ -7,6 +7,7 @@ import type { SessionInfo, StreamChunk, SubagentStatus } from "../types/index.js
|
|
|
7
7
|
import { type PermissionMode } from "../utils/permission-mode.js";
|
|
8
8
|
import type { AbortContext } from "./abort.js";
|
|
9
9
|
import type { LegacyProvider, ProcessMessageObserver } from "./agent-options.js";
|
|
10
|
+
import type { AskUserAskInfo } from "./ask-user.js";
|
|
10
11
|
import type { CouncilManager } from "./council-manager.js";
|
|
11
12
|
import type { CrossTurnDedup } from "./cross-turn-dedup.js";
|
|
12
13
|
import type { PendingCallsLog } from "./pending-calls.js";
|
|
@@ -136,6 +137,8 @@ export interface MessageProcessorDeps extends TurnRunnerDepsBase {
|
|
|
136
137
|
askToolLoopContinue?: ToolLoopCapAsk;
|
|
137
138
|
/** Safety override handler — invoked when a tool call is blocked by the safety filter. */
|
|
138
139
|
askSafetyOverride?: (info: SafetyOverrideAskInfo) => Promise<SafetyOverrideVerdict>;
|
|
140
|
+
/** ask_user handler — invoked when the model calls the `ask_user` tool; resolves the human's answer. */
|
|
141
|
+
askUser?: (info: AskUserAskInfo) => Promise<string>;
|
|
139
142
|
runCouncilV2(userMessage: string, opts: {
|
|
140
143
|
skipClarification: boolean;
|
|
141
144
|
observer?: ProcessMessageObserver;
|
|
@@ -168,6 +171,22 @@ export declare function rewriteSafetyApprovedToolResults<T extends {
|
|
|
168
171
|
role: string;
|
|
169
172
|
content?: any;
|
|
170
173
|
}>(messages: T[]): T[];
|
|
174
|
+
/**
|
|
175
|
+
* Replace the `result` value of a single tool-result part (matched by
|
|
176
|
+
* toolCallId) in an AI-SDK message history, in place, preserving the
|
|
177
|
+
* tool-call/tool-result pairing. Used by the convene_council path to splice the
|
|
178
|
+
* council synthesis into the placeholder tool_result the model saw, so on the
|
|
179
|
+
* restarted step the model reads the conclusion as that tool's result. Pure +
|
|
180
|
+
* exported for unit testing. Returns the same array reference untouched when the
|
|
181
|
+
* toolCallId is absent (with a caller-visible `replaced` flag).
|
|
182
|
+
*/
|
|
183
|
+
export declare function spliceConveneToolResult<T extends {
|
|
184
|
+
role: string;
|
|
185
|
+
content?: any;
|
|
186
|
+
}>(messages: T[], toolCallId: string | null, value: string): {
|
|
187
|
+
messages: T[];
|
|
188
|
+
replaced: boolean;
|
|
189
|
+
};
|
|
171
190
|
export declare class SimpleMutex {
|
|
172
191
|
private queue;
|
|
173
192
|
run<T>(fn: () => Promise<T> | T): Promise<T>;
|
|
@@ -79,6 +79,7 @@ import { createBuiltinTools } from "../tools/registry.js";
|
|
|
79
79
|
import { snapshotFromTodoWriteArgs } from "../tools/todo-write-snapshot.js";
|
|
80
80
|
import { visionToolsNeeded } from "../tools/vision-gate.js";
|
|
81
81
|
import { appendDecisionLog } from "../usage/decision-log.js";
|
|
82
|
+
import { setLoopBreadcrumb } from "../utils/event-loop-monitor.js";
|
|
82
83
|
import { logger } from "../utils/logger.js";
|
|
83
84
|
import { openUrl } from "../utils/open-url.js";
|
|
84
85
|
import { appendAudit, toolNeedsApproval } from "../utils/permission-mode.js";
|
|
@@ -88,9 +89,11 @@ import { resolveShell } from "../utils/shell.js";
|
|
|
88
89
|
import { foldDynamicTailIntoUserMessage, splitFrontAndDynamicTail } from "./cache-prefix.js";
|
|
89
90
|
import { consumeProactiveCompact } from "./compact-request.js";
|
|
90
91
|
import { relaxCompactionSettings } from "./compaction.js";
|
|
92
|
+
import { consumeCouncilConvene, hasPendingCouncilConvene, peekCouncilConveneToolCallId } from "./council-request.js";
|
|
91
93
|
import { wrapToolSetWithDedup } from "./cross-turn-dedup.js";
|
|
92
94
|
import { humanizeApiError, isAuthenticationError, isContextLimitError, summarizeApiErrorForLog } from "./error-utils.js";
|
|
93
95
|
import { buildGroundingFootnote, findUnverifiedClaims } from "./grounding-check.js";
|
|
96
|
+
import { isInteractivePaused } from "./interactive-pause.js";
|
|
94
97
|
import { buildInterruptedTurnNote } from "./interrupted-turn.js";
|
|
95
98
|
import { stableCallId } from "./pending-calls.js";
|
|
96
99
|
import { buildMcpCapabilityBlock, MAX_LLM_CALLS_PER_TURN, } from "./prompts.js";
|
|
@@ -225,6 +228,38 @@ export function rewriteSafetyApprovedToolResults(messages) {
|
|
|
225
228
|
return changed ? { ...m, content: newContent } : m;
|
|
226
229
|
});
|
|
227
230
|
}
|
|
231
|
+
/**
|
|
232
|
+
* Replace the `result` value of a single tool-result part (matched by
|
|
233
|
+
* toolCallId) in an AI-SDK message history, in place, preserving the
|
|
234
|
+
* tool-call/tool-result pairing. Used by the convene_council path to splice the
|
|
235
|
+
* council synthesis into the placeholder tool_result the model saw, so on the
|
|
236
|
+
* restarted step the model reads the conclusion as that tool's result. Pure +
|
|
237
|
+
* exported for unit testing. Returns the same array reference untouched when the
|
|
238
|
+
* toolCallId is absent (with a caller-visible `replaced` flag).
|
|
239
|
+
*/
|
|
240
|
+
export function spliceConveneToolResult(messages, toolCallId, value) {
|
|
241
|
+
if (!toolCallId)
|
|
242
|
+
return { messages, replaced: false };
|
|
243
|
+
let replaced = false;
|
|
244
|
+
const out = messages.map((m) => {
|
|
245
|
+
if (m.role !== "tool" || !Array.isArray(m.content))
|
|
246
|
+
return m;
|
|
247
|
+
let changed = false;
|
|
248
|
+
const newContent = m.content.map((part) => {
|
|
249
|
+
if (part?.type === "tool-result" && part?.toolCallId === toolCallId) {
|
|
250
|
+
changed = true;
|
|
251
|
+
replaced = true;
|
|
252
|
+
// AI SDK v6 tool-result parts carry the value under `output` (typed) or
|
|
253
|
+
// `result` (legacy). Set both so whichever the provider serializer reads
|
|
254
|
+
// sees the synthesis, and clear any error flag.
|
|
255
|
+
return { ...part, isError: false, output: value, result: value };
|
|
256
|
+
}
|
|
257
|
+
return part;
|
|
258
|
+
});
|
|
259
|
+
return changed ? { ...m, content: newContent } : m;
|
|
260
|
+
});
|
|
261
|
+
return { messages: replaced ? out : messages, replaced };
|
|
262
|
+
}
|
|
228
263
|
export class SimpleMutex {
|
|
229
264
|
queue = Promise.resolve();
|
|
230
265
|
async run(fn) {
|
|
@@ -284,6 +319,17 @@ export async function* executeToolEngine(args) {
|
|
|
284
319
|
// (default 6, env MUONROI_TOOL_LIMIT_AUTO_RECOVER_CAP).
|
|
285
320
|
let toolLimitAutoRecoverCount = 0;
|
|
286
321
|
const TOOL_LIMIT_AUTO_RECOVER_CAP = getToolLimitAutoRecoverCap();
|
|
322
|
+
// Convene-council loop guard. The agent may convene the council at most once
|
|
323
|
+
// per turn — a second convene_council call in the SAME turn does NOT re-run a
|
|
324
|
+
// full (5-10min) council; instead its tool-result is a non-binding suggestion
|
|
325
|
+
// to USE the synthesis already above (respond, or ask_user). Turn-scoped (not
|
|
326
|
+
// per-stream-attempt) so a `continue` restart cannot reset it. Live-caught:
|
|
327
|
+
// the model looped convene→synthesis→convene, burning a second council.
|
|
328
|
+
let conveneRunsThisTurn = 0;
|
|
329
|
+
const COUNCIL_MAX_CONVENES_PER_TURN = (() => {
|
|
330
|
+
const raw = Number.parseInt(process.env.MUONROI_MAX_CONVENES_PER_TURN ?? "", 10);
|
|
331
|
+
return Number.isFinite(raw) && raw >= 1 ? raw : 1;
|
|
332
|
+
})();
|
|
287
333
|
let stallTriggered = false;
|
|
288
334
|
// Time-to-first-byte stall RE-PROMPT: some providers (observed:
|
|
289
335
|
// xai/grok-build-0.1) accept the request then never send the first byte —
|
|
@@ -432,18 +478,23 @@ export async function* executeToolEngine(args) {
|
|
|
432
478
|
skipClarification: !isAutoCouncilClarifyEnabled(),
|
|
433
479
|
observer,
|
|
434
480
|
userModelMessage,
|
|
481
|
+
// Suppress the CLI-hardcoded post-debate option card. The follow-up is
|
|
482
|
+
// decided by the agent's own intent via the neutral continuation below,
|
|
483
|
+
// not a fixed CLI menu. (Pre-debate clarification is orthogonal and still
|
|
484
|
+
// runs per skipClarification.)
|
|
485
|
+
convenePath: true,
|
|
435
486
|
});
|
|
436
487
|
const synthesis = deps.councilManager.lastSynthesis;
|
|
437
488
|
const chosenAction = deps.councilManager.lastPostDebateAction;
|
|
438
489
|
deps.councilManager.setLastSynthesis(null);
|
|
439
490
|
deps.councilManager.setLastPostDebateAction(null);
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
//
|
|
443
|
-
//
|
|
444
|
-
//
|
|
445
|
-
const {
|
|
446
|
-
const continuationPrompt = synthesis ?
|
|
491
|
+
// convenePath suppressed the hardcoded card, so there is no chosenAction to
|
|
492
|
+
// branch on. Hand the synthesis to a normal agent turn with a non-binding
|
|
493
|
+
// nudge and let the agent decide the next step (respond / ask_user /
|
|
494
|
+
// implement). Re-entry is guarded by setContinuation(true) below so
|
|
495
|
+
// shouldAutoCouncil (which checks !isContinuation) can't re-fire into a loop.
|
|
496
|
+
const { buildNeutralPostCouncilContinuation } = await import("../council/index.js");
|
|
497
|
+
const continuationPrompt = synthesis ? buildNeutralPostCouncilContinuation(synthesis) || null : null;
|
|
447
498
|
if (continuationPrompt) {
|
|
448
499
|
yield { type: "content", content: "\n[Auto-continuing with council recommendations...]\n" };
|
|
449
500
|
deps.councilManager.setContinuation(true);
|
|
@@ -456,12 +507,10 @@ export async function* executeToolEngine(args) {
|
|
|
456
507
|
}
|
|
457
508
|
return;
|
|
458
509
|
}
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
};
|
|
464
|
-
}
|
|
510
|
+
// Skipping auto-council is the normal, expected path for a reasoning model —
|
|
511
|
+
// not an event the user needs narrated on every turn. The decision (and the
|
|
512
|
+
// gate values behind it) is still recorded via autoCouncilSkipReason above,
|
|
513
|
+
// so forensics keep the full story without the transcript noise.
|
|
465
514
|
if (deps.batchApi) {
|
|
466
515
|
try {
|
|
467
516
|
yield* deps.processMessageBatchTurn({
|
|
@@ -612,15 +661,44 @@ export async function* executeToolEngine(args) {
|
|
|
612
661
|
sessionId: deps.session?.id,
|
|
613
662
|
includeVisionTools,
|
|
614
663
|
consultParentSession: deps.consultParentSession,
|
|
664
|
+
// Register convene_council only when the council is actually usable
|
|
665
|
+
// for this session (enough configured roles) — else the model could
|
|
666
|
+
// call a council that can't convene.
|
|
667
|
+
councilConfigured: configuredRoleCount >= autoCouncilMinRoles,
|
|
668
|
+
askUser: deps.askUser,
|
|
615
669
|
runDebate: async (topic) => {
|
|
670
|
+
// Reset before draining so a generator that throws BEFORE setting
|
|
671
|
+
// synthesis (orchestrator.setLastSynthesis) cannot return a STALE
|
|
672
|
+
// synthesis from a prior council run.
|
|
673
|
+
deps.councilManager.setLastSynthesis(null);
|
|
616
674
|
const gen = deps.runCouncilV2(topic, {
|
|
617
675
|
skipClarification: true,
|
|
618
676
|
userModelMessage: { role: "user", content: `/council ${topic}` },
|
|
677
|
+
// Model-callable debate: the synthesis is returned to the model as
|
|
678
|
+
// the tool result and the model decides the follow-up — so suppress
|
|
679
|
+
// the CLI-hardcoded post-debate card, same as convene_council.
|
|
680
|
+
convenePath: true,
|
|
619
681
|
});
|
|
682
|
+
// Capture a tail of content chunks so an empty-synthesis failure
|
|
683
|
+
// (provider unreachable / sub-phase fail-open / abort — all of which
|
|
684
|
+
// yield a content hint then `return null`) surfaces a real reason in
|
|
685
|
+
// the log instead of a silent "".
|
|
686
|
+
let lastContentHint = "";
|
|
620
687
|
for await (const chunk of gen) {
|
|
621
|
-
|
|
688
|
+
const text = chunk?.content;
|
|
689
|
+
if (chunk?.type === "content" && typeof text === "string" && text.trim()) {
|
|
690
|
+
lastContentHint = text.trim().slice(-200);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
const synthesis = deps.councilManager.lastSynthesis ?? "";
|
|
694
|
+
if (!synthesis.trim()) {
|
|
695
|
+
// No-Silent-Catch: the debate produced no synthesis. plan-council
|
|
696
|
+
// will retry then fall back to the perspective path — log why here
|
|
697
|
+
// so the failure is diagnosable remotely.
|
|
698
|
+
console.error(`[tool-engine] plan-review runDebate returned empty synthesis` +
|
|
699
|
+
`${lastContentHint ? ` (last council chunk: ${lastContentHint})` : ""}`);
|
|
622
700
|
}
|
|
623
|
-
return
|
|
701
|
+
return synthesis;
|
|
624
702
|
},
|
|
625
703
|
});
|
|
626
704
|
// Top-level cumulative cap state. We accumulate the raw tool set
|
|
@@ -799,6 +877,14 @@ export async function* executeToolEngine(args) {
|
|
|
799
877
|
"ee_feedback",
|
|
800
878
|
"ee_write",
|
|
801
879
|
]);
|
|
880
|
+
/**
|
|
881
|
+
* Tools that perform mutations (writes, edits, side-effects).
|
|
882
|
+
* MUTATION_TOOLS are read from this set to route them through the mutation
|
|
883
|
+
* gate and prevent accidental execution when GSD hard gate is active.
|
|
884
|
+
* Registration: add the tool name string to this set and ensure the tool
|
|
885
|
+
* itself is wired in the tool assembly step.
|
|
886
|
+
*/
|
|
887
|
+
const MUTATION_TOOLS = new Set(["lsp_mutation_preview"]);
|
|
802
888
|
// Task 8: native GSD mutation gate. Read once per turn (not per call) since
|
|
803
889
|
// hardGateEnabled/directAnswer don't change mid-turn; the gate itself
|
|
804
890
|
// re-reads STATE.md per call (cheap fs read) so it stays live if the
|
|
@@ -807,12 +893,23 @@ export async function* executeToolEngine(args) {
|
|
|
807
893
|
const gsdDirectAnswer = pilCtx.directAnswer;
|
|
808
894
|
for (const name of Object.keys(tools)) {
|
|
809
895
|
const tool = tools[name];
|
|
810
|
-
if (tool
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
896
|
+
if (!tool || typeof tool.execute !== "function")
|
|
897
|
+
continue;
|
|
898
|
+
const originalExecute = tool.execute;
|
|
899
|
+
// Read-only tools skip the mutation gate and the write mutex, but ALL
|
|
900
|
+
// tools get a breadcrumb: a freeze can start under any of them, and a
|
|
901
|
+
// block report that cannot name a suspect is much weaker evidence.
|
|
902
|
+
const guarded = !READ_ONLY_TOOLS.has(name) && !name.startsWith("respond_");
|
|
903
|
+
tool.execute = async (input, context) => {
|
|
904
|
+
// Never cleared, only overwritten — a block often starts just AFTER
|
|
905
|
+
// a tool returns (the 2026-07-16 freeze began on the tick after a
|
|
906
|
+
// bash result), so "after:bash" is exactly the clue we want. Also
|
|
907
|
+
// why this is not a stack: with parallel tool calls, last-writer-
|
|
908
|
+
// wins is the honest summary; the CPU profile is the real evidence.
|
|
909
|
+
setLoopBreadcrumb(`tool:${name}`);
|
|
910
|
+
try {
|
|
911
|
+
if (!guarded)
|
|
912
|
+
return await originalExecute(input, context);
|
|
816
913
|
const gate = evaluateMutationGate(deps.bash.getCwd(), {
|
|
817
914
|
toolName: name,
|
|
818
915
|
hardGateEnabled: gsdHardGateEnabled,
|
|
@@ -821,9 +918,12 @@ export async function* executeToolEngine(args) {
|
|
|
821
918
|
if (gate.blocked) {
|
|
822
919
|
return { success: false, output: gate.reason, error: gate.reason };
|
|
823
920
|
}
|
|
824
|
-
return writeMutex.run(() => originalExecute(input, context));
|
|
825
|
-
}
|
|
826
|
-
|
|
921
|
+
return await writeMutex.run(() => originalExecute(input, context));
|
|
922
|
+
}
|
|
923
|
+
finally {
|
|
924
|
+
setLoopBreadcrumb(`after-tool:${name}`);
|
|
925
|
+
}
|
|
926
|
+
};
|
|
827
927
|
}
|
|
828
928
|
captureToolSchemas(tools);
|
|
829
929
|
let responseToolCalled = false;
|
|
@@ -1225,6 +1325,14 @@ export async function* executeToolEngine(args) {
|
|
|
1225
1325
|
_hardCapHit = true;
|
|
1226
1326
|
return true;
|
|
1227
1327
|
}
|
|
1328
|
+
// convene_council fast-path: if the model queued a council convening
|
|
1329
|
+
// during this step, STOP now so the outer loop runs the council and
|
|
1330
|
+
// splices the synthesis BEFORE the model consumes the placeholder
|
|
1331
|
+
// tool_result. This is a fast-path only — the authoritative
|
|
1332
|
+
// consumption is the outer-loop check after the stream drains (a
|
|
1333
|
+
// phase-1 SAMR step ends on stepCountIs(1) and never reaches here).
|
|
1334
|
+
if (hasPendingCouncilConvene())
|
|
1335
|
+
return true;
|
|
1228
1336
|
// Terminal response tool: a `respond_*` call IS the model's final
|
|
1229
1337
|
// structured answer (its `execute` is identity — the payload lives
|
|
1230
1338
|
// in the tool-call args). `shouldHaltOnResponseTool` decides if the
|
|
@@ -1337,7 +1445,9 @@ export async function* executeToolEngine(args) {
|
|
|
1337
1445
|
console.error(`[tool-engine] stream aborted: no text/tool output for ${getProviderProgressTimeoutMs()}ms ` +
|
|
1338
1446
|
`(runaway reasoning / no forward progress) model=${runtime.modelId}`);
|
|
1339
1447
|
},
|
|
1340
|
-
}
|
|
1448
|
+
},
|
|
1449
|
+
// Hold the stream open while a blocking `ask_user` card awaits a human.
|
|
1450
|
+
isInteractivePaused);
|
|
1341
1451
|
// F3c — hard-cap LLM calls per turn before this streamText()
|
|
1342
1452
|
if (++llmCallsThisTurn > MAX_LLM_CALLS_PER_TURN) {
|
|
1343
1453
|
stall.dispose();
|
|
@@ -1808,6 +1918,9 @@ export async function* executeToolEngine(args) {
|
|
|
1808
1918
|
const _wireProviderIdTop = runtime.modelInfo?.provider ?? "unknown";
|
|
1809
1919
|
for await (const part of result.fullStream) {
|
|
1810
1920
|
stall.pet(); // chunk arrived — reset the stall watchdog
|
|
1921
|
+
// Breadcrumb the chunk type: if the loop blocks while draining the
|
|
1922
|
+
// stream, this says which part kind we were handling when it froze.
|
|
1923
|
+
setLoopBreadcrumb(`stream:${String(part.type ?? "unknown")}`);
|
|
1811
1924
|
// Count only real content parts. The watchdog abort itself surfaces
|
|
1812
1925
|
// as an "abort" part — counting it would defeat the TTFB-stall gate
|
|
1813
1926
|
// (a frozen-before-first-byte stall yields ONLY the abort part).
|
|
@@ -2861,6 +2974,109 @@ export async function* executeToolEngine(args) {
|
|
|
2861
2974
|
}
|
|
2862
2975
|
}
|
|
2863
2976
|
stall.dispose(); // stream drained normally — stop the stall watchdog
|
|
2977
|
+
// ─── convene_council consumption ───────────────────────────────
|
|
2978
|
+
// The convene_council tool queued a request during THIS step's execute().
|
|
2979
|
+
// Consume it here in the OUTER loop after every stream drain — NOT solely
|
|
2980
|
+
// via dynamicStopWhen, because a phase-1 SAMR step ends on stepCountIs(1)
|
|
2981
|
+
// and never evaluates the stop hook (design-debate BUG 2). Runs the
|
|
2982
|
+
// council autonomously (convenePath suppresses ALL post-debate decision
|
|
2983
|
+
// surface — no card, no continuation), splices the synthesis into the
|
|
2984
|
+
// convene tool_result, grafts into deps.messages, and restarts the step
|
|
2985
|
+
// so the model reads the conclusion as the tool result and continues.
|
|
2986
|
+
if (hasPendingCouncilConvene()) {
|
|
2987
|
+
if (responseToolCalled) {
|
|
2988
|
+
// The same step also emitted a terminal respond_* answer — the model
|
|
2989
|
+
// is done; discard the convene request rather than override the
|
|
2990
|
+
// answer (design-debate BUG 1: never let the flag leak across turns).
|
|
2991
|
+
consumeCouncilConvene();
|
|
2992
|
+
logger.warn("orchestrator", "convene: discarded — step also emitted a terminal response tool");
|
|
2993
|
+
}
|
|
2994
|
+
else {
|
|
2995
|
+
const pendingId = peekCouncilConveneToolCallId();
|
|
2996
|
+
let conveneResponse = null;
|
|
2997
|
+
try {
|
|
2998
|
+
conveneResponse = await result.response;
|
|
2999
|
+
}
|
|
3000
|
+
catch (err) {
|
|
3001
|
+
logger.error("orchestrator", "convene: failed to read response.messages", {
|
|
3002
|
+
error: err?.message,
|
|
3003
|
+
});
|
|
3004
|
+
}
|
|
3005
|
+
// BUG 3 guard: only run council when the pending convene toolCallId is
|
|
3006
|
+
// a recorded tool-result in THIS drain's messages — else a nested
|
|
3007
|
+
// frame's convene call would be wrongly consumed by this loop.
|
|
3008
|
+
const belongsHere = !!conveneResponse &&
|
|
3009
|
+
!!pendingId &&
|
|
3010
|
+
conveneResponse.messages.some((m) => m?.role === "tool" &&
|
|
3011
|
+
Array.isArray(m.content) &&
|
|
3012
|
+
m.content.some((p) => p?.type === "tool-result" && p?.toolCallId === pendingId));
|
|
3013
|
+
if (belongsHere && conveneResponse) {
|
|
3014
|
+
const req = consumeCouncilConvene();
|
|
3015
|
+
// Loop guard: a second convene in the SAME turn short-circuits —
|
|
3016
|
+
// do NOT pay for another council. Splice a non-binding suggestion
|
|
3017
|
+
// pointing the model at the synthesis already in the transcript so
|
|
3018
|
+
// it responds (or asks the user) instead of re-convening forever.
|
|
3019
|
+
if (conveneRunsThisTurn >= COUNCIL_MAX_CONVENES_PER_TURN) {
|
|
3020
|
+
logger.warn("orchestrator", "convene: loop guard hit — suppressing re-convene", {
|
|
3021
|
+
conveneRunsThisTurn,
|
|
3022
|
+
cap: COUNCIL_MAX_CONVENES_PER_TURN,
|
|
3023
|
+
});
|
|
3024
|
+
const suggestion = "[The council already convened this turn — its synthesis is in the transcript above. " +
|
|
3025
|
+
"Do NOT convene again for the same question. Use that synthesis now: give the user your " +
|
|
3026
|
+
"recommendation, or call ask_user if you need their go-ahead before implementing.]";
|
|
3027
|
+
const { messages: guardSpliced, replaced: guardReplaced } = spliceConveneToolResult(conveneResponse.messages, req?.toolCallId ?? null, suggestion);
|
|
3028
|
+
if (!guardReplaced) {
|
|
3029
|
+
logger.warn("orchestrator", "convene: loop-guard splice found no matching toolCallId", {
|
|
3030
|
+
toolCallId: req?.toolCallId ?? null,
|
|
3031
|
+
});
|
|
3032
|
+
}
|
|
3033
|
+
const guardNewMsgs = guardSpliced.slice(deps.messages.length);
|
|
3034
|
+
for (const msg of guardNewMsgs)
|
|
3035
|
+
deps.messages.push(msg);
|
|
3036
|
+
continue; // re-enter with the suggestion as the tool result
|
|
3037
|
+
}
|
|
3038
|
+
conveneRunsThisTurn++;
|
|
3039
|
+
yield { type: "content", content: "\n[Convening the council…]\n" };
|
|
3040
|
+
// Filter the terminal `done` runCouncilV2 emits on its
|
|
3041
|
+
// non-continuation path (orchestrator.ts ~2274): letting it through
|
|
3042
|
+
// would finalize the UI turn BEFORE we restart streamText below, so
|
|
3043
|
+
// the model's post-council continuation would be orphaned/invisible
|
|
3044
|
+
// (live-caught: council ran, synthesis produced, but no final answer
|
|
3045
|
+
// rendered). We continue the SAME turn ourselves via the splice +
|
|
3046
|
+
// `continue` restart, so the council's `done` must not propagate.
|
|
3047
|
+
for await (const chunk of deps.runCouncilV2(userMessage, {
|
|
3048
|
+
convenePath: true,
|
|
3049
|
+
skipClarification: true,
|
|
3050
|
+
observer,
|
|
3051
|
+
userModelMessage,
|
|
3052
|
+
})) {
|
|
3053
|
+
if (chunk.type === "done")
|
|
3054
|
+
continue;
|
|
3055
|
+
yield chunk;
|
|
3056
|
+
}
|
|
3057
|
+
const synthesis = deps.councilManager.lastSynthesis;
|
|
3058
|
+
const spliceValue = synthesis && synthesis.trim().length > 0
|
|
3059
|
+
? synthesis
|
|
3060
|
+
: `[Council could not produce a conclusion${req?.reason ? ` for: ${req.reason}` : ""}. Proceed using your own judgment.]`;
|
|
3061
|
+
if (!synthesis || synthesis.trim().length === 0) {
|
|
3062
|
+
logger.warn("orchestrator", "convene: council returned no synthesis", { reason: req?.reason ?? null });
|
|
3063
|
+
}
|
|
3064
|
+
const { messages: spliced, replaced } = spliceConveneToolResult(conveneResponse.messages, req?.toolCallId ?? null, spliceValue);
|
|
3065
|
+
if (!replaced) {
|
|
3066
|
+
logger.warn("orchestrator", "convene: tool_result splice found no matching toolCallId", {
|
|
3067
|
+
toolCallId: req?.toolCallId ?? null,
|
|
3068
|
+
});
|
|
3069
|
+
}
|
|
3070
|
+
// Graft the new messages (assistant tool-call + spliced tool-result)
|
|
3071
|
+
// into deps.messages, then restart the step so the model reads the
|
|
3072
|
+
// synthesis as the convene tool's result (SAMR restart precedent).
|
|
3073
|
+
const newMsgs = spliced.slice(deps.messages.length);
|
|
3074
|
+
for (const msg of newMsgs)
|
|
3075
|
+
deps.messages.push(msg);
|
|
3076
|
+
continue; // re-enter the loop with the spliced history
|
|
3077
|
+
}
|
|
3078
|
+
}
|
|
3079
|
+
}
|
|
2864
3080
|
// ─── SAMR Phase 1 → Phase 2 transition ─────────────────────────
|
|
2865
3081
|
// Phase 1 (premium model) produced tool calls but the SDK stopped
|
|
2866
3082
|
// before executing them (stopWhen: stepCountIs(1)). Append the
|
|
@@ -33,5 +33,12 @@ export interface TurnWatchdogOptions {
|
|
|
33
33
|
totalMs: number;
|
|
34
34
|
/** Human label used in the thrown error (e.g. "council continuation turn"). */
|
|
35
35
|
label: string;
|
|
36
|
+
/**
|
|
37
|
+
* Optional gate consulted the instant a timer would fire. When it returns
|
|
38
|
+
* true the timer RE-ARMS instead of throwing — used to hold the turn open
|
|
39
|
+
* while a blocking `ask_user` card awaits a human (no chunks flow, but the
|
|
40
|
+
* turn is not hung). See interactive-pause.ts.
|
|
41
|
+
*/
|
|
42
|
+
shouldSuppressFire?: () => boolean;
|
|
36
43
|
}
|
|
37
44
|
export declare function withTurnWatchdog(gen: AsyncGenerator<StreamChunk, void, unknown>, opts: TurnWatchdogOptions): AsyncGenerator<StreamChunk, void, unknown>;
|
|
@@ -7,15 +7,23 @@ export class TurnStallError extends Error {
|
|
|
7
7
|
}
|
|
8
8
|
}
|
|
9
9
|
export async function* withTurnWatchdog(gen, opts) {
|
|
10
|
-
const { idleMs, totalMs, label } = opts;
|
|
10
|
+
const { idleMs, totalMs, label, shouldSuppressFire } = opts;
|
|
11
11
|
const it = gen[Symbol.asyncIterator]();
|
|
12
12
|
let totalTimer;
|
|
13
13
|
const total = totalMs > 0
|
|
14
14
|
? new Promise((_, reject) => {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
const armTotal = () => {
|
|
16
|
+
totalTimer = setTimeout(() => {
|
|
17
|
+
// Hold open while an interactive card blocks the turn.
|
|
18
|
+
if (shouldSuppressFire?.()) {
|
|
19
|
+
armTotal();
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
reject(new TurnStallError("total", `${label} exceeded ${Math.round(totalMs / 1000)}s total watchdog — treated as hung`));
|
|
23
|
+
}, totalMs);
|
|
24
|
+
totalTimer.unref?.();
|
|
25
|
+
};
|
|
26
|
+
armTotal();
|
|
19
27
|
})
|
|
20
28
|
: null;
|
|
21
29
|
try {
|
|
@@ -23,10 +31,17 @@ export async function* withTurnWatchdog(gen, opts) {
|
|
|
23
31
|
let idleTimer;
|
|
24
32
|
const idle = idleMs > 0
|
|
25
33
|
? new Promise((_, reject) => {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
34
|
+
const armIdle = () => {
|
|
35
|
+
idleTimer = setTimeout(() => {
|
|
36
|
+
if (shouldSuppressFire?.()) {
|
|
37
|
+
armIdle();
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
reject(new TurnStallError("idle", `${label} produced no output for ${Math.round(idleMs / 1000)}s — treated as hung`));
|
|
41
|
+
}, idleMs);
|
|
42
|
+
idleTimer.unref?.();
|
|
43
|
+
};
|
|
44
|
+
armIdle();
|
|
30
45
|
})
|
|
31
46
|
: null;
|
|
32
47
|
const racers = [it.next()];
|
|
@@ -38,6 +53,20 @@ export async function* withTurnWatchdog(gen, opts) {
|
|
|
38
53
|
try {
|
|
39
54
|
res = await Promise.race(racers);
|
|
40
55
|
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
// Stall (idle/total) fired while `it.next()` is still pending: the inner
|
|
58
|
+
// generator is suspended at an `await` and, unless told to unwind, its
|
|
59
|
+
// finally blocks (write-mutex release, in-flight council/tool cleanup)
|
|
60
|
+
// NEVER run — the turn "ends" for the UI but leaks a wedged generator
|
|
61
|
+
// that can block the NEXT turn (observed live: council reasoning-model
|
|
62
|
+
// hang, session c1d461439618 — user had to Ctrl+C and relaunch).
|
|
63
|
+
// Signal it to return. Fire-and-forget on purpose: the queued return
|
|
64
|
+
// only settles once the CALLER aborts its controller (in its catch,
|
|
65
|
+
// AFTER we rethrow), which settles the hung provider call — awaiting the
|
|
66
|
+
// return here would deadlock against that ordering.
|
|
67
|
+
void it.return?.(undefined).catch(() => { });
|
|
68
|
+
throw err;
|
|
69
|
+
}
|
|
41
70
|
finally {
|
|
42
71
|
if (idleTimer)
|
|
43
72
|
clearTimeout(idleTimer);
|