1#![deny(missing_docs)]
4#![forbid(unsafe_code)]
5
6use std::{future::Future, pin::Pin, time::Duration};
7
8use anyhow::{Context, ensure};
9use kcode_codex_runtime_v2::{
10 AgentEvent, AgentRequest, DynamicTool, DynamicToolCall, ReasoningEffort, ToolResult,
11};
12use kcode_intelligence_router::{Intelligence, ResolvedAgentModel};
13use serde_json::{Value, json};
14use sha2::{Digest, Sha256};
15use uuid::Uuid;
16
17const DEFAULT_ROUND_LIMIT: u64 = 100;
18const PROTOCOL_TOKEN_RESERVE: u64 = 4_096;
19const INLINE_TOOL_RESULT_CHARACTERS: usize = 1_000;
20
21pub type HostFuture<'a, T> = Pin<Box<dyn Future<Output = anyhow::Result<T>> + Send + 'a>>;
23
24#[derive(Clone, Debug, PartialEq)]
26pub struct ToolCall {
27 pub name: String,
29 pub arguments: Value,
31}
32
33#[derive(Clone, Debug, Eq, PartialEq)]
35pub struct StateUpdate {
36 pub key: String,
38 pub text: Option<String>,
40}
41
42#[derive(Clone, Debug, PartialEq)]
44pub struct ToolOutcome {
45 pub text: String,
47 pub ok: bool,
49 pub state_updates: Vec<StateUpdate>,
51 pub capture: Option<Value>,
53}
54
55impl ToolOutcome {
56 pub fn success(text: impl Into<String>) -> Self {
58 Self {
59 text: text.into(),
60 ok: true,
61 state_updates: Vec::new(),
62 capture: None,
63 }
64 }
65
66 pub fn failure(text: impl Into<String>) -> Self {
68 Self {
69 text: text.into(),
70 ok: false,
71 state_updates: Vec::new(),
72 capture: None,
73 }
74 }
75}
76
77#[derive(Clone)]
79pub struct ContextBudget {
80 projection: Projection,
81 max_input_tokens: u64,
82}
83
84impl ContextBudget {
85 pub fn estimated_tokens(&self) -> u64 {
87 self.projection.estimated_tokens()
88 }
89
90 pub fn max_input_tokens(&self) -> u64 {
92 self.max_input_tokens
93 }
94
95 pub fn fits_state(&self, key: impl Into<String>, text: impl Into<String>) -> bool {
97 let mut projection = self.projection.clone();
98 projection.update_state(key.into(), Some(text.into()));
99 projection.estimated_tokens() <= self.max_input_tokens
100 }
101}
102
103pub trait Host: Send {
105 fn render_tool_call(&mut self, call: &ToolCall) -> anyhow::Result<String>;
107
108 fn execute_tool<'a>(
110 &'a mut self,
111 call: ToolCall,
112 operation_id: Uuid,
113 budget: ContextBudget,
114 ) -> HostFuture<'a, ToolOutcome>;
115
116 fn complete_capture<'a>(
118 &'a mut self,
119 capture: Value,
120 contents: String,
121 budget: ContextBudget,
122 ) -> HostFuture<'a, ToolOutcome>;
123
124 fn record(&mut self, label: &str, value: Value) -> anyhow::Result<()>;
126}
127
128#[derive(Clone, Debug, PartialEq)]
130pub struct RunRequest {
131 pub user_id: String,
133 pub parent_operation_id: Uuid,
135 pub model: String,
137 pub reasoning_effort: String,
139 pub context: Vec<String>,
141 pub task: String,
143 pub timeout: Option<Duration>,
145 pub start_metadata: Value,
147}
148
149#[derive(Clone, Debug, Eq, PartialEq)]
151pub struct RunResult {
152 pub answer: String,
154 pub model: ResolvedAgentModel,
156}
157
158#[derive(Clone)]
160pub struct AgentRuntime {
161 intelligence: Intelligence,
162 round_limit: u64,
163}
164
165impl AgentRuntime {
166 pub fn new(intelligence: Intelligence) -> Self {
168 Self {
169 intelligence,
170 round_limit: DEFAULT_ROUND_LIMIT,
171 }
172 }
173
174 pub async fn resolve_model(&self, requested: &str) -> anyhow::Result<ResolvedAgentModel> {
176 self.intelligence
177 .resolve_agent_model(requested)
178 .await
179 .map_err(anyhow::Error::new)
180 }
181
182 pub async fn run<H: Host>(
184 &self,
185 request: RunRequest,
186 host: &mut H,
187 ) -> anyhow::Result<RunResult> {
188 let selected = self.resolve_model(&request.model).await?;
189 let reasoning_effort = reasoning_effort(&request.reasoning_effort)?;
190 let mut projection = Projection::new(request.context, request.task);
191 ensure_capacity(&projection, selected.max_input_tokens)?;
192 host.record(
193 "subagent_started",
194 json!({
195 "model": request.model,
196 "providerModel": selected.provider_model,
197 "provider": format!("{:?}", selected.provider),
198 "contextWindowTokens": selected.context_window_tokens,
199 "maxInputTokens": selected.max_input_tokens,
200 "context": projection.context,
201 "task": projection.task,
202 "host": request.start_metadata,
203 }),
204 )?;
205 let user = self
206 .intelligence
207 .for_user(request.user_id)
208 .map_err(anyhow::Error::new)?;
209 let mut deferred_capture: Option<Value> = None;
210
211 for round in 0..self.round_limit {
212 let capturing = deferred_capture.is_some();
213 ensure_capacity(&projection, selected.max_input_tokens)?;
214 let input = projection.render();
215 let manifest_hash = hex::encode(Sha256::digest(input.as_bytes()));
216 host.record(
217 "subagent_inference_submitted",
218 json!({
219 "round": round + 1,
220 "manifestHash": manifest_hash,
221 "estimatedInputTokens": projection.estimated_tokens(),
222 }),
223 )?;
224 let mut provider_request = AgentRequest::new(input, selected.requested_model.clone());
225 provider_request.reasoning_effort = reasoning_effort;
226 provider_request.ephemeral = true;
227 provider_request.tools = if capturing {
228 Vec::new()
229 } else {
230 vec![ktool_definition()]
231 };
232 if let Some(timeout) = request.timeout {
233 provider_request.timeout = timeout;
234 }
235 let child_operation_id = Uuid::new_v4();
236 let mut turn = user
237 .start_agent_turn(
238 child_operation_id,
239 Some(request.parent_operation_id),
240 provider_request,
241 )
242 .await
243 .map_err(anyhow::Error::new)?;
244 let mut used_tool = false;
245 let mut pending_capture: Option<Value> = None;
246 let mut requires_rerender = false;
247 let completed = loop {
248 let event = turn
249 .next_event()
250 .await
251 .map_err(anyhow::Error::new)?
252 .context("subagent provider ended without a terminal turn event")?;
253 match event {
254 AgentEvent::ProviderInput(_) => {}
255 AgentEvent::ToolCall(native) => {
256 used_tool = true;
257 if capturing {
258 turn.respond(
259 &native.call_id,
260 ToolResult::failure(
261 "No application tool is available while complete freeform output is being captured.",
262 ),
263 )
264 .await
265 .map_err(anyhow::Error::new)?;
266 continue;
267 }
268 if pending_capture.is_some() {
269 turn.respond(
270 &native.call_id,
271 ToolResult::failure(
272 "A freeform output capture is pending; no other tool can run first.",
273 ),
274 )
275 .await
276 .map_err(anyhow::Error::new)?;
277 continue;
278 }
279 if requires_rerender {
280 turn.respond(
281 &native.call_id,
282 ToolResult::failure(
283 "A state update is waiting to be re-rendered. End this slice before calling another tool.",
284 ),
285 )
286 .await
287 .map_err(anyhow::Error::new)?;
288 continue;
289 }
290 let call = match parse_ktool_call(&native) {
291 Ok(call) => call,
292 Err(error) => {
293 let text = format!("Invalid application tool call: {error}");
294 projection.push_history(format!("Ktool result:\n{text}"));
295 turn.respond(&native.call_id, ToolResult::failure(text))
296 .await
297 .map_err(anyhow::Error::new)?;
298 continue;
299 }
300 };
301 host.record(
302 "subagent_tool_call",
303 json!({"name": call.name, "arguments": call.arguments}),
304 )?;
305 projection.push_history(format!(
306 "Ktool call:\n{}",
307 host.render_tool_call(&call)?
308 ));
309 let budget = ContextBudget {
310 projection: projection.clone(),
311 max_input_tokens: selected.max_input_tokens,
312 };
313 let mut outcome = host
314 .execute_tool(call.clone(), child_operation_id, budget)
315 .await
316 .unwrap_or_else(|error| {
317 ToolOutcome::failure(format!("{} failed: {error}", call.name))
318 });
319 let exact_result = outcome.text.clone();
320 let initially_ok = outcome.ok;
321 let mut provider_result =
322 compact_tool_result(&outcome.text, &outcome.state_updates);
323 let mut candidate = projection.clone();
324 candidate.apply_updates(&outcome.state_updates);
325 candidate.push_history(format!("Ktool result:\n{provider_result}"));
326 let accepted = candidate.estimated_tokens() <= selected.max_input_tokens;
327 if accepted {
328 projection = candidate;
329 requires_rerender = !outcome.state_updates.is_empty();
330 } else {
331 outcome.ok = false;
332 outcome.capture = None;
333 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();
334 projection.push_history(format!("Ktool result:\n{provider_result}"));
335 }
336 host.record(
337 "subagent_tool_result",
338 json!({
339 "name": call.name,
340 "ok": initially_ok,
341 "projectionAccepted": accepted,
342 "result": exact_result,
343 }),
344 )?;
345 pending_capture = outcome.capture.take();
346 turn.respond(
347 &native.call_id,
348 if outcome.ok {
349 ToolResult::success(provider_result)
350 } else {
351 ToolResult::failure(provider_result)
352 },
353 )
354 .await
355 .map_err(anyhow::Error::new)?;
356 }
357 AgentEvent::Completed(completed) => break completed,
358 }
359 };
360 host.record(
361 "subagent_provider_receipt",
362 json!({
363 "round": round + 1,
364 "usage": completed.usage.as_ref().map(|usage| json!({
365 "inputTokens": usage.input_tokens,
366 "outputTokens": usage.output_tokens,
367 "cachedInputTokens": usage.cached_input_tokens,
368 "reasoningOutputTokens": usage.reasoning_output_tokens,
369 "lastInputTokens": usage.last_input_tokens,
370 "lastOutputTokens": usage.last_output_tokens,
371 })),
372 }),
373 )?;
374
375 let capture = deferred_capture.take().or(pending_capture);
376 if let Some(capture) = capture {
377 if !capturing && completed.answer.is_empty() {
378 deferred_capture = Some(capture);
379 continue;
380 }
381 let budget = ContextBudget {
382 projection: projection.clone(),
383 max_input_tokens: selected.max_input_tokens,
384 };
385 let outcome = host
386 .complete_capture(capture, completed.answer, budget)
387 .await?;
388 let mut candidate = projection.clone();
389 candidate.apply_updates(&outcome.state_updates);
390 candidate.push_history(format!("Ktool result:\n{}", outcome.text));
391 ensure_capacity(&candidate, selected.max_input_tokens)?;
392 projection = candidate;
393 continue;
394 }
395 if requires_rerender {
396 let draft = completed.answer.trim();
397 if !draft.is_empty() {
398 projection.push_history(format!(
399 "Assistant draft produced before the state refresh:\n{draft}"
400 ));
401 }
402 continue;
403 }
404 let answer = completed.answer.trim().to_owned();
405 if !answer.is_empty() {
406 host.record(
407 "subagent_completed",
408 json!({"model": request.model, "response": answer}),
409 )?;
410 return Ok(RunResult {
411 answer,
412 model: selected,
413 });
414 }
415 ensure!(
416 used_tool,
417 "subagent provider completed without a response or tool call"
418 );
419 }
420 anyhow::bail!(
421 "subagent exceeded the {}-round tool-loop safety limit",
422 self.round_limit
423 )
424 }
425}
426
427#[derive(Clone)]
428struct Projection {
429 context: Vec<String>,
430 task: String,
431 history: Vec<String>,
432 states: Vec<ProjectedState>,
433}
434
435#[derive(Clone)]
436struct ProjectedState {
437 key: String,
438 text: String,
439}
440
441impl Projection {
442 fn new(context: Vec<String>, task: String) -> Self {
443 Self {
444 context,
445 task,
446 history: Vec::new(),
447 states: Vec::new(),
448 }
449 }
450
451 fn render(&self) -> String {
452 self.context
453 .iter()
454 .map(String::as_str)
455 .chain(std::iter::once(self.task.as_str()))
456 .chain(self.history.iter().map(String::as_str))
457 .chain(self.states.iter().map(|state| state.text.as_str()))
458 .filter(|section| !section.is_empty())
459 .collect::<Vec<_>>()
460 .join("\n\n")
461 }
462
463 fn push_history(&mut self, text: impl Into<String>) {
464 self.history.push(text.into());
465 }
466
467 fn update_state(&mut self, key: String, text: Option<String>) {
468 self.states.retain(|state| state.key != key);
469 if let Some(text) = text {
470 self.states.push(ProjectedState { key, text });
471 }
472 }
473
474 fn apply_updates(&mut self, updates: &[StateUpdate]) {
475 for update in updates {
476 self.update_state(update.key.clone(), update.text.clone());
477 }
478 }
479
480 fn estimated_tokens(&self) -> u64 {
481 (self.render().chars().count() as u64)
482 .div_ceil(4)
483 .saturating_add(PROTOCOL_TOKEN_RESERVE)
484 }
485}
486
487fn compact_tool_result(text: &str, states: &[StateUpdate]) -> String {
488 if states.is_empty() {
489 return text.to_owned();
490 }
491 let result = if text.chars().count() <= INLINE_TOOL_RESULT_CHARACTERS {
492 text
493 } else {
494 "Tool completed successfully."
495 };
496 format!(
497 "{result}\n\nThe updated state will be rendered in the next fresh context slice; end this slice now."
498 )
499}
500
501fn ensure_capacity(projection: &Projection, max_input_tokens: u64) -> anyhow::Result<()> {
502 let estimated = projection.estimated_tokens();
503 ensure!(
504 estimated <= max_input_tokens,
505 "subagent context requires approximately {estimated} input tokens, over the selected model's {max_input_tokens}-token input limit"
506 );
507 Ok(())
508}
509
510fn ktool_definition() -> DynamicTool {
511 DynamicTool::new(
512 "call_ktool",
513 "Call one available Ktool by its exact name.",
514 json!({
515 "type": "object",
516 "additionalProperties": false,
517 "required": ["name", "arguments"],
518 "properties": {
519 "name": {"type": "string"},
520 "arguments": {"type": "object"}
521 }
522 }),
523 )
524}
525
526fn parse_ktool_call(call: &DynamicToolCall) -> anyhow::Result<ToolCall> {
527 ensure!(call.tool == "call_ktool", "unknown provider tool");
528 let arguments = call
529 .arguments
530 .as_object()
531 .context("call_ktool arguments must be an object")?;
532 ensure!(
533 arguments
534 .keys()
535 .all(|key| matches!(key.as_str(), "name" | "arguments")),
536 "call_ktool contains unknown arguments"
537 );
538 let name = arguments
539 .get("name")
540 .and_then(Value::as_str)
541 .map(str::trim)
542 .filter(|name| !name.is_empty() && name.chars().count() <= 100)
543 .context("call_ktool.name must be a non-empty bounded string")?
544 .to_owned();
545 let arguments = arguments
546 .get("arguments")
547 .filter(|value| value.is_object())
548 .context("call_ktool.arguments must be an object")?
549 .clone();
550 Ok(ToolCall { name, arguments })
551}
552
553fn reasoning_effort(value: &str) -> anyhow::Result<ReasoningEffort> {
554 Ok(match value {
555 "none" => ReasoningEffort::None,
556 "minimal" => ReasoningEffort::Minimal,
557 "low" => ReasoningEffort::Low,
558 "medium" => ReasoningEffort::Medium,
559 "high" => ReasoningEffort::High,
560 "xhigh" => ReasoningEffort::XHigh,
561 "max" => ReasoningEffort::Max,
562 _ => anyhow::bail!("unsupported reasoning effort {value:?}"),
563 })
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569
570 #[test]
571 fn projection_replaces_state_and_budget_accounts_for_reserve() {
572 let mut projection = Projection::new(vec!["context".into()], "task".into());
573 projection.update_state("file".into(), Some("old".into()));
574 projection.update_state("file".into(), Some("new".into()));
575 assert_eq!(projection.states.len(), 1);
576 assert!(projection.render().contains("new"));
577 assert!(!projection.render().contains("old"));
578 assert!(projection.estimated_tokens() >= PROTOCOL_TOKEN_RESERVE);
579 }
580
581 #[test]
582 fn state_changes_compact_large_tool_results() {
583 let compacted = compact_tool_result(
584 &"x".repeat(INLINE_TOOL_RESULT_CHARACTERS + 1),
585 &[StateUpdate {
586 key: "state".into(),
587 text: Some("current".into()),
588 }],
589 );
590 assert!(compacted.starts_with("Tool completed successfully."));
591 assert!(compacted.contains("fresh context slice"));
592 }
593
594 #[test]
595 fn native_tool_wrapper_is_strict() {
596 let call = parse_ktool_call(&DynamicToolCall {
597 call_id: "1".into(),
598 tool: "call_ktool".into(),
599 arguments: json!({"name": "Read", "arguments": {"id": 1}}),
600 })
601 .unwrap();
602 assert_eq!(call.name, "Read");
603 assert_eq!(call.arguments["id"], 1);
604 }
605}