mixdog 0.9.11 → 0.9.13

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 (48) hide show
  1. package/package.json +1 -1
  2. package/scripts/internal-comms-bench.mjs +1 -0
  3. package/scripts/internal-comms-smoke.mjs +11 -7
  4. package/src/lib/rules-builder.cjs +15 -11
  5. package/src/mixdog-session-runtime.mjs +48 -0
  6. package/src/rules/agent/00-common.md +5 -17
  7. package/src/rules/agent/00-core.md +21 -0
  8. package/src/rules/lead/lead-brief.md +7 -0
  9. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +39 -2
  10. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +0 -25
  11. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +120 -3
  12. package/src/runtime/agent/orchestrator/agent-trace.mjs +43 -1
  13. package/src/runtime/agent/orchestrator/context/collect.mjs +1 -1
  14. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +48 -3
  15. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +42 -4
  16. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +1 -1
  17. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +24 -3
  18. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +6 -0
  19. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +61 -6
  20. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +31 -0
  21. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +57 -2
  22. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +40 -4
  23. package/src/runtime/agent/orchestrator/session/compact/budget.mjs +0 -62
  24. package/src/runtime/agent/orchestrator/session/compact.mjs +0 -2
  25. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +12 -9
  26. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +12 -0
  27. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +48 -157
  28. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +6 -23
  29. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +0 -13
  30. package/src/runtime/agent/orchestrator/session/loop.mjs +32 -77
  31. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +33 -45
  32. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +7 -6
  33. package/src/runtime/agent/orchestrator/session/manager.mjs +27 -14
  34. package/src/runtime/agent/orchestrator/stall-policy.mjs +16 -0
  35. package/src/runtime/channels/index.mjs +28 -1
  36. package/src/runtime/channels/lib/memory-client.mjs +200 -15
  37. package/src/runtime/memory/index.mjs +11 -11
  38. package/src/runtime/memory/lib/cycle-scheduler.mjs +6 -4
  39. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +22 -17
  40. package/src/runtime/memory/lib/memory-embed.mjs +35 -1
  41. package/src/session-runtime/tool-catalog.mjs +1 -0
  42. package/src/session-runtime/tool-defs.mjs +28 -0
  43. package/src/standalone/agent-tool.mjs +89 -7
  44. package/src/tui/dist/index.mjs +35 -12
  45. package/src/tui/engine.mjs +55 -12
  46. package/src/workflows/default/WORKFLOW.md +2 -0
  47. package/src/workflows/sequential/WORKFLOW.md +2 -0
  48. package/src/workflows/solo/WORKFLOW.md +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.11",
3
+ "version": "0.9.13",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -29,6 +29,7 @@ const PLUGIN_ROOT = join(REPO_ROOT, 'src');
29
29
  const HEADLESS = pathToFileURL(resolve(__dir, '../src/headless-role.mjs')).href;
30
30
 
