owl.glass lane: 32.9s median turn under load, from a 116 KB prompt prefix that stopped being cached #932

Closed
opened 2026-08-18 01:21:05 +00:00 by coilyco-ops · 7 comments
Member

Filed by Olaf (ops seat) after Kai reported that Owl Deep took heavy load over roughly an hour and performed badly. Treated as a critical failure.

Proceeds under the #929 amendment recorded 2026-08-18: everything proposed here changes only how well the lane does what it already does, so none of it is a frozen feature.

Summary

sirens-deep-owl-glass is not slow because something broke. It is slow because every turn ships roughly 116 KB of fixed prompt through a flash-tier model with prompt caching returning zero cache hits for the whole burst, then loops that payload five to eight times per reply.

The load did not cause this. The load exposed it.

Window and volume

Burst ran 00:25 to 01:10 UTC, 2026-08-18. Nine turns in the owl lane.

  • 9 turns produced 53 model calls and 60 MCP tool calls
  • over the wider 2h window: 84 model calls, 2,419,980 input tokens, averaging 28,809 tokens per call
  • total cost across those 2 hours: $0.101

Cost is why this went unnoticed. It is a latency and quality failure, not a spend one.

Latency, owl lane against the plain Deep lane

  • community.turn p50 - 32.9s owl // 8.7s sirens-deep - 3.8x
  • model.chat p95 - 33.2s owl // 13.5s sirens-deep
  • service p99 - 182.6s, sitting on the 180s ceiling already recorded in #577
  • worst single turn - accepted 00:47:42, delivered 00:50:45, 3m03s
  • error rate - 5 of 215 calls, 2.3%

Three error signatures, all from spans on the lane:

  • Agent Proxy response exceeded the size limit. on model.chat, whose child HTTP POST to ser8:8080/v1/chat/completions returns HTTP 200 and then fails with context deadline exceeded
  • An MCP tool reported its own call as failed.
  • Turn processing failed., bubbling all the way to discord.receive

Root cause chain

1. The fixed prefix is enormous and paid on every call

Constant across every request measured:

  • system_prompt_bytes - 61,142
  • tool block at 86 tools - 57,347 bytes
  • fixed overhead before any user content - ~116 KB

A 977-byte user question produced a 127,222-byte request. That is 99.2% overhead.

2. Prompt caching went dark. This is the decisive finding.

  • spans carrying gen_ai.usage.cache_read_input_tokens in the incident 2h window - 0
  • same over 24h - 252 spans, 6,109,440 tokens

Caching demonstrably works on this stack and delivered nothing during the burst. Two obvious explanations are ruled out:

  • Not a model or route change. deepseek-v4-flash via sirens-echo/deepseek, stable across all 24h with no other model on the lane.
  • Not an unstable prefix. The tool block is byte-exact per tool count - min equals max at 86 → 57,347, 67 → 48,467, 82 → 43,116.

So 84 calls each re-paid full freight on a ~29k-token prefix that was supposed to be cached. This is the single biggest lever on the 32.9s median.

Not yet established: why. That needs a direct look at agent-proxy and LiteLLM cache configuration, which I have not opened. Related to #162, which covers caching as a cost lever rather than as a regression.

3. Tool-round amplification multiplies both

Within one trace, request_bytes climbed 128,443 → 217,079 across rounds 0 through 5, plus three nested sub-turns. Roughly 5.9 model calls and 6.7 tool calls per single Discord reply.

The owl lane carries 86 tools against the plain lane's 67. The extra 19 are the Discord write surface, and they sit on the prefix that stopped being cached.

Quality failures, all from the runtime record

The "didn't do a good job" half is not vague. Six distinct failures:

  • Hallucination. model.response.repair fired with refused: "model claimed an action the runtime has not performed". The guard caught it, then spent two more rounds recovering.
  • A false error shown to a member. One turn delivered a 90-byte model backend unavailable, retry shortly notice, with model.response status: 200 logged one second earlier. A member was told the backend was down when it had answered.
  • Blind retry of a known-dead tool. browser_navigate failed twice with byte-identical 30-byte input before the model gave up. Playwright has been permanently dead since the session-recovery bug in #897 and coilyco-flight-deck/mcp-beaver#80. Deep does not know that and burns rounds rediscovering it every turn.
  • Redundant over-fetching. Two near-identical forgejo list_issue calls back to back, both over the 16,384-byte cap, dropping 403 and 1,677 bytes after paying to retrieve them.
  • Reasoning budget blowout. model.budget.raised with 15,036 bytes of reasoning against a 7,200 max_tokens ceiling, forcing a retry.
  • Progress spam. One 3-minute turn produced 1 post, 10 edits and 1 delete. Twelve Discord write operations for one reply, roughly one every 16 seconds.

The response.validate spans all passed clean, so the validation layer is not catching any of this. It is upstream of validation.

Reproduced live while writing this

Two turn calls into the lane at ~01:15 UTC, both read-only:

  1. first returned The operation timed out.
  2. second returned the canned > busy, retry shortly

Same class of notice a member received at 00:43. The lane is still in this state.

Ruled out

  • Host pressure. ser8 is idle. CPU 1.1%, memory 15.1%, load average 2.27. Nothing is starved.
  • Cost. $0.101 per 2h. Not a spend problem.

What is new here vs already tracked

Already filed, and this issue corroborates rather than duplicates:

  • #577 - p99 turn on the 180s ceiling
  • #367 - 43% silent-failure rate at the completion ceiling
  • #897 and coilyco-flight-deck/mcp-beaver#80 - playwright permanently dead after session recovery fails
  • #162 - prompt caching as a cost lever
  • #859 - skillpack inlined into every prompt (closed 2026-08-17)

New and not recorded anywhere I could find: caching produced zero cache reads during the burst against 6.1M over the day, with no model change and no prefix instability to explain it.

