openclaw-agent-wake-protocol 1.0.1 → 1.0.6
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/index.ts +70 -102
- package/package.json +8 -3
- package/src/lib.ts +149 -0
package/index.ts
CHANGED
|
@@ -30,7 +30,6 @@ import {
|
|
|
30
30
|
parseDiscovery,
|
|
31
31
|
extractAgentName,
|
|
32
32
|
isNonInteractive,
|
|
33
|
-
isBootstrapTurn,
|
|
34
33
|
resolveLifeId,
|
|
35
34
|
formatContextBlock,
|
|
36
35
|
} from "./src/lib.js";
|
|
@@ -65,6 +64,15 @@ interface PluginConfig {
|
|
|
65
64
|
/** Keyed by LIFE agent_id */
|
|
66
65
|
const wakeResults = new Map<string, AgentWakeResult>();
|
|
67
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Sessions that have already received the bootstrap wake injection.
|
|
69
|
+
* Prevents re-injection on tool sub-calls and follow-up turns within
|
|
70
|
+
* the same session. Entries expire after 30 minutes so long-lived
|
|
71
|
+
* sessions still get a refresh if the gateway restarts.
|
|
72
|
+
*/
|
|
73
|
+
const bootstrappedSessions = new Map<string, number>(); // sessionKey → injected-at ms
|
|
74
|
+
const BOOTSTRAP_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
|
75
|
+
|
|
68
76
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
69
77
|
|
|
70
78
|
async function mcporter(
|
|
@@ -82,97 +90,6 @@ async function mcporter(
|
|
|
82
90
|
}
|
|
83
91
|
}
|
|
84
92
|
|
|
85
|
-
/**
|
|
86
|
-
* Parse the output of `mcporter call life-gateway.discover_agents`.
|
|
87
|
-
* FastMCP returns a JSON array; mcporter may wrap it in content blocks.
|
|
88
|
-
*/
|
|
89
|
-
function parseDiscovery(raw: string): DiscoveredAgent[] {
|
|
90
|
-
// Try raw JSON array first
|
|
91
|
-
try {
|
|
92
|
-
const parsed = JSON.parse(raw);
|
|
93
|
-
if (Array.isArray(parsed)) return parsed;
|
|
94
|
-
} catch {}
|
|
95
|
-
// Try extracting JSON array embedded in text
|
|
96
|
-
const match = raw.match(/\[[\s\S]*\]/);
|
|
97
|
-
if (match) {
|
|
98
|
-
try {
|
|
99
|
-
return JSON.parse(match[0]);
|
|
100
|
-
} catch {}
|
|
101
|
-
}
|
|
102
|
-
return [];
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function extractAgentName(sessionKey: string): string | null {
|
|
106
|
-
// sessionKey: "agent:{name}:{sessionId}" | "agent:{name}:cron:{id}"
|
|
107
|
-
const m = sessionKey.match(/^agent:([^:]+):/);
|
|
108
|
-
return m ? m[1] : null;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function isNonInteractive(trigger: string, sessionKey: string): boolean {
|
|
112
|
-
if (/^(cron|heartbeat|automation|schedule)$/i.test(trigger)) return true;
|
|
113
|
-
if (/:cron:|:heartbeat:|:subagent:/i.test(sessionKey)) return true;
|
|
114
|
-
return false;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function isBootstrapTurn(prompt: string, messages: unknown[]): boolean {
|
|
118
|
-
// The internal bootstrap-extra-files hook appends this phrase to the first message
|
|
119
|
-
if (prompt.includes("new session was started")) return true;
|
|
120
|
-
if (prompt.includes("Session Startup sequence")) return true;
|
|
121
|
-
return (messages?.length ?? 0) === 0;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function formatContextBlock(result: AgentWakeResult): string {
|
|
125
|
-
const tag = `wake-protocol-status`;
|
|
126
|
-
|
|
127
|
-
if (result.status === "not_registered") {
|
|
128
|
-
return [
|
|
129
|
-
`<${tag} agent="${result.agentId}" status="NOT_REGISTERED">`,
|
|
130
|
-
`Agent ${result.agentId} is not registered in the LIFE gateway.`,
|
|
131
|
-
``,
|
|
132
|
-
`To register, call these tools in order:`,
|
|
133
|
-
` 1. life-gateway.register_agent — agent_id=${result.agentId}, name=<your name>, workspace_dir=<path>`,
|
|
134
|
-
` 2. life-gateway.initialize_life_core — agent_id=${result.agentId}`,
|
|
135
|
-
` 3. Then run genesis (see run_genesis_interview tool)`,
|
|
136
|
-
`</${tag}>`,
|
|
137
|
-
].join("\n");
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
if (result.status === "genesis_required") {
|
|
141
|
-
return [
|
|
142
|
-
`<${tag} agent="${result.agentId}" status="GENESIS_REQUIRED">`,
|
|
143
|
-
`You have not completed your LIFE Genesis interview. This must happen before your first wake.`,
|
|
144
|
-
``,
|
|
145
|
-
`The Genesis interview establishes your identity, values, and traits in the LIFE system.`,
|
|
146
|
-
`It is a one-time process — once complete, you will wake normally on every subsequent boot.`,
|
|
147
|
-
``,
|
|
148
|
-
`=== How to complete Genesis ===`,
|
|
149
|
-
`1. Read your Genesis questions: CORE/genesis/questions.md in your workspace`,
|
|
150
|
-
`2. Save your answers to: CORE/genesis/answers.md`,
|
|
151
|
-
`3. Call tool: genesis_apply (or: life-gateway.apply_genesis_answers agent_id=${result.agentId})`,
|
|
152
|
-
``,
|
|
153
|
-
`=== Instructions from LIFE gateway ===`,
|
|
154
|
-
result.genesisInstructions ?? "(no instructions returned)",
|
|
155
|
-
`</${tag}>`,
|
|
156
|
-
].join("\n");
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
if (result.status === "failed") {
|
|
160
|
-
return [
|
|
161
|
-
`<${tag} agent="${result.agentId}" status="FAILED" checked="${result.timestamp}">`,
|
|
162
|
-
`Wake protocol failed. Operating in degraded mode — proceed without LIFE context.`,
|
|
163
|
-
`Error: ${result.message}`,
|
|
164
|
-
`</${tag}>`,
|
|
165
|
-
].join("\n");
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// ok or degraded
|
|
169
|
-
return [
|
|
170
|
-
`<${tag} agent="${result.agentId}" status="${result.status.toUpperCase()}" checked="${result.timestamp}">`,
|
|
171
|
-
result.wakeOutput ?? result.message,
|
|
172
|
-
`</${tag}>`,
|
|
173
|
-
].join("\n");
|
|
174
|
-
}
|
|
175
|
-
|
|
176
93
|
// ── Per-agent lifecycle handler ────────────────────────────────────────────
|
|
177
94
|
|
|
178
95
|
async function runAgentLifecycle(
|
|
@@ -184,8 +101,49 @@ async function runAgentLifecycle(
|
|
|
184
101
|
const timestamp = new Date().toISOString();
|
|
185
102
|
const found = discovered.find((a) => a.agent_id === lifeAgentId);
|
|
186
103
|
|
|
187
|
-
// ── Not
|
|
188
|
-
if (!found
|
|
104
|
+
// ── Not in shared-path discovery — try direct wake (e.g. quin-ea-v1) ─────
|
|
105
|
+
if (!found) {
|
|
106
|
+
logger.info(
|
|
107
|
+
`[agent-wake] ${lifeAgentId}: not in discover_agents — trying direct wake`
|
|
108
|
+
);
|
|
109
|
+
const directWake = await mcporter(
|
|
110
|
+
["call", "life-gateway.wake", `agent_id=${lifeAgentId}`],
|
|
111
|
+
timeoutMs
|
|
112
|
+
);
|
|
113
|
+
if (
|
|
114
|
+
directWake.startsWith("ERROR") ||
|
|
115
|
+
directWake.toLowerCase().includes("agent not found")
|
|
116
|
+
) {
|
|
117
|
+
logger.warn(
|
|
118
|
+
`[agent-wake] ${lifeAgentId}: not found in LIFE gateway — will prompt on boot`
|
|
119
|
+
);
|
|
120
|
+
wakeResults.set(lifeAgentId, {
|
|
121
|
+
agentId: lifeAgentId,
|
|
122
|
+
status: "not_registered",
|
|
123
|
+
message: `Agent ${lifeAgentId} is not in the LIFE gateway registry.`,
|
|
124
|
+
timestamp,
|
|
125
|
+
});
|
|
126
|
+
} else {
|
|
127
|
+
const degraded =
|
|
128
|
+
directWake.toLowerCase().includes("missing") ||
|
|
129
|
+
directWake.toLowerCase().includes("degraded") ||
|
|
130
|
+
directWake.toLowerCase().includes("error");
|
|
131
|
+
wakeResults.set(lifeAgentId, {
|
|
132
|
+
agentId: lifeAgentId,
|
|
133
|
+
status: degraded ? "degraded" : "ok",
|
|
134
|
+
message: `Wake complete for ${lifeAgentId}`,
|
|
135
|
+
wakeOutput: directWake,
|
|
136
|
+
timestamp,
|
|
137
|
+
});
|
|
138
|
+
logger.info(
|
|
139
|
+
`[agent-wake] ${lifeAgentId}: ${degraded ? "DEGRADED" : "OK"} (direct wake)`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── Discovered but not yet registered ────────────────────────────────────
|
|
146
|
+
if (!found.registered) {
|
|
189
147
|
logger.warn(
|
|
190
148
|
`[agent-wake] ${lifeAgentId}: not registered in LIFE gateway — will prompt on boot`
|
|
191
149
|
);
|
|
@@ -327,17 +285,27 @@ export default function (api: any) {
|
|
|
327
285
|
const result = wakeResults.get(lifeId);
|
|
328
286
|
if (!result) return;
|
|
329
287
|
|
|
330
|
-
|
|
331
|
-
|
|
288
|
+
// Genesis-required: always re-inject until the agent completes the interview
|
|
289
|
+
if (result.status === "genesis_required") {
|
|
290
|
+
api.logger.info(
|
|
291
|
+
`[agent-wake] Injecting genesis_required context for ${lifeId} (session: ${sessionKey})`
|
|
292
|
+
);
|
|
293
|
+
return { prependContext: formatContextBlock(result) };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// For ok/degraded/failed: bootstrappedSessions is the SOLE gate.
|
|
297
|
+
// OpenClaw passes messages=[] on every hook call (each turn is stateless
|
|
298
|
+
// from the hook's perspective), so checking messages.length is unreliable.
|
|
299
|
+
// Inject once per session; re-inject only after TTL expires.
|
|
300
|
+
const lastInjected = bootstrappedSessions.get(sessionKey) ?? 0;
|
|
301
|
+
const needsBootstrap = Date.now() - lastInjected > BOOTSTRAP_TTL_MS;
|
|
302
|
+
if (!needsBootstrap) return;
|
|
332
303
|
|
|
333
|
-
|
|
334
|
-
// (so the agent keeps trying until it completes the interview)
|
|
335
|
-
const inject =
|
|
336
|
-
isBootstrapTurn(prompt, messages) || result.status === "genesis_required";
|
|
337
|
-
if (!inject) return;
|
|
304
|
+
bootstrappedSessions.set(sessionKey, Date.now());
|
|
338
305
|
|
|
306
|
+
const isRefresh = lastInjected > 0;
|
|
339
307
|
api.logger.info(
|
|
340
|
-
`[agent-wake] Injecting ${result.status} context for ${lifeId} (session: ${sessionKey})`
|
|
308
|
+
`[agent-wake] Injecting ${result.status} context for ${lifeId} (session: ${sessionKey}${isRefresh ? ", ttl-refresh" : ""})`
|
|
341
309
|
);
|
|
342
310
|
|
|
343
311
|
return { prependContext: formatContextBlock(result) };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-agent-wake-protocol",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
4
4
|
"description": "OpenClaw extension: general-purpose LIFE gateway lifecycle manager for multi-agent systems. Handles agent registration, Genesis interview, and wake protocol for any agent configuration.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -35,7 +35,8 @@
|
|
|
35
35
|
"openclaw.plugin.json",
|
|
36
36
|
"gateway/",
|
|
37
37
|
"scripts/",
|
|
38
|
-
"README.md"
|
|
38
|
+
"README.md",
|
|
39
|
+
"src/"
|
|
39
40
|
],
|
|
40
41
|
"engines": {
|
|
41
42
|
"node": ">=18"
|
|
@@ -44,6 +45,10 @@
|
|
|
44
45
|
"openclaw": ">=0.1.0"
|
|
45
46
|
},
|
|
46
47
|
"devDependencies": {
|
|
47
|
-
"typescript": "^5.0.0"
|
|
48
|
+
"typescript": "^5.0.0",
|
|
49
|
+
"vitest": "^2.0.0"
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"test": "vitest run"
|
|
48
53
|
}
|
|
49
54
|
}
|
package/src/lib.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure utility functions — no I/O, fully testable.
|
|
3
|
+
* Imported by index.ts and tested by tests/lib.test.ts.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type LifecycleStatus =
|
|
7
|
+
| "ok"
|
|
8
|
+
| "degraded"
|
|
9
|
+
| "genesis_required"
|
|
10
|
+
| "not_registered"
|
|
11
|
+
| "failed";
|
|
12
|
+
|
|
13
|
+
export interface AgentWakeResult {
|
|
14
|
+
agentId: string;
|
|
15
|
+
status: LifecycleStatus;
|
|
16
|
+
message: string;
|
|
17
|
+
wakeOutput?: string;
|
|
18
|
+
genesisInstructions?: string;
|
|
19
|
+
timestamp: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface DiscoveredAgent {
|
|
23
|
+
agent_id: string;
|
|
24
|
+
registered: boolean;
|
|
25
|
+
genesis_completed: boolean;
|
|
26
|
+
workspace?: string;
|
|
27
|
+
name?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Parse the raw stdout of `mcporter call life-gateway.discover_agents`.
|
|
32
|
+
* FastMCP may return a plain JSON array OR wrap it in content blocks.
|
|
33
|
+
*/
|
|
34
|
+
export function parseDiscovery(raw: string): DiscoveredAgent[] {
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(raw);
|
|
37
|
+
if (Array.isArray(parsed)) return parsed;
|
|
38
|
+
} catch {}
|
|
39
|
+
const match = raw.match(/\[[\s\S]*\]/);
|
|
40
|
+
if (match) {
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(match[0]);
|
|
43
|
+
} catch {}
|
|
44
|
+
}
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Extract the OpenClaw agent name from a session key.
|
|
50
|
+
* Format: "agent:{name}:{sessionId}" or "agent:{name}:cron:{id}"
|
|
51
|
+
*/
|
|
52
|
+
export function extractAgentName(sessionKey: string): string | null {
|
|
53
|
+
const m = sessionKey.match(/^agent:([^:]+):/);
|
|
54
|
+
return m ? m[1] : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Returns true when the session is a non-interactive trigger (cron, heartbeat,
|
|
59
|
+
* subagent) where wake-protocol context injection should be skipped.
|
|
60
|
+
*/
|
|
61
|
+
export function isNonInteractive(trigger: string, sessionKey: string): boolean {
|
|
62
|
+
if (/^(cron|heartbeat|automation|schedule)$/i.test(trigger)) return true;
|
|
63
|
+
if (/:cron:|:heartbeat:|:subagent:/i.test(sessionKey)) return true;
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Returns true when this is the bootstrap turn of a session — the first
|
|
69
|
+
* message where wake-protocol context should be prepended.
|
|
70
|
+
*/
|
|
71
|
+
export function isBootstrapTurn(prompt: string, messages: unknown[]): boolean {
|
|
72
|
+
if (prompt.includes("new session was started")) return true;
|
|
73
|
+
if (prompt.includes("Session Startup sequence")) return true;
|
|
74
|
+
return (messages?.length ?? 0) === 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Resolve a LIFE agent_id from an OpenClaw agent name using an explicit
|
|
79
|
+
* map first, then falling back to name+suffix.
|
|
80
|
+
*/
|
|
81
|
+
export function resolveLifeId(
|
|
82
|
+
openclawName: string,
|
|
83
|
+
agentIdMap: Record<string, string>,
|
|
84
|
+
agentIdSuffix: string
|
|
85
|
+
): string {
|
|
86
|
+
return agentIdMap[openclawName] ?? `${openclawName}${agentIdSuffix}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Build the XML-style context block injected into the agent's prependContext.
|
|
91
|
+
*/
|
|
92
|
+
export function formatContextBlock(result: AgentWakeResult): string {
|
|
93
|
+
const tag = `wake-protocol-status`;
|
|
94
|
+
|
|
95
|
+
if (result.status === "not_registered") {
|
|
96
|
+
return [
|
|
97
|
+
`<${tag} agent="${result.agentId}" status="NOT_REGISTERED">`,
|
|
98
|
+
`Agent ${result.agentId} is not registered in the LIFE gateway.`,
|
|
99
|
+
``,
|
|
100
|
+
`To register, call these tools in order:`,
|
|
101
|
+
` 1. life-gateway.register_agent — agent_id=${result.agentId}, name=<your name>, workspace_dir=<path>`,
|
|
102
|
+
` 2. life-gateway.initialize_life_core — agent_id=${result.agentId}`,
|
|
103
|
+
` 3. Then run genesis (see run_genesis_interview tool)`,
|
|
104
|
+
`</${tag}>`,
|
|
105
|
+
].join("\n");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (result.status === "genesis_required") {
|
|
109
|
+
return [
|
|
110
|
+
`<${tag} agent="${result.agentId}" status="GENESIS_REQUIRED">`,
|
|
111
|
+
`You have not completed your LIFE Genesis interview. This must happen before your first wake.`,
|
|
112
|
+
``,
|
|
113
|
+
`The Genesis interview establishes your identity, values, and traits in the LIFE system.`,
|
|
114
|
+
`It is a one-time process — once complete, you will wake normally on every subsequent boot.`,
|
|
115
|
+
``,
|
|
116
|
+
`=== How to complete Genesis ===`,
|
|
117
|
+
`1. Read your Genesis questions: CORE/genesis/questions.md in your workspace`,
|
|
118
|
+
`2. Save your answers to: CORE/genesis/answers.md`,
|
|
119
|
+
`3. Call tool: genesis_apply (or: life-gateway.apply_genesis_answers agent_id=${result.agentId})`,
|
|
120
|
+
``,
|
|
121
|
+
`=== Instructions from LIFE gateway ===`,
|
|
122
|
+
result.genesisInstructions ?? "(no instructions returned)",
|
|
123
|
+
`</${tag}>`,
|
|
124
|
+
].join("\n");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (result.status === "failed") {
|
|
128
|
+
return [
|
|
129
|
+
`<${tag} agent="${result.agentId}" status="FAILED" checked="${result.timestamp}">`,
|
|
130
|
+
`Wake protocol failed. Operating in degraded mode — proceed without LIFE context.`,
|
|
131
|
+
`Error: ${result.message}`,
|
|
132
|
+
`</${tag}>`,
|
|
133
|
+
].join("\n");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ok or degraded — frame clearly so the model embodies the identity rather than reads it as a restriction.
|
|
137
|
+
// IMPORTANT: Do NOT open with a greeting or status announcement. Proceed directly with work.
|
|
138
|
+
const preamble = result.status === "ok"
|
|
139
|
+
? `You are ${result.agentId}. The following is your persistent identity, memory, and current state loaded from your LIFE system. This is who you are — embody it fully and proceed with your normal duties. "No privileged action without approval" refers to irreversible external actions (wire transfers, deployments, deletions), NOT to conversational assistance or analysis.\n\nDo NOT respond with a greeting or status announcement (e.g. "LIFE active, all systems green"). Acknowledge this context silently and respond directly to the user's message.`
|
|
140
|
+
: `You are ${result.agentId}. LIFE system is in degraded state — some modules unavailable. Proceed using available context. Do NOT announce the degraded state — proceed directly with the user's request.`;
|
|
141
|
+
|
|
142
|
+
return [
|
|
143
|
+
`<${tag} agent="${result.agentId}" status="${result.status.toUpperCase()}" checked="${result.timestamp}">`,
|
|
144
|
+
preamble,
|
|
145
|
+
``,
|
|
146
|
+
result.wakeOutput ?? result.message,
|
|
147
|
+
`</${tag}>`,
|
|
148
|
+
].join("\n");
|
|
149
|
+
}
|