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, 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;
20const SUPERSEDED_TOOL_OUTPUT: &str = "[Tool output was displayed here, but has since been updated and now appears elsewhere in the context]";
21const REMOVED_TOOL_OUTPUT: &str = "[Tool output was displayed here, but is no longer current]";
22
23/// A boxed asynchronous host operation.
24pub type HostFuture<'a, T> = Pin<Box<dyn Future<Output = anyhow::Result<T>> + Send + 'a>>;
25
26/// One application tool call requested by a subagent.
27#[derive(Clone, Debug, PartialEq)]
28pub struct ToolCall {
29    /// Exact application tool name.
30    pub name: String,
31    /// Tool arguments.
32    pub arguments: Value,
33}
34
35/// One complete, typed audit fact selected by the subagent runtime.
36#[derive(Clone, Debug, PartialEq)]
37pub enum AuditEvent {
38    /// Immutable starting context and resolved provider capacity.
39    Started {
40        /// Parent operation that causally owns the subagent.
41        parent_operation_id: Uuid,
42        /// Caller-selected model identifier.
43        model: String,
44        /// Exact provider model.
45        provider_model: String,
46        /// Resolved provider transport.
47        provider: AgentProvider,
48        /// Total provider context window.
49        context_window_tokens: u64,
50        /// Maximum permitted input.
51        max_input_tokens: u64,
52        /// Ordered immutable starting context.
53        context: Vec<String>,
54        /// Focused subagent task.
55        task: String,
56        /// Opaque application metadata supplied at launch.
57        host: Value,
58    },
59    /// Exact input identity submitted for one provider round.
60    InferenceSubmitted {
61        /// Parent operation that causally owns the subagent.
62        parent_operation_id: Uuid,
63        /// One-based subagent round.
64        round: u64,
65        /// SHA-256 hash of the exact provider input.
66        manifest_hash: String,
67        /// Runtime input estimate including protocol reserve.
68        estimated_input_tokens: u64,
69    },
70    /// Application tool invocation requested by the provider.
71    ToolCall {
72        /// Parent operation that causally owns the subagent.
73        parent_operation_id: Uuid,
74        /// Exact application tool name.
75        name: String,
76        /// Exact tool arguments.
77        arguments: Value,
78    },
79    /// Complete application tool result retained for audit.
80    ToolResult {
81        /// Parent operation that causally owns the subagent.
82        parent_operation_id: Uuid,
83        /// Exact application tool name.
84        name: String,
85        /// Whether execution itself succeeded.
86        ok: bool,
87        /// Whether the resulting context projection fit.
88        projection_accepted: bool,
89        /// Exact application result before provider compaction.
90        result: String,
91    },
92    /// Canonical accounting for one completed or interrupted provider round.
93    ProviderReceipt {
94        /// Parent operation that causally owns the subagent.
95        parent_operation_id: Uuid,
96        /// One-based subagent round.
97        round: u64,
98        /// SHA-256 hash of the exact provider input.
99        manifest_hash: String,
100        /// Provider-native cumulative usage retained for exact audit detail.
101        usage: Option<kcode_codex_runtime_v2::TokenUsage>,
102        /// Canonical durable receipt written by the intelligence router.
103        receipt: Box<UsageReceipt>,
104    },
105    /// Final non-empty subagent answer.
106    Completed {
107        /// Parent operation that causally owns the subagent.
108        parent_operation_id: Uuid,
109        /// Caller-selected model identifier.
110        model: String,
111        /// Terminal response returned to the application.
112        response: String,
113    },
114}
115
116/// One replaceable state section rendered into every later context slice.
117#[derive(Clone, Debug, Eq, PartialEq)]
118pub struct StateUpdate {
119    /// Stable state identity. A later update with this key replaces the prior text.
120    pub key: String,
121    /// Current rendered state, or `None` to remove it.
122    pub text: Option<String>,
123}
124
125/// Result returned by the application after one tool or capture operation.
126#[derive(Clone, Debug, PartialEq)]
127pub struct ToolOutcome {
128    /// Exact result retained for audit.
129    pub text: String,
130    /// Whether the operation succeeded.
131    pub ok: bool,
132    /// Replaceable state made current by the operation.
133    pub state_updates: Vec<StateUpdate>,
134    /// State identities whose complete values this result displays.
135    ///
136    /// A later update to one of these identities replaces this retained result
137    /// with a concise supersession marker while preserving the exact audit
138    /// result.
139    pub displayed_state_keys: Vec<String>,
140    /// Opaque application token requesting a tool-free freeform output capture.
141    pub capture: Option<Value>,
142}
143
144impl ToolOutcome {
145    /// Constructs a simple successful result.
146    pub fn success(text: impl Into<String>) -> Self {
147        Self {
148            text: text.into(),
149            ok: true,
150            state_updates: Vec::new(),
151            displayed_state_keys: Vec::new(),
152            capture: None,
153        }
154    }
155
156    /// Constructs a simple failed result.
157    pub fn failure(text: impl Into<String>) -> Self {
158        Self {
159            text: text.into(),
160            ok: false,
161            state_updates: Vec::new(),
162            displayed_state_keys: Vec::new(),
163            capture: None,
164        }
165    }
166}
167
168/// Read-only capacity view supplied while a host evaluates a tool.
169#[derive(Clone)]
170pub struct ContextBudget {
171    projection: Projection,
172    max_input_tokens: u64,
173}
174
175impl ContextBudget {
176    /// Current estimated input tokens, including protocol reserve.
177    pub fn estimated_tokens(&self) -> u64 {
178        self.projection.estimated_tokens()
179    }
180
181    /// Maximum permitted input tokens.
182    pub fn max_input_tokens(&self) -> u64 {
183        self.max_input_tokens
184    }
185
186    /// Returns whether replacing one projected state would fit.
187    pub fn fits_state(&self, key: impl Into<String>, text: impl Into<String>) -> bool {
188        let mut projection = self.projection.clone();
189        projection.apply_updates(&[StateUpdate {
190            key: key.into(),
191            text: Some(text.into()),
192        }]);
193        projection.estimated_tokens() <= self.max_input_tokens
194    }
195}
196
197/// Application-owned behavior invoked by the generic subagent loop.
198pub trait Host: Send {
199    /// Renders the retained invocation text before execution.
200    fn render_tool_call(&mut self, call: &ToolCall) -> anyhow::Result<String>;
201
202    /// Executes one application tool.
203    fn execute_tool<'a>(
204        &'a mut self,
205        call: ToolCall,
206        operation_id: Uuid,
207        budget: ContextBudget,
208    ) -> HostFuture<'a, ToolOutcome>;
209
210    /// Completes an opaque freeform capture requested by a prior tool result.
211    fn complete_capture<'a>(
212        &'a mut self,
213        capture: Value,
214        contents: String,
215        budget: ContextBudget,
216    ) -> HostFuture<'a, ToolOutcome>;
217
218    /// Records one durable audit event selected by the runtime.
219    fn record(&mut self, event: AuditEvent) -> anyhow::Result<()>;
220}
221
222/// Inputs shared by every provider call in one primary session run.
223#[derive(Clone, Debug, Eq, PartialEq)]
224pub struct SessionRunRequest {
225    /// Stable user identifier used for router accounting.
226    pub user_id: String,
227    /// Top-level operation whose cancellation propagates to every provider call.
228    pub operation_id: Uuid,
229    /// Number of provider rounds already durably used by a restored session.
230    pub rounds_used: u64,
231    /// Maximum cumulative provider rounds permitted for the logical turn.
232    pub round_limit: u64,
233}
234
235/// Exact provider input prepared by the application for one primary round.
236#[derive(Clone, Debug, Eq, PartialEq)]
237pub struct PreparedRound {
238    /// Complete provider-visible context.
239    pub input: String,
240    /// Exact requested model identifier.
241    pub model: String,
242    /// Provider-neutral reasoning effort.
243    pub reasoning_effort: String,
244    /// Session-specific explanation attached to the single application-tool bridge.
245    pub tool_description: String,
246    /// Optional complete provider-turn timeout.
247    pub timeout: Option<Duration>,
248}
249
250/// Result of preparing the next primary round.
251#[derive(Clone, Debug, Eq, PartialEq)]
252pub enum RoundPreparation {
253    /// Run the prepared provider round.
254    Run(PreparedRound),
255    /// Finish without starting another provider round.
256    Complete(Option<String>),
257}
258
259/// One durable provider-protocol fact emitted by the primary runtime.
260#[derive(Clone, Debug, PartialEq)]
261pub enum SessionEvent {
262    /// Exact input identity submitted for one provider round.
263    InferenceSubmitted {
264        /// Cumulative one-based provider round.
265        round: u64,
266        /// SHA-256 hash of the exact provider input.
267        manifest_hash: String,
268        /// Exact requested model identifier.
269        model: String,
270    },
271    /// Exact provider transport input retained for diagnostics.
272    ProviderInput {
273        /// Cumulative one-based provider round.
274        round: u64,
275        /// Exact provider input record.
276        input: String,
277    },
278    /// Cumulative live usage reported during the provider round.
279    UsageUpdated {
280        /// Cumulative one-based provider round.
281        round: u64,
282        /// Provider-native cumulative usage.
283        usage: TokenUsage,
284    },
285    /// Canonical receipt and final usage for a completed or interrupted call.
286    ProviderReceipt {
287        /// Cumulative one-based provider round.
288        round: u64,
289        /// Final provider-native usage when available.
290        usage: Option<TokenUsage>,
291        /// Canonical durable receipt written by the intelligence router.
292        receipt: Box<UsageReceipt>,
293    },
294}
295
296/// Complete application handling of one primary-session tool call.
297#[derive(Clone, Debug, PartialEq)]
298pub struct SessionToolOutcome {
299    /// Exact result returned through the provider's native tool protocol.
300    pub text: String,
301    /// Whether tool execution succeeded.
302    pub ok: bool,
303    /// Opaque token requesting tool-free freeform output capture.
304    pub capture: Option<Value>,
305    /// Whether the logical session must stop after this tool response.
306    pub stop: bool,
307    /// Whether the logical session should finish after this provider round.
308    pub finish_after_round: bool,
309    /// Whether this tool already emitted the session's externally visible response.
310    pub emitted_response: bool,
311}
312
313impl SessionToolOutcome {
314    /// Constructs a simple successful tool result.
315    pub fn success(text: impl Into<String>) -> Self {
316        Self {
317            text: text.into(),
318            ok: true,
319            capture: None,
320            stop: false,
321            finish_after_round: false,
322            emitted_response: false,
323        }
324    }
325
326    /// Constructs a simple failed tool result.
327    pub fn failure(text: impl Into<String>) -> Self {
328        Self {
329            text: text.into(),
330            ok: false,
331            capture: None,
332            stop: false,
333            finish_after_round: false,
334            emitted_response: false,
335        }
336    }
337}
338
339/// Provider completion summarized for the application-owned session policy.
340#[derive(Clone, Debug, Eq, PartialEq)]
341pub struct RoundCompletion {
342    /// Terminal assistant text, which may be empty after tool use.
343    pub answer: String,
344    /// Whether the provider called at least one application tool.
345    pub used_tool: bool,
346    /// Whether a successful tool requested session completion after this round.
347    pub finish_requested: bool,
348    /// Whether a tool already emitted the externally visible response.
349    pub emitted_response: bool,
350}
351
352/// Application decision after completing a semantic primary-session transition.
353#[derive(Clone, Debug, Eq, PartialEq)]
354pub enum SessionControl {
355    /// Prepare another fresh provider round.
356    Continue,
357    /// Finish the logical turn with the optional externally visible answer.
358    Complete(Option<String>),
359}
360
361/// Application-owned semantics invoked by the primary agent runtime.
362pub trait SessionHost: Send {
363    /// Prepares the complete context and runtime selection for one provider round.
364    fn prepare_round<'a>(&'a mut self, round: u64) -> HostFuture<'a, RoundPreparation>;
365
366    /// Records one provider-protocol fact and durably checkpoints its effects.
367    fn record<'a>(&'a mut self, event: SessionEvent) -> HostFuture<'a, ()>;
368
369    /// Executes and durably projects one parsed or invalid application tool call.
370    fn execute_tool<'a>(
371        &'a mut self,
372        call: anyhow::Result<ToolCall>,
373        provider_operation_id: Uuid,
374    ) -> HostFuture<'a, SessionToolOutcome>;
375
376    /// Completes an opaque freeform capture requested by a prior tool result.
377    fn complete_capture<'a>(
378        &'a mut self,
379        capture: Value,
380        contents: String,
381    ) -> HostFuture<'a, SessionControl>;
382
383    /// Applies the terminal provider answer and decides whether another round is needed.
384    fn complete_round<'a>(
385        &'a mut self,
386        completion: RoundCompletion,
387    ) -> HostFuture<'a, SessionControl>;
388}
389
390/// Error returned when a restored primary turn exhausts its cumulative round budget.
391#[derive(Debug)]
392pub struct SessionRoundLimitError {
393    limit: u64,
394}
395
396impl std::fmt::Display for SessionRoundLimitError {
397    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
398        write!(
399            formatter,
400            "agent exceeded the {}-round tool-loop safety limit",
401            self.limit
402        )
403    }
404}
405
406impl std::error::Error for SessionRoundLimitError {}
407
408/// Returns whether an error is the primary runtime's round-limit signal.
409pub fn is_session_round_limit(error: &anyhow::Error) -> bool {
410    error.downcast_ref::<SessionRoundLimitError>().is_some()
411}
412
413/// Inputs for one complete subagent run.
414#[derive(Clone, Debug, PartialEq)]
415pub struct RunRequest {
416    /// Stable user identifier used for router accounting.
417    pub user_id: String,
418    /// Running parent operation whose cancellation propagates to each turn.
419    pub parent_operation_id: Uuid,
420    /// Exact requested model selector.
421    pub model: String,
422    /// Provider-neutral reasoning effort.
423    pub reasoning_effort: String,
424    /// Ordered immutable context sections.
425    pub context: Vec<String>,
426    /// Exact task presented after the context.
427    pub task: String,
428    /// Optional per-turn timeout.
429    pub timeout: Option<Duration>,
430    /// Additional application metadata included in the start audit event.
431    pub start_metadata: Value,
432}
433
434/// Completed subagent output.
435#[derive(Clone, Debug, Eq, PartialEq)]
436pub struct RunResult {
437    /// Final non-empty assistant answer.
438    pub answer: String,
439    /// Model used for every turn.
440    pub model: ResolvedAgentModel,
441}
442
443/// Cloneable provider-neutral subagent runtime.
444#[derive(Clone)]
445pub struct AgentRuntime {
446    intelligence: Intelligence,
447    round_limit: u64,
448}
449
450impl AgentRuntime {
451    /// Constructs a runtime over the sole direct-model boundary.
452    pub fn new(intelligence: Intelligence) -> Self {
453        Self {
454            intelligence,
455            round_limit: DEFAULT_ROUND_LIMIT,
456        }
457    }
458
459    /// Resolves a model without running an agent.
460    pub async fn resolve_model(&self, requested: &str) -> anyhow::Result<ResolvedAgentModel> {
461        self.intelligence
462            .resolve_agent_model(requested)
463            .await
464            .map_err(anyhow::Error::new)
465    }
466
467    /// Runs one primary logical turn across fresh provider rounds until the host completes it.
468    pub async fn run_session<H: SessionHost>(
469        &self,
470        request: SessionRunRequest,
471        host: &mut H,
472    ) -> anyhow::Result<Option<String>> {
473        ensure!(
474            request.round_limit > 0,
475            "session round limit must be positive"
476        );
477        ensure!(
478            request.rounds_used <= request.round_limit,
479            "restored session round count exceeds its safety limit"
480        );
481        let user = self
482            .intelligence
483            .for_user(request.user_id)
484            .map_err(anyhow::Error::new)?;
485
486        for round_index in request.rounds_used..request.round_limit {
487            let round = round_index + 1;
488            let prepared = match host.prepare_round(round).await? {
489                RoundPreparation::Run(prepared) => prepared,
490                RoundPreparation::Complete(answer) => return Ok(answer),
491            };
492            let manifest_hash = hex::encode(Sha256::digest(prepared.input.as_bytes()));
493            host.record(SessionEvent::InferenceSubmitted {
494                round,
495                manifest_hash: manifest_hash.clone(),
496                model: prepared.model.clone(),
497            })
498            .await?;
499
500            let mut provider_request = AgentRequest::new(prepared.input, prepared.model);
501            provider_request.reasoning_effort = reasoning_effort(&prepared.reasoning_effort)?;
502            provider_request.previous_thread_id = None;
503            provider_request.tools = vec![ktool_definition(&prepared.tool_description)];
504            if let Some(timeout) = prepared.timeout {
505                provider_request.timeout = timeout;
506            }
507            let mut turn = match user
508                .start_agent_turn(request.operation_id, None, provider_request)
509                .await
510            {
511                Ok(turn) => turn,
512                Err(error) => {
513                    if let Some(receipt) = error.receipt().cloned() {
514                        host.record(SessionEvent::ProviderReceipt {
515                            round,
516                            usage: None,
517                            receipt: Box::new(receipt),
518                        })
519                        .await?;
520                    }
521                    return Err(anyhow::Error::new(error));
522                }
523            };
524            let mut used_tool = false;
525            let mut finish_requested = false;
526            let mut emitted_response = false;
527            let mut pending_capture: Option<Value> = None;
528            let completed = loop {
529                let event = match turn.next_event().await {
530                    Ok(Some(event)) => event,
531                    Ok(None) => {
532                        let receipt = turn.finish_unavailable()?.clone();
533                        host.record(SessionEvent::ProviderReceipt {
534                            round,
535                            usage: None,
536                            receipt: Box::new(receipt),
537                        })
538                        .await?;
539                        anyhow::bail!("provider ended without a terminal turn event");
540                    }
541                    Err(error) => {
542                        if let Some(receipt) = error.receipt().cloned() {
543                            host.record(SessionEvent::ProviderReceipt {
544                                round,
545                                usage: None,
546                                receipt: Box::new(receipt),
547                            })
548                            .await?;
549                        }
550                        return Err(anyhow::Error::new(error));
551                    }
552                };
553                match event {
554                    AgentEvent::ProviderInput(input) => {
555                        host.record(SessionEvent::ProviderInput { round, input })
556                            .await?;
557                    }
558                    AgentEvent::UsageUpdated(usage) => {
559                        host.record(SessionEvent::UsageUpdated { round, usage })
560                            .await?;
561                    }
562                    AgentEvent::ToolCall(native) => {
563                        used_tool = true;
564                        let call = parse_ktool_call(&native)
565                            .map_err(|error| anyhow::anyhow!("Invalid Ktool call: {error}"));
566                        let mut outcome = host.execute_tool(call, request.operation_id).await?;
567                        finish_requested |= outcome.ok && outcome.finish_after_round;
568                        emitted_response |= outcome.ok && outcome.emitted_response;
569                        pending_capture = outcome.capture.take();
570                        let stop = outcome.stop;
571                        respond_session_or_record(
572                            &mut turn,
573                            host,
574                            round,
575                            &native.call_id,
576                            if outcome.ok {
577                                ToolResult::success(outcome.text)
578                            } else {
579                                ToolResult::failure(outcome.text)
580                            },
581                        )
582                        .await?;
583                        if stop {
584                            return Ok(None);
585                        }
586                    }
587                    AgentEvent::Completed(completed) => break completed,
588                }
589            };
590            let receipt = turn
591                .receipt()
592                .context("provider completed without a usage receipt")?
593                .clone();
594            host.record(SessionEvent::ProviderReceipt {
595                round,
596                usage: completed.usage.clone(),
597                receipt: Box::new(receipt),
598            })
599            .await?;
600
601            let control = if let Some(capture) = pending_capture {
602                host.complete_capture(capture, completed.answer).await?
603            } else {
604                host.complete_round(RoundCompletion {
605                    answer: completed.answer,
606                    used_tool,
607                    finish_requested,
608                    emitted_response,
609                })
610                .await?
611            };
612            match control {
613                SessionControl::Continue => {}
614                SessionControl::Complete(answer) => return Ok(answer),
615            }
616        }
617        Err(SessionRoundLimitError {
618            limit: request.round_limit,
619        }
620        .into())
621    }
622
623    /// Runs one fresh-context subagent to a final non-empty answer.
624    pub async fn run<H: Host>(
625        &self,
626        request: RunRequest,
627        host: &mut H,
628    ) -> anyhow::Result<RunResult> {
629        let selected = self.resolve_model(&request.model).await?;
630        let reasoning_effort = reasoning_effort(&request.reasoning_effort)?;
631        let mut projection = Projection::new(request.context, request.task);
632        ensure_capacity(&projection, selected.max_input_tokens)?;
633        host.record(AuditEvent::Started {
634            parent_operation_id: request.parent_operation_id,
635            model: request.model.clone(),
636            provider_model: selected.provider_model.clone(),
637            provider: selected.provider,
638            context_window_tokens: selected.context_window_tokens,
639            max_input_tokens: selected.max_input_tokens,
640            context: projection.context.clone(),
641            task: projection.task.clone(),
642            host: request.start_metadata.clone(),
643        })?;
644        let user = self
645            .intelligence
646            .for_user(request.user_id)
647            .map_err(anyhow::Error::new)?;
648        let mut deferred_capture: Option<Value> = None;
649
650        for round in 0..self.round_limit {
651            let capturing = deferred_capture.is_some();
652            ensure_capacity(&projection, selected.max_input_tokens)?;
653            let input = projection.render();
654            let manifest_hash = hex::encode(Sha256::digest(input.as_bytes()));
655            host.record(AuditEvent::InferenceSubmitted {
656                parent_operation_id: request.parent_operation_id,
657                round: round + 1,
658                manifest_hash: manifest_hash.clone(),
659                estimated_input_tokens: projection.estimated_tokens(),
660            })?;
661            let mut provider_request = AgentRequest::new(input, selected.requested_model.clone());
662            provider_request.reasoning_effort = reasoning_effort;
663            provider_request.ephemeral = true;
664            provider_request.tools = if capturing {
665                Vec::new()
666            } else {
667                vec![ktool_definition(
668                    "Call one available Ktool by its exact name.",
669                )]
670            };
671            if let Some(timeout) = request.timeout {
672                provider_request.timeout = timeout;
673            }
674            let child_operation_id = Uuid::new_v4();
675            let mut turn = match user
676                .start_agent_turn(
677                    child_operation_id,
678                    Some(request.parent_operation_id),
679                    provider_request,
680                )
681                .await
682            {
683                Ok(turn) => turn,
684                Err(error) => {
685                    if let Some(receipt) = error.receipt().cloned() {
686                        host.record(AuditEvent::ProviderReceipt {
687                            parent_operation_id: request.parent_operation_id,
688                            round: round + 1,
689                            manifest_hash,
690                            usage: None,
691                            receipt: Box::new(receipt),
692                        })?;
693                    }
694                    return Err(anyhow::Error::new(error));
695                }
696            };
697            let mut used_tool = false;
698            let mut pending_capture: Option<Value> = None;
699            let mut requires_rerender = false;
700            let completed = loop {
701                let event = match turn.next_event().await {
702                    Ok(Some(event)) => event,
703                    Ok(None) => {
704                        let receipt = turn.finish_unavailable()?.clone();
705                        host.record(AuditEvent::ProviderReceipt {
706                            parent_operation_id: request.parent_operation_id,
707                            round: round + 1,
708                            manifest_hash: manifest_hash.clone(),
709                            usage: None,
710                            receipt: Box::new(receipt),
711                        })?;
712                        anyhow::bail!("subagent provider ended without a terminal turn event");
713                    }
714                    Err(error) => {
715                        if let Some(receipt) = error.receipt().cloned() {
716                            host.record(AuditEvent::ProviderReceipt {
717                                parent_operation_id: request.parent_operation_id,
718                                round: round + 1,
719                                manifest_hash: manifest_hash.clone(),
720                                usage: None,
721                                receipt: Box::new(receipt),
722                            })?;
723                        }
724                        return Err(anyhow::Error::new(error));
725                    }
726                };
727                match event {
728                    AgentEvent::ProviderInput(_) => {}
729                    AgentEvent::UsageUpdated(_) => {}
730                    AgentEvent::ToolCall(native) => {
731                        used_tool = true;
732                        if capturing {
733                            respond_or_record(
734                                &mut turn,
735                                host,
736                                request.parent_operation_id,
737                                round + 1,
738                                &manifest_hash,
739                                &native.call_id,
740                                ToolResult::failure(
741                                    "No application tool is available while complete freeform output is being captured.",
742                                ),
743                            )
744                            .await?;
745                            continue;
746                        }
747                        if pending_capture.is_some() {
748                            respond_or_record(
749                                &mut turn,
750                                host,
751                                request.parent_operation_id,
752                                round + 1,
753                                &manifest_hash,
754                                &native.call_id,
755                                ToolResult::failure(
756                                    "A freeform output capture is pending; no other tool can run first.",
757                                ),
758                            )
759                            .await?;
760                            continue;
761                        }
762                        if requires_rerender {
763                            respond_or_record(
764                                &mut turn,
765                                host,
766                                request.parent_operation_id,
767                                round + 1,
768                                &manifest_hash,
769                                &native.call_id,
770                                ToolResult::failure(
771                                    "A state update is waiting to be re-rendered. End this slice before calling another tool.",
772                                ),
773                            )
774                            .await?;
775                            continue;
776                        }
777                        let call = match parse_ktool_call(&native) {
778                            Ok(call) => call,
779                            Err(error) => {
780                                let text = format!("Invalid application tool call: {error}");
781                                projection.push_history(format!("Ktool result:\n{text}"));
782                                respond_or_record(
783                                    &mut turn,
784                                    host,
785                                    request.parent_operation_id,
786                                    round + 1,
787                                    &manifest_hash,
788                                    &native.call_id,
789                                    ToolResult::failure(text),
790                                )
791                                .await?;
792                                continue;
793                            }
794                        };
795                        host.record(AuditEvent::ToolCall {
796                            parent_operation_id: request.parent_operation_id,
797                            name: call.name.clone(),
798                            arguments: call.arguments.clone(),
799                        })?;
800                        projection.push_history(format!(
801                            "Ktool call:\n{}",
802                            host.render_tool_call(&call)?
803                        ));
804                        let budget = ContextBudget {
805                            projection: projection.clone(),
806                            max_input_tokens: selected.max_input_tokens,
807                        };
808                        let mut outcome = host
809                            .execute_tool(call.clone(), child_operation_id, budget)
810                            .await
811                            .unwrap_or_else(|error| {
812                                ToolOutcome::failure(format!("{} failed: {error}", call.name))
813                            });
814                        let exact_result = outcome.text.clone();
815                        let initially_ok = outcome.ok;
816                        let has_state_updates = initially_ok && !outcome.state_updates.is_empty();
817                        let mut provider_result =
818                            compact_tool_result(&outcome.text, successful_state_updates(&outcome));
819                        let mut candidate = projection.clone();
820                        candidate.apply_updates(successful_state_updates(&outcome));
821                        candidate.push_history_displaying(
822                            format!("Ktool result:\n{provider_result}"),
823                            if initially_ok {
824                                outcome.displayed_state_keys.clone()
825                            } else {
826                                Default::default()
827                            },
828                        );
829                        let accepted = candidate.estimated_tokens() <= selected.max_input_tokens;
830                        if accepted {
831                            projection = candidate;
832                            requires_rerender = has_state_updates;
833                        } else {
834                            outcome.ok = false;
835                            outcome.capture = None;
836                            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();
837                            projection.push_history(format!("Ktool result:\n{provider_result}"));
838                        }
839                        host.record(AuditEvent::ToolResult {
840                            parent_operation_id: request.parent_operation_id,
841                            name: call.name.clone(),
842                            ok: initially_ok,
843                            projection_accepted: accepted,
844                            result: exact_result,
845                        })?;
846                        pending_capture = outcome.capture.take();
847                        respond_or_record(
848                            &mut turn,
849                            host,
850                            request.parent_operation_id,
851                            round + 1,
852                            &manifest_hash,
853                            &native.call_id,
854                            if outcome.ok {
855                                ToolResult::success(provider_result)
856                            } else {
857                                ToolResult::failure(provider_result)
858                            },
859                        )
860                        .await?;
861                    }
862                    AgentEvent::Completed(completed) => break completed,
863                }
864            };
865            let receipt = turn
866                .receipt()
867                .context("subagent provider completed without a usage receipt")?
868                .clone();
869            host.record(AuditEvent::ProviderReceipt {
870                parent_operation_id: request.parent_operation_id,
871                round: round + 1,
872                manifest_hash: manifest_hash.clone(),
873                usage: completed.usage.clone(),
874                receipt: Box::new(receipt),
875            })?;
876
877            let capture = deferred_capture.take().or(pending_capture);
878            if let Some(capture) = capture {
879                if !capturing && completed.answer.is_empty() {
880                    deferred_capture = Some(capture);
881                    continue;
882                }
883                let budget = ContextBudget {
884                    projection: projection.clone(),
885                    max_input_tokens: selected.max_input_tokens,
886                };
887                let outcome = host
888                    .complete_capture(capture, completed.answer, budget)
889                    .await?;
890                let mut candidate = projection.clone();
891                candidate.apply_updates(successful_state_updates(&outcome));
892                candidate.push_history(format!("Ktool result:\n{}", outcome.text));
893                ensure_capacity(&candidate, selected.max_input_tokens)?;
894                projection = candidate;
895                continue;
896            }
897            if requires_rerender {
898                let draft = completed.answer.trim();
899                if !draft.is_empty() {
900                    projection.push_history(format!(
901                        "Assistant draft produced before the state refresh:\n{draft}"
902                    ));
903                }
904                continue;
905            }
906            let answer = completed.answer.trim().to_owned();
907            if !answer.is_empty() {
908                host.record(AuditEvent::Completed {
909                    parent_operation_id: request.parent_operation_id,
910                    model: request.model.clone(),
911                    response: answer.clone(),
912                })?;
913                return Ok(RunResult {
914                    answer,
915                    model: selected,
916                });
917            }
918            ensure!(
919                used_tool,
920                "subagent provider completed without a response or tool call"
921            );
922        }
923        anyhow::bail!(
924            "subagent exceeded the {}-round tool-loop safety limit",
925            self.round_limit
926        )
927    }
928}
929
930async fn respond_or_record<H: Host>(
931    turn: &mut kcode_intelligence_router::AgentTurn,
932    host: &mut H,
933    parent_operation_id: Uuid,
934    round: u64,
935    manifest_hash: &str,
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(AuditEvent::ProviderReceipt {
942            parent_operation_id,
943            round,
944            manifest_hash: manifest_hash.into(),
945            usage: None,
946            receipt: Box::new(receipt),
947        })?;
948        return Err(anyhow::Error::new(error));
949    }
950    Ok(())
951}
952
953async fn respond_session_or_record<H: SessionHost>(
954    turn: &mut kcode_intelligence_router::AgentTurn,
955    host: &mut H,
956    round: u64,
957    call_id: &str,
958    result: ToolResult,
959) -> anyhow::Result<()> {
960    if let Err(error) = turn.respond(call_id, result).await {
961        let receipt = turn.finish_unavailable()?.clone();
962        host.record(SessionEvent::ProviderReceipt {
963            round,
964            usage: None,
965            receipt: Box::new(receipt),
966        })
967        .await?;
968        return Err(anyhow::Error::new(error));
969    }
970    Ok(())
971}
972
973#[derive(Clone)]
974struct Projection {
975    context: Vec<String>,
976    task: String,
977    history: Vec<ProjectedHistory>,
978    states: Vec<ProjectedState>,
979}
980
981#[derive(Clone)]
982struct ProjectedHistory {
983    text: String,
984    displayed_state_keys: Vec<String>,
985}
986
987#[derive(Clone)]
988struct ProjectedState {
989    key: String,
990    text: String,
991}
992
993impl Projection {
994    fn new(context: Vec<String>, task: String) -> Self {
995        Self {
996            context,
997            task,
998            history: Vec::new(),
999            states: Vec::new(),
1000        }
1001    }
1002
1003    fn render(&self) -> String {
1004        self.context
1005            .iter()
1006            .map(String::as_str)
1007            .chain(std::iter::once(self.task.as_str()))
1008            .chain(self.history.iter().map(|entry| entry.text.as_str()))
1009            .chain(
1010                self.states
1011                    .iter()
1012                    .filter(|state| {
1013                        !self.history.iter().any(|entry| {
1014                            entry
1015                                .displayed_state_keys
1016                                .iter()
1017                                .any(|key| key == &state.key)
1018                        })
1019                    })
1020                    .map(|state| state.text.as_str()),
1021            )
1022            .filter(|section| !section.is_empty())
1023            .collect::<Vec<_>>()
1024            .join("\n\n")
1025    }
1026
1027    fn push_history(&mut self, text: impl Into<String>) {
1028        self.push_history_displaying(text, Vec::new());
1029    }
1030
1031    fn push_history_displaying(
1032        &mut self,
1033        text: impl Into<String>,
1034        displayed_state_keys: Vec<String>,
1035    ) {
1036        self.history.push(ProjectedHistory {
1037            text: text.into(),
1038            displayed_state_keys,
1039        });
1040    }
1041
1042    fn update_state(&mut self, key: String, text: Option<String>) {
1043        self.states.retain(|state| state.key != key);
1044        if let Some(text) = text {
1045            self.states.push(ProjectedState { key, text });
1046        }
1047    }
1048
1049    fn apply_updates(&mut self, updates: &[StateUpdate]) {
1050        for update in updates {
1051            let marker = if update.text.is_some() {
1052                SUPERSEDED_TOOL_OUTPUT
1053            } else {
1054                REMOVED_TOOL_OUTPUT
1055            };
1056            for entry in &mut self.history {
1057                if entry
1058                    .displayed_state_keys
1059                    .iter()
1060                    .any(|key| key == &update.key)
1061                {
1062                    entry.text = marker.into();
1063                    entry.displayed_state_keys.clear();
1064                }
1065            }
1066            self.update_state(update.key.clone(), update.text.clone());
1067        }
1068    }
1069
1070    fn estimated_tokens(&self) -> u64 {
1071        (self.render().chars().count() as u64)
1072            .div_ceil(4)
1073            .saturating_add(PROTOCOL_TOKEN_RESERVE)
1074    }
1075}
1076
1077fn compact_tool_result(text: &str, states: &[StateUpdate]) -> String {
1078    if states.is_empty() {
1079        return text.to_owned();
1080    }
1081    let result = if text.chars().count() <= INLINE_TOOL_RESULT_CHARACTERS {
1082        text
1083    } else {
1084        "Tool completed successfully."
1085    };
1086    format!(
1087        "{result}\n\nThe updated state will be rendered in the next fresh context slice; end this slice now."
1088    )
1089}
1090
1091fn successful_state_updates(outcome: &ToolOutcome) -> &[StateUpdate] {
1092    if outcome.ok {
1093        &outcome.state_updates
1094    } else {
1095        &[]
1096    }
1097}
1098
1099fn ensure_capacity(projection: &Projection, max_input_tokens: u64) -> anyhow::Result<()> {
1100    let estimated = projection.estimated_tokens();
1101    ensure!(
1102        estimated <= max_input_tokens,
1103        "subagent context requires approximately {estimated} input tokens, over the selected model's {max_input_tokens}-token input limit"
1104    );
1105    Ok(())
1106}
1107
1108fn ktool_definition(description: &str) -> DynamicTool {
1109    DynamicTool::new(
1110        "call_ktool",
1111        description,
1112        json!({
1113            "type": "object",
1114            "additionalProperties": false,
1115            "required": ["name", "arguments"],
1116            "properties": {
1117                "name": {"type": "string"},
1118                "arguments": {"type": "object"}
1119            }
1120        }),
1121    )
1122}
1123
1124fn parse_ktool_call(call: &DynamicToolCall) -> anyhow::Result<ToolCall> {
1125    ensure!(call.tool == "call_ktool", "unknown provider tool");
1126    let arguments = call
1127        .arguments
1128        .as_object()
1129        .context("call_ktool arguments must be an object")?;
1130    ensure!(
1131        arguments
1132            .keys()
1133            .all(|key| matches!(key.as_str(), "name" | "arguments")),
1134        "call_ktool contains unknown arguments"
1135    );
1136    let name = arguments
1137        .get("name")
1138        .and_then(Value::as_str)
1139        .map(str::trim)
1140        .filter(|name| !name.is_empty() && name.chars().count() <= 100)
1141        .context("call_ktool.name must be a non-empty bounded string")?
1142        .to_owned();
1143    let arguments = arguments
1144        .get("arguments")
1145        .filter(|value| value.is_object())
1146        .context("call_ktool.arguments must be an object")?
1147        .clone();
1148    Ok(ToolCall { name, arguments })
1149}
1150
1151fn reasoning_effort(value: &str) -> anyhow::Result<ReasoningEffort> {
1152    Ok(match value {
1153        "none" => ReasoningEffort::None,
1154        "minimal" => ReasoningEffort::Minimal,
1155        "low" => ReasoningEffort::Low,
1156        "medium" => ReasoningEffort::Medium,
1157        "high" => ReasoningEffort::High,
1158        "xhigh" => ReasoningEffort::XHigh,
1159        "max" => ReasoningEffort::Max,
1160        _ => anyhow::bail!("unsupported reasoning effort {value:?}"),
1161    })
1162}
1163
1164#[cfg(test)]
1165mod tests {
1166    use super::*;
1167
1168    #[test]
1169    fn projection_replaces_state_and_budget_accounts_for_reserve() {
1170        let mut projection = Projection::new(vec!["context".into()], "task".into());
1171        projection.update_state("file".into(), Some("old".into()));
1172        projection.update_state("file".into(), Some("new".into()));
1173        assert_eq!(projection.states.len(), 1);
1174        assert!(projection.render().contains("new"));
1175        assert!(!projection.render().contains("old"));
1176        assert!(projection.estimated_tokens() >= PROTOCOL_TOKEN_RESERVE);
1177    }
1178
1179    #[test]
1180    fn state_update_supersedes_earlier_display_and_moves_current_value_after_history() {
1181        let mut projection = Projection::new(vec!["context".into()], "task".into());
1182        projection.push_history("Ktool call: open");
1183        projection.push_history_displaying(
1184            "Ktool result:\ncomplete old source",
1185            vec!["tool-state:7".into()],
1186        );
1187        projection.push_history("Ktool call: write");
1188        projection.apply_updates(&[StateUpdate {
1189            key: "tool-state:7".into(),
1190            text: Some("complete new source".into()),
1191        }]);
1192        projection.push_history("Ktool result: write completed");
1193
1194        let rendered = projection.render();
1195        assert!(!rendered.contains("complete old source"));
1196        assert_eq!(rendered.matches(SUPERSEDED_TOOL_OUTPUT).count(), 1);
1197        assert_eq!(rendered.matches("complete new source").count(), 1);
1198        assert!(
1199            rendered.find(SUPERSEDED_TOOL_OUTPUT).unwrap()
1200                < rendered.find("Ktool call: write").unwrap()
1201        );
1202        assert!(
1203            rendered.find("Ktool result: write completed").unwrap()
1204                < rendered.find("complete new source").unwrap()
1205        );
1206    }
1207
1208    #[test]
1209    fn current_state_is_not_duplicated_when_the_latest_result_displays_it() {
1210        let mut projection = Projection::new(vec!["context".into()], "task".into());
1211        projection.apply_updates(&[StateUpdate {
1212            key: "tool-state:7".into(),
1213            text: Some("complete source".into()),
1214        }]);
1215        projection.push_history_displaying(
1216            "Ktool result:\ncomplete source",
1217            vec!["tool-state:7".into()],
1218        );
1219
1220        assert_eq!(projection.render().matches("complete source").count(), 1);
1221    }
1222
1223    #[test]
1224    fn state_update_only_supersedes_displays_with_the_same_identity() {
1225        let mut projection = Projection::new(vec!["context".into()], "task".into());
1226        projection.push_history_displaying("first output", vec!["first".into()]);
1227        projection.push_history_displaying("second output", vec!["second".into()]);
1228        projection.apply_updates(&[StateUpdate {
1229            key: "first".into(),
1230            text: Some("current first output".into()),
1231        }]);
1232
1233        let rendered = projection.render();
1234        assert!(
1235            !rendered
1236                .split("\n\n")
1237                .any(|section| section == "first output")
1238        );
1239        assert!(rendered.contains(SUPERSEDED_TOOL_OUTPUT));
1240        assert!(rendered.contains("second output"));
1241        assert!(rendered.contains("current first output"));
1242    }
1243
1244    #[test]
1245    fn removed_state_uses_a_truthful_supersession_marker() {
1246        let mut projection = Projection::new(vec!["context".into()], "task".into());
1247        projection.push_history_displaying("retired output", vec!["state".into()]);
1248        projection.apply_updates(&[StateUpdate {
1249            key: "state".into(),
1250            text: None,
1251        }]);
1252
1253        let rendered = projection.render();
1254        assert!(!rendered.contains("retired output"));
1255        assert!(rendered.contains(REMOVED_TOOL_OUTPUT));
1256    }
1257
1258    #[test]
1259    fn failed_outcomes_cannot_update_or_supersede_state() {
1260        let mut projection = Projection::new(vec!["context".into()], "task".into());
1261        projection.push_history_displaying("current output", vec!["state".into()]);
1262        let failed = ToolOutcome {
1263            text: "write failed".into(),
1264            ok: false,
1265            state_updates: vec![StateUpdate {
1266                key: "state".into(),
1267                text: Some("invalid update".into()),
1268            }],
1269            displayed_state_keys: Vec::new(),
1270            capture: None,
1271        };
1272
1273        projection.apply_updates(successful_state_updates(&failed));
1274
1275        let rendered = projection.render();
1276        assert!(rendered.contains("current output"));
1277        assert!(!rendered.contains("invalid update"));
1278        assert!(!rendered.contains(SUPERSEDED_TOOL_OUTPUT));
1279    }
1280
1281    #[test]
1282    fn state_changes_compact_large_tool_results() {
1283        let compacted = compact_tool_result(
1284            &"x".repeat(INLINE_TOOL_RESULT_CHARACTERS + 1),
1285            &[StateUpdate {
1286                key: "state".into(),
1287                text: Some("current".into()),
1288            }],
1289        );
1290        assert!(compacted.starts_with("Tool completed successfully."));
1291        assert!(compacted.contains("fresh context slice"));
1292    }
1293
1294    #[test]
1295    fn native_tool_wrapper_is_strict() {
1296        let call = parse_ktool_call(&DynamicToolCall {
1297            call_id: "1".into(),
1298            tool: "call_ktool".into(),
1299            arguments: json!({"name": "Read", "arguments": {"id": 1}}),
1300        })
1301        .unwrap();
1302        assert_eq!(call.name, "Read");
1303        assert_eq!(call.arguments["id"], 1);
1304    }
1305}