@agentmessier/openclaw-agent-messier 0.3.0

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.
@@ -0,0 +1,67 @@
1
+ import { describe, it, expect, afterEach, vi } from "vitest";
2
+ import { createSoccerTools, _resetVenueCache, type PluginCfg } from "./tools.js";
3
+
4
+ const REGISTRY = { marketplaces: [
5
+ { id: "agent-soccer", name: "Agent Soccer", origin: "pitch", specUrl: "/spec", feeBps: 2000, status: "live", kind: "game" },
6
+ { id: "taskmarket", name: "Agent Task Market", origin: "taskmarket", specUrl: "/spec", feeBps: 1000, status: "live", kind: "work" },
7
+ ] };
8
+
9
+ const WORK_SPEC = {
10
+ game: "taskmarket", specVersion: 1, rulesVersion: 1,
11
+ actions: { type: "string", enum: ["post", "bid", "deliver"], descriptions: {} },
12
+ observe: { mode: "poll", suggestedIntervalMs: 30000 },
13
+ routes: { observe: "/agents/{did}/observe", act: "/agents/{did}/action" },
14
+ instructions: { system: "s", play: "p", output: "o" },
15
+ };
16
+
17
+ function cfg(extra: Partial<PluginCfg> = {}): PluginCfg {
18
+ return { serverUrl: "http://pitch.test", sessionKey: "did:wba:me", ...extra };
19
+ }
20
+ function tool(name: string) {
21
+ const tools = createSoccerTools({ pluginConfig: cfg(), config: {} } as any);
22
+ return tools.find(t => t.name === name)!;
23
+ }
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 }[] };
26
+ return JSON.parse(r.content[0]!.text);
27
+ }
28
+
29
+ describe("multi-venue tools (marketplace registry → generated work tools)", () => {
30
+ afterEach(() => { vi.unstubAllGlobals(); _resetVenueCache(); });
31
+
32
+ it("venues lists the registry with kinds", async () => {
33
+ vi.stubGlobal("fetch", vi.fn(async (url: any) => {
34
+ expect(String(url)).toContain("/platform/marketplaces");
35
+ return { ok: true, json: async () => REGISTRY } as any;
36
+ }));
37
+ const out = await run("venues", {});
38
+ expect(out.venues.map((v: any) => v.kind)).toEqual(["game", "work"]);
39
+ });
40
+
41
+ it("work_observe substitutes the DID into the venue's route template", async () => {
42
+ const seen: string[] = [];
43
+ vi.stubGlobal("fetch", vi.fn(async (url: any, init?: any) => {
44
+ seen.push(String(url));
45
+ if (String(url).endsWith("/spec")) return { ok: true, json: async () => WORK_SPEC } as any;
46
+ expect(init?.headers?.["x-caller-did"]).toBe("did:wba:me");
47
+ return { ok: true, json: async () => ({ summary: "quiet", events: [], cursor: 0 }) } as any;
48
+ }));
49
+ const out = await run("work_observe", {});
50
+ expect(out.summary).toBe("quiet");
51
+ expect(seen.some(u => u.includes("/agents/did%3Awba%3Ame/observe") || u.includes("/agents/did:wba:me/observe"))).toBe(true);
52
+ });
53
+
54
+ it("work_act validates against the venue enum and posts the uniform body", async () => {
55
+ 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;
58
+ posted = JSON.parse(init.body);
59
+ return { ok: true, json: async () => ({ id: "task-1", status: "open" }) } as any;
60
+ }));
61
+ const bad = await run("work_act", { action: "fly" });
62
+ expect(bad.error).toContain("post");
63
+ const ok2 = await run("work_act", { action: "post", title: "t", description: "d", budget: 5 });
64
+ expect(ok2.result.id).toBe("task-1");
65
+ expect(posted).toEqual({ type: "post", title: "t", description: "d", budget: 5 });
66
+ });
67
+ });
@@ -0,0 +1,121 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtempSync, writeFileSync, rmSync, utimesSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { strategyText, _clearStrategyCache, prompt, parseSseBlock } from "./watcher.js";
6
+ import type { TeamView } from "./format.js";
7
+ import type { GameSpec } from "./tools.js";
8
+
9
+ let dir: string;
10
+ beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "soccer-strat-")); _clearStrategyCache(); });
11
+ afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
12
+
13
+ const VIEW = {
14
+ tick: 1, clock: 0, phase: "live", team: "home",
15
+ score: { home: 0, away: 0 },
16
+ field: { length: 105, width: 68, attackGoal: { x: 52.5, y: 0 }, ownGoal: { x: -52.5, y: 0 }, goalHalfWidth: 3.66, tickHz: 10 },
17
+ mine: [{ id: "home-9", number: 9, pos: { x: 0, y: 0 }, vel: { x: 0, y: 0 }, hasBall: true }],
18
+ ball: { pos: { x: 0, y: 0 }, vel: { x: 0, y: 0 }, owner: "home-9" },
19
+ teammates: [], opponents: [],
20
+ } as unknown as TeamView;
21
+
22
+ describe("Phase 5 — strategyText (mtime-cached, capped) + prompt injection", () => {
23
+ it("returns '' when the file is absent", () => {
24
+ expect(strategyText(join(dir, "nope.md"))).toBe("");
25
+ expect(strategyText(undefined)).toBe("");
26
+ });
27
+
28
+ it("reads a present file", () => {
29
+ const f = join(dir, "strategy.md");
30
+ writeFileSync(f, "Press high. Keep the ball.");
31
+ expect(strategyText(f)).toContain("Press high");
32
+ });
33
+
34
+ it("caps the injected text at ~1k chars", () => {
35
+ const f = join(dir, "strategy.md");
36
+ writeFileSync(f, "x".repeat(5000));
37
+ expect(strategyText(f).length).toBeLessThanOrEqual(1000);
38
+ });
39
+
40
+ it("refreshes when the file mtime changes", () => {
41
+ const f = join(dir, "strategy.md");
42
+ writeFileSync(f, "first plan");
43
+ expect(strategyText(f)).toContain("first plan");
44
+ writeFileSync(f, "second plan");
45
+ const future = new Date(Date.now() + 5000);
46
+ utimesSync(f, future, future);
47
+ const out = strategyText(f);
48
+ expect(out).toContain("second plan");
49
+ expect(out).not.toContain("first plan");
50
+ });
51
+
52
+ it("prompt injects the strategy block when a strategyFile is set", () => {
53
+ const f = join(dir, "strategy.md");
54
+ writeFileSync(f, "Park the bus. Counter fast.");
55
+ const p = prompt(VIEW, "easy", f);
56
+ expect(p.toLowerCase()).toContain("standing instructions");
57
+ expect(p).toContain("Park the bus");
58
+ });
59
+
60
+ it("prompt has no strategy block when the file is absent", () => {
61
+ const p = prompt(VIEW, "easy", join(dir, "missing.md"));
62
+ expect(p.toLowerCase()).not.toContain("standing instructions");
63
+ });
64
+ });
65
+
66
+ // ── the self-instructable envelope (generic GSP client) ─────────────────────
67
+ const SPEC = {
68
+ game: "agent-soccer", specVersion: 1, rulesVersion: 2,
69
+ actions: { type: "string", enum: ["chase", "shoot", "lob"], descriptions: { lob: "chip it" } },
70
+ instructions: {
71
+ system: "You are a decisive tactician.",
72
+ play: "SERVER PLAY GUIDANCE: lob when the keeper is off his line.",
73
+ output: 'Reply with ONLY JSON {"moves":{...}}',
74
+ },
75
+ } as unknown as GameSpec;
76
+
77
+ const VIEW_WITH_SUMMARY = { ...VIEW, summary: "⚽ SERVER-RENDERED SITUATION." } as TeamView & { summary: string };
78
+
79
+ describe("generic prompt — instructions + summary come from the server", () => {
80
+ it("uses the server's instructions and summary when a spec is cached", () => {
81
+ const p = prompt(VIEW_WITH_SUMMARY, "easy", undefined, SPEC);
82
+ expect(p).toContain("SERVER-RENDERED SITUATION");
83
+ expect(p).toContain("SERVER PLAY GUIDANCE");
84
+ // tool-calling host: act via the play tool, named generically
85
+ expect(p.toLowerCase()).toContain("play tool");
86
+ // the plugin's hardcoded soccer prose is gone on this path
87
+ expect(p).not.toContain("YOU ATTACK +x: opponent goal at x=+52.5");
88
+ });
89
+
90
+ it("keeps the strategy block on the generic path", () => {
91
+ const f = join(dir, "strategy.md");
92
+ writeFileSync(f, "Park the bus tonight.");
93
+ const p = prompt(VIEW_WITH_SUMMARY, "easy", f, SPEC);
94
+ expect(p).toContain("Park the bus tonight");
95
+ expect(p).toContain("SERVER PLAY GUIDANCE");
96
+ });
97
+
98
+ it("falls back to local rendering when there is no spec (old server)", () => {
99
+ const p = prompt(VIEW, "easy", undefined, null);
100
+ expect(p).toContain("soccer_play"); // the legacy describeTeam path
101
+ });
102
+ });
103
+
104
+ describe("parseSseBlock — named events for the handshake", () => {
105
+ it("parses a named event block", () => {
106
+ const b = parseSseBlock('event: spec\ndata: {"game":"agent-soccer"}');
107
+ expect(b.event).toBe("spec");
108
+ expect(JSON.parse(b.data!)).toEqual({ game: "agent-soccer" });
109
+ });
110
+
111
+ it("parses a plain data block (no event name)", () => {
112
+ const b = parseSseBlock('data: {"tick":1}');
113
+ expect(b.event).toBeUndefined();
114
+ expect(JSON.parse(b.data!)).toEqual({ tick: 1 });
115
+ });
116
+
117
+ it("ignores comments and empty payloads", () => {
118
+ expect(parseSseBlock(": connected").data).toBeUndefined();
119
+ expect(parseSseBlock("data: ").data).toBeUndefined();
120
+ });
121
+ });
package/src/watcher.ts ADDED
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Soccer team-observation watcher — background SSE loop that feeds the agent.
3
+ *
4
+ * Subscribes to GET /matches/:id/agents/:agentId/observe, which streams a
5
+ * TeamView (the agent's whole side) every tick (10 Hz). We never block the
6
+ * match: the reader keeps only the LATEST view; a single in-flight delivery
7
+ * feeds the agent the freshest state one decision at a time. While the agent is
8
+ * thinking, new frames just update `latest`; when it finishes it gets the
9
+ * newest state. Once the match ends, delivery stops (no prompt = no LLM call).
10
+ *
11
+ * SSE loop + exponential-backoff reconnect, signal-abortable.
12
+ */
13
+ import { statSync, readFileSync } from "node:fs";
14
+ import { describeTeam, type TeamView } from "./format.js";
15
+ import { fetchMatchSpec, type GameSpec, type PluginCfg } from "./tools.js";
16
+ import { session } from "./state.js";
17
+
18
+ export type WatcherCfg = {
19
+ serverUrl?: string;
20
+ matchId: string;
21
+ /** Stable agent id whose claimed players this watcher drives. */
22
+ agentId: string;
23
+ /** Tool tier — tailors the move prompt to the agent's tools. Default "easy". */
24
+ mode?: "easy" | "advanced" | "both";
25
+ /** Human-editable strategy.md injected into the move prompt (Phase 5). */
26
+ strategyFile?: string;
27
+ };
28
+
29
+ // ── human-editable strategy (Phase 5) ────────────────────────────────────────
30
+ // A markdown file the manager edits; injected into the move prompt. mtime-cached
31
+ // (no re-read per tick), refreshed on edit, capped so it can't blow the prompt.
32
+ const STRATEGY_CAP = 1000;
33
+ const _strategyCache = new Map<string, { mtimeMs: number; text: string }>();
34
+
35
+ /** Test seam: drop the mtime cache. */
36
+ export function _clearStrategyCache(): void { _strategyCache.clear(); }
37
+
38
+ /** The manager's standing instructions, mtime-cached + capped. '' when the file
39
+ * is unset/absent/unreadable, so no block is injected. */
40
+ export function strategyText(file?: string): string {
41
+ if (!file) return "";
42
+ let mtimeMs: number;
43
+ try { mtimeMs = statSync(file).mtimeMs; }
44
+ catch { _strategyCache.delete(file); return ""; }
45
+ const cached = _strategyCache.get(file);
46
+ if (cached && cached.mtimeMs === mtimeMs) return cached.text;
47
+ let text: string;
48
+ try { text = readFileSync(file, "utf8").trim().slice(0, STRATEGY_CAP); }
49
+ catch { return ""; }
50
+ _strategyCache.set(file, { mtimeMs, text });
51
+ return text;
52
+ }
53
+
54
+ export type WatcherLogger = {
55
+ info: (msg: string) => void;
56
+ warn: (msg: string) => void;
57
+ error: (msg: string) => void;
58
+ };
59
+
60
+ export type WatcherOptions = {
61
+ signal?: AbortSignal;
62
+ logger?: WatcherLogger;
63
+ /** Called when the server no longer knows our claim (404 — e.g. it restarted).
64
+ * Should re-claim players; the watcher then reconnects. */
65
+ onReclaim?: () => Promise<void>;
66
+ };
67
+
68
+ /** One SSE block → {event?, data?}. Named events carry the per-match spec
69
+ * handshake; plain data blocks are observation frames. Pure, unit-tested. */
70
+ export function parseSseBlock(block: string): { event?: string; data?: string } {
71
+ let event: string | undefined;
72
+ let data: string | undefined;
73
+ for (const line of block.split("\n")) {
74
+ if (line.startsWith("event: ")) event = line.slice(7).trim();
75
+ else if (line.startsWith("data: ")) {
76
+ const payload = line.slice(6).trim();
77
+ if (payload) data = payload;
78
+ }
79
+ }
80
+ return event !== undefined ? { event, ...(data !== undefined ? { data } : {}) } : (data !== undefined ? { data } : {});
81
+ }
82
+
83
+ export function prompt(v: TeamView & { summary?: string }, mode: "easy" | "advanced" | "both", strategyFile?: string, spec?: GameSpec | null): string {
84
+ const standing = strategyText(strategyFile);
85
+ const stratBlock = standing ? `## Your manager's standing instructions\n${standing}\n\n` : "";
86
+ const ins = spec?.instructions;
87
+ if (ins && ins.system && ins.play && v.summary) {
88
+ // Generic GSP path: the server authored the instructions AND rendered the
89
+ // situation — the plugin only concatenates. Tool-calling host, so the
90
+ // direct-JSON `output` contract is replaced by a generic tool-act line
91
+ // (host concern, not game knowledge).
92
+ return (
93
+ `${ins.system}\n\n` +
94
+ stratBlock +
95
+ `${v.summary}\n\n` +
96
+ `${ins.play}\n\n` +
97
+ `Decide and act now: make ONE call to your play tool with a move for every player you control. ` +
98
+ `Each is a standing order until you change it.`
99
+ );
100
+ }
101
+ // Fallback (pre-envelope server or handshake not yet arrived): the legacy
102
+ // plugin-side rendering.
103
+ return (
104
+ stratBlock +
105
+ `${describeTeam(v, mode)}\n\n` +
106
+ `Decide and act now: make ONE soccer_play call with a move for every player you control. ` +
107
+ `Each is a standing order until you change it.`
108
+ );
109
+ }
110
+
111
+ export async function startObserveWatcher(
112
+ cfg: WatcherCfg,
113
+ deliver: (msg: string) => void | Promise<void>,
114
+ options: WatcherOptions = {},
115
+ ): Promise<void> {
116
+ const { signal, logger } = options;
117
+ const base = (cfg.serverUrl ?? "http://localhost:3010").replace(/\/$/, "");
118
+ const url = `${base}/matches/${encodeURIComponent(cfg.matchId)}/agents/${encodeURIComponent(cfg.agentId)}/observe`;
119
+
120
+ let latest: TeamView | null = null;
121
+ let latestSeq = 0;
122
+ let deliveredSeq = -1;
123
+ let busy = false;
124
+
125
+ // The match's spec snapshot (instructions frozen per game). Normally arrives
126
+ // as the SSE `event: spec` handshake; the guard below lazily fetches it when
127
+ // a frame shows up first (handshake lost / pre-envelope server) — at most one
128
+ // in-flight attempt, re-tried on later frames until it lands. A null spec
129
+ // only degrades the prompt to the legacy rendering; it never blocks play.
130
+ let spec: GameSpec | null = null;
131
+ let specFetching = false;
132
+ function ensureSpec() {
133
+ if (spec !== null || specFetching) return;
134
+ specFetching = true;
135
+ fetchMatchSpec({ serverUrl: cfg.serverUrl } as PluginCfg, cfg.matchId)
136
+ .then((s) => { if (s) { spec = s; logger?.info(`[agentnet-soccer] spec recovered via API (rulesVersion ${s.rulesVersion})`); } })
137
+ .finally(() => { specFetching = false; });
138
+ }
139
+
140
+ function maybeDeliver() {
141
+ if (busy || signal?.aborted || latest === null || latestSeq === deliveredSeq) return;
142
+ // Match over → stop prompting. No message to the gateway = no LLM call.
143
+ if (latest.phase === "ended") { deliveredSeq = latestSeq; return; }
144
+ busy = true;
145
+ const seq = latestSeq;
146
+ const obs = latest;
147
+ Promise.resolve(deliver(prompt(obs, cfg.mode ?? "easy", cfg.strategyFile, spec)))
148
+ .catch((e) => logger?.error(`[agentnet-soccer] deliver failed: ${String(e)}`))
149
+ .finally(() => { deliveredSeq = seq; busy = false; maybeDeliver(); });
150
+ }
151
+
152
+ let attempt = 0;
153
+ while (true) {
154
+ if (signal?.aborted) return;
155
+ try {
156
+ const res = await fetch(url, { headers: { Accept: "text/event-stream" }, signal });
157
+ if (res.status === 404 && options.onReclaim) {
158
+ // Server forgot us (restart) — re-claim our players, then reconnect.
159
+ try { await options.onReclaim(); } catch (e) { logger?.warn(`[agentnet-soccer] re-claim failed: ${String(e)}`); }
160
+ await backoff(attempt++, signal);
161
+ continue;
162
+ }
163
+ if (!res.ok || !res.body) { await backoff(attempt++, signal); continue; }
164
+ attempt = 0;
165
+ const reader = res.body.getReader();
166
+ const decoder = new TextDecoder();
167
+ let buffer = "";
168
+
169
+ while (true) {
170
+ if (signal?.aborted) { reader.cancel().catch(() => {}); return; }
171
+ let done: boolean, value: Uint8Array | undefined;
172
+ try { ({ done, value } = await reader.read()); } catch { break; }
173
+ if (done) break;
174
+
175
+ buffer += decoder.decode(value, { stream: true });
176
+ const blocks = buffer.split("\n\n");
177
+ buffer = blocks.pop() ?? "";
178
+
179
+ for (const block of blocks) {
180
+ const { event, data } = parseSseBlock(block);
181
+ if (!data) continue;
182
+ if (event === "spec") {
183
+ // The per-game handshake: this match's frozen instructions.
184
+ try {
185
+ const s = JSON.parse(data) as GameSpec;
186
+ if (s && Array.isArray(s.actions?.enum)) spec = s;
187
+ } catch { /* malformed handshake → the ensureSpec guard recovers */ }
188
+ continue;
189
+ }
190
+ let v: TeamView;
191
+ try { v = JSON.parse(data) as TeamView; } catch { continue; }
192
+ if (!v || !Array.isArray(v.mine)) continue; // skip non-team frames
193
+ ensureSpec(); // protection: frame before handshake → fetch via API
194
+ // The team stream is authoritative for which players we control, so
195
+ // a live ratio change on the server is followed without reconnecting.
196
+ session.players = v.mine.map((p) => p.id);
197
+ latest = v;
198
+ latestSeq++;
199
+ maybeDeliver();
200
+ }
201
+ }
202
+ } catch {
203
+ if (signal?.aborted) return;
204
+ }
205
+ await backoff(attempt++, signal);
206
+ }
207
+ }
208
+
209
+ async function backoff(attempt: number, signal?: AbortSignal): Promise<void> {
210
+ const base = Math.min(1000 * Math.pow(2, attempt), 30000);
211
+ const delay = base + base * 0.2 * Math.random();
212
+ await new Promise<void>((resolve) => {
213
+ const t = setTimeout(resolve, delay);
214
+ signal?.addEventListener("abort", () => { clearTimeout(t); resolve(); }, { once: true });
215
+ });
216
+ }