@agentmessier/openclaw-agent-messier 0.3.8 → 0.3.10
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 +31 -4
- package/package.json +1 -1
- package/src/decide.ts +343 -0
- package/src/generate.ts +6 -0
- package/src/state.ts +12 -1
- package/src/tools.ts +15 -2
- package/src/watcher.ts +42 -1
package/index.ts
CHANGED
|
@@ -3,6 +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
7
|
|
|
7
8
|
/** A lobby row is "ours" if it references our agentId anywhere (soccer puts it in
|
|
8
9
|
* sides.home/away; a generic venue may shape it differently). Deep, shape-blind. */
|
|
@@ -83,17 +84,43 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
83
84
|
ctx.logger.warn(`[${label}] no sessionKey configured; cannot deliver move prompts.`);
|
|
84
85
|
return;
|
|
85
86
|
}
|
|
87
|
+
// Option A (docs/design/agent-bridge-plugin.md §2): force ONE agent
|
|
88
|
+
// turn per situation and PARSE its JSON reply — no longer wait for the
|
|
89
|
+
// agent to proactively call soccer_play (that reliance caused m171's
|
|
90
|
+
// 2-decisions-in-157s). soccer_play stays registered for interactive
|
|
91
|
+
// chat play; only AUTOPLAY changes to this server-driven loop.
|
|
92
|
+
//
|
|
86
93
|
// Fresh session per move: each prompt is a complete snapshot, so the
|
|
87
94
|
// agent needs no history — keeps context from overflowing.
|
|
88
95
|
const turn = move++;
|
|
89
96
|
const idempotencyKey = `agentnet:${venue.id}:${seat.id}:${agentId}:${turn}`;
|
|
90
|
-
|
|
97
|
+
// Mark when this prompt was handed to the agent: x-agent-decision-ms is
|
|
98
|
+
// the prompt→reply latency measured inside runAutoplayTurn.
|
|
99
|
+
session.promptDeliveredAt = Date.now();
|
|
100
|
+
const result = await runAutoplayTurn({
|
|
101
|
+
runtime: api.runtime,
|
|
91
102
|
sessionKey: `${sessionKey}:${turn}`,
|
|
92
|
-
message: msg,
|
|
93
|
-
deliver: false,
|
|
94
103
|
idempotencyKey,
|
|
104
|
+
// Steer the agent to reply with ONLY the moves JSON (no tool call).
|
|
105
|
+
message: `${msg}${STRICT_JSON_DIRECTIVE}`,
|
|
106
|
+
// 45s ceiling, matching the watcher's per-delivery watchdog backstop:
|
|
107
|
+
// a run that hasn't produced a decision by then is treated as stalled
|
|
108
|
+
// (was 300s, which let one hung run silence the team for 5 min — m171).
|
|
109
|
+
timeoutMs: 45_000,
|
|
110
|
+
matchId: seat.id!,
|
|
111
|
+
cfg,
|
|
112
|
+
did: session.did ?? agentId,
|
|
113
|
+
token: session.token,
|
|
114
|
+
base,
|
|
115
|
+
logger: ctx.logger,
|
|
95
116
|
});
|
|
96
|
-
|
|
117
|
+
// act-verification by the natural signal: did we parse+post (or did the
|
|
118
|
+
// model act via the tool)? A parse-miss = "responded without acting" —
|
|
119
|
+
// log, keep standing orders, continue (never freeze). The watcher's own
|
|
120
|
+
// lastActAt check stays correct because executeMoves/soccer_play stamp it.
|
|
121
|
+
if (!result.acted) {
|
|
122
|
+
ctx.logger.warn(`[${label}] agent responded without acting (turn ${turn}): ${result.reason}`);
|
|
123
|
+
}
|
|
97
124
|
},
|
|
98
125
|
{
|
|
99
126
|
signal: controller.signal,
|
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.10",
|
|
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
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Option A decision-extraction — the server-driven decision core, ported from
|
|
3
|
+
* the tested prototype `services/soccer-driver/src/driver.ts` and adapted to run
|
|
4
|
+
* IN-PROCESS inside the customer's OpenClaw gateway.
|
|
5
|
+
*
|
|
6
|
+
* The m171 bug was relying on the agent to PROACTIVELY call the act tool: a
|
|
7
|
+
* model-dependent, unreliable behaviour that yielded 2 decisions in 157s. The
|
|
8
|
+
* fix (docs/design/agent-bridge-plugin.md §2) is request/response: per situation
|
|
9
|
+
* we FORCE one agent turn and PARSE its JSON reply — N situations → N decisions,
|
|
10
|
+
* regardless of tool-call discipline.
|
|
11
|
+
*
|
|
12
|
+
* subagent.run({deliver:false}) → waitForRun → getSessionMessages
|
|
13
|
+
* → lastAssistantText → parseMoves → POST /action per player
|
|
14
|
+
*
|
|
15
|
+
* Tool-vs-JSON-reply decision: the OpenClaw subagent API (SubagentRunParams) has
|
|
16
|
+
* NO knob to remove a tool from the run's scope, so the soccer act tool
|
|
17
|
+
* (soccer_play) is still in scope during an autoplay turn. We therefore (a) steer
|
|
18
|
+
* the prompt hard to "reply with ONLY the moves JSON, do NOT call any tool"
|
|
19
|
+
* (STRICT_JSON_DIRECTIVE), and (b) make the act path idempotent at the cycle
|
|
20
|
+
* level: if the model calls soccer_play anyway, that tool already POSTs actions
|
|
21
|
+
* and stamps session.lastActAt — so runAutoplayTurn detects that the team was
|
|
22
|
+
* acted (lastActAt advanced during the turn) and SKIPS the direct POST rather
|
|
23
|
+
* than double-applying. Only when the model replied with text (no tool call) do
|
|
24
|
+
* we parse that text and POST ourselves. Either way the cycle results in exactly
|
|
25
|
+
* one set of moves.
|
|
26
|
+
*/
|
|
27
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
|
28
|
+
import { apiKeyOf, type PluginCfg } from "./tools.js";
|
|
29
|
+
import { session } from "./state.js";
|
|
30
|
+
|
|
31
|
+
export type Vec2 = { x: number; y: number };
|
|
32
|
+
|
|
33
|
+
export type Move = {
|
|
34
|
+
playerId: string;
|
|
35
|
+
type: string;
|
|
36
|
+
dir?: Vec2;
|
|
37
|
+
power?: number;
|
|
38
|
+
zone?: number;
|
|
39
|
+
say?: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Action vocabulary — mirrors services/pitch/src/schemas.ts `actionTypes`.
|
|
43
|
+
* Kept local so the plugin carries no cross-package dependency. */
|
|
44
|
+
export const ACTION_TYPES = [
|
|
45
|
+
"run", "kick", "idle", "chase", "shoot", "dribble",
|
|
46
|
+
"pass", "defend", "press", "cover", "push", "stop",
|
|
47
|
+
] as const;
|
|
48
|
+
|
|
49
|
+
export class DecideError extends Error {
|
|
50
|
+
constructor(message: string) {
|
|
51
|
+
super(message);
|
|
52
|
+
this.name = "DecideError";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Appended to the autoplay move prompt so the agent replies with ONLY the moves
|
|
57
|
+
* JSON instead of (or in addition to) calling the act tool. The strictness is
|
|
58
|
+
* what keeps parseMoves reliable, and "do not call any tool" is what makes the
|
|
59
|
+
* forced-turn-then-parse loop work regardless of the model's tool discipline. */
|
|
60
|
+
export const STRICT_JSON_DIRECTIVE =
|
|
61
|
+
`\n\nDo NOT call any tool. Reply with ONLY a JSON object — no prose, no markdown ` +
|
|
62
|
+
`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":11,"say":"optional"}]}\n` +
|
|
64
|
+
`Valid action types: ${ACTION_TYPES.join(", ")}. ` +
|
|
65
|
+
`"run"/"kick" need dir {x,y}; "kick" also needs power 0..1; "push" needs zone 1..12; ` +
|
|
66
|
+
`"press"/"cover" may add zone 1..12. Omit fields an action does not need. Return the moves JSON now.`;
|
|
67
|
+
|
|
68
|
+
// ── tolerant JSON extraction (ported from driver.ts) ─────────────────────────
|
|
69
|
+
|
|
70
|
+
/** First balanced {…} or […] block, skipping string contents. */
|
|
71
|
+
function firstBalanced(s: string): string | null {
|
|
72
|
+
const start = s.search(/[{[]/);
|
|
73
|
+
if (start < 0) return null;
|
|
74
|
+
const open = s[start];
|
|
75
|
+
const close = open === "{" ? "}" : "]";
|
|
76
|
+
let depth = 0;
|
|
77
|
+
let inStr = false;
|
|
78
|
+
let esc = false;
|
|
79
|
+
for (let i = start; i < s.length; i += 1) {
|
|
80
|
+
const c = s[i]!;
|
|
81
|
+
if (inStr) {
|
|
82
|
+
if (esc) esc = false;
|
|
83
|
+
else if (c === "\\") esc = true;
|
|
84
|
+
else if (c === '"') inStr = false;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (c === '"') inStr = true;
|
|
88
|
+
else if (c === open) depth += 1;
|
|
89
|
+
else if (c === close) {
|
|
90
|
+
depth -= 1;
|
|
91
|
+
if (depth === 0) return s.slice(start, i + 1);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Parse JSON out of an LLM reply: whole body → ```json fence → first balanced
|
|
98
|
+
* block. Returns null if nothing parses. */
|
|
99
|
+
function extractJson(text: string): unknown {
|
|
100
|
+
const t = text.trim();
|
|
101
|
+
try {
|
|
102
|
+
return JSON.parse(t);
|
|
103
|
+
} catch {
|
|
104
|
+
/* fall through */
|
|
105
|
+
}
|
|
106
|
+
const fence = t.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
107
|
+
if (fence?.[1]) {
|
|
108
|
+
try {
|
|
109
|
+
return JSON.parse(fence[1].trim());
|
|
110
|
+
} catch {
|
|
111
|
+
/* fall through */
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const cand = firstBalanced(t);
|
|
115
|
+
if (cand) {
|
|
116
|
+
try {
|
|
117
|
+
return JSON.parse(cand);
|
|
118
|
+
} catch {
|
|
119
|
+
/* fall through */
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function coerceDir(d: unknown): Vec2 | undefined {
|
|
126
|
+
if (d && typeof d === "object" && !Array.isArray(d)) {
|
|
127
|
+
const { x, y } = d as Record<string, unknown>;
|
|
128
|
+
if (typeof x === "number" && typeof y === "number" && Number.isFinite(x) && Number.isFinite(y)) {
|
|
129
|
+
return { x, y };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (Array.isArray(d) && d.length === 2 && d.every((n) => typeof n === "number" && Number.isFinite(n))) {
|
|
133
|
+
return { x: d[0] as number, y: d[1] as number };
|
|
134
|
+
}
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Extract validated moves from a reply. Tolerant of `{"moves":[…]}` or a bare
|
|
140
|
+
* `[…]`, and of `action`/`id`/`player` aliases. Invalid entries are dropped;
|
|
141
|
+
* throws DecideError when nothing usable is found (the caller treats that as
|
|
142
|
+
* "responded without acting").
|
|
143
|
+
*/
|
|
144
|
+
export function parseMoves(text: string): Move[] {
|
|
145
|
+
const obj = extractJson(text);
|
|
146
|
+
if (obj == null) throw new DecideError("no JSON found in reply");
|
|
147
|
+
const arr = Array.isArray(obj)
|
|
148
|
+
? obj
|
|
149
|
+
: Array.isArray((obj as { moves?: unknown }).moves)
|
|
150
|
+
? ((obj as { moves: unknown[] }).moves)
|
|
151
|
+
: null;
|
|
152
|
+
if (!arr) throw new DecideError("reply JSON has no moves array");
|
|
153
|
+
|
|
154
|
+
const valid = new Set<string>(ACTION_TYPES);
|
|
155
|
+
const moves: Move[] = [];
|
|
156
|
+
for (const raw of arr) {
|
|
157
|
+
if (!raw || typeof raw !== "object") continue;
|
|
158
|
+
const r = raw as Record<string, unknown>;
|
|
159
|
+
const playerId =
|
|
160
|
+
typeof r.playerId === "string" ? r.playerId
|
|
161
|
+
: typeof r.id === "string" ? r.id
|
|
162
|
+
: typeof r.player === "string" ? r.player
|
|
163
|
+
: undefined;
|
|
164
|
+
const type = typeof r.type === "string" ? r.type : typeof r.action === "string" ? r.action : undefined;
|
|
165
|
+
if (!playerId || !type || !valid.has(type)) continue;
|
|
166
|
+
const move: Move = { playerId, type };
|
|
167
|
+
const dir = coerceDir(r.dir);
|
|
168
|
+
if (dir) move.dir = dir;
|
|
169
|
+
if (typeof r.power === "number" && Number.isFinite(r.power)) move.power = r.power;
|
|
170
|
+
if (Number.isInteger(r.zone) && (r.zone as number) >= 1 && (r.zone as number) <= 12) move.zone = r.zone as number;
|
|
171
|
+
if (typeof r.say === "string") move.say = r.say;
|
|
172
|
+
moves.push(move);
|
|
173
|
+
}
|
|
174
|
+
if (moves.length === 0) throw new DecideError("no valid moves parsed from reply");
|
|
175
|
+
return moves;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── read the agent's reply from the transcript ───────────────────────────────
|
|
179
|
+
|
|
180
|
+
/** One content block in an assistant message (string content, or an array of
|
|
181
|
+
* {type:"text", text} blocks per the OpenClaw transcript shape). */
|
|
182
|
+
function blockText(content: unknown): string {
|
|
183
|
+
if (typeof content === "string") return content;
|
|
184
|
+
if (Array.isArray(content)) {
|
|
185
|
+
return content
|
|
186
|
+
.map((b) => {
|
|
187
|
+
if (typeof b === "string") return b;
|
|
188
|
+
if (b && typeof b === "object" && typeof (b as { text?: unknown }).text === "string") {
|
|
189
|
+
return (b as { text: string }).text;
|
|
190
|
+
}
|
|
191
|
+
return "";
|
|
192
|
+
})
|
|
193
|
+
.join("");
|
|
194
|
+
}
|
|
195
|
+
return "";
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The text of the LAST assistant message in a getSessionMessages transcript.
|
|
199
|
+
* A fresh per-turn session yields one user + one assistant record, so this is
|
|
200
|
+
* trivially that assistant reply. Returns "" when there is no assistant text. */
|
|
201
|
+
export function lastAssistantText(messages: unknown[]): string {
|
|
202
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
203
|
+
const m = messages[i];
|
|
204
|
+
if (m && typeof m === "object" && (m as { role?: unknown }).role === "assistant") {
|
|
205
|
+
return blockText((m as { content?: unknown }).content);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return "";
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── execute: POST one action per move to the pitch ───────────────────────────
|
|
212
|
+
|
|
213
|
+
export type ExecuteDeps = {
|
|
214
|
+
/** Pitch base URL (cfg.serverUrl), e.g. http://localhost:3010. */
|
|
215
|
+
base: string;
|
|
216
|
+
cfg: PluginCfg;
|
|
217
|
+
/** Caller DID / agentId (session.did ?? agentId). */
|
|
218
|
+
did: string;
|
|
219
|
+
/** Seat token (session.token), sent as x-agent-token. */
|
|
220
|
+
token?: string | null | undefined;
|
|
221
|
+
/** Reported as x-agent-decision-ms — the prompt→reply latency (ms). */
|
|
222
|
+
decisionMs?: number;
|
|
223
|
+
/** Test seam; defaults to global fetch. */
|
|
224
|
+
fetch?: typeof fetch;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* POST one `/action` per move, carrying the decision-observability headers the
|
|
229
|
+
* pitch reads (x-agent-model from session.lastModel, x-agent-decision-ms =
|
|
230
|
+
* prompt→reply latency). Mirrors generate.ts's vfetch act path so capture sees
|
|
231
|
+
* the same shape. Stamps session.lastActAt on the first successful POST so the
|
|
232
|
+
* watcher's act-verification sees "we posted moves this cycle".
|
|
233
|
+
*/
|
|
234
|
+
export async function executeMoves(
|
|
235
|
+
matchId: string,
|
|
236
|
+
moves: Move[],
|
|
237
|
+
deps: ExecuteDeps,
|
|
238
|
+
): Promise<{ posted: number; results: { playerId: string; status: number }[] }> {
|
|
239
|
+
const f = deps.fetch ?? fetch;
|
|
240
|
+
const base = deps.base.replace(/\/$/, "");
|
|
241
|
+
const results: { playerId: string; status: number }[] = [];
|
|
242
|
+
for (const m of moves) {
|
|
243
|
+
const body: Record<string, unknown> = { agentId: deps.did, type: m.type };
|
|
244
|
+
if (m.dir) body.dir = m.dir;
|
|
245
|
+
if (m.power !== undefined) body.power = m.power;
|
|
246
|
+
if (m.zone !== undefined) body.zone = m.zone;
|
|
247
|
+
if (m.say !== undefined) body.say = m.say;
|
|
248
|
+
const headers: Record<string, string> = {
|
|
249
|
+
"Content-Type": "application/json",
|
|
250
|
+
"x-caller-did": deps.did,
|
|
251
|
+
"x-agent-runtime": "openclaw-plugin/autoplay",
|
|
252
|
+
};
|
|
253
|
+
if (deps.token) headers["x-agent-token"] = deps.token;
|
|
254
|
+
if (session.lastModel) headers["x-agent-model"] = session.lastModel;
|
|
255
|
+
if (deps.decisionMs !== undefined) headers["x-agent-decision-ms"] = String(Math.max(0, Math.round(deps.decisionMs)));
|
|
256
|
+
const key = apiKeyOf(deps.cfg);
|
|
257
|
+
if (key) headers.Authorization = `Bearer ${key}`;
|
|
258
|
+
const url = `${base}/matches/${encodeURIComponent(matchId)}/players/${encodeURIComponent(m.playerId)}/action`;
|
|
259
|
+
const res = await f(url, { method: "POST", headers, body: JSON.stringify(body) });
|
|
260
|
+
if (res.ok) session.lastActAt = Date.now(); // act-verification: the team was moved this cycle
|
|
261
|
+
results.push({ playerId: m.playerId, status: res.status });
|
|
262
|
+
}
|
|
263
|
+
return { posted: results.length, results };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ── the full autoplay turn: run → wait → read → parse → post ─────────────────
|
|
267
|
+
|
|
268
|
+
export type AutoplayTurnDeps = {
|
|
269
|
+
runtime: PluginRuntime;
|
|
270
|
+
sessionKey: string;
|
|
271
|
+
idempotencyKey: string;
|
|
272
|
+
/** The strict-JSON move prompt to deliver to the agent. */
|
|
273
|
+
message: string;
|
|
274
|
+
/** Backstop ceiling for waitForRun (the watcher watchdog is the latch backstop). */
|
|
275
|
+
timeoutMs: number;
|
|
276
|
+
matchId: string;
|
|
277
|
+
cfg: PluginCfg;
|
|
278
|
+
did: string;
|
|
279
|
+
token?: string | null;
|
|
280
|
+
base: string;
|
|
281
|
+
logger?: { info: (m: string) => void; warn: (m: string) => void; error: (m: string) => void };
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
export type AutoplayTurnResult =
|
|
285
|
+
| { acted: true; via: "post"; posted: number }
|
|
286
|
+
| { acted: true; via: "tool" } // the model called soccer_play itself
|
|
287
|
+
| { acted: false; reason: string }; // responded without acting (parse miss / empty reply)
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Force exactly one agent turn for a delivered situation and act on its reply.
|
|
291
|
+
* Returns whether the team was acted this cycle (post or tool) or not (the
|
|
292
|
+
* watcher logs/counts a no-act turn and keeps standing orders — never freezes).
|
|
293
|
+
*/
|
|
294
|
+
export async function runAutoplayTurn(deps: AutoplayTurnDeps): Promise<AutoplayTurnResult> {
|
|
295
|
+
const { runtime } = deps;
|
|
296
|
+
// act-verification baseline: if soccer_play runs during this turn it advances
|
|
297
|
+
// session.lastActAt past this — our cue to NOT double-post.
|
|
298
|
+
const actAtBefore = session.lastActAt;
|
|
299
|
+
const startedAt = Date.now();
|
|
300
|
+
|
|
301
|
+
const { runId } = await runtime.subagent.run({
|
|
302
|
+
sessionKey: deps.sessionKey,
|
|
303
|
+
message: deps.message,
|
|
304
|
+
deliver: false,
|
|
305
|
+
idempotencyKey: deps.idempotencyKey,
|
|
306
|
+
});
|
|
307
|
+
await runtime.subagent.waitForRun({ runId, timeoutMs: deps.timeoutMs });
|
|
308
|
+
const decisionMs = Math.max(0, Date.now() - startedAt);
|
|
309
|
+
|
|
310
|
+
// 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).
|
|
312
|
+
if (session.lastActAt !== actAtBefore) {
|
|
313
|
+
return { acted: true, via: "tool" };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
let text = "";
|
|
317
|
+
try {
|
|
318
|
+
const { messages } = await runtime.subagent.getSessionMessages({ sessionKey: deps.sessionKey, limit: 10 });
|
|
319
|
+
text = lastAssistantText(messages);
|
|
320
|
+
} catch (e) {
|
|
321
|
+
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
|
+
}
|
|
327
|
+
|
|
328
|
+
let moves: Move[];
|
|
329
|
+
try {
|
|
330
|
+
moves = parseMoves(text);
|
|
331
|
+
} catch (e) {
|
|
332
|
+
return { acted: false, reason: e instanceof DecideError ? e.message : String(e) };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const { posted } = await executeMoves(deps.matchId, moves, {
|
|
336
|
+
base: deps.base,
|
|
337
|
+
cfg: deps.cfg,
|
|
338
|
+
did: deps.did,
|
|
339
|
+
token: deps.token,
|
|
340
|
+
decisionMs,
|
|
341
|
+
});
|
|
342
|
+
return { acted: true, via: "post", posted };
|
|
343
|
+
}
|
package/src/generate.ts
CHANGED
|
@@ -37,6 +37,10 @@ function did(venueId: string, cfg: PluginCfg): string {
|
|
|
37
37
|
async function vfetch(base: string, path: string, opts: { method?: string; body?: unknown; cfg: PluginCfg; token?: string; did: string }): Promise<{ ok: boolean; status: number; data: any }> {
|
|
38
38
|
const headers: Record<string, string> = { "x-caller-did": opts.did, "x-agent-runtime": "openclaw-plugin/0.2.0" };
|
|
39
39
|
if (session.lastModel) headers["x-agent-model"] = session.lastModel; // effective LLM for the pitch roster
|
|
40
|
+
// Prompt→act latency: ms from when the watcher delivered the move prompt to
|
|
41
|
+
// this call. The pitch reads x-agent-decision-ms to record decision speed. Only
|
|
42
|
+
// meaningful on the act POST, but harmless elsewhere (server ignores it there).
|
|
43
|
+
if (session.promptDeliveredAt != null) headers["x-agent-decision-ms"] = String(Math.max(0, Date.now() - session.promptDeliveredAt));
|
|
40
44
|
const key = apiKeyOf(opts.cfg); if (key) headers["Authorization"] = `Bearer ${key}`;
|
|
41
45
|
if (opts.token) headers["x-agent-token"] = opts.token;
|
|
42
46
|
if (opts.body !== undefined) headers["Content-Type"] = "application/json";
|
|
@@ -168,6 +172,7 @@ export function generateVenueTools(venue: Venue, spec: GameSpec, cfg: PluginCfg)
|
|
|
168
172
|
const path = sub(actRoute, { matchId: seat.id ?? "", did: d, playerId: String(m.player ?? "") });
|
|
169
173
|
const body = { agentId: d, type: action, ...whitelist(m, s.params ?? {}), ...(session.lockstep ? { turn: session.turn } : {}) };
|
|
170
174
|
const r = await vfetch(base, path, { cfg, did: d, method: "POST", body, token: seat.token });
|
|
175
|
+
if (r.ok) session.lastActAt = Date.now(); // act-verification: the agent moved its team this turn
|
|
171
176
|
applied.push(r.ok ? { player: m.player, type: action } : { player: m.player, error: r.data?.error ?? r.status });
|
|
172
177
|
}
|
|
173
178
|
return ok({ applied });
|
|
@@ -182,6 +187,7 @@ export function generateVenueTools(venue: Venue, spec: GameSpec, cfg: PluginCfg)
|
|
|
182
187
|
const path = sub(actRoute, { matchId: seat.id ?? "", did: d, playerId: String(p.player ?? "") });
|
|
183
188
|
const r = await vfetch(base, path, { cfg, did: d, method: "POST", body: { type: action, ...whitelist(p, s.params ?? {}) }, token: seat.token });
|
|
184
189
|
if (!r.ok) return ok({ error: r.data?.error ?? `act ${r.status}` });
|
|
190
|
+
session.lastActAt = Date.now(); // act-verification: the agent acted this turn
|
|
185
191
|
return ok({ type: action, result: r.data });
|
|
186
192
|
} } as AnyAgentTool);
|
|
187
193
|
}
|
package/src/state.ts
CHANGED
|
@@ -23,8 +23,19 @@ export const session: {
|
|
|
23
23
|
* the gateway's llm_output hook — sent as x-agent-model so the pitch records
|
|
24
24
|
* the model that's actually playing (reflects a mid-match /model switch). */
|
|
25
25
|
lastModel: string | null
|
|
26
|
+
/** Epoch ms when the act tool last SUCCESSFULLY POSTed an action. The watcher
|
|
27
|
+
* reads this before/after a delivery to detect "prompted but didn't act"
|
|
28
|
+
* (the run finished without the agent moving its team). null until first act. */
|
|
29
|
+
lastActAt: number | null
|
|
30
|
+
/** How many delivered prompts completed WITHOUT the agent acting (no advance of
|
|
31
|
+
* lastActAt). Pure observability — surfaced for tests/inspection, never gates play. */
|
|
32
|
+
noActTurns: number
|
|
33
|
+
/** Epoch ms when the watcher last handed a move prompt to the agent (set right
|
|
34
|
+
* before subagent.run). The act tool emits Date.now()-this as the prompt→act
|
|
35
|
+
* latency header x-agent-decision-ms. null until the first prompt is delivered. */
|
|
36
|
+
promptDeliveredAt: number | null
|
|
26
37
|
/** Installed by the service: seat into a venue room (matchId omitted = quickmatch
|
|
27
38
|
* find-or-create) and start the observe/act loop. `params` are venue join params
|
|
28
39
|
* (e.g. teamSize/team for soccer). Returns the seat the loop is now driving. */
|
|
29
40
|
joinAndWatch: ((matchId?: string, params?: Record<string, unknown>) => Promise<{ id?: string; controls?: string[]; started?: boolean }>) | null
|
|
30
|
-
} = { matchId: null, players: [], token: null, did: null, turn: 0, lockstep: false, lastModel: null, joinAndWatch: null }
|
|
41
|
+
} = { matchId: null, players: [], token: null, did: null, turn: 0, lockstep: false, lastModel: null, lastActAt: null, noActTurns: 0, promptDeliveredAt: null, joinAndWatch: null }
|
package/src/tools.ts
CHANGED
|
@@ -6,6 +6,17 @@ import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
|
|
6
6
|
import { describeTeam, type TeamView } from "./format.js";
|
|
7
7
|
import { session } from "./state.js";
|
|
8
8
|
|
|
9
|
+
/** Plugin version from package.json (the file the publish pipeline bumps), read
|
|
10
|
+
* once at module load. Sent in x-agent-runtime so the pitch records WHICH
|
|
11
|
+
* plugin version holds a seat. Falls back to 'dev' if the manifest is missing. */
|
|
12
|
+
const PLUGIN_VERSION: string = (() => {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? "dev";
|
|
15
|
+
} catch {
|
|
16
|
+
return "dev";
|
|
17
|
+
}
|
|
18
|
+
})();
|
|
19
|
+
|
|
9
20
|
export type PluginCfg = {
|
|
10
21
|
serverUrl?: string;
|
|
11
22
|
/** Auto quick-match at startup: find a waiting room (teamSize) or create one.
|
|
@@ -178,8 +189,10 @@ export function pitchClient(cfg: PluginCfg) {
|
|
|
178
189
|
const k = apiKeyOf(cfg);
|
|
179
190
|
return k ? { Authorization: `Bearer ${k}` } : {};
|
|
180
191
|
};
|
|
181
|
-
// Tells the lobby roster what kind of client holds this
|
|
182
|
-
|
|
192
|
+
// Tells the lobby roster what kind of client — and which VERSION — holds this
|
|
193
|
+
// seat. PLUGIN_VERSION is read from package.json (bumped by the publish
|
|
194
|
+
// pipeline) so the pitch can see which plugin build a seat is actually running.
|
|
195
|
+
const RUNTIME = { "x-agent-runtime": `openclaw-plugin/agent-messier@${PLUGIN_VERSION}` };
|
|
183
196
|
// The room is dynamic. In-process state is set when the watcher joins, but
|
|
184
197
|
// tools may run in a DIFFERENT process (CLI chat, dashboard) — so fall back
|
|
185
198
|
// to asking the server which room this agentId is seated in.
|
package/src/watcher.ts
CHANGED
|
@@ -35,8 +35,17 @@ export type WatcherCfg = {
|
|
|
35
35
|
actTool?: string;
|
|
36
36
|
/** Log tag for this venue's watcher (e.g. "agent-soccer"). Default "agentnet". */
|
|
37
37
|
label?: string;
|
|
38
|
+
/** Watchdog: max ms a single delivery may hold the in-flight latch. If a
|
|
39
|
+
* deliver (subagent run) hangs or runs longer than this, the watcher releases
|
|
40
|
+
* the latch, warns, and delivers the freshest frame — so a slow/stuck decision
|
|
41
|
+
* can't silence the team for the full subagent timeout. Default 45000. */
|
|
42
|
+
deliverTimeoutMs?: number;
|
|
38
43
|
};
|
|
39
44
|
|
|
45
|
+
/** Default per-delivery watchdog (ms). Matches index.ts's waitForRun timeout so
|
|
46
|
+
* the latch is released right around when the run would time out anyway. */
|
|
47
|
+
export const DEFAULT_DELIVER_TIMEOUT_MS = 45_000;
|
|
48
|
+
|
|
40
49
|
// ── human-editable strategy (Phase 5) ────────────────────────────────────────
|
|
41
50
|
// A markdown file the manager edits; injected into the move prompt. mtime-cached
|
|
42
51
|
// (no re-read per tick), refreshed on edit, capped so it can't blow the prompt.
|
|
@@ -166,6 +175,8 @@ export async function startObserveWatcher(
|
|
|
166
175
|
.finally(() => { specFetching = false; });
|
|
167
176
|
}
|
|
168
177
|
|
|
178
|
+
const deliverTimeoutMs = cfg.deliverTimeoutMs ?? DEFAULT_DELIVER_TIMEOUT_MS;
|
|
179
|
+
|
|
169
180
|
function maybeDeliver() {
|
|
170
181
|
if (busy || signal?.aborted || latest === null || latestSeq === deliveredSeq) return;
|
|
171
182
|
// Match over → stop prompting. No message to the gateway = no LLM call.
|
|
@@ -173,9 +184,39 @@ export async function startObserveWatcher(
|
|
|
173
184
|
busy = true;
|
|
174
185
|
const seq = latestSeq;
|
|
175
186
|
const obs = latest;
|
|
187
|
+
// Act-verification baseline: if the act tool doesn't stamp lastActAt past
|
|
188
|
+
// this during the run, the agent was prompted but never moved its team.
|
|
189
|
+
const actAtBefore = session.lastActAt;
|
|
190
|
+
|
|
191
|
+
// The watchdog and the deliver race for the latch. Whichever fires FIRST
|
|
192
|
+
// owns the release; `settled` makes the loser a no-op, so a slow deliver that
|
|
193
|
+
// resolves AFTER the watchdog can't double-release or re-deliver the same
|
|
194
|
+
// seq. Without this, a hung deliver (subagent run) would hold `busy` for the
|
|
195
|
+
// whole subagent timeout and silence the team on stale standing orders.
|
|
196
|
+
let settled = false;
|
|
197
|
+
let watchdog: ReturnType<typeof setTimeout> | undefined;
|
|
198
|
+
const release = (onTimeout: boolean) => {
|
|
199
|
+
if (settled) return;
|
|
200
|
+
settled = true;
|
|
201
|
+
if (watchdog !== undefined) clearTimeout(watchdog);
|
|
202
|
+
deliveredSeq = seq;
|
|
203
|
+
busy = false;
|
|
204
|
+
if (onTimeout) {
|
|
205
|
+
logger?.warn(`[${tag}] deliver exceeded ${deliverTimeoutMs}ms — releasing latch, agent may be stalled`);
|
|
206
|
+
} else if (session.lastActAt === actAtBefore) {
|
|
207
|
+
// The run finished but no action was POSTed — surfaced, never gated.
|
|
208
|
+
session.noActTurns++;
|
|
209
|
+
logger?.warn(`[${tag}] agent responded without acting (turn ${seq})`);
|
|
210
|
+
}
|
|
211
|
+
maybeDeliver();
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
watchdog = setTimeout(() => release(true), deliverTimeoutMs);
|
|
215
|
+
signal?.addEventListener("abort", () => { if (watchdog !== undefined) clearTimeout(watchdog); }, { once: true });
|
|
216
|
+
|
|
176
217
|
Promise.resolve(deliver(prompt(obs, cfg.mode ?? "easy", cfg.strategyFile, spec, actTool)))
|
|
177
218
|
.catch((e) => logger?.error(`[${tag}] deliver failed: ${String(e)}`))
|
|
178
|
-
.finally(() =>
|
|
219
|
+
.finally(() => release(false));
|
|
179
220
|
}
|
|
180
221
|
|
|
181
222
|
let attempt = 0;
|