mixdog 0.9.40 → 0.9.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/package.json +2 -1
  2. package/scripts/agent-tag-reuse-smoke.mjs +124 -10
  3. package/scripts/anthropic-oauth-refresh-race-test.mjs +397 -0
  4. package/scripts/arg-guard-test.mjs +16 -0
  5. package/scripts/compact-pressure-test.mjs +383 -0
  6. package/scripts/compact-trigger-migration-smoke.mjs +8 -8
  7. package/scripts/internal-comms-bench.mjs +0 -1
  8. package/scripts/internal-comms-smoke.mjs +54 -31
  9. package/scripts/internal-tools-normalization-test.mjs +52 -0
  10. package/scripts/max-output-recovery-persist-test.mjs +81 -0
  11. package/scripts/max-output-recovery-test.mjs +271 -0
  12. package/scripts/mcp-client-normalization-test.mjs +45 -0
  13. package/scripts/provider-toolcall-test.mjs +55 -0
  14. package/scripts/result-classification-test.mjs +75 -0
  15. package/scripts/smoke.mjs +1 -1
  16. package/scripts/submit-commandbusy-race-test.mjs +99 -7
  17. package/scripts/tui-transcript-perf-test.mjs +56 -1
  18. package/src/agents/reviewer/AGENT.md +8 -0
  19. package/src/rules/lead/lead-brief.md +11 -3
  20. package/src/rules/lead/lead-tool.md +0 -2
  21. package/src/rules/shared/01-tool.md +4 -0
  22. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +20 -2
  23. package/src/runtime/agent/orchestrator/context/collect.mjs +7 -3
  24. package/src/runtime/agent/orchestrator/internal-tools.mjs +16 -3
  25. package/src/runtime/agent/orchestrator/mcp/client.mjs +17 -1
  26. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +193 -13
  27. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +15 -2
  28. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +14 -0
  29. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +2 -2
  30. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +59 -2
  31. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +57 -25
  32. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +72 -12
  33. package/src/runtime/agent/orchestrator/session/context-utils.mjs +144 -84
  34. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +124 -8
  35. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +12 -1
  36. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +25 -7
  37. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +12 -3
  38. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +28 -1
  39. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -1
  40. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +31 -4
  41. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +11 -2
  42. package/src/runtime/agent/orchestrator/session/result-classification.mjs +4 -1
  43. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +45 -0
  44. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +7 -2
  45. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +8 -6
  46. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +15 -3
  47. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +22 -21
  48. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
  49. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +1 -1
  50. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -10
  51. package/src/runtime/memory/tool-defs.mjs +4 -3
  52. package/src/runtime/shared/tool-surface.mjs +1 -2
  53. package/src/session-runtime/context-status.mjs +8 -2
  54. package/src/standalone/agent-tool/render.mjs +2 -0
  55. package/src/standalone/agent-tool.mjs +202 -22
  56. package/src/tui/components/StatusLine.jsx +4 -26
  57. package/src/tui/dist/index.mjs +46 -31
  58. package/src/tui/engine/session-api.mjs +3 -0
  59. package/src/tui/engine/session-flow.mjs +19 -8
  60. package/src/tui/engine/turn.mjs +24 -2
  61. package/src/tui/engine.mjs +0 -1
  62. package/src/ui/statusline-agents.mjs +0 -8
  63. package/src/ui/statusline.mjs +2 -4
  64. package/src/workflows/default/WORKFLOW.md +38 -24
  65. package/src/workflows/solo-review/WORKFLOW.md +47 -0
  66. package/src/workflows/bench/WORKFLOW.md +0 -36
@@ -7,12 +7,39 @@ import { createEngineApiA } from '../src/tui/engine/session-api.mjs';
7
7
  // command (commandBusy) or an auto-clear. Wires the real session-flow queue
8
8
  // (enqueue/drain) + the real submit() against a minimal bag whose set() mirrors
9
9
  // engine.mjs's commandBusy-release drain kick.
