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
2 changes: 1 addition & 1 deletion docs/OPENAGENT_ENGINEERING_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,4 @@ The initial tool set covers directory listing, bounded file reads, text search,

Stage 1 is complete. The bounded agent loop is connected to installed Nemotron 3.5 Lightning packages, uses the selected model ID, retains compatible lower-memory fallbacks, and runs inside the attached-root file boundary.

Stage 2 is in progress. OpenAgent now persists run state, ordered tool steps, bounded results, validation state, and before/after workspace checkpoint manifests. Startup recovery marks abandoned runs as interrupted, and typed desktop APIs expose run history and step details. Safe mutation replay/resume, content-addressed reversible file snapshots, token/runtime metrics, and the visible timeline UI are still pending and must not be represented as complete. The OS-level process sandbox remains Stage 3.
Stage 2 is in progress. OpenAgent now persists run state, ordered tool steps, bounded results, validation state, and before/after checkpoints. File-tool checkpoints include bounded file contents plus SHA-256 evidence and block destructive mutations that exceed the entry or byte limits. Startup recovery marks abandoned runs as interrupted, and typed desktop APIs expose run history and step details. Safe restore/replay, terminal-command snapshots, token/runtime metrics, and the visible timeline UI are still pending and must not be represented as complete. The OS-level process sandbox remains Stage 3.
136 changes: 135 additions & 1 deletion src-tauri/src/local_agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ use std::{
time::Duration,
};

use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use rusqlite::{params, OptionalExtension};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use tauri::{AppHandle, Emitter, State};
use tokio::process::Command;

Expand Down Expand Up @@ -36,6 +38,8 @@ const MAX_TOOL_RESULT_CHARS: usize = 10_000;
const MAX_READ_FILE_BYTES: u64 = 2 * 1024 * 1024;
const MAX_READ_LINES: usize = 500;
const MAX_WRITE_CHARS: usize = 2_000_000;
const MAX_CHECKPOINT_BYTES: u64 = 10 * 1024 * 1024;
const MAX_CHECKPOINT_ENTRIES: usize = 500;
const MAX_SEARCH_FILES: usize = 600;
const MAX_SEARCH_MATCHES: usize = 80;
const MAX_SEARCH_QUERY_CHARS: usize = 500;
Expand Down Expand Up @@ -460,6 +464,8 @@ async fn run_agent_message(
&step_id,
"before_mutation",
&agent_context,
tool,
&decision,
)?;
}

