Skip to content

session: title generation request has no output limit and runs away on local thinking models #54401

Open

Description

@ZemtsovVU

Summary

On the first message of a session OpenCode sends a background session-title request to the provider. Unlike the main agent request, this request contains no max_completion_tokens (and no stop), so on a local thinking model it enters an unbounded reasoning loop: the model generates thousands of reasoning_content tokens for ~10 minutes and never emits a title. It also competes for CPU with the main request, slowing the actual chat response to ~1 tok/s.

Environment

  • opencode version: 2.0.26
  • OS: Linux 7.2.9-arch1-1 (x86_64, Arch Linux)
  • Terminal: TERM=xterm-256color, COLORTERM=truecolor
  • Shell: /usr/bin/bash
  • Install/channel: latest (release)
  • Active plugins: none found in config

Provider: LM Studio (Bionic 1.1.8) local server at http://localhost:1235/v1, model qwen3.5-4b@q4_k_m (unsloth Qwen3.5-4B GGUF, Q4_K_M, CPU-only llama.cpp).

Reproduction

  1. Configure LM Studio with a thinking model and point the lmstudio provider at it (~/.config/opencode/opencode.json sets model: "lmstudio/qwen3.5-4b@q4_k_m").
  2. Start opencode, create a new session, send a first message (e.g. hello).
  3. Watch the LM Studio server log (~/.lmstudio/apps/bionic/server-logs/): two POST /v1/chat/completions arrive at the same time — the main agent request, and a second one whose body is only:
{ "model": "qwen3.5-4b@q4_k_m",
  "messages": [
    { "role": "system", "content": "You are a title generator. You output ONLY a thread title..." },
    { "role": "user", "content": "hello" }
  ],
  "stream": true,
  "stream_options": { "include_usage": true },
  "store": false }
  1. The title request keeps streaming reasoning_content for many minutes.

Note the contrast with the main agent request, which does send "max_completion_tokens": 16384 (or 32000).

Expected Behavior

Session title generation should be a small, bounded request: a low output limit (e.g. max_completion_tokens: 64-128), stop sequences, and/or reasoning disabled for this auxiliary call. It should finish in seconds and not delay the main conversation.

Actual Behavior

  • The title request runs effectively unbounded (only the model context limits it).
  • Observed runs in LM Studio logs: one title run generating 1747 tokens over 5 minutes before a lucky stop; another never completing at all.
  • A direct replay of the exact title request shape against the same model: after 75 seconds it had emitted ~870 tokens of pure reasoning_content at ~13 tok/s and still had not produced any title output (the model loops over draft titles in its thinking: "Let's go with 'Hello greeting'. Actually, 'Hello message' sounds better..."). Extrapolated, 10 minutes ≈ 8000-9000 tokens, matching what is observed in the UI.
  • Generation stops only when the HTTP client disconnects (Client disconnected. Stopping generation... in the LM Studio log); OpenCode keeps the connection open.
  • Because llama.cpp here is CPU-only, the parallel title request halves the generation speed of the main request: the reply to hello was generated at 1.08 tok/s while the title run was active.

Additional Context

  • LM Studio logs show a related warning for this model: Reasoning setting 'medium' is not supported ... Falling back to reasoning setting 'on' — reasoning is always on for these requests, which is what the title request runs away into. The model is a hybrid thinking model (Qwen3.5), so even without reasoning_effort it produces reasoning_content first.
  • Separately (may deserve its own issue): on the first message the main agent request is sent twice, byte-identical, about 1-2 seconds apart. Only one of the two starts generation; the other is dropped/queued.
  • Workarounds tried: none effective; disabling reasoning in LM Studio's model loadout or capping output tokens server-side would mask the symptom.
  • Logs used for this report (LM Studio side logs full request bodies): ~/.lmstudio/apps/bionic/server-logs/2026-10/. Prompts and tool schemas elided above.