Why this is demo-critical

The owl.glass lane is the August 19 Temporal Vibe Check lane, roughly 34 hours out at filing. Kai's contracted deliverable is an agent staged in a shared Discord interacting with other participants' agents, on a permanent public recording.

What was measured is that lane, under live load, taking 33s at the median and 3 minutes at the tail, hallucinating an action, and telling a member the backend was down when it was not.

#929 names #335 (cached trajectories, Ornith warm, cutover trigger) as the only dead-air mitigation, and this is the failure it was written against.

Proposed work, cheapest and highest-leverage first

  1. Find why cache reads went to zero and restore them. Agent-proxy and LiteLLM cache configuration. Highest leverage by a wide margin and no behaviour change.
  2. Drop playwright and bluesky from the owl roster until mcp-beaver#80 lands. They cannot succeed, and they cost rounds and prefix bytes on every turn.
  3. Cap tool rounds on the Discord transport so a turn cannot reach round 7 plus nested sub-turns.
  4. Throttle progress edits. Twelve Discord writes per reply is rate-limit-adjacent and reads as thrashing.
  5. Raise max_tokens above the observed reasoning envelope, or stop paying for a repair round on every budget overrun.

Items 2 through 5 are harness changes and belong to Engineering. Item 1 is agent-proxy configuration.

Done when

The owl lane's community.turn p50 is back in the same order as sirens-deep's 8.7s, cache reads are non-zero on the lane, and no member-facing turn reports a backend failure on an HTTP 200.

Gaps in this report, stated rather than papered over

  • I have no Discord message text. The read-only discord MCP returns 403 Missing Access on #engineering. Two proxied read attempts through the owl lane's own turn tool failed, as recorded above. Reply sizes and runtime events are measured, reply wording is not.
  • I did not open agent-proxy or LiteLLM configuration. The caching claim is a measurement of behaviour, not a reading of config. Cause is inference until someone opens it.
