Web Chat — Serving the Local Agent¶
Until September 2026 the NetBox DeepAgents agent was reachable in two ways: a Rich CLI that prints nothing until the whole answer exists, and the evaluation harnesses. Both are fine for measuring. Neither is how anyone would use an assistant whose turns take 35 to 486 seconds. This page records the serving layer that was added on top of the agent, the two things building it revealed that the harnesses could not, and the two things a week of using it revealed (durable memory, and a filter NetBox ignores). The decisions are in ADR-0039, ADR-0040 and ADR-0041.
The rule: the agent does not change¶
query(), the system prompt, the middleware stack and the skills are byte-identical to the
commit the v5 and session-harness results were recorded against. The web layer adds a second
streaming method, stream_events(), and everything else lives in a new src/web/ package and a
frontend/ app. That rule is what keeps every number elsewhere in Phase 5 comparable.
What the operator sees¶
- Text as it is written, with the first token typically 10 s after send on a warm cache and ~75 s after a server restart (the cold prefill ADR-0038 measured).
- Every NetBox tool call as it happens, with its arguments, and its result marked ok, validation error or API error — the same structured errors the filter-recovery middleware produces.
- A turn footer with elapsed time, model calls, tool calls, tokens read (and how many were served from the prompt cache), tokens written, the resident context and its share of the 131,072 window, prefill and decode tokens per second, and a link to the turn's LangSmith trace.
- A context gauge per conversation with a marker at the compaction trigger (0.85 of the window, ~111k), and a divider in the transcript if compaction fires.
- A token ledger per conversation: cumulative read, cached, written, model calls, wall time, with per-turn rows.
- Stop, which frees the single model slot within ~50 ms, and New conversation.
The last two are not conveniences. The session harness showed that the same eleven questions score 0.750 or 0.950 depending only on their order, because a later turn reused a wrong population inherited from an earlier one. A person watching the tool feed can see a turn answer from context without querying; the gauge shows how much context the thread is carrying; "New conversation" is the way to break it.
Where the numbers come from¶
Every figure in the footer is llama-server's own. Each model call ends with a usage block —
prompt_tokens is the resident context, cached_tokens the prefix served from cache,
completion_tokens what the model wrote including its reasoning — and a llama.cpp timings
block with real prefill and decode rates. Getting them took two changes that are worth
recording: langchain-openai only turns on stream_usage for the default OpenAI URL, so with
a custom base_url every usage field is silently None; and it drops the timings block
unless ChatOpenAI is subclassed. Nothing in the UI estimates tokens client-side; the
in-process count_tokens_approximately undercounts this agent's prompts about 19× because the
system prompt, tool schemas and skills are injected at call time.
Cross-checked once against the server mid-turn: /slots reported 8,949 prompt tokens with
8,739 cached; the call's usage chunk reported 8,953 and 8,739.
Finding one: the first real turn died, and only a streaming client could have seen it¶
The first question asked through the browser — how many power outlets does each Halvorsen Logistics PDU provide, and how many PDUs are there — ran for 4 m 06 s through six model calls and seven tool calls, then failed with "No streaming chunk received for 120.0s". The seventh tool result was the tenant's device list, 64,199 characters. The seventh model call had to prefill it, llama-server sends nothing while it prefills, and langchain-openai's default 120-second watchdog on async streaming calls killed the request with zero chunks received.
The CLI and both harnesses call the model without streaming — every LLM run in every earlier
trace in this programme is stream=False — so the identical prompt on 2026-09-24 simply waited
217 s and succeeded. The watchdog is now disabled for the llama.cpp backend. Replayed:
| first run | replay | |
|---|---|---|
| outcome | killed at call 7 | 12 APC AP7901 PDUs, 8 outlets each — correct |
| call 7 | 0 chunks in 120.2 s | 19,976 new tokens prefilled at 119.6 t/s ≈ 167 s of silence |
| context end | 10,867 | 30,673 (23.4%) |
| tokens read / cached / written | 58,566 / 56,398 / 824 | 89,080 / 67,130 / 1,258 |
| wall | 4 m 06 s → error | 324 s |
The general lesson: a measurement path that differs from the product path in a property the product depends on — here, streaming — will not find the product's failures, however many questions it runs.
Finding two: the middleware chain costs 0.27% of wall time¶
The LangSmith trace tree of a single turn holds 105 runs for 7 model calls, because five framework middlewares (Skills, Filesystem, SubAgent, Summarization, AnthropicPromptCaching) each wrap every call. Summed over a real 7-turn session (1,361.5 s, 36 model calls, 31 tool calls):
| Component | Seconds | Share |
|---|---|---|
| model generation | 1,332.4 | 97.9% |
| NetBox tool execution | 27.9 | 2.0% |
| model-call wrappers, 36 calls | 2.60 | 0.19% (72 ms per call) |
| tool-call wrappers | 0.37 | 0.03% |
| hook nodes, 194 runs | 0.74 | 0.05% |
The only prior middleware-latency number in this programme is the +13.7% that deferred QuickJS/PTC (ADR-0032). The framework's wrapper chain is not where time goes; its real cost is the ~8.7k-token fixed prefix it injects into every prompt, which the prompt cache absorbs.
A session, as the UI reports it¶
The seven-turn conversation that surfaced finding one, re-run to completion:
| turn | time | model calls | tools | tokens read | context end |
|---|---|---|---|---|---|
| 1 PDU outlets and count | 222 s | 7 | 6 | 77,156 | 15,411 |
| 2 outlets in use | 278 s | 10 | 9 | 182,155 | 23,524 |
| 3 upstream feeds | 173 s | 6 | 5 | 148,433 | 25,522 |
| 4 host with one supply connected | 144 s | 4 | 3 | 107,500 | 27,830 |
| 5 Halvorsen vs NC State feeds | 345 s | 5 | 6 | 166,695 | 40,303 |
| 6 device count at HVL-SEA-DC1 | 155 s | 2 | 1 | 82,124 | 41,192 |
| 7 rack names at HVL-SEA-DC1 | 45 s | 2 | 1 | 82,887 | 41,611 |
Context climbed from 15k to 41.6k without compaction; about 847k prompt tokens were read in
total because every call re-sends the whole context, which is why the cached share, not the raw
total, is the number to watch. Turns 6 and 7 reused site_id 25 from earlier turns with a
single tool call each — the memory working as intended, and the same mechanism that
contaminated the site-first session run.
Memory that survives a restart, and a cancel that leaves nothing behind (October 2026)¶
A week of use showed the one thing the page above got wrong: after any backend restart the
browser displayed a conversation the model had never seen, and the operator's follow-up
started cold. The checkpoints now live in SQLite (WEB_CHECKPOINT_DB), injected into the web
process only — the CLI and the harnesses keep per-process memory, so nothing measured elsewhere
in Phase 5 changed. Verified by killing the backend mid-conversation: the thread came back
known, and the follow-up was answered from memory with 0 tool calls in 5.0 s.
Stop now rolls the thread back as well. Before, a cancelled turn left the question and any
tool result that had already landed in every later prompt. The rollback found a LangGraph
behaviour worth recording: when a model call issues two tool calls and one is cancelled, the
finished one is a pending write, invisible to the committed message list and committed by the
very aupdate_state call that performs the rollback. The first "clean" cancel therefore carried
a 7,439-character tool result into the next turn (context 11,060 against the 8,738 baseline);
it took a two-pass rollback and a Send-graph regression test to make the next call's input
[system, human] again. The decisions are in
ADR-0040.
The outlets question: a filter that returned 200 instead of 400¶
The first long persisted thread exposed a defect a year old. Its third turn — "Across all
Halvorsen Logistics PDUs, how many of the available power outlets are actually in use?" — spent
283 s in one model call, because the tool result before it was 52,721 characters: a
device_id__in filter over the 12 PDU ids had returned the first 200 power outlets of every
tenant. NetBox does not reject __in; it drops the filter and answers 200 with everything.
Verified live against every field class — primary key, relational id, string, integer — none
honours it, while the bare key with a list value ({"device_id": [149, 150]}) filters correctly.
The agent's validator and skill had been recommending __in since a trace earlier in the
year, on the strength of the MCP server's own whitelist and tool description, which list it.
Both now reject it; the local copy of the MCP server is corrected and the diff kept for an
upstream report. The 11-turn session harness, re-run against the September baseline with the
same order: 20 tool calls against 39, 1,312 s against 1,552 s, the outlets question down to
one tool call (177 s against 195 s and 9 calls), zero __in attempts, same scores. The
scores did not move because their misses are the site-versus-tenant scoping the harness
documented in September, not filter syntax. ADR-0041
records the decision and the measurement principle it adds: a check that inspects only the
status code cannot see a filter the server ignores.
What it does not do¶
- Serve more than one turn at a time: one llama-server slot, one stdio MCP client, one lock.
Remember conversations across a backend restart— it does since 2026-10-05 (ADR-0040); conversations created before that date remain browser-only and show the banner.- Show the model's reasoning:
ChatOpenAIdropsreasoning_content, so reasoning is counted in tokens written but never displayed. - Time out a hung stream on its own — the Stop button is the recovery.
- Prove that scores are unaffected by re-running a benchmark through the browser. The claim
rests on
query()being unchanged, and every timing on this page is a single run.
See also: Local Frontier Inference · Observability & Monitoring · ADR-0039 · ADR-0040 · ADR-0041