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