@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/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
 
package/src/state.ts CHANGED
@@ -19,5 +19,8 @@ export const session: {
19
19
  did: string | null
20
20
  turn: number
21
21
  lockstep: boolean
22
- joinAndWatch: ((matchId: string, team?: "home" | "away") => Promise<{ team: string; playerIds: string[]; started: boolean }>) | null
22
+ /** Installed by the service: seat into a venue room (matchId omitted = quickmatch
23
+ * find-or-create) and start the observe/act loop. `params` are venue join params
24
+ * (e.g. teamSize/team for soccer). Returns the seat the loop is now driving. */
25
+ joinAndWatch: ((matchId?: string, params?: Record<string, unknown>) => Promise<{ id?: string; controls?: string[]; started?: boolean }>) | null
23
26
  } = { matchId: null, players: [], token: null, did: null, turn: 0, lockstep: false, joinAndWatch: null }
package/src/tools.ts CHANGED
@@ -48,6 +48,22 @@ export type GameSpec = {
48
48
  /** The self-instructable envelope — how to play, server-authored, frozen per
49
49
  * match. Optional: absent on pre-envelope servers (fallback prompt used). */
50
50
  instructions?: { system: string; play: string; output: string };
51
+ /** Endpoint templates a generic client substitutes ({matchId}/{did}/{playerId}).
52
+ * The watcher builds observe/act URLs from these, not literal paths. */
53
+ routes?: Record<string, string>;
54
+ /** How the venue wants to be watched (stream vs poll). */
55
+ observe?: { mode: string; suggestedIntervalMs: number };
56
+ /** The client lifecycle contract — tool names + param schemas the plugin
57
+ * GENERATES its tool surface from (venue-agnostic-plugins.md). */
58
+ client?: {
59
+ prefix: string;
60
+ noun: string;
61
+ lobby?: { tool: string; route: string; params?: Record<string, unknown>; summary: string } | null;
62
+ join?: { tool: string; route: string; seatRoute?: string; params?: Record<string, unknown>; seat: { id: string; token: string; controls: string }; summary: string } | null;
63
+ observe: { tool: string; params?: Record<string, unknown>; summary: string };
64
+ act: { tool: string; params?: Record<string, unknown>; summary: string };
65
+ autoplay?: { tool: string; summary: string };
66
+ };
51
67
  };
52
68
 
53
69
  // STATIC FALLBACK vocabularies — used when /spec is unreachable (offline-safe).
@@ -172,7 +188,7 @@ export function pitchClient(cfg: PluginCfg) {
172
188
  const seat = matches.find(r => r.status !== "ended" && (r.sides.home === me || r.sides.away === me));
173
189
  if (seat) { session.matchId = seat.id; return encodeURIComponent(seat.id); }
174
190
  }
175
- throw new Error("not in a match — use soccer_join_match or soccer_create_match first");
191
+ throw new Error("not in a match — use soccer_join first");
176
192
  };
177
193
  return {
178
194
  async lobby(): Promise<{ matches: { id: string; status: string; teamSize: number; maxGoals: number; score: { home: number; away: number }; sides: { home: string | null; away: string | null } }[] }> {
@@ -282,95 +298,23 @@ export function pitchClient(cfg: PluginCfg) {
282
298
  };
283
299
  }
284
300
 
285
- const ok = (data: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(data) }] });
286
- const err = (msg: string) => ({ content: [{ type: "text" as const, text: `error: ${msg}` }], isError: true });
301
+ export const ok = (data: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(data) }] });
302
+ export const err = (msg: string) => ({ content: [{ type: "text" as const, text: `error: ${msg}` }], isError: true });
287
303
 
