@@ -132,57 +132,253 @@ def _top_lessons(query, lessons_md, char_budget=8000):
132132 return _lines_up_to_budget ([l for _ , _ , l in relevant ], char_budget )
133133
134134
135+ _TRUNC_MARKER = "\n \n [truncated to fit budget]"
136+ _OMIT_MARKER_FMT = "[{n} items omitted: budget exceeded]"
137+
138+
139+ class _UsedTokens (int ):
140+ """int subclass that carries an `overflow` flag.
141+
142+ Existing callers do `ctx, used = build_context(...)` and treat `used` as an
143+ int — they still see the correct number. New callers can read
144+ `used.overflow` to learn whether enforcement had to drop or truncate
145+ content. This keeps the public 2-tuple signature compatible.
146+ """
147+
148+ # int subclasses can't accept __slots__ for instance attrs (variable-size
149+ # base type), so we override __new__ to stash overflow on the instance dict
150+ # via plain assignment after relying on the default dict.
151+ def __new__ (cls , value , overflow = False ):
152+ obj = super ().__new__ (cls , value )
153+ obj .overflow = overflow
154+ return obj
155+
156+
157+ def _truncate_to_tokens (text , max_tokens ):
158+ """Truncate text so its token estimate fits in max_tokens, with marker.
159+
160+ Uses the same chars-to-tokens ratio as `_token_estimate` (4 chars/token).
161+ Reserves room for the truncation marker so the post-truncation estimate
162+ still fits the budget the caller passed in.
163+ """
164+ if max_tokens <= 0 :
165+ return ""
166+ if _token_estimate (text ) <= max_tokens :
167+ return text
168+ marker_tokens = _token_estimate (_TRUNC_MARKER )
169+ char_budget = max (0 , (max_tokens - marker_tokens ) * 4 )
170+ if char_budget <= 0 :
171+ # No room even for body. Emit just the marker so the section still
172+ # signals presence (required sections must remain in the output).
173+ return _TRUNC_MARKER .lstrip ()
174+ return text [:char_budget ] + _TRUNC_MARKER
175+
176+
135177def build_context (user_input : str , budget : int = 88000 ):
136- """Returns (context_string, tokens_used). Lean and query-aware."""
178+ """Returns (context_string, used_tokens). Lean, query-aware, budget-enforced.
179+
180+ Budget enforcement (P1 fix):
181+ * Required sections (AGENTS map, active workspace, permissions) are
182+ always present in the output. If they would overflow the budget,
183+ their content is truncated to fit and tagged with a
184+ `[truncated to fit budget]` marker — never dropped silently.
185+ * Optional sections (lessons, episodes, matched skills) are skipped
186+ entirely with an `[N items omitted: budget exceeded]` marker when
187+ they would overflow.
188+ * Every assembled context ends with a `[budget: used X / Y tokens]`
189+ summary so callers can see the final accounting.
190+
191+ Return shape is preserved: a 2-tuple `(context_string, used_tokens)`.
192+ `used_tokens` is an int subclass that exposes an `overflow: bool`
193+ attribute for new callers; existing callers that treat it as a plain
194+ int are unaffected.
195+ """
137196 parts , used = [], 0
197+ overflow = False
198+
199+ # Each appended block costs its own tokens *plus* the `\n\n---\n\n`
200+ # separator that join() will add between it and the next block. We track
201+ # separator overhead explicitly so the budget check matches what the
202+ # caller actually receives.
203+ SEPARATOR_TOKENS = _token_estimate ("\n \n ---\n \n " ) # 9 chars → 2 tokens
204+ # Reserve room for the final `[budget: used X / Y tokens]` summary line
205+ # plus its leading separator. Width here is a conservative upper bound.
206+ SUMMARY_RESERVE_TOKENS = _token_estimate ("[budget: used 99999999 / 99999999 tokens]" ) + SEPARATOR_TOKENS
207+ # Per-block `len(s)//4` truncation undercounts vs the post-join estimate.
208+ # Reserve small headroom so the final joined string still fits the budget.
209+ DRIFT_HEADROOM_TOKENS = 4
138210
139- # always load: personal preferences + live workspace + AGENTS map + DECISIONS
140- # AGENTS.md and DECISIONS.md were missing despite AGENTS.md specifying the
141- # read order — the standalone path was not faithful to its own contract.
142- for rel in (
211+ # Required sections are *mandatory* — their headers + omission markers
212+ # are floor-cost overhead. To keep the joined output within budget, we
213+ # pre-reserve that floor so early required sections don't eat budget
214+ # that later required sections need just for their stub.
215+ required_files = (
143216 "AGENTS.md" ,
144217 "memory/personal/PREFERENCES.md" ,
145218 "memory/working/WORKSPACE.md" ,
146219 "memory/working/REVIEW_QUEUE.md" ,
147220 "memory/semantic/DECISIONS.md" ,
148- ):
221+ )
222+ perms_path = "protocols/permissions.md"
223+
224+ def _stub_cost (rel_or_label ):
225+ """Token cost of the minimum stub (header + omission marker + sep)."""
226+ if rel_or_label == perms_path :
227+ header = "# PERMISSIONS\n "
228+ else :
229+ header = f"# { rel_or_label } \n "
230+ stub = header + _OMIT_MARKER_FMT .format (n = 1 )
231+ return _token_estimate (stub ) + SEPARATOR_TOKENS
232+
233+ # Floor = stub cost for every required file that exists on disk + perms.
234+ required_floor = 0
235+ for rel in required_files :
236+ if _read (rel ):
237+ required_floor += _stub_cost (rel )
238+ if _read (perms_path ):
239+ required_floor += _stub_cost (perms_path )
240+
241+ def _append (block ):
242+ """Append a block, charging both its tokens and the join separator."""
243+ nonlocal used
244+ parts .append (block )
245+ # First block has no preceding separator; subsequent blocks do.
246+ sep_cost = SEPARATOR_TOKENS if len (parts ) > 1 else 0
247+ used += _token_estimate (block ) + sep_cost
248+
249+ def _block_cost (block ):
250+ """Token cost of appending `block` (block + separator if not first)."""
251+ sep_cost = SEPARATOR_TOKENS if len (parts ) >= 1 else 0
252+ return _token_estimate (block ) + sep_cost
253+
254+ # Track how much of `required_floor` we've already paid; the remainder
255+ # is reserved out of `_room()` so we don't overspend on early sections.
256+ paid_floor = 0
257+
258+ def _room ():
259+ # Remaining required_floor we haven't paid yet stays reserved.
260+ remaining_floor = max (0 , required_floor - paid_floor )
261+ return budget - used - SUMMARY_RESERVE_TOKENS - DRIFT_HEADROOM_TOKENS - remaining_floor
262+
263+ # ------------------------------------------------------------------
264+ # Required sections — must appear in output. Truncate if oversized.
265+ # Preserves the original load order: AGENTS map first, then personal
266+ # preferences, live workspace, review queue, semantic decisions. These
267+ # are the sections agentic-stack treats as always-on context.
268+ # ------------------------------------------------------------------
269+ for rel in required_files :
149270 text = _read (rel )
150- if text :
151- parts .append (f"# { rel } \n { text } " )
152- used += _token_estimate (text )
271+ if not text :
272+ continue
273+ header = f"# { rel } \n "
274+ # Pay this section's floor first so _room() releases its reservation.
275+ paid_floor += _stub_cost (rel )
276+ # Room for the *body*, after subtracting header and separator overhead.
277+ sep_cost = SEPARATOR_TOKENS if parts else 0
278+ body_room = _room () - _token_estimate (header ) - sep_cost
279+ body_tokens = _token_estimate (text )
280+ if body_room <= 0 :
281+ # No room left at all. Emit header + omission marker so the
282+ # caller still sees the section name in the assembled context.
283+ block = header + _OMIT_MARKER_FMT .format (n = 1 )
284+ _append (block )
285+ overflow = True
286+ continue
287+ if body_tokens > body_room :
288+ text = _truncate_to_tokens (text , body_room )
289+ overflow = True
290+ _append (header + text )
153291
154- # query-aware lessons
292+ # ------------------------------------------------------------------
293+ # Optional: query-aware lessons. Skip with marker on overflow.
294+ # ------------------------------------------------------------------
155295 lessons_raw = _read ("memory/semantic/LESSONS.md" )
156296 if lessons_raw :
157297 lessons = _top_lessons (user_input , lessons_raw , char_budget = 8000 )
158298 if lessons :
159- parts .append (f"# LESSONS (query-relevant)\n { lessons } " )
160- used += _token_estimate (lessons )
299+ header = "# LESSONS (query-relevant)\n "
300+ block = header + lessons
301+ if _block_cost (block ) <= _room ():
302+ _append (block )
303+ else :
304+ n = sum (1 for ln in lessons .splitlines () if ln .strip ().startswith ("- " ))
305+ marker_block = header + _OMIT_MARKER_FMT .format (n = max (n , 1 ))
306+ if _block_cost (marker_block ) <= _room ():
307+ _append (marker_block )
308+ overflow = True
161309
162- # query-aware top episodes
310+ # ------------------------------------------------------------------
311+ # Optional: query-aware top episodes. Skip with marker on overflow.
312+ # ------------------------------------------------------------------
163313 episodes = _top_episodes (user_input , k = 5 )
164314 if episodes :
165- parts .append (f"# RECENT EPISODES (salience x relevance)\n { episodes } " )
166- used += _token_estimate (episodes )
315+ header = "# RECENT EPISODES (salience x relevance)\n "
316+ block = header + episodes
317+ if _block_cost (block ) <= _room ():
318+ _append (block )
319+ else :
320+ n = sum (1 for ln in episodes .splitlines () if ln .strip ().startswith ("- " ))
321+ marker_block = header + _OMIT_MARKER_FMT .format (n = max (n , 1 ))
322+ if _block_cost (marker_block ) <= _room ():
323+ _append (marker_block )
324+ overflow = True
167325
168- # matched skills only (progressive_load is already input-matched).
326+ # ------------------------------------------------------------------
327+ # Optional: matched skills (progressive_load is already input-matched).
169328 # Lazy import so a missing skill_loader doesn't kill context assembly.
329+ # ------------------------------------------------------------------
170330 try :
171331 from skill_loader import progressive_load
172332 skills = progressive_load (user_input )
173333 except Exception :
174334 skills = []
335+ skipped_skills = 0
175336 for s in skills :
176337 block = f"## Skill: { s ['name' ]} \n { s ['content' ]} "
177- t = _token_estimate (block )
178- if used + t < budget :
179- parts .append (block )
180- used += t
338+ if _block_cost (block ) <= _room ():
339+ _append (block )
340+ else :
341+ skipped_skills += 1
342+ overflow = True
343+ if skipped_skills :
344+ marker_block = _OMIT_MARKER_FMT .format (n = skipped_skills ) + " (skills)"
345+ if _block_cost (marker_block ) <= _room ():
346+ _append (marker_block )
181347
182- # permissions always last, small, safety-critical
183- perms = _read ("protocols/permissions.md" )
348+ # ------------------------------------------------------------------
349+ # Required: permissions. Last and safety-critical — must appear,
350+ # truncated if oversized.
351+ # ------------------------------------------------------------------
352+ perms = _read (perms_path )
184353 if perms :
185- parts .append (f"# PERMISSIONS\n { perms } " )
186- used += _token_estimate (perms )
354+ header = "# PERMISSIONS\n "
355+ # Pay the perms floor so _room() releases its reservation.
356+ paid_floor += _stub_cost (perms_path )
357+ sep_cost = SEPARATOR_TOKENS if parts else 0
358+ body_room = _room () - _token_estimate (header ) - sep_cost
359+ body_tokens = _token_estimate (perms )
360+ if body_room <= 0 :
361+ block = header + _OMIT_MARKER_FMT .format (n = 1 )
362+ _append (block )
363+ overflow = True
364+ else :
365+ if body_tokens > body_room :
366+ perms = _truncate_to_tokens (perms , body_room )
367+ overflow = True
368+ _append (header + perms )
369+
370+ # ------------------------------------------------------------------
371+ # Final summary line. Always appended so callers can audit the
372+ # assembled context's accounting at a glance.
373+ # ------------------------------------------------------------------
374+ summary = f"[budget: used { used } / { budget } tokens]"
375+ _append (summary )
187376
188- return "\n \n ---\n \n " .join (parts ), used
377+ # Reconcile the running tally against the actually joined string. Per-block
378+ # `len(s) // 4` integer truncation undercounts vs the concatenated whole,
379+ # so prefer the post-join estimate as the authoritative number returned.
380+ final = "\n \n ---\n \n " .join (parts )
381+ final_tokens = _token_estimate (final )
382+ if final_tokens > budget :
383+ overflow = True
384+ return final , _UsedTokens (final_tokens , overflow )
0 commit comments