From 68fa20c97120cb9888ef85464c6c3bd0506bf74a Mon Sep 17 00:00:00 2001 From: Reiase Date: Mon, 27 Jul 2026 00:05:22 +0800 Subject: [PATCH] Unify Forge session runtime and tool registry --- Cargo.lock | 4 + crates/pulsing-cli/src/session/mod.rs | 11 +- crates/pulsing-forge/Cargo.toml | 5 +- crates/pulsing-forge/src/agent/events.rs | 4 + crates/pulsing-forge/src/agent/interactive.rs | 11 +- crates/pulsing-forge/src/agent/loop.rs | 206 ++++- crates/pulsing-forge/src/agent/mod.rs | 6 +- crates/pulsing-forge/src/agent/tools.rs | 112 +-- crates/pulsing-forge/src/approval/types.rs | 3 +- crates/pulsing-forge/src/context.rs | 8 + crates/pulsing-forge/src/executor.rs | 5 + .../pulsing-forge/src/handlers/apply_patch.rs | 4 + crates/pulsing-forge/src/handlers/bash.rs | 4 + .../pulsing-forge/src/handlers/discovery.rs | 12 + crates/pulsing-forge/src/handlers/edit.rs | 4 + crates/pulsing-forge/src/handlers/glob.rs | 4 + crates/pulsing-forge/src/handlers/grep.rs | 4 + crates/pulsing-forge/src/handlers/mcp.rs | 26 +- crates/pulsing-forge/src/handlers/mod.rs | 307 ++++++++ crates/pulsing-forge/src/handlers/plan.rs | 4 + crates/pulsing-forge/src/handlers/read.rs | 4 + .../src/handlers/request_permissions.rs | 4 + crates/pulsing-forge/src/handlers/session.rs | 12 + crates/pulsing-forge/src/handlers/shell.rs | 12 + .../pulsing-forge/src/handlers/shell_exec.rs | 68 +- .../pulsing-forge/src/handlers/view_image.rs | 4 + crates/pulsing-forge/src/handlers/write.rs | 4 + crates/pulsing-forge/src/lib.rs | 20 +- crates/pulsing-forge/src/process_group.rs | 49 ++ crates/pulsing-forge/src/protocol/command.rs | 105 +++ crates/pulsing-forge/src/protocol/error.rs | 36 + crates/pulsing-forge/src/protocol/event.rs | 112 +++ crates/pulsing-forge/src/protocol/ids.rs | 58 ++ crates/pulsing-forge/src/protocol/mod.rs | 13 + crates/pulsing-forge/src/pty_session.rs | 38 +- crates/pulsing-forge/src/registry.rs | 305 ++++++++ crates/pulsing-forge/src/runtime.rs | 55 +- crates/pulsing-forge/src/session/client.rs | 132 ++++ crates/pulsing-forge/src/session/mod.rs | 11 + crates/pulsing-forge/src/session/reducer.rs | 326 ++++++++ crates/pulsing-forge/src/session/service.rs | 542 +++++++++++++ crates/pulsing-forge/src/session/store.rs | 96 +++ crates/pulsing-forge/src/turn.rs | 228 ++++++ crates/pulsing-forge/src/unified_exec.rs | 169 ++++- crates/pulsing-gui/src/app/mod.rs | 96 ++- crates/pulsing-gui/src/app/right.rs | 5 +- .../pulsing-gui/src/controller/chat_turn.rs | 294 ++++++- crates/pulsing-gui/src/controller/mod.rs | 2 +- crates/pulsing-gui/src/model/mod.rs | 2 +- crates/pulsing-gui/src/model/sessions.rs | 73 +- crates/pulsing-gui/src/state.rs | 43 +- crates/pulsing-py/src/forge.rs | 192 +++++ docs/design/agent-craft-migration.md | 2 + docs/design/agent-workspace-gui.md | 5 +- docs/mkdocs.yml | 2 + docs/src/design/forge/core-architecture.md | 505 ++++++++++++ docs/src/design/forge/core-architecture.zh.md | 718 ++++++++++++++++++ docs/src/design/forge/craft-architecture.md | 1 + .../src/design/forge/craft-architecture.zh.md | 3 +- docs/src/design/forge/engineering.md | 2 + docs/src/design/forge/engineering.zh.md | 2 + docs/src/forge/abstractions.md | 4 + docs/src/forge/abstractions.zh.md | 4 + docs/src/forge/concepts.md | 4 + docs/src/forge/concepts.zh.md | 4 + docs/src/forge/index.md | 6 +- docs/src/forge/index.zh.md | 4 + python/pulsing/forge/__init__.py | 6 +- python/pulsing/forge/client.py | 73 ++ .../pulsing/forge/discovery/plugin_store.py | 14 +- python/pulsing/forge/host/__init__.py | 4 +- python/pulsing/forge/host/agent.py | 284 ++++--- python/pulsing/forge/host/legacy_agent.py | 229 ++++++ python/pulsing/forge/hybrid_runtime.py | 8 +- python/pulsing/forge/mcp/catalog.py | 8 +- tests/python/forge/test_forge_agent.py | 66 ++ tests/python/test_codex_plugin_compat.py | 11 + tests/python/test_forge_mcp.py | 30 + 78 files changed, 5437 insertions(+), 416 deletions(-) create mode 100644 crates/pulsing-forge/src/process_group.rs create mode 100644 crates/pulsing-forge/src/protocol/command.rs create mode 100644 crates/pulsing-forge/src/protocol/error.rs create mode 100644 crates/pulsing-forge/src/protocol/event.rs create mode 100644 crates/pulsing-forge/src/protocol/ids.rs create mode 100644 crates/pulsing-forge/src/protocol/mod.rs create mode 100644 crates/pulsing-forge/src/registry.rs create mode 100644 crates/pulsing-forge/src/session/client.rs create mode 100644 crates/pulsing-forge/src/session/mod.rs create mode 100644 crates/pulsing-forge/src/session/reducer.rs create mode 100644 crates/pulsing-forge/src/session/service.rs create mode 100644 crates/pulsing-forge/src/session/store.rs create mode 100644 crates/pulsing-forge/src/turn.rs create mode 100644 docs/src/design/forge/core-architecture.md create mode 100644 docs/src/design/forge/core-architecture.zh.md create mode 100644 python/pulsing/forge/client.py create mode 100644 python/pulsing/forge/host/legacy_agent.py diff --git a/Cargo.lock b/Cargo.lock index 26b3c1b3a..41bec5c2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4447,12 +4447,14 @@ version = "0.1.3" dependencies = [ "anyhow", "base64 0.22.1", + "chrono", "clap", "comfy-table", "futures", "futures-util", "glob", "image", + "libc", "portable-pty", "reedline", "regex", @@ -4467,6 +4469,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", + "tokio-util", "toml 0.8.23", "tracing", "tree-sitter", @@ -7263,6 +7266,7 @@ dependencies = [ "getrandom 0.4.3", "js-sys", "rand 0.10.2", + "serde_core", "wasm-bindgen", ] diff --git a/crates/pulsing-cli/src/session/mod.rs b/crates/pulsing-cli/src/session/mod.rs index a0f2187cd..f7e392e20 100644 --- a/crates/pulsing-cli/src/session/mod.rs +++ b/crates/pulsing-cli/src/session/mod.rs @@ -14,7 +14,7 @@ use std::path::PathBuf; use std::process::ExitCode; use anyhow::{Context, Result}; -use pulsing_forge::{run_agent_turn, run_oneshot, AgentConfig, InteractiveConfig}; +use pulsing_forge::{run_oneshot, AgentConfig, InteractiveConfig, LocalForgeClient, SessionId}; use commands::{parse_line, InputAction}; use mode::SessionMode; @@ -103,11 +103,13 @@ struct SessionState { fn run_loop(state: &mut SessionState) -> Result<()> { let rt = config::tokio_runtime()?; + let forge = LocalForgeClient::default(); + let forge_session = rt.block_on(forge.create_session(agent_config(&state.agent)))?; render::print_session_header(&state.mode, &state.agent); while let Some(line) = input::read_line(PROMPT)? { let action = parse_line(&line); - match dispatch(&rt, state, action)? { + match dispatch(&rt, &forge, &forge_session, state, action)? { LoopControl::Continue => {} LoopControl::Break => break, } @@ -122,6 +124,8 @@ enum LoopControl { fn dispatch( rt: &tokio::runtime::Runtime, + forge: &LocalForgeClient, + forge_session: &SessionId, state: &mut SessionState, action: InputAction, ) -> Result { @@ -208,8 +212,7 @@ fn dispatch( } }, InputAction::AgentTask { prompt } => { - let forge_cfg = agent_config(&state.agent); - match rt.block_on(run_agent_turn(&forge_cfg, &prompt)) { + match rt.block_on(forge.run_turn(forge_session.clone(), &prompt)) { Ok(reply) => println!("\n{reply}\n"), Err(err) => eprintln!("error: {err:#}\n"), } diff --git a/crates/pulsing-forge/Cargo.toml b/crates/pulsing-forge/Cargo.toml index 7216d2066..8135a1661 100644 --- a/crates/pulsing-forge/Cargo.toml +++ b/crates/pulsing-forge/Cargo.toml @@ -18,12 +18,14 @@ path = "src/bin/pulsing-forge-repl.rs" [dependencies] anyhow = { workspace = true } base64 = "0.22" +chrono = { workspace = true, features = ["serde"] } clap = { version = "4", features = ["derive"] } comfy-table = "7" futures = { workspace = true } futures-util = { workspace = true } glob = "0.3" image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] } +libc = "0.2" portable-pty = "0.9" reedline = "0.39" regex = "1" @@ -36,12 +38,13 @@ sha1 = "0.10" streaming-iterator = "0.1" thiserror = { workspace = true } tokio = { workspace = true, features = ["process", "io-util", "time", "rt-multi-thread", "macros", "sync"] } +tokio-util = { workspace = true } toml = "0.8" tracing = { workspace = true } tree-sitter = "0.25" tree-sitter-bash = "0.25" url = "2" -uuid = { workspace = true, features = ["v4"] } +uuid = { workspace = true, features = ["v4", "serde"] } rmcp = { version = "1.7.0", default-features = false, features = [ "auth", "base64", diff --git a/crates/pulsing-forge/src/agent/events.rs b/crates/pulsing-forge/src/agent/events.rs index c52b52f4d..801af1809 100644 --- a/crates/pulsing-forge/src/agent/events.rs +++ b/crates/pulsing-forge/src/agent/events.rs @@ -13,7 +13,11 @@ pub enum AgentEvent { ok: bool, summary: String, }, + ToolCancelled { + name: String, + }, Error(String), + Cancelled, Done { text: String, }, diff --git a/crates/pulsing-forge/src/agent/interactive.rs b/crates/pulsing-forge/src/agent/interactive.rs index ecd0dcddf..bb1453ed0 100644 --- a/crates/pulsing-forge/src/agent/interactive.rs +++ b/crates/pulsing-forge/src/agent/interactive.rs @@ -5,7 +5,8 @@ use std::path::PathBuf; use anyhow::Result; -use super::r#loop::{AgentConfig, default_model_for_provider, default_provider, run_agent_turn}; +use super::r#loop::{AgentConfig, default_model_for_provider, default_provider}; +use crate::session::LocalForgeClient; #[derive(Clone, PartialEq)] pub struct InteractiveConfig { @@ -41,6 +42,8 @@ pub async fn run_interactive(cfg: InteractiveConfig) -> Result<()> { if agent_cfg.provider == "demo" { eprintln!("(demo LLM — set ANTHROPIC_API_KEY or OPENAI_API_KEY for live models)"); } + let client = LocalForgeClient::default(); + let session_id = client.create_session(agent_cfg).await?; loop { print!("› "); @@ -57,7 +60,7 @@ pub async fn run_interactive(cfg: InteractiveConfig) -> Result<()> { if prompt == "exit" || prompt == "quit" { break; } - match run_agent_turn(&agent_cfg, prompt).await { + match client.run_turn(session_id.clone(), prompt).await { Ok(reply) => { println!("\n{reply}\n"); } @@ -76,5 +79,7 @@ pub async fn run_oneshot(cfg: InteractiveConfig, prompt: &str) -> Result model: cfg.model, ..AgentConfig::default() }; - run_agent_turn(&agent_cfg, prompt).await + let client = LocalForgeClient::default(); + let session_id = client.create_session(agent_cfg).await?; + Ok(client.run_turn(session_id, prompt).await?) } diff --git a/crates/pulsing-forge/src/agent/loop.rs b/crates/pulsing-forge/src/agent/loop.rs index 60aa80711..bbeaf3e69 100644 --- a/crates/pulsing-forge/src/agent/loop.rs +++ b/crates/pulsing-forge/src/agent/loop.rs @@ -1,23 +1,32 @@ //! Multi-turn Forge agent loop (LLM + tools). +use std::future::Future; use std::path::PathBuf; +use std::pin::Pin; +use std::sync::Arc; use std::sync::mpsc::Sender; +use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; +use thiserror::Error; +use tokio_util::sync::CancellationToken; use crate::agent::events::{AgentEvent, emit}; -use crate::agent::tools::{DEFAULT_TOOL_NAMES, forge_tool_definitions}; +use crate::agent::tools::DEFAULT_TOOL_NAMES; +use crate::approval::ApprovalPolicy; use crate::context::LocalToolSession; use crate::llm::{LlmClient, LlmMessage, LlmStream, StreamRequest}; use crate::result::ToolResult; use crate::runtime::ToolRuntime; +use crate::turn::TurnExecutionContext; +use crate::{SessionId, TurnId}; const DEFAULT_SYSTEM: &str = "You are a capable coding agent with filesystem and shell tools.\n\ Use tools to inspect the workspace before answering.\n\ When multi-step work is needed, call update_plan first.\n\ Be concise in final replies."; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AgentConfig { pub cwd: PathBuf, pub provider: String, @@ -25,10 +34,18 @@ pub struct AgentConfig { pub max_tokens: u32, pub max_turns: usize, pub sandbox: String, + pub approval_policy: ApprovalPolicy, pub tool_names: Vec, pub system_prompt: Option, } +#[derive(Debug, Error)] +#[error("agent turn cancelled")] +pub struct AgentCancelled; + +pub type AgentEventHandler = + Arc Pin + Send + 'static>> + Send + Sync>; + impl Default for AgentConfig { fn default() -> Self { Self { @@ -38,6 +55,7 @@ impl Default for AgentConfig { max_tokens: 8192, max_turns: 20, sandbox: "off".into(), + approval_policy: ApprovalPolicy::OnRequest, tool_names: DEFAULT_TOOL_NAMES.iter().map(|s| s.to_string()).collect(), system_prompt: None, } @@ -94,38 +112,88 @@ pub struct ForgeAgent { runtime: ToolRuntime, messages: Vec, event_tx: Option>, + event_handler: Option, } impl ForgeAgent { pub fn new(config: AgentConfig) -> Self { - let client = LlmClient::new(&config.provider, None, None).expect("LLM client"); - let session = std::sync::Arc::new(LocalToolSession::default()); + Self::try_new(config).expect("LLM client") + } + + pub fn try_new(config: AgentConfig) -> anyhow::Result { + let client = LlmClient::new( + &config.provider, + None, + provider_base_url_from_env(&config.provider), + )?; + let session = std::sync::Arc::new( + LocalToolSession::default().with_approval_policy(config.approval_policy), + ); let runtime = ToolRuntime::new(crate::runtime::ToolRuntimeConfig { cwd: config.cwd.clone(), sandbox_policy: config.sandbox.clone(), session, ..Default::default() }); - Self { + Ok(Self { config, client, runtime, messages: Vec::new(), event_tx: None, - } + event_handler: None, + }) + } + + pub fn set_event_handler(&mut self, handler: Option) { + self.event_handler = handler; } pub async fn run(&mut self, prompt: &str) -> anyhow::Result { - self.messages.clear(); + self.run_cancellable(prompt, CancellationToken::new()).await + } + + pub async fn run_cancellable( + &mut self, + prompt: &str, + cancel: CancellationToken, + ) -> anyhow::Result { + let turn = Arc::new(TurnExecutionContext::with_cancellation( + SessionId::new(), + TurnId::new(), + cancel, + )); + self.run_in_turn(prompt, turn).await + } + + pub async fn run_in_turn( + &mut self, + prompt: &str, + turn: Arc, + ) -> anyhow::Result { + let cancel = turn.cancellation(); + if cancel.is_cancelled() { + self.emit_event(AgentEvent::Cancelled).await; + return Err(AgentCancelled.into()); + } self.messages .push(json!({ "role": "user", "content": prompt })); - let tool_names: Vec<&str> = self.config.tool_names.iter().map(String::as_str).collect(); - let tools = forge_tool_definitions(&tool_names); + let tools = self.runtime.tool_definitions(&self.config.tool_names)?; let mut final_msg: Option = None; for _ in 0..self.config.max_turns { - final_msg = Some(self.stream_one_turn(&tools).await?); + let message = { + let _model_resource = turn.resources().register_passive("model_request"); + tokio::select! { + _ = cancel.cancelled() => { + self.emit_event(AgentEvent::Cancelled).await; + return Err(AgentCancelled.into()); + } + result = self.stream_one_turn(&tools) => result?, + } + }; + final_msg = Some(message); let msg = final_msg.as_ref().expect("final message"); self.messages .push(json!({ "role": "assistant", "content": msg.content })); @@ -133,23 +201,33 @@ impl ForgeAgent { let tool_uses = extract_tool_uses(&msg.content); if tool_uses.is_empty() { let text = text_from_content(&msg.content); - emit(&self.event_tx, AgentEvent::Done { text: text.clone() }); + self.emit_event(AgentEvent::Done { text: text.clone() }) + .await; return Ok(text); } let mut blocks = Vec::new(); for (id, name, input) in tool_uses { - emit(&self.event_tx, AgentEvent::ToolStart { name: name.clone() }); - let result = self.runtime.call_tool(&name, input).await; + self.emit_event(AgentEvent::ToolStart { name: name.clone() }) + .await; + let result = { + let _tool_resource = turn.resources().register_passive(format!("tool:{name}")); + tokio::select! { + _ = cancel.cancelled() => { + self.emit_event(AgentEvent::ToolCancelled { name }).await; + self.emit_event(AgentEvent::Cancelled).await; + return Err(AgentCancelled.into()); + } + result = self.runtime.call_tool_in_turn(turn.clone(), &name, input) => result, + } + }; let summary = result.content.chars().take(200).collect::(); - emit( - &self.event_tx, - AgentEvent::ToolEnd { - name: name.clone(), - ok: !result.is_error, - summary, - }, - ); + self.emit_event(AgentEvent::ToolEnd { + name: name.clone(), + ok: !result.is_error, + summary, + }) + .await; blocks.push(tool_result_block(&id, &result)); if !result.is_error { eprintln!("# tool {name} ok"); @@ -165,7 +243,8 @@ impl ForgeAgent { .map(|m| text_from_content(&m.content)) .filter(|s| !s.is_empty()) .unwrap_or_else(|| "(max turns reached)".into()); - emit(&self.event_tx, AgentEvent::Done { text: text.clone() }); + self.emit_event(AgentEvent::Done { text: text.clone() }) + .await; Ok(text) } @@ -183,25 +262,47 @@ impl ForgeAgent { tools: tools.to_vec(), }; let stream = self.client.stream_messages(req).await?; - emit_stream_text(&stream, &self.event_tx); + self.emit_stream_text(&stream).await; Ok(stream.final_message()) } -} -fn emit_stream_text(stream: &LlmStream, event_tx: &Option>) { - for chunk in stream.text_chunks() { - if let Some(tx) = event_tx { - let _ = tx.send(AgentEvent::TextDelta(chunk.to_string())); - } else { - print!("{chunk}"); - let _ = std::io::Write::flush(&mut std::io::stdout()); + async fn emit_stream_text(&self, stream: &LlmStream) { + for chunk in stream.text_chunks() { + if self.event_tx.is_some() || self.event_handler.is_some() { + self.emit_event(AgentEvent::TextDelta(chunk.to_string())) + .await; + } else { + print!("{chunk}"); + let _ = std::io::Write::flush(&mut std::io::stdout()); + } + } + if self.event_tx.is_none() + && self.event_handler.is_none() + && !stream.text_chunks().is_empty() + { + println!(); } } - if event_tx.is_none() && !stream.text_chunks().is_empty() { - println!(); + + async fn emit_event(&self, event: AgentEvent) { + emit(&self.event_tx, event.clone()); + if let Some(handler) = &self.event_handler { + handler(event).await; + } } } +fn provider_base_url_from_env(provider: &str) -> Option { + let variable = match provider.trim().to_lowercase().as_str() { + "openai" => "OPENAI_BASE_URL", + "anthropic" => "ANTHROPIC_BASE_URL", + _ => return None, + }; + std::env::var(variable) + .ok() + .filter(|value| !value.is_empty()) +} + fn text_from_content(content: &[Value]) -> String { let mut parts = Vec::new(); for block in content { @@ -264,4 +365,43 @@ mod tests { .unwrap(); assert!(!out.is_empty()); } + + #[tokio::test] + async fn forge_agent_preserves_messages_across_user_turns() { + let cfg = AgentConfig { + provider: "demo".into(), + model: "demo".into(), + ..Default::default() + }; + let mut agent = ForgeAgent::new(cfg); + agent.run("remember alpha").await.unwrap(); + let after_first = agent.messages.len(); + agent.run("remember beta").await.unwrap(); + assert!(agent.messages.len() > after_first); + assert_eq!( + agent + .messages + .last() + .and_then(|message| message.get("role")), + Some(&Value::String("assistant".into())) + ); + } + + #[tokio::test] + async fn pre_cancelled_turn_never_starts() { + let cfg = AgentConfig { + provider: "demo".into(), + model: "demo".into(), + ..Default::default() + }; + let mut agent = ForgeAgent::new(cfg); + let cancel = CancellationToken::new(); + cancel.cancel(); + let err = agent + .run_cancellable("must not run", cancel) + .await + .unwrap_err(); + assert!(err.downcast_ref::().is_some()); + assert!(agent.messages.is_empty()); + } } diff --git a/crates/pulsing-forge/src/agent/mod.rs b/crates/pulsing-forge/src/agent/mod.rs index 4c9ef32ad..c7f6fe296 100644 --- a/crates/pulsing-forge/src/agent/mod.rs +++ b/crates/pulsing-forge/src/agent/mod.rs @@ -10,7 +10,7 @@ pub use events::{AgentEvent, AgentEventTx}; pub use init_guide::run_init_guide; pub use interactive::{InteractiveConfig, run_interactive, run_oneshot}; pub use r#loop::{ - AgentConfig, ForgeAgent, default_model_for_provider, default_provider, run_agent_turn, - run_agent_turn_observed, + AgentCancelled, AgentConfig, AgentEventHandler, ForgeAgent, default_model_for_provider, + default_provider, run_agent_turn, run_agent_turn_observed, }; -pub use tools::{DEFAULT_TOOL_NAMES, INIT_TOOL_NAMES, forge_tool_definitions}; +pub use tools::{DEFAULT_TOOL_NAMES, INIT_TOOL_NAMES}; diff --git a/crates/pulsing-forge/src/agent/tools.rs b/crates/pulsing-forge/src/agent/tools.rs index 214adb27e..da302c68a 100644 --- a/crates/pulsing-forge/src/agent/tools.rs +++ b/crates/pulsing-forge/src/agent/tools.rs @@ -1,6 +1,7 @@ -//! Default tool schemas for the Forge agent loop (Anthropic wire format). - -use serde_json::{Value, json}; +//! Default tool selections for Forge agent profiles. +//! +//! Schemas intentionally do not live here. The canonical definitions are +//! derived from the executable [`crate::registry::ToolRegistry`]. pub const DEFAULT_TOOL_NAMES: &[&str] = &["update_plan", "Glob", "Read", "Grep", "shell_command"]; @@ -14,108 +15,3 @@ pub const INIT_TOOL_NAMES: &[&str] = &[ "Edit", "shell_command", ]; - -pub fn forge_tool_definitions(names: &[&str]) -> Vec { - names.iter().filter_map(|name| tool_schema(name)).collect() -} - -fn tool_schema(name: &str) -> Option { - let (description, schema) = match name { - "update_plan" => ( - "Update the multi-step plan visible to the user.", - json!({ - "type": "object", - "properties": { - "plan": { - "type": "array", - "items": { - "type": "object", - "properties": { - "step": { "type": "string" }, - "status": { "type": "string" } - }, - "required": ["step"] - } - } - }, - "required": ["plan"] - }), - ), - "Glob" => ( - "Find files by glob pattern.", - json!({ - "type": "object", - "properties": { - "pattern": { "type": "string" }, - "path": { "type": "string" } - }, - "required": ["pattern"] - }), - ), - "Read" => ( - "Read a file from the workspace.", - json!({ - "type": "object", - "properties": { - "file_path": { "type": "string" }, - "offset": { "type": "integer" }, - "limit": { "type": "integer" } - }, - "required": ["file_path"] - }), - ), - "Grep" => ( - "Search file contents with ripgrep.", - json!({ - "type": "object", - "properties": { - "pattern": { "type": "string" }, - "path": { "type": "string" }, - "glob": { "type": "string" } - }, - "required": ["pattern"] - }), - ), - "shell_command" => ( - "Run a shell command in the workspace.", - json!({ - "type": "object", - "properties": { - "command": { "type": "string" }, - "workdir": { "type": "string" }, - "timeout_ms": { "type": "integer" } - }, - "required": ["command"] - }), - ), - "Write" => ( - "Create or overwrite a file.", - json!({ - "type": "object", - "properties": { - "file_path": { "type": "string" }, - "content": { "type": "string" } - }, - "required": ["file_path", "content"] - }), - ), - "Edit" => ( - "Replace a unique old_string with new_string in a file.", - json!({ - "type": "object", - "properties": { - "file_path": { "type": "string" }, - "old_string": { "type": "string" }, - "new_string": { "type": "string" } - }, - "required": ["file_path", "old_string", "new_string"] - }), - ), - _ => return None, - }; - Some(json!({ - "name": name, - "description": description, - "input_schema": schema, - })) -} diff --git a/crates/pulsing-forge/src/approval/types.rs b/crates/pulsing-forge/src/approval/types.rs index 29dbccb27..4e398e674 100644 --- a/crates/pulsing-forge/src/approval/types.rs +++ b/crates/pulsing-forge/src/approval/types.rs @@ -3,7 +3,8 @@ use std::path::{Component, Path, PathBuf}; use crate::execpolicy::Decision; -#[derive(Clone, Copy, Debug, Eq, PartialEq, Default)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum ApprovalPolicy { /// Prompt on execpolicy Prompt / sandbox escalation (default). #[default] diff --git a/crates/pulsing-forge/src/context.rs b/crates/pulsing-forge/src/context.rs index 28fa58bc6..d187b2bac 100644 --- a/crates/pulsing-forge/src/context.rs +++ b/crates/pulsing-forge/src/context.rs @@ -103,6 +103,7 @@ pub trait ToolSession: Send + Sync { } /// Per-invocation context passed to every handler. +#[derive(Clone)] pub struct ToolCallContext { pub cwd: PathBuf, pub sandbox_policy: SandboxPolicy, @@ -113,6 +114,7 @@ pub struct ToolCallContext { pub approval_cache: Arc, pub tool_catalog: Arc>, pub mcp_runtime: Option, + pub turn: Option>, } impl ToolCallContext { @@ -135,6 +137,7 @@ impl ToolCallContext { approval_cache, tool_catalog, mcp_runtime: None, + turn: None, } } @@ -147,6 +150,11 @@ impl ToolCallContext { self.dangerously_disable_sandbox = disable; self } + + pub fn with_turn(mut self, turn: Arc) -> Self { + self.turn = Some(turn); + self + } } /// In-memory session for local runs and tests. diff --git a/crates/pulsing-forge/src/executor.rs b/crates/pulsing-forge/src/executor.rs index 12da0e74d..4869c7c5c 100644 --- a/crates/pulsing-forge/src/executor.rs +++ b/crates/pulsing-forge/src/executor.rs @@ -6,6 +6,7 @@ use std::future::Future; use std::pin::Pin; use crate::error::ToolError; +use crate::registry::ToolSpec; use crate::result::ToolResult; pub type ToolExecutorFuture<'a> = @@ -28,6 +29,10 @@ impl ToolExposure { pub trait ToolExecutor: Send + Sync { fn tool_name(&self) -> &str; + /// Provider-neutral model schema. Keeping this on the executable contract + /// prevents the agent loop from drifting into a second tool catalog. + fn spec(&self) -> ToolSpec; + fn exposure(&self) -> ToolExposure { ToolExposure::Direct } diff --git a/crates/pulsing-forge/src/handlers/apply_patch.rs b/crates/pulsing-forge/src/handlers/apply_patch.rs index 77cfbedb6..43fac3e6a 100644 --- a/crates/pulsing-forge/src/handlers/apply_patch.rs +++ b/crates/pulsing-forge/src/handlers/apply_patch.rs @@ -13,6 +13,10 @@ impl ToolExecutor for ApplyPatchHandler { "apply_patch" } + fn spec(&self) -> crate::registry::ToolSpec { + super::builtin_spec(self.tool_name()) + } + fn handle<'a>(&'a self, ctx: &'a ToolCallContext, arguments: Value) -> ToolExecutorFuture<'a> { let cwd = ctx.cwd.clone(); Box::pin(async move { apply_patch_impl(&cwd, arguments) }) diff --git a/crates/pulsing-forge/src/handlers/bash.rs b/crates/pulsing-forge/src/handlers/bash.rs index e86a0cb75..f71910d24 100644 --- a/crates/pulsing-forge/src/handlers/bash.rs +++ b/crates/pulsing-forge/src/handlers/bash.rs @@ -12,6 +12,10 @@ impl ToolExecutor for BashHandler { "Bash" } + fn spec(&self) -> crate::registry::ToolSpec { + super::builtin_spec(self.tool_name()) + } + fn handle<'a>(&'a self, ctx: &'a ToolCallContext, arguments: Value) -> ToolExecutorFuture<'a> { Box::pin(async move { run_shell(ctx, &arguments).await }) } diff --git a/crates/pulsing-forge/src/handlers/discovery.rs b/crates/pulsing-forge/src/handlers/discovery.rs index 40dc10a32..59f3b1b84 100644 --- a/crates/pulsing-forge/src/handlers/discovery.rs +++ b/crates/pulsing-forge/src/handlers/discovery.rs @@ -93,6 +93,10 @@ impl ToolExecutor for ToolSearchHandler { "tool_search" } + fn spec(&self) -> crate::registry::ToolSpec { + super::builtin_spec(self.tool_name()) + } + fn handle<'a>(&'a self, ctx: &'a ToolCallContext, arguments: Value) -> ToolExecutorFuture<'a> { Box::pin(async move { tool_search_impl(ctx, arguments) }) } @@ -243,6 +247,10 @@ impl ToolExecutor for ListAvailablePluginsHandler { "list_available_plugins_to_install" } + fn spec(&self) -> crate::registry::ToolSpec { + super::builtin_spec(self.tool_name()) + } + fn handle<'a>(&'a self, ctx: &'a ToolCallContext, _arguments: Value) -> ToolExecutorFuture<'a> { Box::pin(async move { list_plugins_impl(ctx) }) } @@ -269,6 +277,10 @@ impl ToolExecutor for RequestPluginInstallHandler { "request_plugin_install" } + fn spec(&self) -> crate::registry::ToolSpec { + super::builtin_spec(self.tool_name()) + } + fn handle<'a>(&'a self, ctx: &'a ToolCallContext, arguments: Value) -> ToolExecutorFuture<'a> { Box::pin(async move { request_plugin_install_impl(ctx, arguments) }) } diff --git a/crates/pulsing-forge/src/handlers/edit.rs b/crates/pulsing-forge/src/handlers/edit.rs index 0cfcd9a49..96bf7ce00 100644 --- a/crates/pulsing-forge/src/handlers/edit.rs +++ b/crates/pulsing-forge/src/handlers/edit.rs @@ -17,6 +17,10 @@ impl ToolExecutor for EditHandler { "Edit" } + fn spec(&self) -> crate::registry::ToolSpec { + super::builtin_spec(self.tool_name()) + } + fn handle<'a>(&'a self, ctx: &'a ToolCallContext, arguments: Value) -> ToolExecutorFuture<'a> { let cwd = ctx.cwd.clone(); Box::pin(async move { edit_impl(&cwd, &arguments) }) diff --git a/crates/pulsing-forge/src/handlers/glob.rs b/crates/pulsing-forge/src/handlers/glob.rs index e4740dedf..d437ea64a 100644 --- a/crates/pulsing-forge/src/handlers/glob.rs +++ b/crates/pulsing-forge/src/handlers/glob.rs @@ -18,6 +18,10 @@ impl ToolExecutor for GlobHandler { "Glob" } + fn spec(&self) -> crate::registry::ToolSpec { + super::builtin_spec(self.tool_name()) + } + fn handle<'a>(&'a self, ctx: &'a ToolCallContext, arguments: Value) -> ToolExecutorFuture<'a> { let cwd = ctx.cwd.clone(); Box::pin(async move { glob_impl(&cwd, &arguments) }) diff --git a/crates/pulsing-forge/src/handlers/grep.rs b/crates/pulsing-forge/src/handlers/grep.rs index c2e47e882..aa8035268 100644 --- a/crates/pulsing-forge/src/handlers/grep.rs +++ b/crates/pulsing-forge/src/handlers/grep.rs @@ -18,6 +18,10 @@ impl ToolExecutor for GrepHandler { "Grep" } + fn spec(&self) -> crate::registry::ToolSpec { + super::builtin_spec(self.tool_name()) + } + fn handle<'a>(&'a self, ctx: &'a ToolCallContext, arguments: Value) -> ToolExecutorFuture<'a> { let cwd = ctx.cwd.clone(); Box::pin(async move { grep_impl(&cwd, &arguments) }) diff --git a/crates/pulsing-forge/src/handlers/mcp.rs b/crates/pulsing-forge/src/handlers/mcp.rs index db09ed407..81e7e6676 100644 --- a/crates/pulsing-forge/src/handlers/mcp.rs +++ b/crates/pulsing-forge/src/handlers/mcp.rs @@ -10,7 +10,8 @@ use crate::executor::{ToolExecutor, ToolExecutorFuture, ToolExposure}; use crate::mcp::{ LEGACY_MCP_TOOL_NAME_PREFIX, McpRuntime, enforce_resource_size_limit, validate_mcp_resource_uri, }; -use crate::mcp::{McpClientError, ToolInfo}; +use crate::mcp::{McpClientError, ToolInfo, tool_input_schema_json}; +use crate::registry::ToolSpec; use crate::result::ToolResult; fn optional_server_name(arguments: &Value) -> Result, ToolError> { @@ -52,6 +53,10 @@ macro_rules! mcp_handler { $tool } + fn spec(&self) -> crate::registry::ToolSpec { + super::builtin_spec(self.tool_name()) + } + fn supports_parallel(&self) -> bool { true } @@ -87,6 +92,8 @@ mcp_handler!(ReadMcpResourceHandler, "read_mcp_resource"); pub struct McpDynamicToolHandler { pub model_name: String, pub supports_parallel: bool, + description: String, + input_schema: Value, } impl McpDynamicToolHandler { @@ -94,6 +101,8 @@ impl McpDynamicToolHandler { Self { model_name: model_name.into(), supports_parallel: false, + description: "Call a dynamically registered MCP tool.".into(), + input_schema: serde_json::json!({"type": "object", "properties": {}}), } } @@ -101,6 +110,13 @@ impl McpDynamicToolHandler { Self { model_name: info.model_tool_name(prefix_mcp_tool_names), supports_parallel: info.supports_parallel_tool_calls, + description: info + .tool + .description + .as_deref() + .unwrap_or("Call a dynamically registered MCP tool.") + .to_string(), + input_schema: tool_input_schema_json(&info.tool), } } } @@ -114,6 +130,14 @@ impl ToolExecutor for McpDynamicToolHandler { ToolExposure::Deferred } + fn spec(&self) -> ToolSpec { + ToolSpec::function( + self.model_name.clone(), + self.description.clone(), + self.input_schema.clone(), + ) + } + fn supports_parallel(&self) -> bool { self.supports_parallel } diff --git a/crates/pulsing-forge/src/handlers/mod.rs b/crates/pulsing-forge/src/handlers/mod.rs index 051036e74..379e89786 100644 --- a/crates/pulsing-forge/src/handlers/mod.rs +++ b/crates/pulsing-forge/src/handlers/mod.rs @@ -41,7 +41,314 @@ pub use write::WriteHandler; use crate::error::ToolError; use crate::executor::ToolExecutor; +use crate::registry::ToolSpec; use crate::result::ToolResult; +use serde_json::json; + +/// Canonical model schemas for built-in Forge executors. +/// +/// Built-in executors explicitly return their entry from this catalog, so both +/// registry exposure and dispatch are rooted in the same registered object. +/// Dynamic executors such as MCP tools provide their own `ToolExecutor::spec`. +pub(crate) fn builtin_spec(name: &str) -> ToolSpec { + let object = |properties, required: &[&str]| { + json!({ + "type": "object", + "properties": properties, + "required": required, + "additionalProperties": false, + }) + }; + match name { + "shell_command" => ToolSpec::function( + name, + "Run a shell command in the workspace.", + object( + json!({ + "command": {"type": "string"}, + "cmd": {"type": "string"}, + "workdir": {"type": "string"}, + "timeout_ms": {"type": "integer"}, + "login": {"type": "boolean"}, + "sandbox_permissions": {"type": "string"}, + "justification": {"type": "string"} + }), + &[], + ), + ), + "exec_command" => ToolSpec::function( + name, + "Start a command, optionally keeping an interactive PTY session.", + object( + json!({ + "cmd": {"type": "string"}, + "command": {"type": "string"}, + "workdir": {"type": "string"}, + "yield_time_ms": {"type": "integer"}, + "max_output_tokens": {"type": "integer"}, + "tty": {"type": "boolean"}, + "login": {"type": "boolean"}, + "sandbox_permissions": {"type": "string"}, + "justification": {"type": "string"}, + "prefix_rule": {"type": "array", "items": {"type": "string"}} + }), + &[], + ), + ), + "write_stdin" => ToolSpec::function( + name, + "Write input to or poll a running exec_command session.", + object( + json!({ + "session_id": {"type": "integer"}, + "chars": {"type": "string"}, + "yield_time_ms": {"type": "integer"}, + "max_output_tokens": {"type": "integer"} + }), + &["session_id"], + ), + ), + "apply_patch" => ToolSpec::function( + name, + "Apply a structured patch to files in the workspace.", + object(json!({"patch": {"type": "string"}}), &["patch"]), + ), + "view_image" => ToolSpec::function( + name, + "Load a local image for visual inspection.", + object( + json!({ + "path": {"type": "string"}, + "detail": {"type": "string", "enum": ["high", "original"]} + }), + &["path"], + ), + ), + "update_plan" => ToolSpec::function( + name, + "Update the multi-step plan visible to the user.", + object( + json!({ + "plan": { + "type": "array", + "items": { + "type": "object", + "properties": { + "step": {"type": "string"}, + "status": { + "type": "string", + "enum": ["pending", "in_progress", "completed"] + } + }, + "required": ["step", "status"], + "additionalProperties": false + } + }, + "explanation": {"type": "string"} + }), + &["plan"], + ), + ), + "new_context" => ToolSpec::function( + name, + "Request a fresh context window for the current Forge session.", + object(json!({}), &[]), + ), + "get_context_remaining" => ToolSpec::function( + name, + "Return the estimated tokens remaining in the current context.", + object(json!({}), &[]), + ), + "request_user_input" => ToolSpec::function( + name, + "Ask the user one or more short structured questions.", + object( + json!({ + "questions": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "header": {"type": "string"}, + "question": {"type": "string"}, + "isOther": {"type": "boolean"}, + "isSecret": {"type": "boolean"}, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": {"type": "string"}, + "description": {"type": "string"} + }, + "required": ["label", "description"], + "additionalProperties": false + } + } + }, + "required": ["id", "header", "question"], + "additionalProperties": false + } + }, + "autoResolutionMs": {"type": "integer", "minimum": 60000, "maximum": 240000} + }), + &["questions"], + ), + ), + "request_permissions" => ToolSpec::function( + name, + "Request scoped network or filesystem permissions from the host.", + object( + json!({ + "permissions": { + "type": "object", + "properties": { + "network": {"type": "object"}, + "file_system": {"type": "object"} + } + }, + "reason": {"type": "string"} + }), + &["permissions"], + ), + ), + "tool_search" => ToolSpec::function( + name, + "Search deferred tools and integrations available to this session.", + object( + json!({ + "query": {"type": "string"}, + "limit": {"type": "integer", "minimum": 1} + }), + &["query"], + ), + ), + "list_available_plugins_to_install" => ToolSpec::function( + name, + "List Forge plugins that are available but not installed.", + object(json!({}), &[]), + ), + "request_plugin_install" => ToolSpec::function( + name, + "Request installation of a specific Forge plugin.", + object( + json!({ + "tool_id": {"type": "string"}, + "tool_type": {"type": "string", "enum": ["plugin", "connector"]}, + "action_type": {"type": "string", "enum": ["install", "enable"]}, + "suggest_reason": {"type": "string"} + }), + &["tool_id", "suggest_reason"], + ), + ), + "list_mcp_resources" => ToolSpec::function( + name, + "List resources exposed by connected MCP servers.", + object( + json!({ + "server": {"type": "string"}, + "cursor": {"type": "string"} + }), + &[], + ), + ), + "list_mcp_resource_templates" => ToolSpec::function( + name, + "List resource templates exposed by one MCP server.", + object( + json!({ + "server": {"type": "string"}, + "cursor": {"type": "string"} + }), + &["server"], + ), + ), + "read_mcp_resource" => ToolSpec::function( + name, + "Read a resource from a connected MCP server.", + object( + json!({ + "server": {"type": "string"}, + "uri": {"type": "string"} + }), + &["server", "uri"], + ), + ), + "Read" => ToolSpec::function( + name, + "Read a UTF-8 file from the workspace.", + object( + json!({ + "file_path": {"type": "string"}, + "offset": {"type": "integer", "minimum": 1}, + "limit": {"type": "integer", "minimum": 1} + }), + &["file_path"], + ), + ), + "Glob" => ToolSpec::function( + name, + "Find files by glob pattern.", + object( + json!({ + "pattern": {"type": "string"}, + "path": {"type": "string"} + }), + &["pattern"], + ), + ), + "Grep" => ToolSpec::function( + name, + "Search workspace file contents with a regular expression.", + object( + json!({ + "pattern": {"type": "string"}, + "path": {"type": "string"}, + "glob": {"type": "string"} + }), + &["pattern"], + ), + ), + "Edit" => ToolSpec::function( + name, + "Replace one unique string occurrence in a workspace file.", + object( + json!({ + "file_path": {"type": "string"}, + "old_string": {"type": "string"}, + "new_string": {"type": "string"} + }), + &["file_path", "old_string", "new_string"], + ), + ), + "Write" => ToolSpec::function( + name, + "Create or overwrite a file inside the workspace.", + object( + json!({ + "file_path": {"type": "string"}, + "content": {"type": "string"} + }), + &["file_path", "content"], + ), + ), + "Bash" => ToolSpec::function( + name, + "Legacy alias for running a shell command in the workspace.", + object( + json!({ + "command": {"type": "string"}, + "workdir": {"type": "string"}, + "timeout_ms": {"type": "integer"} + }), + &["command"], + ), + ), + _ => panic!("tool {name:?} must implement ToolExecutor::spec"), + } +} pub fn builtin_handlers() -> Vec> { vec![ diff --git a/crates/pulsing-forge/src/handlers/plan.rs b/crates/pulsing-forge/src/handlers/plan.rs index a59226018..3c88d9b47 100644 --- a/crates/pulsing-forge/src/handlers/plan.rs +++ b/crates/pulsing-forge/src/handlers/plan.rs @@ -17,6 +17,10 @@ impl ToolExecutor for UpdatePlanHandler { "update_plan" } + fn spec(&self) -> crate::registry::ToolSpec { + super::builtin_spec(self.tool_name()) + } + fn handle<'a>(&'a self, ctx: &'a ToolCallContext, arguments: Value) -> ToolExecutorFuture<'a> { Box::pin(async move { update_plan_impl(ctx, arguments) }) } diff --git a/crates/pulsing-forge/src/handlers/read.rs b/crates/pulsing-forge/src/handlers/read.rs index 941fe3b61..ae7f6879b 100644 --- a/crates/pulsing-forge/src/handlers/read.rs +++ b/crates/pulsing-forge/src/handlers/read.rs @@ -19,6 +19,10 @@ impl ToolExecutor for ReadHandler { "Read" } + fn spec(&self) -> crate::registry::ToolSpec { + super::builtin_spec(self.tool_name()) + } + fn handle<'a>(&'a self, ctx: &'a ToolCallContext, arguments: Value) -> ToolExecutorFuture<'a> { let cwd = ctx.cwd.clone(); Box::pin(async move { read_impl(&cwd, &arguments) }) diff --git a/crates/pulsing-forge/src/handlers/request_permissions.rs b/crates/pulsing-forge/src/handlers/request_permissions.rs index 7bb46a239..b4d71bac1 100644 --- a/crates/pulsing-forge/src/handlers/request_permissions.rs +++ b/crates/pulsing-forge/src/handlers/request_permissions.rs @@ -13,6 +13,10 @@ impl ToolExecutor for RequestPermissionsHandler { "request_permissions" } + fn spec(&self) -> crate::registry::ToolSpec { + super::builtin_spec(self.tool_name()) + } + fn handle<'a>(&'a self, ctx: &'a ToolCallContext, arguments: Value) -> ToolExecutorFuture<'a> { Box::pin(async move { request_permissions_impl(ctx, arguments) }) } diff --git a/crates/pulsing-forge/src/handlers/session.rs b/crates/pulsing-forge/src/handlers/session.rs index a7e769d61..5ab21d807 100644 --- a/crates/pulsing-forge/src/handlers/session.rs +++ b/crates/pulsing-forge/src/handlers/session.rs @@ -20,6 +20,10 @@ impl ToolExecutor for NewContextHandler { "new_context" } + fn spec(&self) -> crate::registry::ToolSpec { + super::builtin_spec(self.tool_name()) + } + fn handle<'a>(&'a self, ctx: &'a ToolCallContext, _arguments: Value) -> ToolExecutorFuture<'a> { Box::pin(async move { new_context_impl(ctx) }) } @@ -38,6 +42,10 @@ impl ToolExecutor for GetContextRemainingHandler { "get_context_remaining" } + fn spec(&self) -> crate::registry::ToolSpec { + super::builtin_spec(self.tool_name()) + } + fn handle<'a>(&'a self, ctx: &'a ToolCallContext, _arguments: Value) -> ToolExecutorFuture<'a> { Box::pin(async move { get_context_remaining_impl(ctx) }) } @@ -69,6 +77,10 @@ impl ToolExecutor for RequestUserInputHandler { "request_user_input" } + fn spec(&self) -> crate::registry::ToolSpec { + super::builtin_spec(self.tool_name()) + } + fn handle<'a>(&'a self, ctx: &'a ToolCallContext, arguments: Value) -> ToolExecutorFuture<'a> { Box::pin(async move { request_user_input_impl(ctx, arguments) }) } diff --git a/crates/pulsing-forge/src/handlers/shell.rs b/crates/pulsing-forge/src/handlers/shell.rs index fc421f3d9..fb45f74d7 100644 --- a/crates/pulsing-forge/src/handlers/shell.rs +++ b/crates/pulsing-forge/src/handlers/shell.rs @@ -11,6 +11,10 @@ impl ToolExecutor for ShellCommandHandler { "shell_command" } + fn spec(&self) -> crate::registry::ToolSpec { + super::builtin_spec(self.tool_name()) + } + fn handle<'a>(&'a self, ctx: &'a ToolCallContext, arguments: Value) -> ToolExecutorFuture<'a> { Box::pin(async move { run_shell(ctx, &arguments).await }) } @@ -23,6 +27,10 @@ impl ToolExecutor for ExecCommandHandler { "exec_command" } + fn spec(&self) -> crate::registry::ToolSpec { + super::builtin_spec(self.tool_name()) + } + fn handle<'a>(&'a self, ctx: &'a ToolCallContext, arguments: Value) -> ToolExecutorFuture<'a> { Box::pin(async move { ctx.exec.exec_command(ctx, &arguments).await }) } @@ -35,6 +43,10 @@ impl ToolExecutor for WriteStdinHandler { "write_stdin" } + fn spec(&self) -> crate::registry::ToolSpec { + super::builtin_spec(self.tool_name()) + } + fn handle<'a>(&'a self, ctx: &'a ToolCallContext, arguments: Value) -> ToolExecutorFuture<'a> { Box::pin(async move { ctx.exec.write_stdin(ctx, &arguments).await }) } diff --git a/crates/pulsing-forge/src/handlers/shell_exec.rs b/crates/pulsing-forge/src/handlers/shell_exec.rs index bc597e62e..83b8f39ad 100644 --- a/crates/pulsing-forge/src/handlers/shell_exec.rs +++ b/crates/pulsing-forge/src/handlers/shell_exec.rs @@ -16,6 +16,7 @@ use crate::error::ToolError; use crate::exec_output::{SHELL_MAX_BYTES, shell_timeout_ms}; use crate::handlers::write::resolve_within_cwd; use crate::patch::{MaybeApplyPatch, apply_parsed_patch, maybe_parse_apply_patch}; +use crate::process_group::{ProcessGroupGuard, configure}; use crate::result::ToolResult; use crate::sandbox::build_bash_exec; @@ -45,6 +46,9 @@ pub(crate) async fn run_shell( let cwd = resolve_shell_workdir(ctx, args)?; let login = args.get("login").and_then(|v| v.as_bool()).unwrap_or(false); let timeout_ms = shell_timeout_ms(args); + if ctx.turn.as_ref().is_some_and(|turn| turn.is_cancelled()) { + return Ok(ToolResult::err("shell command cancelled before start")); + } ensure_shell_allowed(ctx, args, cmd)?; let policy = effective_sandbox_policy(ctx, args); @@ -75,6 +79,8 @@ pub(crate) async fn run_shell( let mut command = Command::new(&plan.argv[0]); command.args(&plan.argv[1..]); + configure(&mut command); + command.kill_on_drop(true); command .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -86,12 +92,41 @@ pub(crate) async fn run_shell( } } + let _turn_resource = ctx + .turn + .as_ref() + .map(|turn| turn.resources().register_passive("shell_command")); let dur = Duration::from_millis(timeout_ms); - let out = match timeout(dur, command.output()).await { + let child = match command.spawn() { + Ok(child) => child, + Err(err) => return Ok(ToolResult::err(err.to_string())), + }; + let mut process_group = ProcessGroupGuard::new(child.id()); + let output = child.wait_with_output(); + tokio::pin!(output); + let result = if let Some(turn) = &ctx.turn { + let cancellation = turn.cancellation(); + tokio::select! { + _ = cancellation.cancelled() => { + process_group.kill_now(); + let _ = timeout(Duration::from_secs(2), &mut output).await; + return Ok(ToolResult::err("shell command cancelled")); + } + result = timeout(dur, &mut output) => result, + } + } else { + timeout(dur, &mut output).await + }; + let out = match result { Ok(Ok(o)) => o, Ok(Err(e)) => return Ok(ToolResult::err(e.to_string())), - Err(_) => return Ok(ToolResult::err(format!("timed out after {timeout_ms}ms"))), + Err(_) => { + process_group.kill_now(); + let _ = timeout(Duration::from_secs(2), &mut output).await; + return Ok(ToolResult::err(format!("timed out after {timeout_ms}ms"))); + } }; + process_group.disarm(); let mut text = String::new(); text.push_str(&String::from_utf8_lossy(&out.stdout)); text.push_str(&String::from_utf8_lossy(&out.stderr)); @@ -114,7 +149,7 @@ pub(crate) async fn run_shell( #[cfg(test)] mod tests { use super::*; - use crate::approval::{ApprovalCache, new_exec_policy}; + use crate::approval::{ApprovalCache, ApprovalPolicy, new_exec_policy}; use crate::context::{LocalToolSession, ToolCallContext}; use crate::discovery::new_tool_catalog; use crate::unified_exec::UnifiedExecManager; @@ -125,7 +160,7 @@ mod tests { ToolCallContext::new( cwd, "off", - Arc::new(LocalToolSession::default()), + Arc::new(LocalToolSession::default().with_approval_policy(ApprovalPolicy::Always)), Arc::new(UnifiedExecManager::new()), new_exec_policy(), Arc::new(ApprovalCache::default()), @@ -141,4 +176,29 @@ mod tests { resolve_shell_workdir(&ctx, &serde_json::json!({"workdir": "../escape"})).unwrap_err(); assert!(err.to_string().contains("outside working directory")); } + + #[tokio::test] + async fn turn_cancellation_kills_the_shell_process_group() { + let dir = tempfile::tempdir().unwrap(); + let marker = dir.path().join("should-not-exist"); + let turn = Arc::new(crate::turn::TurnExecutionContext::new( + crate::SessionId::new(), + crate::TurnId::new(), + )); + let ctx = test_ctx(dir.path()).with_turn(turn.clone()); + let args = serde_json::json!({ + "cmd": "(sleep 0.3; touch should-not-exist) & wait", + "timeout_ms": 5_000 + }); + + let cancel = async { + tokio::time::sleep(Duration::from_millis(50)).await; + turn.cancel(); + }; + let (result, ()) = tokio::join!(run_shell(&ctx, &args), cancel); + assert!(result.unwrap().is_error); + assert!(turn.resources().wait_for_idle(Duration::from_secs(1)).await); + tokio::time::sleep(Duration::from_millis(400)).await; + assert!(!marker.exists(), "cancelled process tree produced a file"); + } } diff --git a/crates/pulsing-forge/src/handlers/view_image.rs b/crates/pulsing-forge/src/handlers/view_image.rs index fba945df6..044c5d1cb 100644 --- a/crates/pulsing-forge/src/handlers/view_image.rs +++ b/crates/pulsing-forge/src/handlers/view_image.rs @@ -24,6 +24,10 @@ impl ToolExecutor for ViewImageHandler { "view_image" } + fn spec(&self) -> crate::registry::ToolSpec { + super::builtin_spec(self.tool_name()) + } + fn handle<'a>(&'a self, ctx: &'a ToolCallContext, arguments: Value) -> ToolExecutorFuture<'a> { let cwd = ctx.cwd.clone(); Box::pin(async move { view_image_impl(&cwd, &arguments) }) diff --git a/crates/pulsing-forge/src/handlers/write.rs b/crates/pulsing-forge/src/handlers/write.rs index e28eeb34a..05ea015c9 100644 --- a/crates/pulsing-forge/src/handlers/write.rs +++ b/crates/pulsing-forge/src/handlers/write.rs @@ -14,6 +14,10 @@ impl ToolExecutor for WriteHandler { "Write" } + fn spec(&self) -> crate::registry::ToolSpec { + super::builtin_spec(self.tool_name()) + } + fn handle<'a>(&'a self, ctx: &'a ToolCallContext, arguments: Value) -> ToolExecutorFuture<'a> { let cwd = ctx.cwd.clone(); Box::pin(async move { write_impl(&cwd, &arguments) }) diff --git a/crates/pulsing-forge/src/lib.rs b/crates/pulsing-forge/src/lib.rs index fdbc81c05..b79e5c92a 100644 --- a/crates/pulsing-forge/src/lib.rs +++ b/crates/pulsing-forge/src/lib.rs @@ -16,11 +16,16 @@ pub mod handlers; pub mod llm; pub mod mcp; pub mod patch; +mod process_group; +pub mod protocol; pub mod pty_session; +pub mod registry; pub mod result; pub mod runtime; pub mod sandbox; +pub mod session; pub mod session_input; +pub mod turn; pub mod unified_exec; pub use context::{ @@ -29,12 +34,21 @@ pub use context::{ }; pub use agent::{ - AgentConfig, AgentEvent, AgentEventTx, DEFAULT_TOOL_NAMES, INIT_TOOL_NAMES, InteractiveConfig, - default_model_for_provider, default_provider, run_agent_turn, run_agent_turn_observed, - run_init_guide, run_interactive, run_oneshot, + AgentCancelled, AgentConfig, AgentEvent, AgentEventHandler, AgentEventTx, DEFAULT_TOOL_NAMES, + INIT_TOOL_NAMES, InteractiveConfig, default_model_for_provider, default_provider, + run_agent_turn, run_agent_turn_observed, run_init_guide, run_interactive, run_oneshot, }; pub use error::ToolError; pub use executor::{ToolExecutor, ToolExposure}; pub use llm::{LlmClient, LlmError, LlmMessage, LlmStream, LlmUsage, Provider, StreamRequest}; +pub use protocol::{ + CandidateId, CommandId, CommandReceipt, EventId, ForgeEvent, ForgeEventKind, + ForgeProtocolError, ProtocolVersion, SessionId, SessionSpec, TurnId, +}; +pub use registry::{ToolName, ToolRegistry, ToolRegistryError, ToolSpec}; pub use result::ToolResult; pub use runtime::ToolRuntime; +pub use session::{ + EventSubscription, ForgeService, InMemoryEventStore, LocalForgeClient, SessionSnapshot, +}; +pub use turn::{TurnExecutionContext, TurnResourceGuard, TurnResourceRegistry}; diff --git a/crates/pulsing-forge/src/process_group.rs b/crates/pulsing-forge/src/process_group.rs new file mode 100644 index 000000000..a8f6bb6e1 --- /dev/null +++ b/crates/pulsing-forge/src/process_group.rs @@ -0,0 +1,49 @@ +//! Small cross-platform helpers for containing shell process trees. + +#[cfg(unix)] +pub fn configure(command: &mut tokio::process::Command) { + use std::os::unix::process::CommandExt; + command.as_std_mut().process_group(0); +} + +#[cfg(not(unix))] +pub fn configure(_command: &mut tokio::process::Command) {} + +#[cfg(unix)] +pub fn kill(process_id: u32) { + // A negative pid targets the process group created with process_group(0). + unsafe { + libc::kill(-(process_id as i32), libc::SIGKILL); + } +} + +#[cfg(not(unix))] +pub fn kill(_process_id: u32) {} + +pub struct ProcessGroupGuard { + process_id: Option, +} + +impl ProcessGroupGuard { + pub fn new(process_id: Option) -> Self { + Self { process_id } + } + + pub fn disarm(&mut self) { + self.process_id = None; + } + + pub fn kill_now(&self) { + if let Some(process_id) = self.process_id { + kill(process_id); + } + } +} + +impl Drop for ProcessGroupGuard { + fn drop(&mut self) { + if let Some(process_id) = self.process_id { + kill(process_id); + } + } +} diff --git a/crates/pulsing-forge/src/protocol/command.rs b/crates/pulsing-forge/src/protocol/command.rs new file mode 100644 index 000000000..cb374b22c --- /dev/null +++ b/crates/pulsing-forge/src/protocol/command.rs @@ -0,0 +1,105 @@ +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use super::{CommandId, ProtocolVersion, SessionId, TurnId}; +use crate::agent::AgentConfig; +use crate::approval::ApprovalPolicy; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionSpec { + pub cwd: PathBuf, + pub provider: String, + pub model: String, + pub max_tokens: u32, + pub max_turns: usize, + pub sandbox: String, + #[serde(default)] + pub approval_policy: ApprovalPolicy, + pub tool_names: Vec, + pub system_prompt: Option, +} + +impl From for SessionSpec { + fn from(value: AgentConfig) -> Self { + Self { + cwd: value.cwd, + provider: value.provider, + model: value.model, + max_tokens: value.max_tokens, + max_turns: value.max_turns, + sandbox: value.sandbox, + approval_policy: value.approval_policy, + tool_names: value.tool_names, + system_prompt: value.system_prompt, + } + } +} + +impl From for AgentConfig { + fn from(value: SessionSpec) -> Self { + Self { + cwd: value.cwd, + provider: value.provider, + model: value.model, + max_tokens: value.max_tokens, + max_turns: value.max_turns, + sandbox: value.sandbox, + approval_policy: value.approval_policy, + tool_names: value.tool_names, + system_prompt: value.system_prompt, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CommandEnvelope { + pub protocol: String, + pub version: ProtocolVersion, + pub command_id: CommandId, + pub session_id: SessionId, + pub expected_seq: Option, + pub payload: T, +} + +impl CommandEnvelope { + pub fn new(command_id: CommandId, session_id: SessionId, payload: T) -> Self { + Self { + protocol: "forge.session".into(), + version: ProtocolVersion::V1, + command_id, + session_id, + expected_seq: None, + payload, + } + } + + pub fn expecting(mut self, seq: u64) -> Self { + self.expected_seq = Some(seq); + self + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CreateSession { + pub spec: SessionSpec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct StartTurn { + pub turn_id: TurnId, + pub input: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CancelTurn { + pub turn_id: TurnId, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CommandReceipt { + pub command_id: CommandId, + pub session_id: SessionId, + pub turn_id: Option, + pub accepted_seq: u64, +} diff --git a/crates/pulsing-forge/src/protocol/error.rs b/crates/pulsing-forge/src/protocol/error.rs new file mode 100644 index 000000000..8e5948d37 --- /dev/null +++ b/crates/pulsing-forge/src/protocol/error.rs @@ -0,0 +1,36 @@ +use thiserror::Error; + +use super::{SessionId, TurnId}; + +#[derive(Debug, Clone, Error, PartialEq, Eq)] +pub enum ForgeProtocolError { + #[error("session not found: {0}")] + SessionNotFound(SessionId), + #[error("session already exists: {0}")] + SessionAlreadyExists(SessionId), + #[error("session is closed: {0}")] + SessionClosed(SessionId), + #[error("session already has an active turn: {0}")] + SessionBusy(SessionId), + #[error("turn is not active: {0}")] + TurnNotActive(TurnId), + #[error("event sequence conflict: expected {expected}, actual {actual}")] + SequenceConflict { expected: u64, actual: u64 }, + #[error("invalid state transition: {0}")] + InvalidTransition(String), + #[error("event subscription lagged by {0} events")] + SubscriptionLagged(u64), + #[error("event subscription closed")] + SubscriptionClosed, + #[error("invalid protocol: expected {expected}, got {actual}")] + InvalidProtocol { + expected: &'static str, + actual: String, + }, + #[error("unsupported {protocol} protocol major version {major}")] + UnsupportedVersion { protocol: String, major: u16 }, + #[error("agent task failed: {0}")] + Agent(String), + #[error("internal Forge error: {0}")] + Internal(String), +} diff --git a/crates/pulsing-forge/src/protocol/event.rs b/crates/pulsing-forge/src/protocol/event.rs new file mode 100644 index 000000000..aed619ec9 --- /dev/null +++ b/crates/pulsing-forge/src/protocol/event.rs @@ -0,0 +1,112 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use super::{CommandId, EventId, SessionId, TurnId}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProtocolVersion { + pub major: u16, + pub minor: u16, +} + +impl ProtocolVersion { + pub const V1: Self = Self { major: 1, minor: 0 }; +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", content = "payload", rename_all = "snake_case")] +pub enum ForgeEventKind { + SessionCreated, + SessionClosed, + TurnStarted { + input: String, + }, + TurnOutputDelta { + delta: String, + }, + TurnCancelRequested, + TurnCleanupStalled { + resources: Vec, + }, + TurnCancelled, + TurnCompleted { + text: String, + }, + TurnFailed { + message: String, + }, + ToolStarted { + name: String, + }, + ToolCompleted { + name: String, + ok: bool, + summary: String, + }, + ToolCancelled { + name: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ForgeEvent { + pub protocol: String, + pub version: ProtocolVersion, + pub event_id: EventId, + pub session_id: SessionId, + pub seq: u64, + pub occurred_at: DateTime, + pub turn_id: Option, + pub causation_id: Option, + #[serde(flatten)] + pub kind: ForgeEventKind, +} + +impl ForgeEvent { + pub fn new( + session_id: SessionId, + seq: u64, + turn_id: Option, + causation_id: Option, + kind: ForgeEventKind, + ) -> Self { + Self { + protocol: "forge.event".into(), + version: ProtocolVersion::V1, + event_id: EventId::new(), + session_id, + seq, + occurred_at: Utc::now(), + turn_id, + causation_id, + kind, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn event_kind_is_flattened_into_the_versioned_envelope() { + let event = ForgeEvent::new( + SessionId::from_string("session-1"), + 7, + Some(TurnId::from_string("turn-1")), + None, + ForgeEventKind::TurnOutputDelta { + delta: "hello".into(), + }, + ); + + let value = serde_json::to_value(&event).expect("serialize event"); + assert_eq!(value["protocol"], "forge.event"); + assert_eq!(value["version"]["major"], 1); + assert_eq!(value["kind"], "turn_output_delta"); + assert_eq!(value["payload"]["delta"], "hello"); + + let decoded: ForgeEvent = serde_json::from_value(value).expect("deserialize event"); + assert_eq!(decoded, event); + } +} diff --git a/crates/pulsing-forge/src/protocol/ids.rs b/crates/pulsing-forge/src/protocol/ids.rs new file mode 100644 index 000000000..2586a247a --- /dev/null +++ b/crates/pulsing-forge/src/protocol/ids.rs @@ -0,0 +1,58 @@ +use std::fmt; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +macro_rules! opaque_id { + ($name:ident, $prefix:literal) => { + #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] + #[serde(transparent)] + pub struct $name(String); + + impl $name { + pub fn new() -> Self { + Self(format!("{}_{}", $prefix, Uuid::new_v4().simple())) + } + + pub fn from_string(value: impl Into) -> Self { + Self(value.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + } + + impl Default for $name { + fn default() -> Self { + Self::new() + } + } + + impl fmt::Display for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } + } + }; +} + +opaque_id!(SessionId, "ses"); +opaque_id!(TurnId, "turn"); +opaque_id!(EventId, "evt"); +opaque_id!(CommandId, "cmd"); +opaque_id!(CandidateId, "cand"); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ids_are_opaque_and_round_trip() { + let id = SessionId::new(); + let encoded = serde_json::to_string(&id).unwrap(); + let decoded: SessionId = serde_json::from_str(&encoded).unwrap(); + assert_eq!(decoded, id); + assert!(id.as_str().starts_with("ses_")); + } +} diff --git a/crates/pulsing-forge/src/protocol/mod.rs b/crates/pulsing-forge/src/protocol/mod.rs new file mode 100644 index 000000000..408a48ec4 --- /dev/null +++ b/crates/pulsing-forge/src/protocol/mod.rs @@ -0,0 +1,13 @@ +//! Versioned Forge command and event protocol. + +mod command; +mod error; +mod event; +mod ids; + +pub use command::{ + CancelTurn, CommandEnvelope, CommandReceipt, CreateSession, SessionSpec, StartTurn, +}; +pub use error::ForgeProtocolError; +pub use event::{ForgeEvent, ForgeEventKind, ProtocolVersion}; +pub use ids::{CandidateId, CommandId, EventId, SessionId, TurnId}; diff --git a/crates/pulsing-forge/src/pty_session.rs b/crates/pulsing-forge/src/pty_session.rs index 183e7016e..12550a548 100644 --- a/crates/pulsing-forge/src/pty_session.rs +++ b/crates/pulsing-forge/src/pty_session.rs @@ -3,15 +3,16 @@ use std::io::{Read, Write}; use std::path::Path; use std::sync::atomic::{AtomicI32, Ordering}; -use std::sync::{Arc, mpsc}; +use std::sync::{Arc, Mutex, mpsc}; use std::thread::{self, JoinHandle}; -use portable_pty::{CommandBuilder, PtySize, native_pty_system}; +use portable_pty::{ChildKiller, CommandBuilder, PtySize, native_pty_system}; use crate::error::ToolError; use crate::exec_output::{ ExecOutputDelta, ExecStream, OutputBuffer, RUNNING_EXIT_SENTINEL, Utf8ChunkDecoder, }; +use crate::process_group; use crate::sandbox::BashExecPlan; enum PtyCommand { @@ -22,6 +23,8 @@ enum PtyCommand { /// Handle to a background PTY session thread. pub struct PtyExecHandle { cmd_tx: mpsc::Sender, + killer: Arc>>, + process_id: Option, pub exit_code: Arc, _thread: JoinHandle<()>, } @@ -29,7 +32,7 @@ pub struct PtyExecHandle { impl Drop for PtyExecHandle { fn drop(&mut self) { if self.poll_exit_code().is_none() { - let _ = self.cmd_tx.send(PtyCommand::Kill); + let _ = self.kill(); } } } @@ -42,9 +45,18 @@ impl PtyExecHandle { } pub fn kill(&self) -> Result<(), ToolError> { - self.cmd_tx - .send(PtyCommand::Kill) - .map_err(|e| ToolError::respond(format!("pty kill channel closed: {e}"))) + if let Some(process_id) = self.process_id { + process_group::kill(process_id); + } + self.killer + .lock() + .map_err(|_| ToolError::respond("pty killer lock poisoned"))? + .kill() + .map_err(|e| ToolError::respond(format!("pty kill failed: {e}")))?; + // Keep the command for implementations where killing does not + // immediately wake the PTY reader loop. + let _ = self.cmd_tx.send(PtyCommand::Kill); + Ok(()) } pub fn poll_exit_code(&self) -> Option { @@ -66,6 +78,7 @@ pub fn spawn_pty_exec( ) -> Result { let cmd_tx_out; let (cmd_tx, cmd_rx) = mpsc::channel(); + let (killer_tx, killer_rx) = mpsc::sync_channel(1); cmd_tx_out = cmd_tx; let exit_code = Arc::new(AtomicI32::new(RUNNING_EXIT_SENTINEL)); let exit_out = exit_code.clone(); @@ -74,7 +87,9 @@ pub fn spawn_pty_exec( let wd = workdir.to_path_buf(); let thread = thread::spawn(move || { - if let Err(e) = run_pty_thread(&plan, &wd, cmd_rx, buffer, session_id, on_delta, exit_out) { + if let Err(e) = run_pty_thread( + &plan, &wd, cmd_rx, killer_tx, buffer, session_id, on_delta, exit_out, + ) { tracing::warn!("pty session ended with error: {e}"); } // Setup can fail before the wait loop ever runs (e.g. openpty/spawn @@ -85,9 +100,14 @@ pub fn spawn_pty_exec( exit_guard.store(-1, Ordering::SeqCst); } }); + let (killer, process_id) = killer_rx + .recv() + .map_err(|_| ToolError::respond("pty process failed before exposing its kill handle"))?; Ok(PtyExecHandle { cmd_tx: cmd_tx_out, + killer: Arc::new(Mutex::new(killer)), + process_id, exit_code, _thread: thread, }) @@ -97,6 +117,7 @@ fn run_pty_thread( plan: &BashExecPlan, workdir: &Path, cmd_rx: mpsc::Receiver, + killer_tx: mpsc::SyncSender<(Box, Option)>, buffer: Arc>, session_id: i32, on_delta: Option>, @@ -138,6 +159,9 @@ fn run_pty_thread( .slave .spawn_command(cmd_builder) .map_err(|e| report_err(format!("pty spawn failed: {e}")))?; + killer_tx + .send((child.clone_killer(), child.process_id())) + .map_err(|_| report_err("pty owner dropped during startup".into()))?; let mut reader = pair .master diff --git a/crates/pulsing-forge/src/registry.rs b/crates/pulsing-forge/src/registry.rs new file mode 100644 index 000000000..b618bfa02 --- /dev/null +++ b/crates/pulsing-forge/src/registry.rs @@ -0,0 +1,305 @@ +//! Forge's canonical tool registry. +//! +//! A registered executor is the single source of truth for both model-visible +//! metadata and runtime dispatch. Agent clients select tools by name, but never +//! maintain a second copy of their schemas. + +use std::collections::HashMap; +use std::fmt; + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use thiserror::Error; + +use crate::executor::ToolExecutor; + +/// A callable tool name that preserves an optional namespace. +#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +pub struct ToolName { + pub namespace: Option, + pub name: String, +} + +impl ToolName { + pub fn plain(name: impl Into) -> Self { + Self { + namespace: None, + name: name.into(), + } + } + + pub fn namespaced(namespace: impl Into, name: impl Into) -> Self { + Self { + namespace: Some(namespace.into()), + name: name.into(), + } + } + + /// Name sent over the current flat model tool protocol. + pub fn model_name(&self) -> String { + match &self.namespace { + Some(namespace) => format!("{namespace}{}", self.name), + None => self.name.clone(), + } + } +} + +impl fmt::Display for ToolName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.model_name()) + } +} + +impl From<&str> for ToolName { + fn from(value: &str) -> Self { + Self::plain(value) + } +} + +impl From for ToolName { + fn from(value: String) -> Self { + Self::plain(value) + } +} + +/// Provider-neutral metadata owned by the executable tool. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ToolSpec { + pub name: ToolName, + pub description: String, + pub input_schema: Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_schema: Option, +} + +impl ToolSpec { + pub fn function( + name: impl Into, + description: impl Into, + input_schema: Value, + ) -> Self { + Self { + name: name.into(), + description: description.into(), + input_schema: normalize_object_schema(input_schema), + output_schema: None, + } + } + + /// Current Forge LLM clients consume Anthropic-style function definitions; + /// OpenAI conversion happens in `llm::message`. + pub fn model_definition(&self) -> Value { + json!({ + "name": self.name.model_name(), + "description": self.description, + "input_schema": self.input_schema, + }) + } +} + +fn normalize_object_schema(mut schema: Value) -> Value { + if let Some(obj) = schema.as_object_mut() { + obj.entry("type").or_insert_with(|| json!("object")); + obj.entry("properties") + .or_insert_with(|| Value::Object(Default::default())); + } + schema +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ToolRegistryError { + #[error("tool name must not be empty")] + EmptyName, + #[error("tool executor name {executor:?} does not match spec name {spec:?}")] + NameMismatch { executor: String, spec: String }, + #[error("duplicate tool registration: {0}")] + Duplicate(String), + #[error("unknown tool selected for this Forge runtime: {0}")] + Unknown(String), + #[error("tool is not directly model-visible: {0}")] + NotDirect(String), +} + +pub struct ToolRegistry { + tools: HashMap>, +} + +impl ToolRegistry { + pub fn from_executors( + executors: impl IntoIterator>, + ) -> Result { + let mut registry = Self { + tools: HashMap::new(), + }; + for executor in executors { + registry.register(executor)?; + } + Ok(registry) + } + + pub fn register(&mut self, executor: Box) -> Result<(), ToolRegistryError> { + let executor_name = executor.tool_name().trim().to_string(); + if executor_name.is_empty() { + return Err(ToolRegistryError::EmptyName); + } + let spec_name = executor.spec().name.model_name(); + if executor_name != spec_name { + return Err(ToolRegistryError::NameMismatch { + executor: executor_name, + spec: spec_name, + }); + } + if self.tools.contains_key(&executor_name) { + return Err(ToolRegistryError::Duplicate(executor_name)); + } + self.tools.insert(executor_name, executor); + Ok(()) + } + + pub fn get(&self, name: &str) -> Option<&dyn ToolExecutor> { + self.tools.get(name).map(Box::as_ref) + } + + pub fn names(&self) -> Vec { + let mut names: Vec<_> = self.tools.keys().cloned().collect(); + names.sort(); + names + } + + pub fn definitions_for(&self, names: &[String]) -> Result, ToolRegistryError> { + names + .iter() + .map(|name| { + let tool = self + .tools + .get(name) + .ok_or_else(|| ToolRegistryError::Unknown(name.clone()))?; + if !tool.exposure().is_direct() { + return Err(ToolRegistryError::NotDirect(name.clone())); + } + Ok(tool.spec().model_definition()) + }) + .collect() + } + + pub fn direct_definitions(&self) -> Vec { + let mut tools: Vec<_> = self + .tools + .values() + .filter(|tool| tool.exposure().is_direct()) + .map(|tool| tool.spec().model_definition()) + .collect(); + tools.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str())); + tools + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::context::ToolCallContext; + use crate::error::ToolError; + use crate::executor::{ToolExecutorFuture, ToolExposure}; + use crate::result::ToolResult; + + struct TestTool { + runtime_name: &'static str, + spec_name: &'static str, + exposure: ToolExposure, + } + + impl ToolExecutor for TestTool { + fn tool_name(&self) -> &str { + self.runtime_name + } + + fn spec(&self) -> ToolSpec { + ToolSpec::function(self.spec_name, "test", json!({"type": "object"})) + } + + fn exposure(&self) -> ToolExposure { + self.exposure + } + + fn handle<'a>( + &'a self, + _ctx: &'a ToolCallContext, + _arguments: Value, + ) -> ToolExecutorFuture<'a> { + Box::pin(async { Ok::<_, ToolError>(ToolResult::ok("ok")) }) + } + } + + #[test] + fn rejects_name_mismatch_and_duplicates() { + let mismatch = ToolRegistry::from_executors([Box::new(TestTool { + runtime_name: "runtime", + spec_name: "schema", + exposure: ToolExposure::Direct, + }) as Box]) + .err() + .unwrap(); + assert!(matches!(mismatch, ToolRegistryError::NameMismatch { .. })); + + let duplicate = ToolRegistry::from_executors([ + Box::new(TestTool { + runtime_name: "same", + spec_name: "same", + exposure: ToolExposure::Direct, + }) as Box, + Box::new(TestTool { + runtime_name: "same", + spec_name: "same", + exposure: ToolExposure::Direct, + }) as Box, + ]) + .err() + .unwrap(); + assert_eq!(duplicate, ToolRegistryError::Duplicate("same".into())); + } + + #[test] + fn definitions_are_derived_from_registered_executors() { + let registry = ToolRegistry::from_executors([ + Box::new(TestTool { + runtime_name: "visible", + spec_name: "visible", + exposure: ToolExposure::Direct, + }) as Box, + Box::new(TestTool { + runtime_name: "deferred", + spec_name: "deferred", + exposure: ToolExposure::Deferred, + }) as Box, + ]) + .unwrap(); + + assert_eq!(registry.direct_definitions().len(), 1); + assert_eq!(registry.direct_definitions()[0]["name"], "visible"); + assert_eq!( + registry.definitions_for(&["deferred".into()]).unwrap_err(), + ToolRegistryError::NotDirect("deferred".into()) + ); + } + + #[test] + fn every_builtin_executor_has_one_canonical_definition() { + let registry = ToolRegistry::from_executors(crate::handlers::builtin_handlers()).unwrap(); + let names = registry.names(); + let definitions = registry.direct_definitions(); + + assert_eq!(names.len(), 22); + assert_eq!(definitions.len(), names.len()); + for definition in definitions { + let name = definition["name"].as_str().unwrap(); + assert!(names.iter().any(|candidate| candidate == name)); + assert!( + definition["description"] + .as_str() + .is_some_and(|value| !value.is_empty()) + ); + assert_eq!(definition["input_schema"]["type"], "object"); + assert!(definition["input_schema"]["properties"].is_object()); + } + } +} diff --git a/crates/pulsing-forge/src/runtime.rs b/crates/pulsing-forge/src/runtime.rs index 925255b03..afcef0994 100644 --- a/crates/pulsing-forge/src/runtime.rs +++ b/crates/pulsing-forge/src/runtime.rs @@ -1,14 +1,14 @@ -use std::collections::HashMap; use std::path::Path; use std::sync::Arc; use crate::approval::{ApprovalCache, new_exec_policy}; use crate::context::{LocalToolSession, NullToolSession, ToolCallContext, ToolSession}; use crate::discovery::{ToolCatalog, new_tool_catalog}; -use crate::executor::ToolExecutor; use crate::handlers::builtin_handlers; use crate::handlers::try_call_mcp_dynamic_tool; +use crate::registry::{ToolRegistry, ToolRegistryError}; use crate::result::ToolResult; +use crate::turn::TurnExecutionContext; use crate::unified_exec::UnifiedExecManager; pub struct ToolRuntimeConfig { @@ -45,16 +45,14 @@ impl Default for ToolRuntimeConfig { } pub struct ToolRuntime { - handlers: HashMap>, + registry: ToolRegistry, context: ToolCallContext, } impl ToolRuntime { pub fn new(config: ToolRuntimeConfig) -> Self { - let mut handlers = HashMap::new(); - for h in builtin_handlers() { - handlers.insert(h.tool_name().to_string(), h); - } + let registry = ToolRegistry::from_executors(builtin_handlers()) + .expect("built-in Forge tools must form a valid registry"); let context = ToolCallContext::new( config.cwd, &config.sandbox_policy, @@ -70,7 +68,7 @@ impl ToolRuntime { } else { context }; - Self { handlers, context } + Self { registry, context } } pub fn with_local_session(cwd: impl AsRef, sandbox_policy: &str) -> Self { @@ -87,19 +85,48 @@ impl ToolRuntime { } pub fn tool_names(&self) -> Vec { - let mut names: Vec<_> = self.handlers.keys().cloned().collect(); - names.sort(); - names + self.registry.names() + } + + pub fn tool_definitions( + &self, + names: &[String], + ) -> Result, ToolRegistryError> { + self.registry.definitions_for(names) + } + + pub fn registry(&self) -> &ToolRegistry { + &self.registry } pub async fn call_tool(&self, name: &str, arguments: serde_json::Value) -> ToolResult { - if let Some(h) = self.handlers.get(name) { - return match h.handle(&self.context, arguments).await { + self.call_tool_with_context(&self.context, name, arguments) + .await + } + + pub async fn call_tool_in_turn( + &self, + turn: Arc, + name: &str, + arguments: serde_json::Value, + ) -> ToolResult { + let context = self.context.clone().with_turn(turn); + self.call_tool_with_context(&context, name, arguments).await + } + + async fn call_tool_with_context( + &self, + context: &ToolCallContext, + name: &str, + arguments: serde_json::Value, + ) -> ToolResult { + if let Some(h) = self.registry.get(name) { + return match h.handle(context, arguments).await { Ok(r) => r, Err(e) => ToolResult::err(e.to_string()), }; } - if let Some(result) = try_call_mcp_dynamic_tool(&self.context, name, arguments).await { + if let Some(result) = try_call_mcp_dynamic_tool(context, name, arguments).await { return match result { Ok(r) => r, Err(e) => ToolResult::err(e.to_string()), diff --git a/crates/pulsing-forge/src/session/client.rs b/crates/pulsing-forge/src/session/client.rs new file mode 100644 index 000000000..9cc7f8277 --- /dev/null +++ b/crates/pulsing-forge/src/session/client.rs @@ -0,0 +1,132 @@ +use crate::agent::AgentConfig; +use crate::protocol::{ + CancelTurn, CommandEnvelope, CommandId, CommandReceipt, CreateSession, ForgeEventKind, + ForgeProtocolError, SessionId, SessionSpec, StartTurn, TurnId, +}; + +use super::{EventSubscription, ForgeService, SessionSnapshot}; + +#[derive(Clone, Default)] +pub struct LocalForgeClient { + service: ForgeService, +} + +impl LocalForgeClient { + pub fn new(service: ForgeService) -> Self { + Self { service } + } + + pub fn service(&self) -> &ForgeService { + &self.service + } + + pub async fn create_session( + &self, + config: AgentConfig, + ) -> Result { + let session_id = SessionId::new(); + self.create_session_with(CommandId::new(), session_id.clone(), config) + .await?; + Ok(session_id) + } + + pub async fn create_session_with( + &self, + command_id: CommandId, + session_id: SessionId, + config: AgentConfig, + ) -> Result { + self.service + .create_session(CommandEnvelope::new( + command_id, + session_id, + CreateSession { + spec: SessionSpec::from(config), + }, + )) + .await + } + + pub async fn start_turn( + &self, + session_id: SessionId, + input: impl Into, + ) -> Result { + self.start_turn_with(CommandId::new(), session_id, TurnId::new(), input.into()) + .await + } + + pub async fn start_turn_with( + &self, + command_id: CommandId, + session_id: SessionId, + turn_id: TurnId, + input: String, + ) -> Result { + self.service + .start_turn(CommandEnvelope::new( + command_id, + session_id, + StartTurn { turn_id, input }, + )) + .await + } + + pub async fn cancel_turn( + &self, + session_id: SessionId, + turn_id: TurnId, + ) -> Result { + self.service + .cancel_turn(CommandEnvelope::new( + CommandId::new(), + session_id, + CancelTurn { turn_id }, + )) + .await + } + + pub async fn snapshot( + &self, + session_id: &SessionId, + ) -> Result { + self.service.snapshot(session_id).await + } + + pub async fn subscribe( + &self, + session_id: &SessionId, + after_seq: u64, + ) -> Result { + self.service.subscribe(session_id, after_seq).await + } + + pub async fn run_turn( + &self, + session_id: SessionId, + input: impl Into, + ) -> Result { + let receipt = self.start_turn(session_id.clone(), input).await?; + let turn_id = receipt + .turn_id + .clone() + .ok_or_else(|| ForgeProtocolError::Internal("start_turn returned no turn_id".into()))?; + let mut events = self.subscribe(&session_id, receipt.accepted_seq).await?; + loop { + let event = events.recv().await?; + if event.turn_id.as_ref() != Some(&turn_id) { + continue; + } + match event.kind { + ForgeEventKind::TurnCompleted { text } => return Ok(text), + ForgeEventKind::TurnFailed { message } => { + return Err(ForgeProtocolError::Agent(message)); + } + ForgeEventKind::TurnCancelled => { + return Err(ForgeProtocolError::Agent("turn cancelled".into())); + } + _ => {} + } + } + } +} diff --git a/crates/pulsing-forge/src/session/mod.rs b/crates/pulsing-forge/src/session/mod.rs new file mode 100644 index 000000000..21ad04ab4 --- /dev/null +++ b/crates/pulsing-forge/src/session/mod.rs @@ -0,0 +1,11 @@ +//! Persistent Forge sessions, event reduction, and local client. + +mod client; +mod reducer; +mod service; +mod store; + +pub use client::LocalForgeClient; +pub use reducer::{SessionSnapshot, SessionStatus, TurnSnapshot, TurnStatus}; +pub use service::{EventSubscription, ForgeService}; +pub use store::{EventStore, InMemoryEventStore}; diff --git a/crates/pulsing-forge/src/session/reducer.rs b/crates/pulsing-forge/src/session/reducer.rs new file mode 100644 index 000000000..b9b3de2d4 --- /dev/null +++ b/crates/pulsing-forge/src/session/reducer.rs @@ -0,0 +1,326 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use crate::protocol::{ + ForgeEvent, ForgeEventKind, ForgeProtocolError, SessionId, SessionSpec, TurnId, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SessionStatus { + Active, + Closed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Running, + Cancelling, + Unknown, + Completed, + Failed, + Cancelled, +} + +impl TurnStatus { + pub fn is_terminal(self) -> bool { + matches!(self, Self::Completed | Self::Failed | Self::Cancelled) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TurnSnapshot { + pub id: TurnId, + pub input: String, + pub status: TurnStatus, + pub output: Option, + pub error: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionSnapshot { + pub id: SessionId, + pub spec: SessionSpec, + pub status: SessionStatus, + pub last_seq: u64, + pub active_turn: Option, + pub turns: HashMap, +} + +impl SessionSnapshot { + pub(crate) fn uninitialized(id: SessionId, spec: SessionSpec) -> Self { + Self { + id, + spec, + status: SessionStatus::Active, + last_seq: 0, + active_turn: None, + turns: HashMap::new(), + } + } + + pub(crate) fn ensure_can_start(&self) -> Result<(), ForgeProtocolError> { + if self.status == SessionStatus::Closed { + return Err(ForgeProtocolError::SessionClosed(self.id.clone())); + } + if self.active_turn.is_some() { + return Err(ForgeProtocolError::SessionBusy(self.id.clone())); + } + Ok(()) + } + + pub(crate) fn apply(&mut self, event: &ForgeEvent) -> Result<(), ForgeProtocolError> { + if event.session_id != self.id { + return Err(ForgeProtocolError::InvalidTransition(format!( + "event belongs to {}, reducer belongs to {}", + event.session_id, self.id + ))); + } + let expected = self.last_seq + 1; + if event.seq != expected { + return Err(ForgeProtocolError::SequenceConflict { + expected, + actual: event.seq, + }); + } + + match &event.kind { + ForgeEventKind::SessionCreated => { + if self.last_seq != 0 { + return Err(ForgeProtocolError::InvalidTransition( + "session.created must be the first event".into(), + )); + } + } + ForgeEventKind::SessionClosed => { + if self.active_turn.is_some() { + return Err(ForgeProtocolError::InvalidTransition( + "cannot close a session with an active turn".into(), + )); + } + self.status = SessionStatus::Closed; + } + ForgeEventKind::TurnStarted { input } => { + self.ensure_can_start()?; + let turn_id = event.turn_id.clone().ok_or_else(|| { + ForgeProtocolError::InvalidTransition("turn.started missing turn_id".into()) + })?; + self.turns.insert( + turn_id.clone(), + TurnSnapshot { + id: turn_id.clone(), + input: input.clone(), + status: TurnStatus::Running, + output: None, + error: None, + }, + ); + self.active_turn = Some(turn_id); + } + ForgeEventKind::TurnCancelRequested => { + let turn = self.active_turn_mut(event)?; + if turn.status != TurnStatus::Running { + return Err(ForgeProtocolError::InvalidTransition(format!( + "cannot cancel turn in {:?}", + turn.status + ))); + } + turn.status = TurnStatus::Cancelling; + } + ForgeEventKind::TurnCancelled => { + let turn = self.active_turn_mut(event)?; + if !matches!( + turn.status, + TurnStatus::Running | TurnStatus::Cancelling | TurnStatus::Unknown + ) { + return Err(ForgeProtocolError::InvalidTransition(format!( + "cannot complete cancellation from {:?}", + turn.status + ))); + } + turn.status = TurnStatus::Cancelled; + self.active_turn = None; + } + ForgeEventKind::TurnCleanupStalled { resources } => { + let turn = self.active_turn_mut(event)?; + turn.status = TurnStatus::Unknown; + turn.error = Some(format!( + "waiting for turn resources to stop: {}", + resources.join(", ") + )); + } + ForgeEventKind::TurnCompleted { text } => { + let turn = self.active_turn_mut(event)?; + turn.status = TurnStatus::Completed; + turn.output = Some(text.clone()); + self.active_turn = None; + } + ForgeEventKind::TurnFailed { message } => { + let turn = self.active_turn_mut(event)?; + turn.status = TurnStatus::Failed; + turn.error = Some(message.clone()); + self.active_turn = None; + } + ForgeEventKind::TurnOutputDelta { .. } + | ForgeEventKind::ToolStarted { .. } + | ForgeEventKind::ToolCompleted { .. } + | ForgeEventKind::ToolCancelled { .. } => { + let turn = self.active_turn_ref(event)?; + if turn.status.is_terminal() { + return Err(ForgeProtocolError::InvalidTransition( + "activity after terminal turn".into(), + )); + } + } + } + + self.last_seq = event.seq; + Ok(()) + } + + fn event_turn_id<'a>( + &'a self, + event: &'a ForgeEvent, + ) -> Result<&'a TurnId, ForgeProtocolError> { + event.turn_id.as_ref().ok_or_else(|| { + ForgeProtocolError::InvalidTransition("turn event missing turn_id".into()) + }) + } + + fn active_turn_ref(&self, event: &ForgeEvent) -> Result<&TurnSnapshot, ForgeProtocolError> { + let turn_id = self.event_turn_id(event)?; + if self.active_turn.as_ref() != Some(turn_id) { + return Err(ForgeProtocolError::TurnNotActive(turn_id.clone())); + } + self.turns + .get(turn_id) + .ok_or_else(|| ForgeProtocolError::TurnNotActive(turn_id.clone())) + } + + fn active_turn_mut( + &mut self, + event: &ForgeEvent, + ) -> Result<&mut TurnSnapshot, ForgeProtocolError> { + let turn_id = event.turn_id.clone().ok_or_else(|| { + ForgeProtocolError::InvalidTransition("turn event missing turn_id".into()) + })?; + if self.active_turn.as_ref() != Some(&turn_id) { + return Err(ForgeProtocolError::TurnNotActive(turn_id)); + } + self.turns + .get_mut(&turn_id) + .ok_or(ForgeProtocolError::TurnNotActive(turn_id)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::AgentConfig; + use crate::protocol::{CommandId, ForgeEventKind}; + + fn state() -> SessionSnapshot { + let id = SessionId::new(); + let mut state = SessionSnapshot::uninitialized(id.clone(), AgentConfig::default().into()); + state + .apply(&ForgeEvent::new( + id, + 1, + None, + Some(CommandId::new()), + ForgeEventKind::SessionCreated, + )) + .unwrap(); + state + } + + fn event(state: &SessionSnapshot, turn: Option, kind: ForgeEventKind) -> ForgeEvent { + ForgeEvent::new( + state.id.clone(), + state.last_seq + 1, + turn, + Some(CommandId::new()), + kind, + ) + } + + #[test] + fn enforces_single_active_turn() { + let mut state = state(); + let turn = TurnId::new(); + state + .apply(&event( + &state, + Some(turn.clone()), + ForgeEventKind::TurnStarted { + input: "one".into(), + }, + )) + .unwrap(); + let second = event( + &state, + Some(TurnId::new()), + ForgeEventKind::TurnStarted { + input: "two".into(), + }, + ); + assert!(matches!( + state.apply(&second), + Err(ForgeProtocolError::SessionBusy(_)) + )); + } + + #[test] + fn cancellation_is_not_terminal_until_cancelled_event() { + let mut state = state(); + let turn = TurnId::new(); + state + .apply(&event( + &state, + Some(turn.clone()), + ForgeEventKind::TurnStarted { + input: "one".into(), + }, + )) + .unwrap(); + state + .apply(&event( + &state, + Some(turn.clone()), + ForgeEventKind::TurnCancelRequested, + )) + .unwrap(); + assert_eq!(state.active_turn, Some(turn.clone())); + assert_eq!(state.turns[&turn].status, TurnStatus::Cancelling); + + state + .apply(&event( + &state, + Some(turn.clone()), + ForgeEventKind::TurnCancelled, + )) + .unwrap(); + assert_eq!(state.active_turn, None); + assert_eq!(state.turns[&turn].status, TurnStatus::Cancelled); + } + + #[test] + fn rejects_event_sequence_gaps() { + let mut state = state(); + let event = ForgeEvent::new( + state.id.clone(), + state.last_seq + 2, + Some(TurnId::new()), + None, + ForgeEventKind::TurnStarted { + input: "gap".into(), + }, + ); + assert!(matches!( + state.apply(&event), + Err(ForgeProtocolError::SequenceConflict { .. }) + )); + } +} diff --git a/crates/pulsing-forge/src/session/service.rs b/crates/pulsing-forge/src/session/service.rs new file mode 100644 index 000000000..979a2e669 --- /dev/null +++ b/crates/pulsing-forge/src/session/service.rs @@ -0,0 +1,542 @@ +use std::collections::{HashMap, VecDeque}; +use std::sync::Arc; + +use crate::agent::{AgentCancelled, AgentEvent, ForgeAgent}; +use crate::protocol::{ + CancelTurn, CommandEnvelope, CommandId, CommandReceipt, CreateSession, ForgeEvent, + ForgeEventKind, ForgeProtocolError, SessionId, StartTurn, TurnId, +}; +use crate::turn::TurnExecutionContext; +use tokio::sync::{Mutex, RwLock, broadcast}; + +use super::reducer::SessionSnapshot; +use super::store::{EventStore, InMemoryEventStore}; + +const EVENT_CHANNEL_CAPACITY: usize = 256; +const TURN_RESOURCE_CLEANUP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + +#[derive(Clone)] +pub struct ForgeService { + inner: Arc, +} + +struct ServiceInner { + store: Arc, + sessions: RwLock>>, +} + +struct SessionRuntime { + agent: Mutex, + control: Mutex, + events: broadcast::Sender, +} + +struct ControlState { + snapshot: SessionSnapshot, + receipts: HashMap, + active_execution: Option<(TurnId, Arc)>, +} + +pub struct EventSubscription { + replay: VecDeque, + live: broadcast::Receiver, + last_seq: u64, +} + +impl EventSubscription { + pub async fn recv(&mut self) -> Result { + if let Some(event) = self.replay.pop_front() { + self.last_seq = self.last_seq.max(event.seq); + return Ok(event); + } + loop { + match self.live.recv().await { + Ok(event) if event.seq <= self.last_seq => continue, + Ok(event) => { + self.last_seq = event.seq; + return Ok(event); + } + Err(broadcast::error::RecvError::Lagged(count)) => { + return Err(ForgeProtocolError::SubscriptionLagged(count)); + } + Err(broadcast::error::RecvError::Closed) => { + return Err(ForgeProtocolError::SubscriptionClosed); + } + } + } + } +} + +impl Default for ForgeService { + fn default() -> Self { + Self::new() + } +} + +impl ForgeService { + pub fn new() -> Self { + Self::with_store(Arc::new(InMemoryEventStore::new())) + } + + pub fn with_store(store: Arc) -> Self { + Self { + inner: Arc::new(ServiceInner { + store, + sessions: RwLock::new(HashMap::new()), + }), + } + } + + pub async fn create_session( + &self, + command: CommandEnvelope, + ) -> Result { + validate_command(&command)?; + if let Some(existing) = self + .inner + .sessions + .read() + .await + .get(&command.session_id) + .cloned() + { + let control = existing.control.lock().await; + return control + .receipts + .get(&command.command_id) + .cloned() + .ok_or_else(|| { + ForgeProtocolError::SessionAlreadyExists(command.session_id.clone()) + }); + } + + let agent = ForgeAgent::try_new(command.payload.spec.clone().into()) + .map_err(|err| ForgeProtocolError::Agent(err.to_string()))?; + let (event_tx, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY); + let mut snapshot = + SessionSnapshot::uninitialized(command.session_id.clone(), command.payload.spec); + let event = ForgeEvent::new( + command.session_id.clone(), + 1, + None, + Some(command.command_id.clone()), + ForgeEventKind::SessionCreated, + ); + self.inner.store.append(event.clone())?; + snapshot.apply(&event)?; + let receipt = CommandReceipt { + command_id: command.command_id.clone(), + session_id: command.session_id.clone(), + turn_id: None, + accepted_seq: event.seq, + }; + let runtime = Arc::new(SessionRuntime { + agent: Mutex::new(agent), + control: Mutex::new(ControlState { + snapshot, + receipts: HashMap::from([(command.command_id, receipt.clone())]), + active_execution: None, + }), + events: event_tx, + }); + + let mut sessions = self.inner.sessions.write().await; + if sessions.contains_key(&command.session_id) { + return Err(ForgeProtocolError::SessionAlreadyExists(command.session_id)); + } + sessions.insert(receipt.session_id.clone(), runtime); + Ok(receipt) + } + + pub async fn start_turn( + &self, + command: CommandEnvelope, + ) -> Result { + validate_command(&command)?; + let runtime = self.session(&command.session_id).await?; + let execution = Arc::new(TurnExecutionContext::new( + command.session_id.clone(), + command.payload.turn_id.clone(), + )); + let event; + let receipt; + { + let mut control = runtime.control.lock().await; + if let Some(existing) = control.receipts.get(&command.command_id) { + return Ok(existing.clone()); + } + check_expected_seq(command.expected_seq, control.snapshot.last_seq)?; + control.snapshot.ensure_can_start()?; + + event = ForgeEvent::new( + command.session_id.clone(), + control.snapshot.last_seq + 1, + Some(command.payload.turn_id.clone()), + Some(command.command_id.clone()), + ForgeEventKind::TurnStarted { + input: command.payload.input.clone(), + }, + ); + self.inner.store.append(event.clone())?; + control.snapshot.apply(&event)?; + control.active_execution = Some((command.payload.turn_id.clone(), execution.clone())); + receipt = CommandReceipt { + command_id: command.command_id.clone(), + session_id: command.session_id.clone(), + turn_id: Some(command.payload.turn_id.clone()), + accepted_seq: event.seq, + }; + control + .receipts + .insert(command.command_id.clone(), receipt.clone()); + } + let _ = runtime.events.send(event); + + let service = self.clone(); + let session_id = command.session_id; + let turn_id = command.payload.turn_id; + let input = command.payload.input; + tokio::spawn(async move { + service + .run_turn_task(runtime, session_id, turn_id, input, execution) + .await; + }); + Ok(receipt) + } + + pub async fn cancel_turn( + &self, + command: CommandEnvelope, + ) -> Result { + validate_command(&command)?; + let runtime = self.session(&command.session_id).await?; + let event; + let receipt; + let execution; + { + let mut control = runtime.control.lock().await; + if let Some(existing) = control.receipts.get(&command.command_id) { + return Ok(existing.clone()); + } + check_expected_seq(command.expected_seq, control.snapshot.last_seq)?; + let Some((active_turn, active_execution)) = control.active_execution.as_ref() else { + return Err(ForgeProtocolError::TurnNotActive(command.payload.turn_id)); + }; + if active_turn != &command.payload.turn_id { + return Err(ForgeProtocolError::TurnNotActive(command.payload.turn_id)); + } + execution = active_execution.clone(); + event = ForgeEvent::new( + command.session_id.clone(), + control.snapshot.last_seq + 1, + Some(command.payload.turn_id.clone()), + Some(command.command_id.clone()), + ForgeEventKind::TurnCancelRequested, + ); + self.inner.store.append(event.clone())?; + control.snapshot.apply(&event)?; + receipt = CommandReceipt { + command_id: command.command_id.clone(), + session_id: command.session_id.clone(), + turn_id: Some(command.payload.turn_id.clone()), + accepted_seq: event.seq, + }; + control.receipts.insert(command.command_id, receipt.clone()); + } + let _ = runtime.events.send(event); + execution.cancel(); + Ok(receipt) + } + + pub async fn snapshot( + &self, + session_id: &SessionId, + ) -> Result { + let runtime = self.session(session_id).await?; + let snapshot = runtime.control.lock().await.snapshot.clone(); + Ok(snapshot) + } + + pub async fn subscribe( + &self, + session_id: &SessionId, + after_seq: u64, + ) -> Result { + let runtime = self.session(session_id).await?; + // Subscribe before loading history so events emitted during the load are + // either present in replay or in the live channel. Consumers deduplicate. + let live = runtime.events.subscribe(); + let replay = self.inner.store.load(session_id, after_seq)?.into(); + Ok(EventSubscription { + replay, + live, + last_seq: after_seq, + }) + } + + async fn session( + &self, + session_id: &SessionId, + ) -> Result, ForgeProtocolError> { + self.inner + .sessions + .read() + .await + .get(session_id) + .cloned() + .ok_or_else(|| ForgeProtocolError::SessionNotFound(session_id.clone())) + } + + async fn run_turn_task( + &self, + runtime: Arc, + session_id: SessionId, + turn_id: TurnId, + input: String, + execution: Arc, + ) { + let observer_service = self.clone(); + let observer_runtime = runtime.clone(); + let observer_turn = turn_id.clone(); + let observer = Arc::new(move |agent_event: AgentEvent| { + let service = observer_service.clone(); + let runtime = observer_runtime.clone(); + let turn = observer_turn.clone(); + Box::pin(async move { + let kind = match agent_event { + AgentEvent::TextDelta(delta) => Some(ForgeEventKind::TurnOutputDelta { delta }), + AgentEvent::ToolStart { name } => Some(ForgeEventKind::ToolStarted { name }), + AgentEvent::ToolEnd { name, ok, summary } => { + Some(ForgeEventKind::ToolCompleted { name, ok, summary }) + } + AgentEvent::ToolCancelled { name } => { + Some(ForgeEventKind::ToolCancelled { name }) + } + AgentEvent::Error(_) | AgentEvent::Done { .. } | AgentEvent::Cancelled => None, + }; + if let Some(kind) = kind { + let _ = service.record_event(&runtime, Some(turn), None, kind).await; + } + }) as std::pin::Pin + Send>> + }); + + let result = { + let mut agent = runtime.agent.lock().await; + agent.set_event_handler(Some(observer)); + let result = agent.run_in_turn(&input, execution.clone()).await; + agent.set_event_handler(None); + result + }; + + if !execution + .cleanup_and_wait(TURN_RESOURCE_CLEANUP_TIMEOUT) + .await + { + let _ = self + .record_event( + &runtime, + Some(turn_id.clone()), + None, + ForgeEventKind::TurnCleanupStalled { + resources: execution.resources().active_kinds(), + }, + ) + .await; + execution.resources().wait_for_idle_unbounded().await; + } + + let kind = match result { + _ if execution.is_cancelled() => ForgeEventKind::TurnCancelled, + Ok(text) => ForgeEventKind::TurnCompleted { text }, + Err(err) if err.downcast_ref::().is_some() => { + ForgeEventKind::TurnCancelled + } + Err(err) => ForgeEventKind::TurnFailed { + message: err.to_string(), + }, + }; + let _ = self + .record_terminal_event(&runtime, &session_id, turn_id, kind) + .await; + } + + async fn record_event( + &self, + runtime: &SessionRuntime, + turn_id: Option, + causation_id: Option, + kind: ForgeEventKind, + ) -> Result { + let event; + { + let mut control = runtime.control.lock().await; + event = ForgeEvent::new( + control.snapshot.id.clone(), + control.snapshot.last_seq + 1, + turn_id, + causation_id, + kind, + ); + self.inner.store.append(event.clone())?; + control.snapshot.apply(&event)?; + } + let _ = runtime.events.send(event.clone()); + Ok(event) + } + + async fn record_terminal_event( + &self, + runtime: &SessionRuntime, + session_id: &SessionId, + turn_id: TurnId, + kind: ForgeEventKind, + ) -> Result { + let event; + { + let mut control = runtime.control.lock().await; + event = ForgeEvent::new( + session_id.clone(), + control.snapshot.last_seq + 1, + Some(turn_id.clone()), + None, + kind, + ); + self.inner.store.append(event.clone())?; + control.snapshot.apply(&event)?; + if control + .active_execution + .as_ref() + .is_some_and(|(active, _)| active == &turn_id) + { + control.active_execution = None; + } + } + let _ = runtime.events.send(event.clone()); + Ok(event) + } +} + +fn check_expected_seq(expected: Option, actual: u64) -> Result<(), ForgeProtocolError> { + if let Some(expected) = expected + && expected != actual + { + return Err(ForgeProtocolError::SequenceConflict { expected, actual }); + } + Ok(()) +} + +fn validate_command(command: &CommandEnvelope) -> Result<(), ForgeProtocolError> { + if command.protocol != "forge.session" { + return Err(ForgeProtocolError::InvalidProtocol { + expected: "forge.session", + actual: command.protocol.clone(), + }); + } + if command.version.major != 1 { + return Err(ForgeProtocolError::UnsupportedVersion { + protocol: command.protocol.clone(), + major: command.version.major, + }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::AgentConfig; + use crate::protocol::{CreateSession, SessionSpec}; + + async fn create(service: &ForgeService) -> (SessionId, CommandReceipt) { + let session_id = SessionId::new(); + let command = CommandEnvelope::new( + CommandId::new(), + session_id.clone(), + CreateSession { + spec: SessionSpec::from(AgentConfig { + provider: "demo".into(), + model: "demo".into(), + ..AgentConfig::default() + }), + }, + ); + let receipt = service.create_session(command).await.unwrap(); + (session_id, receipt) + } + + #[tokio::test] + async fn duplicate_start_command_is_idempotent() { + let service = ForgeService::new(); + let (session, _) = create(&service).await; + let command_id = CommandId::new(); + let command = CommandEnvelope::new( + command_id, + session.clone(), + StartTurn { + turn_id: TurnId::new(), + input: "hello".into(), + }, + ); + let first = service.start_turn(command.clone()).await.unwrap(); + let second = service.start_turn(command).await.unwrap(); + assert_eq!(first, second); + } + + #[tokio::test] + async fn rejects_unsupported_command_major_version() { + let service = ForgeService::new(); + let session_id = SessionId::new(); + let mut command = CommandEnvelope::new( + CommandId::new(), + session_id, + CreateSession { + spec: AgentConfig::default().into(), + }, + ); + command.version.major = 2; + assert!(matches!( + service.create_session(command).await, + Err(ForgeProtocolError::UnsupportedVersion { major: 2, .. }) + )); + } + + #[tokio::test] + async fn subscription_replays_from_cursor() { + let service = ForgeService::new(); + let (session, created) = create(&service).await; + let events = service.inner.store.load(&session, 0).unwrap(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].seq, created.accepted_seq); + + let mut subscription = service.subscribe(&session, 0).await.unwrap(); + let replay = subscription.recv().await.unwrap(); + assert!(matches!(replay.kind, ForgeEventKind::SessionCreated)); + } + + #[tokio::test] + async fn local_client_runs_multiple_turns_in_one_session() { + let client = super::super::LocalForgeClient::default(); + let session = client + .create_session(AgentConfig { + provider: "demo".into(), + model: "demo".into(), + ..AgentConfig::default() + }) + .await + .unwrap(); + let first = client + .run_turn(session.clone(), "remember alpha") + .await + .unwrap(); + let second = client + .run_turn(session.clone(), "remember beta") + .await + .unwrap(); + assert!(first.contains("alpha")); + assert!(second.contains("beta")); + + let snapshot = client.snapshot(&session).await.unwrap(); + assert_eq!(snapshot.turns.len(), 2); + assert!(snapshot.active_turn.is_none()); + } +} diff --git a/crates/pulsing-forge/src/session/store.rs b/crates/pulsing-forge/src/session/store.rs new file mode 100644 index 000000000..3e9c50d8b --- /dev/null +++ b/crates/pulsing-forge/src/session/store.rs @@ -0,0 +1,96 @@ +use std::collections::HashMap; +use std::sync::Mutex; + +use crate::protocol::{ForgeEvent, ForgeProtocolError, SessionId}; + +pub trait EventStore: Send + Sync { + fn append(&self, event: ForgeEvent) -> Result<(), ForgeProtocolError>; + fn load( + &self, + session_id: &SessionId, + after_seq: u64, + ) -> Result, ForgeProtocolError>; +} + +#[derive(Default)] +pub struct InMemoryEventStore { + events: Mutex>>, +} + +impl InMemoryEventStore { + pub fn new() -> Self { + Self::default() + } +} + +impl EventStore for InMemoryEventStore { + fn append(&self, event: ForgeEvent) -> Result<(), ForgeProtocolError> { + let mut all = self + .events + .lock() + .map_err(|_| ForgeProtocolError::Internal("event store lock poisoned".into()))?; + let events = all.entry(event.session_id.clone()).or_default(); + let expected = events.last().map_or(1, |last| last.seq + 1); + if event.seq != expected { + return Err(ForgeProtocolError::SequenceConflict { + expected, + actual: event.seq, + }); + } + events.push(event); + Ok(()) + } + + fn load( + &self, + session_id: &SessionId, + after_seq: u64, + ) -> Result, ForgeProtocolError> { + let all = self + .events + .lock() + .map_err(|_| ForgeProtocolError::Internal("event store lock poisoned".into()))?; + Ok(all + .get(session_id) + .into_iter() + .flatten() + .filter(|event| event.seq > after_seq) + .cloned() + .collect()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::{ForgeEventKind, SessionId}; + + #[test] + fn append_requires_contiguous_sequence() { + let store = InMemoryEventStore::new(); + let session = SessionId::new(); + store + .append(ForgeEvent::new( + session.clone(), + 1, + None, + None, + ForgeEventKind::SessionCreated, + )) + .unwrap(); + let result = store.append(ForgeEvent::new( + session, + 3, + None, + None, + ForgeEventKind::SessionClosed, + )); + assert!(matches!( + result, + Err(ForgeProtocolError::SequenceConflict { + expected: 2, + actual: 3 + }) + )); + } +} diff --git a/crates/pulsing-forge/src/turn.rs b/crates/pulsing-forge/src/turn.rs new file mode 100644 index 000000000..cb3bf61d4 --- /dev/null +++ b/crates/pulsing-forge/src/turn.rs @@ -0,0 +1,228 @@ +//! Turn-scoped cancellation and resource ownership. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, Weak}; +use std::time::{Duration, Instant}; + +use tokio::sync::Notify; +use tokio_util::sync::CancellationToken; + +use crate::protocol::{SessionId, TurnId}; + +type CancelResource = Arc; + +struct ResourceEntry { + kind: String, + cancel: CancelResource, +} + +/// Registry of external resources owned by one Forge turn. +/// +/// A resource stays registered until its guard is dropped. Cancellation first +/// invokes every registered resource's non-blocking canceller; terminal turn +/// events may then wait until all guards have been released. +pub struct TurnResourceRegistry { + next_id: AtomicU64, + entries: Mutex>, + idle: Notify, +} + +impl Default for TurnResourceRegistry { + fn default() -> Self { + Self { + next_id: AtomicU64::new(1), + entries: Mutex::new(HashMap::new()), + idle: Notify::new(), + } + } +} + +impl TurnResourceRegistry { + pub fn register( + self: &Arc, + kind: impl Into, + cancel: impl Fn() + Send + Sync + 'static, + ) -> TurnResourceGuard { + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + self.entries.lock().expect("turn resources").insert( + id, + ResourceEntry { + kind: kind.into(), + cancel: Arc::new(cancel), + }, + ); + TurnResourceGuard { + registry: Arc::downgrade(self), + id, + } + } + + pub fn register_passive(self: &Arc, kind: impl Into) -> TurnResourceGuard { + self.register(kind, || {}) + } + + pub fn cancel_all(&self) { + let cancellers = self + .entries + .lock() + .expect("turn resources") + .values() + .map(|entry| entry.cancel.clone()) + .collect::>(); + for cancel in cancellers { + cancel(); + } + } + + pub fn active_count(&self) -> usize { + self.entries.lock().expect("turn resources").len() + } + + pub fn active_kinds(&self) -> Vec { + self.entries + .lock() + .expect("turn resources") + .values() + .map(|entry| entry.kind.clone()) + .collect() + } + + pub async fn wait_for_idle(&self, timeout: Duration) -> bool { + let deadline = Instant::now() + timeout; + loop { + let notified = self.idle.notified(); + if self.active_count() == 0 { + return true; + } + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + return false; + }; + if tokio::time::timeout(remaining, notified).await.is_err() { + return self.active_count() == 0; + } + } + } + + pub async fn wait_for_idle_unbounded(&self) { + loop { + let notified = self.idle.notified(); + if self.active_count() == 0 { + return; + } + notified.await; + } + } + + fn release(&self, id: u64) { + if self + .entries + .lock() + .expect("turn resources") + .remove(&id) + .is_some() + { + self.idle.notify_waiters(); + } + } +} + +/// RAII ownership token for one external turn resource. +pub struct TurnResourceGuard { + registry: Weak, + id: u64, +} + +impl Drop for TurnResourceGuard { + fn drop(&mut self) { + if let Some(registry) = self.registry.upgrade() { + registry.release(self.id); + } + } +} + +/// Execution identity, cancellation signal, and resource registry for one turn. +pub struct TurnExecutionContext { + pub session_id: SessionId, + pub turn_id: TurnId, + cancellation: CancellationToken, + resources: Arc, +} + +impl TurnExecutionContext { + pub fn new(session_id: SessionId, turn_id: TurnId) -> Self { + Self::with_cancellation(session_id, turn_id, CancellationToken::new()) + } + + pub fn with_cancellation( + session_id: SessionId, + turn_id: TurnId, + cancellation: CancellationToken, + ) -> Self { + Self { + session_id, + turn_id, + cancellation, + resources: Arc::new(TurnResourceRegistry::default()), + } + } + + pub fn cancellation(&self) -> CancellationToken { + self.cancellation.clone() + } + + pub fn is_cancelled(&self) -> bool { + self.cancellation.is_cancelled() + } + + pub fn resources(&self) -> &Arc { + &self.resources + } + + pub fn cancel(&self) { + self.cancellation.cancel(); + self.resources.cancel_all(); + } + + pub async fn cancel_and_wait(&self, timeout: Duration) -> bool { + self.cancel(); + self.resources.wait_for_idle(timeout).await + } + + pub async fn cleanup_and_wait(&self, timeout: Duration) -> bool { + self.resources.cancel_all(); + self.resources.wait_for_idle(timeout).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::AtomicBool; + + #[tokio::test] + async fn cancellation_invokes_resources_and_waits_for_release() { + let turn = TurnExecutionContext::new(SessionId::new(), TurnId::new()); + let cancelled = Arc::new(AtomicBool::new(false)); + let flag = cancelled.clone(); + let guard = turn + .resources() + .register("test", move || flag.store(true, Ordering::SeqCst)); + + turn.cancel(); + assert!(cancelled.load(Ordering::SeqCst)); + assert_eq!(turn.resources().active_kinds(), vec!["test"]); + assert!( + !turn + .resources() + .wait_for_idle(Duration::from_millis(10)) + .await + ); + drop(guard); + assert!( + turn.resources() + .wait_for_idle(Duration::from_millis(10)) + .await + ); + } +} diff --git a/crates/pulsing-forge/src/unified_exec.rs b/crates/pulsing-forge/src/unified_exec.rs index 3cb781f1d..af45c722a 100644 --- a/crates/pulsing-forge/src/unified_exec.rs +++ b/crates/pulsing-forge/src/unified_exec.rs @@ -22,9 +22,11 @@ use crate::exec_output::{ OutputBuffer, SHELL_MAX_BYTES, Utf8ChunkDecoder, clamp_yield_ms, }; use crate::handlers::shell_exec::resolve_shell_workdir; +use crate::process_group; use crate::pty_session::PtyExecHandle; use crate::result::ToolResult; use crate::sandbox::{SandboxPolicy, build_bash_exec}; +use crate::turn::TurnResourceGuard; pub struct UnifiedExecManager { next_id: AtomicI32, @@ -35,6 +37,7 @@ enum SessionHandle { Pipe { child: Child, stdin: Option, + process_id: Option, }, Pty(PtyExecHandle), } @@ -45,12 +48,19 @@ struct ExecSession { _reader: Option>, started: Instant, tty: bool, + owner_turn: Option, + _turn_resource: Option, } impl Drop for ExecSession { fn drop(&mut self) { match &mut self.handle { - SessionHandle::Pipe { child, .. } => { + SessionHandle::Pipe { + child, process_id, .. + } => { + if let Some(process_id) = process_id { + process_group::kill(*process_id); + } if matches!(child.try_wait(), Ok(None)) { let _ = child.start_kill(); } @@ -99,7 +109,7 @@ impl UnifiedExecManager { } pub async fn exec_command( - &self, + self: &Arc, ctx: &ToolCallContext, args: &Value, ) -> Result { @@ -123,6 +133,21 @@ impl UnifiedExecManager { let tty = args.get("tty").and_then(|v| v.as_bool()).unwrap_or(true); let session_id = self.next_id.fetch_add(1, Ordering::SeqCst); + let turn_resource = ctx.turn.as_ref().map(|turn| { + let weak_manager = Arc::downgrade(self); + turn.resources() + .register(format!("exec:{session_id}"), move || { + let Some(manager) = weak_manager.upgrade() else { + return; + }; + let Ok(runtime) = tokio::runtime::Handle::try_current() else { + return; + }; + runtime.spawn(async move { + manager.stop_session_and_wait(session_id).await; + }); + }) + }); let buffer = Arc::new(Mutex::new(OutputBuffer::new(SHELL_MAX_BYTES))); let on_delta = stream_hook(ctx, session_id); @@ -150,7 +175,15 @@ impl UnifiedExecManager { on_delta, ) .await?; - (SessionHandle::Pipe { child, stdin }, Some(reader)) + let process_id = child.id(); + ( + SessionHandle::Pipe { + child, + stdin, + process_id, + }, + Some(reader), + ) }; self.sessions.lock().await.insert( @@ -161,10 +194,23 @@ impl UnifiedExecManager { _reader: reader, started: Instant::now(), tty, + owner_turn: ctx.turn.as_ref().map(|turn| turn.turn_id.clone()), + _turn_resource: turn_resource, }, ); - tokio::time::sleep(std::time::Duration::from_millis(yield_ms)).await; + if let Some(turn) = &ctx.turn { + let cancellation = turn.cancellation(); + tokio::select! { + _ = cancellation.cancelled() => { + self.stop_session_and_wait(session_id).await; + return Ok(ToolResult::err("exec session cancelled")); + } + _ = tokio::time::sleep(std::time::Duration::from_millis(yield_ms)) => {} + } + } else { + tokio::time::sleep(std::time::Duration::from_millis(yield_ms)).await; + } self.poll_session(session_id, max_tokens, ctx).await } @@ -197,6 +243,13 @@ impl UnifiedExecManager { let session = sessions .get_mut(&session_id) .ok_or_else(|| ToolError::respond(format!("unknown session_id {session_id}")))?; + if let Some(owner) = &session.owner_turn + && ctx.turn.as_ref().map(|turn| &turn.turn_id) != Some(owner) + { + return Ok(ToolResult::err(format!( + "session {session_id} belongs to another turn" + ))); + } if chars != "\x03" && session_has_exited(&mut session.handle) { return Ok(ToolResult::err(format!( @@ -206,7 +259,12 @@ impl UnifiedExecManager { if chars == "\x03" { match &mut session.handle { - SessionHandle::Pipe { child, .. } => { + SessionHandle::Pipe { + child, process_id, .. + } => { + if let Some(process_id) = process_id { + process_group::kill(*process_id); + } let _ = child.start_kill(); } SessionHandle::Pty(pty) => pty.kill()?, @@ -254,14 +312,25 @@ impl UnifiedExecManager { /// Kill all background exec sessions (REPL `/stop` or `/clean`). pub async fn stop_all(&self) -> usize { let mut sessions = self.sessions.lock().await; - let ids: Vec = sessions.keys().copied().collect(); - let count = ids.len(); - for id in ids { - let _ = sessions.remove(&id); + let removed = sessions + .drain() + .map(|(_, session)| session) + .collect::>(); + let count = removed.len(); + drop(sessions); + for session in removed { + session.terminate().await; } count } + async fn stop_session_and_wait(&self, session_id: i32) { + let session = self.sessions.lock().await.remove(&session_id); + if let Some(session) = session { + session.terminate().await; + } + } + async fn poll_session( &self, session_id: i32, @@ -323,6 +392,34 @@ impl UnifiedExecManager { } } +impl ExecSession { + async fn terminate(mut self) { + match &mut self.handle { + SessionHandle::Pipe { + child, process_id, .. + } => { + if let Some(process_id) = process_id.take() { + process_group::kill(process_id); + } + if matches!(child.try_wait(), Ok(None)) { + let _ = child.start_kill(); + } + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), child.wait()).await; + } + SessionHandle::Pty(pty) => { + let _ = pty.kill(); + let deadline = Instant::now() + std::time::Duration::from_secs(2); + while pty.poll_exit_code().is_none() && Instant::now() < deadline { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + } + } + if let Some(reader) = self._reader.take() { + let _ = tokio::time::timeout(std::time::Duration::from_secs(1), reader).await; + } + } +} + fn stream_hook( ctx: &ToolCallContext, _session_id: i32, @@ -382,6 +479,8 @@ async fn spawn_pipe_session( let plan = build_bash_exec(command, Some(workdir), policy, dangerous, login); let mut cmd = Command::new(&plan.argv[0]); cmd.args(&plan.argv[1..]); + process_group::configure(&mut cmd); + cmd.kill_on_drop(true); cmd.current_dir(workdir) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) @@ -577,6 +676,58 @@ mod tests { assert!(output.contains("pty_ok") || structured.get("session_id").is_some()); } + async fn assert_turn_cancel_stops_process_tree(tty: bool) { + let dir = tempfile::tempdir().unwrap(); + let marker = dir.path().join("should-not-exist"); + let exec = Arc::new(UnifiedExecManager::new()); + let session = + Arc::new(LocalToolSession::default().with_approval_policy(ApprovalPolicy::Always)); + let turn = Arc::new(crate::turn::TurnExecutionContext::new( + crate::SessionId::new(), + crate::TurnId::new(), + )); + let ctx = ToolCallContext::new( + dir.path(), + "off", + session, + exec.clone(), + new_exec_policy(), + Arc::new(ApprovalCache::default()), + new_tool_catalog(), + ) + .with_turn(turn.clone()); + let args = serde_json::json!({ + "cmd": "(sleep 0.3; touch should-not-exist) & wait", + "yield_time_ms": 5_000, + "tty": tty + }); + + let cancel = async { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + turn.cancel(); + }; + let (result, ()) = tokio::join!(exec.exec_command(&ctx, &args), cancel); + assert!(result.unwrap().is_error); + assert!( + turn.resources() + .wait_for_idle(std::time::Duration::from_secs(3)) + .await + ); + assert!(exec.list_sessions().await.is_empty()); + tokio::time::sleep(std::time::Duration::from_millis(400)).await; + assert!(!marker.exists(), "cancelled process tree produced a file"); + } + + #[tokio::test] + async fn cancellation_stops_pipe_session_process_tree() { + assert_turn_cancel_stops_process_tree(false).await; + } + + #[tokio::test] + async fn cancellation_stops_pty_session_process_tree() { + assert_turn_cancel_stops_process_tree(true).await; + } + #[tokio::test] async fn write_stdin_unknown_session_returns_clear_error_not_panic() { let exec = Arc::new(UnifiedExecManager::new()); diff --git a/crates/pulsing-gui/src/app/mod.rs b/crates/pulsing-gui/src/app/mod.rs index 9680761db..b4adc9ee9 100644 --- a/crates/pulsing-gui/src/app/mod.rs +++ b/crates/pulsing-gui/src/app/mod.rs @@ -3,15 +3,15 @@ mod left; mod right; use std::path::{Path, PathBuf}; -use std::sync::mpsc; use std::time::{Duration, Instant}; use eframe::egui; -use pulsing_forge::{AgentEvent, InteractiveConfig}; +use pulsing_forge::InteractiveConfig; -use crate::controller::start_agent_turn; +use crate::controller::ForgeController; use crate::model::{ - build_file_tree, count_files, FileTreeNode, SessionStore, WorkspaceAction, WorkspaceModel, + build_file_tree, count_files, FileTreeNode, SessionId, SessionStore, WorkspaceAction, + WorkspaceModel, }; use crate::settings::{build_agent_config, ChatMode}; use crate::state::{ChatMessage, MessageKind}; @@ -50,17 +50,19 @@ pub struct WorkspaceApp { last_file_click: Option<(String, Instant)>, input_text: String, - event_rx: Option>, + forge: ForgeController, toast: Option<(String, Instant)>, } impl WorkspaceApp { fn new(agent: InteractiveConfig) -> Self { let workspace = WorkspaceModel::new(agent.cwd.clone()); + let sessions = + SessionStore::new(agent.provider.clone(), agent.model.clone(), ChatMode::Agent); let mut app = Self { agent, workspace, - sessions: SessionStore::new(), + sessions, chat_mode: ChatMode::Agent, left_tab: LeftTab::Explorer, center_tab: CenterTab::Chat, @@ -68,7 +70,7 @@ impl WorkspaceApp { open_files: Vec::new(), last_file_click: None, input_text: String::new(), - event_rx: None, + forge: ForgeController::start(), toast: None, }; app.rebuild_tree(); @@ -88,6 +90,10 @@ impl WorkspaceApp { WorkspaceAction::NewSession => self.new_session(), WorkspaceAction::FocusSession(id) => { self.sessions.focus(id); + let (provider, model, mode) = self.sessions.active_settings(); + self.agent.provider = provider.to_string(); + self.agent.model = model.to_string(); + self.chat_mode = mode; self.center_tab = CenterTab::Chat; self.toast = Some(( format!("Session: {}", self.sessions.session_title(id)), @@ -107,10 +113,11 @@ impl WorkspaceApp { } fn new_session(&mut self) { - if self.sessions.active_chat().busy { - return; - } - self.sessions.new_session(); + self.sessions.new_session( + self.agent.provider.clone(), + self.agent.model.clone(), + self.chat_mode, + ); self.center_tab = CenterTab::Chat; self.toast = Some(("New chat started".into(), Instant::now())); self.sync_runtime(); @@ -153,20 +160,19 @@ impl WorkspaceApp { } fn poll_agent_events(&mut self) { - let mut events = Vec::new(); - if let Some(rx) = self.event_rx.as_ref() { - while let Ok(event) = rx.try_recv() { - events.push(event); + let mut changed = false; + while let Some(event) = self.forge.try_recv() { + let session_id = SessionId(event.gui_session_id); + if let Some(chat) = self.sessions.chat_mut(session_id) { + chat.apply(event.event); + changed = true; + } else if event.gui_session_id == 0 { + self.toast = Some(("Forge controller failed".into(), Instant::now())); } } - if events.is_empty() { - return; + if changed { + self.sync_runtime(); } - let chat = self.sessions.active_chat_mut(); - for event in events { - chat.apply(event); - } - self.sync_runtime(); } fn try_send(&mut self) { @@ -189,7 +195,9 @@ impl WorkspaceApp { chat.busy = true; self.sync_runtime(); - let handle = start_agent_turn( + let session_id = self.sessions.active_id(); + self.forge.start_turn( + session_id.0, build_agent_config( &self.agent.cwd, &self.agent.provider, @@ -198,31 +206,63 @@ impl WorkspaceApp { ), text, ); - self.event_rx = Some(handle.rx); } fn stop_generation(&mut self) { if !self.sessions.active_chat().busy { return; } - self.event_rx = None; - self.sessions.active_chat_mut().stop(); - self.sync_runtime(); + let session_id = self.sessions.active_id(); + self.forge.cancel_turn(session_id.0); + self.toast = Some(("Cancellation requested".into(), Instant::now())); } fn set_model(&mut self, provider: &str, model: &str) { if self.sessions.active_chat().busy { return; } + if !self.sessions.active_chat().messages.is_empty() { + self.agent.provider = provider.into(); + self.agent.model = model.into(); + self.sessions.new_session( + self.agent.provider.clone(), + self.agent.model.clone(), + self.chat_mode, + ); + self.toast = Some(("Model change started a new chat".into(), Instant::now())); + self.sync_runtime(); + return; + } self.agent.provider = provider.into(); self.agent.model = model.into(); + self.sessions.set_active_settings( + self.agent.provider.clone(), + self.agent.model.clone(), + self.chat_mode, + ); } fn set_chat_mode(&mut self, mode: ChatMode) { if self.sessions.active_chat().busy { return; } + if !self.sessions.active_chat().messages.is_empty() { + self.chat_mode = mode; + self.sessions.new_session( + self.agent.provider.clone(), + self.agent.model.clone(), + self.chat_mode, + ); + self.toast = Some(("Mode change started a new chat".into(), Instant::now())); + self.sync_runtime(); + return; + } self.chat_mode = mode; + self.sessions.set_active_settings( + self.agent.provider.clone(), + self.agent.model.clone(), + self.chat_mode, + ); } fn on_file_click(&mut self, id: &str, is_dir: bool) { @@ -261,7 +301,7 @@ pub fn run(agent: InteractiveConfig) -> anyhow::Result<()> { impl eframe::App for WorkspaceApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.poll_agent_events(); - if self.event_rx.is_some() { + if self.sessions.any_busy() { ctx.request_repaint_after(Duration::from_millis(50)); } diff --git a/crates/pulsing-gui/src/app/right.rs b/crates/pulsing-gui/src/app/right.rs index d564267e4..73d0c3452 100644 --- a/crates/pulsing-gui/src/app/right.rs +++ b/crates/pulsing-gui/src/app/right.rs @@ -39,10 +39,7 @@ pub fn render(app: &mut WorkspaceApp, ui: &mut egui::Ui) { } ui.add_space(8.0); - if ui - .add_enabled(!busy, egui::Button::new("+ New chat")) - .clicked() - { + if ui.button("+ New chat").clicked() { app.dispatch_action(WorkspaceAction::NewSession); } diff --git a/crates/pulsing-gui/src/controller/chat_turn.rs b/crates/pulsing-gui/src/controller/chat_turn.rs index 19b36c7b6..99524c424 100644 --- a/crates/pulsing-gui/src/controller/chat_turn.rs +++ b/crates/pulsing-gui/src/controller/chat_turn.rs @@ -1,28 +1,288 @@ -use std::sync::mpsc; +use std::collections::HashMap; +use std::sync::{mpsc, Arc}; use std::thread; -use pulsing_forge::{run_agent_turn_observed, AgentConfig, AgentEvent}; +use pulsing_forge::protocol::{ForgeEventKind, SessionId, TurnId}; +use pulsing_forge::{AgentConfig, AgentEvent, LocalForgeClient}; +use tokio::sync::{mpsc as tokio_mpsc, Mutex}; -pub struct ChatTurnHandle { - pub rx: mpsc::Receiver, +enum ControllerCommand { + StartTurn { + gui_session_id: u64, + config: AgentConfig, + prompt: String, + }, + CancelTurn { + gui_session_id: u64, + }, } -pub fn start_agent_turn(cfg: AgentConfig, prompt: String) -> ChatTurnHandle { - let (tx, rx) = mpsc::channel(); - thread::spawn(move || { - let rt = match tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - { - Ok(rt) => rt, +#[derive(Debug)] +pub struct ControllerEvent { + pub gui_session_id: u64, + pub event: AgentEvent, +} + +#[derive(Clone)] +struct SessionBinding { + forge_session_id: SessionId, + active_turn: Option, +} + +pub struct ForgeController { + commands: tokio_mpsc::UnboundedSender, + events: mpsc::Receiver, +} + +impl ForgeController { + pub fn start() -> Self { + let (command_tx, command_rx) = tokio_mpsc::unbounded_channel(); + let (event_tx, event_rx) = mpsc::channel(); + thread::spawn(move || { + let runtime = match tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + { + Ok(runtime) => runtime, + Err(err) => { + let _ = event_tx.send(ControllerEvent { + gui_session_id: 0, + event: AgentEvent::Error(err.to_string()), + }); + return; + } + }; + runtime.block_on(run_controller(command_rx, event_tx)); + }); + Self { + commands: command_tx, + events: event_rx, + } + } + + pub fn start_turn(&self, gui_session_id: u64, config: AgentConfig, prompt: String) { + let _ = self.commands.send(ControllerCommand::StartTurn { + gui_session_id, + config, + prompt, + }); + } + + pub fn cancel_turn(&self, gui_session_id: u64) { + let _ = self + .commands + .send(ControllerCommand::CancelTurn { gui_session_id }); + } + + pub fn try_recv(&self) -> Option { + self.events.try_recv().ok() + } +} + +async fn run_controller( + mut commands: tokio_mpsc::UnboundedReceiver, + events: mpsc::Sender, +) { + let client = LocalForgeClient::default(); + let sessions: Arc>> = Arc::new(Mutex::new(HashMap::new())); + + while let Some(command) = commands.recv().await { + match command { + ControllerCommand::StartTurn { + gui_session_id, + config, + prompt, + } => match begin_turn(&client, &sessions, gui_session_id, config, prompt).await { + Ok((forge_session_id, turn_id, after_seq)) => { + tokio::spawn(forward_turn_events( + client.clone(), + sessions.clone(), + events.clone(), + gui_session_id, + forge_session_id, + turn_id, + after_seq, + )); + } + Err(err) => { + let _ = events.send(ControllerEvent { + gui_session_id, + event: AgentEvent::Error(err.to_string()), + }); + } + }, + ControllerCommand::CancelTurn { gui_session_id } => { + let binding = sessions.lock().await.get(&gui_session_id).cloned(); + let Some(SessionBinding { + forge_session_id, + active_turn: Some(turn_id), + }) = binding + else { + continue; + }; + if let Err(err) = client.cancel_turn(forge_session_id, turn_id).await { + let _ = events.send(ControllerEvent { + gui_session_id, + event: AgentEvent::Error(err.to_string()), + }); + } + } + } + } +} + +async fn begin_turn( + client: &LocalForgeClient, + sessions: &Arc>>, + gui_session_id: u64, + config: AgentConfig, + prompt: String, +) -> Result<(SessionId, TurnId, u64), pulsing_forge::protocol::ForgeProtocolError> { + let forge_session_id = { + let existing = sessions + .lock() + .await + .get(&gui_session_id) + .map(|binding| binding.forge_session_id.clone()); + match existing { + Some(id) => id, + None => { + let id = client.create_session(config).await?; + sessions.lock().await.insert( + gui_session_id, + SessionBinding { + forge_session_id: id.clone(), + active_turn: None, + }, + ); + id + } + } + }; + + let receipt = client.start_turn(forge_session_id.clone(), prompt).await?; + let turn_id = receipt + .turn_id + .clone() + .expect("start_turn receipt always has turn_id"); + if let Some(binding) = sessions.lock().await.get_mut(&gui_session_id) { + binding.active_turn = Some(turn_id.clone()); + } + Ok((forge_session_id, turn_id, receipt.accepted_seq)) +} + +async fn forward_turn_events( + client: LocalForgeClient, + sessions: Arc>>, + events: mpsc::Sender, + gui_session_id: u64, + forge_session_id: SessionId, + turn_id: TurnId, + after_seq: u64, +) { + let mut subscription = client + .subscribe(&forge_session_id, after_seq) + .await + .map_err(|err| err.to_string()); + let subscription = match subscription.as_mut() { + Ok(subscription) => subscription, + Err(message) => { + let _ = events.send(ControllerEvent { + gui_session_id, + event: AgentEvent::Error(message.clone()), + }); + clear_active_turn(&sessions, gui_session_id, &turn_id).await; + return; + } + }; + loop { + let event = match subscription.recv().await { + Ok(event) => event, Err(err) => { - let _ = tx.send(AgentEvent::Error(err.to_string())); + let _ = events.send(ControllerEvent { + gui_session_id, + event: AgentEvent::Error(err.to_string()), + }); + clear_active_turn(&sessions, gui_session_id, &turn_id).await; return; } }; - if let Err(err) = rt.block_on(run_agent_turn_observed(&cfg, &prompt, Some(tx.clone()))) { - let _ = tx.send(AgentEvent::Error(err.to_string())); + if event.turn_id.as_ref() != Some(&turn_id) { + continue; + } + let (agent_event, terminal) = match event.kind { + ForgeEventKind::TurnOutputDelta { delta } => { + (Some(AgentEvent::TextDelta(delta)), false) + } + ForgeEventKind::ToolStarted { name } => (Some(AgentEvent::ToolStart { name }), false), + ForgeEventKind::ToolCompleted { name, ok, summary } => { + (Some(AgentEvent::ToolEnd { name, ok, summary }), false) + } + ForgeEventKind::ToolCancelled { name } => { + (Some(AgentEvent::ToolCancelled { name }), false) + } + ForgeEventKind::TurnCompleted { text } => (Some(AgentEvent::Done { text }), true), + ForgeEventKind::TurnFailed { message } => (Some(AgentEvent::Error(message)), true), + ForgeEventKind::TurnCancelled => (Some(AgentEvent::Cancelled), true), + _ => (None, false), + }; + if let Some(event) = agent_event { + let _ = events.send(ControllerEvent { + gui_session_id, + event, + }); + } + if terminal { + clear_active_turn(&sessions, gui_session_id, &turn_id).await; + return; + } + } +} + +async fn clear_active_turn( + sessions: &Arc>>, + gui_session_id: u64, + turn_id: &TurnId, +) { + if let Some(binding) = sessions.lock().await.get_mut(&gui_session_id) { + if binding.active_turn.as_ref() == Some(turn_id) { + binding.active_turn = None; + } + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + use std::time::{Duration, Instant}; + + use super::*; + + fn demo_config() -> AgentConfig { + AgentConfig { + provider: "demo".into(), + model: "demo".into(), + ..AgentConfig::default() + } + } + + #[test] + fn routes_concurrent_session_events_to_their_origin() { + let controller = ForgeController::start(); + controller.start_turn(11, demo_config(), "remember eleven".into()); + controller.start_turn(22, demo_config(), "remember twenty two".into()); + + let deadline = Instant::now() + Duration::from_secs(3); + let mut completed = HashSet::new(); + while Instant::now() < deadline && completed.len() < 2 { + if let Some(event) = controller.try_recv() { + if matches!(event.event, AgentEvent::Done { .. }) { + completed.insert(event.gui_session_id); + } + } else { + thread::sleep(Duration::from_millis(10)); + } } - }); - ChatTurnHandle { rx } + assert_eq!(completed, HashSet::from([11, 22])); + } } diff --git a/crates/pulsing-gui/src/controller/mod.rs b/crates/pulsing-gui/src/controller/mod.rs index d19d22671..e05039570 100644 --- a/crates/pulsing-gui/src/controller/mod.rs +++ b/crates/pulsing-gui/src/controller/mod.rs @@ -1,3 +1,3 @@ mod chat_turn; -pub use chat_turn::start_agent_turn; +pub use chat_turn::ForgeController; diff --git a/crates/pulsing-gui/src/model/mod.rs b/crates/pulsing-gui/src/model/mod.rs index b4d09dfd1..752dc33fd 100644 --- a/crates/pulsing-gui/src/model/mod.rs +++ b/crates/pulsing-gui/src/model/mod.rs @@ -7,5 +7,5 @@ mod workspace; pub use actions::WorkspaceAction; pub use files::{build_file_tree, count_files, FileTreeNode}; -pub use sessions::SessionStore; +pub use sessions::{SessionId, SessionStore}; pub use workspace::WorkspaceModel; diff --git a/crates/pulsing-gui/src/model/sessions.rs b/crates/pulsing-gui/src/model/sessions.rs index 50497d1b0..b15c7366c 100644 --- a/crates/pulsing-gui/src/model/sessions.rs +++ b/crates/pulsing-gui/src/model/sessions.rs @@ -1,3 +1,4 @@ +use crate::settings::ChatMode; use crate::state::ChatState; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -6,6 +7,9 @@ pub struct SessionId(pub u64); pub struct ChatSession { pub id: SessionId, pub chat: ChatState, + pub provider: String, + pub model: String, + pub mode: ChatMode, } pub struct SessionStore { @@ -15,12 +19,15 @@ pub struct SessionStore { } impl SessionStore { - pub fn new() -> Self { + pub fn new(provider: String, model: String, mode: ChatMode) -> Self { let id = SessionId(1); Self { sessions: vec![ChatSession { id, chat: ChatState::empty(), + provider, + model, + mode, }], active: id, next_id: 2, @@ -52,6 +59,17 @@ impl SessionStore { .expect("active session exists") } + pub fn chat_mut(&mut self, id: SessionId) -> Option<&mut ChatState> { + self.sessions + .iter_mut() + .find(|session| session.id == id) + .map(|session| &mut session.chat) + } + + pub fn any_busy(&self) -> bool { + self.sessions.iter().any(|session| session.chat.busy) + } + pub fn session_title(&self, id: SessionId) -> String { self.sessions .iter() @@ -60,12 +78,15 @@ impl SessionStore { .unwrap_or_else(|| "Chat".into()) } - pub fn new_session(&mut self) -> SessionId { + pub fn new_session(&mut self, provider: String, model: String, mode: ChatMode) -> SessionId { let id = SessionId(self.next_id); self.next_id += 1; self.sessions.push(ChatSession { id, chat: ChatState::empty(), + provider, + model, + mode, }); self.active = id; id @@ -76,4 +97,52 @@ impl SessionStore { self.active = id; } } + + pub fn active_settings(&self) -> (&str, &str, ChatMode) { + let session = self + .sessions + .iter() + .find(|session| session.id == self.active) + .expect("active session exists"); + (&session.provider, &session.model, session.mode) + } + + pub fn set_active_settings(&mut self, provider: String, model: String, mode: ChatMode) { + let active = self.active; + let session = self + .sessions + .iter_mut() + .find(|session| session.id == active) + .expect("active session exists"); + session.provider = provider; + session.model = model; + session.mode = mode; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::{ChatMessage, MessageKind}; + + #[test] + fn background_session_updates_do_not_touch_active_chat() { + let mut sessions = SessionStore::new("demo".into(), "demo".into(), ChatMode::Agent); + let first = sessions.active_id(); + let second = sessions.new_session("demo".into(), "demo".into(), ChatMode::Agent); + sessions + .chat_mut(first) + .unwrap() + .messages + .push(ChatMessage { + kind: MessageKind::Assistant { + body: "background".into(), + streaming: false, + }, + }); + + assert_eq!(sessions.active_id(), second); + assert!(sessions.active_chat().messages.is_empty()); + assert_eq!(sessions.chat_mut(first).unwrap().messages.len(), 1); + } } diff --git a/crates/pulsing-gui/src/state.rs b/crates/pulsing-gui/src/state.rs index 298197746..93b0689a8 100644 --- a/crates/pulsing-gui/src/state.rs +++ b/crates/pulsing-gui/src/state.rs @@ -114,6 +114,13 @@ impl ChatState { }, }); } + AgentEvent::ToolCancelled { name } => { + self.apply(AgentEvent::ToolEnd { + name, + ok: false, + summary: "cancelled".into(), + }); + } AgentEvent::Done { text } => { self.finish_streaming_assistant(); if let Some(ChatMessage { @@ -134,14 +141,13 @@ impl ChatState { }); self.busy = false; } + AgentEvent::Cancelled => { + self.finish_streaming_assistant(); + self.busy = false; + } } } - pub fn stop(&mut self) { - self.finish_streaming_assistant(); - self.busy = false; - } - fn finish_streaming_assistant(&mut self) { if let Some(ChatMessage { kind: MessageKind::Assistant { streaming, .. }, @@ -151,3 +157,30 @@ impl ChatState { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cancellation_is_terminal_for_the_chat_projection() { + let mut chat = ChatState { + messages: vec![ChatMessage { + kind: MessageKind::Assistant { + body: "partial".into(), + streaming: true, + }, + }], + busy: true, + }; + chat.apply(AgentEvent::Cancelled); + assert!(!chat.busy); + assert!(matches!( + chat.messages[0].kind, + MessageKind::Assistant { + streaming: false, + .. + } + )); + } +} diff --git a/crates/pulsing-py/src/forge.rs b/crates/pulsing-py/src/forge.rs index 58772ec7c..74d1103ab 100644 --- a/crates/pulsing-py/src/forge.rs +++ b/crates/pulsing-py/src/forge.rs @@ -14,6 +14,7 @@ use pulsing_forge::mcp::{new_shared_mcp_runtime, refresh_mcp_runtime, SharedMcpR use pulsing_forge::result::ToolResult; use pulsing_forge::runtime::{ToolRuntime, ToolRuntimeConfig}; use pulsing_forge::unified_exec::UnifiedExecManager; +use pulsing_forge::{AgentConfig, ForgeEventKind, LocalForgeClient, SessionId, TurnId}; use pyo3::exceptions::PyRuntimeError; use pyo3::prelude::*; use pyo3::types::{PyAny, PyDict}; @@ -301,6 +302,196 @@ pub struct PyForgeRuntime { mcp_slot: Option, } +/// Python client for the canonical Rust Forge session control plane. +#[pyclass(name = "ForgeClient")] +pub struct PyForgeClient { + client: LocalForgeClient, + runtime: tokio::runtime::Runtime, +} + +#[pymethods] +impl PyForgeClient { + #[new] + fn new() -> PyResult { + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(|err| PyRuntimeError::new_err(err.to_string()))?; + Ok(Self { + client: LocalForgeClient::default(), + runtime, + }) + } + + #[pyo3(signature = ( + cwd=".", + provider="demo", + model="demo", + max_tokens=8192, + max_turns=20, + sandbox="off", + tool_names=None, + system_prompt=None, + auto_approve=true + ))] + #[allow(clippy::too_many_arguments)] + fn create_session( + &self, + py: Python<'_>, + cwd: &str, + provider: &str, + model: &str, + max_tokens: u32, + max_turns: usize, + sandbox: &str, + tool_names: Option>, + system_prompt: Option, + auto_approve: bool, + ) -> PyResult { + let config = AgentConfig { + cwd: cwd.into(), + provider: provider.into(), + model: model.into(), + max_tokens, + max_turns, + sandbox: sandbox.into(), + approval_policy: if auto_approve { + ApprovalPolicy::Always + } else { + ApprovalPolicy::OnRequest + }, + tool_names: tool_names.unwrap_or_else(|| { + pulsing_forge::DEFAULT_TOOL_NAMES + .iter() + .map(|name| (*name).to_string()) + .collect() + }), + system_prompt, + }; + let client = self.client.clone(); + py.allow_threads(|| { + self.runtime + .block_on(client.create_session(config)) + .map(|id| id.to_string()) + .map_err(forge_protocol_error) + }) + } + + fn start_turn(&self, py: Python<'_>, session_id: &str, input: &str) -> PyResult { + let client = self.client.clone(); + let session_id = SessionId::from_string(session_id); + let input = input.to_string(); + let receipt = py.allow_threads(|| { + self.runtime + .block_on(client.start_turn(session_id, input)) + .map_err(forge_protocol_error) + })?; + Python::with_gil(|py| { + json_to_py( + py, + &serde_json::json!({ + "command_id": receipt.command_id.as_str(), + "session_id": receipt.session_id.as_str(), + "turn_id": receipt.turn_id.as_ref().map(|id| id.as_str()), + "accepted_seq": receipt.accepted_seq, + }), + ) + .map(Into::into) + }) + } + + fn wait_turn( + &self, + py: Python<'_>, + session_id: &str, + turn_id: &str, + after_seq: u64, + ) -> PyResult { + let client = self.client.clone(); + let session_id = SessionId::from_string(session_id); + let turn_id = TurnId::from_string(turn_id); + let value = py.allow_threads(|| { + self.runtime + .block_on(async move { + let mut subscription = client.subscribe(&session_id, after_seq).await?; + let mut events = Vec::new(); + let terminal = loop { + let event = subscription.recv().await?; + if event.turn_id.as_ref() != Some(&turn_id) { + continue; + } + let terminal = match &event.kind { + ForgeEventKind::TurnCompleted { text } => Some(serde_json::json!({ + "status": "completed", + "text": text, + })), + ForgeEventKind::TurnFailed { message } => Some(serde_json::json!({ + "status": "failed", + "message": message, + })), + ForgeEventKind::TurnCancelled => Some(serde_json::json!({ + "status": "cancelled", + })), + _ => None, + }; + events.push(serde_json::to_value(&event).map_err(|err| { + pulsing_forge::ForgeProtocolError::Internal(err.to_string()) + })?); + if let Some(terminal) = terminal { + break terminal; + } + }; + Ok::<_, pulsing_forge::ForgeProtocolError>(serde_json::json!({ + "terminal": terminal, + "events": events, + })) + }) + .map_err(forge_protocol_error) + })?; + Python::with_gil(|py| json_to_py(py, &value).map(Into::into)) + } + + fn cancel_turn(&self, py: Python<'_>, session_id: &str, turn_id: &str) -> PyResult { + let client = self.client.clone(); + let session_id = SessionId::from_string(session_id); + let turn_id = TurnId::from_string(turn_id); + let receipt = py.allow_threads(|| { + self.runtime + .block_on(client.cancel_turn(session_id, turn_id)) + .map_err(forge_protocol_error) + })?; + Python::with_gil(|py| { + json_to_py( + py, + &serde_json::json!({ + "command_id": receipt.command_id.as_str(), + "session_id": receipt.session_id.as_str(), + "turn_id": receipt.turn_id.as_ref().map(|id| id.as_str()), + "accepted_seq": receipt.accepted_seq, + }), + ) + .map(Into::into) + }) + } + + fn snapshot(&self, py: Python<'_>, session_id: &str) -> PyResult { + let client = self.client.clone(); + let session_id = SessionId::from_string(session_id); + let snapshot = py.allow_threads(|| { + self.runtime + .block_on(client.snapshot(&session_id)) + .map_err(forge_protocol_error) + })?; + let value = serde_json::to_value(snapshot) + .map_err(|err| PyRuntimeError::new_err(err.to_string()))?; + Python::with_gil(|py| json_to_py(py, &value).map(Into::into)) + } +} + +fn forge_protocol_error(err: pulsing_forge::ForgeProtocolError) -> PyErr { + PyRuntimeError::new_err(err.to_string()) +} + #[pymethods] impl PyForgeRuntime { #[new] @@ -533,6 +724,7 @@ fn tool_result_to_py(py: Python<'_>, r: &ToolResult) -> PyResult { pub fn add_to_module(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; + m.add_class::()?; crate::llm::add_to_module(m)?; Ok(()) } diff --git a/docs/design/agent-craft-migration.md b/docs/design/agent-craft-migration.md index 7658642db..84c574bd5 100644 --- a/docs/design/agent-craft-migration.md +++ b/docs/design/agent-craft-migration.md @@ -1,5 +1,7 @@ # pulsing.craft → pulsing.agent 官方迁移计划 +> **状态更新(2026-07-26)**:本文保留为已执行迁移的历史与兼容性清单,不再定义最终产品边界。最终边界以 [`Forge 核心架构`](../src/design/forge/core-architecture.zh.md) 为准:Rust Forge 拥有 Session、Agent loop、Event 与 Evolution 控制面;`pulsing.agent` 收敛为兼容 API、App Protocol 或参考应用,而不是另一套执行引擎。 +> > **目标**:将 `pulsing.craft` 重构为 `pulsing.agent`(统一 Agent SDK),`pulsing.forge` 吸收 Host 集成层,`pulsing.cli` 吸收工作区 CLI;Craft 品牌与 `pcraft` 命令进入弃用期。 > > **给实现 AI 的指令**:严格按本计划分阶段执行。每阶段独立可验收;未列出的细节参考 [`craft-agent-refactor.md`](./craft-agent-refactor.md)、[`craft-npc-refactor.md`](./craft-npc-refactor.md)、[`docs/src/design/forge/craft-architecture.zh.md`](../src/design/forge/craft-architecture.zh.md)。不修改 `crates/pulsing-actor/` 核心语义,Rust Forge 改动限于 Host 集成所需接口。 diff --git a/docs/design/agent-workspace-gui.md b/docs/design/agent-workspace-gui.md index 207195c54..ff58b4fda 100644 --- a/docs/design/agent-workspace-gui.md +++ b/docs/design/agent-workspace-gui.md @@ -1,5 +1,7 @@ # Agent Workspace GUI — 设计文档 +> **状态更新(2026-07-26)**:GUI 的目标边界以 [`Forge 核心架构`](../src/design/forge/core-architecture.zh.md) 为准。GUI 是 `ForgeClient`,只发送命令并投影事件;Session、Turn、取消、工作区 revision 和演化状态都由 Forge 拥有。本文其余内容保留为布局与迁移参考,凡涉及 GUI 自有 worker、事件 receiver 或执行状态的设计均被该边界取代。 +> > 目标:类似 **Zed** 的现代化 Agent 工作空间——文件管理、工作流版本、Duo-Agent 会话、Pulsing 多进程/Actor 运行时可视。 > **实现栈**:`eframe` / `egui`(`pulsing gui` 桌面窗口)。 @@ -9,7 +11,8 @@ |------|------| | **Zed 式布局** | 左侧 Explorer + 中央编辑/对话 + 底部/右侧 Dock(终端、工作流、运行时) | | **单一工作区根** | 以 `WorkspaceLayout`(`.pulsing/`)为真相源,GUI 不另建配置 | -| **事件驱动 UI** | Forge `AgentEvent`、Craft `ForgeEvent`、集群观测 HTTP 统一进 `WorkspaceBus` | +| **事件驱动 UI** | 通过 `ForgeClient.subscribe(session_id, after_seq)` 投影版本化 Forge Event | +| **执行状态外置** | GUI 不拥有 worker、取消 token 或 busy 真相;状态来自 Forge Session 投影 | | **Safe / Extension 分层** | Safe 模式纯 Rust;Extension 模式通过 embed Python 接 Craft 多 Agent | | **渐进交付** | Phase 0→3,每阶段可独立 `pulsing gui` 可用 | diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 415d794bc..85462eb42 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -137,6 +137,7 @@ plugins: AS Actor Decorator: AS Actor 装饰器 Communication Evolution: 集群内通信技术演进 Pulsing Forge: Pulsing Forge + Core Architecture: 核心架构 Getting Started: 快速开始 Concepts: 核心概念 Abstractions: 抽象模型 @@ -228,6 +229,7 @@ nav: - Communication Evolution: design/cluster-communication-evolution.md - Out-Cluster Connect: design/out-cluster-connect.md - Forge: + - Core Architecture: design/forge/core-architecture.md - Engineering: design/forge/engineering.md - Craft Architecture: design/forge/craft-architecture.md - Testing: design/forge/testing.md diff --git a/docs/src/design/forge/core-architecture.md b/docs/src/design/forge/core-architecture.md new file mode 100644 index 000000000..6cc640e3c --- /dev/null +++ b/docs/src/design/forge/core-architecture.md @@ -0,0 +1,505 @@ +# Forge Core Architecture and Evolution Protocol + +> **Status**: Accepted Target Architecture +> +> **Version**: 1.0 (2026-07-26) +> +> **Authority**: This document defines Forge's target product boundary, domain model, and protocols. It supersedes conflicting target-state statements in older Forge, Craft, Agent, and GUI design documents. +> **Implementation status**: Target design. Capability claims must continue to follow code and tests. + +Normative terms are **MUST**, **SHOULD**, and **MAY**. + +--- + +## 1. Decision + +Pulsing has one infrastructure core: the **Actor Runtime**. Forge, the App Protocol, GUI, CLI, and Python SDK are built above it. + +```mermaid +flowchart TB + Actor["Pulsing Actor Runtime
mailbox · lifecycle · stream · cluster · transport"] + App["App Protocol
declaration and deployment"] + Forge["Forge
self-evolving Agent Runtime"] + CLI["CLI / Headless"] + GUI["GUI"] + PySDK["Python SDK"] + RustExec["Rust Executors"] + PyExec["Python Worker"] + ActorExec["Actor Workers"] + + Actor --> App + Actor --> Forge + CLI & GUI & PySDK --> Forge + Forge --> RustExec & PyExec & ActorExec + ActorExec --> Actor +``` + +Decisions: + +1. The Actor Runtime is the only infrastructure core and knows nothing about Forge, LLMs, workspaces, or UI. +2. The App Protocol compiles high-level declarations into Actor Runtime operations; it is not a second actor model. +3. Forge owns persistent sessions, the agent loop, events, governed tool execution, workspace versions, evaluation, promotion, and rollback. +4. Rust Forge is the control plane and normative implementation. Python is an SDK, adapter ecosystem, and governed execution backend. +5. GUI, CLI, Python SDK, and remote integrations are clients of one Forge API. +6. Forge is local-first and uses Actors at process, resource, or failure-domain boundaries—not for every internal object. + +### 1.1 Implementation progress + +As of 2026-07-26, the first local vertical slice is implemented: + +| Capability | Status | +|------------|--------| +| Rust IDs and versioned Command/Event types | Initial implementation | +| Session/Turn reducer, one active Turn, contiguous sequence | Implemented | +| Command idempotency, in-memory EventStore, replay/subscription | Implemented | +| Persistent `ForgeAgent` conversation state | Connected to local Sessions | +| `LocalForgeClient` | Implemented | +| CLI reuses one Forge Session | Migrated | +| GUI routes by Session and sends `CancelTurn` | Initial migration | +| Turn-owned cancellation, Tool/Model resource tracking, shell/UnifiedExec/PTY process-tree cleanup | Initial implementation | +| File EventStore and restart recovery | Not implemented | +| Python `ForgeClient` and default `ForgeAgent` client projection | Initial implementation | +| Python Tool/Provider worker protocol | Not implemented | +| Evolution lifecycle | Not implemented | + +This table reports implementation progress; it does not weaken the invariants below. + +--- + +## 2. Product boundaries + +### Actor Runtime + +The Actor Runtime owns identity, mailbox, lifecycle, supervision, ask/tell/stream, backpressure, spawn, resolve, placement, membership, failure detection, transport, and observability. + +It MUST NOT depend on Forge types, LLM providers, clients, or `.pulsing` workspace semantics. + +### App Protocol + +The App Protocol validates versioned `ApplicationSpec` / `ActorSpec` declarations and translates them to spawn, resolve, route, and expose operations. + +It MUST NOT reimplement mailboxes, registries, placement, or cluster scheduling. Public terminology SHOULD use `App Protocol`, `ApplicationSpec`, or `ServiceSpec` to avoid confusion with the Actor Runtime. + +### Forge + +Forge owns: + +- persistent `Session`, `Turn`, and agent-loop state; +- model orchestration contracts without binding to one provider; +- tool registry, capabilities, approval, sandbox, and execution; +- workspace revisions, immutable candidates, and audit events; +- evaluation, promotion, observation, and rollback; +- common semantics across local, Python, and Actor workers. + +Forge MUST NOT use UI state as execution state, require a cluster for local use, or call unmeasured mutation “evolution.” + +### Dependency rule + +Allowed: + +```text +client → forge → actor-runtime +app-protocol → actor-runtime +python-sdk → forge binding +python-worker → language-neutral Forge protocol +``` + +Forbidden: + +```text +actor-runtime → forge +forge-core → gui or concrete CLI +Python-only object → control-plane source of truth +GUI state → execution ownership +``` + +--- + +## 3. Domain model + +IDs are opaque and never reused. + +| Entity | Purpose | Invariant | +|--------|---------|-----------| +| `Session` | Durable agent context | Own event sequence and policy snapshot | +| `Turn` | One goal-to-result execution | Belongs to one Session; one active Turn by default | +| `Event` | An observed fact | Append-only; monotonic Session sequence | +| `ToolCall` | Governed tool execution | Capability plus terminal result | +| `WorkspaceRevision` | Verifiable workspace state | Complete hash manifest or content address | +| `Candidate` | Immutable proposed change | Baseline, artifact, target, and policy | +| `EvaluationRun` | One controlled candidate evaluation | Auditable inputs, environment, and output | +| `EvaluationReport` | Aggregated verdict | Immutable evidence and thresholds | +| `Promotion` | Activation of a qualified candidate | Policy and approval constrained | +| `Rollback` | Restore a known-safe active version | Adds history; never rewrites it | +| `ClientCursor` | Projection position | Never controls execution | + +Commands and events carry applicable `session_id`, `turn_id`, `candidate_id`, `command_id`, `correlation_id`, and `causation_id`. `command_id` is the idempotency key. + +--- + +## 4. Versioned Session protocol + +```mermaid +stateDiagram-v2 + [*] --> Active: CreateSession + Active --> Running: StartTurn + Running --> WaitingInput: InputRequired + WaitingInput --> Running: ProvideInput + Running --> WaitingApproval: ApprovalRequired + WaitingApproval --> Running: ResolveApproval + Running --> Active: TurnCompleted + Running --> Cancelling: CancelTurn + Cancelling --> Active: TurnCancelled + Active --> Closed: CloseSession +``` + +Session and Turn state are separate. Client disconnect, GUI navigation, or loss of a subscriber MUST NOT change execution state. + +Required commands: + +- `CreateSession` +- `StartTurn` +- `CancelTurn` +- `ProvideInput` +- `ResolveApproval` +- `UpdateSessionPolicy` +- `CloseSession` +- `GetSessionSnapshot` +- `SubscribeEvents` + +State-changing commands use a versioned envelope: + +```json +{ + "protocol": "forge.session", + "version": {"major": 1, "minor": 0}, + "command_id": "opaque", + "session_id": "opaque", + "expected_seq": 42, + "payload": {} +} +``` + +Session invariants: + +1. A Session has at most one active Turn by default. +2. Retrying a `command_id` returns an equivalent result without repeating side effects. +3. `TurnStarted` is durable before model or tool side effects begin. +4. Tool intent is durable before dispatch; every call receives a terminal event. +5. `CancelTurn` is a request. Only `TurnCancelled` means execution has stopped. +6. A backend that cannot stop immediately remains `cancellation_pending`. +7. Recovery uses snapshot plus events, never client memory. + +--- + +## 5. Versioned Event protocol + +```json +{ + "protocol": "forge.event", + "version": {"major": 1, "minor": 0}, + "event_id": "opaque", + "session_id": "opaque", + "seq": 43, + "occurred_at": "RFC3339", + "kind": "tool.completed", + "turn_id": "opaque", + "correlation_id": "opaque", + "causation_id": "opaque", + "payload": {}, + "redaction": {"class": "public"} +} +``` + +Guarantees: + +- `seq` is strictly monotonic within one Session. +- There is no global cross-Session order. +- Subscriptions are at-least-once; clients deduplicate by `event_id` or `(session_id, seq)`. +- `SubscribeEvents(after_seq=N)` replays and then follows events with `seq > N`. +- Events become visible only after persistence. +- A sequence gap triggers replay, not inferred state. + +Minimum event domains: + +| Domain | Required events | +|--------|-----------------| +| Session | created, policy_updated, closed | +| Turn | started, output_delta, completed, failed, cancel_requested, cancelled | +| Model | requested, completed, failed, usage_recorded | +| Tool | requested, approval_required, started, output_delta, completed, failed, cancelled | +| Workspace | revision_created, restored | +| Evolution | candidate.created/prepared/qualified/rejected/promoted/rolled_back, evaluation.started/completed, promotion.requested | + +Compatibility: + +- A major change is breaking and unsupported majors are rejected. +- A minor change only adds optional fields or event kinds. +- Clients ignore unknown optional fields. +- Projection clients preserve and advance past unknown events. +- Command handlers never silently ignore unknown commands. +- Persisted history is not rewritten in place. + +Events MUST NOT store plaintext secrets or unrestricted credentials. Large or binary values go to the artifact store; events carry hashes and controlled references. + +--- + +## 6. Evolution protocol + +A change is evolution only when it has: + +1. an explicit baseline; +2. an immutable candidate; +3. a predeclared evaluation suite; +4. a policy comparison against the baseline; +5. independent approval and atomic promotion; +6. post-promotion observation and rollback. + +Unmeasured modification is mutation, not evolution. + +### Risk levels + +| Level | Target | Default | +|-------|--------|---------| +| L0 | Prompt, Skill content, Workflow config | Automatic evaluation; configurable promotion | +| L1 | Tool schema, provider parameters, routing | Regression evaluation; human approval | +| L2 | User workspace code and deployment config | Sandbox, tests, human approval | +| L3 | Forge or evaluator code | Independent controller and dual approval | + +The first implementation MUST be L0-only. L3 requires a separate security review. + +An immutable Candidate records its target, baseline, artifact hash, producer Session/Turn, declared goal, evaluation suite version, promotion policy version, risk level, and timestamp. Any content change creates a new Candidate ID. + +--- + +## 7. Candidate lifecycle + +```mermaid +stateDiagram-v2 + [*] --> Draft: CreateCandidate + Draft --> Prepared: Validate artifact + Prepared --> Evaluating: StartEvaluation + Evaluating --> Qualified: Policy passed + Evaluating --> Rejected: Policy failed + Qualified --> AwaitingApproval: RequestPromotion + AwaitingApproval --> Promoted: Approve + atomic switch + AwaitingApproval --> Rejected: Deny / expire + Promoted --> Observing: Start guards + Observing --> Accepted: Window passed + Promoted --> RolledBack: Guard/manual rollback + Observing --> RolledBack: Regression +``` + +Transitions occur only through commands and durable events. + +Every EvaluationRun records immutable candidate and baseline references, suite/runner versions, dependency lock, sandbox profile, dataset, seed, resource budget, raw artifact, metrics, thresholds, and verdict. + +Candidate and baseline SHOULD run in equivalent environments. Non-reproducible external judgments are labeled and cannot independently trigger automatic promotion unless policy explicitly allows it. + +A Promotion policy is frozen before evaluation and defines hard gates, minimum baseline improvement, allowed regressions, cost/latency/security limits, run count, aggregation, automatic-promotion permission, approvers, observation window, and rollback guards. + +Promotion MUST: + +1. verify Qualified state, approval, policy, artifact hash, and current baseline; +2. switch the active reference atomically; +3. retain the old active version as rollback target; +4. enter an observation phase; +5. never overwrite an artifact. + +Rollback is a new audited operation. It restores and verifies a complete known-safe state, cancels or isolates affected executions, records its cause, and enters an explicit degraded state if restoration fails. + +The Evolution Controller, policy, artifact verifier, and rollback implementation MUST be outside the Candidate's mutation boundary. A Forge process being replaced cannot certify its own replacement. + +--- + +## 8. Rust control plane and Python execution plane + +Rust owns: + +- Session/Turn reducers; +- command idempotency and optimistic concurrency; +- event ordering and persistence interfaces; +- tool registry and capability gate; +- sandbox policy contract; +- workspace manifest and hash verification; +- Candidate/Evaluation/Promotion reducers; +- cancellation ownership. + +Python owns: + +- SDK bindings; +- model-provider adapters; +- governed Python-tool adapters; +- evaluator and dataset adapters; +- framework integrations; +- user extensions. + +Python MUST change state through Forge commands and MUST NOT become the Session or Event source of truth. + +Rust types are the normative implementation, but schemas are language-neutral. PyO3, direct calls, and Actor RPC may use different encodings while preserving command, event, cancellation, and error semantics. + +Production Python providers and tools SHOULD run behind cancellable worker boundaries. Workers handshake protocol versions and capabilities, accept deadlines and cancellation, never write the Session store, return large values through artifacts, and produce terminal events when they crash. + +--- + +## 9. Unified clients + +Every client uses the same logical API: + +```text +ForgeClient + create_session(...) + start_turn(session_id, input, command_id) + cancel_turn(session_id, turn_id, command_id) + provide_input(...) + resolve_approval(...) + get_snapshot(session_id) + subscribe(session_id, after_seq) +``` + +Implementations: + +- `LocalForgeClient`: typed in-process Rust service; +- `ActorForgeClient`: Pulsing ask/tell/stream; +- `RemoteForgeClient`: future authenticated network transport. + +All implementations pass the same contract suite. + +GUI is a projection plus command sender. It does not spawn detached agent owners, treat a receiver as ownership, route events by the active tab, or report stopped before `TurnCancelled`. + +CLI creates or attaches to Sessions. Terminal exit explicitly chooses detach, cancel, or wait. + +Python SDK no longer owns a stateful `HybridForgeRuntime`; it drives Rust Forge through `ForgeClient` and registers Python execution adapters. + +In the current implementation, `pulsing.forge.ForgeAgent` is that client projection: Rust owns Session, Turn, the agent loop, tool runtime, event sequencing, and cancellation. The former Python loop remains only as the explicit `LegacyPythonForgeAgent` compatibility entry point, while `HybridForgeRuntime` is a transitional mixed-tool adapter. Default entry points and new code MUST NOT select either implicitly. The Python Tool/Provider worker protocol remains Phase 3 work. + +--- + +## 10. Deployment, persistence, and security + +Forge is local by default: + +```text +GUI/CLI/Python → Local Forge control plane → Rust executor / Python worker +``` + +Isolation or distribution uses Actor boundaries: + +```text +Forge control plane → Actor Runtime → ToolExecutorActor / EvaluatorActor / ProviderActor +``` + +Only components needing a separate lifecycle, failure domain, or remote resource become Actors. + +Forge depends on `EventStore`, `SnapshotStore`, `ArtifactStore`, `WorkspaceRevisionStore`, and `ActiveVersionStore`. A local-file implementation is acceptable only with atomic writes, path confinement, hash verification, and crash recovery. + +Every ToolCall and evolution action binds a capability decision to subject, resource scope, argument digest, Session/Turn, expiry, and decision source. Python fallback cannot bypass the Rust capability gate. + +Recovery loads a snapshot, replays events, marks unterminated external calls unknown, reconciles supported executors, and never silently retries non-idempotent effects. + +--- + +## 11. Errors and cancellation + +Structured errors contain `code`, `message`, `retryable`, `origin`, related IDs, and details. + +Categories include validation, conflict, unsupported_version, permission_denied, sandbox_violation, deadline_exceeded, cancelled, worker_lost, provider_error, storage_error, and internal. + +Only explicitly idempotent operations are automatically retried. Model requests, shell commands, and external writes are not silently retried by default. + +Cancellation propagates through model requests, tools, Python workers, Actor workers, and subprocesses. Resources that cannot be confirmed stopped remain cancelling or unknown. + +--- + +## 12. Migration + +### Phase 0 — Freeze boundaries + +- Make this document the target-state authority. +- Label existing Forge docs as the current tool runtime. +- Stop defining independent Session semantics in Craft, Agent, and GUI docs. +- Add protocol compatibility tests. + +### Phase 1 — Rust Session and Event + +- Implement Session/Turn reducers and versioned envelopes. +- Add local EventStore, snapshot, and replay. +- Preserve state across prompts. +- Implement real cancellation ownership. (Initial local process and in-process future ownership is complete; Python and Actor workers remain.) + +### Phase 2 — Unified clients + +- Move CLI and GUI to `ForgeClient`. +- Expose the client through Python. +- Remove GUI detached workers and global event receivers. + +### Phase 3 — Python execution adapters + +- Move Hybrid routing decisions into the Rust registry. +- Run Python-only tools/providers through workers. +- Move duplicate Agent loop, permission, and sandbox state into Forge. +- Reduce `pulsing.agent` to compatibility APIs or a reference application. + +### Phase 4 — L0 evolution + +- Implement Candidate, Evaluation, Promotion, and stores. +- Support Prompt/Skill/Workflow only. +- Require fixed suites, auditable evidence, atomic active pointers, and complete rollback. + +### Phase 5 — Code evolution + +- Add L1/L2 after hermetic evaluation and observation guards. +- Require a separate design review before L3 self-hosting. + +--- + +## 13. Acceptance criteria + +Session/Event: + +- Replaying a command does not duplicate a Turn or Tool side effect. +- A client can disconnect at any event and recover without state loss. +- Stop tests detect surviving subprocesses. +- Rust, Python, GUI, and CLI pass one protocol contract suite. + +Evolution: + +- Candidate content changes create new IDs. +- Evaluation policy cannot change after a run starts. +- Environment drift between baseline and candidate is detected. +- Failed atomic promotion leaves the active version unchanged. +- Rollback restores complete hash-verified state. +- Candidate code cannot modify the Controller, policy, or verifier. +- L0 automatic promotion is fully replayable and auditable. + +Language boundary: + +- Disabling Python fallback does not change Rust-tool semantics. +- Python-worker failure produces a terminal event. +- Unsupported majors are rejected. +- Unknown minor events do not crash clients. + +--- + +## 14. Non-goals and open ADRs + +Non-goals: + +- automatic self-modification of Forge code in the first release; +- mandatory clustering; +- GUI layout in the core protocol; +- global event ordering; +- exactly-once external effects; +- Python-memory control state; +- calling overlay copy a complete rollback. + +Open ADRs: + +1. EventStore format and compaction; +2. ArtifactStore addressing; +3. explicit parallel Turns; +4. provider streaming and usage accounting; +5. statistical evaluation method; +6. default L0 auto-promotion policy; +7. ActorForgeClient leases and recovery; +8. independent trust root for L3 self-hosting. diff --git a/docs/src/design/forge/core-architecture.zh.md b/docs/src/design/forge/core-architecture.zh.md new file mode 100644 index 000000000..b40ff0ce8 --- /dev/null +++ b/docs/src/design/forge/core-architecture.zh.md @@ -0,0 +1,718 @@ +# Forge 核心架构与演化协议 + +> **状态**:Accepted Target Architecture +> +> **版本**:1.0(2026-07-26) +> +> **规范源**:本文件定义 Forge 的目标产品边界、领域模型和协议。与旧 Forge、Craft、Agent、GUI 文档冲突时,以本文件为准。 +> **实现状态**:目标设计,不能据此宣称功能已经落地;公开能力仍以代码和测试为准。 + +本文使用以下规范词: + +- **必须(MUST)**:违反即破坏协议或产品边界。 +- **应该(SHOULD)**:默认遵循;偏离时必须记录理由。 +- **可以(MAY)**:兼容协议的可选实现。 + +--- + +## 1. 决策摘要 + +Pulsing 只有一个核心:**Actor Runtime**。Forge、应用协议、GUI、CLI 和 Python SDK 都建立在它之上。 + +```mermaid +flowchart TB + subgraph Core["Pulsing Core"] + Actor["Actor Runtime
mailbox · lifecycle · stream · cluster · transport"] + end + + subgraph Product["上层产品与协议"] + App["App Protocol
快速声明与部署"] + Forge["Forge
自进化 Agent Runtime"] + end + + subgraph Clients["Forge 客户端"] + CLI["CLI / Headless"] + GUI["GUI"] + PySDK["Python SDK"] + API["Remote API"] + end + + subgraph Exec["执行后端"] + RustExec["Rust Executors"] + PyExec["Python Worker"] + ActorExec["Actor Workers"] + end + + Actor --> App + Actor --> Forge + CLI & GUI & PySDK & API --> Forge + Forge --> RustExec & PyExec & ActorExec + ActorExec --> Actor +``` + +核心决策: + +1. **Actor Runtime 是唯一基础设施核心**,不知道 Forge、LLM、GUI 或工作区。 +2. **App Protocol 是快捷部署协议**,把高级应用声明编译为 Actor Runtime 操作,不形成第二套 Actor 模型。 +3. **Forge 是自进化 Agent Runtime**,拥有会话、Agent loop、事件、工具治理、工作区版本、评估与晋升语义。 +4. **Rust Forge 是控制面和规范实现**;Python 是 SDK、Provider/Tool 适配器和受控执行后端。 +5. **GUI、CLI、Python SDK 是同一 Forge API 的客户端**,不得各自拥有另一套 Agent 状态机。 +6. **本地优先、按边界 Actor 化**;不是每个 Forge 内部对象都必须成为 Actor。 + +### 1.1 实现进度 + +截至 2026-07-26,第一条本地垂直链路已经落地: + +| 能力 | 状态 | +|------|------| +| Rust `SessionId` / `TurnId` / `CommandId` 与版本化 Command/Event | 已实现初版 | +| Session/Turn reducer、单活跃 Turn、连续事件序号 | 已实现 | +| `command_id` 幂等、in-memory EventStore、replay/subscription | 已实现 | +| 持久 `ForgeAgent` 对话状态 | 已接入本地 Session | +| `LocalForgeClient` | 已实现 | +| CLI 复用同一个 Forge Session | 已迁移 | +| GUI 按 Session 路由事件并通过 `CancelTurn` 停止 | 已迁移首版 | +| Turn 级取消所有权、Tool/Model 资源登记、shell/UnifiedExec/PTY 进程树回收 | 已实现首版 | +| 文件 EventStore、snapshot/restart recovery | 未实现 | +| Python `ForgeClient` + 默认 `ForgeAgent` 客户端投影 | 已实现初版 | +| Python Tool/Provider worker protocol | 未实现 | +| Candidate/Evaluation/Promotion/Rollback | 未实现 | + +该表只描述实现进度,不降低本文其余不变量。 + +--- + +## 2. 产品边界与依赖规则 + +### 2.1 Actor Runtime + +Actor Runtime 负责: + +- Actor 身份、邮箱、生命周期和 supervision; +- `ask`、`tell`、stream 与背压; +- spawn、resolve、placement、集群成员和故障检测; +- Message/Tensor 传输; +- 可观测性和 Rust/Python 绑定。 + +Actor Runtime **不得**依赖: + +- Forge Session、Tool 或 Evolution 类型; +- LLM Provider; +- GUI/CLI; +- `.pulsing` 工作区产品语义。 + +### 2.2 App Protocol + +App Protocol 负责把用户声明转换为版本化 `ApplicationSpec` / `ActorSpec`: + +```text +decorator / YAML / CLI + → validate ApplicationSpec + → derive ActorSpec + routes + resources + → spawn / resolve / expose +``` + +App Protocol **不得**重新实现 mailbox、registry、placement 或 cluster scheduler。对外命名应该使用 `App Protocol`、`ApplicationSpec` 或 `ServiceSpec`,避免与底层 Actor Runtime 混淆。 + +### 2.3 Forge + +Forge 负责: + +- 持久 `Session`、`Turn` 和 Agent loop; +- 模型调用的编排协议,但不强绑定某家 Provider; +- Tool registry、capability、审批、sandbox 和执行; +- 工作区 revision、candidate artifact 和审计事件; +- Evaluation、Promotion、Observation 和 Rollback; +- 本地、Python worker 与 Actor worker 的统一执行语义。 + +Forge **不得**把 UI 状态作为执行状态,不得要求集群才能本地运行,也不得把未经评估的“自动修改”称为“演化”。 + +### 2.4 依赖方向 + +允许: + +```text +client → forge → actor-runtime +app-protocol → actor-runtime +python-sdk → forge binding +python-worker → language-neutral Forge protocol +``` + +禁止: + +```text +actor-runtime → forge +forge-core → gui +forge-core → concrete CLI +rust control state → Python-only source of truth +gui state → execution ownership +``` + +--- + +## 3. Forge 领域模型 + +所有 ID 都是不可复用的 opaque identifier。调用方不得从字符串格式推导语义。 + +| 对象 | 作用 | 不变量 | +|------|------|--------| +| `Session` | 持久 Agent 工作上下文 | 独立事件序列和策略快照 | +| `Turn` | 一次用户目标到最终结果的执行 | 属于一个 Session;默认同 Session 单活跃 Turn | +| `Event` | 已发生事实 | append-only;Session 内单调序号 | +| `ToolCall` | 一次受治理的工具调用 | 有 capability、输入摘要、结果或终止原因 | +| `WorkspaceRevision` | 可验证的工作区状态 | 内容寻址或包含完整 hash manifest | +| `Candidate` | 待评估的不可变变更提案 | 指向 baseline、artifact 和 evolution target | +| `EvaluationRun` | Candidate 在固定条件下的一次评估 | 输入、环境、结果可审计 | +| `EvaluationReport` | 多个评估运行的归并结论 | 不可变;包含通过条件和证据 | +| `Promotion` | 将 Qualified Candidate 设为活动版本 | 受 policy 和 approval 约束 | +| `Rollback` | 从已晋升版本恢复到已知安全版本 | 产生新事件,不改写历史 | +| `ClientCursor` | 客户端的消费位置 | 只影响投影,不影响执行状态 | + +### 3.1 标识与关联 + +每个命令和事件必须携带以下关联字段中的适用部分: + +```text +session_id +turn_id +candidate_id +command_id +correlation_id +causation_id +``` + +`command_id` 是幂等键;`correlation_id` 串起一次用户操作;`causation_id` 指向直接导致当前事件的命令或事件。 + +--- + +## 4. 版本化 Session 协议 + +### 4.1 Session 状态 + +```mermaid +stateDiagram-v2 + [*] --> Active: CreateSession + Active --> Running: StartTurn + Running --> WaitingInput: InputRequired + WaitingInput --> Running: ProvideInput + Running --> WaitingApproval: ApprovalRequired + WaitingApproval --> Running: Grant + WaitingApproval --> Running: Deny and continue + Running --> Active: TurnCompleted + Running --> Cancelling: CancelTurn + Cancelling --> Active: TurnCancelled + Active --> Closed: CloseSession + Closed --> [*] +``` + +Session 状态与 Turn 状态必须分开存储。客户端断开、GUI 切换页面或订阅者消失不得改变这两个状态。 + +### 4.2 命令 + +协议至少定义: + +| 命令 | 语义 | +|------|------| +| `CreateSession` | 创建 Session,并冻结初始 policy/provider/workspace 引用 | +| `StartTurn` | 在 Session 中开始一个 Turn | +| `CancelTurn` | 请求取消指定 Turn | +| `ProvideInput` | 响应结构化用户输入请求 | +| `ResolveApproval` | 批准或拒绝 capability 请求 | +| `UpdateSessionPolicy` | 对后续操作更新策略;不能追溯修改历史 | +| `CloseSession` | 不再接受新 Turn;按策略取消或等待当前 Turn | +| `GetSessionSnapshot` | 获取当前投影和最后事件序号 | +| `SubscribeEvents` | 从指定 `after_seq` 订阅事件 | + +所有改变状态的命令必须包含: + +```json +{ + "protocol": "forge.session", + "version": {"major": 1, "minor": 0}, + "command_id": "opaque", + "session_id": "opaque", + "expected_seq": 42, + "payload": {} +} +``` + +`expected_seq` 用于乐观并发控制;不需要强一致检查的幂等命令可以省略。 + +### 4.3 Session 不变量 + +1. 默认每个 Session 最多一个 Running/Waiting/Cancelling Turn。 +2. 同一 `command_id` 重试必须返回等价结果,不能重复产生副作用。 +3. `StartTurn` 被接受后必须先持久化 `TurnStarted`,再发起模型或工具副作用。 +4. 工具调用必须先记录 intent,再 dispatch;完成、失败和取消都必须有终止事件。 +5. `CancelTurn` 是请求,不是假定完成;只有 `TurnCancelled` 才表示执行已停止。 +6. 无法立即终止的后端必须标记 `cancellation_pending`,禁止向客户端报告已停止。 +7. Session 恢复必须从 snapshot + events 重建,不能依赖 GUI/CLI 内存。 + +--- + +## 5. 版本化 Event 协议 + +### 5.1 Event envelope + +```json +{ + "protocol": "forge.event", + "version": {"major": 1, "minor": 0}, + "event_id": "opaque", + "session_id": "opaque", + "seq": 43, + "occurred_at": "RFC3339", + "kind": "tool.completed", + "turn_id": "opaque", + "correlation_id": "opaque", + "causation_id": "opaque", + "payload": {}, + "redaction": {"class": "public"} +} +``` + +### 5.2 顺序与投递保证 + +- Forge 保证 **单 Session 内** `seq` 严格单调且无重复。 +- Forge 不保证跨 Session 全局顺序。 +- 订阅采用 **at-least-once delivery**;客户端必须按 `event_id` 或 `(session_id, seq)` 去重。 +- `SubscribeEvents(after_seq=N)` 返回 `seq > N` 的历史和实时事件。 +- 事件持久化后才能对订阅者可见。 +- 客户端发现序号缺口时必须重新读取,不得自行猜测缺失状态。 + +### 5.3 最小事件集合 + +| 域 | 事件 | +|----|------| +| Session | `session.created`, `session.policy_updated`, `session.closed` | +| Turn | `turn.started`, `turn.output_delta`, `turn.completed`, `turn.failed`, `turn.cancel_requested`, `turn.cancelled` | +| Model | `model.requested`, `model.completed`, `model.failed`, `model.usage_recorded` | +| Tool | `tool.requested`, `tool.approval_required`, `tool.started`, `tool.output_delta`, `tool.completed`, `tool.failed`, `tool.cancelled` | +| Workspace | `workspace.revision_created`, `workspace.restored` | +| Evolution | `candidate.created`, `candidate.prepared`, `evaluation.started`, `evaluation.completed`, `candidate.qualified`, `candidate.rejected`, `promotion.requested`, `candidate.promoted`, `candidate.rolled_back` | + +### 5.4 兼容性 + +- `major` 变化表示破坏兼容;不支持时必须明确拒绝。 +- `minor` 变化只能增加 optional field 或新 event kind。 +- 客户端必须忽略未知 optional field。 +- 投影客户端可以忽略未知 event kind,但必须推进 cursor 并保留原始 envelope。 +- 命令处理器不得静默忽略未知命令。 +- 持久事件不得原地迁移;升级通过新 projector 或显式 migration event 完成。 + +### 5.5 敏感数据 + +事件默认不得保存明文 secret、完整环境变量或未经限制的模型凭据。大输出和二进制内容应进入 artifact store,事件只记录 hash、size、media type 和受控引用。 + +--- + +## 6. Evolution 协议 + +### 6.1 什么是演化 + +只有满足以下条件的变更才称为 Evolution: + +1. 有明确 baseline; +2. 产生不可变 candidate; +3. 在预先声明的 evaluation suite 上运行; +4. 根据 promotion policy 比较 candidate 与 baseline; +5. 有独立的批准和原子晋升; +6. 晋升后持续观察并可以回滚。 + +不经过评估的代码、Prompt 或 Tool 修改只是 mutation,不是 evolution。 + +### 6.2 Evolution target 风险级别 + +| 级别 | Target | 默认策略 | +|------|--------|----------| +| L0 | Prompt、Skill 内容、Workflow 配置 | 可自动评估;满足 policy 后可配置自动晋升 | +| L1 | Tool schema、Provider 参数、路由策略 | 需要回归评估;默认人工批准 | +| L2 | 用户工作区代码、依赖和部署配置 | 必须 sandbox + 测试;人工批准 | +| L3 | Forge 或评估器自身代码 | 独立控制器、双重批准、禁止直接原地替换 | + +第一阶段必须只支持 L0。L2 稳定后才可以设计 L3 self-hosting。 + +### 6.3 Candidate + +Candidate 创建后必须不可变,至少包含: + +```text +candidate_id +target_kind +target_ref +baseline_ref +artifact_ref + content_hash +producer_session_id + producer_turn_id +declared_goal +evaluation_suite_ref + version +promotion_policy_ref + version +risk_level +created_at +``` + +任何内容变化都必须产生新 `candidate_id`,不能覆盖旧 Candidate。 + +--- + +## 7. Candidate → Evaluation → Promotion → Rollback + +### 7.1 状态机 + +```mermaid +stateDiagram-v2 + [*] --> Draft: CreateCandidate + Draft --> Prepared: Materialize + validate artifact + Prepared --> Evaluating: StartEvaluation + Evaluating --> Qualified: Policy passed + Evaluating --> Rejected: Policy failed / invalid + Qualified --> AwaitingApproval: Promotion requested + AwaitingApproval --> Promoted: Approved + atomic switch + AwaitingApproval --> Rejected: Denied / expired + Promoted --> Observing: Post-promotion checks + Observing --> Accepted: Observation window passed + Promoted --> RolledBack: Guard triggered / manual rollback + Observing --> RolledBack: Regression detected + Draft --> Archived: Abandon + Prepared --> Archived: Abandon + Rejected --> Archived + Accepted --> Archived: Superseded + RolledBack --> Archived +``` + +状态只能通过命令和持久事件转换。状态字段不能被客户端直接修改。 + +### 7.2 Evaluation + +每次 EvaluationRun 必须记录: + +- candidate 与 baseline 的不可变引用; +- suite 名称、版本和 hash; +- runner 版本、依赖锁和 sandbox profile; +- 输入数据集或样本版本; +- 随机种子; +- wall time、成本和资源上限; +- 原始结果 artifact; +- 指标、阈值和最终 verdict。 + +Candidate 与 baseline 应该在等价环境运行。无法重现的外部评价必须标记 `non_reproducible`,promotion policy 可以禁止其单独触发自动晋升。 + +### 7.3 Promotion policy + +Policy 必须在 Evaluation 开始前冻结,至少定义: + +- 必须通过的 hard gates; +- 相对 baseline 的最低提升; +- 允许退化的指标和最大幅度; +- 最大成本、延迟和安全违规; +- 最少运行次数和统计聚合方式; +- 是否允许自动晋升; +- 所需批准角色; +- observation window 和 rollback guards。 + +评估完成后修改阈值不能让同一份报告变为通过;必须创建新的 Evaluation。 + +### 7.4 Promotion + +Promotion 必须: + +1. 验证 Candidate 为 `Qualified`; +2. 验证 approval、policy、artifact hash 和当前 baseline; +3. 使用 compare-and-swap 或等价原子操作切换活动引用; +4. 记录旧活动版本,作为 rollback target; +5. 发出 `candidate.promoted` 后进入 observation; +6. 不得直接覆盖 artifact。 + +### 7.5 Rollback + +Rollback 是一次新的受审计操作,不删除 Promotion 历史。它必须: + +- 指向明确的已知安全 revision; +- 验证恢复 artifact 的 hash; +- 恢复完整状态,而不是只覆盖旧文件; +- 取消或隔离仍在使用被回滚版本的执行; +- 记录触发原因、操作者和受影响 Session; +- 在恢复失败时进入显式 degraded state,不能报告成功。 + +### 7.6 信任边界 + +Evolution Controller、Evaluation policy、artifact verifier 和 rollback implementation 必须位于 Candidate 不能修改的信任边界。 + +L3 自修改必须通过独立 Forge Controller 或外部 supervisor 完成。正在被 Candidate 替换的 Forge 进程不能自行证明替换成功。 + +--- + +## 8. Rust 控制面与 Python 执行面 + +### 8.1 Rust 必须拥有 + +| 能力 | 原因 | +|------|------| +| Session/Turn 状态机 | 唯一执行语义 | +| Event envelope、排序、持久化接口 | 所有客户端一致 | +| Command 幂等和并发控制 | 防止重复副作用 | +| Tool registry 与 capability gate | 安全决策不能由 fallback 绕过 | +| Sandbox policy 解析和 enforcement contract | 跨语言同一策略 | +| Workspace revision manifest 和 hash 验证 | Promotion/Rollback 基础 | +| Candidate/Evaluation/Promotion 状态机 | 演化控制面 | +| Cancellation ownership | GUI/CLI 不得伪造停止 | + +### 8.2 Python 可以拥有 + +| 能力 | 约束 | +|------|------| +| Python SDK | 只能通过 ForgeClient 命令和事件协议改变状态 | +| Model Provider adapter | 返回版本化响应;不得持有 Session 真相 | +| Python Tool adapter | 在声明 capability 和 sandbox profile 后注册 | +| 数据集/Evaluator adapter | 必须输出可审计 EvaluationRun | +| Framework integration | LangChain 等只做映射,不复制 Forge loop | +| 用户扩展 | 默认在 worker 进程;进程内仅用于显式开发模式 | + +### 8.3 绑定协议 + +Rust 类型是规范实现,但协议 schema 必须语言中立。PyO3、本地 direct call 和 Actor RPC 可以使用不同编码,必须保持相同命令、事件和错误语义。 + +Python callback 抛出的异常必须转换成结构化 Forge error 和终止事件,不能穿过边界后让 Session 留在 Running。 + +### 8.4 Python worker + +生产环境中的 Python Tool/Provider 应运行在可取消、可回收的 worker 边界。Worker 必须: + +- 进行 protocol handshake; +- 声明支持的 major/minor 和 capabilities; +- 接受 deadline/cancellation; +- 不直接写 Session/Event store; +- 通过 artifact API 返回大结果; +- 崩溃后由 Forge 记录明确终止事件。 + +--- + +## 9. 统一客户端模型 + +GUI、CLI、Python SDK 和 Remote API 必须只依赖同一个逻辑接口: + +```text +ForgeClient + create_session(...) + start_turn(session_id, input, command_id) + cancel_turn(session_id, turn_id, command_id) + provide_input(...) + resolve_approval(...) + get_snapshot(session_id) + subscribe(session_id, after_seq) +``` + +实现可以是: + +- `LocalForgeClient`:同进程调用 Rust service; +- `ActorForgeClient`:通过 Pulsing ask/tell/stream; +- `RemoteForgeClient`:未来的受认证网络接口。 + +三者必须通过同一 contract test。 + +### 9.1 GUI + +GUI 是事件投影和命令发送器: + +- 不创建 detached Agent worker; +- 不把 `event_rx` 当成任务所有权; +- 不因切换 tab 改变事件路由; +- Stop 必须发送 `CancelTurn`,并等待取消完成事件; +- 重启后通过 snapshot + `after_seq` 恢复; +- Session/Turn busy 状态来自 Forge 投影。 + +### 9.2 CLI + +CLI 的交互模式和一次性模式都创建或附着 Forge Session。退出终端不等于取消;CLI 必须明确选择 detach、cancel 或 wait。 + +### 9.3 Python SDK + +Python SDK 不再构造独立 `HybridForgeRuntime` 作为状态所有者。它通过 `ForgeClient` 操作 Rust Forge,并把 Python Provider/Tool 注册为执行适配器。 + +当前实现中,`pulsing.forge.ForgeAgent` 已经是上述客户端投影:Session、Turn、Agent loop、Tool runtime、事件序号与取消所有权都在 Rust。原 Python loop 仅以显式的 `LegacyPythonForgeAgent` 兼容入口保留,`HybridForgeRuntime` 仅作为待迁移的混合 Tool adapter;两者不得被默认入口或新代码隐式选择。Python Tool/Provider worker protocol 仍属于 Phase 3,不能把兼容入口的存在误记为该阶段已经完成。 + +--- + +## 10. 部署模型 + +Forge 默认本地运行,不要求集群: + +```text +GUI/CLI/Python + → Local Forge Control Plane + → local Rust executor / Python worker +``` + +需要隔离或分布式时: + +```text +Forge Control Plane + → Actor Runtime + → ToolExecutorActor / EvaluatorActor / ProviderActor +``` + +只有跨故障域、需要独立生命周期或需要远程资源的组件应该 Actor 化。领域值对象和控制面内部 reducer 保持普通 Rust 对象。 + +--- + +## 11. 持久化、安全与恢复 + +### 11.1 Stores + +Forge 通过接口依赖以下 stores: + +- `EventStore` +- `SnapshotStore` +- `ArtifactStore` +- `WorkspaceRevisionStore` +- `ActiveVersionStore` + +首个实现可以是本地文件,但必须满足原子写、hash 校验、路径约束和崩溃恢复。GUI 目录或内存 channel 不是 store。 + +### 11.2 Capability + +每个 ToolCall 和 Evolution action 必须绑定 capability。审批决定包含: + +```text +subject +capability +resource scope +argument digest +session/turn +expiry +decision source +``` + +审批不能只按工具名无限复用。Python fallback 不得绕过 Rust capability gate。 + +### 11.3 恢复 + +进程重启后: + +1. 加载最近 snapshot; +2. replay 后续事件; +3. 将没有终止事件的外部调用标记为 `unknown`; +4. 查询支持 reconciliation 的执行器; +5. 无法确认时安全失败,不自动重复非幂等操作。 + +--- + +## 12. 错误、重试与取消 + +统一错误至少包含: + +```text +code +message +retryable +origin +session_id / turn_id / tool_call_id +details +``` + +错误类别包括 validation、conflict、unsupported_version、permission_denied、sandbox_violation、deadline_exceeded、cancelled、worker_lost、provider_error、storage_error 和 internal。 + +只有明确标记幂等或携带执行幂等键的操作可以自动重试。模型调用、shell 和外部写操作默认不得静默重试。 + +取消必须从 Session 传播到模型调用、tool call、Python worker、Actor worker 和子进程。不能终止的资源必须继续显示为 cancelling/unknown,直到 reconciliation 完成。 + +--- + +## 13. 迁移计划 + +### Phase 0:冻结边界 + +- 本文成为目标架构规范源; +- 旧 Forge 文档标记为 Current Tool Runtime; +- Craft/Agent/GUI 文档不再定义独立 Session 语义; +- 建立 protocol compatibility 测试目录。 + +### Phase 1:Rust Session + Event + +- 在 `pulsing-forge` 实现 Session/Turn reducer; +- 实现版本化 Command/Event envelope; +- 本地 EventStore、snapshot、replay; +- ForgeAgent 不再每个 prompt 清空状态; +- 实现真实 cancellation ownership。(首版本地进程与 in-process future 已完成;Python/Actor worker 待后续阶段接入) + +### Phase 2:统一客户端 + +- CLI 迁移到 `LocalForgeClient`; +- GUI 迁移到 snapshot/subscription; +- Python 暴露 `ForgeClient`; +- 删除 GUI detached worker 和全局 event receiver。 + +### Phase 3:Python 执行适配器 + +- 把 Hybrid routing 决策移入 Rust registry; +- Python-only Tool/Provider 使用 worker protocol; +- Agent 包中的 loop、permission、sandbox 状态迁入 Forge; +- `pulsing.agent` 收敛为兼容 API 或参考应用。 + +### Phase 4:L0 Evolution + +- Candidate、Evaluation、Promotion stores; +- 只支持 Prompt/Skill/Workflow; +- 固定 suite、人工批准、原子 active pointer、完整 rollback; +- GUI 展示 Candidate 与评估证据,但不拥有状态。 + +### Phase 5:代码演化 + +- 支持 L1/L2; +- hermetic evaluator、资源预算和 observation guards; +- L3 必须另立安全设计评审,不自动继承 L2 能力。 + +--- + +## 14. 验收条件 + +### Session/Event + +- 同一命令重放不会重复启动 Turn 或 Tool; +- GUI 在任意事件点断开后能够无损恢复; +- 同 Session 事件顺序稳定,跨 Session 不做虚假保证; +- Stop 后仍运行的子进程会被测试捕获; +- Rust、Python、GUI、CLI 通过同一协议 contract suite。 + +### Evolution + +- Candidate 内容修改会得到新 ID; +- Evaluation policy 在运行开始后不可改变; +- baseline 与 candidate 的环境差异可被检测; +- promotion 原子失败时 active version 不改变; +- rollback 恢复完整状态并校验 hash; +- Candidate 无法修改 Controller、policy 或 verifier; +- L0 自动晋升可全程 replay 和审计。 + +### 语言边界 + +- 关闭 Python fallback 不影响 Rust 支持工具的语义; +- Python worker 崩溃会产生终止事件,Session 不会永久 Running; +- 不支持的 major version 被明确拒绝; +- 未知 minor event 不会导致 GUI/CLI 崩溃。 + +--- + +## 15. 非目标 + +- 第一阶段不实现 Forge 自身代码的自动自修改; +- 不要求所有 Forge 部署都使用集群; +- 不把 GUI 布局写入核心协议; +- 不保证跨 Session 全局事件顺序; +- 不承诺任意外部副作用 exactly-once; +- 不用 Python 内存对象作为持久控制状态; +- 不将工作区 overlay copy 称为完整 rollback。 + +--- + +## 16. 尚待 ADR 决定 + +以下问题不得由实现代码偶然决定: + +1. EventStore 初始格式和 compaction 策略; +2. ArtifactStore 的本地与远程寻址格式; +3. Session 是否允许显式并行 Turn; +4. Model Provider 的流式协议和 usage 计费模型; +5. Evaluation 的统计比较方法; +6. L0 自动晋升的默认 policy; +7. ActorForgeClient 的命名、租约和故障恢复协议; +8. L3 self-hosting 的独立信任根。 diff --git a/docs/src/design/forge/craft-architecture.md b/docs/src/design/forge/craft-architecture.md index cdaa34679..e0a04e62f 100644 --- a/docs/src/design/forge/craft-architecture.md +++ b/docs/src/design/forge/craft-architecture.md @@ -1,6 +1,7 @@ # Forge × Craft Architecture > **User docs**: [Forge deployment](../../forge/deployment.md) · [Pulsing integration](../../forge/pulsing-integration.md) +> **Status**: Legacy/reference-host integration. The target architecture is [Forge Core Architecture](core-architecture.md), where Forge owns Session and Agent-loop semantics and clients own presentation. Review-grade architecture for how Craft (reference Host) consumes Forge on Pulsing Actors. diff --git a/docs/src/design/forge/craft-architecture.zh.md b/docs/src/design/forge/craft-architecture.zh.md index f420a5ba1..4012522d9 100644 --- a/docs/src/design/forge/craft-architecture.zh.md +++ b/docs/src/design/forge/craft-architecture.zh.md @@ -1,8 +1,9 @@ # Forge × Craft 一体化架构设计 -> **状态**:Review 草案(2026-05) +> **状态**:历史实现 / 参考 Host 集成(2026-05) > **读者**:架构 review、贡献者、Craft / Forge 集成开发 > **关联**:[engineering.md](./engineering.md) · [../../forge/index.md](../../forge/index.md) · [do../../forge/abstractions.md](../../forge/abstractions.md) +> **目标架构**:[Forge 核心架构](core-architecture.zh.md)。其中 Forge 拥有 Session、Agent loop 与 Evolution 语义,Craft/Agent 不再作为独立状态所有者。 --- diff --git a/docs/src/design/forge/engineering.md b/docs/src/design/forge/engineering.md index c18b414c2..db80a5add 100644 --- a/docs/src/design/forge/engineering.md +++ b/docs/src/design/forge/engineering.md @@ -2,6 +2,7 @@ > **User docs**: [Forge chapter](../../forge/index.md) · [Abstractions](../../forge/abstractions.md) > **Package API**: [python/pulsing/forge/README.md](https://github.com/DeepLink-org/pulsing/blob/main/python/pulsing/forge/README.md) +> **Target architecture**: [Forge Core Architecture](core-architecture.md). This page describes the current implementation. Implementation-focused notes for `pulsing-forge` (Rust) and `pulsing.forge` (Python). @@ -34,6 +35,7 @@ Host (LLM + ToolSession) | Doc | Topic | |-----|-------| +| [Core architecture](core-architecture.md) | Target boundaries and versioned protocols | | [Craft architecture](craft-architecture.md) | Forge × Craft integration | | [Naming](naming.md) | Package and gossip names | | [Session REPL](session-repl.md) | `pulsing forge repl` trace/replay | diff --git a/docs/src/design/forge/engineering.zh.md b/docs/src/design/forge/engineering.zh.md index 60f620822..1fea2be83 100644 --- a/docs/src/design/forge/engineering.zh.md +++ b/docs/src/design/forge/engineering.zh.md @@ -3,6 +3,8 @@ > **产品文档**(面向用户):[../../forge/index.md](../../forge/index.md) · [abstractions.md](../../forge/abstractions.md) > > **API 速查**:[python/pulsing/forge/README.md](https://github.com/DeepLink-org/pulsing/blob/main/python/pulsing/forge/README.md) +> +> **范围**:本文描述当前实现。目标产品边界、版本化 Session/Event/Evolution 协议以 [Forge 核心架构](core-architecture.zh.md) 为准。 --- diff --git a/docs/src/forge/abstractions.md b/docs/src/forge/abstractions.md index 3482d7848..283e580fa 100644 --- a/docs/src/forge/abstractions.md +++ b/docs/src/forge/abstractions.md @@ -1,5 +1,9 @@ # Abstractions +!!! info "Current abstraction layer" + + This page describes the current tool-execution abstraction. The versioned Session, Event, and Evolution protocols that supersede `ToolSession` as the product-state boundary are specified in [Forge Core Architecture](../design/forge/core-architecture.md). + Forge provides a **standard, sandboxed workspace** for agents — independent of any single LLM product or CLI. --- diff --git a/docs/src/forge/abstractions.zh.md b/docs/src/forge/abstractions.zh.md index e83feeab9..b3760f780 100644 --- a/docs/src/forge/abstractions.zh.md +++ b/docs/src/forge/abstractions.zh.md @@ -1,5 +1,9 @@ # Pulsing Forge 抽象模型 +!!! info "当前抽象层" + + 本页描述当前工具执行抽象。取代 `ToolSession` 作为产品状态边界的版本化 Session、Event、Evolution 协议见 [Forge 核心架构](../design/forge/core-architecture.zh.md)。 + Forge 的设计目标:**给 Agent 一个标准、可沙箱化的「工作环境」**,而不是绑定某一种 LLM 产品或 CLI。 --- diff --git a/docs/src/forge/concepts.md b/docs/src/forge/concepts.md index edcd53ea1..efa139977 100644 --- a/docs/src/forge/concepts.md +++ b/docs/src/forge/concepts.md @@ -1,5 +1,9 @@ # Core Concepts +!!! info "Scope" + + The Host/Forge split below describes the current tool-runtime API. The target control-plane boundary is defined by [Forge Core Architecture](../design/forge/core-architecture.md): Forge will own Session, Turn, Agent-loop, Event, and Evolution semantics, while clients own presentation and provider/tool adapters. + ## Host vs Forge | Layer | Owns | Does not own | diff --git a/docs/src/forge/concepts.zh.md b/docs/src/forge/concepts.zh.md index 0f528d27f..354b3ef63 100644 --- a/docs/src/forge/concepts.zh.md +++ b/docs/src/forge/concepts.zh.md @@ -1,5 +1,9 @@ # 核心概念 +!!! info "范围" + + 下述 Host/Forge 分工描述当前工具运行时 API。目标控制面边界以 [Forge 核心架构](../design/forge/core-architecture.zh.md) 为准:Forge 将拥有 Session、Turn、Agent loop、Event 与 Evolution 语义,客户端只负责展示和 Provider/Tool 适配。 + ## Host 与 Forge | 层 | 负责 | 不负责 | diff --git a/docs/src/forge/index.md b/docs/src/forge/index.md index 89581719c..9fe81042f 100644 --- a/docs/src/forge/index.md +++ b/docs/src/forge/index.md @@ -5,6 +5,10 @@ > **Snapshot**: 2026-05 · **32 tools callable out of the box** (Hybrid + MCP runtime) > **Import**: `pip install pulsing` → `from pulsing.forge import ForgeEnvironment` +!!! info "Current API and target architecture" + + This chapter documents the **current tool/environment runtime**. The accepted target expands Forge into the persistent, self-evolving Agent Runtime and makes GUI, CLI, and Python SDK clients of one protocol. See [Forge Core Architecture](../design/forge/core-architecture.md). Target-state text is not a claim that those capabilities are implemented. + --- ## One sentence @@ -75,7 +79,7 @@ See [Getting Started](getting-started.md) · [Abstractions](abstractions.md) · **Design deep dives** (Architecture & Design → Forge): -- [Engineering](../design/forge/engineering.md) · [Craft architecture](../design/forge/craft-architecture.md) +- [Core Architecture](../design/forge/core-architecture.md) · [Engineering](../design/forge/engineering.md) · [Craft architecture](../design/forge/craft-architecture.md) **Package README**: [python/pulsing/forge/README.md](https://github.com/DeepLink-org/pulsing/blob/main/python/pulsing/forge/README.md) diff --git a/docs/src/forge/index.zh.md b/docs/src/forge/index.zh.md index 1b1c030f4..37e9e446c 100644 --- a/docs/src/forge/index.zh.md +++ b/docs/src/forge/index.zh.md @@ -4,6 +4,10 @@ > **版本快照**:2026-05 · **32 工具开箱 callable**(Hybrid + MCP runtime) > **代码入口**:`pip install pulsing` → `from pulsing.forge import ForgeEnvironment` +!!! info "当前 API 与目标架构" + + 本章描述的是**当前工具与环境运行时**。已接受的目标架构将 Forge 扩展为持久、自进化的 Agent Runtime,并让 GUI、CLI、Python SDK 成为同一协议的客户端。见 [Forge 核心架构](../design/forge/core-architecture.zh.md)。目标设计不代表对应能力已经实现。 + --- ## 一句话 diff --git a/python/pulsing/forge/__init__.py b/python/pulsing/forge/__init__.py index 16ccc4eb7..85744c003 100644 --- a/python/pulsing/forge/__init__.py +++ b/python/pulsing/forge/__init__.py @@ -18,9 +18,10 @@ ) from pulsing.forge.config import ToolWorkerConfig from pulsing.forge.context import ToolCallContext +from pulsing.forge.client import ForgeClient, RUST_FORGE_CLIENT_AVAILABLE from pulsing.forge.environment import ForgeEnvironment from pulsing.forge.events import ForgeEvent, ForgeEventKind -from pulsing.forge.host import CliEventSink, ForgeAgent +from pulsing.forge.host import CliEventSink, ForgeAgent, LegacyPythonForgeAgent from pulsing.forge.hybrid_runtime import HybridForgeRuntime from pulsing.forge.integrated import ( FORGE_HOST_TOOL_NAMES, @@ -58,6 +59,7 @@ __all__ = [ "CliEventSink", "ForgeAgent", + "ForgeClient", "ForgeBackend", "ForgeBackendMode", "ForgeEnvironment", @@ -71,11 +73,13 @@ "FORGE_ISOLATED_TOOL_NAMES", "FORGE_TOOL_NAMES", "HybridForgeRuntime", + "LegacyPythonForgeAgent", "LocalToolRuntime", "LocalToolSession", "NullToolSession", "OpenAIToolCallAccumulator", "P2PToolSession", + "RUST_FORGE_CLIENT_AVAILABLE", "ParsedToolCall", "PlanItem", "StepStatus", diff --git a/python/pulsing/forge/client.py b/python/pulsing/forge/client.py new file mode 100644 index 000000000..21d240927 --- /dev/null +++ b/python/pulsing/forge/client.py @@ -0,0 +1,73 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Python interface to the canonical Rust Forge session control plane.""" + +from __future__ import annotations + +from typing import Any + +try: + from pulsing._core import ForgeClient as _NativeForgeClient + + RUST_FORGE_CLIENT_AVAILABLE = True +except ImportError: + _NativeForgeClient = None # type: ignore[misc, assignment] + RUST_FORGE_CLIENT_AVAILABLE = False + + +class ForgeClient: + """Thin client; all Session, Turn, Agent loop, and event state lives in Rust.""" + + def __init__(self) -> None: + if not RUST_FORGE_CLIENT_AVAILABLE: + raise RuntimeError( + "Rust ForgeClient is required; rebuild the extension with " + "`maturin develop`" + ) + self._inner = _NativeForgeClient() + + def create_session( + self, + *, + cwd: str = ".", + provider: str = "demo", + model: str = "demo", + max_tokens: int = 8192, + max_turns: int = 20, + sandbox: str = "off", + auto_approve: bool = True, + tool_names: list[str] | tuple[str, ...] | None = None, + system_prompt: str | None = None, + ) -> str: + return str( + self._inner.create_session( + cwd, + provider, + model, + max_tokens, + max_turns, + sandbox, + list(tool_names) if tool_names is not None else None, + system_prompt, + auto_approve, + ) + ) + + def start_turn(self, session_id: str, input: str) -> dict[str, Any]: + return dict(self._inner.start_turn(session_id, input)) + + def wait_turn( + self, + session_id: str, + turn_id: str, + after_seq: int, + ) -> dict[str, Any]: + return dict(self._inner.wait_turn(session_id, turn_id, after_seq)) + + def cancel_turn(self, session_id: str, turn_id: str) -> dict[str, Any]: + return dict(self._inner.cancel_turn(session_id, turn_id)) + + def snapshot(self, session_id: str) -> dict[str, Any]: + return dict(self._inner.snapshot(session_id)) + + +__all__ = ["ForgeClient", "RUST_FORGE_CLIENT_AVAILABLE"] diff --git a/python/pulsing/forge/discovery/plugin_store.py b/python/pulsing/forge/discovery/plugin_store.py index 2644f2767..c840b3ae4 100644 --- a/python/pulsing/forge/discovery/plugin_store.py +++ b/python/pulsing/forge/discovery/plugin_store.py @@ -120,8 +120,18 @@ def _validate_version_segment(version: str) -> None: raise ValueError("invalid plugin version characters") -def _version_sort_key(version: str) -> tuple: - return tuple(int(x) if x.isdigit() else x for x in re.split(r"[.\-+]", version)) +def _version_sort_key(version: str) -> tuple[tuple[int, int | str], ...]: + """Return a deterministic key whose components are always comparable. + + Plugin caches can contain both numeric versions and labels. Returning raw + ``int`` and ``str`` components makes Python 3 raise when two versions first + differ by component type (for example ``1.0.0`` and ``1.0.dev``). + """ + + return tuple( + (0, int(component)) if component.isdigit() else (1, component.casefold()) + for component in re.split(r"[.\-+]", version) + ) def load_plugin_state() -> dict: diff --git a/python/pulsing/forge/host/__init__.py b/python/pulsing/forge/host/__init__.py index 8bffa9c40..c5f7447f9 100644 --- a/python/pulsing/forge/host/__init__.py +++ b/python/pulsing/forge/host/__init__.py @@ -1,7 +1,8 @@ # SPDX-License-Identifier: Apache-2.0 -"""High-level Host layer — thin agent loop on top of Forge.""" +"""High-level Forge client facade plus explicit legacy host adapters.""" from pulsing.forge.host.agent import ForgeAgent, DEFAULT_TOOL_NAMES +from pulsing.forge.host.legacy_agent import LegacyPythonForgeAgent from pulsing.forge.host.cli_events import CliEventSink from pulsing.forge.host.llm import ( LLMClient, @@ -20,6 +21,7 @@ "LLMClient", "LLMMessage", "LLMUsage", + "LegacyPythonForgeAgent", "RUST_LLM_AVAILABLE", "create_llm_client", "default_model", diff --git a/python/pulsing/forge/host/agent.py b/python/pulsing/forge/host/agent.py index 1968b64bf..7c45f4fc0 100644 --- a/python/pulsing/forge/host/agent.py +++ b/python/pulsing/forge/host/agent.py @@ -1,28 +1,16 @@ # SPDX-License-Identifier: Apache-2.0 -"""ForgeAgent — minimal coding-agent loop with Forge tools and CLI feedback.""" +"""Python client projection for the canonical Rust Forge Agent.""" from __future__ import annotations import asyncio -import os from dataclasses import dataclass, field from pathlib import Path from typing import Any -from pulsing.forge.host.llm import LLMClient, LLMMessage -from pulsing.forge.environment import ForgeEnvironment +from pulsing.forge.client import ForgeClient from pulsing.forge.host.cli_events import CliEventSink -from pulsing.forge.hybrid_runtime import HybridForgeRuntime from pulsing.forge.result import ToolResult -from pulsing.forge.rust_runtime import rust_forge_available -from pulsing.forge.runtime import LocalToolRuntime -from pulsing.forge.session import LocalToolSession -from pulsing.forge.tool_calls import ( - anthropic_tool_result_block, - anthropic_tool_results_message, - extract_tool_calls_anthropic, - forge_tool_definitions, -) DEFAULT_TOOL_NAMES: tuple[str, ...] = ( "update_plan", @@ -40,23 +28,9 @@ """ -def _text_from_content(content: list[Any]) -> str: - parts: list[str] = [] - for block in content or []: - if isinstance(block, dict) and block.get("type") == "text": - parts.append(str(block.get("text") or "")) - return "".join(parts) - - @dataclass class ForgeAgent: - """Thin Host: LLM loop + Forge tools + default CLI event output. - - Example:: - - agent = ForgeAgent(cwd=".", provider="demo") - text = await agent.run("List README files in this project") - """ + """Client facade; Rust owns the Session, Agent loop, tools, and cancellation.""" cwd: Path | str = "." provider: str = "demo" @@ -70,153 +44,153 @@ class ForgeAgent: sandbox_policy: str = "off" auto_approve: bool = True events: CliEventSink = field(default_factory=CliEventSink) + _client: ForgeClient | None = field(default=None, init=False, repr=False) + _session_id: str | None = field(default=None, init=False, repr=False) + _active_turn_id: str | None = field(default=None, init=False, repr=False) _messages: list[dict[str, Any]] = field( default_factory=list, init=False, repr=False ) - _runtime: HybridForgeRuntime | LocalToolRuntime | None = field( - default=None, init=False, repr=False - ) - _client: LLMClient | None = field(default=None, init=False, repr=False) def __post_init__(self) -> None: self.cwd = Path(self.cwd).resolve() - if self.provider == "openai" and not self.api_key: - self.api_key = os.environ.get("OPENAI_API_KEY") - self.base_url = self.base_url or os.environ.get("OPENAI_BASE_URL") - elif self.provider == "anthropic" and not self.api_key: - self.api_key = os.environ.get("ANTHROPIC_API_KEY") - self.base_url = self.base_url or os.environ.get("ANTHROPIC_BASE_URL") - - def _ensure_runtime(self) -> HybridForgeRuntime | LocalToolRuntime: - if self._runtime is not None: - return self._runtime - session = LocalToolSession(token_budget=128_000) - if rust_forge_available(): - self._runtime = HybridForgeRuntime.create( - cwd=str(self.cwd), - sandbox_policy=self.sandbox_policy, - session=session, - auto_approve=self.auto_approve, - event_callback=self.events.on_forge_event, - start_mcp=False, + if self.api_key is not None or self.base_url is not None: + raise ValueError( + "canonical ForgeAgent provider credentials are Rust process " + "configuration; use OPENAI_API_KEY/OPENAI_BASE_URL or " + "ANTHROPIC_API_KEY/ANTHROPIC_BASE_URL" ) - else: - self._runtime = ForgeEnvironment( - cwd=str(self.cwd), - sandbox_policy=self.sandbox_policy, - session=session, - auto_approve=self.auto_approve, - ).runtime() - return self._runtime - def _ensure_client(self) -> LLMClient: + def _ensure_client(self) -> ForgeClient: if self._client is None: - self._client = LLMClient( + self._client = ForgeClient() + return self._client + + def _ensure_session(self) -> str: + if self._session_id is None: + self._session_id = self._ensure_client().create_session( + cwd=str(self.cwd), provider=self.provider, - api_key=self.api_key, - base_url=self.base_url, + model=self.model, + max_tokens=self.max_tokens, + max_turns=self.max_turns, + sandbox=self.sandbox_policy, + auto_approve=self.auto_approve, + tool_names=self.tool_names, + system_prompt=self.system_prompt, ) - return self._client + return self._session_id @property def messages(self) -> list[dict[str, Any]]: + """Client-side conversation projection; never used to drive execution.""" return list(self._messages) @property - def session(self) -> LocalToolSession: - rt = self._ensure_runtime() - return rt.python_runtime.session # type: ignore[return-value] - - def close(self) -> None: - if self._runtime is not None: - self._runtime.close() - self._runtime = None + def session(self) -> dict[str, Any]: + """Read-only Rust Session snapshot.""" + return self._ensure_client().snapshot(self._ensure_session()) async def run(self, prompt: str) -> str: - """Run a multi-turn agent session until the model stops calling tools.""" - self._messages = [] + session_id = self._ensure_session() self._messages.append({"role": "user", "content": prompt}) - tools = forge_tool_definitions(list(self.tool_names)) - rt = self._ensure_runtime() - final: LLMMessage | None = None - - for _ in range(self.max_turns): - final = await self._stream_one_llm(self._messages, tools) - self._messages.append({"role": "assistant", "content": list(final.content)}) - self.events.on_assistant_end() - - calls = extract_tool_calls_anthropic(final.content) - if not calls: - return _text_from_content(final.content) - - result_blocks = [] - for call in calls: - result = await self._call_tool(rt, call.name, call.arguments) - result_blocks.append(anthropic_tool_result_block(call.id, result)) - - self._messages.append(anthropic_tool_results_message(result_blocks)) - - if self.session.plan: - self.events.on_plan_updated( - [item.to_dict() for item in self.session.plan] - ) + receipt = await asyncio.to_thread( + self._ensure_client().start_turn, + session_id, + prompt, + ) + turn_id = str(receipt["turn_id"]) + self._active_turn_id = turn_id + try: + outcome = await asyncio.to_thread( + self._ensure_client().wait_turn, + session_id, + turn_id, + int(receipt["accepted_seq"]), + ) + finally: + self._active_turn_id = None + + self._project_events(list(outcome.get("events") or [])) + terminal = dict(outcome.get("terminal") or {}) + status = terminal.get("status") + if status == "completed": + return str(terminal.get("text") or "") + if status == "cancelled": + raise asyncio.CancelledError("Forge turn cancelled") + message = str(terminal.get("message") or "Forge turn failed") + raise RuntimeError(message) + + async def cancel(self) -> bool: + if self._session_id is None or self._active_turn_id is None: + return False + await asyncio.to_thread( + self._ensure_client().cancel_turn, + self._session_id, + self._active_turn_id, + ) + return True - text = _text_from_content(final.content) if final else "" - return text or "(max turns reached)" - - async def _call_tool( - self, - rt: HybridForgeRuntime | LocalToolRuntime, - name: str, - arguments: dict[str, Any], - ) -> ToolResult: - self.events.on_tool_begin(name, arguments) - result = await rt.acall_tool(name, arguments) - self.events.on_tool_end(name, result) - return result - - async def _stream_one_llm( - self, - messages: list[dict[str, Any]], - tools: list[dict[str, Any]], - ) -> LLMMessage: - client = self._ensure_client() - q: asyncio.Queue[tuple[str, Any]] = asyncio.Queue() - loop = asyncio.get_running_loop() - - def _producer() -> None: + def close(self) -> None: + if ( + self._client is not None + and self._session_id is not None + and self._active_turn_id is not None + ): try: - stream = client.stream_messages( - model=self.model, - max_tokens=self.max_tokens, - system=self.system_prompt, - messages=messages, - tools=tools, + self._client.cancel_turn(self._session_id, self._active_turn_id) + except RuntimeError: + pass + self._active_turn_id = None + self._session_id = None + self._client = None + + def _project_events(self, events: list[dict[str, Any]]) -> None: + for event in events: + kind = str(event.get("kind") or "") + payload = dict(event.get("payload") or {}) + if kind == "turn_output_delta": + self.events.on_assistant_delta(str(payload.get("delta") or "")) + elif kind == "tool_started": + name = str(payload.get("name") or "tool") + self.events.on_tool_begin(name, {}) + self._messages.append( + { + "role": "assistant", + "content": [{"type": "tool_use", "name": name}], + } ) - with stream as s: - for text in s.text_stream: - loop.call_soon_threadsafe(q.put_nowait, ("chunk", text)) - loop.call_soon_threadsafe( - q.put_nowait, ("done", s.get_final_message()) - ) - except Exception as exc: - loop.call_soon_threadsafe(q.put_nowait, ("error", exc)) - - producer = asyncio.create_task(asyncio.to_thread(_producer)) - final: LLMMessage | None = None - try: - while True: - kind, payload = await q.get() - if kind == "chunk": - self.events.on_assistant_delta(str(payload)) - elif kind == "done": - final = payload - break - elif kind == "error": - self.events.on_error(str(payload)) - raise payload - finally: - await producer + elif kind == "tool_completed": + name = str(payload.get("name") or "tool") + result = ToolResult( + content=str(payload.get("summary") or ""), + is_error=not bool(payload.get("ok")), + ) + self.events.on_tool_end(name, result) + self._messages.append( + { + "role": "user", + "content": [ + { + "type": "tool_result", + "name": name, + "content": result.content, + "is_error": result.is_error, + } + ], + } + ) + elif kind == "tool_cancelled": + name = str(payload.get("name") or "tool") + self.events.on_tool_end( + name, + ToolResult(content="cancelled", is_error=True), + ) + elif kind == "turn_completed": + text = str(payload.get("text") or "") + self._messages.append({"role": "assistant", "content": text}) + self.events.on_assistant_end() + elif kind == "turn_failed": + self.events.on_error(str(payload.get("message") or "Forge turn failed")) + - assert final is not None - return final +__all__ = ["DEFAULT_TOOL_NAMES", "ForgeAgent"] diff --git a/python/pulsing/forge/host/legacy_agent.py b/python/pulsing/forge/host/legacy_agent.py new file mode 100644 index 000000000..fc5b9cb6f --- /dev/null +++ b/python/pulsing/forge/host/legacy_agent.py @@ -0,0 +1,229 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Explicit legacy Python-owned Forge loop. + +This module is not used by the default ``ForgeAgent``. It remains temporarily +available for development while Python-only tools move behind worker adapters. +""" + +from __future__ import annotations + +import asyncio +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from pulsing.forge.host.llm import LLMClient, LLMMessage +from pulsing.forge.environment import ForgeEnvironment +from pulsing.forge.host.cli_events import CliEventSink +from pulsing.forge.hybrid_runtime import HybridForgeRuntime +from pulsing.forge.result import ToolResult +from pulsing.forge.rust_runtime import rust_forge_available +from pulsing.forge.runtime import LocalToolRuntime +from pulsing.forge.session import LocalToolSession +from pulsing.forge.tool_calls import ( + anthropic_tool_result_block, + anthropic_tool_results_message, + extract_tool_calls_anthropic, + forge_tool_definitions, +) + +DEFAULT_TOOL_NAMES: tuple[str, ...] = ( + "update_plan", + "Glob", + "Read", + "Grep", + "shell_command", +) + +_DEFAULT_SYSTEM = """\ +You are a capable coding agent with filesystem and shell tools. +Use tools to inspect the workspace before answering. +When multi-step work is needed, call update_plan first. +Be concise in final replies.\ +""" + + +def _text_from_content(content: list[Any]) -> str: + parts: list[str] = [] + for block in content or []: + if isinstance(block, dict) and block.get("type") == "text": + parts.append(str(block.get("text") or "")) + return "".join(parts) + + +@dataclass +class LegacyPythonForgeAgent: + """Thin Host: LLM loop + Forge tools + default CLI event output. + + Example:: + + agent = LegacyPythonForgeAgent(cwd=".", provider="demo") + text = await agent.run("List README files in this project") + """ + + cwd: Path | str = "." + provider: str = "demo" + model: str = "demo" + api_key: str | None = None + base_url: str | None = None + max_tokens: int = 8192 + max_turns: int = 20 + tool_names: tuple[str, ...] = DEFAULT_TOOL_NAMES + system_prompt: str = _DEFAULT_SYSTEM + sandbox_policy: str = "off" + auto_approve: bool = True + events: CliEventSink = field(default_factory=CliEventSink) + _messages: list[dict[str, Any]] = field( + default_factory=list, init=False, repr=False + ) + _runtime: HybridForgeRuntime | LocalToolRuntime | None = field( + default=None, init=False, repr=False + ) + _client: LLMClient | None = field(default=None, init=False, repr=False) + + def __post_init__(self) -> None: + self.cwd = Path(self.cwd).resolve() + if self.provider == "openai" and not self.api_key: + self.api_key = os.environ.get("OPENAI_API_KEY") + self.base_url = self.base_url or os.environ.get("OPENAI_BASE_URL") + elif self.provider == "anthropic" and not self.api_key: + self.api_key = os.environ.get("ANTHROPIC_API_KEY") + self.base_url = self.base_url or os.environ.get("ANTHROPIC_BASE_URL") + + def _ensure_runtime(self) -> HybridForgeRuntime | LocalToolRuntime: + if self._runtime is not None: + return self._runtime + session = LocalToolSession(token_budget=128_000) + if rust_forge_available(): + self._runtime = HybridForgeRuntime.create( + cwd=str(self.cwd), + sandbox_policy=self.sandbox_policy, + session=session, + auto_approve=self.auto_approve, + event_callback=self.events.on_forge_event, + start_mcp=False, + ) + else: + self._runtime = ForgeEnvironment( + cwd=str(self.cwd), + sandbox_policy=self.sandbox_policy, + session=session, + auto_approve=self.auto_approve, + ).runtime() + return self._runtime + + def _ensure_client(self) -> LLMClient: + if self._client is None: + self._client = LLMClient( + provider=self.provider, + api_key=self.api_key, + base_url=self.base_url, + ) + return self._client + + @property + def messages(self) -> list[dict[str, Any]]: + return list(self._messages) + + @property + def session(self) -> LocalToolSession: + rt = self._ensure_runtime() + return rt.python_runtime.session # type: ignore[return-value] + + def close(self) -> None: + if self._runtime is not None: + self._runtime.close() + self._runtime = None + + async def run(self, prompt: str) -> str: + """Run a multi-turn agent session until the model stops calling tools.""" + self._messages = [] + self._messages.append({"role": "user", "content": prompt}) + tools = forge_tool_definitions(list(self.tool_names)) + rt = self._ensure_runtime() + final: LLMMessage | None = None + + for _ in range(self.max_turns): + final = await self._stream_one_llm(self._messages, tools) + self._messages.append({"role": "assistant", "content": list(final.content)}) + self.events.on_assistant_end() + + calls = extract_tool_calls_anthropic(final.content) + if not calls: + return _text_from_content(final.content) + + result_blocks = [] + for call in calls: + result = await self._call_tool(rt, call.name, call.arguments) + result_blocks.append(anthropic_tool_result_block(call.id, result)) + + self._messages.append(anthropic_tool_results_message(result_blocks)) + + if self.session.plan: + self.events.on_plan_updated( + [item.to_dict() for item in self.session.plan] + ) + + text = _text_from_content(final.content) if final else "" + return text or "(max turns reached)" + + async def _call_tool( + self, + rt: HybridForgeRuntime | LocalToolRuntime, + name: str, + arguments: dict[str, Any], + ) -> ToolResult: + self.events.on_tool_begin(name, arguments) + result = await rt.acall_tool(name, arguments) + self.events.on_tool_end(name, result) + return result + + async def _stream_one_llm( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]], + ) -> LLMMessage: + client = self._ensure_client() + q: asyncio.Queue[tuple[str, Any]] = asyncio.Queue() + loop = asyncio.get_running_loop() + + def _producer() -> None: + try: + stream = client.stream_messages( + model=self.model, + max_tokens=self.max_tokens, + system=self.system_prompt, + messages=messages, + tools=tools, + ) + with stream as s: + for text in s.text_stream: + loop.call_soon_threadsafe(q.put_nowait, ("chunk", text)) + loop.call_soon_threadsafe( + q.put_nowait, ("done", s.get_final_message()) + ) + except Exception as exc: + loop.call_soon_threadsafe(q.put_nowait, ("error", exc)) + + producer = asyncio.create_task(asyncio.to_thread(_producer)) + final: LLMMessage | None = None + try: + while True: + kind, payload = await q.get() + if kind == "chunk": + self.events.on_assistant_delta(str(payload)) + elif kind == "done": + final = payload + break + elif kind == "error": + self.events.on_error(str(payload)) + raise payload + finally: + await producer + + assert final is not None + return final + + +__all__ = ["LegacyPythonForgeAgent"] diff --git a/python/pulsing/forge/hybrid_runtime.py b/python/pulsing/forge/hybrid_runtime.py index 3ed91a7b8..028a7768a 100644 --- a/python/pulsing/forge/hybrid_runtime.py +++ b/python/pulsing/forge/hybrid_runtime.py @@ -1,5 +1,9 @@ # SPDX-License-Identifier: Apache-2.0 -"""Hybrid Forge runtime — Rust handlers first, Python fallback for Host-only tools.""" +"""Legacy hybrid tool adapter. + +This type no longer owns the default Agent loop or Session. Keep its use +explicit while Python-only tools migrate behind Rust-registered workers. +""" from __future__ import annotations @@ -19,7 +23,7 @@ class HybridForgeRuntime: - """Default Forge runtime when ``pulsing._core`` is available. + """Explicit compatibility adapter for mixed Rust/Python tool execution. Rust executes isolated + most Host tools; Python handles tools without Rust handlers (``exec``, ``wait``, Extension×8) so all 32 Forge tools are callable. diff --git a/python/pulsing/forge/mcp/catalog.py b/python/pulsing/forge/mcp/catalog.py index 8563ca016..d6ba804e4 100644 --- a/python/pulsing/forge/mcp/catalog.py +++ b/python/pulsing/forge/mcp/catalog.py @@ -12,7 +12,7 @@ find_plugin_manifest_path, load_codex_manifest, ) -from pulsing.forge.discovery.codex_paths import codex_home, plugins_cache_root +from pulsing.forge.discovery.codex_paths import codex_home from pulsing.forge.discovery.plugin_store import PluginStore @@ -87,7 +87,9 @@ def load_plugin_mcp_servers() -> list[McpServerEntry]: entries: list[McpServerEntry] = [] store = PluginStore() for pid in store.list_installed_plugin_ids(): - root = plugins_cache_root() / pid.marketplace / pid.name / pid.version + root = store.active_plugin_root(pid) + if root is None: + continue manifest_path = find_plugin_manifest_path(root) if manifest_path is None: continue @@ -101,7 +103,7 @@ def load_plugin_mcp_servers() -> list[McpServerEntry]: mcp_path = (root / mcp_ref).resolve() if not mcp_path.is_file(): continue - plugin_id = f"{manifest.name}@{pid.marketplace}" + plugin_id = pid.id for name, cfg in parse_plugin_mcp_file(root, mcp_path).items(): entries.append( McpServerEntry( diff --git a/tests/python/forge/test_forge_agent.py b/tests/python/forge/test_forge_agent.py index c0d4d9a22..2671da7ed 100644 --- a/tests/python/forge/test_forge_agent.py +++ b/tests/python/forge/test_forge_agent.py @@ -8,6 +8,7 @@ import pytest +from pulsing.forge import ForgeClient, LegacyPythonForgeAgent from pulsing.forge.host import ForgeAgent from pulsing.forge.host.cli_events import CliEventSink from pulsing.forge.result import ToolResult @@ -15,6 +16,41 @@ pytestmark = pytest.mark.forge +def test_default_and_legacy_agents_have_explicit_ownership() -> None: + assert ForgeAgent is not LegacyPythonForgeAgent + assert "_runtime" not in ForgeAgent.__dataclass_fields__ + assert "_client" in ForgeAgent.__dataclass_fields__ + assert "_runtime" in LegacyPythonForgeAgent.__dataclass_fields__ + + +def test_native_forge_client_exposes_versioned_events(tmp_path: Path) -> None: + client = ForgeClient() + session_id = client.create_session( + cwd=str(tmp_path), + provider="demo", + model="demo", + ) + receipt = client.start_turn(session_id, "hello") + outcome = client.wait_turn( + session_id, + str(receipt["turn_id"]), + int(receipt["accepted_seq"]), + ) + + events = outcome["events"] + assert outcome["terminal"]["status"] == "completed" + assert events[-1]["protocol"] == "forge.event" + assert events[-1]["version"] == {"major": 1, "minor": 0} + assert events[-1]["kind"] == "turn_completed" + assert events[-1]["payload"]["text"] == "(demo) Noted: hello" + assert client.snapshot(session_id)["spec"]["approval_policy"] == "always" + + +def test_canonical_agent_rejects_python_owned_provider_credentials() -> None: + with pytest.raises(ValueError, match="Rust process configuration"): + ForgeAgent(provider="openai", api_key="python-owned-secret") + + def test_cli_event_sink_tool_lines() -> None: out = io.StringIO() sink = CliEventSink(out=out, err=out) @@ -57,3 +93,33 @@ async def test_forge_agent_demo_read_then_answer(tmp_path: Path) -> None: assert "user" in roles finally: agent.close() + + +@pytest.mark.asyncio +async def test_forge_agent_reuses_one_rust_session_across_turns( + tmp_path: Path, +) -> None: + agent = ForgeAgent( + cwd=tmp_path, + provider="demo", + events=CliEventSink(stream_assistant=False), + ) + try: + first = await agent.run("remember alpha") + first_snapshot = agent.session + second = await agent.run("remember beta") + second_snapshot = agent.session + + assert first == "(demo) Noted: remember alpha" + assert second == "(demo) Noted: remember beta" + assert first_snapshot["id"] == second_snapshot["id"] + assert len(first_snapshot["turns"]) == 1 + assert len(second_snapshot["turns"]) == 2 + assert [message["role"] for message in agent.messages] == [ + "user", + "assistant", + "user", + "assistant", + ] + finally: + agent.close() diff --git a/tests/python/test_codex_plugin_compat.py b/tests/python/test_codex_plugin_compat.py index d844cb08d..c72b8b5dd 100644 --- a/tests/python/test_codex_plugin_compat.py +++ b/tests/python/test_codex_plugin_compat.py @@ -89,6 +89,17 @@ def test_marketplace_discover_and_install(codex_home: Path) -> None: ).is_file() +def test_plugin_store_sorts_mixed_numeric_and_text_versions( + codex_home: Path, +) -> None: + plugin_id = PluginId(plugin_name="demo", marketplace_name="local-dev") + base = PluginStore().plugin_base_root(plugin_id) + for version in ["1.0.9", "1.0.dev", "1.0.10"]: + (base / version).mkdir(parents=True) + + assert PluginStore().active_plugin_version(plugin_id) == "1.0.dev" + + def test_request_plugin_install_codex_wire(codex_home: Path) -> None: plugin_id = _scaffold_marketplace(codex_home) session = LocalToolSession( diff --git a/tests/python/test_forge_mcp.py b/tests/python/test_forge_mcp.py index f0b707525..8fbde9c38 100644 --- a/tests/python/test_forge_mcp.py +++ b/tests/python/test_forge_mcp.py @@ -6,6 +6,8 @@ import json from pathlib import Path +from pulsing.forge.discovery.plugin_id import PluginId +from pulsing.forge.discovery.plugin_store import PluginStore from pulsing.forge.mcp.catalog import load_mcp_catalog, parse_plugin_mcp_file @@ -31,3 +33,31 @@ def test_load_mcp_catalog_empty(monkeypatch) -> None: monkeypatch.setenv("CODEX_HOME", "/tmp/nonexistent-codex-home-forge-test") snap = load_mcp_catalog() assert isinstance(snap.servers, dict) + + +def test_load_mcp_catalog_uses_active_plugin_version( + tmp_path: Path, monkeypatch +) -> None: + monkeypatch.setenv("CODEX_HOME", str(tmp_path)) + plugin_id = PluginId(plugin_name="demo", marketplace_name="local-dev") + root = PluginStore().plugin_version_root(plugin_id, "1.2.3") + manifest_dir = root / ".codex-plugin" + manifest_dir.mkdir(parents=True) + (manifest_dir / "plugin.json").write_text( + json.dumps( + { + "name": "demo", + "version": "1.2.3", + "mcpServers": ".mcp.json", + } + ), + encoding="utf-8", + ) + (root / ".mcp.json").write_text( + json.dumps({"mcpServers": {"demo-server": {"command": "echo"}}}), + encoding="utf-8", + ) + + snapshot = load_mcp_catalog() + + assert snapshot.servers["demo-server"].plugin_id == plugin_id.id