Skip to content

Commit 5292c42

Browse files
Merge: historian input keeps room posts and PMs whole; explicit, sentence-bounded caps only
Co-authored-by: Alfonso <alfonso-magic-context@users.noreply.github.com>
2 parents 736d43d + 2a4640b commit 5292c42

13 files changed

Lines changed: 482 additions & 64 deletions

File tree

‎assets/magic-context.schema.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -713,7 +713,7 @@
713713
"type": "number"
714714
},
715715
"expand_tools": {
716-
"description": "Readable historian tool expansions, keyed by exact host tool name. Templates override built-in defaults; false disables an expansion. Supports ${input.path}, ${output.path}, bare ${output}, array [N], [*].field, .each(\"${field}\"), .join(\"separator\"), .count and final .truncate(N). Missing fields are empty; placeholders default to 300 characters, lists to 10 elements, expansions to 1000 characters. Valid in user and project config; affects historian/recomp and verbose ctx_expand only, never the wire or default ctx_expand transcript.",
716+
"description": "Readable historian tool expansions, keyed by exact host tool name. Templates override built-in defaults; false disables an expansion. Supports ${input.path}, ${output.path}, bare ${output}, array [N], [*].field, .each(\"${field}\"), .join(\"separator\"), .count and final .truncate(N). Missing fields are empty. Historian text and lists have no implicit caps; explicit .truncate(N) keeps complete sentences and marks omitted characters, or emits only the marker if no sentence fits. When the ctx_expand history-recovery tool is called in verbose mode, its tool-call previews retain their original built-in templates and limits (300 characters per placeholder, 10 list elements, 1000 characters per expansion). Valid in user and project config; never changes the wire or default ctx_expand transcript.",
717717
"x-mc-live-reload": true,
718718
"type": "object",
719719
"propertyNames": {

‎crates/mc-module/src/historian_chunk.rs‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1930,6 +1930,45 @@ mod tests {
19301930
use serde_json::json;
19311931
use sha2::Digest;
19321932

1933+
#[test]
1934+
fn historian_keeps_long_room_posts_and_peer_replies_whole() {
1935+
let fixture: Value =
1936+
serde_json::from_str(include_str!("../testdata/historian-tool-expansions.json"))
1937+
.unwrap();
1938+
let spec = &fixture["longMessage"];
1939+
let body = spec["sentence"]
1940+
.as_str()
1941+
.unwrap()
1942+
.repeat(spec["count"].as_u64().unwrap() as usize)
1943+
+ spec["tail"].as_str().unwrap();
1944+
assert!(body.len() > 1000);
1945+
let messages = vec![msg(
1946+
"a",
1947+
1,
1948+
"assistant",
1949+
vec![
1950+
CkKind::ToolCall {
1951+
id: "r".into(),
1952+
name: "room".into(),
1953+
input: json!({"action": "post", "room_id": "rm_review", "text": body, "description": "Room title…"}),
1954+
provider_executed: false,
1955+
},
1956+
CkKind::ToolCall {
1957+
id: "p".into(),
1958+
name: "peer_send".into(),
1959+
input: json!({"reply_to_pmid": "pm_42", "message": body, "description": "PM title…"}),
1960+
provider_executed: false,
1961+
},
1962+
CkKind::Text {
1963+
text: "Tail text.".into(),
1964+
},
1965+
],
1966+
)];
1967+
let projection = project_messages(&messages).unwrap();
1968+
let chunk = build_historian_chunk(&messages, &projection.blocks, 1, 10_000, 2);
1969+
assert_eq!(chunk.text, format!("[1] A: TC: Room post rm_review: {body} / TC: PM reply to pm_42: {body} / Tail text."));
1970+
}
1971+
19331972
/// The historian chunk follows the TypeScript formatter: ASCII word boundaries for
19341973
/// commit hashes and verbs, and key arguments truncated in UTF-16 units.
19351974
#[test]

‎crates/mc-module/src/historian_tool_template.rs‎

Lines changed: 139 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
//! Non-executable, bounded templates. Twin of shared/historian-tool-template.ts.
1+
//! Non-executable historian templates. Twin of shared/historian-tool-template.ts.
22
use serde_json::Value;
33
use std::collections::BTreeMap;
44
use std::sync::OnceLock;
@@ -16,7 +16,7 @@ struct Expression {
1616
each: Option<Vec<Node>>,
1717
join: Option<String>,
1818
count: bool,
19-
cap: usize,
19+
cap: Option<usize>,
2020
}
2121
#[derive(Clone)]
2222
enum Node {
@@ -131,7 +131,7 @@ impl<'a> Parser<'a> {
131131
each: None,
132132
join: None,
133133
count: false,
134-
cap: 300,
134+
cap: None,
135135
};
136136
if self.take(".each(") {
137137
if relative {
@@ -155,7 +155,7 @@ impl<'a> Parser<'a> {
155155
expr.count = true;
156156
}
157157
if self.take(".truncate(") {
158-
expr.cap = self.integer()?;
158+
expr.cap = Some(self.integer()?);
159159
if !self.take(")") {
160160
return Err(());
161161
}
@@ -185,7 +185,7 @@ fn parse(source: &str, relative: bool) -> Result<Vec<Node>, ()> {
185185
pub fn valid_template(source: &str) -> bool {
186186
parse(source, false).is_ok()
187187
}
188-
fn truncate(source: &str, cap: usize) -> String {
188+
fn truncate(source: &str, cap: Option<usize>) -> String {
189189
let mut normalized = String::new();
190190
let mut newline = false;
191191
for ch in source.chars() {
@@ -199,10 +199,38 @@ fn truncate(source: &str, cap: usize) -> String {
199199
newline = false;
200200
}
201201
}
202-
if normalized.chars().count() <= cap {
202+
let Some(cap) = cap else {
203203
return normalized;
204+
};
205+
let chars: Vec<char> = normalized.chars().collect();
206+
if chars.len() <= cap {
207+
return normalized;
208+
}
209+
// Explicit caps stop after sentence-ending punctuation so a cut is not
210+
// mistaken for an intact tool message. If no sentence fits, show only the marker.
211+
let mut end = 0;
212+
for i in 0..cap.min(chars.len()) {
213+
if !".!?。!?".contains(chars[i]) {
214+
continue;
215+
}
216+
let mut next = i + 1;
217+
while next < chars.len() && "\"'”’)]".contains(chars[next]) {
218+
next += 1;
219+
}
220+
if next <= cap
221+
&& ("。!?".contains(chars[i])
222+
|| next == chars.len()
223+
|| matches!(chars[next], ' ' | '\t'))
224+
{
225+
end = next;
226+
}
204227
}
205-
format!("{}…", normalized.chars().take(cap).collect::<String>())
228+
let prefix: String = chars[..end].iter().collect();
229+
format!(
230+
"{prefix}{}[… {} more characters]",
231+
if end > 0 { " " } else { "" },
232+
chars.len() - end
233+
)
206234
}
207235
fn scalar(value: Option<&Value>) -> String {
208236
match value {
@@ -285,23 +313,16 @@ fn render(nodes: &[Node], root: Option<&Value>, output_text: Option<&str>) -> St
285313
let Some(array) = array else {
286314
return String::new();
287315
};
288-
let mut elements: Vec<String> = array
316+
let elements: Vec<String> = array
289317
.iter()
290-
.take(10)
291318
.map(|v| {
292-
truncate(
293-
&if let Some(each) = &expr.each {
294-
render(each, v.as_ref(), None)
295-
} else {
296-
scalar(v.as_ref())
297-
},
298-
300,
299-
)
319+
if let Some(each) = &expr.each {
320+
render(each, v.as_ref(), None)
321+
} else {
322+
scalar(v.as_ref())
323+
}
300324
})
301325
.collect();
302-
if array.len() > 10 {
303-
elements.push(format!("… +{} more", array.len() - 10));
304-
}
305326
return truncate(
306327
&elements.join(expr.join.as_deref().unwrap_or(if expr.each.is_some() {
307328
"; "
@@ -332,7 +353,7 @@ pub fn render_template(source: &str, input: &Value, output: Option<&Value>) -> O
332353
}
333354
Some(truncate(
334355
&render(&nodes, Some(&Value::Object(root)), Some(&text)),
335-
1000,
356+
None,
336357
))
337358
}
338359
pub fn defaults() -> &'static ExpansionMap {
@@ -354,7 +375,89 @@ pub fn expand(
354375
.get(name)
355376
.or_else(|| defaults().get(name))?
356377
.as_str()?;
357-
render_template(template, input, output)
378+
let labeled = if overrides.contains_key(name) {
379+
input.clone()
380+
} else {
381+
builtin_input(name, input)
382+
};
383+
render_template(template, &labeled, output)
384+
}
385+
386+
// Room actions, private-message addresses and board lanes have multiple input
387+
// shapes. Derive readable labels only for built-ins; configured template overrides
388+
// keep the unmodified tool input, just as they do in the TypeScript renderer.
389+
fn builtin_input(name: &str, input: &Value) -> Value {
390+
let Some(mut labeled) = input.as_object().cloned() else {
391+
return input.clone();
392+
};
393+
let text = |key: &str| input[key].as_str().filter(|s| !s.is_empty());
394+
match name {
395+
"peer_send" => {
396+
let recipient = if let Some(reply) = text("reply_to_pmid") {
397+
format!("reply to {reply}")
398+
} else if let Some(agent) = text("agent").or_else(|| text("agent_id")) {
399+
format!("to {agent}")
400+
} else if let Some(session) = input["external"]["session_id"]
401+
.as_str()
402+
.filter(|s| !s.is_empty())
403+
.or_else(|| text("session_id"))
404+
{
405+
format!("to session {session}")
406+
} else {
407+
"to unknown recipient".into()
408+
};
409+
labeled.insert("recipient".into(), Value::String(recipient));
410+
}
411+
"room" => {
412+
labeled.insert(
413+
"room_label".into(),
414+
Value::String(
415+
text("room_id")
416+
.or_else(|| text("title"))
417+
.unwrap_or_default()
418+
.into(),
419+
),
420+
);
421+
let detail = if matches!(text("action"), Some("create" | "invite")) {
422+
Value::String(format!(
423+
"invitees: {}",
424+
render_template("${input.invitees}", input, None).unwrap_or_default()
425+
))
426+
} else {
427+
input["text"].clone()
428+
};
429+
if !detail.is_null() {
430+
labeled.insert("room_detail".into(), detail);
431+
}
432+
}
433+
"board" => {
434+
labeled.insert(
435+
"board_lane".into(),
436+
Value::String(text("lane").map(|s| format!(" {s}")).unwrap_or_default()),
437+
);
438+
if let Some(ops) = input["ops"].as_array() {
439+
let ops = ops
440+
.iter()
441+
.map(|op| {
442+
let Some(mut op_labeled) = op.as_object().cloned() else {
443+
return op.clone();
444+
};
445+
let lane = op["lane"]
446+
.as_str()
447+
.or_else(|| op["lane"]["title"].as_str())
448+
.or_else(|| op["lane"]["id"].as_str());
449+
if let Some(lane) = lane {
450+
op_labeled.insert("lane_label".into(), Value::String(lane.into()));
451+
}
452+
Value::Object(op_labeled)
453+
})
454+
.collect();
455+
labeled.insert("ops".into(), Value::Array(ops));
456+
}
457+
}
458+
_ => {}
459+
}
460+
Value::Object(labeled)
358461
}
359462

360463
#[cfg(test)]
@@ -381,6 +484,20 @@ mod tests {
381484
case["tool"]
382485
);
383486
}
487+
for case in fixture["variants"].as_array().unwrap() {
488+
assert_eq!(
489+
expand(
490+
case["tool"].as_str().unwrap(),
491+
&case["input"],
492+
case.get("output"),
493+
&BTreeMap::new()
494+
)
495+
.unwrap(),
496+
case["expected"].as_str().unwrap(),
497+
"{}",
498+
case["label"]
499+
);
500+
}
384501
for case in fixture["templates"].as_array().unwrap() {
385502
let mut input = case.get("input").cloned().unwrap_or(serde_json::json!({}));
386503
for key in ["repeatInput", "repeatArray"] {

0 commit comments

Comments
 (0)