@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 +114 -63
- package/openclaw.plugin.json +36 -78
- package/package.json +2 -2
- package/src/decide.ts +137 -20
- 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 +18 -7
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,
|
|
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, …) +
|
|
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;
|
|
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
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
90
|
-
// 2-decisions-in-157s).
|
|
91
|
-
// 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.
|
|
92
124
|
//
|
|
93
|
-
//
|
|
94
|
-
// agent
|
|
125
|
+
// ONE persistent session per MATCH (sessionKey:matchId, stable across
|
|
126
|
+
// turns) so the agent accumulates context — its 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
|
-
|
|
135
|
+
state.promptDeliveredAt = Date.now();
|
|
100
136
|
const result = await runAutoplayTurn({
|
|
101
137
|
runtime: api.runtime,
|
|
102
|
-
sessionKey:
|
|
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.
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
117
|
-
token:
|
|
148
|
+
did,
|
|
149
|
+
token: state.token,
|
|
118
150
|
base,
|
|
151
|
+
state,
|
|
119
152
|
logger: ctx.logger,
|
|
120
153
|
});
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
|
|
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
|
|
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
|
|
154
|
-
else if (cfg.autoJoin) await
|
|
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 (
|
|
164
|
-
//
|
|
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 (
|
|
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
|
|
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
|
-
|
|
190
|
-
|
|
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
|
}
|
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
|
|
|
@@ -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
|
-
*
|
|
207
|
-
*
|
|
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)
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
307
|
-
//
|
|
308
|
-
|
|
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
|
-
|
|
324
|
-
|
|
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 {
|
|
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).
|
|
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
|
|
@@ -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
|
-
|
|
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 =
|
|
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 (
|
|
227
|
+
} else if (state.lastActAt === actAtBefore) {
|
|
217
228
|
// The run finished but no action was POSTed — surfaced, never gated.
|
|
218
|
-
|
|
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
|
-
|
|
287
|
+
state.players = v.mine.map((p) => p.id);
|
|
277
288
|
latest = v;
|
|
278
289
|
latestSeq++;
|
|
279
290
|
maybeDeliver();
|