10
- function makeEngine({ autoClearBeforeSubmit } = {}) {
10
+ function makeEngine({
11
+ autoClearBeforeSubmit,
12
+ autoClearEnabled = false,
13
+ runtimeClear = async () => true,
14
+ } = {}) {
11
15
  let seq = 0;
12
16
  const executed = [];
13
- let state = { queued: [], busy: false, commandBusy: false };
17
+ const providerRequests = [];
18
+ let resolveProviderDispatch;
19
+ const providerDispatch = new Promise((resolve) => { resolveProviderDispatch = resolve; });
20
+ let state = {
21
+ items: [{ kind: 'user', id: 'existing', text: 'existing prompt' }],
22
+ queued: [],
23
+ busy: false,
24
+ commandBusy: false,
25
+ };
14
26
  const bag = {
15
- runtime: { id: null, consumePendingSessionReset: () => null },
27
+ runtime: {
28
+ id: 'session_1',
29
+ session: {
30
+ id: 'session_1',
31
+ messages: [{ role: 'user', content: 'existing prompt' }],
32
+ lastUsedAt: Date.now() - 1000,
33
+ },
34
+ getCompactionSettings: () => ({}),
35
+ clear: runtimeClear,
36
+ consumePendingSessionReset: () => null,
37
+ ask: async (text) => {
38
+ providerRequests.push(text);
39
+ resolveProviderDispatch(text);
40
+ return { result: { content: 'ok' }, session: bag.runtime.session };
41
+ },
42
+ },
16
43
  nextId: () => `id_${++seq}`,
17
44
  tuiDebug: () => {},
18
45
  flags: {},
@@ -30,12 +57,15 @@ function makeEngine({ autoClearBeforeSubmit } = {}) {
30
57
  if (released) queueMicrotask(() => { void bag.drain?.(); });
31
58
  return true;
32
59
  },
33
- pushItem: () => {},
60
+ pushItem: (item) => { state = { ...state, items: [...state.items, item] }; },
34
61
  patchItem: () => {},
35
62
  replaceItems: (x) => x,
36
63
  pushNotice: () => {},
37
- pushUserOrSyntheticItem: (text) => { executed.push(text); },
38
- autoClearState: () => ({ enabled: false }),
64
+ pushUserOrSyntheticItem: (text, id) => {
65
+ executed.push(text);
66
+ state = { ...state, items: [...state.items, { kind: 'user', id, text }] };
67
+ },
68
+ autoClearState: () => ({ enabled: autoClearEnabled, idleMs: 0, minContextPercent: 0 }),
39
69
  agentStatusState: () => ({}),
40
70
  routeState: () => ({}),
41
71
  syncContextStats: () => {},
@@ -44,7 +74,10 @@ function makeEngine({ autoClearBeforeSubmit } = {}) {
44
74
  requeueEntriesFront: () => {},
45
75
  resetStatsAndSyncContext: () => {},
46
76
  flushDeferredExecutionPendingResumeKick: () => {},
47
- runTurn: async () => 'ok',
77
+ runTurn: async (text) => {
78
+ await bag.runtime.ask(text);
79
+ return 'ok';
80
+ },
48
81
  };
49
82
  Object.assign(bag, createSessionFlow(bag));
50
83
  if (autoClearBeforeSubmit) bag.autoClearBeforeSubmit = autoClearBeforeSubmit;
@@ -53,6 +86,14 @@ function makeEngine({ autoClearBeforeSubmit } = {}) {
53
86
  api,
54
87
  bag,
55
88
  getExecuted: () => executed,
89
+ getProviderRequests: () => providerRequests,
90
+ waitForProviderDispatch: (timeoutMs = 250) => new Promise((resolve, reject) => {
91
+ const timer = setTimeout(() => reject(new Error(`provider dispatch timed out after ${timeoutMs}ms`)), timeoutMs);
92
+ providerDispatch.then((text) => {
93
+ clearTimeout(timer);
94
+ resolve(text);
95
+ }, reject);
96
+ }),
56
97
  getState: () => bag.getState(),
57
98
  mutateState: (mutator) => { mutator(state); },
58
99
  };
@@ -107,6 +148,57 @@ test('submit still enqueues when autoClearBeforeSubmit rejects', async () => {
107
148
  assert.deepEqual(getExecuted(), ['survives rejection'], 'rejected auto-clear must not lose the prompt');
108
149
  });
109
150
 
151
+ test('runtime clear false skips auto-clear and sends the first post-failure prompt to the provider', async () => {
152
+ const {
153
+ api, bag, getExecuted, getProviderRequests, getState,
154
+ } = makeEngine({ autoClearEnabled: true, runtimeClear: async () => false });
155
+
156
+ assert.equal(api.submit('first after failed auto-clear'), true);
157
+ await tick(); await tick();
158
+
159
+ assert.deepEqual(getExecuted(), ['first after failed auto-clear'], 'user card remains visible');
160
+ assert.deepEqual(getProviderRequests(), ['first after failed auto-clear'], 'first post-failure input reaches provider');
161
+ assert.equal(getState().items.some((item) => item.label === 'Auto-clear skipped'), true);
162
+ assert.equal(getState().items.some((item) => item.label === 'Auto-clear complete'), false);
163
+ assert.equal(getState().items.some((item) => item.id === 'existing'), true, 'failed clear preserves the existing session UI');
164
+ assert.equal(bag.runtime.session.messages[0].content, 'existing prompt', 'failed clear preserves the runtime session');
165
+ });
166
+
167
+ test('late runtime clear false keeps the UI skipped and sends the first post-timeout prompt', async () => {
168
+ let resolveClear;
169
+ let harness;
170
+ const autoClearBeforeSubmit = () => harness.bag.performSessionClear({
171
+ verb: 'Auto-clearing idle conversation',
172
+ doneLabel: 'Auto-clear complete',
173
+ skipLabel: 'Auto-clear skipped',
174
+ surface: 'auto-clear',
175
+ useCompaction: true,
176
+ compactTimeoutMs: 5,
177
+ });
178
+ harness = makeEngine({
179
+ autoClearBeforeSubmit,
180
+ runtimeClear: () => new Promise((resolve) => { resolveClear = resolve; }),
181
+ });
182
+ harness.bag.runtime.getCompactionSettings = () => ({ compactType: 'summary' });
183
+
184
+ assert.equal(harness.api.submit('first after timed-out auto-clear'), true);
185
+ await harness.waitForProviderDispatch();
186
+ assert.equal(typeof resolveClear, 'function', 'compacting clear started');
187
+ assert.deepEqual(
188
+ harness.getProviderRequests(),
189
+ ['first after timed-out auto-clear'],
190
+ 'first post-timeout input reaches provider before late clear settles',
191
+ );
192
+
193
+ resolveClear(false);
194
+ await tick(); await tick();
195
+
196
+ assert.equal(harness.getState().items.some((item) => item.label === 'Auto-clear skipped'), true);
197
+ assert.equal(harness.getState().items.some((item) => item.label === 'Auto-clear complete'), false);
198
+ assert.equal(harness.getState().items.some((item) => item.id === 'existing'), true, 'late false preserves the existing session UI');
199
+ assert.equal(harness.bag.runtime.session.messages[0].content, 'existing prompt', 'late false preserves the runtime session');
200
+ });
201
+
110
202
  test('empty and whitespace submits are still rejected', () => {
111
203
  const { api, bag } = makeEngine();
112
204
  assert.equal(api.submit(' '), false);
@@ -8,7 +8,7 @@ import { buildTranscriptRowIndexIncremental } from '../src/tui/app/transcript-wi
8
8
 
9
9
  const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
10
10
 
11
- function makeTurnHarness(ask) {
11
+ function makeTurnHarness(ask, stateOverrides = {}) {
12
12
  let seq = 0;
13
13
  let state = {
14
14
  items: [],
@@ -18,6 +18,7 @@ function makeTurnHarness(ask) {
18
18
  busy: false,
19
19
  spinner: null,
20
20
  thinking: null,
21
+ ...stateOverrides,
21
22
  };
22
23
  const itemIndexById = new Map();
23
24
  const set = (patch) => { state = { ...state, ...patch }; return true; };
@@ -43,6 +44,15 @@ function makeTurnHarness(ask) {
43
44
  const clearStreamingTail = (id = null) => {
44
45
  if (id == null || state.streamingTail?.id === id) set({ streamingTail: null });
45
46
  };
47
+ const replaceItems = (items, options = {}) => {
48
+ state = replaceEngineItemsState({
49
+ state,
50
+ items,
51
+ itemIndexById,
52
+ preserveStreamingTail: options.preserveStreamingTail === true,
53
+ });
54
+ return items;
55
+ };
46
56
  const bag = {
47
57
  runtime: { id: null, toolMode: 'auto', ask, abort: () => true },
48
58
  nextId: () => `id_${++seq}`,
@@ -55,6 +65,7 @@ function makeTurnHarness(ask) {
55
65
  set,
56
66
  pushItem,
57
67
  patchItem,
68
+ replaceItems,
58
69
  updateStreamingTail,
59
70
  settleStreamingTail,
60
71
  clearStreamingTail,
@@ -77,6 +88,50 @@ function makeTurnHarness(ask) {
77
88
  return { runTurn: createRunTurn(bag), getState: () => state };
78
89
  }
79
90
 
91
+ test('successful mid-turn compact trims history but preserves live turn references', async () => {
92
+ let preservedTail = false;
93
+ const harness = makeTurnHarness(async (_text, options) => {
94
+ options.onTextDelta('before compact\n');
95
+ await wait(30);
96
+ options.onCompactEvent({ status: 'compacted', trigger: 'reactive' });
97
+ preservedTail = harness.getState().streamingTail?.text === 'before compact\n';
98
+ options.onCompactEvent({ status: 'compacted', trigger: 'reactive' });
99
+ options.onTextDelta('after compact\n');
100
+ return { result: { content: 'before compact\nafter compact\n' }, session: { messages: [] } };
101
+ }, {
102
+ items: [
103
+ { id: 'old', kind: 'assistant', text: 'old history' },
104
+ { id: 'current-user', kind: 'user', text: 'go' },
105
+ ],
106
+ });
107
+
108
+ assert.equal(await harness.runTurn('go', { submittedIds: ['current-user'] }), 'done');
109
+ assert.equal(preservedTail, true);
110
+ assert.equal(harness.getState().items.some((item) => item.id === 'old'), false);
111
+ assert.equal(harness.getState().items.some((item) => item.id === 'current-user'), true);
112
+ assert.equal(
113
+ harness.getState().items.filter((item) => item.label === 'Compact complete (overflow recovery)').length,
114
+ 2,
115
+ );
116
+ assert.equal(
117
+ harness.getState().items.find((item) => item.kind === 'assistant')?.text,
118
+ 'before compact\nafter compact\n',
119
+ );
120
+ });
121
+
122
+ test('failed mid-turn compact leaves prior transcript items untouched', async () => {
123
+ const harness = makeTurnHarness(async (_text, options) => {
124
+ options.onCompactEvent({ status: 'failed' });
125
+ return { result: { content: '' }, session: { messages: [] } };
126
+ }, {
127
+ items: [{ id: 'old', kind: 'assistant', text: 'old history' }],
128
+ });
129
+
130
+ assert.equal(await harness.runTurn('go'), 'done');
131
+ assert.equal(harness.getState().items.some((item) => item.id === 'old'), true);
132
+ assert.equal(harness.getState().items.some((item) => item.label === 'Compact failed'), true);
133
+ });
134
+
80
135
  test('stream flush keeps settled items identity and finalize appends one assistant', async () => {
81
136
  let identityStable = false;
82
137
  const harness = makeTurnHarness(async (_text, options) => {
@@ -9,5 +9,13 @@ Independent regression/risk review agent.
9
9
  Review the approved intent, diff, and tests with independent judgment. Prioritize
10
10
  actionable correctness, regression, security, and verification risks; inspect
11
11
  affected boundaries. Do not reimplement the change or report non-risky nits.
12
+ Treat implementer and Lead reports as claims to test rather than evidence;
13
+ independently evaluate the final deliverable against the original request and
14
+ available evidence with a critical lens, actively seeking errors, unsupported
15
+ assumptions, and counterexamples before confirming.
12
16
  Report findings first, severity-ordered, with one line per `file:line`. If clean,
13
17
  say so in one line and include only material residual risk.
18
+
19
+ When the work comes with stated criteria or reference material for judging
20
+ it, verify against those as given — substituting your own interpretation or
21
+ a self-built check is a verification risk to report.
@@ -1,11 +1,19 @@
1
1
  # Lead Brief
2
2
 
3
- - One-line fragments: `Goal:` `Anchors:` `Allow/Forbid:` `Deliver:` `Verify:`
4
- (+`Stop:` for heavy-worker). Minimum chars, maximum info: omit background,
5
- motivation, role-known rules, repeated context/facts; split scope, don't pad.
3
+ - One-line fragments. `Task:` is mandatory and lossless: include the intent,
4
+ required outcomes, negative outcomes, and completion/stop boundary. Minimum
5
+ chars, maximum info: omit role-known rules and repeated context/facts; split
6
+ scope, don't pad or discard task requirements.
7
+ - Preserve user-supplied exact targets and exact replacements/outputs in
8
+ `Task:`. Never infer exactness from a task name, file count, or perceived
9
+ difficulty.
10
+ - All other fields are optional task-specific deltas: `Anchors:`
11
+ `Allow/Forbid:` `Deliver:` `Verify:`. Omit any that add no information.
6
12
  - Anchors: `file:line` + one-line conclusion; never log/code bodies. Specify
7
13
  outcome, not method unless required. `Deliver:` gives shape/size, never a long
8
14
  handoff. Referenced spec/test beats its summary.
15
+ - Preserve the exact same `Task:` across Worker -> Debugger -> Reviewer. Never
16
+ summarize, rewrite, or replace it when handing the scope to the next role.
9
17
  - Full brief only for fresh spawn/`respawned: true`; live follow-ups are delta.
10
18
  Dead-tag send is cold: re-supply anchors.
11
19
  - Never `send` mid-run; batch one follow-up after completion; interrupt only to
@@ -3,5 +3,3 @@
3
3
  - Write-role agents self-verify via `shell`; Lead runs cross-scope
4
4
  verification, benches, and all git via it.
5
5
  - Use current project/workspace unless user request/tool need.
6
- - If workflow permits delegation, use `agent` for scoped
7
- implementation/research/review/debugging; a no-delegation workflow controls.
@@ -44,3 +44,7 @@
44
44
  reread returned spans. A third window means the first was too narrow.
45
45
  - One carve-out to parallel-first: apply the edit, then verify it in a
46
46
  separate shell turn and consume results in order. Otherwise stay parallel.
47
+ - A long-running command promoted to background is a decision point, not a
48
+ cue to wait: estimate from its observed progress whether it can finish
49
+ within the remaining budget; if not, diagnose the bottleneck and switch to
50
+ an alternative route. Waiting is chosen, never assumed.
@@ -194,6 +194,10 @@ export const DEFAULT_FIRST_RESPONSE_TIMEOUT_MS = envTimeoutMs(
194
194
  'MIXDOG_AGENT_FIRST_RESPONSE_TIMEOUT_MS',
195
195
  120_000,
196
196
  );
197
+ export const DEFAULT_FIRST_VISIBLE_CEILING_MS = envTimeoutMs(
198
+ 'MIXDOG_AGENT_FIRST_VISIBLE_TIMEOUT_MS',
199
+ 600_000,
200
+ );
197
201
  export const DEFAULT_STALE_TIMEOUT_MS = envTimeoutMs(
198
202
  'MIXDOG_AGENT_STALE_TIMEOUT_MS',
199
203
  30 * 60_000,
@@ -209,6 +213,10 @@ export function resolveAgentWatchdogPolicy(agent, overrides = {}) {
209
213
  overrides.firstResponseTimeoutMs,
210
214
  DEFAULT_FIRST_RESPONSE_TIMEOUT_MS,
211
215
  );
216
+ const firstVisibleCeilingMs = resolveExplicitMs(
217
+ overrides.firstVisibleTimeoutMs,
218
+ DEFAULT_FIRST_VISIBLE_CEILING_MS,
219
+ );
212
220
 
213
221
  let idleStaleMs;
214
222
  if (Number.isFinite(overrides.idleTimeoutMs) && overrides.idleTimeoutMs >= 0) {
@@ -238,6 +246,7 @@ export function resolveAgentWatchdogPolicy(agent, overrides = {}) {
238
246
 
239
247
  return {
240
248
  firstResponseMs,
249
+ firstVisibleCeilingMs,
241
250
  idleStaleMs,
242
251
  toolRunningMs,
243
252
  };
@@ -248,8 +257,16 @@ export function evaluateAgentWatchdogAbort(snapshot, now, policy) {
248
257
 
249
258
  if (snapshot.waitingForFirstActivity) {
250
259
  const startedAt = snapshot.modelRequestStartedAt || snapshot.askStartedAt;
251
- if (policy.firstResponseMs > 0 && startedAt && now - startedAt > policy.firstResponseMs) {
252
- return new AgentStallAbortError(`agent first response stale (${policy.firstResponseMs}ms)`);
260
+ const hasTransport = Boolean(
261
+ startedAt
262
+ && snapshot.lastTransportAt
263
+ && snapshot.lastTransportAt > startedAt
264
+ );
265
+ const ceilingMs = hasTransport
266
+ ? policy.firstVisibleCeilingMs
267
+ : policy.firstResponseMs;
268
+ if (ceilingMs > 0 && startedAt && now - startedAt > ceilingMs) {
269
+ return new AgentStallAbortError(`agent first response stale (${ceilingMs}ms)`);
253
270
  }
254
271
  return null;
255
272
  }
@@ -293,6 +310,7 @@ export function evaluateAgentWatchdogAbort(snapshot, now, policy) {
293
310
  export function agentWatchdogPolicyActive(policy) {
294
311
  if (!policy) return false;
295
312
  return (policy.firstResponseMs > 0)
313
+ || (policy.firstVisibleCeilingMs > 0)
296
314
  || (policy.idleStaleMs > 0)
297
315
  || (policy.toolRunningMs > 0);
298
316
  }
@@ -784,6 +784,13 @@ export function composeSystemPrompt(opts) {
784
784
  ? ''
785
785
  : loadScopedRoleInstructions(opts.agent || null, opts.provider || null);
786
786
  const stableSystemParts = [];
787
+ // Active workflow contract leads the role layer: it must outrank the
788
+ // generic role/tool guidance below it, not trail the profile/meta block
789
+ // in BP3 (observed: leads deprioritized delegation rules that sat behind
790
+ // ~3KB of profile/output-style text).
791
+ if (opts.workflowContext && typeof opts.workflowContext === 'string' && opts.workflowContext.trim()) {
792
+ stableSystemParts.push(opts.workflowContext.trim());
793
+ }
787
794
  if (opts.roleRules) stableSystemParts.push(opts.roleRules);
788
795
  if (opts.userPrompt) stableSystemParts.push(opts.userPrompt);
789
796
  if (roleInstructionContext) stableSystemParts.push(roleInstructionContext);
@@ -798,9 +805,6 @@ export function composeSystemPrompt(opts) {
798
805
  if (opts.metaContext && typeof opts.metaContext === 'string' && opts.metaContext.trim()) {
799
806
  sessionMarkerParts.push(opts.metaContext.trim());
800
807
  }
801
- if (opts.workflowContext && typeof opts.workflowContext === 'string' && opts.workflowContext.trim()) {
802
- sessionMarkerParts.push(opts.workflowContext.trim());
803
- }
804
808
  if (!_skip.memory && opts.coreMemoryContext && typeof opts.coreMemoryContext === 'string' && opts.coreMemoryContext.trim()) {
805
809
  sessionMarkerParts.push('# Core Memory\n' + opts.coreMemoryContext.trim());
806
810
  }
@@ -15,7 +15,8 @@
15
15
  * no-tool role guard) — not a permission check. No gating is needed here.
16
16
  */
17
17
 
18
- import { isToolEnvelope, makeToolEnvelope } from './session/tool-envelope.mjs';
18
+ import { isToolEnvelope, makeToolEnvelope, normalizeToolEnvelope } from './session/tool-envelope.mjs';
19
+ import { classifyResultKind } from './session/result-classification.mjs';
19
20
 
20
21
  let _executor = null;
21
22
  let _tools = [];
@@ -62,12 +63,24 @@ function _normalize(result) {
62
63
  // JSON.stringify below and the loop would see the stringified envelope as
63
64
  // the tool_result (body inlined + duplicated, no newMessages).
64
65
  if (isToolEnvelope(result)) {
65
- return makeToolEnvelope(_normalize(result.result), result.newMessages);
66
+ const normalized = normalizeToolEnvelope(_normalize(result.result));
67
+ return makeToolEnvelope(normalized.result, result.newMessages, {
68
+ explicitSuccess: normalized.explicitSuccess || result.explicitSuccess === true,
69
+ });
66
70
  }
67
71
  if (result && typeof result === 'object' && Array.isArray(result.content)) {
68
- return result.content
72
+ const text = result.content
69
73
  .map((c) => (c?.type === 'text' ? c.text || '' : JSON.stringify(c)))
70
74
  .join('\n');
75
+ // Preserve MCP-style handler outcome metadata across the object→string
76
+ // boundary. Explicit failures retain the canonical Error: convention;
77
+ // explicit successes use a transient envelope so legitimate output
78
+ // beginning with Error: is not mistaken for a failed execution.
79
+ if (result.isError === true) return !text.startsWith('Error:') ? `Error: ${text}` : text;
80
+ if (result.isError === false && classifyResultKind(text) === 'error') {
81
+ return makeToolEnvelope(text, [], { explicitSuccess: true });
82
+ }
83
+ return text;
71
84
  }
72
85
  if (typeof result === 'string') return result;
73
86
  return JSON.stringify(result);
@@ -6,6 +6,8 @@ import { randomUUID } from 'crypto';
6
6
  import { smartReadTruncate } from '../tools/builtin/read-formatting.mjs';
7
7
  import { shutdownStdioChild, killStdioChildTreeFast } from './child-tree.mjs';
8
8
  import { readServicePort, markServiceUnreachable, isConnRefuseError } from '../../../shared/service-discovery.mjs';
9
+ import { makeToolEnvelope, normalizeToolEnvelope } from '../session/tool-envelope.mjs';
10
+ import { classifyResultKind } from '../session/result-classification.mjs';
9
11
  // --- Types ---
10
12
  /** Known auto-detect targets: port file path relative to tmpdir.
11
13
  * Note: `mixdog` used to self-loopback via active-instance.json's
@@ -215,6 +217,16 @@ export async function executeMcpTool(name, args) {
215
217
  throw new Error(`Tool call failed: ${firstMsg}; retry after reconnect also failed: ${retryMsg}`);
216
218
  }
217
219
  }
220
+ const normalized = normalizeToolEnvelope(normalizeMcpToolResult(result));
221
+ const text = capMcpOutput(normalized.result);
222
+ return normalized.explicitSuccess
223
+ ? makeToolEnvelope(text, [], { explicitSuccess: true })
224
+ : text;
225
+ }
226
+
227
+ // Preserve MCP failure metadata across the object→string boundary. The
228
+ // session loop classifies the canonical Error: prefix as toolKind:error.
229
+ export function normalizeMcpToolResult(result) {
218
230
  const content = result.content;
219
231
  let text;
220
232
  if (Array.isArray(content)) {
@@ -224,7 +236,11 @@ export async function executeMcpTool(name, args) {
224
236
  } else {
225
237
  text = typeof content === 'string' ? content : JSON.stringify(content);
226
238
  }
227
- return capMcpOutput(text);
239
+ if (result.isError === true) return !text.startsWith('Error:') ? `Error: ${text}` : text;
240
+ if (result.isError === false && classifyResultKind(text) === 'error') {
241
+ return makeToolEnvelope(text, [], { explicitSuccess: true });
242
+ }
243
+ return text;
228
244
  }
229
245
 
230
246
  // MCP per-tool-call timeout. Default 2min: a hung/unresponsive MCP server