From b878fa2a41923ab3b204324f5818c0d489ccce51 Mon Sep 17 00:00:00 2001 From: hanghang zhang Date: Wed, 20 May 2026 12:02:25 +0100 Subject: [PATCH] fix: wake dedupe + inline slot context + complete contracts.tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues making HF→agent wakeup unusable in practice, surfaced by DinD sim end-to-end test (recruiter agent + slot for 招募 manager task): 1. **Plugin re-woke the same slot every 30s.** The inline runCheck only destructured agentId from scheduleCache.getAgentsWithDueSlots() and dropped the slots array, then called wakeAgent without recording the wake. The simplified inline scheduler also never PATCHes slot status server-side from not_started→ongoing, so the next 30s check sees the slot still due and wakes again. After 4 wakes the agent's wakeup session was full of WAKEUP_OK noise. Fix: keep slots in runCheck, add an in-memory wakedSlotKeys set keyed by (agentId, slotId|virtual_id|scheduled_at). Dedupe on this set; clear it inside the sync interval (fresh wake budget per sync). Server-side slot transition still TODO (requires re-introducing the CalendarScheduler class path or PATCH /calendar/slots/.../agent-update here); the dedupe at least stops the wake spam. 2. **Wakeup message had no slot context.** The wakeup body just said 'follow hf-wakeup workflow' with no slot id/event_data/task_code. The agent then had to call harborforge_calendar_status to learn anything — which itself is broken in the simplified scheduler (it queries a CalendarScheduler instance that never gets created). Fix: pass dueSlots into wakeAgent and inline the highest-priority slot's {slot_id, scheduled_at, priority, slot_type, event_data} as a JSON block in the wakeup message. The agent reads event_data. task_code directly and routes via workflow_lookup without any round-trip. Per PLG-CAL-001 docs in hf-hangman-lab SKILL.md, this is the documented contract; we are bringing the message in line. 3. **contracts.tools listed 5 of the 9 registered tools.** Manifest had harborforge_status/telemetry/monitor_telemetry/calendar_status/ calendar_complete. Code also registers calendar_abort, calendar_pause, calendar_resume, harborforge_restart_status. With the new OpenClaw plugin host enforcement (same gotcha that bit Meridian — see zhi/Meridian#2), undeclared tools are silently dropped from the agent's tool list, so abort/pause/resume cannot be called by the agent. plugin doctor was emitting: 'plugin tool is undeclared (harbor-forge): harborforge_calendar_abort' for each missing tool. Fix: add the 4 missing tool names to contracts.tools. Also use api.config as the primary config source in wakeAgent (current public API), falling back to runtime.config.loadConfig() for older hosts — same pattern as the Meridian fix. Co-Authored-By: Claude Opus 4.7 (1M context) --- plugin/index.ts | 90 ++++++++++++++++++++++++++++++++----- plugin/openclaw.plugin.json | 6 ++- plugin/package-lock.json | 8 ++-- plugin/package.json | 2 +- 4 files changed, 89 insertions(+), 17 deletions(-) diff --git a/plugin/index.ts b/plugin/index.ts index 47481ca..1510d05 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -197,7 +197,18 @@ function register(api: PluginAPI): void { * Wake agent via dispatchInboundMessage — same mechanism used by Discord plugin. * Direct in-process call, no WebSocket or CLI needed. */ - async function wakeAgent(agentId: string): Promise { + async function wakeAgent( + agentId: string, + dueSlots?: Array<{ + id?: number | null; + virtual_id?: string | null; + event_data?: any; + scheduled_at?: string; + priority?: number; + slot_type?: string; + [k: string]: unknown; + }> + ): Promise { logger.info(`Waking agent ${agentId}: has due slots`); const sessionKey = `agent:${agentId}:hf-wakeup`; @@ -208,13 +219,39 @@ function register(api: PluginAPI): void { /* webpackIgnore: true */ sdkPath ); - const cfg = api.runtime?.config?.loadConfig?.(); + // api.config first (current public API). Fall back to deprecated + // runtime.config.loadConfig() for older host versions. Both should + // contain agents.list / channels for dispatch routing. + const cfg = (api as any).config ?? api.runtime?.config?.loadConfig?.(); if (!cfg) { logger.error('Cannot load OpenClaw config for dispatch'); return false; } - const wakeupMessage = `You have due slots. Follow the \`hf-wakeup\` workflow of skill \`hf-hangman-lab\` to proceed. Only reply \`WAKEUP_OK\` in this session.`; + // Inline the highest-priority due slot's context so the agent does + // not need a second round-trip to harborforge_calendar_status. The + // agent can read event_data.task_code / task_title etc. directly. + let slotBlock = ''; + const top = dueSlots && dueSlots.length ? dueSlots[0] : undefined; + if (top) { + slotBlock = `\n\nMatching slot:\n\`\`\`json\n${JSON.stringify( + { + slot_id: top.id ?? null, + virtual_id: top.virtual_id ?? null, + scheduled_at: top.scheduled_at ?? null, + priority: top.priority ?? null, + slot_type: top.slot_type ?? null, + event_data: top.event_data ?? null, + }, + null, + 2 + )}\n\`\`\``; + } + + const wakeupMessage = + `You have due slots. Follow the \`hf-wakeup\` workflow of skill ` + + `\`hf-hangman-lab\` to proceed. Only reply \`WAKEUP_OK\` in this ` + + `session.${slotBlock}`; const result = await dispatchInboundMessageWithDispatcher({ ctx: { @@ -308,28 +345,59 @@ function register(api: PluginAPI): void { } } + // Track wakes already dispatched for a slot in the current sync + // window — the simplified inline scheduler does not PATCH slot + // status server-side, so without dedupe the check loop re-wakes + // the same slot every 30s. Set is cleared by runSync (fresh wake + // budget per sync). + const wakedSlotKeys = new Set(); + // Check: find agents with due slots and wake them async function runCheck() { const now = new Date(); const agentsWithDue = scheduleCache.getAgentsWithDueSlots(now); - for (const { agentId } of agentsWithDue) { - // Check if agent is busy - const status = await calendarBridge.getAgentStatus(agentId); + for (const { agentId, slots } of agentsWithDue) { + // Filter out slots we've already woken this sync window + const fresh = slots.filter((s) => { + const key = `${agentId}::${s.id ?? s.virtual_id ?? s.scheduled_at}`; + if (wakedSlotKeys.has(key)) return false; + return true; + }); + if (fresh.length === 0) continue; + + // Check if agent is busy (best effort; backend may 405 the GET + // — treat unknown as not-busy so wakeup still fires) + let status: string | null = null; + try { + status = await calendarBridge.getAgentStatus(agentId); + } catch { + status = null; + } if (status === 'busy' || status === 'offline' || status === 'exhausted') { continue; } - // Wake the agent - await wakeAgent(agentId); + // Wake the agent with the slot context inlined + const ok = await wakeAgent(agentId, fresh); + if (ok) { + for (const s of fresh) { + const key = `${agentId}::${s.id ?? s.virtual_id ?? s.scheduled_at}`; + wakedSlotKeys.add(key); + } + } } } - // Initial sync - runSync(); + // Initial sync (also resets the wake-dedupe window) + const runSyncReset = async () => { + wakedSlotKeys.clear(); + await runSync(); + }; + runSyncReset(); // Start intervals - const syncHandle = setInterval(runSync, SYNC_INTERVAL_MS); + const syncHandle = setInterval(runSyncReset, SYNC_INTERVAL_MS); const checkHandle = setInterval(runCheck, CHECK_INTERVAL_MS); // Store handles for cleanup (reuse calendarScheduler variable) diff --git a/plugin/openclaw.plugin.json b/plugin/openclaw.plugin.json index 9acecef..d49cd37 100644 --- a/plugin/openclaw.plugin.json +++ b/plugin/openclaw.plugin.json @@ -11,7 +11,11 @@ "harborforge_telemetry", "harborforge_monitor_telemetry", "harborforge_calendar_status", - "harborforge_calendar_complete" + "harborforge_calendar_complete", + "harborforge_calendar_abort", + "harborforge_calendar_pause", + "harborforge_calendar_resume", + "harborforge_restart_status" ] }, "configSchema": { diff --git a/plugin/package-lock.json b/plugin/package-lock.json index d57e993..80d8083 100644 --- a/plugin/package-lock.json +++ b/plugin/package-lock.json @@ -9,14 +9,14 @@ "version": "0.2.0", "license": "MIT", "devDependencies": { - "@types/node": "^20.0.0", + "@types/node": "^20.19.41", "typescript": "^5.0.0" } }, "node_modules/@types/node": { - "version": "20.19.37", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", - "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", + "version": "20.19.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", + "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/plugin/package.json b/plugin/package.json index 37a353f..eb3d4fb 100644 --- a/plugin/package.json +++ b/plugin/package.json @@ -10,7 +10,7 @@ "watch": "tsc --watch" }, "devDependencies": { - "@types/node": "^20.0.0", + "@types/node": "^20.19.41", "typescript": "^5.0.0" }, "license": "MIT"