Skip to content
Draft
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
1 change: 1 addition & 0 deletions apps/maple-agent/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions apps/maple-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,13 @@ agent runtime; its desktop Agent page points users to Maple Work.
must still choose the correct folder and bounded scope, and deliberately
detached processes can escape a Unix process group. `head` bounds output,
not runtime.
- A build that bundles CPython gives every task `python`, a persistent
scratchpad: one interpreter per loaded task, started on first use in the
task's folder with the shell's PATH, keeping variables, imports and
background asyncio work between calls, which run one at a time. At most four
run per host; another task's need discards the least recently used idle one,
and archiving, unloading or restarting discards it too, with the model told
on its next call. See [Python scratchpad](docs/python-code-mode.md).
- Slash commands in the composer: `/btw` asks a side question the task
never sees, plus `/compact`, `/new`, `/pin`, `/web`, `/model`, and
`/help`. Skills and prompt templates appear in the same list.
Expand Down
1 change: 1 addition & 0 deletions apps/maple-agent/app/src/ui/chat/transcript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1195,6 +1195,7 @@ fn render_tool_with_diff(
pub(super) fn tool_label_title(title: &str) -> &str {
const LABELS: &[&str] = &[
"Terminal",
"Python",
"Editor",
"Web Search",
"Read file",
Expand Down
1 change: 1 addition & 0 deletions apps/maple-agent/crates/maple-agent/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ acp = ["dep:agent-client-protocol"]
store-bench = []

[dependencies]
maple-code-mode = { path = "../maple-code-mode" }
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
Expand Down
6 changes: 5 additions & 1 deletion apps/maple-agent/crates/maple-agent/src/acp/convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -580,7 +580,11 @@ pub(super) fn timeline_tool_kind(item: &AgentTimelineItem) -> ToolKind {
.unwrap_or_default()
.to_ascii_lowercase();
let input = item.input.as_ref();
if input.and_then(|value| value.get("command")).is_some() || title.contains("terminal") {
if input.and_then(|value| value.get("command")).is_some()
|| input.and_then(|value| value.get("code")).is_some()
|| title.contains("terminal")
|| title.starts_with("python")
{
ToolKind::Execute
} else if input.and_then(|value| value.get("url")).is_some()
|| title.contains("web")
Expand Down
1 change: 1 addition & 0 deletions apps/maple-agent/crates/maple-agent/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1081,6 +1081,7 @@ impl AgentRuntimeHandle {
.map_err(|_| "Project settings worker did not complete".to_owned())??;
if let Some(runtime) = runtime {
runtime.project_root_removed(&path, fallback_path.as_deref());
runtime.discard_project_python(&path).await;
}
let config = load_agent_config_inner(self.paths(), &self.user_id)
.map_err(|error| error.to_string())?;
Expand Down
35 changes: 35 additions & 0 deletions apps/maple-agent/crates/maple-agent/src/agent/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ pub(super) struct AgentRuntime {
pub(super) failures: Failures,
/// The external agents of the account's tasks.
pub(super) external_agents: Arc<ExternalAgentRegistry>,
/// The bundled CPython behind the tasks' `python` scratchpads, when this
/// build has one.
python: Option<Arc<tools::PythonWorkers>>,
}

impl AgentRuntime {
Expand Down Expand Up @@ -155,6 +158,7 @@ impl AgentRuntime {
surfaces: Mutex::new(HashMap::new()),
runs: Runs::default(),
failures: Failures::default(),
python: tools::PythonWorkers::bundled(),
})
}

Expand Down Expand Up @@ -764,6 +768,17 @@ impl AgentRuntime {
login_path: search_path.clone(),
providers: agents.clone(),
}));
if let Some(workers) = &self.python {
let python = workers.task(
&row.id,
PathBuf::from(&row.project_root),
search_path.as_deref(),
row.message_count > 0,
self.lifetime.child_token(),
);
options.tools.push(tools::python_tool(Arc::clone(&python)));
options.extensions.push(tools::python_extension(python));
}
let mcp = TaskMcp::new_with_output_dir(
Path::new(&row.project_root),
search_path,
Expand Down Expand Up @@ -995,9 +1010,29 @@ impl AgentRuntime {
// its persistence retry succeeds. The host must retain this runtime on
// failure and keep its ownership lock/key until a successful retry.
futures_util::future::join_all(sessions.iter().map(AgentSession::shutdown)).await;
if let Some(python) = &self.python {
python.shutdown().await;
}
self.finish_shutdown().await
}

/// Discard a loaded task's Python scratchpad; its next call starts fresh
/// and is told why.
pub(super) async fn discard_python(&self, session_id: &str, reason: &str) {
if let Some(python) = &self.python {
python.discard(session_id, reason).await;
}
}

/// Discard the Python scratchpads of a project's loaded tasks.
pub(super) async fn discard_project_python(&self, project_root: &str) {
if let Some(python) = &self.python {
python
.discard_project(Path::new(project_root), tools::PROJECT_REMOVED_REASON)
.await;
}
}

/// Finish saving an already-drained runtime without notifying extensions or
/// stopping tools again. The service keeps this runtime non-runnable until
/// every retained session is durable.
Expand Down
8 changes: 8 additions & 0 deletions apps/maple-agent/crates/maple-agent/src/agent/tasks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,9 @@ impl AgentRuntimeHandle {
self.verify_generation().await?;
let store = self.store()?;
let task = session_id.clone();
let archived = runtime
.clone()
.filter(|_| state == AgentTaskState::Archived);
let summary = tokio::task::spawn_blocking(move || {
let action = || {
store
Expand All @@ -673,6 +676,11 @@ impl AgentRuntimeHandle {
})
.await
.map_err(|_| "Task state worker did not complete".to_owned())??;
if let Some(runtime) = archived {
runtime
.discard_python(&session_id, super::tools::ARCHIVED_REASON)
.await;
}
emit_agent_event(
&self.service.host.events,
AgentServiceEvent::SessionUpdated {
Expand Down
41 changes: 41 additions & 0 deletions apps/maple-agent/crates/maple-agent/src/agent/timeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -767,6 +767,7 @@ pub(crate) fn friendly_tool_label(name: &str) -> String {
let bare = name.rsplit("__").next().unwrap_or(name);
match bare {
"bash" | "powershell" | "shell" => "Terminal".to_string(),
"python" => "Python".to_string(),
"text_editor" | "str_replace_editor" | "str_replace_based_edit_tool" => {
"Editor".to_string()
}
Expand All @@ -793,6 +794,21 @@ pub(crate) fn descriptive_tool_title<T: Serialize>(
// Most-descriptive argument per tool, in priority order. Only a shell
// is described by its command; an editor call is about the file.
let bare_name = tool_name.rsplit("__").next().unwrap_or(tool_name);
if bare_name == "python" {
let first_line = arguments
.get("code")
.and_then(|code| code.as_str())
.map(str::trim)
.filter(|code| !code.is_empty())
.and_then(|code| code.lines().next());
return Some(match first_line {
Some(line) => format!(
"Python: {}",
bounded_timeline_text(line, MAX_AGENT_SESSION_TITLE_CHARS)
),
None => "Python".to_string(),
});
}
let keys: &[&str] = if matches!(bare_name, "bash" | "powershell" | "shell") {
&[
"command",
Expand Down Expand Up @@ -871,3 +887,28 @@ pub(crate) fn format_tool_title(name: &str) -> String {

#[cfg(test)]
mod tests;

#[cfg(test)]
mod python_title_tests {
use super::*;

#[test]
fn python_titles_keep_one_bounded_code_line_and_label_reset_only_calls() {
assert_eq!(friendly_tool_label("python"), "Python");
assert_eq!(
descriptive_tool_title(
"python",
&json!({"code": "\nvalues = [2, 3, 5]\nsum(values)"})
),
Some("Python: values = [2, 3, 5]".to_string())
);
assert_eq!(
descriptive_tool_title("python", &json!({"code": "", "reset": true})),
Some("Python".to_string())
);
let title = descriptive_tool_title("python", &json!({"code": "é".repeat(1_024)}))
.expect("Python title");
assert!(title.chars().count() <= "Python: ".len() + MAX_AGENT_SESSION_TITLE_CHARS + 1);
assert!(!title.contains('\n'));
}
}
7 changes: 6 additions & 1 deletion apps/maple-agent/crates/maple-agent/src/agent/tools/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,19 @@
//! get the plan the user watches (`todo_write`) and questions the user
//! answers (`request_user_input`), and every task gets `web_search` and
//! `open_url` while its web switch is on. For an image, `read` sends a model
//! that cannot see images to `read_image`, which describes it.
//! that cannot see images to `read_image`, which describes it. A build that
//! bundles CPython also gives every task `python`, a persistent scratchpad.

mod desktop;
mod python;
mod read_image;
pub(super) mod shell;
pub(crate) mod web;

pub(super) use desktop::parse_user_questions;
pub(crate) use python::{
ARCHIVED_REASON, PROJECT_REMOVED_REASON, PythonWorkers, python_extension, python_tool,
};
pub(crate) use read_image::ReadImageFor;

use std::path::PathBuf;
Expand Down
Loading
Loading