From 599ae7fed849b91fc01b79c247f3d6878cfec1c5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 19:47:11 +0100 Subject: [PATCH] docs: clarify tool result details persistence --- docs/concepts/messages.md | 13 +++++++++++++ docs/plugins/hooks.md | 15 +++++++++++++++ src/agents/session-tool-result-guard.ts | 9 +++++++++ 3 files changed, 37 insertions(+) diff --git a/docs/concepts/messages.md b/docs/concepts/messages.md index 1b38872bc17..7d6ee3876af 100644 --- a/docs/concepts/messages.md +++ b/docs/concepts/messages.md @@ -77,6 +77,19 @@ gateway-backed session transcript, so they are the source of truth. Details: [Session management](/concepts/session). +## Tool result metadata + +Tool result `content` is the model-visible result. Tool result `details` is +runtime metadata for UI rendering, diagnostics, media delivery, and plugins. + +OpenClaw keeps that boundary explicit: + +- `toolResult.details` is stripped before provider replay and compaction input. +- Persisted session transcripts keep only bounded `details`; oversized metadata + is replaced with a compact summary marked `persistedDetailsTruncated: true`. +- Plugins and tools should put text the model must read in `content`, not only + in `details`. + ## Inbound bodies and history context OpenClaw separates the **prompt body** from the **command body**: diff --git a/docs/plugins/hooks.md b/docs/plugins/hooks.md index 44c252554b7..89d9763d3ec 100644 --- a/docs/plugins/hooks.md +++ b/docs/plugins/hooks.md @@ -147,6 +147,21 @@ Rules: - `onResolution` receives the resolved approval decision — `allow-once`, `allow-always`, `deny`, `timeout`, or `cancelled`. +### Tool result persistence + +Tool results can include structured `details` for UI rendering, diagnostics, +media routing, or plugin-owned metadata. Treat `details` as runtime metadata, +not prompt content: + +- OpenClaw strips `toolResult.details` before provider replay and compaction + input so metadata does not become model context. +- Persisted session entries keep only bounded `details`. Oversized details are + replaced with a compact summary and `persistedDetailsTruncated: true`. +- `tool_result_persist` and `before_message_write` run before the final + persistence cap. Hooks should still keep returned `details` small and avoid + placing prompt-relevant text only in `details`; put model-visible tool output + in `content`. + ## Prompt and model hooks Use the phase-specific hooks for new plugins: diff --git a/src/agents/session-tool-result-guard.ts b/src/agents/session-tool-result-guard.ts index 1f7600a980b..cc47cc53f43 100644 --- a/src/agents/session-tool-result-guard.ts +++ b/src/agents/session-tool-result-guard.ts @@ -44,6 +44,10 @@ function resolveMaxToolResultChars(opts?: { maxToolResultChars?: number }): numb return Math.max(1, opts?.maxToolResultChars ?? DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS); } +// `details` is runtime/UI metadata, not model-visible tool output. Keep the +// session JSONL useful for debugging without letting metadata blobs dominate +// disk, replay repair, transcript broadcasts, or future tooling that reads raw +// sessions. Model-visible text belongs in tool result `content`. const MAX_PERSISTED_TOOL_RESULT_DETAILS_BYTES = 8_192; const MAX_PERSISTED_DETAIL_STRING_CHARS = 2_000; const MAX_PERSISTED_DETAIL_SESSION_COUNT = 10; @@ -102,6 +106,9 @@ function buildPersistedDetailsFallback( originalSize: BoundedJsonUtf8Bytes, sanitizedBytes?: number, ): Record { + // If even the structured summary is too large, keep only shape and stable + // status fields. This preserves "what happened?" without persisting the raw + // diagnostics payload that caused the cap to trip. const fallback: Record = { persistedDetailsTruncated: true, finalDetailsTruncated: true, @@ -150,6 +157,8 @@ function sanitizeToolResultDetailsForPersistence(details: unknown): unknown { if (details === undefined || details === null) { return details; } + // Measure with an early-exit walker so hostile or enormous details do not + // need to be fully stringified just to learn they exceed the persistence cap. const originalSize = boundedJsonUtf8Bytes(details, MAX_PERSISTED_TOOL_RESULT_DETAILS_BYTES); if (originalSize.complete && originalSize.bytes <= MAX_PERSISTED_TOOL_RESULT_DETAILS_BYTES) { return details;