Skip to main content

kcode_kennedy_sessions/
lib.rs

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