@xdarkicex/openclaw-memory-libravdb 1.9.8 → 1.9.10-beta.1
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/dist/context-engine.js +19 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +18 -7
- package/dist/types.d.ts +2 -0
- package/docs/debugging-tool-loop-regression.md +386 -0
- package/docs/subagent-finds.md +241 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
package/dist/context-engine.js
CHANGED
|
@@ -204,11 +204,11 @@ function normalizeKernelContent(content, options = {}) {
|
|
|
204
204
|
* code must explicitly import the symbol to call the hook.
|
|
205
205
|
*/
|
|
206
206
|
export const FLUSH_ASYNC_INGESTION = Symbol("flushAsyncIngestion");
|
|
207
|
-
let maxOptimizationMemoCacheSize =
|
|
207
|
+
let maxOptimizationMemoCacheSize = 50000;
|
|
208
208
|
const metadataEnvelopeCache = new Map();
|
|
209
209
|
const metadataEnvelopeRetainCache = new Map();
|
|
210
210
|
export function setOptimizationMemoCacheSize(size) {
|
|
211
|
-
maxOptimizationMemoCacheSize = size > 0 ? size :
|
|
211
|
+
maxOptimizationMemoCacheSize = size > 0 ? size : 50000;
|
|
212
212
|
}
|
|
213
213
|
/**
|
|
214
214
|
* Evicts the oldest half of entries from a Map when it exceeds maxSize.
|
|
@@ -1750,17 +1750,32 @@ export function buildContextEngineFactory(runtime, cfg, logger = console) {
|
|
|
1750
1750
|
// BeforeTurnKernel: semantic memory retrieval against the current user query.
|
|
1751
1751
|
// Skip for automated triggers (heartbeat, cron, memory, overflow) — saves
|
|
1752
1752
|
// an embedding call and RPC round trip on non-interactive turns.
|
|
1753
|
+
const btLog = cfg.beforeTurnDebug
|
|
1754
|
+
? (msg) => logger.info?.(msg)
|
|
1755
|
+
: (_msg) => { };
|
|
1753
1756
|
let beforeTurnPredictions = null;
|
|
1754
1757
|
let beforeTurnQueryHint = null;
|
|
1755
|
-
if (cfg.beforeTurnEnabled
|
|
1758
|
+
if (cfg.beforeTurnEnabled === false) {
|
|
1759
|
+
btLog(`BeforeTurnKernel disabled by config sessionId=${sessionId}`);
|
|
1760
|
+
}
|
|
1761
|
+
else if (!isInteractiveTrigger(sessionId)) {
|
|
1762
|
+
btLog(`BeforeTurnKernel skipped: non-interactive trigger sessionId=${sessionId}`);
|
|
1763
|
+
}
|
|
1764
|
+
else {
|
|
1756
1765
|
beforeTurnQueryHint = extractQueryHint(messages, (text) => typeof text === "string" ? text.replace(OPENCLAW_LEADING_TIMESTAMP_PREFIX_RE, "").trim() : text);
|
|
1757
|
-
if (beforeTurnQueryHint
|
|
1766
|
+
if (!beforeTurnQueryHint) {
|
|
1767
|
+
btLog(`BeforeTurnKernel skipped: no query hint extracted sessionId=${sessionId}`);
|
|
1768
|
+
}
|
|
1769
|
+
else if (!isNewUserTurn(messages)) {
|
|
1770
|
+
btLog(`BeforeTurnKernel skipped: not a new user turn sessionId=${sessionId}`);
|
|
1758
1771
|
beforeTurnQueryHint = null;
|
|
1759
1772
|
}
|
|
1760
1773
|
if (beforeTurnQueryHint && isBeforeTurnCircuitOpen(sessionId)) {
|
|
1774
|
+
btLog(`BeforeTurnKernel skipped: circuit open sessionId=${sessionId}`);
|
|
1761
1775
|
beforeTurnQueryHint = null;
|
|
1762
1776
|
}
|
|
1763
1777
|
if (beforeTurnQueryHint) {
|
|
1778
|
+
btLog(`BeforeTurnKernel calling sessionId=${sessionId} hint=${beforeTurnQueryHint.slice(0, 50)}`);
|
|
1764
1779
|
// Include message count in cache key so identical queries
|
|
1765
1780
|
// in different turns don't return stale predictions.
|
|
1766
1781
|
const turnScopedHint = `${messages.length}:${beforeTurnQueryHint}`;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
|
2
2
|
export declare const MEMORY_ID = "libravdb-memory";
|
|
3
|
-
export declare function shouldShutdownRuntimeForLifecycleCleanup(reason: string): boolean;
|
|
3
|
+
export declare function shouldShutdownRuntimeForLifecycleCleanup(reason: string, sessionKey?: string): boolean;
|
|
4
4
|
export declare function register(api: OpenClawPluginApi): void;
|
|
5
5
|
declare const _default: {
|
|
6
6
|
id: string;
|
package/dist/index.js
CHANGED
|
@@ -35657,11 +35657,11 @@ function normalizeKernelContent(content, options = {}) {
|
|
|
35657
35657
|
});
|
|
35658
35658
|
}
|
|
35659
35659
|
var FLUSH_ASYNC_INGESTION = /* @__PURE__ */ Symbol("flushAsyncIngestion");
|
|
35660
|
-
var maxOptimizationMemoCacheSize =
|
|
35660
|
+
var maxOptimizationMemoCacheSize = 5e4;
|
|
35661
35661
|
var metadataEnvelopeCache = /* @__PURE__ */ new Map();
|
|
35662
35662
|
var metadataEnvelopeRetainCache = /* @__PURE__ */ new Map();
|
|
35663
35663
|
function setOptimizationMemoCacheSize(size) {
|
|
35664
|
-
maxOptimizationMemoCacheSize = size > 0 ? size :
|
|
35664
|
+
maxOptimizationMemoCacheSize = size > 0 ? size : 5e4;
|
|
35665
35665
|
}
|
|
35666
35666
|
function evictOldestHalf(map, maxSize) {
|
|
35667
35667
|
if (map.size < maxSize) return;
|
|
@@ -36984,20 +36984,31 @@ function buildContextEngineFactory(runtime, cfg, logger = console) {
|
|
|
36984
36984
|
);
|
|
36985
36985
|
}
|
|
36986
36986
|
}
|
|
36987
|
+
const btLog = cfg.beforeTurnDebug ? (msg) => logger.info?.(msg) : (_msg) => {
|
|
36988
|
+
};
|
|
36987
36989
|
let beforeTurnPredictions = null;
|
|
36988
36990
|
let beforeTurnQueryHint = null;
|
|
36989
|
-
if (cfg.beforeTurnEnabled
|
|
36991
|
+
if (cfg.beforeTurnEnabled === false) {
|
|
36992
|
+
btLog(`BeforeTurnKernel disabled by config sessionId=${sessionId}`);
|
|
36993
|
+
} else if (!isInteractiveTrigger(sessionId)) {
|
|
36994
|
+
btLog(`BeforeTurnKernel skipped: non-interactive trigger sessionId=${sessionId}`);
|
|
36995
|
+
} else {
|
|
36990
36996
|
beforeTurnQueryHint = extractQueryHint(
|
|
36991
36997
|
messages,
|
|
36992
36998
|
(text) => typeof text === "string" ? text.replace(OPENCLAW_LEADING_TIMESTAMP_PREFIX_RE, "").trim() : text
|
|
36993
36999
|
);
|
|
36994
|
-
if (beforeTurnQueryHint
|
|
37000
|
+
if (!beforeTurnQueryHint) {
|
|
37001
|
+
btLog(`BeforeTurnKernel skipped: no query hint extracted sessionId=${sessionId}`);
|
|
37002
|
+
} else if (!isNewUserTurn(messages)) {
|
|
37003
|
+
btLog(`BeforeTurnKernel skipped: not a new user turn sessionId=${sessionId}`);
|
|
36995
37004
|
beforeTurnQueryHint = null;
|
|
36996
37005
|
}
|
|
36997
37006
|
if (beforeTurnQueryHint && isBeforeTurnCircuitOpen(sessionId)) {
|
|
37007
|
+
btLog(`BeforeTurnKernel skipped: circuit open sessionId=${sessionId}`);
|
|
36998
37008
|
beforeTurnQueryHint = null;
|
|
36999
37009
|
}
|
|
37000
37010
|
if (beforeTurnQueryHint) {
|
|
37011
|
+
btLog(`BeforeTurnKernel calling sessionId=${sessionId} hint=${beforeTurnQueryHint.slice(0, 50)}`);
|
|
37001
37012
|
const turnScopedHint = `${messages.length}:${beforeTurnQueryHint}`;
|
|
37002
37013
|
const cached = turnCache.get(sessionId, turnScopedHint);
|
|
37003
37014
|
if (cached?.predictions) {
|
|
@@ -48685,8 +48696,8 @@ function levelFilteredLogger(base, logLevel) {
|
|
|
48685
48696
|
var MEMORY_ID = "libravdb-memory";
|
|
48686
48697
|
var LIGHTWEIGHT_MODES = /* @__PURE__ */ new Set(["cli-metadata", "setup-only"]);
|
|
48687
48698
|
var RUNTIME_CLEANUP_SHUTDOWN_REASONS = /* @__PURE__ */ new Set(["delete"]);
|
|
48688
|
-
function shouldShutdownRuntimeForLifecycleCleanup(reason) {
|
|
48689
|
-
return RUNTIME_CLEANUP_SHUTDOWN_REASONS.has(reason);
|
|
48699
|
+
function shouldShutdownRuntimeForLifecycleCleanup(reason, sessionKey) {
|
|
48700
|
+
return RUNTIME_CLEANUP_SHUTDOWN_REASONS.has(reason) && sessionKey === void 0;
|
|
48690
48701
|
}
|
|
48691
48702
|
function register(api) {
|
|
48692
48703
|
const registrationMode = api.registrationMode;
|
|
@@ -48844,7 +48855,7 @@ function register(api) {
|
|
|
48844
48855
|
id: "libravdb-shutdown",
|
|
48845
48856
|
description: "Shut down the vector service runtime on terminal plugin cleanup",
|
|
48846
48857
|
async cleanup(ctx) {
|
|
48847
|
-
if (shouldShutdownRuntimeForLifecycleCleanup(ctx.reason)) {
|
|
48858
|
+
if (shouldShutdownRuntimeForLifecycleCleanup(ctx.reason, ctx.sessionKey)) {
|
|
48848
48859
|
logger.info?.(`LibraVDB ${ctx.reason} \u2014 shutting down runtime`);
|
|
48849
48860
|
await runtime.shutdown();
|
|
48850
48861
|
} else if (ctx.reason === "disable") {
|
package/dist/types.d.ts
CHANGED
|
@@ -131,6 +131,8 @@ export interface PluginConfig {
|
|
|
131
131
|
grpcEndpointTlsMode?: "auto" | "tls" | "insecure";
|
|
132
132
|
/** Whether BeforeTurnKernel retrieval is enabled. Default: true */
|
|
133
133
|
beforeTurnEnabled?: boolean;
|
|
134
|
+
/** Enable verbose BeforeTurnKernel diagnostic logging. Default: false */
|
|
135
|
+
beforeTurnDebug?: boolean;
|
|
134
136
|
/** Timeout in milliseconds for the BeforeTurnKernel gRPC call. Default: 5000 */
|
|
135
137
|
beforeTurnTimeoutMs?: number;
|
|
136
138
|
/** Timeout in milliseconds for the AssembleContextInternal gRPC call. Default: 30000 */
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
# Tool Call Loop Regression: v1.8.9 → v1.9.7
|
|
2
|
+
|
|
3
|
+
## Symptoms
|
|
4
|
+
- LLM outputs verbatim text twice in a single turn
|
|
5
|
+
- LLM executes the same tool call (image gen) twice in a single turn
|
|
6
|
+
- **Intermittent** — does NOT reproduce on every turn; ~50% hit rate
|
|
7
|
+
- **Tool-call-gated** — only occurs on turns involving tool calls (image gen, file ops, etc.). Plain text conversations never trigger it.
|
|
8
|
+
- "Didn't used to happen" — confirmed clean in v1.8.7–1.8.9
|
|
9
|
+
- First observed after v1.9.0 perf rewrite + v1.9.1 live tool protocol changes
|
|
10
|
+
- Affects provider models broadly (MiniMax M2.7 confirmed); Qwen 3.5 32B INT4 was the target model for the live tool protocol harness
|
|
11
|
+
|
|
12
|
+
**Intermittent nature → race condition, not deterministic logic bug. Tool-only gating → the race specifically affects turns where the daemon's context replay contains tool protocol from the prior turn.**
|
|
13
|
+
|
|
14
|
+
## Observations
|
|
15
|
+
|
|
16
|
+
### O1: ~50% hit rate, tool calls only
|
|
17
|
+
Duplication happens roughly half the time on tool call turns. It never triggers on plain text conversations. This narrows the scope to the tool protocol code paths — specifically the cursor-based gates that only activate when `hasKernelToolCallBlock` or `isToolResultRole` match.
|
|
18
|
+
|
|
19
|
+
### O2: Async ingestion timing window
|
|
20
|
+
The 50/50 split is consistent with a two-processor race: the LLM provider response time (variable, 2-8s) vs the daemon `afterTurn` ingestion time (variable, 0.5-3s). When the provider is slow and ingestion is fast, the async queue drains before the next `assemble()` — no race. When the provider is fast and the daemon is busy (compaction, embedding), the queue hasn't drained — race triggers.
|
|
21
|
+
|
|
22
|
+
### O3: Regular conversation is immune
|
|
23
|
+
Text-only turns have no tool protocol to classify. The live tool protocol gates (`consumeLiveToolAtCursor`, `findLiveToolSourceInCurrentTurn`) are never entered. The provider replay dedup handles plain text correctly. The race only matters when tool protocol exists in the transcript.
|
|
24
|
+
|
|
25
|
+
## Timeline
|
|
26
|
+
|
|
27
|
+
| Version | Key Change | Author |
|
|
28
|
+
|---------|-----------|--------|
|
|
29
|
+
| v1.8.9 | Last known-good | — |
|
|
30
|
+
| v1.9.0 | Perf rewrite: async ingestion, O(1) indexing, memoization caches | Juan |
|
|
31
|
+
| v1.9.1 | Live tool protocol preservation (`78da771`, 164 loc) | Juan |
|
|
32
|
+
| v1.9.4 | Provider replay dedup with source-index awareness | xDarkicex |
|
|
33
|
+
| v1.9.5 | Tool result annotation fix: `[^\n]*` → `\s*` | xDarkicex |
|
|
34
|
+
| v1.9.7 | Assemble deadline (30s) | xDarkicex |
|
|
35
|
+
|
|
36
|
+
## Theory: Live Tool Protocol Harness Feeds Tool Output Back to Model
|
|
37
|
+
|
|
38
|
+
The primary suspect is commit `78da771` ("preserve live tool protocol during context assembly", v1.9.0 → v1.9.1). It added a cursor-guarded tool protocol preservation system that re-injects the current turn's tool calls and results from the source transcript into the assembled context, bypassing the provider replay dedup.
|
|
39
|
+
|
|
40
|
+
**Mechanism:** When the daemon's `assemble()` returns context messages that include historical tool protocol from a prior turn, the `consumeLiveToolAtCursor` and `findLiveToolSourceInCurrentTurn` functions match these against the source transcript. If they match, the original (unsanitized) source message is pushed into context **directly, bypassing the provider-replay dedup** (line 1921).
|
|
41
|
+
|
|
42
|
+
This works for Qwen 3.5 32B INT4 which needed full tool context to avoid orphaned tools. But for other models, injecting the full tool call JSON + tool result JSON from a prior turn into the next turn's context causes the model to treat it as a new instruction — re-executing the tool and repeating the output.
|
|
43
|
+
|
|
44
|
+
## Code Comparison
|
|
45
|
+
|
|
46
|
+
### Message Processing Loop
|
|
47
|
+
|
|
48
|
+
**v1.8.9** — Simple, flat. One tool gate, then provider replay or memory:
|
|
49
|
+
```typescript
|
|
50
|
+
// v1.8.9 — src/context-engine.ts ~line 1525
|
|
51
|
+
if (Array.isArray(result.messages)) {
|
|
52
|
+
for (const message of result.messages) {
|
|
53
|
+
const content = normalizeKernelContent(message.content);
|
|
54
|
+
const historicalToolSource = getHistoricalToolSource(message.role, message.content, content);
|
|
55
|
+
let isRealTranscript = false;
|
|
56
|
+
|
|
57
|
+
if (sourceMessages) {
|
|
58
|
+
// O(n²) linear scan — simple, correct
|
|
59
|
+
isRealTranscript = sourceMessages.some((sm) => {
|
|
60
|
+
if (message.id && sm.id === message.id) return true;
|
|
61
|
+
if (sm.role === message.role && normalizeKernelContent(sm.content) === content) return true;
|
|
62
|
+
return false;
|
|
63
|
+
});
|
|
64
|
+
} else {
|
|
65
|
+
isRealTranscript = message.role === "user" || message.role === "assistant";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// SINGLE tool gate — preserve or drop, simple boolean
|
|
69
|
+
if (isLiveToolProtocolMessage(message, content, sourceMessages)) {
|
|
70
|
+
messages.push(preserveLiveToolProtocolMessage(message));
|
|
71
|
+
} else if (isRealTranscript && !historicalToolSource && isProviderReplayRole(message.role)) {
|
|
72
|
+
// Provider replay — NO dedup, simple push
|
|
73
|
+
const sanitizedContent = sanitizeToolCallPatterns(content, {
|
|
74
|
+
stripOpenClawDirectives: message.role === "assistant",
|
|
75
|
+
});
|
|
76
|
+
if (isHistoricalAssistantActionPromise(message.role, sanitizedContent)) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
// Push without dedup — works because daemon doesn't duplicate
|
|
80
|
+
messages.push({
|
|
81
|
+
role: message.role,
|
|
82
|
+
content: sanitizedContent,
|
|
83
|
+
...(typeof message.id === "string" ? { id: message.id } : {}),
|
|
84
|
+
});
|
|
85
|
+
} else {
|
|
86
|
+
// Memory items only
|
|
87
|
+
if (content.trim().length > 0) {
|
|
88
|
+
const sanitizedContent = sanitizeToolCallPatterns(content, {
|
|
89
|
+
stripOpenClawDirectives: message.role !== "user",
|
|
90
|
+
});
|
|
91
|
+
if (sanitizedContent.trim().length > 0 &&
|
|
92
|
+
shouldRetainHistoricalToolMemory(message.role, historicalToolSource, sanitizedContent)) {
|
|
93
|
+
pushMemoryItem({ content: sanitizedContent, role: message.role, provenance: ... });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
**v1.9.7** — Complex, cursor-guarded. THREE tool gates, source-index-aware dedup:
|
|
102
|
+
```typescript
|
|
103
|
+
// v1.9.7 — src/context-engine.ts ~line 1894
|
|
104
|
+
if (Array.isArray(result.messages)) {
|
|
105
|
+
const lastUserIndex = sourceMessages ? findLastUserMessageIndex(sourceMessages) : -1;
|
|
106
|
+
let liveSourceCursor = sourceMessages ? lastUserIndex + 1 : undefined;
|
|
107
|
+
let providerReplaySourceCursor: number | undefined = sourceMessages ? 0 : undefined;
|
|
108
|
+
for (const message of result.messages) {
|
|
109
|
+
const content = normalizeKernelContent(message.content);
|
|
110
|
+
const historicalToolSource = getHistoricalToolSource(message.role, message.content, content);
|
|
111
|
+
let isRealTranscript = false;
|
|
112
|
+
if (sourceMessages) {
|
|
113
|
+
isRealTranscript = findMatchingSourceMessageIndex(message, content, sourceMessages) >= 0;
|
|
114
|
+
} else {
|
|
115
|
+
isRealTranscript = message.role === "user" || message.role === "assistant";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// GATE 1: consumeLiveToolAtCursor — cursor-guarded, pushes source message directly
|
|
119
|
+
const liveToolProtocolSource = consumeLiveToolAtCursor(
|
|
120
|
+
message, content, sourceMessages, liveSourceCursor,
|
|
121
|
+
lastUserIndex >= 0 ? lastUserIndex : undefined,
|
|
122
|
+
);
|
|
123
|
+
if (liveToolProtocolSource) {
|
|
124
|
+
// ⚠️ PUSHES DIRECTLY — bypasses provider-replay dedup entirely (line 1921)
|
|
125
|
+
messages.push(preserveLiveToolProtocolMessage(liveToolProtocolSource.message));
|
|
126
|
+
liveSourceCursor = liveToolProtocolSource.index + 1;
|
|
127
|
+
}
|
|
128
|
+
// GATE 2: findLiveToolSourceInCurrentTurn — additional skip gate
|
|
129
|
+
else if (findLiveToolSourceInCurrentTurn(message, content, sourceMessages, undefined,
|
|
130
|
+
lastUserIndex >= 0 ? lastUserIndex : undefined) >= 0) {
|
|
131
|
+
// ⚠️ Silently skips — message is dropped
|
|
132
|
+
if (liveSourceCursor !== undefined && sourceMessages) {
|
|
133
|
+
const idx = findMatchingSourceMessageIndex(message, content, sourceMessages, liveSourceCursor);
|
|
134
|
+
if (idx >= liveSourceCursor) liveSourceCursor = idx + 1;
|
|
135
|
+
}
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
// GATE 3: Historical tool derived — filters assistant replies after tool protocol
|
|
139
|
+
else if (isRealTranscript && !historicalToolSource && isProviderReplayRole(message.role)) {
|
|
140
|
+
if (isHistoricalToolDerivedAssistantReply(message, content, sourceMessages)) {
|
|
141
|
+
// ... cursor advance, continue
|
|
142
|
+
}
|
|
143
|
+
const sanitizedContent = sanitizeToolCallPatterns(content, {
|
|
144
|
+
stripOpenClawDirectives: message.role === "assistant",
|
|
145
|
+
});
|
|
146
|
+
if (isHistoricalAssistantActionPromise(message.role, sanitizedContent)) {
|
|
147
|
+
// ... cursor advance, continue
|
|
148
|
+
}
|
|
149
|
+
// ⚠️ pushProviderReplayMessage — has dedup, but only for provider replay path
|
|
150
|
+
const providerReplaySourceIndex = sourceMessages
|
|
151
|
+
? findMatchingSourceMessageIndex(message, content, sourceMessages, providerReplaySourceCursor)
|
|
152
|
+
: undefined;
|
|
153
|
+
pushProviderReplayMessage(
|
|
154
|
+
{ role: message.role, content: sanitizedContent, ... },
|
|
155
|
+
providerReplaySourceIndex,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
// Memory items
|
|
160
|
+
// ...
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Live Tool Protocol Functions
|
|
167
|
+
|
|
168
|
+
**v1.8.9** — Simple boolean check:
|
|
169
|
+
```typescript
|
|
170
|
+
function isLiveToolProtocolMessage(
|
|
171
|
+
message: { role: string; content?: unknown; id?: string },
|
|
172
|
+
normalizedContent: string,
|
|
173
|
+
sourceMessages: OpenClawCompatibleMessage[] | undefined,
|
|
174
|
+
): boolean {
|
|
175
|
+
if (!sourceMessages) return false;
|
|
176
|
+
if (!isToolResultRole(message.role) && !hasKernelToolCallBlock(message.content)) return false;
|
|
177
|
+
|
|
178
|
+
const lastUserIndex = findLastUserMessageIndex(sourceMessages);
|
|
179
|
+
const sourceIndex = findMatchingSourceMessageIndex(
|
|
180
|
+
message, normalizedContent, sourceMessages, lastUserIndex + 1,
|
|
181
|
+
);
|
|
182
|
+
if (sourceIndex < 0) return false;
|
|
183
|
+
if (sourceIndex <= lastUserIndex) return false;
|
|
184
|
+
if (hasCompletedAssistantResponseAfter(sourceMessages, sourceIndex)) return false;
|
|
185
|
+
if (hasKernelToolCallBlock(message.content)) return true;
|
|
186
|
+
return hasLiveToolCallBefore(sourceMessages, lastUserIndex, sourceIndex, getToolResultCallId(message));
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
**v1.9.7** — Cursor-guarded, returns source message + index for position tracking:
|
|
191
|
+
```typescript
|
|
192
|
+
function findLiveToolSourceInCurrentTurn(
|
|
193
|
+
message: { role: string; content?: unknown; id?: string; [key: string]: unknown },
|
|
194
|
+
normalizedContent: string,
|
|
195
|
+
sourceMessages: OpenClawCompatibleMessage[] | undefined,
|
|
196
|
+
preferredStartIndex?: number,
|
|
197
|
+
providedLastUserIndex?: number,
|
|
198
|
+
): number {
|
|
199
|
+
if (!sourceMessages) return -1;
|
|
200
|
+
// ⚠️ Allow assistant messages through — daemon flattens structured toolCall blocks into
|
|
201
|
+
// [tool:name] text, which no longer triggers hasKernelToolCallBlock
|
|
202
|
+
if (!isToolResultRole(message.role) && message.role !== "assistant" && !hasKernelToolCallBlock(message.content)) {
|
|
203
|
+
return -1;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const lastUserIndex = providedLastUserIndex ?? findLastUserMessageIndex(sourceMessages);
|
|
207
|
+
if (lastUserIndex < 0) return -1;
|
|
208
|
+
const searchStartIndex = preferredStartIndex === undefined
|
|
209
|
+
? lastUserIndex + 1
|
|
210
|
+
: Math.max(lastUserIndex + 1, preferredStartIndex);
|
|
211
|
+
const sourceIndex = findMatchingSourceMessageIndex(message, content, sourceMessages, searchStartIndex);
|
|
212
|
+
if (sourceIndex < searchStartIndex) return -1;
|
|
213
|
+
if (hasCompletedAssistantResponseAfter(sourceMessages, sourceIndex)) return -1;
|
|
214
|
+
|
|
215
|
+
const sourceMessage = sourceMessages[sourceIndex];
|
|
216
|
+
if (!sourceMessage) return -1;
|
|
217
|
+
if (sourceMessage.role === "assistant" && hasKernelToolCallBlock(sourceMessage.content)) {
|
|
218
|
+
return sourceIndex;
|
|
219
|
+
}
|
|
220
|
+
if (isToolResultRole(sourceMessage.role)) {
|
|
221
|
+
const toolCallId = getToolResultCallId(sourceMessage) ?? getToolResultCallId(message);
|
|
222
|
+
if (hasLiveToolCallBefore(sourceMessages, lastUserIndex, sourceIndex, toolCallId)) {
|
|
223
|
+
return sourceIndex;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return -1;
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Key difference: v1.9.7 added `message.role !== "assistant"` as an allow condition — daemon-flattened `[tool:name]` text from assistant messages now passes through where v1.8.9's `hasKernelToolCallBlock` would have filtered them.
|
|
231
|
+
|
|
232
|
+
## Theories
|
|
233
|
+
|
|
234
|
+
### T0: Async ingestion races with next assemble() — cursor base shifts (HIGHEST)
|
|
235
|
+
|
|
236
|
+
v1.9.0 (commit `20dc976`, PR #329) made `afterTurn` ingestion asynchronous — it's enqueued via `enqueueAsyncIngestion()` instead of `await`ed inline. The daemon call that ingests the assistant's tool call response may complete AFTER the next `assemble()` starts.
|
|
237
|
+
|
|
238
|
+
When this race hits:
|
|
239
|
+
1. Turn N finishes. LLM generated "An army of me" + `[tool:image_gen]`.
|
|
240
|
+
2. `afterTurn` is enqueued but hasn't completed yet.
|
|
241
|
+
3. Turn N+1 starts. User sends next message. `assemble()` runs.
|
|
242
|
+
4. The source transcript passed to `normalizeAssembleResult` does NOT yet include the assistant's tool call response from turn N.
|
|
243
|
+
5. `consumeLiveToolAtCursor` and `findLiveToolSourceInCurrentTurn` compute cursors against a stale transcript.
|
|
244
|
+
6. When the async afterTurn FINALLY completes, the next `assemble()` NOW sees the tool protocol in the transcript — cursor behavior changes.
|
|
245
|
+
7. Depending on provider latency and message cadence, the race hits ~30-50% of turns.
|
|
246
|
+
|
|
247
|
+
This explains the intermittency perfectly. Before v1.9.0, `afterTurn` was synchronous — the daemon call completed before the LLM response started streaming. The source transcript was always up-to-date. The cursor was always computed against a complete transcript.
|
|
248
|
+
|
|
249
|
+
**Evidence for T0:**
|
|
250
|
+
- Intermittent, ~50% hit rate → timing-dependent, not a code path always taken
|
|
251
|
+
- Only occurs on tool call turns (O1) → the race specifically affects cursor-based tool protocol classification
|
|
252
|
+
- Regular conversations are immune (O3) → `consumeLiveToolAtCursor` / `findLiveToolSourceInCurrentTurn` are only entered when tool protocol exists
|
|
253
|
+
- 50/50 split matches two-variable race window (O2) — provider latency 2-8s vs daemon ingest latency 0.5-3s
|
|
254
|
+
- v1.8.9 was synchronous → `afterTurn` always completed before next `assemble()` → no race, clean
|
|
255
|
+
- v1.9.0 introduced async ingestion → race introduced
|
|
256
|
+
- v1.9.1 added cursor-based tool protocol → race affects tool call classification specifically
|
|
257
|
+
|
|
258
|
+
### T1: Daemon-flattened assistant tool calls leak through (HIGH)
|
|
259
|
+
|
|
260
|
+
In v1.9.7, the `findLiveToolSourceInCurrentTurn` function allows `message.role === "assistant"` through (line ~489). The comment says "Daemon flattens structured toolCall blocks into [tool:name] text, which no longer triggers hasKernelToolCallBlock."
|
|
261
|
+
|
|
262
|
+
When the daemon returns a previous turn's assistant response that contained a tool call, the flattened `[tool:image_gen]` text matches against the source transcript. The function finds the source message, gates 1 or 2 fire, and:
|
|
263
|
+
- Gate 1 pushes the original (unsanitized) source message directly
|
|
264
|
+
- Gate 2 drops it with `continue`
|
|
265
|
+
|
|
266
|
+
BUT: Gate 1 pushes **unsanitized** source messages. If the source message contains the full structured tool call JSON block, the model sees the complete tool call syntax and re-executes it.
|
|
267
|
+
|
|
268
|
+
**T1 is gated on T0** — the race determines whether the source transcript is in a state where this match occurs.
|
|
269
|
+
|
|
270
|
+
### T2: consumeLiveToolAtCursor pushes message that provider replay also matches (MEDIUM)
|
|
271
|
+
|
|
272
|
+
Gate 1 pushes via `preserveLiveToolProtocolMessage` and advances `liveSourceCursor`. But if the SAME message appears again in the daemon's output (e.g., daemon returns it twice — once from session_raw, once from session_summary), the second occurrence won't match gate 1 (cursor advanced past it) but COULD fall through to gate 3 (provider replay) and get pushed again.
|
|
273
|
+
|
|
274
|
+
The `pushProviderReplayMessage` dedup should catch this, but only if the sanitized content matches exactly. If the source message and daemon message have different formats, the keys won't match.
|
|
275
|
+
|
|
276
|
+
**T2 is gated on T0** — the transcript state affects whether daemon returns duplicate entries.
|
|
277
|
+
|
|
278
|
+
## Comparison: lossless-claw Tool Dedup Approach
|
|
279
|
+
|
|
280
|
+
The `lossless-claw` plugin (in `/tmp/lossless-claw`) implements a more robust approach to tool call deduplication that our plugin lacks.
|
|
281
|
+
|
|
282
|
+
### What lossless-claw does differently
|
|
283
|
+
|
|
284
|
+
**1. Tool-use ID-based dedup** (`filterAssistantToolUseBlocks`, transcript-repair.ts:211-254)
|
|
285
|
+
```typescript
|
|
286
|
+
function filterAssistantToolUseBlocks(msg, seenToolUseIds, options) {
|
|
287
|
+
for (const block of content) {
|
|
288
|
+
const id = extractToolCallId(rec);
|
|
289
|
+
if (isToolUse && id) {
|
|
290
|
+
if (dropAll || seenToolUseIds.has(id)) {
|
|
291
|
+
dropped.push({ id, reason: dropAll ? "terminal" : "duplicate" });
|
|
292
|
+
continue; // ← drops duplicate tool_use blocks by ID
|
|
293
|
+
}
|
|
294
|
+
if (record) seenToolUseIds.add(id);
|
|
295
|
+
}
|
|
296
|
+
kept.push(block);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
```
|
|
300
|
+
Tracks tool call IDs in a `Set`. If the same `tool_use` block ID appears in a second assistant message, it's dropped immediately. **Our plugin has no tool-ID-based dedup at all** — it relies on content hashing which fails when the same tool call is formatted differently between daemon-flattened and source-message forms.
|
|
301
|
+
|
|
302
|
+
**2. Tool-result ID-based dedup** (`pushToolResult`, transcript-repair.ts:311-322)
|
|
303
|
+
```typescript
|
|
304
|
+
const pushToolResult = (msg) => {
|
|
305
|
+
const id = extractToolResultId(msg);
|
|
306
|
+
if (id && seenToolResultIds.has(id)) {
|
|
307
|
+
droppedDuplicateCount += 1; // ← drops duplicate tool results by ID
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (id) seenToolResultIds.add(id);
|
|
311
|
+
out.push(msg);
|
|
312
|
+
};
|
|
313
|
+
```
|
|
314
|
+
Duplicated tool results (same `toolCallId`) are dropped. Our plugin pushes tool results as provider replay messages with no tool-ID tracking — the content-based dedup key `${role}\0${content}` doesn't know about tool call IDs at all.
|
|
315
|
+
|
|
316
|
+
**3. Transcript repair** (`sanitizeToolUseResultPairing`, transcript-repair.ts:286-538)
|
|
317
|
+
- Moves `toolResult` messages directly after their matching assistant `tool_use` turn
|
|
318
|
+
- Inserts synthetic error `toolResult` for missing tool call IDs
|
|
319
|
+
- Drops orphaned `toolResult` messages with no matching `tool_use`
|
|
320
|
+
- Drops duplicate `toolResult` messages (same ID)
|
|
321
|
+
- Handles error/aborted turns via `dropAll: true, record: false` mode
|
|
322
|
+
|
|
323
|
+
Our plugin has **no transcript repair at all**. If the daemon returns tool results out of order or orphaned, our plugin passes them through as-is.
|
|
324
|
+
|
|
325
|
+
**4. Content-hash dedup with ordinal tracking** (`buildMessageContentDuplicateClusters`, assembler.ts:1198-1209)
|
|
326
|
+
```typescript
|
|
327
|
+
function buildMessageContentDuplicateClusters(items) {
|
|
328
|
+
for (const item of items) {
|
|
329
|
+
const hash = hashText(item.text);
|
|
330
|
+
const existing = clusters.get(hash) ?? [];
|
|
331
|
+
existing.push(item);
|
|
332
|
+
clusters.set(hash, existing);
|
|
333
|
+
}
|
|
334
|
+
return formatDuplicateClusters(clusters, () => "message-content");
|
|
335
|
+
}
|
|
336
|
+
```
|
|
337
|
+
Groups messages by content hash, then reports clusters with >1 item as duplicates. This is diagnostic-only (used for overflow reporting), not active dedup — but it surfaces when the same content appears multiple times.
|
|
338
|
+
|
|
339
|
+
**5. Replay-ID dedup** (`extractPlainToolReplayTextsById`, engine.ts:521-534)
|
|
340
|
+
```typescript
|
|
341
|
+
function extractPlainToolReplayTextsById(message) {
|
|
342
|
+
const textsById = new Map();
|
|
343
|
+
const duplicateIds = new Set();
|
|
344
|
+
const addText = (replayId, text) => {
|
|
345
|
+
if (duplicateIds.has(replayId)) return; // ← already known duplicate
|
|
346
|
+
if (textsById.has(replayId)) {
|
|
347
|
+
textsById.delete(replayId); // ← second sighting = duplicate
|
|
348
|
+
duplicateIds.add(replayId);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
textsById.set(replayId, text);
|
|
352
|
+
};
|
|
353
|
+
// ... extracts tool replay texts keyed by toolCallId
|
|
354
|
+
}
|
|
355
|
+
```
|
|
356
|
+
Tracks replay IDs. First occurrence is stored. Second occurrence marks it as duplicate and removes from output. Third+ occurrences are dropped silently. This is applied during `afterTurn` ingestion to prevent duplicate tool results from being stored.
|
|
357
|
+
|
|
358
|
+
### Gap analysis: what our plugin is missing
|
|
359
|
+
|
|
360
|
+
| Capability | lossless-claw | libravdb-memory (ours) | Impact |
|
|
361
|
+
|---|---|---|---|
|
|
362
|
+
| Tool-use ID dedup | `filterAssistantToolUseBlocks` — `Set<string>` by ID | None — content-based only | Same tool call with different formatting leaks through |
|
|
363
|
+
| Tool-result ID dedup | `pushToolResult` — `Set<string>` by ID | None — content-based only | Same tool result appears twice in context |
|
|
364
|
+
| Transcript repair | `sanitizeToolUseResultPairing` — reorder, fill gaps, drop orphans | None | Out-of-order tool protocol confuses model |
|
|
365
|
+
| Content hash clusters | `buildMessageContentDuplicateClusters` — diagnostic | None | Can't detect when dedup fails |
|
|
366
|
+
| Replay-ID dedup | `extractPlainToolReplayTextsById` — at ingest time | None | Duplicate tool results stored in daemon |
|
|
367
|
+
| AfterTurn batch dedup | `deduplicateAfterTurnBatch` — tail/suffix match | None | Redundant ingestion of already-stored turns |
|
|
368
|
+
|
|
369
|
+
### Why this matters for the looping bug
|
|
370
|
+
|
|
371
|
+
The live tool protocol system (`78da771`) injects source-format tool messages into context without tracking tool call IDs. If the daemon's context replay includes the same tool call message twice (once from `session_raw`, once from `session_summary`), both instances get pushed because:
|
|
372
|
+
|
|
373
|
+
1. `consumeLiveToolAtCursor` pushes the first instance directly (bypasses dedup)
|
|
374
|
+
2. The second instance falls through to provider replay path
|
|
375
|
+
3. `pushProviderReplayMessage` checks `${role}\0${content}` — but the daemon-flattened `[tool:image_gen]` format differs from the source-format structured tool call JSON
|
|
376
|
+
4. Keys don't match → both are pushed → model sees duplicate tool instruction
|
|
377
|
+
|
|
378
|
+
Lossless-claw would catch this because `filterAssistantToolUseBlocks` tracks the actual tool call ID, not the text representation.
|
|
379
|
+
|
|
380
|
+
## Next Steps
|
|
381
|
+
|
|
382
|
+
1. **Verify T0 (async race)**: Add a debug log at `assemble()` entry showing `sourceMessages.length`, `lastUserIndex`, and whether async ingestion queue has pending items. If duplicated turns correlate with stale transcript length (e.g., missing the last assistant response), T0 is confirmed.
|
|
383
|
+
|
|
384
|
+
2. **Mitigation — drain async queue before assemble**: In `assemble()`, `await` the session's async ingestion queue before computing cursors. The `FLUSH_ASYNC_INGESTION` Symbol hook already exists. If blocking on ingest adds too much latency, add a short deadline (2s).
|
|
385
|
+
|
|
386
|
+
3. **Hardening — make cursor computation tolerate stale transcripts**: If the source transcript is incomplete (async ingest pending), `findLiveToolSourceInCurrentTurn` and `consumeLiveToolAtCursor` should treat unconfirmed messages as historical, not live. Currently they treat anything findable after `lastUserIndex` as live.
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# Subagent Memory Access — Design Analysis
|
|
2
|
+
|
|
3
|
+
## Status
|
|
4
|
+
|
|
5
|
+
**Architectural gap identified.** `contextMode: "fork"` is dead code. Subagents cannot currently access parent session memories.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Current Implementation
|
|
10
|
+
|
|
11
|
+
### Subagent Token Budget
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
// context-engine.ts:2040-2044
|
|
15
|
+
function normalizeSubagentTokenBudget(value: unknown): number {
|
|
16
|
+
if (typeof value !== "number") return 8000;
|
|
17
|
+
if (!Number.isFinite(value) || value < 0) return 8000;
|
|
18
|
+
return Math.floor(value);
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
- Default: 8000 tokens
|
|
23
|
+
- `0` disables `memory_expand` entirely
|
|
24
|
+
- Budget tracked by `childSessionKey` in `Map<string, BudgetEntry>`
|
|
25
|
+
|
|
26
|
+
### prepareSubagentSpawn (Dead Code)
|
|
27
|
+
|
|
28
|
+
```typescript
|
|
29
|
+
// context-engine.ts:3173-3205
|
|
30
|
+
async prepareSubagentSpawn(params: {
|
|
31
|
+
parentSessionKey: string;
|
|
32
|
+
childSessionKey: string;
|
|
33
|
+
contextMode?: "isolated" | "fork"; // DEFINED BUT NEVER USED
|
|
34
|
+
parentSessionId?: string;
|
|
35
|
+
parentSessionFile?: string;
|
|
36
|
+
childSessionId?: string;
|
|
37
|
+
childSessionFile?: string;
|
|
38
|
+
ttlMs?: number;
|
|
39
|
+
}) {
|
|
40
|
+
const budget = normalizeSubagentTokenBudget(cfg.subagentTokenBudget);
|
|
41
|
+
const key = subagentKey(params.childSessionKey);
|
|
42
|
+
subagentBudgets.set(key, { remaining: budget, total: budget, expiresAt: ... });
|
|
43
|
+
// contextMode is never read — no fork/isolate logic exists
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Session Isolation
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
// memory-runtime.ts:229-253
|
|
51
|
+
function resolveSearchCollections(cfg, userId, sessionId, corpus) {
|
|
52
|
+
if (corpus === "sessions") {
|
|
53
|
+
return sessionId ? [resolveSessionSearchCollection(cfg, sessionId)] : [];
|
|
54
|
+
}
|
|
55
|
+
// ...
|
|
56
|
+
// Subagent session collection = session:${subagentSessionId}
|
|
57
|
+
// Parent session collection = session:${parentSessionId}
|
|
58
|
+
// They are DIFFERENT collections — no cross-access
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Lossless-Claw Approach (Reference)
|
|
65
|
+
|
|
66
|
+
### Session Key Pattern
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
agent:<agentId>:session:<sessionId> // main agent
|
|
70
|
+
agent:<agentId>:subagent:<uuid> // subagent
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Parent Context Passing
|
|
74
|
+
|
|
75
|
+
```typescript
|
|
76
|
+
// Subagent session key encodes parent agentId
|
|
77
|
+
const childSessionKey = `agent:${requesterAgentId}:subagent:${crypto.randomUUID()}`
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Delegation Grant System
|
|
81
|
+
|
|
82
|
+
Grants are scoped to specific conversation IDs and bound to the child session key.
|
|
83
|
+
|
|
84
|
+
### Fork Mode Bootstrap
|
|
85
|
+
|
|
86
|
+
> "For forked child sessions, LCM treats a host-copied parent JSONL branch as a first-time bootstrap source and imports only the newest messages that fit within `bootstrapMaxTokens`."
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Required Implementation
|
|
91
|
+
|
|
92
|
+
### 1. Session Key Format
|
|
93
|
+
|
|
94
|
+
Adopt lossless-claw pattern for consistent agent/session encoding:
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
session-key:agent:<agentId>:session:<sessionId> // main agent
|
|
98
|
+
session-key:agent:<agentId>:subagent:<uuid> // subagent
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Or simpler (preserve current format but encode parent relationship):
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
<current-session-key>:fork:<parent-session-key> // fork mode child
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### 2. Implement contextMode: "fork"
|
|
108
|
+
|
|
109
|
+
When `contextMode === "fork"`:
|
|
110
|
+
|
|
111
|
+
1. Store `parentSessionId` in subagent budget entry
|
|
112
|
+
2. In `resolveSearchCollections()`, if fork mode and `sessionId` is a child:
|
|
113
|
+
- Include parent's session collection in search scope
|
|
114
|
+
3. Subagent can bootstrap from parent's session_summary and session_raw
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
// In subagentBudgets Map
|
|
118
|
+
interface SubagentBudgetEntry {
|
|
119
|
+
remaining: number;
|
|
120
|
+
total: number;
|
|
121
|
+
expiresAt: number;
|
|
122
|
+
parentSessionId?: string; // Added for fork mode
|
|
123
|
+
contextMode: "isolated" | "fork";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// In resolveSearchCollections
|
|
127
|
+
if (entry.contextMode === "fork" && entry.parentSessionId) {
|
|
128
|
+
collections.push(resolveSessionSearchCollection(cfg, entry.parentSessionId));
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### 3. Per-Agent Config
|
|
133
|
+
|
|
134
|
+
```typescript
|
|
135
|
+
// openclaw.plugin.json configSchema addition
|
|
136
|
+
{
|
|
137
|
+
"agentOverrides": {
|
|
138
|
+
"type": "object",
|
|
139
|
+
"additionalProperties": true,
|
|
140
|
+
"description": "Per-agent configuration overrides keyed by agentId"
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Config resolution:
|
|
146
|
+
|
|
147
|
+
```typescript
|
|
148
|
+
function resolveAgentConfig(cfg: PluginConfig, agentId?: string): Partial<PluginConfig> {
|
|
149
|
+
if (!agentId || !cfg.agentOverrides?.[agentId]) {
|
|
150
|
+
return {};
|
|
151
|
+
}
|
|
152
|
+
return cfg.agentOverrides[agentId];
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Usage in budget normalization:
|
|
157
|
+
|
|
158
|
+
```typescript
|
|
159
|
+
function getEffectiveSubagentTokenBudget(cfg: PluginConfig, agentId?: string): number {
|
|
160
|
+
const overrides = resolveAgentConfig(cfg, agentId);
|
|
161
|
+
const value = overrides.subagentTokenBudget ?? cfg.subagentTokenBudget;
|
|
162
|
+
return normalizeSubagentTokenBudget(value);
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### 4. Subagent Session Key Encoding
|
|
167
|
+
|
|
168
|
+
When OpenClaw spawns a subagent, it provides:
|
|
169
|
+
- `ctx.sessionKey` — the child's session key
|
|
170
|
+
- `ctx.agentId` — may be different from parent or undefined
|
|
171
|
+
|
|
172
|
+
We can detect subagent via:
|
|
173
|
+
- Session key suffix pattern (if OpenClaw encodes it)
|
|
174
|
+
- Parent-child relationship via lifecycle hooks
|
|
175
|
+
|
|
176
|
+
Current detection (not implemented):
|
|
177
|
+
|
|
178
|
+
```typescript
|
|
179
|
+
// context-engine.ts
|
|
180
|
+
function isSubagentSessionKey(sessionKey: string): boolean {
|
|
181
|
+
// Pattern: detect if this is a subagent spawn vs main agent
|
|
182
|
+
// Depends on OpenClaw's session key format for subagents
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## OpenClaw Agent Flag
|
|
189
|
+
|
|
190
|
+
OpenClaw supports `--agent <id>` for multi-agent deployments. The plugin receives `agentId` via `OpenClawPluginToolContext`.
|
|
191
|
+
|
|
192
|
+
Current handling:
|
|
193
|
+
- `LIBRAVDB_AGENT_ID` env var for container/CI override
|
|
194
|
+
- `cfg.tenantId` for explicit DB isolation
|
|
195
|
+
- `resolveTenantKey()` priority: `tenantId` > `LIBRAVDB_AGENT_ID` > `userId`
|
|
196
|
+
|
|
197
|
+
For per-agent memory isolation:
|
|
198
|
+
- Different `--agent` values → different `agentId` → different collection namespaces
|
|
199
|
+
- Agent's subagents inherit same `agentId` in session key pattern
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
## Implementation Priority
|
|
204
|
+
|
|
205
|
+
| Priority | Item | Description |
|
|
206
|
+
|----------|------|-------------|
|
|
207
|
+
| P0 | Session key pattern | Define how parent/child relationship is encoded |
|
|
208
|
+
| P1 | contextMode: "fork" | Implement parent session access for fork mode |
|
|
209
|
+
| P2 | Per-agent config | Add `agentOverrides` to configSchema |
|
|
210
|
+
| P3 | Bootstrap token limit | Prevent parent import from exceeding budget |
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## Files to Modify
|
|
215
|
+
|
|
216
|
+
- `src/context-engine.ts` — implement fork mode in `prepareSubagentSpawn` and `resolveSearchCollections`
|
|
217
|
+
- `src/memory-scopes.ts` — add subagent session key parsing
|
|
218
|
+
- `src/openclaw.plugin.json` — add `agentOverrides` to configSchema
|
|
219
|
+
- `src/plugin-runtime.ts` — pass agentId to config resolution
|
|
220
|
+
- `src/tools/memory-recall.ts` — include parent session in grep when fork mode
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## Rejected Approaches
|
|
225
|
+
|
|
226
|
+
### excludeAgents / excludeSubagents
|
|
227
|
+
|
|
228
|
+
Peetie's proposal to exclude agents from memory injection is a blunt instrument that:
|
|
229
|
+
1. Bypasses proper isolation (agents are already isolated by session collection)
|
|
230
|
+
2. Cannot grant selective access (all-or-nothing)
|
|
231
|
+
3. Doesn't solve the fork use case (subagent needs parent access, not exclusion)
|
|
232
|
+
|
|
233
|
+
The correct solution is proper fork mode, not exclusion lists.
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
## References
|
|
238
|
+
|
|
239
|
+
- Lossless-claw: `src/plugin/index.ts:44-59` (session key parsing)
|
|
240
|
+
- Lossless-claw: `src/focus-briefs.ts:737` (child session key creation)
|
|
241
|
+
- Lossless-claw: `docs/architecture.md:215-224` (fork bootstrap)
|
package/openclaw.plugin.json
CHANGED