@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 +86 -53
- package/package.json +1 -1
- package/src/generate.test.ts +124 -0
- package/src/generate.ts +275 -0
- package/src/spec.test.ts +16 -15
- package/src/state.ts +4 -1
- package/src/tools.ts +46 -384
- package/src/venues.test.ts +20 -16
- package/src/watcher.test.ts +19 -3
- package/src/watcher.ts +48 -21
package/index.ts
CHANGED
|
@@ -1,16 +1,45 @@
|
|
|
1
1
|
import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
|
2
|
-
import {
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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:
|
|
51
|
+
id: `agentnet-${venue.id}-watcher`,
|
|
23
52
|
|
|
24
53
|
start: async (ctx) => {
|
|
25
54
|
const agentId = agentIdOf(cfg);
|
|
26
|
-
|
|
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
|
-
//
|
|
29
|
-
// Installed into shared state so the
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
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(
|
|
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
|
|
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 —
|
|
64
|
-
// or (room gone
|
|
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
|
|
67
|
-
|
|
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
|
|
72
|
-
ctx.logger.info(`[
|
|
73
|
-
if (q.
|
|
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
|
|
82
|
-
// (find-or-create, atomic server-side); otherwise idle until
|
|
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
|
|
86
|
-
else if (cfg.autoJoin)
|
|
87
|
-
|
|
88
|
-
|
|
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:
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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(
|
|
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.
|
|
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
|
+
});
|
package/src/generate.ts
ADDED
|
@@ -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
|
+
}
|