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.
- package/package.json +1 -1
- package/scripts/internal-comms-bench.mjs +1 -0
- package/scripts/internal-comms-smoke.mjs +11 -7
- package/src/lib/rules-builder.cjs +15 -11
- package/src/mixdog-session-runtime.mjs +48 -0
- package/src/rules/agent/00-common.md +5 -17
- package/src/rules/agent/00-core.md +21 -0
- package/src/rules/lead/lead-brief.md +7 -0
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +39 -2
- package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +0 -25
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +120 -3
- package/src/runtime/agent/orchestrator/agent-trace.mjs +43 -1
- package/src/runtime/agent/orchestrator/context/collect.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +48 -3
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +42 -4
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +24 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +6 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +61 -6
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +31 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +57 -2
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +40 -4
- package/src/runtime/agent/orchestrator/session/compact/budget.mjs +0 -62
- package/src/runtime/agent/orchestrator/session/compact.mjs +0 -2
- package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +12 -9
- package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +12 -0
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +48 -157
- package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +6 -23
- package/src/runtime/agent/orchestrator/session/loop/termination.mjs +0 -13
- package/src/runtime/agent/orchestrator/session/loop.mjs +32 -77
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +33 -45
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +7 -6
- package/src/runtime/agent/orchestrator/session/manager.mjs +27 -14
- package/src/runtime/agent/orchestrator/stall-policy.mjs +16 -0
- package/src/runtime/channels/index.mjs +28 -1
- package/src/runtime/channels/lib/memory-client.mjs +200 -15
- package/src/runtime/memory/index.mjs +11 -11
- package/src/runtime/memory/lib/cycle-scheduler.mjs +6 -4
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +22 -17
- package/src/runtime/memory/lib/memory-embed.mjs +35 -1
- package/src/session-runtime/tool-catalog.mjs +1 -0
- package/src/session-runtime/tool-defs.mjs +28 -0
- package/src/standalone/agent-tool.mjs +89 -7
- package/src/tui/dist/index.mjs +35 -12
- package/src/tui/engine.mjs +55 -12
- package/src/workflows/default/WORKFLOW.md +2 -0
- package/src/workflows/sequential/WORKFLOW.md +2 -0
- package/src/workflows/solo/WORKFLOW.md +2 -0
package/package.json
CHANGED
|
@@ -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-
|
|
42
|
-
const
|
|
43
|
-
assert(/minimum characters, maximum information/i.test(flat(
|
|
44
|
-
assert(/fragments/i.test(
|
|
45
|
-
assert(/file:line/i.test(
|
|
46
|
-
assert(/Banned as pure cost/i.test(flat(
|
|
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(
|
|
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-
|
|
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
|
|
368
|
-
|
|
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
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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) {
|