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