Skip to content

Commit 0ab8199

Browse files
authored
fix(acp): separate v2 thinking blocks (#65)
1 parent ac0e7e0 commit 0ab8199

3 files changed

Lines changed: 115 additions & 11 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "kit"
3-
version = "0.1.117"
3+
version = "0.1.118"
44
edition = "2024"
55
rust-version = "1.94.0"
66
publish = false

src/protocols/acp/v2.rs

Lines changed: 113 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ use agentkit_acp::{
1616
},
1717
};
1818
use agentkit_core::{
19-
CancellationController, FinishReason, Item, ItemKind, Part, SessionId, ToolOutput, Usage,
19+
CancellationController, Delta, FinishReason, Item, ItemKind, Part, PartId, PartKind, SessionId,
20+
ToolOutput, Usage,
2021
};
2122
use agentkit_loop::{
2223
AgentEvent, LoopDriver, LoopError, LoopInterrupt, LoopObserver, LoopStep, ModelSession,
@@ -38,6 +39,7 @@ use super::{
3839

3940
const PAGE_SIZE: usize = 100;
4041
static NEXT_ERROR_MESSAGE_ID: AtomicU64 = AtomicU64::new(1);
42+
static NEXT_THOUGHT_MESSAGE_ID: AtomicU64 = AtomicU64::new(1);
4143

4244
fn available_commands_update(session_id: wire::SessionId) -> wire::UpdateSessionNotification {
4345
wire::UpdateSessionNotification::new(
@@ -131,7 +133,9 @@ impl AcpSessionUpdateSink for ConnectionSink {
131133
#[derive(Default)]
132134
struct CurrentReplacementMessages {
133135
agent: Option<wire::MessageId>,
134-
thought: Option<wire::MessageId>,
136+
thoughts: Vec<wire::MessageId>,
137+
thought_parts: HashMap<PartId, wire::MessageId>,
138+
pending_thought: Option<wire::MessageId>,
135139
replacement: Option<ReplacementGeneration>,
136140
}
137141

@@ -179,6 +183,40 @@ impl<S> ResponseReplacementSink<S> {
179183
}
180184
}
181185

186+
// AgentKit's ACP v2 adapter groups every reasoning part between tool boundaries
187+
// under one message ID. Carry the current PartId across its synchronous observer-to-sink
188+
// call so clients can render each reasoning part as a separate thought block.
189+
fn prepare_content_delta(&self, delta: &Delta) {
190+
let mut current = self
191+
.current
192+
.lock()
193+
.unwrap_or_else(|error| error.into_inner());
194+
current.pending_thought = match delta {
195+
Delta::BeginPart {
196+
part_id,
197+
kind: PartKind::Reasoning,
198+
} => {
199+
current
200+
.thought_parts
201+
.entry(part_id.clone())
202+
.or_insert_with(|| {
203+
let sequence = NEXT_THOUGHT_MESSAGE_ID.fetch_add(1, Ordering::Relaxed);
204+
wire::MessageId::new(format!("kit-thought-{sequence}"))
205+
});
206+
None
207+
}
208+
Delta::AppendText { part_id, .. } => current.thought_parts.get(part_id).cloned(),
209+
_ => None,
210+
};
211+
}
212+
213+
fn clear_pending_thought(&self) {
214+
self.current
215+
.lock()
216+
.unwrap_or_else(|error| error.into_inner())
217+
.pending_thought = None;
218+
}
219+
182220
fn rewrite_and_track(&self, notification: &mut wire::UpdateSessionNotification) {
183221
let mut current = self
184222
.current
@@ -192,10 +230,14 @@ impl<S> ResponseReplacementSink<S> {
192230
current.agent = Some(chunk.message_id.clone());
193231
}
194232
wire::SessionUpdate::AgentThoughtChunk(chunk) => {
195-
if let Some(replacement) = current.replacement.as_mut() {
233+
if let Some(message_id) = current.pending_thought.take() {
234+
chunk.message_id = message_id;
235+
} else if let Some(replacement) = current.replacement.as_mut() {
196236
chunk.message_id = replacement.message_id("thought");
197237
}
198-
current.thought = Some(chunk.message_id.clone());
238+
if !current.thoughts.contains(&chunk.message_id) {
239+
current.thoughts.push(chunk.message_id.clone());
240+
}
199241
}
200242
_ => {}
201243
}
@@ -211,17 +253,19 @@ impl<S> ResponseReplacementSink<S> {
211253

212254
impl<S: AcpSessionUpdateSink> ResponseReplacementSink<S> {
213255
fn clear_current(&self, session_id: &wire::SessionId) -> Vec<Result<(), AcpRuntimeError>> {
214-
let (agent, thought) = {
256+
let (agent, thoughts) = {
215257
let mut current = self
216258
.current
217259
.lock()
218260
.unwrap_or_else(|error| error.into_inner());
219261
let agent = current.agent.take();
220-
let thought = current.thought.take();
262+
let thoughts = std::mem::take(&mut current.thoughts);
263+
current.thought_parts.clear();
264+
current.pending_thought = None;
221265
current.replacement = Some(ReplacementGeneration::new());
222-
(agent, thought)
266+
(agent, thoughts)
223267
};
224-
let mut results = Vec::with_capacity(2);
268+
let mut results = Vec::with_capacity(usize::from(agent.is_some()) + thoughts.len());
225269
if let Some(message_id) = agent {
226270
results.push(self.inner.update(wire::UpdateSessionNotification::new(
227271
session_id.clone(),
@@ -230,7 +274,7 @@ impl<S: AcpSessionUpdateSink> ResponseReplacementSink<S> {
230274
),
231275
)));
232276
}
233-
if let Some(message_id) = thought {
277+
for message_id in thoughts {
234278
results.push(self.inner.update(wire::UpdateSessionNotification::new(
235279
session_id.clone(),
236280
wire::SessionUpdate::AgentThought(
@@ -349,7 +393,11 @@ where
349393
) {
350394
self.sink.reset();
351395
}
396+
if let AgentEvent::ContentDelta(delta) = &event.event {
397+
self.sink.prepare_content_delta(delta);
398+
}
352399
self.inner.handle_event(event);
400+
self.sink.clear_pending_thought();
353401
}
354402
}
355403

@@ -1971,6 +2019,62 @@ mod tests {
19712019
assert!(usage.cost.is_none());
19722020
}
19732021

2022+
#[test]
2023+
fn reasoning_parts_use_separate_thought_message_ids() {
2024+
let integration = AcpIntegration::default();
2025+
let recording = RecordingSink::default();
2026+
let sink = ResponseReplacementSink::new(recording.clone());
2027+
let session_id = wire::SessionId::new("thought-session");
2028+
let loop_session_id = SessionId::new("thought-loop");
2029+
let _handle = integration
2030+
.bind_session(AcpSessionBinding::new(
2031+
session_id.clone(),
2032+
loop_session_id.clone(),
2033+
sink.clone(),
2034+
))
2035+
.unwrap();
2036+
let observer = ResponseReplacementObserver::new(integration, sink, session_id);
2037+
let emit = |delta| {
2038+
observer.handle_event(ObservedEvent {
2039+
session_id: Arc::new(loop_session_id.clone()),
2040+
event: AgentEvent::ContentDelta(delta),
2041+
});
2042+
};
2043+
2044+
let first = PartId::new("reasoning-1");
2045+
let second = PartId::new("reasoning-2");
2046+
emit(Delta::BeginPart {
2047+
part_id: first.clone(),
2048+
kind: PartKind::Reasoning,
2049+
});
2050+
for chunk in ["first", " continued"] {
2051+
emit(Delta::AppendText {
2052+
part_id: first.clone(),
2053+
chunk: chunk.into(),
2054+
});
2055+
}
2056+
emit(Delta::BeginPart {
2057+
part_id: second.clone(),
2058+
kind: PartKind::Reasoning,
2059+
});
2060+
emit(Delta::AppendText {
2061+
part_id: second,
2062+
chunk: "second".into(),
2063+
});
2064+
2065+
let updates = recording.updates.lock().unwrap();
2066+
let ids = updates
2067+
.iter()
2068+
.map(|notification| match &notification.update {
2069+
wire::SessionUpdate::AgentThoughtChunk(chunk) => chunk.message_id.clone(),
2070+
update => panic!("expected thought chunk, got {update:?}"),
2071+
})
2072+
.collect::<Vec<_>>();
2073+
assert_eq!(ids.len(), 3);
2074+
assert_eq!(ids[0], ids[1]);
2075+
assert_ne!(ids[0], ids[2]);
2076+
}
2077+
19742078
#[test]
19752079
fn response_replacement_clears_and_remaps_message_ids_in_new_chunk_order() {
19762080
let integration = AcpIntegration::default();

0 commit comments

Comments
 (0)