@agentmessier/openclaw-agent-messier 0.3.7 → 0.3.9

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
@@ -3,6 +3,7 @@ import { memberTools, venuesTool, agentIdOf, identityOf, venueUrl, type PluginCf
3
3
  import { defaultVenueTools, defaultRealtimeVenue, joinVenue } from "./src/generate.js";
4
4
  import { startObserveWatcher } from "./src/watcher.js";
5
5
  import { session } from "./src/state.js";
6
+ import { runAutoplayTurn, STRICT_JSON_DIRECTIVE } from "./src/decide.js";
6
7
 
7
8
  /** A lobby row is "ours" if it references our agentId anywhere (soccer puts it in
8
9
  * sides.home/away; a generic venue may shape it differently). Deep, shape-blind. */
@@ -83,17 +84,43 @@ export default function register(api: OpenClawPluginApi) {
83
84
  ctx.logger.warn(`[${label}] no sessionKey configured; cannot deliver move prompts.`);
84
85
  return;
85
86
  }
87
+ // Option A (docs/design/agent-bridge-plugin.md §2): force ONE agent
88
+ // turn per situation and PARSE its JSON reply — no longer wait for the
89
+ // agent to proactively call soccer_play (that reliance caused m171's
90
+ // 2-decisions-in-157s). soccer_play stays registered for interactive
91
+ // chat play; only AUTOPLAY changes to this server-driven loop.
92
+ //
86
93
  // Fresh session per move: each prompt is a complete snapshot, so the
87
94
  // agent needs no history — keeps context from overflowing.
88
95
  const turn = move++;
89
96
  const idempotencyKey = `agentnet:${venue.id}:${seat.id}:${agentId}:${turn}`;
90
- const { runId } = await api.runtime.subagent.run({
97
+ // Mark when this prompt was handed to the agent: x-agent-decision-ms is
98
+ // the prompt→reply latency measured inside runAutoplayTurn.
99
+ session.promptDeliveredAt = Date.now();
100
+ const result = await runAutoplayTurn({
101
+ runtime: api.runtime,
91
102
  sessionKey: `${sessionKey}:${turn}`,
92
- message: msg,
93
- deliver: false,
94
103
  idempotencyKey,
104
+ // Steer the agent to reply with ONLY the moves JSON (no tool call).
105
+ message: `${msg}${STRICT_JSON_DIRECTIVE}`,
106
+ // 45s ceiling, matching the watcher's per-delivery watchdog backstop:
107
+ // a run that hasn't produced a decision by then is treated as stalled
108
+ // (was 300s, which let one hung run silence the team for 5 min — m171).
109
+ timeoutMs: 45_000,
110
+ matchId: seat.id!,
111
+ cfg,
112
+ did: session.did ?? agentId,
113
+ token: session.token,
114
+ base,
115
+ logger: ctx.logger,
95
116
  });
96
- await api.runtime.subagent.waitForRun({ runId, timeoutMs: 300_000 });
117
+ // act-verification by the natural signal: did we parse+post (or did the
118
+ // model act via the tool)? A parse-miss = "responded without acting" —
119
+ // log, keep standing orders, continue (never freeze). The watcher's own
120
+ // lastActAt check stays correct because executeMoves/soccer_play stamp it.
121
+ if (!result.acted) {
122
+ ctx.logger.warn(`[${label}] agent responded without acting (turn ${turn}): ${result.reason}`);
123
+ }
97
124
  },
98
125
  {
99
126
  signal: controller.signal,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentmessier/openclaw-agent-messier",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
4
4
  "description": "Agent Messier multi-venue client for OpenClaw \u2014 play games and work tasks on the AgentNet platform (soccer today; venues discovered from the marketplace registry)",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/decide.ts ADDED
@@ -0,0 +1,343 @@
1
+ /**
2
+ * Option A decision-extraction — the server-driven decision core, ported from
3
+ * the tested prototype `services/soccer-driver/src/driver.ts` and adapted to run
4
+ * IN-PROCESS inside the customer's OpenClaw gateway.
5
+ *
6
+ * The m171 bug was relying on the agent to PROACTIVELY call the act tool: a
7
+ * model-dependent, unreliable behaviour that yielded 2 decisions in 157s. The
8
+ * fix (docs/design/agent-bridge-plugin.md §2) is request/response: per situation
9
+ * we FORCE one agent turn and PARSE its JSON reply — N situations → N decisions,
10
+ * regardless of tool-call discipline.
11
+ *
12
+ * subagent.run({deliver:false}) → waitForRun → getSessionMessages
13
+ * → lastAssistantText → parseMoves → POST /action per player
14
+ *
15
+ * Tool-vs-JSON-reply decision: the OpenClaw subagent API (SubagentRunParams) has
16
+ * NO knob to remove a tool from the run's scope, so the soccer act tool
17
+ * (soccer_play) is still in scope during an autoplay turn. We therefore (a) steer
18
+ * the prompt hard to "reply with ONLY the moves JSON, do NOT call any tool"
19
+ * (STRICT_JSON_DIRECTIVE), and (b) make the act path idempotent at the cycle
20
+ * level: if the model calls soccer_play anyway, that tool already POSTs actions
21
+ * and stamps session.lastActAt — so runAutoplayTurn detects that the team was
22
+ * acted (lastActAt advanced during the turn) and SKIPS the direct POST rather
23
+ * than double-applying. Only when the model replied with text (no tool call) do
24
+ * we parse that text and POST ourselves. Either way the cycle results in exactly
25
+ * one set of moves.
26
+ */
27
+ import type { PluginRuntime } from "openclaw/plugin-sdk/core";
28
+ import { apiKeyOf, type PluginCfg } from "./tools.js";
29
+ import { session } from "./state.js";
30
+
31
+ export type Vec2 = { x: number; y: number };
32
+
33
+ export type Move = {
34
+ playerId: string;
35
+ type: string;
36
+ dir?: Vec2;
37
+ power?: number;
38
+ zone?: number;
39
+ say?: string;
40
+ };
41
+
42
+ /** Action vocabulary — mirrors services/pitch/src/schemas.ts `actionTypes`.
43
+ * Kept local so the plugin carries no cross-package dependency. */
44
+ export const ACTION_TYPES = [
45
+ "run", "kick", "idle", "chase", "shoot", "dribble",
46
+ "pass", "defend", "press", "cover", "push", "stop",
47
+ ] as const;
48
+
49
+ export class DecideError extends Error {
50
+ constructor(message: string) {
51
+ super(message);
52
+ this.name = "DecideError";
53
+ }
54
+ }
55
+
56
+ /** Appended to the autoplay move prompt so the agent replies with ONLY the moves
57
+ * JSON instead of (or in addition to) calling the act tool. The strictness is
58
+ * what keeps parseMoves reliable, and "do not call any tool" is what makes the
59
+ * forced-turn-then-parse loop work regardless of the model's tool discipline. */
60
+ export const STRICT_JSON_DIRECTIVE =
61
+ `\n\nDo NOT call any tool. Reply with ONLY a JSON object — no prose, no markdown ` +
62
+ `fences — in exactly this shape, one entry per player you control:\n` +
63
+ `{"moves":[{"playerId":"<id>","type":"<action>","dir":{"x":1,"y":0},"power":0.8,"zone":11,"say":"optional"}]}\n` +
64
+ `Valid action types: ${ACTION_TYPES.join(", ")}. ` +
65
+ `"run"/"kick" need dir {x,y}; "kick" also needs power 0..1; "push" needs zone 1..12; ` +
66
+ `"press"/"cover" may add zone 1..12. Omit fields an action does not need. Return the moves JSON now.`;
67
+
68
+ // ── tolerant JSON extraction (ported from driver.ts) ─────────────────────────
69
+
70
+ /** First balanced {…} or […] block, skipping string contents. */
71
+ function firstBalanced(s: string): string | null {
72
+ const start = s.search(/[{[]/);
73
+ if (start < 0) return null;
74
+ const open = s[start];
75
+ const close = open === "{" ? "}" : "]";
76
+ let depth = 0;
77
+ let inStr = false;
78
+ let esc = false;
79
+ for (let i = start; i < s.length; i += 1) {
80
+ const c = s[i]!;
81
+ if (inStr) {
82
+ if (esc) esc = false;
83
+ else if (c === "\\") esc = true;
84
+ else if (c === '"') inStr = false;
85
+ continue;
86
+ }
87
+ if (c === '"') inStr = true;
88
+ else if (c === open) depth += 1;
89
+ else if (c === close) {
90
+ depth -= 1;
91
+ if (depth === 0) return s.slice(start, i + 1);
92
+ }
93
+ }
94
+ return null;
95
+ }
96
+
97
+ /** Parse JSON out of an LLM reply: whole body → ```json fence → first balanced
98
+ * block. Returns null if nothing parses. */
99
+ function extractJson(text: string): unknown {
100
+ const t = text.trim();
101
+ try {
102
+ return JSON.parse(t);
103
+ } catch {
104
+ /* fall through */
105
+ }
106
+ const fence = t.match(/```(?:json)?\s*([\s\S]*?)```/i);
107
+ if (fence?.[1]) {
108
+ try {
109
+ return JSON.parse(fence[1].trim());
110
+ } catch {
111
+ /* fall through */
112
+ }
113
+ }
114
+ const cand = firstBalanced(t);
115
+ if (cand) {
116
+ try {
117
+ return JSON.parse(cand);
118
+ } catch {
119
+ /* fall through */
120
+ }
121
+ }
122
+ return null;
123
+ }
124
+
125
+ function coerceDir(d: unknown): Vec2 | undefined {
126
+ if (d && typeof d === "object" && !Array.isArray(d)) {
127
+ const { x, y } = d as Record<string, unknown>;
128
+ if (typeof x === "number" && typeof y === "number" && Number.isFinite(x) && Number.isFinite(y)) {
129
+ return { x, y };
130
+ }
131
+ }
132
+ if (Array.isArray(d) && d.length === 2 && d.every((n) => typeof n === "number" && Number.isFinite(n))) {
133
+ return { x: d[0] as number, y: d[1] as number };
134
+ }
135
+ return undefined;
136
+ }
137
+
138
+ /**
139
+ * Extract validated moves from a reply. Tolerant of `{"moves":[…]}` or a bare
140
+ * `[…]`, and of `action`/`id`/`player` aliases. Invalid entries are dropped;
141
+ * throws DecideError when nothing usable is found (the caller treats that as
142
+ * "responded without acting").
143
+ */
144
+ export function parseMoves(text: string): Move[] {
145
+ const obj = extractJson(text);
146
+ if (obj == null) throw new DecideError("no JSON found in reply");
147
+ const arr = Array.isArray(obj)
148
+ ? obj
149
+ : Array.isArray((obj as { moves?: unknown }).moves)
150
+ ? ((obj as { moves: unknown[] }).moves)
151
+ : null;
152
+ if (!arr) throw new DecideError("reply JSON has no moves array");
153
+
154
+ const valid = new Set<string>(ACTION_TYPES);
155
+ const moves: Move[] = [];
156
+ for (const raw of arr) {
157
+ if (!raw || typeof raw !== "object") continue;
158
+ const r = raw as Record<string, unknown>;
159
+ const playerId =
160
+ typeof r.playerId === "string" ? r.playerId
161
+ : typeof r.id === "string" ? r.id
162
+ : typeof r.player === "string" ? r.player
163
+ : undefined;
164
+ const type = typeof r.type === "string" ? r.type : typeof r.action === "string" ? r.action : undefined;
165
+ if (!playerId || !type || !valid.has(type)) continue;
166
+ const move: Move = { playerId, type };
167
+ const dir = coerceDir(r.dir);
168
+ if (dir) move.dir = dir;
169
+ if (typeof r.power === "number" && Number.isFinite(r.power)) move.power = r.power;
170
+ if (Number.isInteger(r.zone) && (r.zone as number) >= 1 && (r.zone as number) <= 12) move.zone = r.zone as number;
171
+ if (typeof r.say === "string") move.say = r.say;
172
+ moves.push(move);
173
+ }
174
+ if (moves.length === 0) throw new DecideError("no valid moves parsed from reply");
175
+ return moves;
176
+ }
177
+
178
+ // ── read the agent's reply from the transcript ───────────────────────────────
179
+
180
+ /** One content block in an assistant message (string content, or an array of
181
+ * {type:"text", text} blocks per the OpenClaw transcript shape). */
182
+ function blockText(content: unknown): string {
183
+ if (typeof content === "string") return content;
184
+ if (Array.isArray(content)) {
185
+ return content
186
+ .map((b) => {
187
+ if (typeof b === "string") return b;
188
+ if (b && typeof b === "object" && typeof (b as { text?: unknown }).text === "string") {
189
+ return (b as { text: string }).text;
190
+ }
191
+ return "";
192
+ })
193
+ .join("");
194
+ }
195
+ return "";
196
+ }
197
+
198
+ /** The text of the LAST assistant message in a getSessionMessages transcript.
199
+ * A fresh per-turn session yields one user + one assistant record, so this is
200
+ * trivially that assistant reply. Returns "" when there is no assistant text. */
201
+ export function lastAssistantText(messages: unknown[]): string {
202
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
203
+ const m = messages[i];
204
+ if (m && typeof m === "object" && (m as { role?: unknown }).role === "assistant") {
205
+ return blockText((m as { content?: unknown }).content);
206
+ }
207
+ }
208
+ return "";
209
+ }
210
+
211
+ // ── execute: POST one action per move to the pitch ───────────────────────────
212
+
213
+ export type ExecuteDeps = {
214
+ /** Pitch base URL (cfg.serverUrl), e.g. http://localhost:3010. */
215
+ base: string;
216
+ cfg: PluginCfg;
217
+ /** Caller DID / agentId (session.did ?? agentId). */
218
+ did: string;
219
+ /** Seat token (session.token), sent as x-agent-token. */
220
+ token?: string | null | undefined;
221
+ /** Reported as x-agent-decision-ms — the prompt→reply latency (ms). */
222
+ decisionMs?: number;
223
+ /** Test seam; defaults to global fetch. */
224
+ fetch?: typeof fetch;
225
+ };
226
+
227
+ /**
228
+ * POST one `/action` per move, carrying the decision-observability headers the
229
+ * pitch reads (x-agent-model from session.lastModel, x-agent-decision-ms =
230
+ * prompt→reply latency). Mirrors generate.ts's vfetch act path so capture sees
231
+ * the same shape. Stamps session.lastActAt on the first successful POST so the
232
+ * watcher's act-verification sees "we posted moves this cycle".
233
+ */
234
+ export async function executeMoves(
235
+ matchId: string,
236
+ moves: Move[],
237
+ deps: ExecuteDeps,
238
+ ): Promise<{ posted: number; results: { playerId: string; status: number }[] }> {
239
+ const f = deps.fetch ?? fetch;
240
+ const base = deps.base.replace(/\/$/, "");
241
+ const results: { playerId: string; status: number }[] = [];
242
+ for (const m of moves) {
243
+ const body: Record<string, unknown> = { agentId: deps.did, type: m.type };
244
+ if (m.dir) body.dir = m.dir;
245
+ if (m.power !== undefined) body.power = m.power;
246
+ if (m.zone !== undefined) body.zone = m.zone;
247
+ if (m.say !== undefined) body.say = m.say;
248
+ const headers: Record<string, string> = {
249
+ "Content-Type": "application/json",
250
+ "x-caller-did": deps.did,
251
+ "x-agent-runtime": "openclaw-plugin/autoplay",
252
+ };
253
+ if (deps.token) headers["x-agent-token"] = deps.token;
254
+ if (session.lastModel) headers["x-agent-model"] = session.lastModel;
255
+ if (deps.decisionMs !== undefined) headers["x-agent-decision-ms"] = String(Math.max(0, Math.round(deps.decisionMs)));
256
+ const key = apiKeyOf(deps.cfg);
257
+ if (key) headers.Authorization = `Bearer ${key}`;
258
+ const url = `${base}/matches/${encodeURIComponent(matchId)}/players/${encodeURIComponent(m.playerId)}/action`;
259
+ const res = await f(url, { method: "POST", headers, body: JSON.stringify(body) });
260
+ if (res.ok) session.lastActAt = Date.now(); // act-verification: the team was moved this cycle
261
+ results.push({ playerId: m.playerId, status: res.status });
262
+ }
263
+ return { posted: results.length, results };
264
+ }
265
+
266
+ // ── the full autoplay turn: run → wait → read → parse → post ─────────────────
267
+
268
+ export type AutoplayTurnDeps = {
269
+ runtime: PluginRuntime;
270
+ sessionKey: string;
271
+ idempotencyKey: string;
272
+ /** The strict-JSON move prompt to deliver to the agent. */
273
+ message: string;
274
+ /** Backstop ceiling for waitForRun (the watcher watchdog is the latch backstop). */
275
+ timeoutMs: number;
276
+ matchId: string;
277
+ cfg: PluginCfg;
278
+ did: string;
279
+ token?: string | null;
280
+ base: string;
281
+ logger?: { info: (m: string) => void; warn: (m: string) => void; error: (m: string) => void };
282
+ };
283
+
284
+ export type AutoplayTurnResult =
285
+ | { acted: true; via: "post"; posted: number }
286
+ | { acted: true; via: "tool" } // the model called soccer_play itself
287
+ | { acted: false; reason: string }; // responded without acting (parse miss / empty reply)
288
+
289
+ /**
290
+ * Force exactly one agent turn for a delivered situation and act on its reply.
291
+ * Returns whether the team was acted this cycle (post or tool) or not (the
292
+ * watcher logs/counts a no-act turn and keeps standing orders — never freezes).
293
+ */
294
+ export async function runAutoplayTurn(deps: AutoplayTurnDeps): Promise<AutoplayTurnResult> {
295
+ const { runtime } = deps;
296
+ // act-verification baseline: if soccer_play runs during this turn it advances
297
+ // session.lastActAt past this — our cue to NOT double-post.
298
+ const actAtBefore = session.lastActAt;
299
+ const startedAt = Date.now();
300
+
301
+ const { runId } = await runtime.subagent.run({
302
+ sessionKey: deps.sessionKey,
303
+ message: deps.message,
304
+ deliver: false,
305
+ idempotencyKey: deps.idempotencyKey,
306
+ });
307
+ await runtime.subagent.waitForRun({ runId, timeoutMs: deps.timeoutMs });
308
+ const decisionMs = Math.max(0, Date.now() - startedAt);
309
+
310
+ // The model called the act tool itself (it POSTed + stamped lastActAt). Treat
311
+ // the cycle as acted; do NOT parse+post again (would double-apply).
312
+ if (session.lastActAt !== actAtBefore) {
313
+ return { acted: true, via: "tool" };
314
+ }
315
+
316
+ let text = "";
317
+ try {
318
+ const { messages } = await runtime.subagent.getSessionMessages({ sessionKey: deps.sessionKey, limit: 10 });
319
+ text = lastAssistantText(messages);
320
+ } catch (e) {
321
+ deps.logger?.warn(`getSessionMessages failed: ${String(e)}`);
322
+ } finally {
323
+ // Fresh session per turn → drop it so transcripts don't accumulate. Cleanup
324
+ // failure is non-fatal (the run already happened).
325
+ runtime.subagent.deleteSession({ sessionKey: deps.sessionKey }).catch(() => {});
326
+ }
327
+
328
+ let moves: Move[];
329
+ try {
330
+ moves = parseMoves(text);
331
+ } catch (e) {
332
+ return { acted: false, reason: e instanceof DecideError ? e.message : String(e) };
333
+ }
334
+
335
+ const { posted } = await executeMoves(deps.matchId, moves, {
336
+ base: deps.base,
337
+ cfg: deps.cfg,
338
+ did: deps.did,
339
+ token: deps.token,
340
+ decisionMs,
341
+ });
342
+ return { acted: true, via: "post", posted };
343
+ }
package/src/generate.ts CHANGED
@@ -37,6 +37,10 @@ function did(venueId: string, cfg: PluginCfg): string {
37
37
  async function vfetch(base: string, path: string, opts: { method?: string; body?: unknown; cfg: PluginCfg; token?: string; did: string }): Promise<{ ok: boolean; status: number; data: any }> {
38
38
  const headers: Record<string, string> = { "x-caller-did": opts.did, "x-agent-runtime": "openclaw-plugin/0.2.0" };
39
39
  if (session.lastModel) headers["x-agent-model"] = session.lastModel; // effective LLM for the pitch roster
40
+ // Prompt→act latency: ms from when the watcher delivered the move prompt to
41
+ // this call. The pitch reads x-agent-decision-ms to record decision speed. Only
42
+ // meaningful on the act POST, but harmless elsewhere (server ignores it there).
43
+ if (session.promptDeliveredAt != null) headers["x-agent-decision-ms"] = String(Math.max(0, Date.now() - session.promptDeliveredAt));
40
44
  const key = apiKeyOf(opts.cfg); if (key) headers["Authorization"] = `Bearer ${key}`;
41
45
  if (opts.token) headers["x-agent-token"] = opts.token;
42
46
  if (opts.body !== undefined) headers["Content-Type"] = "application/json";
@@ -168,6 +172,7 @@ export function generateVenueTools(venue: Venue, spec: GameSpec, cfg: PluginCfg)
168
172
  const path = sub(actRoute, { matchId: seat.id ?? "", did: d, playerId: String(m.player ?? "") });
169
173
  const body = { agentId: d, type: action, ...whitelist(m, s.params ?? {}), ...(session.lockstep ? { turn: session.turn } : {}) };
170
174
  const r = await vfetch(base, path, { cfg, did: d, method: "POST", body, token: seat.token });
175
+ if (r.ok) session.lastActAt = Date.now(); // act-verification: the agent moved its team this turn
171
176
  applied.push(r.ok ? { player: m.player, type: action } : { player: m.player, error: r.data?.error ?? r.status });
172
177
  }
173
178
  return ok({ applied });
@@ -182,6 +187,7 @@ export function generateVenueTools(venue: Venue, spec: GameSpec, cfg: PluginCfg)
182
187
  const path = sub(actRoute, { matchId: seat.id ?? "", did: d, playerId: String(p.player ?? "") });
183
188
  const r = await vfetch(base, path, { cfg, did: d, method: "POST", body: { type: action, ...whitelist(p, s.params ?? {}) }, token: seat.token });
184
189
  if (!r.ok) return ok({ error: r.data?.error ?? `act ${r.status}` });
190
+ session.lastActAt = Date.now(); // act-verification: the agent acted this turn
185
191
  return ok({ type: action, result: r.data });
186
192
  } } as AnyAgentTool);
187
193
  }
package/src/state.ts CHANGED
@@ -23,8 +23,19 @@ export const session: {
23
23
  * the gateway's llm_output hook — sent as x-agent-model so the pitch records
24
24
  * the model that's actually playing (reflects a mid-match /model switch). */
25
25
  lastModel: string | null
26
+ /** Epoch ms when the act tool last SUCCESSFULLY POSTed an action. The watcher
27
+ * reads this before/after a delivery to detect "prompted but didn't act"
28
+ * (the run finished without the agent moving its team). null until first act. */
29
+ lastActAt: number | null
30
+ /** How many delivered prompts completed WITHOUT the agent acting (no advance of
31
+ * lastActAt). Pure observability — surfaced for tests/inspection, never gates play. */
32
+ noActTurns: number
33
+ /** Epoch ms when the watcher last handed a move prompt to the agent (set right
34
+ * before subagent.run). The act tool emits Date.now()-this as the prompt→act
35
+ * latency header x-agent-decision-ms. null until the first prompt is delivered. */
36
+ promptDeliveredAt: number | null
26
37
  /** Installed by the service: seat into a venue room (matchId omitted = quickmatch
27
38
  * find-or-create) and start the observe/act loop. `params` are venue join params
28
39
  * (e.g. teamSize/team for soccer). Returns the seat the loop is now driving. */
29
40
  joinAndWatch: ((matchId?: string, params?: Record<string, unknown>) => Promise<{ id?: string; controls?: string[]; started?: boolean }>) | null
30
- } = { matchId: null, players: [], token: null, did: null, turn: 0, lockstep: false, lastModel: null, joinAndWatch: null }
41
+ } = { matchId: null, players: [], token: null, did: null, turn: 0, lockstep: false, lastModel: null, lastActAt: null, noActTurns: 0, promptDeliveredAt: null, joinAndWatch: null }
package/src/tools.ts CHANGED
@@ -6,6 +6,17 @@ import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk/core";
6
6
  import { describeTeam, type TeamView } from "./format.js";
7
7
  import { session } from "./state.js";
8
8
 
9
+ /** Plugin version from package.json (the file the publish pipeline bumps), read
10
+ * once at module load. Sent in x-agent-runtime so the pitch records WHICH
11
+ * plugin version holds a seat. Falls back to 'dev' if the manifest is missing. */
12
+ const PLUGIN_VERSION: string = (() => {
13
+ try {
14
+ return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? "dev";
15
+ } catch {
16
+ return "dev";
17
+ }
18
+ })();
19
+
9
20
  export type PluginCfg = {
10
21
  serverUrl?: string;
11
22
  /** Auto quick-match at startup: find a waiting room (teamSize) or create one.
@@ -178,8 +189,10 @@ export function pitchClient(cfg: PluginCfg) {
178
189
  const k = apiKeyOf(cfg);
179
190
  return k ? { Authorization: `Bearer ${k}` } : {};
180
191
  };
181
- // Tells the lobby roster what kind of client holds this seat.
182
- const RUNTIME = { "x-agent-runtime": "openclaw-plugin/0.1.0" };
192
+ // Tells the lobby roster what kind of client — and which VERSION — holds this
193
+ // seat. PLUGIN_VERSION is read from package.json (bumped by the publish
194
+ // pipeline) so the pitch can see which plugin build a seat is actually running.
195
+ const RUNTIME = { "x-agent-runtime": `openclaw-plugin/agent-messier@${PLUGIN_VERSION}` };
183
196
  // The room is dynamic. In-process state is set when the watcher joins, but
184
197
  // tools may run in a DIFFERENT process (CLI chat, dashboard) — so fall back
185
198
  // to asking the server which room this agentId is seated in.
package/src/watcher.ts CHANGED
@@ -35,8 +35,17 @@ export type WatcherCfg = {
35
35
  actTool?: string;
36
36
  /** Log tag for this venue's watcher (e.g. "agent-soccer"). Default "agentnet". */
37
37
  label?: string;
38
+ /** Watchdog: max ms a single delivery may hold the in-flight latch. If a
39
+ * deliver (subagent run) hangs or runs longer than this, the watcher releases
40
+ * the latch, warns, and delivers the freshest frame — so a slow/stuck decision
41
+ * can't silence the team for the full subagent timeout. Default 45000. */
42
+ deliverTimeoutMs?: number;
38
43
  };
