Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions apps/maple-agent/crates/UPSTREAM.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ product policy, storage formats, account rules and UI reconciliation belong in
`maple-agent` and the app. A generic API still needs an explicit reason when it
adds behavior absent from Pi.

The initial reference is Pi `v1.0.4`
(`7c10bd4337495ee613f2224843ecdf349b80d1df`). The initial port and its accepted
language/API differences are recorded in
[Maple #1104](https://github.com/MaplePrivacyLabs/Maple/pull/1104).
The crates follow Pi `v1.4.0` (`4a080252f06caf63a525d7873c5d94d3c23887e0`,
2026-10-22), the end of the latest full review below; start the next review
there. The initial port from Pi `v1.0.4` and its accepted language/API
differences are recorded in
[Maple #1104](https://github.com/MaplePrivacyLabs/Maple/pull/1104); each
review's PR records its adopt/skip decisions and any new differences.
This file records the storage follow-up's adaptations; it does not claim a
complete audit of every earlier difference.

Expand Down Expand Up @@ -55,3 +57,14 @@ There is no automatic line-by-line update or requirement to adopt every release.
(`71023aec1546d6d677a65669c5665c0927ce0dd0`): ordinary message persistence
still does not emit it. This was a focused contract check, not a full upstream
review or a Pi update.
- 2026-10-30: full review of Pi `v1.0.4`..`v1.4.0` (`packages/ai/src`,
`packages/agent/src`, `packages/coding-agent/src/core` and `src/utils`) in
[Maple #1389](https://github.com/MaplePrivacyLabs/Maple/pull/1389):
22 upstream commits adopted, among them dropping another model's thinking,
thinking-free summaries without a fixed output cap, and `before_agent_start`
for custom-message runs; provider, catalog, terminal and unported features
skipped. Upstream's session manager and tool output did not change, so the
storage adaptations stay. Skipped `bd3c9b823` (activating extension tools
registered after a branch recorded its tools): it needs every tool the
branch ever named, which a `SessionBase` does not carry. Validated with
`just ci`.
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ fn near_miss_questions_take_the_schema_shape() {
ask.declaration(),
&pi_ai::ToolCall {
id: "call-1".to_string(),
arguments_error: None,
partial_json: None,
name: "request_user_input".to_string(),
arguments: match expected {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,7 @@ fn latest_plan_is_returned_even_when_it_precedes_the_tail_page() {
.as_object()
.unwrap()
.clone(),
arguments_error: None,
partial_json: None,
})],
api: "test".into(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ fn assistant(timestamp: i64, call: Option<&str>) -> EntryKind {
id: id.into(),
name: "read".into(),
arguments: json!({"path":"README.md"}).as_object().unwrap().clone(),
arguments_error: None,
partial_json: None,
}));
}
Expand Down Expand Up @@ -485,6 +486,7 @@ fn chunked_rebuild_resumes_inside_a_large_entry_and_publishes_once() {
id: format!("call-{index}"),
name: "read".into(),
arguments: json!({"path":"file"}).as_object().unwrap().clone(),
arguments_error: None,
partial_json: None,
}));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ fn call(input: &str) -> SessionMessage {
.as_object()
.unwrap()
.clone(),
arguments_error: None,
partial_json: None,
})],
api: "fixture".into(),
Expand Down
41 changes: 41 additions & 0 deletions apps/maple-agent/crates/pi-agent-core/tests/agent_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,46 @@ async fn invalid_arguments_unknown_tools_and_panics_become_error_results() {
);
}

#[tokio::test]
async fn malformed_argument_text_fails_the_call_without_running_it() {
let faux = FauxProvider::new();
faux.push_text("done");
// The first reply streams arguments that are not JSON; the faux provider only sends
// valid ones.
let replies = faux.clone();
let first = AtomicUsize::new(0);
let stream_fn =
move |model: &pi_ai::Model, context: pi_ai::Context, options: pi_ai::StreamOptions| {
if first.fetch_add(1, Ordering::SeqCst) > 0 {
return pi_ai::StreamFn::stream(&replies, model, context, options);
}
let (sender, stream) = pi_ai::AssistantMessageStream::channel();
let mut builder = pi_ai::AssistantMessageBuilder::new(sender, model);
builder.start();
let index = builder.tool_call_start("c1", "explode");
builder.tool_call_delta(index, "{\"a\":.5}");
builder.finish(StopReason::ToolUse, pi_ai::Usage::default());
stream
};
// The tool panics if it runs.
let explode = Arc::new(Explode {
declaration: Tool::new("explode", "Panics", json!({ "type": "object" })),
});
let config = AgentLoopConfig::new(faux.model(), Arc::new(stream_fn));
let (messages, _) = run(&faux, config, context(vec![explode]), "go").await;
let text = result_text(&messages[3]);
assert!(
text.starts_with("Invalid arguments for tool \"explode\": ")
&& text.ends_with("\n\nReceived arguments:\n{\"a\":.5}"),
"{text}"
);
assert!(matches!(&messages[3], Message::ToolResult(result) if result.is_error));
assert_eq!(
roles(&messages),
["system", "user", "assistant", "toolResult", "assistant"]
);
}