Activity

  1. opencode-agent commented on Oct 10, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report, it made this easy to confirm.

    I reproduced it with a fake LM Studio server that logs request bodies. The model was lmstudio/qwen3.5-4b@q4_k_m, and I sent hello once:

    • 2.0.26: the session-title request had no max_completion_tokens, max_tokens or stop. The main agent request in the same run had max_completion_tokens of about 27k.
    • Latest dev (7b3d4ce): same result.

    In the code, the output limit is only applied to the main agent request and to compaction, so title requests go out with no limit. With a thinking model, nothing stops the title request until the model's context runs out. I couldn't run a real thinking model here, so I confirmed the missing limit but not the long reasoning loop itself.

    The script below reproduces it.

    The duplicated main request on the first message is probably a separate problem; please open a separate issue for it.

    Related: #54185 (title agent replies conversationally), #53536 (think tags in auto-titles).

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Repro for anomalyco/opencode#54401: the session-title request has no output-token limit.
    # Starts a fake LM Studio server on 127.0.0.1:1234 that logs every
    # /v1/chat/completions body, runs one prompt, and prints which requests set a token limit.
    # Usage: ./repro.sh            (uses `opencode` from PATH)
    #        OC_DEV=/workspace/repo ./repro.sh   (runs from a source checkout)
    set -euo pipefail
    WORK=$(mktemp -d /tmp/opencode/repro54401.XXXX)
    LOG="$WORK/requests.jsonl"
    cat >"$WORK/mock.ts" <<'EOF'
    const log = process.env.LOG!
    Bun.serve({
      port: 1234, hostname: "127.0.0.1",
      async fetch(req) {
        const url = new URL(req.url)
        if (url.pathname === "/api/v1/models") return Response.json({ models: [{
          type: "llm", key: "qwen3.5-4b@q4_k_m", display_name: "Qwen3.5 4B", architecture: "qwen3",
          loaded_instances: [{ config: { context_length: 32768 } }], max_context_length: 32768,
          capabilities: { vision: false, trained_for_tool_use: true } }] })
        if (url.pathname === "/v1/models") return Response.json({ object: "list", data: [{ id: "qwen3.5-4b@q4_k_m", object: "model" }] })
        if (url.pathname === "/v1/chat/completions") {
          const body = await req.json()
          await Bun.write(Bun.file(log), (await Bun.file(log).exists() ? await Bun.file(log).text() : "") + JSON.stringify(body) + "\n")
          const isTitle = JSON.stringify(body.messages?.[0] ?? "").includes("title generator")
          const text = isTitle ? "Greeting" : "Hi there!"
          const enc = new TextEncoder()
          const chunk = (o: any) => enc.encode(`data: ${JSON.stringify(o)}\n\n`)
          const base = { id: "c1", object: "chat.completion.chunk", created: 0, model: body.model }
          return new Response(new ReadableStream({ start(c) {
            c.enqueue(chunk({ ...base, choices: [{ index: 0, delta: { role: "assistant", content: text }, finish_reason: null }] }))
            c.enqueue(chunk({ ...base, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] }))
            c.enqueue(chunk({ ...base, choices: [], usage: { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12 } }))
            c.enqueue(enc.encode("data: [DONE]\n\n")); c.close() } }), { headers: { "content-type": "text/event-stream" } })
        }
        return new Response("not found", { status: 404 })
      },
    })
    EOF
    LOG="$LOG" bun "$WORK/mock.ts" & MOCK=$!
    trap 'kill $MOCK 2>/dev/null' EXIT
    sleep 1
    mkdir -p "$WORK/project" && cd "$WORK/project" && git init -q
    export HOME="$WORK/home" XDG_CONFIG_HOME="$WORK/home/.config" XDG_DATA_HOME="$WORK/home/.local/share" XDG_STATE_HOME="$WORK/home/.local/state" XDG_CACHE_HOME="$WORK/home/.cache"
    mkdir -p "$XDG_CONFIG_HOME/opencode"
    echo '{ "model": "lmstudio/qwen3.5-4b@q4_k_m" }' >"$XDG_CONFIG_HOME/opencode/opencode.json"
    if [ -n "${OC_DEV:-}" ]; then
      OC=(bun --conditions=browser --preload="$OC_DEV/packages/tui/node_modules/@opentui/solid/scripts/preload.js" "$OC_DEV/packages/cli/src/index.ts")
    else OC=(opencode); fi
    "${OC[@]}" --version
    if [ -n "${OC_DEV:-}" ]; then
      # From source the spawned standalone child lacks the TUI preload, so start the server ourselves.
      OPENCODE_PASSWORD=repro "${OC[@]}" serve --port 4599 >"$WORK/serve.log" 2>&1 & SERVE=$!
      trap 'kill $MOCK $SERVE 2>/dev/null' EXIT
      for i in $(seq 60); do curl -s -o /dev/null http://127.0.0.1:4599 && break; sleep 1; done
      OPENCODE_PASSWORD=repro timeout 90 "${OC[@]}" run --server http://127.0.0.1:4599 hello || true
    else
      timeout 90 "${OC[@]}" run --standalone hello || true
    fi
    sleep 5
    echo "=== chat/completions requests ==="
    bun -e '
    for (const line of (await Bun.file(process.argv[1]).text()).trim().split("\n")) {
      const b = JSON.parse(line)
      const kind = JSON.stringify(b.messages?.[0]).includes("title generator") ? "TITLE" : "MAIN "
      console.log(kind, "max_completion_tokens=", b.max_completion_tokens, "max_tokens=", b.max_tokens, "stop=", b.stop, "reasoning_effort=", b.reasoning_effort)
    }' "$LOG"

    Run it: bash repro.sh

  2. pxzleo commented on Oct 16, 2026

    @pxzleo

    Additional real-model confirmation on OpenCode v2.0.29 on Windows, using the OpenCode executable bundled with OpenChamber. In this case, a background title consumed 36,127 completion tokens and 501.5 seconds, occupying the local backend's only inference slot while the main task waited.

    Environment and observed request

    • OpenAI-compatible backend: Strata 0.1.44.1, one inference slot.
    • Model: qwen3.8-flash-next-unsloth-ud-q5_k_xl.
    • Configured model limits: context: 262144, output: 32768; normal task reasoning variant: high.
    • The normal agent requests did carry an effective output limit of 32,768. This is specifically an auxiliary-title exception, not a claim that all v2 requests ignore limit.output.
    Observed title-request metric Value
    Input tokens 712
    Completion tokens, including reasoning 36,127
    Backend effective maximum 261,424
    Duration 501.5 s, about 8 min 22 s
    Finish reason stop

    The backend's effective maximum is consistent with its fallback for an omitted output limit: 262144 - 712 - 8 = 261424. Consequently, the title could exceed the configured 32,768 model output limit and still finish normally. These are completion-token counts, not the length of the visible title.

    To distinguish this from a long main-agent response, I reconciled a consistent read-only SQLite snapshot: the session's accumulated usage was 20,872 input / 120,290 output, while its 24 ordinary assistant records summed to 20,160 input / 84,163 output. The difference is exactly 712 input / 36,127 output, matching the backend request. The title implementation accounts for title usage at session level without adding an ordinary assistant reply. The request also began immediately after session creation.

    Version-specific source confirmation

    In v2.0.29 session/model-request.ts, only primary and compaction initially receive maxTokens; title and generate start with empty options. The OpenAI protocol conversion therefore has no generation limit to serialize for the unmodified title request.

    The runner starts title generation asynchronously, but application-side concurrency does not prevent slot contention: on a single-slot local server, a title that gets the slot first still delays the useful task for the full generation duration. This reproduces the impact on a GPU-backed server as well as the original CPU-only report.

    Local mitigation verification, separate from the proposed upstream PRs

    I tested a temporary user-directory session.title plugin with the actual installed v2.0.29 executable, an isolated configuration/database, and a fake OpenAI-compatible backend. It waits for the current session's agent loop to become idle, then sets maxTokens: 128 and reasoningEffort: "none" for a compatible model with an explicit supported off setting. Observed wire order and parameters:

    1. Main request: max_completion_tokens: 32768, reasoning_effort: "high".
    2. After the main response finished, title request: max_completion_tokens: 128, reasoning_effort: "none".

    For an unverified/no-off configuration, the plugin returns a short local title instead of making a title model call; the isolated test observed only the main request. This is a local mitigation test, not a test of either upstream PR.

    The bounded title budget in #54411 and lower-reasoning selection in #51939 both seem relevant. A small title-specific cap plus a provider-supported non-thinking mode would address the two causes without reducing normal task output/reasoning settings. Giving auxiliary titles lower priority is also valuable for single-slot backends. No private prompts, conversation text, credentials, or full logs are included here.

  3. added 2 commits that reference this issue on Oct 22, 2026
    843746d
    3f70b0d
  4. added 2 commits that reference this issue on Nov 4, 2026
    48f4655
    1c92b93
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions