From d80d138b979ed567de16a7927b8dc9699b8aae31 Mon Sep 17 00:00:00 2001 From: moss Date: Wed, 20 May 2026 14:54:44 +0700 Subject: [PATCH 1/4] feat: wire OpenClaw queue hooks into bridge --- .../openclaw-opencoat-bridge/README.md | 10 ++- .../openclaw-opencoat-bridge/package.json | 2 +- .../scripts/install-local.sh | 5 +- .../src/hook-bindings.test.ts | 6 +- .../src/hook-bindings.ts | 11 ++- .../openclaw-opencoat-bridge/src/index.ts | 18 +++++ .../src/injector.test.ts | 75 +++++++++++++++++++ .../openclaw-opencoat-bridge/src/injector.ts | 46 ++++++++++++ .../openclaw-opencoat-bridge/src/payloads.ts | 28 +++++++ .../src/runtime-observers.test.ts | 42 +++++++++++ .../src/runtime-observers.ts | 15 ++++ .../openclaw-opencoat-bridge/src/types.ts | 2 +- .../joinpoint/aliases.py | 1 + .../joinpoint/catalog.py | 7 +- .../tests/core/test_joinpoint_aliases.py | 3 +- 15 files changed, 258 insertions(+), 13 deletions(-) create mode 100644 integrations/openclaw-opencoat-bridge/src/injector.test.ts diff --git a/integrations/openclaw-opencoat-bridge/README.md b/integrations/openclaw-opencoat-bridge/README.md index 12b05e1..fd9a505 100644 --- a/integrations/openclaw-opencoat-bridge/README.md +++ b/integrations/openclaw-opencoat-bridge/README.md @@ -10,7 +10,8 @@ the generated `opencoat_plugin/` folder. ## Hook → joinpoint mapping -The bridge registers **26** of **29** OpenClaw plugin hooks (`hook-bindings.ts`). +The bridge registers **28** OpenClaw plugin hooks (`hook-bindings.ts`), including +the OpenCOAT fork's native queue hooks. Skipped: `before_message_write`, `tool_result_persist` (sync hot path — cannot await daemon RPC), `before_install` (install-only). @@ -31,6 +32,8 @@ Skipped: `before_message_write`, `tool_result_persist` (sync hot path — cannot | `message_sending` | `before_response` | submit + **`cancel`** when BLOCK advice | | `before_tool_call` | `before_tool_call` | submit + **`block`** / param guard | | `after_tool_call` | `after_tool_call` | submit (DCN activation) | +| `queue_before_enqueue` | `queue.before_enqueue` | submit + **`block`** / queue prompt rewrite | +| `queue_after_enqueue` | `queue.after_enqueue` | submit (observe) | | `before_compaction` / `after_compaction` | `before_memory_write` / `after_memory_write` | submit | | `subagent_spawning` | `task.before_create` | submit + **`status: error`** when BLOCK | | `subagent_delivery_target` / `subagent_spawned` | `task.after_create` | submit | @@ -46,7 +49,7 @@ that are **not** `api.on` plugin hooks: | --- | --- | --- | | `api.runtime.events.onAgentEvent` | `reply_run.before_begin`, `reply_run.phase.running`, `planning.plan_updated`, `approval.requested`, compaction → memory JPs | Lifecycle `start` ≈ run begin; first assistant/tool/item after start ≈ `running` | | `api.registerHook` `session:compact:*` | `before_memory_write` / `after_memory_write` | Same boundary as plugin compaction hooks | -| Poll `getFollowupQueueDepth` (host dist) | `queue.before_enqueue`, `queue.before_collect` | Depth diff per tracked `sessionKey` | +| Poll `getFollowupQueueDepth` (host dist) | `queue.before_enqueue`, `queue.before_collect` | Fallback for OpenClaw builds without native queue hooks; depth diff per tracked `sessionKey` | | Poll `api.runtime.tasks.runs.bindSession().list()` | `task.before_create`, `task.after_create`, `task.before_terminal` | First sight + status transitions (incl. non-subagent `createTaskRecord`) | Tracked sessions: any hook `ctx.sessionKey` plus agent events. Poll interval: `observerPollMs` (default 500). @@ -90,6 +93,7 @@ openclaw gateway restart Manual `plugins.entries` key must be `**@hyperdustlabs/opencoat-bridge**` (with slash), not `@hyperdustlabs-opencoat-bridge`. Remove legacy `@hyperdust/*` entries. Set +`hooks.allowPromptInjection=true`, `hooks.allowConversationAccess=true`, and `daemonUrl` in plugin config (not `process.env` in the plugin — OpenClaw blocks env+network patterns at install time). @@ -301,4 +305,4 @@ daemon). Extraction updates the concern store; it does not always add rows to - Double joinpoint fire (`on_user_input` + `before_response`) is intentional when concerns list both. - Section discovery depends on hosts passing `sections` on message objects (uncommon today); message-level JPs always apply when `messages` is present. -See also: `[examples/04_openclaw_with_runtime/README.md](../../examples/04_openclaw_with_runtime/README.md)` (toy bus), `[docs/guides/concern-authoring-aop.md](../../docs/guides/concern-authoring-aop.md)`, and `[docs/design/v0.2-system-design.md](../../docs/design/v0.2-system-design.md)` §4.7.1. \ No newline at end of file +See also: `[examples/04_openclaw_with_runtime/README.md](../../examples/04_openclaw_with_runtime/README.md)` (toy bus), `[docs/guides/concern-authoring-aop.md](../../docs/guides/concern-authoring-aop.md)`, and `[docs/design/v0.2-system-design.md](../../docs/design/v0.2-system-design.md)` §4.7.1. diff --git a/integrations/openclaw-opencoat-bridge/package.json b/integrations/openclaw-opencoat-bridge/package.json index 13578bc..70b8d63 100644 --- a/integrations/openclaw-opencoat-bridge/package.json +++ b/integrations/openclaw-opencoat-bridge/package.json @@ -13,7 +13,7 @@ }, "scripts": { "build": "tsc -p tsconfig.json", - "test": "npm run build && node --test dist/messages.test.js dist/hook-bindings.test.js dist/runtime-observers.test.js", + "test": "npm run build && node --test dist/messages.test.js dist/hook-bindings.test.js dist/injector.test.js dist/runtime-observers.test.js", "prepare": "npm run build" }, "devDependencies": { diff --git a/integrations/openclaw-opencoat-bridge/scripts/install-local.sh b/integrations/openclaw-opencoat-bridge/scripts/install-local.sh index dfe0d7a..26f87a6 100755 --- a/integrations/openclaw-opencoat-bridge/scripts/install-local.sh +++ b/integrations/openclaw-opencoat-bridge/scripts/install-local.sh @@ -43,7 +43,10 @@ for stale in ("@hyperdust/opencoat-bridge", "@hyperdust-opencoat-bridge"): entries[os.environ["EXT_ID"]] = { "enabled": True, - "hooks": {"allowPromptInjection": True}, + "hooks": { + "allowPromptInjection": True, + "allowConversationAccess": True, + }, "config": { "daemonUrl": os.environ.get("OPENCOAT_DAEMON_URL", "http://127.0.0.1:7878/rpc"), "logActivations": True, diff --git a/integrations/openclaw-opencoat-bridge/src/hook-bindings.test.ts b/integrations/openclaw-opencoat-bridge/src/hook-bindings.test.ts index b02ccee..e8f82ee 100644 --- a/integrations/openclaw-opencoat-bridge/src/hook-bindings.test.ts +++ b/integrations/openclaw-opencoat-bridge/src/hook-bindings.test.ts @@ -3,8 +3,8 @@ import assert from "node:assert/strict"; import { HOOK_BINDINGS, SKIPPED_HOOKS } from "./hook-bindings.js"; describe("hook-bindings", () => { - it("registers all async-safe plugin hooks (29 total minus 3 skipped)", () => { - assert.equal(HOOK_BINDINGS.length, 26); + it("registers async-safe plugin hooks plus native queue hooks", () => { + assert.equal(HOOK_BINDINGS.length, 28); assert.equal(SKIPPED_HOOKS.length, 3); }); @@ -19,6 +19,8 @@ describe("hook-bindings", () => { "before_prompt_build", "before_tool_call", "after_tool_call", + "queue_before_enqueue", + "queue_after_enqueue", "subagent_spawning", "before_compaction", ]) { diff --git a/integrations/openclaw-opencoat-bridge/src/hook-bindings.ts b/integrations/openclaw-opencoat-bridge/src/hook-bindings.ts index 5a0bf99..ff6fe77 100644 --- a/integrations/openclaw-opencoat-bridge/src/hook-bindings.ts +++ b/integrations/openclaw-opencoat-bridge/src/hook-bindings.ts @@ -1,8 +1,8 @@ /** * OpenClaw plugin hook → OpenCOAT joinpoint bindings (ADR-0011). * - * Queue / reply_run / non-subagent task moments use runtime-observers.ts - * (onAgentEvent + poll), not entries here. + * Legacy OpenClaw queue observations still use runtime-observers.ts + * (queue-depth poll). Native queue hooks are registered here when present. * * Skipped (sync hot path — cannot await daemon RPC): * before_message_write, tool_result_persist @@ -16,6 +16,7 @@ export type HookKind = | "tool_guard" | "message_out" | "subagent_spawn" + | "queue_guard" | "buffer_input"; export type HookBinding = { @@ -24,7 +25,7 @@ export type HookBinding = { kind: HookKind; }; -/** All hooks the bridge registers (30 OpenClaw plugin hooks − 3 skipped). */ +/** All hooks the bridge registers. */ export const HOOK_BINDINGS: HookBinding[] = [ // --- already wired (kept for documentation order) --- { hook: "session_start", joinpoint: "runtime_start", kind: "observe" }, @@ -56,6 +57,10 @@ export const HOOK_BINDINGS: HookBinding[] = [ // --- tools --- { hook: "after_tool_call", joinpoint: "after_tool_call", kind: "observe" }, + // --- queue --- + { hook: "queue_before_enqueue", joinpoint: "queue.before_enqueue", kind: "queue_guard" }, + { hook: "queue_after_enqueue", joinpoint: "queue.after_enqueue", kind: "observe" }, + // --- memory / compaction --- { hook: "before_compaction", joinpoint: "before_memory_write", kind: "observe" }, { hook: "after_compaction", joinpoint: "after_memory_write", kind: "observe" }, diff --git a/integrations/openclaw-opencoat-bridge/src/index.ts b/integrations/openclaw-opencoat-bridge/src/index.ts index 044769d..41f99aa 100644 --- a/integrations/openclaw-opencoat-bridge/src/index.ts +++ b/integrations/openclaw-opencoat-bridge/src/index.ts @@ -21,6 +21,7 @@ import { guardToolCall, mergeInjections, messageSendingDecision, + queueBeforeEnqueueDecision, subagentSpawnDecision, } from "./injector.js"; import { promptPayload } from "./messages.js"; @@ -28,6 +29,7 @@ import { compactionPayload, llmPayload, passThroughPayload, + queuePayload, subagentPayload, toolCallPayload, toolResultPayload, @@ -35,6 +37,7 @@ import { import { createObserveEmitter } from "./emit-joinpoint.js"; import { installRuntimeObservers, + recordQueueDepthSnapshot, trackSessionKey, } from "./runtime-observers.js"; import type { @@ -133,6 +136,10 @@ function buildPayloadForHook( error: typeof e.error === "string" ? e.error : undefined, durationMs: typeof e.durationMs === "number" ? e.durationMs : undefined, }); + case "queue_before_enqueue": + return queuePayload(e, "before_enqueue"); + case "queue_after_enqueue": + return queuePayload(e, "after_enqueue"); case "message_sending": { const content = typeof e.content === "string" ? e.content : ""; return promptPayload({ @@ -237,8 +244,19 @@ async function handleHook( return { status: "ok" }; } + case "queue_guard": { + const inj = await emit(cfg, api, binding.hook, binding.joinpoint, payload, c); + return queueBeforeEnqueueDecision(inj); + } + case "observe": default: { + if (binding.hook === "queue_after_enqueue") { + const ev = asRecord(event); + const sessionKey = + typeof ev.sessionKey === "string" ? ev.sessionKey : c.sessionKey; + recordQueueDepthSnapshot(sessionKey, ev.depthAfter); + } const level = binding.hook === "gateway_start" || binding.hook === "gateway_stop" ? 0 diff --git a/integrations/openclaw-opencoat-bridge/src/injector.test.ts b/integrations/openclaw-opencoat-bridge/src/injector.test.ts new file mode 100644 index 0000000..f0938e1 --- /dev/null +++ b/integrations/openclaw-opencoat-bridge/src/injector.test.ts @@ -0,0 +1,75 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { queueBeforeEnqueueDecision } from "./injector.js"; +import type { ConcernInjection } from "./types.js"; + +function injection(rows: ConcernInjection["injections"]): ConcernInjection { + return { + weave_id: "weave-1", + agent_session_id: "session-1", + injections: rows, + }; +} + +describe("queueBeforeEnqueueDecision", () => { + it("blocks queue enqueue from blocking advice", () => { + const decision = queueBeforeEnqueueDecision( + injection([ + { + concern_id: "queue-policy", + advice_type: "memory_write_guard", + target: "queue.prompt", + mode: "block", + content: "Queue is full for this policy.", + }, + ]), + ); + + assert.deepEqual(decision, { + block: true, + blockReason: "Queue is full for this policy.", + }); + }); + + it("rewrites prompt and summary line from queue targets", () => { + const decision = queueBeforeEnqueueDecision( + injection([ + { + concern_id: "rewrite", + advice_type: "rewrite_guidance", + target: "queue.prompt", + mode: "rewrite", + content: "Summarized follow-up prompt", + }, + { + concern_id: "summary", + advice_type: "rewrite_guidance", + target: "queue.summary_line", + mode: "rewrite", + content: "Short summary", + }, + ]), + ); + + assert.deepEqual(decision, { + prompt: "Summarized follow-up prompt", + summaryLine: "Short summary", + }); + }); + + it("ignores non-queue append advice", () => { + const decision = queueBeforeEnqueueDecision( + injection([ + { + concern_id: "prompt-note", + advice_type: "response_requirement", + target: "runtime_prompt.output_format", + mode: "insert", + content: "Answer in JSON.", + }, + ]), + ); + + assert.deepEqual(decision, {}); + }); +}); diff --git a/integrations/openclaw-opencoat-bridge/src/injector.ts b/integrations/openclaw-opencoat-bridge/src/injector.ts index f851712..3e5a369 100644 --- a/integrations/openclaw-opencoat-bridge/src/injector.ts +++ b/integrations/openclaw-opencoat-bridge/src/injector.ts @@ -11,6 +11,10 @@ function isToolTarget(target: string): boolean { return target === "tool_call" || target.startsWith("tool_call."); } +function isQueueTarget(target: string): boolean { + return target === "queue" || target.startsWith("queue."); +} + /** Fold prompt-level INSERT (etc.) rows into OpenClaw prependSystemContext. */ export function foldPromptInjection(injection: ConcernInjection | null): string { if (!injection?.injections?.length) return ""; @@ -123,6 +127,48 @@ export function subagentSpawnDecision( return { status: "error", error: reason }; } +export type QueueBeforeEnqueueDecision = { + block?: boolean; + blockReason?: string; + prompt?: string; + summaryLine?: string; +}; + +/** Interpret queue-scoped advice for OpenClaw queue_before_enqueue. */ +export function queueBeforeEnqueueDecision( + injection: ConcernInjection | null, +): QueueBeforeEnqueueDecision { + if (!injection?.injections?.length) return {}; + + const reasons: string[] = []; + const decision: QueueBeforeEnqueueDecision = {}; + + for (const row of injection.injections) { + if (!isQueueTarget(row.target) && !BLOCK_MODES.has(row.mode)) continue; + + if (BLOCK_MODES.has(row.mode)) { + decision.block = true; + if (row.content.trim()) reasons.push(row.content.trim()); + continue; + } + + if (!row.content.trim()) continue; + if (row.target === "queue.prompt" && row.mode === "rewrite") { + decision.prompt = row.content.trim(); + } else if ( + (row.target === "queue.summary_line" || row.target === "queue.summaryLine") && + row.mode === "rewrite" + ) { + decision.summaryLine = row.content.trim(); + } + } + + if (reasons.length) { + decision.blockReason = reasons.join("\n"); + } + return decision; +} + export function mergeInjections( ...injections: Array ): ConcernInjection | null { diff --git a/integrations/openclaw-opencoat-bridge/src/payloads.ts b/integrations/openclaw-opencoat-bridge/src/payloads.ts index 3deb141..ec1db07 100644 --- a/integrations/openclaw-opencoat-bridge/src/payloads.ts +++ b/integrations/openclaw-opencoat-bridge/src/payloads.ts @@ -59,6 +59,34 @@ export function toolResultPayload(event: { }; } +export function queuePayload( + event: Record, + stage: "before_enqueue" | "after_enqueue", +): Record { + const text = safeJson(event); + return { + text, + raw_text: text, + content: text, + stage, + queue_key: event.queueKey, + queue_mode: event.queueMode, + drop_policy: event.dropPolicy, + depth_before: event.depthBefore, + depth_after: event.depthAfter, + enqueued: event.enqueued, + prompt: event.prompt, + summary_line: event.summaryLine, + message_id: event.messageId, + originating_channel: event.originatingChannel, + originating_to: event.originatingTo, + originating_account_id: event.originatingAccountId, + originating_thread_id: event.originatingThreadId, + session_id: event.sessionId, + session_key: event.sessionKey, + }; +} + export function messageContentPayload( content: string, extra?: Record, diff --git a/integrations/openclaw-opencoat-bridge/src/runtime-observers.test.ts b/integrations/openclaw-opencoat-bridge/src/runtime-observers.test.ts index 8592623..7c1bda2 100644 --- a/integrations/openclaw-opencoat-bridge/src/runtime-observers.test.ts +++ b/integrations/openclaw-opencoat-bridge/src/runtime-observers.test.ts @@ -5,7 +5,10 @@ import { agentEventRunningJoinpoint, diffQueueDepth, diffTaskSnapshots, + installRuntimeObservers, + recordQueueDepthSnapshot, } from "./runtime-observers.js"; +import type { BridgeConfig, BridgePluginApi } from "./types.js"; describe("agentEventJoinpoint", () => { it("maps lifecycle start to reply_run.before_begin", () => { @@ -58,6 +61,13 @@ describe("diffQueueDepth", () => { ["queue.before_collect"], ); }); + + it("uses native queue hook snapshots as the next poll baseline", () => { + const key = `sess-${Math.random()}`; + recordQueueDepthSnapshot(key, 2); + const events = diffQueueDepth(key, 2); + assert.deepEqual(events, []); + }); }); describe("diffTaskSnapshots", () => { @@ -87,3 +97,35 @@ describe("diffTaskSnapshots", () => { assert.deepEqual(terminal.map((e) => e.name), ["task.before_terminal"]); }); }); + +describe("installRuntimeObservers", () => { + it("names the internal compaction hook registration for OpenClaw", () => { + let registered: + | { events: string | string[]; opts?: { name?: string; description?: string } } + | undefined; + const api: BridgePluginApi = { + on: () => {}, + registerHook: (events, _handler, opts) => { + registered = { events, opts }; + }, + }; + const cfg: BridgeConfig = { + daemonUrl: "http://127.0.0.1:7878/rpc", + enabled: false, + logActivations: false, + extractOnUserMessage: false, + runtimeObservers: true, + observerPollMs: 500, + }; + + installRuntimeObservers(api, cfg, { + observe: async () => null, + }); + + assert.deepEqual(registered?.events, [ + "session:compact:before", + "session:compact:after", + ]); + assert.equal(registered?.opts?.name, "opencoat-bridge-session-compact"); + }); +}); diff --git a/integrations/openclaw-opencoat-bridge/src/runtime-observers.ts b/integrations/openclaw-opencoat-bridge/src/runtime-observers.ts index 0270ca6..4f7e113 100644 --- a/integrations/openclaw-opencoat-bridge/src/runtime-observers.ts +++ b/integrations/openclaw-opencoat-bridge/src/runtime-observers.ts @@ -33,6 +33,17 @@ export function trackSessionKey(sessionKey: string | undefined): void { if (sessionKey?.trim()) trackedSessionKeys.add(sessionKey.trim()); } +export function recordQueueDepthSnapshot( + sessionKey: string | undefined, + depth: unknown, +): void { + if (!sessionKey?.trim() || typeof depth !== "number" || !Number.isFinite(depth)) { + return; + } + trackedSessionKeys.add(sessionKey.trim()); + queueDepthByKey.set(sessionKey.trim(), Math.max(0, Math.floor(depth))); +} + export function agentEventJoinpoint( evt: AgentEventPayload, ): { name: string; payload: Record } | null { @@ -297,6 +308,10 @@ export function installRuntimeObservers( api.registerHook( ["session:compact:before", "session:compact:after"], compactHandler, + { + name: "opencoat-bridge-session-compact", + description: "Mirror OpenClaw session compaction events into OpenCOAT memory joinpoints.", + }, ); } diff --git a/integrations/openclaw-opencoat-bridge/src/types.ts b/integrations/openclaw-opencoat-bridge/src/types.ts index 1808345..a387ae6 100644 --- a/integrations/openclaw-opencoat-bridge/src/types.ts +++ b/integrations/openclaw-opencoat-bridge/src/types.ts @@ -57,7 +57,7 @@ export type BridgePluginApi = { sessionKey?: string; context?: Record; }) => void | Promise, - opts?: { priority?: number }, + opts?: { name?: string; description?: string; priority?: number }, ) => void; }; diff --git a/packages/opencoat-runtime/opencoat_runtime_core/joinpoint/aliases.py b/packages/opencoat-runtime/opencoat_runtime_core/joinpoint/aliases.py index ec805fe..2df7cfa 100644 --- a/packages/opencoat-runtime/opencoat_runtime_core/joinpoint/aliases.py +++ b/packages/opencoat-runtime/opencoat_runtime_core/joinpoint/aliases.py @@ -54,6 +54,7 @@ { "input.received", "queue.before_enqueue", + "queue.after_enqueue", "queue.before_collect", "reply_run.before_begin", "reply_run.phase.running", diff --git a/packages/opencoat-runtime/opencoat_runtime_core/joinpoint/catalog.py b/packages/opencoat-runtime/opencoat_runtime_core/joinpoint/catalog.py index 138e961..e6449af 100644 --- a/packages/opencoat-runtime/opencoat_runtime_core/joinpoint/catalog.py +++ b/packages/opencoat-runtime/opencoat_runtime_core/joinpoint/catalog.py @@ -85,7 +85,12 @@ class CatalogEntry: CatalogEntry( "queue.before_enqueue", JoinpointLevel.LIFECYCLE, - "Bridge: queue depth poll (observe; not sync at enqueueFollowupRun)", + "Bridge: native queue_before_enqueue hook; queue poll fallback on older OpenClaw", + ), + CatalogEntry( + "queue.after_enqueue", + JoinpointLevel.LIFECYCLE, + "Bridge: native queue_after_enqueue hook (observe after queue decision)", ), CatalogEntry( "queue.before_collect", diff --git a/packages/opencoat-runtime/tests/core/test_joinpoint_aliases.py b/packages/opencoat-runtime/tests/core/test_joinpoint_aliases.py index e38988c..65104cd 100644 --- a/packages/opencoat-runtime/tests/core/test_joinpoint_aliases.py +++ b/packages/opencoat-runtime/tests/core/test_joinpoint_aliases.py @@ -50,6 +50,7 @@ def test_matcher_accepts_v01_pointcut_against_legacy_event_name() -> None: assert result.matched -def test_queue_joinpoint_in_catalog_not_emitted_by_bridge_yet() -> None: +def test_queue_joinpoints_in_catalog() -> None: assert "queue.before_enqueue" in JOINPOINT_CATALOG + assert "queue.after_enqueue" in JOINPOINT_CATALOG assert canonical_joinpoint_name("queue.before_enqueue") == "queue.before_enqueue" From a7030aa53b654d3e4957bc06b8ee118e3d697a0f Mon Sep 17 00:00:00 2001 From: moss Date: Thu, 21 May 2026 23:26:23 +0700 Subject: [PATCH 2/4] docs: add queue hook dogfood example and fork dev helpers Ship example 09 (RPC smoke + live block via chat.send), runtime weave tests, and scripts/docs for OpenClaw fork 1:1 alignment with queue hook validation. Co-authored-by: Cursor --- .../opencoat-openclaw-joinpoint-model-v0.1.md | 14 +- docs/guides/concern-authoring-aop.md | 46 +++- docs/guides/openclaw-fork-dev.md | 80 +++++++ examples/09_queue_hook_dogfood/README.md | 137 ++++++++++++ .../concerns/oc.dogfood.queue-block.json | 29 +++ .../oc.dogfood.queue-prompt-rewrite.json | 29 +++ .../oc.dogfood.queue-summary-rewrite.json | 29 +++ .../scripts/live-queue-block-test.sh | 198 ++++++++++++++++++ .../scripts/smoke-rpc.sh | 118 +++++++++++ examples/README.md | 1 + .../openclaw-opencoat-bridge/README.md | 16 +- .../tests/core/test_queue_joinpoint_weave.py | 163 ++++++++++++++ scripts/check-openclaw-fork.sh | 81 +++++++ scripts/use-openclaw-fork.sh | 133 ++++++++++++ 14 files changed, 1062 insertions(+), 12 deletions(-) create mode 100644 docs/guides/openclaw-fork-dev.md create mode 100644 examples/09_queue_hook_dogfood/README.md create mode 100644 examples/09_queue_hook_dogfood/concerns/oc.dogfood.queue-block.json create mode 100644 examples/09_queue_hook_dogfood/concerns/oc.dogfood.queue-prompt-rewrite.json create mode 100644 examples/09_queue_hook_dogfood/concerns/oc.dogfood.queue-summary-rewrite.json create mode 100755 examples/09_queue_hook_dogfood/scripts/live-queue-block-test.sh create mode 100755 examples/09_queue_hook_dogfood/scripts/smoke-rpc.sh create mode 100644 packages/opencoat-runtime/tests/core/test_queue_joinpoint_weave.py create mode 100755 scripts/check-openclaw-fork.sh create mode 100755 scripts/use-openclaw-fork.sh diff --git a/docs/design/opencoat-openclaw-joinpoint-model-v0.1.md b/docs/design/opencoat-openclaw-joinpoint-model-v0.1.md index 35dbbf0..1c2d7b8 100644 --- a/docs/design/opencoat-openclaw-joinpoint-model-v0.1.md +++ b/docs/design/opencoat-openclaw-joinpoint-model-v0.1.md @@ -376,7 +376,8 @@ error.detected | MVP joinpoint | Emitted today | Bridge source | Sync veto at host call site | | --- | --- | --- | --- | | `input.received` | yes | `message_received`, `inbound_claim`, `before_dispatch` | no (observe / buffer) | -| `queue.before_enqueue` | yes (observe) | queue depth poll (`getFollowupQueueDepth`) | no — needs native hook at `enqueueFollowupRun` | +| `queue.before_enqueue` | yes | **`queue_before_enqueue` plugin hook** (fork); queue depth poll fallback | **yes** — `block` / `queue.prompt` / `queue.summary_line` rewrite via bridge `queue_guard` | +| `queue.after_enqueue` | yes (observe) | **`queue_after_enqueue` plugin hook** (fork); poll snapshot sync | no | | `queue.before_collect` | yes (observe) | queue depth poll (depth decrease) | no | | `reply_run.before_begin` | yes (observe) | `onAgentEvent` lifecycle `start` | no | | `reply_run.phase.running` | yes (observe) | first `assistant` / `tool` / `item` after start | no | @@ -413,7 +414,7 @@ C. Not direct — needs OpenClaw middleware/hook PR, or is OpenCOAT-internal **Weakest / internal-only:** `token.*`, `span.*`, `prompt.section.*` (until host passes structured sections), implicit planner steps, true memory read/write on every path, unified `response.before_final` verifier (partial via `message_sending` only). -**Strong control gap (upstream):** synchronous veto at `enqueueFollowupRun`, full `reply_run.phase.*`, and any path where only post-hoc agent events exist today. +**Strong control gap (upstream):** full `reply_run.phase.*`, `tool.result.before_emit`, unified `memory.before_write`, and paths where only post-hoc agent events exist today. **`queue.before_enqueue` sync veto shipped on OpenClaw fork** (`queue_before_enqueue` hook + bridge `queue_guard`); poll remains observe-only fallback. ### 5.2 Tier A — usable now @@ -434,6 +435,8 @@ C. Not direct — needs OpenClaw middleware/hook PR, or is OpenCOAT-internal | Task API | `runtime.tasks.runs.bindSession().list()` | `task.*` | poll diff | observe | | Task hooks | `subagent_*` | `task.after_create`, `task.before_terminal` | yes | observe / spawn veto | | Input | `message_received`, `inbound_claim`, `before_dispatch` | `input.received` | yes | observe / extract | +| Plugin hooks | `queue_before_enqueue` / `queue_after_enqueue` | `queue.before_enqueue` / `queue.after_enqueue` | yes (fork) | **block** / prompt & summaryLine **rewrite** | +| Queue poll | `getFollowupQueueDepth` | `queue.before_enqueue`, `queue.before_collect` | fallback | observe only (no veto) | **Important correction:** OpenClaw’s plugin hook `before_tool_call` **is** a pre-execute guard — do not confuse it with `onAgentEvent` `stream: tool`, which fires around tool **start** and is observe-only. Catalog name `tool.before_call` maps to the plugin hook, not `tool.started`. @@ -441,7 +444,6 @@ C. Not direct — needs OpenClaw middleware/hook PR, or is OpenCOAT-internal | Joinpoint (design) | How to attach | Bridge / OpenCOAT today | | --- | --- | --- | -| `queue.before_enqueue` / `after_enqueue` | wrap `enqueueFollowupRun` or depth poll | poll only (late) | | `queue.before_drain` | wrap `scheduleFollowupDrain` | not wired | | `input.before_enqueue` | adapter before enqueue | partial (`on_user_input` buffer) | | `prompt.before_send_to_model` | `FollowupRun.extraSystemPrompt`, hook fold | plugin + discovery | @@ -458,7 +460,6 @@ OpenCOAT applies **weak modulation** here: extra system prompt, queue/task *poli ```text tool.before_execute # if stricter than plugin before_tool_call (args rewrite mid-flight) -queue.before_enqueue # sync veto at enqueueFollowupRun (bridge poll is too late) reply_run.phase.* # per-phase hooks on ReplyOperation memory.before_read / before_write # unified memory middleware (compaction hooks are partial) response.before_final # unified verifier before channel delivery (message_sending is partial) @@ -499,10 +500,11 @@ Upstream “neurosurgery” (recommended order): | --- | --- | --- | | **Shipped (bridge)** | §4.1 MVP rows marked “yes” | plugin hooks + `runtime-observers.ts` | | **Next (observe)** | `command.output_stream`, `patch.summary_created`, streaming deltas | extend `onAgentEvent` mapping in bridge (use catalog names, not `command.output`) | -| **Next (upstream)** | sync `queue.before_enqueue`, `reply_run.phase.*`, `response.before_final` | OpenClaw plugin hooks at call sites | +| **Shipped (fork + bridge)** | `queue.before_enqueue` / `queue.after_enqueue` sync veto + observe | OpenClaw `queue_before_enqueue` / `queue_after_enqueue` + bridge `queue_guard` | +| **Next (upstream)** | `reply_run.phase.*`, `response.before_final`, `tool.result.before_emit` | OpenClaw plugin hooks at call sites | | **OpenCOAT-only** | `span.*`, `token.*`, message children | discovery on prompt payload | -Design catalog lists **17 MVP names**; bridge **strong loop** today is smaller: input → prompt fold → tool guard → optional outbound cancel → task/subagent edges, plus observe-only queue/run/task/event stream for DCN. +Design catalog lists **17 MVP names**; bridge **strong loop** today: input → prompt fold → tool guard → optional outbound cancel → **queue enqueue veto/rewrite (fork)** → task/subagent edges, plus observe-only run/task/event stream for DCN. Dogfood: [`examples/09_queue_hook_dogfood`](../../examples/09_queue_hook_dogfood/README.md). --- diff --git a/docs/guides/concern-authoring-aop.md b/docs/guides/concern-authoring-aop.md index 173bd72..b4a6e22 100644 --- a/docs/guides/concern-authoring-aop.md +++ b/docs/guides/concern-authoring-aop.md @@ -37,9 +37,11 @@ legacy `pointcut` / `advice` / `weaving_policy` are optional and sync automatica ## Message-level guard (OpenClaw bridge) -The gateway bridge registers **26** plugin hooks plus **runtime observers** (`onAgentEvent`, -queue/task poll) for MVP joinpoints such as `queue.before_enqueue` and `reply_run.before_begin` +The gateway bridge registers **28** plugin hooks plus **runtime observers** (`onAgentEvent`, +queue/task poll) for MVP joinpoints such as `queue.before_enqueue` (sync **block/rewrite** +on OpenClaw fork via `queue_before_enqueue`) and `reply_run.before_begin` (observe-only — see [joinpoint model §4.1](../design/opencoat-openclaw-joinpoint-model-v0.1.md#41-mvp-emit-status-bridge-integrationsopenclaw-opencoat-bridge)). +Dogfood concerns: [`examples/09_queue_hook_dogfood`](../examples/09_queue_hook_dogfood/README.md). Prefer `user_message()` over flat `before_response` when the bridge sends `messages[]`: @@ -62,6 +64,46 @@ Prefer `user_message()` over flat `before_response` when the bridge sends `messa } ``` +## Queue guard (OpenClaw fork + bridge) + +Target `queue.before_enqueue` with explicit `joinpoints` (dotted names are not parsed +from `expression()` today). Bridge maps woven advice to OpenClaw `queue_before_enqueue`: + +| `effect.target` | `effect.mode` | OpenClaw result | +| --- | --- | --- | +| `queue.prompt` | `block` | skip enqueue | +| `queue.prompt` | `rewrite` | replace queued prompt | +| `queue.summary_line` | `rewrite` | replace summary line | + +```json +{ + "id": "oc.dogfood.queue-block", + "pointcuts": [ + { + "id": "pc-queue", + "joinpoints": ["queue.before_enqueue"], + "match": { "any_keywords": ["QUEUE_DOGFOOD_BLOCK"] } + } + ], + "advices": [ + { + "kind": "before", + "pointcut_ref": "pc-queue", + "template": "memory_write_guard", + "content": "Follow-up queue blocked by policy.", + "effect": { + "mode": "block", + "level": "memory_level", + "target": "queue.prompt", + "priority": 0.95 + } + } + ] +} +``` + +Full dogfood set: [`examples/09_queue_hook_dogfood`](../examples/09_queue_hook_dogfood/README.md). + ## Declare precedence ```json diff --git a/docs/guides/openclaw-fork-dev.md b/docs/guides/openclaw-fork-dev.md new file mode 100644 index 0000000..0ab4d5b --- /dev/null +++ b/docs/guides/openclaw-fork-dev.md @@ -0,0 +1,80 @@ +# OpenClaw fork development (1:1 with global CLI) + +OpenCOAT + OpenClaw bridge development uses the **HyperdustLabs fork**, not npm +registry OpenClaw or the upstream `openclaw/openclaw` main tree. + +| Item | Canonical path | +| --- | --- | +| Fork repo | `~/openclaw-fork` | +| Remote | `https://github.com/HyperdustLabs/openclaw.git` | +| Branch | `opencoat/hooks-v0.1` | +| Lock file | `~/.openclaw/openclaw-fork.json` | + +## Policy + +1. **Global `openclaw` must be 1:1 with `~/openclaw-fork`** — same commit, same + `openclaw.mjs`, same `dist/index.js`. No separate npm registry install. +2. **Gateway LaunchAgent** must run `~/openclaw-fork/dist/index.js` (port 18789). +3. **Do not use** `~/openclaw` (upstream clone) for OpenCOAT dogfood — it lacks + queue hooks and fork-specific plugin SDK surfaces. +4. After every `git pull` in the fork, re-run bind + rebuild. + +## One-time setup + +From OpenCOAT repo root: + +```bash +./scripts/use-openclaw-fork.sh --clone # if ~/openclaw-fork missing +# or, if fork already exists: +./scripts/use-openclaw-fork.sh +``` + +This: + +- `npm install -g .` from `~/openclaw-fork` (symlink, not registry copy) +- installs `~/.local/bin/openclaw` → `openclaw-fork` shim +- reinstalls LaunchAgent gateway on fork `dist/` +- writes `~/.openclaw/openclaw-fork.json` + +## Daily workflow + +```bash +cd ~/openclaw-fork +git pull origin opencoat/hooks-v0.1 +# ... edit, commit, push to HyperdustLabs/openclaw ... + +cd /path/to/OpenCOAT +./scripts/use-openclaw-fork.sh --update # pull + pnpm install + build + rebind +./scripts/check-openclaw-fork.sh # verify 1:1 +``` + +Bridge rebuild after TS changes: + +```bash +cd integrations/openclaw-opencoat-bridge && npm run build +openclaw daemon restart +``` + +## Verify + +```bash +./scripts/check-openclaw-fork.sh +openclaw --version # e.g. 2026.5.19 (593c5de) +openclaw gateway status # CLI version == Gateway version +# Command line should include: ~/openclaw-fork/dist/index.js +``` + +## What breaks 1:1 alignment + +| Action | Fix | +| --- | --- | +| `npm install -g openclaw@latest` (registry) | `./scripts/use-openclaw-fork.sh` | +| Running gateway from `/tmp/...` without updating LaunchAgent | `openclaw gateway install --force` | +| Fork pulled but not rebuilt | `cd ~/openclaw-fork && pnpm build` then `./scripts/use-openclaw-fork.sh` | +| Old BAIclaw shim on PATH | removed by `use-openclaw-fork.sh`; use fork shim only | + +## Related + +- [OpenClaw bridge README](../../integrations/openclaw-opencoat-bridge/README.md) +- [Joinpoint model §4.1 queue hooks](../design/opencoat-openclaw-joinpoint-model-v0.1.md) +- HyperdustLabs PR: `opencoat/hooks-v0.1` on `HyperdustLabs/openclaw` diff --git a/examples/09_queue_hook_dogfood/README.md b/examples/09_queue_hook_dogfood/README.md new file mode 100644 index 0000000..ddd3d0e --- /dev/null +++ b/examples/09_queue_hook_dogfood/README.md @@ -0,0 +1,137 @@ +# 09 — Queue hook dogfood (OpenClaw fork + bridge) + +End-to-end dogfood for **native** `queue_before_enqueue` / `queue_after_enqueue` +on the OpenClaw fork (`opencoat/hooks-v0.1`). OpenCOAT matches +`queue.before_enqueue` and returns advice the bridge maps to OpenClaw +`{ block, prompt, summaryLine }`. + +Import **one concern at a time** — all three target the same joinpoint. + +## Layout + +```text +examples/09_queue_hook_dogfood/ +├── README.md +├── concerns/ +│ ├── oc.dogfood.queue-block.json +│ ├── oc.dogfood.queue-prompt-rewrite.json +│ └── oc.dogfood.queue-summary-rewrite.json +└── scripts/ + ├── smoke-rpc.sh # daemon-only smoke (no gateway) + └── live-queue-block-test.sh # live block via gateway chat.send +``` + +## Prerequisites + +1. OpenCOAT daemon: `opencoat runtime up` +2. OpenClaw **fork** 1:1: `./scripts/use-openclaw-fork.sh` then `./scripts/check-openclaw-fork.sh` +3. Bridge linked: `openclaw plugins install -l integrations/openclaw-opencoat-bridge` +4. Gateway restarted on fork build (≥ 2026.5.19) + +## 1. RPC smoke (fast, no Telegram) + +From repo root: + +```bash +chmod +x examples/09_queue_hook_dogfood/scripts/smoke-rpc.sh +./examples/09_queue_hook_dogfood/scripts/smoke-rpc.sh all +``` + +**Pass criteria** (per case, in `result.injections[]`): + +| Case | Trigger keyword | Expected `mode` | Expected `target` | +| --- | --- | --- | --- | +| block | `QUEUE_DOGFOOD_BLOCK` | `block` | `queue.prompt` | +| prompt rewrite | `QUEUE_DOGFOOD_REWRITE_PROMPT` | `rewrite` | `queue.prompt` | +| summary rewrite | `QUEUE_DOGFOOD_REWRITE_SUMMARY` | `rewrite` | `queue.summary_line` | + +## 2. Import concerns + +```bash +opencoat concern import examples/09_queue_hook_dogfood/concerns/oc.dogfood.queue-block.json +# or prompt / summary variant — disable others first if they share keywords +opencoat concern list | grep oc.dogfood.queue +``` + +## 3. Live gateway dogfood + +Enable bridge logging (optional): + +```json +"plugins": { + "entries": { + "@hyperdustlabs/opencoat-bridge": { + "config": { "logActivations": true } + } + } +} +``` + +**Block** (automated script — preferred) + +```bash +chmod +x examples/09_queue_hook_dogfood/scripts/live-queue-block-test.sh +./examples/09_queue_hook_dogfood/scripts/live-queue-block-test.sh +``` + +The script resolves `sessionKey` from `OPENCLAW_SESSION_ID` (default dogfood session), +starts a long run with `gateway call chat.send`, waits `WAIT_ACTIVE_SEC` (default 8), +then sends `QUEUE_DOGFOOD_BLOCK` while the first run is still active. It reads today's +gateway log under `/tmp/openclaw/openclaw-YYYY-MM-DD.log` unless `OPENCLAW_GATEWAY_LOG` +is set. + +**Pass:** gateway log contains +`queue_before_enqueue→queue.before_enqueue: oc.dogfood.queue-block` and (usually) a +fresh DCN row for `oc.dogfood.queue-block`. + +**Block** (manual / Telegram) + +1. Import `oc.dogfood.queue-block.json` +2. Start a long reply (e.g. ask for a multi-step plan) +3. While the run is active, send: `QUEUE_DOGFOOD_BLOCK — also do X` +4. **Pass:** second message is **not** queued; gateway log may show the line above +5. **Pass:** `opencoat dcn activation-log --concern-id oc.dogfood.queue-block` + +Do **not** use two sequential `openclaw agent` calls for overlap — each call blocks +until the turn finishes, so the queue hook never fires. + +**Prompt rewrite** + +1. Remove/disable block concern; import `oc.dogfood.queue-prompt-rewrite.json` +2. Active run + send: `QUEUE_DOGFOOD_REWRITE_PROMPT — tighten scope to Y` +3. **Pass:** follow-up is queued with rewritten prompt (check gateway debug or + queue drain behaviour — model should see the rewritten text, not the raw line) + +**Summary line rewrite** + +1. Import `oc.dogfood.queue-summary-rewrite.json` +2. Active run + send: `QUEUE_DOGFOOD_REWRITE_SUMMARY — minor tweak` +3. **Pass:** queued item summary line becomes + `OpenCOAT: queued follow-up (summary rewritten)` + +## Architecture reminder + +```text +OpenClaw queue_before_enqueue (sync, fork) + │ + ▼ +Bridge queue_guard → joinpoint.submit(queue.before_enqueue) + │ + ▼ +OpenCOAT weave → ConcernInjection + │ + ▼ +Bridge queueBeforeEnqueueDecision → { block | prompt | summaryLine } + │ + ▼ +OpenClaw applies before enqueue / steering +``` + +Poll-based `queue.before_enqueue` in `runtime-observers.ts` remains a +**fallback observe path** when the host lacks native hooks; it cannot veto. + +## Related + +- [Bridge README](../../integrations/openclaw-opencoat-bridge/README.md) +- [Joinpoint model §4.1](../../docs/design/opencoat-openclaw-joinpoint-model-v0.1.md#41-mvp-emit-status-bridge-integrationsopenclaw-opencoat-bridge) +- OpenCOAT PR #77 / OpenClaw fork PR #1 (queue hooks) diff --git a/examples/09_queue_hook_dogfood/concerns/oc.dogfood.queue-block.json b/examples/09_queue_hook_dogfood/concerns/oc.dogfood.queue-block.json new file mode 100644 index 0000000..0d5db29 --- /dev/null +++ b/examples/09_queue_hook_dogfood/concerns/oc.dogfood.queue-block.json @@ -0,0 +1,29 @@ +{ + "id": "oc.dogfood.queue-block", + "kind": "concern", + "name": "Dogfood — block follow-up queue enqueue", + "description": "Blocks queue_before_enqueue when the queued prompt contains QUEUE_DOGFOOD_BLOCK.", + "schema_version": "0.1.0", + "pointcuts": [ + { + "id": "pc-queue-block", + "joinpoints": ["queue.before_enqueue"], + "match": { "any_keywords": ["QUEUE_DOGFOOD_BLOCK"] } + } + ], + "advices": [ + { + "id": "adv-block", + "kind": "before", + "pointcut_ref": "pc-queue-block", + "template": "memory_write_guard", + "content": "Follow-up queue blocked by OpenCOAT dogfood concern (oc.dogfood.queue-block).", + "effect": { + "mode": "block", + "level": "memory_level", + "target": "queue.prompt", + "priority": 0.95 + } + } + ] +} diff --git a/examples/09_queue_hook_dogfood/concerns/oc.dogfood.queue-prompt-rewrite.json b/examples/09_queue_hook_dogfood/concerns/oc.dogfood.queue-prompt-rewrite.json new file mode 100644 index 0000000..f580e0b --- /dev/null +++ b/examples/09_queue_hook_dogfood/concerns/oc.dogfood.queue-prompt-rewrite.json @@ -0,0 +1,29 @@ +{ + "id": "oc.dogfood.queue-prompt-rewrite", + "kind": "concern", + "name": "Dogfood — rewrite queued follow-up prompt", + "description": "Rewrites queue.prompt when the incoming prompt contains QUEUE_DOGFOOD_REWRITE_PROMPT.", + "schema_version": "0.1.0", + "pointcuts": [ + { + "id": "pc-queue-prompt", + "joinpoints": ["queue.before_enqueue"], + "match": { "any_keywords": ["QUEUE_DOGFOOD_REWRITE_PROMPT"] } + } + ], + "advices": [ + { + "id": "adv-rewrite-prompt", + "kind": "around", + "pointcut_ref": "pc-queue-prompt", + "template": "rewrite_guidance", + "content": "[OpenCOAT rewrite] Summarized follow-up: continue the prior task with the new constraint only.", + "effect": { + "mode": "rewrite", + "level": "output_level", + "target": "queue.prompt", + "priority": 0.8 + } + } + ] +} diff --git a/examples/09_queue_hook_dogfood/concerns/oc.dogfood.queue-summary-rewrite.json b/examples/09_queue_hook_dogfood/concerns/oc.dogfood.queue-summary-rewrite.json new file mode 100644 index 0000000..c73d92a --- /dev/null +++ b/examples/09_queue_hook_dogfood/concerns/oc.dogfood.queue-summary-rewrite.json @@ -0,0 +1,29 @@ +{ + "id": "oc.dogfood.queue-summary-rewrite", + "kind": "concern", + "name": "Dogfood — rewrite queued follow-up summary line", + "description": "Rewrites queue.summary_line when the incoming prompt contains QUEUE_DOGFOOD_REWRITE_SUMMARY.", + "schema_version": "0.1.0", + "pointcuts": [ + { + "id": "pc-queue-summary", + "joinpoints": ["queue.before_enqueue"], + "match": { "any_keywords": ["QUEUE_DOGFOOD_REWRITE_SUMMARY"] } + } + ], + "advices": [ + { + "id": "adv-rewrite-summary", + "kind": "around", + "pointcut_ref": "pc-queue-summary", + "template": "rewrite_guidance", + "content": "OpenCOAT: queued follow-up (summary rewritten)", + "effect": { + "mode": "rewrite", + "level": "output_level", + "target": "queue.summary_line", + "priority": 0.8 + } + } + ] +} diff --git a/examples/09_queue_hook_dogfood/scripts/live-queue-block-test.sh b/examples/09_queue_hook_dogfood/scripts/live-queue-block-test.sh new file mode 100755 index 0000000..41a313e --- /dev/null +++ b/examples/09_queue_hook_dogfood/scripts/live-queue-block-test.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash +# Live dogfood: active run + QUEUE_DOGFOOD_BLOCK via gateway chat.send (fork + bridge). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +CONCERN_BLOCK="$SCRIPT_DIR/../concerns/oc.dogfood.queue-block.json" +RPC="${OPENCOAT_RPC:-http://127.0.0.1:7878/rpc}" + +SESSION_ID="${OPENCLAW_SESSION_ID:-ad9fe8a0-c144-4553-83d2-6868821ad452}" +WAIT_ACTIVE_SEC="${WAIT_ACTIVE_SEC:-8}" +NONCE="${DOGFOOD_NONCE:-$(date +%s)}" +MSG1="${MSG1:-DOGFOOD_RUN_${NONCE}: Read /Users/moss/OpenCOAT/docs/design/opencoat-openclaw-joinpoint-model-v0.1.md in five chunks (lines 1-150, 151-300, 301-450, 451-600, 601-end) using read tools only. After EACH chunk output exactly 5 bullets tagged CHUNK-N-${NONCE}. Do not finish until all five chunks are done.}" +MSG2="${MSG2:-QUEUE_DOGFOOD_BLOCK — also add: keep the answer under 200 words.}" + +default_gateway_log() { + local today + today="$(date +%F)" + if [[ -f "/tmp/openclaw/openclaw-${today}.log" ]]; then + echo "/tmp/openclaw/openclaw-${today}.log" + elif [[ -f "${HOME}/.openclaw/logs/gateway.log" ]]; then + echo "${HOME}/.openclaw/logs/gateway.log" + else + echo "/tmp/openclaw/openclaw-${today}.log" + fi +} + +LOG="${OPENCLAW_GATEWAY_LOG:-$(default_gateway_log)}" +TEST_START_ISO="$(date -u +%Y-%m-%dT%H:%M:%S)" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || fail "missing command: $1" +} + +resolve_session_key() { + local session_id="$1" + openclaw sessions list --json 2>/dev/null | python3 -c ' +import json +import sys + +target = sys.argv[1] +raw = sys.stdin.read().strip() +if not raw: + sys.exit(1) +data = json.loads(raw) +rows = data if isinstance(data, list) else data.get("sessions", []) +for row in rows: + if not isinstance(row, dict): + continue + sid = row.get("sessionId") or row.get("id") + if sid == target: + key = row.get("key") or row.get("sessionKey") + if key: + print(key) + sys.exit(0) +sys.exit(1) +' "$session_id" +} + +require_cmd openclaw +require_cmd curl +require_cmd python3 +require_cmd opencoat + +if [[ ! -f "$LOG" ]]; then + fail "gateway log not found: $LOG (set OPENCLAW_GATEWAY_LOG)" +fi + +SESSION_KEY="${OPENCLAW_SESSION_KEY:-}" +if [[ -z "$SESSION_KEY" ]]; then + SESSION_KEY="$(resolve_session_key "$SESSION_ID")" || fail "no sessionKey for sessionId=$SESSION_ID (openclaw sessions list --json)" +fi + +log_start="$(wc -l < "$LOG" | tr -d ' ')" + +echo "== live queue block dogfood ==" +echo "session_id=$SESSION_ID" +echo "session_key=$SESSION_KEY" +echo "gateway_log=$LOG (baseline lines=$log_start)" +echo "test_start_utc=$TEST_START_ISO" + +echo "== import oc.dogfood.queue-block ==" +opencoat concern import "$CONCERN_BLOCK" + +PARAMS_DIR="${TMPDIR:-/tmp}/opencoat-queue-dogfood-$$" +mkdir -p "$PARAMS_DIR" +trap 'rm -rf "$PARAMS_DIR"' EXIT + +python3 - "$PARAMS_DIR" "$SESSION_KEY" "$SESSION_ID" "$MSG1" "$MSG2" "$NONCE" <<'PY' +import json +import sys +from pathlib import Path + +out_dir = Path(sys.argv[1]) +session_key, session_id = sys.argv[2], sys.argv[3] +msg1, msg2, nonce = sys.argv[4], sys.argv[5], sys.argv[6] + +def write(name, payload): + (out_dir / name).write_text(json.dumps(payload), encoding="utf-8") + +write( + "msg1.json", + { + "sessionKey": session_key, + "sessionId": session_id, + "message": msg1, + "deliver": False, + "idempotencyKey": f"dogfood-run-{nonce}", + }, +) +write( + "msg2.json", + { + "sessionKey": session_key, + "sessionId": session_id, + "message": msg2, + "deliver": False, + "idempotencyKey": f"dogfood-block-{nonce}", + }, +) +PY + +echo "== message 1 (chat.send, long run) ==" +openclaw gateway call chat.send --json --timeout 60000 \ + --params "$(cat "$PARAMS_DIR/msg1.json")" \ + | tee "$PARAMS_DIR/msg1-response.json" + +echo "== wait ${WAIT_ACTIVE_SEC}s then message 2 (block trigger) ==" +sleep "$WAIT_ACTIVE_SEC" + +openclaw gateway call chat.send --json --timeout 60000 \ + --params "$(cat "$PARAMS_DIR/msg2.json")" \ + | tee "$PARAMS_DIR/msg2-response.json" + +sleep 2 + +echo "== gateway log (queue / opencoat-bridge) ==" +log_slice="$(mktemp)" +tail -n +"$((log_start + 1))" "$LOG" >"$log_slice" || true +if grep -E 'queue_before_enqueue→queue\.before_enqueue: oc\.dogfood\.queue-block|queue_before_enqueue.*oc\.dogfood\.queue-block' "$log_slice" | tail -5; then + hook_ok=true +else + hook_ok=false + echo "(no queue_before_enqueue activation line for oc.dogfood.queue-block)" + grep -iE 'queue_before_enqueue|oc\.dogfood\.queue-block' "$log_slice" | tail -10 || true +fi +rm -f "$log_slice" + +echo "== DCN activation (oc.dogfood.queue-block) ==" +dcn_json="$(curl -sS "$RPC" -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","method":"dcn.activation_log","params":{"concern_id":"oc.dogfood.queue-block","limit":8},"id":1}')" +echo "$dcn_json" | python3 -m json.tool 2>/dev/null | head -50 || echo "$dcn_json" + +dcn_ok=false +if printf '%s' "$dcn_json" | python3 -c ' +import json +import sys +from datetime import datetime, timezone + +threshold = sys.argv[1] +cutoff = datetime.fromisoformat(threshold.replace("Z", "+00:00")) +if cutoff.tzinfo is None: + cutoff = cutoff.replace(tzinfo=timezone.utc) +cutoff = cutoff.timestamp() - 30 + +payload = json.load(sys.stdin) +for row in payload.get("result") or []: + ts = row.get("ts", "") + try: + dt = datetime.fromisoformat(ts.replace("Z", "+00:00")) + if dt.timestamp() >= cutoff: + print(row.get("joinpoint_id", "")) + sys.exit(0) + except ValueError: + continue +sys.exit(1) +' "$TEST_START_ISO"; then + dcn_ok=true +fi + +if [[ "$hook_ok" == true ]]; then + echo "PASS: gateway saw queue_before_enqueue for oc.dogfood.queue-block" +else + fail "gateway log missing queue_before_enqueue→oc.dogfood.queue-block (enable logActivations on bridge?)" +fi + +if [[ "$dcn_ok" == true ]]; then + echo "PASS: DCN activation logged during this run" +else + echo "WARN: no DCN activation at or after $TEST_START_ISO (hook path may still be OK)" +fi + +echo "== done ==" diff --git a/examples/09_queue_hook_dogfood/scripts/smoke-rpc.sh b/examples/09_queue_hook_dogfood/scripts/smoke-rpc.sh new file mode 100755 index 0000000..4e1ccdc --- /dev/null +++ b/examples/09_queue_hook_dogfood/scripts/smoke-rpc.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# Smoke-test queue.before_enqueue via daemon RPC (no OpenClaw gateway required). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CONCERNS_DIR="$SCRIPT_DIR/../concerns" +RPC="${OPENCOAT_RPC:-http://127.0.0.1:7878/rpc}" + +usage() { + cat <<'EOF' +Usage: smoke-rpc.sh + +Imports the matching dogfood concern (if daemon is up), submits a synthetic +queue.before_enqueue joinpoint, and prints the ConcernInjection JSON. + +Examples: + ./examples/09_queue_hook_dogfood/scripts/smoke-rpc.sh block + ./examples/09_queue_hook_dogfood/scripts/smoke-rpc.sh all +EOF +} + +mode="${1:-}" +if [[ -z "$mode" ]]; then + usage + exit 2 +fi + +rpc() { + curl -sS "$RPC" -H 'Content-Type: application/json' -d "$1" +} + +import_concern() { + local file="$1" + if ! command -v opencoat >/dev/null 2>&1; then + echo "skip import ($file): opencoat CLI not on PATH" >&2 + return 0 + fi + opencoat concern import "$file" || true +} + +submit_queue() { + local prompt="$1" + local summary_line="$2" + local id="$3" + # Keyword matcher reads text/raw_text (see pointcut/_text.py); mirror bridge queuePayload JSON. + local prompt_json + prompt_json=$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$prompt") + local summary_json + summary_json=$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$summary_line") + rpc "$(cat < Concern: + return Concern( + id=concern_id, + name=f"queue test {concern_id}", + pointcuts=[ + PointcutDef( + id="pc-queue", + joinpoints=["queue.before_enqueue"], + match=PointcutMatch(any_keywords=[keyword]), + ) + ], + advices=[ + AopAdvice( + id="adv-queue", + kind=AdviceKind.BEFORE, + pointcut_ref="pc-queue", + content=content, + template=AdviceType.MEMORY_WRITE_GUARD + if mode == WeavingOperation.BLOCK + else AdviceType.REWRITE_GUIDANCE, + effect=WeavingPolicy( + mode=mode, + level=WeavingLevel.OUTPUT_LEVEL, + target=target, + priority=0.9, + ), + ) + ], + ) + + +def _make_loop() -> tuple[JoinpointPipeline, MemoryConcernStore]: + cfg = RuntimeConfig() + store = MemoryConcernStore() + loop = JoinpointPipeline( + config=cfg, + concern_store=store, + dcn_store=MemoryDCNStore(), + matcher=PointcutMatcher(), + coordinator=ConcernCoordinator(budgets=cfg.budgets), + weaver=ConcernWeaver(budgets=cfg.budgets), + advice_plugin=AdviceGenerator(llm=StubLLMClient()), + ) + return loop, store + + +def _queue_joinpoint(prompt: str) -> JoinpointEvent: + return JoinpointEvent( + id="jp-queue-test", + level=1, + name="queue.before_enqueue", + host="openclaw", + agent_session_id="sess-queue", + host_round_id="run-queue", + ts=datetime.now(UTC), + payload={ + "stage": "before_enqueue", + "prompt": prompt, + "summary_line": "user follow-up", + "text": prompt, + "raw_text": prompt, + }, + ) + + +def test_queue_block_advice_woven() -> None: + loop, store = _make_loop() + store.upsert( + _queue_concern( + "oc.test.queue-block", + mode=WeavingOperation.BLOCK, + target="queue.prompt", + keyword="QUEUE_DOGFOOD_BLOCK", + content="blocked by test", + ), + ) + + injection = loop.run(_queue_joinpoint("QUEUE_DOGFOOD_BLOCK enqueue me")) + assert injection is not None + assert any( + row.mode == "block" and row.target == "queue.prompt" for row in injection.injections + ) + + +def test_queue_prompt_rewrite_woven() -> None: + loop, store = _make_loop() + store.upsert( + _queue_concern( + "oc.test.queue-prompt", + mode=WeavingOperation.REWRITE, + target="queue.prompt", + keyword="QUEUE_DOGFOOD_REWRITE_PROMPT", + content="rewritten prompt", + ), + ) + + injection = loop.run( + _queue_joinpoint("QUEUE_DOGFOOD_REWRITE_PROMPT please queue"), + ) + assert injection is not None + assert any( + row.mode == "rewrite" + and row.target == "queue.prompt" + and "rewritten prompt" in row.content + for row in injection.injections + ) + + +def test_queue_summary_rewrite_woven() -> None: + loop, store = _make_loop() + store.upsert( + _queue_concern( + "oc.test.queue-summary", + mode=WeavingOperation.REWRITE, + target="queue.summary_line", + keyword="QUEUE_DOGFOOD_REWRITE_SUMMARY", + content="rewritten summary", + ), + ) + + injection = loop.run( + _queue_joinpoint("QUEUE_DOGFOOD_REWRITE_SUMMARY tweak"), + ) + assert injection is not None + assert any( + row.mode == "rewrite" and row.target == "queue.summary_line" + for row in injection.injections + ) diff --git a/scripts/check-openclaw-fork.sh b/scripts/check-openclaw-fork.sh new file mode 100755 index 0000000..f97fb5a --- /dev/null +++ b/scripts/check-openclaw-fork.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Verify global `openclaw` + gateway service are 1:1 with ~/openclaw-fork. +set -euo pipefail + +FORK_ROOT="${OPENCLAW_FORK_ROOT:-$HOME/openclaw-fork}" +LOCK="${HOME}/.openclaw/openclaw-fork.json" +FAIL=0 + +say_ok() { printf ' ok %s\n' "$1"; } +say_bad() { printf ' FAIL %s\n' "$1"; FAIL=1; } + +echo "OpenClaw fork alignment (expected root: ${FORK_ROOT})" + +if [[ ! -d "$FORK_ROOT/.git" ]]; then + say_bad "fork repo missing: $FORK_ROOT (.git)" +else + commit="$(git -C "$FORK_ROOT" rev-parse --short HEAD 2>/dev/null || echo '?')" + branch="$(git -C "$FORK_ROOT" branch --show-current 2>/dev/null || echo '?')" + say_ok "fork git ${branch} @ ${commit}" +fi + +if [[ ! -f "$FORK_ROOT/openclaw.mjs" || ! -f "$FORK_ROOT/dist/index.js" ]]; then + say_bad "fork not built — run: cd $FORK_ROOT && pnpm install && pnpm build" +else + fork_ver="$(node "$FORK_ROOT/openclaw.mjs" --version 2>/dev/null || true)" + say_ok "fork CLI: ${fork_ver:-unknown}" +fi + +if ! command -v openclaw >/dev/null; then + say_bad "openclaw not on PATH" +else + cli_path="$(command -v openclaw)" + cli_ver="$(openclaw --version 2>/dev/null || true)" + say_ok "PATH openclaw: ${cli_ver:-?} (${cli_path})" +fi + +if command -v npm >/dev/null; then + npm_pkg="$(npm root -g 2>/dev/null)/openclaw" + if [[ -e "$npm_pkg" ]]; then + if [[ -L "$npm_pkg" ]]; then + resolved="$(cd "$npm_pkg" 2>/dev/null && pwd -P || true)" + if [[ "$resolved" == "$FORK_ROOT" ]]; then + say_ok "npm -g openclaw resolves to fork (${resolved})" + else + say_bad "npm -g openclaw resolves to ${resolved:-?}, want ${FORK_ROOT}" + fi + else + say_bad "npm -g openclaw is a copy, not symlink — run: ./scripts/use-openclaw-fork.sh" + fi + else + say_bad "npm -g openclaw not installed — run: ./scripts/use-openclaw-fork.sh" + fi +fi + +if command -v openclaw >/dev/null; then + gw_cmd="$(openclaw gateway status 2>/dev/null | awk -F': ' '/^Command:/{print $2; exit}' || true)" + if [[ "$gw_cmd" == *"$FORK_ROOT/dist/index.js"* ]]; then + say_ok "gateway service uses fork dist" + elif [[ -n "$gw_cmd" ]]; then + say_bad "gateway not on fork: ${gw_cmd}" + else + say_bad "gateway status unavailable — is LaunchAgent running?" + fi + cli_gw="$(openclaw gateway status 2>/dev/null | awk -F': ' '/^CLI version:|^Gateway version:/{print $2}' | tr '\n' ' ' || true)" + if [[ -n "$cli_gw" ]]; then + say_ok "versions: ${cli_gw}" + fi +fi + +if [[ -f "$LOCK" ]]; then + say_ok "lock file: $LOCK" +else + say_bad "missing lock file — run: ./scripts/use-openclaw-fork.sh" +fi + +if [[ "$FAIL" -eq 0 ]]; then + echo "All checks passed — global openclaw is 1:1 with fork." +else + echo "Fix with: ./scripts/use-openclaw-fork.sh [--update] [--build]" >&2 + exit 1 +fi diff --git a/scripts/use-openclaw-fork.sh b/scripts/use-openclaw-fork.sh new file mode 100755 index 0000000..e49a770 --- /dev/null +++ b/scripts/use-openclaw-fork.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# Default OpenClaw = HyperdustLabs fork (opencoat/hooks-v0.1) at ~/openclaw-fork. +# Global npm install and gateway LaunchAgent must be 1:1 with that tree. +# +# Usage: +# ./scripts/use-openclaw-fork.sh # bind CLI + gateway to existing fork +# ./scripts/use-openclaw-fork.sh --clone # git clone fork first +# ./scripts/use-openclaw-fork.sh --update # git pull + pnpm install + rebuild +# ./scripts/use-openclaw-fork.sh --build # pnpm build only (no git pull) +# ./scripts/use-openclaw-fork.sh --check # verify 1:1 (same as check-openclaw-fork.sh) +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +FORK_ROOT="${OPENCLAW_FORK_ROOT:-$HOME/openclaw-fork}" +FORK_REPO="${OPENCLAW_FORK_REPO:-https://github.com/HyperdustLabs/openclaw.git}" +FORK_BRANCH="${OPENCLAW_FORK_BRANCH:-opencoat/hooks-v0.1}" +LOCAL_BIN="${HOME}/.local/bin" +SHIM="${LOCAL_BIN}/openclaw-fork" +LOCK="${HOME}/.openclaw/openclaw-fork.json" + +clone=false +update=false +build=false +check=false +for arg in "$@"; do + case "$arg" in + --clone) clone=true ;; + --update) update=true; build=true ;; + --build) build=true ;; + --check) check=true ;; + -h|--help) + sed -n '2,12p' "$0" + exit 0 + ;; + *) echo "unknown arg: $arg" >&2; exit 2 ;; + esac +done + +if [[ "$check" == true ]]; then + exec "${ROOT}/scripts/check-openclaw-fork.sh" +fi + +if [[ "$clone" == true && ! -d "$FORK_ROOT/.git" ]]; then + git clone --branch "$FORK_BRANCH" "$FORK_REPO" "$FORK_ROOT" +fi + +if [[ ! -d "$FORK_ROOT" ]]; then + echo "missing $FORK_ROOT — run with --clone or set OPENCLAW_FORK_ROOT" >&2 + exit 1 +fi + +if [[ "$update" == true ]]; then + git -C "$FORK_ROOT" fetch origin "$FORK_BRANCH" + git -C "$FORK_ROOT" checkout "$FORK_BRANCH" + git -C "$FORK_ROOT" pull --ff-only origin "$FORK_BRANCH" || true +fi + +if [[ ! -d "$FORK_ROOT/node_modules" ]] || [[ "$update" == true ]]; then + (cd "$FORK_ROOT" && (command -v pnpm >/dev/null && pnpm install || npm install)) +fi + +if [[ "$build" == true ]] || [[ ! -f "$FORK_ROOT/dist/index.js" ]]; then + echo "building openclaw-fork..." + (cd "$FORK_ROOT" && (command -v pnpm >/dev/null && pnpm build || npm run build)) +fi + +if [[ ! -f "$FORK_ROOT/dist/index.js" ]]; then + echo "build failed — dist/index.js missing under $FORK_ROOT" >&2 + exit 1 +fi + +# Remove registry/global copies that are not the fork (avoid version drift). +if command -v npm >/dev/null; then + npm_global="$(npm root -g 2>/dev/null)/openclaw" + if [[ -e "$npm_global" && ! -L "$npm_global" ]]; then + echo "removing non-symlink npm global openclaw (registry copy)..." + npm uninstall -g openclaw 2>/dev/null || true + fi + echo "linking npm -g openclaw -> $FORK_ROOT" + (cd "$FORK_ROOT" && npm install -g .) +fi + +mkdir -p "$LOCAL_BIN" "${HOME}/.openclaw" +cat >"$SHIM" </dev/null; then + rm -f "${LOCAL_BIN}/openclaw" +fi +ln -sf "$SHIM" "${LOCAL_BIN}/openclaw" + +commit="$(git -C "$FORK_ROOT" rev-parse HEAD 2>/dev/null || echo unknown)" +commit_short="$(git -C "$FORK_ROOT" rev-parse --short HEAD 2>/dev/null || echo unknown)" +version="$(node "$FORK_ROOT/openclaw.mjs" --version 2>/dev/null || echo unknown)" +installed_at="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + +python3 - "$LOCK" </dev/null; then + openclaw config validate + openclaw gateway install --force + openclaw daemon restart || true +fi + +echo "" +echo "OpenClaw fork is now the default (1:1 with ${FORK_ROOT})" +echo " version: ${version}" +echo " commit: ${commit_short} (${FORK_BRANCH})" +echo " lock: ${LOCK}" +echo "" +"${ROOT}/scripts/check-openclaw-fork.sh" From ef98fc13a4e3f9065d6f8a6f4af80dad91fb217b Mon Sep 17 00:00:00 2001 From: moss Date: Thu, 21 May 2026 23:33:12 +0700 Subject: [PATCH 3/4] feat(bridge): emit observe joinpoints from agent events Wire command.output_stream, patch.summary_created, and error.detected via runtime observers; add live queue rewrite dogfood scripts and docs. Co-authored-by: Cursor --- .../opencoat-openclaw-joinpoint-model-v0.1.md | 9 +- examples/09_queue_hook_dogfood/README.md | 25 ++- .../scripts/live-queue-rewrite-test.sh | 180 ++++++++++++++++++ .../openclaw-opencoat-bridge/README.md | 2 +- .../src/runtime-observers.test.ts | 27 +++ .../src/runtime-observers.ts | 16 ++ .../joinpoint/aliases.py | 2 + .../joinpoint/catalog.py | 10 + .../tests/core/test_joinpoint_aliases.py | 9 + 9 files changed, 270 insertions(+), 10 deletions(-) create mode 100755 examples/09_queue_hook_dogfood/scripts/live-queue-rewrite-test.sh diff --git a/docs/design/opencoat-openclaw-joinpoint-model-v0.1.md b/docs/design/opencoat-openclaw-joinpoint-model-v0.1.md index 1c2d7b8..475c48b 100644 --- a/docs/design/opencoat-openclaw-joinpoint-model-v0.1.md +++ b/docs/design/opencoat-openclaw-joinpoint-model-v0.1.md @@ -392,7 +392,7 @@ error.detected | `response.before_final` | partial | `message_sending` cancel path | cancel outbound only | | `verification.after_fail` | no | — | — | | `heartbeat.before_run` | no | OpenCOAT `runtime_tick` / future hook | — | -| `error.detected` | partial | `on_error` lifecycle alias via agent error events | no | +| `error.detected` | yes (observe) | `onAgentEvent` lifecycle `error` | no | Default: `runtimeObservers: true`, `observerPollMs: 500`. Full hook table: [bridge README](../../integrations/openclaw-opencoat-bridge/README.md). @@ -430,8 +430,8 @@ C. Not direct — needs OpenClaw middleware/hook PR, or is OpenCOAT-internal | Agent event stream | `stream: compaction` start/end | memory JPs | observer | observe | | Agent event stream | `stream: lifecycle` start | `reply_run.before_begin` | observer | observe | | Agent event stream | `stream: tool` / `item` / `assistant` after start | `reply_run.phase.running` | observer | observe | -| Agent event stream | `stream: command_output` | `command.output_stream` | not wired | observe (future) | -| Agent event stream | `stream: patch` | `patch.summary_created` | not wired | observe (future) | +| Agent event stream | `stream: command_output` | `command.output_stream` | yes (observe) | no | +| Agent event stream | `stream: patch` | `patch.summary_created` | yes (observe) | no | | Task API | `runtime.tasks.runs.bindSession().list()` | `task.*` | poll diff | observe | | Task hooks | `subagent_*` | `task.after_create`, `task.before_terminal` | yes | observe / spawn veto | | Input | `message_received`, `inbound_claim`, `before_dispatch` | `input.received` | yes | observe / extract | @@ -499,7 +499,8 @@ Upstream “neurosurgery” (recommended order): | Wave | Joinpoints | Mechanism | | --- | --- | --- | | **Shipped (bridge)** | §4.1 MVP rows marked “yes” | plugin hooks + `runtime-observers.ts` | -| **Next (observe)** | `command.output_stream`, `patch.summary_created`, streaming deltas | extend `onAgentEvent` mapping in bridge (use catalog names, not `command.output`) | +| **Shipped (observe)** | `command.output_stream`, `patch.summary_created`, `error.detected` | `onAgentEvent` mapping in bridge `runtime-observers.ts` | +| **Next (observe)** | streaming deltas | extend `onAgentEvent` / outbound callbacks | | **Shipped (fork + bridge)** | `queue.before_enqueue` / `queue.after_enqueue` sync veto + observe | OpenClaw `queue_before_enqueue` / `queue_after_enqueue` + bridge `queue_guard` | | **Next (upstream)** | `reply_run.phase.*`, `response.before_final`, `tool.result.before_emit` | OpenClaw plugin hooks at call sites | | **OpenCOAT-only** | `span.*`, `token.*`, message children | discovery on prompt payload | diff --git a/examples/09_queue_hook_dogfood/README.md b/examples/09_queue_hook_dogfood/README.md index ddd3d0e..bb817c7 100644 --- a/examples/09_queue_hook_dogfood/README.md +++ b/examples/09_queue_hook_dogfood/README.md @@ -18,7 +18,8 @@ examples/09_queue_hook_dogfood/ │ └── oc.dogfood.queue-summary-rewrite.json └── scripts/ ├── smoke-rpc.sh # daemon-only smoke (no gateway) - └── live-queue-block-test.sh # live block via gateway chat.send + ├── live-queue-block-test.sh # live block via gateway chat.send + └── live-queue-rewrite-test.sh # live prompt/summary rewrite (chat.send) ``` ## Prerequisites @@ -95,14 +96,28 @@ fresh DCN row for `oc.dogfood.queue-block`. Do **not** use two sequential `openclaw agent` calls for overlap — each call blocks until the turn finishes, so the queue hook never fires. -**Prompt rewrite** +**Prompt / summary rewrite** (automated) + +Import **one** rewrite concern (disable block first if it shares the joinpoint): + +```bash +chmod +x examples/09_queue_hook_dogfood/scripts/live-queue-rewrite-test.sh +./examples/09_queue_hook_dogfood/scripts/live-queue-rewrite-test.sh prompt +# or: +./examples/09_queue_hook_dogfood/scripts/live-queue-rewrite-test.sh summary +``` + +**Pass:** gateway log shows `queue_before_enqueue→…: oc.dogfood.queue-prompt-rewrite` +(or `…-summary-rewrite`) and DCN activation for that concern. Confirm rewritten +prompt/summary in queue drain behaviour manually if needed. + +**Prompt rewrite** (manual) 1. Remove/disable block concern; import `oc.dogfood.queue-prompt-rewrite.json` 2. Active run + send: `QUEUE_DOGFOOD_REWRITE_PROMPT — tighten scope to Y` -3. **Pass:** follow-up is queued with rewritten prompt (check gateway debug or - queue drain behaviour — model should see the rewritten text, not the raw line) +3. **Pass:** follow-up is queued with rewritten prompt (model should see rewritten text) -**Summary line rewrite** +**Summary line rewrite** (manual) 1. Import `oc.dogfood.queue-summary-rewrite.json` 2. Active run + send: `QUEUE_DOGFOOD_REWRITE_SUMMARY — minor tweak` diff --git a/examples/09_queue_hook_dogfood/scripts/live-queue-rewrite-test.sh b/examples/09_queue_hook_dogfood/scripts/live-queue-rewrite-test.sh new file mode 100755 index 0000000..fb70699 --- /dev/null +++ b/examples/09_queue_hook_dogfood/scripts/live-queue-rewrite-test.sh @@ -0,0 +1,180 @@ +#!/usr/bin/env bash +# Live dogfood: active run + queue prompt/summary rewrite via gateway chat.send. +set -euo pipefail + +MODE="${1:-}" +if [[ "$MODE" != "prompt" && "$MODE" != "summary" ]]; then + echo "Usage: $0 " >&2 + exit 2 +fi + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +RPC="${OPENCOAT_RPC:-http://127.0.0.1:7878/rpc}" + +SESSION_ID="${OPENCLAW_SESSION_ID:-ad9fe8a0-c144-4553-83d2-6868821ad452}" +WAIT_ACTIVE_SEC="${WAIT_ACTIVE_SEC:-8}" +NONCE="${DOGFOOD_NONCE:-$(date +%s)}" +MSG1="${MSG1:-DOGFOOD_RUN_${NONCE}: Read /Users/moss/OpenCOAT/docs/design/opencoat-openclaw-joinpoint-model-v0.1.md in five chunks (lines 1-150, 151-300, 301-450, 451-600, 601-end) using read tools only. After EACH chunk output exactly 5 bullets tagged CHUNK-N-${NONCE}. Do not finish until all five chunks are done.}" + +if [[ "$MODE" == "prompt" ]]; then + CONCERN_FILE="$SCRIPT_DIR/../concerns/oc.dogfood.queue-prompt-rewrite.json" + CONCERN_ID="oc.dogfood.queue-prompt-rewrite" + MSG2="${MSG2:-QUEUE_DOGFOOD_REWRITE_PROMPT — tighten scope: keep the answer under 200 words.}" +else + CONCERN_FILE="$SCRIPT_DIR/../concerns/oc.dogfood.queue-summary-rewrite.json" + CONCERN_ID="oc.dogfood.queue-summary-rewrite" + MSG2="${MSG2:-QUEUE_DOGFOOD_REWRITE_SUMMARY — minor tweak only.}" +fi + +default_gateway_log() { + local today + today="$(date +%F)" + if [[ -f "/tmp/openclaw/openclaw-${today}.log" ]]; then + echo "/tmp/openclaw/openclaw-${today}.log" + elif [[ -f "${HOME}/.openclaw/logs/gateway.log" ]]; then + echo "${HOME}/.openclaw/logs/gateway.log" + else + echo "/tmp/openclaw/openclaw-${today}.log" + fi +} + +LOG="${OPENCLAW_GATEWAY_LOG:-$(default_gateway_log)}" +TEST_START_ISO="$(date -u +%Y-%m-%dT%H:%M:%S)" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +resolve_session_key() { + local session_id="$1" + openclaw sessions list --json 2>/dev/null | python3 -c ' +import json +import sys + +target = sys.argv[1] +raw = sys.stdin.read().strip() +if not raw: + sys.exit(1) +data = json.loads(raw) +rows = data if isinstance(data, list) else data.get("sessions", []) +for row in rows: + if not isinstance(row, dict): + continue + sid = row.get("sessionId") or row.get("id") + if sid == target: + key = row.get("key") or row.get("sessionKey") + if key: + print(key) + sys.exit(0) +sys.exit(1) +' "$session_id" +} + +command -v openclaw >/dev/null || fail "missing openclaw" +command -v curl >/dev/null || fail "missing curl" +command -v python3 >/dev/null || fail "missing python3" +command -v opencoat >/dev/null || fail "missing opencoat" +[[ -f "$LOG" ]] || fail "gateway log not found: $LOG" + +SESSION_KEY="${OPENCLAW_SESSION_KEY:-}" +if [[ -z "$SESSION_KEY" ]]; then + SESSION_KEY="$(resolve_session_key "$SESSION_ID")" || fail "no sessionKey for sessionId=$SESSION_ID" +fi + +log_start="$(wc -l < "$LOG" | tr -d ' ')" + +echo "== live queue ${MODE} rewrite dogfood ==" +echo "concern_id=$CONCERN_ID" +echo "session_key=$SESSION_KEY" +echo "gateway_log=$LOG (baseline lines=$log_start)" + +echo "== import ${CONCERN_ID} ==" +opencoat concern import "$CONCERN_FILE" + +PARAMS_DIR="${TMPDIR:-/tmp}/opencoat-queue-dogfood-$$" +mkdir -p "$PARAMS_DIR" +trap 'rm -rf "$PARAMS_DIR"' EXIT + +python3 - "$PARAMS_DIR" "$SESSION_KEY" "$SESSION_ID" "$MSG1" "$MSG2" "$NONCE" "$MODE" <<'PY' +import json +import sys +from pathlib import Path + +out_dir = Path(sys.argv[1]) +session_key, session_id = sys.argv[2], sys.argv[3] +msg1, msg2, nonce, mode = sys.argv[4], sys.argv[5], sys.argv[6], sys.argv[7] + +def write(name, payload): + (out_dir / name).write_text(json.dumps(payload), encoding="utf-8") + +write( + "msg1.json", + { + "sessionKey": session_key, + "sessionId": session_id, + "message": msg1, + "deliver": False, + "idempotencyKey": f"dogfood-run-{nonce}", + }, +) +write( + "msg2.json", + { + "sessionKey": session_key, + "sessionId": session_id, + "message": msg2, + "deliver": False, + "idempotencyKey": f"dogfood-{mode}-{nonce}", + }, +) +PY + +openclaw gateway call chat.send --json --timeout 60000 \ + --params "$(cat "$PARAMS_DIR/msg1.json")" | tee "$PARAMS_DIR/msg1-response.json" + +echo "== wait ${WAIT_ACTIVE_SEC}s then rewrite trigger ==" +sleep "$WAIT_ACTIVE_SEC" + +openclaw gateway call chat.send --json --timeout 60000 \ + --params "$(cat "$PARAMS_DIR/msg2.json")" | tee "$PARAMS_DIR/msg2-response.json" + +sleep 2 + +log_slice="$(mktemp)" +tail -n +"$((log_start + 1))" "$LOG" >"$log_slice" || true +hook_ok=false +if grep -E "queue_before_enqueue→queue\\.before_enqueue: ${CONCERN_ID}|queue_before_enqueue.*${CONCERN_ID}" "$log_slice" | tail -5; then + hook_ok=true +else + echo "(no queue_before_enqueue line for ${CONCERN_ID})" + grep -iE 'queue_before_enqueue' "$log_slice" | tail -10 || true +fi +rm -f "$log_slice" + +dcn_json="$(curl -sS "$RPC" -H 'Content-Type: application/json' \ + -d "{\"jsonrpc\":\"2.0\",\"method\":\"dcn.activation_log\",\"params\":{\"concern_id\":\"${CONCERN_ID}\",\"limit\":8},\"id\":1}")" + +dcn_ok=false +if printf '%s' "$dcn_json" | python3 -c ' +import json, sys +from datetime import datetime, timezone +cutoff = datetime.fromisoformat(sys.argv[1].replace("Z", "+00:00")) +if cutoff.tzinfo is None: + cutoff = cutoff.replace(tzinfo=timezone.utc) +cutoff = cutoff.timestamp() - 30 +for row in json.load(sys.stdin).get("result") or []: + try: + dt = datetime.fromisoformat(row["ts"].replace("Z", "+00:00")) + if dt.timestamp() >= cutoff: + sys.exit(0) + except (KeyError, ValueError): + pass +sys.exit(1) +' "$TEST_START_ISO"; then + dcn_ok=true +fi + +[[ "$hook_ok" == true ]] || fail "gateway log missing queue_before_enqueue→${CONCERN_ID}" +echo "PASS: gateway saw queue_before_enqueue for ${CONCERN_ID}" +[[ "$dcn_ok" == true ]] && echo "PASS: DCN activation during run" || echo "WARN: no fresh DCN row (check daemon)" diff --git a/integrations/openclaw-opencoat-bridge/README.md b/integrations/openclaw-opencoat-bridge/README.md index 06ba586..02fbc17 100644 --- a/integrations/openclaw-opencoat-bridge/README.md +++ b/integrations/openclaw-opencoat-bridge/README.md @@ -47,7 +47,7 @@ that are **not** `api.on` plugin hooks: | Source | Joinpoints emitted | Notes | | --- | --- | --- | -| `api.runtime.events.onAgentEvent` | `reply_run.before_begin`, `reply_run.phase.running`, `planning.plan_updated`, `approval.requested`, compaction → memory JPs | Lifecycle `start` ≈ run begin; first assistant/tool/item after start ≈ `running` | +| `api.runtime.events.onAgentEvent` | `reply_run.before_begin`, `reply_run.phase.running`, `planning.plan_updated`, `approval.requested`, `command.output_stream`, `patch.summary_created`, `error.detected` (lifecycle `error`), compaction → memory JPs | Lifecycle `start` ≈ run begin; first assistant/tool/item after start ≈ `running` | | `api.registerHook` `session:compact:*` | `before_memory_write` / `after_memory_write` | Same boundary as plugin compaction hooks | | Poll `getFollowupQueueDepth` (host dist) | `queue.before_enqueue`, `queue.before_collect` | Fallback for OpenClaw builds without native queue hooks; depth diff per tracked `sessionKey` | | Poll `api.runtime.tasks.runs.bindSession().list()` | `task.before_create`, `task.after_create`, `task.before_terminal` | First sight + status transitions (incl. non-subagent `createTaskRecord`) | diff --git a/integrations/openclaw-opencoat-bridge/src/runtime-observers.test.ts b/integrations/openclaw-opencoat-bridge/src/runtime-observers.test.ts index 7c1bda2..11504a5 100644 --- a/integrations/openclaw-opencoat-bridge/src/runtime-observers.test.ts +++ b/integrations/openclaw-opencoat-bridge/src/runtime-observers.test.ts @@ -28,6 +28,33 @@ describe("agentEventJoinpoint", () => { }); assert.equal(mapped?.name, "planning.plan_updated"); }); + + it("maps lifecycle error to error.detected", () => { + const mapped = agentEventJoinpoint({ + runId: "r1", + stream: "lifecycle", + data: { phase: "error", error: "boom" }, + }); + assert.equal(mapped?.name, "error.detected"); + }); + + it("maps command_output stream to command.output_stream", () => { + const mapped = agentEventJoinpoint({ + runId: "r1", + stream: "command_output", + data: { phase: "delta", output: "line 1" }, + }); + assert.equal(mapped?.name, "command.output_stream"); + }); + + it("maps patch stream to patch.summary_created", () => { + const mapped = agentEventJoinpoint({ + runId: "r1", + stream: "patch", + data: { phase: "end", summary: "2 files changed" }, + }); + assert.equal(mapped?.name, "patch.summary_created"); + }); }); describe("agentEventRunningJoinpoint", () => { diff --git a/integrations/openclaw-opencoat-bridge/src/runtime-observers.ts b/integrations/openclaw-opencoat-bridge/src/runtime-observers.ts index 4f7e113..c79f0a9 100644 --- a/integrations/openclaw-opencoat-bridge/src/runtime-observers.ts +++ b/integrations/openclaw-opencoat-bridge/src/runtime-observers.ts @@ -57,8 +57,24 @@ export function agentEventJoinpoint( payload: { phase, run_id: evt.runId, ...data }, }; } + if (phase === "error") { + return { + name: "error.detected", + payload: { phase, run_id: evt.runId, ...data }, + }; + } return null; } + case "command_output": + return { + name: "command.output_stream", + payload: { run_id: evt.runId, stream: evt.stream, ...data }, + }; + case "patch": + return { + name: "patch.summary_created", + payload: { run_id: evt.runId, stream: evt.stream, ...data }, + }; case "plan": return { name: "planning.plan_updated", diff --git a/packages/opencoat-runtime/opencoat_runtime_core/joinpoint/aliases.py b/packages/opencoat-runtime/opencoat_runtime_core/joinpoint/aliases.py index 2df7cfa..20a8369 100644 --- a/packages/opencoat-runtime/opencoat_runtime_core/joinpoint/aliases.py +++ b/packages/opencoat-runtime/opencoat_runtime_core/joinpoint/aliases.py @@ -21,6 +21,8 @@ "planning.before_start": "before_planning", "planning.after_start": "after_planning", "planning.plan_updated": "after_planning", + "command.output_stream": "command.output_stream", + "patch.summary_created": "patch.summary_created", # Tool "tool.before_call": "before_tool_call", "tool.after_execute": "after_tool_call", diff --git a/packages/opencoat-runtime/opencoat_runtime_core/joinpoint/catalog.py b/packages/opencoat-runtime/opencoat_runtime_core/joinpoint/catalog.py index e6449af..9e64e7b 100644 --- a/packages/opencoat-runtime/opencoat_runtime_core/joinpoint/catalog.py +++ b/packages/opencoat-runtime/opencoat_runtime_core/joinpoint/catalog.py @@ -132,6 +132,16 @@ class CatalogEntry: JoinpointLevel.LIFECYCLE, "Concern verifier fail path (not emitted by bridge yet)", ), + CatalogEntry( + "command.output_stream", + JoinpointLevel.LIFECYCLE, + "Bridge: onAgentEvent command_output stream", + ), + CatalogEntry( + "patch.summary_created", + JoinpointLevel.LIFECYCLE, + "Bridge: onAgentEvent patch stream (summary)", + ), ) # v0.1 dotted aliases registered for inspect / pointcut validation (matcher resolves) diff --git a/packages/opencoat-runtime/tests/core/test_joinpoint_aliases.py b/packages/opencoat-runtime/tests/core/test_joinpoint_aliases.py index 65104cd..0425f17 100644 --- a/packages/opencoat-runtime/tests/core/test_joinpoint_aliases.py +++ b/packages/opencoat-runtime/tests/core/test_joinpoint_aliases.py @@ -54,3 +54,12 @@ def test_queue_joinpoints_in_catalog() -> None: assert "queue.before_enqueue" in JOINPOINT_CATALOG assert "queue.after_enqueue" in JOINPOINT_CATALOG assert canonical_joinpoint_name("queue.before_enqueue") == "queue.before_enqueue" + + +def test_observe_wave_joinpoints_in_catalog() -> None: + for name in ( + "command.output_stream", + "patch.summary_created", + "error.detected", + ): + assert name in JOINPOINT_CATALOG, name From 89256b1292ea8cead870f0a12f505f5fc4adf958 Mon Sep 17 00:00:00 2001 From: moss Date: Thu, 21 May 2026 23:51:15 +0700 Subject: [PATCH 4/4] chore: bridge-only plan wrap-up for queue hooks PR Fix ruff format on queue weave tests; document decision vs observe paths; add fork hook backlog; bind before_agent_run observe; expand bridge checklist. Co-authored-by: Cursor --- .../opencoat-openclaw-joinpoint-model-v0.1.md | 2 +- docs/guides/concern-authoring-aop.md | 13 ++++++++++++- docs/guides/openclaw-fork-dev.md | 14 ++++++++++++++ integrations/openclaw-opencoat-bridge/README.md | 14 +++++++++++++- .../src/hook-bindings.test.ts | 2 +- .../openclaw-opencoat-bridge/src/hook-bindings.ts | 1 + .../tests/core/test_queue_joinpoint_weave.py | 11 +++-------- 7 files changed, 45 insertions(+), 12 deletions(-) diff --git a/docs/design/opencoat-openclaw-joinpoint-model-v0.1.md b/docs/design/opencoat-openclaw-joinpoint-model-v0.1.md index 475c48b..8952b2c 100644 --- a/docs/design/opencoat-openclaw-joinpoint-model-v0.1.md +++ b/docs/design/opencoat-openclaw-joinpoint-model-v0.1.md @@ -659,7 +659,7 @@ Bridge module `runtime-observers.ts` — uses host APIs already available to plu | Source | Joinpoints | Mechanism | | --- | --- | --- | -| `api.runtime.events.onAgentEvent` | `reply_run.before_begin`, `reply_run.phase.running`, `planning.plan_updated`, `approval.requested`, compaction → `before_memory_write` / `after_memory_write` | event stream | +| `api.runtime.events.onAgentEvent` | `reply_run.before_begin`, `reply_run.phase.running`, `planning.plan_updated`, `approval.requested`, `command.output_stream`, `patch.summary_created`, `error.detected` (lifecycle `error`), compaction → `before_memory_write` / `after_memory_write` | event stream | | `api.registerHook` | `session:compact:before` / `after` → memory JPs | internal gateway hooks | | Host `getFollowupQueueDepth` | `queue.before_enqueue`, `queue.before_collect` | `registerService` poll per tracked `sessionKey` | | `api.runtime.tasks.runs.bindSession().list()` | `task.before_create`, `task.after_create`, `task.before_terminal` | task registry diff poll | diff --git a/docs/guides/concern-authoring-aop.md b/docs/guides/concern-authoring-aop.md index b4a6e22..255cf7e 100644 --- a/docs/guides/concern-authoring-aop.md +++ b/docs/guides/concern-authoring-aop.md @@ -37,12 +37,23 @@ legacy `pointcut` / `advice` / `weaving_policy` are optional and sync automatica ## Message-level guard (OpenClaw bridge) -The gateway bridge registers **28** plugin hooks plus **runtime observers** (`onAgentEvent`, +The gateway bridge registers **29** plugin hooks plus **runtime observers** (`onAgentEvent`, queue/task poll) for MVP joinpoints such as `queue.before_enqueue` (sync **block/rewrite** on OpenClaw fork via `queue_before_enqueue`) and `reply_run.before_begin` (observe-only — see [joinpoint model §4.1](../design/opencoat-openclaw-joinpoint-model-v0.1.md#41-mvp-emit-status-bridge-integrationsopenclaw-opencoat-bridge)). Dogfood concerns: [`examples/09_queue_hook_dogfood`](../examples/09_queue_hook_dogfood/README.md). +### Decision vs observe (OpenClaw fork + bridge) + +Use the **HyperdustLabs fork** (`opencoat/hooks-v0.1`, see [openclaw-fork-dev.md](openclaw-fork-dev.md)) — not npm registry OpenClaw — for native queue hooks and dogfood. + +| Class | Joinpoints (examples) | Host effect today | +| --- | --- | --- | +| **Decision** | `queue.before_enqueue`, `tool.before_call`, `subagent_spawning` → `task.before_create` | block, rewrite, spawn veto, prompt prepend, outbound cancel | +| **Observe** | `reply_run.*`, `planning.*`, `approval.requested`, `command.output_stream`, `patch.summary_created`, `error.detected`, queue poll fallback | DCN / activation only; no sync veto | + +**Next decision hooks** ship on the **same fork branch** (`tool_result_persist`, `reply_run.phase.*`, `response.before_final`, …) — not upstream `openclaw/openclaw`. See [fork hook backlog](openclaw-fork-dev.md#fork-hook-backlog-post-queue). + Prefer `user_message()` over flat `before_response` when the bridge sends `messages[]`: ```json diff --git a/docs/guides/openclaw-fork-dev.md b/docs/guides/openclaw-fork-dev.md index 0ab4d5b..ed7bbeb 100644 --- a/docs/guides/openclaw-fork-dev.md +++ b/docs/guides/openclaw-fork-dev.md @@ -73,6 +73,20 @@ openclaw gateway status # CLI version == Gateway version | Fork pulled but not rebuilt | `cd ~/openclaw-fork && pnpm build` then `./scripts/use-openclaw-fork.sh` | | Old BAIclaw shim on PATH | removed by `use-openclaw-fork.sh`; use fork shim only | +## Fork hook backlog (post-queue) + +After [PR #77](https://github.com/HyperdustLabs/OpenCOAT/pull/77) (queue `queue_before_enqueue` / `queue_after_enqueue` + bridge `queue_guard`) lands on `main`, plan **paired fork + OpenCOAT PRs** on `opencoat/hooks-v0.1`: + +| Priority | Fork hook / joinpoint | Notes | +| --- | --- | --- | +| 1 | `tool_result_persist` | Fork hook is **sync-only** today; needs async or local policy cache before bridge can weave | +| 2 | `reply_run.phase.*` | Native hooks at `ReplyOperation` phase edges (not lifecycle approx) | +| 3 | `response.before_final` | Unified verifier before channel delivery (beyond `message_sending` cancel) | +| 4 | `memory.before_write` | Unified memory middleware (compaction hooks are observe-only today) | +| 5 | `queue.before_drain` | Wrap `scheduleFollowupDrain` | + +Bridge skipped (fork has hook, hot path): `before_message_write`, `tool_result_persist` until sync/async contract is extended. + ## Related - [OpenClaw bridge README](../../integrations/openclaw-opencoat-bridge/README.md) diff --git a/integrations/openclaw-opencoat-bridge/README.md b/integrations/openclaw-opencoat-bridge/README.md index 02fbc17..4c6ff44 100644 --- a/integrations/openclaw-opencoat-bridge/README.md +++ b/integrations/openclaw-opencoat-bridge/README.md @@ -10,7 +10,7 @@ the generated `opencoat_plugin/` folder. ## Hook → joinpoint mapping -The bridge registers **28** OpenClaw plugin hooks (`hook-bindings.ts`), including +The bridge registers **29** OpenClaw plugin hooks (`hook-bindings.ts`), including the OpenCOAT fork's native queue hooks. Skipped: `before_message_write`, `tool_result_persist` (sync hot path — cannot await daemon RPC), `before_install` (install-only). @@ -39,6 +39,7 @@ Skipped: `before_message_write`, `tool_result_persist` (sync hot path — cannot | `subagent_delivery_target` / `subagent_spawned` | `task.after_create` | submit | | `subagent_ended` | `task.before_terminal` | submit | | `before_model_resolve` | `before_reasoning` | submit | +| `before_agent_run` | `input.received` | submit (observe only; fork gate not overridden) | ### Runtime observers (no extra OpenClaw plugin hooks) @@ -102,6 +103,17 @@ not `@hyperdustlabs-opencoat-bridge`. Remove legacy `@hyperdust/*` entries. Set `daemonUrl` in plugin config (not `process.env` in the plugin — OpenClaw blocks env+network patterns at install time). +### Pre-flight checklist (fork gateway) + +1. `./scripts/check-openclaw-fork.sh` — CLI and LaunchAgent use `~/openclaw-fork/dist` +2. `opencoat runtime up` — daemon on `127.0.0.1:7878` +3. `openclaw plugins install -l …/integrations/openclaw-opencoat-bridge` then `openclaw gateway restart` +4. In `~/.openclaw/openclaw.json`: `hooks.allowPromptInjection=true`, **`hooks.allowConversationAccess=true`** +5. Plugin entry `@hyperdustlabs/opencoat-bridge` with `daemonUrl` and optional `logActivations: true` +6. Queue dogfood: [`examples/09_queue_hook_dogfood`](../../examples/09_queue_hook_dogfood/README.md) + +If conversation-access hooks are skipped at startup, fix `allowConversationAccess` before debugging weave logic. + ## Verify 1. Chat in OpenClaw (Telegram / CLI) with text that matches your concern keywords, e.g. `Never run rm -rf in shell.` diff --git a/integrations/openclaw-opencoat-bridge/src/hook-bindings.test.ts b/integrations/openclaw-opencoat-bridge/src/hook-bindings.test.ts index e8f82ee..a8f845e 100644 --- a/integrations/openclaw-opencoat-bridge/src/hook-bindings.test.ts +++ b/integrations/openclaw-opencoat-bridge/src/hook-bindings.test.ts @@ -4,7 +4,7 @@ import { HOOK_BINDINGS, SKIPPED_HOOKS } from "./hook-bindings.js"; describe("hook-bindings", () => { it("registers async-safe plugin hooks plus native queue hooks", () => { - assert.equal(HOOK_BINDINGS.length, 28); + assert.equal(HOOK_BINDINGS.length, 29); assert.equal(SKIPPED_HOOKS.length, 3); }); diff --git a/integrations/openclaw-opencoat-bridge/src/hook-bindings.ts b/integrations/openclaw-opencoat-bridge/src/hook-bindings.ts index ff6fe77..56d3e07 100644 --- a/integrations/openclaw-opencoat-bridge/src/hook-bindings.ts +++ b/integrations/openclaw-opencoat-bridge/src/hook-bindings.ts @@ -48,6 +48,7 @@ export const HOOK_BINDINGS: HookBinding[] = [ { hook: "before_reset", joinpoint: "runtime_recovery", kind: "observe" }, // --- messages / dispatch --- + { hook: "before_agent_run", joinpoint: "input.received", kind: "observe" }, { hook: "inbound_claim", joinpoint: "on_user_input", kind: "observe" }, { hook: "before_dispatch", joinpoint: "on_user_input", kind: "observe" }, { hook: "reply_dispatch", joinpoint: "before_response", kind: "observe" }, diff --git a/packages/opencoat-runtime/tests/core/test_queue_joinpoint_weave.py b/packages/opencoat-runtime/tests/core/test_queue_joinpoint_weave.py index 862a906..2b6c1ad 100644 --- a/packages/opencoat-runtime/tests/core/test_queue_joinpoint_weave.py +++ b/packages/opencoat-runtime/tests/core/test_queue_joinpoint_weave.py @@ -112,9 +112,7 @@ def test_queue_block_advice_woven() -> None: injection = loop.run(_queue_joinpoint("QUEUE_DOGFOOD_BLOCK enqueue me")) assert injection is not None - assert any( - row.mode == "block" and row.target == "queue.prompt" for row in injection.injections - ) + assert any(row.mode == "block" and row.target == "queue.prompt" for row in injection.injections) def test_queue_prompt_rewrite_woven() -> None: @@ -134,9 +132,7 @@ def test_queue_prompt_rewrite_woven() -> None: ) assert injection is not None assert any( - row.mode == "rewrite" - and row.target == "queue.prompt" - and "rewritten prompt" in row.content + row.mode == "rewrite" and row.target == "queue.prompt" and "rewritten prompt" in row.content for row in injection.injections ) @@ -158,6 +154,5 @@ def test_queue_summary_rewrite_woven() -> None: ) assert injection is not None assert any( - row.mode == "rewrite" and row.target == "queue.summary_line" - for row in injection.injections + row.mode == "rewrite" and row.target == "queue.summary_line" for row in injection.injections )