Skip to content

hooks: support { skip: true } in before_agent_start to prevent agent run#32802

Open
haosenwang1018 wants to merge 5 commits intoopenclaw:mainfrom
haosenwang1018:feat/before-agent-start-skip
Open

hooks: support { skip: true } in before_agent_start to prevent agent run#32802
haosenwang1018 wants to merge 5 commits intoopenclaw:mainfrom
haosenwang1018:feat/before-agent-start-skip

Conversation

@haosenwang1018
Copy link
Copy Markdown
Contributor

Summary

  • Problem: before_agent_start hook can only modify agent runs (systemPrompt, prependContext, modelOverride) but cannot skip them entirely. This forces group message monitoring plugins to trigger a full LLM agent run for every observed message, wasting resources and bloating session history.
  • Why it matters: Enterprise IM monitoring use cases (Feishu/Lark, WeChat Work) need to observe all group messages for risk detection without triggering the agent or writing to session state.
  • What changed: Added skip?: boolean to PluginHookBeforeAgentStartResult. When a before_agent_start hook returns { skip: true }, the agent run is skipped entirely — no LLM call, no session write. The run returns immediately with an empty payload.
  • What did NOT change (scope boundary): No changes to other hooks, session management, or the message pipeline. The secondary issue (missing conversationId in message_sending ctx) is not addressed here.

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor
  • 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

User-visible / Behavior Changes

  • Plugins can now return { skip: true } from before_agent_start hook handlers to skip the agent run entirely. No LLM call is made and no session state is written.

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/container: Node 22+, Bun
  • Model/provider: N/A (hook-level change)

Steps

  1. Register a before_agent_start hook that returns { skip: true }
  2. Send a message to the agent
  3. Observe that no LLM call is made and no session state is written

Expected

  • Agent run is skipped, empty payload returned

Actual

  • Agent run is skipped, empty payload returned

Evidence

  • Failing test/log before + passing after
  • 6 new unit tests covering: single-plugin skip, skip with other fields, skip merge across plugins, skip=false, skip=undefined default, lower-priority skip propagation

Human Verification (required)

  • Verified scenarios: All 16 tests pass in hooks.before-agent-start.test.ts (10 existing + 6 new)
  • Edge cases checked: skip merging across multiple plugins, skip with other hook fields, skip=false
  • What you did not verify: End-to-end with a real channel plugin

Compatibility / Migration

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

Failure Recovery (if this breaks)

  • How to disable/revert this change quickly: Plugins simply stop returning { skip: true } or the commit is reverted
  • Files/config to restore: N/A
  • Known bad symptoms reviewers should watch for: Agent silently not responding when a plugin incorrectly sets skip=true

Risks and Mitigations

  • Risk: A misconfigured plugin returning { skip: true } unconditionally would silently prevent all agent runs.
    • Mitigation: The skip is logged ([hooks] before_agent_start returned skip=true; skipping agent run) so operators can diagnose via logs.

@openclaw-barnacle openclaw-barnacle bot added agents Agent runtime and tooling size: M labels Mar 3, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 3, 2026

Greptile Summary

This PR adds skip?: boolean to PluginHookBeforeAgentStartResult, allowing before_agent_start hook handlers to abort an agent run before any LLM call or session write occurs. The core feature is cleanly implemented across the type definition (types.ts), the hook merger (hooks.ts), and the runner gate (run.ts), and is well-covered by 6 new targeted unit tests.

Beyond the headline feature, the PR also ships four unrelated but independently sensible fixes:

  • Compaction safeguard: returns a synthetic compaction result instead of { cancel: true } when there are no real messages to summarize, avoiding an SDK throw in the auto-compaction path.
  • Skills config: adds an early return true when enabled === true is set explicitly, so a configured skill is always loaded regardless of unmet binary requirements.
  • sendMedia degradation: makes sendMedia optional in plugin outbound adapters, falling back to a caption-only sendText call with a warning log.
  • Matrix auto-threading: adds resolveMatrixAutoThreadId mirroring the existing Telegram pattern so replies land in the correct thread.

