mixdog 0.9.40 → 0.9.41

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 (33) hide show
  1. package/package.json +2 -1
  2. package/scripts/anthropic-oauth-refresh-race-test.mjs +338 -0
  3. package/scripts/compact-pressure-test.mjs +128 -0
  4. package/scripts/compact-trigger-migration-smoke.mjs +8 -8
  5. package/scripts/internal-comms-smoke.mjs +66 -25
  6. package/scripts/max-output-recovery-persist-test.mjs +81 -0
  7. package/scripts/max-output-recovery-test.mjs +222 -0
  8. package/scripts/provider-toolcall-test.mjs +32 -0
  9. package/src/agents/reviewer/AGENT.md +4 -0
  10. package/src/rules/lead/lead-brief.md +11 -3
  11. package/src/rules/lead/lead-tool.md +0 -2
  12. package/src/rules/shared/01-tool.md +4 -0
  13. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +20 -2
  14. package/src/runtime/agent/orchestrator/context/collect.mjs +7 -3
  15. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +144 -7
  16. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +15 -2
  17. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +2 -2
  18. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +57 -25
  19. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +54 -12
  20. package/src/runtime/agent/orchestrator/session/context-utils.mjs +4 -3
  21. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +106 -8
  22. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +12 -1
  23. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +22 -7
  24. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +26 -1
  25. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -1
  26. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +31 -4
  27. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +11 -2
  28. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +45 -0
  29. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +22 -21
  30. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
  31. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -10
  32. package/src/workflows/bench/WORKFLOW.md +51 -27
  33. package/src/workflows/default/WORKFLOW.md +29 -24
@@ -63,14 +63,23 @@ const leadTool = readSrc('rules', 'lead', 'lead-tool.md');
63
63
  const core = readSrc('rules', 'agent', '00-core.md');
64
64
  const common = readSrc('rules', 'agent', '00-common.md');
65
65
  const skip = readSrc('rules', 'agent', '20-skip-protocol.md');
66
- const BRIEF_FIELDS = ['Goal:', 'Anchors:', 'Allow/Forbid:', 'Deliver:', 'Verify:'];
66
+ const OPTIONAL_BRIEF_FIELDS = ['Anchors:', 'Allow/Forbid:', 'Deliver:', 'Verify:'];
67
67
  const TOKEN_PRINCIPLE = /minimum (?:characters|chars), maximum (?:information|info)/i;
68
68
  function requireAll(text, label, patterns) {
69
69
  for (const pattern of patterns) assert(pattern.test(normalize(text).toLowerCase()), `${label}: missing ${pattern}`);
70
70
  }
71
71
  assert(TOKEN_PRINCIPLE.test(normalize(leadBrief)), 'lead-brief.md: brief must state min-char/max-info principle');
72
- for (const field of BRIEF_FIELDS) assert(leadBrief.includes(field), `lead-brief.md: brief missing labeled field ${field}`);
73
- assert(leadBrief.includes('Stop:'), 'lead-brief.md: brief must add Stop: for heavy-worker bound');
72
+ assert(leadBrief.includes('Task:'), 'lead-brief.md: brief must require labeled field Task:');
73
+ assert(!leadBrief.includes('Goal:'), 'lead-brief.md: Goal: must be replaced by Task:');
74
+ for (const field of OPTIONAL_BRIEF_FIELDS) assert(leadBrief.includes(field), `lead-brief.md: brief missing optional labeled field ${field}`);
75
+ requireAll(leadBrief, 'Lead brief task', [
76
+ /`task:` is mandatory and lossless/, /intent/, /required outcomes/, /negative outcomes/,
77
+ /completion\/stop boundary/, /all other fields are optional task-specific deltas/,
78
+ /preserve user-supplied exact targets and exact replacements\/outputs in `task:`/,
79
+ /never infer exactness from a task name, file count, or perceived difficulty/,
80
+ /exact same `task:` across worker -> debugger -> reviewer/,
81
+ /never summarize, rewrite, or replace it/,
82
+ ]);
74
83
  assert(/role-known|already (?:owns|knows)|wasted cost|wasted/i.test(normalize(leadBrief)), 'lead-brief.md: brief must ban restating known rules/background as cost');
75
84
  assert(/spec\/test beats its summary/i.test(normalize(leadBrief)), 'lead-brief.md: spec/test must beat summary');
76
85
  requireAll(leadBrief, 'Lead brief lifecycle', [
@@ -80,7 +89,7 @@ requireAll(leadBrief, 'Lead brief lifecycle', [
80
89
  /agent communication is english/,
81
90
  ]);
82
91
  assert(/lead brief contract/i.test(normalize(workflow)), 'WORKFLOW.md: must defer to the lead brief contract');
83
- assert(!BRIEF_FIELDS.every((field) => workflow.includes(field)), 'WORKFLOW.md: must not duplicate the full brief field list');
92
+ assert(!OPTIONAL_BRIEF_FIELDS.every((field) => workflow.includes(field)), 'WORKFLOW.md: must not duplicate the optional brief field list');
84
93
 
85
94
  assert(/fragments/i.test(core), '00-core: handoff must require fragments');
86
95
  assert(/file:line/i.test(core), '00-core: handoff must anchor evidence to file:line');
@@ -112,19 +121,52 @@ requireAll(workflow, 'Default approval', [
112
121
  /initial\/additional\/changed requests reset planning/, /scope change needs a revised plan and fresh approval/,
113
122
  /no edits, state mutation, or delegation/,
114
123
  ]);
115
- requireAll(workflow, 'Default routing', [
116
- /delegate by default/, /only coordinates, does git, or an obvious 1-edit\/ 1-check change/,
117
- /implementation\/research\/debugging to its matching agent/, /indicators, not automatic categories/,
118
- /worker: bounded established path, low local risk\/coupling\/verification, clear local check/,
119
- /heavy worker: high risk\/coupling\/verification \(including any high-risk scope\), or coupled staged work needing coordinated verification/,
120
- /reviewer verifies an implementation/, /debugger handles requested debugging or root cause after a failed fix/,
121
- ]);
124
+ function routingReviewBlocks(text, label) {
125
+ const source = String(text).replace(/\r\n/g, '\n');
126
+ return {
127
+ routing: block(source, 'Lead orchestrates and verifies.', '1. Plan:', `${label} routing policy`),
128
+ review: block(source, '3. Review:', '\n4.', `${label} review policy`),
129
+ };
130
+ }
131
+ const defaultPolicy = routingReviewBlocks(workflow, 'Default');
132
+ const benchPolicy = routingReviewBlocks(bench, 'Bench');
133
+ for (const section of ['routing', 'review']) {
134
+ assert(
135
+ defaultPolicy[section] === benchPolicy[section],
136
+ `Default/Bench ${section} policy must be identical after normalization`,
137
+ );
138
+ }
139
+ const ROUTING_REVIEW_POLICY = [
140
+ /lead-direct work is allowed only for pure read\/analysis, git\/configuration/,
141
+ /user explicitly supplies both the exact target and exact replacement\/output/,
142
+ /never infer an exemption from a task name, file count, or perceived difficulty/,
143
+ /every other implementation, reverse engineering, debugging application, or artifact generation delegates to the matching agent/,
144
+ /debugger owns diagnosis and reverse engineering/,
145
+ /worker applies an established bounded change or fully specified artifact/,
146
+ /heavy worker owns implementation that must establish the change through investigation or staged delivery/,
147
+ /applying a debugger result is implementation, not diagnosis/,
148
+ /review is exempt only for pure read\/analysis with no edit, artifact, or state mutation; git\/configuration; or a request where the user explicitly supplies both the exact target and exact replacement\/output\. every non-exempt mutation or artifact/,
149
+ /every non-exempt mutation or artifact/,
150
+ /worker, heavy worker, debugger, or lead/,
151
+ /one reviewer .*lead integration\/cross-scope verification in parallel/,
152
+ /debugger analysis cannot substitute for implementation review/,
153
+ /applying a debugger result triggers the same reviewer \+ lead verification/,
154
+ /loop fix -> re-verify \(same reviewer \+ lead re-check\) until clean/,
155
+ /exempt mutations still require shell self-verification/,
156
+ ];
157
+ for (const [label, text] of [['Default', workflow], ['Bench', bench]]) {
158
+ requireAll(text, `${label} routing/review parity`, ROUTING_REVIEW_POLICY);
159
+ assert(
160
+ !/(high clarity|low structural complexity|immediate 1-step|genuinely simple)/i.test(text),
161
+ `${label}: heuristic routing/review language must not return`,
162
+ );
163
+ }
122
164
  requireAll(workflow, 'Default lifecycle', [
123
- /draft before any implementation/, /parallel gain exceeds coordination\/merge cost/,
124
- /all in one turn, with no count cap/, /real dependency, overlapping write, or inseparable coupling/,
125
- /after async spawn, end the turn/, /every delegated implementation gets one reviewer/,
126
- /lead integration\/cross-scope verification in parallel/, /high-risk scopes add distinct lenses/,
127
- /original live session/, /loop fix -> re-verify \(same reviewer \+ lead re-check\) until clean/,
165
+ /draft before any implementation/, /all ready scopes in one turn, with no count cap/,
166
+ /real dependency, overlapping write, or inseparable coupling/,
167
+ /after async spawn, end the turn/,
168
+ /high-risk scopes add distinct lenses/,
169
+ /original live session/,
128
170
  /bug surviving 2\+ fix cycles/, /agent reports relay.*as in-progress, never conclusions/,
129
171
  /final \(not interim\) report/, /never forward raw agent output/,
130
172
  /explicit user request after issue-free feedback/, /outcome\/direction change, pause and re-consult/,
@@ -137,16 +179,13 @@ requireAll(solo, 'Solo lifecycle', [
137
179
  /scope change needs a revised plan and fresh approval/,
138
180
  /checks\/fixes directly until clean or reports a blocker/, /final \(not interim\) report/,
139
181
  /interim updates are in-progress, never conclusions/,
140
- /issue-free user feedback/,
182
+ /issue-free feedback/,
141
183
  ]);
142
184
  requireAll(bench, 'Bench lifecycle', [
143
- /never wait for approval or ask questions/, /verified complete or provably blocked/,
144
- /maximum independent scopes/, /spawning every scope in one turn/,
145
- /build\/test-green gate/, /no polling, guessing, or dependent work/,
146
- /one reviewer per implementation scope/, /never deferred\/batched/,
147
- /fact-check agent responses and cross-check implementation\/review yourself before acting/,
148
- /return fixes to the original scope/, /loop verify -> fix -> re-verify until clean/,
149
- /skip only simple, low-risk review/, /hard blocked/, /outcome and evidence/,
185
+ /never ask questions, propose plans for approval, or end the turn waiting/,
186
+ /verified complete or provably blocked/, /all ready scopes in one turn, with no count cap/,
187
+ /after async spawn, end the turn/, /original live session/,
188
+ /hard blocked/, /outcome and evidence/,
150
189
  ]);
151
190
  requireAll(leadTool, 'Lead tools', [
152
191
  /write-role agents self-verify/, /cross-scope verification.*benches.*all git/,
@@ -220,7 +259,9 @@ const dataDir = mkdtempSync(join(tmpdir(), 'mixdog-internal-comms-smoke-'));
220
259
  try {
221
260
  const leadRules = rulesBuilder.buildInjectionContent({ PLUGIN_ROOT: join(root, 'src'), DATA_DIR: dataDir });
222
261
  assert(TOKEN_PRINCIPLE.test(normalize(leadRules)), 'injected Lead rules must carry the brief token principle');
223
- for (const field of BRIEF_FIELDS) assert(leadRules.includes(field), `injected Lead rules missing brief field ${field}`);
262
+ assert(leadRules.includes('Task:'), 'injected Lead rules missing mandatory brief field Task:');
263
+ assert(!leadRules.includes('Goal:'), 'injected Lead rules must not retain Goal:');
264
+ for (const field of OPTIONAL_BRIEF_FIELDS) assert(leadRules.includes(field), `injected Lead rules missing optional brief field ${field}`);
224
265
  } finally {
225
266
  rmSync(dataDir, { recursive: true, force: true });
226
267
  }
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+ import test from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { mkdtempSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { tmpdir } from 'node:os';
7
+
8
+ process.env.MIXDOG_AGENT_TRACE_DISABLE = '1';
9
+ process.env.MIXDOG_DATA_DIR = mkdtempSync(join(tmpdir(), 'mixdog-max-output-persist-'));
10
+
11
+ test('askSession persists recovery history without duplicating the returned aggregate', async () => {
12
+ const { initProviders, getProvider } = await import('../src/runtime/agent/orchestrator/providers/registry.mjs');
13
+ await initProviders({ gemini: { enabled: true, apiKey: 'test-only' } });
14
+ const provider = getProvider('gemini');
15
+ const responses = [
16
+ {
17
+ content: 'persisted partial ',
18
+ stopReason: 'MAX_TOKENS',
19
+ truncated: true,
20
+ usage: { inputTokens: 4, outputTokens: 3, cachedTokens: 1, promptTokens: 4 },
21
+ },
22
+ {
23
+ content: 'terminal segment',
24
+ stopReason: 'end_turn',
25
+ usage: { inputTokens: 2, outputTokens: 2, cachedTokens: 0, promptTokens: 2 },
26
+ },
27
+ ];
28
+ let sendIndex = 0;
29
+ const streamed = [];
30
+ provider.send = async (_messages, _model, _tools, opts = {}) => {
31
+ const response = responses[sendIndex++];
32
+ assert.ok(response, `unexpected provider send ${sendIndex}`);
33
+ opts.onTextDelta?.(response.content);
34
+ streamed.push(response.content);
35
+ return response;
36
+ };
37
+
38
+ const { createSession, askSession, getSession } = await import('../src/runtime/agent/orchestrator/session/manager.mjs');
39
+ const session = createSession({
40
+ provider: 'gemini',
41
+ model: 'gemini-test',
42
+ tools: [],
43
+ cwd: process.cwd(),
44
+ skipAgentRules: true,
45
+ skipSkills: true,
46
+ compaction: { auto: false },
47
+ });
48
+
49
+ const result = await askSession(
50
+ session.id,
51
+ 'produce a long answer',
52
+ null,
53
+ null,
54
+ process.cwd(),
55
+ null,
56
+ { onTextDelta: () => {} },
57
+ );
58
+ const persisted = getSession(session.id);
59
+ const partialRows = persisted.messages.filter((message) => message.role === 'assistant' && message.content === 'persisted partial ');
60
+ const terminalRows = persisted.messages.filter((message) => message.role === 'assistant' && message.content === 'terminal segment');
61
+ const aggregateRows = persisted.messages.filter((message) => message.role === 'assistant' && message.content === result.content);
62
+
63
+ assert.equal(result.content, 'persisted partial terminal segment');
64
+ assert.equal(streamed.join(''), result.content, 'streamed TUI text matches the aggregate without duplication');
65
+ assert.equal(partialRows.length, 1);
66
+ assert.equal(terminalRows.length, 1);
67
+ assert.equal(aggregateRows.length, 0, 'aggregate is returned but is not persisted over the recovery chain');
68
+ assert.equal(
69
+ persisted.messages.filter((message) => message?.meta?.source === 'max-output-recovery').length,
70
+ 1,
71
+ );
72
+ assert.deepEqual(
73
+ {
74
+ inputTokens: result.usage.inputTokens,
75
+ outputTokens: result.usage.outputTokens,
76
+ cachedTokens: result.usage.cachedTokens,
77
+ promptTokens: result.usage.promptTokens,
78
+ },
79
+ { inputTokens: 6, outputTokens: 5, cachedTokens: 1, promptTokens: 6 },
80
+ );
81
+ });
@@ -0,0 +1,222 @@
1
+ #!/usr/bin/env node
2
+ import test from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+
5
+ import { agentLoop } from '../src/runtime/agent/orchestrator/session/agent-loop.mjs';
6
+
7
+ function queuedProvider(responses, streamed = []) {
8
+ const sent = [];
9
+ let index = 0;
10
+ return {
11
+ sent,
12
+ async send(messages, _model, _tools, opts = {}) {
13
+ sent.push(structuredClone(messages));
14
+ const response = responses[index++];
15
+ assert.ok(response, `unexpected provider send ${index}`);
16
+ if (response instanceof Error) {
17
+ if (typeof response.partialContent === 'string') {
18
+ opts.onTextDelta?.(response.partialContent);
19
+ streamed.push(response.partialContent);
20
+ }
21
+ throw response;
22
+ }
23
+ if (typeof response.content === 'string') {
24
+ opts.onTextDelta?.(response.content);
25
+ streamed.push(response.content);
26
+ }
27
+ return {
28
+ usage: { inputTokens: 1, outputTokens: 1 },
29
+ ...response,
30
+ };
31
+ },
32
+ };
33
+ }
34
+
35
+ async function run(provider, messages = [{ role: 'user', content: 'answer fully' }], options = {}) {
36
+ return agentLoop(provider, messages, 'fake-model', [], options.onToolCall, process.cwd(), {
37
+ onTextDelta: options.onTextDelta,
38
+ });
39
+ }
40
+
41
+ function persistedAssistantText(messages, result) {
42
+ const terminal = typeof result.historyContent === 'string' ? result.historyContent : result.content;
43
+ return [
44
+ ...messages.filter((message) => message.role === 'assistant').map((message) => message.content),
45
+ terminal,
46
+ ].join('');
47
+ }
48
+
49
+ test('one max-output continuation resumes from preserved partial and returns one complete text', async () => {
50
+ const streamed = [];
51
+ const provider = queuedProvider([
52
+ { content: 'alpha ', stopReason: 'length', truncated: true },
53
+ { content: 'omega', stopReason: 'end_turn' },
54
+ ], streamed);
55
+ const messages = [{ role: 'user', content: 'answer fully' }];
56
+
57
+ const result = await run(provider, messages, { onTextDelta: () => {} });
58
+
59
+ assert.equal(provider.sent.length, 2);
60
+ assert.equal(result.content, 'alpha omega');
61
+ assert.equal(streamed.join(''), result.content);
62
+ assert.equal(result.historyContent, 'omega');
63
+ assert.equal(result.maxOutputRecoveryAttempts, 1);
64
+ assert.equal(result.terminationReason, undefined);
65
+ assert.deepEqual(provider.sent[1].map((message) => message.role), ['user', 'assistant', 'user']);
66
+ assert.equal(provider.sent[1][1].content, 'alpha ');
67
+ assert.match(provider.sent[1][2].content, /Resume directly/);
68
+ assert.match(provider.sent[1][2].content, /no apology, no recap/i);
69
+ assert.equal(persistedAssistantText(messages, result), result.content);
70
+ assert.equal(messages.some((message) => message.role === 'assistant' && message.content === result.content), false);
71
+ });
72
+
73
+ test('Gemini MAX_TOKENS ProviderIncompleteError enters the same bounded recovery and aggregates usage', async () => {
74
+ const streamed = [];
75
+ const geminiIncomplete = Object.assign(new Error('Gemini response incomplete: finishReason=MAX_TOKENS'), {
76
+ name: 'ProviderIncompleteError',
77
+ code: 'PROVIDER_INCOMPLETE',
78
+ providerIncomplete: true,
79
+ finishReason: 'MAX_TOKENS',
80
+ partialContent: 'gemini ',
81
+ partialToolCalls: undefined,
82
+ model: 'gemini-test',
83
+ rawUsage: {
84
+ promptTokenCount: 10,
85
+ candidatesTokenCount: 4,
86
+ thoughtsTokenCount: 1,
87
+ cachedContentTokenCount: 3,
88
+ },
89
+ });
90
+ const provider = queuedProvider([
91
+ geminiIncomplete,
92
+ {
93
+ content: 'complete',
94
+ stopReason: 'end_turn',
95
+ usage: { inputTokens: 1, outputTokens: 2, cachedTokens: 1, promptTokens: 1 },
96
+ },
97
+ ], streamed);
98
+ const messages = [{ role: 'user', content: 'answer fully' }];
99
+
100
+ const result = await run(provider, messages, { onTextDelta: () => {} });
101
+
102
+ assert.equal(provider.sent.length, 2);
103
+ assert.equal(result.content, 'gemini complete');
104
+ assert.equal(streamed.join(''), result.content, 'TUI-facing deltas and final aggregate contain each segment once');
105
+ assert.deepEqual(
106
+ {
107
+ inputTokens: result.usage.inputTokens,
108
+ outputTokens: result.usage.outputTokens,
109
+ cachedTokens: result.usage.cachedTokens,
110
+ promptTokens: result.usage.promptTokens,
111
+ },
112
+ { inputTokens: 11, outputTokens: 7, cachedTokens: 4, promptTokens: 11 },
113
+ );
114
+ assert.equal(result.lastTurnUsage.outputTokens, 2);
115
+ assert.equal(provider.sent[1][1].content, 'gemini ');
116
+ });
117
+
118
+ test('pause_turn and Gemini OTHER preserve prior non-empty semantics and do not recover', async () => {
119
+ for (const stopReason of ['pause_turn', 'OTHER']) {
120
+ const provider = queuedProvider([{ content: `partial-${stopReason}`, stopReason }]);
121
+ const result = await run(provider);
122
+ assert.equal(provider.sent.length, 1, stopReason);
123
+ assert.equal(result.content, `partial-${stopReason}`, stopReason);
124
+ assert.equal(result.terminationReason, undefined, stopReason);
125
+ assert.equal(Object.hasOwn(result, 'historyContent'), false, stopReason);
126
+ }
127
+ });
128
+
129
+ test('non-output ProviderIncompleteError remains an error', async () => {
130
+ const other = Object.assign(new Error('Gemini response incomplete: finishReason=OTHER'), {
131
+ code: 'PROVIDER_INCOMPLETE',
132
+ providerIncomplete: true,
133
+ finishReason: 'OTHER',
134
+ partialContent: 'unsafe partial',
135
+ partialToolCalls: undefined,
136
+ });
137
+ await assert.rejects(run(queuedProvider([other])), (error) => error === other);
138
+ });
139
+
140
+ test('adaptive-thinking replay state is preserved on the partial assistant history turn', async () => {
141
+ const thinkingBlocks = [{ type: 'thinking', thinking: 'signed thought', signature: 'sig-123' }];
142
+ const reasoningItems = [{ type: 'reasoning', encrypted_content: 'enc-1' }];
143
+ const provider = queuedProvider([
144
+ {
145
+ content: 'first ',
146
+ stopReason: 'max_tokens',
147
+ truncated: true,
148
+ thinkingBlocks,
149
+ reasoningItems,
150
+ reasoningContent: 'display thought',
151
+ },
152
+ { content: 'second', stopReason: 'end_turn' },
153
+ ]);
154
+
155
+ const result = await run(provider);
156
+
157
+ assert.equal(result.content, 'first second');
158
+ assert.deepEqual(provider.sent[1][1].thinkingBlocks, thinkingBlocks);
159
+ assert.deepEqual(provider.sent[1][1].reasoningItems, reasoningItems);
160
+ assert.equal(provider.sent[1][1].reasoningContent, 'display thought');
161
+ });
162
+
163
+ test('max-output recovery stops after three continuations and surfaces explicit truncation', async () => {
164
+ const provider = queuedProvider([
165
+ { content: 'A', stopReason: 'max_tokens', truncated: true },
166
+ { content: 'B', stopReason: 'max_tokens', truncated: true },
167
+ { content: 'C', stopReason: 'max_tokens', truncated: true },
168
+ { content: 'D', stopReason: 'max_tokens', truncated: true },
169
+ ]);
170
+ const messages = [{ role: 'user', content: 'answer fully' }];
171
+
172
+ const result = await run(provider, messages);
173
+
174
+ assert.equal(provider.sent.length, 4);
175
+ assert.equal(result.maxOutputRecoveryAttempts, 3);
176
+ assert.equal(result.terminationReason, 'truncated');
177
+ assert.match(result.content, /^ABCD/);
178
+ assert.match(result.content, /remained truncated after 3 continuation attempts/);
179
+ assert.equal(messages.filter((message) => message?.meta?.source === 'max-output-recovery').length, 3);
180
+ assert.equal(messages.filter((message) => message.role === 'assistant').length, 3);
181
+ assert.equal(persistedAssistantText(messages, result), result.content);
182
+ });
183
+
184
+ test('clean end_turn is unchanged and does not enter recovery', async () => {
185
+ const provider = queuedProvider([
186
+ { content: 'clean answer', stopReason: 'end_turn' },
187
+ ]);
188
+ const messages = [{ role: 'user', content: 'answer fully' }];
189
+
190
+ const result = await run(provider, messages);
191
+
192
+ assert.equal(provider.sent.length, 1);
193
+ assert.equal(result.content, 'clean answer');
194
+ assert.equal(result.terminationReason, undefined);
195
+ assert.equal(Object.hasOwn(result, 'historyContent'), false);
196
+ assert.deepEqual(messages, [{ role: 'user', content: 'answer fully' }]);
197
+ });
198
+
199
+ test('tool-call turns keep the normal tool execution path', async () => {
200
+ const provider = queuedProvider([
201
+ {
202
+ content: '',
203
+ stopReason: 'tool_use',
204
+ toolCalls: [{ id: 'call-1', name: 'unknown_test_tool', arguments: {} }],
205
+ },
206
+ { content: 'done', stopReason: 'end_turn' },
207
+ ]);
208
+ const batches = [];
209
+ const messages = [{ role: 'user', content: 'use a tool' }];
210
+
211
+ const result = await run(provider, messages, {
212
+ onToolCall: (_iteration, calls) => batches.push(calls),
213
+ });
214
+
215
+ assert.equal(provider.sent.length, 2);
216
+ assert.equal(result.content, 'done');
217
+ assert.equal(result.toolCallsTotal, 1);
218
+ assert.equal(result.terminationReason, undefined);
219
+ assert.equal(Object.hasOwn(result, 'historyContent'), false);
220
+ assert.equal(batches.length, 1);
221
+ assert.deepEqual(messages.map((message) => message.role), ['user', 'assistant', 'tool']);
222
+ });
@@ -52,6 +52,7 @@ import {
52
52
  } from '../src/runtime/agent/orchestrator/providers/gemini-cache.mjs';
53
53
  import { parseSSEStream as anthropicParseSSEStream } from '../src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs';
54
54
  import { _buildRequestBodyForCacheSmoke } from '../src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs';
55
+ import { _toAnthropicMessagesForTest } from '../src/runtime/agent/orchestrator/providers/anthropic.mjs';
55
56
  import {
56
57
  EFFORT_BETA_HEADER,
57
58
  LEGACY_EFFORT_BUDGET,
@@ -773,6 +774,37 @@ test('anthropic(-oauth): redacted_thinking round-trips exactly as {type,data} (n
773
774
  ]);
774
775
  });
775
776
 
777
+ test('anthropic API-key and OAuth lower plain signed thinkingBlocks before text without tool calls', () => {
778
+ const thinkingBlocks = [
779
+ { type: 'thinking', thinking: 'resume state', signature: 'sig-recovery-1' },
780
+ { type: 'redacted_thinking', data: 'ENCRYPTED_RECOVERY_STATE' },
781
+ ];
782
+ const history = [
783
+ { role: 'user', content: 'write the answer' },
784
+ { role: 'assistant', content: 'partial answer', thinkingBlocks },
785
+ { role: 'user', content: 'resume directly' },
786
+ ];
787
+ const expectedAssistantContent = [
788
+ ...thinkingBlocks,
789
+ { type: 'text', text: 'partial answer' },
790
+ ];
791
+
792
+ const apiKeyMessages = _toAnthropicMessagesForTest(history);
793
+ const oauthMessages = _buildRequestBodyForCacheSmoke(
794
+ history,
795
+ 'claude-sonnet-4-6',
796
+ [],
797
+ {},
798
+ ).messages;
799
+
800
+ for (const lowered of [apiKeyMessages, oauthMessages]) {
801
+ const assistant = lowered.find((message) => message.role === 'assistant');
802
+ assert.ok(assistant, 'plain recovery assistant turn must survive lowering');
803
+ assert.deepEqual(assistant.content, expectedAssistantContent);
804
+ assert.equal(assistant.content.some((block) => block.type === 'tool_use'), false);
805
+ }
806
+ });
807
+
776
808
  test('anthropic effort: legacy claude-3-7-sonnet gets NO adaptive thinking / effort beta', () => {
777
809
  const model = 'claude-3-7-sonnet-20250219';
778
810
  assert.equal(modelSupportsEffort(model), false);
@@ -11,3 +11,7 @@ actionable correctness, regression, security, and verification risks; inspect
11
11
  affected boundaries. Do not reimplement the change or report non-risky nits.
12
12
  Report findings first, severity-ordered, with one line per `file:line`. If clean,
13
13
  say so in one line and include only material residual risk.
14
+
15
+ When the work comes with stated criteria or reference material for judging
16
+ it, verify against those as given — substituting your own interpretation or
17
+ 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
  }