@agentmessier/openclaw-agent-messier 0.3.0 → 0.3.1

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,39 @@ 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: a seat may be taken from ANOTHER process (a chat turn, the
120
+ // generated *_join tool, the dashboard). Poll the venue's lobby for a
121
+ // non-ended room that references our agentId and aim the loop at it. Driven
122
+ // by spec.client.lobby.route no hardcoded /matches path. Skipped for
123
+ // venues with no lobby in their spec.
124
+ if (lobbyRoute) {
125
+ poller = setInterval(async () => {
126
+ try {
127
+ const res = await fetch(`${base}${lobbyRoute}`);
128
+ if (!res.ok) return;
129
+ const data = (await res.json()) as Record<string, any>;
130
+ const rows = (data.matches ?? data.rows ?? []) as Record<string, any>[];
131
+ const mine = rows.find((r) => !ENDED.has(String(r.status ?? "")) && referencesAgent(r, agentId));
132
+ const id = mine?.id ?? mine?.[roomIdField];
133
+ if (id && id !== session.matchId) {
134
+ ctx.logger.info(`[${label}] found my seat in ${id} (taken elsewhere) — starting to play`);
135
+ await session.joinAndWatch!(id);
136
+ }
137
+ } catch { /* server down — the watcher's own backoff handles it */ }
138
+ }, 10_000);
139
+ }
110
140
  },
111
141
 
