Skip to content

[Think] beforeStep message override can discard proactive compaction #2212

Description

@O4epegb

Describe the bug

When Think’s proactive context-overflow guard compacts the conversation before a step, beforeStep still receives the original, uncompacted event.messages.

If beforeStep performs an additive message override based on that context:

override beforeStep(ctx: PrepareStepContext): StepConfig {
  return {
    messages: [
      ...ctx.messages,
      { role: "user", content: "Additional per-step context" }
    ]
  };
}

Think treats the returned messages as authoritative and does not apply the compacted messages. Consequently, the upcoming model request uses the original history again, effectively discarding the proactive compaction.

This only occurs when:

  1. Proactive compaction successfully fires.
  2. beforeStep returns a messages override.

Proactive compaction works normally when beforeStep does not return messages.

To Reproduce

  1. Configure a Think agent with session compaction and a low proactive threshold:
override contextOverflow = {
  proactive: {
    maxInputTokens: 10,
    headroom: 0.9
  }
};
  1. Override beforeStep to append context to the messages it receives:
override beforeStep(ctx: PrepareStepContext): StepConfig {
  return {
    messages: [
      ...ctx.messages,
      { role: "user", content: "Additional per-step context" }
    ]
  };
}
  1. Run a multi-step turn where the first model step reports input usage above the proactive threshold—for example, a tool-call followed by another model step.

  2. Observe that:

    • Session compaction runs.
    • A chat:context:compacted event is emitted.
    • beforeStep receives the original messages without the compaction summary.
    • The next model request contains the original history plus the additional message, rather than the compacted history plus that message.

The relevant control flow currently appears to be:

const guarded = await this._maybeProactiveContextCompact(event);
const result = await this.beforeStep(event);

const base = result == null ? {} : result;
const baseMessages = (base as { messages?: unknown }).messages;

const withMessages =
  guarded && baseMessages === undefined
    ? { ...base, messages: guarded }
    : base;

Because _maybeProactiveContextCompact returns a new message array without mutating event.messages, beforeStep(event) receives the old array. When beforeStep returns messages, guarded is then ignored.

Expected behavior

When proactive compaction succeeds, beforeStep should receive a prepare-step context containing the compacted messages:

const guarded = await this._maybeProactiveContextCompact(event);

const stepEvent = guarded
  ? { ...event, messages: guarded }
  : event;

const result = await this.beforeStep(stepEvent);

This would preserve the documented override behavior: beforeStep could still replace or extend the messages, but its input would be the history Think actually intends to send after compaction.

This also matches the existing source comment:

Proactive context guard runs first so beforeStep sees the recompacted messages and can still override them if it wants to.

Version:

  • @cloudflare/think: 0.17.0
  • agents: 0.22.0

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions