Skip to main content

kcode_agent_runtime/
lib.rs

1//! Provider-neutral agent loops over `kcode-intelligence-router`.
2
3#![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
20/// Default maximum provider calls for primary and delegated agent loops.
21pub 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
26/// A boxed asynchronous host operation.
27pub type HostFuture<'a, T> = Pin<Box<dyn Future<Output = anyhow::Result<T>> + Send + 'a>>;
28
29/// One application tool call requested by a subagent.
30#[derive(Clone, Debug, PartialEq)]
31pub struct ToolCall {
32    /// Exact application tool name.
33    pub name: String,
34    /// Tool arguments.
35    pub arguments: Value,
36}
37
38/// A bounded validation error for one provider-supplied application tool call.
39#[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/// One complete, typed audit fact selected by the subagent runtime.
74#[derive(Clone, Debug, PartialEq)]
75pub enum AuditEvent {
76    /// Immutable starting context and resolved provider capacity.
77    Started {
78        /// Parent operation that causally owns the subagent.
79        parent_operation_id: Uuid,
80        /// Caller-selected model identifier.
81        model: String,
82        /// Exact provider model.
83        provider_model: String,
84        /// Resolved provider transport.
85        provider: AgentProvider,
86        /// Total provider context window.
87        context_window_tokens: u64,
88        /// Maximum permitted input.
89        max_input_tokens: u64,
90        /// Ordered immutable starting context.
91        context: Vec<String>,
92        /// Focused subagent task.
93        task: String,
94        /// Opaque application metadata supplied at launch.
95        host: Value,
96    },
97    /// Exact input identity submitted for one provider round.
98    InferenceSubmitted {
99        /// Parent operation that causally owns the subagent.
100        parent_operation_id: Uuid,
101        /// One-based subagent round.
102        round: u64,
103        /// SHA-256 hash of the exact provider input.
104        manifest_hash: String,
105        /// Runtime input estimate including protocol reserve.
106        estimated_input_tokens: u64,
107    },
108    /// Application tool invocation requested by the provider.
109    ToolCall {
110        /// Parent operation that causally owns the subagent.
111        parent_operation_id: Uuid,
112        /// Exact application tool name.
113        name: String,
114        /// Exact tool arguments.
115        arguments: Value,
116    },
117    /// Complete application tool result retained for audit.
118    ToolResult {
119        /// Parent operation that causally owns the subagent.
120        parent_operation_id: Uuid,
121        /// Exact application tool name.
122        name: String,
123        /// Whether execution itself succeeded.
124        ok: bool,
125        /// Whether the resulting context projection fit.
126        projection_accepted: bool,
127        /// Exact application result.
128        result: String,
129    },
130    /// Canonical accounting for one completed or interrupted provider round.
131    ProviderReceipt {
132        /// Parent operation that causally owns the subagent.
133        parent_operation_id: Uuid,
134        /// One-based subagent round.
135        round: u64,
136        /// SHA-256 hash of the exact provider input.
137        manifest_hash: String,
138        /// Provider-native cumulative usage retained for exact audit detail.
139        usage: Option<kcode_codex_runtime_v2::TokenUsage>,
140        /// Canonical durable receipt written by the intelligence router.
141        receipt: Box<UsageReceipt>,
142    },
143    /// Final non-empty subagent answer.
144    Completed {
145        /// Parent operation that causally owns the subagent.
146        parent_operation_id: Uuid,
147        /// Caller-selected model identifier.
148        model: String,
149        /// Terminal response returned to the application.
150        response: String,
151    },
152}
153
154/// One replaceable state section rendered into every later context slice.
155#[derive(Clone, Debug, Eq, PartialEq)]
156pub struct StateUpdate {
157    /// Stable state identity. A later update with this key replaces the prior text.
158    pub key: String,
159    /// Current rendered state, or `None` to remove it.
160    pub text: Option<String>,
161}
162
163/// Result returned by the application after one tool or capture operation.
164#[derive(Clone, Debug, PartialEq)]
165pub struct ToolOutcome {
166    /// Exact result retained for audit.
167    pub text: String,
168    /// Whether the operation succeeded.
169    pub ok: bool,
170    /// Replaceable state made current by the operation.
171    pub state_updates: Vec<StateUpdate>,
172    /// State identities whose complete values this result displays.
173    ///
174    /// A later update to one of these identities replaces this retained result
175    /// with a concise supersession marker while preserving the exact audit
176    /// result.
177    pub displayed_state_keys: Vec<String>,
178    /// Opaque application token requesting a tool-free freeform output capture.
179    pub capture: Option<Value>,
180}
181
182impl ToolOutcome {
183    /// Constructs a simple successful result.
184    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    /// Constructs a simple failed result.
195    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/// Read-only capacity view supplied while a host evaluates a tool.
207#[derive(Clone)]
208pub struct ContextBudget {
209    projection: Projection,
210    max_input_tokens: u64,
211}
212
213impl ContextBudget {
214    /// Current estimated input tokens, including protocol reserve.
215    pub fn estimated_tokens(&self) -> u64 {
216        self.projection.estimated_tokens()
217    }
218
219    /// Maximum permitted input tokens.
220    pub fn max_input_tokens(&self) -> u64 {
221        self.max_input_tokens
222    }
223
224    /// Returns whether replacing one projected state would fit.
225    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
235/// Application-owned behavior invoked by the generic subagent loop.
236pub trait Host: Send {
237    /// Renders the retained invocation text before execution.
238    fn render_tool_call(&mut self, call: &ToolCall) -> anyhow::Result<String>;
239
240    /// Executes one application tool.
241    fn execute_tool<'a>(
242        &'a mut self,
243        call: ToolCall,
244        operation_id: Uuid,
245        budget: ContextBudget,
246    ) -> HostFuture<'a, ToolOutcome>;
247
248    /// Completes an opaque freeform capture requested by a prior tool result.
249    fn complete_capture<'a>(
250        &'a mut self,
251        capture: Value,
252        contents: String,
253        budget: ContextBudget,
254    ) -> HostFuture<'a, ToolOutcome>;
255
256    /// Records one durable audit event selected by the runtime.
257    fn record(&mut self, event: AuditEvent) -> anyhow::Result<()>;
258}
259
260/// Inputs shared by every provider call in one primary session run.
261#[derive(Clone, Debug, Eq, PartialEq)]
262pub struct SessionRunRequest {
263    /// Stable user identifier used for router accounting.
264    pub user_id: String,
265    /// Top-level operation whose cancellation propagates to every provider call.
266    pub operation_id: Uuid,
267    /// Number of provider rounds already durably used by a restored session.
268    pub rounds_used: u64,
269    /// Maximum cumulative provider rounds permitted for the logical turn.
270    pub round_limit: u64,
271}
272
273/// Exact provider input prepared by the application for one primary round.
274#[derive(Clone, Debug, Eq, PartialEq)]
275pub struct PreparedRound {
276    /// Complete logical context retained for durable audit and capacity checks.
277    pub input: String,
278    /// New context submitted to the provider for this native turn.
279    pub provider_input: String,
280    /// Prior native Codex state when this turn resumes an affine thread.
281    pub continuation: Option<AgentContinuation>,
282    /// Exact requested model identifier.
283    pub model: String,
284    /// Provider-neutral reasoning effort.
285    pub reasoning_effort: String,
286    /// Session-specific explanation attached to the single application-tool bridge.
287    pub tool_description: String,
288    /// Optional complete provider-turn timeout.
289    pub timeout: Option<Duration>,
290}
291
292/// Inputs for one host-independent primary provider inference.
293#[derive(Clone, Debug, Eq, PartialEq)]
294pub struct SessionInferenceRequest {
295    /// Stable user identifier used for router accounting.
296    pub user_id: String,
297    /// Top-level operation whose cancellation propagates to the provider call.
298    pub operation_id: Uuid,
299    /// Cumulative one-based provider round represented by this inference.
300    pub round: u64,
301    /// Exact application-prepared provider input and native continuation.
302    pub prepared: PreparedRound,
303}
304
305/// One normalized event from a host-independent primary provider inference.
306#[derive(Clone, Debug, PartialEq)]
307pub enum SessionInferenceEvent {
308    /// Exact caller-controlled material submitted to the model.
309    ProviderInput {
310        /// Normalized model input and structured material.
311        context: ModelContext,
312    },
313    /// Live usage for the current provider call.
314    UsageUpdated {
315        /// Provider-native current-call usage, normalized from thread totals.
316        usage: TokenUsage,
317    },
318    /// One parsed or invalid application tool invocation.
319    ToolCall {
320        /// Native call identity required when responding.
321        call_id: String,
322        /// Strictly parsed application call or bounded validation failure.
323        call: Result<ToolCall, ToolCallError>,
324    },
325    /// Terminal answer and canonical accounting for the provider call.
326    Completed {
327        /// Terminal assistant text.
328        answer: String,
329        /// Final provider-native current-call usage when available.
330        usage: Option<TokenUsage>,
331        /// Canonical durable receipt written by the intelligence router.
332        receipt: Box<UsageReceipt>,
333        /// Native Codex state after successful completion.
334        continuation: Option<AgentContinuation>,
335    },
336}
337
338/// An active host-independent primary provider inference.
339///
340/// The handle owns the native provider turn but no session, host, or application
341/// chat state.
342pub 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    /// Returns the next normalized semantic provider event.
411    ///
412    /// Raw provider transport-input events are skipped. Returning
413    /// [`SessionInferenceEvent::Completed`] terminalizes the handle.
414    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    /// Sends exactly one native result for a previously surfaced tool call.
465    ///
466    /// A native response failure leaves the handle active so interrupted-call
467    /// accounting remains available through [`Self::finish_unavailable`].
468    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    /// Accounts for an interrupted active inference and terminalizes the handle.
477    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/// Result of preparing the next primary round.
486#[derive(Clone, Debug, Eq, PartialEq)]
487pub enum RoundPreparation {
488    /// Run the prepared provider round.
489    Run(PreparedRound),
490    /// Finish without starting another provider round.
491    Complete(Option<String>),
492}
493
494/// One durable provider-protocol fact emitted by the primary runtime.
495#[derive(Clone, Debug, PartialEq)]
496pub enum SessionEvent {
497    /// Exact input identity submitted for one provider round.
498    InferenceSubmitted {
499        /// Cumulative one-based provider round.
500        round: u64,
501        /// SHA-256 hash of the exact provider input.
502        manifest_hash: String,
503        /// Exact requested model identifier.
504        model: String,
505    },
506    /// Exact caller-controlled material submitted to the model.
507    ProviderInput {
508        /// Cumulative one-based provider round.
509        round: u64,
510        /// Normalized model input and structured material.
511        context: ModelContext,
512    },
513    /// Live usage for the current provider call.
514    UsageUpdated {
515        /// Cumulative one-based provider round.
516        round: u64,
517        /// Provider-native current-call usage, normalized from thread totals.
518        usage: TokenUsage,
519    },
520    /// Canonical receipt and final usage for a completed or interrupted call.
521    ProviderReceipt {
522        /// Cumulative one-based provider round.
523        round: u64,
524        /// Final provider-native current-call usage when available.
525        usage: Option<TokenUsage>,
526        /// Canonical durable receipt written by the intelligence router.
527        receipt: Box<UsageReceipt>,
528        /// Native Codex state after successful completion.
529        continuation: Option<AgentContinuation>,
530    },
531}
532
533/// Complete application handling of one primary-session tool call.
534#[derive(Clone, Debug, PartialEq)]
535pub struct SessionToolOutcome {
536    /// Exact result returned through the provider's native tool protocol.
537    pub text: String,
538    /// Whether tool execution succeeded.
539    pub ok: bool,
540    /// Opaque token requesting tool-free freeform output capture.
541    pub capture: Option<Value>,
542    /// Whether the logical session must stop after this tool response.
543    pub stop: bool,
544    /// Whether the logical session should finish after this provider round.
545    pub finish_after_round: bool,
546    /// Whether this tool already emitted the session's externally visible response.
547    pub emitted_response: bool,
548}
549
550impl SessionToolOutcome {
551    /// Constructs a simple successful tool result.
552    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    /// Constructs a simple failed tool result.
564    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/// Application preparation immediately before a primary provider turn resumes.
577#[derive(Clone, Debug, PartialEq)]
578pub enum ProviderResume {
579    /// Send the prepared native tool result and allow another inference.
580    Continue(SessionToolOutcome),
581    /// Finish the logical session without starting another inference.
582    Complete(Option<String>),
583    /// Account for the interrupted call and select another outer round without responding.
584    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/// Provider completion summarized for the application-owned session policy.
610#[derive(Clone, Debug, Eq, PartialEq)]
611pub struct RoundCompletion {
612    /// Terminal assistant text, which may be empty after tool use.
613    pub answer: String,
614    /// Whether the provider called at least one application tool.
615    pub used_tool: bool,
616    /// Whether a successful tool requested session completion after this round.
617    pub finish_requested: bool,
618    /// Whether a tool already emitted the externally visible response.
619    pub emitted_response: bool,
620}
621
622/// Application decision after completing a semantic primary-session transition.
623#[derive(Clone, Debug, Eq, PartialEq)]
624pub enum SessionControl {
625    /// Prepare another affine provider round.
626    Continue,
627    /// Finish the logical turn with the optional externally visible answer.
628    Complete(Option<String>),
629}
630
631/// Application-owned semantics invoked by the primary agent runtime.
632pub trait SessionHost: Send {
633    /// Prepares the complete context and runtime selection for one provider round.
634    fn prepare_round<'a>(&'a mut self, round: u64) -> HostFuture<'a, RoundPreparation>;
635
636    /// Records one provider-protocol fact and durably checkpoints its effects.
637    fn record<'a>(&'a mut self, event: SessionEvent) -> HostFuture<'a, ()>;
638
639    /// Executes and durably projects one parsed or invalid application tool call.
640    fn execute_tool<'a>(
641        &'a mut self,
642        call: anyhow::Result<ToolCall>,
643        provider_operation_id: Uuid,
644    ) -> HostFuture<'a, SessionToolOutcome>;
645
646    /// Reconciles application policy at the actual provider-resume boundary.
647    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    /// Completes an opaque freeform capture requested by a prior tool result.
655    fn complete_capture<'a>(
656        &'a mut self,
657        capture: Value,
658        contents: String,
659    ) -> HostFuture<'a, SessionControl>;
660
661    /// Applies the terminal provider answer and decides whether another round is needed.
662    fn complete_round<'a>(
663        &'a mut self,
664        completion: RoundCompletion,
665    ) -> HostFuture<'a, SessionControl>;
666}
667
668/// Error returned when a restored primary turn exhausts its cumulative round budget.
669#[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
686/// Returns whether an error is the primary runtime's round-limit signal.
687pub fn is_session_round_limit(error: &anyhow::Error) -> bool {
688    error.downcast_ref::<SessionRoundLimitError>().is_some()
689}
690
691/// Inputs for one complete subagent run.
692#[derive(Clone, Debug, PartialEq)]
693pub struct RunRequest {
694    /// Stable user identifier used for router accounting.
695    pub user_id: String,
696    /// Running parent operation whose cancellation propagates to each turn.
697    pub parent_operation_id: Uuid,
698    /// Exact requested model selector.
699    pub model: String,
700    /// Provider-neutral reasoning effort.
701    pub reasoning_effort: String,
702    /// Ordered immutable starting context sections.
703    pub context: Vec<String>,
704    /// Exact task presented after the context.
705    pub task: String,
706    /// Optional per-turn timeout.
707    pub timeout: Option<Duration>,
708    /// Additional application metadata included in the start audit event.
709    pub start_metadata: Value,
710}
711
712/// Completed subagent output.
713#[derive(Clone, Debug, Eq, PartialEq)]
714pub struct RunResult {
715    /// Final non-empty assistant answer.
716    pub answer: String,
717    /// Model used for every turn.
718    pub model: ResolvedAgentModel,
719}
720
721/// Cloneable provider-neutral subagent runtime.
722#[derive(Clone)]
723pub struct AgentRuntime {
724    intelligence: Intelligence,
725}
726
727impl AgentRuntime {
728    /// Constructs a runtime over the sole direct-model boundary.
729    pub fn new(intelligence: Intelligence) -> Self {
730        Self { intelligence }
731    }
732
733    /// Resolves a model without running an agent.
734    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    /// Starts exactly one host-independent primary provider inference.
742    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    /// Runs one primary logical turn across affine provider rounds until the host completes it.
780    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    /// Runs one fresh-context native subagent turn to a final non-empty answer.
956    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}