mirror of
https://github.com/dtzp555-max/memory-continuity.git
synced 2026-07-21 21:15:07 +00:00
- Add workspace resolution helpers (isSubagentSession, parseParentAgentId, resolveAgentWorkspace) - Parent→Child seeding: inject parent CURRENT_STATE.md into subagent context at startup - Child→Parent recovery: new subagent_ended hook reads child unsurfaced results, merges into parent - Add subagentSeed and subagentRecovery config keys (both default: true) - Add /mc subagents command for cross-workspace state overview - Bump lifecycle plugin to v5.0.0, mc-plugin to v2.1.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
557 lines
19 KiB
Markdown
557 lines
19 KiB
Markdown
# Phase 5 — Conservative Subagent Support (v5.0) Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Enable working-state continuity across parent/child agent boundaries — parent seeds child with structured context, child recovers unsurfaced results back to parent.
|
|
|
|
**Architecture:** Two new lifecycle hooks added to the existing plugin. (1) Enhance `before_agent_start` to detect forked child sessions and inject the parent's CURRENT_STATE.md as a structured seed. (2) Add a new `subagent_ended` hook that reads the child's workspace CURRENT_STATE.md and merges unsurfaced results into the parent's state. Both features are conservative — they degrade gracefully when context is unavailable and never overwrite parent state destructively.
|
|
|
|
**Tech Stack:** Node.js ESM, OpenClaw plugin lifecycle hooks (`before_agent_start`, `subagent_ended`), filesystem-based state passing (cross-workspace read), zero external dependencies.
|
|
|
|
**Research Findings (constraints):**
|
|
- `subagent_ended` event provides: `{ targetSessionKey, reason, outcome, error }` and context `{ childSessionKey, requesterSessionKey }`
|
|
- No pre-spawn hook exists — parent seeding happens via `before_agent_start` on the child side
|
|
- Workspaces are fully isolated — each agent has its own `memory/` directory
|
|
- Session key format: `agent:<agentId>:subagent:<uuid>` — parseable to extract `agentId`
|
|
- Agent workspace is resolvable via config: `agents.list[].workspace` or default `~/.openclaw/workspace/main`
|
|
- `isSubagentSessionKey()` and `parseAgentSessionKey()` are runtime utilities (not available to plugins directly — must parse manually)
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
| Action | File | Responsibility |
|
|
|--------|------|----------------|
|
|
| Modify | `index.js` | Add parent-seed logic to `before_agent_start`, add new `subagent_ended` hook, add helper functions |
|
|
| Modify | `openclaw.plugin.json` | Bump to 5.0.0, add new config keys |
|
|
| Modify | `package.json` | Bump to 5.0.0 |
|
|
|
|
---
|
|
|
|
### Task 1: Add Workspace Resolution Helpers
|
|
|
|
**Files:**
|
|
- Modify: `index.js` (helpers section, before plugin definition)
|
|
|
|
Two new helper functions are needed: one to detect if the current session is a subagent, and one to resolve a workspace directory from an agent ID.
|
|
|
|
- [ ] **Step 1: Add `isSubagentSession` helper**
|
|
|
|
In `index.js`, after the existing `extractStateFromMessages` function (around line 709) and before the `// Plugin Definition` comment, add:
|
|
|
|
```javascript
|
|
/**
|
|
* Detect if the current session is a subagent by checking the session key format.
|
|
* Subagent session keys follow: agent:<agentId>:subagent:<uuid>
|
|
*/
|
|
function isSubagentSession(ctx) {
|
|
const key = ctx?.sessionKey || ctx?.SessionKey || "";
|
|
return /^agent:[^:]+:subagent:/.test(key);
|
|
}
|
|
|
|
/**
|
|
* Extract the parent agent ID from a subagent session key.
|
|
* Session key format: agent:<parentAgentId>:subagent:<uuid>
|
|
* Returns null if not a subagent key.
|
|
*/
|
|
function parseParentAgentId(sessionKey) {
|
|
const match = sessionKey?.match(/^agent:([^:]+):subagent:/);
|
|
return match?.[1] || null;
|
|
}
|
|
|
|
/**
|
|
* Resolve workspace directory for a given agent ID.
|
|
* Searches the OpenClaw config for agent workspace mappings.
|
|
* Falls back to ~/.openclaw/workspace/main for the "main" agent,
|
|
* or ~/.openclaw/workspaces/<agentId> for named agents.
|
|
*/
|
|
function resolveAgentWorkspace(agentId) {
|
|
if (!agentId) return null;
|
|
|
|
const base = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "/tmp", ".openclaw");
|
|
|
|
// Try to read config for explicit workspace mapping
|
|
try {
|
|
const configPath = path.join(base, "openclaw.json");
|
|
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
const agents = config?.agents?.list || [];
|
|
const entry = agents.find(a => a.id === agentId || a.name === agentId);
|
|
if (entry?.workspace) return entry.workspace;
|
|
|
|
// Check defaults
|
|
if (agentId === "main") {
|
|
return config?.agents?.defaults?.workspace || path.join(base, "workspace", "main");
|
|
}
|
|
} catch {}
|
|
|
|
// Fallback heuristics
|
|
if (agentId === "main") return path.join(base, "workspace", "main");
|
|
|
|
// Named agents typically use ~/.openclaw/workspaces/<agentId>
|
|
const namedWs = path.join(base, "workspaces", agentId);
|
|
if (fs.existsSync(namedWs)) return namedWs;
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Extract the Unsurfaced Results section from a state markdown file.
|
|
* Returns the raw text content of the section, or null if empty/placeholder.
|
|
*/
|
|
function extractUnsurfacedResults(md) {
|
|
if (!md) return null;
|
|
const section = extractSection(md, "Unsurfaced Results");
|
|
if (!section || !isMeaningful(section)) return null;
|
|
return section;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Verify the file still parses**
|
|
|
|
```bash
|
|
cd /Users/taodeng/.openclaw/projects/memory-continuity
|
|
node -e "import('./index.js').then(m => console.log('OK:', typeof m.default))"
|
|
```
|
|
|
|
Expected: `OK: object`
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add index.js
|
|
git commit -m "feat(subagent): add workspace resolution and session detection helpers"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Parent→Child Seeding in before_agent_start
|
|
|
|
**Files:**
|
|
- Modify: `index.js` (HOOK 1: before_agent_start)
|
|
|
|
When a child agent starts (detected via subagent session key format), read the parent agent's CURRENT_STATE.md and inject it as additional context alongside the child's own state.
|
|
|
|
- [ ] **Step 1: Enhance the `before_agent_start` hook**
|
|
|
|
In `index.js`, find HOOK 1 (`before_agent_start`). The current code reads the agent's own state and injects it. After the existing relevance injection block (around the `if (config.relevanceInjection !== false)` block), add parent-seed logic:
|
|
|
|
Find this existing code block:
|
|
|
|
```javascript
|
|
// Relevance injection: find related historical entries
|
|
if (config.relevanceInjection !== false) {
|
|
const objective = extractSection(md, "Objective");
|
|
const maxItems = config.maxRelevanceItems ?? 3;
|
|
const history = findRelevantHistory(ws, objective, maxItems);
|
|
if (history) {
|
|
parts.push(history);
|
|
log.info?.("[memory-continuity] Injected relevant history context");
|
|
}
|
|
}
|
|
```
|
|
|
|
Right after this block (before the `return` statement), insert:
|
|
|
|
```javascript
|
|
// Parent seed: if this is a subagent, inject parent's working state
|
|
if (config.subagentSeed !== false && isSubagentSession(_ctx)) {
|
|
try {
|
|
const sessionKey = _ctx?.sessionKey || _ctx?.SessionKey || "";
|
|
const parentAgentId = parseParentAgentId(sessionKey);
|
|
if (parentAgentId) {
|
|
const parentWs = resolveAgentWorkspace(parentAgentId);
|
|
if (parentWs) {
|
|
const parentStatePath = resolveStatePath(parentWs);
|
|
const parentMd = parentStatePath ? readFile(parentStatePath) : null;
|
|
if (parentMd) {
|
|
const parentSnapshot = buildSnapshot(parentMd);
|
|
if (parentSnapshot) {
|
|
parts.push(
|
|
"=== PARENT AGENT CONTEXT ===\n" +
|
|
"The following is the parent agent's working state. " +
|
|
"Use this to understand the broader task context.\n" +
|
|
parentSnapshot +
|
|
"\n=== END PARENT CONTEXT ==="
|
|
);
|
|
log.info?.("[memory-continuity] Injected parent state seed for subagent");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
log.warn?.("[memory-continuity] Parent seed failed (non-fatal): " + err.message);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Verify plugin loads**
|
|
|
|
```bash
|
|
cd /Users/taodeng/.openclaw/projects/memory-continuity
|
|
node -e "import('./index.js').then(m => console.log('OK:', typeof m.default))"
|
|
```
|
|
|
|
Expected: `OK: object`
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add index.js
|
|
git commit -m "feat(subagent): parent-to-child state seeding in before_agent_start"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Child→Parent Recovery via subagent_ended Hook
|
|
|
|
**Files:**
|
|
- Modify: `index.js` (add new HOOK 6: subagent_ended)
|
|
|
|
When a child agent ends, read its CURRENT_STATE.md and check for unsurfaced results. If found, append them to the parent's state file.
|
|
|
|
- [ ] **Step 1: Add the `subagent_ended` hook**
|
|
|
|
In `index.js`, find the section after HOOK 5 (`session_end`) and before the SERVICE section. Add a new hook between them:
|
|
|
|
```javascript
|
|
// ------------------------------------------------------------------
|
|
// HOOK 6: subagent_ended — recover child's unsurfaced results
|
|
// ------------------------------------------------------------------
|
|
api.on("subagent_ended", async (event, _ctx) => {
|
|
const config = getConfig();
|
|
if (config.subagentRecovery === false) return;
|
|
|
|
const parentWs = _ctx?.workspaceDir;
|
|
if (!parentWs) return;
|
|
|
|
const childSessionKey = event?.childSessionKey || _ctx?.childSessionKey;
|
|
if (!childSessionKey) {
|
|
log.info?.("[memory-continuity] subagent_ended: no childSessionKey, skipping");
|
|
return;
|
|
}
|
|
|
|
// Only process successful completions (not kills/errors)
|
|
const outcome = event?.outcome || "";
|
|
const reason = event?.reason || "";
|
|
if (outcome === "error" || reason === "killed" || reason === "spawn-failed") {
|
|
log.info?.("[memory-continuity] subagent_ended: child ended with " + (reason || outcome) + ", skipping recovery");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Parse child agent ID from session key
|
|
const childMatch = childSessionKey.match(/^agent:([^:]+)/);
|
|
const childAgentId = childMatch?.[1];
|
|
if (!childAgentId) return;
|
|
|
|
const childWs = resolveAgentWorkspace(childAgentId);
|
|
if (!childWs) {
|
|
log.info?.("[memory-continuity] subagent_ended: cannot resolve child workspace for " + childAgentId);
|
|
return;
|
|
}
|
|
|
|
// Read child's state
|
|
const childStatePath = resolveStatePath(childWs);
|
|
const childMd = childStatePath ? readFile(childStatePath) : null;
|
|
if (!childMd) return;
|
|
|
|
// Extract unsurfaced results from child
|
|
const childUnsurfaced = extractUnsurfacedResults(childMd);
|
|
if (!childUnsurfaced) {
|
|
log.info?.("[memory-continuity] subagent_ended: no unsurfaced results in child state");
|
|
return;
|
|
}
|
|
|
|
// Also extract child's objective for context
|
|
const childObjective = extractSection(childMd, "Objective");
|
|
|
|
// Read parent's current state
|
|
const parentStatePath = resolveStatePath(parentWs);
|
|
if (!parentStatePath) return;
|
|
|
|
let parentMd = readFile(parentStatePath);
|
|
if (!parentMd) {
|
|
parentMd = STATE_TEMPLATE;
|
|
}
|
|
|
|
// Build the recovery note
|
|
const now = new Date().toISOString();
|
|
const recoveryNote = [
|
|
`[${now}] Subagent "${childAgentId}" completed.`,
|
|
childObjective ? ` Task: ${childObjective.split("\n")[0].slice(0, 120)}` : "",
|
|
` Result: ${childUnsurfaced.split("\n")[0].slice(0, 200)}`,
|
|
].filter(Boolean).join("\n");
|
|
|
|
// Merge into parent's Unsurfaced Results section
|
|
const existingUnsurfaced = extractSection(parentMd, "Unsurfaced Results");
|
|
const mergedUnsurfaced = isMeaningful(existingUnsurfaced)
|
|
? existingUnsurfaced + "\n" + recoveryNote
|
|
: recoveryNote;
|
|
|
|
// Token-aware truncation of merged results
|
|
if (estimateTokens(mergedUnsurfaced) > 500) {
|
|
// Keep only the most recent entries (last 500 tokens)
|
|
const lines = mergedUnsurfaced.split("\n");
|
|
let kept = [];
|
|
let tokens = 0;
|
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
const lineTokens = estimateTokens(lines[i]);
|
|
if (tokens + lineTokens > 500) break;
|
|
kept.unshift(lines[i]);
|
|
tokens += lineTokens;
|
|
}
|
|
const truncated = kept.join("\n");
|
|
parentMd = parentMd.replace(
|
|
/## Unsurfaced Results\n[\s\S]*?(?=\n## |\n$|$)/,
|
|
"## Unsurfaced Results\n" + truncated
|
|
);
|
|
} else {
|
|
parentMd = parentMd.replace(
|
|
/## Unsurfaced Results\n[\s\S]*?(?=\n## |\n$|$)/,
|
|
"## Unsurfaced Results\n" + mergedUnsurfaced
|
|
);
|
|
}
|
|
|
|
// Update the timestamp
|
|
parentMd = parentMd.replace(
|
|
/^> Last updated:.*$/m,
|
|
`> Last updated: ${now}`
|
|
);
|
|
|
|
writeFile(parentStatePath, parentMd);
|
|
log.info?.("[memory-continuity] Recovered unsurfaced results from subagent " + childAgentId);
|
|
} catch (err) {
|
|
log.warn?.("[memory-continuity] subagent_ended recovery failed (non-fatal): " + err.message);
|
|
}
|
|
}, { priority: 50 });
|
|
```
|
|
|
|
- [ ] **Step 2: Verify plugin loads**
|
|
|
|
```bash
|
|
cd /Users/taodeng/.openclaw/projects/memory-continuity
|
|
node -e "import('./index.js').then(m => console.log('OK:', typeof m.default))"
|
|
```
|
|
|
|
Expected: `OK: object`
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add index.js
|
|
git commit -m "feat(subagent): child-to-parent unsurfaced results recovery via subagent_ended"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: New Config Keys + Manifest Bump
|
|
|
|
**Files:**
|
|
- Modify: `openclaw.plugin.json`
|
|
- Modify: `package.json`
|
|
|
|
- [ ] **Step 1: Add subagent config keys to `openclaw.plugin.json`**
|
|
|
|
Bump version to `5.0.0`. Add two new config properties inside `configSchema.properties`:
|
|
|
|
```json
|
|
"subagentSeed": {
|
|
"type": "boolean",
|
|
"default": true,
|
|
"description": "Inject parent working state into subagent context at startup"
|
|
},
|
|
"subagentRecovery": {
|
|
"type": "boolean",
|
|
"default": true,
|
|
"description": "Recover unsurfaced results from completed subagents back to parent"
|
|
}
|
|
```
|
|
|
|
Add corresponding `uiHints`:
|
|
|
|
```json
|
|
"subagentSeed": {
|
|
"label": "Subagent seeding",
|
|
"help": "Give subagents parent context so they understand the broader task"
|
|
},
|
|
"subagentRecovery": {
|
|
"label": "Subagent recovery",
|
|
"help": "Pull completed subagent results back into parent state"
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Bump `package.json` to 5.0.0**
|
|
|
|
Change `"version": "4.0.0"` to `"version": "5.0.0"`.
|
|
|
|
- [ ] **Step 3: Verify JSON validity**
|
|
|
|
```bash
|
|
cd /Users/taodeng/.openclaw/projects/memory-continuity
|
|
python3 -m json.tool openclaw.plugin.json > /dev/null && echo "JSON valid"
|
|
node -e "import('./index.js').then(m => console.log('OK:', typeof m.default))"
|
|
```
|
|
|
|
Expected:
|
|
```
|
|
JSON valid
|
|
OK: object
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add openclaw.plugin.json package.json
|
|
git commit -m "feat(subagent): add subagentSeed and subagentRecovery config keys, bump to v5.0.0"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Add `/mc subagents` Command
|
|
|
|
**Files:**
|
|
- Modify: `mc-plugin/index.js`
|
|
- Modify: `mc-plugin/package.json`
|
|
- Modify: `mc-plugin/openclaw.plugin.json`
|
|
|
|
Add a new `/mc subagents` command that shows subagent state across workspaces.
|
|
|
|
- [ ] **Step 1: Add `cmdSubagents` function to mc-plugin**
|
|
|
|
In `mc-plugin/index.js`, before the `cmdHelp` function, add:
|
|
|
|
```javascript
|
|
function cmdSubagents(args) {
|
|
const agents = discoverAgents();
|
|
if (agents.length === 0) return "No agents with memory found.";
|
|
|
|
let out = "Subagent State Overview\n";
|
|
out += "═══════════════════════\n\n";
|
|
|
|
for (const { name, memDir } of agents) {
|
|
const statePath = path.join(memDir, "CURRENT_STATE.md");
|
|
const content = readFile(statePath);
|
|
if (!content) {
|
|
out += `[${name}] No state file\n\n`;
|
|
continue;
|
|
}
|
|
|
|
const objMatch = content.match(/## Objective\n([\s\S]*?)(?=\n## )/);
|
|
const unsurfMatch = content.match(/## Unsurfaced Results\n([\s\S]*?)(?=\n## |$)/);
|
|
const updatedMatch = content.match(/^> Last updated:\s*(.+)$/m);
|
|
|
|
const objective = objMatch?.[1]?.trim() || "None";
|
|
const unsurfaced = unsurfMatch?.[1]?.trim() || "None";
|
|
const updated = updatedMatch?.[1]?.trim() || "unknown";
|
|
|
|
out += `[${name}] Updated: ${updated}\n`;
|
|
out += ` Objective: ${truncate(objective.split("\n")[0], 80)}\n`;
|
|
if (unsurfaced !== "None") {
|
|
out += ` Unsurfaced: ${truncate(unsurfaced.split("\n")[0], 80)}\n`;
|
|
}
|
|
out += "\n";
|
|
}
|
|
|
|
return out.trimEnd();
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Register the command in the switch statement**
|
|
|
|
Find the `switch (subcmd)` block and add a new case before the `help` case:
|
|
|
|
```javascript
|
|
case "subagents": text = cmdSubagents(subargs || null); break;
|
|
```
|
|
|
|
- [ ] **Step 3: Update help text**
|
|
|
|
In `cmdHelp`, add:
|
|
|
|
```
|
|
/mc subagents Subagent state overview across workspaces
|
|
```
|
|
|
|
- [ ] **Step 4: Bump mc-plugin versions to 2.1.0**
|
|
|
|
In `mc-plugin/package.json`, change version to `"2.1.0"`.
|
|
In `mc-plugin/openclaw.plugin.json`, change version to `"2.1.0"`.
|
|
|
|
- [ ] **Step 5: Verify mc-plugin loads**
|
|
|
|
```bash
|
|
cd /Users/taodeng/.openclaw/projects/memory-continuity/mc-plugin
|
|
node -e "import('./index.js').then(m => console.log('OK:', typeof m.default))"
|
|
```
|
|
|
|
Expected: `OK: function`
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add mc-plugin/
|
|
git commit -m "feat(subagent): add /mc subagents command for cross-workspace state view"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Version Verify + Git + Deploy
|
|
|
|
**Files:** All modified files
|
|
|
|
- [ ] **Step 1: Verify all versions**
|
|
|
|
```bash
|
|
grep '"version"' openclaw.plugin.json package.json mc-plugin/openclaw.plugin.json mc-plugin/package.json
|
|
```
|
|
|
|
Expected:
|
|
```
|
|
openclaw.plugin.json: "version": "5.0.0"
|
|
package.json: "version": "5.0.0"
|
|
mc-plugin/openclaw.plugin.json: "version": "2.1.0"
|
|
mc-plugin/package.json: "version": "2.1.0"
|
|
```
|
|
|
|
- [ ] **Step 2: Final commit + tag + push**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "release: v5.0.0 — conservative subagent support (parent seed + child recovery)"
|
|
git tag v5.0.0
|
|
git push origin main --tags
|
|
```
|
|
|
|
- [ ] **Step 3: Deploy to Cloud**
|
|
|
|
```bash
|
|
scp index.js package.json openclaw.plugin.json opc@152.67.121.35:~/.openclaw/extensions/memory-continuity/
|
|
scp mc-plugin/index.js mc-plugin/package.json mc-plugin/openclaw.plugin.json opc@152.67.121.35:~/.openclaw/extensions/mc/
|
|
ssh opc@152.67.121.35 "pkill -f 'openclaw.*gateway' 2>/dev/null; sleep 2; nohup openclaw gateway start > /dev/null 2>&1 &"
|
|
```
|
|
|
|
- [ ] **Step 4: Deploy to PI**
|
|
|
|
```bash
|
|
scp index.js package.json openclaw.plugin.json administrator@172.16.2.232:~/.openclaw/extensions/memory-continuity/
|
|
scp mc-plugin/index.js mc-plugin/package.json mc-plugin/openclaw.plugin.json administrator@172.16.2.232:~/.openclaw/extensions/mc/
|
|
ssh administrator@172.16.2.232 "pkill -f 'openclaw.*gateway' 2>/dev/null; sleep 2; nohup openclaw gateway start > /dev/null 2>&1 &"
|
|
```
|
|
|
|
- [ ] **Step 5: Deploy to Mac**
|
|
|
|
```bash
|
|
cp index.js package.json openclaw.plugin.json ~/.openclaw/extensions/memory-continuity/
|
|
pkill -f 'openclaw.*gateway' 2>/dev/null; sleep 2; nohup openclaw gateway start > /dev/null 2>&1 &
|
|
```
|
|
|
|
- [ ] **Step 6: Verify all 3 servers**
|
|
|
|
```bash
|
|
grep version ~/.openclaw/extensions/memory-continuity/package.json
|
|
ssh opc@152.67.121.35 "grep version ~/.openclaw/extensions/memory-continuity/package.json"
|
|
ssh administrator@172.16.2.232 "grep version ~/.openclaw/extensions/memory-continuity/package.json"
|
|
```
|
|
|
|
Expected: all show `"version": "5.0.0"`.
|