112
142
  stop: async (ctx) => {
113
- ctx.logger.info("[agentnet-soccer] watcher stopping");
143
+ ctx.logger.info(`[${label}] watcher stopping`);
114
144
  if (poller) { clearInterval(poller); poller = null; }
115
145
  controller?.abort();
116
146
  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.1",
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,113 @@
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("service extras (teamSize/identity) ride along but tool calls omit them", async () => {
90
+ let body: any = null;
91
+ vi.stubGlobal("fetch", vi.fn(async (_u: any, init?: any) => {
92
+ body = JSON.parse(init.body);
93
+ return { ok: true, json: async () => ({ matchId: "r1", token: "t", playerIds: [] }) } as any;
94
+ }));
95
+ await joinVenue(GOLF_VENUE, GOLF_SPEC, cfg(), { params: { holes: 18 }, extra: { teamSize: 5, identity: { name: "Eagles" } } });
96
+ expect(body).toMatchObject({ holes: 18, teamSize: 5, identity: { name: "Eagles" } });
97
+ });
98
+ });
99
+
100
+ describe("realtime-venue detection (which venue the autoplay watcher drives)", () => {
101
+ it("a streamed + seated + autoplay venue is realtime; seatless/poll is not", () => {
102
+ expect(isRealtimeVenue(GOLF_SPEC)).toBe(true);
103
+ expect(isRealtimeVenue({ ...GOLF_SPEC, observe: { mode: "poll", suggestedIntervalMs: 1000 } })).toBe(false);
104
+ expect(isRealtimeVenue({ ...GOLF_SPEC, client: { ...GOLF_SPEC.client!, join: null } } as GameSpec)).toBe(false);
105
+ expect(isRealtimeVenue(null)).toBe(false);
106
+ });
107
+
108
+ it("the baked defaults pick soccer (realtime), never the seatless taskmarket", () => {
109
+ const rt = defaultRealtimeVenue();
110
+ expect(rt?.venue.id).toBe("agent-soccer");
111
+ expect(rt?.spec.client?.act.tool).toBe("soccer_play");
112
+ });
113
+ });
@@ -0,0 +1,272 @@
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
+ const seat: Seat = { id: d[sm.id], token: d[sm.token], controls: d[sm.controls] ?? [], agentId: d.did ?? meId, started: d.started, managerUrl: d.managerUrl };
89
+ seats.set(venue.id, seat);
90
+ // soccer back-compat: the service watcher + seat-poller read `session`.
91
+ session.matchId = seat.id ?? null; session.players = seat.controls ?? []; session.token = seat.token ?? null; session.did = seat.agentId ?? null;
92
+ return seat;
93
+ }
94
+
95
+ export function generateVenueTools(venue: Venue, spec: GameSpec, cfg: PluginCfg): AnyAgentTool[] {
96
+ const c = spec.client!;
97
+ const base = venueUrl(venue.origin, cfg);
98
+ const enumList = spec.actions?.enum ?? [];
99
+ const descs = spec.actions?.descriptions ?? {};
100
+ const out: AnyAgentTool[] = [];
101
+
102
+ if (c.lobby) {
103
+ const s = c.lobby;
104
+ out.push({ name: s.tool, label: s.tool, description: s.summary, parameters: paramsSchema(s.params ?? {}),
105
+ async execute(_id, params) {
106
+ const r = await vfetch(base, s.route, { cfg, did: did(venue.id, cfg) });
107
+ if (!r.ok) return ok({ error: r.data?.error ?? `lobby ${r.status}` });
108
+ let rows = (r.data?.matches ?? r.data?.rows ?? []) as Record<string, unknown>[];
109
+ const want = String((params as any).status ?? "").trim().toLowerCase();
110
+ if (want) rows = rows.filter(x => x["status"] === want);
111
+ return ok({ count: rows.length, rows, hint: `join with ${c.prefix}_join` });
112
+ } } as AnyAgentTool);
113
+ }
114
+
115
+ if (c.join) {
116
+ const s = c.join;
117
+ out.push({ name: s.tool, label: s.tool, description: s.summary, parameters: paramsSchema(s.params ?? {}),
118
+ async execute(_id, params) {
119
+ try {
120
+ const seat = await joinVenue(venue, spec, cfg, { params: params as Record<string, unknown> });
121
+ return ok({ joined: seat.id, yours: seat.controls, watchUrl: `${base}/matches/${seat.id}/view`, managerUrl: seat.managerUrl,
122
+ note: `seated. observe with ${c.observe.tool}, then ${c.act.tool}.${seat.managerUrl ? " GIVE YOUR HUMAN the managerUrl — their console for this room." : ""}` });
123
+ } catch (e) { return ok({ error: String(e instanceof Error ? e.message : e) }); }
124
+ } } as AnyAgentTool);
125
+ }
126
+
127
+ // observe
128
+ {
129
+ const s = c.observe;
130
+ const route = spec.routes?.["state"] ?? spec.routes?.["observe"] ?? "/matches/{matchId}/agents/{did}/observe";
131
+ out.push({ name: s.tool, label: s.tool, description: s.summary, parameters: paramsSchema(s.params ?? {}),
132
+ async execute(_id, params) {
133
+ const seat = seats.get(venue.id) ?? {}; const d = did(venue.id, cfg);
134
+ let path = sub(route, { matchId: seat.id ?? "", did: d });
135
+ if ((s.params ?? {})["cursor"] !== undefined) path += `?cursor=${Number((params as any).cursor ?? 0)}`;
136
+ const r = await vfetch(base, path, { cfg, did: d });
137
+ if (!r.ok) return ok({ error: r.status === 404 ? "your match/seat is gone — join again" : (r.data?.error ?? `observe ${r.status}`) });
138
+ return ok({ view: r.data, hint: `order with ${c.act.tool}` });
139
+ } } as AnyAgentTool);
140
+ }
141
+
142
+ // act — BATCH (moves[]) for venues that seat multiple players (a game), else SINGLE.
143
+ {
144
+ const s = c.act;
145
+ const actRoute = spec.routes?.["act"] ?? "/matches/{matchId}/players/{playerId}/action";
146
+ const batch = !!c.join?.seat?.controls; // games seat players → batch; seatless work → single
147
+ const desc = s.summary + (Object.keys(descs).length ? " Actions — " + enumList.filter(a => descs[a]).map(a => `${a}: ${descs[a]}`).join("; ") : "");
148
+ if (batch) {
149
+ out.push({ name: s.tool, label: s.tool,
150
+ description: desc + " Set actions for ALL players you control in ONE call: pass moves=[{player, type, …}], one per player.",
151
+ 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"]),
152
+ async execute(_id, params) {
153
+ const seat = seats.get(venue.id) ?? {}; const d = did(venue.id, cfg);
154
+ const applied: unknown[] = [];
155
+ for (const m of ((params as any).moves ?? []) as Record<string, unknown>[]) {
156
+ const action = String(m.type ?? "").trim();
157
+ if (!enumList.includes(action)) { applied.push({ player: m.player, error: `type must be one of ${JSON.stringify(enumList)}` }); continue; }
158
+ const path = sub(actRoute, { matchId: seat.id ?? "", did: d, playerId: String(m.player ?? "") });
159
+ const body = { agentId: d, type: action, ...whitelist(m, s.params ?? {}), ...(session.lockstep ? { turn: session.turn } : {}) };
160
+ const r = await vfetch(base, path, { cfg, did: d, method: "POST", body, token: seat.token });
161
+ applied.push(r.ok ? { player: m.player, type: action } : { player: m.player, error: r.data?.error ?? r.status });
162
+ }
163
+ return ok({ applied });
164
+ } } as AnyAgentTool);
165
+ } else {
166
+ out.push({ name: s.tool, label: s.tool, description: desc,
167
+ parameters: paramsSchema(s.params ?? {}, { type: { type: "string", enum: enumList, description: "the action" } }, ["type"]),
168
+ async execute(_id, params) {
169
+ const p = params as Record<string, unknown>; const action = String(p.type ?? "").trim();
170
+ if (!enumList.includes(action)) return ok({ error: `type must be one of ${JSON.stringify(enumList)}` });
171
+ const seat = seats.get(venue.id) ?? {}; const d = did(venue.id, cfg);
172
+ const path = sub(actRoute, { matchId: seat.id ?? "", did: d, playerId: String(p.player ?? "") });
173
+ const r = await vfetch(base, path, { cfg, did: d, method: "POST", body: { type: action, ...whitelist(p, s.params ?? {}) }, token: seat.token });
174
+ if (!r.ok) return ok({ error: r.data?.error ?? `act ${r.status}` });
175
+ return ok({ type: action, result: r.data });
176
+ } } as AnyAgentTool);
177
+ }
178
+ }
179
+
180
+ return out;
181
+ }
182
+
183
+ // ── discovery + offline fallback (mirrors generate.py) ────────────────────────
184
+ const DEFAULT_VENUES: Venue[] = [
185
+ { id: "agent-soccer", origin: "pitch", specUrl: "/spec" },
186
+ { id: "taskmarket", origin: "taskmarket", specUrl: "/spec" },
187
+ ];
188
+
189
+ export async function discoverVenues(cfg: PluginCfg): Promise<Venue[]> {
190
+ try {
191
+ const res = await fetch(`${venueUrl("pitch", cfg)}/platform/marketplaces`);
192
+ if (res.ok) { const { marketplaces } = (await res.json()) as { marketplaces: Venue[] }; if (marketplaces?.length) return marketplaces; }
193
+ } catch { /* offline */ }
194
+ return DEFAULT_VENUES;
195
+ }
196
+
197
+ export async function fetchVenueSpec(venue: Venue, cfg: PluginCfg): Promise<GameSpec | null> {
198
+ try {
199
+ const res = await fetch(`${venueUrl(venue.origin, cfg)}${venue.specUrl ?? "/spec"}`);
200
+ if (res.ok) { const s = (await res.json()) as GameSpec; if (s?.client) return s; }
201
+ } catch { /* offline → caller skips this venue's generated tools */ }
202
+ return null;
203
+ }
204
+
205
+ /** Every venue's generated tools — discovered from the registry, spec per venue. */
206
+ export async function allVenueTools(cfg: PluginCfg): Promise<AnyAgentTool[]> {
207
+ const out: AnyAgentTool[] = [];
208
+ for (const v of await discoverVenues(cfg)) {
209
+ const spec = await fetchVenueSpec(v, cfg);
210
+ if (spec?.client) out.push(...generateVenueTools(v, spec, cfg));
211
+ }
212
+ return out;
213
+ }
214
+
215
+ // ── baked default specs (offline-safe) — register() is sync (can't await a
216
+ // fetch), so it generates from these; they mirror the server's client blocks.
217
+ const DEFAULT_SPECS: Record<string, GameSpec> = {
218
+ "agent-soccer": {
219
+ game: "agent-soccer", specVersion: 1, rulesVersion: 2,
220
+ actions: { type: "string", enum: ["run", "kick", "chase", "shoot", "dribble", "pass", "defend", "press", "cover", "idle", "stop"], descriptions: {} },
221
+ observe: { mode: "stream", suggestedIntervalMs: 3000 },
222
+ routes: { observe: "/matches/{matchId}/agents/{did}/observe", state: "/matches/{matchId}/agents/{did}/state", act: "/matches/{matchId}/players/{playerId}/action" },
223
+ client: {
224
+ prefix: "soccer", noun: "match",
225
+ lobby: { tool: "soccer_matches", route: "/matches", params: { status: { type: "string", enum: ["live", "waiting", "ended"] } }, summary: "List soccer matches: live, open seats, scores." },
226
+ 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." },
227
+ observe: { tool: "soccer_observe", params: {}, summary: "See the pitch from your side's POV: ball, your players, opponents, score." },
228
+ 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)." },
229
+ autoplay: { tool: "soccer_autoplay", summary: "Hands-free play (handled by the watcher service)." },
230
+ },
231
+ },
232
+ "taskmarket": {
233
+ game: "taskmarket", specVersion: 1, rulesVersion: 1,
234
+ actions: { type: "string", enum: ["post", "bid", "accept", "deliver", "confirm", "cancel", "dispute"], descriptions: {} },
235
+ observe: { mode: "poll", suggestedIntervalMs: 30000 },
236
+ routes: { observe: "/agents/{did}/observe", act: "/agents/{did}/action" },
237
+ client: {
238
+ prefix: "taskmarket", noun: "task", lobby: null, join: null,
239
+ observe: { tool: "work_observe", params: { cursor: { type: "number" } }, summary: "See the task market: events, your posts/bids, the open market, per-task legalActions." },
240
+ 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." },
241
+ },
242
+ },
243
+ };
244
+
245
+ /** Sync tool generation from the baked default venues — used by register()
246
+ * (which can't await). Live discovery (new venues) uses allVenueTools(). */
247
+ export function defaultVenueTools(cfg: PluginCfg): AnyAgentTool[] {
248
+ const out: AnyAgentTool[] = [];
249
+ for (const v of DEFAULT_VENUES) {
250
+ const spec = DEFAULT_SPECS[v.id];
251
+ if (spec?.client) out.push(...generateVenueTools(v, spec, cfg));
252
+ }
253
+ return out;
254
+ }
255
+
256
+ /** Does a spec describe a venue the autoplay watcher can drive? It must stream
257
+ * observations, seat the agent (players to control), and offer hands-free play.
258
+ * Soccer qualifies; the seatless poll-based taskmarket does not. */
259
+ export function isRealtimeVenue(spec?: GameSpec | null): boolean {
260
+ return !!spec && spec.observe?.mode === "stream" && !!spec.client?.join?.seat && !!spec.client?.autoplay;
261
+ }
262
+
263
+ /** The realtime venue the watcher service should drive, from the baked defaults
264
+ * (register() is sync). Null when none is realtime. Drives seating/observe/act
265
+ * entirely from {venue, spec} — no soccer literals in the service. */
266
+ export function defaultRealtimeVenue(): { venue: Venue; spec: GameSpec } | null {
267
+ for (const venue of DEFAULT_VENUES) {
268
+ const spec = DEFAULT_SPECS[venue.id];
269
+ if (isRealtimeVenue(spec)) return { venue, spec };
270
+ }
271
+ return null;
272
+ }
package/src/spec.test.ts CHANGED
@@ -1,5 +1,15 @@
1
1
  import { describe, it, expect, afterEach, vi } from "vitest";
