Skip to content

[FEATURE]: Add a per-turn context drift inspector #58459

Open

Description

@richie-rich90454

Feature hasn't been suggested before.

  • I have verified this feature I'm about to request hasn't been suggested before.

Describe the enhancement you want to request

Problem

OpenCode assembles the effective input sent to a model from multiple sources, including system instructions, project instruction files, skills, available tools, MCP schemas, plugin transformations, conversation history, and compaction.

When an agent's behavior changes unexpectedly between turns, inspecting the current system prompt alone does not necessarily explain why. A skill may have been added or removed, a tool definition may have changed, an instruction file may have been reloaded, or conversation history may have been compacted.

Today, diagnosing this kind of context drift can require manually comparing logs, configuration files, prompts, and session history. This makes it unnecessarily difficult to determine whether a behavioral change originated in the model or in the context OpenCode supplied to it.

Proposed solution

Introduce an opt-in context-diff inspector that compares the effective context of the current turn against the preceding turn in the same session.

For example, a command such as opencode debug context-diff could report:

  • Instruction changes: instruction sources added, removed, or modified, including their paths where applicable.
  • Skill changes: skills that entered or left the available context, including changes to their effective definitions.
  • Tool changes: tools added or removed, and changes to their schemas or descriptions.
  • Model configuration changes: changes to the active model, provider, relevant generation settings, or agent.
  • Conversation changes: context compaction, summarized history, or other significant transformations of the conversation supplied to the model.
  • Context budget changes: estimated token-count differences attributable to each category, where measurable.

The output should distinguish confirmed changes from estimates and unavailable information. It should not claim to reconstruct an exact historical request when the necessary data was not recorded.

Suggested interface

A compact report could look like this:

Context changes since the previous turn

Instructions
  ~ AGENTS.md                         modified
  + .opencode/skills/testing/SKILL.md added

Tools
  ~ filesystem.search                 schema changed
  - example_mcp.delete_record         unavailable

Conversation
  ! Context compaction occurred

Estimated context delta
  Instructions                        +1,240 tokens
  Tool definitions                      -380 tokens

The entries above illustrate the proposed interface, not actual OpenCode output.

The inspector should provide enough detail to investigate a change without requiring users to dump the entire model request into the terminal.

Privacy and storage

Context can contain proprietary source code, secrets, private instructions, and user data. The feature should therefore be opt-in, local by default, and careful about what it persists.

A minimal implementation could record source fingerprints, source identifiers, change events, and token estimates rather than storing complete prompts indefinitely. Full prompt inspection could remain a separate, explicitly requested operation.

Acceptance criteria

  1. Users can compare the effective context of two consecutive turns in the same session.
  2. The report identifies which context categories changed and provides source-level details where available.
  3. Context compaction and relevant model or configuration changes are distinguishable from ordinary conversation growth.
  4. Token-count changes are labeled as estimates unless exact measurements are available.
  5. The feature does not silently persist full prompts, credentials, or other sensitive context.
  6. If historical information is unavailable, the inspector clearly identifies that limitation instead of presenting an incomplete diff as authoritative.

Scope

This request is for context-drift diagnostics, not a replacement for prompt export, configuration validation, or general installation diagnostics.

The goal is to make changes to the effective model context observable across turns, so users can investigate unexpected behavioral changes without manually reconstructing the entire request.

