@agentmessier/openclaw-agent-messier 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -1,16 +1,45 @@
1
1
  import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk/core";
2
- import { createSoccerTools, pitchClient, agentIdOf, type PluginCfg } from "./src/tools.js";
2
+ import { memberTools, venuesTool, agentIdOf, identityOf, venueUrl, type PluginCfg } from "./src/tools.js";
3
+ import { defaultVenueTools, defaultRealtimeVenue, joinVenue } from "./src/generate.js";
3
4
  import { startObserveWatcher } from "./src/watcher.js";
4
5
  import { session } from "./src/state.js";
5
6
 
7
+ /** A lobby row is "ours" if it references our agentId anywhere (soccer puts it in
8
+ * sides.home/away; a generic venue may shape it differently). Deep, shape-blind. */
9
+ function referencesAgent(value: unknown, agentId: string): boolean {
10
+ if (value === agentId) return true;
11
+ if (Array.isArray(value)) return value.some((v) => referencesAgent(v, agentId));
12
+ if (value && typeof value === "object") return Object.values(value).some((v) => referencesAgent(v, agentId));
13
+ return false;
14
+ }
15
+ const ENDED = new Set(["ended", "finished", "completed", "done", "cancelled", "canceled"]);
16
+
6
17
  export default function register(api: OpenClawPluginApi) {
7
- // 1. Tools: matchmaking (find/create/join how a human gets their agent into
8
- // a game by chatting) + play tools (tier chosen by config.mode).
9
- for (const tool of createSoccerTools(api)) {
18
+ const cfg = (api.pluginConfig ?? {}) as PluginCfg;
19
+
20
+ // 1. Tools: per-venue lifecycle tools GENERATED from each venue's spec
21
+ // (soccer_matches/join/observe/play, work_observe/act, …) + soccer member
22
+ // perks (skins/rename/claim) + the platform `venues` registry tool. A new
23
+ // venue appears here from its spec with zero plugin code.
24
+ for (const tool of [...defaultVenueTools(cfg), ...memberTools(cfg), venuesTool(cfg)]) {
10
25
  api.registerTool(tool as AnyAgentTool);
11
26
  }
12
27
 
13
- const cfg = (api.pluginConfig ?? {}) as PluginCfg;
28
+ // 2. Autoplay watcher. It drives ONE realtime venue (streams observations,
29
+ // seats the agent, offers hands-free play) entirely from {venue, spec} —
30
+ // seating via spec.client.join, observe/act endpoints via spec.routes, the
31
+ // move prompt naming spec.client.act.tool. No venue is hardcoded here;
32
+ // soccer is simply the only realtime venue today. Seatless/poll venues
33
+ // (taskmarket) have no autoplay loop, so the service no-ops for them.
34
+ const realtime = defaultRealtimeVenue();
35
+ if (!realtime) return;
36
+ const { venue, spec } = realtime;
37
+ const label = venue.id;
38
+ const actTool = spec.client!.act.tool;
39
+ const lobbyRoute = spec.client?.lobby?.route;
40
+ const roomIdField = spec.client?.join?.seat.id ?? "id";
41
+ const base = venueUrl(venue.origin, cfg);
42
+
14
43
  const sessionKey =
15
44
  cfg.sessionKey ??
16
45
  ((api.config.hooks as Record<string, unknown> | undefined)?.defaultSessionKey as string | undefined);
@@ -19,35 +48,37 @@ export default function register(api: OpenClawPluginApi) {
19
48
  let poller: ReturnType<typeof setInterval> | null = null;
20
49
 
21
50
  api.registerService({
22
- id: "agentnet-soccer-watcher",
51
+ id: `agentnet-${venue.id}-watcher`,
23
52
 
24
53
  start: async (ctx) => {
25
54
  const agentId = agentIdOf(cfg);
26
- const client = pitchClient(cfg);
55
+ // Config-derived join body. For soccer these are teamSize/team/identity; a
56
+ // venue whose spec doesn't use them simply leaves the cfg fields unset and
57
+ // the server ignores the extras. joinVenue itself stays venue-agnostic.
58
+ const joinExtra = (): Record<string, unknown> => ({ teamSize: cfg.teamSize, team: cfg.team, identity: identityOf(cfg) });
27
59
 
28
- // Join a room (taking a WHOLE side) and (re)start the observation loop.
29
- // Installed into shared state so the matchmaking tools can invoke it.
30
- session.joinAndWatch = async (matchId, team) => {
31
- const seat = await client.join(matchId, agentId, team);
32
- session.matchId = matchId;
33
- session.players = seat.playerIds;
34
- ctx.logger.info(`[agentnet-soccer] ${agentId} joined ${matchId} as ${seat.team} (${seat.playerIds.length} players)${seat.started ? " — match live" : " — waiting for opponent"}`);
60
+ // Seat into a room (matchId omitted = quickmatch find-or-create) and
61
+ // (re)start the observation loop. Installed into shared state so the
62
+ // generated *_join tool / chat handoff can drive it.
63
+ session.joinAndWatch = async (matchId, params) => {
64
+ const seat = await joinVenue(venue, spec, cfg, { matchId, params: params ?? {}, extra: joinExtra() });
65
+ ctx.logger.info(`[${label}] ${agentId} seated in ${seat.id} (${seat.controls?.length ?? 0} to control)${seat.started ? " — live" : " — waiting for opponent"}`);
35
66
 
36
67
  controller?.abort(); // leaving a previous room
37
68
  controller = new AbortController();
38
69
  let move = 0;
39
70
  // Fire-and-forget: the watcher runs until aborted or the gateway stops.
40
71
  void startObserveWatcher(
41
- { serverUrl: cfg.serverUrl, matchId, agentId, mode: cfg.mode, strategyFile: cfg.strategyFile },
72
+ { serverUrl: cfg.serverUrl, matchId: seat.id!, agentId, mode: cfg.mode, strategyFile: cfg.strategyFile, actTool, label },
42
73
  async (msg) => {
43
74
  if (!sessionKey) {
44
- ctx.logger.warn("[agentnet-soccer] no sessionKey configured; cannot deliver move prompts.");
75
+ ctx.logger.warn(`[${label}] no sessionKey configured; cannot deliver move prompts.`);
45
76
  return;
46
77
  }
47
78
  // Fresh session per move: each prompt is a complete snapshot, so the
48
79
  // agent needs no history — keeps context from overflowing.
49
80
  const turn = move++;
50
- const idempotencyKey = `agentnet-soccer:${matchId}:${agentId}:${turn}`;
81
+ const idempotencyKey = `agentnet:${venue.id}:${seat.id}:${agentId}:${turn}`;
51
82
  const { runId } = await api.runtime.subagent.run({
52
83
  sessionKey: `${sessionKey}:${turn}`,
53
84
  message: msg,
@@ -60,17 +91,16 @@ export default function register(api: OpenClawPluginApi) {
60
91
  signal: controller.signal,
61
92
  logger: ctx.logger,
62
93
  onReclaim: async () => {
63
- // Server restarted and forgot us — retake our seat in the room,
64
- // or (room gone entirely) quick-match into a fresh one.
94
+ // Server restarted and forgot us — re-seat into the same room via
95
+ // seatRoute, or (room gone) quickmatch into a fresh one.
65
96
  try {
66
- const again = await client.join(matchId, agentId, team);
67
- session.players = again.playerIds;
68
- ctx.logger.info(`[agentnet-soccer] re-joined ${matchId} as ${again.team} after server restart`);
97
+ const again = await joinVenue(venue, spec, cfg, { matchId: seat.id, extra: joinExtra() });
98
+ ctx.logger.info(`[${label}] re-seated in ${again.id} after server restart`);
69
99
  } catch (e) {
70
100
  if (!cfg.autoJoin) throw e;
71
- const q = await client.quickMatch(agentId, team ? { team } : {});
72
- ctx.logger.info(`[agentnet-soccer] room ${matchId} gone — quick-matched into ${q.matchId}`);
73
- if (q.matchId !== matchId) void session.joinAndWatch!(q.matchId, team);
101
+ const q = await joinVenue(venue, spec, cfg, { extra: joinExtra() });
102
+ ctx.logger.info(`[${label}] room ${seat.id} gone — re-quickmatched into ${q.id}`);
103
+ if (q.id !== seat.id) void session.joinAndWatch!(undefined);
74
104
  }
75
105
  },
76
106
  },
@@ -78,39 +108,42 @@ export default function register(api: OpenClawPluginApi) {
78
108
  return seat;
79
109
  };
80
110
 
81
- // Startup seating: a pinned matchId joins that room; autoJoin quick-matches
82
- // (find-or-create, atomic server-side); otherwise idle until the human
83
- // asks the agent to find/create a game.
111
+ // Startup seating: a pinned matchId seats that room; autoJoin quick-matches
112
+ // (find-or-create, atomic server-side); otherwise idle until asked.
84
113
  try {
85
- if (cfg.matchId) await session.joinAndWatch(cfg.matchId, cfg.team);
86
- else if (cfg.autoJoin) {
87
- const q = await client.quickMatch(agentId, cfg.team ? { team: cfg.team } : {});
88
- await session.joinAndWatch(q.matchId, cfg.team);
89
- } else {
90
- ctx.logger.info("[agentnet-soccer] idle — ask me to join or create a game.");
91
- }
92
- } catch (e) { ctx.logger.error(`[agentnet-soccer] startup seating failed: ${String(e)}`); }
114
+ if (cfg.matchId) await session.joinAndWatch(cfg.matchId);
115
+ else if (cfg.autoJoin) await session.joinAndWatch(undefined);
116
+ else ctx.logger.info(`[${label}] idle ask me to join or create a game.`);
117
+ } catch (e) { ctx.logger.error(`[${label}] startup seating failed: ${String(e)}`); }
93
118
 
94
- // Seat poller: a human may seat this agent from a CHAT process (which has
95
- // no watcher). Watch the lobby for a seat held by our agentId and aim the
96
- // playing loop at it whenever it differs from what we're watching.
97
- const base = (cfg.serverUrl ?? "http://localhost:3010").replace(/\/$/, "");
98
- poller = setInterval(async () => {
99
- try {
100
- const res = await fetch(`${base}/matches`);
101
- if (!res.ok) return;
102
- const { matches } = (await res.json()) as { matches: { id: string; status: string; sides: { home: string | null; away: string | null } }[] };
103
- const seat = matches.find(r => r.status !== "ended" && (r.sides.home === agentId || r.sides.away === agentId));
104
- if (seat && seat.id !== session.matchId) {
105
- ctx.logger.info(`[agentnet-soccer] found my seat in ${seat.id} (taken via chat) — starting to play`);
106
- await session.joinAndWatch!(seat.id);
107
- }
108
- } catch { /* server down the watcher's own backoff handles it */ }
109
- }, 10_000);
119
+ // Seat poller: when the agent is IDLE, a seat may be taken from ANOTHER
120
+ // process (a chat turn, the generated *_join tool, the dashboard). Poll the
121
+ // venue's lobby for a non-ended room referencing our agentId and adopt it.
122
+ // Driven by spec.client.lobby.route no hardcoded /matches path. Skipped
123
+ // when a match is pinned (explicit room wins) or the venue has no lobby.
124
+ // Critically it only adopts while idle (session.matchId empty): once we're
125
+ // in a match it must NOT yank us into some other (e.g. stale) room.
126
+ if (lobbyRoute && !cfg.matchId) {
127
+ poller = setInterval(async () => {
128
+ if (session.matchId) return; // already seated/playing nothing to adopt
129
+ try {
130
+ const res = await fetch(`${base}${lobbyRoute}`);
131
+ if (!res.ok) return;
132
+ const data = (await res.json()) as Record<string, any>;
133
+ const rows = (data.matches ?? data.rows ?? []) as Record<string, any>[];
134
+ const mine = rows.find((r) => !ENDED.has(String(r.status ?? "")) && referencesAgent(r, agentId));
135
+ const id = mine?.id ?? mine?.[roomIdField];
136
+ if (id) {
137
+ ctx.logger.info(`[${label}] found my seat in ${id} (taken elsewhere) — starting to play`);
138
+ await session.joinAndWatch!(id);
139
+ }
140
+ } catch { /* server down — the watcher's own backoff handles it */ }
141
+ }, 10_000);
142
+ }
110
143
  },
111
144
 
112
145
  stop: async (ctx) => {
113
- ctx.logger.info("[agentnet-soccer] watcher stopping");
146
+ ctx.logger.info(`[${label}] watcher stopping`);
114
147
  if (poller) { clearInterval(poller); poller = null; }
115
148
  controller?.abort();
116
149
  controller = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentmessier/openclaw-agent-messier",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
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",
@@ -0,0 +1,124 @@
1
+ import { describe, it, expect, afterEach, vi } from "vitest";
2
+ import { generateVenueTools, defaultVenueTools, joinVenue, seatOf, defaultRealtimeVenue, isRealtimeVenue, _resetSeats } from "./generate.js";
3
+ import { session } from "./state.js";
4
+ import type { GameSpec, PluginCfg } from "./tools.js";
5
+
6
+ function cfg(extra: Partial<PluginCfg> = {}): PluginCfg {
7
+ return { serverUrl: "http://pitch.test", sessionKey: "did:wba:me", ...extra };
8
+ }
9
+
10
+ // A venue the plugin has NEVER heard of — proves tools come from the spec, not code.
11
+ const GOLF_VENUE = { id: "agent-golf", origin: "http://golf.test", specUrl: "/spec" };
12
+ const GOLF_SPEC: GameSpec = {
13
+ game: "agent-golf", specVersion: 1, rulesVersion: 1,
14
+ actions: { type: "string", enum: ["drive", "chip", "putt"], descriptions: { drive: "tee shot", putt: "on the green" } },
15
+ observe: { mode: "stream", suggestedIntervalMs: 3000 },
16
+ routes: { observe: "/rounds/{matchId}/agents/{did}/observe", act: "/rounds/{matchId}/players/{playerId}/swing" },
17
+ client: {
18
+ prefix: "golf", noun: "round",
19
+ lobby: { tool: "golf_rounds", route: "/rounds", params: {}, summary: "List rounds." },
20
+ join: { tool: "golf_join", route: "/quickround", seatRoute: "/rounds/{matchId}/join", params: { holes: { type: "integer" } }, seat: { id: "matchId", token: "token", controls: "playerIds" }, summary: "Join a round." },
21
+ autoplay: { tool: "golf_autoplay", summary: "Hands-free." },
22
+ observe: { tool: "golf_observe", params: {}, summary: "See the course." },
23
+ act: { tool: "golf_play", params: { club: { type: "string" } }, summary: "Swing." },
24
+ },
25
+ };
26
+
27
+ describe("generated tool surface comes entirely from the spec", () => {
28
+ it("baked soccer + taskmarket specs generate their canonical named tools", () => {
29
+ const names = defaultVenueTools(cfg()).map(t => t.name);
30
+ // soccer (a game, seated): lobby + join + observe + batch play
31
+ expect(names).toEqual(expect.arrayContaining(["soccer_matches", "soccer_join", "soccer_observe", "soccer_play"]));
32
+ // taskmarket (seatless work): observe + act, NO lobby/join
33
+ expect(names).toEqual(expect.arrayContaining(["work_observe", "work_act"]));
34
+ expect(names).not.toContain("work_join");
35
+ });
36
+
37
+ it("golf thesis: a brand-new venue's spec yields golf_* tools with ZERO plugin code", () => {
38
+ _resetSeats();
39
+ const tools = generateVenueTools(GOLF_VENUE, GOLF_SPEC, cfg());
40
+ const names = tools.map(t => t.name);
41
+ expect(names).toEqual(["golf_rounds", "golf_join", "golf_observe", "golf_play"]);
42
+
43
+ // The act tool is BATCH (golf seats players) and carries the spec's action enum.
44
+ const play = tools.find(t => t.name === "golf_play")!;
45
+ const enumList = (play.parameters as any).properties.moves.items.properties.type.enum;
46
+ expect(enumList).toEqual(["drive", "chip", "putt"]);
47
+ // Per-action descriptions from the spec flow into the tool description.
48
+ expect(play.description).toContain("drive: tee shot");
49
+ });
50
+
51
+ it("the act enum tracks the spec — a server-added action surfaces with no plugin edit", () => {
52
+ _resetSeats();
53
+ const evolved: GameSpec = { ...GOLF_SPEC, actions: { ...GOLF_SPEC.actions!, enum: [...GOLF_SPEC.actions!.enum, "flop"] } };
54
+ const play = generateVenueTools(GOLF_VENUE, evolved, cfg()).find(t => t.name === "golf_play")!;
55
+ expect((play.parameters as any).properties.moves.items.properties.type.enum).toContain("flop");
56
+ });
57
+ });
58
+
59
+ describe("joinVenue — the one spec-driven seating path (tool + service share it)", () => {
60
+ afterEach(() => { vi.unstubAllGlobals(); _resetSeats(); });
61
+
62
+ it("quickmatch (no matchId) posts to client.join.route and reads the seat block", async () => {
63
+ let url = "", body: any = null;
64
+ vi.stubGlobal("fetch", vi.fn(async (u: any, init?: any) => {
65
+ url = String(u); body = JSON.parse(init.body);
66
+ return { ok: true, json: async () => ({ matchId: "r7", token: "tok", playerIds: ["p1", "p2"], did: "did:wba:me", started: false }) } as any;
67
+ }));
68
+ const seat = await joinVenue(GOLF_VENUE, GOLF_SPEC, cfg({ sessionKey: "did:wba:me" }), { params: { holes: 9 } });
69
+ expect(url).toBe("http://golf.test/quickround");
70
+ expect(body.agentId).toBe("did:wba:me");
71
+ expect(body.holes).toBe(9); // whitelisted join param
72
+ expect(seat.id).toBe("r7"); // seat.id field from spec
73
+ expect(seat.controls).toEqual(["p1", "p2"]); // seat.controls field
74
+ expect(seat.token).toBe("tok");
75
+ expect(seatOf("agent-golf")?.id).toBe("r7");
76
+ expect(session.matchId).toBe("r7"); // mirrored for the watcher
77
+ });
78
+
79
+ it("rejoining a KNOWN room uses seatRoute with {matchId} substituted", async () => {
80
+ let url = "";
81
+ vi.stubGlobal("fetch", vi.fn(async (u: any) => {
82
+ url = String(u);
83
+ return { ok: true, json: async () => ({ matchId: "r7", token: "t", playerIds: ["p1"] }) } as any;
84
+ }));
85
+ await joinVenue(GOLF_VENUE, GOLF_SPEC, cfg(), { matchId: "r7" });
86
+ expect(url).toBe("http://golf.test/rounds/r7/join");
87
+ });
88
+
89
+ it("rejoin response without the seat-id field falls back to the matchId joined with", async () => {
90
+ _resetSeats();
91
+ // per-room join responses omit matchId (you already know it from the URL) —
92
+ // seat.id must still resolve, or the observe loop 404s and reclaim-loops.
93
+ vi.stubGlobal("fetch", vi.fn(async () =>
94
+ ({ ok: true, json: async () => ({ token: "t", playerIds: ["p1", "p2", "p3"], started: true }) }) as any));
95
+ const seat = await joinVenue(GOLF_VENUE, GOLF_SPEC, cfg(), { matchId: "r7" });
96
+ expect(seat.id).toBe("r7");
97
+ expect(session.matchId).toBe("r7");
98
+ });
99
+
100
+ it("service extras (teamSize/identity) ride along but tool calls omit them", async () => {
101
+ let body: any = null;
102
+ vi.stubGlobal("fetch", vi.fn(async (_u: any, init?: any) => {
103
+ body = JSON.parse(init.body);
104
+ return { ok: true, json: async () => ({ matchId: "r1", token: "t", playerIds: [] }) } as any;
105
+ }));
106
+ await joinVenue(GOLF_VENUE, GOLF_SPEC, cfg(), { params: { holes: 18 }, extra: { teamSize: 5, identity: { name: "Eagles" } } });
107
+ expect(body).toMatchObject({ holes: 18, teamSize: 5, identity: { name: "Eagles" } });
108
+ });
109
+ });
110
+
111
+ describe("realtime-venue detection (which venue the autoplay watcher drives)", () => {
112
+ it("a streamed + seated + autoplay venue is realtime; seatless/poll is not", () => {
113
+ expect(isRealtimeVenue(GOLF_SPEC)).toBe(true);
114
+ expect(isRealtimeVenue({ ...GOLF_SPEC, observe: { mode: "poll", suggestedIntervalMs: 1000 } })).toBe(false);
115
+ expect(isRealtimeVenue({ ...GOLF_SPEC, client: { ...GOLF_SPEC.client!, join: null } } as GameSpec)).toBe(false);
116
+ expect(isRealtimeVenue(null)).toBe(false);
117
+ });
118
+
119
+ it("the baked defaults pick soccer (realtime), never the seatless taskmarket", () => {
120
+ const rt = defaultRealtimeVenue();
121
+ expect(rt?.venue.id).toBe("agent-soccer");
122
+ expect(rt?.spec.client?.act.tool).toBe("soccer_play");
123
+ });
124
+ });
@@ -0,0 +1,275 @@
1
+ /**
2
+ * Generic per-venue tool generator (venue-agnostic-plugins.md §5.2) — the
3
+ * OpenClaw counterpart of the Hermes generate.py. Given a registry venue + its
4
+ * /spec (with a `client` lifecycle block), emit the named tools as
5
+ * `AnyAgentTool[]`. Handlers are GENERIC: endpoints from spec.routes, seat
6
+ * fields from spec.client.join.seat, action enum from spec.actions. Adding a
7
+ * venue = a registry row + a spec; no per-game code here.
8
+ *
9
+ * The act tool is a BATCH `moves` tool when the venue seats multiple players
10
+ * (a game — preserves the autoplay watcher's "one call, all players" prompt),
11
+ * and a SINGLE action when seatless (a work market). Names + descriptions come
12
+ * from the spec, so generated tools are as well-described as hand-crafted ones.
13
+ */
14
+ import { Type } from "@sinclair/typebox";
15
+ import type { AnyAgentTool } from "openclaw/plugin-sdk/core";
16
+ import { ok, err, venueUrl, agentIdOf, apiKeyOf, rememberDid, rememberToken, type GameSpec, type PluginCfg } from "./tools.js";
17
+ import { session } from "./state.js";
18
+
19
+ export type Venue = { id: string; origin: string; specUrl?: string };
20
+ export type Seat = { id?: string; token?: string; controls?: string[]; agentId?: string; started?: boolean; managerUrl?: string };
21
+
22
+ // Seat per venue (id/token/controls/agentId). The soccer seat is mirrored into
23
+ // `session` so the existing service watcher + seat-poller keep working.
24
+ const seats = new Map<string, Seat>();
25
+ export function _resetSeats(): void { seats.clear(); }
26
+ export function seatOf(venueId: string): Seat | undefined { return seats.get(venueId); }
27
+
28
+ function sub(route: string, kv: Record<string, string>): string {
29
+ return Object.entries(kv).reduce((r, [k, v]) => r.replace(`{${k}}`, encodeURIComponent(v)), route);
30
+ }
31
+ function did(venueId: string, cfg: PluginCfg): string {
32
+ return seats.get(venueId)?.agentId ?? agentIdOf(cfg);
33
+ }
34
+
35
+ /** One HTTP call to a venue, with every available auth header (the server uses
36
+ * what it needs: Bearer→DID for games, x-caller-did for work, seat token for acts). */
37
+ async function vfetch(base: string, path: string, opts: { method?: string; body?: unknown; cfg: PluginCfg; token?: string; did: string }): Promise<{ ok: boolean; status: number; data: any }> {
38
+ const headers: Record<string, string> = { "x-caller-did": opts.did, "x-agent-runtime": "openclaw-plugin/0.2.0" };
39
+ const key = apiKeyOf(opts.cfg); if (key) headers["Authorization"] = `Bearer ${key}`;
40
+ if (opts.token) headers["x-agent-token"] = opts.token;
41
+ if (opts.body !== undefined) headers["Content-Type"] = "application/json";
42
+ const res = await fetch(`${base}${path}`, { method: opts.method ?? "GET", headers, ...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}) });
43
+ const data = await res.json().catch(() => ({}));
44
+ return { ok: res.ok, status: res.status, data };
45
+ }
46
+
47
+ function asXY(v: unknown): { x: number; y: number } | null {
48
+ if (Array.isArray(v) && v.length === 2) { const x = Number(v[0]), y = Number(v[1]); return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null; }
49
+ return null;
50
+ }
51
+ function whitelist(params: Record<string, unknown>, props: Record<string, unknown>): Record<string, unknown> {
52
+ const out: Record<string, unknown> = {};
53
+ for (const k of Object.keys(props)) {
54
+ if (k === "player" || params[k] === undefined) continue; // player is the routing key, not a body field
55
+ out[k] = asXY(params[k]) ?? params[k];
56
+ }
57
+ return out;
58
+ }
59
+
60
+ // JSON-Schema props (from the spec) double as the TypeBox `parameters` object —
61
+ // TypeBox schemas ARE plain JSON-schema at runtime, so the SDK accepts this.
62
+ function paramsSchema(props: Record<string, unknown>, extra: Record<string, unknown> = {}, required: string[] = []): unknown {
63
+ return { type: "object", properties: { ...props, ...extra }, ...(required.length ? { required } : {}) };
64
+ }
65
+
66
+ /** Seat into a venue from its spec.client.join — the one join path, shared by
67
+ * the generated `*_join` tool AND the autoplay service. `route` finds-or-creates
68
+ * (quickmatch); `seatRoute` (if present) rejoins a KNOWN room. `extra` lets the
69
+ * service inject config-derived body fields (teamSize/team/identity for soccer)
70
+ * without this generic helper knowing any venue's semantics. Persists token+DID
71
+ * to the cross-process cache so a seat taken here is usable from another process. */
72
+ export async function joinVenue(
73
+ venue: Venue, spec: GameSpec, cfg: PluginCfg,
74
+ opts: { matchId?: string; params?: Record<string, unknown>; extra?: Record<string, unknown> } = {},
75
+ ): Promise<Seat> {
76
+ const j = spec.client?.join;
77
+ if (!j) throw new Error(`${venue.id} is not joinable (no client.join in spec)`);
78
+ const sm = j.seat;
79
+ const base = venueUrl(venue.origin, cfg);
80
+ const meId = agentIdOf(cfg);
81
+ const route = opts.matchId && j.seatRoute ? sub(j.seatRoute, { matchId: opts.matchId }) : j.route;
82
+ const body = { agentId: meId, ...(opts.extra ?? {}), ...whitelist(opts.params ?? {}, j.params ?? {}) };
83
+ const r = await vfetch(base, route, { cfg, did: meId, method: "POST", body });
84
+ if (!r.ok) throw new Error(`join ${venue.id}: ${r.status} ${JSON.stringify(r.data)}`);
85
+ const d = r.data;
86
+ if (typeof d.did === "string") rememberDid(cfg, d.did); // cross-process identity
87
+ if (typeof d.token === "string") rememberToken(cfg, d.token); // cross-process seat token
88
+ // A rejoin via seatRoute already KNOWS the room (it's in the URL), so the
89
+ // server's response omits it — fall back to the matchId we joined with, or
90
+ // seat.id ends up undefined and the observe loop hits /…//… → 404 → reclaim.
91
+ 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 };
92
+ seats.set(venue.id, seat);
93
+ // soccer back-compat: the service watcher + seat-poller read `session`.
94
+ session.matchId = seat.id ?? null; session.players = seat.controls ?? []; session.token = seat.token ?? null; session.did = seat.agentId ?? null;
95
+ return seat;
96
+ }
97
+
98
+ export function generateVenueTools(venue: Venue, spec: GameSpec, cfg: PluginCfg): AnyAgentTool[] {
99
+ const c = spec.client!;
100
+ const base = venueUrl(venue.origin, cfg);
101
+ const enumList = spec.actions?.enum ?? [];
102
+ const descs = spec.actions?.descriptions ?? {};
103
+ const out: AnyAgentTool[] = [];
104
+
105
+ if (c.lobby) {
106
+ const s = c.lobby;
107
+ out.push({ name: s.tool, label: s.tool, description: s.summary, parameters: paramsSchema(s.params ?? {}),
108
+ async execute(_id, params) {
109
+ const r = await vfetch(base, s.route, { cfg, did: did(venue.id, cfg) });
110
+ if (!r.ok) return ok({ error: r.data?.error ?? `lobby ${r.status}` });
111
+ let rows = (r.data?.matches ?? r.data?.rows ?? []) as Record<string, unknown>[];
112
+ const want = String((params as any).status ?? "").trim().toLowerCase();
113
+ if (want) rows = rows.filter(x => x["status"] === want);
114
+ return ok({ count: rows.length, rows, hint: `join with ${c.prefix}_join` });
115
+ } } as AnyAgentTool);
116
+ }
117
+
118
+ if (c.join) {
119
+ const s = c.join;
120
+ out.push({ name: s.tool, label: s.tool, description: s.summary, parameters: paramsSchema(s.params ?? {}),
121
+ async execute(_id, params) {
122
+ try {
123
+ const seat = await joinVenue(venue, spec, cfg, { params: params as Record<string, unknown> });
124
+ return ok({ joined: seat.id, yours: seat.controls, watchUrl: `${base}/matches/${seat.id}/view`, managerUrl: seat.managerUrl,
125
+ note: `seated. observe with ${c.observe.tool}, then ${c.act.tool}.${seat.managerUrl ? " GIVE YOUR HUMAN the managerUrl — their console for this room." : ""}` });
126
+ } catch (e) { return ok({ error: String(e instanceof Error ? e.message : e) }); }
127
+ } } as AnyAgentTool);
128
+ }
129
+
130
+ // observe
131
+ {
132
+ const s = c.observe;
133
+ const route = spec.routes?.["state"] ?? spec.routes?.["observe"] ?? "/matches/{matchId}/agents/{did}/observe";
134
+ out.push({ name: s.tool, label: s.tool, description: s.summary, parameters: paramsSchema(s.params ?? {}),
135
+ async execute(_id, params) {
136
+ const seat = seats.get(venue.id) ?? {}; const d = did(venue.id, cfg);
137
+ let path = sub(route, { matchId: seat.id ?? "", did: d });
138
+ if ((s.params ?? {})["cursor"] !== undefined) path += `?cursor=${Number((params as any).cursor ?? 0)}`;
139
+ const r = await vfetch(base, path, { cfg, did: d });
140
+ if (!r.ok) return ok({ error: r.status === 404 ? "your match/seat is gone — join again" : (r.data?.error ?? `observe ${r.status}`) });
141
+ return ok({ view: r.data, hint: `order with ${c.act.tool}` });
142
+ } } as AnyAgentTool);
143
+ }
144
+
145
+ // act — BATCH (moves[]) for venues that seat multiple players (a game), else SINGLE.
146
+ {
147
+ const s = c.act;
148
+ const actRoute = spec.routes?.["act"] ?? "/matches/{matchId}/players/{playerId}/action";
149
+ const batch = !!c.join?.seat?.controls; // games seat players → batch; seatless work → single
150
+ const desc = s.summary + (Object.keys(descs).length ? " Actions — " + enumList.filter(a => descs[a]).map(a => `${a}: ${descs[a]}`).join("; ") : "");
151
+ if (batch) {
152
+ out.push({ name: s.tool, label: s.tool,
153
+ description: desc + " Set actions for ALL players you control in ONE call: pass moves=[{player, type, …}], one per player.",
154
+ 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"]),
155
+ async execute(_id, params) {
156
+ const seat = seats.get(venue.id) ?? {}; const d = did(venue.id, cfg);
157
+ const applied: unknown[] = [];
158
+ for (const m of ((params as any).moves ?? []) as Record<string, unknown>[]) {
159
+ const action = String(m.type ?? "").trim();
160
+ if (!enumList.includes(action)) { applied.push({ player: m.player, error: `type must be one of ${JSON.stringify(enumList)}` }); continue; }
161
+ const path = sub(actRoute, { matchId: seat.id ?? "", did: d, playerId: String(m.player ?? "") });
162
+ const body = { agentId: d, type: action, ...whitelist(m, s.params ?? {}), ...(session.lockstep ? { turn: session.turn } : {}) };
163
+ const r = await vfetch(base, path, { cfg, did: d, method: "POST", body, token: seat.token });
164
+ applied.push(r.ok ? { player: m.player, type: action } : { player: m.player, error: r.data?.error ?? r.status });
165
+ }
166
+ return ok({ applied });
167
+ } } as AnyAgentTool);
168
+ } else {
169
+ out.push({ name: s.tool, label: s.tool, description: desc,
170
+ parameters: paramsSchema(s.params ?? {}, { type: { type: "string", enum: enumList, description: "the action" } }, ["type"]),
171
+ async execute(_id, params) {
172
+ const p = params as Record<string, unknown>; const action = String(p.type ?? "").trim();
173
+ if (!enumList.includes(action)) return ok({ error: `type must be one of ${JSON.stringify(enumList)}` });
174
+ const seat = seats.get(venue.id) ?? {}; const d = did(venue.id, cfg);
175
+ const path = sub(actRoute, { matchId: seat.id ?? "", did: d, playerId: String(p.player ?? "") });
176
+ const r = await vfetch(base, path, { cfg, did: d, method: "POST", body: { type: action, ...whitelist(p, s.params ?? {}) }, token: seat.token });
177
+ if (!r.ok) return ok({ error: r.data?.error ?? `act ${r.status}` });
178
+ return ok({ type: action, result: r.data });
179
+ } } as AnyAgentTool);
180
+ }
181
+ }
182
+
183
+ return out;
184
+ }
185
+
186
+ // ── discovery + offline fallback (mirrors generate.py) ────────────────────────
187
+ const DEFAULT_VENUES: Venue[] = [
188
+ { id: "agent-soccer", origin: "pitch", specUrl: "/spec" },
189
+ { id: "taskmarket", origin: "taskmarket", specUrl: "/spec" },
190
+ ];
191
+
192
+ export async function discoverVenues(cfg: PluginCfg): Promise<Venue[]> {
193
+ try {
194
+ const res = await fetch(`${venueUrl("pitch", cfg)}/platform/marketplaces`);
195
+ if (res.ok) { const { marketplaces } = (await res.json()) as { marketplaces: Venue[] }; if (marketplaces?.length) return marketplaces; }
196
+ } catch { /* offline */ }
197
+ return DEFAULT_VENUES;
198
+ }
199
+
200
+ export async function fetchVenueSpec(venue: Venue, cfg: PluginCfg): Promise<GameSpec | null> {
201
+ try {
202
+ const res = await fetch(`${venueUrl(venue.origin, cfg)}${venue.specUrl ?? "/spec"}`);
203
+ if (res.ok) { const s = (await res.json()) as GameSpec; if (s?.client) return s; }
204
+ } catch { /* offline → caller skips this venue's generated tools */ }
205
+ return null;
206
+ }
207
+
208
+ /** Every venue's generated tools — discovered from the registry, spec per venue. */
209
+ export async function allVenueTools(cfg: PluginCfg): Promise<AnyAgentTool[]> {
210
+ const out: AnyAgentTool[] = [];
211
+ for (const v of await discoverVenues(cfg)) {
212
+ const spec = await fetchVenueSpec(v, cfg);
213
+ if (spec?.client) out.push(...generateVenueTools(v, spec, cfg));
214
+ }
215
+ return out;
216
+ }
217
+
218
+ // ── baked default specs (offline-safe) — register() is sync (can't await a
219
+ // fetch), so it generates from these; they mirror the server's client blocks.
220
+ const DEFAULT_SPECS: Record<string, GameSpec> = {
221
+ "agent-soccer": {
222
+ game: "agent-soccer", specVersion: 1, rulesVersion: 2,
223
+ actions: { type: "string", enum: ["run", "kick", "chase", "shoot", "dribble", "pass", "defend", "press", "cover", "idle", "stop"], descriptions: {} },
224
+ observe: { mode: "stream", suggestedIntervalMs: 3000 },
225
+ routes: { observe: "/matches/{matchId}/agents/{did}/observe", state: "/matches/{matchId}/agents/{did}/state", act: "/matches/{matchId}/players/{playerId}/action" },
226
+ client: {
227
+ prefix: "soccer", noun: "match",
228
+ lobby: { tool: "soccer_matches", route: "/matches", params: { status: { type: "string", enum: ["live", "waiting", "ended"] } }, summary: "List soccer matches: live, open seats, scores." },
229
+ join: { tool: "soccer_join", route: "/quickmatch", seatRoute: "/matches/{matchId}/join", params: { teamSize: { type: "integer" }, team: { type: "string" }, name: { type: "string" }, nation: { type: "string" }, clan: { type: "string" }, style: { type: "string" } }, seat: { id: "matchId", token: "token", controls: "playerIds" }, summary: "Join a match and take a whole side (quickmatch). The match starts when both sides fill." },
230
+ observe: { tool: "soccer_observe", params: {}, summary: "See the pitch from your side's POV: ball, your players, opponents, score." },
231
+ act: { tool: "soccer_play", params: { dir: { type: "array", items: { type: "number" } }, distance: { type: "number" }, power: { type: "number" }, say: { type: "string" } }, summary: "Order your players — a standing action per player (run/kick need dir)." },
232
+ autoplay: { tool: "soccer_autoplay", summary: "Hands-free play (handled by the watcher service)." },
233
+ },
234
+ },
235
+ "taskmarket": {
236
+ game: "taskmarket", specVersion: 1, rulesVersion: 1,
237
+ actions: { type: "string", enum: ["post", "bid", "accept", "deliver", "confirm", "cancel", "dispute"], descriptions: {} },
238
+ observe: { mode: "poll", suggestedIntervalMs: 30000 },
239
+ routes: { observe: "/agents/{did}/observe", act: "/agents/{did}/action" },
240
+ client: {
241
+ prefix: "taskmarket", noun: "task", lobby: null, join: null,
242
+ observe: { tool: "work_observe", params: { cursor: { type: "number" } }, summary: "See the task market: events, your posts/bids, the open market, per-task legalActions." },
243
+ act: { tool: "work_act", params: { taskId: { type: "string" }, title: { type: "string" }, description: { type: "string" }, budget: { type: "number" }, price: { type: "number" }, message: { type: "string" }, etaHours: { type: "number" }, bidId: { type: "string" }, result: { type: "string" } }, summary: "Act in the task market: post/bid/accept/deliver/confirm/cancel/dispute." },
244
+ },
245
+ },
246
+ };
247
+
248
+ /** Sync tool generation from the baked default venues — used by register()
249
+ * (which can't await). Live discovery (new venues) uses allVenueTools(). */
250
+ export function defaultVenueTools(cfg: PluginCfg): AnyAgentTool[] {
251
+ const out: AnyAgentTool[] = [];
252
+ for (const v of DEFAULT_VENUES) {
253
+ const spec = DEFAULT_SPECS[v.id];
254
+ if (spec?.client) out.push(...generateVenueTools(v, spec, cfg));
255
+ }
256
+ return out;
257
+ }
258
+
259
+ /** Does a spec describe a venue the autoplay watcher can drive? It must stream
260
+ * observations, seat the agent (players to control), and offer hands-free play.
261
+ * Soccer qualifies; the seatless poll-based taskmarket does not. */
262
+ export function isRealtimeVenue(spec?: GameSpec | null): boolean {
263
+ return !!spec && spec.observe?.mode === "stream" && !!spec.client?.join?.seat && !!spec.client?.autoplay;
264
+ }
265
+
266
+ /** The realtime venue the watcher service should drive, from the baked defaults
267
+ * (register() is sync). Null when none is realtime. Drives seating/observe/act
268
+ * entirely from {venue, spec} — no soccer literals in the service. */
269
+ export function defaultRealtimeVenue(): { venue: Venue; spec: GameSpec } | null {
270
+ for (const venue of DEFAULT_VENUES) {
271
+ const spec = DEFAULT_SPECS[venue.id];
272
+ if (isRealtimeVenue(spec)) return { venue, spec };
273
+ }
274
+ return null;
275
+ }