mirror of
https://github.com/storytold/craft-anything.git
synced 2026-11-02 18:06:58 +00:00
Build usage includes context-summary calls (R2.1) (#442)
Build usage and cost include the model calls that summarise earlier work to make room, and each is journaled. Fixes #272.
This commit is contained in:
+98
-19
@@ -328,7 +328,13 @@ impl AgentLoop {
|
||||
turns += 1;
|
||||
let tools = self.tool_defs();
|
||||
let system = self.system.clone().unwrap_or_default();
|
||||
self.fit_context(&system);
|
||||
if let Some((usage, cost)) = self.fit_context(&system) {
|
||||
msg_usage.add(usage);
|
||||
msg_cost = match (msg_cost, cost) {
|
||||
(Some(a), Some(b)) => Some(a + b),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
let Some(result) = self.one_turn(&system, &tools, effort) else { break };
|
||||
self.last_input = result.usage.input_tokens + result.usage.cache_read_tokens + result.usage.cache_write_tokens;
|
||||
let (slot_name, local, model_id) = match self.chain.get(self.current) {
|
||||
@@ -427,13 +433,14 @@ impl AgentLoop {
|
||||
}
|
||||
|
||||
/// Make room when the conversation has outgrown its budget: trim old tool results first; when
|
||||
/// that isn't enough, replace the oldest part with a summary the model writes.
|
||||
fn fit_context(&mut self, system: &str) {
|
||||
/// that isn't enough, replace the oldest part with a summary the model writes. Return the
|
||||
/// successful summary call's usage so the message and build account for it too.
|
||||
fn fit_context(&mut self, system: &str) -> Option<(Usage, Option<f64>)> {
|
||||
let budget = self.context_budget();
|
||||
let estimate = context::estimate_tokens(&self.history).max(1);
|
||||
let size = if self.last_input > 0 { self.last_input } else { estimate };
|
||||
if size <= budget {
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
// Real tokens per estimated token, to judge the trimmed size before the next turn.
|
||||
let scale = size as f64 / estimate as f64;
|
||||
@@ -442,19 +449,31 @@ impl AgentLoop {
|
||||
self.last_input = 0;
|
||||
self.host.journal("context", json!({"action": "trim", "blocks": trimmed, "tokens_before": size, "tokens_after": after, "budget": budget}));
|
||||
if trimmed > 0 && after <= budget * 3 / 4 {
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
let Some(cut) = context::cut_point(&self.history) else { return };
|
||||
let cut = context::cut_point(&self.history)?;
|
||||
self.emit(AgentEvent::Status { text: "Making room: summarising the earlier work…".into() });
|
||||
let messages = context::with_summary_request(&self.history[..cut]);
|
||||
let req = TurnRequest { system, messages: &messages, tools: &[], effort: Effort::Low, max_tokens: crate::anthropic::MAX_TOKENS };
|
||||
let stop = self.stop.clone();
|
||||
let summary = self.chain.get_mut(self.current).map(|slot| slot.model.turn(&req, &mut |_| {}, &stop));
|
||||
let mut accounted = None;
|
||||
let text = match summary {
|
||||
Some(Ok(r)) => {
|
||||
let local = self.chain.get(self.current).is_some_and(|s| s.local);
|
||||
let backend = self.chain.get(self.current).map(|s| s.name.as_str()).unwrap_or_default();
|
||||
let cost = price::cost(&r.model, &r.usage, &self.cfg.prices, local);
|
||||
self.meter.add(r.usage, cost);
|
||||
accounted = Some((r.usage, cost));
|
||||
self.host.journal(
|
||||
"model_turn",
|
||||
json!({
|
||||
"backend": backend, "model": r.model, "input_tokens": r.usage.input_tokens,
|
||||
"output_tokens": r.usage.output_tokens, "cache_read_tokens": r.usage.cache_read_tokens,
|
||||
"cache_write_tokens": r.usage.cache_write_tokens, "cost_usd": cost, "stop_reason": r.stop.as_str(),
|
||||
"effort": Effort::Low.as_str(), "purpose": "context_summary",
|
||||
}),
|
||||
);
|
||||
r.message.text()
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
@@ -472,6 +491,7 @@ impl AgentLoop {
|
||||
self.history = context::summarised(&self.history, cut, &text);
|
||||
self.host.journal("context", json!({"action": "summary", "messages_before": before, "messages_after": self.history.len()}));
|
||||
self.save_transcript();
|
||||
accounted
|
||||
}
|
||||
|
||||
/// One model call, falling back along the chain when a backend fails before producing output.
|
||||
@@ -828,7 +848,7 @@ pub(crate) mod tests {
|
||||
/// A host whose `danger` tool needs consent.
|
||||
struct FakeHost {
|
||||
calls: Mutex<Vec<(String, bool)>>,
|
||||
journal: Mutex<Vec<String>>,
|
||||
journal: Mutex<Vec<(String, Value)>>,
|
||||
}
|
||||
|
||||
impl ToolHost for FakeHost {
|
||||
@@ -856,8 +876,8 @@ pub(crate) mod tests {
|
||||
}
|
||||
Ok(ToolOutput::Value(json!({"ran": id})))
|
||||
}
|
||||
fn journal(&self, kind: &str, _e: Value) {
|
||||
self.journal.lock().unwrap().push(kind.to_string());
|
||||
fn journal(&self, kind: &str, entry: Value) {
|
||||
self.journal.lock().unwrap().push((kind.to_string(), entry));
|
||||
}
|
||||
fn investigation(&self) -> String {
|
||||
"{}".into()
|
||||
@@ -936,7 +956,9 @@ pub(crate) mod tests {
|
||||
assert!(calls.contains(&("danger".into(), true)));
|
||||
assert!(events.iter().any(|e| matches!(e, AgentEvent::TextDelta { text } if text == "done")));
|
||||
let j = host.journal.lock().unwrap().clone();
|
||||
assert!(j.contains(&"user_message".into()) && j.contains(&"model_turn".into()) && j.contains(&"consent".into()));
|
||||
for kind in ["user_message", "model_turn", "consent"] {
|
||||
assert!(j.iter().any(|(k, _)| k == kind));
|
||||
}
|
||||
assert!(matches!(events.last(), Some(AgentEvent::TurnDone { input_tokens: 50, .. })));
|
||||
// cost known for the priced model id
|
||||
assert!(matches!(events.last(), Some(AgentEvent::TurnDone { cost_usd: Some(_), .. })));
|
||||
@@ -949,18 +971,23 @@ pub(crate) mod tests {
|
||||
let host = Arc::new(FakeHost { calls: Mutex::default(), journal: Mutex::default() });
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
let look = |id: &str| turn(vec![Block::ToolUse { id: id.into(), name: "look".into(), input: json!({}) }], StopReason::ToolUse);
|
||||
let mut summary = turn(vec![Block::Text { text: "Made index.html; the menu is left.".into() }], StopReason::EndTurn).unwrap();
|
||||
summary.usage = Usage { input_tokens: 30, output_tokens: 4, cache_read_tokens: 7, cache_write_tokens: 3 };
|
||||
let script = Scripted {
|
||||
turns: VecDeque::from([
|
||||
look("a"),
|
||||
look("b"),
|
||||
turn(vec![Block::Text { text: "Made index.html; the menu is left.".into() }], StopReason::EndTurn),
|
||||
turn(vec![Block::Text { text: "done".into() }], StopReason::EndTurn),
|
||||
]),
|
||||
turns: VecDeque::from([look("a"), look("b"), Ok(summary), turn(vec![Block::Text { text: "done".into() }], StopReason::EndTurn)]),
|
||||
seen: seen.clone(),
|
||||
};
|
||||
let slot = ModelSlot { name: "Fake".into(), model: Box::new(script), local: false, effort: None, max_turns: 10 };
|
||||
// Each turn reports 10 input tokens, over this budget, so room is made before the third.
|
||||
let cfg = || LoopConfig { transcript: Some(path.clone()), context_tokens: 5, ..LoopConfig::default() };
|
||||
// Ordinary turns report 10 input tokens, over this budget, so room is made before the third.
|
||||
let cfg = || LoopConfig {
|
||||
transcript: Some(path.clone()),
|
||||
context_tokens: 5,
|
||||
prices: BTreeMap::from([(
|
||||
"claude-opus-5-5".into(),
|
||||
crate::config::Price { input_per_mtok: 1.0, output_per_mtok: 2.0, cache_read_per_mtok: Some(0.25), cache_write_per_mtok: Some(0.5) },
|
||||
)]),
|
||||
..LoopConfig::default()
|
||||
};
|
||||
let mut c = ApiConversation::start(host.clone(), vec![slot], cfg()).unwrap();
|
||||
assert!(!c.resumed());
|
||||
c.send("Build it");
|
||||
@@ -973,7 +1000,23 @@ pub(crate) mod tests {
|
||||
assert!(seen[3][0].text().contains("Made index.html"), "the summary replaced the oldest part");
|
||||
assert!(seen[3].len() < seen[1].len() + 2);
|
||||
}
|
||||
assert!(host.journal.lock().unwrap().contains(&"context".into()));
|
||||
assert!(
|
||||
matches!(events.last(), Some(AgentEvent::TurnDone { input_tokens: 60, output_tokens: 19, cost_usd: Some(cost), .. }) if (cost - 0.00010125).abs() < 1e-12),
|
||||
"{events:?}"
|
||||
);
|
||||
{
|
||||
let journal = host.journal.lock().unwrap();
|
||||
assert!(journal.iter().any(|(kind, _)| kind == "context"));
|
||||
let turns: Vec<_> = journal.iter().filter(|(kind, _)| kind == "model_turn").map(|(_, entry)| entry).collect();
|
||||
assert_eq!(turns.len(), 4, "all model calls, including the summary, are journaled");
|
||||
let summary = turns.iter().find(|entry| entry["purpose"] == "context_summary").unwrap();
|
||||
assert_eq!(summary["input_tokens"], 30);
|
||||
assert_eq!(summary["output_tokens"], 4);
|
||||
assert_eq!(summary["cache_read_tokens"], 7);
|
||||
assert_eq!(summary["cache_write_tokens"], 3);
|
||||
assert_eq!(summary["effort"], "low");
|
||||
assert!((turns.iter().map(|entry| entry["cost_usd"].as_f64().unwrap()).sum::<f64>() - 0.00010125).abs() < 1e-12);
|
||||
}
|
||||
// A new conversation with the same file continues it.
|
||||
let seen2 = Arc::new(Mutex::new(Vec::new()));
|
||||
let script = Scripted { turns: VecDeque::from([turn(vec![Block::Text { text: "blue now".into() }], StopReason::EndTurn)]), seen: seen2.clone() };
|
||||
@@ -987,6 +1030,42 @@ pub(crate) mod tests {
|
||||
assert!(crate::context::load(&path).unwrap().last().unwrap().text().contains("blue now"), "kept after every message");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_accounting_keeps_unknown_prices_and_failed_calls_distinct() {
|
||||
for (local, failed) in [(false, false), (true, false), (false, true)] {
|
||||
let host = Arc::new(FakeHost { calls: Mutex::default(), journal: Mutex::default() });
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
let look = |id: &str| turn(vec![Block::ToolUse { id: id.into(), name: "look".into(), input: json!({}) }], StopReason::ToolUse);
|
||||
let summary = if failed {
|
||||
Err(ToolError::internal("summary unavailable"))
|
||||
} else {
|
||||
let mut result = turn(vec![Block::Text { text: String::new() }], StopReason::EndTurn).unwrap();
|
||||
result.model = "unpriced-fixture-model".into();
|
||||
Ok(result)
|
||||
};
|
||||
let script = Scripted {
|
||||
turns: VecDeque::from([look("a"), look("b"), summary, turn(vec![Block::Text { text: "done".into() }], StopReason::EndTurn)]),
|
||||
seen: seen.clone(),
|
||||
};
|
||||
let slot = ModelSlot { name: "Fake".into(), model: Box::new(script), local, effort: None, max_turns: 10 };
|
||||
let mut c = ApiConversation::start(host.clone(), vec![slot], LoopConfig { context_tokens: 5, ..LoopConfig::default() }).unwrap();
|
||||
c.send("Build it");
|
||||
let events = drain_until_done(&mut c, |_, _| {});
|
||||
assert_eq!(seen.lock().unwrap().len(), 4, "the summary was attempted");
|
||||
let Some(AgentEvent::TurnDone { input_tokens, output_tokens, cost_usd, .. }) = events.last() else { panic!("{events:?}") };
|
||||
assert_eq!((*input_tokens, *output_tokens), if failed { (30, 15) } else { (40, 20) });
|
||||
if local {
|
||||
assert_eq!(*cost_usd, Some(0.0));
|
||||
} else if failed {
|
||||
assert!(cost_usd.is_some(), "a failed call supplies no usage or unknown price");
|
||||
} else {
|
||||
assert_eq!(*cost_usd, None, "the successful summary's unknown price makes the total unknown");
|
||||
}
|
||||
let journal = host.journal.lock().unwrap();
|
||||
assert_eq!(journal.iter().filter(|(kind, _)| kind == "model_turn").count(), if failed { 3 } else { 4 });
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_chain_and_refusal() {
|
||||
let host = Arc::new(FakeHost { calls: Mutex::default(), journal: Mutex::default() });
|
||||
|
||||
@@ -496,6 +496,7 @@ Owner: open · Status: done (branch `feature/r1.9-versioned-files`)
|
||||
|
||||
### R2.1 Building with API-key backends
|
||||
Owner: maintainers · Status: in progress (built; live runs need keys)
|
||||
- [x] Include successful context-summary calls in message usage, build cost and the model-turn journal (#272).
|
||||
- [x] File and command tools for our own agent (`Build` toolset): `build_files_list`,
|
||||
`build_file_read`, `build_file_write`, `build_file_edit`, `build_command_run`, in the session's
|
||||
workspace, through `platform::process` and the same approval policy as Claude Code builds
|
||||
|
||||
@@ -21,6 +21,7 @@ workspace clippy, layers, fmt and the secret scan of all history were clean. Chi
|
||||
| Websites as targets | `web_site_open` keeps a private browser for the session and every browser tool inspects that page; `web_site_capture` records screenshots at each width (desktop, tablet and phone), rendered HTML, design tokens (colours in hex, spacing, content widths), the CSS breakpoints, the layout at each width (page width, heading sizes, each section's padding, gap, content column and items per row), page health (script and console errors, failed requests) and an accessibility outline, layout outline, assets with font files and requests, links and form outlines. In Clean room the capture keeps only what a visitor sees (no HTML, CSS rules, scripts, requests or errors), and opening a site reports no load errors | Live run on example.com; unit tests |
|
||||
| Electron window comparisons | `app_graphs_compare` retains every unassigned window and every construction with a reused local binding. Windows are paired by module, binding and occurrence, so inserting comments does not hide preference changes | Synthetic application graphs exercise changes in either anonymous window, unique and reused bindings, shifted source lines and unequal counts. Pairing remains positional within each binding group; reordered constructions and real application releases were not checked for this correction |
|
||||
| Rebuild workflow (Study → Build → Check → Refine) | `crates/ai/src/rebuild/` (kinds, checks and stacks are pluggable: docs/extending.md). Three kinds: website, app, game (a project whose stack is a game engine is always a game). The request decides how to build: a language, framework, engine or platform it names wins over the kind's default stack (Rust + egui for apps, Unity for games, the original's for websites), and the build check, the recorded stack and Play follow what was built (an Electron app opens in its own Electron). Websites: Study also captures up to 4 other pages of the site; menus are opened on a computer and on a phone; the check serves the build on loopback and compares every page at 1440, 768 and 390 px (likeness = mean of layout and perceptual detail, SSIM, averaged over pages, leaving out the parts of the original that change by themselves, found by photographing it three times; the note adds text, page structure, the colours, font, text sizes and spacing the build misses, and per width a page that scrolls sideways, heading sizes and sections whose padding, items per row or content column differ, the errors, failed requests and accessibility gaps the build adds, and the opened menus and pop-ups). A stack or library named in the request (Next.js, shadcn/ui, Tailwind CSS…, `stacks.toml`) is what the builder uses; with none named, a website's stack is the builder's choice (told to match what the original is built with) and the stack it used is recorded from app/package.json. The builder leads: it studies, asks the tools follow-up questions, records what it can't find out as unknowns (`core_unknown_add`; the run reports the open ones), and tests its own build mid-turn with `rebuild_check` (the same checks, for every kind); a check with anything it couldn't compare, or any claim that fails (a website page that scrolls sideways, logs new errors, fails to load a file, lacks a landmark, or a menu that doesn't open; for games each self-test step, for Deep-mode sheets each original number in the code, for console apps the same exit and output as the original's runs), isn't good enough; apps and games by whether they build. Up to 6 refinement rounds, or until a website reaches 90% likeness. A turn cut off by a time limit, crash or dropped connection resumes (up to 3 times); builder turns may run 24 h | Unit tests for prompts, stack choice, site detection, feed folding; live runs reached Build and decompiling (below) |
|
||||
| Context-summary usage | Successful API context summaries contribute to the message totals consumed by build usage and receive a `model_turn` journal entry with `purpose: context_summary`. Cached-token pricing is retained; an unknown summary price makes the total unknown, and a failed call adds no invented usage | Scripted-model tests exercise the real `ApiConversation`, transcript resumption, exact input/output and cache-price totals, journal entries, empty summaries, local zero cost, unknown prices and failed summaries. No live provider or end-to-end rebuild was used for this correction |
|
||||
| Games | Engine detection for 12 engines (Unreal, Unity, Godot, Source, GameMaker, RPG Maker, Ren'Py, MonoGame, LÖVE, CryEngine, Cocos, Cube) with versions. Unreal and Godot games are rebuilt in the same engine, others in Unity; installed engines are found, Godot installs itself, others get guided installs. Picking an inner folder (Engine, Binaries, Paks…) finds the game's folder. Just look inside a game shows what it's made of (`core_target_triage` on its folder: engine and version, how its code ships, the libraries its Unity assembly names give away, its own code, scenes and size) and opens it in the chat to ask about it. The builder keeps `app/MECHANICS.md` and its structured twin `app/mechanics.json` (checked after each build), in Deep mode only, may reuse the original's readable files for private use (never decrypting; generated pictures and 3D models replace its art when the person turns them on; Clean room copies none of its files), lists them in `CREDITS.md` (checked, with the private-use line in `README.md`); a game is ready only when its `--self-test` shows the player moving, acting and the HUD drawn, and the result can be started from the project page | Unit tests; live runs on Windows |
|
||||
| Clean room and Deep mode | Clean room (the default) runs only the tools in `engine/src/clean_room.rs`: ones that touch no target or watch from outside (the website as a visitor sees it, pictures of a window, its controls, recorded runs, a phone's screen). Every other tool, a plugin's included, is refused in the app, CLI, MCP and agents, with a way to turn on Deep mode. A Clean room build studies an app or a game only from the person's pictures and words (and a picture of its window when runtime study is on); its builder's reads and commands go through an approval rule that keeps it out of the original's folder and site (with Claude Code, and with ACP agents for what they ask about; other agents aren't held to it, see roadmap R2.2). Deep mode (`deep_mode` in Settings, `--deep` for one CLI run) uses every tool: the builder sees them all, and a website's study also reads its JavaScript and watches its start page for ten seconds (capture/how-it-works.md). Ghidra adds its decompiler in Deep mode when it's installed: it is found, or installed after asking. Study starts one analysis of the main program, saved and shared with the builder's tool server through a lock file (up to 8 h, Java gets half the machine's memory or the amount set in Settings → Engines, 4 to 31 GB, four slow passes skipped). The builder must decompile before the build is checked; addresses the built-in analysis can't place go to Ghidra, which makes a function on demand (for this run only) where its analysis has none, e.g. code reached only through tables of pointers. Without Ghidra, Deep mode uses the built-in engines | Live runs with Ghidra 12.1.4 on Windows (a shared analysis, and a decompile in seconds on a large program). The guided install of Ghidra and its Java is offered on Linux and macOS too, but has only been tried on Windows. Clean room and Deep mode: engine, AI, MCP, CLI and app tests; no live Clean room build run yet |
|
||||
| Auto mode | Settings → Safety, on by default. Builder actions go ahead without asking, except a fixed set that is never done unasked (deleting outside the project, system changes, publishing…); questions get "decide yourself"; asset spending stops at the budget | Unit tests |
|
||||
|
||||
Reference in New Issue
Block a user