@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/src/spec.test.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import { describe, it, expect, afterEach, vi } from "vitest";
|
|
2
|
-
import { fetchSpec, playActionTypes,
|
|
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("
|
|
57
|
-
const tools =
|
|
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
|
|
68
|
-
|
|
69
|
-
expect(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
function
|
|
291
|
-
if (
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
302
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
const
|
|
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: "
|
|
462
|
-
label: "
|
|
463
|
-
description:
|
|
464
|
-
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
}
|
package/src/venues.test.ts
CHANGED
|
@@ -1,40 +1,46 @@
|
|
|
1
1
|
import { describe, it, expect, afterEach, vi } from "vitest";
|
|
2
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
|
21
|
-
const tools =
|
|
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
|
|
25
|
-
const r = await
|
|
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();
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
65
|
+
const bad = await runTool(workTool("work_act"), { type: "fly" });
|
|
62
66
|
expect(bad.error).toContain("post");
|
|
63
|
-
const ok2 = await
|
|
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
|
});
|