Skip to content

fix(reflection): restore reflection on current hosts (hook transcript, runEmbeddedAgent runner, headless CLI fallback) - #995

Open
gorkem2020 wants to merge 6 commits into
CortexReach:masterfrom
gorkem2020:fix/reflection-transcript-from-hook-context
Open

fix(reflection): restore reflection on current hosts (hook transcript, runEmbeddedAgent runner, headless CLI fallback)#995
gorkem2020 wants to merge 6 commits into
CortexReach:masterfrom
gorkem2020:fix/reflection-transcript-from-hook-context

Conversation

@gorkem2020

@gorkem2020 gorkem2020 commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Summary

On OpenClaw hosts with SQLite session storage the reflection hook never produced a reflection. Two independent breaks, both fixed here:

  1. The command:new / command:reset hook context no longer carries a transcript file path (core removed that surface in its plugin SDK, registry entry removed-session-transcript-file-api, shipped in release 2026.7.1 via openclaw#98236), the legacy agents/<id>/sessions/*.jsonl layout is gone once an install migrates, and the session-dir recovery only reads the array form of the agent config. The hook logged missing session file after recovery and returned.
  2. Once the transcript is found, the distiller has no working runner on those hosts: the SDK runner was renamed from runEmbeddedPiAgent to runEmbeddedAgent and the alias removed (core compat registry entry embedded-pi-agent-sdk-aliases, status removed), dist/extensionAPI.js no longer exists, and the CLI layer hardcodes openclaw agent --local, which the host refuses while a gateway owns the state directory. Generation fell through to the minimal fallback pointer on every session boundary, and the 400-char stderr clip showed only startup banners, never the refusal.

Core hands the departing session's recent messages to the same hook as previousSessionMemory (one role: "<json text>" record per line). Reflection now uses that transcript first (session-file lookup stays as the legacy fallback, the same messages-first shape the plugin's before_reset handler already uses) and runs the distiller through the current runner names, with a tool-free completion on the plugin's own LLM lane as the fallback.