31
31
  const RULE_FILES = [
32
+ 'rules/agent/00-core.md',
32
33
  'rules/agent/00-common.md',
33
34
  'agents/worker/AGENT.md',
34
35
  'agents/heavy-worker/AGENT.md',
@@ -38,15 +38,19 @@ assert(/authoritative/i.test(flat(leadBrief)), 'lead-brief.md: brief must state
38
38
  assert(/lead brief contract/i.test(flat(workflow)), 'WORKFLOW.md: must defer to the lead brief contract');
39
39
  assert(!BRIEF_FIELDS.every((field) => workflow.includes(field)), 'WORKFLOW.md: must not duplicate the full brief field list');
40
40
 
41
- // --- Agent handoff contract (00-common.md) ---------------------------------
42
- const common = readSrc('rules', 'agent', '00-common.md');
43
- assert(/minimum characters, maximum information/i.test(flat(common)), '00-common: handoff must state token-optimized principle');
44
- assert(/fragments/i.test(common), '00-common: handoff must require fragments');
45
- assert(/file:line/i.test(common), '00-common: handoff must anchor evidence to file:line');
46
- assert(/Banned as pure cost/i.test(flat(common)), '00-common: handoff must list banned cost items');
41
+ // --- Agent handoff contract (00-core.md) -----------------------------------
42
+ const core = readSrc('rules', 'agent', '00-core.md');
43
+ assert(/minimum characters, maximum information/i.test(flat(core)), '00-core: handoff must state token-optimized principle');
44
+ assert(/fragments/i.test(core), '00-core: handoff must require fragments');
45
+ assert(/file:line/i.test(core), '00-core: handoff must anchor evidence to file:line');
46
+ assert(/Banned as pure cost/i.test(flat(core)), '00-core: handoff must list banned cost items');
47
47
  for (const banned of ['headings', 'tables', 'narration', 'raw logs', 'next-checks']) {
48
- assert(common.toLowerCase().includes(banned), `00-common: banned list missing ${banned}`);
48
+ assert(core.toLowerCase().includes(banned), `00-core: banned list missing ${banned}`);
49
49
  }
50
+ const common = readSrc('rules', 'agent', '00-common.md');
51
+ assert(/Public Agent Constraints/i.test(common), '00-common: must be titled public-only constraints');
52
+ assert(/git operations deferred to Lead/i.test(common), '00-common: must refuse git/Ship');
53
+ assert(/Overflow goes to a file/i.test(common), '00-common: must keep overflow-to-file rule');
50
54
 
51
55
  // --- Per-role output contracts --------------------------------------------
52
56
  const roles = {
@@ -24,7 +24,8 @@
24
24
  * - lead/lead-brief.md — Lead brief contract (agent handoff briefs)
25
25
  * - lead/01-02 — Lead general / channels
26
26
  * - output-styles/<name>.md — Lead output style, selected by config outputStyle
27
- * - agent/00-common.md agent common behavior + universal worker contract (BP2)
27
+ * - agent/00-core.md — universal agent constraints (BP2, all profiles)
28
+ * - agent/00-common.md — public-agent-only extras (BP2 full profile)
28
29
  * - agent/10..50-*.md — per-hidden-agent bodies (consumed by loadScopedRoleInstructions)
29
30
  *
30
31
  * Core memory snapshot is injected separately from the memory worker (pgdata)
@@ -361,11 +362,13 @@ function buildAgentInjectionContent({ PLUGIN_ROOT, DATA_DIR }) {
361
362
  }
362
363
 
363
364
  function buildAgentRoleContent({ PLUGIN_ROOT, profile = 'full' }) {
365
+ const AGENT_DIR = path.join(PLUGIN_ROOT, 'rules', 'agent');
364
366
  if (String(profile || 'full') === 'retrieval') {
365
- return buildAgentRetrievalInjectionContent();
367
+ return buildAgentRetrievalInjectionContent({ PLUGIN_ROOT });
366
368
  }
367
- const AGENT_DIR = path.join(PLUGIN_ROOT, 'rules', 'agent');
368
- return readOptional(path.join(AGENT_DIR, '00-common.md'));
369
+ const core = readOptional(path.join(AGENT_DIR, '00-core.md'));
370
+ const common = readOptional(path.join(AGENT_DIR, '00-common.md'));
371
+ return [core, common].filter(Boolean).join('\n\n');
369
372
  }
370
373
 
371
374
  /**
@@ -376,18 +379,19 @@ function buildAgentRoleContent({ PLUGIN_ROOT, profile = 'full' }) {
376
379
  *
377
380
  * @returns {string}
378
381
  */
379
- function buildAgentRetrievalInjectionContent() {
380
- return [
382
+ function buildAgentRetrievalInjectionContent({ PLUGIN_ROOT }) {
383
+ const AGENT_DIR = path.join(PLUGIN_ROOT, 'rules', 'agent');
384
+ const core = readOptional(path.join(AGENT_DIR, '00-core.md'));
385
+ const parts = [
381
386
  '# Tool Use',
382
387
  '',
383
388
  '- Batch independent read-only lookups in the same tool turn.',
384
389
  '- Use code_graph for symbols/dependencies, grep for exact text, find/glob/list for files, and read only known paths/windows.',
385
390
  '',
386
- '# Agent Constraints',
387
- '',
388
- '- Read-only retrieval role: do not edit files, run shell, or use git.',
389
- '- Follow the role output contract exactly; do not add handoff prose.',
390
- ].join('\n');
391
+ ];
392
+ if (core) parts.push(core.trim());
393
+ parts.push('', '- Read-only retrieval role: do not edit files, run shell, or use git.');
394
+ return parts.join('\n');
391
395
  }
392
396
 
393
397
  /**
@@ -216,6 +216,7 @@ import {
216
216
  TOOL_SEARCH_TOOL,
217
217
  CWD_TOOL,
218
218
  SKILL_TOOL,
219
+ SESSION_MANAGE_TOOL,
219
220
  LEAD_DISALLOWED_TOOLS,
220
221
  applyStandaloneToolDefaults,
221
222
  } from './session-runtime/tool-defs.mjs';
@@ -480,6 +481,9 @@ export async function createMixdogSessionRuntime({
480
481
  let sessionCreatePromise = null;
481
482
  let currentCwd = cwd;
482
483
  let sessionNeedsCwdRefresh = false;
484
+ // session_manage tool: reset request scheduled by the model mid-turn,
485
+ // consumed by the TUI engine at turn end ('clear' | 'compact_clear').
486
+ let pendingSessionReset = null;
483
487
  let closeRequested = false;
484
488
  const warmupTimers = {
485
489
  providerSetupWarmupTimer: null,
@@ -922,6 +926,7 @@ export async function createMixdogSessionRuntime({
922
926
  TOOL_SEARCH_TOOL,
923
927
  SKILL_TOOL,
924
928
  CWD_TOOL,
929
+ SESSION_MANAGE_TOOL,
925
930
  EXPLORE_TOOL,
926
931
  ...(searchToolDefs?.TOOL_DEFS || []).filter((tool) => tool?.name === 'search' || tool?.name === 'web_fetch'),
927
932
  ...(memoryToolDefs?.TOOL_DEFS || []).filter((tool) => tool?.name === 'recall' || tool?.name === 'memory'),
@@ -1018,6 +1023,29 @@ export async function createMixdogSessionRuntime({
1018
1023
  }
1019
1024
  return JSON.stringify({ cwd: currentCwd, sessionId: session?.id || null }, null, 2);
1020
1025
  }
1026
+ if (name === 'session_manage') {
1027
+ // Lead/owner sessions only: an agent worker resetting its own
1028
+ // transcript mid-task would corrupt the delegation contract, and it
1029
+ // must never reach the owner conversation either.
1030
+ const callerSessionId = callerCtx?.callerSessionId || null;
1031
+ if (callerSessionId && session?.id && callerSessionId !== session.id) {
1032
+ throw new Error('session_manage: only the lead session may reset the conversation');
1033
+ }
1034
+ if (!session?.id) throw new Error('session_manage: no active session');
1035
+ const action = clean(args?.action).toLowerCase();
1036
+ if (action !== 'clear' && action !== 'compact_clear') {
1037
+ throw new Error(`session_manage: unknown action "${action}" (use clear | compact_clear)`);
1038
+ }
1039
+ // Never clear mid-turn — the loop is still reading the transcript.
1040
+ // Schedule and let the TUI engine consume it at turn end (same
1041
+ // boundary the idle auto-clear uses).
1042
+ // Pin to the current session id so a resume/new-session between
1043
+ // scheduling and consumption can never clear the wrong conversation.
1044
+ pendingSessionReset = { action, sessionId: session.id };
1045
+ return action === 'clear'
1046
+ ? 'Session reset scheduled: full clear will run when this turn ends. All prior context will be gone.'
1047
+ : 'Session reset scheduled: the conversation will be summarized (compact) and cleared when this turn ends; key context carries forward in the summary.';
1048
+ }
1021
1049
  if (name === 'Skill') {
1022
1050
  return skillToolContent(args?.name);
1023
1051
  }
@@ -1498,6 +1526,16 @@ export async function createMixdogSessionRuntime({
1498
1526
  // seat, or to re-pin forwarding onto the current transcript).
1499
1527
  function startRemote() {
1500
1528
  remoteEnabled = true;
1529
+ // Boot the memory daemon eagerly. The channels worker forwards
1530
+ // transcript ingests/entries to the memory HTTP service, whose port is
1531
+ // published to active-instance.json by getMemoryModule().init(). Without
1532
+ // this, memory only starts on the first turn's getMemoryModule() call —
1533
+ // so early channel traffic finds no memory_port and gets buffered (or,
1534
+ // pre-drainer, silently dropped). Fire-and-forget BEFORE channel claim so
1535
+ // the port is (racing to be) live by the time the worker sends its first
1536
+ // ingest.
1537
+ try { void getMemoryModule().catch((error) => bootProfile('channels:memory-eager-init-failed', { error: error?.message || String(error) })); }
1538
+ catch (error) { bootProfile('channels:memory-eager-init-failed', { error: error?.message || String(error) }); }
1501
1539
  // Publish this session's record + transcript file BEFORE the worker's
1502
1540
  // activate-time discovery polls, so output forwarding binds to this
1503
1541
  // terminal session immediately instead of waiting for the first turn.
@@ -2518,6 +2556,16 @@ export async function createMixdogSessionRuntime({
2518
2556
  invalidateContextStatusCache();
2519
2557
  return true;
2520
2558
  },
2559
+ // session_manage tool handoff: the engine polls this at turn end and, if
2560
+ // set, runs the same clear path the idle auto-clear uses. One-shot read.
2561
+ consumePendingSessionReset() {
2562
+ const pending = pendingSessionReset;
2563
+ pendingSessionReset = null;
2564
+ if (!pending) return null;
2565
+ // Session changed since scheduling (resume / new session) — drop it.
2566
+ if (!session?.id || pending.sessionId !== session.id) return null;
2567
+ return pending.action;
2568
+ },
2521
2569
  async compact(options = {}) {
2522
2570
  if (!session?.id) return null;
2523
2571
  if (activeTurnCount > 0) {
@@ -1,20 +1,8 @@
1
- # Agent Constraints
1
+ # Public Agent Constraints
2
2
 
3
- - Use English for agent task communication.
4
3
  - Do not touch git/Ship. Even when the brief instructs `git add` / `commit` /
5
4
  `push` / `stash`, refuse with `git operations deferred to Lead`.
6
- - NEVER PREAMBLE: no tool-call preambles, status/progress narration, "I
7
- will..." setup, or transition text before tool calls.
8
- - If tools are needed, call them immediately. Emit text only for the final
9
- handoff after tool work is done.
10
- - Final handoff: minimum characters, maximum information for Lead. Follow the
11
- role's stricter output contract if defined; else emit fragments — outcome
12
- (1 line), key `file:line`(s), verification result, material risks (only if
13
- any).
14
- - Handoff cap ~30 lines unless `Deliver:` raises it. Overflow goes to a file;
15
- hand off path + fragments.
16
- - Banned as pure cost: report headings, markdown tables (unless requested),
17
- prose narration, raw logs/tool traces, speculative next-checks, restated
18
- brief, articles/politeness.
19
- - Exception: a runtime wrap-up directive (exploration budget reached) overrides
20
- this — then summarize done/remaining/blocking as instructed.
5
+ - Shell is for verification of your own edits (node --check, targeted tests,
6
+ build/lint) — not for exploration, installs, or state changes beyond the
7
+ brief's scope.
8
+ - Overflow goes to a file; hand off path + fragments.
@@ -0,0 +1,21 @@
1
+ # Agent Constraints
2
+
3
+ - Use English for agent task communication.
4
+ - NEVER PREAMBLE: no tool-call preambles, status/progress narration, "I
5
+ will..." setup, or transition text before tool calls.
6
+ - If tools are needed, call them immediately. Emit text only for the final
7
+ handoff after tool work is done.
8
+ - Final handoff: minimum characters, maximum information for Lead. Follow the
9
+ role's stricter output contract if defined; else emit fragments — outcome
10
+ (1 line), key `file:line`(s), verification result, material risks (only if
11
+ any).
12
+ - Density over length: never repeat what Lead already knows (the brief, the
13
+ process, how you searched); state only what changed and where to look.
14
+ Verification = command + result in one line. Same fact twice = delete one.
15
+ - Handoff cap ~30 lines unless `Deliver:` raises it — a safety ceiling, not
16
+ a target.
17
+ - Banned as pure cost: report headings, markdown tables (unless requested),
18
+ prose narration, raw logs/tool traces, speculative next-checks, restated
19
+ brief, articles/politeness.
20
+ - Exception: a runtime wrap-up directive (exploration budget reached) overrides
21
+ this — then summarize done/remaining/blocking as instructed.
@@ -3,6 +3,13 @@
3
3
  - Brief = one-line fragments `Goal:` `Anchors:` `Allow/Forbid:` `Deliver:`
4
4
  `Verify:` (+`Stop:` heavy-worker). No role-known rules, background, or
5
5
  motivation — minimum characters, maximum information.
6
+ - Density over length: every line must carry information the agent cannot
7
+ know — never restate role rules, prior context, or the same fact twice.
8
+ A brief that feels long is a scope problem; split the scope, don't pad.
9
+ - Anchors = `file:line` + one-line conclusion each. Never paste log/code
10
+ bodies; the agent reads the file itself.
11
+ - Instruct by outcome ("make X behave Y"), not by method (no code snippets,
12
+ no step-by-step edits) unless the method IS the requirement.
6
13
  - `Deliver:` states output size/shape (e.g. "fragments <=15 lines", "verdict
7
14
  + top-3 risks", "detail to file, path only"). Never request a long report
8
15
  in the handoff itself.
@@ -29,11 +29,16 @@ import {
29
29
  askSession,
30
30
  updateSessionStatus,
31
31
  closeSession,
32
+ getSession,
32
33
  } from '../session/manager.mjs';
33
34
  import {
35
+ abortAgentProgressWatchdog,
34
36
  agentWatchdogPolicyActive,
35
37
  evaluateAgentWatchdogAbort,
36
38
  resolveAgentWatchdogPolicy,
39
+ resolveHandoffMessageStartIndex,
40
+ watchdogPartialHandoffFromError,
41
+ AgentStallAbortError,
37
42
  } from './agent-progress-watchdog.mjs';
38
43
 
39
44
  // Cap agent role synthesis to ~3000 tokens (~12 KB at the 4 B/tok
@@ -332,6 +337,7 @@ export function makeAgentDispatch(opts = {}) {
332
337
  const _linkSignal = _managerMod?.linkParentSignalToSession;
333
338
  const _getProgressSnapshot = _managerMod?.getSessionProgressSnapshot;
334
339
  const _getLastProgressAt = _managerMod?.getSessionLastProgressAt;
340
+ const _getSession = _managerMod?.getSession || getSession;
335
341
  if (_linkSignal) {
336
342
  if (opts.parentSignal instanceof AbortSignal) {
337
343
  try { _linkSignal(session.id, opts.parentSignal); } catch { /* ignore */ }
@@ -362,6 +368,7 @@ export function makeAgentDispatch(opts = {}) {
362
368
  const _watchdogAnchorTs = Date.now();
363
369
  const _idleTimer = (_idleController && (typeof _getProgressSnapshot === 'function' || typeof _getLastProgressAt === 'function'))
364
370
  ? setInterval(() => {
371
+ if (_idleController.signal?.aborted) return;
365
372
  const now = Date.now();
366
373
  const snapshot = typeof _getProgressSnapshot === 'function' ? _getProgressSnapshot(session.id) : null;
367
374
  const abortErr = snapshot
@@ -371,12 +378,33 @@ export function makeAgentDispatch(opts = {}) {
371
378
  const reported = typeof _getLastProgressAt === 'function' ? _getLastProgressAt(session.id) : 0;
372
379
  const last = reported || _watchdogAnchorTs;
373
380
  if (_watchdogPolicy.idleStaleMs > 0 && now - last > _watchdogPolicy.idleStaleMs) {
374
- try { _idleController.abort(new Error(`agent task stale (${_watchdogPolicy.idleStaleMs}ms without progress)`)); } catch { /* ignore */ }
381
+ const err = new AgentStallAbortError(`agent task stale (${_watchdogPolicy.idleStaleMs}ms without progress)`);
382
+ const sess = typeof _getSession === 'function' ? _getSession(session.id) : null;
383
+ abortAgentProgressWatchdog(_idleController, {
384
+ sessionId: session.id,
385
+ agent,
386
+ error: err,
387
+ policy: _watchdogPolicy,
388
+ now,
389
+ anchorTs: _watchdogAnchorTs,
390
+ lastProgressAt: reported,
391
+ iteration: typeof sess?.lastIterationIndex === 'number' ? sess.lastIterationIndex : null,
392
+ });
375
393
  }
376
394
  return;
377
395
  }
378
396
  if (abortErr) {
379
- try { _idleController.abort(abortErr); } catch { /* ignore */ }
397
+ const sess = typeof _getSession === 'function' ? _getSession(session.id) : null;
398
+ abortAgentProgressWatchdog(_idleController, {
399
+ sessionId: session.id,
400
+ agent,
401
+ error: abortErr,
402
+ snapshot,
403
+ policy: _watchdogPolicy,
404
+ now,
405
+ anchorTs: _watchdogAnchorTs,
406
+ iteration: typeof sess?.lastIterationIndex === 'number' ? sess.lastIterationIndex : null,
407
+ });
380
408
  }
381
409
  }, 1000)
382
410
  : null;
@@ -384,7 +412,9 @@ export function makeAgentDispatch(opts = {}) {
384
412
  let terminalStatus = 'idle';
385
413
  process.stderr.write(`[agent-dispatch] agent=${agent} preset=${presetName} model=${preset.model} provider=${preset.provider} session=${session.id}\n`);
386
414
  const _agentDispatchT0 = Date.now();
415
+ let _handoffMsgStart = 0;
387
416
  try {
417
+ _handoffMsgStart = resolveHandoffMessageStartIndex(getSession(session.id));
388
418
  const result = await askSession(session.id, finalPrompt, null, null, cwd, undefined, {
389
419
  onCompactEvent: (event) => {
390
420
  try {
@@ -413,6 +443,13 @@ export function makeAgentDispatch(opts = {}) {
413
443
  try { closeSession(session.id, 'ephemeral-done'); } catch { /* ignore */ }
414
444
  return out;
415
445
  } catch (err) {
446
+ const partial = watchdogPartialHandoffFromError(err, getSession(session.id), _handoffMsgStart);
447
+ if (partial) {
448
+ terminalStatus = 'idle';
449
+ try { closeSession(session.id, 'ephemeral-done'); } catch { /* ignore */ }
450
+ if (opts.brief === false) return partial;
451
+ return applyBriefCap(partial);
452
+ }
416
453
  terminalStatus = 'error';
417
454
  try { closeSession(session.id, 'ephemeral-error'); } catch { /* ignore */ }
418
455
  throw err;
@@ -17,31 +17,6 @@ function envPositiveInt(name, fallback) {
17
17
  // to raise/lower the safety ceiling, never used as a per-agent task budget.
18
18
  export const LEAD_MAX_LOOP_ITERATIONS = envPositiveInt('MIXDOG_AGENT_MAX_LOOP', 200);
19
19
 
20
- // Worker soft cap — behavior-based ONLY. There is intentionally no fixed
21
- // iteration threshold: a legitimately long worker task (many edit/verify
22
- // rounds) must never be cut off by a count. The wrap-up is armed exclusively
23
- // by the steering ladder's early-cap signal (repeated ignored level-2 steers
24
- // with zero edits = confirmed read-only stall), after the ladder's own
25
- // warnings have gone out. The env knob remains as an opt-in count for
26
- // operators who want one; by default it is effectively disabled.
27
- export const WORKER_SOFT_CAP_ITERATIONS = envPositiveInt('MIXDOG_AGENT_SOFT_CAP', Number.MAX_SAFE_INTEGER);
28
-
29
- // Agents subject to the soft cap: implementation workers that should wrap up
30
- // once the exploration budget is spent. Reviewer and hidden long-runner agents
31
- // (explorer / cycle / scheduler / …) are legitimately read-only long-running
32
- // and are EXEMPT — capping them would truncate a valid long read pass.
33
- const SOFT_CAP_AGENTS = new Set(['worker', 'heavy-worker', 'maintainer', 'debugger']);
34
-
35
- /**
36
- * Is this session a delegated worker subject to the soft cap?
37
- * Only worker/heavy-worker/maintainer/debugger. Lead, TUI (no agent),
38
- * reviewer, and hidden agents return false.
39
- */
40
- export function isWorkerSoftCapSession(sessionRef) {
41
- const agent = sessionRef?.agent;
42
- return typeof agent === 'string' && SOFT_CAP_AGENTS.has(agent);
43
- }
44
-
45
20
  /**
46
21
  * Resolve the hard cap used by agentLoop for this session.
47
22
  *
@@ -4,12 +4,129 @@
4
4
  * lastProgressAt during long tool work; this module decides when to abort.
5
5
  */
6
6
 
7
+ import { appendAgentTrace } from '../agent-trace-io.mjs';
7
8
  import { getHiddenAgent } from '../internal-agents.mjs';
8
9
  import {
9
10
  resolveAgentStallThresholds,
10
11
  resolveAgentToolThresholdSeconds,
11
12
  } from '../stall-policy.mjs';
12
13
 
14
+ const WATCHDOG_ABORT_RE = /^agent (?:first response stale|task stale|tool running stale)\s*\(/;
15
+
16
+ /**
17
+ * Typed abort error for the agent progress watchdog. Carrying a stable `name`
18
+ * lets the retry-classifier and the WS/SSE abort handlers distinguish a
19
+ * watchdog stall from a user cancel: it is classified as `agent_stall` (a
20
+ * retryable stream failure), NOT a user abort (null classification). The abort
21
+ * signal reason surfaces as this error's `name`, so both the classifier's
22
+ * `err.name` check and the provider abort handlers' `reason.name` check match.
23
+ */
24
+ export class AgentStallAbortError extends Error {
25
+ constructor(message) {
26
+ super(message);
27
+ this.name = 'AgentStallAbortError';
28
+ }
29
+ }
30
+
31
+ export function isAgentProgressWatchdogAbortError(err) {
32
+ const msg = err?.message;
33
+ return typeof msg === 'string' && WATCHDOG_ABORT_RE.test(msg);
34
+ }
35
+
36
+ function assistantMessageText(content) {
37
+ if (typeof content === 'string') return content;
38
+ if (!Array.isArray(content)) return '';
39
+ return content
40
+ .filter((b) => b && (b.type === 'text' || b.type === 'output_text'))
41
+ .map((b) => (typeof b.text === 'string' ? b.text : ''))
42
+ .join('\n');
43
+ }
44
+
45
+ /** Message index at askSession start — salvage only assistant rows appended this run. */
46
+ export function resolveHandoffMessageStartIndex(session) {
47
+ const messages = Array.isArray(session?.messages) ? session.messages : [];
48
+ return messages.length;
49
+ }
50
+
51
+ export function collectSessionAssistantHandoffText(session, messageStartIndex = 0) {
52
+ const messages = Array.isArray(session?.messages) ? session.messages : [];
53
+ const start = Math.max(0, Math.floor(Number(messageStartIndex) || 0));
54
+ const parts = [];
55
+ for (let i = start; i < messages.length; i += 1) {
56
+ const m = messages[i];
57
+ if (m?.role !== 'assistant') continue;
58
+ const t = assistantMessageText(m.content).trim();
59
+ if (t && t !== '.') parts.push(t);
60
+ }
61
+ return parts.length ? parts.join('\n\n') : '';
62
+ }
63
+
64
+ export function watchdogPartialHandoffFromError(error, session, messageStartIndex = 0) {
65
+ if (!isAgentProgressWatchdogAbortError(error)) return null;
66
+ const text = collectSessionAssistantHandoffText(session, messageStartIndex);
67
+ return text.trim() ? text : null;
68
+ }
69
+
70
+ function resolveWatchdogAbortElapsedMs({ error, snapshot, policy, now, anchorTs, lastProgressAt }) {
71
+ if (snapshot && policy) {
72
+ if (snapshot.waitingForFirstActivity) {
73
+ const startedAt = snapshot.modelRequestStartedAt || snapshot.askStartedAt;
74
+ if (startedAt) return Math.max(0, now - startedAt);
75
+ }
76
+ if (snapshot.stage === 'tool_running' && snapshot.toolStartedAt
77
+ && typeof error?.message === 'string' && error.message.includes('tool running stale')) {
78
+ return Math.max(0, now - snapshot.toolStartedAt);
79
+ }
80
+ const last = snapshot.lastProgressAt || snapshot.firstActivityAt;
81
+ if (last) return Math.max(0, now - last);
82
+ }
83
+ const last = lastProgressAt || anchorTs;
84
+ if (last) return Math.max(0, now - last);
85
+ return null;
86
+ }
87
+
88
+ export function recordAgentWatchdogAbort({
89
+ sessionId,
90
+ agent = null,
91
+ error,
92
+ snapshot = null,
93
+ policy = null,
94
+ now = Date.now(),
95
+ anchorTs = 0,
96
+ lastProgressAt = 0,
97
+ iteration = null,
98
+ }) {
99
+ if (!sessionId || !error) return;
100
+ const elapsed = resolveWatchdogAbortElapsedMs({
101
+ error,
102
+ snapshot,
103
+ policy,
104
+ now,
105
+ anchorTs,
106
+ lastProgressAt,
107
+ });
108
+ try {
109
+ appendAgentTrace({
110
+ sessionId,
111
+ iteration: iteration ?? null,
112
+ kind: 'stall_abort',
113
+ agent: agent || null,
114
+ payload: {
115
+ elapsed_ms: elapsed,
116
+ message: typeof error.message === 'string' ? error.message : String(error),
117
+ stage: snapshot?.stage ?? null,
118
+ },
119
+ });
120
+ } catch { /* best-effort */ }
121
+ }
122
+
123
+ export function abortAgentProgressWatchdog(controller, ctx) {
124
+ if (!controller || !ctx?.error) return;
125
+ if (controller.signal?.aborted) return;
126
+ recordAgentWatchdogAbort(ctx);
127
+ try { controller.abort(ctx.error); } catch { /* ignore */ }
128
+ }
129
+
13
130
  function envTimeoutMs(name, fallback) {
14
131
  const raw = process.env[name];
15
132
  if (raw === undefined || raw === '') return fallback;
@@ -76,14 +193,14 @@ export function evaluateAgentWatchdogAbort(snapshot, now, policy) {
76
193
  if (snapshot.waitingForFirstActivity) {
77
194
  const startedAt = snapshot.modelRequestStartedAt || snapshot.askStartedAt;
78
195
  if (policy.firstResponseMs > 0 && startedAt && now - startedAt > policy.firstResponseMs) {
79
- return new Error(`agent first response stale (${policy.firstResponseMs}ms)`);
196
+ return new AgentStallAbortError(`agent first response stale (${policy.firstResponseMs}ms)`);
80
197
  }
81
198
  return null;
82
199
  }
83
200
 
84
201
  const last = snapshot.lastProgressAt || snapshot.firstActivityAt;
85
202
  if (policy.idleStaleMs > 0 && last && now - last > policy.idleStaleMs) {
86
- return new Error(`agent task stale (${policy.idleStaleMs}ms without stream/tool progress)`);
203
+ return new AgentStallAbortError(`agent task stale (${policy.idleStaleMs}ms without stream/tool progress)`);
87
204
  }
88
205
 
89
206
  if (
@@ -92,7 +209,7 @@ export function evaluateAgentWatchdogAbort(snapshot, now, policy) {
92
209
  && policy.toolRunningMs > 0
93
210
  && now - snapshot.toolStartedAt > policy.toolRunningMs
94
211
  ) {
95
- return new Error(`agent tool running stale (${policy.toolRunningMs}ms)`);
212
+ return new AgentStallAbortError(`agent tool running stale (${policy.toolRunningMs}ms)`);
96
213
  }
97
214
 
98
215
  return null;
@@ -155,7 +155,42 @@ function extractThinkingTokens(rawUsage) {
155
155
  return null;
156
156
  }
157
157
 
158
- function traceAgentUsage({ sessionId, iteration, inputTokens, outputTokens, cachedTokens, cacheWriteTokens, promptTokens, model, modelDisplay, responseId, rawUsage, provider, serviceTier, requestKind }) {
158
+ /** xAI Responses cache-chain diagnosis for usage_raw rows (measurement only). */
159
+ function grokCacheChainTraceFields(providerState, requestPrevResponseId, continuationResetReason = null) {
160
+ const lastReceived = typeof providerState?.xaiResponses?.previousResponseId === 'string'
161
+ && providerState.xaiResponses.previousResponseId.length > 0
162
+ ? providerState.xaiResponses.previousResponseId
163
+ : null;
164
+ const sent = typeof requestPrevResponseId === 'string' && requestPrevResponseId.length > 0
165
+ ? requestPrevResponseId
166
+ : null;
167
+ const chainContinuous = sent !== null && sent === lastReceived;
168
+ return {
169
+ requestPrevResponseId: sent,
170
+ chainContinuous,
171
+ continuationResetReason: continuationResetReason || null,
172
+ };
173
+ }
174
+
175
+ function traceAgentUsage({
176
+ sessionId,
177
+ iteration,
178
+ inputTokens,
179
+ outputTokens,
180
+ cachedTokens,
181
+ cacheWriteTokens,
182
+ promptTokens,
183
+ model,
184
+ modelDisplay,
185
+ responseId,
186
+ rawUsage,
187
+ provider,
188
+ serviceTier,
189
+ requestKind,
190
+ requestPrevResponseId,
191
+ chainContinuous,
192
+ continuationResetReason,
193
+ }) {
159
194
  const inclusive = isInclusiveProvider(provider);
160
195
  const inTok = inputTokens || 0;
161
196
  const cacheRead = cachedTokens || 0;
@@ -186,6 +221,9 @@ function traceAgentUsage({ sessionId, iteration, inputTokens, outputTokens, cach
186
221
  response_id: responseId || null,
187
222
  request_kind: typeof requestKind === 'string' && requestKind ? requestKind : null,
188
223
  service_tier: resolvedServiceTier,
224
+ ...(requestPrevResponseId !== undefined ? { request_prev_response_id: requestPrevResponseId } : {}),
225
+ ...(chainContinuous !== undefined ? { chain_continuous: chainContinuous } : {}),
226
+ ...(continuationResetReason !== undefined ? { continuation_reset_reason: continuationResetReason } : {}),
189
227
  payload: {
190
228
  provider: provider || null,
191
229
  prompt_tokens: promptTotal,
@@ -195,6 +233,9 @@ function traceAgentUsage({ sessionId, iteration, inputTokens, outputTokens, cach
195
233
  response_id: responseId || null,
196
234
  service_tier: resolvedServiceTier,
197
235
  raw_usage: rawUsage || null,
236
+ ...(requestPrevResponseId !== undefined ? { request_prev_response_id: requestPrevResponseId } : {}),
237
+ ...(chainContinuous !== undefined ? { chain_continuous: chainContinuous } : {}),
238
+ ...(continuationResetReason !== undefined ? { continuation_reset_reason: continuationResetReason } : {}),
198
239
  },
199
240
  });
200
241
  }
@@ -213,6 +254,7 @@ export {
213
254
  traceAgentToolFailure,
214
255
  traceAgentCompact,
215
256
  traceAgentUsage,
257
+ grokCacheChainTraceFields,
216
258
  traceAgentCompress,
217
259
  traceAgentBatch,
218
260
  traceStreamAborted,
@@ -511,7 +511,7 @@ function loadHiddenAgentSnippets(pluginRoot) {
511
511
  const agentRulesDir = join(pluginRoot, 'rules', 'agent');
512
512
  if (!existsSync(agentRulesDir)) return [];
513
513
  const files = readdirSync(agentRulesDir)
514
- .filter(f => f.endsWith('.md') && f !== '00-common.md')
514
+ .filter(f => f.endsWith('.md') && f !== '00-common.md' && f !== '00-core.md')
515
515
  .sort();
516
516
  const pairs = [];
517
517
  for (const f of files) {