Skip to content

Commit e1b65e8

Browse files
committed
chore: core snapshot pinned by a3s-cli v0.5.12
Snapshot of the core that the a3s CLI v0.5.12 release pins via git rev. The cli's features depend on APIs that live here but not in the crates.io 4.2.x line: the workspace manifest backend + recent-workspace context provider (file-index/search substrate), OsConfig / OS login, and interrupt-keeps-context (finish_interrupted). Branch snapshot for the cli git-dependency — not for main.
1 parent 71a0b64 commit e1b65e8

29 files changed

Lines changed: 3657 additions & 173 deletions

Cargo.lock

Lines changed: 262 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

core/Cargo.toml

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@ path = "src/lib.rs"
1313

1414
[dependencies]
1515
# Internal crates
16-
a3s-common = { version = "0.1.1", path = "../../common" }
17-
a3s-memory = { version = "0.1.1", path = "../../memory" }
18-
a3s-lane = { version = "0.4", path = "../../lane" }
19-
a3s-search = { version = "1.2.3", path = "../../search", default-features = false, features = ["lightpanda"] }
16+
a3s-common = "0.1.1"
17+
a3s-memory = "0.1.1"
18+
a3s-lane = "0.4"
19+
a3s-search = { version = "1.2.3", default-features = false, features = ["lightpanda"] }
2020

