diff --git a/core/Cargo.toml b/core/Cargo.toml index e9da14a..70890c2 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "a3s-code-core" -version = "4.2.2" +version = "4.2.3" edition = "2021" authors = ["A3S Lab Team"] license = "MIT" diff --git a/core/src/agent/execution_state.rs b/core/src/agent/execution_state.rs index d71f23e..6d50455 100644 --- a/core/src/agent/execution_state.rs +++ b/core/src/agent/execution_state.rs @@ -1,5 +1,5 @@ use super::AgentResult; -use crate::llm::{Message, TokenUsage}; +use crate::llm::{ContentBlock, Message, TokenUsage}; use crate::verification::VerificationReport; use serde_json::Value; use std::time::Instant; @@ -211,6 +211,34 @@ impl ExecutionLoopState { } } + /// Build a result from a turn that was cancelled mid-generation. Keeps the + /// conversation accumulated so far (the user's message above all) so the next + /// turn remembers it. Appends a short assistant marker when the log would + /// otherwise end on a user message, so the next user turn still alternates. + pub(super) fn finish_interrupted(mut self) -> AgentResult { + let ends_on_user = self + .messages + .last() + .map(|m| m.role != "assistant") + .unwrap_or(false); + if ends_on_user { + self.messages.push(Message { + role: "assistant".to_string(), + content: vec![ContentBlock::Text { + text: "(Response interrupted by the user.)".to_string(), + }], + reasoning_content: None, + }); + } + AgentResult { + text: String::new(), + messages: self.messages, + usage: self.total_usage, + tool_calls_count: self.tool_calls_count, + verification_reports: self.verification_reports, + } + } + fn tool_signature(tool_name: &str, args: &Value) -> String { format!( "{}:{}", @@ -225,6 +253,27 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn finish_interrupted_keeps_user_message_and_alternates() { + // A cancelled turn must keep the user's message (so the next turn + // remembers it) and end on an assistant message (so it still alternates). + let mut state = ExecutionLoopState::new(&[]); + state.messages.push(Message::user("what is the plan?")); + let result = state.finish_interrupted(); + assert!( + result + .messages + .iter() + .any(|m| m.role == "user" && m.text().contains("what is the plan?")), + "user message must survive the interrupt" + ); + assert_eq!( + result.messages.last().unwrap().role, + "assistant", + "history must end on an assistant message to alternate" + ); + } + #[test] fn duplicate_tool_call_uses_recent_success_and_error_signatures() { let mut state = ExecutionLoopState::new(&[]); diff --git a/core/src/agent/loop_runtime.rs b/core/src/agent/loop_runtime.rs index 5e48fee..22ee770 100644 --- a/core/src/agent/loop_runtime.rs +++ b/core/src/agent/loop_runtime.rs @@ -105,7 +105,7 @@ impl AgentLoop { } loop { - let llm_turn = self + let llm_turn = match self .execute_llm_turn( &mut state, &augmented_system, @@ -114,7 +114,19 @@ impl AgentLoop { &event_tx, cancel_token, ) - .await?; + .await + { + Ok(turn) => turn, + // Interrupted mid-generation (Esc / cancel): keep the conversation + // accumulated so far — above all the user's message — and return it + // as the result so it is committed to history. Without this the + // whole turn is dropped and the agent "forgets" what was just asked + // when the user continues. + Err(_) if cancel_token.is_cancelled() => { + return Ok(state.finish_interrupted()); + } + Err(e) => return Err(e), + }; let turn = llm_turn.turn; let response = llm_turn.response; let tool_calls = llm_turn.tool_calls; diff --git a/core/src/tools/program_tool.rs b/core/src/tools/program_tool.rs index eeeeac7..562c0a7 100644 --- a/core/src/tools/program_tool.rs +++ b/core/src/tools/program_tool.rs @@ -213,6 +213,12 @@ fn script_allowed_tools(args: &serde_json::Value, registry: &ToolRegistry) -> Ha .unwrap_or_else(|| registry.list().into_iter().collect()); allowed.remove("program"); + // Delegation tools can't run inside a PTC script: child agents need the + // multi-threaded session runtime, but the script executes on a nested + // single-thread runtime where they can't fan out. Force the model to call + // them directly instead of `ctx.tool("parallel_task", ...)`. + allowed.remove("task"); + allowed.remove("parallel_task"); allowed } diff --git a/sdk/node/Cargo.toml b/sdk/node/Cargo.toml index 4cac6a4..6506b24 100644 --- a/sdk/node/Cargo.toml +++ b/sdk/node/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "a3s-code-node" -version = "4.2.2" +version = "4.2.3" edition = "2021" authors = ["A3S Lab Team"] license = "MIT" diff --git a/sdk/node/package.json b/sdk/node/package.json index f77d058..a7a9101 100644 --- a/sdk/node/package.json +++ b/sdk/node/package.json @@ -1,6 +1,6 @@ { "name": "@a3s-lab/code", - "version": "4.2.2", + "version": "4.2.3", "description": "A3S Code - Native Node.js bindings for the coding-agent runtime", "main": "index.js", "types": "index.d.ts", diff --git a/sdk/python-bootstrap/pyproject.toml b/sdk/python-bootstrap/pyproject.toml index f605ebc..e6e71a0 100644 --- a/sdk/python-bootstrap/pyproject.toml +++ b/sdk/python-bootstrap/pyproject.toml @@ -7,7 +7,7 @@ name = "a3s-code" # Keep in sync with crates/code core release. The bootstrap loader fetches # the matching native wheel from `https://github.com/AI45Lab/Code/releases/tag/v` # at import time. -version = "4.2.2" +version = "4.2.3" description = "A3S Code Python SDK — pure-Python bootstrap that fetches the native wheel from GitHub Releases" readme = "README.md" license = {text = "MIT"} diff --git a/sdk/python/Cargo.toml b/sdk/python/Cargo.toml index e49825c..da8a6e7 100644 --- a/sdk/python/Cargo.toml +++ b/sdk/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "a3s-code-py" -version = "4.2.2" +version = "4.2.3" edition = "2021" authors = ["A3S Lab Team"] license = "MIT" diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index 7e2e412..103fb75 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "a3s-code" -version = "4.2.2" +version = "4.2.3" description = "A3S Code - Native Python bindings for the coding-agent runtime" readme = "README.md" license = {text = "MIT"}