Skip to content

[Feature]: plugin tool-call replay determinism — Clock injection and frozen-snapshot RPC contract #76758

@100yenadmin

Description

@100yenadmin

Summary

Define a host-side contract for deterministic record-and-replay of plugin tool calls. Today plugins that need replay-stable output have to invent their own Clock injection and frozen-snapshot patterns from scratch.

Problem to solve

Discovered during the lossless-claw#516 audit. The plugin's lcm_recent tool reads the wall clock in 5+ places and originally also called host RPCs whose responses change between recording and replay. Without a host-side replay subsystem, the same tool invocation can produce different bytes on replay than on the original recording — defeating "lossless" semantics.

A grep across openclaw/openclaw (vanilla v2026.4.23 + the rebase tree), the plugin-sdk dist, and host-hook-docs found:

  • RecordedClock, FrozenClock, replayClock, hostClock — zero matches
  • AsyncLocalStorage<{ clock: ... }> — zero matches
  • replay references in plugin-sdk: all about LLM transcript replay/compaction (provider-owned), not tool-call replay
  • No host-side hook documented at openclaw-host-hook-docs/docs/

So every plugin that wants replay-safe output has to invent its own. Lossless-claw#516 just landed a Clock interface on its own LcmDependencies (Clock { now(): Date }, required) — it works, but it's plugin-local. The host has no way to substitute a frozen impl during replay.

Proposed solution

A small RFC + implementation covering:

1. Clock injection convention. Standardize a Clock interface in plugin-sdk:

export interface Clock {
  now(): Date;
  // Future: monotonic(): number for perf timers
}

Host injects via plugin deps construction. During recording, the host provides a live impl ({ now: () => new Date() }). During replay, the host provides a frozen impl ({ now: () => recordedTurnStartTimestamp }). Lossless-claw#516's LcmDependencies.clock is a working prototype that could become the standard shape.

2. Frozen-snapshot RPC results during replay. When a plugin tool calls a curated host RPC (per linked issue on plugin-sdk gateway surface), the replay harness should return the recorded value, not call the live RPC. Concrete contract: plugin calls runtime.gateway.getStatus(); recording captures the response; replay returns the captured response.

3. Test/recording harness. A reusable helper for plugins to pin their tool output: record once, byte-diff on every replay. The current ad-hoc per-plugin testing strategy doesn't catch replay drift.

Alternatives considered

  • Per-plugin clock injection — what lossless-claw#516 just did. Works but balkanizes; every plugin reinvents.
  • Monkey-patch global Date during replay — fragile, leaks across worker boundaries, breaks legitimate Date use.
  • AsyncLocalStorage clock per-call — cleaner but harder to reason about; passing as a dep is more explicit.

Impact

  • Affected: any plugin where replay determinism matters. Today: lossless-claw is the canonical case. Future: any plugin that mediates LLM context (planning, memory, tool-result caching).
  • Severity: the user has called replay determinism out as a P0 concern multiple times. Without a host contract, every plugin has to solve it independently and wrong.
  • Frequency: every replay invocation.

Evidence

  • lossless-claw audit replay-determinism report: audit/pr516/chunk-F-replay.md (in pr516-audit-fixes branch — caught 5 P0 read-path violations all collapsing to one fix surface)
  • Investigation report lines 99-130 documenting the absence of any host replay subsystem
  • Lossless-claw#516's Clock interface as a prototype: src/types.ts after commit b2633a8 on the pr516-audit-fixes branch

Notes

This is RFC-shaped work. Happy to draft a longer design doc if there's interest. The smallest viable starting point would be just (1) — standardize the Clock interface in plugin-sdk, document the convention, leave replay-time substitution to follow-up.

Related: #76756 (gateway-RPC plugin-sdk surface).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions