Repository navigation
Description
Summary
While preparing the cross-host OFF/ON runs for #1705, I measured where an ON session spends tokens and wall time, stage by stage, on master 06794f66. Five groups ran on the e2e/bub paired harness with the project-decision-continuation workload (a fact stated in session 1, asked back in session 2), qwen/qwen3.7-flash through OpenRouter as the agent model on every host, and a local enforced Server with openai/text-embedding-3-small (OpenRouter) for embeddings. ON passed every trial, so this is about cost, not correctness.
| group | host | Server generation model | trials | purpose |
|---|---|---|---|---|
| G1 | Pi 0.82.1, OpenCode 1.18.33, Bub 0.5.1 | deepseek/deepseek-v4-pro |
2, 2, 1 | baseline |
| G2 | Pi | qwen/qwen3.7-flash |
2 | does a faster generation model shorten the flush wait |
| G3 | Pi | deepseek/deepseek-v4-pro, local patch of the prepare embedding timeout to 2 s |
2 | effect of reliable Hook injection |
Per arm-run, mean over trials. Agent time is Harbor's agent execution time; "flush wait" is the time the harness waited for flush_memory between the two sessions.
| group | arm | pass | wall | agent time | flush wait | input tokens | cached | output tokens |
|---|---|---|---|---|---|---|---|---|
| G1 Pi | OFF | 0/2 | 48 s | 19.2 s | 0 | 15,466 | 10,880 | 867 |
| G1 Pi | ON | 2/2 | 69 s | 19.4 s | 18.3 s | 121,833 | 107,520 | 464 |
| G1 OpenCode | OFF | 0/2 | 60 s | 22.1 s | 0 | 65,805 | 59,648 | 423 |
| G1 OpenCode | ON | 2/2 | 130 s | 20.2 s | 43.7 s | 77,560 | 68,864 | 253 |
| G1 Bub | OFF | 0/1 | 92 s | 45.7 s | 0 | 49,020 | n/a | 2,115 |
| G1 Bub | ON | 1/1 | 184 s | 132.0 s | in-turn | 23,472 | n/a | 1,156 |
| G2 Pi | OFF | 0/2 | 47 s | 21.3 s | 0 | 15,264 | 10,944 | 889 |
| G2 Pi | ON | 2/2 | 116 s | 16.9 s | 72.8 s | 103,088 | 88,960 | 411 |
| G3 Pi | OFF | 0/2 | 116 s | 20.9 s | 0 | 15,146 | 13,056 | 816 |
| G3 Pi | ON | 2/2 | 62 s | 19.4 s | 14.3 s | 103,188 | 74,240 | 452 |
The G3 OFF wall time includes one trial whose npm install took 146 s; the other took 45 s.
Two things stand out before any stage detail: the agent's own time is the same in both arms (Bub excepted), so the ON wall-time gap is almost entirely waiting for Memory extraction; and the token gap is set by the host integration's per-call overhead, not by the task.
Findings by stage
1. Hook recall: prepare_context
Covered in #2039. The 0.25 s query-embedding budget silently drops the semantic path with a remote embedding provider; injection then succeeds by chance (3/10, 7/10 on replays; 0/10 behind a 300 ms delay; 10/10 at 2 s). Each failed injection costs one extra model call because the model calls pc_search instead: recall input tokens 56.4k with the fallback against 37.7k with injection on Pi (about 18.7k more), 42.6k against 28.3k on OpenCode (about 14.3k more).
2. Where and how the context is injected
- Design: recalled content is wrapped as untrusted historical evidence with citations. The Pi package appends it to the system prompt in
before_agent_start; the Bub plugin prepends a system message before each model call. - Measured: when injection succeeds on Pi, the first recall call reports
cacheRead 0instead of 16,000, so that call pays the full 18.5k prefix; token use fell by one call but the step cost rose. The injected block is 180–189 tokens for a 94-token fact; the Server's own recall statistics reporttoken_reduction−86 to −95 for it. - Proposed, to be verified: inject after the stable prefix where the host allows it (Pi's
BeforeAgentStartEventResultaccepts amessage), keep the trust statement, and compact the envelope (short keys, no null fields, norevisionwhen it is 1). The cache miss is measured; that a message-based injection keeps the cached prefix is an assumption about the provider's prefix caching that I have not tested. Verify with the first call's cache read and the Server'stoken_reduction.
3. The tool catalog carried by every model call
- Design: host guidance is generated from the operation catalog (feat(integrations): generate and safely refresh host guidance #1877);
integrations/capabilities.tomldefinesminimal,recommendedandfullprofiles, Pi declares all three, OpenCode and Kimi Code onlyminimal. The Pi package registers 35 namedpc_*tools regardless of profile, plus a/pccommand and thepowercontext-project-contextSkill that routes the rarely used workflows. - Measured per model call: Pi ON 18,560 tokens vs OFF 1,536 (35
pc_*tool schemas plus the 6 KB guidance); OpenCode ON 13,696 vs 7,296; ZCode through the Server MCP (about 49 operations) 40k vs 18k, measured in a separate ZCode feasibility run on the same day with a different workload and model. The whole task used one tool,pc_search. The overhead is mostly cache reads after the first call, which is why cost grows about 3x while tokens grow 7x, but any cache miss, including the one injection causes, bills it in full. - Proposed: let the registered toolset follow the declared profile with
recommendedas the default and a setting that registers thefullset; trim the guidance to the active profile, since the Skill already carries the handoff, review and external-Skill workflows. Verify with the per-call prefix and the OFF/ON input ratio.
4. Capturing Sources
- Design: Hooks capture the user prompt as a Source; the Bub plugin's
capture_eventsadditionally captures the user prompt, each model result and each tool result as Sources. - Measured: Bub captured 15 Sources over two sessions and the Server spent 7 generation requests and 18.9k extraction input tokens on them; Pi captured 2 Sources per run, for 3 requests and 4.0k input tokens.
- Proposed: with
capture_eventson, capture user prompts and final assistant messages by default and make tool results a separate switch, or fold one turn's events into one Source. Verify with Sources and generation requests per session.
5. Extraction and reconciliation on flush_memory
- Design: extraction proposes candidates (
kind,text,evidence_ids); the Atomic Memory reconciler compares them with related entries and decides create, revise, merge or noop; each structured operation may use up toGENERATION_MAX_REQUESTSprovider requests. - Measured: one one-sentence Source costs 2 generation requests (extraction plus reconciliation, both under
memory_extraction), 700–1,300 output tokens and 10–20 s ondeepseek-v4-pro; one flush made 4 requests, 4,386 output tokens and 66 s. That flush produced two Memory entries; I did not determine whether its four requests were one extraction plus two reconciliations or included a retry. Switching the Server toqwen/qwen3.7-flash(G2) made it slower, 3,146–8,181 output tokens and 39–106 s per flush. The output schema itself is a few dozen tokens; the volume is model reasoning, and the wait tracks the output token count: roughly 40–80 output tokens per second of waiting across all runs, with no model clearly faster than another. - Proposed, to be verified: default or document generation settings that disable reasoning and cap output for extraction and reconciliation (
GENERATION_MODEL_SETTINGSalready carries provider settings), and state per provider what to set. That the extra output is reasoning is inferred from the size of the output schema, not read from the raw generations, and I have not measured extraction quality with reasoning off. Verify with generation output tokens and seconds per flush, with Memory content unchanged. - Question rather than proposal: when the related set is empty and the window has a single candidate, the reconciliation call only decides create versus noop. A lighter path would halve the requests for first-contact Scopes, but the reconciler also acts as the quality gate, so this needs a maintainer view.
6. Who waits for the flush
- Design:
flush_memoryis synchronous by contract; the Pi package starts its flush atagent_endwith a 1 s closing budget; the Kimi Code integration states "Server owns memory generation; the Hook does not flush";POWERCONTEXT_SERVER_RUNTIME_MEMORY_SCHEDULE_SECONDSturns on automatic Memory admission on an interval; it is unset by default, and the Server reportedautomatic_processing_enabled: falsein these runs. - Measured: the Bub plugin awaits a checkpoint flush every 5 captured events and again in
save_state, inside the turn. These runs raised the plugin's client timeout to 150 s, as the harness README recommends, so every wait ran to completion; at the plugin's default of 10 s each checkpoint wait is capped at 10 s, the checkpoint is recorded as failed, and the Server finishes the extraction on its own. In the ON run its loop stalled 31.7 s and 14.6 s in the capture turn and 41.0 s and 28.2 s in the recall turn; the Server served 5 flushes for 101.5 s; the ON arm-run took 184 s against 92 s for OFF, with agent time 132 s against 46 s. - Proposed: give the Bub plugin the Pi pattern (bounded closing wait) or skip Hook flushes when the Server schedule is on; document
MEMORY_SCHEDULE_SECONDSas the recommended deployment so Hooks only capture; keep the synchronous flush for an explicit "remember now". I have not run with the schedule enabled, so whether it makes the Hook flush unnecessary end to end is untested here. Verify with the gaps between Bub loop steps.
7. pc_search as the recall path of last resort
/v1/memory/search took 0.3–1.0 s and succeeded every time. It is fine as an explicit search; the problem is that today it is the de-facto recall path because of stage 1. Once stage 1 is fixed it should appear only when the user asks to search.
Out of scope here
Harness-side costs (container setup, npm install per trial, compose down) and the harness report's view of the recall path are tracked on the e2e/bub side.
Method notes
The harness is e2e/bub from the #2035 branch, master 06794f66 plus that PR's two commits. Numbers come from Harbor's per-step results, the hosts' own usage logs (per model call), the Server's access log (duration_ms per request) and POST /v1/stats per Scope (usage by purpose). The replay and delay-proxy scripts are small shell and Python files; I can attach them or turn the stable parts into a harness check on request.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsTodo
Activity
Fengzdadi commentedon Oct 23, 2026
Following up on the stage 5 question about reconciliation with an empty related set. I measured it and would like to turn it into a concrete proposal.
What the call can do. When
relatedis empty, the reconciler can onlycreate(optionally rewording) ornoopthe candidate;reviseandmergehave no target. Only the first candidate of a window can see an empty set: later candidates receive the earlier ones asrelated, so in-window deduplication still happens. The extraction prompt already applies the same worthiness, language, time and evidence rules to the same evidence, so this call is a second sample of the extraction decision.Setup. Upstream master 464cd15,
deepseek/deepseek-v4-prothrough OpenRouter, the production prompts and pipeline assembly, full-text retrieval only. Synthetic Sources: 8 single statements, 8 noise messages, 4 multi-fact statements and one 10-step evolving Scope, 2 trials. An earlier run on c0c38de (full-text and hybrid, 112 empty-related calls) is included where noted.Cost. Empty-related reconciliation was 33 of 117 generation requests, 240 of 792 seconds and 18.2k of 61.6k output tokens.
Replay. Each of the 33 recorded requests was sent 5 more times (198 samples including the original):
createnoopof one half of a split preference; the other half stayed as its own candidate27 of the 33 requests returned an identical
createin every sample. There were no language corrections, no dropped noise and no lost facts; the noise Sources produced no candidates at extraction. The earlier run had 104 identical results out of 112, one language correction (a Chinese candidate from an English Source), one dropped noise message, one candidate that lost context ("squash-merge only for pull requests" became "Squash-merge only"), four sibling absorptions and one punctuation change. In that run four other noise candidates, including "thanks, looks good!", were created.End state. I reran the same scenarios with the recorded extractions and empty-related reconciliation replaced by an identical
create. 34 of 42 Scopes ended with the same memories. Of the 8 differences, 3 are granularity (one combined memory against two split ones with the same facts), 3 come from failures in the baseline run (provider unavailable, a 180 s timeout, and an invalidrevisein a later reconciliation, see #2183), and 2 are variance in later reconciliations.Proposal. Skip reconciliation when a candidate has no related items and the Scope uses the built-in
atomic_memory.reconcileprompt; keep the call when the Scope has a custom one, since an administrator may encode create/noop policy there. Expected effect: one fewer generation request for each window whose first candidate has no related items, which halves the requests of a single-candidate flush. Visible change: facts that extraction splits stay split unless a later comparison merges them, which matches the "keep facts that can change independently separate" rule in both prompts. With the skip applied locally,tests/builtinandtests/e2efail only in two usage tests that count two requests for a single-candidate flush.Limits: one model, synthetic Sources, full-text retrieval only. A weaker extraction model may lean more on the second pass; the single language correction is that case.
This removes a request rather than shrinking input, so it should be independent of #1991 (@hidb4ai, #1989). @frf12, unless you see a role for this call that I missed, I will open a PR.
Fengzdadi commentedon Oct 24, 2026
Following up on stage 3, the tool catalog. I prototyped the mechanical slimming of the Pi tool schemas and ran it through the live-model guidance evaluation. Short version: it removes a quarter of the catalog tokens, keeps validation identical, and makes the model break the empty-search rule about three times as often when the Skill body is not loaded. I am not opening a PR for it; the data is below so nobody has to repeat the runs.
Where the tokens are. The Pi plugin registers 35 tools. Serialized as name, description and parameters (o200k_base), the catalog is 15,152 tokens: parameter schemas 12,986 (86%), descriptions 1,623 (11%), plus 1,069 for the guidance block. The heaviest tools are the handoff carriers (
pc_handoff_acknowledge2,554,pc_task_outcome1,646,pc_handoff_finalize1,476,pc_handoff_current1,345), and all of them are in therecommendedprofile, so defaulting Pi torecommendedwould save about a quarter; onlyminimalremoves most of it. Trimming descriptions cannot matter at 11%.The change. The schemas carry constraints the model never needs: 129
minLength: 1, 64 non-blank patterns, 55 printable-ASCII identifier patterns, 85maxLengthof 128 or 256 on identifiers the model copies from Server results, and TypeBox renders every union of literals asanyOfofconstbranches. The prototype keeps the complete schema for validation and gives the model a projection:anyOf-of-constbecomesenum;minLength: 1, the non-blank and charset patterns, andmaxLength <= 256are dropped.prepareArgumentsruns pi-ai'svalidateToolArgumentswith the complete schema before execution, so a rejected call is rejected exactly as before. Catalog: 15,152 to 11,371 tokens (-25.0%);enumalone -5.2%, dropped constraints alone -19.8%. The same projection on the Server MCP catalog (56 tools, 34,944 tokens) would give -21.7%; I did not touch it, it is a public contract.Evaluation.
scripts/evaluate_integration_guidance.pyover the exported Pi catalog,qwen/qwen3.7-flashthrough OpenRouter,max_tokens6000, Skill resource reads disabled, each case in en/zh with the Skill body loaded and unloaded, 2026-10-22/23. The full 12-case set twice per arm, routing plus arguments: baseline 44/48 and 46/48, candidate 45/48 and 45/48. Every recorded candidate call rescored against the complete schemas: 0 of 444 violate it. The failures outsideempty_searchare the same in both arms and unrelated to schemas: handoff carriers with an inventedsource_idor a missingevidence, and one truncated reply.Empty search is where it moves. The case asks "Search my saved memories for Aurora's deployment color" and the controlled reply has no hits. The guidance allows one focused reformulation and says an empty search does not authorize an inventory; a pass is a final reply after at most one reformulation. Three batches of 60 observations per arm (the first with a stopping rule set at 60 before it ran; the other two fresh, interleaved):
pc_memory_list, baselineTwo-sided Fisher. The pass rate was the pre-set metric; the escalation count was chosen after batch 1 and then held on batches 2 and 3. Reformulation use rose with it (74 to 113 of 180). The model did not go silent: after its one reformulation it issued a third
pc_searchor, more often than before, an inventory call.The batch 2 ablation separates the two halves of the change:
enumonly escalated 15/60 (p = 0.011 against the batch's baseline 4/60), dropped constraints only 13/60 (p = 0.034), both 13/60. Dropped constraints change neitherpc_search(its only change ismodebecoming anenum) norpc_memory_list, so the effect comes from the catalog as a whole, not from the definition of the two tools involved.The split by Skill mode is the sharpest result. With the Skill body loaded: pass 67/90 vs 64/90, escalation 4 vs 10 (p = 0.16). Unloaded: pass 66/90 vs 33/90, escalation 9 vs 33 (p < 1e-4). The slim catalog costs nothing while the Skill is in context and halves compliance when it is not.
Batch 3 also ran the unchanged baseline catalog with the registration order reversed, so
pc_searchmoves from first to last: pass 56/60 against 45/60 (p = 0.011), escalation 1 against 5. One exploratory run, no replication, but it says this compliance is sensitive to catalog layout at all, which cuts both ways: a 60-observation test can be moved by things other than token count, and layout may be a cheaper lever than tokens.Limits: one model, one host, synthetic prompts with controlled empty results, and I did not find the mechanism.
What I take from it.
modelSchemaintools.ts, pi-ai validation inprepareArguments, Pi tests passing) is on my fork asperf/pi-tool-schemasif anyone wants to repeat the runs with another model; a model that treats the constraints as noise rather than signal may not regress.recommendeddefault (about -25%, keeping the handoff carriers), or on-demand activation (ExtensionAPI.setActiveToolsexists in Pi 0.82.1; each change rebuilds the system prompt, so it costs one cache miss and interacts with stage 2).If maintainers have a preference between the profile default and the activation route, I would rather know it before spending runs on either.
Fengzdadi commentedon Oct 24, 2026
A correction to the stage 3 numbers above. I ran a fourth, pre-registered batch on 2026-10-23 to test whether one hardened guidance sentence restores
empty_searchcompliance under the slim catalog. Two results, the second more important than the first.The hardened sentence does not help. Four arms, 60 observations each, same evaluator, model and settings as before, catalogs A/B byte-identical to the earlier baseline/candidate. The sentence, inserted after "An empty search does not authorize an inventory.", told the model to stop after at most one reformulation, report what was searched, and not call
pc_memory_listor search again.pc_memory_listpc_searchPass A vs C p = 0.035, A vs D p = 0.0046 (two-sided Fisher). The sentence cuts inventory calls but roughly triples third searches, so it trades one failure for another and lowers the pass rate on the unchanged catalog too. Nothing from this is proposed.
The slim-schema regression did not reproduce. A vs B in this batch is 45 vs 41 on pass (p = 0.54) and 5 vs 5 on escalation; Skill-unloaded is 18/30 vs 18/30. The three earlier batches gave 133/180 vs 97/180 and 13 vs 43. Same catalog bytes, same evaluator revision, same model string and temperature; the only harness difference is a retry wrapper for HTTP 429, which resends the identical request. I cannot attribute the change to anything on our side, so the most likely reading is provider-side drift in
qwen/qwen3.7-flashbetween days, which the evaluator does not record.What I would now claim is weaker than what I wrote above: on this model the empty-search case is not stable enough across sessions for 60-observation arms to settle whether the slim catalog hurts. Pooled over all four batches it still reads as a regression (178/240 vs 138/240 pass, 18 vs 48 escalation), but the pool mixes sessions with very different effect sizes. The prototype stays on my fork, unproposed, and the takeaways about where the tokens are (86% parameters) and the activation/profile levers are unaffected. Raw outputs and the pre-registration are available if anyone wants them.
perf(atomic-memory): skip reconciliation when a candidate has nothing…
Fengzdadi commentedon Nov 5, 2026
Status update, stage by stage, against upstream master
8d01851a.pc_search1. Hook recall. #2231 (merged 10-28) sets one query-embedding deadline per preparation instead of one per search, and raises the default
recall_embedding_timeout_secondsfrom 0.25 s to 0.5 s. With one Scope and a remote provider, ready preparations went from 40–44/60 to 59–60/60. With three Scopes and a stalled provider, a preparation now waits 0.61 s, against 1.06 s with the old per-search 0.25 s budget. For diagnosis, #2144 first addedexplain/reason; #2258 (merged 11-01) replaced them with the opt-in Receipt (include_receipt), which records each retrieval round and its fallback. That covers per-preparation diagnosis. Scope statistics still do not separate "nothing matched" from "matched without the semantic path"; I am leaving that item in #2039 and not working on it. Follow-up: Kimi Code, ZCode and DSH still use a 1 s request / 4 s total prepare budget, against 3 s / 6 s on the other hosts.2. Where the context is injected. I did not implement the Pi
messageplacement proposed above. RFC #2086 (merged 11-02) puts the cache decision in the final executor: it applies the model's cache settings before final rendering, and #2313 adds reuse proofs so an executor can keep prepared context across turns without re-preparing it. With host plugins becoming thin adapters behind the gateway (#2250), cache-preserving placement belongs there, not in one host package. One envelope item did move: the Server already prependsTRUST_POLICY, but several adapters add their own trust sentence on top. #2476 removes it for Kimi Code, and the other adapters follow under #2250.3. Tool catalog. The data is in the two comments above: 86% of the Pi catalog is parameter schemas, and the schema projection stays on my fork. Master is unchanged; Pi still registers 35 tools. Which tools a host exposes is now part of the #2250 thin-plugin plan, so I am not opening a separate Pi profile or
setActiveToolschange.4. Source capture. No change.
capture_eventsis off by default in the Bub plugin, so only runs that turn it on capture each model and tool result.5. Extraction and reconciliation.
6. Who waits for the flush. No change. The Bub plugin still awaits a checkpoint flush every 5 captured events, inside the turn.
What is left here. Stage 5 reasoning settings and the stage 6 Bub flush wait are the two items that no other issue tracks. I am keeping this issue open for them; the rest is either merged or tracked elsewhere (#2039, #2250, RFC #2086).