Skip to content

Commit 11e08d5

Browse files
committed
fix: replace interrupted OpenAI responses safely
1 parent 16be90d commit 11e08d5

9 files changed

Lines changed: 2294 additions & 395 deletions

File tree

Cargo.lock

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

Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "kit"
3-
version = "0.1.92"
3+
version = "0.1.93"
44
edition = "2024"
55
rust-version = "1.94.0"
66
publish = false
@@ -10,6 +10,7 @@ a2a-protocol-client = "=0.9.0"
1010
a2a-protocol-server = { version = "=0.9.0", default-features = false }
1111
a2a-protocol-types = "=0.9.0"
1212
agent-client-protocol = { version = "=2.0.0", features = ["unstable_session_fork", "unstable_session_inject"] }
13+
agent-client-protocol-schema = { git = "https://github.com/danielkov/agent-client-protocol", rev = "6e7e044f9464c4fd652d90699a09e9edc8b3bbad", features = ["unstable_session_notices"] }
1314
agent-client-protocol-http = { version = "=2.0.0", default-features = false, features = ["server"] }
1415
agentkit-acp = { version = "=0.10.9", features = ["unstable-inject"] }
1516
agentkit-adapter-completions = "=0.10.6"
@@ -60,6 +61,7 @@ toml = "=1.1.4"
6061
tokio = { version = "=1.53.1", features = ["fs", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] }
6162
tokio-util = { version = "=0.7.19", features = ["compat"] }
6263
tower = { version = "=0.5.3", features = ["util"] }
64+
tracing = "=0.1.44"
6365
tracing-opentelemetry = { version = "=0.33.0", default-features = false }
6466
tracing-subscriber = { version = "=0.3.23", default-features = false, features = ["registry", "std"] }
6567
unicode-width = "=0.2.2"

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ mod fatal;
99
pub mod plugins;
1010
pub mod protocols;
1111
pub mod provider;
12+
mod response_attempt;
1213
pub mod runtime;
1314
pub mod session;
1415
pub mod telemetry;

src/protocols/acp.rs

Lines changed: 128 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,22 @@ use agentkit_acp::{
1717
BlobResourceContents, CancelNotification, CloseSessionRequest, CloseSessionResponse,
1818
ContentBlock, ContentChunk, EmbeddedResource, EmbeddedResourceResource, ImageContent,
1919
InitializeRequest, InitializeResponse, LoadSessionRequest, LoadSessionResponse,
20-
NewSessionRequest, NewSessionResponse, PromptCapabilities, PromptRequest, PromptResponse,
21-
ResourceLink, SessionAdditionalDirectoriesCapabilities, SessionCapabilities,
22-
SessionCloseCapabilities, SessionConfigOption, SessionConfigOptionCategory,
23-
SessionConfigSelectGroup, SessionConfigSelectOption, SessionNotification, SessionUpdate,
24-
SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, StopReason, TextContent,
25-
TextResourceContents, ToolCallStatus, ToolCallUpdateFields,
20+
NewSessionRequest, NewSessionResponse, Notice, NoticeSeverity, PromptCapabilities,
21+
PromptRequest, PromptResponse, ResourceLink, SessionAdditionalDirectoriesCapabilities,
22+
SessionCapabilities, SessionCloseCapabilities, SessionConfigOption,
23+
SessionConfigOptionCategory, SessionConfigSelectGroup, SessionConfigSelectOption,
24+
SessionNotification, SessionUpdate, SetSessionConfigOptionRequest,
25+
SetSessionConfigOptionResponse, StopReason, TextContent, TextResourceContents, ToolCallStatus,
26+
ToolCallUpdateFields,
2627
};
2728
use agentkit_core::{
2829
CancellationController, DataRef, FinishReason, Item, ItemKind, MediaPart, MetadataMap,
2930
Modality, Part, SessionId as AgentkitSessionId, ToolOutput,
3031
};
31-
use agentkit_loop::{LoopDriver, LoopError, LoopInterrupt, LoopStep, ModelSession};
32+
use agentkit_loop::{
33+
AgentEvent, LoopDriver, LoopError, LoopInterrupt, LoopObserver, LoopStep, ModelSession,
34+
ObservedEvent,
35+
};
3236
use agentkit_task_manager::{TaskEvent, TaskManagerHandle};
3337
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
3438
use serde::{Deserialize, Serialize};
@@ -560,6 +564,49 @@ struct SessionBindingGuard {
560564
session_id: agentkit_acp::SessionId,
561565
}
562566

