76
const SUMMARIZE_PROMPT: &str = "Summarize this coding session so far: decisions made, \
77
files touched, current state, open items. Be concise and factual.";
78
79
+/// Render a history snapshot as a plain-text transcript, with tool calls and
80
+/// tool results spelled out as prose rather than left in their wire shapes.
81
+///
82
+/// Compaction summarizes a conversation that is, by definition, thick with
83
+/// `tool_calls` and `role: "tool"` messages — but the summarization round asks
84
+/// for a plain answer and so sends no `tools` array. Forwarding the raw shapes
85
+/// in that request produces tool blocks with no schema to validate against,
86
+/// which strict endpoints reject outright: Anthropic answers 400, so every
87
+/// compaction of a session that had ever run a tool failed, permanently, no
88
+/// matter how small the history was. Flattening to text keeps everything the
89
+/// summary actually needs and drops the shapes that only make sense alongside
90
+/// a tool schema. It also sidesteps orphaned `tool_call_id`s and role-
91
+/// alternation rules, neither of which a transcript can violate.
92
+fn transcript_for_summary(history: &[serde_json::Value]) -> String {
93
+ let mut lines: Vec<String> = Vec::new();
94
+ for message in history {
95
+ let role = message["role"].as_str().unwrap_or("user");
96
+ // The system prompt is carried over verbatim, so it needn't be summarized.
97
+ if role == "system" {
98
+ continue;
99
+ }
100
+
101
+ let mut parts: Vec<String> = Vec::new();
102
+ if let Some(text) = message["content"].as_str()
103
+ && !text.trim().is_empty()
104
+ {
105
+ parts.push(text.to_string());
106
+ }
107
+ for call in message["tool_calls"].as_array().into_iter().flatten() {
108
+ parts.push(format!(
109
+ "called {}({})",
110
+ call["function"]["name"].as_str().unwrap_or("tool"),
111
+ call["function"]["arguments"].as_str().unwrap_or_default(),
112
+ ));
113
+ }
114
+ if parts.is_empty() {
115
+ continue;
116
+ }
117
+
118
+ let label = if role == "tool" { "tool result" } else { role };
119
+ lines.push(format!("{label}: {}", parts.join("\n")));
120
+ }
121
+ lines.join("\n\n")
122
+}
123
+
124
/// Crude token estimate for a history snapshot: serialized characters / 4.
125
/// Deliberately model-agnostic — it only needs to be in the right ballpark to
126
/// decide when compaction is worth an extra inference round.
863
async fn compact_history(&self, keep_last: usize) -> Result<(), BackendError> {
864
let snapshot: Vec<serde_json::Value> = self.history.lock().await.clone();
865
821
- // Ask the endpoint for a summary of the conversation so far, through
822
- // the ordinary completion machinery (non-streaming).
823
- self.history
824
- .lock()
825
- .await
826
- .push(serde_json::json!({ "role": "user", "content": SUMMARIZE_PROMPT }));
866
+ let system = snapshot
867
+ .first()
868
+ .filter(|message| message["role"] == "system")
869
+ .cloned();
870
+
871
+ // Ask the endpoint for a summary of the conversation so far, through the
872
+ // ordinary completion machinery (non-streaming). The request carries the
873
+ // conversation as a flattened transcript in a single user message rather
874
+ // than the live history: this round offers no tools, and a tool-shaped
875
+ // history sent without a tool schema is rejected upstream (see
876
+ // `transcript_for_summary`).
877
+ let mut request = Vec::new();
878
+ if let Some(system) = system.clone() {
879
+ request.push(system);
880
+ }
881
+ request.push(serde_json::json!({
882
+ "role": "user",
883
+ "content": format!(
884
+ "{}\n\n{SUMMARIZE_PROMPT}",
885
+ transcript_for_summary(&snapshot),
886
+ ),
887
+ }));
888
+ *self.history.lock().await = request;
889
+
890
let summary = match self.complete(None, None).await {
891
Ok(result) => result.text,
892
Err(error) => {
896
}
897
};
898
836
- let system = snapshot
837
- .first()
838
- .filter(|message| message["role"] == "system")
839
- .cloned();
899
let non_system: Vec<serde_json::Value> = snapshot
900
.iter()
901
.filter(|message| message["role"] != "system")
1481
}
1482
1483
/// Minimal scripted OpenAI-compatible endpoint: accepts one HTTP request on
1425
- /// a std listener and answers with a fixed non-streaming completion.
1426
- fn spawn_completion_stub(summary: &str) -> std::net::SocketAddr {
1484
+ /// a std listener and answers with a fixed non-streaming completion. The
1485
+ /// receiver yields the request body the backend actually put on the wire.
1486
+ fn spawn_completion_stub(
1487
+ summary: &str,
1488
+ ) -> (std::net::SocketAddr, std::sync::mpsc::Receiver<String>) {
1489
use std::io::{Read, Write};
1490
1491
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1492
let addr = listener.local_addr().unwrap();
1493
+ let (sender, receiver) = std::sync::mpsc::channel();
1494
let body = serde_json::json!({
1495
"choices": [{ "message": { "role": "assistant", "content": summary } }]
1496
})
1519
})
1520
.unwrap_or(0);
1521
if request.len() >= headers_end + 4 + content_length {
1522
+ let _ = sender.send(
1523
+ String::from_utf8_lossy(&request[headers_end + 4..]).into_owned(),
1524
+ );
1525
break;
1526
}
1527
}
1534
);
1535
let _ = stream.write_all(response.as_bytes());
1536
});
1471
- addr
1537
+ (addr, receiver)
1538
}
1539
1540
#[tokio::test]
1541
async fn compact_history_rebuilds_system_summary_and_tail() {
1476
- let addr = spawn_completion_stub("We refactored backend.rs; tests pass.");
1542
+ let (addr, _requests) = spawn_completion_stub("We refactored backend.rs; tests pass.");
1543
let backend = OpenAiBackend::new(
1544
format!("http://{addr}/v1"),
1545
"test-key",
1576
);
1577
}
1578
1579
+ /// Compacting a tool-heavy session must not put tool shapes on the wire.
1580
+ /// The summarization round offers no `tools`, and endpoints reject tool
1581
+ /// calls and tool results that arrive without a schema — which used to make
1582
+ /// compaction fail forever in any session that had run a single tool.
1583
+ #[tokio::test]
1584
+ async fn compact_history_sends_no_tool_artifacts() {
1585
+ let (addr, requests) = spawn_completion_stub("Ran git status on main.");
1586
+ let backend = OpenAiBackend::new(
1587
+ format!("http://{addr}/v1"),
1588
+ "test-key",
1589
+ "test-model",
1590
+ Some("be helpful".into()),
1591
+ );
1592
+ {
1593
+ let mut history = backend.history.lock().await;
1594
+ history.push(serde_json::json!({ "role": "user", "content": "check the repo" }));
1595
+ history.push(serde_json::json!({
1596
+ "role": "assistant",
1597
+ "content": null,
1598
+ "tool_calls": [{
1599
+ "id": "call_1",
1600
+ "type": "function",
1601
+ "function": { "name": "run_command", "arguments": "{\"command\":\"git status\"}" },
1602
+ }],
1603
+ }));
1604
+ history.push(serde_json::json!({
1605
+ "role": "tool",
1606
+ "tool_call_id": "call_1",
1607
+ "content": "on branch main",
1608
+ }));
1609
+ }
1610
+
1611
+ backend.compact_history(2).await.unwrap();
1612
+
1613
+ let body: serde_json::Value =
1614
+ serde_json::from_str(&requests.recv().unwrap()).expect("request body is JSON");
1615
+ assert!(
1616
+ body.get("tools").is_none(),
1617
+ "summarization offers no tools: {body}"
1618
+ );
1619
+ for message in body["messages"].as_array().unwrap() {
1620
+ assert!(
1621
+ message.get("tool_calls").is_none(),
1622
+ "no tool_calls may be sent without a schema: {message}"
1623
+ );
1624
+ assert_ne!(
1625
+ message["role"], "tool",
1626
+ "no tool results may be sent without a schema: {message}"
1627
+ );
1628
+ }
1629
+
1630
+ // The tool round still has to survive into the summary request as prose,
1631
+ // or the summary loses the work the session actually did.
1632
+ let transcript = body["messages"].as_array().unwrap().last().unwrap()["content"]
1633
+ .as_str()
1634
+ .unwrap();
1635
+ assert!(transcript.contains("called run_command({\"command\":\"git status\"})"));
1636
+ assert!(transcript.contains("tool result: on branch main"));
1637
+ }
1638
+
1639
#[tokio::test]
1640
async fn compact_history_failure_leaves_history_intact() {
1641
// No listener at this address: the summarization request fails, and