Expand Down Expand Up @@ -543,6 +549,8 @@ async fn run_agent_message(
&step_id,
"after_mutation",
&agent_context,
tool,
&decision,
)?;
}
if successful_validation.is_some() {
Expand All @@ -552,6 +560,8 @@ async fn run_agent_message(
&step_id,
"validation",
&agent_context,
tool,
&decision,
)?;
}
update_durable_progress(
Expand Down Expand Up @@ -716,14 +726,21 @@ fn durable_checkpoint(
step_id: &str,
kind: &str,
context: &AgentContext,
tool: &str,
action: &Value,
) -> Result<(), AppError> {
let entries = capture_checkpoint_entries(&context.workspace, tool, action)?;
let snapshot = json!({
"version": 2,
"tool": tool,
"action": action,
"reversible": tool != "terminal",
"projectId": context.project.id,
"roots": context.workspace.roots.iter().map(|root| json!({
"id": root.id,
"path": root.path,
})).collect::<Vec<_>>(),
"workspaceContext": bounded(&context.workspace_context, MAX_TRANSCRIPT_CHARS),
"entries": entries,
});
let db = state
.database
Expand All @@ -732,6 +749,94 @@ fn durable_checkpoint(
OpenAgentRunRepository::new(&db).checkpoint(run_id, step_id, kind, &snapshot.to_string())
}

fn capture_checkpoint_entries(
config: &AgentWorkspaceConfig,
tool: &str,
action: &Value,
) -> Result<Vec<Value>, AppError> {
let root_id = optional_string(action, "rootId");
let paths = match tool {
"write_file" | "replace_text" | "create_dir" | "delete_path" => {
vec![required_string(action, "path")?]
}
"move_path" => vec![
required_string(action, "sourcePath")?,
required_string(action, "targetPath")?,
],
"terminal" => return Ok(Vec::new()),
_ => return Ok(Vec::new()),
};
let mut entries = Vec::new();
let mut total_bytes = 0u64;
for input in paths {
let target = resolve_agent_path(config, root_id.as_deref(), &input, false)?;
capture_checkpoint_path(&target, &target, &mut entries, &mut total_bytes)?;
}
Ok(entries)
}

fn capture_checkpoint_path(
root: &Path,
path: &Path,
entries: &mut Vec<Value>,
total_bytes: &mut u64,
) -> Result<(), AppError> {
if entries.len() >= MAX_CHECKPOINT_ENTRIES {
return Err(AppError::internal(
"mutation checkpoint exceeds the 500-entry safety limit",
));
}
if !path.exists() {
entries.push(json!({
"path": display_path(path),
"relativePath": "",
"kind": "missing",
}));
return Ok(());
}
let metadata = fs::symlink_metadata(path)?;
if metadata.file_type().is_symlink() {
return Err(AppError::internal(
"OpenAgent will not mutate a symlink without a reversible checkpoint",
));
}
let relative = path.strip_prefix(root).unwrap_or(Path::new(""));
if metadata.is_dir() {
entries.push(json!({
"path": display_path(path),
"relativePath": relative.to_string_lossy(),
"kind": "directory",
}));
let mut children = fs::read_dir(path)?.collect::<Result<Vec<_>, _>>()?;
children.sort_by_key(|entry| entry.file_name());
for child in children {
capture_checkpoint_path(root, &child.path(), entries, total_bytes)?;
}
return Ok(());
}
if !metadata.is_file() {
return Err(AppError::internal(
"OpenAgent cannot checkpoint this filesystem object type",
));
}
*total_bytes = total_bytes.saturating_add(metadata.len());
if *total_bytes > MAX_CHECKPOINT_BYTES {
return Err(AppError::internal(
"mutation checkpoint exceeds the 10 MiB safety limit",
));
}
let content = fs::read(path)?;
entries.push(json!({
"path": display_path(path),
"relativePath": relative.to_string_lossy(),
"kind": "file",
"sizeBytes": content.len(),
"sha256": format!("{:x}", Sha256::digest(&content)),
"contentBase64": BASE64.encode(content),
}));
Ok(())
}

fn finish_durable_run(
state: &State<'_, AppState>,
run_id: &str,
Expand Down Expand Up @@ -2223,6 +2328,35 @@ mod tests {
assert!(!tool_mutates_workspace("git_status"));
}

#[test]
fn mutation_checkpoint_captures_original_file_content_and_digest() {
let temp = tempfile::tempdir().unwrap();
let file = temp.path().join("src.txt");
fs::write(&file, b"before").unwrap();
let config = AgentWorkspaceConfig {
full_pc_access: false,
roots: vec![AgentWorkspaceRoot {
id: "root".to_string(),
path: temp.path().display().to_string(),
created_at: "now".to_string(),
}],
};
let entries = capture_checkpoint_entries(
&config,
"write_file",
&json!({"rootId": "root", "path": "src.txt", "content": "after"}),
)
.unwrap();

assert_eq!(entries.len(), 1);
assert_eq!(entries[0]["kind"], "file");
assert_eq!(entries[0]["contentBase64"], BASE64.encode(b"before"));
assert_eq!(
entries[0]["sha256"],
format!("{:x}", Sha256::digest(b"before"))
);
}

#[test]
fn recognizes_common_validation_commands() {
assert!(is_validation_command("npm run lint"));
Expand Down
Loading