Skip to content

feat(plugins): add transform_context hook for per-LLM-call message re…#57585

Closed
HaotianChen616 wants to merge 1 commit into
openclaw:mainfrom
HaotianChen616:main
Closed

feat(plugins): add transform_context hook for per-LLM-call message re…#57585
HaotianChen616 wants to merge 1 commit into
openclaw:mainfrom
HaotianChen616:main

Conversation

@HaotianChen616

Copy link
Copy Markdown

Summary

  • Problem: Plugins have no way to modify the full messages array before each LLM call in the agentic loop. Existing hooks (before_prompt_build, llm_input, tool_result_persist) are either one-shot, observation-only, or per-message β€” none provide context-level access on every LLM turn. And assemble is called per attempt but not per-llm, which is outside the activeSession.prompt() loop.
  • Why it matters: Context compression strategies need to see the entire message history to make intelligent decisions. Without this hook, such compression is impossible to implement as a plugin.
  • What changed: Added a new transform_context plugin hook that wraps Agent.transformContext, firing before every LLM call with the complete messages array. Plugins can return a modified messages array that replaces the original. Execution order: original transformContext β†’ plugin hook β†’ budget enforcement (truncation/compaction).
  • What did NOT change: No changes to pi-agent-core internals, budget enforcement logic, existing hooks, or the agentic loop control flow. The hook is purely additive β€” if no plugin registers transform_context, behavior is identical to before.

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor required for the fix
  • Docs
  • Security hardening
  • Chore/infra

Scope (select all touched areas)

  • Gateway / orchestration
  • Skills / tool execution
  • Auth / tokens
  • Memory / storage
  • Integrations
  • API / contracts
  • UI / DX
  • CI/CD / infra

Linked Issue/PR

  • Closes #
  • Related #
  • This PR fixes a bug or regression

Root Cause / Regression History (if applicable)

N/A

Regression Test Plan (if applicable)

N/A

User-visible / Behavior Changes

None. This is a new plugin API surface β€” no behavior changes unless a plugin explicitly registers a transform_context handler.

Diagram (if applicable)

Before (no hook):
agentic loop β†’ agent.transformContext(msgs) β†’ budget enforcement β†’ LLM

After (with plugin):
agentic loop β†’ agent.transformContext(msgs)
                β†’ original transformContext
                β†’ plugin transform_context hook  ← NEW: plugins can rewrite msgs
                β†’ budget enforcement
                β†’ LLM

Security Impact (required)

  • New permissions/capabilities? (No)
  • Secrets/tokens handling changed? (No)
  • New/changed network calls? (No)
  • Command/tool execution surface changed? (No)
  • Data access scope changed? (No)

Repro + Verification

Environment

  • OS: macOS
  • Runtime: Node.js / Bun
  • Model/provider: N/A (hook level, model-agnostic)

Steps

  1. Register a plugin with a transform_context handler
  2. Run an agentic session that triggers multiple LLM calls (e.g. a coding task requiring tool use)
  3. Verify the handler receives the full messages array on each LLM call
  4. Verify returning { messages: modified } from the handler replaces the messages sent to the LLM
  5. Verify the last tool result in the array is preserved (not compressed) when the handler implements SCCS logic

Expected

  • Handler fires before every LLM call
  • Returned messages array is used by the LLM
  • Budget enforcement still runs after the plugin hook (as a safety net)

Actual

(To be verified)

Evidence

  • Failing test/log before + passing after
  • Trace/log snippets
  • Screenshot/recording
  • Perf numbers (if relevant)

Human Verification (required)

  • Verified scenarios:
    • transform_context hook type definitions compile without errors
    • runTransformContext runner integrates into HookRunner export
    • installToolResultContextGuard accepts and invokes the optional pluginTransform callback
    • attempt.ts correctly wires hookRunner.runTransformContext into the guard
    • Plugin-side handler in openclaw-plugin registers via api.on("transform_context", ...)
  • Edge cases checked:
    • No transform_context handlers registered β†’ pluginTransform is undefined, no-op
    • Handler returns undefined β†’ original messages pass through unchanged
    • Handler returns { messages } β†’ modified messages replace original
  • What you did not verify:
    • End-to-end runtime test with a live LLM session (requires CI/test environment)

Review Conversations

  • I replied to or resolved every bot review conversation I addressed in this PR.
  • I left unresolved only the conversations that still need reviewer or maintainer judgment.

