Chuyển tới nội dung chính

5. Editing with approval

You add a second agent, author, that can edit files and commit in a git working tree. Pushing is marked confirm_required, so the agent stops and asks before it pushes. Your API then lets a person approve, and the run resumes.

This step needs a live model (BEAM_API_KEY): the echo client never calls tools.

Add the crate​

docs-assistant/Cargo.toml
[dependencies]
beam_sandbox = { path = "../riverbeam/crates/beam_sandbox" }
beam_store = { path = "../riverbeam/crates/beam_store" }

A remote to push to​

WorkingTree::push sends no credentials, so use a local bare repository as the remote:

git init --bare /tmp/docs-remote.git

The working tree and the agent​

The tree lives in one directory you choose, never one from the request. main and master are protected, so the agent works on its own branch and can only push that.

docs-assistant/src/agents.rs
use beam_agents::sandbox_tools;
use beam_sandbox::{SandboxRoot, WorkingTree};
use riverbase_core::RiverbaseResult;

/// A working copy on the branch `assistant/edits`, with `origin` set.
pub fn working_tree(root: &str, origin: &str) -> RiverbaseResult<WorkingTree> {
let tree = WorkingTree::new(SandboxRoot::new(root)?, vec!["main".into(), "master".into()]);
tree.init()?;
if tree.log(1)?.is_empty() {
tree.sandbox().write_file("README.md", "# Docs\n")?;
tree.add(".")?;
tree.commit("initial commit")?;
}
tree.checkout_branch("assistant/edits", true)?;
tree.add_origin(origin)?;
Ok(tree)
}

/// The `author` skill with the nine sandbox tools, bound to `tree`.
pub fn author(tree: WorkingTree) -> RigAgentRunner {
RigAgentRunner::for_skill("author", completion_client(), sandbox_tools(Some(tree)))
}

Register it next to the assistant in main, and keep a handle on the store for the resume route:

docs-assistant/src/main.rs
let tree = agents::working_tree("work/docs", "/tmp/docs-remote.git")?;
beam.register_agent(Arc::new(agents::author(tree)));

let store = beam.store().clone(); // before the pipeline moves into the worker
let worker = Arc::new(BeamWorker::new(Arc::new(beam)));
// …
let state = Arc::new(api::AppState {
domain: BeamDomain::new(LocalWorkerClient::new(worker)),
frames,
store,
});

Resume, safely​

The resume route must not trust the client with what gets run:

  • RigAgentRunner runs the tool and arguments found in the response payload, so the route replaces the payload with the ones stored when the agent asked.
  • The runner ignores the response status, so the route only accepts submitted responses. To decline, the client simply does not resume; the action stays pending.
  • The runner skips approval when metadata.confirm is true, and the sandbox tools and memory read other metadata keys, so every route strips them.
docs-assistant/src/api.rs
use beam_domain::ResumePayload;
use beam_store::BeamStore;
use beam_types::ActionResponseStatus;
use serde_json::json;
use uuid::Uuid;

pub struct AppState {
pub domain: BeamDomain<LocalWorkerClient>,
pub frames: Arc<BroadcastTransport>,
pub store: Arc<dyn BeamStore>,
}

/// Request metadata keys that would let a caller skip approval or reach other data.
fn strip_client_controls(metadata: &mut Value) {
if let Some(map) = metadata.as_object_mut() {
for key in ["confirm", "sandbox_root", "protected_refs", "memory_scopes"] {
map.remove(key);
}
}
}

fn bad_request(message: &str) -> ApiError {
(StatusCode::BAD_REQUEST, Json(json!({ "error": message })))
}

async fn resume(
State(state): State<Arc<AppState>>,
Json(mut payload): Json<ResumePayload>,
) -> Result<EventStream, ApiError> {
strip_client_controls(&mut payload.metadata);
for action in &mut payload.actions {
if action.status != ActionResponseStatus::Submitted {
return Err(bad_request("only submitted actions can resume"));
}
let action_id = Uuid::parse_str(&action.action_id).map_err(|_| bad_request("invalid action_id"))?;
let pending = state.store.get_action(action_id).await.map_err(api_error)?;
// Run exactly what the agent asked for, not what the client sent.
action.payload = Some(json!({
"tool": pending.payload.get("tool"),
"args": pending.payload.get("args"),
}));
}
let receiver = state.frames.subscribe();
let ack = state.domain.resume(payload).await.map_err(api_error)?;
Ok(frames_for(receiver, &ack))
}

Call strip_client_controls(&mut payload.metadata) at the top of invoke and astream too (bind the payload as Json(mut payload)), and add the route:

.route("/resume", post(resume))

Try it​

Ask the author to make a change and publish it:

curl -N localhost:8080/astream -H 'content-type: application/json' -d '{
"agent_name": "author",
"session_id": "00000000-0000-0000-0000-000000000002",
"content": [{"type": "text", "text": "Add a Contributing section to README.md, commit it, and push."}]
}'

The model writes the file and commits. When it calls git_push, the run stops. The last frame is done with an interrupted result:

{
"type": "done",
"result": {
"agent_name": "author",
"interaction_id": "40744652-2720-4b10-86ee-7333c1be8165",
"interrupted": true,
"action_required": [
{"action_id": "9c1b2d3e-4f50-4a61-8273-849506172839", "kind": "confirmation", "tool": "git_push", "args": {}}
],
"content": [{"type": "text", "text": "confirmation required"}]
}
}

Approve it:

curl -N localhost:8080/resume -H 'content-type: application/json' -d '{
"agent_name": "author",
"session_id": "00000000-0000-0000-0000-000000000002",
"interaction_id": "40744652-2720-4b10-86ee-7333c1be8165",
"content": [],
"actions": [{
"action_id": "9c1b2d3e-4f50-4a61-8273-849506172839",
"kind": "confirmation",
"request_id": "2b7c4d5e-6f70-4a81-9293-a4b5c6d7e8f9",
"status": "submitted"
}]
}'

The runner pushes assistant/edits to /tmp/docs-remote.git and returns the tool output. Check it:

git --git-dir /tmp/docs-remote.git log --oneline assistant/edits

What to know​

  • An action is marked resolved before the tool runs. If the push then fails, the action cannot be resumed again (BEM-066); ask the agent to try again in a new message.
  • Two simultaneous resumes of one action can both run the tool. Allow one resume per action in your API if that matters.
  • Any error from a tool ends the run. A path the model invents with .. fails with BEM-301.

Details are in Sessions and human approval. Next: split the service for production.