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, PartialEq)]
40pub enum AuditEvent {
41 Started {
43 parent_operation_id: Uuid,
45 model: String,
47 provider_model: String,
49 provider: AgentProvider,
51 context_window_tokens: u64,
53 max_input_tokens: u64,
55 context: Vec<String>,
57 task: String,
59 host: Value,
61 },
62 InferenceSubmitted {
64 parent_operation_id: Uuid,
66 round: u64,
68 manifest_hash: String,
70 estimated_input_tokens: u64,
72 },
73 ToolCall {
75 parent_operation_id: Uuid,
77 name: String,
79 arguments: Value,
81 },
82 ToolResult {
84 parent_operation_id: Uuid,
86 name: String,
88 ok: bool,
90 projection_accepted: bool,
92 result: String,
94 },
95 ProviderReceipt {
97 parent_operation_id: Uuid,
99 round: u64,
101 manifest_hash: String,
103 usage: Option<kcode_codex_runtime_v2::TokenUsage>,
105 receipt: Box<UsageReceipt>,
107 },
108 Completed {
110 parent_operation_id: Uuid,
112 model: String,
114 response: String,
116 },
117}
118
119#[derive(Clone, Debug, Eq, PartialEq)]
121pub struct StateUpdate {
122 pub key: String,
124 pub text: Option<String>,
126}
127
128#[derive(Clone, Debug, PartialEq)]
130pub struct ToolOutcome {
131 pub text: String,
133 pub ok: bool,
135 pub state_updates: Vec<StateUpdate>,
137 pub displayed_state_keys: Vec<String>,
143 pub capture: Option<Value>,
145}
146
147impl ToolOutcome {
148 pub fn success(text: impl Into<String>) -> Self {
150 Self {
151 text: text.into(),
152 ok: true,
153 state_updates: Vec::new(),
154 displayed_state_keys: Vec::new(),
155 capture: None,
156 }
157 }
158
159 pub fn failure(text: impl Into<String>) -> Self {
161 Self {
162 text: text.into(),
163 ok: false,
164 state_updates: Vec::new(),
165 displayed_state_keys: Vec::new(),
166 capture: None,
167 }
168 }
169}
170
171#[derive(Clone)]
173pub struct ContextBudget {
174 projection: Projection,
175 max_input_tokens: u64,
176}
177
178impl ContextBudget {
179 pub fn estimated_tokens(&self) -> u64 {
181 self.projection.estimated_tokens()
182 }
183
184 pub fn max_input_tokens(&self) -> u64 {
186 self.max_input_tokens
187 }
188
189 pub fn fits_state(&self, key: impl Into<String>, text: impl Into<String>) -> bool {
191 let mut projection = self.projection.clone();
192 projection.apply_updates(&[StateUpdate {
193 key: key.into(),
194 text: Some(text.into()),
195 }]);
196 projection.estimated_tokens() <= self.max_input_tokens
197 }
198}
199
200pub trait Host: Send {
202 fn render_tool_call(&mut self, call: &ToolCall) -> anyhow::Result<String>;
204
205 fn execute_tool<'a>(
207 &'a mut self,
208 call: ToolCall,
209 operation_id: Uuid,
210 budget: ContextBudget,
211 ) -> HostFuture<'a, ToolOutcome>;
212
213 fn complete_capture<'a>(
215 &'a mut self,
216 capture: Value,
217 contents: String,
218 budget: ContextBudget,
219 ) -> HostFuture<'a, ToolOutcome>;
220
221 fn record(&mut self, event: AuditEvent) -> anyhow::Result<()>;
223}
224
225#[derive(Clone, Debug, Eq, PartialEq)]
227pub struct SessionRunRequest {
228 pub user_id: String,
230 pub operation_id: Uuid,
232 pub rounds_used: u64,
234 pub round_limit: u64,
236}
237
238#[derive(Clone, Debug, Eq, PartialEq)]
240pub struct PreparedRound {
241 pub input: String,
243 pub provider_input: String,
245 pub continuation: Option<AgentContinuation>,
247 pub model: String,
249 pub reasoning_effort: String,
251 pub tool_description: String,
253 pub timeout: Option<Duration>,
255}
256
257#[derive(Clone, Debug, Eq, PartialEq)]
259pub enum RoundPreparation {
260 Run(PreparedRound),
262 Complete(Option<String>),
264}
265
266#[derive(Clone, Debug, PartialEq)]
268pub enum SessionEvent {
269 InferenceSubmitted {
271 round: u64,
273 manifest_hash: String,
275 model: String,
277 },
278 ProviderInput {
280 round: u64,
282 context: ModelContext,
284 },
285 UsageUpdated {
287 round: u64,
289 usage: TokenUsage,
291 },
292 ProviderReceipt {
294 round: u64,
296 usage: Option<TokenUsage>,
298 receipt: Box<UsageReceipt>,
300 continuation: Option<AgentContinuation>,
302 },
303}
304
305#[derive(Clone, Debug, PartialEq)]
307pub struct SessionToolOutcome {
308 pub text: String,
310 pub ok: bool,
312 pub capture: Option<Value>,
314 pub stop: bool,
316 pub finish_after_round: bool,
318 pub emitted_response: bool,
320}
321
322impl SessionToolOutcome {
323 pub fn success(text: impl Into<String>) -> Self {
325 Self {
326 text: text.into(),
327 ok: true,
328 capture: None,
329 stop: false,
330 finish_after_round: false,
331 emitted_response: false,
332 }
333 }
334
335 pub fn failure(text: impl Into<String>) -> Self {
337 Self {
338 text: text.into(),
339 ok: false,
340 capture: None,
341 stop: false,
342 finish_after_round: false,
343 emitted_response: false,
344 }
345 }
346}
347
348#[derive(Clone, Debug, PartialEq)]
350pub enum ProviderResume {
351 Continue(SessionToolOutcome),
353 Complete(Option<String>),
355 RestartFresh,
357}
358
359enum ProviderResumeAction {
360 Respond(SessionToolOutcome),
361 Interrupt(PostReceiptDisposition),
362}
363
364enum PostReceiptDisposition {
365 Return(Option<String>),
366 NextRound,
367}
368
369fn provider_resume_action(resume: ProviderResume) -> ProviderResumeAction {
370 match resume {
371 ProviderResume::Continue(outcome) => ProviderResumeAction::Respond(outcome),
372 ProviderResume::Complete(answer) => {
373 ProviderResumeAction::Interrupt(PostReceiptDisposition::Return(answer))
374 }
375 ProviderResume::RestartFresh => {
376 ProviderResumeAction::Interrupt(PostReceiptDisposition::NextRound)
377 }
378 }
379}
380
381#[derive(Clone, Debug, Eq, PartialEq)]
383pub struct RoundCompletion {
384 pub answer: String,
386 pub used_tool: bool,
388 pub finish_requested: bool,
390 pub emitted_response: bool,
392}
393
394#[derive(Clone, Debug, Eq, PartialEq)]
396pub enum SessionControl {
397 Continue,
399 Complete(Option<String>),
401}
402
403pub trait SessionHost: Send {
405 fn prepare_round<'a>(&'a mut self, round: u64) -> HostFuture<'a, RoundPreparation>;
407
408 fn record<'a>(&'a mut self, event: SessionEvent) -> HostFuture<'a, ()>;
410
411 fn execute_tool<'a>(
413 &'a mut self,
414 call: anyhow::Result<ToolCall>,
415 provider_operation_id: Uuid,
416 ) -> HostFuture<'a, SessionToolOutcome>;
417
418 fn prepare_provider_resume<'a>(
420 &'a mut self,
421 outcome: SessionToolOutcome,
422 ) -> HostFuture<'a, ProviderResume> {
423 Box::pin(async move { Ok(ProviderResume::Continue(outcome)) })
424 }
425
426 fn complete_capture<'a>(
428 &'a mut self,
429 capture: Value,
430 contents: String,
431 ) -> HostFuture<'a, SessionControl>;
432
433 fn complete_round<'a>(
435 &'a mut self,
436 completion: RoundCompletion,
437 ) -> HostFuture<'a, SessionControl>;
438}
439
440#[derive(Debug)]
442pub struct SessionRoundLimitError {
443 limit: u64,
444}
445
446impl std::fmt::Display for SessionRoundLimitError {
447 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
448 write!(
449 formatter,
450 "agent exceeded the {}-round tool-loop safety limit",
451 self.limit
452 )
453 }
454}
455
456impl std::error::Error for SessionRoundLimitError {}
457
458pub fn is_session_round_limit(error: &anyhow::Error) -> bool {
460 error.downcast_ref::<SessionRoundLimitError>().is_some()
461}
462
463#[derive(Clone, Debug, PartialEq)]
465pub struct RunRequest {
466 pub user_id: String,
468 pub parent_operation_id: Uuid,
470 pub model: String,
472 pub reasoning_effort: String,
474 pub context: Vec<String>,
476 pub task: String,
478 pub timeout: Option<Duration>,
480 pub start_metadata: Value,
482}
483
484#[derive(Clone, Debug, Eq, PartialEq)]
486pub struct RunResult {
487 pub answer: String,
489 pub model: ResolvedAgentModel,
491}
492
493#[derive(Clone)]
495pub struct AgentRuntime {
496 intelligence: Intelligence,
497}
498
499impl AgentRuntime {
500 pub fn new(intelligence: Intelligence) -> Self {
502 Self { intelligence }
503 }
504
505 pub async fn resolve_model(&self, requested: &str) -> anyhow::Result<ResolvedAgentModel> {
507 self.intelligence
508 .resolve_agent_model(requested)
509 .await
510 .map_err(anyhow::Error::new)
511 }
512
513 pub async fn run_session<H: SessionHost>(
515 &self,
516 request: SessionRunRequest,
517 host: &mut H,
518 ) -> anyhow::Result<Option<String>> {
519 ensure!(
520 request.round_limit > 0,
521 "session round limit must be positive"
522 );
523 ensure!(
524 request.rounds_used <= request.round_limit,
525 "restored session round count exceeds its safety limit"
526 );
527 let user = self
528 .intelligence
529 .for_user(request.user_id)
530 .map_err(anyhow::Error::new)?;
531
532 'rounds: for round_index in request.rounds_used..request.round_limit {
533 let round = round_index + 1;
534 let prepared = match host.prepare_round(round).await? {
535 RoundPreparation::Run(prepared) => prepared,
536 RoundPreparation::Complete(answer) => return Ok(answer),
537 };
538 let manifest_hash = hex::encode(Sha256::digest(prepared.input.as_bytes()));
539 host.record(SessionEvent::InferenceSubmitted {
540 round,
541 manifest_hash: manifest_hash.clone(),
542 model: prepared.model.clone(),
543 })
544 .await?;
545
546 let mut provider_request = AgentRequest::new(prepared.provider_input, prepared.model);
547 provider_request.reasoning_effort = reasoning_effort(&prepared.reasoning_effort)?;
548 provider_request.tools = vec![ktool_definition(&prepared.tool_description)];
549 if let Some(timeout) = prepared.timeout {
550 provider_request.timeout = timeout;
551 }
552 let mut turn = match user
553 .start_agent_turn(
554 request.operation_id,
555 None,
556 prepared.continuation,
557 provider_request,
558 )
559 .await
560 {
561 Ok(turn) => turn,
562 Err(error) => {
563 if let Some(receipt) = error.receipt().cloned() {
564 host.record(SessionEvent::ProviderReceipt {
565 round,
566 usage: None,
567 receipt: Box::new(receipt),
568 continuation: None,
569 })
570 .await?;
571 }
572 return Err(anyhow::Error::new(error));
573 }
574 };
575 let mut used_tool = false;
576 let mut finish_requested = false;
577 let mut emitted_response = false;
578 let mut pending_capture: Option<Value> = None;
579 let completed = loop {
580 let event = match turn.next_event().await {
581 Ok(Some(event)) => event,
582 Ok(None) => {
583 record_unavailable_session_turn(&mut turn, host, round).await?;
584 anyhow::bail!("provider ended without a terminal turn event");
585 }
586 Err(error) => {
587 if let Some(receipt) = error.receipt().cloned() {
588 host.record(SessionEvent::ProviderReceipt {
589 round,
590 usage: None,
591 receipt: Box::new(receipt),
592 continuation: None,
593 })
594 .await?;
595 }
596 return Err(anyhow::Error::new(error));
597 }
598 };
599 match event {
600 AgentEvent::ProviderInput(_) => {}
601 AgentEvent::ModelContextSubmitted(context) => {
602 host.record(SessionEvent::ProviderInput { round, context })
603 .await?;
604 }
605 AgentEvent::UsageUpdated(usage) => {
606 host.record(SessionEvent::UsageUpdated { round, usage })
607 .await?;
608 }
609 AgentEvent::ToolCall(native) => {
610 used_tool = true;
611 let call = parse_ktool_call(&native)
612 .map_err(|error| anyhow::anyhow!("Invalid Ktool call: {error}"));
613 let outcome = match host.execute_tool(call, request.operation_id).await {
614 Ok(outcome) => outcome,
615 Err(error) => {
616 record_unavailable_session_turn(&mut turn, host, round).await?;
617 return Err(error);
618 }
619 };
620 let resume = match host.prepare_provider_resume(outcome).await {
621 Ok(resume) => resume,
622 Err(error) => {
623 record_unavailable_session_turn(&mut turn, host, round).await?;
624 return Err(error);
625 }
626 };
627 let mut outcome = match provider_resume_action(resume) {
628 ProviderResumeAction::Respond(outcome) => outcome,
629 ProviderResumeAction::Interrupt(disposition) => {
630 record_unavailable_session_turn(&mut turn, host, round).await?;
631 match disposition {
632 PostReceiptDisposition::Return(answer) => return Ok(answer),
633 PostReceiptDisposition::NextRound => continue 'rounds,
634 }
635 }
636 };
637 finish_requested |= outcome.ok && outcome.finish_after_round;
638 emitted_response |= outcome.ok && outcome.emitted_response;
639 pending_capture = outcome.capture.take();
640 let stop = outcome.stop;
641 respond_session_or_record(
642 &mut turn,
643 host,
644 round,
645 &native.call_id,
646 if outcome.ok {
647 ToolResult::success(outcome.text)
648 } else {
649 ToolResult::failure(outcome.text)
650 },
651 )
652 .await?;
653 if stop {
654 return Ok(None);
655 }
656 }
657 AgentEvent::Completed(completed) => break completed,
658 }
659 };
660 let receipt = turn
661 .receipt()
662 .context("provider completed without a usage receipt")?
663 .clone();
664 let continuation = turn.continuation().cloned();
665 host.record(SessionEvent::ProviderReceipt {
666 round,
667 usage: completed.usage.clone(),
668 receipt: Box::new(receipt),
669 continuation,
670 })
671 .await?;
672
673 let control = if let Some(capture) = pending_capture {
674 host.complete_capture(capture, completed.answer).await?
675 } else {
676 host.complete_round(RoundCompletion {
677 answer: completed.answer,
678 used_tool,
679 finish_requested,
680 emitted_response,
681 })
682 .await?
683 };
684 match control {
685 SessionControl::Continue => {}
686 SessionControl::Complete(answer) => return Ok(answer),
687 }
688 }
689 Err(SessionRoundLimitError {
690 limit: request.round_limit,
691 }
692 .into())
693 }
694
695 pub async fn run<H: Host>(
697 &self,
698 request: RunRequest,
699 host: &mut H,
700 ) -> anyhow::Result<RunResult> {
701 let selected = self.resolve_model(&request.model).await?;
702 let reasoning_effort = reasoning_effort(&request.reasoning_effort)?;
703 let mut projection = Projection::new(request.context, request.task);
704 ensure_capacity(&projection, selected.max_input_tokens)?;
705 host.record(AuditEvent::Started {
706 parent_operation_id: request.parent_operation_id,
707 model: request.model.clone(),
708 provider_model: selected.provider_model.clone(),
709 provider: selected.provider,
710 context_window_tokens: selected.context_window_tokens,
711 max_input_tokens: selected.max_input_tokens,
712 context: projection.context.clone(),
713 task: projection.task.clone(),
714 host: request.start_metadata.clone(),
715 })?;
716 let user = self
717 .intelligence
718 .for_user(request.user_id)
719 .map_err(anyhow::Error::new)?;
720 for round in 0..1 {
721 let capturing = false;
722 ensure_capacity(&projection, selected.max_input_tokens)?;
723 let input = projection.render();
724 let manifest_hash = hex::encode(Sha256::digest(input.as_bytes()));
725 host.record(AuditEvent::InferenceSubmitted {
726 parent_operation_id: request.parent_operation_id,
727 round: round + 1,
728 manifest_hash: manifest_hash.clone(),
729 estimated_input_tokens: projection.estimated_tokens(),
730 })?;
731 let mut provider_request = AgentRequest::new(input, selected.requested_model.clone());
732 provider_request.reasoning_effort = reasoning_effort;
733 provider_request.ephemeral = true;
734 provider_request.tools = if capturing {
735 Vec::new()
736 } else {
737 vec![ktool_definition(
738 "Call one available Ktool by its exact name.",
739 )]
740 };
741 if let Some(timeout) = request.timeout {
742 provider_request.timeout = timeout;
743 }
744 let child_operation_id = Uuid::new_v4();
745 let mut turn = match user
746 .start_agent_turn(
747 child_operation_id,
748 Some(request.parent_operation_id),
749 None,
750 provider_request,
751 )
752 .await
753 {
754 Ok(turn) => turn,
755 Err(error) => {
756 if let Some(receipt) = error.receipt().cloned() {
757 host.record(AuditEvent::ProviderReceipt {
758 parent_operation_id: request.parent_operation_id,
759 round: round + 1,
760 manifest_hash,
761 usage: None,
762 receipt: Box::new(receipt),
763 })?;
764 }
765 return Err(anyhow::Error::new(error));
766 }
767 };
768 let mut used_tool = false;
769 let mut pending_capture: Option<Value> = None;
770 let completed = loop {
771 let event = match turn.next_event().await {
772 Ok(Some(event)) => event,
773 Ok(None) => {
774 let receipt = turn.finish_unavailable()?.clone();
775 host.record(AuditEvent::ProviderReceipt {
776 parent_operation_id: request.parent_operation_id,
777 round: round + 1,
778 manifest_hash: manifest_hash.clone(),
779 usage: None,
780 receipt: Box::new(receipt),
781 })?;
782 anyhow::bail!("subagent provider ended without a terminal turn event");
783 }
784 Err(error) => {
785 if let Some(receipt) = error.receipt().cloned() {
786 host.record(AuditEvent::ProviderReceipt {
787 parent_operation_id: request.parent_operation_id,
788 round: round + 1,
789 manifest_hash: manifest_hash.clone(),
790 usage: None,
791 receipt: Box::new(receipt),
792 })?;
793 }
794 return Err(anyhow::Error::new(error));
795 }
796 };
797 match event {
798 AgentEvent::ProviderInput(_) => {}
799 AgentEvent::ModelContextSubmitted(_) => {}
800 AgentEvent::UsageUpdated(_) => {}
801 AgentEvent::ToolCall(native) => {
802 used_tool = true;
803 if capturing {
804 respond_or_record(
805 &mut turn,
806 host,
807 request.parent_operation_id,
808 round + 1,
809 &manifest_hash,
810 &native.call_id,
811 ToolResult::failure(
812 "No application tool is available while complete freeform output is being captured.",
813 ),
814 )
815 .await?;
816 continue;
817 }
818 if pending_capture.is_some() {
819 respond_or_record(
820 &mut turn,
821 host,
822 request.parent_operation_id,
823 round + 1,
824 &manifest_hash,
825 &native.call_id,
826 ToolResult::failure(
827 "A freeform output capture is pending; no other tool can run first.",
828 ),
829 )
830 .await?;
831 continue;
832 }
833 let call = match parse_ktool_call(&native) {
834 Ok(call) => call,
835 Err(error) => {
836 let text = format!("Invalid application tool call: {error}");
837 projection.push_history(format!("Ktool result:\n{text}"));
838 respond_or_record(
839 &mut turn,
840 host,
841 request.parent_operation_id,
842 round + 1,
843 &manifest_hash,
844 &native.call_id,
845 ToolResult::failure(text),
846 )
847 .await?;
848 continue;
849 }
850 };
851 host.record(AuditEvent::ToolCall {
852 parent_operation_id: request.parent_operation_id,
853 name: call.name.clone(),
854 arguments: call.arguments.clone(),
855 })?;
856 projection.push_history(format!(
857 "Ktool call:\n{}",
858 host.render_tool_call(&call)?
859 ));
860 let budget = ContextBudget {
861 projection: projection.clone(),
862 max_input_tokens: selected.max_input_tokens,
863 };
864 let mut outcome = host
865 .execute_tool(call.clone(), child_operation_id, budget)
866 .await
867 .unwrap_or_else(|error| {
868 ToolOutcome::failure(format!("{} failed: {error}", call.name))
869 });
870 let exact_result = outcome.text.clone();
871 let initially_ok = outcome.ok;
872 let mut provider_result = outcome.text.clone();
873 let mut candidate = projection.clone();
874 candidate.apply_updates(successful_state_updates(&outcome));
875 candidate.push_history_displaying(
876 format!("Ktool result:\n{provider_result}"),
877 if initially_ok {
878 outcome.displayed_state_keys.clone()
879 } else {
880 Default::default()
881 },
882 );
883 let accepted = candidate.estimated_tokens() <= selected.max_input_tokens;
884 if accepted {
885 projection = candidate;
886 } else {
887 outcome.ok = false;
888 outcome.capture = None;
889 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();
890 projection.push_history(format!("Ktool result:\n{provider_result}"));
891 }
892 host.record(AuditEvent::ToolResult {
893 parent_operation_id: request.parent_operation_id,
894 name: call.name.clone(),
895 ok: initially_ok,
896 projection_accepted: accepted,
897 result: exact_result,
898 })?;
899 pending_capture = outcome.capture.take();
900 respond_or_record(
901 &mut turn,
902 host,
903 request.parent_operation_id,
904 round + 1,
905 &manifest_hash,
906 &native.call_id,
907 if outcome.ok {
908 ToolResult::success(provider_result)
909 } else {
910 ToolResult::failure(provider_result)
911 },
912 )
913 .await?;
914 }
915 AgentEvent::Completed(completed) => break completed,
916 }
917 };
918 let receipt = turn
919 .receipt()
920 .context("subagent provider completed without a usage receipt")?
921 .clone();
922 host.record(AuditEvent::ProviderReceipt {
923 parent_operation_id: request.parent_operation_id,
924 round: round + 1,
925 manifest_hash: manifest_hash.clone(),
926 usage: completed.usage.clone(),
927 receipt: Box::new(receipt),
928 })?;
929
930 if let Some(capture) = pending_capture {
931 ensure!(
932 !completed.answer.trim().is_empty(),
933 "subagent provider completed an output capture without contents"
934 );
935 let budget = ContextBudget {
936 projection: projection.clone(),
937 max_input_tokens: selected.max_input_tokens,
938 };
939 let outcome = host
940 .complete_capture(capture, completed.answer, budget)
941 .await?;
942 let mut candidate = projection.clone();
943 candidate.apply_updates(successful_state_updates(&outcome));
944 candidate.push_history(format!("Ktool result:\n{}", outcome.text));
945 ensure_capacity(&candidate, selected.max_input_tokens)?;
946 let answer = outcome.text.trim().to_owned();
947 ensure!(
948 !answer.is_empty(),
949 "subagent output capture completed without a result"
950 );
951 host.record(AuditEvent::Completed {
952 parent_operation_id: request.parent_operation_id,
953 model: request.model.clone(),
954 response: answer.clone(),
955 })?;
956 return Ok(RunResult {
957 answer,
958 model: selected,
959 });
960 }
961 let answer = completed.answer.trim().to_owned();
962 if !answer.is_empty() {
963 host.record(AuditEvent::Completed {
964 parent_operation_id: request.parent_operation_id,
965 model: request.model.clone(),
966 response: answer.clone(),
967 })?;
968 return Ok(RunResult {
969 answer,
970 model: selected,
971 });
972 }
973 ensure!(
974 !used_tool,
975 "subagent provider completed without a response or tool call"
976 );
977 }
978 anyhow::bail!("subagent provider completed without a terminal response")
979 }
980}
981
982async fn respond_or_record<H: Host>(
983 turn: &mut kcode_intelligence_router::AgentTurn,
984 host: &mut H,
985 parent_operation_id: Uuid,
986 round: u64,
987 manifest_hash: &str,
988 call_id: &str,
989 result: ToolResult,
990) -> anyhow::Result<()> {
991 if let Err(error) = turn.respond(call_id, result).await {
992 let receipt = turn.finish_unavailable()?.clone();
993 host.record(AuditEvent::ProviderReceipt {
994 parent_operation_id,
995 round,
996 manifest_hash: manifest_hash.into(),
997 usage: None,
998 receipt: Box::new(receipt),
999 })?;
1000 return Err(anyhow::Error::new(error));
1001 }
1002 Ok(())
1003}
1004
1005async fn respond_session_or_record<H: SessionHost>(
1006 turn: &mut kcode_intelligence_router::AgentTurn,
1007 host: &mut H,
1008 round: u64,
1009 call_id: &str,
1010 result: ToolResult,
1011) -> anyhow::Result<()> {
1012 if let Err(error) = turn.respond(call_id, result).await {
1013 record_unavailable_session_turn(turn, host, round).await?;
1014 return Err(anyhow::Error::new(error));
1015 }
1016 Ok(())
1017}
1018
1019async fn record_unavailable_session_turn<H: SessionHost>(
1020 turn: &mut kcode_intelligence_router::AgentTurn,
1021 host: &mut H,
1022 round: u64,
1023) -> anyhow::Result<()> {
1024 let receipt = turn.finish_unavailable()?.clone();
1025 host.record(SessionEvent::ProviderReceipt {
1026 round,
1027 usage: None,
1028 receipt: Box::new(receipt),
1029 continuation: None,
1030 })
1031 .await
1032}
1033
1034#[derive(Clone)]
1035struct Projection {
1036 context: Vec<String>,
1037 task: String,
1038 history: Vec<ProjectedHistory>,
1039 states: Vec<ProjectedState>,
1040}
1041
1042#[derive(Clone)]
1043struct ProjectedHistory {
1044 text: String,
1045 displayed_state_keys: Vec<String>,
1046}
1047
1048#[derive(Clone)]
1049struct ProjectedState {
1050 key: String,
1051 text: String,
1052}
1053
1054impl Projection {
1055 fn new(context: Vec<String>, task: String) -> Self {
1056 Self {
1057 context,
1058 task,
1059 history: Vec::new(),
1060 states: Vec::new(),
1061 }
1062 }
1063
1064 fn render(&self) -> String {
1065 self.context
1066 .iter()
1067 .map(String::as_str)
1068 .chain(std::iter::once(self.task.as_str()))
1069 .chain(self.history.iter().map(|entry| entry.text.as_str()))
1070 .chain(
1071 self.states
1072 .iter()
1073 .filter(|state| {
1074 !self.history.iter().any(|entry| {
1075 entry
1076 .displayed_state_keys
1077 .iter()
1078 .any(|key| key == &state.key)
1079 })
1080 })
1081 .map(|state| state.text.as_str()),
1082 )
1083 .filter(|section| !section.is_empty())
1084 .collect::<Vec<_>>()
1085 .join("\n\n")
1086 }
1087
1088 fn push_history(&mut self, text: impl Into<String>) {
1089 self.push_history_displaying(text, Vec::new());
1090 }
1091
1092 fn push_history_displaying(
1093 &mut self,
1094 text: impl Into<String>,
1095 displayed_state_keys: Vec<String>,
1096 ) {
1097 self.history.push(ProjectedHistory {
1098 text: text.into(),
1099 displayed_state_keys,
1100 });
1101 }
1102
1103 fn update_state(&mut self, key: String, text: Option<String>) {
1104 self.states.retain(|state| state.key != key);
1105 if let Some(text) = text {
1106 self.states.push(ProjectedState { key, text });
1107 }
1108 }
1109
1110 fn apply_updates(&mut self, updates: &[StateUpdate]) {
1111 for update in updates {
1112 let marker = if update.text.is_some() {
1113 SUPERSEDED_TOOL_OUTPUT
1114 } else {
1115 REMOVED_TOOL_OUTPUT
1116 };
1117 for entry in &mut self.history {
1118 if entry
1119 .displayed_state_keys
1120 .iter()
1121 .any(|key| key == &update.key)
1122 {
1123 entry.text = marker.into();
1124 entry.displayed_state_keys.clear();
1125 }
1126 }
1127 self.update_state(update.key.clone(), update.text.clone());
1128 }
1129 }
1130
1131 fn estimated_tokens(&self) -> u64 {
1132 (self.render().chars().count() as u64)
1133 .div_ceil(4)
1134 .saturating_add(PROTOCOL_TOKEN_RESERVE)
1135 }
1136}
1137
1138fn successful_state_updates(outcome: &ToolOutcome) -> &[StateUpdate] {
1139 if outcome.ok {
1140 &outcome.state_updates
1141 } else {
1142 &[]
1143 }
1144}
1145
1146fn ensure_capacity(projection: &Projection, max_input_tokens: u64) -> anyhow::Result<()> {
1147 let estimated = projection.estimated_tokens();
1148 ensure!(
1149 estimated <= max_input_tokens,
1150 "subagent context requires approximately {estimated} input tokens, over the selected model's {max_input_tokens}-token input limit"
1151 );
1152 Ok(())
1153}
1154
1155fn ktool_definition(description: &str) -> DynamicTool {
1156 DynamicTool::new(
1157 "call_ktool",
1158 description,
1159 json!({
1160 "type": "object",
1161 "additionalProperties": false,
1162 "required": ["name", "arguments"],
1163 "properties": {
1164 "name": {"type": "string"},
1165 "arguments": {"type": "object"}
1166 }
1167 }),
1168 )
1169}
1170
1171fn parse_ktool_call(call: &DynamicToolCall) -> anyhow::Result<ToolCall> {
1172 ensure!(call.tool == "call_ktool", "unknown provider tool");
1173 let arguments = call
1174 .arguments
1175 .as_object()
1176 .context("call_ktool arguments must be an object")?;
1177 ensure!(
1178 arguments
1179 .keys()
1180 .all(|key| matches!(key.as_str(), "name" | "arguments")),
1181 "call_ktool contains unknown arguments"
1182 );
1183 let name = arguments
1184 .get("name")
1185 .and_then(Value::as_str)
1186 .map(str::trim)
1187 .filter(|name| !name.is_empty() && name.chars().count() <= 100)
1188 .context("call_ktool.name must be a non-empty bounded string")?
1189 .to_owned();
1190 let arguments = arguments
1191 .get("arguments")
1192 .filter(|value| value.is_object())
1193 .context("call_ktool.arguments must be an object")?
1194 .clone();
1195 Ok(ToolCall { name, arguments })
1196}
1197
1198fn reasoning_effort(value: &str) -> anyhow::Result<ReasoningEffort> {
1199 Ok(match value {
1200 "none" => ReasoningEffort::None,
1201 "minimal" => ReasoningEffort::Minimal,
1202 "low" => ReasoningEffort::Low,
1203 "medium" => ReasoningEffort::Medium,
1204 "high" => ReasoningEffort::High,
1205 "xhigh" => ReasoningEffort::XHigh,
1206 "max" => ReasoningEffort::Max,
1207 _ => anyhow::bail!("unsupported reasoning effort {value:?}"),
1208 })
1209}
1210
1211#[cfg(test)]
1212mod tests {
1213 use super::*;
1214
1215 #[test]
1216 fn projection_replaces_state_and_budget_accounts_for_reserve() {
1217 let mut projection = Projection::new(vec!["context".into()], "task".into());
1218 projection.update_state("file".into(), Some("old".into()));
1219 projection.update_state("file".into(), Some("new".into()));
1220 assert_eq!(projection.states.len(), 1);
1221 assert!(projection.render().contains("new"));
1222 assert!(!projection.render().contains("old"));
1223 assert!(projection.estimated_tokens() >= PROTOCOL_TOKEN_RESERVE);
1224 }
1225
1226 #[test]
1227 fn state_update_supersedes_earlier_display_and_moves_current_value_after_history() {
1228 let mut projection = Projection::new(vec!["context".into()], "task".into());
1229 projection.push_history("Ktool call: open");
1230 projection.push_history_displaying(
1231 "Ktool result:\ncomplete old source",
1232 vec!["tool-state:7".into()],
1233 );
1234 projection.push_history("Ktool call: write");
1235 projection.apply_updates(&[StateUpdate {
1236 key: "tool-state:7".into(),
1237 text: Some("complete new source".into()),
1238 }]);
1239 projection.push_history("Ktool result: write completed");
1240
1241 let rendered = projection.render();
1242 assert!(!rendered.contains("complete old source"));
1243 assert_eq!(rendered.matches(SUPERSEDED_TOOL_OUTPUT).count(), 1);
1244 assert_eq!(rendered.matches("complete new source").count(), 1);
1245 assert!(
1246 rendered.find(SUPERSEDED_TOOL_OUTPUT).unwrap()
1247 < rendered.find("Ktool call: write").unwrap()
1248 );
1249 assert!(
1250 rendered.find("Ktool result: write completed").unwrap()
1251 < rendered.find("complete new source").unwrap()
1252 );
1253 }
1254
1255 #[test]
1256 fn current_state_is_not_duplicated_when_the_latest_result_displays_it() {
1257 let mut projection = Projection::new(vec!["context".into()], "task".into());
1258 projection.apply_updates(&[StateUpdate {
1259 key: "tool-state:7".into(),
1260 text: Some("complete source".into()),
1261 }]);
1262 projection.push_history_displaying(
1263 "Ktool result:\ncomplete source",
1264 vec!["tool-state:7".into()],
1265 );
1266
1267 assert_eq!(projection.render().matches("complete source").count(), 1);
1268 }
1269
1270 #[test]
1271 fn state_update_only_supersedes_displays_with_the_same_identity() {
1272 let mut projection = Projection::new(vec!["context".into()], "task".into());
1273 projection.push_history_displaying("first output", vec!["first".into()]);
1274 projection.push_history_displaying("second output", vec!["second".into()]);
1275 projection.apply_updates(&[StateUpdate {
1276 key: "first".into(),
1277 text: Some("current first output".into()),
1278 }]);
1279
1280 let rendered = projection.render();
1281 assert!(
1282 !rendered
1283 .split("\n\n")
1284 .any(|section| section == "first output")
1285 );
1286 assert!(rendered.contains(SUPERSEDED_TOOL_OUTPUT));
1287 assert!(rendered.contains("second output"));
1288 assert!(rendered.contains("current first output"));
1289 }
1290
1291 #[test]
1292 fn removed_state_uses_a_truthful_supersession_marker() {
1293 let mut projection = Projection::new(vec!["context".into()], "task".into());
1294 projection.push_history_displaying("retired output", vec!["state".into()]);
1295 projection.apply_updates(&[StateUpdate {
1296 key: "state".into(),
1297 text: None,
1298 }]);
1299
1300 let rendered = projection.render();
1301 assert!(!rendered.contains("retired output"));
1302 assert!(rendered.contains(REMOVED_TOOL_OUTPUT));
1303 }
1304
1305 #[test]
1306 fn failed_outcomes_cannot_update_or_supersede_state() {
1307 let mut projection = Projection::new(vec!["context".into()], "task".into());
1308 projection.push_history_displaying("current output", vec!["state".into()]);
1309 let failed = ToolOutcome {
1310 text: "write failed".into(),
1311 ok: false,
1312 state_updates: vec![StateUpdate {
1313 key: "state".into(),
1314 text: Some("invalid update".into()),
1315 }],
1316 displayed_state_keys: Vec::new(),
1317 capture: None,
1318 };
1319
1320 projection.apply_updates(successful_state_updates(&failed));
1321
1322 let rendered = projection.render();
1323 assert!(rendered.contains("current output"));
1324 assert!(!rendered.contains("invalid update"));
1325 assert!(!rendered.contains(SUPERSEDED_TOOL_OUTPUT));
1326 }
1327
1328 #[test]
1329 fn large_stateful_result_is_rendered_exactly_once() {
1330 let loaded_node = format!(
1331 "Node body:\n{}\n\nFixed connections:\nfixed-node\n\nRecent connections:\nrecent-node",
1332 "x".repeat(2_000)
1333 );
1334 let outcome = ToolOutcome {
1335 text: loaded_node.clone(),
1336 ok: true,
1337 state_updates: vec![StateUpdate {
1338 key: "loaded-node".into(),
1339 text: Some(loaded_node.clone()),
1340 }],
1341 displayed_state_keys: vec!["loaded-node".into()],
1342 capture: None,
1343 };
1344 let mut projection =
1345 Projection::new(vec!["initial node description".into()], "task".into());
1346 let provider_result = outcome.text.clone();
1347 projection.apply_updates(successful_state_updates(&outcome));
1348 projection.push_history_displaying(
1349 format!("Ktool result:\n{provider_result}"),
1350 outcome.displayed_state_keys,
1351 );
1352
1353 let rendered = projection.render();
1354 assert_eq!(rendered.matches(loaded_node.as_str()).count(), 1);
1355 assert!(rendered.contains("Fixed connections:\nfixed-node"));
1356 assert!(rendered.contains("Recent connections:\nrecent-node"));
1357 }
1358
1359 #[test]
1360 fn native_tool_wrapper_is_strict() {
1361 let call = parse_ktool_call(&DynamicToolCall {
1362 call_id: "1".into(),
1363 tool: "call_ktool".into(),
1364 arguments: json!({"name": "Read", "arguments": {"id": 1}}),
1365 })
1366 .unwrap();
1367 assert_eq!(call.name, "Read");
1368 assert_eq!(call.arguments["id"], 1);
1369 }
1370
1371 #[test]
1372 fn restart_fresh_selects_interrupt_then_next_round_disposition() {
1373 let ProviderResumeAction::Interrupt(disposition) =
1374 provider_resume_action(ProviderResume::RestartFresh)
1375 else {
1376 panic!("restart-fresh must not select native response");
1377 };
1378 assert!(matches!(disposition, PostReceiptDisposition::NextRound));
1379 }
1380
1381 #[test]
1382 fn existing_provider_resume_actions_keep_their_prior_paths() {
1383 let outcome = SessionToolOutcome::success("durable result");
1384 assert!(matches!(
1385 provider_resume_action(ProviderResume::Continue(outcome.clone())),
1386 ProviderResumeAction::Respond(returned) if returned == outcome
1387 ));
1388 let ProviderResumeAction::Interrupt(disposition) =
1389 provider_resume_action(ProviderResume::Complete(Some("done".into())))
1390 else {
1391 panic!("complete must not select native response");
1392 };
1393 assert!(matches!(
1394 disposition,
1395 PostReceiptDisposition::Return(Some(answer)) if answer == "done"
1396 ));
1397 }
1398}