Compatibility / Migration

  • Backward compatible? (Yes)
  • Config/env changes? (No)
  • Migration needed? (No)

Risks and Mitigations

  • Risk: A misbehaving plugin could return a malformed messages array, causing the LLM call to fail.
    • Mitigation: Budget enforcement runs after the plugin hook as a safety net. Existing error handling in the agentic loop will catch malformed input and retry/abort as appropriate. This is the same trust model as before_prompt_build (which can also inject arbitrary content).

…writing

Expose Agent.transformContext as a plugin hook so plugins can inspect
and modify the full messages array before every LLM call in the
agentic loop. This enables context-level transformations (e.g.
compressing historical tool results while preserving the most recent
one) that per-message hooks like tool_result_persist cannot achieve.

- Add PluginHookTransformContextEvent/Result types and handler signature
- Add runTransformContext to HookRunner (sequential, lastDefined merge)
- Extend installToolResultContextGuard with optional pluginTransform
  callback, invoked between the original transformContext and budget
  enforcement
- Wire hookRunner.runTransformContext through attempt.ts
@openclaw-barnacle openclaw-barnacle Bot added agents Agent runtime and tooling size: S labels Mar 30, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

πŸ’‘ Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4c0229f29a

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with πŸ‘.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/plugins/types.ts
@greptile-apps

greptile-apps Bot commented Mar 30, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a transform_context plugin hook that fires before every LLM call in the agentic loop, giving plugins the ability to inspect and rewrite the full messages array (e.g. for context compression). The change is purely additive: when no transform_context handlers are registered the code path is identical to before, and budget enforcement continues to run as a safety net after any plugin transform.

Key observations from the review:

  • Execution order is correct: originalTransformContext β†’ pluginTransform β†’ enforceToolResultContextBudgetInPlace. The fallback logic in both tool-result-context-guard.ts and attempt.ts correctly preserves the prior-transformed messages if the plugin returns nothing.
  • "Last handler wins" with non-composable transforms: All handlers in runTransformContext receive the same original event.messages (the hook runner does not update the event between calls). With hooks sorted highest-priority-first and lastDefined favouring next, a lower-priority handler's result silently overwrites a higher-priority handler's transformation without ever seeing it. This is consistent with other runModifyingHook uses in the codebase but is worth documenting explicitly for plugin authors.
  • pluginTransform never returns undefined: The ?? messages fallback in attempt.ts means the callback always resolves to AgentMessage[], making the if (pluginMessages) nil-check in tool-result-context-guard.ts always true. Not a functional bug, but the parameter type and implementation are misaligned.
  • unknown[] typing: messages in both the event and result types uses unknown[], consistent with other hooks, but the downstream cast result?.messages as AgentMessage[] is unchecked.

Confidence Score: 5/5

Safe to merge β€” all findings are P2 style/design suggestions with no functional breakage on the changed path.

No P0 or P1 issues found. The feature is purely additive, budget enforcement is unchanged, and the fallback behavior is correct. Remaining comments concern documentation clarity, type-contract alignment, and non-composable handler semantics β€” all non-blocking.

No files require special attention; minor follow-ups in src/plugins/hooks.ts (composability docs) and src/agents/pi-embedded-runner/run/attempt.ts (return-type alignment) are optional.

Important Files Changed

Filename Overview
src/agents/pi-embedded-runner/run/attempt.ts Wires hookRunner.runTransformContext into installToolResultContextGuard via the new pluginTransform callback. The ?? messages fallback means the callback never returns undefined, making the guard's nil-check a no-op, but there is no functional breakage.
src/agents/pi-embedded-runner/tool-result-context-guard.ts Adds the optional pluginTransform callback, invoked after originalTransformContext and before budget enforcement. Logic is correct: falls back to the original transformed value if the plugin returns nothing, and preserves all existing budget-enforcement behavior.
src/plugins/hooks.ts Adds runTransformContext using runModifyingHook with 'last defined wins' semantics. All handlers receive the original event messages rather than the progressively transformed output, which is consistent with other hooks but may surprise plugin authors expecting composable transforms.
src/plugins/types.ts Adds PluginHookTransformContextEvent and PluginHookTransformContextResult types using unknown[] for messages (consistent with other hooks), adds transform_context to the PluginHookName union and PLUGIN_HOOK_NAMES array, and registers the handler signature in PluginHookHandlerMap.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/plugins/types.ts
Line: 1991-2002

Comment:
**`unknown[]` in event/result loses type safety at the boundary**

