@agentmessier/openclaw-agent-messier 0.3.10 → 0.3.12
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 +50 -7
- package/package.json +1 -1
- package/src/decide.ts +128 -20
- package/src/watcher.ts +37 -21
package/index.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { memberTools, venuesTool, agentIdOf, identityOf, venueUrl, type PluginCf
|
|
|
3
3
|
import { defaultVenueTools, defaultRealtimeVenue, joinVenue } from "./src/generate.js";
|
|
4
4
|
import { startObserveWatcher } from "./src/watcher.js";
|
|
5
5
|
import { session } from "./src/state.js";
|
|
6
|
-
import { runAutoplayTurn, STRICT_JSON_DIRECTIVE } from "./src/decide.js";
|
|
6
|
+
import { runAutoplayTurn, postDecisionReport, STRICT_JSON_DIRECTIVE } from "./src/decide.js";
|
|
7
7
|
|
|
8
8
|
/** A lobby row is "ours" if it references our agentId anywhere (soccer puts it in
|
|
9
9
|
* sides.home/away; a generic venue may shape it differently). Deep, shape-blind. */
|
|
@@ -55,6 +55,10 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
55
55
|
|
|
56
56
|
let controller: AbortController | null = null;
|
|
57
57
|
let poller: ReturnType<typeof setInterval> | null = null;
|
|
58
|
+
// The per-match session key currently in play. We persist ONE session per match
|
|
59
|
+
// (so the agent accumulates its own decision history); on LEAVING a match we
|
|
60
|
+
// delete it so a new match starts fresh and stale transcripts don't pile up.
|
|
61
|
+
let activeSessionKey: string | null = null;
|
|
58
62
|
|
|
59
63
|
api.registerService({
|
|
60
64
|
id: `agentnet-${venue.id}-watcher`,
|
|
@@ -75,11 +79,18 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
75
79
|
|
|
76
80
|
controller?.abort(); // leaving a previous room
|
|
77
81
|
controller = new AbortController();
|
|
82
|
+
// Leaving the previous match → drop its persistent session so the new
|
|
83
|
+
// match starts with fresh context (and stale transcripts don't pile up).
|
|
84
|
+
if (sessionKey && activeSessionKey && activeSessionKey !== `${sessionKey}:${seat.id}`) {
|
|
85
|
+
api.runtime.subagent.deleteSession({ sessionKey: activeSessionKey }).catch(() => {});
|
|
86
|
+
}
|
|
87
|
+
// ONE persistent session for THIS match, stable across all its turns.
|
|
88
|
+
activeSessionKey = sessionKey ? `${sessionKey}:${seat.id}` : null;
|
|
78
89
|
let move = 0;
|
|
79
90
|
// Fire-and-forget: the watcher runs until aborted or the gateway stops.
|
|
80
91
|
void startObserveWatcher(
|
|
81
92
|
{ serverUrl: cfg.serverUrl, matchId: seat.id!, agentId, mode: cfg.mode, strategyFile: cfg.strategyFile, actTool, label },
|
|
82
|
-
async (
|
|
93
|
+
async ({ system, user, tick, clock }) => {
|
|
83
94
|
if (!sessionKey) {
|
|
84
95
|
ctx.logger.warn(`[${label}] no sessionKey configured; cannot deliver move prompts.`);
|
|
85
96
|
return;
|
|
@@ -90,30 +101,58 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
90
101
|
// 2-decisions-in-157s). soccer_play stays registered for interactive
|
|
91
102
|
// chat play; only AUTOPLAY changes to this server-driven loop.
|
|
92
103
|
//
|
|
93
|
-
//
|
|
94
|
-
// agent
|
|
104
|
+
// ONE persistent session per MATCH (sessionKey:matchId, stable across
|
|
105
|
+
// turns) so the agent accumulates context — its own prior decisions —
|
|
106
|
+
// and the session transcript is the full per-match request log. `turn`
|
|
107
|
+
// is kept only for the idempotency key and the context-growth cap.
|
|
95
108
|
const turn = move++;
|
|
109
|
+
const matchSessionKey = `${sessionKey}:${seat.id}`;
|
|
96
110
|
const idempotencyKey = `agentnet:${venue.id}:${seat.id}:${agentId}:${turn}`;
|
|
111
|
+
const did = session.did ?? agentId;
|
|
112
|
+
const sys = system || undefined;
|
|
113
|
+
const msg = `${user}${STRICT_JSON_DIRECTIVE}`;
|
|
97
114
|
// Mark when this prompt was handed to the agent: x-agent-decision-ms is
|
|
98
115
|
// the prompt→reply latency measured inside runAutoplayTurn.
|
|
99
116
|
session.promptDeliveredAt = Date.now();
|
|
100
117
|
const result = await runAutoplayTurn({
|
|
101
118
|
runtime: api.runtime,
|
|
102
|
-
sessionKey:
|
|
119
|
+
sessionKey: matchSessionKey,
|
|
120
|
+
turn,
|
|
103
121
|
idempotencyKey,
|
|
122
|
+
// The static rulebook (spec.instructions.system) rides the SYSTEM
|
|
123
|
+
// channel; the per-tick board is the user message. '' on the
|
|
124
|
+
// fallback path → no extra system prompt.
|
|
125
|
+
extraSystemPrompt: sys,
|
|
104
126
|
// Steer the agent to reply with ONLY the moves JSON (no tool call).
|
|
105
|
-
message:
|
|
127
|
+
message: msg,
|
|
106
128
|
// 45s ceiling, matching the watcher's per-delivery watchdog backstop:
|
|
107
129
|
// a run that hasn't produced a decision by then is treated as stalled
|
|
108
130
|
// (was 300s, which let one hung run silence the team for 5 min — m171).
|
|
109
131
|
timeoutMs: 45_000,
|
|
110
132
|
matchId: seat.id!,
|
|
111
133
|
cfg,
|
|
112
|
-
did
|
|
134
|
+
did,
|
|
113
135
|
token: session.token,
|
|
114
136
|
base,
|
|
115
137
|
logger: ctx.logger,
|
|
116
138
|
});
|
|
139
|
+
// Report EVERY decision to the pitch — acted AND no-response — so the
|
|
140
|
+
// decision inspector sees no-response turns too. Best-effort, off the
|
|
141
|
+
// hot path, never throws.
|
|
142
|
+
void postDecisionReport(
|
|
143
|
+
{
|
|
144
|
+
tick,
|
|
145
|
+
clock,
|
|
146
|
+
prompt: { ...(sys ? { system: sys } : {}), user: msg },
|
|
147
|
+
outcome: result.outcome,
|
|
148
|
+
...(result.reason ? { reason: result.reason } : {}),
|
|
149
|
+
...(result.moves ? { moves: result.moves } : {}),
|
|
150
|
+
rawText: result.rawText,
|
|
151
|
+
latencyMs: result.latencyMs,
|
|
152
|
+
...(session.lastModel ? { model: session.lastModel } : {}),
|
|
153
|
+
},
|
|
154
|
+
{ base, matchId: seat.id!, agentId: did, token: session.token, logger: ctx.logger },
|
|
155
|
+
);
|
|
117
156
|
// act-verification by the natural signal: did we parse+post (or did the
|
|
118
157
|
// model act via the tool)? A parse-miss = "responded without acting" —
|
|
119
158
|
// log, keep standing orders, continue (never freeze). The watcher's own
|
|
@@ -182,6 +221,10 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
182
221
|
if (poller) { clearInterval(poller); poller = null; }
|
|
183
222
|
controller?.abort();
|
|
184
223
|
controller = null;
|
|
224
|
+
if (activeSessionKey) {
|
|
225
|
+
api.runtime.subagent.deleteSession({ sessionKey: activeSessionKey }).catch(() => {});
|
|
226
|
+
activeSessionKey = null;
|
|
227
|
+
}
|
|
185
228
|
session.joinAndWatch = null;
|
|
186
229
|
session.matchId = null;
|
|
187
230
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentmessier/openclaw-agent-messier",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.12",
|
|
4
4
|
"description": "Agent Messier multi-venue client for OpenClaw \u2014 play games and work tasks on the AgentNet platform (soccer today; venues discovered from the marketplace registry)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/decide.ts
CHANGED
|
@@ -35,7 +35,9 @@ export type Move = {
|
|
|
35
35
|
type: string;
|
|
36
36
|
dir?: Vec2;
|
|
37
37
|
power?: number;
|
|
38
|
-
|
|
38
|
+
/** Zone is now a NAME (e.g. "att-left"); an integer is accepted for back-compat
|
|
39
|
+
* and forwarded as-is (the server normalizes name→id). */
|
|
40
|
+
zone?: string | number;
|
|
39
41
|
say?: string;
|
|
40
42
|
};
|
|
41
43
|
|
|
@@ -60,10 +62,11 @@ export class DecideError extends Error {
|
|
|
60
62
|
export const STRICT_JSON_DIRECTIVE =
|
|
61
63
|
`\n\nDo NOT call any tool. Reply with ONLY a JSON object — no prose, no markdown ` +
|
|
62
64
|
`fences — in exactly this shape, one entry per player you control:\n` +
|
|
63
|
-
`{"moves":[{"playerId":"<id>","type":"<action>","dir":{"x":1,"y":0},"power":0.8,"zone":
|
|
65
|
+
`{"moves":[{"playerId":"<id>","type":"<action>","dir":{"x":1,"y":0},"power":0.8,"zone":"att-left","say":"optional"}]}\n` +
|
|
64
66
|
`Valid action types: ${ACTION_TYPES.join(", ")}. ` +
|
|
65
|
-
`"run"/"kick" need dir {x,y}; "kick" also needs power 0..1; "push" needs zone
|
|
66
|
-
`"press"/"cover" may add zone
|
|
67
|
+
`"run"/"kick" need dir {x,y}; "kick" also needs power 0..1; "push" needs a zone NAME; ` +
|
|
68
|
+
`"press"/"cover" may add a zone NAME. Use the zone names from the board. ` +
|
|
69
|
+
`Omit fields an action does not need. Return the moves JSON now.`;
|
|
67
70
|
|
|
68
71
|
// ── tolerant JSON extraction (ported from driver.ts) ─────────────────────────
|
|
69
72
|
|
|
@@ -167,7 +170,11 @@ export function parseMoves(text: string): Move[] {
|
|
|
167
170
|
const dir = coerceDir(r.dir);
|
|
168
171
|
if (dir) move.dir = dir;
|
|
169
172
|
if (typeof r.power === "number" && Number.isFinite(r.power)) move.power = r.power;
|
|
170
|
-
|
|
173
|
+
// Zones are now NAMES (e.g. "att-left"); accept a non-empty string and pass
|
|
174
|
+
// it through verbatim (the server is the authority — no client-side name
|
|
175
|
+
// validation). Still accept an integer for back-compat. Anything else drops.
|
|
176
|
+
if (typeof r.zone === "string" && r.zone.trim() !== "") move.zone = r.zone;
|
|
177
|
+
else if (Number.isInteger(r.zone)) move.zone = r.zone as number;
|
|
171
178
|
if (typeof r.say === "string") move.say = r.say;
|
|
172
179
|
moves.push(move);
|
|
173
180
|
}
|
|
@@ -196,8 +203,10 @@ function blockText(content: unknown): string {
|
|
|
196
203
|
}
|
|
197
204
|
|
|
198
205
|
/** The text of the LAST assistant message in a getSessionMessages transcript.
|
|
199
|
-
*
|
|
200
|
-
*
|
|
206
|
+
* With a PERSISTENT per-match session the transcript GROWS across turns
|
|
207
|
+
* (user/assistant pairs accumulate), so we scan from the end and return the
|
|
208
|
+
* most recent assistant reply — never an earlier turn's. Returns "" when there
|
|
209
|
+
* is no assistant text. */
|
|
201
210
|
export function lastAssistantText(messages: unknown[]): string {
|
|
202
211
|
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
203
212
|
const m = messages[i];
|
|
@@ -263,14 +272,76 @@ export async function executeMoves(
|
|
|
263
272
|
return { posted: results.length, results };
|
|
264
273
|
}
|
|
265
274
|
|
|
275
|
+
// ── decision reporting: POST every turn's decision to the pitch ──────────────
|
|
276
|
+
|
|
277
|
+
export type DecisionReportDeps = {
|
|
278
|
+
base: string;
|
|
279
|
+
matchId: string;
|
|
280
|
+
/** Reporting agentId (session.did ?? agentId). */
|
|
281
|
+
agentId: string;
|
|
282
|
+
/** Seat token — sent as x-agent-token (same auth as executeMoves). */
|
|
283
|
+
token?: string | null | undefined;
|
|
284
|
+
fetch?: typeof fetch;
|
|
285
|
+
logger?: { warn: (m: string) => void };
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
export type DecisionReport = {
|
|
289
|
+
tick: number;
|
|
290
|
+
clock: number;
|
|
291
|
+
prompt: { system?: string; user: string };
|
|
292
|
+
outcome: "acted" | "no_response";
|
|
293
|
+
reason?: string;
|
|
294
|
+
moves?: Move[];
|
|
295
|
+
rawText?: string;
|
|
296
|
+
latencyMs?: number;
|
|
297
|
+
model?: string;
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* POST a decision report to `/matches/:id/agents/:agentId/decision` so EVERY
|
|
302
|
+
* turn — acted AND no-response — reaches the pitch's decision inspector. Auth is
|
|
303
|
+
* the seat token (x-agent-token), same as executeMoves. Best-effort and off the
|
|
304
|
+
* hot path: it NEVER throws (caller fires it `.catch`-free via this swallow) so a
|
|
305
|
+
* reporting failure can't disrupt play.
|
|
306
|
+
*/
|
|
307
|
+
export async function postDecisionReport(report: DecisionReport, deps: DecisionReportDeps): Promise<void> {
|
|
308
|
+
const f = deps.fetch ?? fetch;
|
|
309
|
+
const base = deps.base.replace(/\/$/, "");
|
|
310
|
+
const url = `${base}/matches/${encodeURIComponent(deps.matchId)}/agents/${encodeURIComponent(deps.agentId)}/decision`;
|
|
311
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
312
|
+
if (deps.token) headers["x-agent-token"] = deps.token;
|
|
313
|
+
try {
|
|
314
|
+
await f(url, { method: "POST", headers, body: JSON.stringify(report) });
|
|
315
|
+
} catch (e) {
|
|
316
|
+
deps.logger?.warn(`decision report POST failed: ${String(e)}`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
266
320
|
// ── the full autoplay turn: run → wait → read → parse → post ─────────────────
|
|
267
321
|
|
|
322
|
+
/** Context-growth safety cap. A persistent per-match session accumulates the
|
|
323
|
+
* agent's whole decision history (good: it remembers its own prior orders), but
|
|
324
|
+
* a long match would otherwise grow the transcript without bound. OpenClaw
|
|
325
|
+
* auto-compacts, but as a hard backstop we RESET the session (deleteSession +
|
|
326
|
+
* start fresh) once a single match exceeds this many turns. Tradeoff: the agent
|
|
327
|
+
* loses its accumulated in-match memory at the reset boundary, but context can
|
|
328
|
+
* never grow unbounded. Keep it generous enough that most matches never hit it. */
|
|
329
|
+
export const SESSION_RESET_TURN_CAP = 60;
|
|
330
|
+
|
|
268
331
|
export type AutoplayTurnDeps = {
|
|
269
332
|
runtime: PluginRuntime;
|
|
333
|
+
/** Stable PER-MATCH session key — persists across turns so the agent
|
|
334
|
+
* accumulates context (its own prior decisions) and the transcript is the
|
|
335
|
+
* full per-match request log. Reset only on leave/new-match/cap. */
|
|
270
336
|
sessionKey: string;
|
|
337
|
+
/** This match's turn ordinal (0-based). Used for the reset cap only. */
|
|
338
|
+
turn: number;
|
|
271
339
|
idempotencyKey: string;
|
|
272
|
-
/** The strict-JSON move prompt to deliver to the agent. */
|
|
340
|
+
/** The strict-JSON move prompt (the per-tick board) to deliver to the agent. */
|
|
273
341
|
message: string;
|
|
342
|
+
/** The static game rulebook (spec.instructions.system), delivered on the run's
|
|
343
|
+
* SYSTEM channel rather than concatenated into the per-tick board. */
|
|
344
|
+
extraSystemPrompt?: string;
|
|
274
345
|
/** Backstop ceiling for waitForRun (the watcher watchdog is the latch backstop). */
|
|
275
346
|
timeoutMs: number;
|
|
276
347
|
matchId: string;
|
|
@@ -281,10 +352,29 @@ export type AutoplayTurnDeps = {
|
|
|
281
352
|
logger?: { info: (m: string) => void; warn: (m: string) => void; error: (m: string) => void };
|
|
282
353
|
};
|
|
283
354
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
355
|
+
/** Everything a decision REPORT needs, returned alongside the act outcome so the
|
|
356
|
+
* caller (index.ts) can POST one /decision report per turn — for acted AND
|
|
357
|
+
* no-response turns — using the tick/clock it has from the delivered frame. */
|
|
358
|
+
export type AutoplayTurnObservability = {
|
|
359
|
+
/** "acted" when the team was moved this cycle (direct post OR the model's own
|
|
360
|
+
* tool call); "no_response" when the run finished without a usable decision. */
|
|
361
|
+
outcome: "acted" | "no_response";
|
|
362
|
+
/** No-act reason (parse miss / empty reply); undefined when acted. */
|
|
363
|
+
reason?: string;
|
|
364
|
+
/** The agent's raw reply text (last assistant message), "" if unreadable. */
|
|
365
|
+
rawText: string;
|
|
366
|
+
/** Parsed moves (present only when we parsed + posted; undefined on tool/miss). */
|
|
367
|
+
moves?: Move[];
|
|
368
|
+
/** Measured prompt→reply latency (ms). */
|
|
369
|
+
latencyMs: number;
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
export type AutoplayTurnResult = AutoplayTurnObservability &
|
|
373
|
+
(
|
|
374
|
+
| { acted: true; via: "post"; posted: number }
|
|
375
|
+
| { acted: true; via: "tool" } // the model called soccer_play itself
|
|
376
|
+
| { acted: false } // responded without acting (parse miss / empty reply)
|
|
377
|
+
);
|
|
288
378
|
|
|
289
379
|
/**
|
|
290
380
|
* Force exactly one agent turn for a delivered situation and act on its reply.
|
|
@@ -296,11 +386,21 @@ export async function runAutoplayTurn(deps: AutoplayTurnDeps): Promise<AutoplayT
|
|
|
296
386
|
// act-verification baseline: if soccer_play runs during this turn it advances
|
|
297
387
|
// session.lastActAt past this — our cue to NOT double-post.
|
|
298
388
|
const actAtBefore = session.lastActAt;
|
|
389
|
+
|
|
390
|
+
// Context-growth backstop: once a single match's persistent session exceeds the
|
|
391
|
+
// cap, drop it so the next run starts fresh — bounds transcript size at the
|
|
392
|
+
// cost of the agent's accumulated in-match memory (see SESSION_RESET_TURN_CAP).
|
|
393
|
+
if (deps.turn > 0 && deps.turn % SESSION_RESET_TURN_CAP === 0) {
|
|
394
|
+
await runtime.subagent.deleteSession({ sessionKey: deps.sessionKey }).catch(() => {});
|
|
395
|
+
deps.logger?.info(`session reset at turn ${deps.turn} (cap ${SESSION_RESET_TURN_CAP}) — context bounded`);
|
|
396
|
+
}
|
|
397
|
+
|
|
299
398
|
const startedAt = Date.now();
|
|
300
399
|
|
|
301
400
|
const { runId } = await runtime.subagent.run({
|
|
302
401
|
sessionKey: deps.sessionKey,
|
|
303
402
|
message: deps.message,
|
|
403
|
+
...(deps.extraSystemPrompt ? { extraSystemPrompt: deps.extraSystemPrompt } : {}),
|
|
304
404
|
deliver: false,
|
|
305
405
|
idempotencyKey: deps.idempotencyKey,
|
|
306
406
|
});
|
|
@@ -308,28 +408,36 @@ export async function runAutoplayTurn(deps: AutoplayTurnDeps): Promise<AutoplayT
|
|
|
308
408
|
const decisionMs = Math.max(0, Date.now() - startedAt);
|
|
309
409
|
|
|
310
410
|
// The model called the act tool itself (it POSTed + stamped lastActAt). Treat
|
|
311
|
-
// the cycle as acted; do NOT parse+post again (would double-apply).
|
|
411
|
+
// the cycle as acted; do NOT parse+post again (would double-apply). The
|
|
412
|
+
// session PERSISTS — no deleteSession here; it's the per-match request log.
|
|
312
413
|
if (session.lastActAt !== actAtBefore) {
|
|
313
|
-
return { acted: true, via: "tool" };
|
|
414
|
+
return { acted: true, via: "tool", outcome: "acted", rawText: "", latencyMs: decisionMs };
|
|
314
415
|
}
|
|
315
416
|
|
|
316
417
|
let text = "";
|
|
317
418
|
try {
|
|
419
|
+
// The persistent transcript GROWS each turn; we read the most recent slice
|
|
420
|
+
// and lastAssistantText returns the LATEST assistant reply (not an old one).
|
|
318
421
|
const { messages } = await runtime.subagent.getSessionMessages({ sessionKey: deps.sessionKey, limit: 10 });
|
|
319
422
|
text = lastAssistantText(messages);
|
|
320
423
|
} catch (e) {
|
|
321
424
|
deps.logger?.warn(`getSessionMessages failed: ${String(e)}`);
|
|
322
|
-
} finally {
|
|
323
|
-
// Fresh session per turn → drop it so transcripts don't accumulate. Cleanup
|
|
324
|
-
// failure is non-fatal (the run already happened).
|
|
325
|
-
runtime.subagent.deleteSession({ sessionKey: deps.sessionKey }).catch(() => {});
|
|
326
425
|
}
|
|
426
|
+
// NOTE: no per-turn deleteSession — the session persists for the whole match so
|
|
427
|
+
// the agent accumulates its own decision history. Reset happens only on
|
|
428
|
+
// leave/new-match (index.ts) or the turn cap above.
|
|
327
429
|
|
|
328
430
|
let moves: Move[];
|
|
329
431
|
try {
|
|
330
432
|
moves = parseMoves(text);
|
|
331
433
|
} catch (e) {
|
|
332
|
-
return {
|
|
434
|
+
return {
|
|
435
|
+
acted: false,
|
|
436
|
+
outcome: "no_response",
|
|
437
|
+
reason: e instanceof DecideError ? e.message : String(e),
|
|
438
|
+
rawText: text,
|
|
439
|
+
latencyMs: decisionMs,
|
|
440
|
+
};
|
|
333
441
|
}
|
|
334
442
|
|
|
335
443
|
const { posted } = await executeMoves(deps.matchId, moves, {
|
|
@@ -339,5 +447,5 @@ export async function runAutoplayTurn(deps: AutoplayTurnDeps): Promise<AutoplayT
|
|
|
339
447
|
token: deps.token,
|
|
340
448
|
decisionMs,
|
|
341
449
|
});
|
|
342
|
-
return { acted: true, via: "post", posted };
|
|
450
|
+
return { acted: true, via: "post", posted, outcome: "acted", rawText: text, moves, latencyMs: decisionMs };
|
|
343
451
|
}
|
package/src/watcher.ts
CHANGED
|
@@ -100,36 +100,52 @@ export function parseSseBlock(block: string): { event?: string; data?: string }
|
|
|
100
100
|
return event !== undefined ? { event, ...(data !== undefined ? { data } : {}) } : (data !== undefined ? { data } : {});
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
|
|
103
|
+
/** A delivered turn, split so the rulebook can ride the model's SYSTEM channel.
|
|
104
|
+
* - `system`: the STATIC game rules (spec.instructions.system) — passed as
|
|
105
|
+
* subagent.run's extraSystemPrompt. Empty on the fallback path (no spec).
|
|
106
|
+
* - `user`: the PER-TICK board — strategy + rendered situation + play guidance
|
|
107
|
+
* + the act/JSON directive. This is what changes every tick.
|
|
108
|
+
* - `tick`/`clock`: the delivered frame's game time, threaded through so the
|
|
109
|
+
* deliver callback can stamp the per-turn decision report. */
|
|
110
|
+
export type DeliverPrompt = { system: string; user: string; tick: number; clock: number };
|
|
111
|
+
|
|
112
|
+
const actDirective = (actTool: string) =>
|
|
113
|
+
`Decide and act NOW: make ONE ${actTool} call with a move for every player you control — ` +
|
|
114
|
+
`every time you are prompted, even if the plan is unchanged. The order holds until you change it, ` +
|
|
115
|
+
`so if you go quiet your team freezes on stale orders. Never reply without acting.`;
|
|
116
|
+
|
|
117
|
+
export function prompt(v: TeamView & { summary?: string }, mode: "easy" | "advanced" | "both", strategyFile?: string, spec?: GameSpec | null, actTool = "soccer_play"): DeliverPrompt {
|
|
104
118
|
const standing = strategyText(strategyFile);
|
|
105
119
|
const stratBlock = standing ? `## Your manager's standing instructions\n${standing}\n\n` : "";
|
|
106
120
|
const ins = spec?.instructions;
|
|
107
121
|
if (ins && ins.system && ins.play && v.summary) {
|
|
108
122
|
// Generic GSP path: the server authored the instructions AND rendered the
|
|
109
|
-
// situation
|
|
110
|
-
//
|
|
111
|
-
// (host concern,
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
123
|
+
// situation. The rulebook (static) rides the SYSTEM channel; the board
|
|
124
|
+
// (per-tick) is the user message. Tool-calling host, so the direct-JSON
|
|
125
|
+
// `output` contract is replaced by a generic tool-act line (host concern,
|
|
126
|
+
// not game knowledge).
|
|
127
|
+
return {
|
|
128
|
+
system: ins.system,
|
|
129
|
+
user:
|
|
130
|
+
stratBlock +
|
|
131
|
+
`${v.summary}\n\n` +
|
|
132
|
+
`${ins.play}\n\n` +
|
|
133
|
+
actDirective(actTool),
|
|
134
|
+
tick: v.tick,
|
|
135
|
+
clock: v.clock,
|
|
136
|
+
};
|
|
121
137
|
}
|
|
122
138
|
// Fallback (pre-envelope server or handshake not yet arrived). describeTeam is
|
|
123
139
|
// the soccer-specific renderer — only valid for a TeamView; for any other
|
|
124
140
|
// venue with no server instructions, dump the raw view rather than crash.
|
|
141
|
+
// No server-authored rulebook here, so the system channel is empty.
|
|
125
142
|
const rendered = Array.isArray(v.mine) ? describeTeam(v, mode) : JSON.stringify(v);
|
|
126
|
-
return
|
|
127
|
-
|
|
128
|
-
`${rendered}\n\n` +
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
);
|
|
143
|
+
return {
|
|
144
|
+
system: "",
|
|
145
|
+
user: stratBlock + `${rendered}\n\n` + actDirective(actTool),
|
|
146
|
+
tick: v.tick,
|
|
147
|
+
clock: v.clock,
|
|
148
|
+
};
|
|
133
149
|
}
|
|
134
150
|
|
|
135
151
|
/** The observe path for a venue, from spec.routes ({matchId}/{did} substituted),
|
|
@@ -143,7 +159,7 @@ export function observeUrl(spec: GameSpec | null, matchId: string, did: string):
|
|
|
143
159
|
|
|
144
160
|
export async function startObserveWatcher(
|
|
145
161
|
cfg: WatcherCfg,
|
|
146
|
-
deliver: (
|
|
162
|
+
deliver: (p: DeliverPrompt) => void | Promise<void>,
|
|
147
163
|
options: WatcherOptions = {},
|
|
148
164
|
): Promise<void> {
|
|
149
165
|
const { signal, logger } = options;
|