openclaw-agent-wake-protocol 1.0.0 → 1.0.5

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 CHANGED
@@ -23,38 +23,21 @@
23
23
 
24
24
  import { exec } from "node:child_process";
25
25
  import { promisify } from "node:util";
26
+ import {
27
+ type LifecycleStatus,
28
+ type AgentWakeResult,
29
+ type DiscoveredAgent,
30
+ parseDiscovery,
31
+ extractAgentName,
32
+ isNonInteractive,
33
+ resolveLifeId,
34
+ formatContextBlock,
35
+ } from "./src/lib.js";
26
36
 
27
37
  const execAsync = promisify(exec);
28
38
 
29
39
  // ── Types ──────────────────────────────────────────────────────────────────
30
40
 
31
- type LifecycleStatus =
32
- | "ok"
33
- | "degraded"
34
- | "genesis_required"
35
- | "not_registered"
36
- | "failed";
37
-
38
- interface AgentWakeResult {
39
- agentId: string;
40
- status: LifecycleStatus;
41
- /** Human-readable summary injected into context */
42
- message: string;
43
- /** Raw output from wake command (status=ok/degraded) */
44
- wakeOutput?: string;
45
- /** Instructions returned by run_genesis_interview (status=genesis_required) */
46
- genesisInstructions?: string;
47
- timestamp: string;
48
- }
49
-
50
- interface DiscoveredAgent {
51
- agent_id: string;
52
- registered: boolean;
53
- genesis_completed: boolean;
54
- workspace?: string;
55
- name?: string;
56
- }
57
-
58
41
  interface PluginConfig {
59
42
  /**
60
43
  * Map of OpenClaw agent name → LIFE agent_id.
@@ -81,6 +64,15 @@ interface PluginConfig {
81
64
  /** Keyed by LIFE agent_id */
82
65
  const wakeResults = new Map<string, AgentWakeResult>();
83
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
+
84
76
  // ── Helpers ────────────────────────────────────────────────────────────────
85
77
 
86
78
  async function mcporter(
@@ -98,97 +90,6 @@ async function mcporter(
98
90
  }
99
91
  }
100
92
 
101
- /**
102
- * Parse the output of `mcporter call life-gateway.discover_agents`.
103
- * FastMCP returns a JSON array; mcporter may wrap it in content blocks.
104
- */
105
- function parseDiscovery(raw: string): DiscoveredAgent[] {
106
- // Try raw JSON array first
107
- try {
108
- const parsed = JSON.parse(raw);
109
- if (Array.isArray(parsed)) return parsed;
110
- } catch {}
111
- // Try extracting JSON array embedded in text
112
- const match = raw.match(/\[[\s\S]*\]/);
113
- if (match) {
114
- try {
115
- return JSON.parse(match[0]);
116
- } catch {}
117
- }
118
- return [];
119
- }
120
-
121
- function extractAgentName(sessionKey: string): string | null {
122
- // sessionKey: "agent:{name}:{sessionId}" | "agent:{name}:cron:{id}"
123
- const m = sessionKey.match(/^agent:([^:]+):/);
124
- return m ? m[1] : null;
125
- }
126
-
127
- function isNonInteractive(trigger: string, sessionKey: string): boolean {
128
- if (/^(cron|heartbeat|automation|schedule)$/i.test(trigger)) return true;
129
- if (/:cron:|:heartbeat:|:subagent:/i.test(sessionKey)) return true;
130
- return false;
131
- }
132
-
133
- function isBootstrapTurn(prompt: string, messages: unknown[]): boolean {
134
- // The internal bootstrap-extra-files hook appends this phrase to the first message
135
- if (prompt.includes("new session was started")) return true;
136
- if (prompt.includes("Session Startup sequence")) return true;
137
- return (messages?.length ?? 0) === 0;
138
- }
139
-
140
- function formatContextBlock(result: AgentWakeResult): string {
141
- const tag = `wake-protocol-status`;
142
-
143
- if (result.status === "not_registered") {
144
- return [
145
- `<${tag} agent="${result.agentId}" status="NOT_REGISTERED">`,
146
- `Agent ${result.agentId} is not registered in the LIFE gateway.`,
147
- ``,
148
- `To register, call these tools in order:`,
149
- ` 1. life-gateway.register_agent — agent_id=${result.agentId}, name=<your name>, workspace_dir=<path>`,
150
- ` 2. life-gateway.initialize_life_core — agent_id=${result.agentId}`,
151
- ` 3. Then run genesis (see run_genesis_interview tool)`,
152
- `</${tag}>`,
153
- ].join("\n");
154
- }
155
-
156
- if (result.status === "genesis_required") {
157
- return [
158
- `<${tag} agent="${result.agentId}" status="GENESIS_REQUIRED">`,
159
- `You have not completed your LIFE Genesis interview. This must happen before your first wake.`,
160
- ``,
161
- `The Genesis interview establishes your identity, values, and traits in the LIFE system.`,
162
- `It is a one-time process — once complete, you will wake normally on every subsequent boot.`,
163
- ``,
164
- `=== How to complete Genesis ===`,
165
- `1. Read your Genesis questions: CORE/genesis/questions.md in your workspace`,
166
- `2. Save your answers to: CORE/genesis/answers.md`,
167
- `3. Call tool: genesis_apply (or: life-gateway.apply_genesis_answers agent_id=${result.agentId})`,
168
- ``,
169
- `=== Instructions from LIFE gateway ===`,
170
- result.genesisInstructions ?? "(no instructions returned)",
171
- `</${tag}>`,
172
- ].join("\n");
173
- }
174
-
175
- if (result.status === "failed") {
176
- return [
177
- `<${tag} agent="${result.agentId}" status="FAILED" checked="${result.timestamp}">`,
178
- `Wake protocol failed. Operating in degraded mode — proceed without LIFE context.`,
179
- `Error: ${result.message}`,
180
- `</${tag}>`,
181
- ].join("\n");
182
- }
183
-
184
- // ok or degraded
185
- return [
186
- `<${tag} agent="${result.agentId}" status="${result.status.toUpperCase()}" checked="${result.timestamp}">`,
187
- result.wakeOutput ?? result.message,
188
- `</${tag}>`,
189
- ].join("\n");
190
- }
191
-
192
93
  // ── Per-agent lifecycle handler ────────────────────────────────────────────
193
94
 
194
95
  async function runAgentLifecycle(
@@ -200,8 +101,49 @@ async function runAgentLifecycle(
200
101
  const timestamp = new Date().toISOString();
201
102
  const found = discovered.find((a) => a.agent_id === lifeAgentId);
202
103
 
203
- // ── Not registered ────────────────────────────────────────────────────────
204
- if (!found || !found.registered) {
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) {
205
147
  logger.warn(
206
148
  `[agent-wake] ${lifeAgentId}: not registered in LIFE gateway — will prompt on boot`
207
149
  );
@@ -277,7 +219,7 @@ export default function (api: any) {
277
219
 
278
220
  // ── Service: discover and wake all agents at gateway startup ──────────────
279
221
  api.registerService({
280
- id: "agent-wake-protocol",
222
+ id: "openclaw-agent-wake-protocol",
281
223
 
282
224
  async start() {
283
225
  api.logger.info(
@@ -343,17 +285,27 @@ export default function (api: any) {
343
285
  const result = wakeResults.get(lifeId);
344
286
  if (!result) return;
345
287
 
346
- const prompt: string = event?.prompt ?? "";
347
- const messages: unknown[] = event?.messages ?? [];
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;
348
303
 
349
- // Always inject on bootstrap; also re-inject on every turn if genesis still pending
350
- // (so the agent keeps trying until it completes the interview)
351
- const inject =
352
- isBootstrapTurn(prompt, messages) || result.status === "genesis_required";
353
- if (!inject) return;
304
+ bootstrappedSessions.set(sessionKey, Date.now());
354
305
 
306
+ const isRefresh = lastInjected > 0;
355
307
  api.logger.info(
356
- `[agent-wake] Injecting ${result.status} context for ${lifeId} (session: ${sessionKey})`
308
+ `[agent-wake] Injecting ${result.status} context for ${lifeId} (session: ${sessionKey}${isRefresh ? ", ttl-refresh" : ""})`
357
309
  );
358
310
 
359
311
  return { prependContext: formatContextBlock(result) };
@@ -1,5 +1,5 @@
1
1
  {
2
- "id": "agent-wake-protocol",
2
+ "id": "openclaw-agent-wake-protocol",
3
3
  "name": "Agent Wake Protocol",
4
4
  "description": "General-purpose LIFE gateway lifecycle manager for OpenClaw multi-agent systems. Handles agent registration, Genesis interview, and wake protocol for any agent configuration.",
5
5
  "configSchema": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-agent-wake-protocol",
3
- "version": "1.0.0",
3
+ "version": "1.0.5",
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,148 @@
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
+ const preamble = result.status === "ok"
138
+ ? `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.`
139
+ : `You are ${result.agentId}. LIFE system is in degraded state — some modules unavailable. Proceed using available context.`;
140
+
141
+ return [
142
+ `<${tag} agent="${result.agentId}" status="${result.status.toUpperCase()}" checked="${result.timestamp}">`,
143
+ preamble,
144
+ ``,
145
+ result.wakeOutput ?? result.message,
146
+ `</${tag}>`,
147
+ ].join("\n");
148
+ }