Key findings:

  • The case-insensitive room ID comparison in resolveMatrixAutoThreadId deviates from the Matrix spec (room IDs are case-sensitive) and from how peer functions handle IDs — this could silently match the wrong room in edge cases.
  • compaction-safeguard.ts uses ?? instead of || for the previousSummary fallback, meaning an empty-string previousSummary is passed through unchanged rather than replaced with the fallback message.
  • The skip merge expression (acc?.skip || next.skip) yields false (not undefined) when both sides are explicitly false, creating a minor inconsistency with other optional fields on the result object.

Confidence Score: 3/5

  • Core skip feature is safe, but the PR bundles multiple independent changes and the Matrix room-ID case comparison is a spec deviation worth resolving before merge.
  • The skip implementation itself is correct and well-tested. However, the Matrix auto-threading code performs a case-insensitive comparison on room IDs that are case-sensitive by spec, which is a logic issue. Additionally, bundling five independent features in one PR raises review risk — a bug in any of the four auxiliary changes would block the skip feature from landing cleanly. The compaction and skills-config changes are behavioral (not just additive), further increasing the surface area to validate.
  • src/infra/outbound/message-action-params.ts (case-insensitive Matrix room ID comparison) and src/agents/pi-extensions/compaction-safeguard.ts (nullish vs falsy check for previousSummary).

Last reviewed commit: 956838b

Copy link
Copy Markdown
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

13 files reviewed, 3 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +108 to +110
return undefined;
}
return context.currentThreadTs;
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.

Case-insensitive Matrix room ID comparison

Matrix room IDs are case-sensitive by the Matrix specification (e.g., !abc123:matrix.org and !ABC123:matrix.org are distinct rooms). The .toLowerCase() comparison on both sides could cause a room mismatch if room IDs happen to differ only by case — while unlikely in practice, it's a silent correctness risk.

The Telegram equivalent (resolveTelegramAutoThreadId) does a direct equality comparison without case-folding, which is the correct default for these identifiers.