Activity

  1. opencode-agent commented on Oct 23, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed write-up! You're asking for an opt-in inspector that shows what changed in the context sent to the model between consecutive turns of a session: instructions, skills, tools and schemas, model/agent/settings, compaction and estimated token deltas. Maintainers will consider it.

    These existing issues are closely related, though none asks for a turn-to-turn diff:

    In the meantime, much of this can be done with a v2 plugin using the session context hook, which runs right before each model call. The plugin below stores only fingerprints in memory, never prompt text. It appends a diff of each request against the previous one in the same session to ~/.local/share/opencode/context-diff/<sessionID>.log. To install it, save it as .opencode/plugins/context-diff/index.ts in your project. It type-checks against the v2 plugin API, but I haven't run it inside OpenCode.

    import { Plugin } from "@opencode/plugin"
    import { createHash } from "node:crypto"
    import { appendFile, mkdir } from "node:fs/promises"
    import { homedir } from "node:os"
    import { join } from "node:path"
    
    type Entry = Map<string, { hash: string; tokens: number }>
    type Snapshot = {
      model: string
      agent: string
      options: string
      system: Entry
      tools: Entry
      messages: { count: number; firstHash: string; tokens: number }
    }
    
    const hash = (v: unknown) => createHash("sha256").update(JSON.stringify(v) ?? "").digest("hex").slice(0, 12)
    // Rough estimate (~4 chars per token), always labelled as such.
    const estimate = (v: unknown) => Math.ceil((JSON.stringify(v) ?? "").length / 4)
    const label = (text: string, i: number) => `#${i} ${(text.split("\n").find((l) => l.trim()) ?? "").trim().slice(0, 50)}`
    
    const diffMap = (title: string, before: Entry, after: Entry, out: string[]) => {
      const lines: string[] = []
      for (const [k, v] of after) {
        const p = before.get(k)
        if (!p) lines.push(`  + ${k}  added`)
        else if (p.hash !== v.hash) lines.push(`  ~ ${k}  changed`)
      }
      for (const k of before.keys()) if (!after.has(k)) lines.push(`  - ${k}  removed`)
      if (lines.length) out.push(title, ...lines)
      const sum = (m: Entry) => [...m.values()].reduce((n, v) => n + v.tokens, 0)
      return sum(after) - sum(before)
    }
    const signed = (n: number) => (n >= 0 ? `+${n}` : `${n}`)
    
    export default Plugin.define({
      id: "context-diff",
      async setup(ctx) {
        const dir = (ctx.options?.dir as string | undefined) ?? join(homedir(), ".local/share/opencode/context-diff")
        await mkdir(dir, { recursive: true })
        const last = new Map<string, Snapshot>()
    
        await ctx.session.hook("context", async (event) => {
          const snap: Snapshot = {
            model: `${event.model.providerID}/${event.model.id}`,
            agent: String(event.agent),
            options: hash(event.options),
            system: new Map(event.system.map((p, i) => [label(p.text, i), { hash: hash(p.text), tokens: estimate(p.text) }])),
            tools: new Map(Object.entries(event.tools).map(([n, t]) => [n, { hash: hash(t), tokens: estimate(t) }])),
            messages: { count: event.messages.length, firstHash: hash(event.messages[0]), tokens: estimate(event.messages) },
          }
          const prev = last.get(event.sessionID)
          last.set(event.sessionID, snap)
    
          const out = [`[${new Date().toISOString()}] session ${event.sessionID}`]
          if (!prev) out.push("  (no previous request recorded since the plugin loaded; nothing to compare)")
          else {
            if (prev.model !== snap.model) out.push(`Model\n  ~ ${prev.model} -> ${snap.model}`)
            if (prev.agent !== snap.agent) out.push(`Agent\n  ~ ${prev.agent} -> ${snap.agent}`)
            if (prev.options !== snap.options) out.push("Generation options\n  ~ changed")
            const sys = diffMap("System / instructions", prev.system, snap.system, out)
            const tools = diffMap("Tools", prev.tools, snap.tools, out)
            const m = snap.messages
            if (m.count < prev.messages.count || m.firstHash !== prev.messages.firstHash)
              out.push("Conversation\n  ! history rewritten (likely compaction)")
            out.push(
              "Estimated delta (chars/4, not exact)",
              `  System        ${signed(sys)}`,
              `  Tools         ${signed(tools)}`,
              `  Conversation  ${signed(m.tokens - prev.messages.tokens)}`,
            )
          }
          await appendFile(join(dir, `${event.sessionID}.log`), out.join("\n") + "\n\n")
        })
      },
    })

    The plugin has some limits compared to what you proposed:

    • System parts reach plugins as plain text with no source path, so changes appear as changed system blocks, not as AGENTS.md or a specific skill file.
    • It compares consecutive model requests, so steps within a single turn are compared too.
    • History is kept only in memory, so the first request after a restart has nothing to compare against.
    • Compaction is guessed when the history gets shorter or its first message changes.
    • It writes to a log file; plugins can't add a debug context-diff CLI command.
  2. Dante-dan commented on Oct 23, 2026

    @Dante-dan
    Contributor

    I’d like to take this through design review. I propose a small core inspector with opt-in, in-memory capture of two consecutive input turns, exposed through the running local service. A fresh CLI process cannot recover an ephemeral baseline.

    I checked the current v2 path at 43868389223f3553c6786c2f1cfa70a657f8a8bf:

    • SessionContext exposes model, agent, system, messages, options and tools, but no turn identifier or instruction/skill source mapping. A plugin can observe blocks, but cannot reliably label them as particular files.
    • SessionModelRequest.prepare changes tools and normalizes media after the context hook. HTTP/WebSocket hooks can change transport data later. The inspector should summarize the prepared core request, explicitly label that boundary, and report transport-level changes as unavailable.

    Proposed first slice

    1. Record a summary after primary request preparation and associate it with admitted input identity in the runner. Compare the first primary request for each consecutive input turn; tool steps/retries do not advance the turn baseline. Mark later requests within a turn separately. Title, generate and compaction requests never replace a primary baseline. Queued/steering input admission must have an explicit boundary rather than inferring turns from message counts.
    2. Keep ordered, keyed fingerprints for final system blocks, tool names/descriptions/schemas, conversation structure, model/provider/agent and an allowlist of generation settings. Record successful compaction/revert events explicitly; ordinary history growth or a changed first-message hash is not evidence of compaction.
    3. Add a separate source summary at instruction selection/loading, using known source keys and project-relative paths where available. Associate it with the prepared request without pretending that pre-hook sources remain individually attributable after arbitrary plugin rewrites. Report unknown instruction/skill/plugin provenance as unavailable; do not parse labels from prompt text. This also distinguishes available skill definitions from skills actually loaded into history.
    4. Retain only two turn summaries per enabled session, with a bounded session cache; clear on disable, session removal or service shutdown. Use ephemeral keyed content fingerprints, opaque identifiers for external paths, and no text-derived labels. No prompt/message bodies, tool arguments/results, headers, credentials, arbitrary provider options, disk logs or export integration. Unknown settings are omitted and marked unavailable. Display safe allowlisted values and changed/unchanged fingerprints only.
    5. Show per-category text/schema token estimates, naming the estimator. Source summaries are explanatory and must not be counted twice in request totals. Media cost and unattributable skill/instruction splits are unavailable; provider aggregate usage cannot establish exact category counts. First capture, restart, eviction and missed admission boundaries report “no comparable baseline,” never a reconstructed historical request.

    Acceptance checks: consecutive input turns versus multi-step/retry/steering flows; instruction/tool/model changes and block reordering; successful/failed compaction versus ordinary growth/revert; explicit estimate/unavailable output; capture disabled by default with no persistent sensitive payload; and restart/eviction/missing-provenance behavior. These are proposed checks, not executed results.

    Could a maintainer approve this capture boundary and ephemeral scope, particularly the input-admission definition of a turn?

  3. richie-rich90454 commented on Oct 24, 2026

    @richie-rich90454
    Author

    This seems consistent with what I had in mind for the feature, especially the distinction between primary input turns and intermediate requests, as well as the emphasis on privacy and explicitly acknowledging unavailable information.

    I would be interested in hearing the maintainers' thoughts on the proposed capture boundary whenever they have a chance.

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