Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion integrations/openclaw-opencoat-bridge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -350,9 +350,34 @@ Gateway log when enabled:

See [v0.3 §10.5](../../docs/design/v0.3-morphogenetic-architecture.md#105-实现分期-2026-05).

## `r_t` JSONL emission (v0.3 step 3 prototype)

When `emitRtJsonl` is enabled (default **on** if `inProcReflexToolGuard` is true), the bridge
fire-and-forgets structured outcome records to daemon `credit.r_t.append`:

| Hook | `r_t` signal |
| --- | --- |
| `before_tool_call` (in-proc deny) | `tool_blocked` + reflex metadata |
| `after_tool_call` | `tool_outcome` (links prior reflex decision when present) |
| `llm_output` | `llm_output` |
| `agent_end` | `turn_complete` |

Log file: `~/.opencoat/r_t.jsonl`. Each append runs warm-path **reweight** (v0.3 §3.6 subset): reflex `tool_blocked` / `deny` reinforces the matching concern (`policy_id`). Heartbeat also drains unread lines via `RtPlasticityWorker`. Inspect:

```bash
curl -sS http://127.0.0.1:7878/rpc -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","method":"credit.r_t.stats","params":{},"id":1}' | python3 -m json.tool
curl -sS http://127.0.0.1:7878/rpc -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","method":"credit.r_t.consume","params":{},"id":2}' | python3 -m json.tool
tail -3 ~/.opencoat/r_t.jsonl | python3 -m json.tool
opencoat concern show demo-tool-block
```

Requires daemon built from repo (includes `credit.r_t.append` / `credit.r_t.consume` RPCs).

## Limitations (v0.1 bridge)

- **v0.3 gap:** when `inProcReflexToolGuard` is off, guards are **collaborative** (daemon RPC, fail-open on bridge error). Enable in-proc mode for authoritative fail-closed `tool_guard` ([v0.3 §10.5](../../docs/design/v0.3-morphogenetic-architecture.md#105-实现分期-2026-05)).
- **v0.3 gap:** guards are **collaborative** (daemon RPC, fail-open on bridge error), not in-proc authoritative `ReflexMonitor` fail-closed ([v0.3 §10.5](../../docs/design/v0.3-morphogenetic-architecture.md#105-实现分期-2026-05)). **`r_t` JSONL** is available when `emitRtJsonl` is on (default with `inProcReflexToolGuard`) — see below.
- Prompt folding uses `prependSystemContext` only (not full dotted-path injector parity with Python `OpenClawInjector`).
- **`queue.before_enqueue`** sync veto/rewrite requires OpenClaw **fork** (`queue_before_enqueue` hook). Poll fallback in `runtime-observers.ts` is observe-only.
- Non-subagent **`task.before_create`** is observe-only (task poll); spawn veto works on `subagent_spawning` only.
Expand Down
62 changes: 61 additions & 1 deletion integrations/openclaw-opencoat-bridge/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ import {
import { createObserveEmitter } from "./emit-joinpoint.js";
import { loadReflexRuntime, buildReflexRuntime } from "./reflex-policy-sync.js";
import type { ReflexRuntime } from "./reflex-policy-sync.js";
import type { DecisionRecord } from "./reflex-monitor.js";
import {
appendRtRecordFireAndForget,
buildLlmOutputRt,
buildToolBlockedRt,
buildToolOutcomeRt,
buildTurnCompleteRt,
} from "./r-t-emit.js";
import {
buildReflexState,
buildToolCallAction,
Expand All @@ -57,6 +65,11 @@ import type {

const pendingByRun = new Map<string, ConcernInjection | null>();
const reflexState: { runtime: ReflexRuntime | null } = { runtime: null };
const lastReflexByRunTool = new Map<string, DecisionRecord>();

function reflexToolKey(run: string, toolName: string): string {
return `${run}:${toolName}`;
}

function auditToolGuardJoinpoint(
cfg: BridgeConfig,
Expand Down Expand Up @@ -271,6 +284,17 @@ async function handleHook(
decision.blockReason,
);
if (decision.block) {
appendRtRecordFireAndForget(
cfg,
buildToolBlockedRt(
binding.hook,
binding.joinpoint,
c,
toolName,
decision.record,
decision.blockReason,
),
);
return {
block: true,
blockReason:
Expand All @@ -279,6 +303,12 @@ async function handleHook(
params: decision.params,
};
}
if (decision.record) {
lastReflexByRunTool.set(
reflexToolKey(run, toolName),
decision.record,
);
}
return decision.params !== params ? { params: decision.params } : {};
} catch (err) {
return failClosedToolGuard(params, err);
Expand Down Expand Up @@ -331,6 +361,36 @@ async function handleHook(
await emit(cfg, api, binding.hook, binding.joinpoint, payload, c, {
level,
});
if (cfg.emitRtJsonl) {
const ev = asRecord(event);
if (binding.hook === "after_tool_call") {
const toolName =
typeof ev.toolName === "string" ? ev.toolName : "tool";
const key = reflexToolKey(run, toolName);
const reflex = lastReflexByRunTool.get(key);
lastReflexByRunTool.delete(key);
appendRtRecordFireAndForget(
cfg,
buildToolOutcomeRt(
binding.hook,
binding.joinpoint,
c,
ev,
reflex,
),
);
} else if (binding.hook === "llm_output") {
appendRtRecordFireAndForget(
cfg,
buildLlmOutputRt(binding.hook, binding.joinpoint, c, ev),
);
} else if (binding.hook === "agent_end") {
appendRtRecordFireAndForget(
cfg,
buildTurnCompleteRt(binding.hook, binding.joinpoint, c, ev),
);
}
}
return;
}
}
Expand Down Expand Up @@ -393,6 +453,6 @@ export default function register(api: BridgePluginApi): void {
cfg.enabled ? cfg.daemonUrl : "disabled"
}${observerNote}${
cfg.inProcReflexToolGuard ? "; in-proc ReflexMonitor tool_guard" : ""
})`,
}${cfg.emitRtJsonl ? "; r_t JSONL emit" : ""})`,
);
}
54 changes: 54 additions & 0 deletions integrations/openclaw-opencoat-bridge/src/r-t-emit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
buildToolBlockedRt,
buildToolOutcomeRt,
buildTurnCompleteRt,
} from "./r-t-emit.js";

describe("r_t record builders", () => {
const ctx = { runId: "run-1", sessionKey: "sk-1" };

it("builds tool_blocked with reflex metadata", () => {
const row = buildToolBlockedRt(
"before_tool_call",
"before_tool_call",
ctx,
"shell.exec",
{
turn_id: "run-1",
action_kind: "tool_call",
action_name: "shell.exec",
decision: "deny",
policy_id: "demo-tool-block",
},
"blocked",
);
assert.equal(row.event, "r_t");
assert.equal(row.r, 0);
assert.equal(row.signal.kind, "tool_blocked");
assert.equal(row.signal.reflex?.policy_id, "demo-tool-block");
});

it("builds tool_outcome success", () => {
const row = buildToolOutcomeRt(
"after_tool_call",
"after_tool_call",
ctx,
{ toolName: "read", durationMs: 12 },
);
assert.equal(row.r, 1);
assert.equal(row.signal.kind, "tool_outcome");
});

it("builds turn_complete", () => {
const row = buildTurnCompleteRt(
"agent_end",
"after_response",
ctx,
{},
);
assert.equal(row.signal.kind, "turn_complete");
assert.equal(row.r, 1);
});
});
196 changes: 196 additions & 0 deletions integrations/openclaw-opencoat-bridge/src/r-t-emit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import type { AgentHookCtx, BridgeConfig } from "./types.js";
import type { DecisionRecord } from "./reflex-monitor.js";

export type RtSignalKind =
| "tool_outcome"
| "tool_blocked"
| "llm_output"
| "turn_complete";

export type RtRecordWire = {
record_version: 1;
event: "r_t";
ts: string;
session_id: string;
turn_id: string;
joinpoint: string;
host: "openclaw";
hook: string;
signal: {
kind: RtSignalKind;
tool_name?: string;
blocked?: boolean;
error?: string;
duration_ms?: number;
reflex?: Record<string, unknown>;
payload?: Record<string, unknown>;
};
r: number;
baseline_b: number;
};

function sessionId(ctx: AgentHookCtx): string {
return ctx.sessionId ?? ctx.sessionKey ?? "default";
}

function turnId(ctx: AgentHookCtx): string {
return ctx.runId ?? ctx.sessionKey ?? "default";
}

export function buildToolBlockedRt(
hook: string,
joinpoint: string,
ctx: AgentHookCtx,
toolName: string,
reflex?: DecisionRecord,
reason?: string,
): RtRecordWire {
return {
record_version: 1,
event: "r_t",
ts: new Date().toISOString(),
session_id: sessionId(ctx),
turn_id: turnId(ctx),
joinpoint,
host: "openclaw",
hook,
signal: {
kind: "tool_blocked",
tool_name: toolName,
blocked: true,
error: reason,
reflex: reflex ? { ...reflex } : undefined,
},
r: 0,
baseline_b: 0,
};
}

export function buildToolOutcomeRt(
hook: string,
joinpoint: string,
ctx: AgentHookCtx,
event: Record<string, unknown>,
reflex?: DecisionRecord,
): RtRecordWire {
const toolName =
typeof event.toolName === "string" ? event.toolName : "tool";
const error = typeof event.error === "string" ? event.error : undefined;
const durationMs =
typeof event.durationMs === "number" ? event.durationMs : undefined;
const blocked = reflex?.decision === "deny";
const success = !error && !blocked;

return {
record_version: 1,
event: "r_t",
ts: new Date().toISOString(),
session_id: sessionId(ctx),
turn_id: turnId(ctx),
joinpoint,
host: "openclaw",
hook,
signal: {
kind: "tool_outcome",
tool_name: toolName,
blocked,
error,
duration_ms: durationMs,
reflex: reflex ? { ...reflex } : undefined,
payload: {
has_result: event.result !== undefined,
},
},
r: success ? 1 : 0,
baseline_b: 0,
};
}

export function buildLlmOutputRt(
hook: string,
joinpoint: string,
ctx: AgentHookCtx,
event: Record<string, unknown>,
): RtRecordWire {
return {
record_version: 1,
event: "r_t",
ts: new Date().toISOString(),
session_id: sessionId(ctx),
turn_id: turnId(ctx),
joinpoint,
host: "openclaw",
hook,
signal: {
kind: "llm_output",
payload: {
text_len:
typeof event.text === "string"
? event.text.length
: typeof event.content === "string"
? event.content.length
: 0,
},
},
r: 1,
baseline_b: 0,
};
}

export function buildTurnCompleteRt(
hook: string,
joinpoint: string,
ctx: AgentHookCtx,
event: Record<string, unknown>,
): RtRecordWire {
const error = typeof event.error === "string" ? event.error : undefined;
return {
record_version: 1,
event: "r_t",
ts: new Date().toISOString(),
session_id: sessionId(ctx),
turn_id: turnId(ctx),
joinpoint,
host: "openclaw",
hook,
signal: {
kind: "turn_complete",
error,
payload: event,
},
r: error ? 0 : 1,
baseline_b: 0,
};
}

export async function appendRtRecord(
cfg: BridgeConfig,
record: RtRecordWire,
): Promise<void> {
if (!cfg.enabled || !cfg.emitRtJsonl) return;

const body = {
jsonrpc: "2.0",
method: "credit.r_t.append",
id: `rt-${crypto.randomUUID()}`,
params: { record },
};

try {
await fetch(cfg.daemonUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: AbortSignal.timeout(5_000),
});
} catch {
// Observe path — never block the host on r_t append failures.
}
}

export function appendRtRecordFireAndForget(
cfg: BridgeConfig,
record: RtRecordWire,
): void {
void appendRtRecord(cfg, record);
}
Loading
Loading