io_harness/context.rs
1//! The observation log the model sees, and what bounds it.
2//!
3//! Until 0.10 the workspace loop kept one `String`, appended every tool result to
4//! it, and re-sent the whole thing verbatim every turn: nothing bounded it,
5//! nothing removed a read the agent had already superseded, and nothing noticed
6//! when a write made an earlier read wrong. This module replaces that string with
7//! a [`Ledger`] of typed [`Observation`]s — history, never mutated — and an
8//! [`assemble`] step that decides, per turn, what of that history the model
9//! actually sees under a [`ContextBudget`].
10//!
11//! Three things it does that a growing string cannot:
12//!
13//! - **Bounds.** One [`ContextBudget`] derives the whole prompt's ceiling *and*
14//! the per-observation cap ([`entry_cap_chars`]), so the two cannot drift apart
15//! the way four independent constants did.
16//! - **Supersedes.** Two reads of one path, or two greps of one pattern, are one
17//! answer; the older becomes a one-line stub naming the newer.
18//! - **Freshens.** A read the agent later wrote over is stale, and a stale read
19//! that would otherwise be carried is re-read at assembly time — through the
20//! policy, so freshening cannot read what the run may not read.
21//!
22//! What it deliberately does not do: nothing here shrinks what an operator can
23//! audit. The store's `steps.result` keeps the full, unelided log
24//! ([`Ledger::full_text`]); eliding is a decision about the *request*, not about
25//! the trace.
26
27use serde::{Deserialize, Serialize};
28
29use crate::error::Result;
30use crate::policy::{Act, Effect, Policy};
31use crate::state::{ContextEvent, MemoryEntry, Store};
32use crate::tools::Workspace;
33
34/// What an observation was, so assembly can reason about it.
35///
36/// The serde rendering (`read`, `grep`, `find`, `write`, `skill`, `tool`, `mcp`,
37/// `child`, `message`, `error` — snake_case, as [`Act`] and [`Effect`] already
38/// are) is a *wire format*: it is what a persisted ledger's `kind` column holds,
39/// so each of those ten strings is a stored value that a later release may not
40/// rename. It is deliberately not [`ObsKind::label`], which renders different
41/// words for a different reader — see the note there.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub enum ObsKind {
45 /// A file read into context.
46 Read,
47 /// A content search.
48 Grep,
49 /// A filename search.
50 Find,
51 /// A file written.
52 Write,
53 /// A skill body loaded.
54 Skill,
55 /// A tool the embedding program registered.
56 Tool,
57 /// A tool an MCP server offered.
58 Mcp,
59 /// A sub-agent's composed result.
60 Child,
61 /// The model said something instead of calling a tool.
62 Message,
63 /// A tool failed, or the policy refused it.
64 Error,
65}
66
67impl ObsKind {
68 /// Whether a later observation of the same target replaces this one.
69 ///
70 /// True where the target *is* the subject of the answer: a path, a search
71 /// pattern, a glob, a skill's name. False where the target is only the name
72 /// of the thing that answered — a registered or MCP tool called twice with
73 /// different arguments gave two different answers, and stubbing the first as
74 /// "superseded" would throw one of them away.
75 pub fn target_is_the_subject(self) -> bool {
76 matches!(
77 self,
78 ObsKind::Read | ObsKind::Grep | ObsKind::Find | ObsKind::Write | ObsKind::Skill
79 )
80 }
81
82 /// The word a stub uses for this kind — the same word the observation's own
83 /// header uses, so a stub reads as the thing it replaced.
84 ///
85 /// **Not the serialized form, and must not be unified with it.** These are
86 /// English for the model and the operator reading a prompt (`Write` is
87 /// "wrote", `Mcp` is "mcp tool"); the serde rendering on the type above is a
88 /// stored value. Making either one match the other changes the prompt text
89 /// or orphans every persisted ledger, and neither failure announces itself.
90 pub fn label(self) -> &'static str {
91 match self {
92 ObsKind::Read => "read",
93 ObsKind::Grep => "grep",
94 ObsKind::Find => "find",
95 ObsKind::Write => "wrote",
96 ObsKind::Skill => "skill",
97 ObsKind::Tool => "tool",
98 ObsKind::Mcp => "mcp tool",
99 ObsKind::Child => "child",
100 ObsKind::Message => "note",
101 ObsKind::Error => "error",
102 }
103 }
104}
105
106/// One observation exactly as it happened.
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct Observation {
109 /// The step it happened on.
110 pub step: u32,
111 /// What it was.
112 pub kind: ObsKind,
113 /// The path or subject the tool named, where it names one.
114 pub target: Option<String>,
115 /// The text the model would see, already bounded by [`entry_cap_chars`] at
116 /// the point it entered the log.
117 pub text: String,
118}
119
120impl Observation {
121 /// One observation of `kind` about `target`.
122 pub fn new(step: u32, kind: ObsKind, target: Option<String>, text: impl Into<String>) -> Self {
123 Self {
124 step,
125 kind,
126 target,
127 text: text.into(),
128 }
129 }
130}
131
132/// The observations of one run, in order. Assembly reads it; nothing mutates
133/// history.
134///
135/// `entries` stays private through serde too — it serializes as the one field it
136/// is, so a restored ledger is still append-only through [`Ledger::push`].
137#[derive(Debug, Clone, Default, Serialize, Deserialize)]
138pub struct Ledger {
139 entries: Vec<Observation>,
140}
141
142impl Ledger {
143 /// An empty log.
144 pub fn new() -> Self {
145 Self::default()
146 }
147
148 /// Append one observation. Append-only by design: the elided view is built
149 /// per turn by [`assemble`], so bounding a request never loses history.
150 pub fn push(&mut self, obs: Observation) {
151 self.entries.push(obs);
152 }
153
154 /// How many observations the run has made.
155 pub fn len(&self) -> usize {
156 self.entries.len()
157 }
158
159 /// Whether the run has observed nothing yet.
160 pub fn is_empty(&self) -> bool {
161 self.entries.is_empty()
162 }
163
164 /// Every observation, in order.
165 pub fn entries(&self) -> &[Observation] {
166 &self.entries
167 }
168
169 /// The whole log, unelided — what an operator reconstructing a run wants, so
170 /// bounding what the model sees never bounds what can be audited.
171 pub fn full_text(&self) -> String {
172 self.entries.iter().map(|e| e.text.as_str()).collect()
173 }
174
175 /// The observations made on one step: what that step's trace row records.
176 ///
177 /// The trace stores a per-step delta rather than the whole log per step, so
178 /// concatenating the rows in step order reproduces [`Ledger::full_text`]
179 /// exactly while the trace stays linear in the step count instead of
180 /// quadratic — which matters on the 24-hour runs 0.7.0 supports.
181 pub fn text_for_step(&self, step: u32) -> String {
182 self.entries
183 .iter()
184 .filter(|e| e.step == step)
185 .map(|e| e.text.as_str())
186 .collect()
187 }
188}
189
190/// Estimated tokens for `text`. An estimate, never a count.
191// ponytail: 4-chars-per-token heuristic — no tokenizer dependency is permitted in
192// this release. Drift is recorded in the trace beside the provider's own number;
193// upgrade path is a per-provider tokenizer if the recorded drift ever matters.
194pub fn estimate_tokens(text: &str) -> u64 {
195 (text.chars().count() as u64).div_ceil(4)
196}
197
198/// How much of a request the observation log may occupy.
199///
200/// The half of budgeting that [`TaskContract::with_token_budget`] is not:
201/// that bounds what a whole run may spend, this bounds what any *one* request
202/// carries of what the run has already observed. Without it a long workspace
203/// run re-sends its entire history every turn and the cost of turn *n* grows
204/// with *n*.
205///
206/// ```
207/// use io_harness::ContextBudget;
208///
209/// let budget = ContextBudget::default();
210/// assert_eq!(budget.max_tokens, 24_000);
211/// assert_eq!(budget.share, 0.5);
212///
213/// // With no run token budget, the ceiling is `max_tokens` flat.
214/// assert_eq!(budget.effective_tokens(None), 24_000);
215///
216/// // With one, the prompt takes `share` of what is *left* — so a run running
217/// // low stops spending what remains on re-sending history and leaves it for
218/// // doing the work.
219/// assert_eq!(budget.effective_tokens(Some(100_000)), 24_000); // capped by max_tokens
220/// assert_eq!(budget.effective_tokens(Some(20_000)), 10_000); // half of the remainder
221/// assert_eq!(budget.effective_tokens(Some(1_000)), 2_000); // floored, never zero
222/// ```
223///
224/// That last line is the floor: a prompt too small to carry one observation is
225/// a turn the agent cannot act on, so the final turns still get a usable
226/// request even when it exceeds what is nominally left.
227///
228/// Tighten it for a model with a small window, or for a run whose observations
229/// are large:
230///
231/// ```
232/// use io_harness::{ContextBudget, TaskContract, Verification};
233///
234/// let contract = TaskContract::workspace(
235/// "make the failing test pass",
236/// "/path/to/repo",
237/// Verification::WorkspaceFileContains { file: "OK".into(), needle: "ok".into() },
238/// )
239/// .with_token_budget(200_000)
240/// .with_context_budget(ContextBudget { max_tokens: 8_000, share: 0.25 });
241/// # let _ = contract;
242/// ```
243///
244/// [`TaskContract::with_token_budget`]: crate::TaskContract::with_token_budget
245#[derive(Debug, Clone, Copy, PartialEq)]
246pub struct ContextBudget {
247 /// Absolute per-request ceiling for the assembled prompt.
248 pub max_tokens: u64,
249 /// Share of the token budget still unspent that the prompt may use.
250 pub share: f32,
251}
252
253impl Default for ContextBudget {
254 fn default() -> Self {
255 Self {
256 max_tokens: 24_000,
257 share: 0.5,
258 }
259 }
260}
261
262/// The smallest assembled section a nearly-exhausted budget still gets: a prompt
263/// too small to carry one observation is a turn the agent cannot act on.
264const BUDGET_FLOOR: u64 = 2_000;
265
266impl ContextBudget {
267 /// The ceiling for this turn's assembled section.
268 ///
269 /// With no run token budget it is [`max_tokens`](ContextBudget::max_tokens)
270 /// flat. With one, it is the configured `share` of what is *left*, so a run
271 /// running out of budget spends less of it on re-sent history — floored at
272 /// `2000` tokens so the last turns still send a usable prompt, and never
273 /// above `max_tokens`.
274 pub fn effective_tokens(&self, remaining_budget: Option<u64>) -> u64 {
275 match remaining_budget {
276 None => self.max_tokens,
277 Some(remaining) => {
278 let share = (remaining as f32 * self.share) as u64;
279 self.max_tokens.min(share.max(BUDGET_FLOOR))
280 }
281 }
282 }
283}
284
285/// Chars a single observation may contribute. Derived from the same budget as the
286/// whole prompt, so the four independent constants this replaces cannot drift
287/// apart.
288pub fn entry_cap_chars(effective_tokens: u64) -> usize {
289 // An eighth of the budget, in chars: one observation may not crowd out the
290 // seven before it.
291 (2_000).max(effective_tokens as usize * 4 / 8)
292}
293
294/// Bound one observation to `cap` chars, marked so the model can see what it is
295/// missing and act on it.
296///
297/// The head is kept for most kinds; for a [`ObsKind::Read`] the tail is kept,
298/// because the end of a file is what a writer needs. Never splits a char
299/// boundary.
300pub fn bound(text: &str, cap: usize, kind: ObsKind) -> String {
301 let total = text.chars().count();
302 if total <= cap {
303 return text.to_string();
304 }
305 // "truncated" as well as "elided": one word for the operator reading a trace,
306 // one for the model reading the prompt, and one marker rather than two.
307 let mark = format!(
308 "…[truncated: elided {} of {} chars — re-read or narrow the query if you need the rest]",
309 commas(total - cap),
310 commas(total)
311 );
312 let at = |n: usize| {
313 text.char_indices()
314 .nth(n)
315 .map(|(i, _)| i)
316 .unwrap_or(text.len())
317 };
318 if kind == ObsKind::Read {
319 format!("{mark}\n{}", &text[at(total - cap)..])
320 } else {
321 format!("{}\n{mark}", &text[..at(cap)])
322 }
323}
324
325/// `41220` -> `41,220`. Sizes in a stub are for a human and a model to judge
326/// "is that worth re-reading", and unseparated digits read as noise.
327fn commas(n: usize) -> String {
328 let d = n.to_string();
329 let mut out = String::with_capacity(d.len() + d.len() / 3);
330 for (i, c) in d.chars().enumerate() {
331 if i > 0 && (d.len() - i).is_multiple_of(3) {
332 out.push(',');
333 }
334 out.push(c);
335 }
336 out
337}
338
339/// Where one assembly happens: the run it belongs to, and what it may read.
340///
341/// Bundled rather than passed loose because these five travel together and never
342/// vary independently — the turn changes, the run does not.
343// No `Debug`: `Store` has none, and what a caller wants printed is the run, not the
344// connection.
345#[derive(Clone, Copy)]
346pub struct Assembly<'a> {
347 /// The workspace a stale read is refreshed through, if the run has one.
348 pub ws: Option<&'a Workspace>,
349 /// The policy that decides whether a refresh may read.
350 pub policy: &'a Policy,
351 /// Where the assembly's own decisions are recorded.
352 pub store: &'a Store,
353 /// The run being assembled for.
354 pub run_id: i64,
355 /// The step whose request this is.
356 pub step: u32,
357}
358
359/// The observation section for one turn, and what it cost.
360#[derive(Debug, Clone, Default)]
361pub struct Assembled {
362 /// The text to put in the prompt.
363 pub text: String,
364 /// Observations carried whole.
365 pub carried: usize,
366 /// Observations replaced by a one-line stub.
367 pub stubbed: usize,
368 /// Stale reads re-read at assembly time (whether or not the re-read worked).
369 pub reread: usize,
370 /// Notes from earlier runs carried into this turn.
371 pub recalled: usize,
372 /// Whether the stubs were collapsed into one line to hold the ceiling.
373 pub collapsed: bool,
374 /// Estimated tokens for `text` — see [`estimate_tokens`].
375 pub est_tokens: u64,
376}
377
378/// How one entry is going to appear this turn.
379enum Shape {
380 /// Carried, with the text to carry (a re-read entry's text is the fresh one).
381 Whole(String),
382 /// Elided, with the reason.
383 Stub(String),
384}
385
386/// Build the observation section the model sees this turn.
387///
388/// Rules, in order: supersession (a later observation of the same kind and target
389/// replaces an earlier one), invalidation (a write makes an earlier read of that
390/// path stale), re-read (a stale read that would otherwise be carried is refreshed
391/// through the policy), fit (newest first, whole while it fits, stubs after), and
392/// chronological emission so the model reads the run forwards.
393///
394/// One `assembled` trace row per turn, plus one per re-read. Never a row per stub.
395pub async fn assemble(
396 ledger: &Ledger,
397 budget_tokens: u64,
398 notes: &[MemoryEntry],
399 at: Assembly<'_>,
400) -> Result<Assembled> {
401 let Assembly {
402 ws,
403 policy,
404 store,
405 run_id,
406 step,
407 } = at;
408 let entries = ledger.entries();
409 let n = entries.len();
410 let cap = entry_cap_chars(budget_tokens);
411 let mut out = Assembled::default();
412
413 // Memory first. Notes from earlier runs are the cheapest context there is —
414 // they are what makes a second run over a workspace cheaper than the first —
415 // but they are also the part a long run must not let crowd out what it just
416 // observed, so they get a quarter of the ceiling and the observations get what
417 // is left.
418 let (notes_text, notes_carried) = render_notes(notes, budget_tokens / 4);
419 out.recalled = notes_carried;
420 let budget_tokens = budget_tokens.saturating_sub(estimate_tokens(¬es_text));
421
422 // 1. Supersession, and 2. invalidation. Both are "is there a later entry
423 // that makes this one not the current answer".
424 let superseded: Vec<Option<u32>> = (0..n)
425 .map(|i| {
426 if !entries[i].kind.target_is_the_subject() {
427 return None;
428 }
429 entries[i].target.as_ref().and_then(|t| {
430 entries[i + 1..]
431 .iter()
432 .find(|l| l.kind == entries[i].kind && l.target.as_deref() == Some(t.as_str()))
433 .map(|l| l.step)
434 })
435 })
436 .collect();
437 let invalidated: Vec<Option<u32>> = (0..n)
438 .map(|i| {
439 if entries[i].kind != ObsKind::Read {
440 return None;
441 }
442 entries[i].target.as_ref().and_then(|t| {
443 entries[i + 1..]
444 .iter()
445 .find(|l| l.kind == ObsKind::Write && l.target.as_deref() == Some(t.as_str()))
446 .map(|l| l.step)
447 })
448 })
449 .collect();
450
451 // 3. Re-read. A stale read is worth carrying only as its *current* contents,
452 // so it is refreshed here — through the policy, at this step, because the
453 // read the model would otherwise trust was decided many steps ago.
454 let mut shapes: Vec<Option<Shape>> = (0..n).map(|_| None).collect();
455 for i in 0..n {
456 let (Some(wrote_at), None) = (invalidated[i], superseded[i]) else {
457 continue;
458 };
459 let target = entries[i].target.clone().unwrap_or_default();
460 out.reread += 1;
461 match refresh(ws, policy, &target, cap) {
462 Ok(fresh) => {
463 store.record_context_event(
464 run_id,
465 &ContextEvent::reread(step, format!("{target} (written at step {wrote_at})")),
466 )?;
467 shapes[i] = Some(Shape::Whole(format!(
468 "\n[read {target}] (re-read at step {step}; the read at step {} was invalidated \
469 by the write at step {wrote_at})\n{fresh}\n",
470 entries[i].step
471 )));
472 }
473 Err(why) => {
474 store.record_context_event(
475 run_id,
476 &ContextEvent::reread_refused(step, format!("{target}: {why}")),
477 )?;
478 shapes[i] = Some(Shape::Stub(format!(
479 "invalidated by the write at step {wrote_at}; the re-read at step {step} could \
480 not be done ({why}) — read it yourself"
481 )));
482 }
483 }
484 }
485
486 // 4. Fit: newest first, whole while the running total stays inside the
487 // ceiling; once one does not fit, every older entry is a stub. Superseded and
488 // stale-unrefreshable entries never consume budget — they are stubs already.
489 let mut used = 0u64;
490 let mut whole = vec![false; n];
491 for i in (0..n).rev() {
492 if superseded[i].is_some() || matches!(shapes[i], Some(Shape::Stub(_))) {
493 continue;
494 }
495 let text = match &shapes[i] {
496 Some(Shape::Whole(t)) => t.as_str(),
497 _ => entries[i].text.as_str(),
498 };
499 let t = estimate_tokens(text);
500 if used + t > budget_tokens {
501 break;
502 }
503 used += t;
504 whole[i] = true;
505 }
506
507 // 5. Emit chronologically, so the model reads the run forwards — the notes it
508 // had before this run first, then the run itself.
509 //
510 // Stub lines are the one part of the section that grows with a run's LENGTH
511 // rather than with what it observed: 4 elisions a step is ~60 tokens a step,
512 // which on a 200-step run would exceed the ceiling one stub at a time. Past a
513 // slice of the budget they collapse into a single line, so the ceiling holds on
514 // a long run instead of merely holding on a short one.
515 let stub_ceiling = (budget_tokens / 8).max(64);
516 let mut pieces: Vec<(bool, String)> = Vec::with_capacity(n);
517 for i in 0..n {
518 let e = &entries[i];
519 if whole[i] {
520 out.carried += 1;
521 let text = match &shapes[i] {
522 Some(Shape::Whole(t)) => t.clone(),
523 _ => e.text.clone(),
524 };
525 pieces.push((true, text));
526 continue;
527 }
528 out.stubbed += 1;
529 let why = match (&shapes[i], superseded[i]) {
530 (_, Some(at)) => format!("superseded by the {} at step {at}", e.kind.label()),
531 (Some(Shape::Stub(why)), _) => why.clone(),
532 _ => format!(
533 "{} chars, older than the current context window — re-run if you need it",
534 commas(e.text.chars().count())
535 ),
536 };
537 let subject = match &e.target {
538 Some(t) => format!("{} {t}", e.kind.label()),
539 None => e.kind.label().to_string(),
540 };
541 pieces.push((false, format!("\n[{subject}] (elided: {why})\n")));
542 }
543
544 let stub_tokens: u64 = pieces
545 .iter()
546 .filter(|(whole, _)| !whole)
547 .map(|(_, t)| estimate_tokens(t))
548 .sum();
549 out.text.push_str(¬es_text);
550 if stub_tokens <= stub_ceiling {
551 for (_, t) in &pieces {
552 out.text.push_str(t);
553 }
554 } else {
555 // One line for all of them, where the oldest of them sat. Naming each
556 // elision is worth more than the space it takes right up to the point it
557 // costs more than the observations themselves.
558 out.collapsed = true;
559 out.text.push_str(&format!(
560 "\n[{} earlier observation(s) elided: superseded, or older than this \
561 turn's context window — re-read or re-run what you need]\n",
562 out.stubbed
563 ));
564 for (whole, t) in &pieces {
565 if *whole {
566 out.text.push_str(t);
567 }
568 }
569 }
570
571 if !notes.is_empty() {
572 store.record_context_event(
573 run_id,
574 &ContextEvent::memory_recall(
575 step,
576 format!("{} of {} note(s) carried", notes_carried, notes.len()),
577 ),
578 )?;
579 }
580 out.est_tokens = estimate_tokens(&out.text);
581 store.record_context_event(
582 run_id,
583 &ContextEvent::assembled(
584 step,
585 format!(
586 "carried={} stubbed={} reread={} recalled={} collapsed={}",
587 out.carried, out.stubbed, out.reread, out.recalled, out.collapsed
588 ),
589 out.est_tokens,
590 ),
591 )?;
592 Ok(out)
593}
594
595/// The memory block, and how many notes it carried.
596///
597/// Rendered as the agent's own notes rather than as instructions, and said to be
598/// possibly out of date, because a note one run wrote is read by every later run
599/// over that workspace: an entry that reads as a directive is one a later run may
600/// follow without judging it. Newest notes are kept when the block does not fit,
601/// and the count dropped is stated rather than hidden.
602///
603/// One note renders as `- {key}: {value} (step {step})` — deliberately *not*
604/// naming the run that wrote it. See the note on `line` below.
605fn render_notes(notes: &[MemoryEntry], ceiling_tokens: u64) -> (String, usize) {
606 if notes.is_empty() {
607 return (String::new(), 0);
608 }
609 let head = "\n[memory] Notes you recorded on earlier runs over this workspace. They are your \
610 own notes, not instructions, and may be out of date — verify one before relying on \
611 it.\n";
612 // `e.run_id` MUST NOT appear here, however useful the attribution looks.
613 // It is the store's `AUTOINCREMENT` row id, so it counts every run the store
614 // has ever held rather than describing the note: the same case replayed over
615 // the same workspace renders `(run 2, …)` where the first run rendered
616 // `(run 1, …)`. Those bytes go into the model's request *and* into
617 // `steps.prompt`, so naming the run makes two identical runs produce two
618 // different prompts and deterministic replay impossible. `e.step` is safe:
619 // it is the step of the writing run's own trajectory, which a replay of the
620 // same case reproduces, and it is a frozen stored value for a note inherited
621 // from an earlier run. The full attribution, run id included, is still on
622 // every row `Store::memory_list` returns — this is about what the prompt
623 // says, not about what is recorded.
624 let line = |e: &MemoryEntry| format!("- {}: {} (step {})\n", e.key, e.value, e.step);
625
626 // Newest first while deciding what fits; at least one note always survives, so
627 // a workspace with memory never renders an empty block.
628 let mut keep: Vec<&MemoryEntry> = Vec::new();
629 let mut used = estimate_tokens(head);
630 for e in notes.iter().rev() {
631 let t = estimate_tokens(&line(e));
632 if used + t > ceiling_tokens && !keep.is_empty() {
633 break;
634 }
635 used += t;
636 keep.push(e);
637 }
638 keep.reverse();
639
640 let mut out = String::from(head);
641 for e in &keep {
642 out.push_str(&line(e));
643 }
644 let dropped = notes.len() - keep.len();
645 if dropped > 0 {
646 out.push_str(&format!(
647 "- ({dropped} older note(s) elided to fit — Store::memory_list has all of them)\n"
648 ));
649 }
650 (out, keep.len())
651}
652
653/// Re-read `target`'s current contents for assembly, or say why not.
654///
655/// Two guards, both the same ones the read being refreshed passed. The policy
656/// decides first, so freshening cannot read what the run itself may not read, and
657/// `Effect::Ask` is a refusal here because assembly has no approver and no turn to
658/// spend on one. The read itself then goes through [`Workspace::read_file`], whose
659/// `resolve` is what keeps a target inside the root — checking the policy while
660/// reading the filesystem directly would copy the wrong half of the pair.
661fn refresh(
662 ws: Option<&Workspace>,
663 policy: &Policy,
664 target: &str,
665 cap: usize,
666) -> std::result::Result<String, String> {
667 let Some(ws) = ws else {
668 return Err("this run has no workspace to re-read from".into());
669 };
670 let verdict = policy.check(Act::Read, target);
671 if verdict.effect != Effect::Allow {
672 let rule = verdict
673 .rule
674 .as_deref()
675 .map(|r| format!(" by rule {r}"))
676 .unwrap_or_default();
677 let what = if verdict.effect == Effect::Deny {
678 "the policy denies reading it"
679 } else {
680 // No approver and no turn to spend on one, so `Ask` is a refusal here.
681 "the policy sends reading it to a human"
682 };
683 return Err(format!("{what}{rule}"));
684 }
685 match ws.read_file(target) {
686 // `read_file` reads a missing path as empty rather than failing, so an
687 // empty result is reported as what it is: nothing left to carry.
688 Ok(body) if body.is_empty() => Err("it is now empty, or gone".into()),
689 Ok(body) => Ok(bound(&body, cap, ObsKind::Read)),
690 Err(e) => Err(format!("the re-read failed: {e}")),
691 }
692}
693
694#[cfg(test)]
695mod tests {
696 use super::*;
697
698 #[test]
699 fn estimate_tokens_is_four_chars_per_token_rounded_up() {
700 assert_eq!(estimate_tokens(""), 0);
701 assert_eq!(estimate_tokens("a"), 1);
702 assert_eq!(estimate_tokens("abcd"), 1);
703 assert_eq!(estimate_tokens("abcde"), 2);
704 assert_eq!(estimate_tokens(&"x".repeat(4_000)), 1_000);
705 // Chars, not bytes: a multi-byte char is one char.
706 assert_eq!(estimate_tokens("éééé"), 1);
707 }
708
709 #[test]
710 fn effective_tokens_is_the_ceiling_when_no_budget_is_set() {
711 let b = ContextBudget::default();
712 assert_eq!(b.effective_tokens(None), 24_000);
713 }
714
715 #[test]
716 fn effective_tokens_takes_the_configured_share_of_what_is_left() {
717 let b = ContextBudget::default();
718 assert_eq!(b.effective_tokens(Some(40_000)), 20_000);
719 // Never above the absolute ceiling, however much budget is left.
720 assert_eq!(b.effective_tokens(Some(10_000_000)), 24_000);
721 }
722
723 #[test]
724 fn a_nearly_exhausted_budget_still_gets_a_usable_floor() {
725 let b = ContextBudget::default();
726 assert_eq!(b.effective_tokens(Some(10)), BUDGET_FLOOR);
727 assert_eq!(b.effective_tokens(Some(0)), BUDGET_FLOOR);
728 // A ceiling below the floor still wins: the floor never raises a
729 // caller's explicit ceiling.
730 let tiny = ContextBudget {
731 max_tokens: 500,
732 share: 0.5,
733 };
734 assert_eq!(tiny.effective_tokens(Some(0)), 500);
735 }
736
737 #[test]
738 fn entry_cap_chars_is_an_eighth_of_the_budget_with_a_floor() {
739 assert_eq!(entry_cap_chars(24_000), 12_000);
740 assert_eq!(entry_cap_chars(2_000), 2_000);
741 // The floor holds for any tiny budget, so one observation is still usable.
742 assert_eq!(entry_cap_chars(0), 2_000);
743 }
744
745 #[test]
746 fn bounding_keeps_the_head_for_most_kinds_and_the_tail_for_a_read() {
747 let text: String = ('a'..='z').cycle().take(100).collect();
748 let head = bound(&text, 10, ObsKind::Grep);
749 assert!(head.starts_with(&text[..10]), "got {head}");
750 assert!(head.contains("elided 90 of 100 chars"), "got {head}");
751 let tail = bound(&text, 10, ObsKind::Read);
752 assert!(tail.ends_with(&text[90..]), "got {tail}");
753 assert!(tail.contains("elided 90 of 100 chars"), "got {tail}");
754 // Under the cap, nothing is touched.
755 assert_eq!(bound("short", 10, ObsKind::Read), "short");
756 }
757
758 #[test]
759 fn bounding_never_splits_a_char_boundary() {
760 let text = "é".repeat(100);
761 for kind in [ObsKind::Read, ObsKind::Grep] {
762 let b = bound(&text, 7, kind);
763 assert!(b.contains("elided 93 of 100 chars"));
764 assert_eq!(b.matches('é').count(), 7, "kept exactly the cap in chars");
765 }
766 }
767
768 #[test]
769 fn concatenating_each_steps_text_reproduces_the_whole_log() {
770 let mut l = Ledger::new();
771 for (step, text) in [
772 (1u32, "\n[read a]\nA\n"),
773 (1, "\n[grep x]\nX\n"),
774 (2, "\n[wrote a] (1 chars)\n"),
775 (4, "\n[read a]\nB\n"),
776 ] {
777 l.push(Observation::new(step, ObsKind::Read, None, text));
778 }
779 // The property the trace's per-step delta rests on: rows concatenated in
780 // step order are the whole log, so nothing is lost by not repeating it.
781 let joined: String = (0..=5).map(|s| l.text_for_step(s)).collect();
782 assert_eq!(joined, l.full_text());
783 assert_eq!(
784 l.text_for_step(3),
785 "",
786 "a step with no observations is empty"
787 );
788 assert_eq!(l.text_for_step(1), "\n[read a]\nA\n\n[grep x]\nX\n");
789 }
790
791 #[test]
792 fn only_a_target_that_is_the_subject_supersedes() {
793 for kind in [
794 ObsKind::Read,
795 ObsKind::Grep,
796 ObsKind::Find,
797 ObsKind::Write,
798 ObsKind::Skill,
799 ] {
800 assert!(kind.target_is_the_subject(), "{kind:?} names its subject");
801 }
802 for kind in [
803 ObsKind::Tool,
804 ObsKind::Mcp,
805 ObsKind::Child,
806 ObsKind::Message,
807 ObsKind::Error,
808 ] {
809 assert!(
810 !kind.target_is_the_subject(),
811 "{kind:?} names the answerer, not the subject"
812 );
813 }
814 }
815
816 /// Every variant, written out. Deliberately not derived from anything: an
817 /// eleventh variant must be added here by hand, which is the point — a new
818 /// kind that nothing pins is a new wire string nothing pins.
819 const ALL_KINDS: [ObsKind; 10] = [
820 ObsKind::Read,
821 ObsKind::Grep,
822 ObsKind::Find,
823 ObsKind::Write,
824 ObsKind::Skill,
825 ObsKind::Tool,
826 ObsKind::Mcp,
827 ObsKind::Child,
828 ObsKind::Message,
829 ObsKind::Error,
830 ];
831
832 #[test]
833 fn every_obs_kind_round_trips_through_json() {
834 for kind in ALL_KINDS {
835 let json = serde_json::to_string(&kind).unwrap();
836 let back: ObsKind = serde_json::from_str(&json).unwrap();
837 assert_eq!(back, kind, "{kind:?} did not survive {json}");
838 }
839 }
840
841 #[test]
842 fn obs_kind_wire_strings_are_pinned() {
843 // Stored values. Changing one silently orphans every ledger already on
844 // disk, so each is asserted literally rather than derived from the enum.
845 let expected = [
846 "\"read\"",
847 "\"grep\"",
848 "\"find\"",
849 "\"write\"",
850 "\"skill\"",
851 "\"tool\"",
852 "\"mcp\"",
853 "\"child\"",
854 "\"message\"",
855 "\"error\"",
856 ];
857 for (kind, want) in ALL_KINDS.into_iter().zip(expected) {
858 assert_eq!(serde_json::to_string(&kind).unwrap(), want, "{kind:?}");
859 }
860 // The wire string is not the display word: `label` renders `Write` as
861 // "wrote" and `Mcp` as "mcp tool". Both are correct, for different
862 // readers — this pins that they are allowed to differ.
863 assert_eq!(ObsKind::Write.label(), "wrote");
864 assert_eq!(ObsKind::Mcp.label(), "mcp tool");
865 }
866
867 #[test]
868 fn an_unknown_kind_string_fails_rather_than_defaulting() {
869 // A silently-defaulted kind would restore a ledger that reads as valid
870 // and is not: a `write` decoded as a `read` un-invalidates the reads it
871 // should have made stale.
872 let bad: std::result::Result<ObsKind, _> = serde_json::from_str("\"wrote\"");
873 assert!(bad.is_err(), "got {bad:?}");
874 assert!(serde_json::from_str::<ObsKind>("\"Read\"").is_err());
875 assert!(serde_json::from_str::<ObsKind>("\"sing\"").is_err());
876 }
877
878 #[test]
879 fn an_observation_round_trips_with_and_without_a_target() {
880 for obs in [
881 Observation::new(3, ObsKind::Read, Some("src/lib.rs".into()), "\n[read]\nA\n"),
882 Observation::new(4, ObsKind::Message, None, "thinking"),
883 ] {
884 let json = serde_json::to_string(&obs).unwrap();
885 let back: Observation = serde_json::from_str(&json).unwrap();
886 assert_eq!(back, obs, "round-trip changed {json}");
887 }
888 }
889
890 #[test]
891 fn a_ledger_round_trips_its_entries_in_order() {
892 let mut l = Ledger::new();
893 l.push(Observation::new(1, ObsKind::Read, Some("a".into()), "A"));
894 l.push(Observation::new(1, ObsKind::Grep, Some("x".into()), "X"));
895 l.push(Observation::new(2, ObsKind::Write, Some("a".into()), "W"));
896 l.push(Observation::new(2, ObsKind::Error, None, "boom"));
897
898 let back: Ledger = serde_json::from_str(&serde_json::to_string(&l).unwrap()).unwrap();
899 // Order is load-bearing: supersession and invalidation both read "is
900 // there a *later* entry", so a reordered restore changes the answer.
901 assert_eq!(back.entries(), l.entries());
902 assert_eq!(back.full_text(), l.full_text());
903 assert_eq!(back.text_for_step(2), l.text_for_step(2));
904 }
905
906 #[test]
907 fn commas_group_thousands() {
908 assert_eq!(commas(0), "0");
909 assert_eq!(commas(999), "999");
910 assert_eq!(commas(1_000), "1,000");
911 assert_eq!(commas(49_220), "49,220");
912 assert_eq!(commas(1_234_567), "1,234,567");
913 }
914}