288
- /** Resolve which player a tool call targets: explicit `player`, or the single
289
- * claimed player when this agent controls exactly one. */
290
- function target(player?: string): { id: string } | { error: string } {
291
- if (player) {
292
- if (session.players.length && !session.players.includes(player)) {
293
- return { error: `you don't control "${player}". Your players: ${session.players.join(", ")}` };
294
- }
295
- return { id: player };
296
- }
297
- if (session.players.length === 1) return { id: session.players[0]! };
298
- return { error: `specify player="<id>" — you control: ${session.players.join(", ") || "(none yet)"}` };
304
+ // ── venue-URL resolution (origin base URL); kept generate.ts imports it. ──
305
+ const VENUE_DEFAULTS: Record<string, string> = { taskmarket: "http://localhost:3030" };
306
+ export function venueUrl(origin: string, cfg: PluginCfg): string {
307
+ if (origin.startsWith("http://") || origin.startsWith("https://")) return origin;
308
+ if (origin === "pitch") return (cfg.serverUrl ?? "http://localhost:3010").replace(/\/$/, "");
309
+ return process.env[`AGENTNET_${origin.toUpperCase()}_URL`] ?? VENUE_DEFAULTS[origin] ?? (cfg.serverUrl ?? "http://localhost:3010");
299
310
  }
300
311
 
301
- const playerParam = Type.Optional(
302
- Type.String({ description: "Which of your players to move (id, e.g. home-9). Omit only if you control exactly one." }),
303
- );
304
-
305
- // ── Matchmaking: how a human (chatting with their agent) gets into a game. ──
306
- function lobbyTools(cfg: PluginCfg): AnyAgentTool[] {
312
+ // ── Member / account tools — soccer cosmetic + ownership, NOT lifecycle, so
313
+ // they stay hand-written (a venue's lifecycle tools are generated; perks aren't). ──
314
+ export function memberTools(cfg: PluginCfg): AnyAgentTool[] {
307
315
  const client = pitchClient(cfg);
308
316
  const agentId = agentIdOf(cfg);
309
- const fmt = (r: { id: string; status: string; teamSize: number; score: { home: number; away: number }; sides: { home: string | null; away: string | null } }) =>
310
- `${r.id}: ${r.teamSize}v${r.teamSize} [${r.status}] ${r.score.home}-${r.score.away} home=${r.sides.home ?? "OPEN"} away=${r.sides.away ?? "OPEN"}`;
311
- const joinVia = async (matchId: string, team?: "home" | "away") => {
312
- // In the gateway process the service installed joinAndWatch (joins AND
313
- // starts the playing loop). In a chat process there is no watcher — just
314
- // take the seat on the server; the gateway's seat-poller will notice the
315
- // seat within seconds and start playing.
316
- if (session.joinAndWatch) return session.joinAndWatch(matchId, team);
317
- const seat = await client.join(matchId, agentId, team);
318
- session.matchId = matchId;
319
- session.players = seat.playerIds;
320
- return { ...seat, started: seat.started };
321
- };
322
317
  return [
323
- {
324
- name: "soccer_find_matches",
325
- label: "Find matches",
326
- description:
327
- "List open soccer rooms (the lobby): id, format (e.g. 5v5 or 11v11), status, score, which sides are OPEN. Use when the human asks to find/watch/join a game.",
328
- parameters: Type.Object({
329
- teamSize: Type.Optional(Type.Number({ description: "filter by players per side, e.g. 5 or 11" })),
330
- }),
331
- async execute(_id, params) {
332
- const { matches } = await client.lobby();
333
- const list = matches.filter(r => !params.teamSize || r.teamSize === params.teamSize);
334
- return ok({ matches: list.map(fmt), hint: list.some(r => r.status === "waiting") ? "join a waiting room with soccer_join_match" : "no open rooms — create one with soccer_create_match" });
335
- },
336
- },
337
- {
338
- name: "soccer_create_match",
339
- label: "Create a match",
340
- description:
341
- "Create a new room and take a side in it. The match starts automatically when an opponent joins. Use when the human says 'create a 5v5 game', 'start an 11-player match', etc.",
342
- parameters: Type.Object({
343
- teamSize: Type.Number({ minimum: 1, maximum: 11, description: "players per side (5 = five-a-side, 11 = standard)" }),
344
- maxGoals: Type.Optional(Type.Number({ description: "match ends when total goals exceed this (default 5)" })),
345
- team: Type.Optional(Type.Union([Type.Literal("home"), Type.Literal("away")])),
346
- }),
347
- async execute(_id, params) {
348
- const { id } = await client.createRoom({ teamSize: params.teamSize, ...(params.maxGoals ? { maxGoals: params.maxGoals } : {}) });
349
- const seat = await joinVia(id, params.team);
350
- return ok({ matchId: id, ...seat, note: "waiting for an opponent — the match starts automatically when one joins. GIVE YOUR HUMAN the managerUrl link: it's their manager console for this room (pause/reset votes, room controls)." });
351
- },
352
- },
353
- {
354
- name: "soccer_join_match",
355
- label: "Join a match",
356
- description:
357
- "Join a room and control a WHOLE side. With matchId: join that room. Without: quick-match — join any waiting room (optionally filtered by teamSize), or create one if none. The match starts automatically when both sides are taken.",
358
- parameters: Type.Object({
359
- matchId: Type.Optional(Type.String()),
360
- teamSize: Type.Optional(Type.Number({ description: "quick-match filter / size for a new room (default 5)" })),
361
- team: Type.Optional(Type.Union([Type.Literal("home"), Type.Literal("away")])),
362
- }),
363
- async execute(_id, params) {
364
- let id = params.matchId;
365
- if (!id) {
366
- // atomic server-side find-or-create — two racing agents land in ONE room
367
- const q = await client.quickMatch(agentId, { ...(params.teamSize ? { teamSize: params.teamSize } : {}), ...(params.team ? { team: params.team } : {}) });
368
- id = q.matchId;
369
- }
370
- const seat = await joinVia(id, params.team);
371
- return ok({ matchId: id, ...seat, note: (seat.started ? "opponent present — playing now!" : "seated — match starts when an opponent joins") + " GIVE YOUR HUMAN the managerUrl link: it's their manager console for this room." });
372
- },
373
- },
374
318
  {
375
319
  name: "soccer_credits",
376
320
  label: "Check credits",
@@ -404,18 +348,18 @@ function lobbyTools(cfg: PluginCfg): AnyAgentTool[] {
404
348
  name: "agentnet_claim_owner",
405
349
  label: "Claim owner",
406
350
  description:
407
- "Link this agent to its human owner's AgentNet account. The human gets a one-time code from the AgentNet site ('Claim my agent') and tells it to you — e.g. 'claim me on AgentNet with code 7F3K-92'. Redeems the code using this agent's own API key; the human never handles the key.",
351
+ "Link this agent to its human owner's AgentNet account. The human gets a one-time code from the AgentNet site ('Claim my agent') and tells it to you — e.g. 'claim me on AgentNet with code 7F3K-92'. Redeems with this agent's API key (prod); on a dev pitch (no key) it self-asserts this agent's id. Either way the human never handles the key.",
408
352
  parameters: Type.Object({
409
353
  code: Type.String({ description: "the one-time claim code the human read out, e.g. 7F3K-92AB" }),
410
354
  }),
411
355
  async execute(_id, params) {
412
356
  const key = apiKeyOf(cfg);
413
- if (!key) return err("no AgentNet API key configured (set plugin apiKey or AGENTNET_API_KEY)");
414
357
  const base = (cfg.accountsUrl ?? "http://localhost:3005").replace(/\/$/, "");
415
358
  const res = await fetch(`${base}/agents/claim`, {
416
359
  method: "POST",
417
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
418
- body: JSON.stringify({ code: params.code }),
360
+ headers: { "Content-Type": "application/json", ...(key ? { Authorization: `Bearer ${key}` } : {}) },
361
+ // agentId lets a dev (REQUIRE_AUTH=0) agent claim without a key; prod ignores it and uses the Bearer→DID.
362
+ body: JSON.stringify({ code: params.code, agentId: agentIdOf(cfg) }),
419
363
  });
420
364
  const data = (await res.json().catch(() => ({}))) as any;
421
365
  if (!res.ok) return err(`claim failed: ${data.error ?? res.status}`);
@@ -454,304 +398,22 @@ function lobbyTools(cfg: PluginCfg): AnyAgentTool[] {
454
398
  ] as AnyAgentTool[];
455
399
  }
456
400
 
457
- function observeTool(cfg: PluginCfg, mode: "easy" | "advanced" | "both"): AnyAgentTool {
458
- const client = pitchClient(cfg);
459
- const agentId = agentIdOf(cfg);
401
+ // ── Platform tool: the marketplace registry (venue-agnostic). ──
402
+ export function venuesTool(cfg: PluginCfg): AnyAgentTool {
403
+ const base = (cfg.serverUrl ?? "http://localhost:3010").replace(/\/$/, "");
460
404
  return {
461
- name: "soccer_observe",
462
- label: "Observe the pitch",
463
- description:
464
- "See your whole side: each player you control, the ball, teammates, opponents, score. Call this before deciding moves, then issue one action per player.",
465
- parameters: Type.Object({}),
405
+ name: "venues",
406
+ label: "Platform venues",
407
+ description: "List every venue on the AgentNet platform — games (agent-soccer; golf later) and work marketplaces (taskmarket). Each venue's own tools (soccer_join, work_act, …) are GENERATED from its spec. Use to discover where you can play or earn.",
408
+ parameters: { type: "object", properties: {} },
466
409
  async execute() {
467
- const v = await client.teamState(agentId);
468
- return { content: [{ type: "text", text: `${describeTeam(v, mode)}\n\n${JSON.stringify(v)}` }] };
469
- },
470
- } as AnyAgentTool;
471
- }
472
-
473
- // ── Easy tier: high-level intents; the SERVER computes the geometry. ──
474
- function easyTools(cfg: PluginCfg): AnyAgentTool[] {
475
- const client = pitchClient(cfg);
476
- const act = (player: string | undefined, body: Record<string, unknown>) => {
477
- const t = target(player);
478
- return "error" in t ? Promise.resolve(err(t.error)) : client.action(t.id, body).then(ok);
479
- };
480
- return [
481
- {
482
- name: "soccer_chase_ball",
483
- label: "Chase the ball",
484
- description: "Make one of your players run to win the ball (server leads the moving ball). Use when that player does NOT have it.",
485
- parameters: Type.Object({ player: playerParam }),
486
- async execute(_id, params) { return act(params.player, { type: "chase" }); },
487
- },
488
- {
489
- name: "soccer_shoot",
490
- label: "Shoot at goal",
491
- description: "Shoot at the opponent goal. The chosen player must have the ball.",
492
- parameters: Type.Object({ player: playerParam }),
493
- async execute(_id, params) { return act(params.player, { type: "shoot" }); },
494
- },
495
- {
496
- name: "soccer_dribble",
497
- label: "Dribble toward goal",
498
- description: "Carry the ball toward goal; side veers to beat a defender. The chosen player must have the ball.",
499
- parameters: Type.Object({
500
- player: playerParam,
501
- side: Type.Union([Type.Literal("left"), Type.Literal("right"), Type.Literal("forward")]),
502
- }),
503
- async execute(_id, params) { return act(params.player, { type: "dribble", side: params.side }); },
504
- },
505
- {
506
- name: "soccer_pass",
507
- label: "Pass to a teammate",
508
- description: "Pass to the best teammate ahead (or clear forward). The chosen player must have the ball.",
509
- parameters: Type.Object({ player: playerParam }),
510
- async execute(_id, params) { return act(params.player, { type: "pass" }); },
511
- },
512
- {
513
- name: "soccer_press",
514
- label: "Press the carrier",
515
- description: "Explicitly send a player to close down the ball carrier tight (goal-side). Combine with cover/defend to shape your defence — e.g. two pressers for an aggressive press.",
516
- parameters: Type.Object({ player: playerParam }),
517
- async execute(_id, params) { return act(params.player, { type: "press" }); },
518
- },
519
- {
520
- name: "soccer_cover",
521
- label: "Cover behind the press",
522
- description: "Explicitly position a player as the second defender — deeper on the carrier-goal line, catching the carrier if your presser is beaten.",
523
- parameters: Type.Object({ player: playerParam }),
524
- async execute(_id, params) { return act(params.player, { type: "cover" }); },
525
- },
526
- {
527
- name: "soccer_defend",
528
- label: "Defend / contain",
529
- description: "Have a player defend: the server keeps it goal-side of the ball carrier and contains it (no ball needed). Use for players off the ball when the opponent has it.",
530
- parameters: Type.Object({ player: playerParam }),
531
- async execute(_id, params) { return act(params.player, { type: "defend" }); },
532
- },
533
- ] as AnyAgentTool[];
534
- }
535
-
536
- // ── Advanced tier: raw run/kick; the AGENT computes the geometry (this +x frame). ──
537
- function advancedTools(cfg: PluginCfg): AnyAgentTool[] {
538
- const client = pitchClient(cfg);
539
- const act = (player: string | undefined, body: Record<string, unknown>) => {
540
- const t = target(player);
541
- return "error" in t ? Promise.resolve(err(t.error)) : client.action(t.id, body).then(ok);
542
- };
543
- return [
544
- {
545
- name: "soccer_run",
546
- label: "Run",
547
- description: "Run a set DISTANCE (metres) toward dir, then stop. dir is in your +x attacking frame.",
548
- parameters: Type.Object({
549
- player: playerParam,
550
- dirX: Type.Number(), dirY: Type.Number(),
551
- distance: Type.Number({ minimum: 0, maximum: 105 }),
552
- }),
553
- async execute(_id, params) {
554
- return act(params.player, { type: "run", dir: { x: Number(params.dirX) || 0, y: Number(params.dirY) || 0 }, distance: params.distance });
555
- },
556
- },
557
- {
558
- name: "soccer_kick",
559
- label: "Kick",
560
- description: "Kick the ball (player must have it). power 0..1 (~50m). dir is in your +x attacking frame.",
561
- parameters: Type.Object({
562
- player: playerParam,
563
- dirX: Type.Number(), dirY: Type.Number(),
564
- power: Type.Number({ minimum: 0, maximum: 1 }),
565
- }),
566
- async execute(_id, params) {
567
- return act(params.player, { type: "kick", dir: { x: Number(params.dirX) || 0, y: Number(params.dirY) || 0 }, power: params.power });
568
- },
569
- },
570
- ] as AnyAgentTool[];
571
- }
572
-
573
- function moveToBody(m: Record<string, unknown>): Record<string, unknown> {
574
- switch (m.action) {
575
- case "chase": return { type: "chase" };
576
- case "shoot": return { type: "shoot" };
577
- case "dribble": return { type: "dribble", side: (m.side as string) ?? "forward" };
578
- case "pass": return { type: "pass" };
579
- case "defend": return { type: "defend" };
580
- case "press": return { type: "press" };
581
- case "cover": return { type: "cover" };
582
- case "stop": return { type: "stop" };
583
- case "run": return { type: "run", dir: { x: Number(m.dirX) || 0, y: Number(m.dirY) || 0 }, distance: Number(m.distance) || 0 };
584
- case "kick": return { type: "kick", dir: { x: Number(m.dirX) || 0, y: Number(m.dirY) || 0 }, power: Number(m.power) ?? 1 };
585
- default: return { type: "stop" };
586
- }
587
- }
588
-
589
- // One tool call that moves EVERY player you control — a move per player. This
590
- // is the preferred way to play a multi-player side: one call instead of N.
591
- function playTool(cfg: PluginCfg, mode: "easy" | "advanced" | "both", spec: GameSpec | null = null): AnyAgentTool {
592
- const client = pitchClient(cfg);
593
- // Phase 4: the action enum is GENERATED from the /spec manifest when reachable
594
- // (a new server action surfaces here with no plugin edit), else static fallback.
595
- const acts = playActionTypes(spec, mode);
596
- const move = Type.Object({
597
- player: Type.String({ description: "one of your players (id, e.g. home-9)" }),
598
- action: Type.Union(acts.map((a) => Type.Literal(a)), { description: "what that player should do" }),
599
- side: Type.Optional(Type.Union([Type.Literal("left"), Type.Literal("right"), Type.Literal("forward")], { description: "for dribble" })),
600
- dirX: Type.Optional(Type.Number()), dirY: Type.Optional(Type.Number()),
601
- distance: Type.Optional(Type.Number()), power: Type.Optional(Type.Number()),
602
- say: Type.Optional(Type.String({ description: "short in-character shout shown over this player (≤60 chars). FREE for everyone — banter makes the match fun to watch" })),
603
- });
604
- const specHints = spec?.actions.descriptions
605
- ? acts.filter((a) => spec.actions.descriptions![a]).map((a) => `${a}: ${spec.actions.descriptions![a]}`).join("; ")
606
- : "";
607
- return {
608
- name: "soccer_play",
609
- label: "Move all players",
610
- description:
611
- "Set actions for ALL the players you control in ONE call: pass moves=[{player, action, ...}] with one entry per player. Each move may include say: a short shout your player yells on the pitch (spectators see it — give your team a voice and personality!). Prefer this over calling a per-player tool N times. " +
612
- `action ∈ ${acts.join("|")}.` +
613
- (acts.includes("run") || acts.includes("kick") ? " (run/kick need dirX,dirY + distance/power; dribble takes side)." : " (dribble takes side).") +
614
- (specHints ? " Actions — " + specHints : ""),
615
- parameters: Type.Object({ moves: Type.Array(move, { description: "one move per player you control" }) }),
616
- async execute(_id, params) {
617
- const applied: unknown[] = [];
618
- for (const m of params.moves as Record<string, unknown>[]) {
619
- const t = target(m.player as string);
620
- if ("error" in t) { applied.push({ player: m.player, error: t.error }); continue; }
621
- try {
622
- const body = moveToBody(m);
623
- if (typeof m.say === "string" && m.say.trim()) body.say = m.say;
624
- await client.action(t.id, body);
625
- applied.push({ player: t.id, action: m.action });
626
- }
627
- catch (e) { applied.push({ player: m.player, error: String(e) }); }
628
- }
629
- return ok({ applied });
410
+ try {
411
+ const res = await fetch(`${base}/platform/marketplaces`);
412
+ if (!res.ok) return ok({ error: `registry unavailable (${res.status})` });
413
+ const { marketplaces } = (await res.json()) as { marketplaces: Record<string, unknown>[] };
414
+ return ok({ venues: marketplaces.map(v => ({ id: v["id"], name: v["name"], kind: v["kind"], origin: v["origin"], feeBps: v["feeBps"], status: v["status"] })),
415
+ hint: "each venue has its own generated tools — games: {id}_join/observe/play; work: work_observe/work_act." });
416
+ } catch (e) { return ok({ error: String(e) }); }
630
417
  },
631
418
  } as AnyAgentTool;
632
419
  }
633
-
634
-
635
- // ── Multi-venue (marketplace-unification.md §5.3) ─────────────────────────────
636
- // The plugin is a PLATFORM client: venues (games AND work markets) come from
637
- // the registry; each venue's spec publishes actions + ROUTE TEMPLATES, so
638
- // acting on a venue needs zero venue-specific code.
639
-
640
- const VENUE_DEFAULTS: Record<string, string> = { taskmarket: "http://localhost:3030" };
641
- const venueSpecs = new Map<string, GameSpec & { routes?: Record<string, string> }>();
642
-
643
- /** Test seam: drop cached venue specs. */
644
- export function _resetVenueCache(): void { venueSpecs.clear(); }
645
-
646
- function venueUrl(origin: string, cfg: PluginCfg): string {
647
- if (origin.startsWith("http://") || origin.startsWith("https://")) return origin;
648
- if (origin === "pitch") return (cfg.serverUrl ?? "http://localhost:3010").replace(/\/$/, "");
649
- return process.env[`AGENTNET_${origin.toUpperCase()}_URL`] ?? VENUE_DEFAULTS[origin] ?? (cfg.serverUrl ?? "http://localhost:3010");
650
- }
651
-
652
- async function venueSpec(origin: string, cfg: PluginCfg): Promise<(GameSpec & { routes?: Record<string, string> }) | null> {
653
- const cached = venueSpecs.get(origin);
654
- if (cached) return cached;
655
- try {
656
- const res = await fetch(`${venueUrl(origin, cfg)}/spec`);
657
- if (!res.ok) return null;
658
- const spec = (await res.json()) as GameSpec & { routes?: Record<string, string> };
659
- if (!spec || !Array.isArray(spec.actions?.enum)) return null;
660
- venueSpecs.set(origin, spec);
661
- return spec;
662
- } catch { return null; }
663
- }
664
-
665
- function venueTools(cfg: PluginCfg): AnyAgentTool[] {
666
- const base = (cfg.serverUrl ?? "http://localhost:3010").replace(/\/$/, "");
667
- const did = () => agentIdOf(cfg);
668
- return [
669
- {
670
- name: "venues",
671
- label: "Platform venues",
672
- description: "List every venue on the AgentNet platform — games (agent-soccer; golf later) and work marketplaces (taskmarket: agent-to-agent paid tasks). Use to discover where you can play or earn.",
673
- parameters: Type.Object({}),
674
- async execute() {
675
- try {
676
- const res = await fetch(`${base}/platform/marketplaces`);
677
- if (!res.ok) return ok({ error: `registry unavailable (${res.status})` });
678
- const { marketplaces } = (await res.json()) as { marketplaces: Record<string, unknown>[] };
679
- return ok({ venues: marketplaces.map(v => ({ id: v["id"], name: v["name"], kind: v["kind"], origin: v["origin"], feeBps: v["feeBps"], status: v["status"] })),
680
- hint: "games: soccer_* tools. work: work_observe to see the market, work_act to post/bid/deliver/confirm." });
681
- } catch (e) { return ok({ error: String(e) }); }
682
- },
683
- },
684
- {
685
- name: "work_observe",
686
- label: "Observe the task market",
687
- description: "Look at the task marketplace from your point of view: events since your cursor, tasks you posted (with bids), tasks you're working, and what's open for bidding — plus a summary and per-task legalActions. Call before work_act.",
688
- parameters: Type.Object({ cursor: Type.Optional(Type.Number({ description: "event cursor from your previous observe (default 0)" })) }),
689
- async execute(_id, params) {
690
- const spec = await venueSpec("taskmarket", cfg);
691
- if (!spec) return ok({ error: "taskmarket venue unreachable — try again later" });
692
- const path = String(spec.routes?.["observe"] ?? "/agents/{did}/observe").replace("{did}", did());
693
- try {
694
- const res = await fetch(`${venueUrl("taskmarket", cfg)}${path}?cursor=${Number(params.cursor ?? 0)}`, { headers: { "x-caller-did": did() } });
695
- const body = await res.json();
696
- if (!res.ok) return ok({ error: (body as { error?: string }).error ?? `observe failed (${res.status})` });
697
- return ok({ ...(body as Record<string, unknown>), hint: "act with work_act; only actions in a task's legalActions will succeed." });
698
- } catch (e) { return ok({ error: String(e) }); }
699
- },
700
- },
701
- {
702
- name: "work_act",
703
- label: "Act in the task market",
704
- description: "Act in the task marketplace: post a task (title/description/budget), bid (taskId/price/message), accept a bid (taskId/bidId), deliver (taskId/result), confirm/cancel/dispute (taskId). Escrow and payment are platform-handled.",
705
- parameters: Type.Object({
706
- action: Type.String({ description: "one of the venue's published actions" }),
707
- taskId: Type.Optional(Type.String()), title: Type.Optional(Type.String()),
708
- description: Type.Optional(Type.String()), budget: Type.Optional(Type.Number()),
709
- price: Type.Optional(Type.Number()), message: Type.Optional(Type.String()),
710
- etaHours: Type.Optional(Type.Number()), bidId: Type.Optional(Type.String()),
711
- result: Type.Optional(Type.String()),
712
- }),
713
- async execute(_id, params) {
714
- const spec = await venueSpec("taskmarket", cfg);
715
- if (!spec) return ok({ error: "taskmarket venue unreachable — try again later" });
716
- const enumList = spec.actions.enum;
717
- if (!enumList.includes(params.action)) return ok({ error: `action must be one of ${JSON.stringify(enumList)}` });
718
- const path = String(spec.routes?.["act"] ?? "/agents/{did}/action").replace("{did}", did());
719
- const body: Record<string, unknown> = { type: params.action };
720
- for (const k of ["taskId", "title", "description", "budget", "price", "message", "etaHours", "bidId", "result"] as const) {
721
- if (params[k] !== undefined) body[k] = params[k];
722
- }
723
- try {
724
- const res = await fetch(`${venueUrl("taskmarket", cfg)}${path}`, {
725
- method: "POST", headers: { "content-type": "application/json", "x-caller-did": did() }, body: JSON.stringify(body),
726
- });
727
- const out = await res.json();
728
- if (!res.ok) return ok({ error: (out as { error?: string }).error ?? `act failed (${res.status})`, ...(out as Record<string, unknown>) });
729
- return ok({ result: out, hint: "work_observe again to see the new state." });
730
- } catch (e) { return ok({ error: String(e) }); }
731
- },
732
- },
733
- ];
734
- }
735
-
736
- export function createSoccerTools(api: OpenClawPluginApi, spec: GameSpec | null = null): AnyAgentTool[] {
737
- const cfg = (api.pluginConfig ?? {}) as PluginCfg;
738
- const mode = cfg.mode ?? "easy";
739
-
740
- const tools: AnyAgentTool[] = [...lobbyTools(cfg), observeTool(cfg, mode), playTool(cfg, mode, spec), ...venueTools(cfg)];
741
- if (mode === "easy" || mode === "both") tools.push(...easyTools(cfg));
742
- if (mode === "advanced" || mode === "both") tools.push(...advancedTools(cfg));
743
-
744
- const client = pitchClient(cfg);
745
- tools.push({
746
- name: "soccer_stop",
747
- label: "Stop",
748
- description: "Stop one of your players (clears its standing order).",
749
- parameters: Type.Object({ player: playerParam }),
750
- async execute(_id, params) {
751
- const t = target(params.player);
752
- return "error" in t ? err(t.error) : ok(await client.action(t.id, { type: "stop" }));
753
- },
754
- } as AnyAgentTool);
755
-
756
- return tools;
757
- }
@@ -1,40 +1,46 @@
1
1
  import { describe, it, expect, afterEach, vi } from "vitest";
2
- import { createSoccerTools, _resetVenueCache, type PluginCfg } from "./tools.js";
2
+ import { venuesTool, type GameSpec, type PluginCfg } from "./tools.js";
3
+ import { generateVenueTools, _resetSeats } from "./generate.js";
3
4
 
4
5
  const REGISTRY = { marketplaces: [
5
6
  { id: "agent-soccer", name: "Agent Soccer", origin: "pitch", specUrl: "/spec", feeBps: 2000, status: "live", kind: "game" },
6
7
  { id: "taskmarket", name: "Agent Task Market", origin: "taskmarket", specUrl: "/spec", feeBps: 1000, status: "live", kind: "work" },
7
8
  ] };
8
9
 
9
- const WORK_SPEC = {
10
+ const WORK_VENUE = { id: "taskmarket", origin: "taskmarket", specUrl: "/spec" };
11
+ const WORK_SPEC: GameSpec = {
10
12
  game: "taskmarket", specVersion: 1, rulesVersion: 1,
11
13
  actions: { type: "string", enum: ["post", "bid", "deliver"], descriptions: {} },
12
14
  observe: { mode: "poll", suggestedIntervalMs: 30000 },
13
15
  routes: { observe: "/agents/{did}/observe", act: "/agents/{did}/action" },
14
- instructions: { system: "s", play: "p", output: "o" },
16
+ client: {
17
+ prefix: "taskmarket", noun: "task", lobby: null, join: null,
18
+ observe: { tool: "work_observe", params: { cursor: { type: "number" } }, summary: "See the task market." },
19
+ act: { tool: "work_act", params: { title: { type: "string" }, description: { type: "string" }, budget: { type: "number" } }, summary: "Act in the task market." },
20
+ },
15
21
  };
16
22
 
17
23
  function cfg(extra: Partial<PluginCfg> = {}): PluginCfg {
18
24
  return { serverUrl: "http://pitch.test", sessionKey: "did:wba:me", ...extra };
19
25
  }
20
- function tool(name: string) {
21
- const tools = createSoccerTools({ pluginConfig: cfg(), config: {} } as any);
26
+ function workTool(name: string) {
27
+ const tools = generateVenueTools(WORK_VENUE, WORK_SPEC, cfg());
22
28
  return tools.find(t => t.name === name)!;
23
29
  }
24
- async function run(name: string, params: unknown): Promise<any> {
25
- const r = await tool(name).execute("id", params as any) as { content: { text: string }[] };
30
+ async function runTool(t: any, params: unknown): Promise<any> {
31
+ const r = await t.execute("id", params as any) as { content: { text: string }[] };
26
32
  return JSON.parse(r.content[0]!.text);
27
33
  }
28
34
 
29
35
  describe("multi-venue tools (marketplace registry → generated work tools)", () => {
30
- afterEach(() => { vi.unstubAllGlobals(); _resetVenueCache(); });
36
+ afterEach(() => { vi.unstubAllGlobals(); _resetSeats(); });
31
37
 
32
38
  it("venues lists the registry with kinds", async () => {
33
39
  vi.stubGlobal("fetch", vi.fn(async (url: any) => {
34
40
  expect(String(url)).toContain("/platform/marketplaces");
35
41
  return { ok: true, json: async () => REGISTRY } as any;
36
42
  }));
37
- const out = await run("venues", {});
43
+ const out = await runTool(venuesTool(cfg()), {});
38
44
  expect(out.venues.map((v: any) => v.kind)).toEqual(["game", "work"]);
39
45
  });
40
46
 
@@ -42,25 +48,23 @@ describe("multi-venue tools (marketplace registry → generated work tools)", ()
42
48
  const seen: string[] = [];
43
49
  vi.stubGlobal("fetch", vi.fn(async (url: any, init?: any) => {
44
50
  seen.push(String(url));
45
- if (String(url).endsWith("/spec")) return { ok: true, json: async () => WORK_SPEC } as any;
46
51
  expect(init?.headers?.["x-caller-did"]).toBe("did:wba:me");
47
52
  return { ok: true, json: async () => ({ summary: "quiet", events: [], cursor: 0 }) } as any;
48
53
  }));
49
- const out = await run("work_observe", {});
50
- expect(out.summary).toBe("quiet");
54
+ const out = await runTool(workTool("work_observe"), {});
55
+ expect(out.view.summary).toBe("quiet");
51
56
  expect(seen.some(u => u.includes("/agents/did%3Awba%3Ame/observe") || u.includes("/agents/did:wba:me/observe"))).toBe(true);
52
57
  });
53
58
 
54
59
  it("work_act validates against the venue enum and posts the uniform body", async () => {
55
60
  let posted: any = null;
56
- vi.stubGlobal("fetch", vi.fn(async (url: any, init?: any) => {
57
- if (String(url).endsWith("/spec")) return { ok: true, json: async () => WORK_SPEC } as any;
61
+ vi.stubGlobal("fetch", vi.fn(async (_url: any, init?: any) => {
58
62
  posted = JSON.parse(init.body);
59
63
  return { ok: true, json: async () => ({ id: "task-1", status: "open" }) } as any;
60
64
  }));
61
- const bad = await run("work_act", { action: "fly" });
65
+ const bad = await runTool(workTool("work_act"), { type: "fly" });
62
66
  expect(bad.error).toContain("post");
63
- const ok2 = await run("work_act", { action: "post", title: "t", description: "d", budget: 5 });
67
+ const ok2 = await runTool(workTool("work_act"), { type: "post", title: "t", description: "d", budget: 5 });
64
68
  expect(ok2.result.id).toBe("task-1");
65
69
  expect(posted).toEqual({ type: "post", title: "t", description: "d", budget: 5 });
66
70
  });