@agentmessier/openclaw-agent-messier 0.3.12 → 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 +72 -60
- package/openclaw.plugin.json +36 -78
- package/package.json +2 -2
- package/src/decide.ts +26 -6
- package/src/generate.ts +40 -12
- package/src/probe/contention-probe.ts +172 -0
- package/src/state.ts +28 -9
- package/src/tools.ts +17 -14
- package/src/watcher.ts +10 -5
package/index.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
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,
|
|
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";
|
|
5
|
+
import { session, createSession, type RuntimeSession } from "./src/state.js";
|
|
6
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
|
|
@@ -19,40 +19,60 @@ 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, …) +
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
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).
|
|
31
|
-
//
|
|
32
|
-
//
|
|
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
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const
|
|
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;
|
|
58
78
|
// The per-match session key currently in play. We persist ONE session per match
|
|
@@ -65,15 +85,16 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
65
85
|
|
|
66
86
|
start: async (ctx) => {
|
|
67
87
|
const agentId = agentIdOf(cfg);
|
|
68
|
-
// Config-derived join body
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
|
|
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) });
|
|
72
93
|
|
|
73
94
|
// Seat into a room (matchId omitted = quickmatch find-or-create) and
|
|
74
|
-
// (re)start the observation loop. Installed into
|
|
95
|
+
// (re)start the observation loop. Installed into this venue's state so the
|
|
75
96
|
// generated *_join tool / chat handoff can drive it.
|
|
76
|
-
|
|
97
|
+
state.joinAndWatch = async (matchId, params) => {
|
|
77
98
|
const seat = await joinVenue(venue, spec, cfg, { matchId, params: params ?? {}, extra: joinExtra() });
|
|
78
99
|
ctx.logger.info(`[${label}] ${agentId} seated in ${seat.id} (${seat.controls?.length ?? 0} to control)${seat.started ? " — live" : " — waiting for opponent"}`);
|
|
79
100
|
|
|
@@ -97,43 +118,37 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
97
118
|
}
|
|
98
119
|
// Option A (docs/design/agent-bridge-plugin.md §2): force ONE agent
|
|
99
120
|
// turn per situation and PARSE its JSON reply — no longer wait for the
|
|
100
|
-
// agent to proactively call
|
|
101
|
-
// 2-decisions-in-157s).
|
|
102
|
-
// chat play; only AUTOPLAY
|
|
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.
|
|
103
124
|
//
|
|
104
125
|
// ONE persistent session per MATCH (sessionKey:matchId, stable across
|
|
105
|
-
// turns) so the agent accumulates context — its 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.
|
|
126
|
+
// turns) so the agent accumulates context — its own prior decisions.
|
|
108
127
|
const turn = move++;
|
|
109
128
|
const matchSessionKey = `${sessionKey}:${seat.id}`;
|
|
110
129
|
const idempotencyKey = `agentnet:${venue.id}:${seat.id}:${agentId}:${turn}`;
|
|
111
|
-
const did =
|
|
130
|
+
const did = state.did ?? agentId;
|
|
112
131
|
const sys = system || undefined;
|
|
113
132
|
const msg = `${user}${STRICT_JSON_DIRECTIVE}`;
|
|
114
133
|
// Mark when this prompt was handed to the agent: x-agent-decision-ms is
|
|
115
134
|
// the prompt→reply latency measured inside runAutoplayTurn.
|
|
116
|
-
|
|
135
|
+
state.promptDeliveredAt = Date.now();
|
|
117
136
|
const result = await runAutoplayTurn({
|
|
118
137
|
runtime: api.runtime,
|
|
119
138
|
sessionKey: matchSessionKey,
|
|
120
139
|
turn,
|
|
121
140
|
idempotencyKey,
|
|
122
141
|
// The static rulebook (spec.instructions.system) rides the SYSTEM
|
|
123
|
-
// channel; the per-tick board is the user message.
|
|
124
|
-
// fallback path → no extra system prompt.
|
|
142
|
+
// channel; the per-tick board is the user message.
|
|
125
143
|
extraSystemPrompt: sys,
|
|
126
|
-
// Steer the agent to reply with ONLY the moves JSON (no tool call).
|
|
127
144
|
message: msg,
|
|
128
|
-
// 45s ceiling, matching the watcher's per-delivery watchdog backstop:
|
|
129
|
-
// a run that hasn't produced a decision by then is treated as stalled
|
|
130
|
-
// (was 300s, which let one hung run silence the team for 5 min — m171).
|
|
131
145
|
timeoutMs: 45_000,
|
|
132
146
|
matchId: seat.id!,
|
|
133
147
|
cfg,
|
|
134
148
|
did,
|
|
135
|
-
token:
|
|
149
|
+
token: state.token,
|
|
136
150
|
base,
|
|
151
|
+
state,
|
|
137
152
|
logger: ctx.logger,
|
|
138
153
|
});
|
|
139
154
|
// Report EVERY decision to the pitch — acted AND no-response — so the
|
|
@@ -151,12 +166,8 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
151
166
|
latencyMs: result.latencyMs,
|
|
152
167
|
...(session.lastModel ? { model: session.lastModel } : {}),
|
|
153
168
|
},
|
|
154
|
-
{ base, matchId: seat.id!, agentId: did, token:
|
|
169
|
+
{ base, matchId: seat.id!, agentId: did, token: state.token, logger: ctx.logger },
|
|
155
170
|
);
|
|
156
|
-
// act-verification by the natural signal: did we parse+post (or did the
|
|
157
|
-
// model act via the tool)? A parse-miss = "responded without acting" —
|
|
158
|
-
// log, keep standing orders, continue (never freeze). The watcher's own
|
|
159
|
-
// lastActAt check stays correct because executeMoves/soccer_play stamp it.
|
|
160
171
|
if (!result.acted) {
|
|
161
172
|
ctx.logger.warn(`[${label}] agent responded without acting (turn ${turn}): ${result.reason}`);
|
|
162
173
|
}
|
|
@@ -164,6 +175,7 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
164
175
|
{
|
|
165
176
|
signal: controller.signal,
|
|
166
177
|
logger: ctx.logger,
|
|
178
|
+
state,
|
|
167
179
|
onReclaim: async () => {
|
|
168
180
|
// Server restarted and forgot us — re-seat into the same room via
|
|
169
181
|
// seatRoute, or (room gone) quickmatch into a fresh one.
|
|
@@ -174,7 +186,7 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
174
186
|
if (!cfg.autoJoin) throw e;
|
|
175
187
|
const q = await joinVenue(venue, spec, cfg, { extra: joinExtra() });
|
|
176
188
|
ctx.logger.info(`[${label}] room ${seat.id} gone — re-quickmatched into ${q.id}`);
|
|
177
|
-
if (q.id !== seat.id) void
|
|
189
|
+
if (q.id !== seat.id) void state.joinAndWatch!(undefined);
|
|
178
190
|
}
|
|
179
191
|
},
|
|
180
192
|
},
|
|
@@ -185,8 +197,8 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
185
197
|
// Startup seating: a pinned matchId seats that room; autoJoin quick-matches
|
|
186
198
|
// (find-or-create, atomic server-side); otherwise idle until asked.
|
|
187
199
|
try {
|
|
188
|
-
if (cfg.matchId) await
|
|
189
|
-
else if (cfg.autoJoin) await
|
|
200
|
+
if (cfg.matchId) await state.joinAndWatch(cfg.matchId);
|
|
201
|
+
else if (cfg.autoJoin) await state.joinAndWatch(undefined);
|
|
190
202
|
else ctx.logger.info(`[${label}] idle — ask me to join or create a game.`);
|
|
191
203
|
} catch (e) { ctx.logger.error(`[${label}] startup seating failed: ${String(e)}`); }
|
|
192
204
|
|
|
@@ -195,11 +207,11 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
195
207
|
// venue's lobby for a non-ended room referencing our agentId and adopt it.
|
|
196
208
|
// Driven by spec.client.lobby.route — no hardcoded /matches path. Skipped
|
|
197
209
|
// when a match is pinned (explicit room wins) or the venue has no lobby.
|
|
198
|
-
// Critically it only adopts while idle (
|
|
199
|
-
//
|
|
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.
|
|
200
212
|
if (lobbyRoute && !cfg.matchId) {
|
|
201
213
|
poller = setInterval(async () => {
|
|
202
|
-
if (
|
|
214
|
+
if (state.matchId) return; // already seated/playing → nothing to adopt
|
|
203
215
|
try {
|
|
204
216
|
const res = await fetch(`${base}${lobbyRoute}`);
|
|
205
217
|
if (!res.ok) return;
|
|
@@ -209,7 +221,7 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
209
221
|
const id = mine?.id ?? mine?.[roomIdField];
|
|
210
222
|
if (id) {
|
|
211
223
|
ctx.logger.info(`[${label}] found my seat in ${id} (taken elsewhere) — starting to play`);
|
|
212
|
-
await
|
|
224
|
+
await state.joinAndWatch!(id);
|
|
213
225
|
}
|
|
214
226
|
} catch { /* server down — the watcher's own backoff handles it */ }
|
|
215
227
|
}, 10_000);
|
|
@@ -225,8 +237,8 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
225
237
|
api.runtime.subagent.deleteSession({ sessionKey: activeSessionKey }).catch(() => {});
|
|
226
238
|
activeSessionKey = null;
|
|
227
239
|
}
|
|
228
|
-
|
|
229
|
-
|
|
240
|
+
state.joinAndWatch = null;
|
|
241
|
+
state.matchId = null;
|
|
230
242
|
},
|
|
231
243
|
});
|
|
232
244
|
}
|
package/openclaw.plugin.json
CHANGED
|
@@ -3,107 +3,65 @@
|
|
|
3
3
|
"kind": "tool",
|
|
4
4
|
"uiHints": {
|
|
5
5
|
"serverUrl": {
|
|
6
|
-
"label": "
|
|
6
|
+
"label": "Server URL",
|
|
7
7
|
"placeholder": "http://localhost:3010",
|
|
8
|
-
"help": "Base URL of the
|
|
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-
|
|
13
|
-
"help": "Stable id for this agent's seat
|
|
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
|
|
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
|
|
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
|
-
"
|
|
56
|
-
"label": "
|
|
57
|
-
"placeholder": "
|
|
58
|
-
"help": "
|
|
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":
|
|
53
|
+
"additionalProperties": true,
|
|
64
54
|
"properties": {
|
|
65
|
-
"serverUrl": {
|
|
66
|
-
|
|
67
|
-
},
|
|
68
|
-
"
|
|
69
|
-
|
|
70
|
-
},
|
|
71
|
-
"
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
+
"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-
|
|
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
|
|
|
@@ -229,6 +235,9 @@ export type ExecuteDeps = {
|
|
|
229
235
|
token?: string | null | undefined;
|
|
230
236
|
/** Reported as x-agent-decision-ms — the prompt→reply latency (ms). */
|
|
231
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;
|
|
232
241
|
/** Test seam; defaults to global fetch. */
|
|
233
242
|
fetch?: typeof fetch;
|
|
234
243
|
};
|
|
@@ -246,6 +255,7 @@ export async function executeMoves(
|
|
|
246
255
|
deps: ExecuteDeps,
|
|
247
256
|
): Promise<{ posted: number; results: { playerId: string; status: number }[] }> {
|
|
248
257
|
const f = deps.fetch ?? fetch;
|
|
258
|
+
const state = deps.state ?? session;
|
|
249
259
|
const base = deps.base.replace(/\/$/, "");
|
|
250
260
|
const results: { playerId: string; status: number }[] = [];
|
|
251
261
|
for (const m of moves) {
|
|
@@ -258,15 +268,18 @@ export async function executeMoves(
|
|
|
258
268
|
"Content-Type": "application/json",
|
|
259
269
|
"x-caller-did": deps.did,
|
|
260
270
|
"x-agent-runtime": "openclaw-plugin/autoplay",
|
|
271
|
+
"x-agent-os": AGENT_OS,
|
|
261
272
|
};
|
|
262
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.
|
|
263
276
|
if (session.lastModel) headers["x-agent-model"] = session.lastModel;
|
|
264
277
|
if (deps.decisionMs !== undefined) headers["x-agent-decision-ms"] = String(Math.max(0, Math.round(deps.decisionMs)));
|
|
265
278
|
const key = apiKeyOf(deps.cfg);
|
|
266
279
|
if (key) headers.Authorization = `Bearer ${key}`;
|
|
267
280
|
const url = `${base}/matches/${encodeURIComponent(matchId)}/players/${encodeURIComponent(m.playerId)}/action`;
|
|
268
281
|
const res = await f(url, { method: "POST", headers, body: JSON.stringify(body) });
|
|
269
|
-
if (res.ok)
|
|
282
|
+
if (res.ok) state.lastActAt = Date.now(); // act-verification: the team was moved this cycle
|
|
270
283
|
results.push({ playerId: m.playerId, status: res.status });
|
|
271
284
|
}
|
|
272
285
|
return { posted: results.length, results };
|
|
@@ -349,6 +362,11 @@ export type AutoplayTurnDeps = {
|
|
|
349
362
|
did: string;
|
|
350
363
|
token?: string | null;
|
|
351
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;
|
|
352
370
|
logger?: { info: (m: string) => void; warn: (m: string) => void; error: (m: string) => void };
|
|
353
371
|
};
|
|
354
372
|
|
|
@@ -383,9 +401,10 @@ export type AutoplayTurnResult = AutoplayTurnObservability &
|
|
|
383
401
|
*/
|
|
384
402
|
export async function runAutoplayTurn(deps: AutoplayTurnDeps): Promise<AutoplayTurnResult> {
|
|
385
403
|
const { runtime } = deps;
|
|
386
|
-
|
|
387
|
-
//
|
|
388
|
-
|
|
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;
|
|
389
408
|
|
|
390
409
|
// Context-growth backstop: once a single match's persistent session exceeds the
|
|
391
410
|
// cap, drop it so the next run starts fresh — bounds transcript size at the
|
|
@@ -410,7 +429,7 @@ export async function runAutoplayTurn(deps: AutoplayTurnDeps): Promise<AutoplayT
|
|
|
410
429
|
// The model called the act tool itself (it POSTed + stamped lastActAt). Treat
|
|
411
430
|
// the cycle as acted; do NOT parse+post again (would double-apply). The
|
|
412
431
|
// session PERSISTS — no deleteSession here; it's the per-match request log.
|
|
413
|
-
if (
|
|
432
|
+
if (state.lastActAt !== actAtBefore) {
|
|
414
433
|
return { acted: true, via: "tool", outcome: "acted", rawText: "", latencyMs: decisionMs };
|
|
415
434
|
}
|
|
416
435
|
|
|
@@ -446,6 +465,7 @@ export async function runAutoplayTurn(deps: AutoplayTurnDeps): Promise<AutoplayT
|
|
|
446
465
|
did: deps.did,
|
|
447
466
|
token: deps.token,
|
|
448
467
|
decisionMs,
|
|
468
|
+
state,
|
|
449
469
|
});
|
|
450
470
|
return { acted: true, via: "post", posted, outcome: "acted", rawText: text, moves, latencyMs: decisionMs };
|
|
451
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).
|
|
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
|
-
//
|
|
99
|
-
|
|
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 ?? {}), ...(
|
|
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)
|
|
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
|
-
|
|
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);
|
|
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))
|
|
324
|
+
if (isRealtimeVenue(spec)) out.push({ venue, spec });
|
|
304
325
|
}
|
|
305
|
-
return
|
|
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
|
-
*
|
|
3
|
-
* service) see the same values.
|
|
2
|
+
* Per-venue autoplay runtime state.
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
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
|
-
}
|
|
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
|
|
23
|
-
*
|
|
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
|
|
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-
|
|
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
|
|
@@ -163,6 +167,7 @@ export async function startObserveWatcher(
|
|
|
163
167
|
options: WatcherOptions = {},
|
|
164
168
|
): Promise<void> {
|
|
165
169
|
const { signal, logger } = options;
|
|
170
|
+
const state = options.state ?? session; // per-venue runtime (default: global)
|
|
166
171
|
const base = (cfg.serverUrl ?? "http://localhost:3010").replace(/\/$/, "");
|
|
167
172
|
const tag = cfg.label ?? "agentnet"; // venue-derived log prefix
|
|
168
173
|
const actTool = cfg.actTool ?? "soccer_play";
|
|
@@ -202,7 +207,7 @@ export async function startObserveWatcher(
|
|
|
202
207
|
const obs = latest;
|
|
203
208
|
// Act-verification baseline: if the act tool doesn't stamp lastActAt past
|
|
204
209
|
// this during the run, the agent was prompted but never moved its team.
|
|
205
|
-
const actAtBefore =
|
|
210
|
+
const actAtBefore = state.lastActAt;
|
|
206
211
|
|
|
207
212
|
// The watchdog and the deliver race for the latch. Whichever fires FIRST
|
|
208
213
|
// owns the release; `settled` makes the loser a no-op, so a slow deliver that
|
|
@@ -219,9 +224,9 @@ export async function startObserveWatcher(
|
|
|
219
224
|
busy = false;
|
|
220
225
|
if (onTimeout) {
|
|
221
226
|
logger?.warn(`[${tag}] deliver exceeded ${deliverTimeoutMs}ms — releasing latch, agent may be stalled`);
|
|
222
|
-
} else if (
|
|
227
|
+
} else if (state.lastActAt === actAtBefore) {
|
|
223
228
|
// The run finished but no action was POSTed — surfaced, never gated.
|
|
224
|
-
|
|
229
|
+
state.noActTurns++;
|
|
225
230
|
logger?.warn(`[${tag}] agent responded without acting (turn ${seq})`);
|
|
226
231
|
}
|
|
227
232
|
maybeDeliver();
|
|
@@ -279,7 +284,7 @@ export async function startObserveWatcher(
|
|
|
279
284
|
ensureSpec(); // protection: frame before handshake → fetch via API
|
|
280
285
|
// The team stream is authoritative for which players we control, so
|
|
281
286
|
// a live ratio change on the server is followed without reconnecting.
|
|
282
|
-
|
|
287
|
+
state.players = v.mine.map((p) => p.id);
|
|
283
288
|
latest = v;
|
|
284
289
|
latestSeq++;
|
|
285
290
|
maybeDeliver();
|