`PluginHookTransformContextEvent.messages` and `PluginHookTransformContextResult.messages` are both `unknown[]`, while the actual runtime values are `AgentMessage[]`. The cast in `attempt.ts` (`result?.messages as AgentMessage[]`) is unchecked β€” a plugin returning structurally invalid objects in the array will only fail later, deep inside the budget-enforcement or LLM-call paths, with no actionable error message.

Other event types in this file (e.g. `PluginHookBeforeCompactionEvent`) also use `unknown[]` for messages, so this is consistent with the existing API surface. If that's intentional for plugin isolation, it's worth a brief comment here noting that callers should treat the returned array as unvalidated and handle failures accordingly.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/plugins/hooks.ts
Line: 549-568

Comment:
**Multiple handlers receive original messages, not chained output**

The `runModifyingHook` loop passes the same `event` object to every handler without updating `event.messages` between calls. Combined with `lastDefined(acc?.messages, next.messages)` (last non-undefined value wins, with hooks sorted highest-priority-first), this means:

- Handler A (priority=100) receives original messages and returns `[compressed…]`
- Handler B (priority=50) receives the **same original messages** β€” not Handler A's output β€” and returns `[annotated…]`
- Final result: `[annotated…]` (Handler A's compression is silently discarded)

The docstring says "last handler that returns `{ messages }` wins," which is correct, but it doesn't make clear that handlers **cannot see or build on prior handlers' transformations**. This is a meaningful footgun for plugin authors who assume sequential transforms compose. Consider either:

1. Updating `event.messages` to the accumulated result before each handler call so transforms chain, or
2. Making the non-composable intent explicit in the JSDoc.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/agents/pi-embedded-runner/run/attempt.ts
Line: 868

Comment:
**`pluginTransform` never returns `undefined`, making the guard's `if (pluginMessages)` check dead code**

The `?? messages` fallback ensures `pluginTransform` always resolves to an `AgentMessage[]`, never `undefined`. The `if (pluginMessages)` guard in `tool-result-context-guard.ts` is therefore always truthy when `pluginTransform` is set.

This is not a functional bug, but the `Promise<AgentMessage[] | undefined>` return type on `installToolResultContextGuard`'s `pluginTransform` parameter is misleading. Either return `undefined` explicitly when `result?.messages` is nullish, or tighten the parameter type to `(messages: AgentMessage[]) => Promise<AgentMessage[]>`.

```suggestion
              return result?.messages != null ? (result.messages as AgentMessage[]) : undefined;
```

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "feat(plugins): add transform_context hoo..." | Re-trigger Greptile

Comment thread src/plugins/types.ts
Comment on lines +1991 to +2002
export type PluginHookTransformContextEvent = {
/** The complete messages array about to be sent to the LLM. */
messages: unknown[];
};

export type PluginHookTransformContextResult = {
/**
* If provided, replaces the messages array sent to the LLM.
* Return only the modified messages β€” the caller will use this in place of the original.
*/
messages?: unknown[];
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 unknown[] in event/result loses type safety at the boundary

PluginHookTransformContextEvent.messages and PluginHookTransformContextResult.messages are both unknown[], while the actual runtime values are AgentMessage[]. The cast in attempt.ts (result?.messages as AgentMessage[]) is unchecked β€” a plugin returning structurally invalid objects in the array will only fail later, deep inside the budget-enforcement or LLM-call paths, with no actionable error message.

Other event types in this file (e.g. PluginHookBeforeCompactionEvent) also use unknown[] for messages, so this is consistent with the existing API surface. If that's intentional for plugin isolation, it's worth a brief comment here noting that callers should treat the returned array as unvalidated and handle failures accordingly.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/plugins/types.ts
Line: 1991-2002

Comment:
**`unknown[]` in event/result loses type safety at the boundary**

`PluginHookTransformContextEvent.messages` and `PluginHookTransformContextResult.messages` are both `unknown[]`, while the actual runtime values are `AgentMessage[]`. The cast in `attempt.ts` (`result?.messages as AgentMessage[]`) is unchecked β€” a plugin returning structurally invalid objects in the array will only fail later, deep inside the budget-enforcement or LLM-call paths, with no actionable error message.

Other event types in this file (e.g. `PluginHookBeforeCompactionEvent`) also use `unknown[]` for messages, so this is consistent with the existing API surface. If that's intentional for plugin isolation, it's worth a brief comment here noting that callers should treat the returned array as unvalidated and handle failures accordingly.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread src/plugins/hooks.ts
Comment on lines +549 to +568
/**
* Run transform_context hook.
* Allows plugins to modify the full messages array before each LLM call.
* Runs sequentially; the last handler that returns { messages } wins.
*/
async function runTransformContext(
event: PluginHookTransformContextEvent,
ctx: PluginHookAgentContext,
): Promise<PluginHookTransformContextResult | undefined> {
return runModifyingHook<"transform_context", PluginHookTransformContextResult>(
"transform_context",
event,
ctx,
{
mergeResults: (acc, next) => ({
messages: lastDefined(acc?.messages, next.messages),
}),
},
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Multiple handlers receive original messages, not chained output

The runModifyingHook loop passes the same event object to every handler without updating event.messages between calls. Combined with lastDefined(acc?.messages, next.messages) (last non-undefined value wins, with hooks sorted highest-priority-first), this means:

  • Handler A (priority=100) receives original messages and returns [compressed…]
  • Handler B (priority=50) receives the same original messages β€” not Handler A's output β€” and returns [annotated…]
  • Final result: [annotated…] (Handler A's compression is silently discarded)

The docstring says "last handler that returns { messages } wins," which is correct, but it doesn't make clear that handlers cannot see or build on prior handlers' transformations. This is a meaningful footgun for plugin authors who assume sequential transforms compose. Consider either:

  1. Updating event.messages to the accumulated result before each handler call so transforms chain, or
  2. Making the non-composable intent explicit in the JSDoc.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/plugins/hooks.ts
Line: 549-568

Comment:
**Multiple handlers receive original messages, not chained output**

The `runModifyingHook` loop passes the same `event` object to every handler without updating `event.messages` between calls. Combined with `lastDefined(acc?.messages, next.messages)` (last non-undefined value wins, with hooks sorted highest-priority-first), this means:

- Handler A (priority=100) receives original messages and returns `[compressed…]`
- Handler B (priority=50) receives the **same original messages** β€” not Handler A's output β€” and returns `[annotated…]`
- Final result: `[annotated…]` (Handler A's compression is silently discarded)

The docstring says "last handler that returns `{ messages }` wins," which is correct, but it doesn't make clear that handlers **cannot see or build on prior handlers' transformations**. This is a meaningful footgun for plugin authors who assume sequential transforms compose. Consider either:

1. Updating `event.messages` to the accumulated result before each handler call so transforms chain, or
2. Making the non-composable intent explicit in the JSDoc.

How can I resolve this? If you propose a fix, please make it concise.

workspaceDir: params.workspaceDir,
},
);
return (result?.messages as AgentMessage[]) ?? messages;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 pluginTransform never returns undefined, making the guard's if (pluginMessages) check dead code

The ?? messages fallback ensures pluginTransform always resolves to an AgentMessage[], never undefined. The if (pluginMessages) guard in tool-result-context-guard.ts is therefore always truthy when pluginTransform is set.

This is not a functional bug, but the Promise<AgentMessage[] | undefined> return type on installToolResultContextGuard's pluginTransform parameter is misleading. Either return undefined explicitly when result?.messages is nullish, or tighten the parameter type to (messages: AgentMessage[]) => Promise<AgentMessage[]>.

Suggested change
return (result?.messages as AgentMessage[]) ?? messages;
return result?.messages != null ? (result.messages as AgentMessage[]) : undefined;
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/pi-embedded-runner/run/attempt.ts
Line: 868

Comment:
**`pluginTransform` never returns `undefined`, making the guard's `if (pluginMessages)` check dead code**

The `?? messages` fallback ensures `pluginTransform` always resolves to an `AgentMessage[]`, never `undefined`. The `if (pluginMessages)` guard in `tool-result-context-guard.ts` is therefore always truthy when `pluginTransform` is set.

This is not a functional bug, but the `Promise<AgentMessage[] | undefined>` return type on `installToolResultContextGuard`'s `pluginTransform` parameter is misleading. Either return `undefined` explicitly when `result?.messages` is nullish, or tighten the parameter type to `(messages: AgentMessage[]) => Promise<AgentMessage[]>`.

```suggestion
              return result?.messages != null ? (result.messages as AgentMessage[]) : undefined;
```

How can I resolve this? If you propose a fix, please make it concise.

@clawsweeper

clawsweeper Bot commented Apr 26, 2026

Copy link
Copy Markdown
Contributor

Closing this as implemented after Codex automated review.

Current main already solves the PR's central request through the shipped ContextEngine plugin surface. A plugin can register a context engine, receive the full AgentMessage[] in assemble(), return a replacement ordered message array, and, when it owns compaction, run through Agent.transformContext during long tool loops. Tests cover repeated loop iterations and replacement arrays. The raw transform_context hook proposed here is no longer the best core shape, especially given the review feedback about prompt-mutation policy.

Best possible solution:

Keep the shipped ContextEngine plugin surface as the supported path for full-context compression and model-context rewriting. Plugin authors should register a context engine and select it with plugins.slots.contextEngine; separate llm_input/llm_output interception work can remain in its own PR if maintainers want prompt/response-level hooks.

What I checked:

  • Context engine contract exposes full message rewriting: ContextEngine.assemble receives the full AgentMessage[] plus token budget/model/prompt context and returns an AssembleResult whose messages are the ordered model context. (src/context-engine/types.ts:236, 7e376e5aba32)
  • Plugin API registers context engines: Plugin registration exposes api.registerContextEngine and records plugin-owned context engine ids, so this is available as a plugin surface rather than a core-only internal hook. (src/plugins/registry.ts:1510, 7e376e5aba32)
  • Agent loop wires owning engines into transformContext: The embedded attempt runner installs installContextEngineLoopHook when params.contextEngine.info.ownsCompaction is true, replacing the ordinary tool-result guard for engine-owned context management. (src/agents/pi-embedded-runner/run/attempt.ts:1419, 7e376e5aba32)
  • Per-loop assembly replaces messages sent onward: installContextEngineLoopHook wraps Agent.transformContext, calls afterTurn/ingest for new messages, calls contextEngine.assemble with the full source messages, and returns assembled.messages when the engine provides a replacement view. (src/agents/pi-embedded-runner/tool-result-context-guard.ts:188, 7e376e5aba32)
  • Tests cover repeated LLM-loop deltas and rewrites: Tests verify afterTurn and assemble run when messages are appended across iterations, the loop fence advances across multiple iterations, and returned assembled views replace the source even when message count is unchanged. (src/agents/pi-embedded-runner/tool-result-context-guard.test.ts:444, 7e376e5aba32)
  • Docs and changelog describe the shipped surface: Docs show plugin authors registering api.registerContextEngine and selecting plugins.slots.contextEngine; the changelog records the context-engine plugin interface in v2026.3.7 and the long tool-loop bounded/engine-owned fix in v2026.4.14. Public docs: docs/concepts/context-engine.md. (docs/concepts/context-engine.md:125, 7e376e5aba32)

So I’m closing this as already implemented rather than keeping a duplicate issue open.

Codex Review notes: model gpt-5.5, reasoning high; reviewed against 7e376e5aba32; fix evidence: release v2026.4.14, commit 7e376e5aba32.

@clawsweeper clawsweeper Bot closed this Apr 26, 2026
@HaotianChen616

Copy link
Copy Markdown
Author

I appreciate the review, but I believe the closure conflates two separate concerns.What the bot's argument gets right:
"A plugin can register a context engine, receive the full AgentMessage[] in assemble(), return a replacement ordered message array" β€” This is accurate. assemble() does receive the full message array and can reorder or rewrite it.What the bot's argument misses:
The issue this PR addresses is not about what assemble() can do, but when it gets called.The actual call flow:

User prompt
β†’ assembleAttemptContextEngine() ← fires ONCE, before the loop
β†’ activeSession.prompt() ← agentic loop starts here
LLM call 1
β†’ tool execution β†’ tool result
β†’ LLM call 2 ← ❌ no assemble() before this call
β†’ tool execution β†’ tool result
β†’ LLM call 3 ← ❌ no assemble() before this call
β†’ final response

assembleAttemptContextEngine() is called at attempt.ts:1904, outside and before activeSession.prompt(). It fires once per user prompt, not per LLM call within the agentic loop.

Why installContextEngineLoopHook doesn't fully close the gap:

  1. The transformContext hook path is only active when contextEngine.info.ownsCompaction === true.
  2. transformContext's invocation frequency in pi-agent-core is not per-LLM-call β€” it fires at specific internal points that don't align with every iteration of the agentic loop.

This is exactly the gap this PR addresses: context compression needs to inspect and potentially rewrite the message array before each LLM call, not just once at the start.

I'd respectfully ask that this PR be reopened, or at minimum, that this gap be tracked as a follow-up issue so it doesn't get lost.

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

Labels

agents Agent runtime and tooling size: S

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant