@agentmessier/openclaw-agent-messier 0.3.11 → 0.3.12

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,7 +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
+ import { runAutoplayTurn, postDecisionReport, STRICT_JSON_DIRECTIVE } from "./src/decide.js";
7
7
 
8
8
  /** A lobby row is "ours" if it references our agentId anywhere (soccer puts it in
9
9
  * sides.home/away; a generic venue may shape it differently). Deep, shape-blind. */
@@ -55,6 +55,10 @@ export default function register(api: OpenClawPluginApi) {
55
55
 
56
56
  let controller: AbortController | null = null;
57
57
  let poller: ReturnType<typeof setInterval> | null = null;
58
+ // The per-match session key currently in play. We persist ONE session per match
59
+ // (so the agent accumulates its own decision history); on LEAVING a match we
60
+ // delete it so a new match starts fresh and stale transcripts don't pile up.
61
+ let activeSessionKey: string | null = null;
58
62
 
59
63
  api.registerService({
60
64
  id: `agentnet-${venue.id}-watcher`,
@@ -75,11 +79,18 @@ export default function register(api: OpenClawPluginApi) {
75
79
 
76
80
  controller?.abort(); // leaving a previous room
77
81
  controller = new AbortController();
82
+ // Leaving the previous match → drop its persistent session so the new
83
+ // match starts with fresh context (and stale transcripts don't pile up).
84
+ if (sessionKey && activeSessionKey && activeSessionKey !== `${sessionKey}:${seat.id}`) {
85
+ api.runtime.subagent.deleteSession({ sessionKey: activeSessionKey }).catch(() => {});
86
+ }
87
+ // ONE persistent session for THIS match, stable across all its turns.
88
+ activeSessionKey = sessionKey ? `${sessionKey}:${seat.id}` : null;
78
89
  let move = 0;
79
90
  // Fire-and-forget: the watcher runs until aborted or the gateway stops.
80
91
  void startObserveWatcher(
81
92
  { serverUrl: cfg.serverUrl, matchId: seat.id!, agentId, mode: cfg.mode, strategyFile: cfg.strategyFile, actTool, label },
82
- async ({ system, user }) => {
93
+ async ({ system, user, tick, clock }) => {
83
94
  if (!sessionKey) {
84
95
  ctx.logger.warn(`[${label}] no sessionKey configured; cannot deliver move prompts.`);
85
96
  return;
@@ -90,34 +101,58 @@ export default function register(api: OpenClawPluginApi) {
90
101
  // 2-decisions-in-157s). soccer_play stays registered for interactive
91
102
  // chat play; only AUTOPLAY changes to this server-driven loop.
92
103
  //
93
- // Fresh session per move: each prompt is a complete snapshot, so the
94
- // agent needs no history keeps context from overflowing.
104
+ // ONE persistent session per MATCH (sessionKey:matchId, stable across
105
+ // turns) so the agent accumulates contextits own prior decisions —
106
+ // and the session transcript is the full per-match request log. `turn`
107
+ // is kept only for the idempotency key and the context-growth cap.
95
108
  const turn = move++;
109
+ const matchSessionKey = `${sessionKey}:${seat.id}`;
96
110
  const idempotencyKey = `agentnet:${venue.id}:${seat.id}:${agentId}:${turn}`;
111
+ const did = session.did ?? agentId;
112
+ const sys = system || undefined;
113
+ const msg = `${user}${STRICT_JSON_DIRECTIVE}`;
97
114
  // Mark when this prompt was handed to the agent: x-agent-decision-ms is
98
115
  // the prompt→reply latency measured inside runAutoplayTurn.
99
116
  session.promptDeliveredAt = Date.now();
100
117
  const result = await runAutoplayTurn({
101
118
  runtime: api.runtime,
102
- sessionKey: `${sessionKey}:${turn}`,
119
+ sessionKey: matchSessionKey,
120
+ turn,
103
121
  idempotencyKey,
104
122
  // The static rulebook (spec.instructions.system) rides the SYSTEM
105
123
  // channel; the per-tick board is the user message. '' on the
106
124
  // fallback path → no extra system prompt.
107
- extraSystemPrompt: system || undefined,
125
+ extraSystemPrompt: sys,
108
126
  // Steer the agent to reply with ONLY the moves JSON (no tool call).
109
- message: `${user}${STRICT_JSON_DIRECTIVE}`,
127
+ message: msg,
110
128
  // 45s ceiling, matching the watcher's per-delivery watchdog backstop:
111
129
  // a run that hasn't produced a decision by then is treated as stalled
112
130
  // (was 300s, which let one hung run silence the team for 5 min — m171).
113
131
  timeoutMs: 45_000,
114
132
  matchId: seat.id!,
115
133
  cfg,
116
- did: session.did ?? agentId,
134
+ did,
117
135
  token: session.token,
118
136
  base,
119
137
  logger: ctx.logger,
120
138
  });
139
+ // Report EVERY decision to the pitch — acted AND no-response — so the
140
+ // decision inspector sees no-response turns too. Best-effort, off the
141
+ // hot path, never throws.
142
+ void postDecisionReport(
143
+ {
144
+ tick,
145
+ clock,
146
+ prompt: { ...(sys ? { system: sys } : {}), user: msg },
147
+ outcome: result.outcome,
148
+ ...(result.reason ? { reason: result.reason } : {}),
149
+ ...(result.moves ? { moves: result.moves } : {}),
150
+ rawText: result.rawText,
151
+ latencyMs: result.latencyMs,
152
+ ...(session.lastModel ? { model: session.lastModel } : {}),
153
+ },
154
+ { base, matchId: seat.id!, agentId: did, token: session.token, logger: ctx.logger },
155
+ );
121
156
  // act-verification by the natural signal: did we parse+post (or did the
122
157
  // model act via the tool)? A parse-miss = "responded without acting" —
123
158
  // log, keep standing orders, continue (never freeze). The watcher's own
@@ -186,6 +221,10 @@ export default function register(api: OpenClawPluginApi) {
186
221
  if (poller) { clearInterval(poller); poller = null; }
187
222
  controller?.abort();
188
223
  controller = null;
224
+ if (activeSessionKey) {
225
+ api.runtime.subagent.deleteSession({ sessionKey: activeSessionKey }).catch(() => {});
226
+ activeSessionKey = null;
227
+ }
189
228
  session.joinAndWatch = null;
190
229
  session.matchId = null;
191
230
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentmessier/openclaw-agent-messier",
3
- "version": "0.3.11",
3
+ "version": "0.3.12",
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 CHANGED
@@ -203,8 +203,10 @@ function blockText(content: unknown): string {
203
203
  }
204
204
 
205
205
  /** The text of the LAST assistant message in a getSessionMessages transcript.
206
- * A fresh per-turn session yields one user + one assistant record, so this is
207
- * trivially that assistant reply. Returns "" when there is no assistant text. */
206
+ * With a PERSISTENT per-match session the transcript GROWS across turns
207
+ * (user/assistant pairs accumulate), so we scan from the end and return the
208
+ * most recent assistant reply — never an earlier turn's. Returns "" when there
209
+ * is no assistant text. */
208
210
  export function lastAssistantText(messages: unknown[]): string {
209
211
  for (let i = messages.length - 1; i >= 0; i -= 1) {
210
212
  const m = messages[i];
@@ -270,11 +272,70 @@ export async function executeMoves(
270
272
  return { posted: results.length, results };
271
273
  }
272
274
 
275
+ // ── decision reporting: POST every turn's decision to the pitch ──────────────
276
+
277
+ export type DecisionReportDeps = {
278
+ base: string;
279
+ matchId: string;
280
+ /** Reporting agentId (session.did ?? agentId). */
281
+ agentId: string;
282
+ /** Seat token — sent as x-agent-token (same auth as executeMoves). */
283
+ token?: string | null | undefined;
284
+ fetch?: typeof fetch;
285
+ logger?: { warn: (m: string) => void };
286
+ };
287
+
288
+ export type DecisionReport = {
289
+ tick: number;
290
+ clock: number;
291
+ prompt: { system?: string; user: string };
292
+ outcome: "acted" | "no_response";
293
+ reason?: string;
294
+ moves?: Move[];
295
+ rawText?: string;
296
+ latencyMs?: number;
297
+ model?: string;
298
+ };
299
+
300
+ /**
301
+ * POST a decision report to `/matches/:id/agents/:agentId/decision` so EVERY
302
+ * turn — acted AND no-response — reaches the pitch's decision inspector. Auth is
303
+ * the seat token (x-agent-token), same as executeMoves. Best-effort and off the
304
+ * hot path: it NEVER throws (caller fires it `.catch`-free via this swallow) so a
305
+ * reporting failure can't disrupt play.
306
+ */
307
+ export async function postDecisionReport(report: DecisionReport, deps: DecisionReportDeps): Promise<void> {
308
+ const f = deps.fetch ?? fetch;
309
+ const base = deps.base.replace(/\/$/, "");
310
+ const url = `${base}/matches/${encodeURIComponent(deps.matchId)}/agents/${encodeURIComponent(deps.agentId)}/decision`;
311
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
312
+ if (deps.token) headers["x-agent-token"] = deps.token;
313
+ try {
314
+ await f(url, { method: "POST", headers, body: JSON.stringify(report) });
315
+ } catch (e) {
316
+ deps.logger?.warn(`decision report POST failed: ${String(e)}`);
317
+ }
318
+ }
319
+
273
320
  // ── the full autoplay turn: run → wait → read → parse → post ─────────────────
274
321
 
322
+ /** Context-growth safety cap. A persistent per-match session accumulates the
323
+ * agent's whole decision history (good: it remembers its own prior orders), but
324
+ * a long match would otherwise grow the transcript without bound. OpenClaw
325
+ * auto-compacts, but as a hard backstop we RESET the session (deleteSession +
326
+ * start fresh) once a single match exceeds this many turns. Tradeoff: the agent
327
+ * loses its accumulated in-match memory at the reset boundary, but context can
328
+ * never grow unbounded. Keep it generous enough that most matches never hit it. */
329
+ export const SESSION_RESET_TURN_CAP = 60;
330
+
275
331
  export type AutoplayTurnDeps = {
276
332
  runtime: PluginRuntime;
333
+ /** Stable PER-MATCH session key — persists across turns so the agent
334
+ * accumulates context (its own prior decisions) and the transcript is the
335
+ * full per-match request log. Reset only on leave/new-match/cap. */
277
336
  sessionKey: string;
337
+ /** This match's turn ordinal (0-based). Used for the reset cap only. */
338
+ turn: number;
278
339
  idempotencyKey: string;
279
340
  /** The strict-JSON move prompt (the per-tick board) to deliver to the agent. */
280
341
  message: string;
@@ -291,10 +352,29 @@ export type AutoplayTurnDeps = {
291
352
  logger?: { info: (m: string) => void; warn: (m: string) => void; error: (m: string) => void };
292
353
  };
293
354
 
294
- export type AutoplayTurnResult =
295
- | { acted: true; via: "post"; posted: number }
296
- | { acted: true; via: "tool" } // the model called soccer_play itself
297
- | { acted: false; reason: string }; // responded without acting (parse miss / empty reply)
355
+ /** Everything a decision REPORT needs, returned alongside the act outcome so the
356
+ * caller (index.ts) can POST one /decision report per turn — for acted AND
357
+ * no-response turns using the tick/clock it has from the delivered frame. */
358
+ export type AutoplayTurnObservability = {
359
+ /** "acted" when the team was moved this cycle (direct post OR the model's own
360
+ * tool call); "no_response" when the run finished without a usable decision. */
361
+ outcome: "acted" | "no_response";
362
+ /** No-act reason (parse miss / empty reply); undefined when acted. */
363
+ reason?: string;
364
+ /** The agent's raw reply text (last assistant message), "" if unreadable. */
365
+ rawText: string;
366
+ /** Parsed moves (present only when we parsed + posted; undefined on tool/miss). */
367
+ moves?: Move[];
368
+ /** Measured prompt→reply latency (ms). */
369
+ latencyMs: number;
370
+ };
371
+
372
+ export type AutoplayTurnResult = AutoplayTurnObservability &
373
+ (
374
+ | { acted: true; via: "post"; posted: number }
375
+ | { acted: true; via: "tool" } // the model called soccer_play itself
376
+ | { acted: false } // responded without acting (parse miss / empty reply)
377
+ );
298
378
 
299
379
  /**
300
380
  * Force exactly one agent turn for a delivered situation and act on its reply.
@@ -306,6 +386,15 @@ export async function runAutoplayTurn(deps: AutoplayTurnDeps): Promise<AutoplayT
306
386
  // act-verification baseline: if soccer_play runs during this turn it advances
307
387
  // session.lastActAt past this — our cue to NOT double-post.
308
388
  const actAtBefore = session.lastActAt;
389
+
390
+ // Context-growth backstop: once a single match's persistent session exceeds the
391
+ // cap, drop it so the next run starts fresh — bounds transcript size at the
392
+ // cost of the agent's accumulated in-match memory (see SESSION_RESET_TURN_CAP).
393
+ if (deps.turn > 0 && deps.turn % SESSION_RESET_TURN_CAP === 0) {
394
+ await runtime.subagent.deleteSession({ sessionKey: deps.sessionKey }).catch(() => {});
395
+ deps.logger?.info(`session reset at turn ${deps.turn} (cap ${SESSION_RESET_TURN_CAP}) — context bounded`);
396
+ }
397
+
309
398
  const startedAt = Date.now();
310
399
 
311
400
  const { runId } = await runtime.subagent.run({
@@ -319,28 +408,36 @@ export async function runAutoplayTurn(deps: AutoplayTurnDeps): Promise<AutoplayT
319
408
  const decisionMs = Math.max(0, Date.now() - startedAt);
320
409
 
321
410
  // The model called the act tool itself (it POSTed + stamped lastActAt). Treat
322
- // the cycle as acted; do NOT parse+post again (would double-apply).
411
+ // the cycle as acted; do NOT parse+post again (would double-apply). The
412
+ // session PERSISTS — no deleteSession here; it's the per-match request log.
323
413
  if (session.lastActAt !== actAtBefore) {
324
- return { acted: true, via: "tool" };
414
+ return { acted: true, via: "tool", outcome: "acted", rawText: "", latencyMs: decisionMs };
325
415
  }
326
416
 
327
417
  let text = "";
328
418
  try {
419
+ // The persistent transcript GROWS each turn; we read the most recent slice
420
+ // and lastAssistantText returns the LATEST assistant reply (not an old one).
329
421
  const { messages } = await runtime.subagent.getSessionMessages({ sessionKey: deps.sessionKey, limit: 10 });
330
422
  text = lastAssistantText(messages);
331
423
  } catch (e) {
332
424
  deps.logger?.warn(`getSessionMessages failed: ${String(e)}`);
333
- } finally {
334
- // Fresh session per turn → drop it so transcripts don't accumulate. Cleanup
335
- // failure is non-fatal (the run already happened).
336
- runtime.subagent.deleteSession({ sessionKey: deps.sessionKey }).catch(() => {});
337
425
  }
426
+ // NOTE: no per-turn deleteSession — the session persists for the whole match so
427
+ // the agent accumulates its own decision history. Reset happens only on
428
+ // leave/new-match (index.ts) or the turn cap above.
338
429
 
339
430
  let moves: Move[];
340
431
  try {
341
432
  moves = parseMoves(text);
342
433
  } catch (e) {
343
- return { acted: false, reason: e instanceof DecideError ? e.message : String(e) };
434
+ return {
435
+ acted: false,
436
+ outcome: "no_response",
437
+ reason: e instanceof DecideError ? e.message : String(e),
438
+ rawText: text,
439
+ latencyMs: decisionMs,
440
+ };
344
441
  }
345
442
 
346
443
  const { posted } = await executeMoves(deps.matchId, moves, {
@@ -350,5 +447,5 @@ export async function runAutoplayTurn(deps: AutoplayTurnDeps): Promise<AutoplayT
350
447
  token: deps.token,
351
448
  decisionMs,
352
449
  });
353
- return { acted: true, via: "post", posted };
450
+ return { acted: true, via: "post", posted, outcome: "acted", rawText: text, moves, latencyMs: decisionMs };
354
451
  }
package/src/watcher.ts CHANGED
@@ -104,8 +104,10 @@ export function parseSseBlock(block: string): { event?: string; data?: string }
104
104
  * - `system`: the STATIC game rules (spec.instructions.system) — passed as
105
105
  * subagent.run's extraSystemPrompt. Empty on the fallback path (no spec).
106
106
  * - `user`: the PER-TICK board — strategy + rendered situation + play guidance
107
- * + the act/JSON directive. This is what changes every tick. */
108
- export type DeliverPrompt = { system: string; user: string };
107
+ * + the act/JSON directive. This is what changes every tick.
108
+ * - `tick`/`clock`: the delivered frame's game time, threaded through so the
109
+ * deliver callback can stamp the per-turn decision report. */
110
+ export type DeliverPrompt = { system: string; user: string; tick: number; clock: number };
109
111
 
110
112
  const actDirective = (actTool: string) =>
111
113
  `Decide and act NOW: make ONE ${actTool} call with a move for every player you control — ` +
@@ -129,6 +131,8 @@ export function prompt(v: TeamView & { summary?: string }, mode: "easy" | "advan
129
131
  `${v.summary}\n\n` +
130
132
  `${ins.play}\n\n` +
131
133
  actDirective(actTool),
134
+ tick: v.tick,
135
+ clock: v.clock,
132
136
  };
133
137
  }
134
138
  // Fallback (pre-envelope server or handshake not yet arrived). describeTeam is
@@ -139,6 +143,8 @@ export function prompt(v: TeamView & { summary?: string }, mode: "easy" | "advan
139
143
  return {
140
144
  system: "",
141
145
  user: stratBlock + `${rendered}\n\n` + actDirective(actTool),
146
+ tick: v.tick,
147
+ clock: v.clock,
142
148
  };
143
149
  }
144
150