Changes

  • index.ts, transcript: conversationFromHookSessionMemory parses previousSessionMemory back into turns and feeds the existing summarizeRecentConversationMessages pipeline; runMemoryReflection tries it before any file resolution and logs using the hook-provided transcript. The file path, the reset-suffix fallback and the empty-session guard are unchanged for hosts that still write files.
  • src/session-recovery.ts: agent enumeration reads agents.entries (object keyed by agent id) alongside agents.list (array), for both ids and workspace-derived homes.
  • index.ts, runner: resolveEmbeddedRunnerExportName picks runEmbeddedAgent first and still accepts runEmbeddedPiAgent, for both the SDK layer and the extensionAPI.js import layer. The distiller run is marked sessionPersistence: "detached" so it never lands in the host session store (hosts that predate the field ignore it).
  • index.ts and src/llm-client.ts, fallback: when the embedded runner is unavailable the distiller prompts go to completeText, a new plain-text completion on the plugin's LLM client (host, api-key and OAuth transports), built for the reflection lane from memoryReflection.model and the configured llm.* credentials. No agent turn, no tool surface, no agent identity to resolve. The CLI runner (openclaw agent exec / --local) and its argument, retry and diagnostic helpers are removed. Model resolution lives in resolveReflectionModelTarget.
  • index.ts, runner cache: the embedded runner and its export name are cached together (loadEmbeddedPiRunner resolves { runner, exportName }), so a host surface seen later cannot relabel an already cached runner; the transcript-file decision reads the cached kind.
  • Tests: test/reflection-hook-session-memory.test.mjs (reflection from the hook transcript with no session file, the file path without a hook transcript, empty and unavailable hook transcripts falling back to the file lookup, search dirs from both config shapes) and test/reflection-runner-embedded-agent.test.mjs (runner-name preference and legacy acceptance, the detached embedded run through runEmbeddedAgent, the legacy runner's transcript file, the completion fallback receiving the distiller prompts, the static fallback when the completion returns nothing or no client exists, and the runner cache keeping runner and kind together across host surfaces). Both registered in the npm test chain and the CI manifest (core-regression).

Notes

  • previousSessionMemory is bounded by the host's hooks.internal.entries["session-memory"].messages (default 15), which is smaller than memoryReflection.messageCount; hosts that want the full window can raise that setting. A deeper read through the plugin SDK's transcript helpers is a possible follow-up; the upstream request for companion-friendly transcript access is openclaw#79902.
  • Observed on OpenClaw 2026.9.2 after the SQLite session migration: command:reset and command:new both logged missing session file after recovery with a single non-existent candidate dir, while auto-capture on the same session kept working because agent_end carries the messages directly. With the transcript fix alone, generation ended in runner=fallback and the written file held only the fallback pointer; reproducing the CLI layer by hand showed the --local refusal that the clipped diagnostic had hidden.
  • The fallback is a completion rather than an agent turn on purpose: an agent turn runs with the host's default tool profile, and a user-controlled instruction in the departing transcript could reach shell or filesystem tools during an automatic reset. A completion has no tools to reach.

Verification

  • npm run build, npm test (935 passing after round 2), both new regression files red against the previous code by construction, plus the existing command-reflection-guard, issue606_sdk-migration, raw-run-distiller-hooks, reflection-per-agent-lane and windows-reflection-fallback suites.

Update: typed before_reset continuation (third commit)

On the Gateway command path core emits command:new before it captures the departing transcript, so previousSessionMemory is absent there (and on the immediate web chat /new path), while /reset reaches the reply path with it present. Core's maintainers pointed at the typed before_reset hook as the supported integration (openclaw#146358): it fires right after the command hooks on every path and carries the departing messages plus the session identity.

  • A command:new / command:reset hook that finds neither a hook transcript nor a session file now parks the event (per session key, 60 s TTL) instead of recording the empty guard, and logs that it waits for before_reset.
  • The plugin listens to before_reset; for a parked session key with reason new or reset it builds the conversation from event.messages (the same summarizer the session-memory feature uses) and finishes the same reflection pipeline with it, bypassing the event dedup and the serial-guard stamp left by the parked hook. Nothing runs when no hook is parked, when the reason is not a session boundary, or when the command hook already carried the transcript, so a boundary reflects at most once.
  • Empty before_reset messages end in the existing empty/unusable guard.
  • Tests: test/reflection-before-reset-transcript.test.mjs (park, then finish from before_reset with exactly one run; an orphan before_reset is ignored; no second run when the command hook had the transcript; empty messages record the guard; non-boundary reasons leave the parked hook alone). The hook-session-memory test's missing-file cases now assert the parking log line.
  • Fourth commit: the continuation runs inside an AsyncLocalStorage.snapshot() taken at plugin registration. Core refuses embedded sub-runs enqueued from a released root-work context (isGatewaySubordinateWorkAdmissionClosed), and the fire-and-forget before_reset hook inherits the released /new root, so without the snapshot every /new reflection fell back to the completion path. The global restart and suspension fences still apply to the continuation. Covered by a test that fires the hooks inside a foreign async context and asserts the embedded runner is invoked outside it.

Round 2 (review of 2026-09-13)

  • The CLI fallback is gone; the fallback is the tool-free completion described above, so no tool surface exists to enforce and no agent identity has to be resolved.
  • The embedded runner is cached together with its kind.
  • A recovered transcript file that holds no usable conversation now parks the boundary for before_reset too; the continuation ignores the empty guard and records it only when the before_reset messages are empty. test/command-reflection-guard.test.mjs drives the repeated-empty case through before_reset accordingly, and test/reflection-before-reset-transcript.test.mjs adds the stale-file case.
  • test/windows-reflection-fallback.test.mjs keeps its import-specifier cases and loses the two spawn-command cases with the CLI code.

…on SQLite hosts

OpenClaw hosts with SQLite session storage no longer expose a transcript
file to plugins: the session entry carries no sessionFile, the legacy
sessions directories are gone, and the command:new / command:reset hook
context provides the departing session's recent messages instead
(previousSessionMemory, one role-prefixed JSON record per line). The
reflection hook kept resolving a session file, logged "missing session
file after recovery" and returned, so no reflection ran on those hosts.

Reflection now parses previousSessionMemory back into turns and runs the
same pipeline on them first; the session-file lookup remains as the legacy
fallback for hosts that still write files. Session-dir recovery also reads
agents.entries (the object form of the agent config) alongside
agents.list, matching the md-mirror path.

Regressions: reflection runs from the hook transcript with no session
file, the file path still works without a hook transcript, an empty or
unavailable hook transcript falls back to the file lookup, and the search
dirs enumerate agents.entries ids and workspaces.
…dless CLI exec turn

The host renamed api.runtime.agent.runEmbeddedPiAgent to runEmbeddedAgent
and removed the alias, so the embedded distiller layer stopped resolving a
runner; the extensionAPI.js import layer no longer exists on those hosts;
and the CLI layer hardcoded `openclaw agent --local`, which the host now
refuses while a gateway owns the state directory. Every reflection fell
through to the minimal fallback pointer, and the 400-char diagnostic clip
showed only startup banners instead of the refusal.

The runner lookup now accepts runEmbeddedAgent first and the legacy name
second (SDK layer and extensionAPI.js layer alike); the distiller run is
marked sessionPersistence: "detached" so it never lands in the session
store; the CLI fallback drives `openclaw agent exec` (prompt over stdin,
workspace via --cwd, resolved provider/model via --model) and retries the
legacy --local shape only when the host rejects the exec arguments; and
stderr diagnostics keep the tail with state-migration banners and ANSI
codes removed.

Regressions: runner-name preference and legacy acceptance, the detached
embedded run through runEmbeddedAgent, the exec and legacy argument
shapes, the legacy retry decision, tail-preserving diagnostic clipping,
and the exec JSON envelope (payloads then final).
@gorkem2020 gorkem2020 changed the title fix(reflection): read the departing transcript from the hook context on SQLite hosts fix(reflection): restore reflection on current hosts (hook transcript, runEmbeddedAgent runner, headless CLI fallback) Sep 12, 2026
… runner

Current hosts run a plugin session-ownership check on every embedded run
and treat a sessionFile that is not a session key as a foreign transcript
("Plugin session ownership checks require a SQLite transcript marker"), so
the distiller's temporary jsonl path made runEmbeddedAgent refuse the run
and reflection fell through to the CLI layer on every boundary. The
legacy runEmbeddedPiAgent still reads its transcript from that path.

The runner loader now records which export it resolved, and the distiller
passes sessionFile only when the legacy runner is in use; the detached
run on current hosts carries no transcript path at all.

Regressions: the renamed-runner run sees no sessionFile; the legacy runner
still receives a jsonl path.
@gorkem2020
gorkem2020 marked this pull request as ready for review September 12, 2026 16:15
… before_reset messages

The Gateway command path emits command:new before the departing transcript
is captured, so the hook can arrive with neither previousSessionMemory nor a
session file. Park such a boundary per session key and finish the same
reflection pipeline from the typed before_reset hook, which core fires right
after the command hooks on every path with the departing messages. A boundary
reflects at most once; orphan or non-boundary before_reset events are ignored.
…d's root-work context

Core refuses embedded sub-runs enqueued from a released root-work context,
which is where the fire-and-forget before_reset hook runs, so the
continuation fell back to the CLI runner on every /new. Capture an async
context snapshot at registration and run the continuation in it; the global
restart and suspension fences still apply.

@rwmjhb rwmjhb left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The core direction is good, and I verified the relevant contracts against current OpenClaw 2026.9.1: before_reset, runEmbeddedAgent, sessionPersistence: "detached", and the proposed agent exec flags all exist. The full suite and npm run build also pass. The fallback still has four correctness/safety blockers:

  • The embedded path is explicitly model-only (disableTools: true, modelRun: true), but the CLI fallback sends the session transcript to openclaw agent exec in the real workspace with no enforced tool restriction. Current OpenClaw defaults that command to tools.profile = "coding" and tools.exec.mode = "full", and its CLI exposes no disable-tools option. A user-controlled instruction in the transcript can therefore invoke shell/filesystem tools during an automatic reset. Please use a genuinely tool-free completion path or otherwise enforce a deny-all tool surface and test that enforcement.
  • Exec mode drops params.agentId. Current agent exec has no --agent flag and resolves the ambient/default owner and that owner's auth directory, so a non-default agent can use the wrong OAuth profile/configuration or fail despite having valid credentials. The fallback must preserve the source agent identity.
  • embeddedRunnerExportName is updated independently of the embeddedPiRunnerPromise cached with ??=. Reproducing a legacy-runner resolution followed by a current-runner resolution returns the cached legacy runner twice, while the second call labels it current and omits sessionFile. Cache the runner and its kind atomically.
  • A recovered but empty/unusable legacy transcript goes directly to the empty guard and never parks for before_reset. On a migrated host with a stale JSONL artifact, that stale file can therefore hide the valid SQLite-backed messages supplied by the typed hook. Park the boundary when the file yields no usable conversation as well.

Please add integration coverage for a tool-free, source-agent-preserving CLI fallback, plus regressions for runner-kind cache transitions and the empty-file-to-before_reset continuation.

…park empty transcripts for before_reset

The CLI fallback ran the departing transcript through an agent turn with
the host's default tool surface and no --agent flag; replace it with a
plain completion on the plugin's own LLM lane (completeText on every
transport), so no tool can be reached and no agent identity has to be
resolved. Cache the embedded runner together with its kind so a later host
surface cannot relabel it. A recovered transcript file that yields no usable
conversation parks the boundary for the typed before_reset messages instead
of recording the empty guard.
@gorkem2020

Copy link
Copy Markdown
Contributor Author

Thanks, all four are addressed on the new head (307a21c).

  • Tool exposure: the CLI runner is removed. The fallback is now a plain completion on the plugin's own LLM lane (completeText, added to the LLM client for the host, api-key and OAuth transports), fed the distiller's system and user prompts. There is no agent turn, so there is no tool surface to restrict and nothing to enforce; tests cover the prompts reaching the completion, the static fallback when it returns nothing, and the case with no completion client.
  • Agent identity: with no agent turn there is no owner or auth directory to resolve. The completion uses the lane the plugin already owns (model from memoryReflection.model, credentials from llm.*), the same lane extraction and admission use.
  • Runner cache: loadEmbeddedPiRunner now caches { runner, exportName } as one value and the transcript-file decision reads the cached kind; a regression resolves a legacy surface first and a current one second and checks the cached runner stays labeled legacy and keeps its transcript file.
  • Empty recovered transcript: a file that yields no usable conversation parks the boundary for before_reset like a missing file does; the continuation bypasses the empty guard and records it only when the before_reset messages are empty. The repeated-empty guard test now drives that through before_reset, and a stale-file case was added.

Suite and build are green; the PR body is updated to match.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants