Skip to content

Commit cebe245

Browse files
authored
Merge pull request #40 from codejunkie99/feature/trust-console-tui
feat: trust console + memory check layer (v0.15.0)
2 parents a628b28 + bddc63b commit cebe245

25 files changed

Lines changed: 4521 additions & 71 deletions

.agent/harness/context_budget.py

Lines changed: 221 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
135177
def 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

Comments
 (0)