@agentmessier/openclaw-agent-messier 0.3.11 → 0.4.0

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
@@ -1,9 +1,9 @@
1
1
  import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk/core";
2
- import { memberTools, venuesTool, agentIdOf, identityOf, venueUrl, type PluginCfg } from "./src/tools.js";
3
- import { defaultVenueTools, defaultRealtimeVenue, joinVenue } from "./src/generate.js";
2
+ import { memberTools, venuesTool, agentIdOf, identityOf, venueUrl, type PluginCfg, type GameSpec } from "./src/tools.js";
3
+ import { defaultVenueTools, defaultRealtimeVenues, hasBakedVenuePrefix, joinVenue, setVenueState, type Venue } from "./src/generate.js";
4
4
  import { startObserveWatcher } from "./src/watcher.js";
5
- import { session } from "./src/state.js";
6
- import { runAutoplayTurn, STRICT_JSON_DIRECTIVE } from "./src/decide.js";
5
+ import { session, createSession, type RuntimeSession } from "./src/state.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. */
@@ -19,109 +19,155 @@ export default function register(api: OpenClawPluginApi) {
19
19
  const cfg = (api.pluginConfig ?? {}) as PluginCfg;
20
20
 
21
21
  // 1. Tools: per-venue lifecycle tools GENERATED from each venue's spec
22
- // (soccer_matches/join/observe/play, work_observe/act, …) + soccer member
23
- // perks (skins/rename/claim) + the platform `venues` registry tool. A new
24
- // venue appears here from its spec with zero plugin code.
25
- for (const tool of [...defaultVenueTools(cfg), ...memberTools(cfg), venuesTool(cfg)]) {
26
- api.registerTool(tool as AnyAgentTool);
27
- }
22
+ // (soccer_matches/join/observe/play, work_observe/act, …) + the platform
23
+ // `venues` registry tool. A new venue appears here from its spec with zero
24
+ // plugin code. The soccer member perks (skins/rename/identity) are
25
+ // soccer-specific and hand-written, so they're registered ONLY when a soccer
26
+ // venue is present — a non-soccer deployment doesn't see them.
27
+ const tools: AnyAgentTool[] = [...defaultVenueTools(cfg), venuesTool(cfg)];
28
+ if (hasBakedVenuePrefix("soccer")) tools.push(...memberTools(cfg));
29
+ for (const tool of tools) api.registerTool(tool as AnyAgentTool);
28
30
 
29
31
  // Capture the EFFECTIVE model of each decision from the gateway's llm_output
30
- // hook (the provider/model of the call that just ran). vfetch sends it as
31
- // x-agent-model, so the pitch records the model actually PLAYING — reflecting
32
- // a mid-match /model switch, not a static configured default.
32
+ // hook (the provider/model of the call that just ran). It's process-wide (the
33
+ // hook can't attribute a model to a venue), so it lives on the global session;
34
+ // vfetch/executeMoves send it as x-agent-model so the pitch records the model
35
+ // actually PLAYING — reflecting a mid-match /model switch.
33
36
  api.registerHook("llm_output", ((event: { provider?: string; model?: string }) => {
34
37
  if (event?.model) session.lastModel = event.provider ? `${event.provider}/${event.model}` : event.model;
35
38
  }) as Parameters<typeof api.registerHook>[1]);
36
39
 
37
- // 2. Autoplay watcher. It drives ONE realtime venue (streams observations,
38
- // seats the agent, offers hands-free play) entirely from {venue, spec}
39
- // seating via spec.client.join, observe/act endpoints via spec.routes, the
40
- // move prompt naming spec.client.act.tool. No venue is hardcoded here;
41
- // soccer is simply the only realtime venue today. Seatless/poll venues
42
- // (taskmarket) have no autoplay loop, so the service no-ops for them.
43
- const realtime = defaultRealtimeVenue();
44
- if (!realtime) return;
45
- const { venue, spec } = realtime;
40
+ // 2. Autoplay watchers ONE per realtime venue (streams observations, seats the
41
+ // agent, offers hands-free play), each with its OWN runtime state so multiple
42
+ // games (soccer + a future golf) never clobber each other. Seating via
43
+ // spec.client.join, observe/act via spec.routes, the prompt naming
44
+ // spec.client.act.tool. No venue is hardcoded; soccer is just the only
45
+ // realtime venue today. The FIRST realtime venue reuses the global session so
46
+ // the interactive/cosmetic tools (which read it) stay in sync; the rest get a
47
+ // fresh isolated state.
48
+ const realtime = defaultRealtimeVenues();
49
+ const sessionKey =
50
+ cfg.sessionKey ??
51
+ ((api.config.hooks as Record<string, unknown> | undefined)?.defaultSessionKey as string | undefined);
52
+
53
+ realtime.forEach(({ venue, spec }, i) => {
54
+ const state = i === 0 ? session : createSession();
55
+ setVenueState(venue.id, state); // the generated act tool stamps THIS instance
56
+ registerVenueService(api, cfg, venue, spec, state, sessionKey);
57
+ });
58
+ }
59
+
60
+ /** Register the autoplay service for one realtime venue. All seating/observe/act
61
+ * is driven from {venue, spec} + the venue's own RuntimeSession. */
62
+ function registerVenueService(
63
+ api: OpenClawPluginApi,
64
+ cfg: PluginCfg,
65
+ venue: Venue,
66
+ spec: GameSpec,
67
+ state: RuntimeSession,
68
+ sessionKey: string | undefined,
69
+ ) {
46
70
  const label = venue.id;
47
71
  const actTool = spec.client!.act.tool;
48
72
  const lobbyRoute = spec.client?.lobby?.route;
49
73
  const roomIdField = spec.client?.join?.seat.id ?? "id";
50
74
  const base = venueUrl(venue.origin, cfg);
51
75
 
52
- const sessionKey =
53
- cfg.sessionKey ??
54
- ((api.config.hooks as Record<string, unknown> | undefined)?.defaultSessionKey as string | undefined);
55
-
56
76
  let controller: AbortController | null = null;
57
77
  let poller: ReturnType<typeof setInterval> | null = null;
78
+ // The per-match session key currently in play. We persist ONE session per match
79
+ // (so the agent accumulates its own decision history); on LEAVING a match we
80
+ // delete it so a new match starts fresh and stale transcripts don't pile up.
81
+ let activeSessionKey: string | null = null;
58
82
 
59
83
  api.registerService({
60
84
  id: `agentnet-${venue.id}-watcher`,
61
85
 
62
86
  start: async (ctx) => {
63
87
  const agentId = agentIdOf(cfg);
64
- // Config-derived join body. For soccer these are teamSize/team/identity; a
65
- // venue whose spec doesn't use them simply leaves the cfg fields unset and
66
- // the server ignores the extras. joinVenue itself stays venue-agnostic.
67
- const joinExtra = (): Record<string, unknown> => ({ teamSize: cfg.teamSize, team: cfg.team, identity: identityOf(cfg) });
88
+ // Config-derived join body: a generic identity object plus any venue join
89
+ // params (cfg.join teamSize/team for soccer, holes for golf, …). A venue
90
+ // whose spec doesn't use a field simply leaves it unset; the server ignores
91
+ // extras. joinVenue itself stays venue-agnostic.
92
+ const joinExtra = (): Record<string, unknown> => ({ ...(cfg.join ?? {}), identity: identityOf(cfg) });
68
93
 
69
94
  // Seat into a room (matchId omitted = quickmatch find-or-create) and
70
- // (re)start the observation loop. Installed into shared state so the
95
+ // (re)start the observation loop. Installed into this venue's state so the
71
96
  // generated *_join tool / chat handoff can drive it.
72
- session.joinAndWatch = async (matchId, params) => {
97
+ state.joinAndWatch = async (matchId, params) => {
73
98
  const seat = await joinVenue(venue, spec, cfg, { matchId, params: params ?? {}, extra: joinExtra() });
74
99
  ctx.logger.info(`[${label}] ${agentId} seated in ${seat.id} (${seat.controls?.length ?? 0} to control)${seat.started ? " — live" : " — waiting for opponent"}`);
75
100
 
76
101
  controller?.abort(); // leaving a previous room
77
102
  controller = new AbortController();
103
+ // Leaving the previous match → drop its persistent session so the new
104
+ // match starts with fresh context (and stale transcripts don't pile up).
105
+ if (sessionKey && activeSessionKey && activeSessionKey !== `${sessionKey}:${seat.id}`) {
106
+ api.runtime.subagent.deleteSession({ sessionKey: activeSessionKey }).catch(() => {});
107
+ }
108
+ // ONE persistent session for THIS match, stable across all its turns.
109
+ activeSessionKey = sessionKey ? `${sessionKey}:${seat.id}` : null;
78
110
  let move = 0;
79
111
  // Fire-and-forget: the watcher runs until aborted or the gateway stops.
80
112
  void startObserveWatcher(
81
113
  { serverUrl: cfg.serverUrl, matchId: seat.id!, agentId, mode: cfg.mode, strategyFile: cfg.strategyFile, actTool, label },
82
- async ({ system, user }) => {
114
+ async ({ system, user, tick, clock }) => {
83
115
  if (!sessionKey) {
84
116
  ctx.logger.warn(`[${label}] no sessionKey configured; cannot deliver move prompts.`);
85
117
  return;
86
118
  }
87
119
  // Option A (docs/design/agent-bridge-plugin.md §2): force ONE agent
88
120
  // 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.
121
+ // agent to proactively call the act tool (that reliance caused m171's
122
+ // 2-decisions-in-157s). The act tool stays registered for interactive
123
+ // chat play; only AUTOPLAY uses this server-driven loop.
92
124
  //
93
- // Fresh session per move: each prompt is a complete snapshot, so the
94
- // agent needs no history keeps context from overflowing.
125
+ // ONE persistent session per MATCH (sessionKey:matchId, stable across
126
+ // turns) so the agent accumulates contextits own prior decisions.
95
127
  const turn = move++;
128
+ const matchSessionKey = `${sessionKey}:${seat.id}`;
96
129
  const idempotencyKey = `agentnet:${venue.id}:${seat.id}:${agentId}:${turn}`;
130
+ const did = state.did ?? agentId;
131
+ const sys = system || undefined;
132
+ const msg = `${user}${STRICT_JSON_DIRECTIVE}`;
97
133
  // Mark when this prompt was handed to the agent: x-agent-decision-ms is
98
134
  // the prompt→reply latency measured inside runAutoplayTurn.
99
- session.promptDeliveredAt = Date.now();
135
+ state.promptDeliveredAt = Date.now();
100
136
  const result = await runAutoplayTurn({
101
137
  runtime: api.runtime,
102
- sessionKey: `${sessionKey}:${turn}`,
138
+ sessionKey: matchSessionKey,
139
+ turn,
103
140
  idempotencyKey,
104
141
  // The static rulebook (spec.instructions.system) rides the SYSTEM
105
- // channel; the per-tick board is the user message. '' on the
106
- // fallback path → no extra system prompt.
107
- extraSystemPrompt: system || undefined,
108
- // Steer the agent to reply with ONLY the moves JSON (no tool call).
109
- message: `${user}${STRICT_JSON_DIRECTIVE}`,
110
- // 45s ceiling, matching the watcher's per-delivery watchdog backstop:
111
- // a run that hasn't produced a decision by then is treated as stalled
112
- // (was 300s, which let one hung run silence the team for 5 min — m171).
142
+ // channel; the per-tick board is the user message.
143
+ extraSystemPrompt: sys,
144
+ message: msg,
113
145
  timeoutMs: 45_000,
114
146
  matchId: seat.id!,
115
147
  cfg,
116
- did: session.did ?? agentId,
117
- token: session.token,
148
+ did,
149
+ token: state.token,
118
150
  base,
151
+ state,
119
152
  logger: ctx.logger,
120
153
  });
121
- // act-verification by the natural signal: did we parse+post (or did the
122
- // model act via the tool)? A parse-miss = "responded without acting" —
123
- // log, keep standing orders, continue (never freeze). The watcher's own
124
- // lastActAt check stays correct because executeMoves/soccer_play stamp it.
154
+ // Report EVERY decision to the pitch acted AND no-response so the
155
+ // decision inspector sees no-response turns too. Best-effort, off the
156
+ // hot path, never throws.
157
+ void postDecisionReport(
158
+ {
159
+ tick,
160
+ clock,
161
+ prompt: { ...(sys ? { system: sys } : {}), user: msg },
162
+ outcome: result.outcome,
163
+ ...(result.reason ? { reason: result.reason } : {}),
164
+ ...(result.moves ? { moves: result.moves } : {}),
165
+ rawText: result.rawText,
166
+ latencyMs: result.latencyMs,
167
+ ...(session.lastModel ? { model: session.lastModel } : {}),
168
+ },
169
+ { base, matchId: seat.id!, agentId: did, token: state.token, logger: ctx.logger },
170
+ );
125
171
  if (!result.acted) {
126
172
  ctx.logger.warn(`[${label}] agent responded without acting (turn ${turn}): ${result.reason}`);
127
173
  }
@@ -129,6 +175,7 @@ export default function register(api: OpenClawPluginApi) {
129
175
  {
130
176
  signal: controller.signal,
131
177
  logger: ctx.logger,
178
+ state,
132
179
  onReclaim: async () => {
133
180
  // Server restarted and forgot us — re-seat into the same room via
134
181
  // seatRoute, or (room gone) quickmatch into a fresh one.
@@ -139,7 +186,7 @@ export default function register(api: OpenClawPluginApi) {
139
186
  if (!cfg.autoJoin) throw e;
140
187
  const q = await joinVenue(venue, spec, cfg, { extra: joinExtra() });
141
188
  ctx.logger.info(`[${label}] room ${seat.id} gone — re-quickmatched into ${q.id}`);
142
- if (q.id !== seat.id) void session.joinAndWatch!(undefined);
189
+ if (q.id !== seat.id) void state.joinAndWatch!(undefined);
143
190
  }
144
191
  },
145
192
  },
@@ -150,8 +197,8 @@ export default function register(api: OpenClawPluginApi) {
150
197
  // Startup seating: a pinned matchId seats that room; autoJoin quick-matches
151
198
  // (find-or-create, atomic server-side); otherwise idle until asked.
152
199
  try {
153
- if (cfg.matchId) await session.joinAndWatch(cfg.matchId);
154
- else if (cfg.autoJoin) await session.joinAndWatch(undefined);
200
+ if (cfg.matchId) await state.joinAndWatch(cfg.matchId);
201
+ else if (cfg.autoJoin) await state.joinAndWatch(undefined);
155
202
  else ctx.logger.info(`[${label}] idle — ask me to join or create a game.`);
156
203
  } catch (e) { ctx.logger.error(`[${label}] startup seating failed: ${String(e)}`); }
157
204
 
@@ -160,11 +207,11 @@ export default function register(api: OpenClawPluginApi) {
160
207
  // venue's lobby for a non-ended room referencing our agentId and adopt it.
161
208
  // Driven by spec.client.lobby.route — no hardcoded /matches path. Skipped
162
209
  // when a match is pinned (explicit room wins) or the venue has no lobby.
163
- // Critically it only adopts while idle (session.matchId empty): once we're
164
- // in a match it must NOT yank us into some other (e.g. stale) room.
210
+ // Critically it only adopts while idle (state.matchId empty): once we're in
211
+ // a match it must NOT yank us into some other (e.g. stale) room.
165
212
  if (lobbyRoute && !cfg.matchId) {
166
213
  poller = setInterval(async () => {
167
- if (session.matchId) return; // already seated/playing → nothing to adopt
214
+ if (state.matchId) return; // already seated/playing → nothing to adopt
168
215
  try {
169
216
  const res = await fetch(`${base}${lobbyRoute}`);
170
217
  if (!res.ok) return;
@@ -174,7 +221,7 @@ export default function register(api: OpenClawPluginApi) {
174
221
  const id = mine?.id ?? mine?.[roomIdField];
175
222
  if (id) {
176
223
  ctx.logger.info(`[${label}] found my seat in ${id} (taken elsewhere) — starting to play`);
177
- await session.joinAndWatch!(id);
224
+ await state.joinAndWatch!(id);
178
225
  }
179
226
  } catch { /* server down — the watcher's own backoff handles it */ }
180
227
  }, 10_000);
@@ -186,8 +233,12 @@ export default function register(api: OpenClawPluginApi) {
186
233
  if (poller) { clearInterval(poller); poller = null; }
187
234
  controller?.abort();
188
235
  controller = null;
189
- session.joinAndWatch = null;
190
- session.matchId = null;
236
+ if (activeSessionKey) {
237
+ api.runtime.subagent.deleteSession({ sessionKey: activeSessionKey }).catch(() => {});
238
+ activeSessionKey = null;
239
+ }
240
+ state.joinAndWatch = null;
241
+ state.matchId = null;
191
242
  },
192
243
  });
193
244
  }
@@ -3,107 +3,65 @@
3
3
  "kind": "tool",
4
4
  "uiHints": {
5
5
  "serverUrl": {
6
- "label": "Pitch Server URL",
6
+ "label": "Server URL",
7
7
  "placeholder": "http://localhost:3010",
8
- "help": "Base URL of the agent-soccer pitch service"
8
+ "help": "Base URL of the AgentNet platform / venue service (the pitch for soccer)"
9
9
  },
10
10
  "sessionKey": {
11
11
  "label": "Agent identity",
12
- "placeholder": "my-team",
13
- "help": "Stable id for this agent's seat in a room (also the chat session key)"
12
+ "placeholder": "my-agent",
13
+ "help": "Stable id for this agent's seat (also the chat session key)"
14
14
  },
15
15
  "mode": {
16
16
  "label": "Tool tier",
17
17
  "placeholder": "easy",
18
- "help": "easy = high-level intents (server computes geometry); advanced = raw run/kick; both = all tools"
18
+ "help": "easy = high-level intents (server computes detail); advanced = raw low-level actions; both = all tools"
19
19
  },
20
20
  "matchId": {
21
21
  "label": "Auto-join room (optional)",
22
22
  "placeholder": "m1",
23
23
  "help": "Join this room at startup; normally leave empty and ask your agent to find/create a game"
24
24
  },
25
- "team": {
26
- "label": "Side preference (optional)",
27
- "placeholder": "home",
28
- "help": "Preferred side when auto-joining"
29
- },
30
- "teamName": {
31
- "label": "Team name",
32
- "placeholder": "\u84dd\u9e70",
33
- "help": "Default team name (human can rename at runtime via chat)"
34
- },
35
- "nation": {
36
- "label": "Nation (ISO code)",
37
- "placeholder": "NL",
38
- "help": "Shown as a flag in the viewer"
39
- },
40
- "clan": {
41
- "label": "Clan",
42
- "placeholder": "\u9b54\u517d\u5de5\u4f1a",
43
- "help": "Guild/clan tag shown with the team"
44
- },
45
- "style": {
46
- "label": "Playing style",
47
- "placeholder": "\u5168\u653b\u5168\u5b88 total football",
48
- "help": "Fed into the agent's prompts \u2014 shapes actual play"
49
- },
50
25
  "autoJoin": {
51
26
  "label": "Auto quick-match",
52
27
  "placeholder": "true",
53
- "help": "Find-or-create a game at startup (zero-touch bot team)"
28
+ "help": "Find-or-create a game at startup (zero-touch bot agent)"
29
+ },
30
+ "identity": {
31
+ "label": "Display identity",
32
+ "placeholder": "{ \"name\": \"蓝鹰\", \"nation\": \"NL\" }",
33
+ "help": "Venue-neutral identity sent on join (soccer: name/nation/clan/style; shown in the viewer). Human can change it at runtime via chat where supported."
34
+ },
35
+ "join": {
36
+ "label": "Join options",
37
+ "placeholder": "{ \"teamSize\": 5, \"team\": \"home\" }",
38
+ "help": "Venue join params merged into the join body — soccer: teamSize/team; golf: holes; etc. Unknown fields are ignored by the server."
39
+ },
40
+ "apiKey": {
41
+ "label": "AgentNet API key (optional)",
42
+ "placeholder": "ak_…",
43
+ "help": "Sent as a Bearer token so the server can verify identity (REQUIRE_AUTH deployments)"
54
44
  },
55
- "teamSize": {
56
- "label": "Preferred team size",
57
- "placeholder": "5",
58
- "help": "Players per side for quick-match / created rooms"
45
+ "strategyFile": {
46
+ "label": "Strategy file (optional)",
47
+ "placeholder": "~/strategy.md",
48
+ "help": "Path to a human-editable markdown file injected into the agent's move prompt"
59
49
  }
60
50
  },
61
51
  "configSchema": {
62
52
  "type": "object",
63
- "additionalProperties": false,
53
+ "additionalProperties": true,
64
54
  "properties": {
65
- "serverUrl": {
66
- "type": "string"
67
- },
68
- "sessionKey": {
69
- "type": "string"
70
- },
71
- "mode": {
72
- "type": "string",
73
- "enum": [
74
- "easy",
75
- "advanced",
76
- "both"
77
- ]
78
- },
79
- "matchId": {
80
- "type": "string"
81
- },
82
- "team": {
83
- "type": "string",
84
- "enum": [
85
- "home",
86
- "away"
87
- ]
88
- },
89
- "teamName": {
90
- "type": "string"
91
- },
92
- "nation": {
93
- "type": "string"
94
- },
95
- "clan": {
96
- "type": "string"
97
- },
98
- "style": {
99
- "type": "string"
100
- },
101
- "autoJoin": {
102
- "type": "boolean"
103
- },
104
- "teamSize": {
105
- "type": "number"
106
- }
55
+ "serverUrl": { "type": "string" },
56
+ "sessionKey": { "type": "string" },
57
+ "apiKey": { "type": "string" },
58
+ "accountsUrl": { "type": "string" },
59
+ "mode": { "type": "string", "enum": ["easy", "advanced", "both"] },
60
+ "matchId": { "type": "string" },
61
+ "autoJoin": { "type": "boolean" },
62
+ "strategyFile": { "type": "string" },
63
+ "identity": { "type": "object", "additionalProperties": true },
64
+ "join": { "type": "object", "additionalProperties": true }
107
65
  },
108
66
  "required": []
109
67
  }
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@agentmessier/openclaw-agent-messier",
3
- "version": "0.3.11",
3
+ "version": "0.4.0",
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",
7
7
  "repository": {
8
8
  "type": "git",
9
9
  "url": "https://github.com/agentmessier-ai/agent-messier-plugins.git",
10
- "directory": "openclaw-agent-soccer"
10
+ "directory": "openclaw-agent-messier"
11
11
  },
12
12
  "keywords": [
13
13
  "openclaw",
package/src/decide.ts CHANGED
@@ -24,9 +24,15 @@
24
24
  * we parse that text and POST ourselves. Either way the cycle results in exactly
25
25
  * one set of moves.
26
26
  */
27
+ import os from "node:os";
27
28
  import type { PluginRuntime } from "openclaw/plugin-sdk/core";
28
29
  import { apiKeyOf, type PluginCfg } from "./tools.js";
29
- import { session } from "./state.js";
30
+ import { session, type RuntimeSession } from "./state.js";
31
+
32
+ /** Agent host OS + version — sent as x-agent-os so the pitch records the platform
33
+ * (NOT the machine name: os.platform/release/version/arch carry os/version/arch
34
+ * only, never the hostname). Computed once (static per host); Windows-safe. */
35
+ const AGENT_OS = `${os.platform()} ${os.release()} (${os.version()}) ${os.arch()}`;
30
36
 
31
37
  export type Vec2 = { x: number; y: number };
32
38
 
@@ -203,8 +209,10 @@ function blockText(content: unknown): string {
203
209
  }
204
210
 
205
211
  /** 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. */
212
+ * With a PERSISTENT per-match session the transcript GROWS across turns
213
+ * (user/assistant pairs accumulate), so we scan from the end and return the
214
+ * most recent assistant reply — never an earlier turn's. Returns "" when there
215
+ * is no assistant text. */
208
216
  export function lastAssistantText(messages: unknown[]): string {
209
217
  for (let i = messages.length - 1; i >= 0; i -= 1) {
210
218
  const m = messages[i];
@@ -227,6 +235,9 @@ export type ExecuteDeps = {
227
235
  token?: string | null | undefined;
228
236
  /** Reported as x-agent-decision-ms — the prompt→reply latency (ms). */
229
237
  decisionMs?: number;
238
+ /** Per-venue runtime session (defaults to the global one). Its lastModel rides
239
+ * the header and its lastActAt is stamped on a successful POST. */
240
+ state?: RuntimeSession;
230
241
  /** Test seam; defaults to global fetch. */
231
242
  fetch?: typeof fetch;
232
243
  };
@@ -244,6 +255,7 @@ export async function executeMoves(
244
255
  deps: ExecuteDeps,
245
256
  ): Promise<{ posted: number; results: { playerId: string; status: number }[] }> {
246
257
  const f = deps.fetch ?? fetch;
258
+ const state = deps.state ?? session;
247
259
  const base = deps.base.replace(/\/$/, "");
248
260
  const results: { playerId: string; status: number }[] = [];
249
261
  for (const m of moves) {
@@ -256,25 +268,87 @@ export async function executeMoves(
256
268
  "Content-Type": "application/json",
257
269
  "x-caller-did": deps.did,
258
270
  "x-agent-runtime": "openclaw-plugin/autoplay",
271
+ "x-agent-os": AGENT_OS,
259
272
  };
260
273
  if (deps.token) headers["x-agent-token"] = deps.token;
274
+ // Model is process-wide (the llm_output hook writes the GLOBAL session), so
275
+ // the header reads it globally; only lastActAt below is per-venue.
261
276
  if (session.lastModel) headers["x-agent-model"] = session.lastModel;
262
277
  if (deps.decisionMs !== undefined) headers["x-agent-decision-ms"] = String(Math.max(0, Math.round(deps.decisionMs)));
263
278
  const key = apiKeyOf(deps.cfg);
264
279
  if (key) headers.Authorization = `Bearer ${key}`;
265
280
  const url = `${base}/matches/${encodeURIComponent(matchId)}/players/${encodeURIComponent(m.playerId)}/action`;
266
281
  const res = await f(url, { method: "POST", headers, body: JSON.stringify(body) });
267
- if (res.ok) session.lastActAt = Date.now(); // act-verification: the team was moved this cycle
282
+ if (res.ok) state.lastActAt = Date.now(); // act-verification: the team was moved this cycle
268
283
  results.push({ playerId: m.playerId, status: res.status });
269
284
  }
270
285
  return { posted: results.length, results };
271
286
  }
272
287
 
288
+ // ── decision reporting: POST every turn's decision to the pitch ──────────────
289
+
290
+ export type DecisionReportDeps = {
291
+ base: string;
292
+ matchId: string;
293
+ /** Reporting agentId (session.did ?? agentId). */
294
+ agentId: string;
295
+ /** Seat token — sent as x-agent-token (same auth as executeMoves). */
296
+ token?: string | null | undefined;
297
+ fetch?: typeof fetch;
298
+ logger?: { warn: (m: string) => void };
299
+ };
300
+
301
+ export type DecisionReport = {
302
+ tick: number;
303
+ clock: number;
304
+ prompt: { system?: string; user: string };
305
+ outcome: "acted" | "no_response";
306
+ reason?: string;
307
+ moves?: Move[];
308
+ rawText?: string;
309
+ latencyMs?: number;
310
+ model?: string;
311
+ };
312
+
313
+ /**
314
+ * POST a decision report to `/matches/:id/agents/:agentId/decision` so EVERY
315
+ * turn — acted AND no-response — reaches the pitch's decision inspector. Auth is
316
+ * the seat token (x-agent-token), same as executeMoves. Best-effort and off the
317
+ * hot path: it NEVER throws (caller fires it `.catch`-free via this swallow) so a
318
+ * reporting failure can't disrupt play.
319
+ */
320
+ export async function postDecisionReport(report: DecisionReport, deps: DecisionReportDeps): Promise<void> {
321
+ const f = deps.fetch ?? fetch;
322
+ const base = deps.base.replace(/\/$/, "");
323
+ const url = `${base}/matches/${encodeURIComponent(deps.matchId)}/agents/${encodeURIComponent(deps.agentId)}/decision`;
324
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
325
+ if (deps.token) headers["x-agent-token"] = deps.token;
326
+ try {
327
+ await f(url, { method: "POST", headers, body: JSON.stringify(report) });
328
+ } catch (e) {
329
+ deps.logger?.warn(`decision report POST failed: ${String(e)}`);
330
+ }
331
+ }
332
+
273
333
  // ── the full autoplay turn: run → wait → read → parse → post ─────────────────
274
334
 
335
+ /** Context-growth safety cap. A persistent per-match session accumulates the
336
+ * agent's whole decision history (good: it remembers its own prior orders), but
337
+ * a long match would otherwise grow the transcript without bound. OpenClaw
338
+ * auto-compacts, but as a hard backstop we RESET the session (deleteSession +
339
+ * start fresh) once a single match exceeds this many turns. Tradeoff: the agent
340
+ * loses its accumulated in-match memory at the reset boundary, but context can
341
+ * never grow unbounded. Keep it generous enough that most matches never hit it. */
342
+ export const SESSION_RESET_TURN_CAP = 60;
343
+
275
344
  export type AutoplayTurnDeps = {
276
345
  runtime: PluginRuntime;
346
+ /** Stable PER-MATCH session key — persists across turns so the agent
347
+ * accumulates context (its own prior decisions) and the transcript is the
348
+ * full per-match request log. Reset only on leave/new-match/cap. */
277
349
  sessionKey: string;
350
+ /** This match's turn ordinal (0-based). Used for the reset cap only. */
351
+ turn: number;
278
352
  idempotencyKey: string;
279
353
  /** The strict-JSON move prompt (the per-tick board) to deliver to the agent. */
280
354
  message: string;
@@ -288,13 +362,37 @@ export type AutoplayTurnDeps = {
288
362
  did: string;
289
363
  token?: string | null;
290
364
  base: string;
365
+ /** Per-venue runtime session (defaults to the global one). Its lastActAt is the
366
+ * act-verification signal — read for the baseline + tool-call detection, and
367
+ * stamped by executeMoves on a direct post. The generated act tool stamps the
368
+ * SAME instance (via generate.stateOf), so all three writers agree per venue. */
369
+ state?: RuntimeSession;
291
370
  logger?: { info: (m: string) => void; warn: (m: string) => void; error: (m: string) => void };
292
371
  };
293
372
 
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)
373
+ /** Everything a decision REPORT needs, returned alongside the act outcome so the
374
+ * caller (index.ts) can POST one /decision report per turn — for acted AND
375
+ * no-response turns using the tick/clock it has from the delivered frame. */
376
+ export type AutoplayTurnObservability = {
377
+ /** "acted" when the team was moved this cycle (direct post OR the model's own
378
+ * tool call); "no_response" when the run finished without a usable decision. */
379
+ outcome: "acted" | "no_response";
380
+ /** No-act reason (parse miss / empty reply); undefined when acted. */
381
+ reason?: string;
382
+ /** The agent's raw reply text (last assistant message), "" if unreadable. */
383
+ rawText: string;
384
+ /** Parsed moves (present only when we parsed + posted; undefined on tool/miss). */
385
+ moves?: Move[];
386
+ /** Measured prompt→reply latency (ms). */
387
+ latencyMs: number;
388
+ };
389
+
390
+ export type AutoplayTurnResult = AutoplayTurnObservability &
391
+ (
392
+ | { acted: true; via: "post"; posted: number }
393
+ | { acted: true; via: "tool" } // the model called soccer_play itself
394
+ | { acted: false } // responded without acting (parse miss / empty reply)
395
+ );
298
396
 
299
397
  /**
300
398
  * Force exactly one agent turn for a delivered situation and act on its reply.
@@ -303,9 +401,19 @@ export type AutoplayTurnResult =
303
401
  */
304
402
  export async function runAutoplayTurn(deps: AutoplayTurnDeps): Promise<AutoplayTurnResult> {
305
403
  const { runtime } = deps;
306
- // act-verification baseline: if soccer_play runs during this turn it advances
307
- // session.lastActAt past this our cue to NOT double-post.
308
- const actAtBefore = session.lastActAt;
404
+ const state = deps.state ?? session;
405
+ // act-verification baseline: if the act tool runs during this turn it advances
406
+ // this venue's lastActAt past this — our cue to NOT double-post.
407
+ const actAtBefore = state.lastActAt;
408
+
409
+ // Context-growth backstop: once a single match's persistent session exceeds the
410
+ // cap, drop it so the next run starts fresh — bounds transcript size at the
411
+ // cost of the agent's accumulated in-match memory (see SESSION_RESET_TURN_CAP).
412
+ if (deps.turn > 0 && deps.turn % SESSION_RESET_TURN_CAP === 0) {
413
+ await runtime.subagent.deleteSession({ sessionKey: deps.sessionKey }).catch(() => {});
414
+ deps.logger?.info(`session reset at turn ${deps.turn} (cap ${SESSION_RESET_TURN_CAP}) — context bounded`);
415
+ }
416
+
309
417
  const startedAt = Date.now();
310
418
 
311
419
  const { runId } = await runtime.subagent.run({
@@ -319,28 +427,36 @@ export async function runAutoplayTurn(deps: AutoplayTurnDeps): Promise<AutoplayT
319
427
  const decisionMs = Math.max(0, Date.now() - startedAt);
320
428
 
321
429
  // 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).
323
- if (session.lastActAt !== actAtBefore) {
324
- return { acted: true, via: "tool" };
430
+ // the cycle as acted; do NOT parse+post again (would double-apply). The
431
+ // session PERSISTS no deleteSession here; it's the per-match request log.
432
+ if (state.lastActAt !== actAtBefore) {
433
+ return { acted: true, via: "tool", outcome: "acted", rawText: "", latencyMs: decisionMs };
325
434
  }
326
435
 
327
436
  let text = "";
328
437
  try {
438
+ // The persistent transcript GROWS each turn; we read the most recent slice
439
+ // and lastAssistantText returns the LATEST assistant reply (not an old one).
329
440
  const { messages } = await runtime.subagent.getSessionMessages({ sessionKey: deps.sessionKey, limit: 10 });
330
441
  text = lastAssistantText(messages);
331
442
  } catch (e) {
332
443
  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
444
  }
445
+ // NOTE: no per-turn deleteSession — the session persists for the whole match so
446
+ // the agent accumulates its own decision history. Reset happens only on
447
+ // leave/new-match (index.ts) or the turn cap above.
338
448
 
339
449
  let moves: Move[];
340
450
  try {
341
451
  moves = parseMoves(text);
342
452
  } catch (e) {
343
- return { acted: false, reason: e instanceof DecideError ? e.message : String(e) };
453
+ return {
454
+ acted: false,
455
+ outcome: "no_response",
456
+ reason: e instanceof DecideError ? e.message : String(e),
457
+ rawText: text,
458
+ latencyMs: decisionMs,
459
+ };
344
460
  }
345
461
 
346
462
  const { posted } = await executeMoves(deps.matchId, moves, {
@@ -349,6 +465,7 @@ export async function runAutoplayTurn(deps: AutoplayTurnDeps): Promise<AutoplayT
349
465
  did: deps.did,
350
466
  token: deps.token,
351
467
  decisionMs,
468
+ state,
352
469
  });
353
- return { acted: true, via: "post", posted };
470
+ return { acted: true, via: "post", posted, outcome: "acted", rawText: text, moves, latencyMs: decisionMs };
354
471
  }
package/src/generate.ts CHANGED
@@ -14,17 +14,26 @@
14
14
  import { Type } from "@sinclair/typebox";
15
15
  import type { AnyAgentTool } from "openclaw/plugin-sdk/core";
16
16
  import { ok, err, venueUrl, agentIdOf, apiKeyOf, rememberDid, rememberToken, type GameSpec, type PluginCfg } from "./tools.js";
17
- import { session } from "./state.js";
17
+ import { session, type RuntimeSession } from "./state.js";
18
18
 
19
19
  export type Venue = { id: string; origin: string; specUrl?: string };
20
20
  export type Seat = { id?: string; token?: string; controls?: string[]; agentId?: string; started?: boolean; managerUrl?: string };
21
21
 
22
- // Seat per venue (id/token/controls/agentId). The soccer seat is mirrored into
23
- // `session` so the existing service watcher + seat-poller keep working.
22
+ // Seat per venue (id/token/controls/agentId).
24
23
  const seats = new Map<string, Seat>();
25
24
  export function _resetSeats(): void { seats.clear(); }
26
25
  export function seatOf(venueId: string): Seat | undefined { return seats.get(venueId); }
27
26
 
27
+ // Per-venue runtime state. The autoplay service registers each realtime venue's
28
+ // RuntimeSession here so the GENERATED act tool stamps the SAME lastActAt the
29
+ // venue's watcher + decision core read (act-verification agreement across all
30
+ // three writers). Unregistered venues (interactive-only) fall back to the global
31
+ // session, preserving single-venue behaviour.
32
+ const venueStates = new Map<string, RuntimeSession>();
33
+ export function setVenueState(venueId: string, state: RuntimeSession): void { venueStates.set(venueId, state); }
34
+ export function _resetVenueStates(): void { venueStates.clear(); }
35
+ function stateOf(venueId: string): RuntimeSession { return venueStates.get(venueId) ?? session; }
36
+
28
37
  function sub(route: string, kv: Record<string, string>): string {
29
38
  return Object.entries(kv).reduce((r, [k, v]) => r.replace(`{${k}}`, encodeURIComponent(v)), route);
30
39
  }
@@ -95,8 +104,10 @@ export async function joinVenue(
95
104
  // seat.id ends up undefined and the observe loop hits /…//… → 404 → reclaim.
96
105
  const seat: Seat = { id: d[sm.id] ?? opts.matchId, token: d[sm.token], controls: d[sm.controls] ?? [], agentId: d.did ?? meId, started: d.started, managerUrl: d.managerUrl };
97
106
  seats.set(venue.id, seat);
98
- // soccer back-compat: the service watcher + seat-poller read `session`.
99
- session.matchId = seat.id ?? null; session.players = seat.controls ?? []; session.token = seat.token ?? null; session.did = seat.agentId ?? null;
107
+ // Mirror into this venue's runtime state (the global session when none is
108
+ // registered) so the watcher/seat-poller and interactive tools see the seat.
109
+ const st = stateOf(venue.id);
110
+ st.matchId = seat.id ?? null; st.players = seat.controls ?? []; st.token = seat.token ?? null; st.did = seat.agentId ?? null;
100
111
  return seat;
101
112
  }
102
113
 
@@ -164,15 +175,15 @@ export function generateVenueTools(venue: Venue, spec: GameSpec, cfg: PluginCfg)
164
175
  description: desc + " Set actions for ALL players you control in ONE call: pass moves=[{player, type, …}], one per player.",
165
176
  parameters: paramsSchema({}, { moves: { type: "array", items: paramsSchema(s.params ?? {}, { player: { type: "string", description: "your player id, e.g. home-9" }, type: { type: "string", enum: enumList, description: "the action" } }, ["player", "type"]) } }, ["moves"]),
166
177
  async execute(_id, params) {
167
- const seat = seats.get(venue.id) ?? {}; const d = did(venue.id, cfg);
178
+ const seat = seats.get(venue.id) ?? {}; const d = did(venue.id, cfg); const st = stateOf(venue.id);
168
179
  const applied: unknown[] = [];
169
180
  for (const m of ((params as any).moves ?? []) as Record<string, unknown>[]) {
170
181
  const action = String(m.type ?? "").trim();
171
182
  if (!enumList.includes(action)) { applied.push({ player: m.player, error: `type must be one of ${JSON.stringify(enumList)}` }); continue; }
172
183
  const path = sub(actRoute, { matchId: seat.id ?? "", did: d, playerId: String(m.player ?? "") });
173
- const body = { agentId: d, type: action, ...whitelist(m, s.params ?? {}), ...(session.lockstep ? { turn: session.turn } : {}) };
184
+ const body = { agentId: d, type: action, ...whitelist(m, s.params ?? {}), ...(st.lockstep ? { turn: st.turn } : {}) };
174
185
  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
186
+ if (r.ok) st.lastActAt = Date.now(); // act-verification: the agent moved its team this turn
176
187
  applied.push(r.ok ? { player: m.player, type: action } : { player: m.player, error: r.data?.error ?? r.status });
177
188
  }
178
189
  return ok({ applied });
@@ -187,7 +198,7 @@ export function generateVenueTools(venue: Venue, spec: GameSpec, cfg: PluginCfg)
187
198
  const path = sub(actRoute, { matchId: seat.id ?? "", did: d, playerId: String(p.player ?? "") });
188
199
  const r = await vfetch(base, path, { cfg, did: d, method: "POST", body: { type: action, ...whitelist(p, s.params ?? {}) }, token: seat.token });
189
200
  if (!r.ok) return ok({ error: r.data?.error ?? `act ${r.status}` });
190
- session.lastActAt = Date.now(); // act-verification: the agent acted this turn
201
+ stateOf(venue.id).lastActAt = Date.now(); // act-verification: the agent acted this turn
191
202
  return ok({ type: action, result: r.data });
192
203
  } } as AnyAgentTool);
193
204
  }
@@ -204,7 +215,8 @@ export function generateVenueTools(venue: Venue, spec: GameSpec, cfg: PluginCfg)
204
215
  const path = sub(s.route, { matchId: seat.id, did: d });
205
216
  const r = await vfetch(base, path, { cfg, did: d, method: "POST", body: { agentId: d }, token: seat.token });
206
217
  // Clear our seat either way — a failed leave shouldn't leave us wedged.
207
- seats.delete(venue.id); session.matchId = null; session.players = []; session.token = null;
218
+ seats.delete(venue.id);
219
+ { const st = stateOf(venue.id); st.matchId = null; st.players = []; st.token = null; }
208
220
  if (!r.ok) return ok({ error: r.data?.error ?? `leave ${r.status}`, note: "seat cleared locally; you can try joining again" });
209
221
  return ok({ left: r.data?.left ?? seat.id, ...r.data, hint: `you're free — ${c.join?.tool ?? "join"} another room` });
210
222
  } } as AnyAgentTool);
@@ -298,9 +310,25 @@ export function isRealtimeVenue(spec?: GameSpec | null): boolean {
298
310
  * (register() is sync). Null when none is realtime. Drives seating/observe/act
299
311
  * entirely from {venue, spec} — no soccer literals in the service. */
300
312
  export function defaultRealtimeVenue(): { venue: Venue; spec: GameSpec } | null {
313
+ return defaultRealtimeVenues()[0] ?? null;
314
+ }
315
+
316
+ /** EVERY realtime venue from the baked defaults — the service starts one watcher
317
+ * per entry, each with its own runtime state, so multiple games (soccer + a
318
+ * future golf) play concurrently without clobbering each other. Soccer is simply
319
+ * the only realtime venue today; nothing here is soccer-specific. */
320
+ export function defaultRealtimeVenues(): { venue: Venue; spec: GameSpec }[] {
321
+ const out: { venue: Venue; spec: GameSpec }[] = [];
301
322
  for (const venue of DEFAULT_VENUES) {
302
323
  const spec = DEFAULT_SPECS[venue.id];
303
- if (isRealtimeVenue(spec)) return { venue, spec };
324
+ if (isRealtimeVenue(spec)) out.push({ venue, spec });
304
325
  }
305
- return null;
326
+ return out;
327
+ }
328
+
329
+ /** Whether a venue with this client prefix is among the baked defaults — used to
330
+ * gate soccer-only member tools (skins/rename/identity) so they aren't offered
331
+ * on a deployment with no soccer venue. register() is sync, hence the baked set. */
332
+ export function hasBakedVenuePrefix(prefix: string): boolean {
333
+ return DEFAULT_VENUES.some((v) => DEFAULT_SPECS[v.id]?.client?.prefix === prefix);
306
334
  }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Watcher contention probe — diagnostic harness (NOT production code).
3
+ *
4
+ * Drives a real pitch match through the REAL watcher mechanism
5
+ * (startObserveWatcher: same single-in-flight latch + deliverTimeoutMs watchdog)
6
+ * and only swaps the `deliver` callback — exactly as production swaps in
7
+ * runAutoplayTurn. Confirms or disproves hypothesis B: that decisions freeze
8
+ * because the shared single-slot epyc2 model (llama.cpp --parallel 1) is busy.
9
+ *
10
+ * Reuses watcher.ts read-only; touches no production behaviour. Stub seats only,
11
+ * no paid gateways. See docs/design/watcher-contention-probe.md.
12
+ *
13
+ * Usage:
14
+ * tsx src/probe/contention-probe.ts --mode fast --decisions 6
15
+ * tsx src/probe/contention-probe.ts --mode slow:3000 --deliver-timeout-ms 2000
16
+ * tsx src/probe/contention-probe.ts --mode real
17
+ * tsx src/probe/contention-probe.ts --mode real+load:2
18
+ */
19
+ import { startObserveWatcher, type DeliverPrompt } from '../watcher.js'
20
+ import type { WatcherLogger } from '../watcher.js'
21
+
22
+ // ── args ─────────────────────────────────────────────────────────────────────
23
+ const arg = (k: string, d?: string) => {
24
+ const hit = process.argv.find(a => a.startsWith(`--${k}=`)) ?? process.argv[process.argv.indexOf(`--${k}`) + 1]
25
+ if (process.argv.includes(`--${k}`)) return process.argv[process.argv.indexOf(`--${k}`) + 1] ?? d
26
+ return hit?.startsWith('--') ? d : (hit?.includes('=') ? hit.split('=')[1] : d)
27
+ }
28
+ const BASE = process.env.BASE_URL ?? 'http://localhost:3010'
29
+ const MODE = arg('mode', 'real')! // fast | slow:<ms> | real | real+load:<N>
30
+ const DECISIONS = Number(arg('decisions', '6'))
31
+ const TEAM_SIZE = Number(arg('team-size', '11'))
32
+ const DELIVER_TIMEOUT_MS = Number(arg('deliver-timeout-ms', '45000'))
33
+ const MODEL_URL = process.env.MODEL_URL ?? 'http://10.0.0.226:8080/v1' // epyc2-local
34
+ const MODEL_NAME = process.env.MODEL_NAME ?? 'gemma-4'
35
+ const LOAD_CONC = Number((MODE.match(/real\+load:(\d+)/) ?? [])[1] ?? arg('load-conc', '2'))
36
+
37
+ const nowMs = () => Number(process.hrtime.bigint() / 1_000_000n)
38
+
39
+ // ── model backend (the injected "subagent run") ───────────────────────────────
40
+ type LlamaTimings = { prompt_ms?: number; predicted_ms?: number }
41
+ type Turn = { rawText: string; timings?: LlamaTimings; runStartedAt: number; completedAt: number }
42
+
43
+ async function callModel(p: DeliverPrompt): Promise<Turn> {
44
+ const runStartedAt = nowMs()
45
+ const res = await fetch(`${MODEL_URL}/chat/completions`, {
46
+ method: 'POST', headers: { 'content-type': 'application/json' },
47
+ body: JSON.stringify({
48
+ model: MODEL_NAME, max_tokens: 256, temperature: 0.3,
49
+ messages: [{ role: 'system', content: p.system || 'You are a soccer coach.' }, { role: 'user', content: p.user }],
50
+ }),
51
+ })
52
+ const j = await res.json() as { choices?: { message?: { content?: string } }[]; timings?: LlamaTimings }
53
+ return { rawText: j.choices?.[0]?.message?.content ?? '', timings: j.timings, runStartedAt, completedAt: nowMs() }
54
+ }
55
+
56
+ function makeBackend(mode: string): (p: DeliverPrompt) => Promise<Turn> {
57
+ if (mode === 'fast') return async () => ({ rawText: '{"moves":{}}', runStartedAt: nowMs(), completedAt: nowMs() })
58
+ const slow = mode.match(/^slow:(\d+)/)
59
+ if (slow) {
60
+ const ms = Number(slow[1])
61
+ return async () => { const s = nowMs(); await new Promise(r => setTimeout(r, ms)); return { rawText: '{"moves":{}}', runStartedAt: s, completedAt: nowMs() } }
62
+ }
63
+ return callModel // 'real' and 'real+load:N' both call the model; load runs separately
64
+ }
65
+
66
+ // ── load generator: occupy the SAME model slot (the independent variable) ──────
67
+ function startLoadGen(concurrency: number, signal: AbortSignal): void {
68
+ const oneLoop = async () => {
69
+ while (!signal.aborted) {
70
+ try {
71
+ await fetch(`${MODEL_URL}/chat/completions`, {
72
+ method: 'POST', headers: { 'content-type': 'application/json' }, signal,
73
+ // Long generation mimics a real interactive/reasoning turn that holds
74
+ // the --parallel 1 slot for many seconds (env LOAD_TOKENS to tune).
75
+ body: JSON.stringify({ model: MODEL_NAME, max_tokens: Number(process.env.LOAD_TOKENS ?? '1500'), temperature: 0.7,
76
+ messages: [{ role: 'user', content: 'Write a long, detailed essay about deep-sky astronomy and galaxy formation.' }] }),
77
+ }).then(r => r.json()).catch(() => {})
78
+ } catch { /* aborted */ }
79
+ }
80
+ }
81
+ for (let i = 0; i < concurrency; i++) void oneLoop()
82
+ }
83
+
84
+ // ── sample + report ────────────────────────────────────────────────────────────
85
+ type Sample = { seq: number; queueWaitMs: number; inferenceMs: number; totalMs: number; timedOut: boolean }
86
+ const samples: Sample[] = []
87
+ let timeoutWarns = 0
88
+
89
+ const pct = (xs: number[], p: number) => { if (!xs.length) return NaN; const s = [...xs].sort((a, b) => a - b); return s[Math.min(s.length - 1, Math.floor(p / 100 * s.length))]! }
90
+ const r0 = (n: number) => Number.isFinite(n) ? Math.round(n) : NaN
91
+
92
+ function report(): void {
93
+ const inf = samples.map(s => s.inferenceMs).filter(Number.isFinite)
94
+ const qw = samples.map(s => s.queueWaitMs).filter(Number.isFinite)
95
+ const tot = samples.map(s => s.totalMs)
96
+ const frozen = samples.filter(s => s.timedOut).length
97
+ console.log(`\n${'═'.repeat(64)}`)
98
+ console.log(` CONTENTION PROBE — mode=${MODE} watchdog=${DELIVER_TIMEOUT_MS}ms n=${samples.length}`)
99
+ console.log(`${'═'.repeat(64)}`)
100
+ const row = (label: string, xs: number[]) => console.log(` ${label.padEnd(22)} p50=${String(r0(pct(xs, 50))).padStart(7)} p95=${String(r0(pct(xs, 95))).padStart(7)} max=${String(xs.length ? r0(Math.max(...xs)) : NaN).padStart(7)}`)
101
+ if (inf.length) row('inference (compute) ms', inf)
102
+ if (qw.length) row('queue-wait ms', qw)
103
+ row('total ms', tot)
104
+ console.log(` ${'─'.repeat(60)}`)
105
+ console.log(` watchdog timeouts (freezes): ${frozen}/${samples.length} · warn lines: ${timeoutWarns}`)
106
+ const verdict =
107
+ frozen > 0 && inf.length && pct(qw, 50) > pct(inf, 50)
108
+ ? '⚠ B SUPPORTED — queue-wait dominates; decisions starved by a busy slot'
109
+ : frozen > 0
110
+ ? '⚠ freezes occurred — inspect split (could be slow model, not contention)'
111
+ : '✓ no freezes in this run'
112
+ console.log(` VERDICT: ${verdict}`)
113
+ console.log(`${'═'.repeat(64)}\n`)
114
+ }
115
+
116
+ // ── bootstrap a stub match (no LLM) ────────────────────────────────────────────
117
+ async function bootstrap(): Promise<{ matchId: string; did: string; token: string; playerIds: string[] }> {
118
+ const agentId = `probe-${MODE.replace(/[^a-z0-9]/gi, '')}-${TEAM_SIZE}`
119
+ const res = await fetch(`${BASE}/quickmatch`, {
120
+ method: 'POST', headers: { 'content-type': 'application/json' },
121
+ body: JSON.stringify({ agentId, teamSize: TEAM_SIZE, name: `probe ${MODE}` }),
122
+ })
123
+ const j = await res.json() as { matchId: string; did?: string; token: string; playerIds: string[] }
124
+ return { matchId: j.matchId, did: j.did ?? agentId, token: j.token, playerIds: j.playerIds }
125
+ }
126
+
127
+ async function postChase(matchId: string, playerId: string, token: string): Promise<void> {
128
+ await fetch(`${BASE}/matches/${matchId}/players/${playerId}/action`, {
129
+ method: 'POST', headers: { 'content-type': 'application/json', 'x-agent-token': token },
130
+ body: JSON.stringify({ type: 'chase' }),
131
+ }).catch(() => {})
132
+ }
133
+
134
+ // ── run ─────────────────────────────────────────────────────────────────────
135
+ async function main(): Promise<void> {
136
+ const { matchId, did, token, playerIds } = await bootstrap()
137
+ console.log(`[probe] match=${matchId} did=${did} players=${playerIds.length} mode=${MODE} → driving via REAL startObserveWatcher`)
138
+
139
+ const ac = new AbortController()
140
+ if (MODE.startsWith('real+load')) { startLoadGen(LOAD_CONC, ac.signal); console.log(`[probe] load generator: ${LOAD_CONC} concurrent loops on ${MODEL_URL}`) }
141
+
142
+ const backend = makeBackend(MODE)
143
+ const logger: WatcherLogger = {
144
+ info: () => {},
145
+ warn: (m) => { if (/exceeded \d+ms/.test(m)) timeoutWarns++ },
146
+ error: (m) => console.error(`[deliver error] ${m}`),
147
+ }
148
+
149
+ const deliver = async (p: DeliverPrompt): Promise<void> => {
150
+ const deliveredAt = nowMs()
151
+ let turn: Turn
152
+ try { turn = await backend(p) } catch (e) { console.error('[backend]', String(e)); return }
153
+ const totalMs = turn.completedAt - deliveredAt
154
+ const inferenceMs = turn.timings ? (turn.timings.prompt_ms ?? 0) + (turn.timings.predicted_ms ?? 0) : (MODE.startsWith('real') ? NaN : totalMs)
155
+ const queueWaitMs = Number.isFinite(inferenceMs) ? Math.max(0, totalMs - inferenceMs) : NaN
156
+ const timedOut = totalMs >= DELIVER_TIMEOUT_MS
157
+ samples.push({ seq: p.tick, queueWaitMs, inferenceMs, totalMs, timedOut })
158
+ await postChase(matchId, playerIds[1] ?? playerIds[0]!, token) // keep the seat live
159
+ console.log(` decision #${samples.length} tick=${p.tick} total=${r0(totalMs)}ms infer=${r0(inferenceMs)}ms queue=${r0(queueWaitMs)}ms${timedOut ? ' ⚠TIMEOUT' : ''}`)
160
+ if (samples.length >= DECISIONS) ac.abort()
161
+ }
162
+
163
+ await startObserveWatcher(
164
+ { serverUrl: BASE, matchId, agentId: did, mode: 'easy', deliverTimeoutMs: DELIVER_TIMEOUT_MS },
165
+ deliver,
166
+ { signal: ac.signal, logger },
167
+ )
168
+ report()
169
+ process.exit(0)
170
+ }
171
+
172
+ main().catch((e) => { console.error(e); process.exit(1) })
package/src/state.ts CHANGED
@@ -1,15 +1,20 @@
1
1
  /**
2
- * Shared plugin state, module-level so all parts of the plugin (watcher, tools,
3
- * service) see the same values.
2
+ * Per-venue autoplay runtime state.
4
3
  *
5
- * - `matchId`: the room this agent is currently in (set by the matchmaking
6
- * tools; null until the human asks their agent to join/create a game).
7
- * - `players`: the player ids this agent controls (its WHOLE side).
8
- * - `joinAndWatch`: installed by the service at startup; matchmaking tools call
9
- * it to join a room and start the observation loop.
4
+ * ONE `RuntimeSession` per venue the agent is actively playing. The autoplay
5
+ * service creates a separate instance per realtime venue (soccer, golf, ) and
6
+ * threads it through the watcher + decision core, so two venues running at once
7
+ * never clobber each other's matchId/players/token/counters. The seat itself
8
+ * (id/token/controls/agentId) is the per-venue `seats` map in generate.ts; this
9
+ * holds the loop's mutable bookkeeping. Mirrors the pitch's session-keyed
10
+ * decision capture (DecisionCapture keys by matchId:agentId).
11
+ *
12
+ * - `matchId`: the room this venue's loop is in (null until seated).
13
+ * - `players`: the player ids this agent controls at this venue (its WHOLE side).
14
+ * - `joinAndWatch`: installed by the service; seat into a room and start the loop.
10
15
  * - `turn`/`lockstep`: lockstep-mode bookkeeping.
11
16
  */
12
- export const session: {
17
+ export type RuntimeSession = {
13
18
  matchId: string | null
14
19
  players: string[]
15
20
  /** Seat token issued by the server at join (proof of identity for actions). */
@@ -38,4 +43,18 @@ export const session: {
38
43
  * find-or-create) and start the observe/act loop. `params` are venue join params
39
44
  * (e.g. teamSize/team for soccer). Returns the seat the loop is now driving. */
40
45
  joinAndWatch: ((matchId?: string, params?: Record<string, unknown>) => Promise<{ id?: string; controls?: string[]; started?: boolean }>) | null
41
- } = { matchId: null, players: [], token: null, did: null, turn: 0, lockstep: false, lastModel: null, lastActAt: null, noActTurns: 0, promptDeliveredAt: null, joinAndWatch: null }
46
+ }
47
+
48
+ /** A fresh per-venue runtime session (all fields zeroed). */
49
+ export function createSession(): RuntimeSession {
50
+ return { matchId: null, players: [], token: null, did: null, turn: 0, lockstep: false, lastModel: null, lastActAt: null, noActTurns: 0, promptDeliveredAt: null, joinAndWatch: null }
51
+ }
52
+
53
+ /**
54
+ * The DEFAULT/global runtime session. It backs the interactive, soccer-specific
55
+ * tools (pitchClient, member perks) and is the default `state` everywhere a
56
+ * per-venue instance isn't threaded in — so single-venue callers (and the whole
57
+ * existing test suite) keep working unchanged. The autoplay service overrides it
58
+ * per realtime venue.
59
+ */
60
+ export const session: RuntimeSession = createSession()
package/src/tools.ts CHANGED
@@ -19,15 +19,11 @@ const PLUGIN_VERSION: string = (() => {
19
19
 
20
20
  export type PluginCfg = {
21
21
  serverUrl?: string;
22
- /** Auto quick-match at startup: find a waiting room (teamSize) or create one.
23
- * The standard zero-touch way to get a demo/bot team playing. */
22
+ /** Auto quick-match at startup: find-or-create a room. The standard zero-touch
23
+ * way to get a demo/bot agent playing. */
24
24
  autoJoin?: boolean;
25
- /** Preferred players-per-side for quick-match / created rooms (default 5). */
26
- teamSize?: number;
27
25
  /** Optional: pin a specific room at startup (overrides autoJoin). */
28
26
  matchId?: string;
29
- /** Optional side preference. */
30
- team?: "home" | "away";
31
27
  sessionKey?: string;
32
28
  /** AgentNet API key — sent as Bearer on join so the server can verify identity
33
29
  * (REQUIRE_AUTH). Falls back to the AGENTNET_API_KEY env var. */
@@ -35,18 +31,21 @@ export type PluginCfg = {
35
31
  /** AgentNet accounts service (person plane) — used to redeem owner claim codes. */
36
32
  accountsUrl?: string;
37
33
  mode?: "easy" | "advanced" | "both";
38
- /** Default team identity (changeable at runtime via soccer_set_identity). */
39
- teamName?: string;
40
- nation?: string; // ISO code like NL/IT/CN → flag shown in the viewer
41
- clan?: string; // e.g. 魔兽工会-style guild tag
42
- style?: string; // playing identity, e.g. 全攻全守 total football — shapes play
43
34
  /** Path to a human-editable strategy.md injected into the watcher move prompt
44
35
  * (Phase 5). Capped ~1k chars, mtime-cached so edits apply mid-match. */
45
36
  strategyFile?: string;
37
+ /** Venue-neutral display identity sent on join — e.g. {name,nation,clan,style}
38
+ * for soccer; any venue's identity fields. Changeable at runtime where the
39
+ * venue supports it (e.g. soccer_set_identity). */
40
+ identity?: Record<string, unknown>;
41
+ /** Venue join params merged into the join body — e.g. {teamSize,team} for
42
+ * soccer, {holes} for golf. Whitelisted server-side against the venue's spec,
43
+ * so unknown fields are ignored, not rejected. */
44
+ join?: Record<string, unknown>;
46
45
  };
47
46
 
48
47
  export function identityOf(cfg: PluginCfg): Record<string, unknown> {
49
- return { name: cfg.teamName, nation: cfg.nation, clan: cfg.clan, style: cfg.style };
48
+ return cfg.identity ?? {};
50
49
  }
51
50
 
52
51
  // ── /spec manifest (Phase 4 — generate tools from the published action schema) ──
@@ -151,7 +150,7 @@ export function agentIdOf(cfg: PluginCfg): string {
151
150
  /** Per-agent tmp caches shared across processes (the gateway joins; chat-turn
152
151
  * processes need the same token + DID to act). Keyed on the stable idKey. */
153
152
  function cachePath(kind: string, key: string): string {
154
- return joinPath(tmpdir(), `agentnet-soccer-${kind}-${key.replace(/[^a-zA-Z0-9_-]/g, "_")}.json`);
153
+ return joinPath(tmpdir(), `agentnet-messier-${kind}-${key.replace(/[^a-zA-Z0-9_-]/g, "_")}.json`);
155
154
  }
156
155
  export function rememberToken(cfg: PluginCfg, token: string): void {
157
156
  session.token = token;
@@ -227,7 +226,7 @@ export function pitchClient(cfg: PluginCfg) {
227
226
  const res = await fetch(`${base}/quickmatch`, {
228
227
  method: "POST",
229
228
  headers: { "Content-Type": "application/json", ...RUNTIME, ...bearer() },
230
- body: JSON.stringify({ agentId, teamSize: opts.teamSize ?? cfg.teamSize ?? 5, team: opts.team, identity: identityOf(cfg) }),
229
+ body: JSON.stringify({ agentId, teamSize: opts.teamSize ?? (cfg.join?.teamSize as number | undefined) ?? 5, team: opts.team ?? (cfg.join?.team as "home" | "away" | undefined), identity: identityOf(cfg) }),
231
230
  });
232
231
  if (!res.ok) throw new Error(`pitch quickmatch: ${res.status} ${await res.text()}`);
233
232
  const data = (await res.json()) as any;
@@ -326,6 +325,10 @@ export function venueUrl(origin: string, cfg: PluginCfg): string {
326
325
  // configured serverUrl). Short names fall back to a baked default. No OS
327
326
  // environment read here — keeps this network module config-only, so
328
327
  // OpenClaw's env+network scanner stays quiet.
328
+ // NOTE: a NEW venue (e.g. golf) must advertise its origin as a full http(s)
329
+ // URL in the registry; an unknown short name has no entry in VENUE_DEFAULTS and
330
+ // would fall through to the pitch URL (wrong host). Full-URL origins are the
331
+ // contract for any venue not co-located with the pitch.
329
332
  if (origin.startsWith("http://") || origin.startsWith("https://")) return origin;
330
333
  if (origin === "pitch") return (cfg.serverUrl ?? "http://localhost:3010").replace(/\/$/, "");
331
334
  return VENUE_DEFAULTS[origin] ?? (cfg.serverUrl ?? "http://localhost:3010");
package/src/watcher.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  import { statSync, readFileSync } from "node:fs";
20
20
  import { describeTeam, type TeamView } from "./format.js";
21
21
  import { fetchMatchSpec, type GameSpec, type PluginCfg } from "./tools.js";
22
- import { session } from "./state.js";
22
+ import { session, type RuntimeSession } from "./state.js";
23
23
 
24
24
  export type WatcherCfg = {
25
25
  serverUrl?: string;
@@ -83,6 +83,10 @@ export type WatcherOptions = {
83
83
  /** Called when the server no longer knows our claim (404 — e.g. it restarted).
84
84
  * Should re-claim players; the watcher then reconnects. */
85
85
  onReclaim?: () => Promise<void>;
86
+ /** Per-venue runtime session (defaults to the global one). The act-verification
87
+ * baseline (lastActAt), the controlled-players list, and the no-act counter all
88
+ * live here, so two venues' watchers don't clobber each other. */
89
+ state?: RuntimeSession;
86
90
  };
87
91
 
88
92
  /** One SSE block → {event?, data?}. Named events carry the per-match spec
@@ -104,8 +108,10 @@ export function parseSseBlock(block: string): { event?: string; data?: string }
104
108
  * - `system`: the STATIC game rules (spec.instructions.system) — passed as
105
109
  * subagent.run's extraSystemPrompt. Empty on the fallback path (no spec).
106
110
  * - `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 };
111
+ * + the act/JSON directive. This is what changes every tick.
112
+ * - `tick`/`clock`: the delivered frame's game time, threaded through so the
113
+ * deliver callback can stamp the per-turn decision report. */
114
+ export type DeliverPrompt = { system: string; user: string; tick: number; clock: number };
109
115
 
110
116
  const actDirective = (actTool: string) =>
111
117
  `Decide and act NOW: make ONE ${actTool} call with a move for every player you control — ` +
@@ -129,6 +135,8 @@ export function prompt(v: TeamView & { summary?: string }, mode: "easy" | "advan
129
135
  `${v.summary}\n\n` +
130
136
  `${ins.play}\n\n` +
131
137
  actDirective(actTool),
138
+ tick: v.tick,
139
+ clock: v.clock,
132
140
  };
133
141
  }
134
142
  // Fallback (pre-envelope server or handshake not yet arrived). describeTeam is
@@ -139,6 +147,8 @@ export function prompt(v: TeamView & { summary?: string }, mode: "easy" | "advan
139
147
  return {
140
148
  system: "",
141
149
  user: stratBlock + `${rendered}\n\n` + actDirective(actTool),
150
+ tick: v.tick,
151
+ clock: v.clock,
142
152
  };
143
153
  }
144
154
 
@@ -157,6 +167,7 @@ export async function startObserveWatcher(
157
167
  options: WatcherOptions = {},
158
168
  ): Promise<void> {
159
169
  const { signal, logger } = options;
170
+ const state = options.state ?? session; // per-venue runtime (default: global)
160
171
  const base = (cfg.serverUrl ?? "http://localhost:3010").replace(/\/$/, "");
161
172
  const tag = cfg.label ?? "agentnet"; // venue-derived log prefix
162
173
  const actTool = cfg.actTool ?? "soccer_play";
@@ -196,7 +207,7 @@ export async function startObserveWatcher(
196
207
  const obs = latest;
197
208
  // Act-verification baseline: if the act tool doesn't stamp lastActAt past
198
209
  // this during the run, the agent was prompted but never moved its team.
199
- const actAtBefore = session.lastActAt;
210
+ const actAtBefore = state.lastActAt;
200
211
 
201
212
  // The watchdog and the deliver race for the latch. Whichever fires FIRST
202
213
  // owns the release; `settled` makes the loser a no-op, so a slow deliver that
@@ -213,9 +224,9 @@ export async function startObserveWatcher(
213
224
  busy = false;
214
225
  if (onTimeout) {
215
226
  logger?.warn(`[${tag}] deliver exceeded ${deliverTimeoutMs}ms — releasing latch, agent may be stalled`);
216
- } else if (session.lastActAt === actAtBefore) {
227
+ } else if (state.lastActAt === actAtBefore) {
217
228
  // The run finished but no action was POSTed — surfaced, never gated.
218
- session.noActTurns++;
229
+ state.noActTurns++;
219
230
  logger?.warn(`[${tag}] agent responded without acting (turn ${seq})`);
220
231
  }
221
232
  maybeDeliver();
@@ -273,7 +284,7 @@ export async function startObserveWatcher(
273
284
  ensureSpec(); // protection: frame before handshake → fetch via API
274
285
  // The team stream is authoritative for which players we control, so
275
286
  // a live ratio change on the server is followed without reconnecting.
276
- session.players = v.mine.map((p) => p.id);
287
+ state.players = v.mine.map((p) => p.id);
277
288
  latest = v;
278
289
  latestSeq++;
279
290
  maybeDeliver();