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