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, ReasoningEffort, TokenUsage, ToolResult,
11};
12use kcode_intelligence_router::{AgentProvider, Intelligence, ResolvedAgentModel, UsageReceipt};
13use serde_json::{Value, json};
14use sha2::{Digest, Sha256};
15use uuid::Uuid;
16
17const DEFAULT_ROUND_LIMIT: u64 = 100;
18const PROTOCOL_TOKEN_RESERVE: u64 = 4_096;
19const INLINE_TOOL_RESULT_CHARACTERS: usize = 1_000;
20
21pub type HostFuture<'a, T> = Pin<Box<dyn Future<Output = anyhow::Result<T>> + Send + 'a>>;
23
24#[derive(Clone, Debug, PartialEq)]
26pub struct ToolCall {
27 pub name: String,
29 pub arguments: Value,
31}
32
33#[derive(Clone, Debug, PartialEq)]
35pub enum AuditEvent {
36 Started {
38 parent_operation_id: Uuid,
40 model: String,
42 provider_model: String,
44 provider: AgentProvider,
46 context_window_tokens: u64,
48 max_input_tokens: u64,
50 context: Vec<String>,
52 task: String,
54 host: Value,
56 },
57 InferenceSubmitted {
59 parent_operation_id: Uuid,
61 round: u64,
63 manifest_hash: String,
65 estimated_input_tokens: u64,
67 },
68 ToolCall {
70 parent_operation_id: Uuid,
72 name: String,
74 arguments: Value,
76 },
77 ToolResult {
79 parent_operation_id: Uuid,
81 name: String,
83 ok: bool,
85 projection_accepted: bool,
87 result: String,
89 },
90 ProviderReceipt {
92 parent_operation_id: Uuid,
94 round: u64,
96 manifest_hash: String,
98 usage: Option<kcode_codex_runtime_v2::TokenUsage>,
100 receipt: Box<UsageReceipt>,
102 },
103 Completed {
105 parent_operation_id: Uuid,
107 model: String,
109 response: String,
111 },
112}
113
114#[derive(Clone, Debug, Eq, PartialEq)]
116pub struct StateUpdate {
117 pub key: String,
119 pub text: Option<String>,
121}
122
123#[derive(Clone, Debug, PartialEq)]
125pub struct ToolOutcome {
126 pub text: String,
128 pub ok: bool,
130 pub state_updates: Vec<StateUpdate>,
132 pub capture: Option<Value>,
134}
135
136impl ToolOutcome {
137 pub fn success(text: impl Into<String>) -> Self {
139 Self {
140 text: text.into(),
141 ok: true,
142 state_updates: Vec::new(),
143 capture: None,
144 }
145 }
146
147 pub fn failure(text: impl Into<String>) -> Self {
149 Self {
150 text: text.into(),
151 ok: false,
152 state_updates: Vec::new(),
153 capture: None,
154 }
155 }
156}
157
158#[derive(Clone)]
160pub struct ContextBudget {
161 projection: Projection,
162 max_input_tokens: u64,
163}
164
165impl ContextBudget {
166 pub fn estimated_tokens(&self) -> u64 {
168 self.projection.estimated_tokens()
169 }
170
171 pub fn max_input_tokens(&self) -> u64 {
173 self.max_input_tokens
174 }
175
176 pub fn fits_state(&self, key: impl Into<String>, text: impl Into<String>) -> bool {
178 let mut projection = self.projection.clone();
179 projection.update_state(key.into(), Some(text.into()));
180 projection.estimated_tokens() <= self.max_input_tokens
181 }
182}
183
184pub trait Host: Send {
186 fn render_tool_call(&mut self, call: &ToolCall) -> anyhow::Result<String>;
188
189 fn execute_tool<'a>(
191 &'a mut self,
192 call: ToolCall,
193 operation_id: Uuid,
194 budget: ContextBudget,
195 ) -> HostFuture<'a, ToolOutcome>;
196
197 fn complete_capture<'a>(
199 &'a mut self,
200 capture: Value,
201 contents: String,
202 budget: ContextBudget,
203 ) -> HostFuture<'a, ToolOutcome>;
204
205 fn record(&mut self, event: AuditEvent) -> anyhow::Result<()>;
207}
208
209#[derive(Clone, Debug, Eq, PartialEq)]
211pub struct SessionRunRequest {
212 pub user_id: String,
214 pub operation_id: Uuid,
216 pub rounds_used: u64,
218 pub round_limit: u64,
220}
221
222#[derive(Clone, Debug, Eq, PartialEq)]
224pub struct PreparedRound {
225 pub input: String,
227 pub model: String,
229 pub reasoning_effort: String,
231 pub tool_description: String,
233 pub timeout: Option<Duration>,
235}
236
237#[derive(Clone, Debug, Eq, PartialEq)]
239pub enum RoundPreparation {
240 Run(PreparedRound),
242 Complete(Option<String>),
244}
245
246#[derive(Clone, Debug, PartialEq)]
248pub enum SessionEvent {
249 InferenceSubmitted {
251 round: u64,
253 manifest_hash: String,
255 model: String,
257 },
258 ProviderInput {
260 round: u64,
262 input: String,
264 },
265 UsageUpdated {
267 round: u64,
269 usage: TokenUsage,
271 },
272 ProviderReceipt {
274 round: u64,
276 usage: Option<TokenUsage>,
278 receipt: Box<UsageReceipt>,
280 },
281}
282
283#[derive(Clone, Debug, PartialEq)]
285pub struct SessionToolOutcome {
286 pub text: String,
288 pub ok: bool,
290 pub capture: Option<Value>,
292 pub stop: bool,
294 pub finish_after_round: bool,
296 pub emitted_response: bool,
298}
299
300impl SessionToolOutcome {
301 pub fn success(text: impl Into<String>) -> Self {
303 Self {
304 text: text.into(),
305 ok: true,
306 capture: None,
307 stop: false,
308 finish_after_round: false,
309 emitted_response: false,
310 }
311 }
312
313 pub fn failure(text: impl Into<String>) -> Self {
315 Self {
316 text: text.into(),
317 ok: false,
318 capture: None,
319 stop: false,
320 finish_after_round: false,
321 emitted_response: false,
322 }
323 }
324}
325
326#[derive(Clone, Debug, Eq, PartialEq)]
328pub struct RoundCompletion {
329 pub answer: String,
331 pub used_tool: bool,
333 pub finish_requested: bool,
335 pub emitted_response: bool,
337}
338
339#[derive(Clone, Debug, Eq, PartialEq)]
341pub enum SessionControl {
342 Continue,
344 Complete(Option<String>),
346}
347
348pub trait SessionHost: Send {
350 fn prepare_round<'a>(&'a mut self, round: u64) -> HostFuture<'a, RoundPreparation>;
352
353 fn record<'a>(&'a mut self, event: SessionEvent) -> HostFuture<'a, ()>;
355
356 fn execute_tool<'a>(
358 &'a mut self,
359 call: anyhow::Result<ToolCall>,
360 provider_operation_id: Uuid,
361 ) -> HostFuture<'a, SessionToolOutcome>;
362
363 fn complete_capture<'a>(
365 &'a mut self,
366 capture: Value,
367 contents: String,
368 ) -> HostFuture<'a, SessionControl>;
369
370 fn complete_round<'a>(
372 &'a mut self,
373 completion: RoundCompletion,
374 ) -> HostFuture<'a, SessionControl>;
375}
376
377#[derive(Debug)]
379pub struct SessionRoundLimitError {
380 limit: u64,
381}
382
383impl std::fmt::Display for SessionRoundLimitError {
384 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
385 write!(
386 formatter,
387 "agent exceeded the {}-round tool-loop safety limit",
388 self.limit
389 )
390 }
391}
392
393impl std::error::Error for SessionRoundLimitError {}
394
395pub fn is_session_round_limit(error: &anyhow::Error) -> bool {
397 error.downcast_ref::<SessionRoundLimitError>().is_some()
398}
399
400#[derive(Clone, Debug, PartialEq)]
402pub struct RunRequest {
403 pub user_id: String,
405 pub parent_operation_id: Uuid,
407 pub model: String,
409 pub reasoning_effort: String,
411 pub context: Vec<String>,
413 pub task: String,
415 pub timeout: Option<Duration>,
417 pub start_metadata: Value,
419}
420
421#[derive(Clone, Debug, Eq, PartialEq)]
423pub struct RunResult {
424 pub answer: String,
426 pub model: ResolvedAgentModel,
428}
429
430#[derive(Clone)]
432pub struct AgentRuntime {
433 intelligence: Intelligence,
434 round_limit: u64,
435}
436
437impl AgentRuntime {
438 pub fn new(intelligence: Intelligence) -> Self {
440 Self {
441 intelligence,
442 round_limit: DEFAULT_ROUND_LIMIT,
443 }
444 }
445
446 pub async fn resolve_model(&self, requested: &str) -> anyhow::Result<ResolvedAgentModel> {
448 self.intelligence
449 .resolve_agent_model(requested)
450 .await
451 .map_err(anyhow::Error::new)
452 }
453
454 pub async fn run_session<H: SessionHost>(
456 &self,
457 request: SessionRunRequest,
458 host: &mut H,
459 ) -> anyhow::Result<Option<String>> {
460 ensure!(
461 request.round_limit > 0,
462 "session round limit must be positive"
463 );
464 ensure!(
465 request.rounds_used <= request.round_limit,
466 "restored session round count exceeds its safety limit"
467 );
468 let user = self
469 .intelligence
470 .for_user(request.user_id)
471 .map_err(anyhow::Error::new)?;
472
473 for round_index in request.rounds_used..request.round_limit {
474 let round = round_index + 1;
475 let prepared = match host.prepare_round(round).await? {
476 RoundPreparation::Run(prepared) => prepared,
477 RoundPreparation::Complete(answer) => return Ok(answer),
478 };
479 let manifest_hash = hex::encode(Sha256::digest(prepared.input.as_bytes()));
480 host.record(SessionEvent::InferenceSubmitted {
481 round,
482 manifest_hash: manifest_hash.clone(),
483 model: prepared.model.clone(),
484 })
485 .await?;
486
487 let mut provider_request = AgentRequest::new(prepared.input, prepared.model);
488 provider_request.reasoning_effort = reasoning_effort(&prepared.reasoning_effort)?;
489 provider_request.previous_thread_id = None;
490 provider_request.tools = vec![ktool_definition(&prepared.tool_description)];
491 if let Some(timeout) = prepared.timeout {
492 provider_request.timeout = timeout;
493 }
494 let mut turn = match user
495 .start_agent_turn(request.operation_id, None, provider_request)
496 .await
497 {
498 Ok(turn) => turn,
499 Err(error) => {
500 if let Some(receipt) = error.receipt().cloned() {
501 host.record(SessionEvent::ProviderReceipt {
502 round,
503 usage: None,
504 receipt: Box::new(receipt),
505 })
506 .await?;
507 }
508 return Err(anyhow::Error::new(error));
509 }
510 };
511 let mut used_tool = false;
512 let mut finish_requested = false;
513 let mut emitted_response = false;
514 let mut pending_capture: Option<Value> = None;
515 let completed = loop {
516 let event = match turn.next_event().await {
517 Ok(Some(event)) => event,
518 Ok(None) => {
519 let receipt = turn.finish_unavailable()?.clone();
520 host.record(SessionEvent::ProviderReceipt {
521 round,
522 usage: None,
523 receipt: Box::new(receipt),
524 })
525 .await?;
526 anyhow::bail!("provider ended without a terminal turn event");
527 }
528 Err(error) => {
529 if let Some(receipt) = error.receipt().cloned() {
530 host.record(SessionEvent::ProviderReceipt {
531 round,
532 usage: None,
533 receipt: Box::new(receipt),
534 })
535 .await?;
536 }
537 return Err(anyhow::Error::new(error));
538 }
539 };
540 match event {
541 AgentEvent::ProviderInput(input) => {
542 host.record(SessionEvent::ProviderInput { round, input })
543 .await?;
544 }
545 AgentEvent::UsageUpdated(usage) => {
546 host.record(SessionEvent::UsageUpdated { round, usage })
547 .await?;
548 }
549 AgentEvent::ToolCall(native) => {
550 used_tool = true;
551 let call = parse_ktool_call(&native)
552 .map_err(|error| anyhow::anyhow!("Invalid Ktool call: {error}"));
553 let mut outcome = host.execute_tool(call, request.operation_id).await?;
554 finish_requested |= outcome.ok && outcome.finish_after_round;
555 emitted_response |= outcome.ok && outcome.emitted_response;
556 pending_capture = outcome.capture.take();
557 let stop = outcome.stop;
558 respond_session_or_record(
559 &mut turn,
560 host,
561 round,
562 &native.call_id,
563 if outcome.ok {
564 ToolResult::success(outcome.text)
565 } else {
566 ToolResult::failure(outcome.text)
567 },
568 )
569 .await?;
570 if stop {
571 return Ok(None);
572 }
573 }
574 AgentEvent::Completed(completed) => break completed,
575 }
576 };
577 let receipt = turn
578 .receipt()
579 .context("provider completed without a usage receipt")?
580 .clone();
581 host.record(SessionEvent::ProviderReceipt {
582 round,
583 usage: completed.usage.clone(),
584 receipt: Box::new(receipt),
585 })
586 .await?;
587
588 let control = if let Some(capture) = pending_capture {
589 host.complete_capture(capture, completed.answer).await?
590 } else {
591 host.complete_round(RoundCompletion {
592 answer: completed.answer,
593 used_tool,
594 finish_requested,
595 emitted_response,
596 })
597 .await?
598 };
599 match control {
600 SessionControl::Continue => {}
601 SessionControl::Complete(answer) => return Ok(answer),
602 }
603 }
604 Err(SessionRoundLimitError {
605 limit: request.round_limit,
606 }
607 .into())
608 }
609
610 pub async fn run<H: Host>(
612 &self,
613 request: RunRequest,
614 host: &mut H,
615 ) -> anyhow::Result<RunResult> {
616 let selected = self.resolve_model(&request.model).await?;
617 let reasoning_effort = reasoning_effort(&request.reasoning_effort)?;
618 let mut projection = Projection::new(request.context, request.task);
619 ensure_capacity(&projection, selected.max_input_tokens)?;
620 host.record(AuditEvent::Started {
621 parent_operation_id: request.parent_operation_id,
622 model: request.model.clone(),
623 provider_model: selected.provider_model.clone(),
624 provider: selected.provider,
625 context_window_tokens: selected.context_window_tokens,
626 max_input_tokens: selected.max_input_tokens,
627 context: projection.context.clone(),
628 task: projection.task.clone(),
629 host: request.start_metadata.clone(),
630 })?;
631 let user = self
632 .intelligence
633 .for_user(request.user_id)
634 .map_err(anyhow::Error::new)?;
635 let mut deferred_capture: Option<Value> = None;
636
637 for round in 0..self.round_limit {
638 let capturing = deferred_capture.is_some();
639 ensure_capacity(&projection, selected.max_input_tokens)?;
640 let input = projection.render();
641 let manifest_hash = hex::encode(Sha256::digest(input.as_bytes()));
642 host.record(AuditEvent::InferenceSubmitted {
643 parent_operation_id: request.parent_operation_id,
644 round: round + 1,
645 manifest_hash: manifest_hash.clone(),
646 estimated_input_tokens: projection.estimated_tokens(),
647 })?;
648 let mut provider_request = AgentRequest::new(input, selected.requested_model.clone());
649 provider_request.reasoning_effort = reasoning_effort;
650 provider_request.ephemeral = true;
651 provider_request.tools = if capturing {
652 Vec::new()
653 } else {
654 vec![ktool_definition(
655 "Call one available Ktool by its exact name.",
656 )]
657 };
658 if let Some(timeout) = request.timeout {
659 provider_request.timeout = timeout;
660 }
661 let child_operation_id = Uuid::new_v4();
662 let mut turn = match user
663 .start_agent_turn(
664 child_operation_id,
665 Some(request.parent_operation_id),
666 provider_request,
667 )
668 .await
669 {
670 Ok(turn) => turn,
671 Err(error) => {
672 if let Some(receipt) = error.receipt().cloned() {
673 host.record(AuditEvent::ProviderReceipt {
674 parent_operation_id: request.parent_operation_id,
675 round: round + 1,
676 manifest_hash,
677 usage: None,
678 receipt: Box::new(receipt),
679 })?;
680 }
681 return Err(anyhow::Error::new(error));
682 }
683 };
684 let mut used_tool = false;
685 let mut pending_capture: Option<Value> = None;
686 let mut requires_rerender = false;
687 let completed = loop {
688 let event = match turn.next_event().await {
689 Ok(Some(event)) => event,
690 Ok(None) => {
691 let receipt = turn.finish_unavailable()?.clone();
692 host.record(AuditEvent::ProviderReceipt {
693 parent_operation_id: request.parent_operation_id,
694 round: round + 1,
695 manifest_hash: manifest_hash.clone(),
696 usage: None,
697 receipt: Box::new(receipt),
698 })?;
699 anyhow::bail!("subagent provider ended without a terminal turn event");
700 }
701 Err(error) => {
702 if let Some(receipt) = error.receipt().cloned() {
703 host.record(AuditEvent::ProviderReceipt {
704 parent_operation_id: request.parent_operation_id,
705 round: round + 1,
706 manifest_hash: manifest_hash.clone(),
707 usage: None,
708 receipt: Box::new(receipt),
709 })?;
710 }
711 return Err(anyhow::Error::new(error));
712 }
713 };
714 match event {
715 AgentEvent::ProviderInput(_) => {}
716 AgentEvent::UsageUpdated(_) => {}
717 AgentEvent::ToolCall(native) => {
718 used_tool = true;
719 if capturing {
720 respond_or_record(
721 &mut turn,
722 host,
723 request.parent_operation_id,
724 round + 1,
725 &manifest_hash,
726 &native.call_id,
727 ToolResult::failure(
728 "No application tool is available while complete freeform output is being captured.",
729 ),
730 )
731 .await?;
732 continue;
733 }
734 if pending_capture.is_some() {
735 respond_or_record(
736 &mut turn,
737 host,
738 request.parent_operation_id,
739 round + 1,
740 &manifest_hash,
741 &native.call_id,
742 ToolResult::failure(
743 "A freeform output capture is pending; no other tool can run first.",
744 ),
745 )
746 .await?;
747 continue;
748 }
749 if requires_rerender {
750 respond_or_record(
751 &mut turn,
752 host,
753 request.parent_operation_id,
754 round + 1,
755 &manifest_hash,
756 &native.call_id,
757 ToolResult::failure(
758 "A state update is waiting to be re-rendered. End this slice before calling another tool.",
759 ),
760 )
761 .await?;
762 continue;
763 }
764 let call = match parse_ktool_call(&native) {
765 Ok(call) => call,
766 Err(error) => {
767 let text = format!("Invalid application tool call: {error}");
768 projection.push_history(format!("Ktool result:\n{text}"));
769 respond_or_record(
770 &mut turn,
771 host,
772 request.parent_operation_id,
773 round + 1,
774 &manifest_hash,
775 &native.call_id,
776 ToolResult::failure(text),
777 )
778 .await?;
779 continue;
780 }
781 };
782 host.record(AuditEvent::ToolCall {
783 parent_operation_id: request.parent_operation_id,
784 name: call.name.clone(),
785 arguments: call.arguments.clone(),
786 })?;
787 projection.push_history(format!(
788 "Ktool call:\n{}",
789 host.render_tool_call(&call)?
790 ));
791 let budget = ContextBudget {
792 projection: projection.clone(),
793 max_input_tokens: selected.max_input_tokens,
794 };
795 let mut outcome = host
796 .execute_tool(call.clone(), child_operation_id, budget)
797 .await
798 .unwrap_or_else(|error| {
799 ToolOutcome::failure(format!("{} failed: {error}", call.name))
800 });
801 let exact_result = outcome.text.clone();
802 let initially_ok = outcome.ok;
803 let mut provider_result =
804 compact_tool_result(&outcome.text, &outcome.state_updates);
805 let mut candidate = projection.clone();
806 candidate.apply_updates(&outcome.state_updates);
807 candidate.push_history(format!("Ktool result:\n{provider_result}"));
808 let accepted = candidate.estimated_tokens() <= selected.max_input_tokens;
809 if accepted {
810 projection = candidate;
811 requires_rerender = !outcome.state_updates.is_empty();
812 } else {
813 outcome.ok = false;
814 outcome.capture = None;
815 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();
816 projection.push_history(format!("Ktool result:\n{provider_result}"));
817 }
818 host.record(AuditEvent::ToolResult {
819 parent_operation_id: request.parent_operation_id,
820 name: call.name.clone(),
821 ok: initially_ok,
822 projection_accepted: accepted,
823 result: exact_result,
824 })?;
825 pending_capture = outcome.capture.take();
826 respond_or_record(
827 &mut turn,
828 host,
829 request.parent_operation_id,
830 round + 1,
831 &manifest_hash,
832 &native.call_id,
833 if outcome.ok {
834 ToolResult::success(provider_result)
835 } else {
836 ToolResult::failure(provider_result)
837 },
838 )
839 .await?;
840 }
841 AgentEvent::Completed(completed) => break completed,
842 }
843 };
844 let receipt = turn
845 .receipt()
846 .context("subagent provider completed without a usage receipt")?
847 .clone();
848 host.record(AuditEvent::ProviderReceipt {
849 parent_operation_id: request.parent_operation_id,
850 round: round + 1,
851 manifest_hash: manifest_hash.clone(),
852 usage: completed.usage.clone(),
853 receipt: Box::new(receipt),
854 })?;
855
856 let capture = deferred_capture.take().or(pending_capture);
857 if let Some(capture) = capture {
858 if !capturing && completed.answer.is_empty() {
859 deferred_capture = Some(capture);
860 continue;
861 }
862 let budget = ContextBudget {
863 projection: projection.clone(),
864 max_input_tokens: selected.max_input_tokens,
865 };
866 let outcome = host
867 .complete_capture(capture, completed.answer, budget)
868 .await?;
869 let mut candidate = projection.clone();
870 candidate.apply_updates(&outcome.state_updates);
871 candidate.push_history(format!("Ktool result:\n{}", outcome.text));
872 ensure_capacity(&candidate, selected.max_input_tokens)?;
873 projection = candidate;
874 continue;
875 }
876 if requires_rerender {
877 let draft = completed.answer.trim();
878 if !draft.is_empty() {
879 projection.push_history(format!(
880 "Assistant draft produced before the state refresh:\n{draft}"
881 ));
882 }
883 continue;
884 }
885 let answer = completed.answer.trim().to_owned();
886 if !answer.is_empty() {
887 host.record(AuditEvent::Completed {
888 parent_operation_id: request.parent_operation_id,
889 model: request.model.clone(),
890 response: answer.clone(),
891 })?;
892 return Ok(RunResult {
893 answer,
894 model: selected,
895 });
896 }
897 ensure!(
898 used_tool,
899 "subagent provider completed without a response or tool call"
900 );
901 }
902 anyhow::bail!(
903 "subagent exceeded the {}-round tool-loop safety limit",
904 self.round_limit
905 )
906 }
907}
908
909async fn respond_or_record<H: Host>(
910 turn: &mut kcode_intelligence_router::AgentTurn,
911 host: &mut H,
912 parent_operation_id: Uuid,
913 round: u64,
914 manifest_hash: &str,
915 call_id: &str,
916 result: ToolResult,
917) -> anyhow::Result<()> {
918 if let Err(error) = turn.respond(call_id, result).await {
919 let receipt = turn.finish_unavailable()?.clone();
920 host.record(AuditEvent::ProviderReceipt {
921 parent_operation_id,
922 round,
923 manifest_hash: manifest_hash.into(),
924 usage: None,
925 receipt: Box::new(receipt),
926 })?;
927 return Err(anyhow::Error::new(error));
928 }
929 Ok(())
930}
931
932async fn respond_session_or_record<H: SessionHost>(
933 turn: &mut kcode_intelligence_router::AgentTurn,
934 host: &mut H,
935 round: u64,
936 call_id: &str,
937 result: ToolResult,
938) -> anyhow::Result<()> {
939 if let Err(error) = turn.respond(call_id, result).await {
940 let receipt = turn.finish_unavailable()?.clone();
941 host.record(SessionEvent::ProviderReceipt {
942 round,
943 usage: None,
944 receipt: Box::new(receipt),
945 })
946 .await?;
947 return Err(anyhow::Error::new(error));
948 }
949 Ok(())
950}
951
952#[derive(Clone)]
953struct Projection {
954 context: Vec<String>,
955 task: String,
956 history: Vec<String>,
957 states: Vec<ProjectedState>,
958}
959
960#[derive(Clone)]
961struct ProjectedState {
962 key: String,
963 text: String,
964}
965
966impl Projection {
967 fn new(context: Vec<String>, task: String) -> Self {
968 Self {
969 context,
970 task,
971 history: Vec::new(),
972 states: Vec::new(),
973 }
974 }
975
976 fn render(&self) -> String {
977 self.context
978 .iter()
979 .map(String::as_str)
980 .chain(std::iter::once(self.task.as_str()))
981 .chain(self.history.iter().map(String::as_str))
982 .chain(self.states.iter().map(|state| state.text.as_str()))
983 .filter(|section| !section.is_empty())
984 .collect::<Vec<_>>()
985 .join("\n\n")
986 }
987
988 fn push_history(&mut self, text: impl Into<String>) {
989 self.history.push(text.into());
990 }
991
992 fn update_state(&mut self, key: String, text: Option<String>) {
993 self.states.retain(|state| state.key != key);
994 if let Some(text) = text {
995 self.states.push(ProjectedState { key, text });
996 }
997 }
998
999 fn apply_updates(&mut self, updates: &[StateUpdate]) {
1000 for update in updates {
1001 self.update_state(update.key.clone(), update.text.clone());
1002 }
1003 }
1004
1005 fn estimated_tokens(&self) -> u64 {
1006 (self.render().chars().count() as u64)
1007 .div_ceil(4)
1008 .saturating_add(PROTOCOL_TOKEN_RESERVE)
1009 }
1010}
1011
1012fn compact_tool_result(text: &str, states: &[StateUpdate]) -> String {
1013 if states.is_empty() {
1014 return text.to_owned();
1015 }
1016 let result = if text.chars().count() <= INLINE_TOOL_RESULT_CHARACTERS {
1017 text
1018 } else {
1019 "Tool completed successfully."
1020 };
1021 format!(
1022 "{result}\n\nThe updated state will be rendered in the next fresh context slice; end this slice now."
1023 )
1024}
1025
1026fn ensure_capacity(projection: &Projection, max_input_tokens: u64) -> anyhow::Result<()> {
1027 let estimated = projection.estimated_tokens();
1028 ensure!(
1029 estimated <= max_input_tokens,
1030 "subagent context requires approximately {estimated} input tokens, over the selected model's {max_input_tokens}-token input limit"
1031 );
1032 Ok(())
1033}
1034
1035fn ktool_definition(description: &str) -> DynamicTool {
1036 DynamicTool::new(
1037 "call_ktool",
1038 description,
1039 json!({
1040 "type": "object",
1041 "additionalProperties": false,
1042 "required": ["name", "arguments"],
1043 "properties": {
1044 "name": {"type": "string"},
1045 "arguments": {"type": "object"}
1046 }
1047 }),
1048 )
1049}
1050
1051fn parse_ktool_call(call: &DynamicToolCall) -> anyhow::Result<ToolCall> {
1052 ensure!(call.tool == "call_ktool", "unknown provider tool");
1053 let arguments = call
1054 .arguments
1055 .as_object()
1056 .context("call_ktool arguments must be an object")?;
1057 ensure!(
1058 arguments
1059 .keys()
1060 .all(|key| matches!(key.as_str(), "name" | "arguments")),
1061 "call_ktool contains unknown arguments"
1062 );
1063 let name = arguments
1064 .get("name")
1065 .and_then(Value::as_str)
1066 .map(str::trim)
1067 .filter(|name| !name.is_empty() && name.chars().count() <= 100)
1068 .context("call_ktool.name must be a non-empty bounded string")?
1069 .to_owned();
1070 let arguments = arguments
1071 .get("arguments")
1072 .filter(|value| value.is_object())
1073 .context("call_ktool.arguments must be an object")?
1074 .clone();
1075 Ok(ToolCall { name, arguments })
1076}
1077
1078fn reasoning_effort(value: &str) -> anyhow::Result<ReasoningEffort> {
1079 Ok(match value {
1080 "none" => ReasoningEffort::None,
1081 "minimal" => ReasoningEffort::Minimal,
1082 "low" => ReasoningEffort::Low,
1083 "medium" => ReasoningEffort::Medium,
1084 "high" => ReasoningEffort::High,
1085 "xhigh" => ReasoningEffort::XHigh,
1086 "max" => ReasoningEffort::Max,
1087 _ => anyhow::bail!("unsupported reasoning effort {value:?}"),
1088 })
1089}
1090
1091#[cfg(test)]
1092mod tests {
1093 use super::*;
1094
1095 #[test]
1096 fn projection_replaces_state_and_budget_accounts_for_reserve() {
1097 let mut projection = Projection::new(vec!["context".into()], "task".into());
1098 projection.update_state("file".into(), Some("old".into()));
1099 projection.update_state("file".into(), Some("new".into()));
1100 assert_eq!(projection.states.len(), 1);
1101 assert!(projection.render().contains("new"));
1102 assert!(!projection.render().contains("old"));
1103 assert!(projection.estimated_tokens() >= PROTOCOL_TOKEN_RESERVE);
1104 }
1105
1106 #[test]
1107 fn state_changes_compact_large_tool_results() {
1108 let compacted = compact_tool_result(
1109 &"x".repeat(INLINE_TOOL_RESULT_CHARACTERS + 1),
1110 &[StateUpdate {
1111 key: "state".into(),
1112 text: Some("current".into()),
1113 }],
1114 );
1115 assert!(compacted.starts_with("Tool completed successfully."));
1116 assert!(compacted.contains("fresh context slice"));
1117 }
1118
1119 #[test]
1120 fn native_tool_wrapper_is_strict() {
1121 let call = parse_ktool_call(&DynamicToolCall {
1122 call_id: "1".into(),
1123 tool: "call_ktool".into(),
1124 arguments: json!({"name": "Read", "arguments": {"id": 1}}),
1125 })
1126 .unwrap();
1127 assert_eq!(call.name, "Read");
1128 assert_eq!(call.arguments["id"], 1);
1129 }
1130}