2
- import { fetchSpec, playActionTypes, createSoccerTools, type GameSpec, type PluginCfg } from "./tools.js";
2
+ import { fetchSpec, playActionTypes, type GameSpec, type PluginCfg } from "./tools.js";
3
+ import { generateVenueTools } from "./generate.js";
4
+
5
+ const SOCCER_VENUE = { id: "agent-soccer", origin: "pitch", specUrl: "/spec" };
6
+ function withClient(spec: GameSpec): GameSpec {
7
+ return { ...spec, routes: { act: "/matches/{matchId}/players/{playerId}/action", observe: "/matches/{matchId}/agents/{did}/observe" },
8
+ client: { prefix: "soccer", noun: "match",
9
+ join: { tool: "soccer_join", route: "/quickmatch", params: {}, seat: { id: "matchId", token: "token", controls: "playerIds" }, summary: "join" },
10
+ observe: { tool: "soccer_observe", params: {}, summary: "see" },
11
+ act: { tool: "soccer_play", params: {}, summary: "order" } } };
12
+ }
3
13
 
4
14
  // A FIXTURE manifest with a FAKE action the static list never had. Adding it
5
15
  // here must surface it in the generated tool with zero further code change.
@@ -53,21 +63,12 @@ describe("Phase 4 — soccer tools generate from /spec (static fallback when abs
53
63
  expect(await fetchSpec(cfg())).toBeNull();
54
64
  });