39
44
 
45
+ /** Default per-delivery watchdog (ms). Matches index.ts's waitForRun timeout so
46
+ * the latch is released right around when the run would time out anyway. */
47
+ export const DEFAULT_DELIVER_TIMEOUT_MS = 45_000;
48
+
40
49
  // ── human-editable strategy (Phase 5) ────────────────────────────────────────
41
50
  // A markdown file the manager edits; injected into the move prompt. mtime-cached
42
51
  // (no re-read per tick), refreshed on edit, capped so it can't blow the prompt.
@@ -105,8 +114,9 @@ export function prompt(v: TeamView & { summary?: string }, mode: "easy" | "advan
105
114
  stratBlock +
106
115
  `${v.summary}\n\n` +
107
116
  `${ins.play}\n\n` +
108
- `Decide and act now: make ONE ${actTool} call with a move for every player you control. ` +
109
- `Each is a standing order until you change it.`
117
+ `Decide and act NOW: make ONE ${actTool} call with a move for every player you control ` +
118
+ `every time you are prompted, even if the plan is unchanged. The order holds until you change it, ` +
119
+ `so if you go quiet your team freezes on stale orders. Never reply without acting.`
110
120
  );
111
121
  }
112
122
  // Fallback (pre-envelope server or handshake not yet arrived). describeTeam is
