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 grant_marked_user_launch_authority(
2612 user_turn_id,
2613 &mut self.orchestration,
2614 &mut self.launch_bootstrap_pending,
2615 &mut self.launch_user_turn_id,
2616 );
2617 self.prune_launch_intents();
2618 self.rounds_used = 0;
2619 turn.completed_rounds = 0;
2620 self.pending_external_event_id = external_event_id;
2621 }
2622 AdmissionKind::Source => {
2623 anyhow::ensure!(
2624 user_turn_id.is_none(),
2625 "staged Source admission has a user-turn ID"
2626 );
2627 }
2628 }
2629
2630 checkpoint(self.snapshot()?).await?;
2631 Ok(true)
2632 }
2633
2634 async fn finish_stepped_turn<C, F>(
2635 &mut self,
2636 turn: SessionTurn,
2637 result: Option<String>,
2638 checkpoint: &mut C,
2639 ) -> anyhow::Result<TurnBoundary>
2640 where
2641 C: FnMut(Value) -> F + Send,
2642 F: Future<Output = anyhow::Result<()>> + Send,
2643 {
2644 turn.lease.validate(&self.turn_lease_slot)?;
2645 self.turn_lease_slot = None;
2646 self.clear_turn_deadlines();
2647 let output = match self.mode {
2648 AgentMode::Conversation => {
2649 if self.journal.state().source_terminated {
2650 self.provider_affinity = None;
2651 self.next_thread_reset_reason = None;
2652 self.pending_turn = false;
2653 self.pending_external_event_id = None;
2654 self.clear_launch_turn_authority();
2655 checkpoint(self.snapshot()?).await?;
2656 None
2657 } else {
2658 let Some(answer) = result else {
2659 if self
2660 .pending_external_event_id
2661 .as_deref()
2662 .and_then(|id| self.answer_for_external_event(id))
2663 .is_some()
2664 {
2665 self.pending_turn = false;
2666 self.pending_external_event_id = None;
2667 self.clear_launch_turn_authority();
2668 checkpoint(self.snapshot()?).await?;
2669 return Ok(TurnBoundary::Complete(None));
2670 }
2671 anyhow::bail!(
2672 "Kennedy ended a conversational turn without an assistant response"
2673 );
2674 };
2675 self.pending_turn = false;
2676 self.pending_external_event_id = None;
2677 self.clear_launch_turn_authority();
2678 checkpoint(self.snapshot()?).await?;
2679 Some(answer)
2680 }
2681 }
2682 AgentMode::FreeTime | AgentMode::Wakeup | AgentMode::Ingress { .. } => {
2683 self.pending_turn = false;
2684 self.pending_external_event_id = None;
2685 self.clear_launch_turn_authority();
2686 self.finalize_kweb_session()?;
2687 self.completed = true;
2688 checkpoint(self.snapshot()?).await?;
2689 None
2690 }
2691 };
2692 Ok(TurnBoundary::Complete(output))
2693 }
2694
2695 pub async fn advance_pending_turn<C, F>(
2696 &mut self,
2697 mut turn: SessionTurn,
2698 checkpoint: &mut C,
2699 ) -> anyhow::Result<TurnBoundary>
2700 where
2701 C: FnMut(Value) -> F + Send,
2702 F: Future<Output = anyhow::Result<()>> + Send,
2703 {
2704 turn.lease.validate(&self.turn_lease_slot)?;
2705 anyhow::ensure!(
2706 !turn.admission_poisoned,
2707 "the stepped session turn admission handle is poisoned"
2708 );
2709 if turn.at_yielded_boundary {
2710 turn.at_yielded_boundary = false;
2711 }
2712 let result = self.advance_pending_turn_validated(turn, checkpoint).await;
2713 if result.is_err() {
2714 self.clear_turn_deadlines();
2715 }
2716 result
2717 }
2718
2719 async fn advance_pending_turn_validated<C, F>(
2720 &mut self,
2721 mut turn: SessionTurn,
2722 checkpoint: &mut C,
2723 ) -> anyhow::Result<TurnBoundary>
2724 where
2725 C: FnMut(Value) -> F + Send,
2726 F: Future<Output = anyhow::Result<()>> + Send,
2727 {
2728 if turn.completed_rounds >= turn.round_limit {
2729 let runtime = self.api.agent_runtime();
2730 let operation_id = turn.state.operation_id;
2731 let answer = {
2732 let mut host = KennedySessionHost {
2733 session: self,
2734 checkpoint,
2735 state: &mut turn.state,
2736 };
2737 runtime
2738 .run_session(
2739 kcode_agent_runtime::SessionRunRequest {
2740 user_id: turn.user_id.clone(),
2741 operation_id,
2742 rounds_used: turn.round_limit,
2743 round_limit: turn.round_limit,
2744 },
2745 &mut host,
2746 )
2747 .await?
2748 };
2749 return self.finish_stepped_turn(turn, answer, checkpoint).await;
2750 }
2751
2752 let round = turn.completed_rounds + 1;
2753 let operation_id = turn.state.operation_id;
2754 let runtime = self.api.agent_runtime();
2755 let prepared = {
2756 let mut host = KennedySessionHost {
2757 session: self,
2758 checkpoint,
2759 state: &mut turn.state,
2760 };
2761 match host.prepare_round(round).await? {
2762 kcode_agent_runtime::RoundPreparation::Run(prepared) => {
2763 let manifest_hash = hex::encode(Sha256::digest(prepared.input.as_bytes()));
2764 host.record(kcode_agent_runtime::SessionEvent::InferenceSubmitted {
2765 round,
2766 manifest_hash,
2767 model: prepared.model.clone(),
2768 })
2769 .await?;
2770 prepared
2771 }
2772 kcode_agent_runtime::RoundPreparation::Complete(answer) => {
2773 return self.finish_stepped_turn(turn, answer, checkpoint).await;
2774 }
2775 }
2776 };
2777 turn.completed_rounds = round;
2778 Ok(TurnBoundary::Await(PendingSessionInference {
2779 turn,
2780 action: Box::new(PendingInferenceAction::Start {
2781 runtime,
2782 request: kcode_agent_runtime::SessionInferenceRequest {
2783 user_id: self
2784 .root_node_ids
2785 .first()
2786 .context("session has no user root for intelligence accounting")?
2787 .clone(),
2788 operation_id,
2789 round,
2790 prepared,
2791 },
2792 }),
2793 }))
2794 }
2795
2796 pub async fn apply_inference_wake<C, F>(
2797 &mut self,
2798 wake: SessionInferenceWake,
2799 checkpoint: &mut C,
2800 ) -> anyhow::Result<TurnBoundary>
2801 where
2802 C: FnMut(Value) -> F + Send,
2803 F: Future<Output = anyhow::Result<()>> + Send,
2804 {
2805 wake.turn.lease.validate(&self.turn_lease_slot)?;
2806 let result = self.apply_inference_wake_validated(wake, checkpoint).await;
2807 if result.is_err() {
2808 self.clear_turn_deadlines();
2809 }
2810 result
2811 }
2812
2813 async fn apply_inference_wake_validated<C, F>(
2814 &mut self,
2815 wake: SessionInferenceWake,
2816 checkpoint: &mut C,
2817 ) -> anyhow::Result<TurnBoundary>
2818 where
2819 C: FnMut(Value) -> F + Send,
2820 F: Future<Output = anyhow::Result<()>> + Send,
2821 {
2822 let SessionInferenceWake { mut turn, kind } = wake;
2823 let round = turn.completed_rounds;
2824 let operation_id = turn.state.operation_id;
2825 match kind {
2826 SessionInferenceWakeKind::StartFailed(error) => {
2827 let mut host = KennedySessionHost {
2828 session: self,
2829 checkpoint,
2830 state: &mut turn.state,
2831 };
2832 if let Some(receipt) = inference_error_receipt(&error) {
2833 host.record(kcode_agent_runtime::SessionEvent::ProviderReceipt {
2834 round,
2835 usage: None,
2836 receipt: Box::new(receipt),
2837 continuation: None,
2838 })
2839 .await?;
2840 }
2841 Err(error)
2842 }
2843 SessionInferenceWakeKind::RespondFailed {
2844 mut inference,
2845 error,
2846 } => {
2847 let mut host = KennedySessionHost {
2848 session: self,
2849 checkpoint,
2850 state: &mut turn.state,
2851 };
2852 record_unavailable_inference(&mut host, &mut inference, round).await?;
2853 Err(error)
2854 }
2855 SessionInferenceWakeKind::RespondedStop { inference } => {
2856 drop(inference);
2857 self.finish_stepped_turn(turn, None, checkpoint).await
2858 }
2859 SessionInferenceWakeKind::Event {
2860 mut inference,
2861 event,
2862 } => {
2863 let event = match event {
2864 Ok(Some(event)) => event,
2865 Ok(None) => {
2866 let mut host = KennedySessionHost {
2867 session: self,
2868 checkpoint,
2869 state: &mut turn.state,
2870 };
2871 record_unavailable_inference(&mut host, &mut inference, round).await?;
2872 anyhow::bail!("provider ended without a terminal turn event");
2873 }
2874 Err(error) => {
2875 let mut host = KennedySessionHost {
2876 session: self,
2877 checkpoint,
2878 state: &mut turn.state,
2879 };
2880 if let Some(receipt) = inference_error_receipt(&error) {
2881 host.record(kcode_agent_runtime::SessionEvent::ProviderReceipt {
2882 round,
2883 usage: None,
2884 receipt: Box::new(receipt),
2885 continuation: None,
2886 })
2887 .await?;
2888 }
2889 return Err(error);
2890 }
2891 };
2892
2893 match event {
2894 kcode_agent_runtime::SessionInferenceEvent::ProviderInput { context } => {
2895 let mut host = KennedySessionHost {
2896 session: self,
2897 checkpoint,
2898 state: &mut turn.state,
2899 };
2900 host.record(kcode_agent_runtime::SessionEvent::ProviderInput {
2901 round,
2902 context,
2903 })
2904 .await?;
2905 Ok(TurnBoundary::Await(PendingSessionInference {
2906 turn,
2907 action: Box::new(PendingInferenceAction::Next { inference }),
2908 }))
2909 }
2910 kcode_agent_runtime::SessionInferenceEvent::UsageUpdated { usage } => {
2911 let mut host = KennedySessionHost {
2912 session: self,
2913 checkpoint,
2914 state: &mut turn.state,
2915 };
2916 host.record(kcode_agent_runtime::SessionEvent::UsageUpdated {
2917 round,
2918 usage,
2919 })
2920 .await?;
2921 Ok(TurnBoundary::Await(PendingSessionInference {
2922 turn,
2923 action: Box::new(PendingInferenceAction::Next { inference }),
2924 }))
2925 }
2926 kcode_agent_runtime::SessionInferenceEvent::ToolCall { call_id, call } => {
2927 turn.state.used_tool = true;
2928 let call =
2929 call.map_err(|error| anyhow::anyhow!("Invalid Ktool call: {error}"));
2930 let mut host = KennedySessionHost {
2931 session: self,
2932 checkpoint,
2933 state: &mut turn.state,
2934 };
2935 let outcome = match host.execute_tool(call, operation_id).await {
2936 Ok(outcome) => outcome,
2937 Err(error) => {
2938 record_unavailable_inference(&mut host, &mut inference, round)
2939 .await?;
2940 return Err(error);
2941 }
2942 };
2943 let resume = match host.prepare_provider_resume(outcome).await {
2944 Ok(resume) => resume,
2945 Err(error) => {
2946 record_unavailable_inference(&mut host, &mut inference, round)
2947 .await?;
2948 return Err(error);
2949 }
2950 };
2951 match resume {
2952 kcode_agent_runtime::ProviderResume::Continue(mut outcome) => {
2953 host.state.finish_requested |=
2954 outcome.ok && outcome.finish_after_round;
2955 host.state.emitted_response |=
2956 outcome.ok && outcome.emitted_response;
2957 host.state.pending_capture = outcome.capture.take();
2958 let stop = outcome.stop;
2959 let result = if outcome.ok {
2960 kcode_codex_runtime_v2::ToolResult::success(outcome.text)
2961 } else {
2962 kcode_codex_runtime_v2::ToolResult::failure(outcome.text)
2963 };
2964 Ok(TurnBoundary::Await(PendingSessionInference {
2965 turn,
2966 action: Box::new(PendingInferenceAction::Respond {
2967 inference,
2968 call_id,
2969 result,
2970 stop,
2971 }),
2972 }))
2973 }
2974 kcode_agent_runtime::ProviderResume::Complete(answer) => {
2975 record_unavailable_inference(&mut host, &mut inference, round)
2976 .await?;
2977 self.finish_stepped_turn(turn, answer, checkpoint).await
2978 }
2979 kcode_agent_runtime::ProviderResume::RestartFresh => {
2980 record_unavailable_inference(&mut host, &mut inference, round)
2981 .await?;
2982 turn.at_yielded_boundary = true;
2983 Ok(TurnBoundary::Yield(turn))
2984 }
2985 }
2986 }
2987 kcode_agent_runtime::SessionInferenceEvent::Completed {
2988 answer,
2989 usage,
2990 receipt,
2991 continuation,
2992 } => {
2993 drop(inference);
2994 let mut host = KennedySessionHost {
2995 session: self,
2996 checkpoint,
2997 state: &mut turn.state,
2998 };
2999 host.record(kcode_agent_runtime::SessionEvent::ProviderReceipt {
3000 round,
3001 usage,
3002 receipt,
3003 continuation,
3004 })
3005 .await?;
3006 let capture = host.state.pending_capture.take();
3007 let control = if let Some(capture) = capture {
3008 host.complete_capture(capture, answer).await?
3009 } else {
3010 let completion = kcode_agent_runtime::RoundCompletion {
3011 answer,
3012 used_tool: host.state.used_tool,
3013 finish_requested: host.state.finish_requested,
3014 emitted_response: host.state.emitted_response,
3015 };
3016 host.complete_round(completion).await?
3017 };
3018 host.state.used_tool = false;
3019 host.state.finish_requested = false;
3020 host.state.emitted_response = false;
3021 match control {
3022 kcode_agent_runtime::SessionControl::Continue => {
3023 turn.at_yielded_boundary = true;
3024 Ok(TurnBoundary::Yield(turn))
3025 }
3026 kcode_agent_runtime::SessionControl::Complete(answer) => {
3027 self.finish_stepped_turn(turn, answer, checkpoint).await
3028 }
3029 }
3030 }
3031 }
3032 }
3033 }
3034 }
3035
3036 pub async fn run_pending_turn<C, F>(
3037 &mut self,
3038 operation_id: Uuid,
3039 turn_deadline: Option<TurnDeadline>,
3040 mut checkpoint: C,
3041 ) -> anyhow::Result<Option<String>>
3042 where
3043 C: FnMut(Value) -> F + Send,
3044 F: Future<Output = anyhow::Result<()>> + Send,
3045 {
3046 let Some(turn) = self.begin_pending_turn(operation_id, turn_deadline)? else {
3047 return Ok(None);
3048 };
3049 let mut boundary = self.advance_pending_turn(turn, &mut checkpoint).await?;
3050 loop {
3051 boundary = match boundary {
3052 TurnBoundary::Await(pending) => {
3053 let wake = pending.wait().await;
3054 self.apply_inference_wake(wake, &mut checkpoint).await?
3055 }
3056 TurnBoundary::Yield(turn) => {
3057 self.advance_pending_turn(turn, &mut checkpoint).await?
3058 }
3059 TurnBoundary::Complete(answer) => return Ok(answer),
3060 };
3061 }
3062 }
3063
3064 fn project_descendant<T>(
3065 &mut self,
3066 outcome: Result<kcode_intelligence_router::Accounted<T>, services::ApiError>,
3067 ) -> anyhow::Result<T> {
3068 match outcome {
3069 Ok(accounted) => {
3070 kcode_intelligence_chatend::record_descendant_receipt(
3071 &mut self.journal,
3072 &accounted.receipt,
3073 )?;
3074 Ok(accounted.value)
3075 }
3076 Err(error) => {
3077 if let Some(receipt) = &error.receipt {
3078 kcode_intelligence_chatend::record_descendant_receipt(
3079 &mut self.journal,
3080 receipt,
3081 )?;
3082 }
3083 Err(error.into())
3084 }
3085 }
3086 }
3087
3088 async fn run_subagent(
3089 &mut self,
3090 model: String,
3091 reasoning_effort: Option<String>,
3092 context_node_ids: Vec<String>,
3093 task: String,
3094 parent_operation_id: Uuid,
3095 ) -> anyhow::Result<String> {
3096 let reasoning_effort =
3097 reasoning_effort.unwrap_or_else(|| self.runtime.reasoning_effort.clone());
3098 let mut selected_node_descriptions = Vec::with_capacity(context_node_ids.len());
3099 for node_id in &context_node_ids {
3100 selected_node_descriptions.push(self.api.kmap_node(node_id)?.data.long_description);
3101 }
3102 let user_id = self
3103 .root_node_ids
3104 .first()
3105 .context("session has no user root for subagent intelligence accounting")?
3106 .clone();
3107 let timeout = self.agent_request_timeout();
3108 let runtime = self.api.agent_runtime();
3109 let provider = runtime.resolve_model(&model).await?.provider;
3110 let first_event = self.journal.state().events.len();
3111 let cost_before = self.projection().status;
3112 let subagent_context = SubagentContext::new(
3113 self.root_node_ids.clone(),
3114 self.api.loads_fixed_connections(),
3115 provider,
3116 self.subagent_codex_prompt.clone(),
3117 selected_node_descriptions,
3118 )?;
3119 let initial_sections = subagent_context.initial_sections().to_vec();
3120 let result = {
3121 let mut host = KennedySubagentHost {
3122 session: self,
3123 context: subagent_context,
3124 captures: HashMap::new(),
3125 };
3126 runtime
3127 .run(
3128 kcode_agent_runtime::RunRequest {
3129 user_id,
3130 parent_operation_id,
3131 model,
3132 reasoning_effort,
3133 context: initial_sections,
3134 task,
3135 timeout,
3136 start_metadata: json!({"contextNodeIds":context_node_ids}),
3137 },
3138 &mut host,
3139 )
3140 .await
3141 };
3142 match result {
3143 Ok(result) => {
3144 let cost_after = self.projection().status;
3145 Ok(format!(
3146 "{}\n\n[{}]",
3147 result.answer,
3148 cost_summary(
3149 "subagent cost",
3150 cost_after
3151 .estimated_cost_usd_nanos
3152 .saturating_sub(cost_before.estimated_cost_usd_nanos),
3153 cost_after
3154 .unpriced_provider_calls
3155 .saturating_sub(cost_before.unpriced_provider_calls),
3156 )
3157 ))
3158 }
3159 Err(error) => {
3160 let may_have_effects =
3161 self.journal.state().events[first_event..]
3162 .iter()
3163 .any(|event| {
3164 matches!(
3165 &event.kind,
3166 EventKind::Note { label, .. } if label == "subagent_tool_call"
3167 )
3168 });
3169 if may_have_effects {
3170 Err(error.context(
3171 "the subagent failed after making Ktool calls; some tool effects may already have occurred",
3172 ))
3173 } else {
3174 Err(error)
3175 }
3176 }
3177 }
3178 }
3179
3180 async fn complete_subagent_freeform_write(
3181 &mut self,
3182 context: &mut SubagentContext,
3183 request: FreeformWrite,
3184 contents: String,
3185 budget: &kcode_agent_runtime::ContextBudget,
3186 ) -> anyhow::Result<kcode_agent_runtime::ToolOutcome> {
3187 let kind = request.kind();
3188 let freeform_tool = request.write_tool();
3189 anyhow::ensure!(
3190 context.source_is_open(kind, request.name()),
3191 "{} {:?} is not open in this subagent context. Call {} first.",
3192 kind.label(),
3193 request.name(),
3194 kind.open_tool()
3195 );
3196 let backend_arguments = request.capture_subagent(&mut self.journal, &now(), contents)?;
3197 let preview = self
3198 .api
3199 .managed_source_execute(
3200 &self.rust_lib_session_id,
3201 request.preview_tool(),
3202 backend_arguments.clone(),
3203 Vec::new(),
3204 )
3205 .await?;
3206 let preview = preview
3207 .snapshot
3208 .context("subagent freeform write preview omitted its source snapshot")?;
3209 let preview_state = context.source_state(&preview);
3210 anyhow::ensure!(
3211 budget.fits_state(preview_state.key, preview_state.text),
3212 "{freeform_tool} was not run because its resulting source state would exceed the subagent context limit"
3213 );
3214 let execution = self
3215 .api
3216 .managed_source_execute(
3217 &self.rust_lib_session_id,
3218 freeform_tool,
3219 backend_arguments,
3220 Vec::new(),
3221 )
3222 .await?;
3223 let snapshot = execution
3224 .snapshot
3225 .context("subagent freeform write omitted its resulting source snapshot")?;
3226 let state = context.apply_source_snapshot(snapshot);
3227 Ok(kcode_agent_runtime::ToolOutcome {
3228 text: execution.text,
3229 ok: true,
3230 state_updates: state.update.into_iter().collect(),
3231 displayed_state_keys: Vec::new(),
3232 capture: None,
3233 })
3234 }
3235
3236 async fn complete_freeform_write(
3237 &mut self,
3238 pending: PendingFreeformWrite,
3239 contents: String,
3240 ) -> anyhow::Result<ToolOutcome> {
3241 let request = pending.request;
3242 let freeform_tool = request.write_tool();
3243 let backend_arguments =
3244 request.capture(&mut self.journal, &now(), pending.call_box_id, contents)?;
3245 let preview_result = self
3246 .api
3247 .managed_source_execute(
3248 &self.rust_lib_session_id,
3249 request.preview_tool(),
3250 backend_arguments.clone(),
3251 Vec::new(),
3252 )
3253 .await;
3254 let preview = match preview_result {
3255 Ok(preview) => preview,
3256 Err(error) => {
3257 return Ok(ToolOutcome {
3258 text: format!("{freeform_tool} failed: {error}"),
3259 store_result: true,
3260 ok: false,
3261 end_session: false,
3262 freeform_write: None,
3263 managed_source_snapshot: None,
3264 exact_result: false,
3265 });
3266 }
3267 };
3268 let _preview = preview
3269 .snapshot
3270 .context("freeform write preview omitted the resulting source snapshot")?;
3271 request.source_box_id(&self.journal)?;
3272
3273 let execution_result = self
3274 .api
3275 .managed_source_execute(
3276 &self.rust_lib_session_id,
3277 freeform_tool,
3278 backend_arguments,
3279 Vec::new(),
3280 )
3281 .await;
3282 let execution = match execution_result {
3283 Ok(execution) => execution,
3284 Err(error) => {
3285 return Ok(ToolOutcome {
3286 text: format!("{freeform_tool} failed: {error}"),
3287 store_result: true,
3288 ok: false,
3289 end_session: false,
3290 freeform_write: None,
3291 managed_source_snapshot: None,
3292 exact_result: false,
3293 });
3294 }
3295 };
3296 let snapshot = execution
3297 .snapshot
3298 .context("freeform write omitted the resulting source snapshot")?;
3299 apply_snapshot(&mut self.journal, &now(), snapshot)?;
3300 Ok(ToolOutcome {
3301 text: execution.text,
3302 store_result: false,
3303 ok: true,
3304 end_session: false,
3305 freeform_write: None,
3306 managed_source_snapshot: None,
3307 exact_result: false,
3308 })
3309 }
3310
3311 async fn send_telegram_dm(&mut self, arguments: &Value) -> anyhow::Result<String> {
3312 let request = kcode_telegram_session_coordinator::parse_private_request(arguments)?;
3313 let attachments = self.telegram_delivery_attachments(request.attachments)?;
3314 let caller_holds_user_lock = self.session_type == "telegram"
3315 && self.channel.get("telegramUserId").and_then(Value::as_i64)
3316 == Some(request.telegram_user_id);
3317 self.api
3318 .telegram()
3319 .send_private(kcode_telegram_session_coordinator::PrivateDelivery {
3320 telegram_user_id: request.telegram_user_id,
3321 message: request.message,
3322 attachments,
3323 caller_holds_user_lock,
3324 })
3325 .await
3326 }
3327
3328 async fn send_telegram_group_message(&mut self, arguments: &Value) -> anyhow::Result<String> {
3329 let request = kcode_telegram_session_coordinator::parse_group_request(arguments)?;
3330 let attachments = self.telegram_delivery_attachments(request.attachments)?;
3331 self.api
3332 .telegram()
3333 .send_group(kcode_telegram_session_coordinator::GroupDelivery {
3334 root_node_id: request.root_node_id,
3335 message: request.message,
3336 attachments,
3337 })
3338 .await
3339 }
3340
3341 fn telegram_delivery_attachments(
3342 &mut self,
3343 requests: Vec<kcode_telegram_session_coordinator::AttachmentRequest>,
3344 ) -> anyhow::Result<Vec<kcode_telegram_session_coordinator::Attachment>> {
3345 let api = self.api.clone();
3346 kcode_kennedy_session_objects::delivery_attachments(
3347 &mut self.journal,
3348 requests,
3349 move |canonical_id| api.kmap_file(canonical_id).map_err(Into::into),
3350 )
3351 }
3352
3353 async fn execute_tool(
3354 &mut self,
3355 call: &ToolCall,
3356 operation_id: Uuid,
3357 ) -> anyhow::Result<ToolOutcome> {
3358 self.assert_tool_allowed(&call.name)?;
3359 anyhow::ensure!(
3360 call.name != LAUNCH_SESSION_TOOL,
3361 "LaunchSession requires the checkpointed launch dispatch lane"
3362 );
3363 let decoded = decode(&call.name, &call.arguments)?;
3364 let mut end_session = false;
3365 let mut store_result = true;
3366 let mut freeform_write = None;
3367 let mut managed_source_snapshot = None;
3368 let text = match (call.name.as_str(), decoded) {
3369 ("NoteToSelf", None) => {
3370 decode_note_to_self(&call.arguments)?;
3371 store_result = false;
3372 "Note saved.".into()
3373 }
3374 ("SendTelegramDM", _) => self.send_telegram_dm(&call.arguments).await?,
3375 ("SendTelegramGroupMessage", _) => {
3376 self.send_telegram_group_message(&call.arguments).await?
3377 }
3378 (
3379 "RunSubagent",
3380 Some(DecodedTool::RunSubagent {
3381 model,
3382 reasoning_effort,
3383 context_node_ids,
3384 task,
3385 }),
3386 ) => {
3387 let first_event = self.journal.state().events.len();
3388 match self
3389 .run_subagent(
3390 model,
3391 reasoning_effort,
3392 context_node_ids,
3393 task,
3394 operation_id,
3395 )
3396 .await
3397 {
3398 Ok(response) => response,
3399 Err(error) => {
3400 let may_have_effects = self.journal.state().events[first_event..]
3401 .iter()
3402 .any(|event| {
3403 matches!(
3404 &event.kind,
3405 EventKind::Note { label, .. }
3406 if label == "subagent_tool_call"
3407 )
3408 });
3409 if may_have_effects {
3410 return Err(error.context(
3411 "the subagent failed after making Ktool calls; some tool effects may already have occurred",
3412 ));
3413 }
3414 return Err(error);
3415 }
3416 }
3417 }
3418 ("EndSession", Some(DecodedTool::EndSession { message })) => {
3419 anyhow::ensure!(
3420 !matches!(self.mode, AgentMode::Conversation),
3421 "EndSession is only available during an autonomous or history-ingress session"
3422 );
3423 end_session = true;
3424 if matches!(self.mode, AgentMode::FreeTime)
3425 && let Some(message) = message.filter(|message| !message.trim().is_empty())
3426 {
3427 self.free_time["nextSessionMessage"] = json!(message);
3428 }
3429 "Session ending.".into()
3430 }
3431 ("DehydrateBoxes", Some(DecodedTool::BoxIds(ids))) => {
3432 self.journal.dehydrate_boxes(now(), &ids)?;
3433 format!(
3434 "Dehydrated boxes {}.",
3435 ids.iter()
3436 .map(ToString::to_string)
3437 .collect::<Vec<_>>()
3438 .join(", ")
3439 )
3440 }
3441 ("SummarizeBox", Some(DecodedTool::SummarizeBox { box_id, summary })) => {
3442 self.journal.summarize_box(now(), box_id, summary)?;
3443 format!("Summarized box {box_id}.")
3444 }
3445 ("HydrateBox", Some(DecodedTool::BoxId(id))) => {
3446 self.journal.rehydrate_box(now(), id)?;
3447 let external_event_id = self.pending_external_event_id.clone();
3448 match self.recover_context_overflow(external_event_id.as_deref(), &[id])? {
3449 ContextRecovery::NotNeeded => format!("Hydrated box {id}."),
3450 ContextRecovery::Recovered => {
3451 format!("Hydrated box {id}.\n\n{CONTEXT_OVERFLOW_WARNING}")
3452 }
3453 ContextRecovery::Irreducible => anyhow::bail!(CONTEXT_OVERFLOW_WARNING),
3454 }
3455 }
3456 ("BoxesIntoObjects", Some(DecodedTool::BoxIds(ids))) => {
3457 kcode_kennedy_box_text_objects::stage_box_text_objects(
3458 &mut self.journal,
3459 &ids,
3460 &now(),
3461 )?
3462 }
3463 ("LoadNodes", Some(DecodedTool::LoadNodes(identifiers))) => {
3464 load_durable_batch(self.api.kmap(), &mut self.context, &identifiers)?;
3465 let changed = self.sync_load_kweb_boxes()?;
3466 store_result = false;
3467 render_load_nodes_result(
3468 &self.journal,
3469 &changed,
3470 &self.runtime_budget().footer_lines(),
3471 )?
3472 }
3473 (
3474 "EmitObject",
3475 Some(DecodedTool::EmitObject {
3476 object_id,
3477 file_name,
3478 }),
3479 ) => {
3480 anyhow::ensure!(
3481 matches!(self.mode, AgentMode::Conversation),
3482 "EmitObject is only available in a conversation"
3483 );
3484 let object = self.resolve_object(&object_id)?;
3485 let file_name = file_name.unwrap_or_else(|| object.file_name.clone());
3486 if let Some(maximum) = self.channel.get("maxObjectBytes").and_then(Value::as_u64) {
3487 anyhow::ensure!(
3488 !object.bytes.is_empty(),
3489 "object {object_id} is empty and cannot be sent through this channel"
3490 );
3491 anyhow::ensure!(
3492 object.bytes.len() as u64 <= maximum,
3493 "object {object_id} is {} bytes, over this channel's {maximum}-byte limit",
3494 object.bytes.len()
3495 );
3496 }
3497 let descriptor = json!({
3498 "objectId":object_id,
3499 "fileName":file_name,
3500 "mediaType":object.media_type,
3501 "byteLength":object.bytes.len(),
3502 });
3503 let mut metadata = json!({
3504 "outputKind":"object",
3505 "attachments":[descriptor.clone()],
3506 });
3507 if let Some(external_event_id) = &self.pending_external_event_id {
3508 metadata["externalEventId"] = json!(external_event_id);
3509 }
3510 let content = BoxContent {
3511 text: String::new(),
3512 objects: vec![object_id.clone()],
3513 metadata,
3514 };
3515 self.journal
3516 .create_box(now(), "Kennedy message", BoxOwner::Kennedy, content)?;
3517 let mut transcript = json!({
3518 "role":"kennedy",
3519 "content":"",
3520 "objects":[object_id],
3521 "attachments":[descriptor],
3522 });
3523 if let Some(external_event_id) = &self.pending_external_event_id {
3524 transcript["externalEventId"] = json!(external_event_id);
3525 }
3526 self.transcript.push(transcript);
3527 store_result = false;
3528 "Object emitted to the user.".into()
3529 }
3530 ("WebSearch", Some(DecodedTool::WebSearch { question, model })) => {
3531 let user_id = self
3532 .root_node_ids
3533 .first()
3534 .context("session has no user root for intelligence accounting")?
3535 .clone();
3536 let outcome = self
3537 .api
3538 .search(
3539 &user_id,
3540 kcode_intelligence_router::SearchRequest {
3541 question,
3542 model,
3543 operation_id: Uuid::new_v4(),
3544 parent_operation_id: Some(operation_id),
3545 },
3546 )
3547 .await;
3548 let result = self.project_descendant(outcome)?;
3549 render_web_search_result(&result)?
3550 }
3551 ("WebFetch", Some(DecodedTool::WebFetch(url))) => {
3552 let user_id = self
3553 .root_node_ids
3554 .first()
3555 .context("session has no user root for intelligence accounting")?;
3556 let result = self
3557 .api
3558 .fetch(
3559 user_id,
3560 kcode_intelligence_router::FetchRequest {
3561 url,
3562 operation_id: Uuid::new_v4(),
3563 parent_operation_id: Some(operation_id),
3564 },
3565 )
3566 .await?;
3567 render_web_fetch_result(&result)?
3568 }
3569 ("StageTelegramGroupMedia", Some(DecodedTool::StageTelegramGroupMedia(message_id))) => {
3570 let media_ref = kcode_telegram_session_coordinator::group_media_reference(
3571 &self.group_context,
3572 message_id,
3573 )?;
3574 let chat_id = media_ref.chat_id;
3575 let api = self.api.clone();
3576 let staged = kcode_kennedy_session_objects::stage_telegram_group_media(
3577 &mut self.journal,
3578 kcode_kennedy_session_objects::TelegramStageRequest {
3579 chat_id,
3580 message_id,
3581 maximum_bytes: MAX_MEDIA_ENRICHMENT_BYTES,
3582 transport_metadata: media_ref.transport_metadata(),
3583 recorded_at: now(),
3584 },
3585 || api.telegram().group_message_media(chat_id, message_id),
3586 |media_type| {
3587 kcode_telegram_session_coordinator::group_media_file_name(
3588 &media_ref, media_type,
3589 )
3590 },
3591 )?;
3592 render(RenderRequest::StagedTelegramMedia {
3593 pending_id: &staged.descriptor.pending_id,
3594 kind: &staged.kind,
3595 file_name: &staged.descriptor.file_name,
3596 media_type: &staged.descriptor.media_type,
3597 size_bytes: staged.descriptor.size_bytes,
3598 message_id,
3599 reused: staged.reused,
3600 })?
3601 }
3602 (
3603 "TranscribeAudio",
3604 Some(DecodedTool::MediaEnrichment {
3605 object_id,
3606 model,
3607 prompt,
3608 }),
3609 ) => {
3610 let object = self.resolve_media_object(&object_id)?;
3611 validate(ValidationRequest::TranscribableAudio(&object.media_type))?;
3612 validate(ValidationRequest::TranscriptionModel(&model))?;
3613 let user_id = self
3614 .root_node_ids
3615 .first()
3616 .context("session has no user root for intelligence accounting")?
3617 .clone();
3618 let outcome = self
3619 .api
3620 .transcribe_audio(
3621 &user_id,
3622 &model,
3623 &prompt,
3624 object.bytes,
3625 object.file_name.clone(),
3626 &object.media_type,
3627 None,
3628 operation_id,
3629 )
3630 .await;
3631 let result = self.project_descendant(outcome)?;
3632 render_audio_transcription_result(
3633 &object.object_id,
3634 &object.file_name,
3635 &object.media_type,
3636 &result,
3637 )?
3638 }
3639 (
3640 "AnnotateMedia",
3641 Some(DecodedTool::MediaEnrichment {
3642 object_id,
3643 model,
3644 prompt,
3645 }),
3646 ) => {
3647 let media = self.resolve_media_object(&object_id)?;
3648 validate(ValidationRequest::Annotation {
3649 model: &model,
3650 media_type: &media.media_type,
3651 })?;
3652 let user_id = self
3653 .root_node_ids
3654 .first()
3655 .context("session has no user root for intelligence accounting")?
3656 .clone();
3657 let outcome = self
3658 .api
3659 .annotate_media(
3660 &user_id,
3661 &model,
3662 &prompt,
3663 media.bytes,
3664 media.file_name.clone(),
3665 &media.media_type,
3666 operation_id,
3667 )
3668 .await;
3669 let result = self.project_descendant(outcome)?;
3670 render_media_annotation_result(
3671 &media.object_id,
3672 &media.file_name,
3673 &media.media_type,
3674 &result,
3675 )?
3676 }
3677 (
3678 "GenerateImage",
3679 Some(DecodedTool::GenerateImage {
3680 model,
3681 prompt,
3682 reference_object_ids,
3683 }),
3684 ) => {
3685 let mut references = Vec::with_capacity(reference_object_ids.len());
3686 for object_id in &reference_object_ids {
3687 references.push(self.resolve_image_object(object_id)?);
3688 }
3689 let user_id = self
3690 .root_node_ids
3691 .first()
3692 .context("session has no user root for intelligence accounting")?
3693 .clone();
3694 let outcome = self
3695 .api
3696 .generate_image(&user_id, &model, &prompt, references, operation_id)
3697 .await;
3698 let result = self.project_descendant(outcome)?;
3699 let size = result.bytes.len();
3700 let file_name =
3701 format!("generated-image.{}", image_extension(&result.content_type));
3702 let object_id = self.api.save_generated_image(
3703 result.bytes,
3704 &file_name,
3705 &result.content_type,
3706 &result.model,
3707 )?;
3708 format!(
3709 "Generated image.\nObject: {object_id}\nFile: {file_name}\nContent type: {}\nSize: {size} bytes\nModel: {}\nUse EmitObject with {object_id} to deliver it.",
3710 result.content_type, result.model
3711 )
3712 }
3713 ("ExtractDocumentText", Some(DecodedTool::ObjectId(object_id))) => {
3714 let object = self.resolve_media_object(&object_id)?;
3715 validate(ValidationRequest::ExtractableDocument {
3716 media_type: &object.media_type,
3717 file_name: &object.file_name,
3718 })?;
3719 let result = self
3720 .api
3721 .extract_document(object.bytes, object.file_name.clone(), &object.media_type)
3722 .await?;
3723 render_document_extraction_result(&object.object_id, &object.file_name, &result)?
3724 }
3725 (name, None) if SPEECH_CLASSIFICATION_TOOLS.contains(&name) => {
3726 self.api
3727 .execute_speech_classification_tool(name, call.arguments.clone())
3728 .await?
3729 }
3730 (name, None) if TASK_BOARD_TOOLS.contains(&name) => {
3731 self.execute_task_board_tool(name, &call.arguments).await?
3732 }
3733 (name, Some(decoded)) if is_kweb_mutation(name) => {
3734 let (text, _) = execute_kweb_mutation(
3735 name,
3736 decoded,
3737 &self.context,
3738 &mut self.plan,
3739 &mut self.journal,
3740 )?;
3741 self.sync_kweb_boxes()?;
3742 text
3743 }
3744 (name, None)
3745 if RUST_LIB_TOOLS.contains(&name)
3746 || WEB_LIB_TOOLS.contains(&name)
3747 || RUST_BIN_TOOLS.contains(&name) =>
3748 {
3749 if let Some(request) = prepare_freeform_write(&self.journal, name, &call.arguments)?
3750 {
3751 store_result = false;
3752 let acknowledgement = request.acknowledgement();
3753 freeform_write = Some(request);
3754 acknowledgement
3755 } else {
3756 let object_ids = if name == CALL_RUST_BIN_TOOL {
3757 decode_managed_objects(ManagedObjectArguments::RustBinary(&call.arguments))?
3758 } else if name == ATTACH_OBJECT_WEB_LIB_TOOL {
3759 decode_managed_objects(ManagedObjectArguments::WebLibraryAttachment(
3760 &call.arguments,
3761 ))?
3762 } else {
3763 Vec::new()
3764 };
3765 let mut objects = Vec::with_capacity(object_ids.len());
3766 for object_id in object_ids {
3767 objects.push(self.resolve_object(&object_id)?.bytes);
3768 }
3769 let execution = self
3770 .api
3771 .managed_source_execute(
3772 &self.rust_lib_session_id,
3773 name,
3774 call.arguments.clone(),
3775 objects,
3776 )
3777 .await?;
3778 if let Some(snapshot) = execution.snapshot {
3779 managed_source_snapshot = Some(snapshot);
3780 store_result = false;
3781 }
3782 execution.text
3783 }
3784 }
3785 (name, Some(_)) => {
3786 anyhow::bail!("decoded contract for {name} did not match its dispatch lane")
3787 }
3788 (name, None) => anyhow::bail!("Tool {name} is not available"),
3789 };
3790 Ok(ToolOutcome {
3791 text,
3792 store_result,
3793 ok: true,
3794 end_session,
3795 freeform_write,
3796 managed_source_snapshot,
3797 exact_result: false,
3798 })
3799 }
3800
3801 async fn execute_task_board_tool(
3802 &self,
3803 name: &str,
3804 arguments: &Value,
3805 ) -> anyhow::Result<String> {
3806 let board = self
3807 .api
3808 .task_board()
3809 .context("task board is not configured")?
3810 .clone();
3811 let name = name.to_owned();
3812 let arguments = arguments.clone();
3813 let user_id = self
3814 .root_node_ids
3815 .first()
3816 .context("session has no user root for task-category lookup")?
3817 .clone();
3818 tokio::task::spawn_blocking(move || -> anyhow::Result<String> {
3819 let output = match name.as_str() {
3820 "CreateTaskCategory" => serde_json::to_string_pretty(
3821 &board.create_category(serde_json::from_value(arguments)?)?,
3822 )?,
3823 "GetTaskCategory" => {
3824 let call: CategoryCall = serde_json::from_value(arguments)?;
3825 serde_json::to_string_pretty(&board.category(
3826 &call.category_id,
3827 kcode_task_board::BrowsePage {
3828 user_id,
3829 offset: call.offset,
3830 limit: call.limit,
3831 },
3832 )?)?
3833 }
3834 "RemoveTaskCategory" => {
3835 let call: CategoryId = serde_json::from_value(arguments)?;
3836 board.remove_category(&call.category_id)?;
3837 format!("Removed category {}.", call.category_id)
3838 }
3839 "CreateTask" => serde_json::to_string_pretty(
3840 &board.create_task(serde_json::from_value(arguments)?)?,
3841 )?,
3842 "GetTask" => {
3843 let call: TaskId = serde_json::from_value(arguments)?;
3844 serde_json::to_string_pretty(&board.task(&call.task_id)?)?
3845 }
3846 "UpdateTask" => serde_json::to_string_pretty(
3847 &board.update_task(serde_json::from_value(arguments)?)?,
3848 )?,
3849 "RemoveTask" => {
3850 let call: TaskId = serde_json::from_value(arguments)?;
3851 board.remove_task(&call.task_id)?;
3852 format!("Removed task {}.", call.task_id)
3853 }
3854 "GetTopTaskOrphan" => {
3855 let _: EmptyCall = serde_json::from_value(arguments)?;
3856 serde_json::to_string_pretty(&board.top_orphan()?)?
3857 }
3858 _ => anyhow::bail!("Tool {name} is not a task-board operation"),
3859 };
3860 Ok(output)
3861 })
3862 .await
3863 .context("task-board worker stopped")?
3864 }
3865
3866 fn assert_tool_allowed(&self, name: &str) -> anyhow::Result<()> {
3867 let write = matches!(
3868 name,
3869 "ConnectNodes"
3870 | "ConsolidateFanout"
3871 | "SetFixedConnection"
3872 | "CreateNode"
3873 | "UpdateNode"
3874 );
3875 anyhow::ensure!(
3876 !write || !matches!(self.mode, AgentMode::Conversation),
3877 "{name} requires the global Kweb write lane and is unavailable in a read-only conversation"
3878 );
3879 if name == "EndSession" {
3880 anyhow::ensure!(
3881 !matches!(self.mode, AgentMode::Conversation),
3882 "EndSession is unavailable in a conversation"
3883 );
3884 }
3885 if name == LAUNCH_SESSION_TOOL {
3886 anyhow::ensure!(
3887 self.launch_session_authorized(),
3888 "LaunchSession is unavailable without a genuine current user turn in an eligible conversation"
3889 );
3890 }
3891 Ok(())
3892 }
3893
3894 fn sync_kweb_boxes(&mut self) -> anyhow::Result<Vec<BoxId>> {
3895 let (updates, creates) = self.plan.context_projection();
3896 self.context
3897 .sync_chatend(&mut self.journal, now(), &updates, &creates)
3898 .map_err(anyhow::Error::new)
3899 }
3900
3901 fn sync_load_kweb_boxes(&mut self) -> anyhow::Result<Vec<BoxId>> {
3902 let before = kweb_slot_box_ids(&self.journal);
3903 let (updates, creates) = self.plan.context_projection();
3904 let stale = self
3905 .context
3906 .sync_load_chatend(&mut self.journal, now(), &updates, &creates)
3907 .map_err(anyhow::Error::new)?;
3908 let after = kweb_slot_box_ids(&self.journal);
3909 Ok(load_box_changes(&before, &after, &stale))
3910 }
3911
3912 fn record_tool_invocation(
3913 &mut self,
3914 name: &str,
3915 arguments: Value,
3916 ) -> anyhow::Result<RecordedToolInvocation> {
3917 let invocation_id = Uuid::new_v4().to_string();
3918 let invocation = RecordedToolInvocation {
3919 tool_instance: tool_instance_for_invocation(name, &invocation_id),
3920 invocation_id,
3921 tool_name: name.into(),
3922 };
3923 self.journal.record(
3924 now(),
3925 EventKind::ToolInvoked {
3926 tool_instance: invocation.tool_instance.clone(),
3927 tool_name: invocation.tool_name.clone(),
3928 arguments,
3929 invocation_id: Some(invocation.invocation_id.clone()),
3930 },
3931 )?;
3932 Ok(invocation)
3933 }
3934
3935 fn record_tool_completion(
3936 &mut self,
3937 invocation: Option<&RecordedToolInvocation>,
3938 outcome: Value,
3939 ) -> anyhow::Result<EventId> {
3940 record_tool_completion_event(&mut self.journal, invocation, outcome)
3941 }
3942
3943 fn finalize_kweb_session(&mut self) -> anyhow::Result<()> {
3944 self.provider_affinity = None;
3945 self.next_thread_reset_reason = None;
3946 if self.commit_receipt.is_some() {
3947 return Ok(());
3948 }
3949 self.repair_unfinished_tools()?;
3950 self.journal.seal()?;
3951 let archive = self.journal.archive_bytes()?;
3952 let object_locations = self
3953 .journal
3954 .objects()
3955 .iter()
3956 .map(|(id, location)| (id.clone(), location.clone()))
3957 .collect::<Vec<_>>();
3958 let mut objects = BTreeMap::new();
3959 for (id, location) in object_locations {
3960 let pending_id = id.to_string();
3961 let transport_kind =
3962 kcode_kennedy_session_objects::staged_descriptor(&self.journal, &id)?
3963 .transport_kind;
3964 let bytes = encode_file(
3965 &pending_id,
3966 location.metadata.file_name.as_deref(),
3967 &location.metadata.media_type,
3968 transport_kind.as_deref(),
3969 self.journal.read_object(&id)?,
3970 )
3971 .with_context(|| format!("encoding staged object {pending_id}"))?;
3972 anyhow::ensure!(
3973 objects.insert(pending_id.clone(), bytes).is_none(),
3974 "duplicate staged object {pending_id}"
3975 );
3976 }
3977 let material = self.plan.commit_material()?;
3978 let result = self.api.commit_kweb_session(CommitRequest {
3979 idempotency_key: self.journal.state().metadata.session_id.clone(),
3980 author: self.commit_author.clone(),
3981 source_created_at: DateTime::parse_from_rfc3339(&self.started_at)
3982 .context("session start timestamp is invalid")?
3983 .with_timezone(&Utc),
3984 archive,
3985 objects,
3986 creates: material.creates,
3987 updates: material.updates,
3988 })?;
3989 self.journal
3990 .mark_completed(result.session_object_id.to_string());
3991 self.commit_receipt = Some(result);
3992 Ok(())
3993 }
3994
3995 fn prepare_free_time_round(&mut self) -> anyhow::Result<bool> {
3996 if !matches!(self.mode, AgentMode::FreeTime) {
3997 return Ok(false);
3998 }
3999 let Some(deadline) = deadline(&self.free_time) else {
4000 return Ok(false);
4001 };
4002 if Utc::now() >= deadline {
4003 self.free_time_end_reason = Some("deadline".into());
4004 self.journal.create_box(
4005 now(),
4006 "Self-time timer",
4007 BoxOwner::Controller,
4008 BoxContent::text(
4009 "The self-time deadline has arrived. Finish without starting more tool work.",
4010 ),
4011 )?;
4012 return Ok(true);
4013 }
4014 Ok(false)
4015 }
4016
4017 fn agent_request_timeout(&self) -> Option<Duration> {
4018 if matches!(self.mode, AgentMode::Conversation) && self.session_type == "conversation" {
4019 return Some(BROWSER_CONVERSATION_REQUEST_TIMEOUT);
4020 }
4021 if matches!(self.mode, AgentMode::Ingress { .. }) {
4022 return Some(HISTORY_INGRESS_REQUEST_TIMEOUT);
4023 }
4024 if matches!(self.mode, AgentMode::Wakeup) {
4025 return Some(WAKEUP_REQUEST_TIMEOUT);
4026 }
4027 if matches!(self.mode, AgentMode::FreeTime) {
4028 let deadline = deadline(&self.free_time)?;
4029 return Some(Duration::from_secs(
4030 (deadline - Utc::now()).num_seconds().max(1) as u64
4031 + SELF_TIME_HARD_STOP_ALLOWANCE.as_secs(),
4032 ));
4033 }
4034 None
4035 }
4036
4037 pub fn refresh_telegram_group_context(
4038 &mut self,
4039 group_context: &Value,
4040 current_message_id: Option<&str>,
4041 ) -> anyhow::Result<()> {
4042 if self.session_type != "telegram-group" {
4043 return Ok(());
4044 }
4045 self.invalidate_active_stepped_turn();
4046 self.channel["groupContext"] = group_context.clone();
4047 self.group_context = group_context.clone();
4048 self.journal.create_box(
4049 now(),
4050 "Telegram group update",
4051 BoxOwner::Controller,
4052 BoxContent::text(kcode_telegram_session_coordinator::format_group_context(
4053 group_context,
4054 )),
4055 )?;
4056 self.recover_context_overflow(current_message_id, &[])?;
4057 Ok(())
4058 }
4059
4060 pub fn finalize_free_time(&mut self, reason: &str) -> anyhow::Result<()> {
4061 anyhow::ensure!(
4062 matches!(reason, "tool" | "deadline" | "hard-stop" | "user-stop"),
4063 "invalid self-time completion reason"
4064 );
4065 self.invalidate_active_stepped_turn();
4066 self.free_time["sliceEndedReason"] = json!(reason);
4067 self.free_time["sliceEndedAt"] = json!(now());
4068 self.pending_turn = false;
4069 self.pending_external_event_id = None;
4070 self.clear_launch_turn_authority();
4071 Ok(())
4072 }
4073
4074 pub fn commit_current_write_session(&mut self) -> anyhow::Result<()> {
4075 anyhow::ensure!(
4076 matches!(
4077 self.mode,
4078 AgentMode::FreeTime | AgentMode::Wakeup | AgentMode::Ingress { .. }
4079 ),
4080 "a read-only conversation cannot be committed as a Kweb write session"
4081 );
4082 self.invalidate_active_stepped_turn();
4083 self.finalize_kweb_session()?;
4084 self.completed = true;
4085 Ok(())
4086 }
4087
4088 pub fn snapshot(&self) -> anyhow::Result<Value> {
4089 let projection = self.projection();
4090 let submitted = self
4091 .journal
4092 .state()
4093 .current_ingress_attempt_events()
4094 .iter()
4095 .rev()
4096 .find_map(|event| {
4097 let EventKind::ProviderInputSubmitted { round, context, .. } = &event.kind else {
4098 return None;
4099 };
4100 Some((event.recorded_at.as_str(), *round, context))
4101 });
4102 let (chatend_text, chatend_text_source, structured_material) = match submitted {
4103 Some((submitted_at, round, submitted)) => (
4104 submitted.input.clone(),
4105 "submitted",
4106 json!({
4107 "provider":submitted.provider,
4108 "model":submitted.model,
4109 "reasoningEffort":submitted.reasoning_effort,
4110 "baseInstructions":submitted.base_instructions,
4111 "developerInstructions":submitted.developer_instructions,
4112 "tools":submitted.tools,
4113 "round":round,
4114 "submittedAt":submitted_at,
4115 }),
4116 ),
4117 None => (projection.render(), "reconstructed", Value::Null),
4118 };
4119 let session_status = projection.status.clone();
4120 let completed_invocations = completed_invocation_ids(&self.journal);
4121 let launch_intents = pruned_launch_intents(
4122 &self.launch_intents,
4123 self.launch_user_turn_id,
4124 &completed_invocations,
4125 );
4126 Ok(json!({
4127 "format":"kennedy-chatend",
4128 "version":1,
4129 "stateVersion":CHECKPOINT_STATE_VERSION,
4130 "sessionId":self.journal.state().metadata.session_id,
4131 "chatendMetadata":self.journal.state().metadata,
4132 "sessionType":self.session_type,
4133 "sourceSessionType":self.source_session_type,
4134 "channel":self.channel,
4135 "freeTime":self.free_time,
4136 "orchestration":self.orchestration,
4137 "provenanceId":self.provenance_id,
4138 "launchProvenance":self.launch_provenance,
4139 "launchContextNodeIds":self.launch_context_node_ids,
4140 "launchUserTurnId":self.launch_user_turn_id,
4141 "launchIntents":launch_intents,
4142 "rustLibSessionId":self.rust_lib_session_id,
4143 "rootNodeIds":self.root_node_ids,
4144 "referenceRootNodeIds":self.reference_root_node_ids,
4145 "startedAt":self.started_at,
4146 "transcript":self.transcript,
4147 "pendingTurn":self.pending_turn,
4148 "pendingExternalEventId":self.pending_external_event_id,
4149 "roundsUsed":self.rounds_used,
4150 "providerAffinity":self.provider_affinity,
4151 "nextThreadResetReason":self.next_thread_reset_reason,
4152 "completed":self.completed,
4153 "sessionObjectId":self.journal.state().completed_session_object,
4154 "commitReceipt":self.commit_receipt,
4155 "commitAuthor":self.commit_author,
4156 "providerModel":self.runtime.model,
4157 "kwebPlan":self.plan.checkpoint_value()?,
4158 "boxCount":self.journal.state().boxes.len(),
4159 "eventCount":self.journal.state().events.len(),
4160 "boxes":self.journal.state().boxes,
4161 "events":self.journal.state().events,
4162 "context":projection,
4163 "sessionStatus":session_status,
4164 "chatendText":chatend_text,
4165 "chatendTextSource":chatend_text_source,
4166 "structuredMaterial":structured_material,
4167 }))
4168 }
4169
4170 pub async fn release_managed_sources(&self) {
4171 self.api
4172 .release_managed_sources(&self.rust_lib_session_id)
4173 .await;
4174 }
4175}
4176
4177impl<C, F> kcode_agent_runtime::SessionHost for KennedySessionHost<'_, C>
4178where
4179 C: FnMut(Value) -> F + Send,
4180 F: Future<Output = anyhow::Result<()>> + Send,
4181{
4182 fn prepare_round<'a>(
4183 &'a mut self,
4184 round: u64,
4185 ) -> kcode_agent_runtime::HostFuture<'a, kcode_agent_runtime::RoundPreparation> {
4186 Box::pin(async move {
4187 self.session.rounds_used = round;
4188 self.state.deadline_after_response = self.session.prepare_free_time_round()?;
4189 let external_event_id = self.session.pending_external_event_id.clone();
4190 if self
4191 .session
4192 .recover_context_overflow(external_event_id.as_deref(), &[])?
4193 == ContextRecovery::Irreducible
4194 || (matches!(self.session.mode, AgentMode::Ingress { .. })
4195 && self.session.ingress_force_commit_requested())
4196 {
4197 return Ok(kcode_agent_runtime::RoundPreparation::Complete(None));
4198 }
4199 let ingress_time_remaining = self.session.ingress_time_remaining()?;
4200 let timeout = self.session.agent_request_timeout();
4201 self.session.begin_provider_call_budget(timeout);
4202 let tool_description = call_ktool_description(self.session.launch_session_authorized());
4203 let material_fingerprint = self
4204 .session
4205 .provider_material_fingerprint(&tool_description);
4206 let mut thread_reset_reason = self.session.next_thread_reset_reason.take();
4207 let mut continuation = None;
4208 let mut resume_after = None;
4209 if let Some(affinity) = &self.session.provider_affinity {
4210 if affinity.material_fingerprint == material_fingerprint {
4211 continuation = Some(affinity.continuation.clone());
4212 resume_after = Some(affinity.synchronized_event_id);
4213 } else {
4214 self.session.provider_affinity = None;
4215 thread_reset_reason = Some("provider_material_changed".into());
4216 }
4217 }
4218 if continuation.is_some() {
4219 self.session.provider_affinity = None;
4220 self.session.next_thread_reset_reason =
4221 Some("prior_provider_turn_ambiguous".into());
4222 }
4223 let footer_lines = self.session.runtime_budget().footer_lines();
4224 let prepared = if let Some(remaining_seconds) = ingress_time_remaining {
4225 self.session
4226 .journal
4227 .prepare_provider_projection_with_ingress_time(
4228 now(),
4229 &footer_lines,
4230 &material_fingerprint,
4231 resume_after,
4232 remaining_seconds,
4233 self.session.previous_ingress_attempt_timed_out,
4234 )?
4235 } else {
4236 self.session.journal.prepare_provider_projection(
4237 now(),
4238 &footer_lines,
4239 &material_fingerprint,
4240 resume_after,
4241 )?
4242 };
4243 if let Some(reason) = prepared.thread_reset_reason.clone() {
4244 self.session.provider_affinity = None;
4245 continuation = None;
4246 thread_reset_reason = Some(reason);
4247 }
4248 if continuation.is_none() && thread_reset_reason.is_some() {
4249 self.session.next_thread_reset_reason = thread_reset_reason.clone();
4250 }
4251 let input = prepared.projection.render();
4252 let projection_hash = hex::encode(Sha256::digest(input.as_bytes()));
4253 let provider_input_hash =
4254 hex::encode(Sha256::digest(prepared.provider_input.as_bytes()));
4255 let provider_input_bytes = prepared.provider_input.len() as u64;
4256 let thread_action = if continuation.is_some() {
4257 "resume"
4258 } else {
4259 "start"
4260 }
4261 .to_owned();
4262 self.state.prepared_cache = Some(PreparedCacheObservation {
4263 cacheable_prefix_bytes: prepared.cacheable_prefix_bytes,
4264 expectation: prepared.expectation,
4265 material_fingerprint,
4266 projection_hash,
4267 logical_input: input.clone(),
4268 provider_input_hash,
4269 provider_input_bytes,
4270 thread_action,
4271 thread_reset_reason,
4272 estimated_input_tokens: prepared.projection.estimated_tokens,
4273 raw_estimated_input_tokens: prepared.projection.raw_estimated_tokens,
4274 provider: String::new(),
4275 model: self.session.runtime.model.clone(),
4276 });
4277 Ok(kcode_agent_runtime::RoundPreparation::Run(
4278 kcode_agent_runtime::PreparedRound {
4279 input,
4280 provider_input: prepared.provider_input,
4281 continuation,
4282 model: self.session.runtime.model.clone(),
4283 reasoning_effort: self.session.runtime.reasoning_effort.clone(),
4284 tool_description,
4285 timeout,
4286 },
4287 ))
4288 })
4289 }
4290
4291 fn record<'a>(
4292 &'a mut self,
4293 event: kcode_agent_runtime::SessionEvent,
4294 ) -> kcode_agent_runtime::HostFuture<'a, ()> {
4295 Box::pin(async move {
4296 match event {
4297 kcode_agent_runtime::SessionEvent::InferenceSubmitted {
4298 manifest_hash,
4299 model,
4300 ..
4301 } => {
4302 let prepared =
4303 self.state.prepared_cache.as_ref().context(
4304 "inference was submitted before provider context preparation",
4305 )?;
4306 anyhow::ensure!(
4307 prepared.projection_hash == manifest_hash,
4308 "provider input hash changed after context preparation"
4309 );
4310 self.state.accounting = Some(kcode_intelligence_chatend::TopLevelCall::new(
4311 manifest_hash.clone(),
4312 model,
4313 ));
4314 self.session.journal.record(
4315 now(),
4316 EventKind::InferenceSubmitted {
4317 manifest_hash,
4318 estimated_input_tokens: prepared.estimated_input_tokens,
4319 raw_estimated_input_tokens: Some(prepared.raw_estimated_input_tokens),
4320 },
4321 )?;
4322 }
4323 kcode_agent_runtime::SessionEvent::ProviderInput { round, context } => {
4324 let prepared = self
4325 .state
4326 .prepared_cache
4327 .as_ref()
4328 .context("provider context arrived before context preparation")?;
4329 anyhow::ensure!(
4330 hex::encode(Sha256::digest(context.input.as_bytes()))
4331 == prepared.provider_input_hash,
4332 "provider submitted transport input different from the prepared continuation delta"
4333 );
4334 let provider = context.provider.clone();
4335 let model = context.model.clone();
4336 let synchronized_after = self.session.journal.record(
4337 now(),
4338 EventKind::ProviderInputSubmitted {
4339 round,
4340 context: ProviderContext {
4341 input: prepared.logical_input.clone(),
4342 provider: context.provider,
4343 model: context.model,
4344 reasoning_effort: context.reasoning_effort,
4345 base_instructions: context.base_instructions,
4346 developer_instructions: context.developer_instructions,
4347 tools: context
4348 .tools
4349 .into_iter()
4350 .map(|tool| ProviderToolDefinition {
4351 name: tool.name,
4352 description: tool.description,
4353 input_schema: tool.input_schema,
4354 })
4355 .collect(),
4356 },
4357 transport_input_hash: Some(prepared.provider_input_hash.clone()),
4358 transport_input_bytes: Some(prepared.provider_input_bytes),
4359 thread_action: Some(prepared.thread_action.clone()),
4360 thread_reset_reason: prepared.thread_reset_reason.clone(),
4361 cacheable_prefix_bytes: prepared.cacheable_prefix_bytes,
4362 material_fingerprint: prepared.material_fingerprint.clone(),
4363 cache_expectation: prepared.expectation.label().into(),
4364 planned_invalidation_reason: prepared
4365 .expectation
4366 .planned_reason()
4367 .map(str::to_owned),
4368 },
4369 )?;
4370 self.state.provider_synchronized_after = Some(synchronized_after);
4371 if let Some(prepared) = self.state.prepared_cache.as_mut() {
4372 prepared.provider = provider;
4373 prepared.model = model;
4374 }
4375 }
4376 kcode_agent_runtime::SessionEvent::UsageUpdated { usage, .. } => {
4377 self.state
4378 .accounting
4379 .as_mut()
4380 .context("provider usage arrived before inference submission")?
4381 .usage_updated(&mut self.session.journal, &now(), &usage)?;
4382 }
4383 kcode_agent_runtime::SessionEvent::ProviderReceipt {
4384 usage,
4385 receipt,
4386 continuation,
4387 ..
4388 } => {
4389 self.state
4390 .accounting
4391 .take()
4392 .context("provider receipt arrived before inference submission")?
4393 .completed(&mut self.session.journal, &now(), usage.as_ref())?;
4394 let prepared = self
4395 .state
4396 .prepared_cache
4397 .take()
4398 .context("provider receipt arrived before context preparation")?;
4399 if let Some(reason) = self.state.restart_fresh_reason.take() {
4400 anyhow::ensure!(
4401 continuation.is_none(),
4402 "restart-fresh receipt unexpectedly retained a native continuation"
4403 );
4404 self.session.provider_affinity = None;
4405 self.session.next_thread_reset_reason = Some(reason);
4406 } else if let Some(continuation) = continuation {
4407 anyhow::ensure!(
4408 receipt.provider_thread_id.as_deref()
4409 == Some(continuation.thread_id.as_str()),
4410 "provider receipt thread differs from continuation state"
4411 );
4412 let synchronized_event_id = self
4413 .session
4414 .journal
4415 .state()
4416 .events
4417 .last()
4418 .context("provider completion did not create a journal event")?
4419 .id;
4420 self.session.provider_affinity = Some(ProviderAffinityState {
4421 continuation,
4422 synchronized_event_id,
4423 material_fingerprint: prepared.material_fingerprint.clone(),
4424 });
4425 self.session.next_thread_reset_reason = None;
4426 } else {
4427 self.session.provider_affinity = None;
4428 self.session.next_thread_reset_reason = Some(
4429 if prepared.thread_action == "resume" {
4430 "provider_thread_resume_unavailable"
4431 } else {
4432 "provider_continuation_unavailable"
4433 }
4434 .into(),
4435 );
4436 }
4437 log_primary_thread_observation(
4438 self.state.operation_id,
4439 self.session.rounds_used,
4440 &self.session.runtime.model,
4441 &prepared,
4442 receipt.provider_thread_id.as_deref(),
4443 usage.as_ref().map_or(0, |usage| usage.input_tokens),
4444 usage.as_ref().map_or(0, |usage| usage.cached_input_tokens),
4445 );
4446 }
4447 }
4448 let snapshot = self.session.snapshot()?;
4449 (self.checkpoint)(snapshot).await
4450 })
4451 }
4452
4453 fn execute_tool<'a>(
4454 &'a mut self,
4455 call: anyhow::Result<kcode_agent_runtime::ToolCall>,
4456 operation_id: Uuid,
4457 ) -> kcode_agent_runtime::HostFuture<'a, kcode_agent_runtime::SessionToolOutcome> {
4458 Box::pin(async move {
4459 if let Some(pending) = &self.state.pending_freeform_write {
4460 let text = format!(
4461 "{} is awaiting the complete file contents; no other Ktool can run before that output.",
4462 pending.request.write_tool()
4463 );
4464 self.session
4465 .record_tool_completion(None, json!({"ok":false,"result":text}))?;
4466 return Ok(kcode_agent_runtime::SessionToolOutcome {
4467 text,
4468 ok: false,
4469 capture: Some(json!(true)),
4470 stop: false,
4471 finish_after_round: false,
4472 emitted_response: false,
4473 });
4474 }
4475 let tool_started_at = std::time::Instant::now();
4476 let mut created_call_box_id = None;
4477 let mut recorded_invocation = None;
4478 let transcript_start = self.session.transcript.len();
4479 let mut emitted_response = false;
4480 let mut outcome = match call {
4481 Ok(call) => {
4482 let call = ToolCall {
4483 name: call.name,
4484 arguments: call.arguments,
4485 };
4486 let call_name = format!("Kennedy tool call: {}", call.name);
4487 let call_content = tool_invocation_content(&call.name, &call.arguments)?;
4488 recorded_invocation = Some(
4489 self.session
4490 .record_tool_invocation(&call.name, call.arguments.clone())?,
4491 );
4492 created_call_box_id = Some(self.session.journal.create_box(
4493 now(),
4494 call_name,
4495 BoxOwner::Kennedy,
4496 call_content,
4497 )?);
4498 let external_event_id = self.session.pending_external_event_id.clone();
4499 if self
4500 .session
4501 .recover_context_overflow(external_event_id.as_deref(), &[])?
4502 == ContextRecovery::Irreducible
4503 {
4504 ToolOutcome {
4505 text: CONTEXT_OVERFLOW_WARNING.into(),
4506 store_result: false,
4507 ok: false,
4508 end_session: false,
4509 freeform_write: None,
4510 managed_source_snapshot: None,
4511 exact_result: false,
4512 }
4513 } else if call.name == LAUNCH_SESSION_TOOL {
4514 let invocation = recorded_invocation
4515 .as_ref()
4516 .context("LaunchSession invocation was not recorded")?;
4517 let result =
4518 (|| -> anyhow::Result<(LaunchSessionArguments, LaunchIntent)> {
4519 self.session.assert_tool_allowed(LAUNCH_SESSION_TOOL)?;
4520 let arguments = decode_launch_session_arguments(&call.arguments)?;
4521 let intent =
4522 self.session.prepare_launch_intent(invocation, &arguments)?;
4523 Ok((arguments, intent))
4524 })();
4525 match result {
4526 Ok((arguments, intent)) => {
4527 (self.checkpoint)(self.session.snapshot()?).await?;
4528 match self.session.lower_launch(&intent, &arguments).await {
4529 Ok(launch) => ToolOutcome {
4530 text: launch_success_json(
4531 &launch.session_id,
4532 &launch.command_id,
4533 )?,
4534 store_result: true,
4535 ok: true,
4536 end_session: false,
4537 freeform_write: None,
4538 managed_source_snapshot: None,
4539 exact_result: true,
4540 },
4541 Err(error)
4542 if matches!(
4543 error.kind,
4544 HistoryErrorKind::InvalidInput
4545 | HistoryErrorKind::Conflict
4546 ) =>
4547 {
4548 ToolOutcome {
4549 text: format!(
4550 "LaunchSession failed: {}",
4551 error.message
4552 ),
4553 store_result: true,
4554 ok: false,
4555 end_session: false,
4556 freeform_write: None,
4557 managed_source_snapshot: None,
4558 exact_result: false,
4559 }
4560 }
4561 Err(error) => {
4562 return Err(anyhow::anyhow!(
4563 "LaunchSession remains unresolved ({}): {}",
4564 error.kind.code(),
4565 error.message
4566 ));
4567 }
4568 }
4569 }
4570 Err(error) => ToolOutcome {
4571 text: format!("LaunchSession failed: {error}"),
4572 store_result: true,
4573 ok: false,
4574 end_session: false,
4575 freeform_write: None,
4576 managed_source_snapshot: None,
4577 exact_result: false,
4578 },
4579 }
4580 } else {
4581 match self.session.execute_tool(&call, operation_id).await {
4582 Ok(outcome) => {
4583 emitted_response = call.name == "EmitObject" && outcome.ok;
4584 outcome
4585 }
4586 Err(error) => ToolOutcome {
4587 text: format!("{} failed: {error}", call.name),
4588 store_result: call.name != "LoadNodes",
4589 ok: false,
4590 end_session: false,
4591 freeform_write: None,
4592 managed_source_snapshot: None,
4593 exact_result: false,
4594 },
4595 }
4596 }
4597 }
4598 Err(error) => ToolOutcome {
4599 text: error.to_string(),
4600 store_result: true,
4601 ok: false,
4602 end_session: false,
4603 freeform_write: None,
4604 managed_source_snapshot: None,
4605 exact_result: false,
4606 },
4607 };
4608 if let Some(snapshot) = outcome.managed_source_snapshot.take() {
4609 apply_snapshot(&mut self.session.journal, &now(), snapshot)?;
4610 outcome.store_result = false;
4611 }
4612 if !outcome.exact_result {
4613 append_slow_tool_duration(&mut outcome.text, tool_started_at.elapsed());
4614 }
4615 self.state.exact_tool_result = outcome.exact_result;
4616 let capture = if let Some(request) = outcome.freeform_write.take() {
4617 self.state.pending_freeform_write = Some(PendingFreeformWrite {
4618 request,
4619 call_box_id: created_call_box_id
4620 .context("freeform write call box was not created")?,
4621 });
4622 Some(json!(true))
4623 } else {
4624 None
4625 };
4626 if outcome.store_result {
4627 outcome.text = ensure_tool_result_box(
4628 &mut self.session.journal,
4629 recorded_invocation.as_ref(),
4630 &outcome.text,
4631 outcome.ok,
4632 )?;
4633 }
4634 let external_event_id = self.session.pending_external_event_id.clone();
4635 let recovery = self
4636 .session
4637 .recover_context_overflow(external_event_id.as_deref(), &[])?;
4638 let context_warning_added =
4639 self.session.transcript[transcript_start..]
4640 .iter()
4641 .any(|entry| {
4642 entry.get("contextOverflowWarning").and_then(Value::as_bool) == Some(true)
4643 });
4644 let mut provider_text = outcome.text.clone();
4645 if !outcome.exact_result
4646 && context_warning_added
4647 && !provider_text.contains(CONTEXT_OVERFLOW_WARNING)
4648 {
4649 if !provider_text.is_empty() {
4650 provider_text.push_str("\n\n");
4651 }
4652 provider_text.push_str(CONTEXT_OVERFLOW_WARNING);
4653 }
4654 self.session.record_tool_completion(
4655 recorded_invocation.as_ref(),
4656 json!({"ok":outcome.ok,"result":outcome.text}),
4657 )?;
4658 let stop = recovery == ContextRecovery::Irreducible
4659 || (matches!(self.session.mode, AgentMode::Ingress { .. })
4660 && self.session.ingress_force_commit_requested())
4661 || (!matches!(self.session.mode, AgentMode::Ingress { .. })
4662 && self.session.journal.state().source_terminated);
4663 Ok(kcode_agent_runtime::SessionToolOutcome {
4664 text: provider_text,
4665 ok: outcome.ok,
4666 capture,
4667 stop,
4668 finish_after_round: outcome.end_session,
4669 emitted_response,
4670 })
4671 })
4672 }
4673
4674 fn prepare_provider_resume<'a>(
4675 &'a mut self,
4676 mut outcome: kcode_agent_runtime::SessionToolOutcome,
4677 ) -> kcode_agent_runtime::HostFuture<'a, kcode_agent_runtime::ProviderResume> {
4678 Box::pin(async move {
4679 if completes_before_provider_resume(&outcome) {
4680 (self.checkpoint)(self.session.snapshot()?).await?;
4681 return Ok(kcode_agent_runtime::ProviderResume::Complete(None));
4682 }
4683
4684 let ingress_time = self
4685 .session
4686 .ingress_time_remaining()?
4687 .map(|remaining| (remaining, self.session.previous_ingress_attempt_timed_out));
4688 let synchronized_after = self
4689 .state
4690 .provider_synchronized_after
4691 .context("provider resume was prepared before its input was recorded")?;
4692 let prepared = self.session.journal.prepare_provider_resume(
4693 now(),
4694 synchronized_after,
4695 ingress_time,
4696 )?;
4697 match apply_prepared_provider_resume(
4698 &mut self.session.provider_affinity,
4699 &mut self.session.next_thread_reset_reason,
4700 prepared,
4701 ) {
4702 NativeProviderResumePreparation::Continue { marker_lines } => {
4703 self.state.provider_synchronized_after = Some(
4704 self.session
4705 .journal
4706 .state()
4707 .events
4708 .last()
4709 .context("provider resume preparation left no journal event")?
4710 .id,
4711 );
4712 if self.state.exact_tool_result {
4713 self.state.exact_tool_result = false;
4714 } else {
4715 let mut footer_lines = marker_lines;
4716 footer_lines.extend(self.session.runtime_budget().footer_lines());
4717 outcome.text = provider_tool_result_with_context_footer(
4718 &footer_lines.join("\n"),
4719 &outcome.text,
4720 );
4721 }
4722 (self.checkpoint)(self.session.snapshot()?).await?;
4723 Ok(kcode_agent_runtime::ProviderResume::Continue(outcome))
4724 }
4725 NativeProviderResumePreparation::RestartFresh { reason } => {
4726 self.state.exact_tool_result = false;
4727 self.state.restart_fresh_reason = Some(reason);
4728 (self.checkpoint)(self.session.snapshot()?).await?;
4729 Ok(kcode_agent_runtime::ProviderResume::RestartFresh)
4730 }
4731 }
4732 })
4733 }
4734
4735 fn complete_capture<'a>(
4736 &'a mut self,
4737 _capture: Value,
4738 contents: String,
4739 ) -> kcode_agent_runtime::HostFuture<'a, kcode_agent_runtime::SessionControl> {
4740 Box::pin(async move {
4741 let pending = self
4742 .state
4743 .pending_freeform_write
4744 .take()
4745 .context("provider completed without a pending freeform write")?;
4746 let result_metadata = pending.request.clone();
4747 let outcome = self
4748 .session
4749 .complete_freeform_write(pending, contents)
4750 .await?;
4751 if outcome.store_result {
4752 self.session.journal.create_box(
4753 now(),
4754 "Kennedy tool result",
4755 BoxOwner::Controller,
4756 BoxContent::text(&outcome.text),
4757 )?;
4758 }
4759 self.session.journal.record(
4760 now(),
4761 EventKind::Note {
4762 label: "write_file_freeform_result".into(),
4763 value: result_metadata.result_record(outcome.ok, &outcome.text),
4764 },
4765 )?;
4766 let external_event_id = self.session.pending_external_event_id.clone();
4767 let recovery = self
4768 .session
4769 .recover_context_overflow(external_event_id.as_deref(), &[])?;
4770 let snapshot = self.session.snapshot()?;
4771 (self.checkpoint)(snapshot).await?;
4772 if recovery == ContextRecovery::Irreducible
4773 || (matches!(self.session.mode, AgentMode::Ingress { .. })
4774 && self.session.ingress_force_commit_requested())
4775 || (!matches!(self.session.mode, AgentMode::Ingress { .. })
4776 && self.session.journal.state().source_terminated)
4777 || self.state.deadline_after_response
4778 {
4779 return Ok(kcode_agent_runtime::SessionControl::Complete(None));
4780 }
4781 self.session.journal.create_box(
4782 now(),
4783 controller_box_name(&self.session.mode),
4784 BoxOwner::Controller,
4785 BoxContent::text(controller_message(
4786 &self.session.mode,
4787 &self.session.free_time,
4788 )),
4789 )?;
4790 Ok(kcode_agent_runtime::SessionControl::Continue)
4791 })
4792 }
4793
4794 fn complete_round<'a>(
4795 &'a mut self,
4796 completion: kcode_agent_runtime::RoundCompletion,
4797 ) -> kcode_agent_runtime::HostFuture<'a, kcode_agent_runtime::SessionControl> {
4798 Box::pin(async move {
4799 let answer = completion.answer.trim().to_owned();
4800 let mut completion_recovery = ContextRecovery::NotNeeded;
4801 if !answer.is_empty() {
4802 let mut content = BoxContent::text(answer.clone());
4803 if let Some(id) = &self.session.pending_external_event_id {
4804 content.metadata["externalEventId"] = json!(id);
4805 }
4806 self.session.journal.create_box(
4807 now(),
4808 "Kennedy message",
4809 BoxOwner::Kennedy,
4810 content,
4811 )?;
4812 let mut transcript = json!({"role":"kennedy","content":answer});
4813 if let Some(id) = &self.session.pending_external_event_id {
4814 transcript["externalEventId"] = json!(id);
4815 }
4816 self.session.transcript.push(transcript);
4817 self.session.synchronize_provider_known_events();
4818 let external_event_id = self.session.pending_external_event_id.clone();
4819 completion_recovery = self
4820 .session
4821 .recover_context_overflow(external_event_id.as_deref(), &[])?;
4822 }
4823 let snapshot = self.session.snapshot()?;
4824 (self.checkpoint)(snapshot).await?;
4825 if completion_recovery == ContextRecovery::Irreducible
4826 || (matches!(self.session.mode, AgentMode::Ingress { .. })
4827 && self.session.ingress_force_commit_requested())
4828 || (!matches!(self.session.mode, AgentMode::Ingress { .. })
4829 && self.session.journal.state().source_terminated)
4830 {
4831 return Ok(kcode_agent_runtime::SessionControl::Complete(None));
4832 }
4833 if completion.finish_requested || self.state.deadline_after_response {
4834 return Ok(kcode_agent_runtime::SessionControl::Complete(
4835 (!answer.is_empty()).then_some(answer),
4836 ));
4837 }
4838 if matches!(self.session.mode, AgentMode::Conversation) && !answer.is_empty() {
4839 return Ok(kcode_agent_runtime::SessionControl::Complete(Some(answer)));
4840 }
4841 if matches!(self.session.mode, AgentMode::Conversation) && completion.emitted_response {
4842 return Ok(kcode_agent_runtime::SessionControl::Complete(None));
4843 }
4844 let solo_ingress_response =
4845 matches!(self.session.mode, AgentMode::Ingress { .. }) && !answer.is_empty();
4846 anyhow::ensure!(
4847 completion.used_tool || solo_ingress_response,
4848 "provider completed without a response or tool call"
4849 );
4850 self.session.journal.create_box(
4851 now(),
4852 controller_box_name(&self.session.mode),
4853 BoxOwner::Controller,
4854 BoxContent::text(controller_message(
4855 &self.session.mode,
4856 &self.session.free_time,
4857 )),
4858 )?;
4859 Ok(kcode_agent_runtime::SessionControl::Continue)
4860 })
4861 }
4862}
4863
4864impl kcode_agent_runtime::Host for KennedySubagentHost<'_> {
4865 fn render_tool_call(&mut self, call: &kcode_agent_runtime::ToolCall) -> anyhow::Result<String> {
4866 Ok(tool_invocation_content(&call.name, &call.arguments)?.text)
4867 }
4868
4869 fn execute_tool<'a>(
4870 &'a mut self,
4871 call: kcode_agent_runtime::ToolCall,
4872 operation_id: Uuid,
4873 budget: kcode_agent_runtime::ContextBudget,
4874 ) -> kcode_agent_runtime::HostFuture<'a, kcode_agent_runtime::ToolOutcome> {
4875 Box::pin(async move {
4876 let call = ToolCall {
4877 name: call.name,
4878 arguments: call.arguments,
4879 };
4880 if let Some(reason) = subagent_unavailable_reason(&call.name) {
4881 return Ok(kcode_agent_runtime::ToolOutcome::failure(reason));
4882 }
4883 if budget.estimated_tokens() > budget.max_input_tokens() {
4884 return Ok(kcode_agent_runtime::ToolOutcome::failure(
4885 "The Ktool call was not run because its retained invocation would exceed the subagent context limit.",
4886 ));
4887 }
4888 if !subagent_managed_write_fits(&self.context, &call, &budget) {
4889 return Ok(kcode_agent_runtime::ToolOutcome::failure(
4890 "The managed-source write was not run because its resulting current state would exceed the subagent context limit.",
4891 ));
4892 }
4893
4894 let tool_started_at = std::time::Instant::now();
4895
4896 if call.name == "LoadNodes" {
4897 let Some(DecodedTool::LoadNodes(identifiers)) =
4898 decode(&call.name, &call.arguments)?
4899 else {
4900 return Ok(kcode_agent_runtime::ToolOutcome::failure(
4901 "LoadNodes did not match its tool contract.",
4902 ));
4903 };
4904 load_durable_batch(
4905 self.session.api.kmap(),
4906 self.context.kweb_mut(),
4907 &identifiers,
4908 )?;
4909 let (updates, creates) = self.session.plan.context_projection();
4910 let changes = self.context.reconcile_kweb(&updates, &creates)?;
4911 let displayed_state_keys = changes.displayed_state_keys();
4912 let mut text = if changes.is_empty() {
4913 "LoadNodes completed. The subagent Kweb projection was already current.".into()
4914 } else {
4915 changes.display_text()
4916 };
4917 append_slow_tool_duration(&mut text, tool_started_at.elapsed());
4918 return Ok(kcode_agent_runtime::ToolOutcome {
4919 text,
4920 ok: true,
4921 state_updates: changes.updates,
4922 displayed_state_keys,
4923 capture: None,
4924 });
4925 }
4926
4927 if is_kweb_mutation(&call.name) {
4928 self.session.assert_tool_allowed(&call.name)?;
4929 let decoded = decode(&call.name, &call.arguments)?
4930 .with_context(|| format!("{} did not match its tool contract", call.name))?;
4931 let prior_create_count = self.session.plan.create_count();
4932 let (mut text, referenced_pending) = execute_kweb_mutation(
4933 &call.name,
4934 decoded,
4935 self.context.kweb(),
4936 &mut self.session.plan,
4937 &mut self.session.journal,
4938 )?;
4939 self.context.include_staged_nodes(
4940 referenced_pending
4941 .into_iter()
4942 .chain(self.session.plan.pending_ids_from(prior_create_count)),
4943 );
4944 let (updates, creates) = self.session.plan.context_projection();
4945 let changes = self.context.reconcile_kweb(&updates, &creates)?;
4946 append_slow_tool_duration(&mut text, tool_started_at.elapsed());
4947 return Ok(kcode_agent_runtime::ToolOutcome {
4948 text,
4949 ok: true,
4950 state_updates: changes.updates,
4951 displayed_state_keys: Vec::new(),
4952 capture: None,
4953 });
4954 }
4955
4956 if let Some(request) = decode_freeform_write(&call.name, &call.arguments)? {
4957 if !self.context.source_is_open(request.kind(), request.name()) {
4958 return Ok(kcode_agent_runtime::ToolOutcome::failure(format!(
4959 "{} {:?} is not open in this subagent context. Call {} first.",
4960 request.kind().label(),
4961 request.name(),
4962 request.kind().open_tool()
4963 )));
4964 }
4965 let acknowledgement = request.acknowledgement();
4966 let id = Uuid::new_v4().to_string();
4967 self.captures.insert(id.clone(), request);
4968 return Ok(kcode_agent_runtime::ToolOutcome {
4969 text: acknowledgement,
4970 ok: true,
4971 state_updates: Vec::new(),
4972 displayed_state_keys: Vec::new(),
4973 capture: Some(Value::String(id)),
4974 });
4975 }
4976
4977 let mut outcome = match self.session.execute_tool(&call, operation_id).await {
4978 Ok(outcome) => outcome,
4979 Err(error) => {
4980 let mut text = format!("{} failed: {error}", call.name);
4981 append_slow_tool_duration(&mut text, tool_started_at.elapsed());
4982 return Ok(kcode_agent_runtime::ToolOutcome::failure(text));
4983 }
4984 };
4985 let displays_managed_snapshot = outcome
4986 .managed_source_snapshot
4987 .as_ref()
4988 .is_some_and(|snapshot| result_displays_snapshot(&outcome.text, snapshot));
4989 append_slow_tool_duration(&mut outcome.text, tool_started_at.elapsed());
4990 let (state_updates, displayed_state_keys) =
4991 if let Some(snapshot) = outcome.managed_source_snapshot.take() {
4992 let state = self.context.apply_source_snapshot(snapshot);
4993 let displayed = displays_managed_snapshot.then_some(state.key);
4994 (
4995 state.update.into_iter().collect(),
4996 displayed.into_iter().collect(),
4997 )
4998 } else {
4999 (Vec::new(), Vec::new())
5000 };
5001 let capture = outcome.freeform_write.take().map(|request| {
5002 let id = Uuid::new_v4().to_string();
5003 self.captures.insert(id.clone(), request);
5004 Value::String(id)
5005 });
5006 Ok(kcode_agent_runtime::ToolOutcome {
5007 text: outcome.text,
5008 ok: outcome.ok,
5009 state_updates,
5010 displayed_state_keys,
5011 capture,
5012 })
5013 })
5014 }
5015
5016 fn complete_capture<'a>(
5017 &'a mut self,
5018 capture: Value,
5019 contents: String,
5020 budget: kcode_agent_runtime::ContextBudget,
5021 ) -> kcode_agent_runtime::HostFuture<'a, kcode_agent_runtime::ToolOutcome> {
5022 Box::pin(async move {
5023 let id = capture
5024 .as_str()
5025 .context("subagent freeform capture token is invalid")?;
5026 let request = self
5027 .captures
5028 .remove(id)
5029 .context("subagent freeform capture token is unknown")?;
5030 self.session
5031 .complete_subagent_freeform_write(&mut self.context, request, contents, &budget)
5032 .await
5033 })
5034 }
5035
5036 fn record(&mut self, event: kcode_agent_runtime::AuditEvent) -> anyhow::Result<()> {
5037 kcode_intelligence_chatend::record_subagent_event(&mut self.session.journal, &now(), &event)
5038 }
5039}
5040
5041fn cost_summary(label: &str, estimated_cost_usd_nanos: u64, unpriced_calls: u64) -> String {
5042 render(RenderRequest::CostSummary {
5043 label,
5044 estimated_cost_usd_nanos,
5045 unpriced_calls,
5046 })
5047 .expect("cost-summary rendering is infallible")
5048}
5049
5050fn restore_kweb_context(journal: &HistorySession, context: &mut KwebContext) -> anyhow::Result<()> {
5051 let Some(tool) = journal.state().tools.get(KWEB_TOOL_INSTANCE) else {
5052 return Ok(());
5053 };
5054 let mut nodes = BTreeMap::new();
5055 for slot in &tool.slots {
5056 let state = journal
5057 .state()
5058 .box_state(slot.box_id)
5059 .context("Kweb slot references a missing box")?;
5060 if let Some(node) = state.canonical.content.metadata.get("storedNode") {
5061 let node = match serde_json::from_value::<KwebNode>(node.clone()) {
5062 Ok(node) => node,
5063 Err(_) => node_from_value(node).context("decoding a stored Kweb context node")?,
5064 };
5065 nodes.insert(node.id.clone(), node);
5066 }
5067 }
5068 let mut direct = journal
5069 .state()
5070 .current_ingress_attempt_events()
5071 .iter()
5072 .flat_map(|event| {
5073 let EventKind::ToolInvoked {
5074 tool_name,
5075 arguments,
5076 ..
5077 } = &event.kind
5078 else {
5079 return Vec::new();
5080 };
5081 match tool_name.as_str() {
5082 "LoadNodes" => arguments
5083 .get("identifiers")
5084 .and_then(Value::as_array)
5085 .into_iter()
5086 .flatten()
5087 .filter_map(Value::as_str)
5088 .map(str::to_owned)
5089 .collect(),
5090 "LoadNode" => arguments
5091 .get("identifier")
5092 .and_then(Value::as_str)
5093 .map(str::to_owned)
5094 .into_iter()
5095 .collect(),
5096 _ => Vec::new(),
5097 }
5098 })
5099 .collect::<Vec<_>>();
5100 if direct.is_empty() {
5101 direct = context.root_node_ids().to_vec();
5102 }
5103 context
5104 .restore(nodes.into_values(), direct)
5105 .map_err(anyhow::Error::new)
5106}
5107
5108fn session_kind(session_type: &str, mode: &AgentMode) -> SessionKind {
5109 if matches!(mode, AgentMode::Ingress { .. }) {
5110 return SessionKind::HistoryIngress;
5111 }
5112 match session_type {
5113 "conversation" => SessionKind::Conversation,
5114 "telegram" => SessionKind::Telegram,
5115 "telegram-group" => SessionKind::TelegramGroup,
5116 "free-time" => SessionKind::SelfTime,
5117 "wakeup" => SessionKind::Other("wakeup".into()),
5118 "audio" => SessionKind::AudioIngress,
5119 other => SessionKind::Other(other.into()),
5120 }
5121}
5122
5123fn tool_instance_for_invocation(name: &str, invocation_id: &str) -> String {
5124 if name == "LoadNodes" {
5125 return KWEB_TOOL_INSTANCE.into();
5126 }
5127 format!("{name}:{invocation_id}")
5128}
5129
5130fn canonical_id(value: &str) -> anyhow::Result<String> {
5131 value
5132 .parse::<NodeId>()
5133 .with_context(|| format!("{value:?} is not a canonical node ID"))?;
5134 Ok(value.into())
5135}
5136
5137fn image_extension(media_type: &str) -> &'static str {
5138 match media_type
5139 .split(';')
5140 .next()
5141 .unwrap_or(media_type)
5142 .trim()
5143 .to_ascii_lowercase()
5144 .as_str()
5145 {
5146 "image/jpeg" => "jpg",
5147 "image/webp" => "webp",
5148 _ => "png",
5149 }
5150}
5151
5152fn call_ktool_description(include_launch_session: bool) -> String {
5153 let mut description = render(RenderRequest::CallKtoolDescription)
5154 .expect("Ktool-description rendering is infallible");
5155 if include_launch_session {
5156 description.push_str(
5157 "\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.",
5158 );
5159 }
5160 description
5161}
5162
5163fn now() -> String {
5164 Utc::now().to_rfc3339()
5165}
5166
5167fn deadline(value: &Value) -> Option<DateTime<Utc>> {
5168 value
5169 .get("deadlineAt")
5170 .and_then(Value::as_str)
5171 .and_then(|value| DateTime::parse_from_rfc3339(value).ok())
5172 .map(|value| value.with_timezone(&Utc))
5173}
5174
5175fn remaining_until(deadline: DateTime<Utc>) -> Duration {
5176 (deadline - Utc::now()).to_std().unwrap_or(Duration::ZERO)
5177}
5178
5179fn controller_box_name(mode: &AgentMode) -> &'static str {
5180 match mode {
5181 AgentMode::Conversation => "Turn continuation",
5182 AgentMode::FreeTime => "Self-time continuation",
5183 AgentMode::Wakeup => "Wakeup continuation",
5184 AgentMode::Ingress { .. } => "History-ingress continuation",
5185 }
5186}
5187
5188fn controller_message(mode: &AgentMode, free_time: &Value) -> String {
5189 let mode = match mode {
5190 AgentMode::Conversation => "conversation",
5191 AgentMode::FreeTime => "free-time",
5192 AgentMode::Wakeup => "wakeup",
5193 AgentMode::Ingress { .. } => "ingress",
5194 };
5195 render(RenderRequest::ControllerMessage { mode, free_time })
5196 .expect("known controller modes render successfully")
5197}