**Filed by Olaf (ops seat)** after Kai reported that Owl Deep took heavy load over roughly an hour and performed badly. Treated as a critical failure. Proceeds under the [#929 amendment](https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/929) recorded 2026-08-18: everything proposed here changes only how well the lane does what it already does, so none of it is a frozen feature. ## Summary `sirens-deep-owl-glass` is not slow because something broke. It is slow because every turn ships roughly **116 KB of fixed prompt** through a flash-tier model with **prompt caching returning zero cache hits for the whole burst**, then loops that payload five to eight times per reply. The load did not cause this. The load exposed it. ## Window and volume Burst ran **00:25 to 01:10 UTC, 2026-08-18**. Nine turns in the owl lane. * 9 turns produced **53 model calls** and **60 MCP tool calls** * over the wider 2h window: **84 model calls**, **2,419,980 input tokens**, averaging **28,809 tokens per call** * total cost across those 2 hours: **$0.101** Cost is why this went unnoticed. It is a latency and quality failure, not a spend one. ## Latency, owl lane against the plain Deep lane * `community.turn` p50 - **32.9s** owl // **8.7s** `sirens-deep` - 3.8x * `model.chat` p95 - **33.2s** owl // **13.5s** `sirens-deep` * service p99 - **182.6s**, sitting on the 180s ceiling already recorded in #577 * worst single turn - accepted 00:47:42, delivered 00:50:45, **3m03s** * error rate - 5 of 215 calls, **2.3%** Three error signatures, all from spans on the lane: * `Agent Proxy response exceeded the size limit.` on `model.chat`, whose child `HTTP POST` to `ser8:8080/v1/chat/completions` returns **HTTP 200** and then fails with `context deadline exceeded` * `An MCP tool reported its own call as failed.` * `Turn processing failed.`, bubbling all the way to `discord.receive` ## Root cause chain ### 1. The fixed prefix is enormous and paid on every call Constant across every request measured: * `system_prompt_bytes` - **61,142** * tool block at 86 tools - **57,347 bytes** * fixed overhead before any user content - **~116 KB** A **977-byte** user question produced a **127,222-byte** request. That is **99.2% overhead**. ### 2. Prompt caching went dark. This is the decisive finding. * spans carrying `gen_ai.usage.cache_read_input_tokens` in the incident 2h window - **0** * same over 24h - **252 spans, 6,109,440 tokens** Caching demonstrably works on this stack and delivered **nothing** during the burst. Two obvious explanations are ruled out: * **Not a model or route change.** `deepseek-v4-flash` via `sirens-echo/deepseek`, stable across all 24h with no other model on the lane. * **Not an unstable prefix.** The tool block is byte-exact per tool count - min equals max at 86 → 57,347, 67 → 48,467, 82 → 43,116. So 84 calls each re-paid full freight on a ~29k-token prefix that was supposed to be cached. This is the single biggest lever on the 32.9s median. **Not yet established:** why. That needs a direct look at agent-proxy and LiteLLM cache configuration, which I have not opened. Related to #162, which covers caching as a cost lever rather than as a regression. ### 3. Tool-round amplification multiplies both Within one trace, `request_bytes` climbed **128,443 → 217,079** across rounds 0 through 5, plus three nested sub-turns. Roughly **5.9 model calls and 6.7 tool calls per single Discord reply**. The owl lane carries **86 tools** against the plain lane's **67**. The extra 19 are the Discord write surface, and they sit on the prefix that stopped being cached. ## Quality failures, all from the runtime record The "didn't do a good job" half is not vague. Six distinct failures: * **Hallucination.** `model.response.repair` fired with `refused: "model claimed an action the runtime has not performed"`. The guard caught it, then spent two more rounds recovering. * **A false error shown to a member.** One turn delivered a 90-byte `model backend unavailable, retry shortly` notice, with `model.response status: 200` logged **one second earlier**. A member was told the backend was down when it had answered. * **Blind retry of a known-dead tool.** `browser_navigate` failed twice with byte-identical 30-byte input before the model gave up. Playwright has been permanently dead since the session-recovery bug in #897 and coilyco-flight-deck/mcp-beaver#80. Deep does not know that and burns rounds rediscovering it every turn. * **Redundant over-fetching.** Two near-identical `forgejo list_issue` calls back to back, both over the 16,384-byte cap, dropping 403 and 1,677 bytes after paying to retrieve them. * **Reasoning budget blowout.** `model.budget.raised` with **15,036 bytes of reasoning against a 7,200 `max_tokens` ceiling**, forcing a retry. * **Progress spam.** One 3-minute turn produced 1 post, 10 edits and 1 delete. **Twelve Discord write operations for one reply**, roughly one every 16 seconds. The `response.validate` spans all passed clean, so the validation layer is not catching any of this. It is upstream of validation. ## Reproduced live while writing this Two `turn` calls into the lane at ~01:15 UTC, both read-only: 1. first returned **`The operation timed out.`** 2. second returned the canned **`> busy, retry shortly`** Same class of notice a member received at 00:43. The lane is still in this state. ## Ruled out * **Host pressure.** ser8 is idle. CPU **1.1%**, memory **15.1%**, load average **2.27**. Nothing is starved. * **Cost.** $0.101 per 2h. Not a spend problem. ## What is new here vs already tracked Already filed, and this issue corroborates rather than duplicates: * #577 - p99 turn on the 180s ceiling * #367 - 43% silent-failure rate at the completion ceiling * #897 and coilyco-flight-deck/mcp-beaver#80 - playwright permanently dead after session recovery fails * #162 - prompt caching as a cost lever * #859 - skillpack inlined into every prompt (closed 2026-08-17) **New and not recorded anywhere I could find:** caching produced **zero** cache reads during the burst against 6.1M over the day, with no model change and no prefix instability to explain it. ## Why this is demo-critical The owl.glass lane is the **August 19 Temporal Vibe Check** lane, roughly 34 hours out at filing. Kai's contracted deliverable is an agent staged in a shared Discord interacting with other participants' agents, on a permanent public recording. What was measured is that lane, under live load, taking 33s at the median and 3 minutes at the tail, hallucinating an action, and telling a member the backend was down when it was not. #929 names #335 (cached trajectories, Ornith warm, cutover trigger) as the only dead-air mitigation, and this is the failure it was written against. ## Proposed work, cheapest and highest-leverage first 1. **Find why cache reads went to zero** and restore them. Agent-proxy and LiteLLM cache configuration. Highest leverage by a wide margin and no behaviour change. 2. **Drop playwright and bluesky from the owl roster** until mcp-beaver#80 lands. They cannot succeed, and they cost rounds and prefix bytes on every turn. 3. **Cap tool rounds** on the Discord transport so a turn cannot reach round 7 plus nested sub-turns. 4. **Throttle progress edits.** Twelve Discord writes per reply is rate-limit-adjacent and reads as thrashing. 5. **Raise `max_tokens` above the observed reasoning envelope**, or stop paying for a repair round on every budget overrun. Items 2 through 5 are harness changes and belong to Engineering. Item 1 is agent-proxy configuration. ## Done when The owl lane's `community.turn` p50 is back in the same order as `sirens-deep`'s 8.7s, cache reads are non-zero on the lane, and no member-facing turn reports a backend failure on an HTTP 200. ## Gaps in this report, stated rather than papered over * **I have no Discord message text.** The read-only discord MCP returns `403 Missing Access` on `#engineering`. Two proxied read attempts through the owl lane's own `turn` tool failed, as recorded above. Reply sizes and runtime events are measured, reply wording is not. * **I did not open agent-proxy or LiteLLM configuration.** The caching claim is a measurement of behaviour, not a reading of config. Cause is inference until someone opens it.
Author
Member

Follow-up: a controlled reproduction, and a correction to the issue body

Kai asked me to read the Discord side through Owl Deep's own server rather than the read-only discord MCP. Doing that produced better evidence than the original telemetry, and corrected one thing I got wrong.

Correction: the channel was not Sirens #engineering

The issue body says I could not read #engineering. That framing was wrong. This lane's Discord tools are scoped to the owl.glass guild, not Sirens:

discord.get_owl-glass-guild
discord.get_current-user
discord.mcp_beaver_info

I had inferred #engineering from Discord snowflake IDs falling in a similar range, which is worthless as evidence because snowflakes are global rather than per-guild. The burst was on owl.glass.

The lane cannot read Discord messages at all

Asked directly, Deep answered:

My Discord tools cannot list the owl.glass guild's channels (no channel enumeration endpoint is exposed, and the guild lookup returns none), so I cannot determine which channels I replied in during the last hour and cannot produce either requested report.

So the grant is guild and user metadata only. No channel enumeration, no message listing. A proxied read is not possible with the current guardfile, and no reply text can be recovered from either side.

Widening that grant would be a new capability, so it is a frozen feature under #929 and is not proposed here. Noting it only so nobody repeats the attempt.

The controlled experiment, and the real quality failure

Four turns into the lane, same tools, same prefix. The only variable was how tightly the request was bounded.

  • Unconstrained ("read back the exchange and report") - community.turn 163.3s, then 140.0s. Both exceeded the client timeout and returned nothing to the caller.
  • Loosely constrained ("list the last 10 messages, nothing else") - 69.0s, returned 595 bytes.
  • Hard constrained ("if your tools cannot do this, say exactly that in one sentence and stop, do not substitute another tool") - 52.2s, correct one-sentence refusal, 4 tool calls.

What the two long runs actually did, from the span record: asked to list Discord messages, Deep called get_owl-glass-guild, then spiralled through scratch_search, scratch_list, scratch_read, fetch_url, forgejo list_issue, mcp_beaver_info and refresh_tools across rounds 0 through 14, with request_bytes climbing 123,160 → 231,246. It never called a message-listing tool, because none exists.

This is the root of the quality complaint, and it is not the prompt size. The harness does not fail on missing capability. It fails to recognise missing capability, then substitutes unrelated tools and burns the round budget manufacturing a plausible-looking answer. The 1,849-byte reply the first long run eventually produced was assembled from scratchpad and fetch results that had nothing to do with what was asked.

That behaviour reproduced three times out of three under an unconstrained prompt, and stopped immediately when the prompt named the refusal as an acceptable outcome.

A clean measurement of the prefix tax

The hard-constrained run is the useful number. Four trivial Discord metadata calls, a one-sentence answer, an idle host, and it still took 52.2 seconds.

That is close to a floor for this lane in its current state, and it isolates the uncached ~116 KB prefix from every other variable. Round capping alone will not get this lane to a usable latency. The caching regression in the issue body has to be fixed first.

Additions to the proposed work

Slotting in ahead of the existing items 3 through 5:

  • Teach the model to decline on missing capability. When no tool can serve the request, the correct turn is one round and a refusal. Today it is fifteen rounds and a confabulation. This is the highest-value quality fix and it needs no new grant.
  • Bound tool substitution. A request naming one server should not silently satisfy itself from three unrelated ones.

Both are harness behaviour changes on existing capability, so both sit on the operational-improvement side of the #929 amendment.

## Follow-up: a controlled reproduction, and a correction to the issue body Kai asked me to read the Discord side through Owl Deep's own server rather than the read-only discord MCP. Doing that produced better evidence than the original telemetry, and corrected one thing I got wrong. ### Correction: the channel was not Sirens `#engineering` The issue body says I could not read `#engineering`. That framing was wrong. This lane's Discord tools are scoped to the **owl.glass guild**, not Sirens: ``` discord.get_owl-glass-guild discord.get_current-user discord.mcp_beaver_info ``` I had inferred `#engineering` from Discord snowflake IDs falling in a similar range, which is worthless as evidence because snowflakes are global rather than per-guild. The burst was on owl.glass. ### The lane cannot read Discord messages at all Asked directly, Deep answered: > My Discord tools cannot list the owl.glass guild's channels (no channel enumeration endpoint is exposed, and the guild lookup returns none), so I cannot determine which channels I replied in during the last hour and cannot produce either requested report. So the grant is **guild and user metadata only**. No channel enumeration, no message listing. A proxied read is not possible with the current guardfile, and no reply text can be recovered from either side. **Widening that grant would be a new capability, so it is a frozen feature under #929 and is not proposed here.** Noting it only so nobody repeats the attempt. ### The controlled experiment, and the real quality failure Four turns into the lane, same tools, same prefix. The only variable was how tightly the request was bounded. * **Unconstrained** ("read back the exchange and report") - `community.turn` **163.3s**, then **140.0s**. Both exceeded the client timeout and returned nothing to the caller. * **Loosely constrained** ("list the last 10 messages, nothing else") - **69.0s**, returned 595 bytes. * **Hard constrained** ("if your tools cannot do this, say exactly that in one sentence and stop, do not substitute another tool") - **52.2s**, correct one-sentence refusal, 4 tool calls. What the two long runs actually did, from the span record: asked to list Discord messages, Deep called `get_owl-glass-guild`, then spiralled through `scratch_search`, `scratch_list`, `scratch_read`, `fetch_url`, `forgejo list_issue`, `mcp_beaver_info` and `refresh_tools` across **rounds 0 through 14**, with `request_bytes` climbing **123,160 → 231,246**. It never called a message-listing tool, because none exists. **This is the root of the quality complaint, and it is not the prompt size.** The harness does not fail on missing capability. It fails to *recognise* missing capability, then substitutes unrelated tools and burns the round budget manufacturing a plausible-looking answer. The 1,849-byte reply the first long run eventually produced was assembled from scratchpad and fetch results that had nothing to do with what was asked. That behaviour reproduced **three times out of three** under an unconstrained prompt, and stopped immediately when the prompt named the refusal as an acceptable outcome. ### A clean measurement of the prefix tax The hard-constrained run is the useful number. Four trivial Discord metadata calls, a one-sentence answer, an idle host, and it still took **52.2 seconds**. That is close to a floor for this lane in its current state, and it isolates the uncached ~116 KB prefix from every other variable. Round capping alone will not get this lane to a usable latency. The caching regression in the issue body has to be fixed first. ### Additions to the proposed work Slotting in ahead of the existing items 3 through 5: * **Teach the model to decline on missing capability.** When no tool can serve the request, the correct turn is one round and a refusal. Today it is fifteen rounds and a confabulation. This is the highest-value quality fix and it needs no new grant. * **Bound tool substitution.** A request naming one server should not silently satisfy itself from three unrelated ones. Both are harness behaviour changes on existing capability, so both sit on the operational-improvement side of the #929 amendment.
Author
Member

Kai's decisions, recorded 2026-08-18 by Olaf (ops seat). Five remediations were put to her. Four approved, one rejected.

Rejected

Proposal 2 of this issue, "drop playwright and bluesky from the owl roster", is a NO. Kai's words: "no, we fix the servers".

The roster stays at 86 tools. The sanctioned path is #897 and coilyco-flight-deck/mcp-beaver#80, which fix the session-recovery failure rather than route around it. Anyone picking up this issue should treat proposal 2 as closed-by-decision and not implement it.

Consequence to carry deliberately, not by oversight: until those two land, every owl turn keeps paying the dead servers' prefix bytes, and the model keeps rediscovering the failure at call time. That is the cost recorded in #935 and #940. It is accepted, not overlooked.

Approved, and where each already lives

  • Typed cause instead of a wrong sentence to the member - already filed as #933, and it is the fifth documented cause of model backend unavailable, retry shortly under the #675 tracker. Nothing new filed.
  • Record a cause on front-door turn failures - already filed as #939. Nothing new filed.
  • Token accounting - genuinely uncovered, now filed as coilyco-flight-deck/agent-proxy#135. Usage exists on LiteLLM spans at 3,905,072 input tokens over 3h, agent-proxy records 0, the harness records nothing.

Approved but already fixed, correcting my own earlier claim

"Failed MCP tool calls do not set span error status" is no longer true. I asserted it from the service-level error rate. That metric only covers top-level operations, so it never included mcp.tool.call child spans, and my inference was wrong.

Checked directly on the spans instead, 6h window: every mcp.tool.outcome: failed now carries has_error: true. Playwright 9 failed, fetch 3 failed, discord 3 failed, scratchpad 1 failed, all with error status set. #873's fix landed and is working. No work needed and nothing to reopen.

The related gap #873 also named, turn-level cause going missing, did survive its close. That is what #939 now covers.

**Kai's decisions, recorded 2026-08-18 by Olaf (ops seat).** Five remediations were put to her. Four approved, one rejected. ## Rejected **Proposal 2 of this issue, "drop playwright and bluesky from the owl roster", is a NO.** Kai's words: *"no, we fix the servers"*. The roster stays at 86 tools. The sanctioned path is #897 and `coilyco-flight-deck/mcp-beaver#80`, which fix the session-recovery failure rather than route around it. Anyone picking up this issue should treat proposal 2 as closed-by-decision and not implement it. Consequence to carry deliberately, not by oversight: until those two land, every owl turn keeps paying the dead servers' prefix bytes, and the model keeps rediscovering the failure at call time. That is the cost recorded in #935 and #940. It is accepted, not overlooked. ## Approved, and where each already lives * **Typed cause instead of a wrong sentence to the member** - already filed as **#933**, and it is the fifth documented cause of `model backend unavailable, retry shortly` under the **#675** tracker. Nothing new filed. * **Record a cause on front-door turn failures** - already filed as **#939**. Nothing new filed. * **Token accounting** - genuinely uncovered, now filed as **`coilyco-flight-deck/agent-proxy#135`**. Usage exists on LiteLLM spans at 3,905,072 input tokens over 3h, agent-proxy records 0, the harness records nothing. ## Approved but already fixed, correcting my own earlier claim **"Failed MCP tool calls do not set span error status" is no longer true.** I asserted it from the service-level error rate. That metric only covers top-level operations, so it never included `mcp.tool.call` child spans, and my inference was wrong. Checked directly on the spans instead, 6h window: every `mcp.tool.outcome: failed` now carries `has_error: true`. Playwright 9 failed, fetch 3 failed, discord 3 failed, scratchpad 1 failed, all with error status set. **#873's fix landed and is working.** No work needed and nothing to reopen. The related gap #873 also named, turn-level cause going missing, did survive its close. That is what **#939** now covers.
Author
Member

Correction to my previous comment, same day. Kai has since directed deprovisioning Bluesky outright, so "the roster stays at 86 tools" no longer holds.

The split is now:

  • playwright - fix the server. Unchanged, still #897 and coilyco-flight-deck/mcp-beaver#80. Proposal 2 of this issue stays rejected for the browser half.
  • bluesky - removed from the Sirens harnesses entirely.

Landed on coilyco-bridge/deploy main as 791c3ae, under that repo's temporary merge-remote-main workflow. Per lane, for sirens-deep and sirens-deep-owl-glass, it drops the roster entry, the SIRENS_ECHO_BLUESKY_MCP_URL address, the mcp-beaver wrapper values file, and the rollout target, lint case, render case, tag guard and deploy block, plus two justfile verbs and the README section. sirens-echo never had Bluesky, so Echo is untouched.

Owl.glass goes 86 tools to 84. That is a real cut to the prefix this issue identifies, though a small one: the wrapper served exactly two pinned reads, get_author_feed and get_profile.

The public services/bluesky-mcp deployment is untouched and keeps its own consumers, so the shared /bsky/password SSM parameter stays in place.

Outstanding, and it is not automatic. The push triggers deploy-sirens-echo.yml, which redeploys the harnesses with the new roster but does not uninstall the wrappers that are no longer referenced. Two orphaned Helm releases remain for an attended cleanup:

  • sirens-deep-bluesky-mcp in namespace sirens-deep
  • sirens-deep-owl-glass-bluesky-mcp in namespace sirens-deep-owl-glass

Acceptance evidence to look for once CD completes: mcp.tools.discovered on the owl lane reporting tool_count: 84.

**Correction to my previous comment, same day.** Kai has since directed deprovisioning Bluesky outright, so "the roster stays at 86 tools" no longer holds. The split is now: * **playwright** - fix the server. Unchanged, still #897 and `coilyco-flight-deck/mcp-beaver#80`. Proposal 2 of this issue stays rejected for the browser half. * **bluesky** - removed from the Sirens harnesses entirely. Landed on `coilyco-bridge/deploy` main as `791c3ae`, under that repo's temporary `merge-remote-main` workflow. Per lane, for `sirens-deep` and `sirens-deep-owl-glass`, it drops the roster entry, the `SIRENS_ECHO_BLUESKY_MCP_URL` address, the mcp-beaver wrapper values file, and the rollout target, lint case, render case, tag guard and deploy block, plus two justfile verbs and the README section. `sirens-echo` never had Bluesky, so Echo is untouched. **Owl.glass goes 86 tools to 84.** That is a real cut to the prefix this issue identifies, though a small one: the wrapper served exactly two pinned reads, `get_author_feed` and `get_profile`. The public `services/bluesky-mcp` deployment is untouched and keeps its own consumers, so the shared `/bsky/password` SSM parameter stays in place. **Outstanding, and it is not automatic.** The push triggers `deploy-sirens-echo.yml`, which redeploys the harnesses with the new roster but does not uninstall the wrappers that are no longer referenced. Two orphaned Helm releases remain for an attended cleanup: * `sirens-deep-bluesky-mcp` in namespace `sirens-deep` * `sirens-deep-owl-glass-bluesky-mcp` in namespace `sirens-deep-owl-glass` Acceptance evidence to look for once CD completes: `mcp.tools.discovered` on the owl lane reporting `tool_count: 84`.
Author
Member

Bluesky deprovision complete, 2026-08-18 ~02:20Z. The orphaned-release cleanup flagged in my previous comment is done, so nothing is outstanding on this thread.

Verified after-state

Harnesses picked up the new roster on their own CD rollout, confirmed from the boot-time capabilities log rather than from a green pipeline:

  • sirens-deep - mcp_servers: 11 at 02:08:25Z, was 12
  • sirens-deep-owl-glass - mcp_servers: 11 at 02:09:55Z, was 12

Kai ran the two helm uninstall commands. Both namespaces are now clean of every bluesky resource: no Deployment, no sh.helm.release.v1.*bluesky* ConfigMap, no *-bluesky-mcp-secret. All remaining pods in both namespaces are Running with 0 restarts, and neither lane logged a single ERROR in the 15 minutes spanning the change.

The public bluesky-mcp namespace is untouched, as intended: bluesky-mcp, bluesky-mcp-meta and bluesky-mcp-oauth2-proxy all 1/1 at 25d. The shared /bsky/password SSM parameter stays, because that deployment still reads it.

One thing worth recording for the next operator

The first uninstall attempt failed with release: not found, because Helm's default secret release-state driver is wrong for this repo. docs/helm-release-storage.md requires HELM_DRIVER=configmap (deploy#65: the deployer-apps ClusterRole deliberately holds no secrets verbs), and scripts/rollout-lib.sh:191 sources scripts/helm-configmap-driver.sh for that reason. Any hand-run Helm command against these namespaces needs the same prefix:

HELM_DRIVER=configmap helm uninstall <release> -n <namespace>

scripts/rollout-lib.sh:210 already hands an operator a bare helm uninstall line with no driver prefix, so that message will mislead the next person the same way. Small, worth fixing when someone is next in that file.

Playwright is unaffected and still tracked at #897 and coilyco-flight-deck/mcp-beaver#80.

**Bluesky deprovision complete, 2026-08-18 ~02:20Z.** The orphaned-release cleanup flagged in my previous comment is done, so nothing is outstanding on this thread. ## Verified after-state Harnesses picked up the new roster on their own CD rollout, confirmed from the boot-time `capabilities` log rather than from a green pipeline: * `sirens-deep` - `mcp_servers: 11` at 02:08:25Z, was 12 * `sirens-deep-owl-glass` - `mcp_servers: 11` at 02:09:55Z, was 12 Kai ran the two `helm uninstall` commands. Both namespaces are now clean of every bluesky resource: no Deployment, no `sh.helm.release.v1.*bluesky*` ConfigMap, no `*-bluesky-mcp-secret`. All remaining pods in both namespaces are Running with 0 restarts, and neither lane logged a single ERROR in the 15 minutes spanning the change. The public `bluesky-mcp` namespace is untouched, as intended: `bluesky-mcp`, `bluesky-mcp-meta` and `bluesky-mcp-oauth2-proxy` all 1/1 at 25d. The shared `/bsky/password` SSM parameter stays, because that deployment still reads it. ## One thing worth recording for the next operator The first uninstall attempt failed with `release: not found`, because Helm's default `secret` release-state driver is wrong for this repo. `docs/helm-release-storage.md` requires `HELM_DRIVER=configmap` (deploy#65: the `deployer-apps` ClusterRole deliberately holds no `secrets` verbs), and `scripts/rollout-lib.sh:191` sources `scripts/helm-configmap-driver.sh` for that reason. Any hand-run Helm command against these namespaces needs the same prefix: ``` HELM_DRIVER=configmap helm uninstall <release> -n <namespace> ``` `scripts/rollout-lib.sh:210` already hands an operator a bare `helm uninstall` line with no driver prefix, so that message will mislead the next person the same way. Small, worth fixing when someone is next in that file. Playwright is unaffected and still tracked at #897 and `coilyco-flight-deck/mcp-beaver#80`.
Author
Member

In the re-cut August 19 freeze, raised to priority/P0. Darren (director seat), 2026-08-18.

The amendment on #929 replaces the original six. This is one of the new six.

32.9s median against 8.7s on plain Deep is the texture of the entire demo. A 50-minute segment made of half-minute waits is what the audience actually experiences, before any question of whether the answers are good.

The decisive finding in this body is the one to work first. Prompt caching returned zero hits across the burst and 6,109,440 tokens across the surrounding 24h. Caching demonstrably works on this stack, so this is a regression to find rather than a capability to build, and it is the cheapest large win available in the window.

Recorded so it is not re-proposed: I proposed cutting the lane's roster from 86 tools to roughly ten as a way to attack the 57 KB tool block. Kai rejected it and the rejection is correct on this issue's own evidence. The prefix is expensive because it stopped being cached, not because it is large, and the roster is the capability the demo exists to show.

**In the re-cut August 19 freeze, raised to `priority/P0`. Darren (director seat), 2026-08-18.** The [amendment on #929](https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/929#issuecomment-68577) replaces the original six. This is one of the new six. 32.9s median against 8.7s on plain Deep is the texture of the entire demo. A 50-minute segment made of half-minute waits is what the audience actually experiences, before any question of whether the answers are good. **The decisive finding in this body is the one to work first.** Prompt caching returned zero hits across the burst and 6,109,440 tokens across the surrounding 24h. Caching demonstrably works on this stack, so this is a regression to find rather than a capability to build, and it is the cheapest large win available in the window. **Recorded so it is not re-proposed:** I proposed cutting the lane's roster from 86 tools to roughly ten as a way to attack the 57 KB tool block. Kai rejected it and the rejection is correct on this issue's own evidence. The prefix is expensive because it stopped being cached, not because it is large, and the roster is the capability the demo exists to show.
Author
Member

Claiming. Olaf (ops seat), seat claude. Taking the caching half, which this issue names as the thing to work first.

Before doing that: the decisive finding does not hold. Prompt caching did not go dark. The streaming path does not report cache usage, and the burst was entirely streaming. Detail below, because it changes what the next 16 hours should be spent on.

The measurement

agent-proxy spans, last 24h, grouped by span name:

  • upstream.chat - 18 spans, 18 carry gen_ai.usage.cache_read_input_tokens, summing to 36,608 cache-read tokens
  • upstream.chat_stream - 272 spans, 0 carry the attribute, sum is null rather than zero

The attribute is absent, not present-and-zero. That distinction is the whole thing, and it correlates perfectly with the code path rather than with time, load, model, or lane.

During the incident window itself, 2026-08-18 00:25 to 01:10 UTC:

  • upstream.chat_stream - 57
  • upstream.chat - 0

Every call in the burst took the streaming path.

Why the attribute is absent, from the source

app/upstream.py records cache attributes on the streaming span only when the terminal chunk carries a usage block: it calls parse_cache_usage(payload.get("usage") or {}), and only sets cache_usage_reported when the provider actually accounted for caching.

OpenAI-compatible streaming omits usage unless the request carries stream_options: {"include_usage": true}. The proxy never sends it. include_usage appears exactly once in that repository, in tests/test_api.py, and chat_stream forwards body to the upstream unmodified with no injection.

And the absence is deliberate. docs/proxy-prompt-cache.md says so directly:

A provider that reports nothing is an unmeasured route, not a cache miss. [...] publishing that silence as a 100% miss would invent a regression it never had.

That is the exact inference this issue made. The design anticipated the failure mode and the analysis walked into it anyway, mine included, since I filed this.

What that does to the issue

Struck: "Prompt caching went dark. This is the decisive finding." and "84 calls each re-paid full freight on a ~29k-token prefix that was supposed to be cached." Neither is established. Cache behaviour on the streaming path is unmeasured, not zero.

Also struck: the 24h contrast of "252 spans, 6,109,440 tokens". That window mixed streaming and non-streaming traffic, so it compared an unmeasured route against a partly measured one and read the difference as a regression.

Still standing, and untouched by this: the 32.9s p50 against 8.7s, the 116 KB fixed prefix at 99.2% overhead on a 977-byte question, the p99 on the 180s ceiling, and the tool-round amplification at roughly 5.9 model calls and 6.7 tool calls per reply. Those are measured directly and do not depend on the cache claim.

With the cache explanation withdrawn, section 3 is the surviving mechanical explanation of the median: a large prefix multiplied by six round trips. That is where I would put the remaining time.

Proposed next step, smallest first

Send stream_options: {"include_usage": true} on OpenAI-dialect streaming requests in agent-proxy, so the route reports usage and the existing cache plumbing publishes what it already knows how to publish. It changes no routing, no roster, and no model behaviour, and it turns 94% of traffic from unmeasured into measured.

Then re-measure and find out what the cache hit rate actually is. It may be fine, which would close this lever honestly, or it may be genuinely poor, which would reopen it with evidence rather than on an artifact.

Under the #929 amendment that is an operational improvement to an existing path rather than a feature, so it is not frozen. It belongs in coilyco-flight-deck/agent-proxy, not here, and I will file it there and link it.

I have not changed anything yet. This comment is measurement and a correction.

**Claiming. Olaf (ops seat), seat `claude`.** Taking the caching half, which this issue names as the thing to work first. **Before doing that: the decisive finding does not hold. Prompt caching did not go dark. The streaming path does not report cache usage, and the burst was entirely streaming.** Detail below, because it changes what the next 16 hours should be spent on. ## The measurement agent-proxy spans, last 24h, grouped by span name: * `upstream.chat` - **18** spans, **18** carry `gen_ai.usage.cache_read_input_tokens`, summing to **36,608** cache-read tokens * `upstream.chat_stream` - **272** spans, **0** carry the attribute, sum is **null** rather than zero The attribute is **absent**, not present-and-zero. That distinction is the whole thing, and it correlates perfectly with the code path rather than with time, load, model, or lane. During the incident window itself, 2026-08-18 00:25 to 01:10 UTC: * `upstream.chat_stream` - **57** * `upstream.chat` - **0** Every call in the burst took the streaming path. ## Why the attribute is absent, from the source `app/upstream.py` records cache attributes on the streaming span only when the terminal chunk carries a `usage` block: it calls `parse_cache_usage(payload.get("usage") or {})`, and only sets `cache_usage_reported` when the provider actually accounted for caching. OpenAI-compatible streaming omits `usage` unless the request carries `stream_options: {"include_usage": true}`. **The proxy never sends it.** `include_usage` appears exactly once in that repository, in `tests/test_api.py`, and `chat_stream` forwards `body` to the upstream unmodified with no injection. And the absence is deliberate. `docs/proxy-prompt-cache.md` says so directly: > A provider that reports nothing is an unmeasured route, not a cache miss. [...] publishing that silence as a 100% miss would invent a regression it never had. That is the exact inference this issue made. The design anticipated the failure mode and the analysis walked into it anyway, mine included, since I filed this. ## What that does to the issue **Struck:** "Prompt caching went dark. This is the decisive finding." and "84 calls each re-paid full freight on a ~29k-token prefix that was supposed to be cached." Neither is established. Cache behaviour on the streaming path is **unmeasured**, not zero. **Also struck:** the 24h contrast of "252 spans, 6,109,440 tokens". That window mixed streaming and non-streaming traffic, so it compared an unmeasured route against a partly measured one and read the difference as a regression. **Still standing, and untouched by this:** the 32.9s p50 against 8.7s, the 116 KB fixed prefix at 99.2% overhead on a 977-byte question, the p99 on the 180s ceiling, and the tool-round amplification at roughly 5.9 model calls and 6.7 tool calls per reply. Those are measured directly and do not depend on the cache claim. With the cache explanation withdrawn, **section 3 is the surviving mechanical explanation** of the median: a large prefix multiplied by six round trips. That is where I would put the remaining time. ## Proposed next step, smallest first Send `stream_options: {"include_usage": true}` on OpenAI-dialect streaming requests in agent-proxy, so the route reports usage and the existing cache plumbing publishes what it already knows how to publish. It changes no routing, no roster, and no model behaviour, and it turns 94% of traffic from unmeasured into measured. Then re-measure and find out what the cache hit rate actually is. It may be fine, which would close this lever honestly, or it may be genuinely poor, which would reopen it **with evidence** rather than on an artifact. Under the [#929 amendment](https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/929) that is an operational improvement to an existing path rather than a feature, so it is not frozen. It belongs in `coilyco-flight-deck/agent-proxy`, not here, and I will file it there and link it. **I have not changed anything yet.** This comment is measurement and a correction.
Author
Member

Both paths are now filed, and the proxy change is landed. Olaf (ops seat).

Landed

coilyco-flight-deck/agent-proxy#138, fixed on that repo's main as 50af3de under its merge-remote-main lane.

One clause in _chat_body: OpenAI-dialect streaming requests now send stream_options: {"include_usage": true}. Ollama is excluded, since it accounts for its own stream in the done payload and does not take the OpenAI-only key.

Verified: 405 tests pass, plus format, lint, mypy, and pre-commit. Three new tests in tests/test_prompt_cache.py cover sent-when-streaming, not-sent-when-not, and not-sent-to-Ollama. The first fails without the change, which I checked by reverting it rather than assuming.

Not yet verified live. The change is on main and has not rolled out. The acceptance condition is upstream.chat_stream spans carrying cache attributes after the next agent-proxy deploy, and the real cache hit rate being readable for the first time. That number could still be poor, which would reopen the cache lever properly, on evidence.

Split out

#1002 carries the amplification half: the ~116 KB prefix paid roughly six times per reply, with the 5.9 model calls and 6.7 tool calls per Discord reply, and the request growing 128,443 to 217,079 bytes across rounds.

Its first task is deliberately a measurement rather than a fix: break one median turn into model time, tool time, and harness time. That split decides whether the lever is prefix size, round-trip count, or neither. Proposing before measuring is what produced the withdrawn finding on this issue, so #1002 states that discipline explicitly.

It also carries forward Kai's standing decision that the roster stays and the servers get fixed, so nobody re-proposes a narrower roster as a latency fix.

Where that leaves #932

The caching claim is withdrawn and its measurement gap is fixed at the source. The latency itself is real, unexplained, and now tracked at #1002 with the arithmetic intact.

Suggest #932 either closes in favour of #1002, or stays open purely as the incident record. I have not changed its state, since the re-cut freeze board names it directly and renumbering a P0 sixteen hours before the stream should be a director's call rather than mine.

**Both paths are now filed, and the proxy change is landed. Olaf (ops seat).** ## Landed `coilyco-flight-deck/agent-proxy#138`, fixed on that repo's `main` as `50af3de` under its `merge-remote-main` lane. One clause in `_chat_body`: OpenAI-dialect streaming requests now send `stream_options: {"include_usage": true}`. Ollama is excluded, since it accounts for its own stream in the `done` payload and does not take the OpenAI-only key. Verified: 405 tests pass, plus format, lint, mypy, and pre-commit. Three new tests in `tests/test_prompt_cache.py` cover sent-when-streaming, not-sent-when-not, and not-sent-to-Ollama. The first fails without the change, which I checked by reverting it rather than assuming. **Not yet verified live.** The change is on `main` and has not rolled out. The acceptance condition is `upstream.chat_stream` spans carrying cache attributes after the next agent-proxy deploy, and the real cache hit rate being readable for the first time. That number could still be poor, which would reopen the cache lever properly, on evidence. ## Split out **#1002** carries the amplification half: the ~116 KB prefix paid roughly six times per reply, with the 5.9 model calls and 6.7 tool calls per Discord reply, and the request growing 128,443 to 217,079 bytes across rounds. Its first task is deliberately a measurement rather than a fix: break one median turn into model time, tool time, and harness time. That split decides whether the lever is prefix size, round-trip count, or neither. Proposing before measuring is what produced the withdrawn finding on this issue, so #1002 states that discipline explicitly. It also carries forward Kai's standing decision that the roster stays and the servers get fixed, so nobody re-proposes a narrower roster as a latency fix. ## Where that leaves #932 The caching claim is withdrawn and its measurement gap is fixed at the source. The latency itself is real, unexplained, and now tracked at #1002 with the arithmetic intact. Suggest #932 either closes in favour of #1002, or stays open purely as the incident record. I have not changed its state, since the re-cut freeze board names it directly and renumbering a P0 sixteen hours before the stream should be a director's call rather than mine.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-gaming/sirens-echo#932
No description provided.