@@ -116,8 +126,9 @@ export function prompt(v: TeamView & { summary?: string }, mode: "easy" | "advan
116
126
  return (
117
127
  stratBlock +
118
128
  `${rendered}\n\n` +
119
- `Decide and act now: make ONE ${actTool} call with a move for every player you control. ` +
120
- `Each is a standing order until you change it.`
129
+ `Decide and act NOW: make ONE ${actTool} call with a move for every player you control ` +
130
+ `every time you are prompted, even if the plan is unchanged. The order holds until you change it, ` +
131
+ `so if you go quiet your team freezes on stale orders. Never reply without acting.`
121
132
  );
122
133
  }
123
134
 
@@ -164,6 +175,8 @@ export async function startObserveWatcher(
164
175
  .finally(() => { specFetching = false; });
165
176
  }
166
177
 
178
+ const deliverTimeoutMs = cfg.deliverTimeoutMs ?? DEFAULT_DELIVER_TIMEOUT_MS;
179
+
167
180
  function maybeDeliver() {
168
181
  if (busy || signal?.aborted || latest === null || latestSeq === deliveredSeq) return;
169
182
  // Match over → stop prompting. No message to the gateway = no LLM call.
@@ -171,9 +184,39 @@ export async function startObserveWatcher(
171
184
  busy = true;
172
185
  const seq = latestSeq;
173
186
  const obs = latest;
187
+ // Act-verification baseline: if the act tool doesn't stamp lastActAt past
188
+ // this during the run, the agent was prompted but never moved its team.
189
+ const actAtBefore = session.lastActAt;
190
+
191
+ // The watchdog and the deliver race for the latch. Whichever fires FIRST
192
+ // owns the release; `settled` makes the loser a no-op, so a slow deliver that
193
+ // resolves AFTER the watchdog can't double-release or re-deliver the same
194
+ // seq. Without this, a hung deliver (subagent run) would hold `busy` for the
195
+ // whole subagent timeout and silence the team on stale standing orders.
196
+ let settled = false;
197
+ let watchdog: ReturnType<typeof setTimeout> | undefined;
198
+ const release = (onTimeout: boolean) => {
199
+ if (settled) return;
200
+ settled = true;
201
+ if (watchdog !== undefined) clearTimeout(watchdog);
202
+ deliveredSeq = seq;
203
+ busy = false;
204
+ if (onTimeout) {
205
+ logger?.warn(`[${tag}] deliver exceeded ${deliverTimeoutMs}ms — releasing latch, agent may be stalled`);
206
+ } else if (session.lastActAt === actAtBefore) {
207
+ // The run finished but no action was POSTed — surfaced, never gated.
208
+ session.noActTurns++;
209
+ logger?.warn(`[${tag}] agent responded without acting (turn ${seq})`);
210
+ }
211
+ maybeDeliver();
212
+ };
213
+
214
+ watchdog = setTimeout(() => release(true), deliverTimeoutMs);
215
+ signal?.addEventListener("abort", () => { if (watchdog !== undefined) clearTimeout(watchdog); }, { once: true });
216
+
174
217
  Promise.resolve(deliver(prompt(obs, cfg.mode ?? "easy", cfg.strategyFile, spec, actTool)))
175
218
  .catch((e) => logger?.error(`[${tag}] deliver failed: ${String(e)}`))
176
- .finally(() => { deliveredSeq = seq; busy = false; maybeDeliver(); });
219
+ .finally(() => release(false));
177
220
  }
178
221
 
179
222
  let attempt = 0;