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