diff --git a/integrations/openclaw-opencoat-bridge/README.md b/integrations/openclaw-opencoat-bridge/README.md index c44d777..fcfca92 100644 --- a/integrations/openclaw-opencoat-bridge/README.md +++ b/integrations/openclaw-opencoat-bridge/README.md @@ -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. diff --git a/integrations/openclaw-opencoat-bridge/src/index.ts b/integrations/openclaw-opencoat-bridge/src/index.ts index 775626b..d9dbedf 100644 --- a/integrations/openclaw-opencoat-bridge/src/index.ts +++ b/integrations/openclaw-opencoat-bridge/src/index.ts @@ -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, @@ -57,6 +65,11 @@ import type { const pendingByRun = new Map(); const reflexState: { runtime: ReflexRuntime | null } = { runtime: null }; +const lastReflexByRunTool = new Map(); + +function reflexToolKey(run: string, toolName: string): string { + return `${run}:${toolName}`; +} function auditToolGuardJoinpoint( cfg: BridgeConfig, @@ -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: @@ -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); @@ -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; } } @@ -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" : ""})`, ); } diff --git a/integrations/openclaw-opencoat-bridge/src/r-t-emit.test.ts b/integrations/openclaw-opencoat-bridge/src/r-t-emit.test.ts new file mode 100644 index 0000000..48f2f00 --- /dev/null +++ b/integrations/openclaw-opencoat-bridge/src/r-t-emit.test.ts @@ -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); + }); +}); diff --git a/integrations/openclaw-opencoat-bridge/src/r-t-emit.ts b/integrations/openclaw-opencoat-bridge/src/r-t-emit.ts new file mode 100644 index 0000000..330aafe --- /dev/null +++ b/integrations/openclaw-opencoat-bridge/src/r-t-emit.ts @@ -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; + payload?: Record; + }; + 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, + 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, +): 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, +): 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 { + 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); +} diff --git a/packages/opencoat-runtime/opencoat_runtime_core/credit/__init__.py b/packages/opencoat-runtime/opencoat_runtime_core/credit/__init__.py new file mode 100644 index 0000000..b40b6f9 --- /dev/null +++ b/packages/opencoat-runtime/opencoat_runtime_core/credit/__init__.py @@ -0,0 +1,16 @@ +from .plasticity_engine import PlasticityEngine, ReweightStats +from .r_t_reader import RtJsonlTailReader +from .r_t_record import EVENT_R_T, RtRecord, RtSignal, reward_from_signal + +# RtPlasticityService is not re-exported here — it pulls in r_t_recorder and would +# create a circular import when storage imports credit.r_t_record. + +__all__ = [ + "EVENT_R_T", + "PlasticityEngine", + "ReweightStats", + "RtJsonlTailReader", + "RtRecord", + "RtSignal", + "reward_from_signal", +] diff --git a/packages/opencoat-runtime/opencoat_runtime_core/credit/plasticity_engine.py b/packages/opencoat-runtime/opencoat_runtime_core/credit/plasticity_engine.py new file mode 100644 index 0000000..cc16f91 --- /dev/null +++ b/packages/opencoat-runtime/opencoat_runtime_core/credit/plasticity_engine.py @@ -0,0 +1,120 @@ +"""Warm-path plasticity: reweight concerns from structured ``r_t`` (v0.3 §3.6 subset).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from opencoat_runtime_core.concern.lifecycle import ConcernLifecycleManager +from opencoat_runtime_core.credit.r_t_record import RtRecord +from opencoat_runtime_core.ports import ConcernStore + + +@dataclass(frozen=True) +class ReweightStats: + read: int = 0 + reinforced: int = 0 + weakened: int = 0 + skipped: int = 0 + + def as_dict(self) -> dict[str, int]: + return { + "read": self.read, + "reinforced": self.reinforced, + "weakened": self.weakened, + "skipped": self.skipped, + } + + +class PlasticityEngine: + """Prototype ``⇩_slow`` reweight only — no split/lift/connect/prune yet.""" + + DEFAULT_DELTA = 0.05 + + def __init__(self, *, step_delta: float = DEFAULT_DELTA) -> None: + if not 0.0 < step_delta <= 1.0: + raise ValueError(f"step_delta must be in (0, 1]; got {step_delta!r}") + self._step_delta = step_delta + + def reweight( + self, + records: list[RtRecord], + *, + concern_store: ConcernStore, + lifecycle: ConcernLifecycleManager, + ) -> ReweightStats: + reinforced = 0 + weakened = 0 + skipped = 0 + for record in records: + concern_id, direction = self._attribute(record) + if concern_id is None or direction == 0: + skipped += 1 + continue + concern = concern_store.get(concern_id) + if concern is None: + skipped += 1 + continue + state = concern.lifecycle_state + if state == "archived": + try: + concern = lifecycle.revive(concern) + except Exception: + skipped += 1 + continue + delta = min(abs(direction) * self._step_delta, self._step_delta) + try: + if direction > 0: + lifecycle.reinforce(concern, delta=delta) + reinforced += 1 + else: + lifecycle.weaken(concern, delta=delta) + weakened += 1 + except Exception: + skipped += 1 + return ReweightStats( + read=len(records), + reinforced=reinforced, + weakened=weakened, + skipped=skipped, + ) + + def _attribute(self, record: RtRecord) -> tuple[str | None, float]: + """Map one ``r_t`` row to ``(concern_id, direction)`` for reweight.""" + reflex = record.signal.reflex if isinstance(record.signal.reflex, dict) else None + policy_id = reflex.get("policy_id") if reflex else None + if isinstance(policy_id, str) and policy_id.strip(): + concern_id = policy_id.strip() + if record.signal.kind == "tool_blocked": + return concern_id, +1.0 + decision = reflex.get("decision") if reflex else None + if decision == "deny": + return concern_id, +1.0 + advantage = record.r - record.baseline_b + if advantage > 0: + return concern_id, +advantage + if advantage < 0: + return concern_id, advantage + return None, 0.0 + + if record.signal.kind in {"llm_output", "turn_complete"}: + return None, 0.0 + + advantage = record.r - record.baseline_b + if advantage > 0: + return None, 0.0 + if advantage < 0: + return None, 0.0 + return None, 0.0 + + +def concern_ids_from_records(records: list[RtRecord]) -> list[str]: + engine = PlasticityEngine() + ids: list[str] = [] + for rec in records: + cid, direction = engine._attribute(rec) + if cid and direction != 0: + ids.append(cid) + return ids + + +__all__ = ["PlasticityEngine", "ReweightStats", "concern_ids_from_records"] diff --git a/packages/opencoat-runtime/opencoat_runtime_core/credit/r_t_reader.py b/packages/opencoat-runtime/opencoat_runtime_core/credit/r_t_reader.py new file mode 100644 index 0000000..f192f5e --- /dev/null +++ b/packages/opencoat-runtime/opencoat_runtime_core/credit/r_t_reader.py @@ -0,0 +1,69 @@ +"""Tail-read ``r_t.jsonl`` with a durable byte cursor.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from opencoat_runtime_core.credit.r_t_record import RtRecord + + +class RtJsonlTailReader: + """Read newly appended ``r_t`` lines since the last consume.""" + + def __init__(self, path: Path, *, cursor_path: Path | None = None) -> None: + self._path = path + self._cursor_path = cursor_path or path.with_suffix(".cursor.json") + + @property + def path(self) -> Path: + return self._path + + @property + def cursor_path(self) -> Path: + return self._cursor_path + + def cursor_offset(self) -> int: + if not self._cursor_path.exists(): + return 0 + try: + data = json.loads(self._cursor_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return 0 + offset = data.get("offset") + return int(offset) if isinstance(offset, int) and offset >= 0 else 0 + + def read_new(self, *, max_records: int | None = None) -> list[RtRecord]: + if not self._path.exists(): + return [] + offset = self.cursor_offset() + records: list[RtRecord] = [] + with self._path.open("rb") as fh: + fh.seek(offset) + while True: + line = fh.readline() + if not line: + break + text = line.decode("utf-8").strip() + if not text: + continue + row: dict[str, Any] = json.loads(text) + records.append(RtRecord.model_validate(row)) + if max_records is not None and len(records) >= max_records: + break + new_offset = fh.tell() + if new_offset > offset: + self._write_cursor(new_offset) + return records + + def _write_cursor(self, offset: int) -> None: + self._cursor_path.parent.mkdir(parents=True, exist_ok=True) + payload = {"offset": offset, "path": str(self._path)} + self._cursor_path.write_text( + json.dumps(payload, ensure_ascii=False, sort_keys=True), + encoding="utf-8", + ) + + +__all__ = ["RtJsonlTailReader"] diff --git a/packages/opencoat-runtime/opencoat_runtime_core/credit/r_t_record.py b/packages/opencoat-runtime/opencoat_runtime_core/credit/r_t_record.py new file mode 100644 index 0000000..a84d874 --- /dev/null +++ b/packages/opencoat-runtime/opencoat_runtime_core/credit/r_t_record.py @@ -0,0 +1,79 @@ +"""Structured effector outcome records ``r_t`` (v0.3 §10.1 step 3 prototype).""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + +RtSignalKind = Literal[ + "tool_outcome", + "tool_blocked", + "llm_output", + "turn_complete", + "reflex_decision", +] + +RECORD_VERSION = 1 +EVENT_R_T = "r_t" + + +class RtSignal(BaseModel): + model_config = ConfigDict(extra="allow") + + kind: RtSignalKind + tool_name: str | None = None + blocked: bool | None = None + error: str | None = None + duration_ms: float | None = None + reflex: dict[str, Any] | None = None + payload: dict[str, Any] = Field(default_factory=dict) + + +class RtRecord(BaseModel): + """One append-only JSONL line for credit / plasticity consumption.""" + + model_config = ConfigDict(extra="forbid") + + record_version: int = RECORD_VERSION + event: Literal["r_t"] = EVENT_R_T + ts: datetime + session_id: str + turn_id: str + joinpoint: str + host: str = "openclaw" + hook: str + signal: RtSignal + r: float = Field(description="Observed reward (prototype: 0|1).") + baseline_b: float = 0.0 + + def to_jsonl(self) -> dict[str, Any]: + return self.model_dump(mode="json") + + +def reward_from_signal(signal: RtSignal) -> float: + """Prototype tier-1 reward: success=1, block/error=0.""" + if signal.kind == "tool_blocked": + return 0.0 + if signal.kind == "tool_outcome": + if signal.blocked: + return 0.0 + if signal.error: + return 0.0 + return 1.0 + if signal.kind == "turn_complete": + if signal.error: + return 0.0 + return 1.0 + return 0.0 + + +__all__ = [ + "EVENT_R_T", + "RECORD_VERSION", + "RtRecord", + "RtSignal", + "RtSignalKind", + "reward_from_signal", +] diff --git a/packages/opencoat-runtime/opencoat_runtime_core/credit/rt_plasticity_service.py b/packages/opencoat-runtime/opencoat_runtime_core/credit/rt_plasticity_service.py new file mode 100644 index 0000000..e883f60 --- /dev/null +++ b/packages/opencoat-runtime/opencoat_runtime_core/credit/rt_plasticity_service.py @@ -0,0 +1,76 @@ +"""Daemon-side ``r_t`` append + consume pipeline (credit field warm path).""" + +from __future__ import annotations + +import threading +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from opencoat_runtime_storage.jsonl.r_t_recorder import RtJsonlRecorder, default_r_t_path + +from opencoat_runtime_core.concern.lifecycle import ConcernLifecycleManager +from opencoat_runtime_core.credit.plasticity_engine import PlasticityEngine, ReweightStats +from opencoat_runtime_core.credit.r_t_reader import RtJsonlTailReader +from opencoat_runtime_core.credit.r_t_record import RtRecord, reward_from_signal +from opencoat_runtime_core.ports import ConcernStore, DCNStore + + +@dataclass +class RtPlasticityService: + concern_store: ConcernStore + dcn_store: DCNStore + path: Path | str | None = None + engine: PlasticityEngine = field(default_factory=PlasticityEngine) + _recorder: RtJsonlRecorder | None = field(default=None, repr=False) + _reader: RtJsonlTailReader | None = field(default=None, repr=False) + _lifecycle: ConcernLifecycleManager | None = field(default=None, repr=False) + _consume_lock: threading.RLock = field(default_factory=threading.RLock, repr=False) + last_consume: ReweightStats | None = field(default=None, repr=False) + + def __post_init__(self) -> None: + log_path = default_r_t_path() if self.path is None else Path(self.path) + self._recorder = RtJsonlRecorder(log_path) + self._recorder.__enter__() + self._reader = RtJsonlTailReader(self._recorder.path) + self._lifecycle = ConcernLifecycleManager( + concern_store=self.concern_store, + dcn_store=self.dcn_store, + ) + + def append(self, record: RtRecord) -> dict[str, Any]: + assert self._recorder is not None + normalized = record.model_copy(update={"r": reward_from_signal(record.signal)}) + return self._recorder.append(normalized) + + def consume(self, *, max_records: int | None = None) -> ReweightStats: + """Single-consumer drain: safe under concurrent JSON-RPC and heartbeat.""" + assert self._reader is not None and self._lifecycle is not None + with self._consume_lock: + records = self._reader.read_new(max_records=max_records) + stats = self.engine.reweight( + records, + concern_store=self.concern_store, + lifecycle=self._lifecycle, + ) + self.last_consume = stats + return stats + + def stats(self) -> dict[str, Any]: + assert self._recorder is not None and self._reader is not None + payload: dict[str, Any] = { + "path": str(self._recorder.path), + "count": self._recorder.count, + "cursor_offset": self._reader.cursor_offset(), + } + if self.last_consume is not None: + payload["last_consume"] = self.last_consume.as_dict() + return payload + + def close(self) -> None: + if self._recorder is not None: + self._recorder.close() + self._recorder = None + + +__all__ = ["RtPlasticityService"] diff --git a/packages/opencoat-runtime/opencoat_runtime_daemon/daemon.py b/packages/opencoat-runtime/opencoat_runtime_daemon/daemon.py index da29555..870f537 100644 --- a/packages/opencoat-runtime/opencoat_runtime_daemon/daemon.py +++ b/packages/opencoat-runtime/opencoat_runtime_daemon/daemon.py @@ -111,6 +111,7 @@ def start(self) -> None: self._handler = JsonRpcHandler( self._built.runtime, llm_info=self._built.llm_info, + rt_service=self._built.rt_plasticity, ) self._maybe_start_http() self._maybe_start_scheduler() @@ -140,7 +141,11 @@ def reload(self) -> None: old_built = self._built new_built = build_runtime(self._config, env=self._env) warm_persistent_stores(new_built.runtime) - new_handler = JsonRpcHandler(new_built.runtime, llm_info=new_built.llm_info) + new_handler = JsonRpcHandler( + new_built.runtime, + llm_info=new_built.llm_info, + rt_service=new_built.rt_plasticity, + ) # Swap before closing the old runtime so in-flight RPCs that # already grabbed the handler reference keep working; new # arrivals immediately see the new runtime. diff --git a/packages/opencoat-runtime/opencoat_runtime_daemon/ipc/jsonrpc_dispatch.py b/packages/opencoat-runtime/opencoat_runtime_daemon/ipc/jsonrpc_dispatch.py index 7341959..dc43881 100644 --- a/packages/opencoat-runtime/opencoat_runtime_daemon/ipc/jsonrpc_dispatch.py +++ b/packages/opencoat-runtime/opencoat_runtime_daemon/ipc/jsonrpc_dispatch.py @@ -48,6 +48,17 @@ Params: ``{"action_kind"?: "tool_call"}``. Returns portable deterministic policy specs for the bridge in-proc ``ReflexMonitor`` (v0.3 §10.4). +``credit.r_t.append`` + Params: ``{"record": }``. Appends one structured ``r_t`` + outcome line to ``~/.opencoat/r_t.jsonl`` (v0.3 §10.1 step 3 prototype). + +``credit.r_t.stats`` + Result: ``{"path": str, "count": int, "cursor_offset": int, "last_consume"?: {...}}``. + +``credit.r_t.consume`` + Params: ``{"max_records"?: int}``. Reads unread ``r_t`` lines and runs warm-path + :class:`PlasticityEngine` reweight (v0.3 §3.6 subset). + ``health.ping`` Result: ``{"ok": true}`` — proves the handler is wired without touching stores. @@ -64,6 +75,8 @@ from opencoat_runtime_core.concern import ConcernBuilder, ConcernExtractor from opencoat_runtime_core.concern.chat_extract import chat_text_for_extraction from opencoat_runtime_core.concern.reflex_policy_export import export_reflex_policies +from opencoat_runtime_core.credit.r_t_record import RtRecord +from opencoat_runtime_core.credit.rt_plasticity_service import RtPlasticityService from opencoat_runtime_protocol import Concern, ConcernInjection, JoinpointEvent from pydantic import ValidationError @@ -153,9 +166,14 @@ def __init__( runtime: OpenCOATRuntime, *, llm_info: LLMInfo | None = None, + rt_service: RtPlasticityService | None = None, ) -> None: self._rt = runtime self._llm_info = llm_info if llm_info is not None else _UNKNOWN_LLM_INFO + self._rt_service = rt_service or RtPlasticityService( + concern_store=runtime.concern_store, + dcn_store=runtime.dcn_store, + ) # ConcernExtractor is built lazily so the dispatcher pays the # construction cost only when a host actually calls # ``concern.extract`` (most daemons do nothing but @@ -175,6 +193,9 @@ def __init__( "runtime.llm_info": self._runtime_llm_info, "dcn.activation_log": self._dcn_activation_log, "reflex.policies.export": self._reflex_policies_export, + "credit.r_t.append": self._credit_rt_append, + "credit.r_t.stats": self._credit_rt_stats, + "credit.r_t.consume": self._credit_rt_consume, } def handle(self, message: str | dict[str, Any]) -> dict[str, Any] | None: @@ -426,5 +447,30 @@ def _reflex_policies_export(self, params: dict[str, Any] | list[Any]) -> dict[st concerns = self._rt.concern_store.list() return export_reflex_policies(concerns, action_kind=action_kind) + def _credit_rt_append(self, params: dict[str, Any] | list[Any]) -> dict[str, Any]: + p = _expect_params_dict(params) + raw = p.get("record") + if not isinstance(raw, dict): + raise JsonRpcParamsError("record must be an object") + record = RtRecord.model_validate(raw) + written = self._rt_service.append(record) + plasticity = self._rt_service.consume(max_records=64).as_dict() + return { + "ok": True, + "path": str(self._rt_service.stats()["path"]), + "record": written, + "plasticity": plasticity, + } + + def _credit_rt_stats(self, _params: dict[str, Any] | list[Any]) -> dict[str, Any]: + return self._rt_service.stats() + + def _credit_rt_consume(self, params: dict[str, Any] | list[Any]) -> dict[str, Any]: + p = _expect_params_dict(params) + max_raw = p.get("max_records") + max_records = int(max_raw) if isinstance(max_raw, int) and max_raw > 0 else None + stats = self._rt_service.consume(max_records=max_records) + return {"ok": True, **stats.as_dict()} + __all__ = ["JsonRpcHandler", "JsonRpcParamsError"] diff --git a/packages/opencoat-runtime/opencoat_runtime_daemon/runtime_builder.py b/packages/opencoat-runtime/opencoat_runtime_daemon/runtime_builder.py index 5738de0..630c911 100644 --- a/packages/opencoat-runtime/opencoat_runtime_daemon/runtime_builder.py +++ b/packages/opencoat-runtime/opencoat_runtime_daemon/runtime_builder.py @@ -45,6 +45,7 @@ from opencoat_runtime_core import OpenCOATRuntime from opencoat_runtime_core.config import HeartbeatMaintenance +from opencoat_runtime_core.credit.rt_plasticity_service import RtPlasticityService from opencoat_runtime_core.llm import StubLLMClient from opencoat_runtime_core.loops.heartbeat_loop import MaintenanceFn from opencoat_runtime_core.ports import ConcernStore, DCNStore, LLMClient @@ -52,7 +53,7 @@ from opencoat_runtime_storage.sqlite import SqliteConcernStore, SqliteDCNStore from .config.loader import DaemonConfig, LLMSettings, StorageBackend -from .workers import ConflictScannerWorker, DecayWorker, MergeArchiverWorker +from .workers import ConflictScannerWorker, DecayWorker, MergeArchiverWorker, RtPlasticityWorker logger = logging.getLogger(__name__) @@ -62,8 +63,9 @@ def build_heartbeat_maintenance( dcn_store: DCNStore, *, maintenance: HeartbeatMaintenance | None = None, + rt_plasticity: RtPlasticityService | None = None, ) -> MaintenanceFn: - """Daemon-side M6 maintenance: decay + merge/archive + conflict scan.""" + """Daemon-side M6 maintenance: decay + merge/archive + conflict scan + r_t reweight.""" maint = maintenance or HeartbeatMaintenance() decay = DecayWorker(concern_store=concern_store, dcn_store=dcn_store) merge_archiver = MergeArchiverWorker( @@ -74,17 +76,21 @@ def build_heartbeat_maintenance( archive_cold_max_score=maint.archive_cold_max_score, ) conflict = ConflictScannerWorker(concern_store=concern_store, dcn_store=dcn_store) + rt_worker = RtPlasticityWorker(rt_service=rt_plasticity) if rt_plasticity is not None else None def maintenance(now: datetime) -> dict[str, int]: decay_stats = decay.run(now) merge_stats = merge_archiver.run(now) conflict_stats = conflict.run(now) + rt_stats = rt_worker.run(now) if rt_worker is not None else {} return { "decay_count": int(decay_stats.get("touched", 0)), "archive_count": int(decay_stats.get("archived", 0)) + int(merge_stats.get("archived", 0)), "merge_count": int(merge_stats.get("merged", 0)), "conflict_count": int(conflict_stats.get("edges_added", 0)), + "rt_reinforced": int(rt_stats.get("reinforced", 0)), + "rt_weakened": int(rt_stats.get("weakened", 0)), } return maintenance @@ -171,12 +177,15 @@ class BuiltRuntime: llm_label: str llm_info: LLMInfo closers: list[Callable[[], None]] = field(default_factory=list) + rt_plasticity: RtPlasticityService | None = None _closed: bool = False def close(self) -> None: if self._closed: return self._closed = True + if self.rt_plasticity is not None: + self.rt_plasticity.close() for fn in self.closers: fn() @@ -222,11 +231,13 @@ def build_runtime( logger.warning("OpenCOAT LLM provider degraded to %s — %s", info.label, info.hint) maintenance: MaintenanceFn | None = None + rt_plasticity = RtPlasticityService(concern_store=concern_store, dcn_store=dcn_store) if config.runtime.loops.heartbeat_enabled: maintenance = build_heartbeat_maintenance( concern_store, dcn_store, maintenance=config.runtime.loops.maintenance, + rt_plasticity=rt_plasticity, ) runtime = OpenCOATRuntime( config.runtime, @@ -240,6 +251,7 @@ def build_runtime( llm_label=info.label, llm_info=info, closers=closers, + rt_plasticity=rt_plasticity, ) diff --git a/packages/opencoat-runtime/opencoat_runtime_daemon/workers/__init__.py b/packages/opencoat-runtime/opencoat_runtime_daemon/workers/__init__.py index 319f09c..234cbdf 100644 --- a/packages/opencoat-runtime/opencoat_runtime_daemon/workers/__init__.py +++ b/packages/opencoat-runtime/opencoat_runtime_daemon/workers/__init__.py @@ -9,6 +9,7 @@ from .extraction_worker import ExtractionWorker from .merge_archiver import MergeArchiverWorker from .meta_review_worker import MetaReviewWorker +from .rt_plasticity_worker import RtPlasticityWorker from .verification_worker import VerificationWorker __all__ = [ @@ -17,5 +18,6 @@ "ExtractionWorker", "MergeArchiverWorker", "MetaReviewWorker", + "RtPlasticityWorker", "VerificationWorker", ] diff --git a/packages/opencoat-runtime/opencoat_runtime_daemon/workers/rt_plasticity_worker.py b/packages/opencoat-runtime/opencoat_runtime_daemon/workers/rt_plasticity_worker.py new file mode 100644 index 0000000..917985b --- /dev/null +++ b/packages/opencoat-runtime/opencoat_runtime_daemon/workers/rt_plasticity_worker.py @@ -0,0 +1,24 @@ +"""Consume ``r_t.jsonl`` and apply warm-path reweight on heartbeat.""" + +from __future__ import annotations + +from datetime import datetime + +from opencoat_runtime_core.credit.rt_plasticity_service import RtPlasticityService + +from ._base import Worker + + +class RtPlasticityWorker(Worker): + """Run :class:`PlasticityEngine` reweight over unread ``r_t`` rows.""" + + def __init__(self, *, rt_service: RtPlasticityService) -> None: + self._rt_service = rt_service + + def run(self, now: datetime) -> dict: + del now # stateless — cursor lives on disk + stats = self._rt_service.consume() + return stats.as_dict() + + +__all__ = ["RtPlasticityWorker"] diff --git a/packages/opencoat-runtime/opencoat_runtime_storage/jsonl/r_t_recorder.py b/packages/opencoat-runtime/opencoat_runtime_storage/jsonl/r_t_recorder.py new file mode 100644 index 0000000..a900b88 --- /dev/null +++ b/packages/opencoat-runtime/opencoat_runtime_storage/jsonl/r_t_recorder.py @@ -0,0 +1,66 @@ +"""Append-only JSONL writer for ``r_t`` outcome records.""" + +from __future__ import annotations + +import json +import threading +from pathlib import Path +from typing import Any, TextIO + +from opencoat_runtime_core.credit.r_t_record import RtRecord + + +class RtJsonlRecorder: + """Thread-safe append-only ``r_t`` log (v0.3 credit field input).""" + + def __init__(self, path: str | Path) -> None: + self._path = Path(path) + self._lock = threading.RLock() + self._fp: TextIO | None = None + self._count = 0 + + @property + def path(self) -> Path: + return self._path + + @property + def count(self) -> int: + return self._count + + def __enter__(self) -> RtJsonlRecorder: + self._path.parent.mkdir(parents=True, exist_ok=True) + with self._lock: + if self._path.exists(): + with self._path.open(encoding="utf-8") as rf: + self._count = sum(1 for line in rf if line.strip()) + self._fp = self._path.open("a", encoding="utf-8") + return self + + def __exit__(self, *exc: object) -> None: + self.close() + + def close(self) -> None: + with self._lock: + if self._fp is not None: + self._fp.flush() + self._fp.close() + self._fp = None + + def append(self, record: RtRecord) -> dict[str, Any]: + with self._lock: + if self._fp is None: + self._path.parent.mkdir(parents=True, exist_ok=True) + self._fp = self._path.open("a", encoding="utf-8") + payload = record.to_jsonl() + line = json.dumps(payload, ensure_ascii=False, sort_keys=True) + self._fp.write(line + "\n") + self._fp.flush() + self._count += 1 + return payload + + +def default_r_t_path() -> Path: + return Path.home() / ".opencoat" / "r_t.jsonl" + + +__all__ = ["RtJsonlRecorder", "default_r_t_path"] diff --git a/packages/opencoat-runtime/tests/core/test_plasticity_engine.py b/packages/opencoat-runtime/tests/core/test_plasticity_engine.py new file mode 100644 index 0000000..21ab4b3 --- /dev/null +++ b/packages/opencoat-runtime/tests/core/test_plasticity_engine.py @@ -0,0 +1,74 @@ +"""Tests for PlasticityEngine reweight attribution.""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from opencoat_runtime_core.concern.lifecycle import ConcernLifecycleManager +from opencoat_runtime_core.credit.plasticity_engine import PlasticityEngine +from opencoat_runtime_core.credit.r_t_record import RtRecord, RtSignal +from opencoat_runtime_protocol import Concern +from opencoat_runtime_storage.memory import MemoryConcernStore, MemoryDCNStore + + +def _record(kind: str, **reflex: object) -> RtRecord: + return RtRecord( + ts=datetime(2026, 5, 24, tzinfo=UTC), + session_id="s1", + turn_id="run-1", + joinpoint="before_tool_call", + hook="before_tool_call", + signal=RtSignal(kind=kind, reflex=dict(reflex) if reflex else None), + r=0.0, + ) + + +def test_tool_blocked_reinforces_reflex_concern() -> None: + store = MemoryConcernStore() + dcn = MemoryDCNStore() + store.upsert(Concern(id="demo-tool-block", name="block")) + lifecycle = ConcernLifecycleManager(concern_store=store, dcn_store=dcn) + engine = PlasticityEngine(step_delta=0.1) + + stats = engine.reweight( + [ + _record( + "tool_blocked", + policy_id="demo-tool-block", + decision="deny", + ) + ], + concern_store=store, + lifecycle=lifecycle, + ) + + assert stats.reinforced == 1 + updated = store.get("demo-tool-block") + assert updated is not None + assert updated.lifecycle_state == "reinforced" + + +def test_tool_blocked_revives_archived_concern() -> None: + store = MemoryConcernStore() + dcn = MemoryDCNStore() + concern = Concern(id="demo-tool-block", name="block", lifecycle_state="archived") + store.upsert(concern) + lifecycle = ConcernLifecycleManager(concern_store=store, dcn_store=dcn) + engine = PlasticityEngine(step_delta=0.1) + + stats = engine.reweight( + [ + _record( + "tool_blocked", + policy_id="demo-tool-block", + decision="deny", + ) + ], + concern_store=store, + lifecycle=lifecycle, + ) + + assert stats.reinforced == 1 + updated = store.get("demo-tool-block") + assert updated is not None + assert updated.lifecycle_state == "reinforced" diff --git a/packages/opencoat-runtime/tests/core/test_rt_plasticity_service.py b/packages/opencoat-runtime/tests/core/test_rt_plasticity_service.py new file mode 100644 index 0000000..cec9ee1 --- /dev/null +++ b/packages/opencoat-runtime/tests/core/test_rt_plasticity_service.py @@ -0,0 +1,76 @@ +"""Integration test: append r_t then consume reweight.""" + +from __future__ import annotations + +import threading +from datetime import UTC, datetime +from pathlib import Path + +from opencoat_runtime_core.credit.r_t_record import RtRecord, RtSignal +from opencoat_runtime_core.credit.rt_plasticity_service import RtPlasticityService +from opencoat_runtime_protocol import Concern +from opencoat_runtime_storage.memory import MemoryConcernStore, MemoryDCNStore + + +def test_append_and_consume_reinforces_concern(tmp_path: Path) -> None: + store = MemoryConcernStore() + dcn = MemoryDCNStore() + store.upsert(Concern(id="demo-tool-block", name="block")) + log = tmp_path / "r_t.jsonl" + svc = RtPlasticityService(concern_store=store, dcn_store=dcn, path=log) + svc.append( + RtRecord( + ts=datetime(2026, 5, 24, tzinfo=UTC), + session_id="s1", + turn_id="run-1", + joinpoint="before_tool_call", + hook="before_tool_call", + signal=RtSignal( + kind="tool_blocked", + reflex={"policy_id": "demo-tool-block", "decision": "deny"}, + ), + r=0.0, + ) + ) + stats = svc.consume() + assert stats.reinforced == 1 + updated = store.get("demo-tool-block") + assert updated is not None + assert updated.lifecycle_state == "reinforced" + + +def test_concurrent_consume_processes_rows_once(tmp_path: Path) -> None: + store = MemoryConcernStore() + dcn = MemoryDCNStore() + store.upsert(Concern(id="demo-tool-block", name="block")) + log = tmp_path / "r_t.jsonl" + svc = RtPlasticityService(concern_store=store, dcn_store=dcn, path=log) + record = RtRecord( + ts=datetime(2026, 5, 24, tzinfo=UTC), + session_id="s1", + turn_id="run-1", + joinpoint="before_tool_call", + hook="before_tool_call", + signal=RtSignal( + kind="tool_blocked", + reflex={"policy_id": "demo-tool-block", "decision": "deny"}, + ), + r=0.0, + ) + svc.append(record) + + results: list = [] + barrier = threading.Barrier(2) + + def worker() -> None: + barrier.wait() + results.append(svc.consume()) + + threads = [threading.Thread(target=worker) for _ in range(2)] + for t in threads: + t.start() + for t in threads: + t.join() + + reinforced = sum(s.reinforced for s in results) + assert reinforced == 1 diff --git a/packages/opencoat-runtime/tests/daemon/test_credit_rt_jsonrpc.py b/packages/opencoat-runtime/tests/daemon/test_credit_rt_jsonrpc.py new file mode 100644 index 0000000..f694db4 --- /dev/null +++ b/packages/opencoat-runtime/tests/daemon/test_credit_rt_jsonrpc.py @@ -0,0 +1,56 @@ +"""Tests for credit.r_t JSON-RPC methods.""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from opencoat_runtime_core import OpenCOATRuntime +from opencoat_runtime_core.credit.r_t_record import RtRecord, RtSignal +from opencoat_runtime_core.credit.rt_plasticity_service import RtPlasticityService +from opencoat_runtime_core.llm import StubLLMClient +from opencoat_runtime_daemon.ipc.jsonrpc_dispatch import JsonRpcHandler +from opencoat_runtime_storage.memory import MemoryConcernStore, MemoryDCNStore + + +def _req(method: str, params: dict | None = None, req_id: int = 1) -> dict: + return { + "jsonrpc": "2.0", + "method": method, + "params": params or {}, + "id": req_id, + } + + +def test_credit_rt_append_and_stats(monkeypatch, tmp_path) -> None: + from opencoat_runtime_cli.demo_concerns import demo_concerns + + store = MemoryConcernStore() + for c in demo_concerns(): + store.upsert(c) + rt = OpenCOATRuntime( + concern_store=store, + dcn_store=MemoryDCNStore(), + llm=StubLLMClient(), + ) + log = tmp_path / "r_t.jsonl" + svc = RtPlasticityService(concern_store=store, dcn_store=rt.dcn_store, path=log) + h = JsonRpcHandler(rt, rt_service=svc) + record = RtRecord( + ts=datetime(2026, 5, 24, 12, 0, tzinfo=UTC), + session_id="s1", + turn_id="run-1", + joinpoint="before_tool_call", + hook="before_tool_call", + signal=RtSignal( + kind="tool_blocked", + reflex={"policy_id": "demo-tool-block", "decision": "deny"}, + ), + r=0.0, + ) + out = h.handle(_req("credit.r_t.append", {"record": record.model_dump(mode="json")})) + assert "error" not in out + assert out["result"]["ok"] is True + assert out["result"]["plasticity"]["reinforced"] == 1 + + stats = h.handle(_req("credit.r_t.stats")) + assert stats["result"]["count"] == 1 diff --git a/packages/opencoat-runtime/tests/storage/test_r_t_recorder.py b/packages/opencoat-runtime/tests/storage/test_r_t_recorder.py new file mode 100644 index 0000000..80b708e --- /dev/null +++ b/packages/opencoat-runtime/tests/storage/test_r_t_recorder.py @@ -0,0 +1,31 @@ +"""Tests for r_t JSONL recorder.""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime +from pathlib import Path + +from opencoat_runtime_core.credit.r_t_record import RtRecord, RtSignal +from opencoat_runtime_storage.jsonl.r_t_recorder import RtJsonlRecorder + + +def test_append_rt_record(tmp_path: Path) -> None: + path = tmp_path / "r_t.jsonl" + rec = RtRecord( + ts=datetime(2026, 5, 24, 12, 0, tzinfo=UTC), + session_id="s1", + turn_id="run-1", + joinpoint="after_tool_call", + hook="after_tool_call", + signal=RtSignal(kind="tool_outcome", tool_name="shell.exec"), + r=1.0, + ) + with RtJsonlRecorder(path) as writer: + writer.append(rec) + assert writer.count == 1 + lines = path.read_text(encoding="utf-8").strip().splitlines() + assert len(lines) == 1 + row = json.loads(lines[0]) + assert row["event"] == "r_t" + assert row["signal"]["tool_name"] == "shell.exec"