fn sleeper(
name: &str,
millis: u64,
Expand Down Expand Up @@ -890,6 +930,7 @@ async fn run_tool_call_applies_the_same_gates_outside_the_loop() {
id: "nested/1".into(),
name: "echo".into(),
arguments: json!({ "text": "hi" }).as_object().cloned().unwrap(),
arguments_error: None,
partial_json: None,
};
let allowed = run_tool_call(
Expand Down
73 changes: 72 additions & 1 deletion apps/maple-agent/crates/pi-ai/src/json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,31 @@ pub fn parse_streaming_json(raw: &str) -> Map<String, Value> {
.unwrap_or_default()
}

/// Parse the complete argument text of a finished tool call, as Pi's
/// `parseToolCallArguments`. Unlike [`parse_streaming_json`], text that is not a JSON
/// object, even after its strings are repaired, is not cut back to a valid prefix: the
/// error describes it and includes the text, so validation can show the model what it
/// sent.
pub fn parse_tool_call_arguments(raw: &str) -> Result<Map<String, Value>, String> {
if raw.trim().is_empty() {
return Ok(Map::new());
}
let parsed = serde_json::from_str::<Value>(raw).or_else(|error| {
let repaired = repair_strings(raw);
if repaired == raw {
return Err(error);
}
serde_json::from_str::<Value>(&repaired)
});
match parsed {
Ok(Value::Object(map)) => Ok(map),
Ok(_) => Err(format!(
"Tool arguments must be a JSON object\n\nReceived arguments:\n{raw}"
)),
Err(error) => Err(format!("{error}\n\nReceived arguments:\n{raw}")),
}
}

/// Escape what JSON does not allow inside strings: raw control characters, and
/// backslashes that start no valid escape.
fn repair_strings(json: &str) -> String {
Expand All @@ -51,7 +76,8 @@ fn repair_strings(json: &str) -> String {
repaired.push_str(&json[offset..offset + 6]);
chars.nth(4);
}
Some(next @ ('"' | '\\' | '/' | 'b' | 'f' | 'n' | 'r' | 't' | 'u')) => {
// `\u` without four hex digits, as in `\usepackage`, is no escape either.
Some(next @ ('"' | '\\' | '/' | 'b' | 'f' | 'n' | 'r' | 't')) => {
repaired.push('\\');
repaired.push(next);
chars.next();
Expand Down Expand Up @@ -275,6 +301,51 @@ mod tests {
);
}

#[test]
fn a_backslash_u_without_hex_digits_stays_literal() {
assert_eq!(
parse(r#"{"text":"\url{x}"}"#),
json!({ "text": "\\url{x}" })
);
// The fields after it are kept.
assert_eq!(
parse(r#"{"path":"a.tex","content":"\usepackage{x}","mode":"w"}"#),
json!({ "path": "a.tex", "content": "\\usepackage{x}", "mode": "w" })
);
assert_eq!(
parse(r#"{"text":"caf\u00e9 \url"}"#),
json!({ "text": "caf\u{e9} \\url" })
);
assert_eq!(parse(r#"{"text":"ab\u00"#), json!({ "text": "ab" }));
}

#[test]
fn finished_arguments_are_parsed_strictly() {
assert_eq!(
parse_tool_call_arguments(r#"{"kind":"seek","note":"\url"}"#),
Ok(json!({ "kind": "seek", "note": "\\url" })
.as_object()
.cloned()
.unwrap())
);
assert_eq!(parse_tool_call_arguments(" "), Ok(Map::new()));
// Neither the valid prefix of malformed JSON nor cut-off JSON is kept.
for raw in [
r#"{"action":"apply","actions":[{"kind":"seek","time":.5}]}"#,
r#"{"kind":"seek","clipId":"clip-fl"#,
] {
let error = parse_tool_call_arguments(raw).unwrap_err();
assert!(
error.ends_with(&format!("\n\nReceived arguments:\n{raw}")),
"{error}"
);
}
assert_eq!(
parse_tool_call_arguments("[1,2]"),
Err("Tool arguments must be a JSON object\n\nReceived arguments:\n[1,2]".into())
);
}

#[test]
fn non_objects_and_garbage_become_empty() {
assert_eq!(parse(""), json!({}));
Expand Down
2 changes: 1 addition & 1 deletion apps/maple-agent/crates/pi-ai/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ pub use classifier::{
ClassifierStopReason, ClassifyFn,
};
pub use estimate::{estimate_message_tokens, estimate_text_tokens, estimate_tool_tokens};
pub use json::parse_streaming_json;
pub use json::{parse_streaming_json, parse_tool_call_arguments};
pub use overflow::{
is_context_overflow, is_recoverable_length, is_retryable_error, retry_delay_ms,
};
Expand Down
Loading
Loading