2121
# Async runtime
2222
tokio = { version = "1.35", features = [
@@ -60,6 +60,7 @@ reqwest = { version = "0.11", default-features = false, features = ["json", "str
6060
# File operations
6161
glob = "0.3"
6262
ignore = "0.4"
63+
notify = "8"
6364

6465
# Diff for edit tool
6566
similar = "2.4"
@@ -113,10 +114,10 @@ aws-smithy-types = { version = "1", optional = true }
113114
aws-smithy-runtime-api = { version = "1", optional = true }
114115

115116
[target.'cfg(unix)'.dependencies]
116-
a3s-ahp = { version = "2.4", path = "../../ahp", optional = true, features = ["http", "websocket", "unix-socket"] }
117+
a3s-ahp = { version = "2.4", optional = true, features = ["http", "websocket", "unix-socket"] }
117118

118119
[target.'cfg(not(unix))'.dependencies]
119-
a3s-ahp = { version = "2.4", path = "../../ahp", optional = true, features = ["http", "websocket"] }
120+
a3s-ahp = { version = "2.4", optional = true, features = ["http", "websocket"] }
120121

121122
[features]
122123
default = []
@@ -145,7 +146,7 @@ serve = ["dep:cron"]
145146

146147
[dev-dependencies]
147148
# AHP for integration tests
148-
a3s-ahp = { path = "../../ahp" }
149+
a3s-ahp = "2.4"
149150
# HTTP mocking for the RemoteGitBackend (and any future HTTP-backed workspace
150151
# provider). Production code does not depend on wiremock.
151152
wiremock = "0.6"

core/src/agent/execution_state.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,26 @@ impl ExecutionLoopState {
211211
}
212212
}
213213

214+
/// Finish a host-cancelled (Esc) turn. Unlike an `Err` return — which the
215+
/// run lifecycle never records, dropping the turn from history — this returns
216+
/// a normal result so the partial exchange is committed and the next turn
217+
/// remembers what was asked. The committed history must end on an assistant
218+
/// message; if the turn was cancelled before any assistant reply, append a
219+
/// short marker so user/assistant alternation stays valid.
220+
pub(super) fn finish_interrupted(mut self) -> AgentResult {
221+
if self.messages.last().map(|m| m.role.as_str()) == Some("user") {
222+
self.messages
223+
.push(Message::assistant("(Response interrupted)"));
224+
}
225+
AgentResult {
226+
text: String::new(),
227+
messages: self.messages,
228+
usage: self.total_usage,
229+
tool_calls_count: self.tool_calls_count,
230+
verification_reports: self.verification_reports,
231+
}
232+
}
233+
214234
fn tool_signature(tool_name: &str, args: &Value) -> String {
215235
format!(
216236
"{}:{}",
@@ -225,6 +245,30 @@ mod tests {
225245
use super::*;
226246
use serde_json::json;
227247

248+
#[test]
249+
fn finish_interrupted_appends_marker_when_history_ends_on_user() {
250+
// Cancelled before any assistant reply: commit the user turn + a marker
251+
// so the next turn remembers it and role alternation stays valid.
252+
let state = ExecutionLoopState::new(&[Message::user("do X")]);
253+
let result = state.finish_interrupted();
254+
assert_eq!(result.messages.len(), 2);
255+
assert_eq!(result.messages[0].role.as_str(), "user");
256+
assert_eq!(result.messages[1].role.as_str(), "assistant");
257+
assert!(result.messages[1].text().contains("interrupted"));
258+
assert!(result.text.is_empty());
259+
}
260+
261+
#[test]
262+
fn finish_interrupted_keeps_history_when_it_ends_on_assistant() {
263+
// A partial assistant reply was already recorded: don't append a marker.
264+
let state =
265+
ExecutionLoopState::new(&[Message::user("do X"), Message::assistant("partial answer")]);
266+
let result = state.finish_interrupted();
267+
assert_eq!(result.messages.len(), 2);
268+
assert_eq!(result.messages[1].role.as_str(), "assistant");
269+
assert_eq!(result.messages[1].text(), "partial answer");
270+
}
271+
228272
#[test]
229273
fn duplicate_tool_call_uses_recent_success_and_error_signatures() {
230274
let mut state = ExecutionLoopState::new(&[]);

core/src/agent/extra_agent_tests.rs

Lines changed: 85 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1197,6 +1197,80 @@ async fn test_execute_plan_delegates_parallel_task_wave_once() {
11971197
.all(|task| task.status == TaskStatus::Completed));
11981198
}
11991199

1200+
#[tokio::test]
1201+
async fn test_execute_plan_auto_delegates_unmarked_parallel_wave_when_enabled() {
1202+
use crate::planning::{Complexity, ExecutionPlan, Task};
1203+
use crate::subagent::AgentRegistry;
1204+
use crate::tools::register_task;
1205+
1206+
let child_client = Arc::new(MockLlmClient::new(vec![
1207+
MockLlmClient::text_response("auth exploration complete"),
1208+
MockLlmClient::text_response("docs exploration complete"),
1209+
]));
1210+
let tool_executor = Arc::new(ToolExecutor::new("/tmp".to_string()));
1211+
let agent_registry = Arc::new(AgentRegistry::new());
1212+
register_task(
1213+
tool_executor.registry(),
1214+
child_client,
1215+
Arc::clone(&agent_registry),
1216+
"/tmp".to_string(),
1217+
);
1218+
let auto_delegation = crate::config::AutoDelegationConfig {
1219+
enabled: true,
1220+
auto_parallel: true,
1221+
..Default::default()
1222+
};
1223+
let config = AgentConfig {
1224+
auto_delegation,
1225+
agent_registry: Some(agent_registry),
1226+
..AgentConfig::default()
1227+
};
1228+
let agent = AgentLoop::new(
1229+
Arc::new(MockLlmClient::new(vec![])),
1230+
tool_executor,
1231+
test_tool_context(),
1232+
config,
1233+
);
1234+
1235+
let mut plan = ExecutionPlan::new("Explore independent areas", Complexity::Medium);
1236+
plan.add_step(Task::new("s1", "Find auth code"));
1237+
plan.add_step(Task::new("s2", "Find documentation"));
1238+
1239+
let (tx, mut rx) = mpsc::channel(100);
1240+
let result = agent
1241+
.execute_plan(&[], &plan, Some("auto-plan-parallel-session"), Some(tx))
1242+
.await
1243+
.unwrap();
1244+
1245+
assert_eq!(
1246+
result.tool_calls_count, 1,
1247+
"auto-parallel plan wave should collapse into one parallel_task call"
1248+
);
1249+
assert!(result.text.contains("auth exploration complete"));
1250+
assert!(result.text.contains("docs exploration complete"));
1251+
1252+
let mut parallel_task_starts = 0;
1253+
let mut completed_steps = Vec::new();
1254+
rx.close();
1255+
while let Some(event) = rx.recv().await {
1256+
match event {
1257+
AgentEvent::ToolStart { name, .. } if name == "parallel_task" => {
1258+
parallel_task_starts += 1;
1259+
}
1260+
AgentEvent::StepEnd {
1261+
step_id,
1262+
status: TaskStatus::Completed,
1263+
..
1264+
} => completed_steps.push(step_id),
1265+
_ => {}
1266+
}
1267+
}
1268+
1269+
completed_steps.sort();
1270+
assert_eq!(parallel_task_starts, 1);
1271+
assert_eq!(completed_steps, vec!["s1".to_string(), "s2".to_string()]);
1272+
}
1273+
12001274
#[tokio::test]
12011275
async fn test_execute_plan_delegated_parallel_wave_maps_child_failure() {
12021276
use crate::planning::{Complexity, ExecutionPlan, Task};
@@ -1301,9 +1375,11 @@ async fn test_auto_delegation_runs_parallel_specialists_when_enabled() {
13011375
agent_registry.clone(),
13021376
"/tmp".to_string(),
13031377
);
1304-
let mut auto_delegation = crate::config::AutoDelegationConfig::default();
1305-
auto_delegation.enabled = true;
1306-
auto_delegation.max_tasks = 2;
1378+
let auto_delegation = crate::config::AutoDelegationConfig {
1379+
enabled: true,
1380+
max_tasks: 2,
1381+
..Default::default()
1382+
};
13071383
let config = AgentConfig {
13081384
planning_mode: PlanningMode::Disabled,
13091385
auto_delegation,
@@ -1357,10 +1433,12 @@ async fn test_auto_delegation_global_parallel_switch_uses_single_task() {
13571433
agent_registry.clone(),
13581434
"/tmp".to_string(),
13591435
);
1360-
let mut auto_delegation = crate::config::AutoDelegationConfig::default();
1361-
auto_delegation.enabled = true;
1362-
auto_delegation.auto_parallel = false;
1363-
auto_delegation.max_tasks = 2;
1436+
let auto_delegation = crate::config::AutoDelegationConfig {
1437+
enabled: true,
1438+
auto_parallel: false,
1439+
max_tasks: 2,
1440+
..Default::default()
1441+
};
13641442
let config = AgentConfig {
13651443
planning_mode: PlanningMode::Disabled,
13661444
auto_delegation,

core/src/agent/loop_runtime.rs

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ impl AgentLoop {
105105
}
106106

107107
loop {
108-
let llm_turn = self
108+
let llm_turn = match self
109109
.execute_llm_turn(
110110
&mut state,
111111
&augmented_system,
@@ -114,7 +114,17 @@ impl AgentLoop {
114114
&event_tx,
115115
cancel_token,
116116
)
117-
.await?;
117+
.await
118+
{
119+
Ok(turn) => turn,
120+
// Host cancelled (Esc) mid-turn: commit the partial exchange to
121+
// history instead of dropping it, so the next turn remembers what
122+
// was asked. Other errors propagate as before.
123+
Err(_) if cancel_token.is_cancelled() => {
124+
return Ok(state.finish_interrupted());
125+
}
126+
Err(e) => return Err(e),
127+
};
118128
let turn = llm_turn.turn;
119129
let response = llm_turn.response;
120130
let tool_calls = llm_turn.tool_calls;
@@ -137,14 +147,23 @@ impl AgentLoop {
137147
}
138148
}
139149

140-
self.execute_tool_turn(
141-
tool_calls,
142-
&mut state,
143-
&event_tx,
144-
session_id,
145-
effective_prompt,
146-
)
147-
.await?;
150+
if let Err(e) = self
151+
.execute_tool_turn(
152+
tool_calls,
153+
&mut state,
154+
&event_tx,
155+
session_id,
156+
effective_prompt,
157+
)
158+
.await
159+
{
160+
// Same as above: a cancelled tool round commits its partial
161+
// history rather than being dropped.
162+
if cancel_token.is_cancelled() {
163+
return Ok(state.finish_interrupted());
164+
}
165+
return Err(e);
166+
}
148167

149168
// Quiescent boundary: the tool round has fully resolved and
150169
// `state.messages` is consistent. Persist a checkpoint so a

core/src/agent/plan_execution.rs

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,25 @@ impl AgentLoop {
5757
)
5858
}
5959

60+
fn can_auto_delegate_plan_wave(&self, steps: &[(Task, usize)]) -> bool {
61+
let config = &self.config.auto_delegation;
62+
config.enabled
63+
&& config.auto_parallel
64+
&& config.max_tasks > 0
65+
&& self.config.agent_registry.is_some()
66+
&& self.tool_executor.registry().contains("parallel_task")
67+
&& steps.iter().all(|(step, _)| {
68+
Self::normalized_plan_tool(step).is_none() || Self::should_delegate_plan_step(step)
69+
})
70+
}
71+
72+
fn should_delegate_plan_wave(&self, steps: &[(Task, usize)]) -> bool {
73+
steps
74+
.iter()
75+
.all(|(step, _)| Self::should_delegate_plan_step(step))
76+
|| self.can_auto_delegate_plan_wave(steps)
77+
}
78+
6079
pub(super) fn delegated_agent_for_step(step: &Task) -> &'static str {
6180
let text = format!(
6281
"{}\n{}",
@@ -441,10 +460,7 @@ impl AgentLoop {
441460
}
442461

443462
let mut parallel_results: Vec<ParallelStepResult> = Vec::new();
444-
if ready_steps
445-
.iter()
446-
.all(|(step, _)| Self::should_delegate_plan_step(step))
447-
{
463+
if self.should_delegate_plan_wave(&ready_steps) {
448464
let args = Self::parallel_delegated_task_args_with_goal(
449465
Some(&plan.goal),
450466
&ready_steps,

core/src/agent_api/runtime.rs

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,13 @@ impl BlockingRunContext {
6666
let mut agent_loop = build_agent_loop(session);
6767
agent_loop.set_checkpoint_run(&run_id);
6868
let (runtime_tx, runtime_rx) = mpsc::channel(2048);
69-
let runtime_collector =
70-
RuntimeEventSink::from_session(session, &run_id).spawn_collector(runtime_rx);
69+
let session_events = session
70+
.tool_context
71+
.agent_event_tx
72+
.as_ref()
73+
.map(|tx| tx.subscribe());
74+
let runtime_collector = RuntimeEventSink::from_session(session, &run_id)
75+
.spawn_collector(runtime_rx, session_events);
7176
let lifecycle = BlockingRunLifecycle::from_session(session, &run_id, persistence);
7277
let cancel_token = session.session_cancel.child_token();
7378
lifecycle.set_cancel_token(cancel_token.clone()).await;
@@ -174,8 +179,16 @@ impl StreamRunContext {
174179
let cancel_token = session.session_cancel.child_token();
175180
lifecycle.set_cancel_token(cancel_token.clone()).await;
176181
let worker_state = lifecycle.worker_state();
177-
let forwarder =
178-
RuntimeEventSink::from_session(session, &run_id).spawn_forwarder(runtime_rx, tx);
182+
let session_events = session
183+
.tool_context
184+
.agent_event_tx
185+
.as_ref()
186+
.map(|tx| tx.subscribe());
187+
let forwarder = RuntimeEventSink::from_session(session, &run_id).spawn_forwarder(
188+
runtime_rx,
189+
tx,
190+
session_events,
191+
);
179192

180193
Self {
181194
agent_loop,

0 commit comments

Comments
 (0)