Suggested change
return undefined;
}
return context.currentThreadTs;
if (normalizedTo !== normalizedChannel) {
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/infra/outbound/message-action-params.ts
Line: 108-110

Comment:
**Case-insensitive Matrix room ID comparison**

Matrix room IDs are case-sensitive by the Matrix specification (e.g., `!abc123:matrix.org` and `!ABC123:matrix.org` are distinct rooms). The `.toLowerCase()` comparison on both sides could cause a room mismatch if room IDs happen to differ only by case — while unlikely in practice, it's a silent correctness risk.

The Telegram equivalent (`resolveTelegramAutoThreadId`) does a direct equality comparison without case-folding, which is the correct default for these identifiers.

```suggestion
  if (normalizedTo !== normalizedChannel) {
```

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

Comment on lines +219 to +220
const summary =
(preparation.previousSummary ?? "No conversation history to summarize.") + fileOpsSummary;
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.

previousSummary empty-string edge case

The ?? (nullish coalescing) operator only falls back for null / undefined. If preparation.previousSummary is "" (an empty string set by an upstream caller), the fallback text "No conversation history to summarize." is skipped and summary becomes "" + fileOpsSummary. This could leave the stored summary starting with file-op content and no human-readable header.

Consider using the logical-OR operator (or an explicit falsy check) if an empty string should also trigger the fallback:

Suggested change
const summary =
(preparation.previousSummary ?? "No conversation history to summarize.") + fileOpsSummary;
const summary =
(preparation.previousSummary || "No conversation history to summarize.") + fileOpsSummary;
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/pi-extensions/compaction-safeguard.ts
Line: 219-220

Comment:
**`previousSummary` empty-string edge case**

The `??` (nullish coalescing) operator only falls back for `null` / `undefined`. If `preparation.previousSummary` is `""` (an empty string set by an upstream caller), the fallback text `"No conversation history to summarize."` is skipped and `summary` becomes `"" + fileOpsSummary`. This could leave the stored summary starting with file-op content and no human-readable header.

Consider using the logical-OR operator (or an explicit falsy check) if an empty string should also trigger the fallback:

```suggestion
      const summary =
        (preparation.previousSummary || "No conversation history to summarize.") + fileOpsSummary;
```

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

(acc, next) => ({
...mergeBeforePromptBuild(acc, next),
...mergeBeforeModelResolve(acc, next),
skip: acc?.skip || next.skip,
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.

skip merge produces false when both sides are false

acc?.skip || next.skip evaluates to false (not undefined) when both values are explicitly false. While the downstream consumer (if (legacyBeforeAgentStartResult?.skip)) only checks truthiness and is unaffected, this differs from the behaviour of all other optional fields on the result (which remain undefined when absent). It's a minor inconsistency but worth noting in case other consumers inspect the property more strictly.

A more explicit form that preserves undefined semantics:

Suggested change
skip: acc?.skip || next.skip,
skip: (acc?.skip || next.skip) || undefined,
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/plugins/hooks.ts
Line: 308

Comment:
**`skip` merge produces `false` when both sides are `false`**

`acc?.skip || next.skip` evaluates to `false` (not `undefined`) when both values are explicitly `false`. While the downstream consumer (`if (legacyBeforeAgentStartResult?.skip)`) only checks truthiness and is unaffected, this differs from the behaviour of all other optional fields on the result (which remain `undefined` when absent). It's a minor inconsistency but worth noting in case other consumers inspect the property more strictly.

A more explicit form that preserves `undefined` semantics:

```suggestion
        skip: (acc?.skip || next.skip) || undefined,
```

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

Copy link
Copy Markdown

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

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: 956838b0bd

ℹ️ 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".

if (normalizedTo.toLowerCase() !== normalizedChannel.toLowerCase()) {
return undefined;
}
return context.currentThreadTs;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Restrict Matrix auto-threading to real thread contexts

resolveMatrixAutoThreadId unconditionally returns currentThreadTs for same-room sends, but Matrix tool context can set currentThreadTs from ReplyToId when the inbound message is a plain reply (not a thread). In that case, message actions with replyToMode=first|all will now inject a threadId and send as m.thread replies in the wrong context, instead of normal room replies; this changes delivery behavior for non-thread conversations.

Useful? React with 👍 / 👎.

Comment on lines +154 to +158
((ctx: ChannelOutboundContext) => {
log.warn("sendMedia not implemented; degrading to sendText", {
channel: params.channel,
});
return sendText({ ...ctx, mediaUrl: undefined });
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid empty fallback sends for multi-media payloads

The new sendMedia fallback always calls sendText, but outbound delivery invokes sendMedia once per media URL and only the first URL carries the caption. For text-only adapters (no sendMedia), payloads with multiple media URLs now trigger extra empty text sends (or channel-side validation failures on blank messages) for the second and later items, which is a regression from expected single-message degradation behavior.

Useful? React with 👍 / 👎.

@bolander72
Copy link
Copy Markdown

Strong +1 from a production user. I'm running OpenClaw on a Mac mini with Claude Max ($200/mo subscription) + local Ollama (Qwen 3.5:4b). I built a message:preprocessed hook that classifies inbound messages via Qwen and handles simple ones locally (math, smart home commands, greetings) — saves Claude quota for complex tasks.

The classification works perfectly (0.98 confidence, 2.3s latency), but message:preprocessed is fire-and-forget so there's no way to prevent the agent from also running. This PR would solve it — my hook could return { skip: true } after sending its own response.

Real-world routing stats from testing: ~60-70% of messages are simple enough for local Qwen. That's a lot of wasted Claude tokens on "what time is it" and "turn off the lamp."

Would love to see this merged.

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: M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: before_agent_start hook should support { skip: true } to prevent agent run

2 participants