mixdog 0.9.23 → 0.9.25
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 +2 -1
- package/scripts/boot-smoke.mjs +1 -1
- package/scripts/channel-daemon-smoke.mjs +327 -9
- package/scripts/channel-daemon-stub.mjs +12 -1
- package/scripts/debounced-skills-async-save-test.mjs +57 -0
- package/scripts/explore-bench-tmp.mjs +17 -0
- package/scripts/find-fuzzy-hidden-test.mjs +145 -0
- package/scripts/mcp-grace-deferred-test.mjs +149 -0
- package/scripts/tool-smoke.mjs +38 -30
- package/src/defaults/cycle3-review-prompt.md +11 -4
- package/src/defaults/memory-promote-prompt.md +9 -0
- package/src/rules/agent/30-explorer.md +6 -0
- package/src/rules/shared/01-tool.md +11 -4
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +76 -1
- package/src/runtime/agent/orchestrator/config.mjs +33 -7
- package/src/runtime/agent/orchestrator/context/collect.mjs +43 -8
- package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +39 -0
- package/src/runtime/agent/orchestrator/mcp/client.mjs +145 -31
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +38 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +39 -1
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +24 -7
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +11 -1
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +7 -3
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +40 -6
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +13 -5
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +62 -24
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -6
- package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +20 -0
- package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +34 -3
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +220 -27
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +61 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +43 -16
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -5
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +97 -54
- package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +49 -31
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +35 -2
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +70 -21
- package/src/runtime/channels/backends/discord.mjs +10 -3
- package/src/runtime/channels/lib/crash-log.mjs +4 -2
- package/src/runtime/channels/lib/inbound-handler.mjs +45 -1
- package/src/runtime/channels/lib/output-forwarder.mjs +2 -1
- package/src/runtime/channels/lib/owned-runtime.mjs +65 -180
- package/src/runtime/channels/lib/owner-heartbeat.mjs +9 -13
- package/src/runtime/channels/lib/runtime-paths.mjs +6 -6
- package/src/runtime/channels/lib/tool-dispatch.mjs +9 -17
- package/src/runtime/channels/lib/tool-format.mjs +7 -2
- package/src/runtime/channels/lib/worker-main.mjs +9 -28
- package/src/runtime/memory/lib/cycle-scheduler.mjs +17 -1
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +59 -0
- package/src/runtime/memory/lib/memory-cycle2.mjs +10 -3
- package/src/runtime/memory/lib/memory-cycle3.mjs +79 -9
- package/src/runtime/memory/lib/query-handlers.mjs +4 -1
- package/src/runtime/memory/lib/recall-format.mjs +7 -3
- package/src/runtime/memory/tool-defs.mjs +1 -1
- package/src/runtime/shared/atomic-file.mjs +130 -2
- package/src/runtime/shared/background-tasks.mjs +1 -1
- package/src/runtime/shared/config.mjs +53 -1
- package/src/runtime/shared/tool-execution-contract.mjs +1 -1
- package/src/runtime/shared/tool-surface.mjs +19 -0
- package/src/runtime/shared/update-checker.mjs +3 -0
- package/src/runtime/shared/user-data-guard.mjs +66 -0
- package/src/session-runtime/config-lifecycle.mjs +175 -15
- package/src/session-runtime/mcp-glue.mjs +30 -0
- package/src/session-runtime/runtime-core.mjs +91 -7
- package/src/session-runtime/session-turn-api.mjs +42 -16
- package/src/session-runtime/tool-catalog.mjs +44 -0
- package/src/standalone/channel-admin.mjs +32 -3
- package/src/standalone/channel-daemon-client.mjs +3 -1
- package/src/standalone/channel-daemon-transport.mjs +202 -8
- package/src/standalone/channel-daemon.mjs +54 -17
- package/src/standalone/channel-worker.mjs +18 -7
- package/src/standalone/explore-tool.mjs +87 -15
- package/src/tui/App.jsx +2 -2
- package/src/tui/components/StatusLine.jsx +3 -3
- package/src/tui/components/ToolExecution.jsx +14 -2
- package/src/tui/components/TranscriptItem.jsx +1 -1
- package/src/tui/dist/index.mjs +246 -47
- package/src/tui/engine/agent-job-feed.mjs +47 -3
- package/src/tui/engine/notification-plan.mjs +5 -0
- package/src/tui/engine/session-api.mjs +6 -1
- package/src/tui/engine/tool-card-results.mjs +14 -5
- package/src/tui/engine/turn.mjs +9 -2
- package/src/tui/engine.mjs +31 -12
- package/src/ui/statusline-agents.mjs +36 -0
- package/src/ui/statusline.mjs +15 -5
- package/src/workflows/default/WORKFLOW.md +29 -38
- package/src/workflows/solo/WORKFLOW.md +15 -20
- package/src/runtime/channels/lib/seat-lock.mjs +0 -196
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Regression tests for turn-time deferred-manifest construction (claude-code
|
|
2
|
+
// style). An MCP server that finishes its handshake BETWEEN session-create and
|
|
3
|
+
// the user's FIRST send is folded — on the first turn, synchronously, with no
|
|
4
|
+
// boot await — into the INITIAL <available-deferred-tools> manifest (BP1) and
|
|
5
|
+
// pre-marked announced, so the first-turn tool-catalog reconcile emits NO late
|
|
6
|
+
// <system-reminder> for it. A server that connects AFTER the first turn keeps
|
|
7
|
+
// the append-only late-reminder path. Unit-style: exercise the tool-catalog
|
|
8
|
+
// exports directly (no runtime, no spawn) the way session-turn-api/runtime-core
|
|
9
|
+
// wire them.
|
|
10
|
+
import test from 'node:test';
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import {
|
|
13
|
+
applyDeferredToolSurface,
|
|
14
|
+
refreshInitialDeferredMcpSurface,
|
|
15
|
+
reconcileDeferredMcpToolCatalog,
|
|
16
|
+
} from '../src/session-runtime/tool-catalog.mjs';
|
|
17
|
+
|
|
18
|
+
function baseSession() {
|
|
19
|
+
return {
|
|
20
|
+
provider: 'legacy',
|
|
21
|
+
tools: [{ name: 'read', description: 'read a file' }, { name: 'grep', description: 'search' }],
|
|
22
|
+
messages: [{ role: 'system', content: 'BASE PROMPT' }],
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// A freshly-created session: the create-time surface is baked WITHOUT any MCP
|
|
27
|
+
// (server still mid-handshake at create). `shell` is a deferred (non-active)
|
|
28
|
+
// standalone tool so BP1 carries a manifest block even before MCP arrives.
|
|
29
|
+
function createdSession() {
|
|
30
|
+
const session = baseSession();
|
|
31
|
+
applyDeferredToolSurface(session, 'full', [{ name: 'shell', description: 'run a command' }], { provider: 'legacy' });
|
|
32
|
+
return session;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function systemContent(session) {
|
|
36
|
+
return session.messages.find((m) => m.role === 'system').content;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const mcpTool = { name: 'mcp__unity__get_scene', description: 'Return the active Unity scene graph.' };
|
|
40
|
+
const mcpTool2 = { name: 'mcp__unity__run_tests', description: 'Run the Unity test runner.' };
|
|
41
|
+
|
|
42
|
+
// Mirror the session-turn-api first-turn gate: refresh runs ONCE, only for a
|
|
43
|
+
// session flagged fresh (deferredInitialRefreshPending) at create; a resumed
|
|
44
|
+
// session (reloaded transcript, flag absent) takes the late-reconcile path and
|
|
45
|
+
// its already-baked BP1 is never rebuilt or re-announced.
|
|
46
|
+
function firstTurnGate(session, liveMcp) {
|
|
47
|
+
if (session.deferredInitialRefreshPending) {
|
|
48
|
+
session.deferredInitialRefreshPending = false;
|
|
49
|
+
return refreshInitialDeferredMcpSurface(session, liveMcp) ? 'refreshed' : 'refresh-noop';
|
|
50
|
+
}
|
|
51
|
+
return 'late';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
test('MCP connecting between session-create and first send lands in the INITIAL manifest, not a late reminder', () => {
|
|
55
|
+
const session = createdSession();
|
|
56
|
+
assert.ok(!systemContent(session).includes('mcp__unity__get_scene'), 'MCP absent from create-time BP1');
|
|
57
|
+
|
|
58
|
+
// First-turn refresh: the live registry now includes the connected server.
|
|
59
|
+
const changed = refreshInitialDeferredMcpSurface(session, [mcpTool]);
|
|
60
|
+
assert.equal(changed, true, 'first-turn refresh folded the newly-connected MCP tool');
|
|
61
|
+
|
|
62
|
+
const sys = systemContent(session);
|
|
63
|
+
assert.ok(sys.includes('<available-deferred-tools>'), 'manifest block present');
|
|
64
|
+
assert.ok(sys.includes('mcp__unity__get_scene'), 'MCP tool in the INITIAL manifest');
|
|
65
|
+
assert.ok(session.deferredAnnouncedTools.includes('mcp__unity__get_scene'), 'MCP tool pre-marked announced');
|
|
66
|
+
|
|
67
|
+
let enqueued = null;
|
|
68
|
+
const result = reconcileDeferredMcpToolCatalog(session, [mcpTool], {
|
|
69
|
+
enqueue: (text) => { enqueued = text; return true; },
|
|
70
|
+
});
|
|
71
|
+
assert.equal(result, null, 'no late-tool announcement for a first-turn-folded tool');
|
|
72
|
+
assert.equal(enqueued, null, 'nothing enqueued for a first-turn-folded tool');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('an MCP server connecting AFTER the first turn is still announced via the late reminder', () => {
|
|
76
|
+
const session = createdSession();
|
|
77
|
+
// First turn: no MCP connected yet — nothing to fold.
|
|
78
|
+
assert.equal(refreshInitialDeferredMcpSurface(session, []), false, 'no live MCP => first-turn no-op');
|
|
79
|
+
assert.ok(!(session.deferredAnnouncedTools || []).includes('mcp__unity__get_scene'), 'MCP not pre-announced');
|
|
80
|
+
|
|
81
|
+
// A later turn: the server has since connected → late path fires.
|
|
82
|
+
let enqueued = null;
|
|
83
|
+
const result = reconcileDeferredMcpToolCatalog(session, [mcpTool], {
|
|
84
|
+
enqueue: (text) => { enqueued = text; return true; },
|
|
85
|
+
});
|
|
86
|
+
assert.deepEqual(result, ['mcp__unity__get_scene'], 'late tool announced');
|
|
87
|
+
assert.ok(enqueued && enqueued.includes('mcp__unity__get_scene'), 'late reminder enqueued');
|
|
88
|
+
assert.ok(enqueued.includes('connected after this session started'), 'reminder carries the late-tool sentinel');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('recreated session (MCP already connected at create) seeds its manifest, no late re-announce', () => {
|
|
92
|
+
// Recreate/reset path: createCurrentSession folds the live MCP tools into the
|
|
93
|
+
// surface at create time, so a cwd-change recreate seeds its BP1 directly and
|
|
94
|
+
// never needs the first-turn refresh.
|
|
95
|
+
const session = baseSession();
|
|
96
|
+
applyDeferredToolSurface(session, 'full', [{ name: 'shell', description: 'run a command' }, mcpTool], { provider: 'legacy' });
|
|
97
|
+
assert.ok(systemContent(session).includes('mcp__unity__get_scene'), 'MCP in recreated BP1');
|
|
98
|
+
assert.ok(session.deferredAnnouncedTools.includes('mcp__unity__get_scene'), 'recreated MCP pre-marked announced');
|
|
99
|
+
|
|
100
|
+
let enqueued = null;
|
|
101
|
+
const result = reconcileDeferredMcpToolCatalog(session, [mcpTool], {
|
|
102
|
+
enqueue: (text) => { enqueued = text; return true; },
|
|
103
|
+
});
|
|
104
|
+
assert.equal(result, null, 'no late announcement on recreate');
|
|
105
|
+
assert.equal(enqueued, null, 'nothing enqueued on recreate');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('first-turn refresh is idempotent: a re-render with no new MCP is a no-op and keeps ONE manifest block', () => {
|
|
109
|
+
const session = createdSession();
|
|
110
|
+
assert.equal(refreshInitialDeferredMcpSurface(session, [mcpTool]), true, 'first fold applies');
|
|
111
|
+
const once = systemContent(session);
|
|
112
|
+
assert.equal(refreshInitialDeferredMcpSurface(session, [mcpTool]), false, 'no genuinely-new MCP => no-op');
|
|
113
|
+
const twice = systemContent(session);
|
|
114
|
+
assert.equal(once, twice, 'BP1 byte-identical on re-render');
|
|
115
|
+
assert.equal((twice.match(/<available-deferred-tools>/g) || []).length, 1, 'exactly one manifest block (no duplicate)');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('a second newly-connected MCP tool re-renders BP1 in place (both listed, still one block)', () => {
|
|
119
|
+
const session = createdSession();
|
|
120
|
+
refreshInitialDeferredMcpSurface(session, [mcpTool]);
|
|
121
|
+
assert.equal(refreshInitialDeferredMcpSurface(session, [mcpTool, mcpTool2]), true, 're-fold applies the new tool');
|
|
122
|
+
const sys = systemContent(session);
|
|
123
|
+
assert.ok(sys.includes('mcp__unity__get_scene') && sys.includes('mcp__unity__run_tests'), 'both MCP tools listed');
|
|
124
|
+
assert.equal((sys.match(/<available-deferred-tools>/g) || []).length, 1, 'still exactly one block (rebuilt in place)');
|
|
125
|
+
assert.ok(session.deferredAnnouncedTools.includes('mcp__unity__run_tests'), 'the new tool is pre-announced too');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('a resumed session (no fresh flag, prior baked BP1) is NOT refreshed on its next turn', () => {
|
|
129
|
+
// A prior run baked BP1 with the MCP tool; resume reloads that transcript.
|
|
130
|
+
const session = baseSession();
|
|
131
|
+
applyDeferredToolSurface(session, 'full', [{ name: 'shell', description: 'run a command' }, mcpTool], { provider: 'legacy' });
|
|
132
|
+
const before = systemContent(session);
|
|
133
|
+
const announcedBefore = [...session.deferredAnnouncedTools];
|
|
134
|
+
// Resume path never sets the per-session fresh flag.
|
|
135
|
+
assert.equal(session.deferredInitialRefreshPending, undefined, 'resumed session carries no fresh flag');
|
|
136
|
+
const route = firstTurnGate(session, [mcpTool, mcpTool2]);
|
|
137
|
+
assert.equal(route, 'late', 'resumed session takes the late-reconcile path, not the initial refresh');
|
|
138
|
+
assert.equal(systemContent(session), before, 'BP1 untouched on resume (no rebuild)');
|
|
139
|
+
assert.deepEqual([...session.deferredAnnouncedTools], announcedBefore, 'announced set unchanged on resume');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('a fresh session (flagged) is refreshed exactly once, then falls to the late path', () => {
|
|
143
|
+
const session = createdSession();
|
|
144
|
+
session.deferredInitialRefreshPending = true;
|
|
145
|
+
assert.equal(firstTurnGate(session, [mcpTool]), 'refreshed', 'fresh session refreshes on its first turn');
|
|
146
|
+
assert.equal(session.deferredInitialRefreshPending, false, 'fresh flag consumed (one-shot)');
|
|
147
|
+
assert.ok(session.deferredAnnouncedTools.includes('mcp__unity__get_scene'), 'first-turn MCP pre-announced');
|
|
148
|
+
assert.equal(firstTurnGate(session, [mcpTool2]), 'late', 'second turn no longer refreshes');
|
|
149
|
+
});
|
package/scripts/tool-smoke.mjs
CHANGED
|
@@ -1050,32 +1050,28 @@ const prevChannelSingleton = process.env.MIXDOG_CHANNEL_SINGLETON;
|
|
|
1050
1050
|
const prevChannelWorkerProcess = process.env.MIXDOG_CHANNEL_WORKER_PROCESS;
|
|
1051
1051
|
const prevRuntimeRoot = process.env.MIXDOG_RUNTIME_ROOT;
|
|
1052
1052
|
const prevEnvOut = process.env.SMOKE_CHANNEL_ENV_OUT;
|
|
1053
|
+
const prevDaemonEntry = process.env.MIXDOG_CHANNEL_DAEMON_ENTRY;
|
|
1053
1054
|
try {
|
|
1054
|
-
|
|
1055
|
+
// Daemon-mode worker env coverage: start() spawn-or-attaches the machine
|
|
1056
|
+
// -global daemon (the stub daemon entry — no Discord token) instead of
|
|
1057
|
+
// forking `entry`, so assert the flags on the SPAWNED DAEMON's env (the stub
|
|
1058
|
+
// dumps them to SMOKE_CHANNEL_ENV_OUT). The old fork-path env assertion died
|
|
1059
|
+
// with the fork path itself; full flip/attach coverage lives in
|
|
1060
|
+
// scripts/channel-daemon-smoke.mjs.
|
|
1061
|
+
const stubEntry = join(root, 'scripts', 'channel-daemon-stub.mjs');
|
|
1055
1062
|
const dataDir = join(channelWorkerTmp, 'data');
|
|
1056
1063
|
const runtimeDir = join(channelWorkerTmp, 'runtime');
|
|
1057
1064
|
const envOut = join(channelWorkerTmp, 'env.json');
|
|
1058
1065
|
mkdirSync(dataDir, { recursive: true });
|
|
1059
1066
|
mkdirSync(runtimeDir, { recursive: true });
|
|
1060
|
-
writeFileSync(entry, `
|
|
1061
|
-
import { writeFileSync } from 'node:fs';
|
|
1062
|
-
writeFileSync(process.env.SMOKE_CHANNEL_ENV_OUT, JSON.stringify({
|
|
1063
|
-
cliOwned: process.env.MIXDOG_CLI_OWNED,
|
|
1064
|
-
daemon: process.env.MIXDOG_CHANNEL_DAEMON,
|
|
1065
|
-
}));
|
|
1066
|
-
process.send?.({ type: 'ready' });
|
|
1067
|
-
process.on('message', (msg) => {
|
|
1068
|
-
if (msg?.type === 'shutdown') process.exit(0);
|
|
1069
|
-
});
|
|
1070
|
-
setInterval(() => {}, 10000);
|
|
1071
|
-
`);
|
|
1072
1067
|
process.env.MIXDOG_CHANNEL_DAEMON = '1';
|
|
1073
1068
|
process.env.MIXDOG_CHANNEL_SINGLETON = '1';
|
|
1074
1069
|
process.env.MIXDOG_CHANNEL_WORKER_PROCESS = '1';
|
|
1075
1070
|
process.env.MIXDOG_RUNTIME_ROOT = runtimeDir;
|
|
1076
1071
|
process.env.SMOKE_CHANNEL_ENV_OUT = envOut;
|
|
1072
|
+
process.env.MIXDOG_CHANNEL_DAEMON_ENTRY = stubEntry;
|
|
1077
1073
|
channelEnvWorker = createStandaloneChannelWorker({
|
|
1078
|
-
entry,
|
|
1074
|
+
entry: stubEntry,
|
|
1079
1075
|
rootDir: root,
|
|
1080
1076
|
dataDir,
|
|
1081
1077
|
cwd: root,
|
|
@@ -1100,6 +1096,11 @@ setInterval(() => {}, 10000);
|
|
|
1100
1096
|
else process.env.MIXDOG_RUNTIME_ROOT = prevRuntimeRoot;
|
|
1101
1097
|
if (prevEnvOut == null) delete process.env.SMOKE_CHANNEL_ENV_OUT;
|
|
1102
1098
|
else process.env.SMOKE_CHANNEL_ENV_OUT = prevEnvOut;
|
|
1099
|
+
if (prevDaemonEntry == null) delete process.env.MIXDOG_CHANNEL_DAEMON_ENTRY;
|
|
1100
|
+
else process.env.MIXDOG_CHANNEL_DAEMON_ENTRY = prevDaemonEntry;
|
|
1101
|
+
// Detach only ends OUR attachment; the stub daemon self-shuts after its
|
|
1102
|
+
// client-grace window. Give it that window before deleting its tmp root.
|
|
1103
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, 700));
|
|
1103
1104
|
rmSync(channelWorkerTmp, { recursive: true, force: true });
|
|
1104
1105
|
}
|
|
1105
1106
|
|
|
@@ -1191,10 +1192,10 @@ if (EXPLORE_TOOL.annotations?.agentHidden === true) {
|
|
|
1191
1192
|
}
|
|
1192
1193
|
}
|
|
1193
1194
|
const exploreProps = EXPLORE_TOOL.inputSchema?.properties || {};
|
|
1194
|
-
if (!/
|
|
1195
|
-
throw new Error('explore description must
|
|
1195
|
+
if (!/broad\/uncertain/i.test(EXPLORE_TOOL.description || '') || !/machine-wide/i.test(EXPLORE_TOOL.description || '') || !/independent targets/i.test(EXPLORE_TOOL.description || '') || (EXPLORE_TOOL.description || '').length > 600) {
|
|
1196
|
+
throw new Error('explore description must keep the locator + facet fan-out contract');
|
|
1196
1197
|
}
|
|
1197
|
-
if (!/Narrow locator query/i.test(exploreProps.query?.description || '') || !/independent
|
|
1198
|
+
if (!/Narrow locator query/i.test(exploreProps.query?.description || '') || !/independent facets/i.test(exploreProps.query?.description || '') || !/Project\/root/i.test(exploreProps.cwd?.description || '')) {
|
|
1198
1199
|
throw new Error('explore schema must stay compact and preserve query/cwd shape');
|
|
1199
1200
|
}
|
|
1200
1201
|
const normalizedExplore = normalizeExploreQueries('["where is model selection?"," ","which file owns agent async?"]');
|
|
@@ -1412,7 +1413,11 @@ setInternalToolsProvider({
|
|
|
1412
1413
|
const writeTools = (writeAgentSession.tools || []).map((tool) => tool?.name).filter(Boolean);
|
|
1413
1414
|
const fullTools = (fullAgentSession.tools || []).map((tool) => tool?.name).filter(Boolean);
|
|
1414
1415
|
const publicExploreTools = (publicExploreSession.tools || []).map((tool) => tool?.name).filter(Boolean);
|
|
1415
|
-
|
|
1416
|
+
// Read-role AGENT sessions carry shell/task so review/debug agents can run
|
|
1417
|
+
// their own verification (build/test); the plain readonly preset (public
|
|
1418
|
+
// explore role) still omits them.
|
|
1419
|
+
const expectedReadTools = ['code_graph', 'find', 'glob', 'list', 'grep', 'read', 'shell', 'task', 'explore', 'search', 'web_fetch', 'Skill'];
|
|
1420
|
+
const expectedPublicReadTools = ['code_graph', 'find', 'glob', 'list', 'grep', 'read', 'explore', 'search', 'web_fetch', 'Skill'];
|
|
1416
1421
|
const expectedWriteTools = ['code_graph', 'find', 'glob', 'list', 'grep', 'read', 'apply_patch', 'shell', 'task', 'explore', 'search', 'web_fetch', 'Skill'];
|
|
1417
1422
|
if (JSON.stringify(readTools) !== JSON.stringify(expectedReadTools)) {
|
|
1418
1423
|
throw new Error(`read agent schema must be fixed allow-list: expected=${expectedReadTools.join(', ')} actual=${readTools.join(', ')}`);
|
|
@@ -1423,12 +1428,15 @@ setInternalToolsProvider({
|
|
|
1423
1428
|
if (readTools.includes('load_tool') || writeTools.includes('load_tool')) {
|
|
1424
1429
|
throw new Error(`agent session fixed schemas must omit load_tool: read=${readTools.join(', ')} write=${writeTools.join(', ')}`);
|
|
1425
1430
|
}
|
|
1426
|
-
if (readTools.includes('
|
|
1427
|
-
throw new Error(`read agent schema must omit
|
|
1431
|
+
if (readTools.includes('apply_patch')) {
|
|
1432
|
+
throw new Error(`read agent schema must omit apply_patch: read=${readTools.join(', ')}`);
|
|
1428
1433
|
}
|
|
1429
|
-
for (const name of ['shell', '
|
|
1430
|
-
if (readTools.includes(name)) {
|
|
1431
|
-
throw new Error(`read agent schema must
|
|
1434
|
+
for (const name of ['shell', 'task']) {
|
|
1435
|
+
if (!readTools.includes(name)) {
|
|
1436
|
+
throw new Error(`read agent schema must carry verification tool ${name}: read=${readTools.join(', ')}`);
|
|
1437
|
+
}
|
|
1438
|
+
if (publicExploreTools.includes(name)) {
|
|
1439
|
+
throw new Error(`public explore role must omit ${name}: explore=${publicExploreTools.join(', ')}`);
|
|
1432
1440
|
}
|
|
1433
1441
|
}
|
|
1434
1442
|
for (const name of ['apply_patch', 'shell', 'task']) {
|
|
@@ -1450,13 +1458,13 @@ setInternalToolsProvider({
|
|
|
1450
1458
|
if (!fullTools.includes('explore')) {
|
|
1451
1459
|
throw new Error(`full agent schema must expose explore: full=${fullTools.join(', ')}`);
|
|
1452
1460
|
}
|
|
1453
|
-
//
|
|
1454
|
-
//
|
|
1455
|
-
//
|
|
1456
|
-
//
|
|
1457
|
-
//
|
|
1458
|
-
if (JSON.stringify(publicExploreTools) !== JSON.stringify(
|
|
1459
|
-
throw new Error(`public explore role must ship the
|
|
1461
|
+
// The explore wrapper stays IN the schema for every read role — including
|
|
1462
|
+
// the explore agent itself. Recursion is broken at call time in
|
|
1463
|
+
// pre-dispatch-deny.mjs via recursiveWrapperToolNameForPublicAgent, not by
|
|
1464
|
+
// schema stripping. (Read AGENT sessions add shell/task on top, so the
|
|
1465
|
+
// public explore bundle is its own cache group now.)
|
|
1466
|
+
if (JSON.stringify(publicExploreTools) !== JSON.stringify(expectedPublicReadTools)) {
|
|
1467
|
+
throw new Error(`public explore role must ship the readonly bundle (incl. explore): expected=${expectedPublicReadTools.join(', ')} actual=${publicExploreTools.join(', ')}`);
|
|
1460
1468
|
}
|
|
1461
1469
|
if (recursiveWrapperToolNameForPublicAgent('explore') !== 'explore') {
|
|
1462
1470
|
throw new Error('call-time anti-recursion must map public explore agent to its own wrapper tool');
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
You review user-curated CORE memory against the current project memory. Each
|
|
4
4
|
entry is shown with its most-related current memory. Emit ONE verdict line per
|
|
5
5
|
entry id. The system may conservatively apply safe compression updates and
|
|
6
|
-
strict duplicate merges automatically
|
|
7
|
-
|
|
6
|
+
strict duplicate merges automatically, and now also auto-applies deletes you
|
|
7
|
+
tag as clear junk (see the delete reasons below), capped per run for safety;
|
|
8
|
+
non-junk or unreasoned deletes and broad rewrites still require explicit user
|
|
9
|
+
confirmation. Extracting a lesson from a narrative is a BROAD
|
|
8
10
|
rewrite — emit it only as a proposal (proposal mode), never as an auto-applied
|
|
9
11
|
conservative "safe compression" update; conservative mode must not lossy-rewrite
|
|
10
12
|
user-curated core. The first character of your response is a digit.
|
|
@@ -64,7 +66,12 @@ or inconclusive, keep the CORE entry.
|
|
|
64
66
|
so a CORE copy is redundant); OR is sourced from code, rules files, or skill
|
|
65
67
|
docs, or is an implementation spec, constant, measurement, resolved-bug
|
|
66
68
|
story, or status snapshot that fits no L1/L2/L3 layer. Do not delete an entry
|
|
67
|
-
that adds durable specifics the rule itself does not state.
|
|
69
|
+
that adds durable specifics the rule itself does not state. ALWAYS append a
|
|
70
|
+
reason tag: `<id>|delete|<reason>` where `<reason>` is one of `duplicate`,
|
|
71
|
+
`default` (restates a built-in/default rule), `restatement`, `obsolete`,
|
|
72
|
+
`implemented`, `resolved`, `stale`, `past_event`, or `log`. A bare
|
|
73
|
+
`<id>|delete` with no reason, or any reason outside that set, is treated as
|
|
74
|
+
"needs confirmation" and only removed on an explicit APPLY CYCLE3 run.
|
|
68
75
|
|
|
69
76
|
A verbose durable entry is always `update`, never `keep`.
|
|
70
77
|
Delete is the rarest verdict. Prefer `keep` for durable rules/preferences and
|
|
@@ -97,7 +104,7 @@ One line per entry id, any order:
|
|
|
97
104
|
<id>|update|<element>|<summary>
|
|
98
105
|
<id>|merge|<target_id>|<source_ids_csv>
|
|
99
106
|
<id>|superseded|<newer_id>
|
|
100
|
-
<id>|delete
|
|
107
|
+
<id>|delete|<reason>
|
|
101
108
|
```
|
|
102
109
|
|
|
103
110
|
`summary` ≤120 chars, one clause. No literal `|` or newline inside a field
|
|
@@ -112,6 +112,15 @@ A/B pending rows that encode lasting behavior or map anchors; transient
|
|
|
112
112
|
When `Active > cap`, contract strictly: any active entry without a concrete
|
|
113
113
|
A/B reason must archive.
|
|
114
114
|
|
|
115
|
+
**Structural block (enforced, not advisory).** Pending `task`/`issue` rows and
|
|
116
|
+
any row that reads as status/review churn or a benchmark/measurement result
|
|
117
|
+
snapshot are blocked from promotion by the pipeline itself — an `active` verdict
|
|
118
|
+
on them is dropped and the row is **held pending and re-judged on a later
|
|
119
|
+
cycle** (it is neither promoted nor force-archived here). Do NOT archive such a
|
|
120
|
+
row just because it cannot promote now: leave it to be re-confirmed, or promote
|
|
121
|
+
the durable L2 lesson distilled from it under a durable category. Durable
|
|
122
|
+
rules/preferences/constraints promote normally.
|
|
123
|
+
|
|
115
124
|
If useful content is buried inside work narrative, keep only the durable L2
|
|
116
125
|
behavior lesson (via `update` on an active row, or `active` for a pending row);
|
|
117
126
|
archive the surrounding story.
|
|
@@ -22,6 +22,12 @@ synonyms, library/domain names), plus `find` with name fragments, plus `code_gra
|
|
|
22
22
|
symbol_search when the query names an identifier. A single-pattern,
|
|
23
23
|
single-tool first turn is a defect.
|
|
24
24
|
|
|
25
|
+
A turn-1 broad grep MUST set `output_mode:"files_with_matches"` — an
|
|
26
|
+
unscoped `content`/`content_with_context` scan reads every match body and is
|
|
27
|
+
a defect (a full-content scan across the tree costs seconds). Use
|
|
28
|
+
`content_with_context` ONLY against a `path` that appeared in an earlier
|
|
29
|
+
result THIS session, and always with `head_limit`.
|
|
30
|
+
|
|
25
31
|
Search tokens are CODE tokens: first translate natural-language or
|
|
26
32
|
non-English queries into probable English identifiers (e.g. "최대 루프 반복
|
|
27
33
|
횟수" → maxLoop, loop-policy, iterations). Grep non-ASCII text only when
|
|
@@ -4,12 +4,19 @@
|
|
|
4
4
|
prior result. Merge equivalent variants/scopes into ONE call wherever the
|
|
5
5
|
schema accepts arrays (`pattern[]`, `path[]`, `symbols[]`, `query[]`).
|
|
6
6
|
- Route by what is already known: known symbol/relation → `code_graph`;
|
|
7
|
-
exact text in a known scope → `grep`; unknown location
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
exact text in a known scope → `grep`; unknown location, machine-wide/
|
|
8
|
+
out-of-repo whereabouts, or concept-level question → `explore` (which uses
|
|
9
|
+
the hardened `find` internally); name fragment → `find`; exact name pattern
|
|
10
|
+
→ `glob`; known directory → `list`; known file/region → `read`.
|
|
11
|
+
- `explore` fan-out: at task start, decompose what the task needs to know
|
|
12
|
+
into independent facets (implementation site, config/load path, tests,
|
|
13
|
+
error origin, ...) and send them as ONE `query[]` call — facets run in
|
|
14
|
+
parallel. Never fan out rephrasings of the same target; on
|
|
15
|
+
EXPLORATION_FAILED, retry once with changed tokens.
|
|
10
16
|
- Valid anchors come from user input or tool output in this session; locate
|
|
11
17
|
anything else with `find` before `grep`/`read`. On ENOENT the next call is
|
|
12
|
-
`find` on the basename — never a retried guess
|
|
18
|
+
`find` on the basename — never a retried guess; and never guess an absolute
|
|
19
|
+
path outside the project — `find` from a verified broad root instead.
|
|
13
20
|
- Retrieval stops when evidence covers the deliverable: single-answer tasks
|
|
14
21
|
end at the first sufficient anchor; enumeration tasks (review, audit) end
|
|
15
22
|
when the stated scope is covered. Never re-verify a hit already on screen;
|
|
@@ -33,6 +33,62 @@ export function isAgentProgressWatchdogAbortError(err) {
|
|
|
33
33
|
return typeof msg === 'string' && WATCHDOG_ABORT_RE.test(msg);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
// Tools that enforce their own execution deadline: 'shell' kills the process
|
|
37
|
+
// at its configured timeout; 'task' wait is bounded by its own wait budget.
|
|
38
|
+
// These are NOT blanket-exempted from the tool-running watchdog — if their own
|
|
39
|
+
// deadline timer dies the session would otherwise hang forever. Instead the
|
|
40
|
+
// watchdog raises the tool-running ceiling to their self-deadline + a grace
|
|
41
|
+
// window (below), so normal long runs are allowed but a dead timer is still
|
|
42
|
+
// caught. Unknown/missing self-deadline falls back to the plain toolRunningMs.
|
|
43
|
+
const SELF_DEADLINE_TOOLS = new Set(['shell', 'task']);
|
|
44
|
+
// Grace added on top of a tool's own deadline before the watchdog steps in, so
|
|
45
|
+
// the tool's in-process kill always fires first under normal operation.
|
|
46
|
+
const TOOL_SELF_DEADLINE_GRACE_MS = 60_000;
|
|
47
|
+
// Fallback deadlines matching the tool implementations (bash-tool.mjs default
|
|
48
|
+
// 120s foreground timeout; task/shell-job wait default 30s budget).
|
|
49
|
+
const SHELL_DEFAULT_TIMEOUT_MS = 120_000;
|
|
50
|
+
const TASK_DEFAULT_WAIT_MS = 30_000;
|
|
51
|
+
|
|
52
|
+
function bareToolName(toolName) {
|
|
53
|
+
if (typeof toolName !== 'string' || !toolName) return '';
|
|
54
|
+
// Strip any MCP/server prefix (e.g. 'server__shell' or 'server.shell').
|
|
55
|
+
return toolName.split(/[.]|__/).pop();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isSelfDeadlineTool(toolName) {
|
|
59
|
+
const bare = bareToolName(toolName);
|
|
60
|
+
return SELF_DEADLINE_TOOLS.has(bare) || SELF_DEADLINE_TOOLS.has(toolName);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolve the self-enforced deadline (ms) for a tool call from its arguments,
|
|
65
|
+
* recorded into the progress snapshot at dispatch time. Returns a positive
|
|
66
|
+
* number when the tool enforces its own deadline, or null when unknown/missing
|
|
67
|
+
* (caller then falls back to the plain toolRunningMs ceiling).
|
|
68
|
+
* - shell: explicit `timeout` (ms) if positive, else the 120s default.
|
|
69
|
+
* - task: explicit `timeout_ms` if positive, else 30s for the `wait` action
|
|
70
|
+
* (other actions carry no wait budget -> null).
|
|
71
|
+
*/
|
|
72
|
+
export function resolveToolSelfDeadlineMs(toolName, args) {
|
|
73
|
+
if (!isSelfDeadlineTool(toolName)) return null;
|
|
74
|
+
const bare = bareToolName(toolName);
|
|
75
|
+
const a = (args && typeof args === 'object') ? args : {};
|
|
76
|
+
if (bare === 'shell') {
|
|
77
|
+
const t = Number(a.timeout);
|
|
78
|
+
if (Number.isFinite(t) && t > 0) return t;
|
|
79
|
+
const envDefault = parseInt(process.env.BASH_DEFAULT_TIMEOUT_MS ?? '', 10);
|
|
80
|
+
return envDefault > 0 ? envDefault : SHELL_DEFAULT_TIMEOUT_MS;
|
|
81
|
+
}
|
|
82
|
+
if (bare === 'task') {
|
|
83
|
+
const t = Number(a.timeout_ms);
|
|
84
|
+
if (Number.isFinite(t) && t > 0) return t;
|
|
85
|
+
const action = typeof a.action === 'string' ? a.action : '';
|
|
86
|
+
if (action === 'wait') return TASK_DEFAULT_WAIT_MS;
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
36
92
|
function assistantMessageText(content) {
|
|
37
93
|
if (typeof content === 'string') return content;
|
|
38
94
|
if (!Array.isArray(content)) return '';
|
|
@@ -209,7 +265,26 @@ export function evaluateAgentWatchdogAbort(snapshot, now, policy) {
|
|
|
209
265
|
&& policy.toolRunningMs > 0
|
|
210
266
|
&& now - snapshot.toolStartedAt > policy.toolRunningMs
|
|
211
267
|
) {
|
|
212
|
-
|
|
268
|
+
// Deadline-aware ceiling for tools that self-enforce their own deadline
|
|
269
|
+
// ('shell'/'task'). Rather than a blanket exemption (which would hang
|
|
270
|
+
// forever if the tool's own timer died), raise the tool-running ceiling
|
|
271
|
+
// to max(toolRunningMs, selfDeadlineMs + grace): normal long runs are
|
|
272
|
+
// allowed because the tool kills itself first, but a dead deadline timer
|
|
273
|
+
// is still caught after the grace window. Unknown/missing self-deadline
|
|
274
|
+
// (selfDeadlineMs <= 0) keeps the plain toolRunningMs behavior. All
|
|
275
|
+
// other tools are unaffected.
|
|
276
|
+
let ceilingMs = policy.toolRunningMs;
|
|
277
|
+
const selfDeadlineMs = Number(snapshot.toolSelfDeadlineMs);
|
|
278
|
+
if (
|
|
279
|
+
isSelfDeadlineTool(snapshot.currentTool)
|
|
280
|
+
&& Number.isFinite(selfDeadlineMs)
|
|
281
|
+
&& selfDeadlineMs > 0
|
|
282
|
+
) {
|
|
283
|
+
ceilingMs = Math.max(policy.toolRunningMs, selfDeadlineMs + TOOL_SELF_DEADLINE_GRACE_MS);
|
|
284
|
+
}
|
|
285
|
+
if (now - snapshot.toolStartedAt > ceilingMs) {
|
|
286
|
+
return new AgentStallAbortError(`agent tool running stale (${ceilingMs}ms)`);
|
|
287
|
+
}
|
|
213
288
|
}
|
|
214
289
|
|
|
215
290
|
return null;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { resolvePluginData } from '../../shared/plugin-paths.mjs';
|
|
2
|
-
import { readSection, updateSection, getAgentApiKey, AGENT_PROVIDER_ENV } from '../../shared/config.mjs';
|
|
2
|
+
import { readSection, updateSection, updateSectionAsync, getAgentApiKey, AGENT_PROVIDER_ENV } from '../../shared/config.mjs';
|
|
3
3
|
import { OPENAI_COMPAT_PRESETS } from './providers/openai-compat-presets.mjs';
|
|
4
4
|
import {
|
|
5
5
|
hasAnthropicOAuthCredentials,
|
|
@@ -241,6 +241,14 @@ function persistAgentConfig(build) {
|
|
|
241
241
|
updateSection('agent', (current) => build(hasKeys(current) ? current : {}));
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
+
// Async twin of persistAgentConfig: same in-lock rebase-on-current semantics,
|
|
245
|
+
// but the whole-section RMW runs through updateSectionAsync so a debounced
|
|
246
|
+
// timer flush never blocks the event loop on icacls/backup. Reuses the same
|
|
247
|
+
// config lock file, so it stays linearizable with the sync writers.
|
|
248
|
+
async function persistAgentConfigAsync(build) {
|
|
249
|
+
await updateSectionAsync('agent', (current) => build(hasKeys(current) ? current : {}));
|
|
250
|
+
}
|
|
251
|
+
|
|
244
252
|
// Recap toggle (recap.enabled, default true) gates ONLY the background memory
|
|
245
253
|
// cycles. The memory module itself is always-on. On load we fold a legacy
|
|
246
254
|
// `modules.memory === false` flag into recap.enabled=false one time; the caller
|
|
@@ -506,23 +514,34 @@ export function loadConfig(options = {}) {
|
|
|
506
514
|
* concurrent instance's edits are not reverted.
|
|
507
515
|
*/
|
|
508
516
|
/** In-lock patch of `skills.disabled` only (avoids whole-config lost-update). */
|
|
509
|
-
|
|
517
|
+
function buildSkillsDisabledPatch(disabledNames) {
|
|
510
518
|
const names = disabledNames instanceof Set
|
|
511
519
|
? [...disabledNames]
|
|
512
520
|
: (Array.isArray(disabledNames) ? disabledNames : []);
|
|
513
521
|
const nextSkills = normalizeSkillsConfig({ disabled: names });
|
|
514
|
-
|
|
522
|
+
const build = (current) => {
|
|
515
523
|
const cur = { ...current };
|
|
516
524
|
const target = (cur.agent && cur.agent.providers)
|
|
517
525
|
? (cur.agent = { ...cur.agent })
|
|
518
526
|
: cur;
|
|
519
527
|
target.skills = nextSkills;
|
|
520
528
|
return cur;
|
|
521
|
-
}
|
|
529
|
+
};
|
|
530
|
+
return { build, nextSkills };
|
|
531
|
+
}
|
|
532
|
+
export function patchSkillsDisabled(disabledNames) {
|
|
533
|
+
const { build, nextSkills } = buildSkillsDisabledPatch(disabledNames);
|
|
534
|
+
persistAgentConfig(build);
|
|
535
|
+
return nextSkills;
|
|
536
|
+
}
|
|
537
|
+
// Async twin used by the debounced skills flush timer.
|
|
538
|
+
export async function patchSkillsDisabledAsync(disabledNames) {
|
|
539
|
+
const { build, nextSkills } = buildSkillsDisabledPatch(disabledNames);
|
|
540
|
+
await persistAgentConfigAsync(build);
|
|
522
541
|
return nextSkills;
|
|
523
542
|
}
|
|
524
543
|
|
|
525
|
-
|
|
544
|
+
function buildAgentSaveBuilder(config) {
|
|
526
545
|
// Strip ephemeral defaults from providers but preserve any unknown
|
|
527
546
|
// per-provider subkey so future schema additions round-trip through the
|
|
528
547
|
// setup UI without changes here. apiKey is intentionally omitted —
|
|
@@ -556,7 +575,7 @@ export function saveConfig(config) {
|
|
|
556
575
|
// Build the replacement from `existingRaw` — the section read INSIDE the
|
|
557
576
|
// file lock — not a snapshot taken before it, so unmanaged keys written by
|
|
558
577
|
// a concurrent instance survive the save (lost-update guard).
|
|
559
|
-
|
|
578
|
+
return (existingRaw) => ({
|
|
560
579
|
...existingRaw,
|
|
561
580
|
guide: config.guide || existingRaw.guide || undefined,
|
|
562
581
|
providers: persistedProviders,
|
|
@@ -582,7 +601,14 @@ export function saveConfig(config) {
|
|
|
582
601
|
update: config.update || {},
|
|
583
602
|
recap: config.recap || {},
|
|
584
603
|
modules: config.modules || existingRaw.modules || {},
|
|
585
|
-
})
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
export function saveConfig(config) {
|
|
607
|
+
persistAgentConfig(buildAgentSaveBuilder(config));
|
|
608
|
+
}
|
|
609
|
+
// Async twin used by the debounced config-save flush timer.
|
|
610
|
+
export async function saveConfigAsync(config) {
|
|
611
|
+
await persistAgentConfigAsync(buildAgentSaveBuilder(config));
|
|
586
612
|
}
|
|
587
613
|
// --- Preset helpers ---
|
|
588
614
|
// preset shape: { id, name, type: 'agent', provider, model, effort?, fast?, tools? }
|
|
@@ -407,9 +407,38 @@ export function stripDeferredToolManifestBlock(text) {
|
|
|
407
407
|
.trimEnd();
|
|
408
408
|
}
|
|
409
409
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
410
|
+
// Rebuild path: replace the FIRST previously-injected <available-deferred-tools>
|
|
411
|
+
// block (with its leading `---` separator) with the fresh manifest IN PLACE, so
|
|
412
|
+
// the block keeps its original position and no sibling BP1 block (skills
|
|
413
|
+
// manifest, agent rules, …) is reordered or dropped. The fresh manifest already
|
|
414
|
+
// carries the mcp-instructions companion, so any pre-existing standalone one is
|
|
415
|
+
// removed first to avoid duplication.
|
|
416
|
+
export function rebuildDeferredToolManifestBlock(text, manifest) {
|
|
417
|
+
let out = String(text || '').replace(MCP_INSTRUCTIONS_BLOCK_RE, '');
|
|
418
|
+
let replaced = false;
|
|
419
|
+
out = out.replace(DEFERRED_TOOLS_BLOCK_RE, (match, sep) => {
|
|
420
|
+
if (replaced) return '';
|
|
421
|
+
replaced = true;
|
|
422
|
+
return `${sep || ''}${manifest}`;
|
|
423
|
+
});
|
|
424
|
+
if (!replaced) {
|
|
425
|
+
const base = out.trimEnd();
|
|
426
|
+
out = base ? `${base}\n\n---\n\n${manifest}` : manifest;
|
|
427
|
+
}
|
|
428
|
+
return out;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Inject the skill-style deferred pool (name + description) into BP1 at session
|
|
433
|
+
* start. Normally once; with `{ rebuild: true }` it strips any existing
|
|
434
|
+
* <available-deferred-tools>/<mcp-instructions> block and re-injects the fresh
|
|
435
|
+
* pool in place (used by the first-turn MCP refresh, before the prompt renders,
|
|
436
|
+
* so late-connected MCP tools land in the INITIAL manifest — never duplicated).
|
|
437
|
+
*/
|
|
438
|
+
export function applyInitialDeferredToolManifestToBp1(session, poolNames, options = {}) {
|
|
439
|
+
const rebuild = options?.rebuild === true;
|
|
440
|
+
if (!session || !Array.isArray(session.messages)) return false;
|
|
441
|
+
if (session.deferredToolBp1Applied && !rebuild) return false;
|
|
413
442
|
const pool = Array.isArray(poolNames) ? poolNames : [];
|
|
414
443
|
const descByName = new Map();
|
|
415
444
|
for (const tool of Array.isArray(session?.deferredToolCatalog) ? session.deferredToolCatalog : []) {
|
|
@@ -432,15 +461,21 @@ export function applyInitialDeferredToolManifestToBp1(session, poolNames) {
|
|
|
432
461
|
}
|
|
433
462
|
if (idx === -1) return false;
|
|
434
463
|
const raw = typeof session.messages[idx].content === 'string' ? session.messages[idx].content : '';
|
|
435
|
-
if (bp1HasDeferredToolManifestBlock(raw)) {
|
|
464
|
+
if (bp1HasDeferredToolManifestBlock(raw) && !rebuild) {
|
|
436
465
|
session.deferredToolBp1Applied = true;
|
|
437
466
|
return true;
|
|
438
467
|
}
|
|
439
468
|
if (manifest) {
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
469
|
+
if (rebuild && bp1HasDeferredToolManifestBlock(raw)) {
|
|
470
|
+
// Anchored in-place rebuild: swap the previously injected manifest
|
|
471
|
+
// block for the fresh one at its EXISTING position.
|
|
472
|
+
session.messages[idx].content = rebuildDeferredToolManifestBlock(raw, manifest);
|
|
473
|
+
} else {
|
|
474
|
+
const base = stripDeferredToolManifestBlock(raw);
|
|
475
|
+
session.messages[idx].content = base
|
|
476
|
+
? `${base}\n\n---\n\n${manifest}`
|
|
477
|
+
: manifest;
|
|
478
|
+
}
|
|
444
479
|
}
|
|
445
480
|
session.deferredToolBp1Applied = true;
|
|
446
481
|
session.updatedAt = Date.now();
|