1#![forbid(unsafe_code)]
4
5mod services;
6#[cfg(test)]
7mod tests;
8
9pub use kcode_kennedy_session_objects::ResolvedObject;
10pub use kcode_kennedy_turn_admission::PendingTurnAdmission;
11pub use kcode_telegram_session_coordinator::validate_file_name as validate_delivery_file_name;
12pub use services::{Api as Service, LocalServices as Capabilities};
13
14use std::{
15 collections::{BTreeMap, BTreeSet, HashMap},
16 future::Future,
17 sync::{Arc, Weak},
18 time::{Duration, Instant},
19};
20
21use anyhow::Context as _;
22use chrono::{DateTime, Utc};
23use kcode_agent_runtime::SessionHost as _;
24use kcode_commit_session::{CommitReceipt, CommitRequest};
25use kcode_dev_tools::{
26 ATTACH_OBJECT_WEB_LIB_TOOL, CALL_RUST_BIN_TOOL, RUST_BIN_TOOLS, RUST_LIB_TOOLS, WEB_LIB_TOOLS,
27 proposed_write_snapshot,
28};
29use kcode_dev_tools_chatend::{
30 FreeformWrite, SourceSnapshot, apply_snapshot, decode_freeform_write, prepare_freeform_write,
31};
32use kcode_history_ingress_context::{
33 Outcome as HistoryIngressContextOutcome, RecoveryOutcome as ContextRecoveryOutcome,
34};
35use kcode_kennedy_kweb_loader::{load_durable_batch, node_from_value};
36use kcode_kennedy_kweb_plan::{
37 Mutation as KwebMutation, Plan as KwebPlan, referenced_pending_nodes,
38};
39use kcode_kennedy_session_ingress::{is_terminal_external_response, restore_pending_turn};
40use kcode_kennedy_session_presentation::{RenderRequest, render};
41use kcode_kennedy_session_tool_contracts::{
42 DecodedTool, ManagedObjectArguments, ValidationRequest, decode, decode_managed_objects,
43 decode_note_to_self, validate,
44};
45use kcode_kennedy_session_tool_presentation::invocation_box_content;
46use kcode_kennedy_subagent_context::Context as SubagentContext;
47use kcode_kennedy_turn_admission::{
48 AdmissionKind, stage_turn_admission, validate_authoritative_user_event,
49};
50use kcode_kweb_context::{Context as KwebContext, Node as KwebNode};
51use kcode_kweb_db::NodeId;
52use kcode_server_object_envelopes::encode_file;
53use kcode_session_history::{
54 ErrorKind as HistoryErrorKind, LaunchSession as HistoryLaunchSession, NewSession,
55 Session as HistorySession,
56 chatend::{
57 BoxContent, BoxId, BoxOwner, CacheExpectation, ContextProjection, Event, EventId,
58 EventKind, PreparedProviderResume, ProviderContext, ProviderToolDefinition, SessionKind,
59 SessionMetadata,
60 },
61};
62use kcode_session_runtime_budget::{RoundBudget, RuntimeBudget, TimeBudget, TimeBudgetKind};
63use kcode_speaker_system::KTOOLS as SPEECH_CLASSIFICATION_TOOLS;
64use serde::{Deserialize, Serialize};
65use serde_json::{Value, json};
66use sha2::{Digest, Sha256};
67use uuid::Uuid;
68
69const BROWSER_CONVERSATION_REQUEST_TIMEOUT: Duration = Duration::from_secs(225 * 60);
70const HISTORY_INGRESS_REQUEST_TIMEOUT: Duration = Duration::from_secs(225 * 60);
71const HISTORY_INGRESS_ATTEMPT_DURATION: Duration = Duration::from_secs(45 * 60);
72const WAKEUP_REQUEST_TIMEOUT: Duration = Duration::from_secs(225 * 60);
73const SELF_TIME_HARD_STOP_ALLOWANCE: Duration = Duration::from_secs(15 * 60);
74const MAX_MEDIA_ENRICHMENT_BYTES: u64 = 20 * 1024 * 1024;
75const MAX_LAUNCH_INTENTS_PER_USER_TURN: usize = 10;
76const LAUNCH_SESSION_TOOL: &str = "LaunchSession";
77const KWEB_TOOL_INSTANCE: &str = "kweb";
78const TASK_BOARD_TOOLS: [&str; 8] = [
79 "CreateTaskCategory",
80 "GetTaskCategory",
81 "RemoveTaskCategory",
82 "CreateTask",
83 "GetTask",
84 "UpdateTask",
85 "RemoveTask",
86 "GetTopTaskOrphan",
87];
88const CONTEXT_OVERFLOW_WARNING_BOX_NAME: &str = "Context overflow warning";
89const CONTEXT_OVERFLOW_WARNING: &str = "Context size was exceeded, some context has been dehydrated. The session is now at risk of destabilizing, please perform any cleanup tasks and end the session";
90const INGRESS_FORCE_COMMIT_NOTE: &str = "ingress_force_commit";
91const PENDING_TURN_ADMISSION_KIND: &str = "pendingTurnAdmissionKind";
92const PENDING_TURN_ADMISSION_USER: &str = "user";
93const PENDING_TURN_ADMISSION_SOURCE: &str = "source";
94const CHECKPOINT_STATE_VERSION: u64 = 5;
95
96#[derive(Debug)]
97struct IngressTimeExpired;
98
99impl std::fmt::Display for IngressTimeExpired {
100 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101 formatter.write_str("history ingress time expired before EndSession")
102 }
103}
104
105impl std::error::Error for IngressTimeExpired {}
106
107pub fn is_ingress_time_expired(error: &anyhow::Error) -> bool {
108 error.is::<IngressTimeExpired>()
109}
110
111fn ingress_time_remaining_at(deadline: &mut Option<Instant>, now: Instant) -> anyhow::Result<u64> {
112 let Some(current) = *deadline else {
113 *deadline = Some(
114 now.checked_add(HISTORY_INGRESS_ATTEMPT_DURATION)
115 .context("history ingress deadline overflow")?,
116 );
117 return Ok(HISTORY_INGRESS_ATTEMPT_DURATION.as_secs());
118 };
119 if now >= current {
120 return Err(anyhow::Error::new(IngressTimeExpired));
121 }
122 Ok(current.duration_since(now).as_secs())
123}
124
125#[derive(Clone, Debug)]
126pub struct RuntimeModel {
127 pub model: String,
128 pub reasoning_effort: String,
129 pub context_window_tokens: u64,
130}
131
132impl RuntimeModel {
133 pub fn from_intelligence(runtime: kcode_intelligence_router::RuntimeModel) -> Self {
134 Self {
135 model: runtime.model,
136 reasoning_effort: runtime.reasoning_effort,
137 context_window_tokens: runtime.context_window_tokens,
138 }
139 }
140
141 fn attribution(&self) -> String {
142 format!("{}-{}", self.model, self.reasoning_effort)
143 }
144}
145
146#[derive(Clone, Debug, PartialEq, Eq)]
147pub enum AgentMode {
148 Conversation,
149 FreeTime,
150 Wakeup,
151 Ingress { record_id: Option<String> },
152}
153
154#[derive(Clone, Copy, Debug, Eq, PartialEq)]
155pub enum TurnDeadlineKind {
156 Telegram,
157 SelfTimeHardStop,
158}
159
160#[derive(Clone, Copy, Debug, Eq, PartialEq)]
161pub struct TurnDeadline {
162 pub kind: TurnDeadlineKind,
163 pub at: DateTime<Utc>,
164}
165
166#[derive(Clone, Debug)]
167pub struct SessionOptions {
168 pub session_type: String,
169 pub root_node_ids: Vec<String>,
170 pub reference_root_node_ids: Vec<String>,
171 pub channel: Value,
172 pub free_time: Value,
173 pub orchestration: Value,
174 pub provenance_id: Option<String>,
175 pub mode: AgentMode,
176 pub source_session_type: Option<String>,
177 pub group_context: Value,
178 pub rust_lib_session_id: Option<String>,
179}
180
181impl SessionOptions {
182 pub fn conversation(session_type: impl Into<String>, roots: Vec<String>) -> Self {
183 Self {
184 session_type: session_type.into(),
185 root_node_ids: roots,
186 reference_root_node_ids: Vec::new(),
187 channel: Value::Null,
188 free_time: Value::Null,
189 orchestration: json!({"owner":"backend","status":"idle"}),
190 provenance_id: None,
191 mode: AgentMode::Conversation,
192 source_session_type: None,
193 group_context: Value::Null,
194 rust_lib_session_id: None,
195 }
196 }
197}
198
199fn restore_session_type(options: &mut SessionOptions, state: &Value) {
200 if !matches!(&options.mode, AgentMode::Ingress { .. }) {
201 options.session_type = state
202 .get("sessionType")
203 .and_then(Value::as_str)
204 .unwrap_or(&options.session_type)
205 .to_owned();
206 }
207}
208
209fn restore_commit_receipt(restored: Option<&Value>) -> anyhow::Result<Option<CommitReceipt>> {
210 restored
211 .and_then(|state| state.get("commitReceipt"))
212 .filter(|receipt| !receipt.is_null())
213 .cloned()
214 .map(serde_json::from_value)
215 .transpose()
216 .context("decoding the stored session commit receipt")
217}
218
219fn journal_kweb_plan(journal: &HistorySession) -> Option<&Value> {
220 journal
221 .state()
222 .current_ingress_attempt_events()
223 .iter()
224 .rev()
225 .find_map(|event| {
226 let EventKind::KwebPlanChanged { operation } = &event.kind else {
227 return None;
228 };
229 operation.get("plan")
230 })
231}
232
233#[derive(Clone, Debug, Deserialize)]
234#[serde(rename_all = "camelCase", deny_unknown_fields)]
235struct LaunchSessionArguments {
236 directive: String,
237 context_node_ids: Vec<String>,
238}
239
240#[derive(Clone, Debug, Deserialize, Serialize)]
241#[serde(rename_all = "camelCase")]
242struct LaunchIntent {
243 invocation_id: String,
244 user_turn_id: EventId,
245 started_at: String,
246 parent_session_id: String,
247 effective_context_tokens: u64,
248 root_node_ids: Vec<String>,
249 reference_root_node_ids: Vec<String>,
250 context_node_ids: Vec<String>,
251}
252
253#[derive(Serialize)]
254#[serde(rename_all = "camelCase")]
255struct LaunchSuccess<'a> {
256 session_id: &'a str,
257 command_id: &'a str,
258}
259
260fn decode_launch_session_arguments(value: &Value) -> anyhow::Result<LaunchSessionArguments> {
261 let arguments: LaunchSessionArguments =
262 serde_json::from_value(value.clone()).context("LaunchSession arguments are invalid")?;
263 anyhow::ensure!(
264 !arguments.directive.trim().is_empty(),
265 "LaunchSession directive must not be blank"
266 );
267 validate_canonical_distinct_ids(&arguments.context_node_ids, "context node")?;
268 Ok(arguments)
269}
270
271fn validate_canonical_distinct_ids(ids: &[String], label: &str) -> anyhow::Result<()> {
272 let mut seen = BTreeSet::new();
273 for id in ids {
274 canonical_id(id).with_context(|| format!("LaunchSession {label} ID is invalid"))?;
275 anyhow::ensure!(
276 seen.insert(id.as_str()),
277 "LaunchSession {label} ID {id} is duplicated"
278 );
279 }
280 Ok(())
281}
282
283fn validate_loaded_launch_context(
284 context: &KwebContext,
285 context_node_ids: &[String],
286) -> anyhow::Result<()> {
287 for id in context_node_ids {
288 anyhow::ensure!(
289 context.contains_full_node(id),
290 "LaunchSession context node {id} is not fully loaded in the parent context"
291 );
292 }
293 Ok(())
294}
295
296fn authoritative_user_box_event(journal: &HistorySession, event: &Event) -> Option<EventId> {
297 let EventKind::BoxCreated {
298 box_id,
299 owner: BoxOwner::User,
300 ..
301 } = &event.kind
302 else {
303 return None;
304 };
305 if *box_id != BoxId(event.id.0) {
306 return None;
307 }
308 journal
309 .state()
310 .box_state(*box_id)
311 .filter(|state| matches!(state.owner, BoxOwner::User))
312 .map(|_| event.id)
313}
314
315fn unique_user_box_event(
316 journal: &HistorySession,
317 events: &[Event],
318) -> anyhow::Result<Option<EventId>> {
319 let ids = events
320 .iter()
321 .filter_map(|event| authoritative_user_box_event(journal, event))
322 .collect::<Vec<_>>();
323 anyhow::ensure!(
324 ids.len() <= 1,
325 "multiple authoritative user inputs appeared in one launch-authority interval"
326 );
327 Ok(ids.into_iter().next())
328}
329
330#[derive(Clone)]
331struct RecoveredUserTurn {
332 id: EventId,
333 external_event_id: Option<String>,
334}
335
336enum RecoveredAdmission {
337 Unmarked(RecoveredUserTurn),
338 MarkedUser(RecoveredUserTurn),
339 MarkedSource,
340}
341
342fn classify_recovered_admission(
343 journal: &HistorySession,
344 event: &Event,
345) -> anyhow::Result<Option<RecoveredAdmission>> {
346 let Some(id) = authoritative_user_box_event(journal, event) else {
347 return Ok(None);
348 };
349 let Some(state) = journal.state().box_state(BoxId(id.0)) else {
350 return Ok(None);
351 };
352 let metadata = &state.canonical.content.metadata;
353 let record = RecoveredUserTurn {
354 id,
355 external_event_id: metadata
356 .get("externalEventId")
357 .and_then(Value::as_str)
358 .map(str::to_owned),
359 };
360 match metadata.get(PENDING_TURN_ADMISSION_KIND) {
361 None => Ok(Some(RecoveredAdmission::Unmarked(record))),
362 Some(Value::String(kind)) if kind == PENDING_TURN_ADMISSION_USER => {
363 validate_authoritative_user_event(journal, event.id)?;
364 Ok(Some(RecoveredAdmission::MarkedUser(record)))
365 }
366 Some(Value::String(kind)) if kind == PENDING_TURN_ADMISSION_SOURCE => {
367 Ok(Some(RecoveredAdmission::MarkedSource))
368 }
369 Some(_) => anyhow::bail!("pending turn admission marker is invalid"),
370 }
371}
372
373fn recovered_user_turns(
374 journal: &HistorySession,
375 events: &[Event],
376) -> anyhow::Result<Vec<RecoveredAdmission>> {
377 let mut found_unmarked = false;
378 let mut recovered = Vec::new();
379 for event in events {
380 match classify_recovered_admission(journal, event)? {
381 Some(RecoveredAdmission::Unmarked(record)) => {
382 anyhow::ensure!(
383 !found_unmarked,
384 "multiple unmarked authoritative user inputs appeared in one launch-authority interval"
385 );
386 found_unmarked = true;
387 recovered.push(RecoveredAdmission::Unmarked(record));
388 }
389 Some(admission) => recovered.push(admission),
390 None => {}
391 }
392 }
393 Ok(recovered)
394}
395
396fn validate_user_turn_id(journal: &HistorySession, id: EventId) -> anyhow::Result<()> {
397 let event = journal
398 .state()
399 .event(id)
400 .context("restored launch user-turn event does not exist")?;
401 anyhow::ensure!(
402 matches!(
403 classify_recovered_admission(journal, event)?,
404 Some(RecoveredAdmission::Unmarked(_) | RecoveredAdmission::MarkedUser(_))
405 ),
406 "restored launch user-turn event is not an authoritative user BoxCreated event"
407 );
408 Ok(())
409}
410
411fn consume_launch_bootstrap_marker(
412 orchestration: &mut Value,
413 launch_bootstrap_pending: &mut bool,
414) -> bool {
415 if !*launch_bootstrap_pending {
416 return false;
417 }
418 *launch_bootstrap_pending = false;
419 if !orchestration.is_object() {
420 *orchestration = json!({});
421 }
422 orchestration["launchBootstrapPending"] = json!(false);
423 true
424}
425
426fn grant_marked_user_launch_authority(
427 id: EventId,
428 orchestration: &mut Value,
429 launch_bootstrap_pending: &mut bool,
430 launch_user_turn_id: &mut Option<EventId>,
431) {
432 consume_launch_bootstrap_marker(orchestration, launch_bootstrap_pending);
433 *launch_user_turn_id = Some(id);
434}
435
436fn user_turn_launch_authority(
437 user_turn: Option<EventId>,
438 orchestration: &mut Value,
439 launch_bootstrap_pending: &mut bool,
440) -> Option<EventId> {
441 if consume_launch_bootstrap_marker(orchestration, launch_bootstrap_pending) {
442 None
443 } else {
444 user_turn
445 }
446}
447
448fn reconcile_recovered_launch_authority(
449 pending_turn: bool,
450 recovered_user_turn: Option<EventId>,
451 launch_provenance: &Value,
452 orchestration: &mut Value,
453 launch_bootstrap_pending: &mut bool,
454 launch_user_turn_id: &mut Option<EventId>,
455) {
456 if let Some(recovered) = recovered_user_turn {
457 if *launch_bootstrap_pending || !launch_provenance.is_null() {
458 consume_launch_bootstrap_marker(orchestration, launch_bootstrap_pending);
459 *launch_user_turn_id = None;
460 } else {
461 *launch_user_turn_id = Some(recovered);
462 }
463 }
464 if !pending_turn || *launch_bootstrap_pending {
465 *launch_user_turn_id = None;
466 }
467}
468
469struct RecoveredAdmissionReplayState<'a> {
470 pending_turn: &'a bool,
471 launch_provenance: &'a Value,
472 orchestration: &'a mut Value,
473 launch_bootstrap_pending: &'a mut bool,
474 launch_user_turn_id: &'a mut Option<EventId>,
475 rounds_used: &'a mut u64,
476 pending_external_event_id: &'a mut Option<String>,
477}
478
479fn replay_recovered_admissions(
480 recovered: Vec<RecoveredAdmission>,
481 state: RecoveredAdmissionReplayState<'_>,
482) {
483 for admission in recovered {
484 match admission {
485 RecoveredAdmission::Unmarked(record) => {
486 reconcile_recovered_launch_authority(
487 *state.pending_turn,
488 Some(record.id),
489 state.launch_provenance,
490 state.orchestration,
491 state.launch_bootstrap_pending,
492 state.launch_user_turn_id,
493 );
494 *state.rounds_used = 0;
495 *state.pending_external_event_id = record.external_event_id;
496 }
497 RecoveredAdmission::MarkedUser(record) => {
498 grant_marked_user_launch_authority(
499 record.id,
500 state.orchestration,
501 state.launch_bootstrap_pending,
502 state.launch_user_turn_id,
503 );
504 *state.rounds_used = 0;
505 *state.pending_external_event_id = record.external_event_id;
506 }
507 RecoveredAdmission::MarkedSource => {}
508 }
509 }
510}
511
512fn completed_invocation_ids(journal: &HistorySession) -> BTreeSet<String> {
513 journal
514 .state()
515 .events
516 .iter()
517 .filter_map(|event| {
518 let EventKind::ToolCompleted {
519 invocation_id: Some(id),
520 ..
521 } = &event.kind
522 else {
523 return None;
524 };
525 Some(id.clone())
526 })
527 .collect()
528}
529
530fn pruned_launch_intents(
531 intents: &[LaunchIntent],
532 current_turn: Option<EventId>,
533 completed: &BTreeSet<String>,
534) -> Vec<LaunchIntent> {
535 intents
536 .iter()
537 .filter(|intent| {
538 Some(intent.user_turn_id) == current_turn || !completed.contains(&intent.invocation_id)
539 })
540 .cloned()
541 .collect()
542}
543
544fn invocation_arguments<'a>(
545 journal: &'a HistorySession,
546 invocation_id: &str,
547) -> anyhow::Result<&'a Value> {
548 journal
549 .state()
550 .events
551 .iter()
552 .find_map(|event| {
553 let EventKind::ToolInvoked {
554 tool_name,
555 arguments,
556 invocation_id: Some(id),
557 ..
558 } = &event.kind
559 else {
560 return None;
561 };
562 (tool_name == LAUNCH_SESSION_TOOL && id == invocation_id).then_some(arguments)
563 })
564 .with_context(|| {
565 format!(
566 "launch intent {} has no matching ToolInvoked event",
567 invocation_id
568 )
569 })
570}
571
572fn launch_success_json(session_id: &str, command_id: &str) -> anyhow::Result<String> {
573 serde_json::to_string(&LaunchSuccess {
574 session_id,
575 command_id,
576 })
577 .context("serializing LaunchSession result")
578}
579
580fn tool_invocation_content(name: &str, arguments: &Value) -> anyhow::Result<BoxContent> {
581 if name == LAUNCH_SESSION_TOOL {
582 return Ok(BoxContent::text("LaunchSession"));
583 }
584 invocation_box_content(name, arguments)
585}
586
587pub struct Session {
588 api: Service,
589 subagent_codex_prompt: String,
590 runtime: RuntimeModel,
591 journal: HistorySession,
592 plan: KwebPlan,
593 pub session_type: String,
594 pub channel: Value,
595 pub free_time: Value,
596 pub orchestration: Value,
597 pub provenance_id: Option<String>,
598 pub rust_lib_session_id: String,
599 pub root_node_ids: Vec<String>,
600 pub reference_root_node_ids: Vec<String>,
601 pub started_at: String,
602 pub transcript: Vec<Value>,
603 pub pending_turn: bool,
604 pub pending_external_event_id: Option<String>,
605 pub completed: bool,
606 pub rounds_used: u64,
607 commit_receipt: Option<CommitReceipt>,
608 commit_author: String,
609 mode: AgentMode,
610 source_session_type: Option<String>,
611 group_context: Value,
612 context: KwebContext,
613 free_time_end_reason: Option<String>,
614 fatal_persistence_error: Option<String>,
615 active_provider_deadline: Option<DateTime<Utc>>,
616 active_turn_deadline: Option<TurnDeadline>,
617 provider_affinity: Option<ProviderAffinityState>,
618 next_thread_reset_reason: Option<String>,
619 ingress_deadline: Option<Instant>,
620 previous_ingress_attempt_timed_out: bool,
621 launch_provenance: Value,
622 launch_context_node_ids: Vec<String>,
623 launch_user_turn_id: Option<EventId>,
624 launch_intents: Vec<LaunchIntent>,
625 launch_bootstrap_pending: bool,
626 turn_lease_slot: Option<Weak<TurnLeaseToken>>,
627}
628
629struct TurnLeaseToken;
630
631struct TurnLease {
632 token: Arc<TurnLeaseToken>,
633}
634
635impl TurnLease {
636 fn acquire(slot: &mut Option<Weak<TurnLeaseToken>>) -> anyhow::Result<Self> {
637 anyhow::ensure!(
638 slot.as_ref().and_then(Weak::upgrade).is_none(),
639 "a stepped session turn is already active"
640 );
641 let token = Arc::new(TurnLeaseToken);
642 *slot = Some(Arc::downgrade(&token));
643 Ok(Self { token })
644 }
645
646 fn validate(&self, slot: &Option<Weak<TurnLeaseToken>>) -> anyhow::Result<()> {
647 anyhow::ensure!(
648 slot.as_ref()
649 .and_then(Weak::upgrade)
650 .is_some_and(|active| Arc::ptr_eq(&self.token, &active)),
651 "the stepped session turn is stale"
652 );
653 Ok(())
654 }
655}
656
657struct PrimaryTurnState {
658 accounting: Option<kcode_intelligence_chatend::TopLevelCall>,
659 pending_freeform_write: Option<PendingFreeformWrite>,
660 deadline_after_response: bool,
661 operation_id: Uuid,
662 prepared_cache: Option<PreparedCacheObservation>,
663 provider_synchronized_after: Option<EventId>,
664 restart_fresh_reason: Option<String>,
665 exact_tool_result: bool,
666 used_tool: bool,
667 finish_requested: bool,
668 emitted_response: bool,
669 pending_capture: Option<Value>,
670}
671
672#[must_use]
673pub struct SessionTurn {
674 lease: TurnLease,
675 user_id: String,
676 completed_rounds: u64,
677 round_limit: u64,
678 state: PrimaryTurnState,
679 at_yielded_boundary: bool,
680 admission_poisoned: bool,
681}
682
683enum PendingInferenceAction {
684 Start {
685 runtime: kcode_agent_runtime::AgentRuntime,
686 request: kcode_agent_runtime::SessionInferenceRequest,
687 },
688 Next {
689 inference: kcode_agent_runtime::SessionInference,
690 },
691 Respond {
692 inference: kcode_agent_runtime::SessionInference,
693 call_id: String,
694 result: kcode_codex_runtime_v2::ToolResult,
695 stop: bool,
696 },
697}
698
699#[must_use]
700pub struct PendingSessionInference {
701 turn: SessionTurn,
702 action: Box<PendingInferenceAction>,
703}
704
705enum SessionInferenceWakeKind {
706 Event {
707 inference: kcode_agent_runtime::SessionInference,
708 event: anyhow::Result<Option<kcode_agent_runtime::SessionInferenceEvent>>,
709 },
710 StartFailed(anyhow::Error),
711 RespondFailed {
712 inference: kcode_agent_runtime::SessionInference,
713 error: anyhow::Error,
714 },
715 RespondedStop {
716 inference: kcode_agent_runtime::SessionInference,
717 },
718}
719
720#[must_use]
721pub struct SessionInferenceWake {
722 turn: SessionTurn,
723 kind: SessionInferenceWakeKind,
724}
725
726#[must_use]
727pub enum TurnBoundary {
728 Await(PendingSessionInference),
729 Yield(SessionTurn),
730 Complete(Option<String>),
731}
732
733impl PendingSessionInference {
734 pub async fn wait(self) -> SessionInferenceWake {
735 let Self { turn, action } = self;
736 let kind = match *action {
737 PendingInferenceAction::Start { runtime, request } => {
738 match runtime.start_session_inference(request).await {
739 Ok(mut inference) => {
740 let event = inference.next_event().await;
741 SessionInferenceWakeKind::Event { inference, event }
742 }
743 Err(error) => SessionInferenceWakeKind::StartFailed(error),
744 }
745 }
746 PendingInferenceAction::Next { mut inference } => {
747 let event = inference.next_event().await;
748 SessionInferenceWakeKind::Event { inference, event }
749 }
750 PendingInferenceAction::Respond {
751 mut inference,
752 call_id,
753 result,
754 stop,
755 } => match inference.respond(&call_id, result).await {
756 Ok(()) if stop => SessionInferenceWakeKind::RespondedStop { inference },
757 Ok(()) => {
758 let event = inference.next_event().await;
759 SessionInferenceWakeKind::Event { inference, event }
760 }
761 Err(error) => SessionInferenceWakeKind::RespondFailed { inference, error },
762 },
763 };
764 SessionInferenceWake { turn, kind }
765 }
766}
767
768#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
769#[serde(rename_all = "camelCase")]
770struct ProviderAffinityState {
771 continuation: kcode_intelligence_router::AgentContinuation,
772 synchronized_event_id: EventId,
773 material_fingerprint: String,
774}
775
776#[derive(Debug, Eq, PartialEq)]
777enum NativeProviderResumePreparation {
778 Continue { marker_lines: Vec<String> },
779 RestartFresh { reason: String },
780}
781
782fn apply_prepared_provider_resume(
783 provider_affinity: &mut Option<ProviderAffinityState>,
784 next_thread_reset_reason: &mut Option<String>,
785 prepared: PreparedProviderResume,
786) -> NativeProviderResumePreparation {
787 match prepared.thread_reset_reason {
788 Some(reason) => {
789 *provider_affinity = None;
790 *next_thread_reset_reason = Some(reason.clone());
791 NativeProviderResumePreparation::RestartFresh { reason }
792 }
793 None => NativeProviderResumePreparation::Continue {
794 marker_lines: prepared.marker_lines,
795 },
796 }
797}
798
799fn restore_provider_affinity(
800 restored: Option<&Value>,
801 fresh_ingress_attempt: bool,
802) -> anyhow::Result<Option<ProviderAffinityState>> {
803 let state_version = restored
804 .and_then(|state| state.get("stateVersion"))
805 .and_then(Value::as_u64);
806 if fresh_ingress_attempt || state_version != Some(CHECKPOINT_STATE_VERSION) {
807 return Ok(None);
808 }
809 restored
810 .and_then(|state| state.get("providerAffinity"))
811 .filter(|value| !value.is_null())
812 .cloned()
813 .map(serde_json::from_value)
814 .transpose()
815 .context("restored provider affinity is invalid")
816}
817
818#[derive(Clone, Copy, Debug, Eq, PartialEq)]
819enum InputStage {
820 Accepted,
821}
822
823#[derive(Clone, Copy, Debug, Eq, PartialEq)]
824enum ContextRecovery {
825 NotNeeded,
826 Recovered,
827 Irreducible,
828}
829
830fn kweb_slot_box_ids(journal: &HistorySession) -> Vec<BoxId> {
831 journal
832 .state()
833 .tools
834 .get(KWEB_TOOL_INSTANCE)
835 .map(|tool| tool.slots.iter().map(|slot| slot.box_id).collect())
836 .unwrap_or_default()
837}
838
839fn load_box_changes(before: &[BoxId], after: &[BoxId], stale: &[BoxId]) -> Vec<BoxId> {
840 let before = before.iter().copied().collect::<BTreeSet<_>>();
841 let stale = stale.iter().copied().collect::<BTreeSet<_>>();
842 let mut emitted = BTreeSet::new();
843 after
844 .iter()
845 .copied()
846 .filter(|id| (!before.contains(id) || stale.contains(id)) && emitted.insert(*id))
847 .collect()
848}
849
850fn render_load_nodes_result(
851 journal: &HistorySession,
852 changed_box_ids: &[BoxId],
853 footer_lines: &[String],
854) -> anyhow::Result<String> {
855 let changed_box_ids = changed_box_ids
856 .iter()
857 .map(ToString::to_string)
858 .collect::<Vec<_>>();
859 let projected_boxes = journal
860 .state()
861 .projection_with_footer_lines(footer_lines)
862 .items
863 .into_iter()
864 .filter(|item| !item.marker)
865 .map(|item| (item.box_id.to_string(), item.text))
866 .collect::<Vec<_>>();
867 render(RenderRequest::LoadNodes {
868 changed_box_ids: &changed_box_ids,
869 projected_boxes: &projected_boxes,
870 })
871}
872
873fn provider_tool_result_with_context_footer(footer: &str, result: &str) -> String {
874 render(RenderRequest::ProviderFooter { result, footer })
875 .expect("provider-footer rendering is infallible")
876}
877
878fn completes_before_provider_resume(outcome: &kcode_agent_runtime::SessionToolOutcome) -> bool {
879 outcome.stop || (outcome.ok && outcome.finish_after_round)
880}
881
882fn append_slow_tool_duration(text: &mut String, elapsed: Duration) {
883 *text = render(RenderRequest::SlowTool { text, elapsed })
884 .expect("slow-tool rendering is infallible");
885}
886
887fn log_primary_thread_observation(
888 operation_id: Uuid,
889 round: u64,
890 requested_model: &str,
891 prepared: &PreparedCacheObservation,
892 provider_thread_id: Option<&str>,
893 input_tokens: u64,
894 cached_input_tokens: u64,
895) {
896 tracing::info!(
897 affinity_scope = "primary",
898 %operation_id,
899 round,
900 provider = prepared.provider,
901 requested_model,
902 model = prepared.model,
903 thread_action = prepared.thread_action,
904 provider_thread_id = provider_thread_id.unwrap_or(""),
905 thread_reset_reason = prepared.thread_reset_reason.as_deref().unwrap_or(""),
906 projection_hash = prepared.projection_hash,
907 provider_input_hash = prepared.provider_input_hash,
908 provider_input_bytes = prepared.provider_input_bytes,
909 input_tokens,
910 cached_input_tokens,
911 "Provider thread-affinity observation"
912 );
913}
914
915fn render_web_search_result(
916 result: &kcode_intelligence_router::SearchResponse,
917) -> anyhow::Result<String> {
918 let sources = result
919 .sources
920 .iter()
921 .map(|source| (source.title.clone(), source.url.clone()))
922 .collect::<Vec<_>>();
923 render(RenderRequest::WebSearch {
924 answer: &result.answer,
925 sources: &sources,
926 })
927}
928
929fn render_web_fetch_result(
930 result: &kcode_intelligence_router::FetchResponse,
931) -> anyhow::Result<String> {
932 render(RenderRequest::WebFetch {
933 url: &result.url,
934 title: result.title.as_deref(),
935 content_type: &result.content_type,
936 truncated: result.truncated,
937 content: &result.content,
938 })
939}
940
941fn render_media_annotation_result(
942 object_id: &str,
943 file_name: &str,
944 content_type: &str,
945 result: &kcode_intelligence_router::AnnotationResponse,
946) -> anyhow::Result<String> {
947 render(RenderRequest::MediaAnnotation {
948 object_id,
949 file_name,
950 content_type,
951 model: &result.model,
952 complete: result.complete,
953 incomplete_reason: result.incomplete_reason.as_deref(),
954 text: &result.text,
955 })
956}
957
958fn render_audio_transcription_result(
959 object_id: &str,
960 file_name: &str,
961 content_type: &str,
962 result: &kcode_intelligence_router::TranscriptionResponse,
963) -> anyhow::Result<String> {
964 render(RenderRequest::AudioTranscription {
965 object_id,
966 file_name,
967 content_type,
968 model: &result.model,
969 text: &result.text,
970 })
971}
972
973fn render_document_extraction_result(
974 object_id: &str,
975 file_name: &str,
976 result: &kcode_intelligence_router::DocumentExtraction,
977) -> anyhow::Result<String> {
978 render(RenderRequest::DocumentExtraction {
979 object_id,
980 file_name,
981 format: &result.format,
982 characters: result.characters,
983 truncated: result.truncated,
984 text: &result.text,
985 })
986}
987
988struct ToolCall {
989 name: String,
990 arguments: Value,
991}
992
993#[derive(Deserialize)]
994#[serde(rename_all = "camelCase", deny_unknown_fields)]
995struct TaskId {
996 task_id: String,
997}
998
999#[derive(Deserialize)]
1000#[serde(rename_all = "camelCase", deny_unknown_fields)]
1001struct CategoryId {
1002 category_id: String,
1003}
1004
1005#[derive(Deserialize)]
1006#[serde(rename_all = "camelCase", deny_unknown_fields)]
1007struct CategoryCall {
1008 category_id: String,
1009 #[serde(default)]
1010 offset: u64,
1011 #[serde(default = "task_page_limit")]
1012 limit: u32,
1013}
1014
1015#[derive(Deserialize)]
1016#[serde(deny_unknown_fields)]
1017struct EmptyCall {}
1018
1019fn task_page_limit() -> u32 {
1020 50
1021}
1022
1023struct RecordedToolInvocation {
1024 invocation_id: String,
1025 tool_instance: String,
1026 tool_name: String,
1027}
1028
1029fn record_tool_completion_event(
1030 journal: &mut HistorySession,
1031 invocation: Option<&RecordedToolInvocation>,
1032 outcome: Value,
1033) -> anyhow::Result<EventId> {
1034 let (tool_instance, tool_name, invocation_id) = invocation
1035 .map(|invocation| {
1036 (
1037 invocation.tool_instance.clone(),
1038 invocation.tool_name.clone(),
1039 Some(invocation.invocation_id.clone()),
1040 )
1041 })
1042 .unwrap_or_else(|| ("call_ktool".into(), "call_ktool".into(), None));
1043 journal.record(
1044 now(),
1045 EventKind::ToolCompleted {
1046 tool_instance,
1047 tool_name,
1048 outcome,
1049 invocation_id,
1050 },
1051 )
1052}
1053
1054fn ensure_tool_result_box(
1055 journal: &mut HistorySession,
1056 invocation: Option<&RecordedToolInvocation>,
1057 text: &str,
1058 ok: bool,
1059) -> anyhow::Result<String> {
1060 let Some(invocation) = invocation else {
1061 journal.create_box(
1062 now(),
1063 "Kennedy tool result",
1064 BoxOwner::Controller,
1065 BoxContent::text(text),
1066 )?;
1067 return Ok(text.to_owned());
1068 };
1069 let matches = journal
1070 .state()
1071 .boxes
1072 .values()
1073 .filter(|state| {
1074 matches!(state.owner, BoxOwner::Controller)
1075 && state
1076 .canonical
1077 .content
1078 .metadata
1079 .get("toolInvocationId")
1080 .and_then(Value::as_str)
1081 == Some(invocation.invocation_id.as_str())
1082 })
1083 .map(|state| {
1084 (
1085 state.id,
1086 state.canonical.content.text.clone(),
1087 state
1088 .canonical
1089 .content
1090 .metadata
1091 .get("toolResultOk")
1092 .and_then(Value::as_bool),
1093 )
1094 })
1095 .collect::<Vec<_>>();
1096 anyhow::ensure!(
1097 matches.len() <= 1,
1098 "tool invocation {} has duplicate durable result boxes",
1099 invocation.invocation_id
1100 );
1101 if let Some((_box_id, stored_text, stored_ok)) = matches.into_iter().next() {
1102 anyhow::ensure!(
1103 stored_ok == Some(ok),
1104 "tool invocation {} has a result box with a conflicting outcome",
1105 invocation.invocation_id
1106 );
1107 return Ok(stored_text);
1108 }
1109
1110 let mut content = BoxContent::text(text);
1111 content.metadata = json!({
1112 "toolInvocationId":invocation.invocation_id,
1113 "toolInstance":invocation.tool_instance,
1114 "toolName":invocation.tool_name,
1115 "toolResultOk":ok,
1116 });
1117 journal.create_box(now(), "Kennedy tool result", BoxOwner::Controller, content)?;
1118 Ok(text.to_owned())
1119}
1120
1121fn complete_launch_reconciliation(
1122 journal: &mut HistorySession,
1123 invocation: &RecordedToolInvocation,
1124 result: Result<kcode_session_history::SessionLaunch, kcode_session_history::Error>,
1125) -> anyhow::Result<()> {
1126 if completed_invocation_ids(journal).contains(&invocation.invocation_id) {
1127 return Ok(());
1128 }
1129 let (ok, text) = match result {
1130 Ok(launch) => (
1131 true,
1132 launch_success_json(&launch.session_id, &launch.command_id)?,
1133 ),
1134 Err(error)
1135 if matches!(
1136 error.kind,
1137 HistoryErrorKind::InvalidInput | HistoryErrorKind::Conflict
1138 ) =>
1139 {
1140 (false, format!("LaunchSession failed: {}", error.message))
1141 }
1142 Err(error) => {
1143 anyhow::bail!(
1144 "LaunchSession reconciliation remains unresolved ({}): {}",
1145 error.kind.code(),
1146 error.message
1147 );
1148 }
1149 };
1150 let text = ensure_tool_result_box(journal, Some(invocation), &text, ok)?;
1151 record_tool_completion_event(journal, Some(invocation), json!({"ok":ok,"result":text}))?;
1152 Ok(())
1153}
1154
1155struct PendingFreeformWrite {
1156 request: FreeformWrite,
1157 call_box_id: BoxId,
1158}
1159
1160struct ToolOutcome {
1161 text: String,
1162 store_result: bool,
1163 ok: bool,
1164 end_session: bool,
1165 freeform_write: Option<FreeformWrite>,
1166 managed_source_snapshot: Option<SourceSnapshot>,
1167 exact_result: bool,
1168}
1169
1170fn result_displays_snapshot(result: &str, snapshot: &SourceSnapshot) -> bool {
1171 result == snapshot.text
1172}
1173
1174fn subagent_managed_write_fits(
1175 context: &SubagentContext,
1176 call: &ToolCall,
1177 budget: &kcode_agent_runtime::ContextBudget,
1178) -> bool {
1179 let Some(snapshot) = proposed_write_snapshot(&call.name, &call.arguments) else {
1180 return true;
1181 };
1182 let state = context.source_state(&snapshot);
1183 budget.fits_state(state.key, state.text)
1184}
1185
1186struct KennedySubagentHost<'a> {
1187 session: &'a mut Session,
1188 context: SubagentContext,
1189 captures: HashMap<String, FreeformWrite>,
1190}
1191
1192struct KennedySessionHost<'a, C> {
1193 session: &'a mut Session,
1194 checkpoint: &'a mut C,
1195 state: &'a mut PrimaryTurnState,
1196}
1197
1198struct PreparedCacheObservation {
1199 cacheable_prefix_bytes: u64,
1200 expectation: CacheExpectation,
1201 material_fingerprint: String,
1202 projection_hash: String,
1203 logical_input: String,
1204 provider_input_hash: String,
1205 provider_input_bytes: u64,
1206 thread_action: String,
1207 thread_reset_reason: Option<String>,
1208 estimated_input_tokens: u64,
1209 raw_estimated_input_tokens: u64,
1210 provider: String,
1211 model: String,
1212}
1213
1214fn is_kweb_mutation(name: &str) -> bool {
1215 matches!(
1216 name,
1217 "ConnectNodes" | "ConsolidateFanout" | "SetFixedConnection" | "CreateNode" | "UpdateNode"
1218 )
1219}
1220
1221fn subagent_unavailable_reason(name: &str) -> Option<&'static str> {
1222 match name {
1223 LAUNCH_SESSION_TOOL => Some(
1224 "LaunchSession is unavailable inside a subagent. Only an authorized genuine parent user turn may launch a session.",
1225 ),
1226 "RunSubagent" => {
1227 Some("RunSubagent is unavailable inside a subagent. Only Kennedy may launch subagents.")
1228 }
1229 "EndSession" => Some(
1230 "EndSession is unavailable inside a subagent. A child cannot control the parent session lifecycle.",
1231 ),
1232 "DehydrateBoxes" | "SummarizeBox" | "HydrateBox" | "BoxesIntoObjects" => {
1233 Some("Parent box controls are unavailable inside a box-free subagent context.")
1234 }
1235 _ => None,
1236 }
1237}
1238
1239fn ensure_plan_node_known(plan: &KwebPlan, context: &KwebContext, id: &str) -> anyhow::Result<()> {
1240 if id.starts_with("pending:") {
1241 anyhow::ensure!(
1242 plan.contains_pending(id),
1243 "pending node {id} is not part of this session"
1244 );
1245 } else {
1246 canonical_id(id)?;
1247 anyhow::ensure!(
1248 context.contains_full_node(id),
1249 "node {id} is not loaded; call LoadNodes first"
1250 );
1251 }
1252 Ok(())
1253}
1254
1255fn kweb_mutation(
1256 decoded: DecodedTool,
1257 context: &KwebContext,
1258 plan: &KwebPlan,
1259 journal: &mut HistorySession,
1260) -> anyhow::Result<KwebMutation> {
1261 Ok(match decoded {
1262 DecodedTool::ConnectNodes(nodes) => KwebMutation::ConnectNodes(nodes),
1263 DecodedTool::ConsolidateFanout {
1264 parent,
1265 fanout,
1266 aggregator,
1267 } => KwebMutation::ConsolidateFanout {
1268 parent,
1269 fanout,
1270 aggregator,
1271 },
1272 DecodedTool::SetFixedConnection {
1273 parent,
1274 child,
1275 slot,
1276 } => KwebMutation::SetFixedConnection {
1277 parent,
1278 child,
1279 slot,
1280 },
1281 DecodedTool::CreateNode {
1282 parents,
1283 owner,
1284 short_name,
1285 short_description,
1286 long_description,
1287 } => {
1288 for id in parents.iter().chain(std::iter::once(&owner)) {
1289 if id != "self" && id != "unowned" {
1290 ensure_plan_node_known(plan, context, id)?;
1291 }
1292 }
1293 KwebMutation::CreateNode {
1294 pending_id: journal.allocate_pending_node(now())?.to_string(),
1295 parents,
1296 owner,
1297 short_name,
1298 short_description,
1299 long_description,
1300 }
1301 }
1302 DecodedTool::UpdateNode {
1303 id,
1304 owner,
1305 short_name,
1306 short_description,
1307 long_description,
1308 } => KwebMutation::UpdateNode {
1309 id,
1310 owner,
1311 short_name,
1312 short_description,
1313 long_description,
1314 },
1315 _ => anyhow::bail!("decoded contract did not match a Kweb mutation"),
1316 })
1317}
1318
1319fn connect_nodes_result_with_counts(
1320 result: String,
1321 plan: &KwebPlan,
1322 ids: &[String],
1323) -> anyhow::Result<String> {
1324 let (updates, creates) = plan.context_projection();
1325 let mut seen = BTreeSet::new();
1326 let mut counts = Vec::new();
1327 for id in ids {
1328 if !seen.insert(id.as_str()) {
1329 continue;
1330 }
1331 let count = updates
1332 .get(id)
1333 .map(|node| node.recent_connections.len())
1334 .or_else(|| {
1335 creates
1336 .iter()
1337 .find(|create| create.pending_id == id.as_str())
1338 .map(|create| create.data.recent_connections.len())
1339 })
1340 .with_context(|| format!("ConnectNodes did not stage touched node {id}"))?;
1341 counts.push(format!("{id}: {count}"));
1342 }
1343 Ok(format!(
1344 "{result}\nPost-call recent connection counts: {}.",
1345 counts.join(", ")
1346 ))
1347}
1348
1349fn execute_kweb_mutation(
1350 name: &str,
1351 decoded: DecodedTool,
1352 context: &KwebContext,
1353 plan: &mut KwebPlan,
1354 journal: &mut HistorySession,
1355) -> anyhow::Result<(String, Vec<String>)> {
1356 let mutation = kweb_mutation(decoded, context, plan, journal)
1357 .with_context(|| format!("decoded contract for {name} did not match its Kweb mutation"))?;
1358 let connect_nodes = match &mutation {
1359 KwebMutation::ConnectNodes(ids) => Some(ids.clone()),
1360 _ => None,
1361 };
1362 let referenced = referenced_pending_nodes(&mutation);
1363 let mut result = plan.apply(context, mutation)?;
1364 if let Some(ids) = connect_nodes {
1365 result = connect_nodes_result_with_counts(result, plan, &ids)?;
1366 }
1367 Ok((result, referenced))
1368}
1369
1370fn inference_error_receipt(
1371 error: &anyhow::Error,
1372) -> Option<kcode_intelligence_router::UsageReceipt> {
1373 error.chain().find_map(|cause| {
1374 cause
1375 .downcast_ref::<kcode_intelligence_router::Error>()
1376 .and_then(|error| error.receipt().cloned())
1377 })
1378}
1379
1380async fn record_unavailable_inference<C, F>(
1381 host: &mut KennedySessionHost<'_, C>,
1382 inference: &mut kcode_agent_runtime::SessionInference,
1383 round: u64,
1384) -> anyhow::Result<()>
1385where
1386 C: FnMut(Value) -> F + Send,
1387 F: Future<Output = anyhow::Result<()>> + Send,
1388{
1389 let receipt = inference.finish_unavailable()?;
1390 host.record(kcode_agent_runtime::SessionEvent::ProviderReceipt {
1391 round,
1392 usage: None,
1393 receipt,
1394 continuation: None,
1395 })
1396 .await
1397}
1398
1399impl Session {
1400 pub fn mark_previous_ingress_attempt_timed_out(&mut self) {
1401 if matches!(self.mode, AgentMode::Ingress { .. })
1402 && !self.previous_ingress_attempt_timed_out
1403 {
1404 self.invalidate_active_stepped_turn();
1405 self.previous_ingress_attempt_timed_out = true;
1406 }
1407 }
1408
1409 fn ingress_time_remaining(&mut self) -> anyhow::Result<Option<u64>> {
1410 if !matches!(self.mode, AgentMode::Ingress { .. }) {
1411 return Ok(None);
1412 }
1413 ingress_time_remaining_at(&mut self.ingress_deadline, Instant::now()).map(Some)
1414 }
1415
1416 fn runtime_budget(&self) -> RuntimeBudget {
1417 let Some(provider_deadline) = self.active_provider_deadline else {
1418 return RuntimeBudget::default();
1419 };
1420 let mut time_limits = vec![TimeBudget {
1421 kind: TimeBudgetKind::ProviderCall,
1422 remaining: remaining_until(provider_deadline),
1423 }];
1424 if matches!(self.mode, AgentMode::FreeTime)
1425 && let Some(work_deadline) = deadline(&self.free_time)
1426 {
1427 time_limits.push(TimeBudget {
1428 kind: TimeBudgetKind::SelfTimeWork,
1429 remaining: remaining_until(work_deadline),
1430 });
1431 }
1432 if let Some(outer) = self.active_turn_deadline {
1433 time_limits.push(TimeBudget {
1434 kind: match outer.kind {
1435 TurnDeadlineKind::Telegram => TimeBudgetKind::TelegramTurn,
1436 TurnDeadlineKind::SelfTimeHardStop => TimeBudgetKind::SelfTimeHardStop,
1437 },
1438 remaining: remaining_until(outer.at),
1439 });
1440 }
1441 RuntimeBudget {
1442 rounds: Some(RoundBudget {
1443 used: self.rounds_used,
1444 limit: kcode_agent_runtime::DEFAULT_ROUND_LIMIT,
1445 }),
1446 time_limits,
1447 }
1448 }
1449
1450 fn projection(&self) -> ContextProjection {
1451 self.journal
1452 .state()
1453 .projection_with_footer_lines(&self.runtime_budget().footer_lines())
1454 }
1455
1456 fn provider_material_fingerprint(&self, tool_description: &str) -> String {
1457 let material = json!({
1458 "model":self.runtime.model,
1459 "reasoningEffort":self.runtime.reasoning_effort,
1460 "tool":"call_ktool",
1461 "toolDescription":tool_description,
1462 });
1463 hex::encode(Sha256::digest(
1464 serde_json::to_vec(&material).expect("provider material always serializes"),
1465 ))
1466 }
1467
1468 fn begin_provider_call_budget(&mut self, timeout: Option<Duration>) {
1469 self.active_provider_deadline = timeout.and_then(|timeout| {
1470 chrono::Duration::from_std(timeout)
1471 .ok()
1472 .map(|timeout| Utc::now() + timeout)
1473 });
1474 }
1475
1476 fn clear_turn_deadlines(&mut self) {
1477 self.active_provider_deadline = None;
1478 self.active_turn_deadline = None;
1479 }
1480
1481 fn invalidate_active_stepped_turn(&mut self) {
1482 self.turn_lease_slot = None;
1483 self.clear_turn_deadlines();
1484 }
1485
1486 fn synchronize_provider_known_events(&mut self) {
1487 if let Some(affinity) = self.provider_affinity.as_mut()
1488 && let Some(event) = self.journal.state().events.last()
1489 {
1490 affinity.synchronized_event_id = event.id;
1491 }
1492 }
1493
1494 pub async fn new(
1495 api: Service,
1496 system_prompt: String,
1497 subagent_codex_prompt: String,
1498 runtime: RuntimeModel,
1499 started_at: String,
1500 mut options: SessionOptions,
1501 restored: Option<&Value>,
1502 ) -> anyhow::Result<Self> {
1503 if let Some(state) = restored {
1504 restore_session_type(&mut options, state);
1505 options.channel = state.get("channel").cloned().unwrap_or(options.channel);
1506 options.free_time = state.get("freeTime").cloned().unwrap_or(options.free_time);
1507 options.orchestration = state
1508 .get("orchestration")
1509 .cloned()
1510 .unwrap_or(options.orchestration);
1511 }
1512 if options.group_context.is_null() {
1513 options.group_context = options
1514 .channel
1515 .get("groupContext")
1516 .cloned()
1517 .unwrap_or(Value::Null);
1518 }
1519 options
1520 .reference_root_node_ids
1521 .retain(|id| !options.root_node_ids.contains(id));
1522 options.reference_root_node_ids.sort();
1523 options.reference_root_node_ids.dedup();
1524
1525 DateTime::parse_from_rfc3339(&started_at).context("session start timestamp is invalid")?;
1526 if let Some(restored_started_at) = restored
1527 .and_then(|state| state.get("startedAt"))
1528 .and_then(Value::as_str)
1529 {
1530 anyhow::ensure!(
1531 restored_started_at == started_at,
1532 "restored session start timestamp changed"
1533 );
1534 }
1535 let rust_lib_session_id = restored
1536 .and_then(|state| state.get("rustLibSessionId"))
1537 .and_then(Value::as_str)
1538 .map(str::to_owned)
1539 .or(options.rust_lib_session_id.clone())
1540 .unwrap_or_else(|| format!("kennedy:{}", Uuid::new_v4()));
1541 let history_session_id = restored
1542 .and_then(|state| state.get("sessionId"))
1543 .and_then(Value::as_str)
1544 .map(str::to_owned);
1545 let source_session_type = options.source_session_type.clone().or_else(|| {
1546 restored
1547 .and_then(|state| state.get("sourceSessionType"))
1548 .and_then(Value::as_str)
1549 .map(str::to_owned)
1550 });
1551 let session_id = history_session_id
1552 .clone()
1553 .unwrap_or_else(|| Uuid::new_v4().to_string());
1554 let metadata = SessionMetadata {
1555 session_id: session_id.clone(),
1556 kind: session_kind(&options.session_type, &options.mode),
1557 created_at: started_at.clone(),
1558 effective_context_tokens: runtime.context_window_tokens,
1559 channel: options.channel.clone(),
1560 };
1561 let mut journal = if history_session_id.is_some() {
1562 api.history_session(metadata, &runtime.model)
1563 .with_context(|| {
1564 format!(
1565 "opening authoritative session {session_id} (legacy snapshots are intentionally unsupported)"
1566 )
1567 })?
1568 } else {
1569 api.create_history_session(NewSession {
1570 kind: metadata.kind,
1571 created_at: metadata.created_at,
1572 effective_context_tokens: metadata.effective_context_tokens,
1573 channel: metadata.channel,
1574 })?
1575 };
1576 let checkpoint_event_count = restored
1577 .and_then(|state| state.get("eventCount"))
1578 .and_then(Value::as_u64)
1579 .map(|count| usize::try_from(count).context("checkpoint event count is too large"))
1580 .transpose()?
1581 .unwrap_or(journal.state().events.len());
1582 anyhow::ensure!(
1583 checkpoint_event_count <= journal.state().events.len(),
1584 "checkpoint event count is ahead of the durable journal"
1585 );
1586 let fresh_ingress_attempt = matches!(options.mode, AgentMode::Ingress { .. })
1587 && !journal.is_sealed()
1588 && journal.state().history_ingress_started;
1589 if fresh_ingress_attempt {
1590 journal.reset_history_ingress_attempt(now())?;
1591 }
1592 let launch_context_node_ids = restored
1593 .and_then(|state| state.get("launchContextNodeIds"))
1594 .cloned()
1595 .map(serde_json::from_value::<Vec<String>>)
1596 .transpose()
1597 .context("restored launch context node IDs are invalid")?
1598 .unwrap_or_default();
1599 validate_canonical_distinct_ids(&launch_context_node_ids, "context node")?;
1600 let mut context = KwebContext::with_fixed_connections(
1601 options.root_node_ids.clone(),
1602 api.loads_fixed_connections(),
1603 )
1604 .map_err(anyhow::Error::new)?;
1605 restore_kweb_context(&journal, &mut context)?;
1606 let plan = if fresh_ingress_attempt {
1607 KwebPlan::default()
1608 } else {
1609 KwebPlan::restore(
1610 restored.and_then(|state| state.get("kwebPlan")),
1611 journal_kweb_plan(&journal),
1612 )?
1613 };
1614 let transcript = kcode_kennedy_session_ingress::transcript_from_journal(&journal);
1615 let (pending_turn, mut pending_external_event_id) =
1616 restore_pending_turn(restored, &transcript);
1617 let mut rounds_used = (!fresh_ingress_attempt)
1618 .then(|| {
1619 restored
1620 .and_then(|state| state.get("roundsUsed"))
1621 .and_then(Value::as_u64)
1622 })
1623 .flatten()
1624 .unwrap_or_default();
1625
1626 let needs_initialization = !journal
1627 .state()
1628 .boxes
1629 .values()
1630 .any(|state| matches!(state.owner, BoxOwner::System));
1631 let commit_receipt = restore_commit_receipt(restored)?;
1632 let commit_author = restored
1633 .and_then(|state| state.get("commitAuthor"))
1634 .and_then(Value::as_str)
1635 .map(str::to_owned)
1636 .unwrap_or_else(|| runtime.attribution());
1637 let provider_affinity = restore_provider_affinity(restored, fresh_ingress_attempt)?;
1638 let next_thread_reset_reason = (!fresh_ingress_attempt)
1639 .then(|| {
1640 restored
1641 .and_then(|state| state.get("nextThreadResetReason"))
1642 .and_then(Value::as_str)
1643 .map(str::to_owned)
1644 })
1645 .flatten();
1646 if let Some(receipt) = &commit_receipt {
1647 journal.mark_completed(receipt.session_object_id.to_string());
1648 }
1649 let completed =
1650 journal.state().completed_session_object.is_some() || commit_receipt.is_some();
1651 let launch_provenance = restored
1652 .and_then(|state| state.get("launchProvenance"))
1653 .cloned()
1654 .unwrap_or(Value::Null);
1655 let mut launch_bootstrap_pending = restored
1656 .and_then(|state| state.get("orchestration"))
1657 .and_then(|value| value.get("launchBootstrapPending"))
1658 .and_then(Value::as_bool)
1659 .unwrap_or(!launch_provenance.is_null());
1660 let mut launch_user_turn_id = restored
1661 .and_then(|state| state.get("launchUserTurnId"))
1662 .filter(|value| !value.is_null())
1663 .cloned()
1664 .map(serde_json::from_value::<EventId>)
1665 .transpose()
1666 .context("restored launch user-turn ID is invalid")?;
1667 if let Some(id) = launch_user_turn_id {
1668 validate_user_turn_id(&journal, id)?;
1669 }
1670 let recovered_admissions =
1671 if pending_turn && checkpoint_event_count < journal.state().events.len() {
1672 recovered_user_turns(&journal, &journal.state().events[checkpoint_event_count..])?
1673 } else {
1674 Vec::new()
1675 };
1676 replay_recovered_admissions(
1677 recovered_admissions,
1678 RecoveredAdmissionReplayState {
1679 pending_turn: &pending_turn,
1680 launch_provenance: &launch_provenance,
1681 orchestration: &mut options.orchestration,
1682 launch_bootstrap_pending: &mut launch_bootstrap_pending,
1683 launch_user_turn_id: &mut launch_user_turn_id,
1684 rounds_used: &mut rounds_used,
1685 pending_external_event_id: &mut pending_external_event_id,
1686 },
1687 );
1688 let launch_intents = restored
1689 .and_then(|state| state.get("launchIntents"))
1690 .cloned()
1691 .map(serde_json::from_value::<Vec<LaunchIntent>>)
1692 .transpose()
1693 .context("restored launch intents are invalid")?
1694 .unwrap_or_default();
1695 let mut session = Self {
1696 api,
1697 subagent_codex_prompt,
1698 runtime,
1699 journal,
1700 plan,
1701 session_type: options.session_type,
1702 channel: options.channel,
1703 free_time: options.free_time,
1704 orchestration: options.orchestration,
1705 provenance_id: options.provenance_id,
1706 rust_lib_session_id,
1707 root_node_ids: options.root_node_ids,
1708 reference_root_node_ids: options.reference_root_node_ids,
1709 started_at,
1710 transcript,
1711 pending_turn,
1712 pending_external_event_id,
1713 completed,
1714 rounds_used,
1715 commit_receipt,
1716 commit_author,
1717 mode: options.mode,
1718 source_session_type,
1719 group_context: options.group_context,
1720 context,
1721 free_time_end_reason: None,
1722 fatal_persistence_error: None,
1723 active_provider_deadline: None,
1724 active_turn_deadline: None,
1725 provider_affinity,
1726 next_thread_reset_reason,
1727 ingress_deadline: None,
1728 previous_ingress_attempt_timed_out: false,
1729 launch_provenance,
1730 launch_context_node_ids,
1731 launch_user_turn_id,
1732 launch_intents,
1733 launch_bootstrap_pending,
1734 turn_lease_slot: None,
1735 };
1736 session.validate_launch_intents()?;
1737
1738 if session.journal.is_sealed() {
1739 session.provider_affinity = None;
1740 session.next_thread_reset_reason = None;
1741 anyhow::ensure!(
1742 !matches!(session.mode, AgentMode::Conversation),
1743 "a read-only conversation has an unexpectedly sealed session log"
1744 );
1745 if session.commit_receipt.is_none() {
1746 session.finalize_kweb_session()?;
1747 }
1748 session.completed = true;
1749 return Ok(session);
1750 }
1751
1752 session.reconcile_launch_intents().await?;
1753 session.prune_launch_intents();
1754 session.repair_unfinished_tools()?;
1755
1756 if needs_initialization {
1757 session.journal.create_box(
1758 now(),
1759 "Kennedy system prompt",
1760 BoxOwner::System,
1761 BoxContent::text(&system_prompt),
1762 )?;
1763 if session.session_type == "telegram-group" && !session.group_context.is_null() {
1764 session.journal.create_box(
1765 now(),
1766 "Telegram group context",
1767 BoxOwner::Controller,
1768 BoxContent::text(kcode_telegram_session_coordinator::format_group_context(
1769 &session.group_context,
1770 )),
1771 )?;
1772 }
1773 let identifiers = session.initial_context_identifiers();
1774 let invocation =
1775 session.record_tool_invocation("LoadNodes", json!({"identifiers":&identifiers}))?;
1776 let result =
1777 load_durable_batch(session.api.kmap(), &mut session.context, &identifiers)?;
1778 for id in &session.launch_context_node_ids {
1779 anyhow::ensure!(
1780 session.context.contains_full_node(id),
1781 "launched child context node {id} is unavailable"
1782 );
1783 }
1784 session.sync_load_kweb_boxes()?;
1785 session.record_tool_completion(
1786 Some(&invocation),
1787 json!({"ok":true,"automatic":true,"identifiers":identifiers,"result":result}),
1788 )?;
1789 } else {
1790 if !session.launch_context_node_ids.is_empty() {
1791 let identifiers = session.initial_context_identifiers();
1792 load_durable_batch(session.api.kmap(), &mut session.context, &identifiers)?;
1793 for id in &session.launch_context_node_ids {
1794 anyhow::ensure!(
1795 session.context.contains_full_node(id),
1796 "launched child context node {id} is unavailable"
1797 );
1798 }
1799 }
1800 session.sync_load_kweb_boxes()?;
1801 }
1802 if fresh_ingress_attempt {
1803 session.revalidate_loaded_nodes().await?;
1804 session.pending_turn = true;
1805 }
1806 if matches!(session.mode, AgentMode::Ingress { .. })
1807 && !session.completed
1808 && !session.journal.state().history_ingress_started
1809 {
1810 session.prepare_history_ingress(&system_prompt).await?;
1811 }
1812 Ok(session)
1813 }
1814
1815 fn initial_context_identifiers(&self) -> Vec<String> {
1816 let mut identifiers = self.root_node_ids.clone();
1817 if !self.launch_context_node_ids.is_empty() {
1818 for id in self
1819 .reference_root_node_ids
1820 .iter()
1821 .chain(self.launch_context_node_ids.iter())
1822 {
1823 if !identifiers.contains(id) {
1824 identifiers.push(id.clone());
1825 }
1826 }
1827 }
1828 identifiers
1829 }
1830
1831 fn launch_session_authorized(&self) -> bool {
1832 self.pending_turn
1833 && self.launch_user_turn_id.is_some()
1834 && !self.launch_bootstrap_pending
1835 && matches!(self.mode, AgentMode::Conversation)
1836 && matches!(
1837 self.session_type.as_str(),
1838 "conversation" | "telegram" | "telegram-group"
1839 )
1840 }
1841
1842 fn validate_launch_intents(&self) -> anyhow::Result<()> {
1843 let mut seen = BTreeSet::new();
1844 for intent in &self.launch_intents {
1845 Uuid::parse_str(&intent.invocation_id).with_context(|| {
1846 format!("launch intent {} has an invalid UUID", intent.invocation_id)
1847 })?;
1848 anyhow::ensure!(
1849 seen.insert(intent.invocation_id.as_str()),
1850 "duplicate launch intent {}",
1851 intent.invocation_id
1852 );
1853 validate_user_turn_id(&self.journal, intent.user_turn_id)?;
1854 DateTime::parse_from_rfc3339(&intent.started_at)
1855 .context("launch intent timestamp is invalid")?;
1856 anyhow::ensure!(
1857 intent.parent_session_id == self.journal.state().metadata.session_id,
1858 "launch intent parent session changed"
1859 );
1860 anyhow::ensure!(
1861 intent.effective_context_tokens > 0,
1862 "launch intent effective context size is invalid"
1863 );
1864 validate_canonical_distinct_ids(&intent.root_node_ids, "root node")?;
1865 validate_canonical_distinct_ids(
1866 &intent.reference_root_node_ids,
1867 "reference root node",
1868 )?;
1869 validate_canonical_distinct_ids(&intent.context_node_ids, "context node")?;
1870 let _ = invocation_arguments(&self.journal, &intent.invocation_id)?;
1871 }
1872 Ok(())
1873 }
1874
1875 fn prune_launch_intents(&mut self) {
1876 let completed = completed_invocation_ids(&self.journal);
1877 self.launch_intents =
1878 pruned_launch_intents(&self.launch_intents, self.launch_user_turn_id, &completed);
1879 }
1880
1881 fn unfinished_launch_intents(&self) -> Vec<&LaunchIntent> {
1882 let completed = completed_invocation_ids(&self.journal);
1883 self.launch_intents
1884 .iter()
1885 .filter(|intent| !completed.contains(&intent.invocation_id))
1886 .collect()
1887 }
1888
1889 fn ensure_no_unfinished_launch_intents(&self) -> anyhow::Result<()> {
1890 let unfinished = self.unfinished_launch_intents();
1891 anyhow::ensure!(
1892 unfinished.is_empty(),
1893 "session has an unfinished intent-backed LaunchSession invocation"
1894 );
1895 Ok(())
1896 }
1897
1898 fn repair_unfinished_tools(&mut self) -> anyhow::Result<()> {
1899 self.ensure_no_unfinished_launch_intents()?;
1900 self.journal.repair_unfinished_tools(now())?;
1901 Ok(())
1902 }
1903
1904 fn prepare_launch_intent(
1905 &mut self,
1906 invocation: &RecordedToolInvocation,
1907 arguments: &LaunchSessionArguments,
1908 ) -> anyhow::Result<LaunchIntent> {
1909 anyhow::ensure!(
1910 self.launch_session_authorized(),
1911 "LaunchSession is unavailable without a genuine current user turn in an eligible conversation"
1912 );
1913 validate_loaded_launch_context(&self.context, &arguments.context_node_ids)?;
1914 if let Some(existing) = self
1915 .launch_intents
1916 .iter()
1917 .find(|intent| intent.invocation_id == invocation.invocation_id)
1918 {
1919 return Ok(existing.clone());
1920 }
1921 let user_turn_id = self
1922 .launch_user_turn_id
1923 .context("LaunchSession user-turn authority is missing")?;
1924 let current_count = self
1925 .launch_intents
1926 .iter()
1927 .filter(|intent| intent.user_turn_id == user_turn_id)
1928 .count();
1929 anyhow::ensure!(
1930 current_count < MAX_LAUNCH_INTENTS_PER_USER_TURN,
1931 "LaunchSession permits at most ten new sessions per genuine user turn"
1932 );
1933 let intent = LaunchIntent {
1934 invocation_id: invocation.invocation_id.clone(),
1935 user_turn_id,
1936 started_at: now(),
1937 parent_session_id: self.journal.state().metadata.session_id.clone(),
1938 effective_context_tokens: self.runtime.context_window_tokens,
1939 root_node_ids: self.root_node_ids.clone(),
1940 reference_root_node_ids: self.reference_root_node_ids.clone(),
1941 context_node_ids: arguments.context_node_ids.clone(),
1942 };
1943 self.launch_intents.push(intent.clone());
1944 Ok(intent)
1945 }
1946
1947 fn launch_request(intent: &LaunchIntent, directive: &str) -> HistoryLaunchSession {
1948 let provenance = json!({
1949 "kind":"synthetic-launch-bootstrap",
1950 "denyLaunchSession":true,
1951 "parentSessionId":intent.parent_session_id,
1952 "parentInvocationId":intent.invocation_id,
1953 "parentUserTurnId":intent.user_turn_id,
1954 });
1955 HistoryLaunchSession {
1956 session_id: intent.invocation_id.clone(),
1957 started_at: intent.started_at.clone(),
1958 effective_context_tokens: intent.effective_context_tokens,
1959 channel: json!({"kind":"browser"}),
1960 state: json!({
1961 "sessionId":intent.invocation_id,
1962 "chatendMetadata":{
1963 "sessionId":intent.invocation_id,
1964 "kind":SessionKind::Conversation,
1965 "createdAt":intent.started_at,
1966 "effectiveContextTokens":intent.effective_context_tokens,
1967 "channel":{"kind":"browser"},
1968 },
1969 "sessionType":"conversation",
1970 "channel":{"kind":"browser"},
1971 "freeTime":Value::Null,
1972 "orchestration":{
1973 "owner":"backend",
1974 "status":"idle",
1975 "launchBootstrapPending":true,
1976 },
1977 "launchProvenance":provenance,
1978 "rootNodeIds":intent.root_node_ids,
1979 "referenceRootNodeIds":intent.reference_root_node_ids,
1980 "launchContextNodeIds":intent.context_node_ids,
1981 "startedAt":intent.started_at,
1982 "pendingTurn":false,
1983 "completed":false,
1984 }),
1985 initial_message: json!({
1986 "text":directive,
1987 "metadata":{
1988 "launchProvenance":provenance,
1989 },
1990 }),
1991 }
1992 }
1993
1994 async fn lower_launch(
1995 &self,
1996 intent: &LaunchIntent,
1997 arguments: &LaunchSessionArguments,
1998 ) -> Result<kcode_session_history::SessionLaunch, kcode_session_history::Error> {
1999 self.api
2000 .launch_session(Self::launch_request(intent, &arguments.directive))
2001 .await
2002 }
2003
2004 async fn reconcile_launch_intents(&mut self) -> anyhow::Result<()> {
2005 let completed = completed_invocation_ids(&self.journal);
2006 let unfinished = self
2007 .launch_intents
2008 .iter()
2009 .filter(|intent| !completed.contains(&intent.invocation_id))
2010 .cloned()
2011 .collect::<Vec<_>>();
2012 for intent in unfinished {
2013 let raw = invocation_arguments(&self.journal, &intent.invocation_id)?.clone();
2014 let arguments = decode_launch_session_arguments(&raw)?;
2015 let invocation = RecordedToolInvocation {
2016 invocation_id: intent.invocation_id.clone(),
2017 tool_instance: tool_instance_for_invocation(
2018 LAUNCH_SESSION_TOOL,
2019 &intent.invocation_id,
2020 ),
2021 tool_name: LAUNCH_SESSION_TOOL.into(),
2022 };
2023 let result = self.lower_launch(&intent, &arguments).await;
2024 complete_launch_reconciliation(&mut self.journal, &invocation, result)?;
2025 }
2026 Ok(())
2027 }
2028
2029 async fn prepare_history_ingress(&mut self, prompt: &str) -> anyhow::Result<()> {
2030 let cost_at_ingress = self.projection().status;
2031 if !self.journal.state().source_terminated {
2032 self.journal.record(
2033 now(),
2034 EventKind::SourceTerminated {
2035 reason: "history_ingress".into(),
2036 },
2037 )?;
2038 }
2039 let system_box = self
2040 .journal
2041 .state()
2042 .boxes
2043 .values()
2044 .find(|state| matches!(state.owner, BoxOwner::System))
2045 .map(|state| state.id)
2046 .context("session has no system-prompt box")?;
2047 let recorded_at = now();
2048 self.journal
2049 .update_box(recorded_at.clone(), system_box, BoxContent::text(prompt))?;
2050 self.journal.rehydrate_box(recorded_at, system_box)?;
2051 let ingress_kind = session_kind(&self.session_type, &self.mode);
2052 if self.journal.state().metadata.effective_context_tokens
2053 != self.runtime.context_window_tokens
2054 || self.journal.state().metadata.kind != ingress_kind
2055 {
2056 self.journal
2057 .configure_context(ingress_kind, self.runtime.context_window_tokens);
2058 }
2059 self.journal.create_box(
2060 now(),
2061 "Session cost at ingress",
2062 BoxOwner::Controller,
2063 BoxContent::text(cost_summary(
2064 "session cost before history ingress",
2065 cost_at_ingress.estimated_cost_usd_nanos,
2066 cost_at_ingress.unpriced_provider_calls,
2067 )),
2068 )?;
2069 self.revalidate_loaded_nodes().await?;
2070 match kcode_history_ingress_context::prepare(&mut self.journal, now())? {
2071 HistoryIngressContextOutcome::Ready => {}
2072 HistoryIngressContextOutcome::OverCapacity {
2073 estimated_tokens,
2074 target_tokens,
2075 } => {
2076 self.journal.record(
2077 now(),
2078 EventKind::Note {
2079 label: INGRESS_FORCE_COMMIT_NOTE.into(),
2080 value: json!({
2081 "reason":"fully_dehydrated_context_above_initial_target",
2082 "estimatedTokens":estimated_tokens,
2083 "initialTargetTokens":target_tokens,
2084 }),
2085 },
2086 )?;
2087 self.clear_launch_turn_authority();
2088 self.pending_turn = false;
2089 self.finalize_kweb_session()?;
2090 self.completed = true;
2091 return Ok(());
2092 }
2093 }
2094 self.journal
2095 .record(now(), EventKind::HistoryIngressStarted)?;
2096 self.pending_turn = true;
2097 Ok(())
2098 }
2099
2100 async fn revalidate_loaded_nodes(&mut self) -> anyhow::Result<()> {
2101 let direct = self.context.loaded_node_ids().to_vec();
2102 load_durable_batch(self.api.kmap(), &mut self.context, &direct)?;
2103 self.sync_load_kweb_boxes()?;
2104 Ok(())
2105 }
2106
2107 fn stage_user_input(&mut self, text: &str, metadata: &Value) -> Option<InputStage> {
2108 let recorded_at = now();
2109 let result = (|| -> anyhow::Result<Option<InputStage>> {
2110 let Some(staged) = kcode_kennedy_session_ingress::stage_user_input(
2111 &mut self.journal,
2112 text,
2113 metadata,
2114 &recorded_at,
2115 )?
2116 else {
2117 return Ok(None);
2118 };
2119 self.transcript.push(staged.transcript);
2120 self.recover_context_overflow(staged.external_event_id.as_deref(), &[])?;
2121 Ok(Some(InputStage::Accepted))
2122 })();
2123 match result {
2124 Ok(stage) => stage,
2125 Err(error) => {
2126 self.fatal_persistence_error = Some(error.to_string());
2127 tracing::error!(error=%error, "Could not durably stage session input");
2128 Some(InputStage::Accepted)
2129 }
2130 }
2131 }
2132
2133 pub fn append_final_user_message(&mut self, text: &str, metadata: &Value) -> bool {
2134 let accepted = self.stage_user_input(text, metadata).is_some();
2135 if accepted {
2136 self.invalidate_active_stepped_turn();
2137 }
2138 accepted
2139 }
2140
2141 pub fn stage_source_message(
2142 &mut self,
2143 kennedy: bool,
2144 text: &str,
2145 metadata: Value,
2146 ) -> anyhow::Result<()> {
2147 let staged = kcode_kennedy_session_ingress::stage_source_input(
2148 &mut self.journal,
2149 kennedy,
2150 text,
2151 metadata,
2152 &now(),
2153 )?;
2154 self.invalidate_active_stepped_turn();
2155 self.transcript.push(staged.transcript);
2156 self.recover_context_overflow(staged.external_event_id.as_deref(), &[])?;
2157 Ok(())
2158 }
2159
2160 pub fn answer_for_external_event(&self, id: &str) -> Option<&Value> {
2161 self.transcript.iter().rev().find(|entry| {
2162 is_terminal_external_response(entry)
2163 && entry.get("externalEventId").and_then(Value::as_str) == Some(id)
2164 })
2165 }
2166
2167 pub fn responses_for_external_event(&self, id: &str) -> Vec<&Value> {
2168 self.transcript
2169 .iter()
2170 .filter(|entry| {
2171 matches!(
2172 entry.get("role").and_then(Value::as_str),
2173 Some("kennedy" | "system")
2174 ) && entry.get("externalEventId").and_then(Value::as_str) == Some(id)
2175 })
2176 .collect()
2177 }
2178
2179 pub fn resolve_object(&mut self, object_id: &str) -> anyhow::Result<ResolvedObject> {
2180 let api = self.api.clone();
2181 kcode_kennedy_session_objects::resolve_object(
2182 &mut self.journal,
2183 object_id,
2184 move |canonical_id| api.kmap_file(canonical_id).map_err(Into::into),
2185 )
2186 }
2187
2188 fn resolve_media_object(&mut self, object_id: &str) -> anyhow::Result<ResolvedObject> {
2189 let api = self.api.clone();
2190 kcode_kennedy_session_objects::resolve_media_object(
2191 &mut self.journal,
2192 object_id,
2193 MAX_MEDIA_ENRICHMENT_BYTES,
2194 move |canonical_id| api.kmap_file(canonical_id).map_err(Into::into),
2195 )
2196 }
2197
2198 fn resolve_image_object(
2199 &mut self,
2200 object_id: &str,
2201 ) -> anyhow::Result<(Vec<u8>, String, String)> {
2202 let resolved = self.resolve_media_object(object_id)?;
2203 anyhow::ensure!(
2204 resolved.media_type.starts_with("image/"),
2205 "GenerateImage reference {object_id} is not an image"
2206 );
2207 Ok((resolved.bytes, resolved.file_name, resolved.media_type))
2208 }
2209
2210 fn recover_context_overflow(
2211 &mut self,
2212 external_event_id: Option<&str>,
2213 pinned_box_ids: &[BoxId],
2214 ) -> anyhow::Result<ContextRecovery> {
2215 let projection = self.projection();
2216 let target_tokens = self.journal.state().active_context_limit();
2217 if projection.estimated_tokens <= target_tokens {
2218 return Ok(ContextRecovery::NotNeeded);
2219 }
2220 let projection_hash = hex::encode(Sha256::digest(projection.render().as_bytes()));
2221 let already_irreducible = self
2222 .journal
2223 .state()
2224 .events
2225 .iter()
2226 .rev()
2227 .find_map(|event| match &event.kind {
2228 EventKind::Note { label, value } if label == "context_overflow_recovery" => {
2229 Some(value)
2230 }
2231 _ => None,
2232 })
2233 .is_some_and(|value| {
2234 value.get("irreducible").and_then(Value::as_bool) == Some(true)
2235 && value.get("limitTokens").and_then(Value::as_u64) == Some(target_tokens)
2236 && value.get("projectionHash").and_then(Value::as_str)
2237 == Some(projection_hash.as_str())
2238 });
2239 if already_irreducible {
2240 return Ok(ContextRecovery::Irreducible);
2241 }
2242
2243 let before_tokens = projection.estimated_tokens;
2244 let mut metadata = json!({
2245 "transcriptRole":"system",
2246 "contextOverflowWarning":true,
2247 "projectedTokens":before_tokens,
2248 "limitTokens":target_tokens,
2249 });
2250 if let Some(id) = external_event_id {
2251 metadata["externalEventId"] = json!(id);
2252 }
2253 let warning_box_id = self.journal.create_box(
2254 now(),
2255 CONTEXT_OVERFLOW_WARNING_BOX_NAME,
2256 BoxOwner::Controller,
2257 BoxContent {
2258 text: CONTEXT_OVERFLOW_WARNING.into(),
2259 objects: Vec::new(),
2260 metadata,
2261 },
2262 )?;
2263 let mut transcript = json!({
2264 "role":"system",
2265 "content":CONTEXT_OVERFLOW_WARNING,
2266 "contextOverflowWarning":true,
2267 });
2268 if let Some(id) = external_event_id {
2269 transcript["externalEventId"] = json!(id);
2270 }
2271 self.transcript.push(transcript);
2272
2273 let mut pins = pinned_box_ids.to_vec();
2274 if !pins.contains(&warning_box_id) {
2275 pins.push(warning_box_id);
2276 }
2277 let outcome = kcode_history_ingress_context::recover(&mut self.journal, now(), &pins)?;
2278 let (dehydrated_box_ids, estimated_tokens, target_tokens, irreducible) = match outcome {
2279 ContextRecoveryOutcome::Recovered {
2280 dehydrated_box_ids,
2281 estimated_tokens,
2282 target_tokens,
2283 } => (dehydrated_box_ids, estimated_tokens, target_tokens, false),
2284 ContextRecoveryOutcome::OverCapacity {
2285 dehydrated_box_ids,
2286 estimated_tokens,
2287 target_tokens,
2288 } => (dehydrated_box_ids, estimated_tokens, target_tokens, true),
2289 };
2290 let final_projection_hash =
2291 hex::encode(Sha256::digest(self.projection().render().as_bytes()));
2292 self.journal.record(
2293 now(),
2294 EventKind::Note {
2295 label: "context_overflow_recovery".into(),
2296 value: json!({
2297 "beforeTokens":before_tokens,
2298 "estimatedTokens":estimated_tokens,
2299 "limitTokens":target_tokens,
2300 "dehydratedBoxIds":dehydrated_box_ids,
2301 "irreducible":irreducible,
2302 "projectionHash":final_projection_hash,
2303 }),
2304 },
2305 )?;
2306 if irreducible {
2307 if matches!(self.mode, AgentMode::Ingress { .. }) {
2308 self.request_ingress_force_commit(
2309 "irreducible_context_overflow",
2310 estimated_tokens,
2311 )?;
2312 } else if !self.journal.state().source_terminated {
2313 self.journal.record(
2314 now(),
2315 EventKind::SourceTerminated {
2316 reason: "irreducible_context_overflow".into(),
2317 },
2318 )?;
2319 }
2320 Ok(ContextRecovery::Irreducible)
2321 } else {
2322 Ok(ContextRecovery::Recovered)
2323 }
2324 }
2325
2326 fn request_ingress_force_commit(
2327 &mut self,
2328 reason: &str,
2329 projected_tokens: u64,
2330 ) -> anyhow::Result<()> {
2331 if self.ingress_force_commit_requested() {
2332 return Ok(());
2333 }
2334 self.journal.record(
2335 now(),
2336 EventKind::Note {
2337 label: INGRESS_FORCE_COMMIT_NOTE.into(),
2338 value: json!({
2339 "reason":reason,
2340 "projectedTokens":projected_tokens,
2341 "limitTokens":self.journal.state().ingress_context_limit(),
2342 }),
2343 },
2344 )?;
2345 Ok(())
2346 }
2347
2348 fn ingress_force_commit_requested(&self) -> bool {
2349 self.journal
2350 .state()
2351 .current_ingress_attempt_events()
2352 .iter()
2353 .rev()
2354 .any(|event| {
2355 matches!(
2356 &event.kind,
2357 EventKind::Note { label, .. } if label == INGRESS_FORCE_COMMIT_NOTE
2358 )
2359 })
2360 }
2361
2362 pub fn requires_history_ingress(&self) -> bool {
2363 matches!(self.mode, AgentMode::Conversation) && self.journal.state().source_terminated
2364 }
2365
2366 pub fn stage_free_time_opening(&mut self) -> bool {
2367 if self.pending_turn {
2368 return false;
2369 }
2370 self.invalidate_active_stepped_turn();
2371 self.launch_user_turn_id = None;
2372 self.prune_launch_intents();
2373 let mut blocks = vec![
2374 render(RenderRequest::FreeTimeOpening {
2375 free_time: &self.free_time,
2376 })
2377 .expect("free-time opening rendering is infallible"),
2378 ];
2379 if let Some(message) = self
2380 .free_time
2381 .get("handoffMessage")
2382 .and_then(Value::as_str)
2383 .filter(|message| !message.trim().is_empty())
2384 {
2385 blocks.push(format!(
2386 "Message from the previous self-time session:\n\n{message}"
2387 ));
2388 }
2389 let Some(stage) = self.stage_user_input(&blocks.join("\n\n"), &json!({"kind":"self-time"}))
2390 else {
2391 return false;
2392 };
2393 self.pending_turn = matches!(stage, InputStage::Accepted);
2394 true
2395 }
2396
2397 pub fn stage_wakeup_opening(&mut self) -> anyhow::Result<bool> {
2398 if self.pending_turn {
2399 return Ok(false);
2400 }
2401 self.invalidate_active_stepped_turn();
2402 self.launch_user_turn_id = None;
2403 self.prune_launch_intents();
2404 let marker = self
2405 .channel
2406 .get("wakeupMarker")
2407 .and_then(Value::as_str)
2408 .context("wakeup session is missing its acquired time marker")?;
2409 let marker = DateTime::parse_from_rfc3339(marker)
2410 .context("wakeup session has an invalid acquired time marker")?
2411 .with_timezone(&Utc);
2412 let text = render(RenderRequest::WakeupOpening { marker })?;
2413 let Some(stage) = self.stage_user_input(
2414 &text,
2415 &json!({"kind":"wakeup","wakeupMarker":marker.to_rfc3339()}),
2416 ) else {
2417 return Ok(false);
2418 };
2419 self.pending_turn = matches!(stage, InputStage::Accepted);
2420 Ok(true)
2421 }
2422
2423 pub fn begin_user_turn(&mut self, text: &str, metadata: &Value) -> bool {
2424 if self.pending_turn {
2425 return false;
2426 }
2427 self.invalidate_active_stepped_turn();
2428 self.launch_user_turn_id = None;
2429 self.prune_launch_intents();
2430 let first_event = self.journal.state().events.len();
2431 let Some(stage) = self.stage_user_input(text, metadata) else {
2432 return false;
2433 };
2434 debug_assert_eq!(stage, InputStage::Accepted);
2435 let user_turn =
2436 unique_user_box_event(&self.journal, &self.journal.state().events[first_event..])
2437 .ok()
2438 .flatten();
2439 self.launch_user_turn_id = user_turn_launch_authority(
2440 user_turn,
2441 &mut self.orchestration,
2442 &mut self.launch_bootstrap_pending,
2443 );
2444 self.rounds_used = 0;
2445 self.pending_turn = true;
2446 self.pending_external_event_id = metadata
2447 .get("externalEventId")
2448 .and_then(Value::as_str)
2449 .map(str::to_owned);
2450 true
2451 }
2452
2453 fn clear_launch_turn_authority(&mut self) {
2454 self.launch_user_turn_id = None;
2455 self.prune_launch_intents();
2456 }
2457
2458 pub fn reset_exhausted_turn_rounds_for_retry(&mut self) {
2459 if matches!(self.mode, AgentMode::Conversation)
2460 && self.rounds_used >= kcode_agent_runtime::DEFAULT_ROUND_LIMIT
2461 {
2462 self.invalidate_active_stepped_turn();
2463 self.rounds_used = 0;
2464 }
2465 }
2466
2467 pub fn interrupt_current_turn(&mut self) -> anyhow::Result<()> {
2468 self.ensure_no_unfinished_launch_intents()?;
2469 self.invalidate_active_stepped_turn();
2470 self.provider_affinity = None;
2471 self.next_thread_reset_reason = Some("prior_provider_turn_interrupted".into());
2472 self.repair_unfinished_tools()?;
2473 let notice = "The user stopped this agent turn.";
2474 let mut metadata = json!({"transcriptRole":"system","userStopped":true});
2475 let mut transcript_entry = json!({
2476 "role":"system",
2477 "content":notice,
2478 "userStopped":true,
2479 });
2480 if let Some(external_event_id) = &self.pending_external_event_id {
2481 metadata["externalEventId"] = json!(external_event_id);
2482 transcript_entry["externalEventId"] = json!(external_event_id);
2483 }
2484 self.journal.create_box(
2485 now(),
2486 "Turn stopped",
2487 BoxOwner::Controller,
2488 BoxContent {
2489 text: notice.into(),
2490 objects: Vec::new(),
2491 metadata,
2492 },
2493 )?;
2494 self.transcript.push(transcript_entry);
2495 self.pending_turn = false;
2496 self.pending_external_event_id = None;
2497 self.clear_launch_turn_authority();
2498 self.orchestration =
2499 json!({"owner":"backend","status":"idle","lastOutcome":"user-stopped"});
2500 Ok(())
2501 }
2502
2503 pub fn begin_pending_turn(
2504 &mut self,
2505 operation_id: Uuid,
2506 turn_deadline: Option<TurnDeadline>,
2507 ) -> anyhow::Result<Option<SessionTurn>> {
2508 if let Some(error) = self.fatal_persistence_error.take() {
2509 anyhow::bail!("session journal write failed: {error}");
2510 }
2511 if !self.pending_turn {
2512 return Ok(None);
2513 }
2514 let user_id = self
2515 .root_node_ids
2516 .first()
2517 .context("session has no user root for intelligence accounting")?
2518 .clone();
2519 let lease = TurnLease::acquire(&mut self.turn_lease_slot)?;
2520 self.active_provider_deadline = None;
2521 self.active_turn_deadline = turn_deadline;
2522 Ok(Some(SessionTurn {
2523 lease,
2524 user_id,
2525 completed_rounds: self.rounds_used,
2526 round_limit: kcode_agent_runtime::DEFAULT_ROUND_LIMIT,
2527 state: PrimaryTurnState {
2528 accounting: None,
2529 pending_freeform_write: None,
2530 deadline_after_response: false,
2531 operation_id,
2532 prepared_cache: None,
2533 provider_synchronized_after: None,
2534 restart_fresh_reason: None,
2535 exact_tool_result: false,
2536 used_tool: false,
2537 finish_requested: false,
2538 emitted_response: false,
2539 pending_capture: None,
2540 },
2541 at_yielded_boundary: false,
2542 admission_poisoned: false,
2543 }))
2544 }
2545
2546 pub async fn admit_pending_turn<C, F>(
2547 &mut self,
2548 turn: &mut SessionTurn,
2549 admission: PendingTurnAdmission,
2550 recorded_at: &str,
2551 checkpoint: &mut C,
2552 ) -> anyhow::Result<bool>
2553 where
2554 C: FnMut(Value) -> F + Send,
2555 F: Future<Output = anyhow::Result<()>> + Send,
2556 {
2557 turn.lease.validate(&self.turn_lease_slot)?;
2558 anyhow::ensure!(self.pending_turn, "the session has no current pending turn");
2559 anyhow::ensure!(
2560 turn.at_yielded_boundary,
2561 "the stepped session turn is not at a yielded boundary"
2562 );
2563 anyhow::ensure!(
2564 !turn.admission_poisoned,
2565 "the stepped session turn admission handle is poisoned"
2566 );
2567
2568 let expected_kind = match &admission {
2569 PendingTurnAdmission::User { .. } => AdmissionKind::User,
2570 PendingTurnAdmission::Source { .. } => AdmissionKind::Source,
2571 };
2572 let result = self
2573 .admit_pending_turn_staged(turn, admission, expected_kind, recorded_at, checkpoint)
2574 .await;
2575 if result.is_err() {
2576 turn.admission_poisoned = true;
2577 }
2578 result
2579 }
2580
2581 async fn admit_pending_turn_staged<C, F>(
2582 &mut self,
2583 turn: &mut SessionTurn,
2584 admission: PendingTurnAdmission,
2585 expected_kind: AdmissionKind,
2586 recorded_at: &str,
2587 checkpoint: &mut C,
2588 ) -> anyhow::Result<bool>
2589 where
2590 C: FnMut(Value) -> F + Send,
2591 F: Future<Output = anyhow::Result<()>> + Send,
2592 {
2593 let Some(staged) = stage_turn_admission(&mut self.journal, admission, recorded_at)? else {
2594 return Ok(false);
2595 };
2596
2597 let kind = staged.kind;
2598 let external_event_id = staged.external_event_id;
2599 let user_turn_id = staged.user_turn_id;
2600 self.transcript.push(staged.transcript);
2601 self.recover_context_overflow(external_event_id.as_deref(), &[])?;
2602
2603 anyhow::ensure!(
2604 kind == expected_kind,
2605 "staged turn admission kind differs from the requested kind"
2606 );
2607 match kind {
2608 AdmissionKind::User => {
2609 let user_turn_id =
2610 user_turn_id.context("staged User admission has no user-turn ID")?;
2611 self.launch_user_turn_id = user_turn_launch_authority(
2612 Some(user_turn_id),
2613 &mut self.orchestration,
2614 &mut self.launch_bootstrap_pending,
2615 );
2616 self.prune_launch_intents();
2617 self.rounds_used = 0;
2618 turn.completed_rounds = 0;
2619 self.pending_external_event_id = external_event_id;
2620 }
2621 AdmissionKind::Source => {
2622 anyhow::ensure!(
2623 user_turn_id.is_none(),
2624 "staged Source admission has a user-turn ID"
2625 );
2626 }
2627 }
2628
2629 checkpoint(self.snapshot()?).await?;
2630 Ok(true)
2631 }
2632
2633 async fn finish_stepped_turn<C, F>(
2634 &mut self,
2635 turn: SessionTurn,
2636 result: Option<String>,
2637 checkpoint: &mut C,
2638 ) -> anyhow::Result<TurnBoundary>
2639 where
2640 C: FnMut(Value) -> F + Send,
2641 F: Future<Output = anyhow::Result<()>> + Send,
2642 {
2643 turn.lease.validate(&self.turn_lease_slot)?;
2644 self.turn_lease_slot = None;
2645 self.clear_turn_deadlines();
2646 let output = match self.mode {
2647 AgentMode::Conversation => {
2648 if self.journal.state().source_terminated {
2649 self.provider_affinity = None;
2650 self.next_thread_reset_reason = None;
2651 self.pending_turn = false;
2652 self.pending_external_event_id = None;
2653 self.clear_launch_turn_authority();
2654 checkpoint(self.snapshot()?).await?;
2655 None
2656 } else {
2657 let Some(answer) = result else {
2658 if self
2659 .pending_external_event_id
2660 .as_deref()
2661 .and_then(|id| self.answer_for_external_event(id))
2662 .is_some()
2663 {
2664 self.pending_turn = false;
2665 self.pending_external_event_id = None;
2666 self.clear_launch_turn_authority();
2667 checkpoint(self.snapshot()?).await?;
2668 return Ok(TurnBoundary::Complete(None));
2669 }
2670 anyhow::bail!(
2671 "Kennedy ended a conversational turn without an assistant response"
2672 );
2673 };
2674 self.pending_turn = false;
2675 self.pending_external_event_id = None;
2676 self.clear_launch_turn_authority();
2677 checkpoint(self.snapshot()?).await?;
2678 Some(answer)
2679 }
2680 }
2681 AgentMode::FreeTime | AgentMode::Wakeup | AgentMode::Ingress { .. } => {
2682 self.pending_turn = false;
2683 self.pending_external_event_id = None;
2684 self.clear_launch_turn_authority();
2685 self.finalize_kweb_session()?;
2686 self.completed = true;
2687 checkpoint(self.snapshot()?).await?;
2688 None
2689 }
2690 };
2691 Ok(TurnBoundary::Complete(output))
2692 }
2693
2694 pub async fn advance_pending_turn<C, F>(
2695 &mut self,
2696 mut turn: SessionTurn,
2697 checkpoint: &mut C,
2698 ) -> anyhow::Result<TurnBoundary>
2699 where
2700 C: FnMut(Value) -> F + Send,
2701 F: Future<Output = anyhow::Result<()>> + Send,
2702 {
2703 turn.lease.validate(&self.turn_lease_slot)?;
2704 anyhow::ensure!(
2705 !turn.admission_poisoned,
2706 "the stepped session turn admission handle is poisoned"
2707 );
2708 if turn.at_yielded_boundary {
2709 turn.at_yielded_boundary = false;
2710 }
2711 let result = self.advance_pending_turn_validated(turn, checkpoint).await;
2712 if result.is_err() {
2713 self.clear_turn_deadlines();
2714 }
2715 result
2716 }
2717
2718 async fn advance_pending_turn_validated<C, F>(
2719 &mut self,
2720 mut turn: SessionTurn,
2721 checkpoint: &mut C,
2722 ) -> anyhow::Result<TurnBoundary>
2723 where
2724 C: FnMut(Value) -> F + Send,
2725 F: Future<Output = anyhow::Result<()>> + Send,
2726 {
2727 if turn.completed_rounds >= turn.round_limit {
2728 let runtime = self.api.agent_runtime();
2729 let operation_id = turn.state.operation_id;
2730 let answer = {
2731 let mut host = KennedySessionHost {
2732 session: self,
2733 checkpoint,
2734 state: &mut turn.state,
2735 };
2736 runtime
2737 .run_session(
2738 kcode_agent_runtime::SessionRunRequest {
2739 user_id: turn.user_id.clone(),
2740 operation_id,
2741 rounds_used: turn.round_limit,
2742 round_limit: turn.round_limit,
2743 },
2744 &mut host,
2745 )
2746 .await?
2747 };
2748 return self.finish_stepped_turn(turn, answer, checkpoint).await;
2749 }
2750
2751 let round = turn.completed_rounds + 1;
2752 let operation_id = turn.state.operation_id;
2753 let runtime = self.api.agent_runtime();
2754 let prepared = {
2755 let mut host = KennedySessionHost {
2756 session: self,
2757 checkpoint,
2758 state: &mut turn.state,
2759 };
2760 match host.prepare_round(round).await? {
2761 kcode_agent_runtime::RoundPreparation::Run(prepared) => {
2762 let manifest_hash = hex::encode(Sha256::digest(prepared.input.as_bytes()));
2763 host.record(kcode_agent_runtime::SessionEvent::InferenceSubmitted {
2764 round,
2765 manifest_hash,
2766 model: prepared.model.clone(),
2767 })
2768 .await?;
2769 prepared
2770 }
2771 kcode_agent_runtime::RoundPreparation::Complete(answer) => {
2772 return self.finish_stepped_turn(turn, answer, checkpoint).await;
2773 }
2774 }
2775 };
2776 turn.completed_rounds = round;
2777 Ok(TurnBoundary::Await(PendingSessionInference {
2778 turn,
2779 action: Box::new(PendingInferenceAction::Start {
2780 runtime,
2781 request: kcode_agent_runtime::SessionInferenceRequest {
2782 user_id: self
2783 .root_node_ids
2784 .first()
2785 .context("session has no user root for intelligence accounting")?
2786 .clone(),
2787 operation_id,
2788 round,
2789 prepared,
2790 },
2791 }),
2792 }))
2793 }
2794
2795 pub async fn apply_inference_wake<C, F>(
2796 &mut self,
2797 wake: SessionInferenceWake,
2798 checkpoint: &mut C,
2799 ) -> anyhow::Result<TurnBoundary>
2800 where
2801 C: FnMut(Value) -> F + Send,
2802 F: Future<Output = anyhow::Result<()>> + Send,
2803 {
2804 wake.turn.lease.validate(&self.turn_lease_slot)?;
2805 let result = self.apply_inference_wake_validated(wake, checkpoint).await;
2806 if result.is_err() {
2807 self.clear_turn_deadlines();
2808 }
2809 result
2810 }
2811
2812 async fn apply_inference_wake_validated<C, F>(
2813 &mut self,
2814 wake: SessionInferenceWake,
2815 checkpoint: &mut C,
2816 ) -> anyhow::Result<TurnBoundary>
2817 where
2818 C: FnMut(Value) -> F + Send,
2819 F: Future<Output = anyhow::Result<()>> + Send,
2820 {
2821 let SessionInferenceWake { mut turn, kind } = wake;
2822 let round = turn.completed_rounds;
2823 let operation_id = turn.state.operation_id;
2824 match kind {
2825 SessionInferenceWakeKind::StartFailed(error) => {
2826 let mut host = KennedySessionHost {
2827 session: self,
2828 checkpoint,
2829 state: &mut turn.state,
2830 };
2831 if let Some(receipt) = inference_error_receipt(&error) {
2832 host.record(kcode_agent_runtime::SessionEvent::ProviderReceipt {
2833 round,
2834 usage: None,
2835 receipt: Box::new(receipt),
2836 continuation: None,
2837 })
2838 .await?;
2839 }
2840 Err(error)
2841 }
2842 SessionInferenceWakeKind::RespondFailed {
2843 mut inference,
2844 error,
2845 } => {
2846 let mut host = KennedySessionHost {
2847 session: self,
2848 checkpoint,
2849 state: &mut turn.state,
2850 };
2851 record_unavailable_inference(&mut host, &mut inference, round).await?;
2852 Err(error)
2853 }
2854 SessionInferenceWakeKind::RespondedStop { inference } => {
2855 drop(inference);
2856 self.finish_stepped_turn(turn, None, checkpoint).await
2857 }
2858 SessionInferenceWakeKind::Event {
2859 mut inference,
2860 event,
2861 } => {
2862 let event = match event {
2863 Ok(Some(event)) => event,
2864 Ok(None) => {
2865 let mut host = KennedySessionHost {
2866 session: self,
2867 checkpoint,
2868 state: &mut turn.state,
2869 };
2870 record_unavailable_inference(&mut host, &mut inference, round).await?;
2871 anyhow::bail!("provider ended without a terminal turn event");
2872 }
2873 Err(error) => {
2874 let mut host = KennedySessionHost {
2875 session: self,
2876 checkpoint,
2877 state: &mut turn.state,
2878 };
2879 if let Some(receipt) = inference_error_receipt(&error) {
2880 host.record(kcode_agent_runtime::SessionEvent::ProviderReceipt {
2881 round,
2882 usage: None,
2883 receipt: Box::new(receipt),
2884 continuation: None,
2885 })
2886 .await?;
2887 }
2888 return Err(error);
2889 }
2890 };
2891
2892 match event {
2893 kcode_agent_runtime::SessionInferenceEvent::ProviderInput { context } => {
2894 let mut host = KennedySessionHost {
2895 session: self,
2896 checkpoint,
2897 state: &mut turn.state,
2898 };
2899 host.record(kcode_agent_runtime::SessionEvent::ProviderInput {
2900 round,
2901 context,
2902 })
2903 .await?;
2904 Ok(TurnBoundary::Await(PendingSessionInference {
2905 turn,
2906 action: Box::new(PendingInferenceAction::Next { inference }),
2907 }))
2908 }
2909 kcode_agent_runtime::SessionInferenceEvent::UsageUpdated { usage } => {
2910 let mut host = KennedySessionHost {
2911 session: self,
2912 checkpoint,
2913 state: &mut turn.state,
2914 };
2915 host.record(kcode_agent_runtime::SessionEvent::UsageUpdated {
2916 round,
2917 usage,
2918 })
2919 .await?;
2920 Ok(TurnBoundary::Await(PendingSessionInference {
2921 turn,
2922 action: Box::new(PendingInferenceAction::Next { inference }),
2923 }))
2924 }
2925 kcode_agent_runtime::SessionInferenceEvent::ToolCall { call_id, call } => {
2926 turn.state.used_tool = true;
2927 let call =
2928 call.map_err(|error| anyhow::anyhow!("Invalid Ktool call: {error}"));
2929 let mut host = KennedySessionHost {
2930 session: self,
2931 checkpoint,
2932 state: &mut turn.state,
2933 };
2934 let outcome = match host.execute_tool(call, operation_id).await {
2935 Ok(outcome) => outcome,
2936 Err(error) => {
2937 record_unavailable_inference(&mut host, &mut inference, round)
2938 .await?;
2939 return Err(error);
2940 }
2941 };
2942 let resume = match host.prepare_provider_resume(outcome).await {
2943 Ok(resume) => resume,
2944 Err(error) => {
2945 record_unavailable_inference(&mut host, &mut inference, round)
2946 .await?;
2947 return Err(error);
2948 }
2949 };
2950 match resume {
2951 kcode_agent_runtime::ProviderResume::Continue(mut outcome) => {
2952 host.state.finish_requested |=
2953 outcome.ok && outcome.finish_after_round;
2954 host.state.emitted_response |=
2955 outcome.ok && outcome.emitted_response;
2956 host.state.pending_capture = outcome.capture.take();
2957 let stop = outcome.stop;
2958 let result = if outcome.ok {
2959 kcode_codex_runtime_v2::ToolResult::success(outcome.text)
2960 } else {
2961 kcode_codex_runtime_v2::ToolResult::failure(outcome.text)
2962 };
2963 Ok(TurnBoundary::Await(PendingSessionInference {
2964 turn,
2965 action: Box::new(PendingInferenceAction::Respond {
2966 inference,
2967 call_id,
2968 result,
2969 stop,
2970 }),
2971 }))
2972 }
2973 kcode_agent_runtime::ProviderResume::Complete(answer) => {
2974 record_unavailable_inference(&mut host, &mut inference, round)
2975 .await?;
2976 self.finish_stepped_turn(turn, answer, checkpoint).await
2977 }
2978 kcode_agent_runtime::ProviderResume::RestartFresh => {
2979 record_unavailable_inference(&mut host, &mut inference, round)
2980 .await?;
2981 turn.at_yielded_boundary = true;
2982 Ok(TurnBoundary::Yield(turn))
2983 }
2984 }
2985 }
2986 kcode_agent_runtime::SessionInferenceEvent::Completed {
2987 answer,
2988 usage,
2989 receipt,
2990 continuation,
2991 } => {
2992 drop(inference);
2993 let mut host = KennedySessionHost {
2994 session: self,
2995 checkpoint,
2996 state: &mut turn.state,
2997 };
2998 host.record(kcode_agent_runtime::SessionEvent::ProviderReceipt {
2999 round,
3000 usage,
3001 receipt,
3002 continuation,
3003 })
3004 .await?;
3005 let capture = host.state.pending_capture.take();
3006 let control = if let Some(capture) = capture {
3007 host.complete_capture(capture, answer).await?
3008 } else {
3009 let completion = kcode_agent_runtime::RoundCompletion {
3010 answer,
3011 used_tool: host.state.used_tool,
3012 finish_requested: host.state.finish_requested,
3013 emitted_response: host.state.emitted_response,
3014 };
3015 host.complete_round(completion).await?
3016 };
3017 host.state.used_tool = false;
3018 host.state.finish_requested = false;
3019 host.state.emitted_response = false;
3020 match control {
3021 kcode_agent_runtime::SessionControl::Continue => {
3022 turn.at_yielded_boundary = true;
3023 Ok(TurnBoundary::Yield(turn))
3024 }
3025 kcode_agent_runtime::SessionControl::Complete(answer) => {
3026 self.finish_stepped_turn(turn, answer, checkpoint).await
3027 }
3028 }
3029 }
3030 }
3031 }
3032 }
3033 }
3034
3035 pub async fn run_pending_turn<C, F>(
3036 &mut self,
3037 operation_id: Uuid,
3038 turn_deadline: Option<TurnDeadline>,
3039 mut checkpoint: C,
3040 ) -> anyhow::Result<Option<String>>
3041 where
3042 C: FnMut(Value) -> F + Send,
3043 F: Future<Output = anyhow::Result<()>> + Send,
3044 {
3045 let Some(turn) = self.begin_pending_turn(operation_id, turn_deadline)? else {
3046 return Ok(None);
3047 };
3048 let mut boundary = self.advance_pending_turn(turn, &mut checkpoint).await?;
3049 loop {
3050 boundary = match boundary {
3051 TurnBoundary::Await(pending) => {
3052 let wake = pending.wait().await;
3053 self.apply_inference_wake(wake, &mut checkpoint).await?
3054 }
3055 TurnBoundary::Yield(turn) => {
3056 self.advance_pending_turn(turn, &mut checkpoint).await?
3057 }
3058 TurnBoundary::Complete(answer) => return Ok(answer),
3059 };
3060 }
3061 }
3062
3063 fn project_descendant<T>(
3064 &mut self,
3065 outcome: Result<kcode_intelligence_router::Accounted<T>, services::ApiError>,
3066 ) -> anyhow::Result<T> {
3067 match outcome {
3068 Ok(accounted) => {
3069 kcode_intelligence_chatend::record_descendant_receipt(
3070 &mut self.journal,
3071 &accounted.receipt,
3072 )?;
3073 Ok(accounted.value)
3074 }
3075 Err(error) => {
3076 if let Some(receipt) = &error.receipt {
3077 kcode_intelligence_chatend::record_descendant_receipt(
3078 &mut self.journal,
3079 receipt,
3080 )?;
3081 }
3082 Err(error.into())
3083 }
3084 }
3085 }
3086
3087 async fn run_subagent(
3088 &mut self,
3089 model: String,
3090 reasoning_effort: Option<String>,
3091 context_node_ids: Vec<String>,
3092 task: String,
3093 parent_operation_id: Uuid,
3094 ) -> anyhow::Result<String> {
3095 let reasoning_effort =
3096 reasoning_effort.unwrap_or_else(|| self.runtime.reasoning_effort.clone());
3097 let mut selected_node_descriptions = Vec::with_capacity(context_node_ids.len());
3098 for node_id in &context_node_ids {
3099 selected_node_descriptions.push(self.api.kmap_node(node_id)?.data.long_description);
3100 }
3101 let user_id = self
3102 .root_node_ids
3103 .first()
3104 .context("session has no user root for subagent intelligence accounting")?
3105 .clone();
3106 let timeout = self.agent_request_timeout();
3107 let runtime = self.api.agent_runtime();
3108 let provider = runtime.resolve_model(&model).await?.provider;
3109 let first_event = self.journal.state().events.len();
3110 let cost_before = self.projection().status;
3111 let subagent_context = SubagentContext::new(
3112 self.root_node_ids.clone(),
3113 self.api.loads_fixed_connections(),
3114 provider,
3115 self.subagent_codex_prompt.clone(),
3116 selected_node_descriptions,
3117 )?;
3118 let initial_sections = subagent_context.initial_sections().to_vec();
3119 let result = {
3120 let mut host = KennedySubagentHost {
3121 session: self,
3122 context: subagent_context,
3123 captures: HashMap::new(),
3124 };
3125 runtime
3126 .run(
3127 kcode_agent_runtime::RunRequest {
3128 user_id,
3129 parent_operation_id,
3130 model,
3131 reasoning_effort,
3132 context: initial_sections,
3133 task,
3134 timeout,
3135 start_metadata: json!({"contextNodeIds":context_node_ids}),
3136 },
3137 &mut host,
3138 )
3139 .await
3140 };
3141 match result {
3142 Ok(result) => {
3143 let cost_after = self.projection().status;
3144 Ok(format!(
3145 "{}\n\n[{}]",
3146 result.answer,
3147 cost_summary(
3148 "subagent cost",
3149 cost_after
3150 .estimated_cost_usd_nanos
3151 .saturating_sub(cost_before.estimated_cost_usd_nanos),
3152 cost_after
3153 .unpriced_provider_calls
3154 .saturating_sub(cost_before.unpriced_provider_calls),
3155 )
3156 ))
3157 }
3158 Err(error) => {
3159 let may_have_effects =
3160 self.journal.state().events[first_event..]
3161 .iter()
3162 .any(|event| {
3163 matches!(
3164 &event.kind,
3165 EventKind::Note { label, .. } if label == "subagent_tool_call"
3166 )
3167 });
3168 if may_have_effects {
3169 Err(error.context(
3170 "the subagent failed after making Ktool calls; some tool effects may already have occurred",
3171 ))
3172 } else {
3173 Err(error)
3174 }
3175 }
3176 }
3177 }
3178
3179 async fn complete_subagent_freeform_write(
3180 &mut self,
3181 context: &mut SubagentContext,
3182 request: FreeformWrite,
3183 contents: String,
3184 budget: &kcode_agent_runtime::ContextBudget,
3185 ) -> anyhow::Result<kcode_agent_runtime::ToolOutcome> {
3186 let kind = request.kind();
3187 let freeform_tool = request.write_tool();
3188 anyhow::ensure!(
3189 context.source_is_open(kind, request.name()),
3190 "{} {:?} is not open in this subagent context. Call {} first.",
3191 kind.label(),
3192 request.name(),
3193 kind.open_tool()
3194 );
3195 let backend_arguments = request.capture_subagent(&mut self.journal, &now(), contents)?;
3196 let preview = self
3197 .api
3198 .managed_source_execute(
3199 &self.rust_lib_session_id,
3200 request.preview_tool(),
3201 backend_arguments.clone(),
3202 Vec::new(),
3203 )
3204 .await?;
3205 let preview = preview
3206 .snapshot
3207 .context("subagent freeform write preview omitted its source snapshot")?;
3208 let preview_state = context.source_state(&preview);
3209 anyhow::ensure!(
3210 budget.fits_state(preview_state.key, preview_state.text),
3211 "{freeform_tool} was not run because its resulting source state would exceed the subagent context limit"
3212 );
3213 let execution = self
3214 .api
3215 .managed_source_execute(
3216 &self.rust_lib_session_id,
3217 freeform_tool,
3218 backend_arguments,
3219 Vec::new(),
3220 )
3221 .await?;
3222 let snapshot = execution
3223 .snapshot
3224 .context("subagent freeform write omitted its resulting source snapshot")?;
3225 let state = context.apply_source_snapshot(snapshot);
3226 Ok(kcode_agent_runtime::ToolOutcome {
3227 text: execution.text,
3228 ok: true,
3229 state_updates: state.update.into_iter().collect(),
3230 displayed_state_keys: Vec::new(),
3231 capture: None,
3232 })
3233 }
3234
3235 async fn complete_freeform_write(
3236 &mut self,
3237 pending: PendingFreeformWrite,
3238 contents: String,
3239 ) -> anyhow::Result<ToolOutcome> {
3240 let request = pending.request;
3241 let freeform_tool = request.write_tool();
3242 let backend_arguments =
3243 request.capture(&mut self.journal, &now(), pending.call_box_id, contents)?;
3244 let preview_result = self
3245 .api
3246 .managed_source_execute(
3247 &self.rust_lib_session_id,
3248 request.preview_tool(),
3249 backend_arguments.clone(),
3250 Vec::new(),
3251 )
3252 .await;
3253 let preview = match preview_result {
3254 Ok(preview) => preview,
3255 Err(error) => {
3256 return Ok(ToolOutcome {
3257 text: format!("{freeform_tool} failed: {error}"),
3258 store_result: true,
3259 ok: false,
3260 end_session: false,
3261 freeform_write: None,
3262 managed_source_snapshot: None,
3263 exact_result: false,
3264 });
3265 }
3266 };
3267 let _preview = preview
3268 .snapshot
3269 .context("freeform write preview omitted the resulting source snapshot")?;
3270 request.source_box_id(&self.journal)?;
3271
3272 let execution_result = self
3273 .api
3274 .managed_source_execute(
3275 &self.rust_lib_session_id,
3276 freeform_tool,
3277 backend_arguments,
3278 Vec::new(),
3279 )
3280 .await;
3281 let execution = match execution_result {
3282 Ok(execution) => execution,
3283 Err(error) => {
3284 return Ok(ToolOutcome {
3285 text: format!("{freeform_tool} failed: {error}"),
3286 store_result: true,
3287 ok: false,
3288 end_session: false,
3289 freeform_write: None,
3290 managed_source_snapshot: None,
3291 exact_result: false,
3292 });
3293 }
3294 };
3295 let snapshot = execution
3296 .snapshot
3297 .context("freeform write omitted the resulting source snapshot")?;
3298 apply_snapshot(&mut self.journal, &now(), snapshot)?;
3299 Ok(ToolOutcome {
3300 text: execution.text,
3301 store_result: false,
3302 ok: true,
3303 end_session: false,
3304 freeform_write: None,
3305 managed_source_snapshot: None,
3306 exact_result: false,
3307 })
3308 }
3309
3310 async fn send_telegram_dm(&mut self, arguments: &Value) -> anyhow::Result<String> {
3311 let request = kcode_telegram_session_coordinator::parse_private_request(arguments)?;
3312 let attachments = self.telegram_delivery_attachments(request.attachments)?;
3313 let caller_holds_user_lock = self.session_type == "telegram"
3314 && self.channel.get("telegramUserId").and_then(Value::as_i64)
3315 == Some(request.telegram_user_id);
3316 self.api
3317 .telegram()
3318 .send_private(kcode_telegram_session_coordinator::PrivateDelivery {
3319 telegram_user_id: request.telegram_user_id,
3320 message: request.message,
3321 attachments,
3322 caller_holds_user_lock,
3323 })
3324 .await
3325 }
3326
3327 async fn send_telegram_group_message(&mut self, arguments: &Value) -> anyhow::Result<String> {
3328 let request = kcode_telegram_session_coordinator::parse_group_request(arguments)?;
3329 let attachments = self.telegram_delivery_attachments(request.attachments)?;
3330 self.api
3331 .telegram()
3332 .send_group(kcode_telegram_session_coordinator::GroupDelivery {
3333 root_node_id: request.root_node_id,
3334 message: request.message,
3335 attachments,
3336 })
3337 .await
3338 }
3339
3340 fn telegram_delivery_attachments(
3341 &mut self,
3342 requests: Vec<kcode_telegram_session_coordinator::AttachmentRequest>,
3343 ) -> anyhow::Result<Vec<kcode_telegram_session_coordinator::Attachment>> {
3344 let api = self.api.clone();
3345 kcode_kennedy_session_objects::delivery_attachments(
3346 &mut self.journal,
3347 requests,
3348 move |canonical_id| api.kmap_file(canonical_id).map_err(Into::into),
3349 )
3350 }
3351
3352 async fn execute_tool(
3353 &mut self,
3354 call: &ToolCall,
3355 operation_id: Uuid,
3356 ) -> anyhow::Result<ToolOutcome> {
3357 self.assert_tool_allowed(&call.name)?;
3358 anyhow::ensure!(
3359 call.name != LAUNCH_SESSION_TOOL,
3360 "LaunchSession requires the checkpointed launch dispatch lane"
3361 );
3362 let decoded = decode(&call.name, &call.arguments)?;
3363 let mut end_session = false;
3364 let mut store_result = true;
3365 let mut freeform_write = None;
3366 let mut managed_source_snapshot = None;
3367 let text = match (call.name.as_str(), decoded) {
3368 ("NoteToSelf", None) => {
3369 decode_note_to_self(&call.arguments)?;
3370 store_result = false;
3371 "Note saved.".into()
3372 }
3373 ("SendTelegramDM", _) => self.send_telegram_dm(&call.arguments).await?,
3374 ("SendTelegramGroupMessage", _) => {
3375 self.send_telegram_group_message(&call.arguments).await?
3376 }
3377 (
3378 "RunSubagent",
3379 Some(DecodedTool::RunSubagent {
3380 model,
3381 reasoning_effort,
3382 context_node_ids,
3383 task,
3384 }),
3385 ) => {
3386 let first_event = self.journal.state().events.len();
3387 match self
3388 .run_subagent(
3389 model,
3390 reasoning_effort,
3391 context_node_ids,
3392 task,
3393 operation_id,
3394 )
3395 .await
3396 {
3397 Ok(response) => response,
3398 Err(error) => {
3399 let may_have_effects = self.journal.state().events[first_event..]
3400 .iter()
3401 .any(|event| {
3402 matches!(
3403 &event.kind,
3404 EventKind::Note { label, .. }
3405 if label == "subagent_tool_call"
3406 )
3407 });
3408 if may_have_effects {
3409 return Err(error.context(
3410 "the subagent failed after making Ktool calls; some tool effects may already have occurred",
3411 ));
3412 }
3413 return Err(error);
3414 }
3415 }
3416 }
3417 ("EndSession", Some(DecodedTool::EndSession { message })) => {
3418 anyhow::ensure!(
3419 !matches!(self.mode, AgentMode::Conversation),
3420 "EndSession is only available during an autonomous or history-ingress session"
3421 );
3422 end_session = true;
3423 if matches!(self.mode, AgentMode::FreeTime)
3424 && let Some(message) = message.filter(|message| !message.trim().is_empty())
3425 {
3426 self.free_time["nextSessionMessage"] = json!(message);
3427 }
3428 "Session ending.".into()
3429 }
3430 ("DehydrateBoxes", Some(DecodedTool::BoxIds(ids))) => {
3431 self.journal.dehydrate_boxes(now(), &ids)?;
3432 format!(
3433 "Dehydrated boxes {}.",
3434 ids.iter()
3435 .map(ToString::to_string)
3436 .collect::<Vec<_>>()
3437 .join(", ")
3438 )
3439 }
3440 ("SummarizeBox", Some(DecodedTool::SummarizeBox { box_id, summary })) => {
3441 self.journal.summarize_box(now(), box_id, summary)?;
3442 format!("Summarized box {box_id}.")
3443 }
3444 ("HydrateBox", Some(DecodedTool::BoxId(id))) => {
3445 self.journal.rehydrate_box(now(), id)?;
3446 let external_event_id = self.pending_external_event_id.clone();
3447 match self.recover_context_overflow(external_event_id.as_deref(), &[id])? {
3448 ContextRecovery::NotNeeded => format!("Hydrated box {id}."),
3449 ContextRecovery::Recovered => {
3450 format!("Hydrated box {id}.\n\n{CONTEXT_OVERFLOW_WARNING}")
3451 }
3452 ContextRecovery::Irreducible => anyhow::bail!(CONTEXT_OVERFLOW_WARNING),
3453 }
3454 }
3455 ("BoxesIntoObjects", Some(DecodedTool::BoxIds(ids))) => {
3456 kcode_kennedy_box_text_objects::stage_box_text_objects(
3457 &mut self.journal,
3458 &ids,
3459 &now(),
3460 )?
3461 }
3462 ("LoadNodes", Some(DecodedTool::LoadNodes(identifiers))) => {
3463 load_durable_batch(self.api.kmap(), &mut self.context, &identifiers)?;
3464 let changed = self.sync_load_kweb_boxes()?;
3465 store_result = false;
3466 render_load_nodes_result(
3467 &self.journal,
3468 &changed,
3469 &self.runtime_budget().footer_lines(),
3470 )?
3471 }
3472 (
3473 "EmitObject",
3474 Some(DecodedTool::EmitObject {
3475 object_id,
3476 file_name,
3477 }),
3478 ) => {
3479 anyhow::ensure!(
3480 matches!(self.mode, AgentMode::Conversation),
3481 "EmitObject is only available in a conversation"
3482 );
3483 let object = self.resolve_object(&object_id)?;
3484 let file_name = file_name.unwrap_or_else(|| object.file_name.clone());
3485 if let Some(maximum) = self.channel.get("maxObjectBytes").and_then(Value::as_u64) {
3486 anyhow::ensure!(
3487 !object.bytes.is_empty(),
3488 "object {object_id} is empty and cannot be sent through this channel"
3489 );
3490 anyhow::ensure!(
3491 object.bytes.len() as u64 <= maximum,
3492 "object {object_id} is {} bytes, over this channel's {maximum}-byte limit",
3493 object.bytes.len()
3494 );
3495 }
3496 let descriptor = json!({
3497 "objectId":object_id,
3498 "fileName":file_name,
3499 "mediaType":object.media_type,
3500 "byteLength":object.bytes.len(),
3501 });
3502 let mut metadata = json!({
3503 "outputKind":"object",
3504 "attachments":[descriptor.clone()],
3505 });
3506 if let Some(external_event_id) = &self.pending_external_event_id {
3507 metadata["externalEventId"] = json!(external_event_id);
3508 }
3509 let content = BoxContent {
3510 text: String::new(),
3511 objects: vec![object_id.clone()],
3512 metadata,
3513 };
3514 self.journal
3515 .create_box(now(), "Kennedy message", BoxOwner::Kennedy, content)?;
3516 let mut transcript = json!({
3517 "role":"kennedy",
3518 "content":"",
3519 "objects":[object_id],
3520 "attachments":[descriptor],
3521 });
3522 if let Some(external_event_id) = &self.pending_external_event_id {
3523 transcript["externalEventId"] = json!(external_event_id);
3524 }
3525 self.transcript.push(transcript);
3526 store_result = false;
3527 "Object emitted to the user.".into()
3528 }
3529 ("WebSearch", Some(DecodedTool::WebSearch { question, model })) => {
3530 let user_id = self
3531 .root_node_ids
3532 .first()
3533 .context("session has no user root for intelligence accounting")?
3534 .clone();
3535 let outcome = self
3536 .api
3537 .search(
3538 &user_id,
3539 kcode_intelligence_router::SearchRequest {
3540 question,
3541 model,
3542 operation_id: Uuid::new_v4(),
3543 parent_operation_id: Some(operation_id),
3544 },
3545 )
3546 .await;
3547 let result = self.project_descendant(outcome)?;
3548 render_web_search_result(&result)?
3549 }
3550 ("WebFetch", Some(DecodedTool::WebFetch(url))) => {
3551 let user_id = self
3552 .root_node_ids
3553 .first()
3554 .context("session has no user root for intelligence accounting")?;
3555 let result = self
3556 .api
3557 .fetch(
3558 user_id,
3559 kcode_intelligence_router::FetchRequest {
3560 url,
3561 operation_id: Uuid::new_v4(),
3562 parent_operation_id: Some(operation_id),
3563 },
3564 )
3565 .await?;
3566 render_web_fetch_result(&result)?
3567 }
3568 ("StageTelegramGroupMedia", Some(DecodedTool::StageTelegramGroupMedia(message_id))) => {
3569 let media_ref = kcode_telegram_session_coordinator::group_media_reference(
3570 &self.group_context,
3571 message_id,
3572 )?;
3573 let chat_id = media_ref.chat_id;
3574 let api = self.api.clone();
3575 let staged = kcode_kennedy_session_objects::stage_telegram_group_media(
3576 &mut self.journal,
3577 kcode_kennedy_session_objects::TelegramStageRequest {
3578 chat_id,
3579 message_id,
3580 maximum_bytes: MAX_MEDIA_ENRICHMENT_BYTES,
3581 transport_metadata: media_ref.transport_metadata(),
3582 recorded_at: now(),
3583 },
3584 || api.telegram().group_message_media(chat_id, message_id),
3585 |media_type| {
3586 kcode_telegram_session_coordinator::group_media_file_name(
3587 &media_ref, media_type,
3588 )
3589 },
3590 )?;
3591 render(RenderRequest::StagedTelegramMedia {
3592 pending_id: &staged.descriptor.pending_id,
3593 kind: &staged.kind,
3594 file_name: &staged.descriptor.file_name,
3595 media_type: &staged.descriptor.media_type,
3596 size_bytes: staged.descriptor.size_bytes,
3597 message_id,
3598 reused: staged.reused,
3599 })?
3600 }
3601 (
3602 "TranscribeAudio",
3603 Some(DecodedTool::MediaEnrichment {
3604 object_id,
3605 model,
3606 prompt,
3607 }),
3608 ) => {
3609 let object = self.resolve_media_object(&object_id)?;
3610 validate(ValidationRequest::TranscribableAudio(&object.media_type))?;
3611 validate(ValidationRequest::TranscriptionModel(&model))?;
3612 let user_id = self
3613 .root_node_ids
3614 .first()
3615 .context("session has no user root for intelligence accounting")?
3616 .clone();
3617 let outcome = self
3618 .api
3619 .transcribe_audio(
3620 &user_id,
3621 &model,
3622 &prompt,
3623 object.bytes,
3624 object.file_name.clone(),
3625 &object.media_type,
3626 None,
3627 operation_id,
3628 )
3629 .await;
3630 let result = self.project_descendant(outcome)?;
3631 render_audio_transcription_result(
3632 &object.object_id,
3633 &object.file_name,
3634 &object.media_type,
3635 &result,
3636 )?
3637 }
3638 (
3639 "AnnotateMedia",
3640 Some(DecodedTool::MediaEnrichment {
3641 object_id,
3642 model,
3643 prompt,
3644 }),
3645 ) => {
3646 let media = self.resolve_media_object(&object_id)?;
3647 validate(ValidationRequest::Annotation {
3648 model: &model,
3649 media_type: &media.media_type,
3650 })?;
3651 let user_id = self
3652 .root_node_ids
3653 .first()
3654 .context("session has no user root for intelligence accounting")?
3655 .clone();
3656 let outcome = self
3657 .api
3658 .annotate_media(
3659 &user_id,
3660 &model,
3661 &prompt,
3662 media.bytes,
3663 media.file_name.clone(),
3664 &media.media_type,
3665 operation_id,
3666 )
3667 .await;
3668 let result = self.project_descendant(outcome)?;
3669 render_media_annotation_result(
3670 &media.object_id,
3671 &media.file_name,
3672 &media.media_type,
3673 &result,
3674 )?
3675 }
3676 (
3677 "GenerateImage",
3678 Some(DecodedTool::GenerateImage {
3679 model,
3680 prompt,
3681 reference_object_ids,
3682 }),
3683 ) => {
3684 let mut references = Vec::with_capacity(reference_object_ids.len());
3685 for object_id in &reference_object_ids {
3686 references.push(self.resolve_image_object(object_id)?);
3687 }
3688 let user_id = self
3689 .root_node_ids
3690 .first()
3691 .context("session has no user root for intelligence accounting")?
3692 .clone();
3693 let outcome = self
3694 .api
3695 .generate_image(&user_id, &model, &prompt, references, operation_id)
3696 .await;
3697 let result = self.project_descendant(outcome)?;
3698 let size = result.bytes.len();
3699 let file_name =
3700 format!("generated-image.{}", image_extension(&result.content_type));
3701 let object_id = self.api.save_generated_image(
3702 result.bytes,
3703 &file_name,
3704 &result.content_type,
3705 &result.model,
3706 )?;
3707 format!(
3708 "Generated image.\nObject: {object_id}\nFile: {file_name}\nContent type: {}\nSize: {size} bytes\nModel: {}\nUse EmitObject with {object_id} to deliver it.",
3709 result.content_type, result.model
3710 )
3711 }
3712 ("ExtractDocumentText", Some(DecodedTool::ObjectId(object_id))) => {
3713 let object = self.resolve_media_object(&object_id)?;
3714 validate(ValidationRequest::ExtractableDocument {
3715 media_type: &object.media_type,
3716 file_name: &object.file_name,
3717 })?;
3718 let result = self
3719 .api
3720 .extract_document(object.bytes, object.file_name.clone(), &object.media_type)
3721 .await?;
3722 render_document_extraction_result(&object.object_id, &object.file_name, &result)?
3723 }
3724 (name, None) if SPEECH_CLASSIFICATION_TOOLS.contains(&name) => {
3725 self.api
3726 .execute_speech_classification_tool(name, call.arguments.clone())
3727 .await?
3728 }
3729 (name, None) if TASK_BOARD_TOOLS.contains(&name) => {
3730 self.execute_task_board_tool(name, &call.arguments).await?
3731 }
3732 (name, Some(decoded)) if is_kweb_mutation(name) => {
3733 let (text, _) = execute_kweb_mutation(
3734 name,
3735 decoded,
3736 &self.context,
3737 &mut self.plan,
3738 &mut self.journal,
3739 )?;
3740 self.sync_kweb_boxes()?;
3741 text
3742 }
3743 (name, None)
3744 if RUST_LIB_TOOLS.contains(&name)
3745 || WEB_LIB_TOOLS.contains(&name)
3746 || RUST_BIN_TOOLS.contains(&name) =>
3747 {
3748 if let Some(request) = prepare_freeform_write(&self.journal, name, &call.arguments)?
3749 {
3750 store_result = false;
3751 let acknowledgement = request.acknowledgement();
3752 freeform_write = Some(request);
3753 acknowledgement
3754 } else {
3755 let object_ids = if name == CALL_RUST_BIN_TOOL {
3756 decode_managed_objects(ManagedObjectArguments::RustBinary(&call.arguments))?
3757 } else if name == ATTACH_OBJECT_WEB_LIB_TOOL {
3758 decode_managed_objects(ManagedObjectArguments::WebLibraryAttachment(
3759 &call.arguments,
3760 ))?
3761 } else {
3762 Vec::new()
3763 };
3764 let mut objects = Vec::with_capacity(object_ids.len());
3765 for object_id in object_ids {
3766 objects.push(self.resolve_object(&object_id)?.bytes);
3767 }
3768 let execution = self
3769 .api
3770 .managed_source_execute(
3771 &self.rust_lib_session_id,
3772 name,
3773 call.arguments.clone(),
3774 objects,
3775 )
3776 .await?;
3777 if let Some(snapshot) = execution.snapshot {
3778 managed_source_snapshot = Some(snapshot);
3779 store_result = false;
3780 }
3781 execution.text
3782 }
3783 }
3784 (name, Some(_)) => {
3785 anyhow::bail!("decoded contract for {name} did not match its dispatch lane")
3786 }
3787 (name, None) => anyhow::bail!("Tool {name} is not available"),
3788 };
3789 Ok(ToolOutcome {
3790 text,
3791 store_result,
3792 ok: true,
3793 end_session,
3794 freeform_write,
3795 managed_source_snapshot,
3796 exact_result: false,
3797 })
3798 }
3799
3800 async fn execute_task_board_tool(
3801 &self,
3802 name: &str,
3803 arguments: &Value,
3804 ) -> anyhow::Result<String> {
3805 let board = self
3806 .api
3807 .task_board()
3808 .context("task board is not configured")?
3809 .clone();
3810 let name = name.to_owned();
3811 let arguments = arguments.clone();
3812 let user_id = self
3813 .root_node_ids
3814 .first()
3815 .context("session has no user root for task-category lookup")?
3816 .clone();
3817 tokio::task::spawn_blocking(move || -> anyhow::Result<String> {
3818 let output = match name.as_str() {
3819 "CreateTaskCategory" => serde_json::to_string_pretty(
3820 &board.create_category(serde_json::from_value(arguments)?)?,
3821 )?,
3822 "GetTaskCategory" => {
3823 let call: CategoryCall = serde_json::from_value(arguments)?;
3824 serde_json::to_string_pretty(&board.category(
3825 &call.category_id,
3826 kcode_task_board::BrowsePage {
3827 user_id,
3828 offset: call.offset,
3829 limit: call.limit,
3830 },
3831 )?)?
3832 }
3833 "RemoveTaskCategory" => {
3834 let call: CategoryId = serde_json::from_value(arguments)?;
3835 board.remove_category(&call.category_id)?;
3836 format!("Removed category {}.", call.category_id)
3837 }
3838 "CreateTask" => serde_json::to_string_pretty(
3839 &board.create_task(serde_json::from_value(arguments)?)?,
3840 )?,
3841 "GetTask" => {
3842 let call: TaskId = serde_json::from_value(arguments)?;
3843 serde_json::to_string_pretty(&board.task(&call.task_id)?)?
3844 }
3845 "UpdateTask" => serde_json::to_string_pretty(
3846 &board.update_task(serde_json::from_value(arguments)?)?,
3847 )?,
3848 "RemoveTask" => {
3849 let call: TaskId = serde_json::from_value(arguments)?;
3850 board.remove_task(&call.task_id)?;
3851 format!("Removed task {}.", call.task_id)
3852 }
3853 "GetTopTaskOrphan" => {
3854 let _: EmptyCall = serde_json::from_value(arguments)?;
3855 serde_json::to_string_pretty(&board.top_orphan()?)?
3856 }
3857 _ => anyhow::bail!("Tool {name} is not a task-board operation"),
3858 };
3859 Ok(output)
3860 })
3861 .await
3862 .context("task-board worker stopped")?
3863 }
3864
3865 fn assert_tool_allowed(&self, name: &str) -> anyhow::Result<()> {
3866 let write = matches!(
3867 name,
3868 "ConnectNodes"
3869 | "ConsolidateFanout"
3870 | "SetFixedConnection"
3871 | "CreateNode"
3872 | "UpdateNode"
3873 );
3874 anyhow::ensure!(
3875 !write || !matches!(self.mode, AgentMode::Conversation),
3876 "{name} requires the global Kweb write lane and is unavailable in a read-only conversation"
3877 );
3878 if name == "EndSession" {
3879 anyhow::ensure!(
3880 !matches!(self.mode, AgentMode::Conversation),
3881 "EndSession is unavailable in a conversation"
3882 );
3883 }
3884 if name == LAUNCH_SESSION_TOOL {
3885 anyhow::ensure!(
3886 self.launch_session_authorized(),
3887 "LaunchSession is unavailable without a genuine current user turn in an eligible conversation"
3888 );
3889 }
3890 Ok(())
3891 }
3892
3893 fn sync_kweb_boxes(&mut self) -> anyhow::Result<Vec<BoxId>> {
3894 let (updates, creates) = self.plan.context_projection();
3895 self.context
3896 .sync_chatend(&mut self.journal, now(), &updates, &creates)
3897 .map_err(anyhow::Error::new)
3898 }
3899
3900 fn sync_load_kweb_boxes(&mut self) -> anyhow::Result<Vec<BoxId>> {
3901 let before = kweb_slot_box_ids(&self.journal);
3902 let (updates, creates) = self.plan.context_projection();
3903 let stale = self
3904 .context
3905 .sync_load_chatend(&mut self.journal, now(), &updates, &creates)
3906 .map_err(anyhow::Error::new)?;
3907 let after = kweb_slot_box_ids(&self.journal);
3908 Ok(load_box_changes(&before, &after, &stale))
3909 }
3910
3911 fn record_tool_invocation(
3912 &mut self,
3913 name: &str,
3914 arguments: Value,
3915 ) -> anyhow::Result<RecordedToolInvocation> {
3916 let invocation_id = Uuid::new_v4().to_string();
3917 let invocation = RecordedToolInvocation {
3918 tool_instance: tool_instance_for_invocation(name, &invocation_id),
3919 invocation_id,
3920 tool_name: name.into(),
3921 };
3922 self.journal.record(
3923 now(),
3924 EventKind::ToolInvoked {
3925 tool_instance: invocation.tool_instance.clone(),
3926 tool_name: invocation.tool_name.clone(),
3927 arguments,
3928 invocation_id: Some(invocation.invocation_id.clone()),
3929 },
3930 )?;
3931 Ok(invocation)
3932 }
3933
3934 fn record_tool_completion(
3935 &mut self,
3936 invocation: Option<&RecordedToolInvocation>,
3937 outcome: Value,
3938 ) -> anyhow::Result<EventId> {
3939 record_tool_completion_event(&mut self.journal, invocation, outcome)
3940 }
3941
3942 fn finalize_kweb_session(&mut self) -> anyhow::Result<()> {
3943 self.provider_affinity = None;
3944 self.next_thread_reset_reason = None;
3945 if self.commit_receipt.is_some() {
3946 return Ok(());
3947 }
3948 self.repair_unfinished_tools()?;
3949 self.journal.seal()?;
3950 let archive = self.journal.archive_bytes()?;
3951 let object_locations = self
3952 .journal
3953 .objects()
3954 .iter()
3955 .map(|(id, location)| (id.clone(), location.clone()))
3956 .collect::<Vec<_>>();
3957 let mut objects = BTreeMap::new();
3958 for (id, location) in object_locations {
3959 let pending_id = id.to_string();
3960 let transport_kind =
3961 kcode_kennedy_session_objects::staged_descriptor(&self.journal, &id)?
3962 .transport_kind;
3963 let bytes = encode_file(
3964 &pending_id,
3965 location.metadata.file_name.as_deref(),
3966 &location.metadata.media_type,
3967 transport_kind.as_deref(),
3968 self.journal.read_object(&id)?,
3969 )
3970 .with_context(|| format!("encoding staged object {pending_id}"))?;
3971 anyhow::ensure!(
3972 objects.insert(pending_id.clone(), bytes).is_none(),
3973 "duplicate staged object {pending_id}"
3974 );
3975 }
3976 let material = self.plan.commit_material()?;
3977 let result = self.api.commit_kweb_session(CommitRequest {
3978 idempotency_key: self.journal.state().metadata.session_id.clone(),
3979 author: self.commit_author.clone(),
3980 source_created_at: DateTime::parse_from_rfc3339(&self.started_at)
3981 .context("session start timestamp is invalid")?
3982 .with_timezone(&Utc),
3983 archive,
3984 objects,
3985 creates: material.creates,
3986 updates: material.updates,
3987 })?;
3988 self.journal
3989 .mark_completed(result.session_object_id.to_string());
3990 self.commit_receipt = Some(result);
3991 Ok(())
3992 }
3993
3994 fn prepare_free_time_round(&mut self) -> anyhow::Result<bool> {
3995 if !matches!(self.mode, AgentMode::FreeTime) {
3996 return Ok(false);
3997 }
3998 let Some(deadline) = deadline(&self.free_time) else {
3999 return Ok(false);
4000 };
4001 if Utc::now() >= deadline {
4002 self.free_time_end_reason = Some("deadline".into());
4003 self.journal.create_box(
4004 now(),
4005 "Self-time timer",
4006 BoxOwner::Controller,
4007 BoxContent::text(
4008 "The self-time deadline has arrived. Finish without starting more tool work.",
4009 ),
4010 )?;
4011 return Ok(true);
4012 }
4013 Ok(false)
4014 }
4015
4016 fn agent_request_timeout(&self) -> Option<Duration> {
4017 if matches!(self.mode, AgentMode::Conversation) && self.session_type == "conversation" {
4018 return Some(BROWSER_CONVERSATION_REQUEST_TIMEOUT);
4019 }
4020 if matches!(self.mode, AgentMode::Ingress { .. }) {
4021 return Some(HISTORY_INGRESS_REQUEST_TIMEOUT);
4022 }
4023 if matches!(self.mode, AgentMode::Wakeup) {
4024 return Some(WAKEUP_REQUEST_TIMEOUT);
4025 }
4026 if matches!(self.mode, AgentMode::FreeTime) {
4027 let deadline = deadline(&self.free_time)?;
4028 return Some(Duration::from_secs(
4029 (deadline - Utc::now()).num_seconds().max(1) as u64
4030 + SELF_TIME_HARD_STOP_ALLOWANCE.as_secs(),
4031 ));
4032 }
4033 None
4034 }
4035
4036 pub fn refresh_telegram_group_context(
4037 &mut self,
4038 group_context: &Value,
4039 current_message_id: Option<&str>,
4040 ) -> anyhow::Result<()> {
4041 if self.session_type != "telegram-group" {
4042 return Ok(());
4043 }
4044 self.invalidate_active_stepped_turn();
4045 self.channel["groupContext"] = group_context.clone();
4046 self.group_context = group_context.clone();
4047 self.journal.create_box(
4048 now(),
4049 "Telegram group update",
4050 BoxOwner::Controller,
4051 BoxContent::text(kcode_telegram_session_coordinator::format_group_context(
4052 group_context,
4053 )),
4054 )?;
4055 self.recover_context_overflow(current_message_id, &[])?;
4056 Ok(())
4057 }
4058
4059 pub fn finalize_free_time(&mut self, reason: &str) -> anyhow::Result<()> {
4060 anyhow::ensure!(
4061 matches!(reason, "tool" | "deadline" | "hard-stop" | "user-stop"),
4062 "invalid self-time completion reason"
4063 );
4064 self.invalidate_active_stepped_turn();
4065 self.free_time["sliceEndedReason"] = json!(reason);
4066 self.free_time["sliceEndedAt"] = json!(now());
4067 self.pending_turn = false;
4068 self.pending_external_event_id = None;
4069 self.clear_launch_turn_authority();
4070 Ok(())
4071 }
4072
4073 pub fn commit_current_write_session(&mut self) -> anyhow::Result<()> {
4074 anyhow::ensure!(
4075 matches!(
4076 self.mode,
4077 AgentMode::FreeTime | AgentMode::Wakeup | AgentMode::Ingress { .. }
4078 ),
4079 "a read-only conversation cannot be committed as a Kweb write session"
4080 );
4081 self.invalidate_active_stepped_turn();
4082 self.finalize_kweb_session()?;
4083 self.completed = true;
4084 Ok(())
4085 }
4086
4087 pub fn snapshot(&self) -> anyhow::Result<Value> {
4088 let projection = self.projection();
4089 let submitted = self
4090 .journal
4091 .state()
4092 .current_ingress_attempt_events()
4093 .iter()
4094 .rev()
4095 .find_map(|event| {
4096 let EventKind::ProviderInputSubmitted { round, context, .. } = &event.kind else {
4097 return None;
4098 };
4099 Some((event.recorded_at.as_str(), *round, context))
4100 });
4101 let (chatend_text, chatend_text_source, structured_material) = match submitted {
4102 Some((submitted_at, round, submitted)) => (
4103 submitted.input.clone(),
4104 "submitted",
4105 json!({
4106 "provider":submitted.provider,
4107 "model":submitted.model,
4108 "reasoningEffort":submitted.reasoning_effort,
4109 "baseInstructions":submitted.base_instructions,
4110 "developerInstructions":submitted.developer_instructions,
4111 "tools":submitted.tools,
4112 "round":round,
4113 "submittedAt":submitted_at,
4114 }),
4115 ),
4116 None => (projection.render(), "reconstructed", Value::Null),
4117 };
4118 let session_status = projection.status.clone();
4119 let completed_invocations = completed_invocation_ids(&self.journal);
4120 let launch_intents = pruned_launch_intents(
4121 &self.launch_intents,
4122 self.launch_user_turn_id,
4123 &completed_invocations,
4124 );
4125 Ok(json!({
4126 "format":"kennedy-chatend",
4127 "version":1,
4128 "stateVersion":CHECKPOINT_STATE_VERSION,
4129 "sessionId":self.journal.state().metadata.session_id,
4130 "chatendMetadata":self.journal.state().metadata,
4131 "sessionType":self.session_type,
4132 "sourceSessionType":self.source_session_type,
4133 "channel":self.channel,
4134 "freeTime":self.free_time,
4135 "orchestration":self.orchestration,
4136 "provenanceId":self.provenance_id,
4137 "launchProvenance":self.launch_provenance,
4138 "launchContextNodeIds":self.launch_context_node_ids,
4139 "launchUserTurnId":self.launch_user_turn_id,
4140 "launchIntents":launch_intents,
4141 "rustLibSessionId":self.rust_lib_session_id,
4142 "rootNodeIds":self.root_node_ids,
4143 "referenceRootNodeIds":self.reference_root_node_ids,
4144 "startedAt":self.started_at,
4145 "transcript":self.transcript,
4146 "pendingTurn":self.pending_turn,
4147 "pendingExternalEventId":self.pending_external_event_id,
4148 "roundsUsed":self.rounds_used,
4149 "providerAffinity":self.provider_affinity,
4150 "nextThreadResetReason":self.next_thread_reset_reason,
4151 "completed":self.completed,
4152 "sessionObjectId":self.journal.state().completed_session_object,
4153 "commitReceipt":self.commit_receipt,
4154 "commitAuthor":self.commit_author,
4155 "providerModel":self.runtime.model,
4156 "kwebPlan":self.plan.checkpoint_value()?,
4157 "boxCount":self.journal.state().boxes.len(),
4158 "eventCount":self.journal.state().events.len(),
4159 "boxes":self.journal.state().boxes,
4160 "events":self.journal.state().events,
4161 "context":projection,
4162 "sessionStatus":session_status,
4163 "chatendText":chatend_text,
4164 "chatendTextSource":chatend_text_source,
4165 "structuredMaterial":structured_material,
4166 }))
4167 }
4168
4169 pub async fn release_managed_sources(&self) {
4170 self.api
4171 .release_managed_sources(&self.rust_lib_session_id)
4172 .await;
4173 }
4174}
4175
4176impl<C, F> kcode_agent_runtime::SessionHost for KennedySessionHost<'_, C>
4177where
4178 C: FnMut(Value) -> F + Send,
4179 F: Future<Output = anyhow::Result<()>> + Send,
4180{
4181 fn prepare_round<'a>(
4182 &'a mut self,
4183 round: u64,
4184 ) -> kcode_agent_runtime::HostFuture<'a, kcode_agent_runtime::RoundPreparation> {
4185 Box::pin(async move {
4186 self.session.rounds_used = round;
4187 self.state.deadline_after_response = self.session.prepare_free_time_round()?;
4188 let external_event_id = self.session.pending_external_event_id.clone();
4189 if self
4190 .session
4191 .recover_context_overflow(external_event_id.as_deref(), &[])?
4192 == ContextRecovery::Irreducible
4193 || (matches!(self.session.mode, AgentMode::Ingress { .. })
4194 && self.session.ingress_force_commit_requested())
4195 {
4196 return Ok(kcode_agent_runtime::RoundPreparation::Complete(None));
4197 }
4198 let ingress_time_remaining = self.session.ingress_time_remaining()?;
4199 let timeout = self.session.agent_request_timeout();
4200 self.session.begin_provider_call_budget(timeout);
4201 let tool_description = call_ktool_description(self.session.launch_session_authorized());
4202 let material_fingerprint = self
4203 .session
4204 .provider_material_fingerprint(&tool_description);
4205 let mut thread_reset_reason = self.session.next_thread_reset_reason.take();
4206 let mut continuation = None;
4207 let mut resume_after = None;
4208 if let Some(affinity) = &self.session.provider_affinity {
4209 if affinity.material_fingerprint == material_fingerprint {
4210 continuation = Some(affinity.continuation.clone());
4211 resume_after = Some(affinity.synchronized_event_id);
4212 } else {
4213 self.session.provider_affinity = None;
4214 thread_reset_reason = Some("provider_material_changed".into());
4215 }
4216 }
4217 if continuation.is_some() {
4218 self.session.provider_affinity = None;
4219 self.session.next_thread_reset_reason =
4220 Some("prior_provider_turn_ambiguous".into());
4221 }
4222 let footer_lines = self.session.runtime_budget().footer_lines();
4223 let prepared = if let Some(remaining_seconds) = ingress_time_remaining {
4224 self.session
4225 .journal
4226 .prepare_provider_projection_with_ingress_time(
4227 now(),
4228 &footer_lines,
4229 &material_fingerprint,
4230 resume_after,
4231 remaining_seconds,
4232 self.session.previous_ingress_attempt_timed_out,
4233 )?
4234 } else {
4235 self.session.journal.prepare_provider_projection(
4236 now(),
4237 &footer_lines,
4238 &material_fingerprint,
4239 resume_after,
4240 )?
4241 };
4242 if let Some(reason) = prepared.thread_reset_reason.clone() {
4243 self.session.provider_affinity = None;
4244 continuation = None;
4245 thread_reset_reason = Some(reason);
4246 }
4247 if continuation.is_none() && thread_reset_reason.is_some() {
4248 self.session.next_thread_reset_reason = thread_reset_reason.clone();
4249 }
4250 let input = prepared.projection.render();
4251 let projection_hash = hex::encode(Sha256::digest(input.as_bytes()));
4252 let provider_input_hash =
4253 hex::encode(Sha256::digest(prepared.provider_input.as_bytes()));
4254 let provider_input_bytes = prepared.provider_input.len() as u64;
4255 let thread_action = if continuation.is_some() {
4256 "resume"
4257 } else {
4258 "start"
4259 }
4260 .to_owned();
4261 self.state.prepared_cache = Some(PreparedCacheObservation {
4262 cacheable_prefix_bytes: prepared.cacheable_prefix_bytes,
4263 expectation: prepared.expectation,
4264 material_fingerprint,
4265 projection_hash,
4266 logical_input: input.clone(),
4267 provider_input_hash,
4268 provider_input_bytes,
4269 thread_action,
4270 thread_reset_reason,
4271 estimated_input_tokens: prepared.projection.estimated_tokens,
4272 raw_estimated_input_tokens: prepared.projection.raw_estimated_tokens,
4273 provider: String::new(),
4274 model: self.session.runtime.model.clone(),
4275 });
4276 Ok(kcode_agent_runtime::RoundPreparation::Run(
4277 kcode_agent_runtime::PreparedRound {
4278 input,
4279 provider_input: prepared.provider_input,
4280 continuation,
4281 model: self.session.runtime.model.clone(),
4282 reasoning_effort: self.session.runtime.reasoning_effort.clone(),
4283 tool_description,
4284 timeout,
4285 },
4286 ))
4287 })
4288 }
4289
4290 fn record<'a>(
4291 &'a mut self,
4292 event: kcode_agent_runtime::SessionEvent,
4293 ) -> kcode_agent_runtime::HostFuture<'a, ()> {
4294 Box::pin(async move {
4295 match event {
4296 kcode_agent_runtime::SessionEvent::InferenceSubmitted {
4297 manifest_hash,
4298 model,
4299 ..
4300 } => {
4301 let prepared =
4302 self.state.prepared_cache.as_ref().context(
4303 "inference was submitted before provider context preparation",
4304 )?;
4305 anyhow::ensure!(
4306 prepared.projection_hash == manifest_hash,
4307 "provider input hash changed after context preparation"
4308 );
4309 self.state.accounting = Some(kcode_intelligence_chatend::TopLevelCall::new(
4310 manifest_hash.clone(),
4311 model,
4312 ));
4313 self.session.journal.record(
4314 now(),
4315 EventKind::InferenceSubmitted {
4316 manifest_hash,
4317 estimated_input_tokens: prepared.estimated_input_tokens,
4318 raw_estimated_input_tokens: Some(prepared.raw_estimated_input_tokens),
4319 },
4320 )?;
4321 }
4322 kcode_agent_runtime::SessionEvent::ProviderInput { round, context } => {
4323 let prepared = self
4324 .state
4325 .prepared_cache
4326 .as_ref()
4327 .context("provider context arrived before context preparation")?;
4328 anyhow::ensure!(
4329 hex::encode(Sha256::digest(context.input.as_bytes()))
4330 == prepared.provider_input_hash,
4331 "provider submitted transport input different from the prepared continuation delta"
4332 );
4333 let provider = context.provider.clone();
4334 let model = context.model.clone();
4335 let synchronized_after = self.session.journal.record(
4336 now(),
4337 EventKind::ProviderInputSubmitted {
4338 round,
4339 context: ProviderContext {
4340 input: prepared.logical_input.clone(),
4341 provider: context.provider,
4342 model: context.model,
4343 reasoning_effort: context.reasoning_effort,
4344 base_instructions: context.base_instructions,
4345 developer_instructions: context.developer_instructions,
4346 tools: context
4347 .tools
4348 .into_iter()
4349 .map(|tool| ProviderToolDefinition {
4350 name: tool.name,
4351 description: tool.description,
4352 input_schema: tool.input_schema,
4353 })
4354 .collect(),
4355 },
4356 transport_input_hash: Some(prepared.provider_input_hash.clone()),
4357 transport_input_bytes: Some(prepared.provider_input_bytes),
4358 thread_action: Some(prepared.thread_action.clone()),
4359 thread_reset_reason: prepared.thread_reset_reason.clone(),
4360 cacheable_prefix_bytes: prepared.cacheable_prefix_bytes,
4361 material_fingerprint: prepared.material_fingerprint.clone(),
4362 cache_expectation: prepared.expectation.label().into(),
4363 planned_invalidation_reason: prepared
4364 .expectation
4365 .planned_reason()
4366 .map(str::to_owned),
4367 },
4368 )?;
4369 self.state.provider_synchronized_after = Some(synchronized_after);
4370 if let Some(prepared) = self.state.prepared_cache.as_mut() {
4371 prepared.provider = provider;
4372 prepared.model = model;
4373 }
4374 }
4375 kcode_agent_runtime::SessionEvent::UsageUpdated { usage, .. } => {
4376 self.state
4377 .accounting
4378 .as_mut()
4379 .context("provider usage arrived before inference submission")?
4380 .usage_updated(&mut self.session.journal, &now(), &usage)?;
4381 }
4382 kcode_agent_runtime::SessionEvent::ProviderReceipt {
4383 usage,
4384 receipt,
4385 continuation,
4386 ..
4387 } => {
4388 self.state
4389 .accounting
4390 .take()
4391 .context("provider receipt arrived before inference submission")?
4392 .completed(&mut self.session.journal, &now(), usage.as_ref())?;
4393 let prepared = self
4394 .state
4395 .prepared_cache
4396 .take()
4397 .context("provider receipt arrived before context preparation")?;
4398 if let Some(reason) = self.state.restart_fresh_reason.take() {
4399 anyhow::ensure!(
4400 continuation.is_none(),
4401 "restart-fresh receipt unexpectedly retained a native continuation"
4402 );
4403 self.session.provider_affinity = None;
4404 self.session.next_thread_reset_reason = Some(reason);
4405 } else if let Some(continuation) = continuation {
4406 anyhow::ensure!(
4407 receipt.provider_thread_id.as_deref()
4408 == Some(continuation.thread_id.as_str()),
4409 "provider receipt thread differs from continuation state"
4410 );
4411 let synchronized_event_id = self
4412 .session
4413 .journal
4414 .state()
4415 .events
4416 .last()
4417 .context("provider completion did not create a journal event")?
4418 .id;
4419 self.session.provider_affinity = Some(ProviderAffinityState {
4420 continuation,
4421 synchronized_event_id,
4422 material_fingerprint: prepared.material_fingerprint.clone(),
4423 });
4424 self.session.next_thread_reset_reason = None;
4425 } else {
4426 self.session.provider_affinity = None;
4427 self.session.next_thread_reset_reason = Some(
4428 if prepared.thread_action == "resume" {
4429 "provider_thread_resume_unavailable"
4430 } else {
4431 "provider_continuation_unavailable"
4432 }
4433 .into(),
4434 );
4435 }
4436 log_primary_thread_observation(
4437 self.state.operation_id,
4438 self.session.rounds_used,
4439 &self.session.runtime.model,
4440 &prepared,
4441 receipt.provider_thread_id.as_deref(),
4442 usage.as_ref().map_or(0, |usage| usage.input_tokens),
4443 usage.as_ref().map_or(0, |usage| usage.cached_input_tokens),
4444 );
4445 }
4446 }
4447 let snapshot = self.session.snapshot()?;
4448 (self.checkpoint)(snapshot).await
4449 })
4450 }
4451
4452 fn execute_tool<'a>(
4453 &'a mut self,
4454 call: anyhow::Result<kcode_agent_runtime::ToolCall>,
4455 operation_id: Uuid,
4456 ) -> kcode_agent_runtime::HostFuture<'a, kcode_agent_runtime::SessionToolOutcome> {
4457 Box::pin(async move {
4458 if let Some(pending) = &self.state.pending_freeform_write {
4459 let text = format!(
4460 "{} is awaiting the complete file contents; no other Ktool can run before that output.",
4461 pending.request.write_tool()
4462 );
4463 self.session
4464 .record_tool_completion(None, json!({"ok":false,"result":text}))?;
4465 return Ok(kcode_agent_runtime::SessionToolOutcome {
4466 text,
4467 ok: false,
4468 capture: Some(json!(true)),
4469 stop: false,
4470 finish_after_round: false,
4471 emitted_response: false,
4472 });
4473 }
4474 let tool_started_at = std::time::Instant::now();
4475 let mut created_call_box_id = None;
4476 let mut recorded_invocation = None;
4477 let transcript_start = self.session.transcript.len();
4478 let mut emitted_response = false;
4479 let mut outcome = match call {
4480 Ok(call) => {
4481 let call = ToolCall {
4482 name: call.name,
4483 arguments: call.arguments,
4484 };
4485 let call_name = format!("Kennedy tool call: {}", call.name);
4486 let call_content = tool_invocation_content(&call.name, &call.arguments)?;
4487 recorded_invocation = Some(
4488 self.session
4489 .record_tool_invocation(&call.name, call.arguments.clone())?,
4490 );
4491 created_call_box_id = Some(self.session.journal.create_box(
4492 now(),
4493 call_name,
4494 BoxOwner::Kennedy,
4495 call_content,
4496 )?);
4497 let external_event_id = self.session.pending_external_event_id.clone();
4498 if self
4499 .session
4500 .recover_context_overflow(external_event_id.as_deref(), &[])?
4501 == ContextRecovery::Irreducible
4502 {
4503 ToolOutcome {
4504 text: CONTEXT_OVERFLOW_WARNING.into(),
4505 store_result: false,
4506 ok: false,
4507 end_session: false,
4508 freeform_write: None,
4509 managed_source_snapshot: None,
4510 exact_result: false,
4511 }
4512 } else if call.name == LAUNCH_SESSION_TOOL {
4513 let invocation = recorded_invocation
4514 .as_ref()
4515 .context("LaunchSession invocation was not recorded")?;
4516 let result =
4517 (|| -> anyhow::Result<(LaunchSessionArguments, LaunchIntent)> {
4518 self.session.assert_tool_allowed(LAUNCH_SESSION_TOOL)?;
4519 let arguments = decode_launch_session_arguments(&call.arguments)?;
4520 let intent =
4521 self.session.prepare_launch_intent(invocation, &arguments)?;
4522 Ok((arguments, intent))
4523 })();
4524 match result {
4525 Ok((arguments, intent)) => {
4526 (self.checkpoint)(self.session.snapshot()?).await?;
4527 match self.session.lower_launch(&intent, &arguments).await {
4528 Ok(launch) => ToolOutcome {
4529 text: launch_success_json(
4530 &launch.session_id,
4531 &launch.command_id,
4532 )?,
4533 store_result: true,
4534 ok: true,
4535 end_session: false,
4536 freeform_write: None,
4537 managed_source_snapshot: None,
4538 exact_result: true,
4539 },
4540 Err(error)
4541 if matches!(
4542 error.kind,
4543 HistoryErrorKind::InvalidInput
4544 | HistoryErrorKind::Conflict
4545 ) =>
4546 {
4547 ToolOutcome {
4548 text: format!(
4549 "LaunchSession failed: {}",
4550 error.message
4551 ),
4552 store_result: true,
4553 ok: false,
4554 end_session: false,
4555 freeform_write: None,
4556 managed_source_snapshot: None,
4557 exact_result: false,
4558 }
4559 }
4560 Err(error) => {
4561 return Err(anyhow::anyhow!(
4562 "LaunchSession remains unresolved ({}): {}",
4563 error.kind.code(),
4564 error.message
4565 ));
4566 }
4567 }
4568 }
4569 Err(error) => ToolOutcome {
4570 text: format!("LaunchSession failed: {error}"),
4571 store_result: true,
4572 ok: false,
4573 end_session: false,
4574 freeform_write: None,
4575 managed_source_snapshot: None,
4576 exact_result: false,
4577 },
4578 }
4579 } else {
4580 match self.session.execute_tool(&call, operation_id).await {
4581 Ok(outcome) => {
4582 emitted_response = call.name == "EmitObject" && outcome.ok;
4583 outcome
4584 }
4585 Err(error) => ToolOutcome {
4586 text: format!("{} failed: {error}", call.name),
4587 store_result: call.name != "LoadNodes",
4588 ok: false,
4589 end_session: false,
4590 freeform_write: None,
4591 managed_source_snapshot: None,
4592 exact_result: false,
4593 },
4594 }
4595 }
4596 }
4597 Err(error) => ToolOutcome {
4598 text: error.to_string(),
4599 store_result: true,
4600 ok: false,
4601 end_session: false,
4602 freeform_write: None,
4603 managed_source_snapshot: None,
4604 exact_result: false,
4605 },
4606 };
4607 if let Some(snapshot) = outcome.managed_source_snapshot.take() {
4608 apply_snapshot(&mut self.session.journal, &now(), snapshot)?;
4609 outcome.store_result = false;
4610 }
4611 if !outcome.exact_result {
4612 append_slow_tool_duration(&mut outcome.text, tool_started_at.elapsed());
4613 }
4614 self.state.exact_tool_result = outcome.exact_result;
4615 let capture = if let Some(request) = outcome.freeform_write.take() {
4616 self.state.pending_freeform_write = Some(PendingFreeformWrite {
4617 request,
4618 call_box_id: created_call_box_id
4619 .context("freeform write call box was not created")?,
4620 });
4621 Some(json!(true))
4622 } else {
4623 None
4624 };
4625 if outcome.store_result {
4626 outcome.text = ensure_tool_result_box(
4627 &mut self.session.journal,
4628 recorded_invocation.as_ref(),
4629 &outcome.text,
4630 outcome.ok,
4631 )?;
4632 }
4633 let external_event_id = self.session.pending_external_event_id.clone();
4634 let recovery = self
4635 .session
4636 .recover_context_overflow(external_event_id.as_deref(), &[])?;
4637 let context_warning_added =
4638 self.session.transcript[transcript_start..]
4639 .iter()
4640 .any(|entry| {
4641 entry.get("contextOverflowWarning").and_then(Value::as_bool) == Some(true)
4642 });
4643 let mut provider_text = outcome.text.clone();
4644 if !outcome.exact_result
4645 && context_warning_added
4646 && !provider_text.contains(CONTEXT_OVERFLOW_WARNING)
4647 {
4648 if !provider_text.is_empty() {
4649 provider_text.push_str("\n\n");
4650 }
4651 provider_text.push_str(CONTEXT_OVERFLOW_WARNING);
4652 }
4653 self.session.record_tool_completion(
4654 recorded_invocation.as_ref(),
4655 json!({"ok":outcome.ok,"result":outcome.text}),
4656 )?;
4657 let stop = recovery == ContextRecovery::Irreducible
4658 || (matches!(self.session.mode, AgentMode::Ingress { .. })
4659 && self.session.ingress_force_commit_requested())
4660 || (!matches!(self.session.mode, AgentMode::Ingress { .. })
4661 && self.session.journal.state().source_terminated);
4662 Ok(kcode_agent_runtime::SessionToolOutcome {
4663 text: provider_text,
4664 ok: outcome.ok,
4665 capture,
4666 stop,
4667 finish_after_round: outcome.end_session,
4668 emitted_response,
4669 })
4670 })
4671 }
4672
4673 fn prepare_provider_resume<'a>(
4674 &'a mut self,
4675 mut outcome: kcode_agent_runtime::SessionToolOutcome,
4676 ) -> kcode_agent_runtime::HostFuture<'a, kcode_agent_runtime::ProviderResume> {
4677 Box::pin(async move {
4678 if completes_before_provider_resume(&outcome) {
4679 (self.checkpoint)(self.session.snapshot()?).await?;
4680 return Ok(kcode_agent_runtime::ProviderResume::Complete(None));
4681 }
4682
4683 let ingress_time = self
4684 .session
4685 .ingress_time_remaining()?
4686 .map(|remaining| (remaining, self.session.previous_ingress_attempt_timed_out));
4687 let synchronized_after = self
4688 .state
4689 .provider_synchronized_after
4690 .context("provider resume was prepared before its input was recorded")?;
4691 let prepared = self.session.journal.prepare_provider_resume(
4692 now(),
4693 synchronized_after,
4694 ingress_time,
4695 )?;
4696 match apply_prepared_provider_resume(
4697 &mut self.session.provider_affinity,
4698 &mut self.session.next_thread_reset_reason,
4699 prepared,
4700 ) {
4701 NativeProviderResumePreparation::Continue { marker_lines } => {
4702 self.state.provider_synchronized_after = Some(
4703 self.session
4704 .journal
4705 .state()
4706 .events
4707 .last()
4708 .context("provider resume preparation left no journal event")?
4709 .id,
4710 );
4711 if self.state.exact_tool_result {
4712 self.state.exact_tool_result = false;
4713 } else {
4714 let mut footer_lines = marker_lines;
4715 footer_lines.extend(self.session.runtime_budget().footer_lines());
4716 outcome.text = provider_tool_result_with_context_footer(
4717 &footer_lines.join("\n"),
4718 &outcome.text,
4719 );
4720 }
4721 (self.checkpoint)(self.session.snapshot()?).await?;
4722 Ok(kcode_agent_runtime::ProviderResume::Continue(outcome))
4723 }
4724 NativeProviderResumePreparation::RestartFresh { reason } => {
4725 self.state.exact_tool_result = false;
4726 self.state.restart_fresh_reason = Some(reason);
4727 (self.checkpoint)(self.session.snapshot()?).await?;
4728 Ok(kcode_agent_runtime::ProviderResume::RestartFresh)
4729 }
4730 }
4731 })
4732 }
4733
4734 fn complete_capture<'a>(
4735 &'a mut self,
4736 _capture: Value,
4737 contents: String,
4738 ) -> kcode_agent_runtime::HostFuture<'a, kcode_agent_runtime::SessionControl> {
4739 Box::pin(async move {
4740 let pending = self
4741 .state
4742 .pending_freeform_write
4743 .take()
4744 .context("provider completed without a pending freeform write")?;
4745 let result_metadata = pending.request.clone();
4746 let outcome = self
4747 .session
4748 .complete_freeform_write(pending, contents)
4749 .await?;
4750 if outcome.store_result {
4751 self.session.journal.create_box(
4752 now(),
4753 "Kennedy tool result",
4754 BoxOwner::Controller,
4755 BoxContent::text(&outcome.text),
4756 )?;
4757 }
4758 self.session.journal.record(
4759 now(),
4760 EventKind::Note {
4761 label: "write_file_freeform_result".into(),
4762 value: result_metadata.result_record(outcome.ok, &outcome.text),
4763 },
4764 )?;
4765 let external_event_id = self.session.pending_external_event_id.clone();
4766 let recovery = self
4767 .session
4768 .recover_context_overflow(external_event_id.as_deref(), &[])?;
4769 let snapshot = self.session.snapshot()?;
4770 (self.checkpoint)(snapshot).await?;
4771 if recovery == ContextRecovery::Irreducible
4772 || (matches!(self.session.mode, AgentMode::Ingress { .. })
4773 && self.session.ingress_force_commit_requested())
4774 || (!matches!(self.session.mode, AgentMode::Ingress { .. })
4775 && self.session.journal.state().source_terminated)
4776 || self.state.deadline_after_response
4777 {
4778 return Ok(kcode_agent_runtime::SessionControl::Complete(None));
4779 }
4780 self.session.journal.create_box(
4781 now(),
4782 controller_box_name(&self.session.mode),
4783 BoxOwner::Controller,
4784 BoxContent::text(controller_message(
4785 &self.session.mode,
4786 &self.session.free_time,
4787 )),
4788 )?;
4789 Ok(kcode_agent_runtime::SessionControl::Continue)
4790 })
4791 }
4792
4793 fn complete_round<'a>(
4794 &'a mut self,
4795 completion: kcode_agent_runtime::RoundCompletion,
4796 ) -> kcode_agent_runtime::HostFuture<'a, kcode_agent_runtime::SessionControl> {
4797 Box::pin(async move {
4798 let answer = completion.answer.trim().to_owned();
4799 let mut completion_recovery = ContextRecovery::NotNeeded;
4800 if !answer.is_empty() {
4801 let mut content = BoxContent::text(answer.clone());
4802 if let Some(id) = &self.session.pending_external_event_id {
4803 content.metadata["externalEventId"] = json!(id);
4804 }
4805 self.session.journal.create_box(
4806 now(),
4807 "Kennedy message",
4808 BoxOwner::Kennedy,
4809 content,
4810 )?;
4811 let mut transcript = json!({"role":"kennedy","content":answer});
4812 if let Some(id) = &self.session.pending_external_event_id {
4813 transcript["externalEventId"] = json!(id);
4814 }
4815 self.session.transcript.push(transcript);
4816 self.session.synchronize_provider_known_events();
4817 let external_event_id = self.session.pending_external_event_id.clone();
4818 completion_recovery = self
4819 .session
4820 .recover_context_overflow(external_event_id.as_deref(), &[])?;
4821 }
4822 let snapshot = self.session.snapshot()?;
4823 (self.checkpoint)(snapshot).await?;
4824 if completion_recovery == ContextRecovery::Irreducible
4825 || (matches!(self.session.mode, AgentMode::Ingress { .. })
4826 && self.session.ingress_force_commit_requested())
4827 || (!matches!(self.session.mode, AgentMode::Ingress { .. })
4828 && self.session.journal.state().source_terminated)
4829 {
4830 return Ok(kcode_agent_runtime::SessionControl::Complete(None));
4831 }
4832 if completion.finish_requested || self.state.deadline_after_response {
4833 return Ok(kcode_agent_runtime::SessionControl::Complete(
4834 (!answer.is_empty()).then_some(answer),
4835 ));
4836 }
4837 if matches!(self.session.mode, AgentMode::Conversation) && !answer.is_empty() {
4838 return Ok(kcode_agent_runtime::SessionControl::Complete(Some(answer)));
4839 }
4840 if matches!(self.session.mode, AgentMode::Conversation) && completion.emitted_response {
4841 return Ok(kcode_agent_runtime::SessionControl::Complete(None));
4842 }
4843 let solo_ingress_response =
4844 matches!(self.session.mode, AgentMode::Ingress { .. }) && !answer.is_empty();
4845 anyhow::ensure!(
4846 completion.used_tool || solo_ingress_response,
4847 "provider completed without a response or tool call"
4848 );
4849 self.session.journal.create_box(
4850 now(),
4851 controller_box_name(&self.session.mode),
4852 BoxOwner::Controller,
4853 BoxContent::text(controller_message(
4854 &self.session.mode,
4855 &self.session.free_time,
4856 )),
4857 )?;
4858 Ok(kcode_agent_runtime::SessionControl::Continue)
4859 })
4860 }
4861}
4862
4863impl kcode_agent_runtime::Host for KennedySubagentHost<'_> {
4864 fn render_tool_call(&mut self, call: &kcode_agent_runtime::ToolCall) -> anyhow::Result<String> {
4865 Ok(tool_invocation_content(&call.name, &call.arguments)?.text)
4866 }
4867
4868 fn execute_tool<'a>(
4869 &'a mut self,
4870 call: kcode_agent_runtime::ToolCall,
4871 operation_id: Uuid,
4872 budget: kcode_agent_runtime::ContextBudget,
4873 ) -> kcode_agent_runtime::HostFuture<'a, kcode_agent_runtime::ToolOutcome> {
4874 Box::pin(async move {
4875 let call = ToolCall {
4876 name: call.name,
4877 arguments: call.arguments,
4878 };
4879 if let Some(reason) = subagent_unavailable_reason(&call.name) {
4880 return Ok(kcode_agent_runtime::ToolOutcome::failure(reason));
4881 }
4882 if budget.estimated_tokens() > budget.max_input_tokens() {
4883 return Ok(kcode_agent_runtime::ToolOutcome::failure(
4884 "The Ktool call was not run because its retained invocation would exceed the subagent context limit.",
4885 ));
4886 }
4887 if !subagent_managed_write_fits(&self.context, &call, &budget) {
4888 return Ok(kcode_agent_runtime::ToolOutcome::failure(
4889 "The managed-source write was not run because its resulting current state would exceed the subagent context limit.",
4890 ));
4891 }
4892
4893 let tool_started_at = std::time::Instant::now();
4894
4895 if call.name == "LoadNodes" {
4896 let Some(DecodedTool::LoadNodes(identifiers)) =
4897 decode(&call.name, &call.arguments)?
4898 else {
4899 return Ok(kcode_agent_runtime::ToolOutcome::failure(
4900 "LoadNodes did not match its tool contract.",
4901 ));
4902 };
4903 load_durable_batch(
4904 self.session.api.kmap(),
4905 self.context.kweb_mut(),
4906 &identifiers,
4907 )?;
4908 let (updates, creates) = self.session.plan.context_projection();
4909 let changes = self.context.reconcile_kweb(&updates, &creates)?;
4910 let displayed_state_keys = changes.displayed_state_keys();
4911 let mut text = if changes.is_empty() {
4912 "LoadNodes completed. The subagent Kweb projection was already current.".into()
4913 } else {
4914 changes.display_text()
4915 };
4916 append_slow_tool_duration(&mut text, tool_started_at.elapsed());
4917 return Ok(kcode_agent_runtime::ToolOutcome {
4918 text,
4919 ok: true,
4920 state_updates: changes.updates,
4921 displayed_state_keys,
4922 capture: None,
4923 });
4924 }
4925
4926 if is_kweb_mutation(&call.name) {
4927 self.session.assert_tool_allowed(&call.name)?;
4928 let decoded = decode(&call.name, &call.arguments)?
4929 .with_context(|| format!("{} did not match its tool contract", call.name))?;
4930 let prior_create_count = self.session.plan.create_count();
4931 let (mut text, referenced_pending) = execute_kweb_mutation(
4932 &call.name,
4933 decoded,
4934 self.context.kweb(),
4935 &mut self.session.plan,
4936 &mut self.session.journal,
4937 )?;
4938 self.context.include_staged_nodes(
4939 referenced_pending
4940 .into_iter()
4941 .chain(self.session.plan.pending_ids_from(prior_create_count)),
4942 );
4943 let (updates, creates) = self.session.plan.context_projection();
4944 let changes = self.context.reconcile_kweb(&updates, &creates)?;
4945 append_slow_tool_duration(&mut text, tool_started_at.elapsed());
4946 return Ok(kcode_agent_runtime::ToolOutcome {
4947 text,
4948 ok: true,
4949 state_updates: changes.updates,
4950 displayed_state_keys: Vec::new(),
4951 capture: None,
4952 });
4953 }
4954
4955 if let Some(request) = decode_freeform_write(&call.name, &call.arguments)? {
4956 if !self.context.source_is_open(request.kind(), request.name()) {
4957 return Ok(kcode_agent_runtime::ToolOutcome::failure(format!(
4958 "{} {:?} is not open in this subagent context. Call {} first.",
4959 request.kind().label(),
4960 request.name(),
4961 request.kind().open_tool()
4962 )));
4963 }
4964 let acknowledgement = request.acknowledgement();
4965 let id = Uuid::new_v4().to_string();
4966 self.captures.insert(id.clone(), request);
4967 return Ok(kcode_agent_runtime::ToolOutcome {
4968 text: acknowledgement,
4969 ok: true,
4970 state_updates: Vec::new(),
4971 displayed_state_keys: Vec::new(),
4972 capture: Some(Value::String(id)),
4973 });
4974 }
4975
4976 let mut outcome = match self.session.execute_tool(&call, operation_id).await {
4977 Ok(outcome) => outcome,
4978 Err(error) => {
4979 let mut text = format!("{} failed: {error}", call.name);
4980 append_slow_tool_duration(&mut text, tool_started_at.elapsed());
4981 return Ok(kcode_agent_runtime::ToolOutcome::failure(text));
4982 }
4983 };
4984 let displays_managed_snapshot = outcome
4985 .managed_source_snapshot
4986 .as_ref()
4987 .is_some_and(|snapshot| result_displays_snapshot(&outcome.text, snapshot));
4988 append_slow_tool_duration(&mut outcome.text, tool_started_at.elapsed());
4989 let (state_updates, displayed_state_keys) =
4990 if let Some(snapshot) = outcome.managed_source_snapshot.take() {
4991 let state = self.context.apply_source_snapshot(snapshot);
4992 let displayed = displays_managed_snapshot.then_some(state.key);
4993 (
4994 state.update.into_iter().collect(),
4995 displayed.into_iter().collect(),
4996 )
4997 } else {
4998 (Vec::new(), Vec::new())
4999 };
5000 let capture = outcome.freeform_write.take().map(|request| {
5001 let id = Uuid::new_v4().to_string();
5002 self.captures.insert(id.clone(), request);
5003 Value::String(id)
5004 });
5005 Ok(kcode_agent_runtime::ToolOutcome {
5006 text: outcome.text,
5007 ok: outcome.ok,
5008 state_updates,
5009 displayed_state_keys,
5010 capture,
5011 })
5012 })
5013 }
5014
5015 fn complete_capture<'a>(
5016 &'a mut self,
5017 capture: Value,
5018 contents: String,
5019 budget: kcode_agent_runtime::ContextBudget,
5020 ) -> kcode_agent_runtime::HostFuture<'a, kcode_agent_runtime::ToolOutcome> {
5021 Box::pin(async move {
5022 let id = capture
5023 .as_str()
5024 .context("subagent freeform capture token is invalid")?;
5025 let request = self
5026 .captures
5027 .remove(id)
5028 .context("subagent freeform capture token is unknown")?;
5029 self.session
5030 .complete_subagent_freeform_write(&mut self.context, request, contents, &budget)
5031 .await
5032 })
5033 }
5034
5035 fn record(&mut self, event: kcode_agent_runtime::AuditEvent) -> anyhow::Result<()> {
5036 kcode_intelligence_chatend::record_subagent_event(&mut self.session.journal, &now(), &event)
5037 }
5038}
5039
5040fn cost_summary(label: &str, estimated_cost_usd_nanos: u64, unpriced_calls: u64) -> String {
5041 render(RenderRequest::CostSummary {
5042 label,
5043 estimated_cost_usd_nanos,
5044 unpriced_calls,
5045 })
5046 .expect("cost-summary rendering is infallible")
5047}
5048
5049fn restore_kweb_context(journal: &HistorySession, context: &mut KwebContext) -> anyhow::Result<()> {
5050 let Some(tool) = journal.state().tools.get(KWEB_TOOL_INSTANCE) else {
5051 return Ok(());
5052 };
5053 let mut nodes = BTreeMap::new();
5054 for slot in &tool.slots {
5055 let state = journal
5056 .state()
5057 .box_state(slot.box_id)
5058 .context("Kweb slot references a missing box")?;
5059 if let Some(node) = state.canonical.content.metadata.get("storedNode") {
5060 let node = match serde_json::from_value::<KwebNode>(node.clone()) {
5061 Ok(node) => node,
5062 Err(_) => node_from_value(node).context("decoding a stored Kweb context node")?,
5063 };
5064 nodes.insert(node.id.clone(), node);
5065 }
5066 }
5067 let mut direct = journal
5068 .state()
5069 .current_ingress_attempt_events()
5070 .iter()
5071 .flat_map(|event| {
5072 let EventKind::ToolInvoked {
5073 tool_name,
5074 arguments,
5075 ..
5076 } = &event.kind
5077 else {
5078 return Vec::new();
5079 };
5080 match tool_name.as_str() {
5081 "LoadNodes" => arguments
5082 .get("identifiers")
5083 .and_then(Value::as_array)
5084 .into_iter()
5085 .flatten()
5086 .filter_map(Value::as_str)
5087 .map(str::to_owned)
5088 .collect(),
5089 "LoadNode" => arguments
5090 .get("identifier")
5091 .and_then(Value::as_str)
5092 .map(str::to_owned)
5093 .into_iter()
5094 .collect(),
5095 _ => Vec::new(),
5096 }
5097 })
5098 .collect::<Vec<_>>();
5099 if direct.is_empty() {
5100 direct = context.root_node_ids().to_vec();
5101 }
5102 context
5103 .restore(nodes.into_values(), direct)
5104 .map_err(anyhow::Error::new)
5105}
5106
5107fn session_kind(session_type: &str, mode: &AgentMode) -> SessionKind {
5108 if matches!(mode, AgentMode::Ingress { .. }) {
5109 return SessionKind::HistoryIngress;
5110 }
5111 match session_type {
5112 "conversation" => SessionKind::Conversation,
5113 "telegram" => SessionKind::Telegram,
5114 "telegram-group" => SessionKind::TelegramGroup,
5115 "free-time" => SessionKind::SelfTime,
5116 "wakeup" => SessionKind::Other("wakeup".into()),
5117 "audio" => SessionKind::AudioIngress,
5118 other => SessionKind::Other(other.into()),
5119 }
5120}
5121
5122fn tool_instance_for_invocation(name: &str, invocation_id: &str) -> String {
5123 if name == "LoadNodes" {
5124 return KWEB_TOOL_INSTANCE.into();
5125 }
5126 format!("{name}:{invocation_id}")
5127}
5128
5129fn canonical_id(value: &str) -> anyhow::Result<String> {
5130 value
5131 .parse::<NodeId>()
5132 .with_context(|| format!("{value:?} is not a canonical node ID"))?;
5133 Ok(value.into())
5134}
5135
5136fn image_extension(media_type: &str) -> &'static str {
5137 match media_type
5138 .split(';')
5139 .next()
5140 .unwrap_or(media_type)
5141 .trim()
5142 .to_ascii_lowercase()
5143 .as_str()
5144 {
5145 "image/jpeg" => "jpg",
5146 "image/webp" => "webp",
5147 _ => "png",
5148 }
5149}
5150
5151fn call_ktool_description(include_launch_session: bool) -> String {
5152 let mut description = render(RenderRequest::CallKtoolDescription)
5153 .expect("Ktool-description rendering is infallible");
5154 if include_launch_session {
5155 description.push_str(
5156 "\n\nLaunchSession is available for this genuine user turn. Call it with exactly directive (a nonblank string) and contextNodeIds (an ordered array of distinct fully loaded canonical node IDs). It launches an ordinary browser conversation and returns exactly sessionId and commandId.",
5157 );
5158 }
5159 description
5160}
5161
5162fn now() -> String {
5163 Utc::now().to_rfc3339()
5164}
5165
5166fn deadline(value: &Value) -> Option<DateTime<Utc>> {
5167 value
5168 .get("deadlineAt")
5169 .and_then(Value::as_str)
5170 .and_then(|value| DateTime::parse_from_rfc3339(value).ok())
5171 .map(|value| value.with_timezone(&Utc))
5172}
5173
5174fn remaining_until(deadline: DateTime<Utc>) -> Duration {
5175 (deadline - Utc::now()).to_std().unwrap_or(Duration::ZERO)
5176}
5177
5178fn controller_box_name(mode: &AgentMode) -> &'static str {
5179 match mode {
5180 AgentMode::Conversation => "Turn continuation",
5181 AgentMode::FreeTime => "Self-time continuation",
5182 AgentMode::Wakeup => "Wakeup continuation",
5183 AgentMode::Ingress { .. } => "History-ingress continuation",
5184 }
5185}
5186
5187fn controller_message(mode: &AgentMode, free_time: &Value) -> String {
5188 let mode = match mode {
5189 AgentMode::Conversation => "conversation",
5190 AgentMode::FreeTime => "free-time",
5191 AgentMode::Wakeup => "wakeup",
5192 AgentMode::Ingress { .. } => "ingress",
5193 };
5194 render(RenderRequest::ControllerMessage { mode, free_time })
5195 .expect("known controller modes render successfully")
5196}