1#![deny(missing_docs)]
4#![forbid(unsafe_code)]
5
6use std::{future::Future, pin::Pin, time::Duration};
7
8use anyhow::{Context, ensure};
9use kcode_codex_runtime_v2::{
10 AgentEvent, AgentRequest, DynamicTool, DynamicToolCall, ModelContext, ReasoningEffort,
11 TokenUsage, ToolResult,
12};
13use kcode_intelligence_router::{
14 AgentContinuation, AgentProvider, Intelligence, ResolvedAgentModel, UsageReceipt,
15};
16use serde_json::{Value, json};
17use sha2::{Digest, Sha256};
18use uuid::Uuid;
19
20pub const DEFAULT_ROUND_LIMIT: u64 = 250;
22const PROTOCOL_TOKEN_RESERVE: u64 = 4_096;
23const SUPERSEDED_TOOL_OUTPUT: &str = "[Tool output was displayed here, but has since been updated and now appears elsewhere in the context]";
24const REMOVED_TOOL_OUTPUT: &str = "[Tool output was displayed here, but is no longer current]";
25
26pub type HostFuture<'a, T> = Pin<Box<dyn Future<Output = anyhow::Result<T>> + Send + 'a>>;
28
29#[derive(Clone, Debug, PartialEq)]
31pub struct ToolCall {
32 pub name: String,
34 pub arguments: Value,
36}
37
38#[derive(Clone, Debug, Eq, PartialEq)]
40pub struct ToolCallError {
41 kind: ToolCallErrorKind,
42}
43
44#[derive(Clone, Copy, Debug, Eq, PartialEq)]
45enum ToolCallErrorKind {
46 UnknownTool,
47 ArgumentsNotObject,
48 UnknownArgument,
49 InvalidName,
50 InvalidArguments,
51}
52
53impl ToolCallError {
54 fn new(kind: ToolCallErrorKind) -> Self {
55 Self { kind }
56 }
57}
58
59impl std::fmt::Display for ToolCallError {
60 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61 formatter.write_str(match self.kind {
62 ToolCallErrorKind::UnknownTool => "unknown provider tool",
63 ToolCallErrorKind::ArgumentsNotObject => "call_ktool arguments must be an object",
64 ToolCallErrorKind::UnknownArgument => "call_ktool contains unknown arguments",
65 ToolCallErrorKind::InvalidName => "call_ktool.name must be a non-empty bounded string",
66 ToolCallErrorKind::InvalidArguments => "call_ktool.arguments must be an object",
67 })
68 }
69}
70
71impl std::error::Error for ToolCallError {}
72
73#[derive(Clone, Debug, PartialEq)]
75pub enum AuditEvent {
76 Started {
78 parent_operation_id: Uuid,
80 model: String,
82 provider_model: String,
84 provider: AgentProvider,
86 context_window_tokens: u64,
88 max_input_tokens: u64,
90 context: Vec<String>,
92 task: String,
94 host: Value,
96 },
97 InferenceSubmitted {
99 parent_operation_id: Uuid,
101 round: u64,
103 manifest_hash: String,
105 estimated_input_tokens: u64,
107 },
108 ToolCall {
110 parent_operation_id: Uuid,
112 name: String,
114 arguments: Value,
116 },
117 ToolResult {
119 parent_operation_id: Uuid,
121 name: String,
123 ok: bool,
125 projection_accepted: bool,
127 result: String,
129 },
130 ProviderReceipt {
132 parent_operation_id: Uuid,
134 round: u64,
136 manifest_hash: String,
138 usage: Option<kcode_codex_runtime_v2::TokenUsage>,
140 receipt: Box<UsageReceipt>,
142 },
143 Completed {
145 parent_operation_id: Uuid,
147 model: String,
149 response: String,
151 },
152}
153
154#[derive(Clone, Debug, Eq, PartialEq)]
156pub struct StateUpdate {
157 pub key: String,
159 pub text: Option<String>,
161}
162
163#[derive(Clone, Debug, PartialEq)]
165pub struct ToolOutcome {
166 pub text: String,
168 pub ok: bool,
170 pub state_updates: Vec<StateUpdate>,
172 pub displayed_state_keys: Vec<String>,
178 pub capture: Option<Value>,
180}
181
182impl ToolOutcome {
183 pub fn success(text: impl Into<String>) -> Self {
185 Self {
186 text: text.into(),
187 ok: true,
188 state_updates: Vec::new(),
189 displayed_state_keys: Vec::new(),
190 capture: None,
191 }
192 }
193
194 pub fn failure(text: impl Into<String>) -> Self {
196 Self {
197 text: text.into(),
198 ok: false,
199 state_updates: Vec::new(),
200 displayed_state_keys: Vec::new(),
201 capture: None,
202 }
203 }
204}
205
206#[derive(Clone)]
208pub struct ContextBudget {
209 projection: Projection,
210 max_input_tokens: u64,
211}
212
213impl ContextBudget {
214 pub fn estimated_tokens(&self) -> u64 {
216 self.projection.estimated_tokens()
217 }
218
219 pub fn max_input_tokens(&self) -> u64 {
221 self.max_input_tokens
222 }
223
224 pub fn fits_state(&self, key: impl Into<String>, text: impl Into<String>) -> bool {
226 let mut projection = self.projection.clone();
227 projection.apply_updates(&[StateUpdate {
228 key: key.into(),
229 text: Some(text.into()),
230 }]);
231 projection.estimated_tokens() <= self.max_input_tokens
232 }
233}
234
235pub trait Host: Send {
237 fn render_tool_call(&mut self, call: &ToolCall) -> anyhow::Result<String>;
239
240 fn execute_tool<'a>(
242 &'a mut self,
243 call: ToolCall,
244 operation_id: Uuid,
245 budget: ContextBudget,
246 ) -> HostFuture<'a, ToolOutcome>;
247
248 fn complete_capture<'a>(
250 &'a mut self,
251 capture: Value,
252 contents: String,
253 budget: ContextBudget,
254 ) -> HostFuture<'a, ToolOutcome>;
255
256 fn record(&mut self, event: AuditEvent) -> anyhow::Result<()>;
258}
259
260#[derive(Clone, Debug, Eq, PartialEq)]
262pub struct SessionRunRequest {
263 pub user_id: String,
265 pub operation_id: Uuid,
267 pub rounds_used: u64,
269 pub round_limit: u64,
271}
272
273#[derive(Clone, Debug, Eq, PartialEq)]
275pub struct PreparedRound {
276 pub input: String,
278 pub provider_input: String,
280 pub continuation: Option<AgentContinuation>,
282 pub model: String,
284 pub reasoning_effort: String,
286 pub tool_description: String,
288 pub timeout: Option<Duration>,
290}
291
292#[derive(Clone, Debug, Eq, PartialEq)]
294pub struct SessionInferenceRequest {
295 pub user_id: String,
297 pub operation_id: Uuid,
299 pub round: u64,
301 pub prepared: PreparedRound,
303}
304
305#[derive(Clone, Debug, PartialEq)]
307pub enum SessionInferenceEvent {
308 ProviderInput {
310 context: ModelContext,
312 },
313 UsageUpdated {
315 usage: TokenUsage,
317 },
318 ToolCall {
320 call_id: String,
322 call: Result<ToolCall, ToolCallError>,
324 },
325 Completed {
327 answer: String,
329 usage: Option<TokenUsage>,
331 receipt: Box<UsageReceipt>,
333 continuation: Option<AgentContinuation>,
335 },
336}
337
338pub struct SessionInference {
343 turn: kcode_intelligence_router::AgentTurn,
344 round: u64,
345 state: SessionInferenceState,
346}
347
348#[derive(Clone, Copy, Debug, Eq, PartialEq)]
349enum SessionInferenceState {
350 Active,
351 Terminal,
352}
353
354impl SessionInferenceState {
355 fn require_active(self, round: u64) -> anyhow::Result<()> {
356 ensure!(
357 self == Self::Active,
358 "session inference for round {round} is already terminal"
359 );
360 Ok(())
361 }
362
363 fn finish(&mut self, round: u64) -> anyhow::Result<()> {
364 self.require_active(round)?;
365 *self = Self::Terminal;
366 Ok(())
367 }
368}
369
370#[derive(Debug)]
371struct SessionInferenceFailure {
372 receipt: Option<UsageReceipt>,
373 source: Box<dyn std::error::Error + Send + Sync>,
374}
375
376impl SessionInferenceFailure {
377 fn new<E>(receipt: Option<UsageReceipt>, source: E) -> Self
378 where
379 E: std::error::Error + Send + Sync + 'static,
380 {
381 Self {
382 receipt,
383 source: Box::new(source),
384 }
385 }
386}
387
388impl std::fmt::Display for SessionInferenceFailure {
389 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
390 std::fmt::Display::fmt(&self.source, formatter)
391 }
392}
393
394impl std::error::Error for SessionInferenceFailure {
395 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
396 Some(self.source.as_ref())
397 }
398}
399
400fn attached_session_receipt(error: &anyhow::Error) -> Option<&UsageReceipt> {
401 for cause in error.chain() {
402 if let Some(failure) = cause.downcast_ref::<SessionInferenceFailure>() {
403 return failure.receipt.as_ref();
404 }
405 }
406 None
407}
408
409impl SessionInference {
410 pub async fn next_event(&mut self) -> anyhow::Result<Option<SessionInferenceEvent>> {
415 self.state.require_active(self.round)?;
416 loop {
417 let event = match self.turn.next_event().await {
418 Ok(event) => event,
419 Err(error) => {
420 let receipt = error.receipt().cloned();
421 if receipt.is_some() {
422 self.state.finish(self.round)?;
423 }
424 return Err(SessionInferenceFailure::new(receipt, error).into());
425 }
426 };
427 let Some(event) = event else {
428 return Ok(None);
429 };
430 match event {
431 AgentEvent::ProviderInput(_) => {}
432 AgentEvent::ModelContextSubmitted(context) => {
433 return Ok(Some(SessionInferenceEvent::ProviderInput { context }));
434 }
435 AgentEvent::UsageUpdated(usage) => {
436 return Ok(Some(SessionInferenceEvent::UsageUpdated { usage }));
437 }
438 AgentEvent::ToolCall(native) => {
439 let call = parse_ktool_call(&native);
440 return Ok(Some(SessionInferenceEvent::ToolCall {
441 call_id: native.call_id,
442 call,
443 }));
444 }
445 AgentEvent::Completed(completed) => {
446 let receipt = self
447 .turn
448 .receipt()
449 .context("provider completed without a usage receipt")?
450 .clone();
451 let continuation = self.turn.continuation().cloned();
452 self.state.finish(self.round)?;
453 return Ok(Some(SessionInferenceEvent::Completed {
454 answer: completed.answer,
455 usage: completed.usage,
456 receipt: Box::new(receipt),
457 continuation,
458 }));
459 }
460 }
461 }
462 }
463
464 pub async fn respond(&mut self, call_id: &str, result: ToolResult) -> anyhow::Result<()> {
469 self.state.require_active(self.round)?;
470 self.turn
471 .respond(call_id, result)
472 .await
473 .map_err(anyhow::Error::new)
474 }
475
476 pub fn finish_unavailable(&mut self) -> anyhow::Result<Box<UsageReceipt>> {
478 self.state.require_active(self.round)?;
479 let receipt = self.turn.finish_unavailable()?.clone();
480 self.state.finish(self.round)?;
481 Ok(Box::new(receipt))
482 }
483}
484
485#[derive(Clone, Debug, Eq, PartialEq)]
487pub enum RoundPreparation {
488 Run(PreparedRound),
490 Complete(Option<String>),
492}
493
494#[derive(Clone, Debug, PartialEq)]
496pub enum SessionEvent {
497 InferenceSubmitted {
499 round: u64,
501 manifest_hash: String,
503 model: String,
505 },
506 ProviderInput {
508 round: u64,
510 context: ModelContext,
512 },
513 UsageUpdated {
515 round: u64,
517 usage: TokenUsage,
519 },
520 ProviderReceipt {
522 round: u64,
524 usage: Option<TokenUsage>,
526 receipt: Box<UsageReceipt>,
528 continuation: Option<AgentContinuation>,
530 },
531}
532
533#[derive(Clone, Debug, PartialEq)]
535pub struct SessionToolOutcome {
536 pub text: String,
538 pub ok: bool,
540 pub capture: Option<Value>,
542 pub stop: bool,
544 pub finish_after_round: bool,
546 pub emitted_response: bool,
548}
549
550impl SessionToolOutcome {
551 pub fn success(text: impl Into<String>) -> Self {
553 Self {
554 text: text.into(),
555 ok: true,
556 capture: None,
557 stop: false,
558 finish_after_round: false,
559 emitted_response: false,
560 }
561 }
562
563 pub fn failure(text: impl Into<String>) -> Self {
565 Self {
566 text: text.into(),
567 ok: false,
568 capture: None,
569 stop: false,
570 finish_after_round: false,
571 emitted_response: false,
572 }
573 }
574}
575
576#[derive(Clone, Debug, PartialEq)]
578pub enum ProviderResume {
579 Continue(SessionToolOutcome),
581 Complete(Option<String>),
583 RestartFresh,
585}
586
587enum ProviderResumeAction {
588 Respond(SessionToolOutcome),
589 Interrupt(PostReceiptDisposition),
590}
591
592enum PostReceiptDisposition {
593 Return(Option<String>),
594 NextRound,
595}
596
597fn provider_resume_action(resume: ProviderResume) -> ProviderResumeAction {
598 match resume {
599 ProviderResume::Continue(outcome) => ProviderResumeAction::Respond(outcome),
600 ProviderResume::Complete(answer) => {
601 ProviderResumeAction::Interrupt(PostReceiptDisposition::Return(answer))
602 }
603 ProviderResume::RestartFresh => {
604 ProviderResumeAction::Interrupt(PostReceiptDisposition::NextRound)
605 }
606 }
607}
608
609#[derive(Clone, Debug, Eq, PartialEq)]
611pub struct RoundCompletion {
612 pub answer: String,
614 pub used_tool: bool,
616 pub finish_requested: bool,
618 pub emitted_response: bool,
620}
621
622#[derive(Clone, Debug, Eq, PartialEq)]
624pub enum SessionControl {
625 Continue,
627 Complete(Option<String>),
629}
630
631pub trait SessionHost: Send {
633 fn prepare_round<'a>(&'a mut self, round: u64) -> HostFuture<'a, RoundPreparation>;
635
636 fn record<'a>(&'a mut self, event: SessionEvent) -> HostFuture<'a, ()>;
638
639 fn execute_tool<'a>(
641 &'a mut self,
642 call: anyhow::Result<ToolCall>,
643 provider_operation_id: Uuid,
644 ) -> HostFuture<'a, SessionToolOutcome>;
645
646 fn prepare_provider_resume<'a>(
648 &'a mut self,
649 outcome: SessionToolOutcome,
650 ) -> HostFuture<'a, ProviderResume> {
651 Box::pin(async move { Ok(ProviderResume::Continue(outcome)) })
652 }
653
654 fn complete_capture<'a>(
656 &'a mut self,
657 capture: Value,
658 contents: String,
659 ) -> HostFuture<'a, SessionControl>;
660
661 fn complete_round<'a>(
663 &'a mut self,
664 completion: RoundCompletion,
665 ) -> HostFuture<'a, SessionControl>;
666}
667
668#[derive(Debug)]
670pub struct SessionRoundLimitError {
671 limit: u64,
672}
673
674impl std::fmt::Display for SessionRoundLimitError {
675 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
676 write!(
677 formatter,
678 "agent exceeded the {}-round tool-loop safety limit",
679 self.limit
680 )
681 }
682}
683
684impl std::error::Error for SessionRoundLimitError {}
685
686pub fn is_session_round_limit(error: &anyhow::Error) -> bool {
688 error.downcast_ref::<SessionRoundLimitError>().is_some()
689}
690
691#[derive(Clone, Debug, PartialEq)]
693pub struct RunRequest {
694 pub user_id: String,
696 pub parent_operation_id: Uuid,
698 pub model: String,
700 pub reasoning_effort: String,
702 pub context: Vec<String>,
704 pub task: String,
706 pub timeout: Option<Duration>,
708 pub start_metadata: Value,
710}
711
712#[derive(Clone, Debug, Eq, PartialEq)]
714pub struct RunResult {
715 pub answer: String,
717 pub model: ResolvedAgentModel,
719}
720
721#[derive(Clone)]
723pub struct AgentRuntime {
724 intelligence: Intelligence,
725}
726
727impl AgentRuntime {
728 pub fn new(intelligence: Intelligence) -> Self {
730 Self { intelligence }
731 }
732
733 pub async fn resolve_model(&self, requested: &str) -> anyhow::Result<ResolvedAgentModel> {
735 self.intelligence
736 .resolve_agent_model(requested)
737 .await
738 .map_err(anyhow::Error::new)
739 }
740
741 pub async fn start_session_inference(
743 &self,
744 request: SessionInferenceRequest,
745 ) -> anyhow::Result<SessionInference> {
746 let user = self
747 .intelligence
748 .for_user(request.user_id)
749 .map_err(anyhow::Error::new)?;
750 let prepared = request.prepared;
751 let mut provider_request = AgentRequest::new(prepared.provider_input, prepared.model);
752 provider_request.reasoning_effort = reasoning_effort(&prepared.reasoning_effort)?;
753 provider_request.tools = vec![ktool_definition(&prepared.tool_description)];
754 if let Some(timeout) = prepared.timeout {
755 provider_request.timeout = timeout;
756 }
757 let turn = match user
758 .start_agent_turn(
759 request.operation_id,
760 None,
761 prepared.continuation,
762 provider_request,
763 )
764 .await
765 {
766 Ok(turn) => turn,
767 Err(error) => {
768 let receipt = error.receipt().cloned();
769 return Err(SessionInferenceFailure::new(receipt, error).into());
770 }
771 };
772 Ok(SessionInference {
773 turn,
774 round: request.round,
775 state: SessionInferenceState::Active,
776 })
777 }
778
779 pub async fn run_session<H: SessionHost>(
781 &self,
782 request: SessionRunRequest,
783 host: &mut H,
784 ) -> anyhow::Result<Option<String>> {
785 ensure!(
786 request.round_limit > 0,
787 "session round limit must be positive"
788 );
789 ensure!(
790 request.rounds_used <= request.round_limit,
791 "restored session round count exceeds its safety limit"
792 );
793
794 'rounds: for round_index in request.rounds_used..request.round_limit {
795 let round = round_index + 1;
796 let prepared = match host.prepare_round(round).await? {
797 RoundPreparation::Run(prepared) => prepared,
798 RoundPreparation::Complete(answer) => return Ok(answer),
799 };
800 let manifest_hash = hex::encode(Sha256::digest(prepared.input.as_bytes()));
801 host.record(SessionEvent::InferenceSubmitted {
802 round,
803 manifest_hash: manifest_hash.clone(),
804 model: prepared.model.clone(),
805 })
806 .await?;
807
808 let mut inference = match self
809 .start_session_inference(SessionInferenceRequest {
810 user_id: request.user_id.clone(),
811 operation_id: request.operation_id,
812 round,
813 prepared,
814 })
815 .await
816 {
817 Ok(inference) => inference,
818 Err(error) => {
819 if let Some(receipt) = attached_session_receipt(&error).cloned() {
820 host.record(SessionEvent::ProviderReceipt {
821 round,
822 usage: None,
823 receipt: Box::new(receipt),
824 continuation: None,
825 })
826 .await?;
827 }
828 return Err(error);
829 }
830 };
831 let mut used_tool = false;
832 let mut finish_requested = false;
833 let mut emitted_response = false;
834 let mut pending_capture: Option<Value> = None;
835 let completed = loop {
836 let event = match inference.next_event().await {
837 Ok(Some(event)) => event,
838 Ok(None) => {
839 record_unavailable_session_inference(&mut inference, host, round).await?;
840 anyhow::bail!("provider ended without a terminal turn event");
841 }
842 Err(error) => {
843 if let Some(receipt) = attached_session_receipt(&error).cloned() {
844 host.record(SessionEvent::ProviderReceipt {
845 round,
846 usage: None,
847 receipt: Box::new(receipt),
848 continuation: None,
849 })
850 .await?;
851 }
852 return Err(error);
853 }
854 };
855 match event {
856 SessionInferenceEvent::ProviderInput { context } => {
857 host.record(SessionEvent::ProviderInput { round, context })
858 .await?;
859 }
860 SessionInferenceEvent::UsageUpdated { usage } => {
861 host.record(SessionEvent::UsageUpdated { round, usage })
862 .await?;
863 }
864 SessionInferenceEvent::ToolCall { call_id, call } => {
865 used_tool = true;
866 let call =
867 call.map_err(|error| anyhow::anyhow!("Invalid Ktool call: {error}"));
868 let outcome = match host.execute_tool(call, request.operation_id).await {
869 Ok(outcome) => outcome,
870 Err(error) => {
871 record_unavailable_session_inference(&mut inference, host, round)
872 .await?;
873 return Err(error);
874 }
875 };
876 let resume = match host.prepare_provider_resume(outcome).await {
877 Ok(resume) => resume,
878 Err(error) => {
879 record_unavailable_session_inference(&mut inference, host, round)
880 .await?;
881 return Err(error);
882 }
883 };
884 let mut outcome = match provider_resume_action(resume) {
885 ProviderResumeAction::Respond(outcome) => outcome,
886 ProviderResumeAction::Interrupt(disposition) => {
887 record_unavailable_session_inference(&mut inference, host, round)
888 .await?;
889 match disposition {
890 PostReceiptDisposition::Return(answer) => return Ok(answer),
891 PostReceiptDisposition::NextRound => continue 'rounds,
892 }
893 }
894 };
895 finish_requested |= outcome.ok && outcome.finish_after_round;
896 emitted_response |= outcome.ok && outcome.emitted_response;
897 pending_capture = outcome.capture.take();
898 let stop = outcome.stop;
899 respond_session_or_record(
900 &mut inference,
901 host,
902 round,
903 &call_id,
904 if outcome.ok {
905 ToolResult::success(outcome.text)
906 } else {
907 ToolResult::failure(outcome.text)
908 },
909 )
910 .await?;
911 if stop {
912 return Ok(None);
913 }
914 }
915 SessionInferenceEvent::Completed {
916 answer,
917 usage,
918 receipt,
919 continuation,
920 } => {
921 host.record(SessionEvent::ProviderReceipt {
922 round,
923 usage,
924 receipt,
925 continuation,
926 })
927 .await?;
928 break answer;
929 }
930 }
931 };
932
933 let control = if let Some(capture) = pending_capture {
934 host.complete_capture(capture, completed).await?
935 } else {
936 host.complete_round(RoundCompletion {
937 answer: completed,
938 used_tool,
939 finish_requested,
940 emitted_response,
941 })
942 .await?
943 };
944 match control {
945 SessionControl::Continue => {}
946 SessionControl::Complete(answer) => return Ok(answer),
947 }
948 }
949 Err(SessionRoundLimitError {
950 limit: request.round_limit,
951 }
952 .into())
953 }
954
955 pub async fn run<H: Host>(
957 &self,
958 request: RunRequest,
959 host: &mut H,
960 ) -> anyhow::Result<RunResult> {
961 let selected = self.resolve_model(&request.model).await?;
962 let reasoning_effort = reasoning_effort(&request.reasoning_effort)?;
963 let mut projection = Projection::new(request.context, request.task);
964 ensure_capacity(&projection, selected.max_input_tokens)?;
965 host.record(AuditEvent::Started {
966 parent_operation_id: request.parent_operation_id,
967 model: request.model.clone(),
968 provider_model: selected.provider_model.clone(),
969 provider: selected.provider,
970 context_window_tokens: selected.context_window_tokens,
971 max_input_tokens: selected.max_input_tokens,
972 context: projection.context.clone(),
973 task: projection.task.clone(),
974 host: request.start_metadata.clone(),
975 })?;
976 let user = self
977 .intelligence
978 .for_user(request.user_id)
979 .map_err(anyhow::Error::new)?;
980 for round in 0..1 {
981 let capturing = false;
982 ensure_capacity(&projection, selected.max_input_tokens)?;
983 let input = projection.render();
984 let manifest_hash = hex::encode(Sha256::digest(input.as_bytes()));
985 host.record(AuditEvent::InferenceSubmitted {
986 parent_operation_id: request.parent_operation_id,
987 round: round + 1,
988 manifest_hash: manifest_hash.clone(),
989 estimated_input_tokens: projection.estimated_tokens(),
990 })?;
991 let mut provider_request = AgentRequest::new(input, selected.requested_model.clone());
992 provider_request.reasoning_effort = reasoning_effort;
993 provider_request.ephemeral = true;
994 provider_request.tools = if capturing {
995 Vec::new()
996 } else {
997 vec![ktool_definition(
998 "Call one available Ktool by its exact name.",
999 )]
1000 };
1001 if let Some(timeout) = request.timeout {
1002 provider_request.timeout = timeout;
1003 }
1004 let child_operation_id = Uuid::new_v4();
1005 let mut turn = match user
1006 .start_agent_turn(
1007 child_operation_id,
1008 Some(request.parent_operation_id),
1009 None,
1010 provider_request,
1011 )
1012 .await
1013 {
1014 Ok(turn) => turn,
1015 Err(error) => {
1016 if let Some(receipt) = error.receipt().cloned() {
1017 host.record(AuditEvent::ProviderReceipt {
1018 parent_operation_id: request.parent_operation_id,
1019 round: round + 1,
1020 manifest_hash,
1021 usage: None,
1022 receipt: Box::new(receipt),
1023 })?;
1024 }
1025 return Err(anyhow::Error::new(error));
1026 }
1027 };
1028 let mut used_tool = false;
1029 let mut pending_capture: Option<Value> = None;
1030 let completed = loop {
1031 let event = match turn.next_event().await {
1032 Ok(Some(event)) => event,
1033 Ok(None) => {
1034 let receipt = turn.finish_unavailable()?.clone();
1035 host.record(AuditEvent::ProviderReceipt {
1036 parent_operation_id: request.parent_operation_id,
1037 round: round + 1,
1038 manifest_hash: manifest_hash.clone(),
1039 usage: None,
1040 receipt: Box::new(receipt),
1041 })?;
1042 anyhow::bail!("subagent provider ended without a terminal turn event");
1043 }
1044 Err(error) => {
1045 if let Some(receipt) = error.receipt().cloned() {
1046 host.record(AuditEvent::ProviderReceipt {
1047 parent_operation_id: request.parent_operation_id,
1048 round: round + 1,
1049 manifest_hash: manifest_hash.clone(),
1050 usage: None,
1051 receipt: Box::new(receipt),
1052 })?;
1053 }
1054 return Err(anyhow::Error::new(error));
1055 }
1056 };
1057 match event {
1058 AgentEvent::ProviderInput(_) => {}
1059 AgentEvent::ModelContextSubmitted(_) => {}
1060 AgentEvent::UsageUpdated(_) => {}
1061 AgentEvent::ToolCall(native) => {
1062 used_tool = true;
1063 if capturing {
1064 respond_or_record(
1065 &mut turn,
1066 host,
1067 request.parent_operation_id,
1068 round + 1,
1069 &manifest_hash,
1070 &native.call_id,
1071 ToolResult::failure(
1072 "No application tool is available while complete freeform output is being captured.",
1073 ),
1074 )
1075 .await?;
1076 continue;
1077 }
1078 if pending_capture.is_some() {
1079 respond_or_record(
1080 &mut turn,
1081 host,
1082 request.parent_operation_id,
1083 round + 1,
1084 &manifest_hash,
1085 &native.call_id,
1086 ToolResult::failure(
1087 "A freeform output capture is pending; no other tool can run first.",
1088 ),
1089 )
1090 .await?;
1091 continue;
1092 }
1093 let call = match parse_ktool_call(&native) {
1094 Ok(call) => call,
1095 Err(error) => {
1096 let text = format!("Invalid application tool call: {error}");
1097 projection.push_history(format!("Ktool result:\n{text}"));
1098 respond_or_record(
1099 &mut turn,
1100 host,
1101 request.parent_operation_id,
1102 round + 1,
1103 &manifest_hash,
1104 &native.call_id,
1105 ToolResult::failure(text),
1106 )
1107 .await?;
1108 continue;
1109 }
1110 };
1111 host.record(AuditEvent::ToolCall {
1112 parent_operation_id: request.parent_operation_id,
1113 name: call.name.clone(),
1114 arguments: call.arguments.clone(),
1115 })?;
1116 projection.push_history(format!(
1117 "Ktool call:\n{}",
1118 host.render_tool_call(&call)?
1119 ));
1120 let budget = ContextBudget {
1121 projection: projection.clone(),
1122 max_input_tokens: selected.max_input_tokens,
1123 };
1124 let mut outcome = host
1125 .execute_tool(call.clone(), child_operation_id, budget)
1126 .await
1127 .unwrap_or_else(|error| {
1128 ToolOutcome::failure(format!("{} failed: {error}", call.name))
1129 });
1130 let exact_result = outcome.text.clone();
1131 let initially_ok = outcome.ok;
1132 let mut provider_result = outcome.text.clone();
1133 let mut candidate = projection.clone();
1134 candidate.apply_updates(successful_state_updates(&outcome));
1135 candidate.push_history_displaying(
1136 format!("Ktool result:\n{provider_result}"),
1137 if initially_ok {
1138 outcome.displayed_state_keys.clone()
1139 } else {
1140 Default::default()
1141 },
1142 );
1143 let accepted = candidate.estimated_tokens() <= selected.max_input_tokens;
1144 if accepted {
1145 projection = candidate;
1146 } else {
1147 outcome.ok = false;
1148 outcome.capture = None;
1149 provider_result = "The tool ran, but its result or updated state could not fit in the subagent context. Do not retry it; report the capacity failure to Kennedy.".into();
1150 projection.push_history(format!("Ktool result:\n{provider_result}"));
1151 }
1152 host.record(AuditEvent::ToolResult {
1153 parent_operation_id: request.parent_operation_id,
1154 name: call.name.clone(),
1155 ok: initially_ok,
1156 projection_accepted: accepted,
1157 result: exact_result,
1158 })?;
1159 pending_capture = outcome.capture.take();
1160 respond_or_record(
1161 &mut turn,
1162 host,
1163 request.parent_operation_id,
1164 round + 1,
1165 &manifest_hash,
1166 &native.call_id,
1167 if outcome.ok {
1168 ToolResult::success(provider_result)
1169 } else {
1170 ToolResult::failure(provider_result)
1171 },
1172 )
1173 .await?;
1174 }
1175 AgentEvent::Completed(completed) => break completed,
1176 }
1177 };
1178 let receipt = turn
1179 .receipt()
1180 .context("subagent provider completed without a usage receipt")?
1181 .clone();
1182 host.record(AuditEvent::ProviderReceipt {
1183 parent_operation_id: request.parent_operation_id,
1184 round: round + 1,
1185 manifest_hash: manifest_hash.clone(),
1186 usage: completed.usage.clone(),
1187 receipt: Box::new(receipt),
1188 })?;
1189
1190 if let Some(capture) = pending_capture {
1191 ensure!(
1192 !completed.answer.trim().is_empty(),
1193 "subagent provider completed an output capture without contents"
1194 );
1195 let budget = ContextBudget {
1196 projection: projection.clone(),
1197 max_input_tokens: selected.max_input_tokens,
1198 };
1199 let outcome = host
1200 .complete_capture(capture, completed.answer, budget)
1201 .await?;
1202 let mut candidate = projection.clone();
1203 candidate.apply_updates(successful_state_updates(&outcome));
1204 candidate.push_history(format!("Ktool result:\n{}", outcome.text));
1205 ensure_capacity(&candidate, selected.max_input_tokens)?;
1206 let answer = outcome.text.trim().to_owned();
1207 ensure!(
1208 !answer.is_empty(),
1209 "subagent output capture completed without a result"
1210 );
1211 host.record(AuditEvent::Completed {
1212 parent_operation_id: request.parent_operation_id,
1213 model: request.model.clone(),
1214 response: answer.clone(),
1215 })?;
1216 return Ok(RunResult {
1217 answer,
1218 model: selected,
1219 });
1220 }
1221 let answer = completed.answer.trim().to_owned();
1222 if !answer.is_empty() {
1223 host.record(AuditEvent::Completed {
1224 parent_operation_id: request.parent_operation_id,
1225 model: request.model.clone(),
1226 response: answer.clone(),
1227 })?;
1228 return Ok(RunResult {
1229 answer,
1230 model: selected,
1231 });
1232 }
1233 ensure!(
1234 !used_tool,
1235 "subagent provider completed without a response or tool call"
1236 );
1237 }
1238 anyhow::bail!("subagent provider completed without a terminal response")
1239 }
1240}
1241
1242async fn respond_or_record<H: Host>(
1243 turn: &mut kcode_intelligence_router::AgentTurn,
1244 host: &mut H,
1245 parent_operation_id: Uuid,
1246 round: u64,
1247 manifest_hash: &str,
1248 call_id: &str,
1249 result: ToolResult,
1250) -> anyhow::Result<()> {
1251 if let Err(error) = turn.respond(call_id, result).await {
1252 let receipt = turn.finish_unavailable()?.clone();
1253 host.record(AuditEvent::ProviderReceipt {
1254 parent_operation_id,
1255 round,
1256 manifest_hash: manifest_hash.into(),
1257 usage: None,
1258 receipt: Box::new(receipt),
1259 })?;
1260 return Err(anyhow::Error::new(error));
1261 }
1262 Ok(())
1263}
1264
1265async fn respond_session_or_record<H: SessionHost>(
1266 inference: &mut SessionInference,
1267 host: &mut H,
1268 round: u64,
1269 call_id: &str,
1270 result: ToolResult,
1271) -> anyhow::Result<()> {
1272 if let Err(error) = inference.respond(call_id, result).await {
1273 record_unavailable_session_inference(inference, host, round).await?;
1274 return Err(error);
1275 }
1276 Ok(())
1277}
1278
1279async fn record_unavailable_session_inference<H: SessionHost>(
1280 inference: &mut SessionInference,
1281 host: &mut H,
1282 round: u64,
1283) -> anyhow::Result<()> {
1284 let receipt = inference.finish_unavailable()?;
1285 host.record(SessionEvent::ProviderReceipt {
1286 round,
1287 usage: None,
1288 receipt,
1289 continuation: None,
1290 })
1291 .await
1292}
1293
1294#[derive(Clone)]
1295struct Projection {
1296 context: Vec<String>,
1297 task: String,
1298 history: Vec<ProjectedHistory>,
1299 states: Vec<ProjectedState>,
1300}
1301
1302#[derive(Clone)]
1303struct ProjectedHistory {
1304 text: String,
1305 displayed_state_keys: Vec<String>,
1306}
1307
1308#[derive(Clone)]
1309struct ProjectedState {
1310 key: String,
1311 text: String,
1312}
1313
1314impl Projection {
1315 fn new(context: Vec<String>, task: String) -> Self {
1316 Self {
1317 context,
1318 task,
1319 history: Vec::new(),
1320 states: Vec::new(),
1321 }
1322 }
1323
1324 fn render(&self) -> String {
1325 self.context
1326 .iter()
1327 .map(String::as_str)
1328 .chain(std::iter::once(self.task.as_str()))
1329 .chain(self.history.iter().map(|entry| entry.text.as_str()))
1330 .chain(
1331 self.states
1332 .iter()
1333 .filter(|state| {
1334 !self.history.iter().any(|entry| {
1335 entry
1336 .displayed_state_keys
1337 .iter()
1338 .any(|key| key == &state.key)
1339 })
1340 })
1341 .map(|state| state.text.as_str()),
1342 )
1343 .filter(|section| !section.is_empty())
1344 .collect::<Vec<_>>()
1345 .join("\n\n")
1346 }
1347
1348 fn push_history(&mut self, text: impl Into<String>) {
1349 self.push_history_displaying(text, Vec::new());
1350 }
1351
1352 fn push_history_displaying(
1353 &mut self,
1354 text: impl Into<String>,
1355 displayed_state_keys: Vec<String>,
1356 ) {
1357 self.history.push(ProjectedHistory {
1358 text: text.into(),
1359 displayed_state_keys,
1360 });
1361 }
1362
1363 fn update_state(&mut self, key: String, text: Option<String>) {
1364 self.states.retain(|state| state.key != key);
1365 if let Some(text) = text {
1366 self.states.push(ProjectedState { key, text });
1367 }
1368 }
1369
1370 fn apply_updates(&mut self, updates: &[StateUpdate]) {
1371 for update in updates {
1372 let marker = if update.text.is_some() {
1373 SUPERSEDED_TOOL_OUTPUT
1374 } else {
1375 REMOVED_TOOL_OUTPUT
1376 };
1377 for entry in &mut self.history {
1378 if entry
1379 .displayed_state_keys
1380 .iter()
1381 .any(|key| key == &update.key)
1382 {
1383 entry.text = marker.into();
1384 entry.displayed_state_keys.clear();
1385 }
1386 }
1387 self.update_state(update.key.clone(), update.text.clone());
1388 }
1389 }
1390
1391 fn estimated_tokens(&self) -> u64 {
1392 (self.render().chars().count() as u64)
1393 .div_ceil(4)
1394 .saturating_add(PROTOCOL_TOKEN_RESERVE)
1395 }
1396}
1397
1398fn successful_state_updates(outcome: &ToolOutcome) -> &[StateUpdate] {
1399 if outcome.ok {
1400 &outcome.state_updates
1401 } else {
1402 &[]
1403 }
1404}
1405
1406fn ensure_capacity(projection: &Projection, max_input_tokens: u64) -> anyhow::Result<()> {
1407 let estimated = projection.estimated_tokens();
1408 ensure!(
1409 estimated <= max_input_tokens,
1410 "subagent context requires approximately {estimated} input tokens, over the selected model's {max_input_tokens}-token input limit"
1411 );
1412 Ok(())
1413}
1414
1415fn ktool_definition(description: &str) -> DynamicTool {
1416 DynamicTool::new(
1417 "call_ktool",
1418 description,
1419 json!({
1420 "type": "object",
1421 "additionalProperties": false,
1422 "required": ["name", "arguments"],
1423 "properties": {
1424 "name": {"type": "string"},
1425 "arguments": {"type": "object"}
1426 }
1427 }),
1428 )
1429}
1430
1431fn parse_ktool_call(call: &DynamicToolCall) -> Result<ToolCall, ToolCallError> {
1432 if call.tool != "call_ktool" {
1433 return Err(ToolCallError::new(ToolCallErrorKind::UnknownTool));
1434 }
1435 let Some(arguments) = call.arguments.as_object() else {
1436 return Err(ToolCallError::new(ToolCallErrorKind::ArgumentsNotObject));
1437 };
1438 if !arguments
1439 .keys()
1440 .all(|key| matches!(key.as_str(), "name" | "arguments"))
1441 {
1442 return Err(ToolCallError::new(ToolCallErrorKind::UnknownArgument));
1443 }
1444 let Some(name) = arguments
1445 .get("name")
1446 .and_then(Value::as_str)
1447 .map(str::trim)
1448 .filter(|name| !name.is_empty() && name.chars().count() <= 100)
1449 else {
1450 return Err(ToolCallError::new(ToolCallErrorKind::InvalidName));
1451 };
1452 let Some(arguments) = arguments
1453 .get("arguments")
1454 .filter(|value| value.is_object())
1455 .cloned()
1456 else {
1457 return Err(ToolCallError::new(ToolCallErrorKind::InvalidArguments));
1458 };
1459 Ok(ToolCall {
1460 name: name.to_owned(),
1461 arguments,
1462 })
1463}
1464
1465fn reasoning_effort(value: &str) -> anyhow::Result<ReasoningEffort> {
1466 Ok(match value {
1467 "none" => ReasoningEffort::None,
1468 "minimal" => ReasoningEffort::Minimal,
1469 "low" => ReasoningEffort::Low,
1470 "medium" => ReasoningEffort::Medium,
1471 "high" => ReasoningEffort::High,
1472 "xhigh" => ReasoningEffort::XHigh,
1473 "max" => ReasoningEffort::Max,
1474 _ => anyhow::bail!("unsupported reasoning effort {value:?}"),
1475 })
1476}
1477
1478#[cfg(test)]
1479mod tests {
1480 use super::*;
1481
1482 #[test]
1483 fn projection_replaces_state_and_budget_accounts_for_reserve() {
1484 let mut projection = Projection::new(vec!["context".into()], "task".into());
1485 projection.update_state("file".into(), Some("old".into()));
1486 projection.update_state("file".into(), Some("new".into()));
1487 assert_eq!(projection.states.len(), 1);
1488 assert!(projection.render().contains("new"));
1489 assert!(!projection.render().contains("old"));
1490 assert!(projection.estimated_tokens() >= PROTOCOL_TOKEN_RESERVE);
1491 }
1492
1493 #[test]
1494 fn state_update_supersedes_earlier_display_and_moves_current_value_after_history() {
1495 let mut projection = Projection::new(vec!["context".into()], "task".into());
1496 projection.push_history("Ktool call: open");
1497 projection.push_history_displaying(
1498 "Ktool result:\ncomplete old source",
1499 vec!["tool-state:7".into()],
1500 );
1501 projection.push_history("Ktool call: write");
1502 projection.apply_updates(&[StateUpdate {
1503 key: "tool-state:7".into(),
1504 text: Some("complete new source".into()),
1505 }]);
1506 projection.push_history("Ktool result: write completed");
1507
1508 let rendered = projection.render();
1509 assert!(!rendered.contains("complete old source"));
1510 assert_eq!(rendered.matches(SUPERSEDED_TOOL_OUTPUT).count(), 1);
1511 assert_eq!(rendered.matches("complete new source").count(), 1);
1512 assert!(
1513 rendered.find(SUPERSEDED_TOOL_OUTPUT).unwrap()
1514 < rendered.find("Ktool call: write").unwrap()
1515 );
1516 assert!(
1517 rendered.find("Ktool result: write completed").unwrap()
1518 < rendered.find("complete new source").unwrap()
1519 );
1520 }
1521
1522 #[test]
1523 fn current_state_is_not_duplicated_when_the_latest_result_displays_it() {
1524 let mut projection = Projection::new(vec!["context".into()], "task".into());
1525 projection.apply_updates(&[StateUpdate {
1526 key: "tool-state:7".into(),
1527 text: Some("complete source".into()),
1528 }]);
1529 projection.push_history_displaying(
1530 "Ktool result:\ncomplete source",
1531 vec!["tool-state:7".into()],
1532 );
1533
1534 assert_eq!(projection.render().matches("complete source").count(), 1);
1535 }
1536
1537 #[test]
1538 fn state_update_only_supersedes_displays_with_the_same_identity() {
1539 let mut projection = Projection::new(vec!["context".into()], "task".into());
1540 projection.push_history_displaying("first output", vec!["first".into()]);
1541 projection.push_history_displaying("second output", vec!["second".into()]);
1542 projection.apply_updates(&[StateUpdate {
1543 key: "first".into(),
1544 text: Some("current first output".into()),
1545 }]);
1546
1547 let rendered = projection.render();
1548 assert!(
1549 !rendered
1550 .split("\n\n")
1551 .any(|section| section == "first output")
1552 );
1553 assert!(rendered.contains(SUPERSEDED_TOOL_OUTPUT));
1554 assert!(rendered.contains("second output"));
1555 assert!(rendered.contains("current first output"));
1556 }
1557
1558 #[test]
1559 fn removed_state_uses_a_truthful_supersession_marker() {
1560 let mut projection = Projection::new(vec!["context".into()], "task".into());
1561 projection.push_history_displaying("retired output", vec!["state".into()]);
1562 projection.apply_updates(&[StateUpdate {
1563 key: "state".into(),
1564 text: None,
1565 }]);
1566
1567 let rendered = projection.render();
1568 assert!(!rendered.contains("retired output"));
1569 assert!(rendered.contains(REMOVED_TOOL_OUTPUT));
1570 }
1571
1572 #[test]
1573 fn failed_outcomes_cannot_update_or_supersede_state() {
1574 let mut projection = Projection::new(vec!["context".into()], "task".into());
1575 projection.push_history_displaying("current output", vec!["state".into()]);
1576 let failed = ToolOutcome {
1577 text: "write failed".into(),
1578 ok: false,
1579 state_updates: vec![StateUpdate {
1580 key: "state".into(),
1581 text: Some("invalid update".into()),
1582 }],
1583 displayed_state_keys: Vec::new(),
1584 capture: None,
1585 };
1586
1587 projection.apply_updates(successful_state_updates(&failed));
1588
1589 let rendered = projection.render();
1590 assert!(rendered.contains("current output"));
1591 assert!(!rendered.contains("invalid update"));
1592 assert!(!rendered.contains(SUPERSEDED_TOOL_OUTPUT));
1593 }
1594
1595 #[test]
1596 fn large_stateful_result_is_rendered_exactly_once() {
1597 let loaded_node = format!(
1598 "Node body:\n{}\n\nFixed connections:\nfixed-node\n\nRecent connections:\nrecent-node",
1599 "x".repeat(2_000)
1600 );
1601 let outcome = ToolOutcome {
1602 text: loaded_node.clone(),
1603 ok: true,
1604 state_updates: vec![StateUpdate {
1605 key: "loaded-node".into(),
1606 text: Some(loaded_node.clone()),
1607 }],
1608 displayed_state_keys: vec!["loaded-node".into()],
1609 capture: None,
1610 };
1611 let mut projection =
1612 Projection::new(vec!["initial node description".into()], "task".into());
1613 let provider_result = outcome.text.clone();
1614 projection.apply_updates(successful_state_updates(&outcome));
1615 projection.push_history_displaying(
1616 format!("Ktool result:\n{provider_result}"),
1617 outcome.displayed_state_keys,
1618 );
1619
1620 let rendered = projection.render();
1621 assert_eq!(rendered.matches(loaded_node.as_str()).count(), 1);
1622 assert!(rendered.contains("Fixed connections:\nfixed-node"));
1623 assert!(rendered.contains("Recent connections:\nrecent-node"));
1624 }
1625
1626 #[test]
1627 fn native_tool_wrapper_is_strict() {
1628 let call = parse_ktool_call(&DynamicToolCall {
1629 call_id: "1".into(),
1630 tool: "call_ktool".into(),
1631 arguments: json!({"name": "Read", "arguments": {"id": 1}}),
1632 })
1633 .unwrap();
1634 assert_eq!(call.name, "Read");
1635 assert_eq!(call.arguments["id"], 1);
1636 }
1637
1638 #[test]
1639 fn native_tool_wrapper_returns_typed_bounded_errors() {
1640 let cases = [
1641 (
1642 DynamicToolCall {
1643 call_id: "1".into(),
1644 tool: "other".into(),
1645 arguments: json!({}),
1646 },
1647 ToolCallErrorKind::UnknownTool,
1648 "unknown provider tool",
1649 ),
1650 (
1651 DynamicToolCall {
1652 call_id: "2".into(),
1653 tool: "call_ktool".into(),
1654 arguments: json!([]),
1655 },
1656 ToolCallErrorKind::ArgumentsNotObject,
1657 "call_ktool arguments must be an object",
1658 ),
1659 (
1660 DynamicToolCall {
1661 call_id: "3".into(),
1662 tool: "call_ktool".into(),
1663 arguments: json!({"name": "Read", "arguments": {}, "extra": true}),
1664 },
1665 ToolCallErrorKind::UnknownArgument,
1666 "call_ktool contains unknown arguments",
1667 ),
1668 (
1669 DynamicToolCall {
1670 call_id: "4".into(),
1671 tool: "call_ktool".into(),
1672 arguments: json!({"name": " ", "arguments": {}}),
1673 },
1674 ToolCallErrorKind::InvalidName,
1675 "call_ktool.name must be a non-empty bounded string",
1676 ),
1677 (
1678 DynamicToolCall {
1679 call_id: "5".into(),
1680 tool: "call_ktool".into(),
1681 arguments: json!({"name": "Read", "arguments": []}),
1682 },
1683 ToolCallErrorKind::InvalidArguments,
1684 "call_ktool.arguments must be an object",
1685 ),
1686 ];
1687
1688 for (call, kind, message) in cases {
1689 let error: ToolCallError = parse_ktool_call(&call).unwrap_err();
1690 assert_eq!(error, ToolCallError::new(kind));
1691 assert_eq!(error.to_string(), message);
1692 assert!(error.to_string().len() <= 64);
1693 }
1694 }
1695
1696 #[test]
1697 fn inference_state_terminalization_is_single_use() {
1698 let mut state = SessionInferenceState::Active;
1699 state.require_active(7).unwrap();
1700 state.finish(7).unwrap();
1701
1702 let error = state.require_active(7).unwrap_err();
1703 assert_eq!(
1704 error.to_string(),
1705 "session inference for round 7 is already terminal"
1706 );
1707 assert!(state.finish(7).is_err());
1708 }
1709
1710 #[test]
1711 fn restart_fresh_selects_interrupt_then_next_round_disposition() {
1712 let ProviderResumeAction::Interrupt(disposition) =
1713 provider_resume_action(ProviderResume::RestartFresh)
1714 else {
1715 panic!("restart-fresh must not select native response");
1716 };
1717 assert!(matches!(disposition, PostReceiptDisposition::NextRound));
1718 }
1719
1720 #[test]
1721 fn existing_provider_resume_actions_keep_their_prior_paths() {
1722 let outcome = SessionToolOutcome::success("durable result");
1723 assert!(matches!(
1724 provider_resume_action(ProviderResume::Continue(outcome.clone())),
1725 ProviderResumeAction::Respond(returned) if returned == outcome
1726 ));
1727 let ProviderResumeAction::Interrupt(disposition) =
1728 provider_resume_action(ProviderResume::Complete(Some("done".into())))
1729 else {
1730 panic!("complete must not select native response");
1731 };
1732 assert!(matches!(
1733 disposition,
1734 PostReceiptDisposition::Return(Some(answer)) if answer == "done"
1735 ));
1736 }
1737}