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