567+
#[derive(Clone)]
568+
struct ResponseInterruptionNoticeObserver {
569+
inner: AcpIntegration,
570+
client: AcpClientHandle,
571+
session_id: agentkit_acp::SessionId,
572+
}
573+
574+
impl ResponseInterruptionNoticeObserver {
575+
fn new(
576+
inner: AcpIntegration,
577+
client: AcpClientHandle,
578+
session_id: agentkit_acp::SessionId,
579+
) -> Self {
580+
Self {
581+
inner,
582+
client,
583+
session_id,
584+
}
585+
}
586+
}
587+
588+
impl LoopObserver for ResponseInterruptionNoticeObserver {
589+
fn handle_event(&self, event: ObservedEvent) {
590+
if matches!(
591+
&event.event,
592+
AgentEvent::ContentDelta(delta) if crate::response_attempt::is_marker(delta)
593+
) {
594+
let notification = SessionNotification::new(
595+
self.session_id.clone(),
596+
SessionUpdate::Notice(Notice::new(
597+
NoticeSeverity::Warning,
598+
"Response interrupted; replacement follows",
599+
)),
600+
);
601+
if let Err(error) = self.client.notify_session(notification) {
602+
tracing::debug!(%error, "failed to queue ACP v1 interruption notice");
603+
}
604+
return;
605+
}
606+
self.inner.handle_event(event);
607+
}
608+
}
609+
563610
impl SessionBindingGuard {
564611
fn new(integration: Arc<AcpIntegration>, session_id: agentkit_acp::SessionId) -> Self {
565612
Self {
@@ -702,20 +749,27 @@ impl Server {
702749
json!(additional_directories),
703750
);
704751
let mcp_events = self.runtime.subscribe_mcp(session_id.to_string());
705-
let binding = AcpSessionBinding::new(session_id.clone(), agentkit_session_id, client)
706-
.cancellation(cancellation)
707-
.workspace(cwd.clone(), additional_directories.clone())
708-
.metadata(metadata);
752+
let binding =
753+
AcpSessionBinding::new(session_id.clone(), agentkit_session_id, client.clone())
754+
.cancellation(cancellation)
755+
.workspace(cwd.clone(), additional_directories.clone())
756+
.metadata(metadata);
709757
let handle = self
710758
.integration
711759
.bind_session(binding)
712760
.map_err(|error| record_acp_runtime_failure(&session_id, "session_bind", error))?;
713761
let binding = SessionBindingGuard::new(Arc::clone(&self.integration), session_id.clone());
762+
let observer = ResponseInterruptionNoticeObserver::new(
763+
self.integration.as_ref().clone(),
764+
client,
765+
session_id.clone(),
766+
);
714767
let context = AcpDriverContext {
715768
cwd,
716769
additional_directories,
717-
integration: Arc::clone(&self.integration),
770+
integration: Arc::new(observer),
718771
cancellation: handle.cancellation_handle(),
772+
response_attempt_replacement: true,
719773
};
720774
let driver = match self.runtime.start_acp_driver(context, &mut claim).await {
721775
Ok(driver) => driver,
@@ -1804,6 +1858,68 @@ mod tests {
18041858
));
18051859
}
18061860

1861+
#[tokio::test]
1862+
async fn response_interruption_marker_becomes_v1_warning_before_replacement() {
1863+
let integration = AcpIntegration::builder()
1864+
.name("response-interruption-test")
1865+
.approval_resolver(AutoDenyResolver)
1866+
.build()
1867+
.unwrap();
1868+
let session_id = agentkit_acp::SessionId::new("v1-interruption");
1869+
let loop_session_id = AgentkitSessionId::new("v1-interruption-loop");
1870+
let (client, mut messages) = AcpClientHandle::channel();
1871+
integration
1872+
.bind_session(AcpSessionBinding::new(
1873+
session_id.clone(),
1874+
loop_session_id.clone(),
1875+
client.clone(),
1876+
))
1877+
.unwrap();
1878+
let observer = ResponseInterruptionNoticeObserver::new(
1879+
integration.clone(),
1880+
client,
1881+
session_id.clone(),
1882+
);
1883+
let emit = |event| {
1884+
observer.handle_event(ObservedEvent {
1885+
session_id: Arc::new(loop_session_id.clone()),
1886+
event,
1887+
});
1888+
};
1889+
let ModelTurnEvent::Delta(marker) = crate::response_attempt::marker_event() else {
1890+
panic!("replacement marker must be a delta");
1891+
};
1892+
1893+
emit(AgentEvent::ContentDelta(marker));
1894+
emit(AgentEvent::ContentDelta(Delta::BeginPart {
1895+
part_id: PartId::new("replacement"),
1896+
kind: PartKind::Text,
1897+
}));
1898+
emit(AgentEvent::ContentDelta(Delta::AppendText {
1899+
part_id: PartId::new("replacement"),
1900+
chunk: "fresh response".into(),
1901+
}));
1902+
1903+
let Some(AcpClientMessage::SessionNotification(notice)) = messages.recv().await else {
1904+
panic!("expected interruption notice");
1905+
};
1906+
let SessionUpdate::Notice(notice) = notice.update else {
1907+
panic!("expected notice before replacement output");
1908+
};
1909+
assert_eq!(notice.severity, NoticeSeverity::Warning);
1910+
assert_eq!(notice.title, "Response interrupted; replacement follows");
1911+
1912+
let Some(AcpClientMessage::SessionNotification(replacement)) = messages.recv().await else {
1913+
panic!("expected replacement output");
1914+
};
1915+
assert!(matches!(
1916+
replacement.update,
1917+
SessionUpdate::AgentMessageChunk(chunk)
1918+
if matches!(&chunk.content, ContentBlock::Text(text) if text.text == "fresh response")
1919+
));
1920+
integration.unbind_session(&session_id).unwrap();
1921+
}
1922+
18071923
#[test]
18081924
fn dropping_an_in_flight_binding_guard_releases_the_durable_identity() {
18091925
let integration = Arc::new(

0 commit comments

Comments
 (0)