55
65
 
56
- it("createSoccerTools wires the spec into soccer_play's action enum", () => {
57
- const tools = createSoccerTools({ pluginConfig: cfg({ mode: "easy" }), config: {} } as any, FIXTURE);
58
- const play = tools.find(t => t.name === "soccer_play")!;
59
- const moveSchema: any = (play.parameters as any).properties.moves.items;
60
- const actionEnum: string[] = moveSchema.properties.action.anyOf.map((s: any) => s.const);
61
- expect(actionEnum).toContain("teleport");
62
- });
63
-
64
- it("createSoccerTools without a spec keeps the static play vocabulary", () => {
65
- const tools = createSoccerTools({ pluginConfig: cfg({ mode: "easy" }), config: {} } as any);
66
+ it("the generated soccer_play (batch) carries the spec's action enum (fake action included)", () => {
67
+ const tools = generateVenueTools(SOCCER_VENUE, withClient(FIXTURE), cfg());
66
68
  const play = tools.find(t => t.name === "soccer_play")!;
67
- const moveSchema: any = (play.parameters as any).properties.moves.items;
68
- const actionEnum: string[] = moveSchema.properties.action.anyOf.map((s: any) => s.const);
69
- expect(actionEnum).toContain("chase");
70
- expect(actionEnum).not.toContain("teleport");
69
+ const enumList: string[] = (play.parameters as any).properties.moves.items.properties.type.enum;
70
+ expect(enumList).toContain("teleport"); // a server-added action surfaces with zero plugin edit
71
+ expect(enumList).toContain("shoot");
71
72
  });
72
73
  });
73
74