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
7use chrono::{Datelike, Timelike};
8
9pub use kcode_telegram_session_coordinator::validate_file_name as validate_delivery_file_name;
10pub use services::{Api as Service, LocalServices as Capabilities};
11
12/// Application-selected primary model facts used mechanically by a session.
13#[derive(Clone, Debug)]
14pub struct RuntimeModel {
15    pub model: String,
16    pub reasoning_effort: String,
17    pub context_window_tokens: u64,
18}
19
20impl RuntimeModel {
21    pub fn from_intelligence(runtime: kcode_intelligence_router::RuntimeModel) -> Self {
22        Self {
23            model: runtime.model,
24            reasoning_effort: runtime.reasoning_effort,
25            context_window_tokens: runtime.context_window_tokens,
26        }
27    }
28
29    fn attribution(&self) -> String {
30        format!("{}-{}", self.model, self.reasoning_effort)
31    }
32}
33
34use std::{
35    collections::{BTreeMap, HashMap, HashSet},
36    future::Future,
37    time::Duration,
38};
39
40use anyhow::Context as _;
41use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
42use chrono::{DateTime, Utc};
43use kcode_commit_session::{CommitReceipt, CommitRequest, PlannedNode};
44use kcode_dev_tools::{
45    ATTACH_OBJECT_WEB_LIB_TOOL, CALL_RUST_BIN_TOOL, RUST_BIN_TOOLS, RUST_LIB_TOOLS, WEB_LIB_TOOLS,
46    WRITE_RUST_BIN_TOOL, WRITE_RUST_LIB_TOOL, WRITE_WEB_LIB_TOOL, proposed_write_snapshot,
47};
48use kcode_dev_tools_chatend::{
49    FreeformWrite, SourceSnapshot, apply_snapshot, prepare_freeform_write, source_box_id,
50};
51use kcode_history_ingress_context::{
52    Outcome as HistoryIngressContextOutcome, RecoveryOutcome as ContextRecoveryOutcome,
53};
54use kcode_kennedy_kweb_loader::{load_durable_batch, node_from_value};
55use kcode_kweb_context::{
56    Context as KwebContext, Node as KwebNode, NodeDraft, StagedCreate as KwebStagedCreate,
57};
58use kcode_kweb_db::{NodeId, ObjectId};
59use kcode_server_object_envelopes::{StoredFile, encode_file, sanitize_file_name};
60use kcode_session_history::{
61    NewSession, Session as HistorySession,
62    chatend::{
63        BoxContent, BoxId, BoxOwner, EventId, EventKind, ObjectMetadata, PendingId, Representation,
64        SessionKind, SessionMetadata,
65    },
66};
67use kcode_speech_classification::KTOOLS as SPEECH_CLASSIFICATION_TOOLS;
68use serde::{Deserialize, Serialize};
69use serde_json::{Value, json};
70use sha2::{Digest, Sha256};
71use uuid::Uuid;
72
73const AGENT_LOOP_ROUND_LIMIT: u64 = 100;
74const BROWSER_CONVERSATION_REQUEST_TIMEOUT: Duration = Duration::from_secs(90 * 60);
75const HISTORY_INGRESS_REQUEST_TIMEOUT: Duration = Duration::from_secs(90 * 60);
76const WAKEUP_REQUEST_TIMEOUT: Duration = Duration::from_secs(90 * 60);
77const MIN_NODE_SHORT_NAME_CHARACTERS: usize = 4;
78const MAX_NODE_SHORT_NAME_CHARACTERS: usize = 50;
79const MAX_NODE_SHORT_DESCRIPTION_CHARACTERS: usize = 200;
80const MAX_NODE_LONG_DESCRIPTION_CHARACTERS: usize = 5_000;
81const MAX_MEDIA_ENRICHMENT_BYTES: u64 = 20 * 1024 * 1024;
82const KWEB_TOOL_INSTANCE: &str = "kweb";
83const CONTEXT_OVERFLOW_WARNING_BOX_NAME: &str = "Context overflow warning";
84const 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";
85const INGRESS_FORCE_COMMIT_NOTE: &str = "ingress_force_commit";
86const SUBAGENT_CONTEXT_NODE_LIMIT: usize = 64;
87const BOX_TEXT_OBJECT_SOURCE: &str = "kennedy-box-text";
88const BOX_TEXT_MEDIA_TYPE: &str = "text/plain; charset=utf-8";
89const SLOW_TOOL_THRESHOLD: Duration = Duration::from_secs(3);
90
91#[derive(Clone, Debug, PartialEq, Eq)]
92pub enum AgentMode {
93    Conversation,
94    FreeTime,
95    Wakeup,
96    Ingress { record_id: Option<String> },
97}
98
99#[derive(Clone, Debug)]
100pub struct SessionOptions {
101    pub session_type: String,
102    pub root_node_ids: Vec<String>,
103    pub reference_root_node_ids: Vec<String>,
104    pub channel: Value,
105    pub free_time: Value,
106    pub orchestration: Value,
107    pub provenance_id: Option<String>,
108    pub mode: AgentMode,
109    pub source_session_type: Option<String>,
110    pub group_context: Value,
111    pub rust_lib_session_id: Option<String>,
112}
113
114impl SessionOptions {
115    pub fn conversation(session_type: impl Into<String>, roots: Vec<String>) -> Self {
116        Self {
117            session_type: session_type.into(),
118            root_node_ids: roots,
119            reference_root_node_ids: Vec::new(),
120            channel: Value::Null,
121            free_time: Value::Null,
122            orchestration: json!({"owner":"backend","status":"idle"}),
123            provenance_id: None,
124            mode: AgentMode::Conversation,
125            source_session_type: None,
126            group_context: Value::Null,
127            rust_lib_session_id: None,
128        }
129    }
130}
131
132fn restore_session_type(options: &mut SessionOptions, state: &Value) {
133    if !matches!(&options.mode, AgentMode::Ingress { .. }) {
134        options.session_type = state
135            .get("sessionType")
136            .and_then(Value::as_str)
137            .unwrap_or(&options.session_type)
138            .to_owned();
139    }
140}
141
142fn restore_commit_receipt(restored: Option<&Value>) -> anyhow::Result<Option<CommitReceipt>> {
143    restored
144        .and_then(|state| state.get("commitReceipt"))
145        .filter(|receipt| !receipt.is_null())
146        .cloned()
147        .map(serde_json::from_value)
148        .transpose()
149        .context("decoding the stored session commit receipt")
150}
151
152#[derive(Clone, Debug, Default, Deserialize, Serialize)]
153#[serde(rename_all = "camelCase")]
154struct KwebPlan {
155    creates: Vec<StagedNodeCreate>,
156    updates: BTreeMap<String, PlannedNode>,
157}
158
159#[derive(Clone, Debug, Deserialize, Serialize)]
160#[serde(rename_all = "camelCase")]
161struct StagedNodeCreate {
162    pending_id: String,
163    data: PlannedNode,
164}
165
166impl KwebPlan {
167    fn restore(restored: Option<&Value>, journal: &HistorySession) -> anyhow::Result<Self> {
168        if let Some(plan) = restored.and_then(|state| state.get("kwebPlan")) {
169            return serde_json::from_value(plan.clone()).context("decoding the staged Kweb plan");
170        }
171        // Transitional compatibility for journals written before Kweb plans
172        // moved into KennedyServer lifecycle state.
173        let latest = journal.state().events.iter().rev().find_map(|event| {
174            let EventKind::KwebPlanChanged { operation } = &event.kind else {
175                return None;
176            };
177            operation.get("plan")
178        });
179        latest
180            .cloned()
181            .map(serde_json::from_value)
182            .transpose()
183            .context("decoding the staged Kweb plan")
184            .map(Option::unwrap_or_default)
185    }
186
187    fn created(&self, id: &str) -> Option<&PlannedNode> {
188        self.creates
189            .iter()
190            .find(|create| create.pending_id == id)
191            .map(|create| &create.data)
192    }
193
194    fn created_mut(&mut self, id: &str) -> Option<&mut PlannedNode> {
195        self.creates
196            .iter_mut()
197            .find(|create| create.pending_id == id)
198            .map(|create| &mut create.data)
199    }
200}
201
202pub struct Session {
203    api: Service,
204    runtime: RuntimeModel,
205    journal: HistorySession,
206    plan: KwebPlan,
207    pub session_type: String,
208    pub channel: Value,
209    pub free_time: Value,
210    pub orchestration: Value,
211    pub provenance_id: Option<String>,
212    pub rust_lib_session_id: String,
213    pub root_node_ids: Vec<String>,
214    pub reference_root_node_ids: Vec<String>,
215    pub started_at: String,
216    pub transcript: Vec<Value>,
217    pub pending_turn: bool,
218    pub pending_external_event_id: Option<String>,
219    pub completed: bool,
220    pub rounds_used: u64,
221    commit_receipt: Option<CommitReceipt>,
222    commit_author: String,
223    mode: AgentMode,
224    source_session_type: Option<String>,
225    group_context: Value,
226    context: KwebContext,
227    free_time_end_reason: Option<String>,
228    fatal_persistence_error: Option<String>,
229}
230
231#[derive(Clone, Debug, Eq, PartialEq)]
232pub struct ResolvedObject {
233    pub object_id: String,
234    pub bytes: Vec<u8>,
235    pub file_name: String,
236    pub media_type: String,
237    pub transport_kind: Option<String>,
238}
239
240#[derive(Clone, Copy, Debug, Eq, PartialEq)]
241enum InputStage {
242    Accepted,
243}
244
245#[derive(Clone, Copy, Debug, Eq, PartialEq)]
246enum ContextRecovery {
247    NotNeeded,
248    Recovered,
249    Irreducible,
250}
251
252fn render_load_nodes_result(
253    journal: &HistorySession,
254    changed_box_ids: &[BoxId],
255) -> anyhow::Result<String> {
256    if changed_box_ids.is_empty() {
257        return Ok("LoadNodes completed. The shared Kweb boxes were already current.".into());
258    }
259    let rendered = journal
260        .state()
261        .projection()
262        .items
263        .into_iter()
264        .filter(|item| !item.marker)
265        .map(|item| (item.box_id, item.text))
266        .collect::<BTreeMap<_, _>>();
267    changed_box_ids
268        .iter()
269        .map(|box_id| {
270            rendered
271                .get(box_id)
272                .cloned()
273                .with_context(|| format!("updated Kweb box {box_id} is absent from the projection"))
274        })
275        .collect::<anyhow::Result<Vec<_>>>()
276        .map(|boxes| boxes.join("\n\n"))
277}
278
279fn provider_tool_result_with_context_footer(journal: &HistorySession, result: &str) -> String {
280    // A provider turn may perform several inference/tool steps without being
281    // restarted, so put the newly projected status at the end of every tool
282    // continuation rather than leaving the model with the turn-opening value.
283    let footer = journal.state().projection().footer;
284    if result.is_empty() {
285        footer
286    } else {
287        format!("{result}\n\n{footer}")
288    }
289}
290
291fn append_slow_tool_duration(text: &mut String, elapsed: Duration) {
292    if elapsed <= SLOW_TOOL_THRESHOLD {
293        return;
294    }
295    if !text.is_empty() && !text.ends_with('\n') {
296        text.push('\n');
297    }
298    text.push_str(&format!("[tool duration: {:.3}s]", elapsed.as_secs_f64()));
299}
300
301fn render_web_search_result(result: &kcode_intelligence_router::SearchResponse) -> String {
302    let mut text = result.answer.clone();
303    if !result.sources.is_empty() {
304        text.push_str("\n\nSources:");
305        for source in &result.sources {
306            let title = if source.title.trim().is_empty() {
307                &source.url
308            } else {
309                &source.title
310            };
311            text.push_str("\n- ");
312            text.push_str(title);
313            if title != &source.url {
314                text.push_str(": ");
315                text.push_str(&source.url);
316            }
317        }
318    }
319    text
320}
321
322fn render_web_fetch_result(result: &kcode_intelligence_router::FetchResponse) -> String {
323    let mut text = format!("Source URL: {}", result.url);
324    if let Some(title) = result
325        .title
326        .as_deref()
327        .filter(|title| !title.trim().is_empty())
328    {
329        text.push_str("\nTitle: ");
330        text.push_str(title);
331    }
332    text.push_str("\nContent type: ");
333    text.push_str(&result.content_type);
334    if result.truncated {
335        text.push_str("\nThe returned page text was truncated.");
336    }
337    text.push_str("\n\n");
338    text.push_str(&result.content);
339    text
340}
341
342fn render_media_annotation_result(
343    object_id: &str,
344    file_name: &str,
345    content_type: &str,
346    result: &kcode_intelligence_router::AnnotationResponse,
347) -> anyhow::Result<String> {
348    anyhow::ensure!(
349        !result.text.trim().is_empty(),
350        "media annotation response has no text"
351    );
352    let status = if result.complete {
353        "complete"
354    } else {
355        "incomplete"
356    };
357    let mut rendered = format!(
358        "Annotation for {object_id}\nFile: {file_name}\nContent type: {content_type}\nModel: {}\nStatus: {status}",
359        result.model
360    );
361    if let Some(reason) = result
362        .incomplete_reason
363        .as_deref()
364        .filter(|value| !value.trim().is_empty())
365    {
366        rendered.push_str("\nIncomplete reason: ");
367        rendered.push_str(reason);
368    }
369    rendered.push_str("\n\n");
370    rendered.push_str(&result.text);
371    Ok(rendered)
372}
373
374fn render_audio_transcription_result(
375    object_id: &str,
376    file_name: &str,
377    content_type: &str,
378    result: &kcode_intelligence_router::TranscriptionResponse,
379) -> anyhow::Result<String> {
380    anyhow::ensure!(
381        !result.text.trim().is_empty(),
382        "audio transcription response has no text"
383    );
384    Ok(format!(
385        "Transcription for {object_id}\nFile: {file_name}\nContent type: {content_type}\nModel: {}\nStatus: complete\n\n{}",
386        result.model, result.text
387    ))
388}
389
390fn render_document_extraction_result(
391    object_id: &str,
392    file_name: &str,
393    result: &kcode_intelligence_router::DocumentExtraction,
394) -> String {
395    format!(
396        "Extracted text for {object_id}\nFile: {file_name}\nFormat: {}\nCharacters: {}\nTruncated: {}\n\n{}",
397        result.format, result.characters, result.truncated, result.text
398    )
399}
400
401struct ToolCall {
402    name: String,
403    arguments: Value,
404}
405
406struct RecordedToolInvocation {
407    invocation_id: String,
408    tool_instance: String,
409    tool_name: String,
410}
411
412struct PendingFreeformWrite {
413    request: FreeformWrite,
414    call_box_id: BoxId,
415}
416
417fn tool_call_box_content(call: &ToolCall) -> anyhow::Result<BoxContent> {
418    if matches!(
419        call.name.as_str(),
420        WRITE_RUST_LIB_TOOL | WRITE_WEB_LIB_TOOL | WRITE_RUST_BIN_TOOL
421    ) {
422        let name = call
423            .arguments
424            .get("name")
425            .and_then(Value::as_str)
426            .map(|name| name.chars().take(255).collect::<String>());
427        let file_count = call
428            .arguments
429            .get("files")
430            .and_then(Value::as_array)
431            .map(Vec::len);
432        return Ok(BoxContent {
433            text: serde_json::to_string_pretty(&json!({
434                "name":call.name,
435                "arguments":{
436                    "name":name,
437                    "fileCount":file_count,
438                    "completeFileContents":"omitted from active context; retained in the durable tool invocation"
439                }
440            }))?,
441            objects: Vec::new(),
442            metadata: json!({
443                "compactedToolInvocation":true,
444                "toolName":call.name,
445            }),
446        });
447    }
448    Ok(BoxContent::text(serde_json::to_string_pretty(
449        &json!({"name":call.name,"arguments":call.arguments}),
450    )?))
451}
452
453struct ToolOutcome {
454    text: String,
455    store_result: bool,
456    ok: bool,
457    end_session: bool,
458    freeform_write: Option<FreeformWrite>,
459    managed_source_snapshot: Option<SourceSnapshot>,
460}
461
462#[derive(Clone)]
463struct ChangedSubagentState {
464    box_id: BoxId,
465    name: String,
466    text: Option<String>,
467    hide_from_parent: bool,
468}
469
470#[derive(Clone, Copy)]
471struct CanonicalBoxVersion {
472    event_id: EventId,
473    active: bool,
474    tool_owned: bool,
475    dehydrated: bool,
476}
477
478type CanonicalBoxVersions = BTreeMap<BoxId, CanonicalBoxVersion>;
479
480fn canonical_box_versions(journal: &HistorySession) -> CanonicalBoxVersions {
481    journal
482        .state()
483        .boxes
484        .iter()
485        .map(|(box_id, state)| {
486            (
487                *box_id,
488                CanonicalBoxVersion {
489                    event_id: state.canonical.event_id,
490                    active: state.active,
491                    tool_owned: matches!(state.owner, BoxOwner::Tool { .. }),
492                    dehydrated: matches!(state.representation, Representation::Dehydrated { .. }),
493                },
494            )
495        })
496        .collect()
497}
498
499fn changed_subagent_tool_states(
500    journal: &HistorySession,
501    previous: &CanonicalBoxVersions,
502) -> Vec<ChangedSubagentState> {
503    journal
504        .state()
505        .boxes
506        .iter()
507        .filter_map(|(box_id, state)| {
508            let current_tool_owned = matches!(state.owner, BoxOwner::Tool { .. });
509            let prior = previous.get(box_id);
510            if !current_tool_owned && !prior.is_some_and(|prior| prior.tool_owned) {
511                return None;
512            }
513            let changed = prior.is_none_or(|prior| {
514                prior.event_id != state.canonical.event_id
515                    || prior.active != state.active
516                    || prior.tool_owned != current_tool_owned
517            });
518            changed.then(|| ChangedSubagentState {
519                box_id: *box_id,
520                name: state.name.clone(),
521                text: (current_tool_owned && !state.canonical.content.text.is_empty())
522                    .then(|| state.canonical.content.text.clone()),
523                hide_from_parent: prior.is_none_or(|prior| prior.dehydrated || !prior.active),
524            })
525        })
526        .collect()
527}
528
529fn subagent_managed_write_fits(
530    journal: &HistorySession,
531    call: &ToolCall,
532    budget: &kcode_agent_runtime::ContextBudget,
533) -> bool {
534    let Some(snapshot) = proposed_write_snapshot(&call.name, &call.arguments) else {
535        return true;
536    };
537    let kind = snapshot.kind;
538    let key = source_box_id(journal, kind, &snapshot.name)
539        .map(|box_id| format!("tool-state:{box_id}"))
540        .unwrap_or_else(|| format!("prospective-managed-state:{:?}:{}", kind, snapshot.name));
541    budget.fits_state(
542        key,
543        format!(
544            "Current Managed {} {}:\n{}",
545            kind.label(),
546            snapshot.name,
547            snapshot.text
548        ),
549    )
550}
551
552fn subagent_state_updates(
553    states: &[ChangedSubagentState],
554) -> Vec<kcode_agent_runtime::StateUpdate> {
555    states
556        .iter()
557        .map(|state| kcode_agent_runtime::StateUpdate {
558            key: format!("tool-state:{}", state.box_id),
559            text: state
560                .text
561                .as_ref()
562                .map(|text| format!("Current {}:\n{text}", state.name)),
563        })
564        .collect()
565}
566
567struct KennedySubagentHost<'a> {
568    session: &'a mut Session,
569    captures: HashMap<String, FreeformWrite>,
570}
571
572struct KennedySessionHost<'a, C> {
573    session: &'a mut Session,
574    checkpoint: &'a mut C,
575    accounting: Option<kcode_intelligence_chatend::TopLevelCall>,
576    pending_freeform_write: Option<PendingFreeformWrite>,
577    deadline_after_response: bool,
578}
579
580impl Session {
581    pub async fn new(
582        api: Service,
583        system_prompt: String,
584        runtime: RuntimeModel,
585        mut options: SessionOptions,
586        restored: Option<&Value>,
587    ) -> anyhow::Result<Self> {
588        if let Some(state) = restored {
589            restore_session_type(&mut options, state);
590            options.channel = state.get("channel").cloned().unwrap_or(options.channel);
591            options.free_time = state.get("freeTime").cloned().unwrap_or(options.free_time);
592            options.orchestration = state
593                .get("orchestration")
594                .cloned()
595                .unwrap_or(options.orchestration);
596        }
597        if options.group_context.is_null() {
598            options.group_context = options
599                .channel
600                .get("groupContext")
601                .cloned()
602                .unwrap_or(Value::Null);
603        }
604        options
605            .reference_root_node_ids
606            .retain(|id| !options.root_node_ids.contains(id));
607        options.reference_root_node_ids.sort();
608        options.reference_root_node_ids.dedup();
609
610        let started_at = restored
611            .and_then(|state| state.get("startedAt"))
612            .and_then(Value::as_str)
613            .map(str::to_owned)
614            .unwrap_or_else(|| Utc::now().to_rfc3339());
615        let rust_lib_session_id = restored
616            .and_then(|state| state.get("rustLibSessionId"))
617            .and_then(Value::as_str)
618            .map(str::to_owned)
619            .or(options.rust_lib_session_id.clone())
620            .unwrap_or_else(|| format!("kennedy:{}", Uuid::new_v4()));
621        let history_session_id = restored
622            .and_then(|state| state.get("sessionId"))
623            .and_then(Value::as_str)
624            .map(str::to_owned);
625        let source_session_type = options.source_session_type.clone().or_else(|| {
626            restored
627                .and_then(|state| state.get("sourceSessionType"))
628                .and_then(Value::as_str)
629                .map(str::to_owned)
630        });
631        let session_id = history_session_id
632            .clone()
633            .unwrap_or_else(|| Uuid::new_v4().to_string());
634        let metadata = SessionMetadata {
635            session_id: session_id.clone(),
636            kind: session_kind(&options.session_type, &options.mode),
637            created_at: started_at.clone(),
638            effective_context_tokens: runtime.context_window_tokens,
639            channel: options.channel.clone(),
640        };
641        let mut journal = if history_session_id.is_some() {
642            api.history_session(metadata, &runtime.model)
643                .with_context(|| {
644                    format!(
645                        "opening authoritative session {session_id} (legacy snapshots are intentionally unsupported)"
646                    )
647                })?
648        } else {
649            api.create_history_session(NewSession {
650                kind: metadata.kind,
651                created_at: metadata.created_at,
652                effective_context_tokens: metadata.effective_context_tokens,
653                channel: metadata.channel,
654            })?
655        };
656        let mut context =
657            KwebContext::new(options.root_node_ids.clone()).map_err(anyhow::Error::new)?;
658        restore_kweb_context(&journal, &mut context)?;
659        let plan = KwebPlan::restore(restored, &journal)?;
660        let transcript = transcript_from_journal(&journal);
661        let (pending_turn, pending_external_event_id) = restore_pending_turn(restored, &transcript);
662
663        let needs_initialization = !journal
664            .state()
665            .boxes
666            .values()
667            .any(|state| matches!(state.owner, BoxOwner::System));
668        let commit_receipt = restore_commit_receipt(restored)?;
669        let commit_author = restored
670            .and_then(|state| state.get("commitAuthor"))
671            .and_then(Value::as_str)
672            .map(str::to_owned)
673            .unwrap_or_else(|| runtime.attribution());
674        if let Some(receipt) = &commit_receipt {
675            journal.mark_completed(receipt.session_object_id.to_string());
676        }
677        let completed =
678            journal.state().completed_session_object.is_some() || commit_receipt.is_some();
679        let mut session = Self {
680            api,
681            runtime,
682            journal,
683            plan,
684            session_type: options.session_type,
685            channel: options.channel,
686            free_time: options.free_time,
687            orchestration: options.orchestration,
688            provenance_id: options.provenance_id,
689            rust_lib_session_id,
690            root_node_ids: options.root_node_ids,
691            reference_root_node_ids: options.reference_root_node_ids,
692            started_at,
693            transcript,
694            pending_turn,
695            pending_external_event_id,
696            completed,
697            rounds_used: restored
698                .and_then(|state| state.get("roundsUsed"))
699                .and_then(Value::as_u64)
700                .unwrap_or_default(),
701            commit_receipt,
702            commit_author,
703            mode: options.mode,
704            source_session_type,
705            group_context: options.group_context,
706            context,
707            free_time_end_reason: None,
708            fatal_persistence_error: None,
709        };
710
711        if matches!(session.mode, AgentMode::Ingress { .. }) && !session.journal.is_sealed() {
712            session.journal.repair_unfinished_tools(now())?;
713        }
714        if session.journal.is_sealed() {
715            anyhow::ensure!(
716                !matches!(session.mode, AgentMode::Conversation),
717                "a read-only conversation has an unexpectedly sealed session log"
718            );
719            if session.commit_receipt.is_none() {
720                session.finalize_kweb_session()?;
721            }
722            session.completed = true;
723            return Ok(session);
724        }
725
726        if needs_initialization {
727            session.journal.create_box(
728                now(),
729                "Kennedy system prompt",
730                BoxOwner::System,
731                BoxContent::text(&system_prompt),
732            )?;
733            if session.session_type == "telegram-group" && !session.group_context.is_null() {
734                session.journal.create_box(
735                    now(),
736                    "Telegram group context",
737                    BoxOwner::Controller,
738                    BoxContent::text(kcode_telegram_session_coordinator::format_group_context(
739                        &session.group_context,
740                    )),
741                )?;
742            }
743            let roots = session.root_node_ids.clone();
744            let invocation =
745                session.record_tool_invocation("LoadNodes", json!({"identifiers":&roots}))?;
746            let result = load_durable_batch(session.api.kmap(), &mut session.context, &roots)?;
747            session.sync_kweb_boxes()?;
748            session.record_tool_completion(
749                Some(&invocation),
750                json!({"ok":true,"automatic":true,"identifiers":roots,"result":result}),
751            )?;
752        } else {
753            session.sync_kweb_boxes()?;
754        }
755        if matches!(session.mode, AgentMode::Ingress { .. })
756            && !session.completed
757            && !session.journal.state().history_ingress_started
758        {
759            session.prepare_history_ingress(&system_prompt).await?;
760        }
761        Ok(session)
762    }
763
764    async fn prepare_history_ingress(&mut self, prompt: &str) -> anyhow::Result<()> {
765        let cost_at_ingress = self.journal.state().projection().status;
766        if !self.journal.state().source_terminated {
767            self.journal.record(
768                now(),
769                EventKind::SourceTerminated {
770                    reason: "history_ingress".into(),
771                },
772            )?;
773        }
774        let system_box = self
775            .journal
776            .state()
777            .boxes
778            .values()
779            .find(|state| matches!(state.owner, BoxOwner::System))
780            .map(|state| state.id)
781            .context("session has no system-prompt box")?;
782        self.journal
783            .update_box(now(), system_box, BoxContent::text(prompt))?;
784        let ingress_kind = session_kind(&self.session_type, &self.mode);
785        if self.journal.state().metadata.effective_context_tokens
786            != self.runtime.context_window_tokens
787            || self.journal.state().metadata.kind != ingress_kind
788        {
789            self.journal
790                .configure_context(ingress_kind, self.runtime.context_window_tokens);
791        }
792        self.journal.create_box(
793            now(),
794            "Session cost at ingress",
795            BoxOwner::Controller,
796            BoxContent::text(cost_summary(
797                "session cost before history ingress",
798                cost_at_ingress.estimated_cost_usd_nanos,
799                cost_at_ingress.unpriced_provider_calls,
800            )),
801        )?;
802        self.revalidate_loaded_nodes().await?;
803        match kcode_history_ingress_context::prepare(&mut self.journal, now())? {
804            HistoryIngressContextOutcome::Ready => {}
805            HistoryIngressContextOutcome::OverCapacity {
806                estimated_tokens,
807                target_tokens,
808            } => {
809                self.journal.record(
810                    now(),
811                    EventKind::Note {
812                        label: INGRESS_FORCE_COMMIT_NOTE.into(),
813                        value: json!({
814                            "reason":"fully_dehydrated_context_above_initial_target",
815                            "estimatedTokens":estimated_tokens,
816                            "initialTargetTokens":target_tokens,
817                        }),
818                    },
819                )?;
820                self.pending_turn = false;
821                self.finalize_kweb_session()?;
822                self.completed = true;
823                return Ok(());
824            }
825        }
826        self.journal
827            .record(now(), EventKind::HistoryIngressStarted)?;
828        self.pending_turn = true;
829        Ok(())
830    }
831
832    async fn revalidate_loaded_nodes(&mut self) -> anyhow::Result<()> {
833        let direct = self.context.loaded_node_ids().to_vec();
834        load_durable_batch(self.api.kmap(), &mut self.context, &direct)?;
835        self.sync_kweb_boxes()?;
836        Ok(())
837    }
838
839    fn stage_user_input(&mut self, text: &str, metadata: &Value) -> Option<InputStage> {
840        let text = text.trim();
841        let attachments = metadata
842            .get("attachments")
843            .and_then(Value::as_array)
844            .cloned()
845            .unwrap_or_default();
846        if text.is_empty() && attachments.is_empty() && metadata.get("media").is_none() {
847            return None;
848        }
849        let result = self.stage_user_input_inner(text, metadata, attachments);
850        match result {
851            Ok(stage) => Some(stage),
852            Err(error) => {
853                self.fatal_persistence_error = Some(error.to_string());
854                tracing::error!(error=%error, "Could not durably stage session input");
855                Some(InputStage::Accepted)
856            }
857        }
858    }
859
860    fn stage_user_input_inner(
861        &mut self,
862        text: &str,
863        metadata: &Value,
864        attachments: Vec<Value>,
865    ) -> anyhow::Result<InputStage> {
866        let mut content = BoxContent::text(text);
867        content.metadata = message_metadata_without_attachment_payloads(metadata);
868        let mut attachment_boxes = Vec::new();
869        let mut attachment_names = Vec::new();
870        let mut canonical_attachments = Vec::with_capacity(attachments.len());
871        for attachment in attachments {
872            let mut descriptor = attachment_metadata_without_payload(&attachment);
873            let mut file_name = ingress_object_filename(
874                attachment.get("fileName").and_then(Value::as_str),
875                "document",
876            );
877            if let Some(pending_id) = attachment.get("pendingId").and_then(Value::as_str) {
878                let pending_id = PendingId::parse(pending_id.to_owned())?;
879                anyhow::ensure!(
880                    self.journal.objects().contains_key(&pending_id),
881                    "attached object {pending_id} is not staged in this session"
882                );
883                content.objects.push(pending_id.to_string());
884                file_name = canonicalize_staged_file_descriptor(
885                    &self.journal,
886                    &pending_id,
887                    &mut descriptor,
888                )?;
889            } else if let Some(data_url) = attachment.get("dataUrl").and_then(Value::as_str) {
890                let (media_type, bytes) = decode_data_url(data_url)?;
891                let id = self.journal.stage_object(
892                    now(),
893                    media_type,
894                    Some(file_name.clone()),
895                    descriptor.clone(),
896                    &bytes,
897                )?;
898                content.objects.push(id.to_string());
899                file_name =
900                    canonicalize_staged_file_descriptor(&self.journal, &id, &mut descriptor)?;
901            }
902            attachment_names.push(file_name.clone());
903            if let Some(extracted) = attachment
904                .get("text")
905                .and_then(Value::as_str)
906                .filter(|text| !text.is_empty())
907            {
908                attachment_boxes.push((
909                    format!("User attachment text: {file_name}"),
910                    BoxContent {
911                        text: extracted.into(),
912                        objects: Vec::new(),
913                        metadata: json!({
914                            "boxKind":"attachmentText",
915                            "attachment":descriptor.clone(),
916                        }),
917                    },
918                ));
919            }
920            canonical_attachments.push(descriptor);
921        }
922        if !content.metadata.is_object() {
923            content.metadata = json!({});
924        }
925        content.metadata["attachments"] = json!(canonical_attachments);
926        if let Some(media) = metadata.get("media") {
927            let mut descriptor = attachment_metadata_without_payload(media);
928            if let Some(pending_id) = media.get("pendingId").and_then(Value::as_str) {
929                let pending_id = PendingId::parse(pending_id.to_owned())?;
930                anyhow::ensure!(
931                    self.journal.objects().contains_key(&pending_id),
932                    "voice object {pending_id} is not staged in this session"
933                );
934                content.objects.push(pending_id.to_string());
935                canonicalize_staged_file_descriptor(&self.journal, &pending_id, &mut descriptor)?;
936            } else if let Some(data_url) = media.get("dataUrl").and_then(Value::as_str) {
937                let (media_type, bytes) = decode_data_url(data_url)?;
938                let file_name =
939                    ingress_object_filename(media.get("fileName").and_then(Value::as_str), "media");
940                let id = self.journal.stage_object(
941                    now(),
942                    media_type,
943                    Some(file_name),
944                    descriptor.clone(),
945                    &bytes,
946                )?;
947                content.objects.push(id.to_string());
948                canonicalize_staged_file_descriptor(&self.journal, &id, &mut descriptor)?;
949            }
950            content.metadata["media"] = descriptor;
951        }
952        if content.text.trim().is_empty()
953            && content.objects.is_empty()
954            && !attachment_names.is_empty()
955        {
956            content.text = attachment_names
957                .iter()
958                .map(|name| format!("Attachment provided: {name}"))
959                .collect::<Vec<_>>()
960                .join("\n");
961        }
962        let visible = if content.text.trim().is_empty() {
963            content
964                .objects
965                .iter()
966                .map(|id| format!("Object provided: {id}"))
967                .collect::<Vec<_>>()
968                .join("\n")
969        } else {
970            content.text.clone()
971        };
972        if !content.objects.is_empty() {
973            if !content.metadata.is_object() {
974                content.metadata = json!({});
975            }
976            content.metadata["transcriptText"] = json!(visible);
977        }
978        append_user_file_metadata(&self.journal, &mut content)?;
979        let mut prospective_boxes =
980            vec![("User message".to_owned(), BoxOwner::User, content.clone())];
981        prospective_boxes.extend(
982            attachment_boxes
983                .iter()
984                .map(|(name, content)| (name.clone(), BoxOwner::User, content.clone())),
985        );
986        let recorded_at = now();
987        let transcript_objects = content.objects.clone();
988        let mut transcript_attachments = content
989            .metadata
990            .get("attachments")
991            .and_then(Value::as_array)
992            .cloned()
993            .unwrap_or_default();
994        if let Some(media) = content
995            .metadata
996            .get("media")
997            .filter(|value| value.is_object())
998        {
999            transcript_attachments.push(media.clone());
1000        }
1001        for (name, owner, content) in prospective_boxes {
1002            self.journal
1003                .create_box(recorded_at.clone(), name, owner, content)?;
1004        }
1005        let mut transcript = json!({"role":"user","content":visible});
1006        if !transcript_objects.is_empty() {
1007            transcript["objects"] = json!(transcript_objects);
1008        }
1009        if !transcript_attachments.is_empty() {
1010            transcript["attachments"] = json!(transcript_attachments);
1011        }
1012        if let Some(id) = metadata.get("externalEventId").and_then(Value::as_str) {
1013            transcript["externalEventId"] = json!(id);
1014        }
1015        self.transcript.push(transcript);
1016        self.recover_context_overflow(
1017            metadata.get("externalEventId").and_then(Value::as_str),
1018            &[],
1019        )?;
1020        Ok(InputStage::Accepted)
1021    }
1022
1023    pub fn append_final_user_message(&mut self, text: &str, metadata: &Value) -> bool {
1024        self.stage_user_input(text, metadata).is_some()
1025    }
1026
1027    pub fn stage_source_message(
1028        &mut self,
1029        kennedy: bool,
1030        text: &str,
1031        metadata: Value,
1032    ) -> anyhow::Result<()> {
1033        let external_event_id = metadata
1034            .get("externalEventId")
1035            .and_then(Value::as_str)
1036            .map(str::to_owned);
1037        let owner = if kennedy {
1038            BoxOwner::Kennedy
1039        } else {
1040            BoxOwner::User
1041        };
1042        let name = if kennedy {
1043            "Kennedy message"
1044        } else {
1045            "User message"
1046        };
1047        self.journal.create_box(
1048            now(),
1049            name,
1050            owner,
1051            BoxContent {
1052                text: text.into(),
1053                objects: Vec::new(),
1054                metadata: metadata.clone(),
1055            },
1056        )?;
1057        let mut transcript = json!({
1058            "role":if kennedy {"kennedy"} else {"user"},
1059            "content":text,
1060            "metadata":metadata,
1061        });
1062        if let Some(id) = &external_event_id {
1063            transcript["externalEventId"] = json!(id);
1064        }
1065        self.transcript.push(transcript);
1066        self.recover_context_overflow(external_event_id.as_deref(), &[])?;
1067        Ok(())
1068    }
1069
1070    pub fn answer_for_external_event(&self, id: &str) -> Option<&Value> {
1071        self.transcript.iter().rev().find(|entry| {
1072            is_terminal_external_response(entry)
1073                && entry.get("externalEventId").and_then(Value::as_str) == Some(id)
1074        })
1075    }
1076
1077    pub fn responses_for_external_event(&self, id: &str) -> Vec<&Value> {
1078        self.transcript
1079            .iter()
1080            .filter(|entry| {
1081                matches!(
1082                    entry.get("role").and_then(Value::as_str),
1083                    Some("kennedy" | "system")
1084                ) && entry.get("externalEventId").and_then(Value::as_str) == Some(id)
1085            })
1086            .collect()
1087    }
1088
1089    pub fn resolve_object(&mut self, object_id: &str) -> anyhow::Result<ResolvedObject> {
1090        let api = self.api.clone();
1091        resolve_object_using(&mut self.journal, object_id, move |canonical_id| {
1092            api.kmap_file(canonical_id).map_err(Into::into)
1093        })
1094    }
1095
1096    fn resolve_media_object(&mut self, object_id: &str) -> anyhow::Result<ResolvedObject> {
1097        let mut resolved = self.resolve_object(object_id)?;
1098        resolved.media_type = normalized_media_type(&resolved.media_type);
1099        anyhow::ensure!(
1100            !resolved.bytes.is_empty(),
1101            "media object {} is empty",
1102            resolved.object_id
1103        );
1104        anyhow::ensure!(
1105            resolved.bytes.len() as u64 <= MAX_MEDIA_ENRICHMENT_BYTES,
1106            "media object {} is {} bytes, over the {}-byte enrichment limit",
1107            resolved.object_id,
1108            resolved.bytes.len(),
1109            MAX_MEDIA_ENRICHMENT_BYTES
1110        );
1111        Ok(resolved)
1112    }
1113
1114    fn resolve_image_object(
1115        &mut self,
1116        object_id: &str,
1117    ) -> anyhow::Result<(Vec<u8>, String, String)> {
1118        let resolved = self.resolve_media_object(object_id)?;
1119        anyhow::ensure!(
1120            resolved.media_type.starts_with("image/"),
1121            "GenerateImage reference {object_id} is not an image"
1122        );
1123        Ok((resolved.bytes, resolved.file_name, resolved.media_type))
1124    }
1125
1126    fn recover_context_overflow(
1127        &mut self,
1128        external_event_id: Option<&str>,
1129        pinned_box_ids: &[BoxId],
1130    ) -> anyhow::Result<ContextRecovery> {
1131        let projection = self.journal.state().projection();
1132        let target_tokens = self.journal.state().active_context_limit();
1133        if projection.estimated_tokens <= target_tokens {
1134            return Ok(ContextRecovery::NotNeeded);
1135        }
1136        let projection_hash = hex::encode(Sha256::digest(projection.render().as_bytes()));
1137        let already_irreducible = self
1138            .journal
1139            .state()
1140            .events
1141            .iter()
1142            .rev()
1143            .find_map(|event| match &event.kind {
1144                EventKind::Note { label, value } if label == "context_overflow_recovery" => {
1145                    Some(value)
1146                }
1147                _ => None,
1148            })
1149            .is_some_and(|value| {
1150                value.get("irreducible").and_then(Value::as_bool) == Some(true)
1151                    && value.get("limitTokens").and_then(Value::as_u64) == Some(target_tokens)
1152                    && value.get("projectionHash").and_then(Value::as_str)
1153                        == Some(projection_hash.as_str())
1154            });
1155        if already_irreducible {
1156            return Ok(ContextRecovery::Irreducible);
1157        }
1158
1159        let before_tokens = projection.estimated_tokens;
1160        let mut metadata = json!({
1161            "transcriptRole":"system",
1162            "contextOverflowWarning":true,
1163            "projectedTokens":before_tokens,
1164            "limitTokens":target_tokens,
1165        });
1166        if let Some(id) = external_event_id {
1167            metadata["externalEventId"] = json!(id);
1168        }
1169        let warning_box_id = self.journal.create_box(
1170            now(),
1171            CONTEXT_OVERFLOW_WARNING_BOX_NAME,
1172            BoxOwner::Controller,
1173            BoxContent {
1174                text: CONTEXT_OVERFLOW_WARNING.into(),
1175                objects: Vec::new(),
1176                metadata,
1177            },
1178        )?;
1179        let mut transcript = json!({
1180            "role":"system",
1181            "content":CONTEXT_OVERFLOW_WARNING,
1182            "contextOverflowWarning":true,
1183        });
1184        if let Some(id) = external_event_id {
1185            transcript["externalEventId"] = json!(id);
1186        }
1187        self.transcript.push(transcript);
1188
1189        let mut pins = pinned_box_ids.to_vec();
1190        if !pins.contains(&warning_box_id) {
1191            pins.push(warning_box_id);
1192        }
1193        let outcome = kcode_history_ingress_context::recover(&mut self.journal, now(), &pins)?;
1194        let (dehydrated_box_ids, estimated_tokens, target_tokens, irreducible) = match outcome {
1195            ContextRecoveryOutcome::Recovered {
1196                dehydrated_box_ids,
1197                estimated_tokens,
1198                target_tokens,
1199            } => (dehydrated_box_ids, estimated_tokens, target_tokens, false),
1200            ContextRecoveryOutcome::OverCapacity {
1201                dehydrated_box_ids,
1202                estimated_tokens,
1203                target_tokens,
1204            } => (dehydrated_box_ids, estimated_tokens, target_tokens, true),
1205        };
1206        let final_projection_hash = hex::encode(Sha256::digest(
1207            self.journal.state().projection().render().as_bytes(),
1208        ));
1209        self.journal.record(
1210            now(),
1211            EventKind::Note {
1212                label: "context_overflow_recovery".into(),
1213                value: json!({
1214                    "beforeTokens":before_tokens,
1215                    "estimatedTokens":estimated_tokens,
1216                    "limitTokens":target_tokens,
1217                    "dehydratedBoxIds":dehydrated_box_ids,
1218                    "irreducible":irreducible,
1219                    "projectionHash":final_projection_hash,
1220                }),
1221            },
1222        )?;
1223        if irreducible {
1224            if matches!(self.mode, AgentMode::Ingress { .. }) {
1225                self.request_ingress_force_commit(
1226                    "irreducible_context_overflow",
1227                    estimated_tokens,
1228                )?;
1229            } else if !self.journal.state().source_terminated {
1230                self.journal.record(
1231                    now(),
1232                    EventKind::SourceTerminated {
1233                        reason: "irreducible_context_overflow".into(),
1234                    },
1235                )?;
1236            }
1237            Ok(ContextRecovery::Irreducible)
1238        } else {
1239            Ok(ContextRecovery::Recovered)
1240        }
1241    }
1242
1243    fn request_ingress_force_commit(
1244        &mut self,
1245        reason: &str,
1246        projected_tokens: u64,
1247    ) -> anyhow::Result<()> {
1248        if self.ingress_force_commit_requested() {
1249            return Ok(());
1250        }
1251        self.journal.record(
1252            now(),
1253            EventKind::Note {
1254                label: INGRESS_FORCE_COMMIT_NOTE.into(),
1255                value: json!({
1256                    "reason":reason,
1257                    "projectedTokens":projected_tokens,
1258                    "limitTokens":self.journal.state().ingress_context_limit(),
1259                }),
1260            },
1261        )?;
1262        Ok(())
1263    }
1264
1265    fn ingress_force_commit_requested(&self) -> bool {
1266        self.journal.state().events.iter().rev().any(|event| {
1267            matches!(
1268                &event.kind,
1269                EventKind::Note { label, .. } if label == INGRESS_FORCE_COMMIT_NOTE
1270            )
1271        })
1272    }
1273
1274    pub fn requires_history_ingress(&self) -> bool {
1275        matches!(self.mode, AgentMode::Conversation) && self.journal.state().source_terminated
1276    }
1277
1278    pub fn stage_free_time_opening(&mut self) -> bool {
1279        if self.pending_turn {
1280            return false;
1281        }
1282        let mut blocks = vec![free_time_opening(&self.free_time)];
1283        if let Some(message) = self
1284            .free_time
1285            .get("handoffMessage")
1286            .and_then(Value::as_str)
1287            .filter(|message| !message.trim().is_empty())
1288        {
1289            blocks.push(format!(
1290                "Message from the previous self-time session:\n\n{message}"
1291            ));
1292        }
1293        let Some(stage) = self.stage_user_input(&blocks.join("\n\n"), &json!({"kind":"self-time"}))
1294        else {
1295            return false;
1296        };
1297        self.pending_turn = matches!(stage, InputStage::Accepted);
1298        true
1299    }
1300
1301    pub fn stage_wakeup_opening(&mut self) -> anyhow::Result<bool> {
1302        if self.pending_turn {
1303            return Ok(false);
1304        }
1305        let marker = self
1306            .channel
1307            .get("wakeupMarker")
1308            .and_then(Value::as_str)
1309            .context("wakeup session is missing its acquired time marker")?;
1310        let marker = DateTime::parse_from_rfc3339(marker)
1311            .context("wakeup session has an invalid acquired time marker")?
1312            .with_timezone(&Utc);
1313        let text = wakeup_opening(marker);
1314        let Some(stage) = self.stage_user_input(
1315            &text,
1316            &json!({"kind":"wakeup","wakeupMarker":marker.to_rfc3339()}),
1317        ) else {
1318            return Ok(false);
1319        };
1320        self.pending_turn = matches!(stage, InputStage::Accepted);
1321        Ok(true)
1322    }
1323
1324    pub fn begin_user_turn(&mut self, text: &str, metadata: &Value) -> bool {
1325        if self.pending_turn {
1326            return false;
1327        }
1328        let Some(stage) = self.stage_user_input(text, metadata) else {
1329            return false;
1330        };
1331        debug_assert_eq!(stage, InputStage::Accepted);
1332        self.rounds_used = 0;
1333        self.pending_turn = true;
1334        self.pending_external_event_id = metadata
1335            .get("externalEventId")
1336            .and_then(Value::as_str)
1337            .map(str::to_owned);
1338        true
1339    }
1340
1341    pub fn reset_exhausted_turn_rounds_for_retry(&mut self) {
1342        if matches!(self.mode, AgentMode::Conversation)
1343            && self.rounds_used >= AGENT_LOOP_ROUND_LIMIT
1344        {
1345            self.rounds_used = 0;
1346        }
1347    }
1348
1349    pub fn interrupt_current_turn(&mut self) -> anyhow::Result<()> {
1350        self.journal.repair_unfinished_tools(now())?;
1351        let notice = "The user stopped this agent turn.";
1352        let mut metadata = json!({"transcriptRole":"system","userStopped":true});
1353        let mut transcript_entry = json!({
1354            "role":"system",
1355            "content":notice,
1356            "userStopped":true,
1357        });
1358        if let Some(external_event_id) = &self.pending_external_event_id {
1359            metadata["externalEventId"] = json!(external_event_id);
1360            transcript_entry["externalEventId"] = json!(external_event_id);
1361        }
1362        self.journal.create_box(
1363            now(),
1364            "Turn stopped",
1365            BoxOwner::Controller,
1366            BoxContent {
1367                text: notice.into(),
1368                objects: Vec::new(),
1369                metadata,
1370            },
1371        )?;
1372        self.transcript.push(transcript_entry);
1373        self.pending_turn = false;
1374        self.pending_external_event_id = None;
1375        self.orchestration =
1376            json!({"owner":"backend","status":"idle","lastOutcome":"user-stopped"});
1377        Ok(())
1378    }
1379
1380    pub async fn run_pending_turn<C, F>(
1381        &mut self,
1382        operation_id: Uuid,
1383        mut checkpoint: C,
1384    ) -> anyhow::Result<Option<String>>
1385    where
1386        C: FnMut(Value) -> F + Send,
1387        F: Future<Output = anyhow::Result<()>> + Send,
1388    {
1389        if let Some(error) = self.fatal_persistence_error.take() {
1390            anyhow::bail!("session journal write failed: {error}");
1391        }
1392        if !self.pending_turn {
1393            return Ok(None);
1394        }
1395        let runtime = self.api.agent_runtime();
1396        let user_id = self
1397            .root_node_ids
1398            .first()
1399            .context("session has no user root for intelligence accounting")?
1400            .clone();
1401        let request = kcode_agent_runtime::SessionRunRequest {
1402            user_id,
1403            operation_id,
1404            rounds_used: self.rounds_used,
1405            round_limit: AGENT_LOOP_ROUND_LIMIT,
1406        };
1407        let mut host = KennedySessionHost {
1408            session: self,
1409            checkpoint: &mut checkpoint,
1410            accounting: None,
1411            pending_freeform_write: None,
1412            deadline_after_response: false,
1413        };
1414        let result = runtime.run_session(request, &mut host).await;
1415        let round_limit = result
1416            .as_ref()
1417            .is_err_and(kcode_agent_runtime::is_session_round_limit);
1418        let result = if round_limit && matches!(host.session.mode, AgentMode::Ingress { .. }) {
1419            host.session.request_ingress_force_commit(
1420                "agent_loop_round_limit",
1421                host.session.journal.state().projection().estimated_tokens,
1422            )?;
1423            (host.checkpoint)(host.session.snapshot()?).await?;
1424            None
1425        } else {
1426            result?
1427        };
1428        drop(host);
1429        match self.mode {
1430            AgentMode::Conversation => {
1431                if self.journal.state().source_terminated {
1432                    self.pending_turn = false;
1433                    self.pending_external_event_id = None;
1434                    checkpoint(self.snapshot()?).await?;
1435                    return Ok(None);
1436                }
1437                let Some(answer) = result else {
1438                    if self
1439                        .pending_external_event_id
1440                        .as_deref()
1441                        .and_then(|id| self.answer_for_external_event(id))
1442                        .is_some()
1443                    {
1444                        self.pending_turn = false;
1445                        self.pending_external_event_id = None;
1446                        checkpoint(self.snapshot()?).await?;
1447                        return Ok(None);
1448                    }
1449                    anyhow::bail!(
1450                        "Kennedy ended a conversational turn without an assistant response"
1451                    );
1452                };
1453                self.pending_turn = false;
1454                self.pending_external_event_id = None;
1455                checkpoint(self.snapshot()?).await?;
1456                Ok(Some(answer))
1457            }
1458            AgentMode::FreeTime | AgentMode::Wakeup | AgentMode::Ingress { .. } => {
1459                self.pending_turn = false;
1460                self.pending_external_event_id = None;
1461                if matches!(
1462                    self.mode,
1463                    AgentMode::FreeTime | AgentMode::Wakeup | AgentMode::Ingress { .. }
1464                ) {
1465                    self.finalize_kweb_session()?;
1466                    self.completed = true;
1467                }
1468                checkpoint(self.snapshot()?).await?;
1469                Ok(None)
1470            }
1471        }
1472    }
1473
1474    fn project_descendant<T>(
1475        &mut self,
1476        outcome: Result<kcode_intelligence_router::Accounted<T>, services::ApiError>,
1477    ) -> anyhow::Result<T> {
1478        match outcome {
1479            Ok(accounted) => {
1480                kcode_intelligence_chatend::record_descendant_receipt(
1481                    &mut self.journal,
1482                    &accounted.receipt,
1483                )?;
1484                Ok(accounted.value)
1485            }
1486            Err(error) => {
1487                if let Some(receipt) = &error.receipt {
1488                    kcode_intelligence_chatend::record_descendant_receipt(
1489                        &mut self.journal,
1490                        receipt,
1491                    )?;
1492                }
1493                Err(error.into())
1494            }
1495        }
1496    }
1497
1498    async fn run_subagent(
1499        &mut self,
1500        arguments: &Value,
1501        parent_operation_id: Uuid,
1502    ) -> anyhow::Result<String> {
1503        validate_arguments(
1504            arguments,
1505            &["model", "contextNodeIds", "task"],
1506            &["reasoningEffort"],
1507        )?;
1508        let model = nonempty_string(arguments, "model", 128)?;
1509        let reasoning_effort = arguments
1510            .get("reasoningEffort")
1511            .map(|_| nonempty_string(arguments, "reasoningEffort", 32))
1512            .transpose()?
1513            .unwrap_or_else(|| self.runtime.reasoning_effort.clone());
1514        let task = bounded_nonempty_string(arguments, "task", 100_000)?;
1515        let context_node_ids =
1516            canonical_node_id_array(arguments, "contextNodeIds", SUBAGENT_CONTEXT_NODE_LIMIT)?;
1517        let mut context = Vec::with_capacity(context_node_ids.len());
1518        for node_id in &context_node_ids {
1519            context.push(self.api.kmap_node(node_id)?.data.long_description);
1520        }
1521        let user_id = self
1522            .root_node_ids
1523            .first()
1524            .context("session has no user root for subagent intelligence accounting")?
1525            .clone();
1526        let timeout = self.agent_request_timeout();
1527        let runtime = self.api.agent_runtime();
1528        let first_event = self.journal.state().events.len();
1529        let cost_before = self.journal.state().projection().status;
1530        let result = {
1531            let mut host = KennedySubagentHost {
1532                session: self,
1533                captures: HashMap::new(),
1534            };
1535            runtime
1536                .run(
1537                    kcode_agent_runtime::RunRequest {
1538                        user_id,
1539                        parent_operation_id,
1540                        model,
1541                        reasoning_effort,
1542                        context,
1543                        task,
1544                        timeout,
1545                        start_metadata: json!({"contextNodeIds":context_node_ids}),
1546                    },
1547                    &mut host,
1548                )
1549                .await
1550        };
1551        match result {
1552            Ok(result) => {
1553                let cost_after = self.journal.state().projection().status;
1554                Ok(format!(
1555                    "{}\n\n[{}]",
1556                    result.answer,
1557                    cost_summary(
1558                        "subagent cost",
1559                        cost_after
1560                            .estimated_cost_usd_nanos
1561                            .saturating_sub(cost_before.estimated_cost_usd_nanos),
1562                        cost_after
1563                            .unpriced_provider_calls
1564                            .saturating_sub(cost_before.unpriced_provider_calls),
1565                    )
1566                ))
1567            }
1568            Err(error) => {
1569                let may_have_effects =
1570                    self.journal.state().events[first_event..]
1571                        .iter()
1572                        .any(|event| {
1573                            matches!(
1574                                &event.kind,
1575                                EventKind::Note { label, .. } if label == "subagent_tool_call"
1576                            )
1577                        });
1578                if may_have_effects {
1579                    Err(error.context(
1580                        "the subagent failed after making Ktool calls; some tool effects may already have occurred",
1581                    ))
1582                } else {
1583                    Err(error)
1584                }
1585            }
1586        }
1587    }
1588
1589    async fn complete_subagent_freeform_write(
1590        &mut self,
1591        request: FreeformWrite,
1592        contents: String,
1593        budget: &kcode_agent_runtime::ContextBudget,
1594    ) -> anyhow::Result<(ToolOutcome, Vec<ChangedSubagentState>)> {
1595        let kind = request.kind();
1596        let freeform_tool = request.write_tool();
1597        let backend_arguments = request.capture_subagent(&mut self.journal, &now(), contents)?;
1598        let preview = self
1599            .api
1600            .managed_source_execute(
1601                &self.rust_lib_session_id,
1602                request.preview_tool(),
1603                backend_arguments.clone(),
1604                Vec::new(),
1605            )
1606            .await?;
1607        let preview = preview
1608            .snapshot
1609            .context("subagent freeform write preview omitted its source snapshot")?;
1610        let source_box_id = request.source_box_id(&self.journal)?;
1611        anyhow::ensure!(
1612            budget.fits_state(
1613                format!("tool-state:{source_box_id}"),
1614                format!(
1615                    "Current Managed {} {}:\n{}",
1616                    kind.label(),
1617                    preview.name,
1618                    preview.text
1619                ),
1620            ),
1621            "{freeform_tool} was not run because its resulting source state would exceed the subagent context limit"
1622        );
1623        let previous = canonical_box_versions(&self.journal);
1624        let execution = self
1625            .api
1626            .managed_source_execute(
1627                &self.rust_lib_session_id,
1628                freeform_tool,
1629                backend_arguments,
1630                Vec::new(),
1631            )
1632            .await?;
1633        let snapshot = execution
1634            .snapshot
1635            .context("subagent freeform write omitted its resulting source snapshot")?;
1636        apply_snapshot(&mut self.journal, &now(), snapshot)?;
1637        let states = changed_subagent_tool_states(&self.journal, &previous);
1638        Ok((
1639            ToolOutcome {
1640                text: execution.text,
1641                store_result: false,
1642                ok: true,
1643                end_session: false,
1644                freeform_write: None,
1645                managed_source_snapshot: None,
1646            },
1647            states,
1648        ))
1649    }
1650
1651    async fn complete_freeform_write(
1652        &mut self,
1653        pending: PendingFreeformWrite,
1654        contents: String,
1655    ) -> anyhow::Result<ToolOutcome> {
1656        let request = pending.request;
1657        let freeform_tool = request.write_tool();
1658        let backend_arguments =
1659            request.capture(&mut self.journal, &now(), pending.call_box_id, contents)?;
1660        let preview_result = self
1661            .api
1662            .managed_source_execute(
1663                &self.rust_lib_session_id,
1664                request.preview_tool(),
1665                backend_arguments.clone(),
1666                Vec::new(),
1667            )
1668            .await;
1669        let preview = match preview_result {
1670            Ok(preview) => preview,
1671            Err(error) => {
1672                return Ok(ToolOutcome {
1673                    text: format!("{freeform_tool} failed: {error}"),
1674                    store_result: true,
1675                    ok: false,
1676                    end_session: false,
1677                    freeform_write: None,
1678                    managed_source_snapshot: None,
1679                });
1680            }
1681        };
1682        let _preview = preview
1683            .snapshot
1684            .context("freeform write preview omitted the resulting source snapshot")?;
1685        request.source_box_id(&self.journal)?;
1686
1687        let execution_result = self
1688            .api
1689            .managed_source_execute(
1690                &self.rust_lib_session_id,
1691                freeform_tool,
1692                backend_arguments,
1693                Vec::new(),
1694            )
1695            .await;
1696        let execution = match execution_result {
1697            Ok(execution) => execution,
1698            Err(error) => {
1699                return Ok(ToolOutcome {
1700                    text: format!("{freeform_tool} failed: {error}"),
1701                    store_result: true,
1702                    ok: false,
1703                    end_session: false,
1704                    freeform_write: None,
1705                    managed_source_snapshot: None,
1706                });
1707            }
1708        };
1709        let snapshot = execution
1710            .snapshot
1711            .context("freeform write omitted the resulting source snapshot")?;
1712        apply_snapshot(&mut self.journal, &now(), snapshot)?;
1713        Ok(ToolOutcome {
1714            text: execution.text,
1715            store_result: false,
1716            ok: true,
1717            end_session: false,
1718            freeform_write: None,
1719            managed_source_snapshot: None,
1720        })
1721    }
1722
1723    async fn send_telegram_dm(&mut self, arguments: &Value) -> anyhow::Result<String> {
1724        let request = kcode_telegram_session_coordinator::parse_private_request(arguments)?;
1725        let attachments = self.telegram_delivery_attachments(request.attachments)?;
1726        let caller_holds_user_lock = self.session_type == "telegram"
1727            && self.channel.get("telegramUserId").and_then(Value::as_i64)
1728                == Some(request.telegram_user_id);
1729        self.api
1730            .telegram()
1731            .send_private(kcode_telegram_session_coordinator::PrivateDelivery {
1732                telegram_user_id: request.telegram_user_id,
1733                message: request.message,
1734                attachments,
1735                caller_holds_user_lock,
1736            })
1737            .await
1738    }
1739
1740    async fn send_telegram_group_message(&mut self, arguments: &Value) -> anyhow::Result<String> {
1741        let request = kcode_telegram_session_coordinator::parse_group_request(arguments)?;
1742        let attachments = self.telegram_delivery_attachments(request.attachments)?;
1743        self.api
1744            .telegram()
1745            .send_group(kcode_telegram_session_coordinator::GroupDelivery {
1746                root_node_id: request.root_node_id,
1747                message: request.message,
1748                attachments,
1749            })
1750            .await
1751    }
1752
1753    fn telegram_delivery_attachments(
1754        &mut self,
1755        requests: Vec<kcode_telegram_session_coordinator::AttachmentRequest>,
1756    ) -> anyhow::Result<Vec<kcode_telegram_session_coordinator::Attachment>> {
1757        requests
1758            .into_iter()
1759            .map(|request| {
1760                let object = self.resolve_object(&request.object_id)?;
1761                let file_name = request
1762                    .file_name
1763                    .unwrap_or_else(|| object.file_name.clone());
1764                Ok(kcode_telegram_session_coordinator::Attachment {
1765                    object_id: object.object_id,
1766                    bytes: object.bytes,
1767                    file_name,
1768                    media_type: object.media_type,
1769                    transport_kind: object.transport_kind,
1770                })
1771            })
1772            .collect()
1773    }
1774
1775    async fn execute_tool(
1776        &mut self,
1777        call: &ToolCall,
1778        operation_id: Uuid,
1779    ) -> anyhow::Result<ToolOutcome> {
1780        self.assert_tool_allowed(&call.name)?;
1781        let mut end_session = false;
1782        let mut store_result = true;
1783        let mut freeform_write = None;
1784        let mut managed_source_snapshot = None;
1785        let text = match call.name.as_str() {
1786            "SendTelegramDM" => self.send_telegram_dm(&call.arguments).await?,
1787            "SendTelegramGroupMessage" => self.send_telegram_group_message(&call.arguments).await?,
1788            "RunSubagent" => {
1789                store_result = true;
1790                let first_event = self.journal.state().events.len();
1791                match self.run_subagent(&call.arguments, operation_id).await {
1792                    Ok(response) => response,
1793                    Err(error) => {
1794                        let may_have_effects = self.journal.state().events[first_event..]
1795                            .iter()
1796                            .any(|event| {
1797                                matches!(
1798                                    &event.kind,
1799                                    EventKind::Note { label, .. }
1800                                        if label == "subagent_tool_call"
1801                                )
1802                            });
1803                        if may_have_effects {
1804                            return Err(error.context(
1805                                "the subagent failed after making Ktool calls; some tool effects may already have occurred",
1806                            ));
1807                        }
1808                        return Err(error);
1809                    }
1810                }
1811            }
1812            "EndSession" => {
1813                validate_arguments(&call.arguments, &[], &["message"])?;
1814                anyhow::ensure!(
1815                    !matches!(self.mode, AgentMode::Conversation),
1816                    "EndSession is only available during an autonomous or history-ingress session"
1817                );
1818                end_session = true;
1819                if matches!(self.mode, AgentMode::FreeTime)
1820                    && let Some(message) = call
1821                        .arguments
1822                        .get("message")
1823                        .and_then(Value::as_str)
1824                        .filter(|message| !message.trim().is_empty())
1825                {
1826                    self.free_time["nextSessionMessage"] = json!(message);
1827                }
1828                "Session ending.".into()
1829            }
1830            "DehydrateBoxes" => {
1831                validate_arguments(&call.arguments, &["boxIds"], &[])?;
1832                let ids = box_id_array(&call.arguments, "boxIds")?;
1833                self.journal.dehydrate_boxes(now(), &ids)?;
1834                format!(
1835                    "Dehydrated boxes {}.",
1836                    ids.iter()
1837                        .map(ToString::to_string)
1838                        .collect::<Vec<_>>()
1839                        .join(", ")
1840                )
1841            }
1842            "SummarizeBox" => {
1843                validate_arguments(&call.arguments, &["boxId", "summary"], &[])?;
1844                let id = box_id(&call.arguments, "boxId")?;
1845                let summary = nonempty_string(&call.arguments, "summary", 1_000_000)?;
1846                self.journal.summarize_box(now(), id, summary)?;
1847                format!("Summarized box {id}.")
1848            }
1849            "HydrateBox" => {
1850                validate_arguments(&call.arguments, &["boxId"], &[])?;
1851                let id = box_id(&call.arguments, "boxId")?;
1852                self.journal.rehydrate_box(now(), id)?;
1853                let external_event_id = self.pending_external_event_id.clone();
1854                match self.recover_context_overflow(external_event_id.as_deref(), &[id])? {
1855                    ContextRecovery::NotNeeded => format!("Hydrated box {id}."),
1856                    ContextRecovery::Recovered => {
1857                        format!("Hydrated box {id}.\n\n{CONTEXT_OVERFLOW_WARNING}")
1858                    }
1859                    ContextRecovery::Irreducible => anyhow::bail!(CONTEXT_OVERFLOW_WARNING),
1860                }
1861            }
1862            "BoxesIntoObjects" => {
1863                validate_arguments(&call.arguments, &["boxIds"], &[])?;
1864                let ids = box_id_array(&call.arguments, "boxIds")?;
1865                let objects = stage_box_text_objects(&mut self.journal, &ids, &now())?;
1866                render_box_text_objects(&objects)
1867            }
1868            "LoadNodes" => {
1869                validate_arguments(&call.arguments, &["identifiers"], &[])?;
1870                let identifiers = canonical_node_id_list(&call.arguments, "identifiers")?;
1871                load_durable_batch(self.api.kmap(), &mut self.context, &identifiers)?;
1872                let changed = self.sync_kweb_boxes()?;
1873                store_result = false;
1874                render_load_nodes_result(&self.journal, &changed)?
1875            }
1876            "EmitObject" => {
1877                validate_arguments(&call.arguments, &["objectId"], &["fileName"])?;
1878                anyhow::ensure!(
1879                    matches!(self.mode, AgentMode::Conversation),
1880                    "EmitObject is only available in a conversation"
1881                );
1882                let object_id = nonempty_string(&call.arguments, "objectId", 64)?;
1883                let object = self.resolve_object(&object_id)?;
1884                let file_name = optional_delivery_file_name(&call.arguments, "fileName")?
1885                    .unwrap_or_else(|| object.file_name.clone());
1886                if let Some(maximum) = self.channel.get("maxObjectBytes").and_then(Value::as_u64) {
1887                    anyhow::ensure!(
1888                        !object.bytes.is_empty(),
1889                        "object {object_id} is empty and cannot be sent through this channel"
1890                    );
1891                    anyhow::ensure!(
1892                        object.bytes.len() as u64 <= maximum,
1893                        "object {object_id} is {} bytes, over this channel's {maximum}-byte limit",
1894                        object.bytes.len()
1895                    );
1896                }
1897                let descriptor = json!({
1898                    "objectId":object_id,
1899                    "fileName":file_name,
1900                    "mediaType":object.media_type,
1901                    "byteLength":object.bytes.len(),
1902                });
1903                let mut metadata = json!({
1904                    "outputKind":"object",
1905                    "attachments":[descriptor.clone()],
1906                });
1907                if let Some(external_event_id) = &self.pending_external_event_id {
1908                    metadata["externalEventId"] = json!(external_event_id);
1909                }
1910                let content = BoxContent {
1911                    text: String::new(),
1912                    objects: vec![object_id.clone()],
1913                    metadata,
1914                };
1915                self.journal
1916                    .create_box(now(), "Kennedy message", BoxOwner::Kennedy, content)?;
1917                let mut transcript = json!({
1918                    "role":"kennedy",
1919                    "content":"",
1920                    "objects":[object_id],
1921                    "attachments":[descriptor],
1922                });
1923                if let Some(external_event_id) = &self.pending_external_event_id {
1924                    transcript["externalEventId"] = json!(external_event_id);
1925                }
1926                self.transcript.push(transcript);
1927                store_result = false;
1928                "Object emitted to the user.".into()
1929            }
1930            "WebSearch" => {
1931                validate_arguments(&call.arguments, &["question", "model"], &[])?;
1932                let model = nonempty_string(&call.arguments, "model", 128)?;
1933                let user_id = self
1934                    .root_node_ids
1935                    .first()
1936                    .context("session has no user root for intelligence accounting")?
1937                    .clone();
1938                let outcome = self
1939                    .api
1940                    .search(
1941                        &user_id,
1942                        kcode_intelligence_router::SearchRequest {
1943                            question: nonempty_string(&call.arguments, "question", 4_000)?,
1944                            model,
1945                            operation_id: Uuid::new_v4(),
1946                            parent_operation_id: Some(operation_id),
1947                        },
1948                    )
1949                    .await;
1950                let result = self.project_descendant(outcome)?;
1951                render_web_search_result(&result)
1952            }
1953            "WebFetch" => {
1954                validate_arguments(&call.arguments, &["url"], &[])?;
1955                let user_id = self
1956                    .root_node_ids
1957                    .first()
1958                    .context("session has no user root for intelligence accounting")?;
1959                let result = self
1960                    .api
1961                    .fetch(
1962                        user_id,
1963                        kcode_intelligence_router::FetchRequest {
1964                            url: nonempty_string(&call.arguments, "url", 4_096)?,
1965                            operation_id: Uuid::new_v4(),
1966                            parent_operation_id: Some(operation_id),
1967                        },
1968                    )
1969                    .await?;
1970                render_web_fetch_result(&result)
1971            }
1972            "StageTelegramGroupMedia" => {
1973                validate_arguments(&call.arguments, &["messageId"], &[])?;
1974                let message_id = positive_integer(&call.arguments, "messageId")?;
1975                let message_id = i64::try_from(message_id)
1976                    .context("messageId exceeds Telegram's supported integer range")?;
1977                let media_ref = kcode_telegram_session_coordinator::group_media_reference(
1978                    &self.group_context,
1979                    message_id,
1980                )?;
1981                let chat_id = media_ref.chat_id;
1982                if let Some((pending_id, metadata, size_bytes)) =
1983                    staged_telegram_group_media(&self.journal, chat_id, message_id)
1984                {
1985                    render_staged_telegram_group_media(
1986                        &pending_id,
1987                        &metadata,
1988                        size_bytes,
1989                        message_id,
1990                        true,
1991                    )?
1992                } else {
1993                    let (bytes, downloaded_media_type) = self
1994                        .api
1995                        .telegram()
1996                        .group_message_media(chat_id, message_id)?;
1997                    anyhow::ensure!(
1998                        !bytes.is_empty(),
1999                        "Telegram group media message {message_id} is empty"
2000                    );
2001                    anyhow::ensure!(
2002                        bytes.len() as u64 <= MAX_MEDIA_ENRICHMENT_BYTES,
2003                        "Telegram group media message {message_id} is {} bytes, over the {}-byte enrichment limit",
2004                        bytes.len(),
2005                        MAX_MEDIA_ENRICHMENT_BYTES
2006                    );
2007                    let media_type = normalized_media_type(&downloaded_media_type);
2008                    let file_name = kcode_telegram_session_coordinator::group_media_file_name(
2009                        &media_ref,
2010                        &media_type,
2011                    );
2012                    let pending_id = self.journal.stage_object(
2013                        now(),
2014                        media_type.clone(),
2015                        Some(file_name),
2016                        media_ref.transport_metadata(),
2017                        &bytes,
2018                    )?;
2019                    let metadata = self
2020                        .journal
2021                        .objects()
2022                        .get(&pending_id)
2023                        .context("newly staged Telegram group media is missing")?
2024                        .metadata
2025                        .clone();
2026                    render_staged_telegram_group_media(
2027                        &pending_id,
2028                        &metadata,
2029                        bytes.len() as u64,
2030                        message_id,
2031                        false,
2032                    )?
2033                }
2034            }
2035            "TranscribeAudio" => {
2036                validate_arguments(&call.arguments, &["objectId", "model", "prompt"], &[])?;
2037                let model = nonempty_string(&call.arguments, "model", 128)?;
2038                let prompt = nonblank_string(&call.arguments, "prompt")?;
2039                let object_id = nonempty_string(&call.arguments, "objectId", 64)?;
2040                let object = self.resolve_media_object(&object_id)?;
2041                validate_transcribable_audio(&object.media_type)?;
2042                validate_transcription_model(&model)?;
2043                let user_id = self
2044                    .root_node_ids
2045                    .first()
2046                    .context("session has no user root for intelligence accounting")?
2047                    .clone();
2048                let outcome = self
2049                    .api
2050                    .transcribe_audio(
2051                        &user_id,
2052                        &model,
2053                        &prompt,
2054                        object.bytes,
2055                        object.file_name.clone(),
2056                        &object.media_type,
2057                        None,
2058                        operation_id,
2059                    )
2060                    .await;
2061                let result = self.project_descendant(outcome)?;
2062                render_audio_transcription_result(
2063                    &object.object_id,
2064                    &object.file_name,
2065                    &object.media_type,
2066                    &result,
2067                )?
2068            }
2069            "AnnotateMedia" => {
2070                validate_arguments(&call.arguments, &["objectId", "model", "prompt"], &[])?;
2071                let model = nonempty_string(&call.arguments, "model", 128)?;
2072                let prompt = bounded_nonempty_string(&call.arguments, "prompt", 4_000)?;
2073                let object_id = nonempty_string(&call.arguments, "objectId", 64)?;
2074                let media = self.resolve_media_object(&object_id)?;
2075                validate_annotation_media(&model, &media.media_type)?;
2076                let user_id = self
2077                    .root_node_ids
2078                    .first()
2079                    .context("session has no user root for intelligence accounting")?
2080                    .clone();
2081                let outcome = self
2082                    .api
2083                    .annotate_media(
2084                        &user_id,
2085                        &model,
2086                        &prompt,
2087                        media.bytes,
2088                        media.file_name.clone(),
2089                        &media.media_type,
2090                        operation_id,
2091                    )
2092                    .await;
2093                let result = self.project_descendant(outcome)?;
2094                render_media_annotation_result(
2095                    &media.object_id,
2096                    &media.file_name,
2097                    &media.media_type,
2098                    &result,
2099                )?
2100            }
2101            "GenerateImage" => {
2102                validate_arguments(
2103                    &call.arguments,
2104                    &["model", "prompt"],
2105                    &["referenceObjectIds"],
2106                )?;
2107                let model = nonempty_string(&call.arguments, "model", 128)?;
2108                validate_image_model(&model)?;
2109                let prompt = bounded_nonempty_string(&call.arguments, "prompt", 100_000)?;
2110                let reference_ids =
2111                    optional_object_id_array(&call.arguments, "referenceObjectIds", 14)?;
2112                let mut references = Vec::with_capacity(reference_ids.len());
2113                for object_id in &reference_ids {
2114                    references.push(self.resolve_image_object(object_id)?);
2115                }
2116                let user_id = self
2117                    .root_node_ids
2118                    .first()
2119                    .context("session has no user root for intelligence accounting")?
2120                    .clone();
2121                let outcome = self
2122                    .api
2123                    .generate_image(&user_id, &model, &prompt, references, operation_id)
2124                    .await;
2125                let result = self.project_descendant(outcome)?;
2126                let size = result.bytes.len();
2127                let file_name =
2128                    format!("generated-image.{}", image_extension(&result.content_type));
2129                let object_id = self.api.save_generated_image(
2130                    result.bytes,
2131                    &file_name,
2132                    &result.content_type,
2133                    &result.model,
2134                )?;
2135                format!(
2136                    "Generated image.\nObject: {object_id}\nFile: {file_name}\nContent type: {}\nSize: {size} bytes\nModel: {}\nUse EmitObject with {object_id} to deliver it.",
2137                    result.content_type, result.model
2138                )
2139            }
2140            "ExtractDocumentText" => {
2141                validate_arguments(&call.arguments, &["objectId"], &[])?;
2142                let object_id = nonempty_string(&call.arguments, "objectId", 64)?;
2143                let object = self.resolve_media_object(&object_id)?;
2144                validate_extractable_document(&object.media_type, &object.file_name)?;
2145                let result = self
2146                    .api
2147                    .extract_document(object.bytes, object.file_name.clone(), &object.media_type)
2148                    .await?;
2149                render_document_extraction_result(&object.object_id, &object.file_name, &result)
2150            }
2151            name if SPEECH_CLASSIFICATION_TOOLS.contains(&name) => {
2152                self.api
2153                    .execute_speech_classification_tool(name, call.arguments.clone())
2154                    .await?
2155            }
2156            "ConnectNodes" => self.connect_nodes(&call.arguments)?,
2157            "ConsolidateFanout" => self.consolidate_fanout(&call.arguments)?,
2158            "SetFixedConnection" => self.set_fixed_connection(&call.arguments)?,
2159            "CreateNode" => self.create_node(&call.arguments)?,
2160            "UpdateNode" => self.update_node(&call.arguments)?,
2161            name if RUST_LIB_TOOLS.contains(&name)
2162                || WEB_LIB_TOOLS.contains(&name)
2163                || RUST_BIN_TOOLS.contains(&name) =>
2164            {
2165                if let Some(request) = prepare_freeform_write(&self.journal, name, &call.arguments)?
2166                {
2167                    store_result = false;
2168                    let acknowledgement = request.acknowledgement();
2169                    freeform_write = Some(request);
2170                    acknowledgement
2171                } else {
2172                    let mut objects = Vec::new();
2173                    if name == CALL_RUST_BIN_TOOL {
2174                        let object_ids = rust_binary_object_ids(&call.arguments)?;
2175                        objects.reserve(object_ids.len());
2176                        for object_id in object_ids {
2177                            objects.push(self.resolve_object(&object_id)?.bytes);
2178                        }
2179                    } else if name == ATTACH_OBJECT_WEB_LIB_TOOL {
2180                        let object_id = web_library_object_id(&call.arguments)?;
2181                        objects.push(self.resolve_object(&object_id)?.bytes);
2182                    }
2183                    let execution = self
2184                        .api
2185                        .managed_source_execute(
2186                            &self.rust_lib_session_id,
2187                            name,
2188                            call.arguments.clone(),
2189                            objects,
2190                        )
2191                        .await?;
2192                    if let Some(snapshot) = execution.snapshot {
2193                        managed_source_snapshot = Some(snapshot);
2194                        store_result = false;
2195                    }
2196                    execution.text
2197                }
2198            }
2199            _ => anyhow::bail!("Tool {} is not available", call.name),
2200        };
2201        Ok(ToolOutcome {
2202            text,
2203            store_result,
2204            ok: true,
2205            end_session,
2206            freeform_write,
2207            managed_source_snapshot,
2208        })
2209    }
2210
2211    fn assert_tool_allowed(&self, name: &str) -> anyhow::Result<()> {
2212        let write = matches!(
2213            name,
2214            "ConnectNodes"
2215                | "ConsolidateFanout"
2216                | "SetFixedConnection"
2217                | "CreateNode"
2218                | "UpdateNode"
2219        );
2220        anyhow::ensure!(
2221            !write || !matches!(self.mode, AgentMode::Conversation),
2222            "{name} requires the global Kweb write lane and is unavailable in a read-only conversation"
2223        );
2224        if name == "EndSession" {
2225            anyhow::ensure!(
2226                !matches!(self.mode, AgentMode::Conversation),
2227                "EndSession is unavailable in a conversation"
2228            );
2229        }
2230        Ok(())
2231    }
2232
2233    fn sync_kweb_boxes(&mut self) -> anyhow::Result<Vec<BoxId>> {
2234        let updates = self
2235            .plan
2236            .updates
2237            .iter()
2238            .map(|(id, node)| (id.clone(), kweb_node_draft(node)))
2239            .collect::<BTreeMap<_, _>>();
2240        let creates = self
2241            .plan
2242            .creates
2243            .iter()
2244            .map(|create| KwebStagedCreate {
2245                pending_id: create.pending_id.clone(),
2246                data: kweb_node_draft(&create.data),
2247            })
2248            .collect::<Vec<_>>();
2249        self.context
2250            .sync_chatend(&mut self.journal, now(), &updates, &creates)
2251            .map_err(anyhow::Error::new)
2252    }
2253
2254    fn record_tool_invocation(
2255        &mut self,
2256        name: &str,
2257        arguments: Value,
2258    ) -> anyhow::Result<RecordedToolInvocation> {
2259        let invocation = RecordedToolInvocation {
2260            invocation_id: Uuid::new_v4().to_string(),
2261            tool_instance: tool_instance(name),
2262            tool_name: name.into(),
2263        };
2264        self.journal.record(
2265            now(),
2266            EventKind::ToolInvoked {
2267                tool_instance: invocation.tool_instance.clone(),
2268                tool_name: invocation.tool_name.clone(),
2269                arguments,
2270                invocation_id: Some(invocation.invocation_id.clone()),
2271            },
2272        )?;
2273        Ok(invocation)
2274    }
2275
2276    fn record_tool_completion(
2277        &mut self,
2278        invocation: Option<&RecordedToolInvocation>,
2279        outcome: Value,
2280    ) -> anyhow::Result<EventId> {
2281        let (tool_instance, tool_name, invocation_id) = invocation
2282            .map(|invocation| {
2283                (
2284                    invocation.tool_instance.clone(),
2285                    invocation.tool_name.clone(),
2286                    Some(invocation.invocation_id.clone()),
2287                )
2288            })
2289            .unwrap_or_else(|| ("call_ktool".into(), "call_ktool".into(), None));
2290        self.journal.record(
2291            now(),
2292            EventKind::ToolCompleted {
2293                tool_instance,
2294                tool_name,
2295                outcome,
2296                invocation_id,
2297            },
2298        )
2299    }
2300
2301    fn stage_plan(&mut self) -> anyhow::Result<()> {
2302        self.sync_kweb_boxes()?;
2303        Ok(())
2304    }
2305
2306    fn node_data(&self, id: &str) -> anyhow::Result<PlannedNode> {
2307        if let Some(data) = self.plan.created(id) {
2308            return Ok(data.clone());
2309        }
2310        if let Some(data) = self.plan.updates.get(id) {
2311            return Ok(data.clone());
2312        }
2313        let node = self
2314            .context
2315            .node(id)
2316            .with_context(|| format!("Kweb context does not contain node {id}"))?;
2317        Ok(planned_node(node))
2318    }
2319
2320    fn put_node_data(&mut self, id: &str, data: PlannedNode) -> anyhow::Result<()> {
2321        if let Some(created) = self.plan.created_mut(id) {
2322            *created = data;
2323        } else {
2324            canonical_id(id)?;
2325            self.plan.updates.insert(id.to_owned(), data);
2326        }
2327        Ok(())
2328    }
2329
2330    fn connect_nodes(&mut self, args: &Value) -> anyhow::Result<String> {
2331        validate_arguments(args, &["identifiers"], &[])?;
2332        let ids = resource_id_array(args, "identifiers", 2)?;
2333        for id in &ids {
2334            self.ensure_known_node(id)?;
2335        }
2336        for id in &ids {
2337            let mut data = self.node_data(id)?;
2338            let mut recent = ids
2339                .iter()
2340                .filter(|other| *other != id)
2341                .cloned()
2342                .collect::<Vec<_>>();
2343            for other in data.recent_connections {
2344                if &other != id && !recent.contains(&other) {
2345                    recent.push(other);
2346                }
2347            }
2348            data.recent_connections = recent;
2349            self.put_node_data(id, data)?;
2350        }
2351        self.stage_plan()?;
2352        Ok(format!(
2353            "Staged connections among nodes {}.",
2354            ids.join(", ")
2355        ))
2356    }
2357
2358    fn consolidate_fanout(&mut self, args: &Value) -> anyhow::Result<String> {
2359        validate_arguments(
2360            args,
2361            &[
2362                "parentIdentifier",
2363                "fanoutIdentifiers",
2364                "aggregatorIdentifier",
2365            ],
2366            &[],
2367        )?;
2368        let parent = resource_id(args, "parentIdentifier")?;
2369        let aggregator = resource_id(args, "aggregatorIdentifier")?;
2370        let fanout = resource_id_array(args, "fanoutIdentifiers", 1)?;
2371        for id in std::iter::once(&parent)
2372            .chain(std::iter::once(&aggregator))
2373            .chain(fanout.iter())
2374        {
2375            self.ensure_known_node(id)?;
2376        }
2377        let mut parent_data = self.node_data(&parent)?;
2378        parent_data
2379            .recent_connections
2380            .retain(|id| !fanout.contains(id));
2381        if !parent_data.recent_connections.contains(&aggregator) {
2382            parent_data.recent_connections.push(aggregator.clone());
2383        }
2384        let mut aggregator_data = self.node_data(&aggregator)?;
2385        for id in fanout {
2386            if !aggregator_data.recent_connections.contains(&id) {
2387                aggregator_data.recent_connections.push(id);
2388            }
2389        }
2390        self.put_node_data(&parent, parent_data)?;
2391        self.put_node_data(&aggregator, aggregator_data)?;
2392        self.stage_plan()?;
2393        Ok(format!(
2394            "Staged fanout consolidation from node {parent} into node {aggregator}."
2395        ))
2396    }
2397
2398    fn set_fixed_connection(&mut self, args: &Value) -> anyhow::Result<String> {
2399        validate_arguments(args, &["parentIdentifier", "childIdentifier", "slot"], &[])?;
2400        let parent = resource_id(args, "parentIdentifier")?;
2401        self.ensure_known_node(&parent)?;
2402        let child = args
2403            .get("childIdentifier")
2404            .and_then(Value::as_str)
2405            .filter(|value| *value != "blank")
2406            .map(parse_resource_id)
2407            .transpose()?;
2408        if let Some(child) = &child {
2409            self.ensure_known_node(child)?;
2410            anyhow::ensure!(child != &parent, "a node cannot connect to itself");
2411        }
2412        let slot = positive_integer(args, "slot")? as usize;
2413        let mut data = self.node_data(&parent)?;
2414        if let Some(child) = child.clone() {
2415            anyhow::ensure!(
2416                slot <= data.fixed_connections.len() + 1,
2417                "fixed connection positions must remain contiguous"
2418            );
2419            data.fixed_connections.retain(|id| id != &child);
2420            if slot - 1 < data.fixed_connections.len() {
2421                data.fixed_connections[slot - 1] = child;
2422            } else {
2423                data.fixed_connections.push(child);
2424            }
2425        } else if slot > 0 && slot - 1 < data.fixed_connections.len() {
2426            data.fixed_connections.remove(slot - 1);
2427        }
2428        self.put_node_data(&parent, data)?;
2429        self.stage_plan()?;
2430        Ok(match child {
2431            Some(child) => {
2432                format!("Staged node {child} in fixed slot {slot} of node {parent}.")
2433            }
2434            None => format!("Cleared fixed slot {slot} of node {parent} in the staged plan."),
2435        })
2436    }
2437
2438    fn create_node(&mut self, args: &Value) -> anyhow::Result<String> {
2439        validate_arguments(
2440            args,
2441            &[
2442                "parentIdentifiers",
2443                "ownerIdentifier",
2444                "shortName",
2445                "shortDescription",
2446                "longDescription",
2447            ],
2448            &[],
2449        )?;
2450        let (short_name, short_description, long_description) =
2451            node_text_arguments(args, "shortName", "shortDescription", "longDescription")?;
2452        let parents = resource_id_array(args, "parentIdentifiers", 1)?;
2453        let owner = resource_id(args, "ownerIdentifier")?;
2454        for id in parents.iter().chain(std::iter::once(&owner)) {
2455            if id != "self" && id != "unowned" {
2456                self.ensure_known_node(id)?;
2457            }
2458        }
2459        let pending = self.journal.allocate_pending_node(now())?.to_string();
2460        self.plan.creates.push(StagedNodeCreate {
2461            pending_id: pending.clone(),
2462            data: PlannedNode {
2463                short_name,
2464                short_description,
2465                long_description,
2466                owner,
2467                fixed_connections: Vec::new(),
2468                recent_connections: parents.clone(),
2469                objects: Vec::new(),
2470                attach_session_archive: true,
2471            },
2472        });
2473        for parent in parents {
2474            let mut data = self.node_data(&parent)?;
2475            data.recent_connections.retain(|id| id != &pending);
2476            data.recent_connections.insert(0, pending.clone());
2477            self.put_node_data(&parent, data)?;
2478        }
2479        self.stage_plan()?;
2480        Ok(format!("Created staged node {pending}."))
2481    }
2482
2483    fn update_node(&mut self, args: &Value) -> anyhow::Result<String> {
2484        validate_arguments(
2485            args,
2486            &[
2487                "identifier",
2488                "ownerIdentifier",
2489                "newShortName",
2490                "newShortDescription",
2491                "newLongDescription",
2492            ],
2493            &[],
2494        )?;
2495        let (short_name, short_description, long_description) = node_text_arguments(
2496            args,
2497            "newShortName",
2498            "newShortDescription",
2499            "newLongDescription",
2500        )?;
2501        let id = resource_id(args, "identifier")?;
2502        let owner = resource_id(args, "ownerIdentifier")?;
2503        self.ensure_known_node(&id)?;
2504        if owner != "self" && owner != "unowned" {
2505            self.ensure_known_node(&owner)?;
2506        }
2507        let mut data = self.node_data(&id)?;
2508        data.owner = owner;
2509        data.short_name = short_name;
2510        data.short_description = short_description;
2511        data.long_description = long_description;
2512        data.attach_session_archive = true;
2513        self.put_node_data(&id, data)?;
2514        self.stage_plan()?;
2515        Ok(format!("Staged the update to node {id}."))
2516    }
2517
2518    fn ensure_known_node(&self, id: &str) -> anyhow::Result<()> {
2519        if id.starts_with("pending:") {
2520            anyhow::ensure!(
2521                self.plan.created(id).is_some(),
2522                "pending node {id} is not part of this session"
2523            );
2524        } else {
2525            canonical_id(id)?;
2526            anyhow::ensure!(
2527                self.context.contains_full_node(id) || self.plan.updates.contains_key(id),
2528                "node {id} is not loaded; call LoadNodes first"
2529            );
2530        }
2531        Ok(())
2532    }
2533
2534    fn finalize_kweb_session(&mut self) -> anyhow::Result<()> {
2535        if self.commit_receipt.is_some() {
2536            return Ok(());
2537        }
2538        self.journal.repair_unfinished_tools(now())?;
2539        self.journal.seal()?;
2540        let archive = self.journal.archive_bytes()?;
2541        let object_locations = self
2542            .journal
2543            .objects()
2544            .iter()
2545            .map(|(id, location)| (id.clone(), location.clone()))
2546            .collect::<Vec<_>>();
2547        let mut objects = BTreeMap::new();
2548        for (id, location) in object_locations {
2549            let pending_id = id.to_string();
2550            let bytes = encode_file(
2551                &pending_id,
2552                location.metadata.file_name.as_deref(),
2553                &location.metadata.media_type,
2554                staged_object_transport_kind(&self.journal, &id).as_deref(),
2555                self.journal.read_object(&id)?,
2556            )
2557            .with_context(|| format!("encoding staged object {pending_id}"))?;
2558            anyhow::ensure!(
2559                objects.insert(pending_id.clone(), bytes).is_none(),
2560                "duplicate staged object {pending_id}"
2561            );
2562        }
2563        let mut creates = BTreeMap::new();
2564        for create in &self.plan.creates {
2565            anyhow::ensure!(
2566                creates
2567                    .insert(create.pending_id.clone(), create.data.clone())
2568                    .is_none(),
2569                "duplicate staged node {}",
2570                create.pending_id
2571            );
2572        }
2573        let updates = self
2574            .plan
2575            .updates
2576            .iter()
2577            .map(|(node_id, data)| {
2578                node_id
2579                    .parse::<NodeId>()
2580                    .with_context(|| format!("{node_id:?} is not a canonical node ID"))
2581                    .map(|node_id| (node_id, data.clone()))
2582            })
2583            .collect::<anyhow::Result<BTreeMap<_, _>>>()?;
2584        let result = self.api.commit_kweb_session(CommitRequest {
2585            idempotency_key: self.journal.state().metadata.session_id.clone(),
2586            author: self.commit_author.clone(),
2587            source_created_at: DateTime::parse_from_rfc3339(&self.started_at)
2588                .context("session start timestamp is invalid")?
2589                .with_timezone(&Utc),
2590            archive,
2591            objects,
2592            creates,
2593            updates,
2594        })?;
2595        self.journal
2596            .mark_completed(result.session_object_id.to_string());
2597        self.commit_receipt = Some(result);
2598        Ok(())
2599    }
2600
2601    fn prepare_free_time_round(&mut self) -> anyhow::Result<bool> {
2602        if !matches!(self.mode, AgentMode::FreeTime) {
2603            return Ok(false);
2604        }
2605        let Some(deadline) = deadline(&self.free_time) else {
2606            return Ok(false);
2607        };
2608        if Utc::now() >= deadline {
2609            self.free_time_end_reason = Some("deadline".into());
2610            self.journal.create_box(
2611                now(),
2612                "Self-time timer",
2613                BoxOwner::Controller,
2614                BoxContent::text(
2615                    "The self-time deadline has arrived. Finish without starting more tool work.",
2616                ),
2617            )?;
2618            return Ok(true);
2619        }
2620        Ok(false)
2621    }
2622
2623    fn refresh_runtime_prompt(&mut self) -> anyhow::Result<()> {
2624        let Some(system_box) = self
2625            .journal
2626            .state()
2627            .boxes
2628            .values()
2629            .find(|state| matches!(state.owner, BoxOwner::System))
2630            .map(|state| state.id)
2631        else {
2632            return Ok(());
2633        };
2634        let current = self
2635            .journal
2636            .state()
2637            .boxes
2638            .get(&system_box)
2639            .context("system-prompt box disappeared")?
2640            .canonical
2641            .content
2642            .text
2643            .clone();
2644        let marker = "\n\nCurrent runtime\n\n";
2645        let Some((prefix, _)) = current.rsplit_once(marker) else {
2646            return Ok(());
2647        };
2648        let refreshed = format!(
2649            "{prefix}{marker}{}",
2650            runtime_description(&self.runtime, Utc::now())
2651        );
2652        if refreshed != current {
2653            self.journal
2654                .update_box(now(), system_box, BoxContent::text(refreshed))?;
2655        }
2656        Ok(())
2657    }
2658
2659    fn agent_request_timeout(&self) -> Option<Duration> {
2660        if matches!(self.mode, AgentMode::Conversation) && self.session_type == "conversation" {
2661            return Some(BROWSER_CONVERSATION_REQUEST_TIMEOUT);
2662        }
2663        if matches!(self.mode, AgentMode::Ingress { .. }) {
2664            return Some(HISTORY_INGRESS_REQUEST_TIMEOUT);
2665        }
2666        if matches!(self.mode, AgentMode::Wakeup) {
2667            return Some(WAKEUP_REQUEST_TIMEOUT);
2668        }
2669        if matches!(self.mode, AgentMode::FreeTime) {
2670            let deadline = deadline(&self.free_time)?;
2671            return Some(Duration::from_secs(
2672                (deadline - Utc::now()).num_seconds().max(1) as u64 + 360,
2673            ));
2674        }
2675        None
2676    }
2677
2678    pub fn refresh_telegram_group_context(
2679        &mut self,
2680        group_context: &Value,
2681        current_message_id: Option<&str>,
2682    ) -> anyhow::Result<()> {
2683        if self.session_type != "telegram-group" {
2684            return Ok(());
2685        }
2686        self.channel["groupContext"] = group_context.clone();
2687        self.group_context = group_context.clone();
2688        self.journal.create_box(
2689            now(),
2690            "Telegram group update",
2691            BoxOwner::Controller,
2692            BoxContent::text(kcode_telegram_session_coordinator::format_group_context(
2693                group_context,
2694            )),
2695        )?;
2696        self.recover_context_overflow(current_message_id, &[])?;
2697        Ok(())
2698    }
2699
2700    pub fn finalize_free_time(&mut self, reason: &str) -> anyhow::Result<()> {
2701        anyhow::ensure!(
2702            matches!(reason, "tool" | "deadline" | "hard-stop" | "user-stop"),
2703            "invalid self-time completion reason"
2704        );
2705        self.free_time["sliceEndedReason"] = json!(reason);
2706        self.free_time["sliceEndedAt"] = json!(now());
2707        self.pending_turn = false;
2708        self.pending_external_event_id = None;
2709        Ok(())
2710    }
2711
2712    pub fn commit_current_write_session(&mut self) -> anyhow::Result<()> {
2713        anyhow::ensure!(
2714            matches!(
2715                self.mode,
2716                AgentMode::FreeTime | AgentMode::Wakeup | AgentMode::Ingress { .. }
2717            ),
2718            "a read-only conversation cannot be committed as a Kweb write session"
2719        );
2720        self.finalize_kweb_session()?;
2721        self.completed = true;
2722        Ok(())
2723    }
2724
2725    pub fn snapshot(&self) -> anyhow::Result<Value> {
2726        let projection = self.journal.state().projection();
2727        let chatend_text = projection.render();
2728        let session_status = projection.status.clone();
2729        Ok(json!({
2730            "format":"kennedy-chatend",
2731            "version":1,
2732            "stateVersion":3,
2733            "sessionId":self.journal.state().metadata.session_id,
2734            "chatendMetadata":self.journal.state().metadata,
2735            "sessionType":self.session_type,
2736            "sourceSessionType":self.source_session_type,
2737            "channel":self.channel,
2738            "freeTime":self.free_time,
2739            "orchestration":self.orchestration,
2740            "provenanceId":self.provenance_id,
2741            "rustLibSessionId":self.rust_lib_session_id,
2742            "rootNodeIds":self.root_node_ids,
2743            "referenceRootNodeIds":self.reference_root_node_ids,
2744            "startedAt":self.started_at,
2745            "transcript":self.transcript,
2746            "pendingTurn":self.pending_turn,
2747            "pendingExternalEventId":self.pending_external_event_id,
2748            "roundsUsed":self.rounds_used,
2749            "completed":self.completed,
2750            "sessionObjectId":self.journal.state().completed_session_object,
2751            "commitReceipt":self.commit_receipt,
2752            "commitAuthor":self.commit_author,
2753            "providerModel":self.runtime.model,
2754            "kwebPlan":self.plan,
2755            "boxCount":self.journal.state().boxes.len(),
2756            "eventCount":self.journal.state().events.len(),
2757            "boxes":self.journal.state().boxes,
2758            "events":self.journal.state().events,
2759            "context":projection,
2760            "sessionStatus":session_status,
2761            "chatendText":chatend_text,
2762        }))
2763    }
2764
2765    pub async fn release_managed_sources(&self) {
2766        self.api
2767            .release_managed_sources(&self.rust_lib_session_id)
2768            .await;
2769    }
2770}
2771
2772impl<C, F> kcode_agent_runtime::SessionHost for KennedySessionHost<'_, C>
2773where
2774    C: FnMut(Value) -> F + Send,
2775    F: Future<Output = anyhow::Result<()>> + Send,
2776{
2777    fn prepare_round<'a>(
2778        &'a mut self,
2779        round: u64,
2780    ) -> kcode_agent_runtime::HostFuture<'a, kcode_agent_runtime::RoundPreparation> {
2781        Box::pin(async move {
2782            self.session.rounds_used = round;
2783            self.session.refresh_runtime_prompt()?;
2784            self.deadline_after_response = self.session.prepare_free_time_round()?;
2785            let external_event_id = self.session.pending_external_event_id.clone();
2786            if self
2787                .session
2788                .recover_context_overflow(external_event_id.as_deref(), &[])?
2789                == ContextRecovery::Irreducible
2790                || (matches!(self.session.mode, AgentMode::Ingress { .. })
2791                    && self.session.ingress_force_commit_requested())
2792            {
2793                return Ok(kcode_agent_runtime::RoundPreparation::Complete(None));
2794            }
2795            let input = self.session.journal.state().projection().render();
2796            Ok(kcode_agent_runtime::RoundPreparation::Run(
2797                kcode_agent_runtime::PreparedRound {
2798                    input,
2799                    model: self.session.runtime.model.clone(),
2800                    reasoning_effort: self.session.runtime.reasoning_effort.clone(),
2801                    tool_description: call_ktool_description().into(),
2802                    timeout: self.session.agent_request_timeout(),
2803                },
2804            ))
2805        })
2806    }
2807
2808    fn record<'a>(
2809        &'a mut self,
2810        event: kcode_agent_runtime::SessionEvent,
2811    ) -> kcode_agent_runtime::HostFuture<'a, ()> {
2812        Box::pin(async move {
2813            match event {
2814                kcode_agent_runtime::SessionEvent::InferenceSubmitted {
2815                    manifest_hash,
2816                    model,
2817                    ..
2818                } => {
2819                    let projection = self.session.journal.state().projection();
2820                    self.accounting = Some(kcode_intelligence_chatend::TopLevelCall::new(
2821                        manifest_hash.clone(),
2822                        model,
2823                    ));
2824                    self.session.journal.record(
2825                        now(),
2826                        EventKind::InferenceSubmitted {
2827                            manifest_hash,
2828                            estimated_input_tokens: projection.estimated_tokens,
2829                            raw_estimated_input_tokens: Some(projection.raw_estimated_tokens),
2830                        },
2831                    )?;
2832                }
2833                kcode_agent_runtime::SessionEvent::ProviderInput { input, .. } => {
2834                    self.session.journal.record(
2835                        now(),
2836                        EventKind::Note {
2837                            label: "provider_input".into(),
2838                            value: Value::String(input),
2839                        },
2840                    )?;
2841                }
2842                kcode_agent_runtime::SessionEvent::UsageUpdated { usage, .. } => {
2843                    self.accounting
2844                        .as_mut()
2845                        .context("provider usage arrived before inference submission")?
2846                        .usage_updated(&mut self.session.journal, &now(), &usage)?;
2847                }
2848                kcode_agent_runtime::SessionEvent::ProviderReceipt { usage, .. } => {
2849                    self.accounting
2850                        .take()
2851                        .context("provider receipt arrived before inference submission")?
2852                        .completed(&mut self.session.journal, &now(), usage.as_ref())?;
2853                }
2854            }
2855            let snapshot = self.session.snapshot()?;
2856            (self.checkpoint)(snapshot).await
2857        })
2858    }
2859
2860    fn execute_tool<'a>(
2861        &'a mut self,
2862        call: anyhow::Result<kcode_agent_runtime::ToolCall>,
2863        operation_id: Uuid,
2864    ) -> kcode_agent_runtime::HostFuture<'a, kcode_agent_runtime::SessionToolOutcome> {
2865        Box::pin(async move {
2866            if let Some(pending) = &self.pending_freeform_write {
2867                let text = format!(
2868                    "{} is awaiting the complete file contents; no other Ktool can run before that output.",
2869                    pending.request.write_tool()
2870                );
2871                self.session
2872                    .record_tool_completion(None, json!({"ok":false,"result":text}))?;
2873                let text = provider_tool_result_with_context_footer(&self.session.journal, &text);
2874                (self.checkpoint)(self.session.snapshot()?).await?;
2875                return Ok(kcode_agent_runtime::SessionToolOutcome {
2876                    text,
2877                    ok: false,
2878                    capture: Some(json!(true)),
2879                    stop: false,
2880                    finish_after_round: false,
2881                    emitted_response: false,
2882                });
2883            }
2884            let tool_started_at = std::time::Instant::now();
2885            let mut created_call_box_id = None;
2886            let mut recorded_invocation = None;
2887            let transcript_start = self.session.transcript.len();
2888            let mut emitted_response = false;
2889            let mut outcome = match call {
2890                Ok(call) => {
2891                    let call = ToolCall {
2892                        name: call.name,
2893                        arguments: call.arguments,
2894                    };
2895                    let call_name = format!("Kennedy tool call: {}", call.name);
2896                    let call_content = tool_call_box_content(&call)?;
2897                    recorded_invocation = Some(
2898                        self.session
2899                            .record_tool_invocation(&call.name, call.arguments.clone())?,
2900                    );
2901                    created_call_box_id = Some(self.session.journal.create_box(
2902                        now(),
2903                        call_name,
2904                        BoxOwner::Kennedy,
2905                        call_content,
2906                    )?);
2907                    let external_event_id = self.session.pending_external_event_id.clone();
2908                    if self
2909                        .session
2910                        .recover_context_overflow(external_event_id.as_deref(), &[])?
2911                        == ContextRecovery::Irreducible
2912                    {
2913                        ToolOutcome {
2914                            text: CONTEXT_OVERFLOW_WARNING.into(),
2915                            store_result: false,
2916                            ok: false,
2917                            end_session: false,
2918                            freeform_write: None,
2919                            managed_source_snapshot: None,
2920                        }
2921                    } else {
2922                        match self.session.execute_tool(&call, operation_id).await {
2923                            Ok(outcome) => {
2924                                emitted_response = call.name == "EmitObject" && outcome.ok;
2925                                outcome
2926                            }
2927                            Err(error) => ToolOutcome {
2928                                text: format!("{} failed: {error}", call.name),
2929                                store_result: call.name != "LoadNodes",
2930                                ok: false,
2931                                end_session: false,
2932                                freeform_write: None,
2933                                managed_source_snapshot: None,
2934                            },
2935                        }
2936                    }
2937                }
2938                Err(error) => ToolOutcome {
2939                    text: error.to_string(),
2940                    store_result: true,
2941                    ok: false,
2942                    end_session: false,
2943                    freeform_write: None,
2944                    managed_source_snapshot: None,
2945                },
2946            };
2947            if let Some(snapshot) = outcome.managed_source_snapshot.take() {
2948                apply_snapshot(&mut self.session.journal, &now(), snapshot)?;
2949                outcome.store_result = false;
2950            }
2951            append_slow_tool_duration(&mut outcome.text, tool_started_at.elapsed());
2952            let capture = if let Some(request) = outcome.freeform_write.take() {
2953                self.pending_freeform_write = Some(PendingFreeformWrite {
2954                    request,
2955                    call_box_id: created_call_box_id
2956                        .context("freeform write call box was not created")?,
2957                });
2958                Some(json!(true))
2959            } else {
2960                None
2961            };
2962            if outcome.store_result {
2963                self.session.journal.create_box(
2964                    now(),
2965                    "Kennedy tool result",
2966                    BoxOwner::Controller,
2967                    BoxContent::text(&outcome.text),
2968                )?;
2969            }
2970            let external_event_id = self.session.pending_external_event_id.clone();
2971            let recovery = self
2972                .session
2973                .recover_context_overflow(external_event_id.as_deref(), &[])?;
2974            let context_warning_added =
2975                self.session.transcript[transcript_start..]
2976                    .iter()
2977                    .any(|entry| {
2978                        entry.get("contextOverflowWarning").and_then(Value::as_bool) == Some(true)
2979                    });
2980            let mut provider_text = outcome.text.clone();
2981            if context_warning_added && !provider_text.contains(CONTEXT_OVERFLOW_WARNING) {
2982                if !provider_text.is_empty() {
2983                    provider_text.push_str("\n\n");
2984                }
2985                provider_text.push_str(CONTEXT_OVERFLOW_WARNING);
2986            }
2987            let provider_text =
2988                provider_tool_result_with_context_footer(&self.session.journal, &provider_text);
2989            self.session.record_tool_completion(
2990                recorded_invocation.as_ref(),
2991                json!({"ok":outcome.ok,"result":outcome.text}),
2992            )?;
2993            let stop = recovery == ContextRecovery::Irreducible
2994                || (matches!(self.session.mode, AgentMode::Ingress { .. })
2995                    && self.session.ingress_force_commit_requested())
2996                || (!matches!(self.session.mode, AgentMode::Ingress { .. })
2997                    && self.session.journal.state().source_terminated);
2998            let snapshot = self.session.snapshot()?;
2999            (self.checkpoint)(snapshot).await?;
3000            Ok(kcode_agent_runtime::SessionToolOutcome {
3001                text: provider_text,
3002                ok: outcome.ok,
3003                capture,
3004                stop,
3005                finish_after_round: outcome.end_session,
3006                emitted_response,
3007            })
3008        })
3009    }
3010
3011    fn complete_capture<'a>(
3012        &'a mut self,
3013        _capture: Value,
3014        contents: String,
3015    ) -> kcode_agent_runtime::HostFuture<'a, kcode_agent_runtime::SessionControl> {
3016        Box::pin(async move {
3017            let pending = self
3018                .pending_freeform_write
3019                .take()
3020                .context("provider completed without a pending freeform write")?;
3021            let result_metadata = pending.request.clone();
3022            let outcome = self
3023                .session
3024                .complete_freeform_write(pending, contents)
3025                .await?;
3026            if outcome.store_result {
3027                self.session.journal.create_box(
3028                    now(),
3029                    "Kennedy tool result",
3030                    BoxOwner::Controller,
3031                    BoxContent::text(&outcome.text),
3032                )?;
3033            }
3034            self.session.journal.record(
3035                now(),
3036                EventKind::Note {
3037                    label: "write_file_freeform_result".into(),
3038                    value: result_metadata.result_record(outcome.ok, &outcome.text),
3039                },
3040            )?;
3041            let external_event_id = self.session.pending_external_event_id.clone();
3042            let recovery = self
3043                .session
3044                .recover_context_overflow(external_event_id.as_deref(), &[])?;
3045            let snapshot = self.session.snapshot()?;
3046            (self.checkpoint)(snapshot).await?;
3047            if recovery == ContextRecovery::Irreducible
3048                || (matches!(self.session.mode, AgentMode::Ingress { .. })
3049                    && self.session.ingress_force_commit_requested())
3050                || (!matches!(self.session.mode, AgentMode::Ingress { .. })
3051                    && self.session.journal.state().source_terminated)
3052                || self.deadline_after_response
3053            {
3054                return Ok(kcode_agent_runtime::SessionControl::Complete(None));
3055            }
3056            self.session.journal.create_box(
3057                now(),
3058                controller_box_name(&self.session.mode),
3059                BoxOwner::Controller,
3060                BoxContent::text(controller_message(
3061                    &self.session.mode,
3062                    &self.session.free_time,
3063                )),
3064            )?;
3065            Ok(kcode_agent_runtime::SessionControl::Continue)
3066        })
3067    }
3068
3069    fn complete_round<'a>(
3070        &'a mut self,
3071        completion: kcode_agent_runtime::RoundCompletion,
3072    ) -> kcode_agent_runtime::HostFuture<'a, kcode_agent_runtime::SessionControl> {
3073        Box::pin(async move {
3074            let answer = completion.answer.trim().to_owned();
3075            let mut completion_recovery = ContextRecovery::NotNeeded;
3076            if !answer.is_empty() {
3077                let mut content = BoxContent::text(answer.clone());
3078                if let Some(id) = &self.session.pending_external_event_id {
3079                    content.metadata["externalEventId"] = json!(id);
3080                }
3081                self.session.journal.create_box(
3082                    now(),
3083                    "Kennedy message",
3084                    BoxOwner::Kennedy,
3085                    content,
3086                )?;
3087                let mut transcript = json!({"role":"kennedy","content":answer});
3088                if let Some(id) = &self.session.pending_external_event_id {
3089                    transcript["externalEventId"] = json!(id);
3090                }
3091                self.session.transcript.push(transcript);
3092                let external_event_id = self.session.pending_external_event_id.clone();
3093                completion_recovery = self
3094                    .session
3095                    .recover_context_overflow(external_event_id.as_deref(), &[])?;
3096            }
3097            let snapshot = self.session.snapshot()?;
3098            (self.checkpoint)(snapshot).await?;
3099            if completion_recovery == ContextRecovery::Irreducible
3100                || (matches!(self.session.mode, AgentMode::Ingress { .. })
3101                    && self.session.ingress_force_commit_requested())
3102                || (!matches!(self.session.mode, AgentMode::Ingress { .. })
3103                    && self.session.journal.state().source_terminated)
3104            {
3105                return Ok(kcode_agent_runtime::SessionControl::Complete(None));
3106            }
3107            if completion.finish_requested || self.deadline_after_response {
3108                return Ok(kcode_agent_runtime::SessionControl::Complete(
3109                    (!answer.is_empty()).then_some(answer),
3110                ));
3111            }
3112            if matches!(self.session.mode, AgentMode::Conversation) && !answer.is_empty() {
3113                return Ok(kcode_agent_runtime::SessionControl::Complete(Some(answer)));
3114            }
3115            if matches!(self.session.mode, AgentMode::Conversation) && completion.emitted_response {
3116                return Ok(kcode_agent_runtime::SessionControl::Complete(None));
3117            }
3118            let solo_ingress_response =
3119                matches!(self.session.mode, AgentMode::Ingress { .. }) && !answer.is_empty();
3120            anyhow::ensure!(
3121                completion.used_tool || solo_ingress_response,
3122                "provider completed without a response or tool call"
3123            );
3124            self.session.journal.create_box(
3125                now(),
3126                controller_box_name(&self.session.mode),
3127                BoxOwner::Controller,
3128                BoxContent::text(controller_message(
3129                    &self.session.mode,
3130                    &self.session.free_time,
3131                )),
3132            )?;
3133            Ok(kcode_agent_runtime::SessionControl::Continue)
3134        })
3135    }
3136}
3137
3138impl kcode_agent_runtime::Host for KennedySubagentHost<'_> {
3139    fn render_tool_call(&mut self, call: &kcode_agent_runtime::ToolCall) -> anyhow::Result<String> {
3140        Ok(tool_call_box_content(&ToolCall {
3141            name: call.name.clone(),
3142            arguments: call.arguments.clone(),
3143        })?
3144        .text)
3145    }
3146
3147    fn execute_tool<'a>(
3148        &'a mut self,
3149        call: kcode_agent_runtime::ToolCall,
3150        operation_id: Uuid,
3151        budget: kcode_agent_runtime::ContextBudget,
3152    ) -> kcode_agent_runtime::HostFuture<'a, kcode_agent_runtime::ToolOutcome> {
3153        Box::pin(async move {
3154            let call = ToolCall {
3155                name: call.name,
3156                arguments: call.arguments,
3157            };
3158            if call.name == "RunSubagent" {
3159                return Ok(kcode_agent_runtime::ToolOutcome::failure(
3160                    "RunSubagent is unavailable inside a subagent. Only Kennedy may launch subagents.",
3161                ));
3162            }
3163            if matches!(
3164                call.name.as_str(),
3165                "SendTelegramDM" | "SendTelegramGroupMessage"
3166            ) {
3167                return Ok(kcode_agent_runtime::ToolOutcome::failure(
3168                    "Telegram cold delivery is unavailable inside a subagent. Only Kennedy may initiate a Telegram message.",
3169                ));
3170            }
3171            if budget.estimated_tokens() > budget.max_input_tokens() {
3172                return Ok(kcode_agent_runtime::ToolOutcome::failure(
3173                    "The Ktool call was not run because its retained invocation would exceed the subagent context limit.",
3174                ));
3175            }
3176            if !subagent_managed_write_fits(&self.session.journal, &call, &budget) {
3177                return Ok(kcode_agent_runtime::ToolOutcome::failure(
3178                    "The managed-source write was not run because its resulting current state would exceed the subagent context limit.",
3179                ));
3180            }
3181
3182            let tool_started_at = std::time::Instant::now();
3183            let previous = canonical_box_versions(&self.session.journal);
3184            let mut outcome = match self.session.execute_tool(&call, operation_id).await {
3185                Ok(outcome) => outcome,
3186                Err(error) => {
3187                    let mut text = format!("{} failed: {error}", call.name);
3188                    append_slow_tool_duration(&mut text, tool_started_at.elapsed());
3189                    return Ok(kcode_agent_runtime::ToolOutcome::failure(text));
3190                }
3191            };
3192            append_slow_tool_duration(&mut outcome.text, tool_started_at.elapsed());
3193            if let Some(snapshot) = outcome.managed_source_snapshot.take() {
3194                apply_snapshot(&mut self.session.journal, &now(), snapshot)?;
3195                outcome.store_result = false;
3196            }
3197            let states = if outcome.ok {
3198                changed_subagent_tool_states(&self.session.journal, &previous)
3199            } else {
3200                Vec::new()
3201            };
3202            let hidden = states
3203                .iter()
3204                .filter(|state| state.hide_from_parent && state.text.is_some())
3205                .map(|state| state.box_id)
3206                .collect::<Vec<_>>();
3207            if !hidden.is_empty() {
3208                self.session.journal.dehydrate_boxes(now(), &hidden)?;
3209            }
3210            let capture = outcome.freeform_write.take().map(|request| {
3211                let id = Uuid::new_v4().to_string();
3212                self.captures.insert(id.clone(), request);
3213                Value::String(id)
3214            });
3215            Ok(kcode_agent_runtime::ToolOutcome {
3216                text: outcome.text,
3217                ok: outcome.ok,
3218                state_updates: subagent_state_updates(&states),
3219                capture,
3220            })
3221        })
3222    }
3223
3224    fn complete_capture<'a>(
3225        &'a mut self,
3226        capture: Value,
3227        contents: String,
3228        budget: kcode_agent_runtime::ContextBudget,
3229    ) -> kcode_agent_runtime::HostFuture<'a, kcode_agent_runtime::ToolOutcome> {
3230        Box::pin(async move {
3231            let id = capture
3232                .as_str()
3233                .context("subagent freeform capture token is invalid")?;
3234            let request = self
3235                .captures
3236                .remove(id)
3237                .context("subagent freeform capture token is unknown")?;
3238            let (outcome, states) = self
3239                .session
3240                .complete_subagent_freeform_write(request, contents, &budget)
3241                .await?;
3242            let hidden = states
3243                .iter()
3244                .filter(|state| state.hide_from_parent && state.text.is_some())
3245                .map(|state| state.box_id)
3246                .collect::<Vec<_>>();
3247            if !hidden.is_empty() {
3248                self.session.journal.dehydrate_boxes(now(), &hidden)?;
3249            }
3250            Ok(kcode_agent_runtime::ToolOutcome {
3251                text: outcome.text,
3252                ok: outcome.ok,
3253                state_updates: subagent_state_updates(&states),
3254                capture: None,
3255            })
3256        })
3257    }
3258
3259    fn record(&mut self, event: kcode_agent_runtime::AuditEvent) -> anyhow::Result<()> {
3260        kcode_intelligence_chatend::record_subagent_event(&mut self.session.journal, &now(), &event)
3261    }
3262}
3263
3264fn cost_summary(label: &str, estimated_cost_usd_nanos: u64, unpriced_calls: u64) -> String {
3265    let rounded_milli_pennies = estimated_cost_usd_nanos.saturating_add(5_000) / 10_000;
3266    let pennies = format!(
3267        "{}.{:03}",
3268        rounded_milli_pennies / 1_000,
3269        rounded_milli_pennies % 1_000
3270    );
3271    if unpriced_calls == 0 {
3272        format!("Estimated {label}: {pennies} pennies at standard API rates.")
3273    } else {
3274        format!(
3275            "Estimated {label}: {pennies} pennies at standard API rates; {unpriced_calls} provider {} could not be priced.",
3276            if unpriced_calls == 1 { "call" } else { "calls" }
3277        )
3278    }
3279}
3280
3281fn restore_kweb_context(journal: &HistorySession, context: &mut KwebContext) -> anyhow::Result<()> {
3282    let Some(tool) = journal.state().tools.get(KWEB_TOOL_INSTANCE) else {
3283        return Ok(());
3284    };
3285    let mut nodes = BTreeMap::new();
3286    for slot in &tool.slots {
3287        let state = journal
3288            .state()
3289            .box_state(slot.box_id)
3290            .context("Kweb slot references a missing box")?;
3291        if let Some(node) = state.canonical.content.metadata.get("storedNode") {
3292            let node = match serde_json::from_value::<KwebNode>(node.clone()) {
3293                Ok(node) => node,
3294                Err(_) => node_from_value(node).context("decoding a stored Kweb context node")?,
3295            };
3296            nodes.insert(node.id.clone(), node);
3297        }
3298    }
3299    let mut direct = journal
3300        .state()
3301        .events
3302        .iter()
3303        .flat_map(|event| {
3304            let EventKind::ToolInvoked {
3305                tool_name,
3306                arguments,
3307                ..
3308            } = &event.kind
3309            else {
3310                return Vec::new();
3311            };
3312            match tool_name.as_str() {
3313                "LoadNodes" => arguments
3314                    .get("identifiers")
3315                    .and_then(Value::as_array)
3316                    .into_iter()
3317                    .flatten()
3318                    .filter_map(Value::as_str)
3319                    .map(str::to_owned)
3320                    .collect(),
3321                // This is replay-only compatibility for persisted pre-batch sessions.
3322                "LoadNode" => arguments
3323                    .get("identifier")
3324                    .and_then(Value::as_str)
3325                    .map(str::to_owned)
3326                    .into_iter()
3327                    .collect(),
3328                _ => Vec::new(),
3329            }
3330        })
3331        .collect::<Vec<_>>();
3332    if direct.is_empty() {
3333        direct = context.root_node_ids().to_vec();
3334    }
3335    context
3336        .restore(nodes.into_values(), direct)
3337        .map_err(anyhow::Error::new)
3338}
3339
3340fn transcript_from_journal(journal: &HistorySession) -> Vec<Value> {
3341    journal
3342        .state()
3343        .boxes
3344        .values()
3345        .filter_map(|state| {
3346            let role = match state.owner {
3347                BoxOwner::User if state.name == "User message" => "user",
3348                BoxOwner::Kennedy if state.name == "Kennedy message" => "kennedy",
3349                BoxOwner::Controller
3350                    if state
3351                        .canonical
3352                        .content
3353                        .metadata
3354                        .get("transcriptRole")
3355                        .and_then(Value::as_str)
3356                        == Some("system") =>
3357                {
3358                    "system"
3359                }
3360                _ => return None,
3361            };
3362            let transcript_text = state
3363                .canonical
3364                .content
3365                .metadata
3366                .get("transcriptText")
3367                .and_then(Value::as_str)
3368                .unwrap_or(&state.canonical.content.text);
3369            let mut entry = json!({
3370                "role":role,
3371                "content":transcript_text,
3372            });
3373            if !state.canonical.content.objects.is_empty() {
3374                entry["objects"] = json!(state.canonical.content.objects);
3375            }
3376            if let Some(attachments) = state
3377                .canonical
3378                .content
3379                .metadata
3380                .get("attachments")
3381                .filter(|value| value.is_array())
3382            {
3383                entry["attachments"] = attachments.clone();
3384            } else if let Some(media) = state
3385                .canonical
3386                .content
3387                .metadata
3388                .get("media")
3389                .filter(|value| value.is_object())
3390            {
3391                entry["attachments"] = json!([media]);
3392            }
3393            if let Some(id) = state.canonical.content.metadata.get("externalEventId") {
3394                entry["externalEventId"] = id.clone();
3395            }
3396            if state
3397                .canonical
3398                .content
3399                .metadata
3400                .get("contextOverflowWarning")
3401                .and_then(Value::as_bool)
3402                == Some(true)
3403            {
3404                entry["contextOverflowWarning"] = json!(true);
3405            }
3406            if state
3407                .canonical
3408                .content
3409                .metadata
3410                .get("userStopped")
3411                .and_then(Value::as_bool)
3412                == Some(true)
3413            {
3414                entry["userStopped"] = json!(true);
3415            }
3416            Some(entry)
3417        })
3418        .collect()
3419}
3420
3421fn is_terminal_external_response(entry: &Value) -> bool {
3422    match entry.get("role").and_then(Value::as_str) {
3423        Some("kennedy") => true,
3424        Some("system") => {
3425            entry.get("contextOverflowWarning").and_then(Value::as_bool) != Some(true)
3426        }
3427        _ => false,
3428    }
3429}
3430
3431fn restore_pending_turn(restored: Option<&Value>, transcript: &[Value]) -> (bool, Option<String>) {
3432    let mut unanswered_external = Vec::<String>::new();
3433    let mut answered_external = HashSet::<String>::new();
3434    for entry in transcript {
3435        if entry.get("role").and_then(Value::as_str) == Some("user") {
3436            if let Some(id) = entry.get("externalEventId").and_then(Value::as_str) {
3437                answered_external.remove(id);
3438                unanswered_external.retain(|candidate| candidate != id);
3439                unanswered_external.push(id.to_owned());
3440            }
3441            continue;
3442        }
3443        if !is_terminal_external_response(entry) {
3444            continue;
3445        }
3446        if let Some(id) = entry.get("externalEventId").and_then(Value::as_str) {
3447            answered_external.insert(id.to_owned());
3448            unanswered_external.retain(|candidate| candidate != id);
3449        } else if entry.get("userStopped").and_then(Value::as_bool) == Some(true)
3450            && let Some(id) = unanswered_external.pop()
3451        {
3452            // Older stop boxes did not identify their user event. A stop closes
3453            // the most recent unanswered turn that precedes it in the journal.
3454            answered_external.insert(id);
3455        }
3456    }
3457    let journal_pending_external = unanswered_external.last().cloned();
3458    let restored_pending = restored
3459        .and_then(|state| state.get("pendingTurn"))
3460        .and_then(Value::as_bool)
3461        .unwrap_or(false);
3462    let restored_external = restored
3463        .and_then(|state| state.get("pendingExternalEventId"))
3464        .and_then(Value::as_str);
3465    // The response box is journaled before the lifecycle checkpoint that clears
3466    // the turn, so recovery must tolerate a crash between those two writes.
3467    let restored_external_answered =
3468        restored_external.is_some_and(|id| answered_external.contains(id));
3469    let pending_external_event_id = journal_pending_external.or_else(|| {
3470        restored_external
3471            .filter(|id| !answered_external.contains(*id))
3472            .map(str::to_owned)
3473    });
3474    let pending_turn =
3475        pending_external_event_id.is_some() || (restored_pending && !restored_external_answered);
3476    (pending_turn, pending_external_event_id)
3477}
3478
3479fn staged_object_transport_kind(
3480    journal: &HistorySession,
3481    pending_id: &PendingId,
3482) -> Option<String> {
3483    let pending_id_text = pending_id.to_string();
3484    for state in journal.state().boxes.values() {
3485        let Some(index) = state
3486            .canonical
3487            .content
3488            .objects
3489            .iter()
3490            .position(|object_id| object_id == &pending_id_text)
3491        else {
3492            continue;
3493        };
3494        let metadata = &state.canonical.content.metadata;
3495        let descriptor = metadata
3496            .get("attachments")
3497            .and_then(Value::as_array)
3498            .and_then(|attachments| {
3499                attachments
3500                    .iter()
3501                    .find(|attachment| {
3502                        attachment.get("pendingId").and_then(Value::as_str)
3503                            == Some(pending_id_text.as_str())
3504                    })
3505                    .or_else(|| attachments.get(index))
3506            })
3507            .or_else(|| metadata.get("media").filter(|value| value.is_object()));
3508        if let Some(kind) = descriptor
3509            .and_then(|descriptor| descriptor.get("kind"))
3510            .and_then(Value::as_str)
3511            .filter(|kind| !kind.trim().is_empty())
3512        {
3513            return Some(kind.to_owned());
3514        }
3515    }
3516    journal
3517        .objects()
3518        .get(pending_id)
3519        .and_then(|location| location.metadata.transport.get("kind"))
3520        .and_then(Value::as_str)
3521        .filter(|kind| !kind.trim().is_empty())
3522        .map(str::to_owned)
3523}
3524
3525fn kweb_node_draft(node: &PlannedNode) -> NodeDraft {
3526    NodeDraft {
3527        short_name: node.short_name.clone(),
3528        short_description: node.short_description.clone(),
3529        long_description: node.long_description.clone(),
3530        owner: node.owner.clone(),
3531        fixed_connections: node.fixed_connections.clone(),
3532        recent_connections: node.recent_connections.clone(),
3533        objects: node.objects.clone(),
3534    }
3535}
3536
3537fn planned_node(node: &KwebNode) -> PlannedNode {
3538    PlannedNode {
3539        short_name: node.short_name.clone(),
3540        short_description: node.short_description.clone(),
3541        long_description: node.long_description.clone(),
3542        owner: node.owner.clone(),
3543        fixed_connections: node
3544            .fixed_connections
3545            .iter()
3546            .map(|connection| connection.id.clone())
3547            .collect(),
3548        recent_connections: node
3549            .recent_connections
3550            .iter()
3551            .map(|connection| connection.id.clone())
3552            .collect(),
3553        objects: node.objects.clone(),
3554        attach_session_archive: true,
3555    }
3556}
3557
3558fn session_kind(session_type: &str, mode: &AgentMode) -> SessionKind {
3559    if matches!(mode, AgentMode::Ingress { .. }) {
3560        return SessionKind::HistoryIngress;
3561    }
3562    match session_type {
3563        "conversation" => SessionKind::Conversation,
3564        "telegram" => SessionKind::Telegram,
3565        "telegram-group" => SessionKind::TelegramGroup,
3566        "free-time" => SessionKind::SelfTime,
3567        "wakeup" => SessionKind::Other("wakeup".into()),
3568        "audio" => SessionKind::AudioIngress,
3569        other => SessionKind::Other(other.into()),
3570    }
3571}
3572
3573fn tool_instance(name: &str) -> String {
3574    if name == "LoadNodes" {
3575        return KWEB_TOOL_INSTANCE.into();
3576    }
3577    format!("{name}:{}", Uuid::new_v4())
3578}
3579
3580fn resolve_object_using(
3581    journal: &mut HistorySession,
3582    object_id: &str,
3583    read_canonical: impl FnOnce(&str) -> anyhow::Result<StoredFile>,
3584) -> anyhow::Result<ResolvedObject> {
3585    if object_id.starts_with("pending:") {
3586        let pending_id = PendingId::parse(object_id.to_owned())?;
3587        let location = journal
3588            .objects()
3589            .get(&pending_id)
3590            .cloned()
3591            .with_context(|| {
3592                format!("staged object {pending_id} does not exist in this session")
3593            })?;
3594        let transport_kind = staged_object_transport_kind(journal, &pending_id);
3595        let bytes = journal.read_object(&pending_id)?;
3596        anyhow::ensure!(
3597            bytes.len() as u64 == location.payload_len,
3598            "staged object {pending_id} declared {} bytes but resolved to {}",
3599            location.payload_len,
3600            bytes.len()
3601        );
3602        let fallback = format!("object-{}.bin", pending_id.number());
3603        Ok(ResolvedObject {
3604            object_id: pending_id.to_string(),
3605            bytes,
3606            file_name: sanitize_file_name(
3607                location.metadata.file_name.as_deref().unwrap_or_default(),
3608                &fallback,
3609            ),
3610            media_type: location.metadata.media_type,
3611            transport_kind,
3612        })
3613    } else {
3614        let canonical_id = object_id
3615            .parse::<ObjectId>()
3616            .with_context(|| format!("{object_id:?} is not an object ID"))?;
3617        let file = read_canonical(object_id)?;
3618        anyhow::ensure!(
3619            file.object_id == canonical_id,
3620            "object store returned {} while resolving {canonical_id}",
3621            file.object_id
3622        );
3623        Ok(ResolvedObject {
3624            object_id: canonical_id.to_string(),
3625            bytes: file.bytes,
3626            file_name: file.file_name,
3627            media_type: file.media_type,
3628            transport_kind: file.transport_kind,
3629        })
3630    }
3631}
3632
3633fn rust_binary_object_ids(arguments: &Value) -> anyhow::Result<Vec<String>> {
3634    let Some(object_ids) = arguments.get("objectIds") else {
3635        return Ok(Vec::new());
3636    };
3637    object_ids
3638        .as_array()
3639        .context("Rust-binary objectIds must be an array")?
3640        .iter()
3641        .map(|object_id| {
3642            object_id
3643                .as_str()
3644                .map(str::to_owned)
3645                .context("Rust-binary objectIds must contain only strings")
3646        })
3647        .collect()
3648}
3649
3650fn web_library_object_id(arguments: &Value) -> anyhow::Result<String> {
3651    arguments
3652        .get("objectId")
3653        .and_then(Value::as_str)
3654        .filter(|object_id| !object_id.trim().is_empty())
3655        .map(str::to_owned)
3656        .context("Web-library attachment objectId must be a nonempty string")
3657}
3658
3659fn ingress_object_filename(value: Option<&str>, fallback: &str) -> String {
3660    value
3661        .filter(|value| !value.trim().is_empty())
3662        .unwrap_or(fallback)
3663        .to_owned()
3664}
3665
3666fn file_name_extension(file_name: &str) -> String {
3667    file_name
3668        .rsplit_once('.')
3669        .and_then(|(stem, extension)| {
3670            (!stem.is_empty() && !extension.is_empty()).then_some(extension)
3671        })
3672        .map(|extension| format!(".{extension}"))
3673        .unwrap_or_else(|| "(none)".into())
3674}
3675
3676fn render_user_file_metadata(
3677    ordinal: usize,
3678    object_id: &str,
3679    file_name: &str,
3680    media_type: &str,
3681    size_bytes: u64,
3682) -> String {
3683    format!(
3684        "User-provided file {ordinal}\nObject reference: {object_id}\nOriginal filename: {file_name}\nExtension: {}\nMIME type: {}\nSize: {size_bytes} bytes",
3685        file_name_extension(file_name),
3686        normalized_media_type(media_type),
3687    )
3688}
3689
3690fn authoritative_staged_file_metadata(
3691    journal: &HistorySession,
3692    pending_id: &PendingId,
3693) -> anyhow::Result<(String, String, u64)> {
3694    let location = journal
3695        .objects()
3696        .get(pending_id)
3697        .with_context(|| format!("user-provided object {pending_id} is not staged"))?;
3698    let fallback = format!("object-{}.bin", pending_id.number());
3699    let file_name = sanitize_file_name(
3700        location.metadata.file_name.as_deref().unwrap_or_default(),
3701        &fallback,
3702    );
3703    Ok((
3704        file_name,
3705        normalized_media_type(&location.metadata.media_type),
3706        location.payload_len,
3707    ))
3708}
3709
3710fn canonicalize_staged_file_descriptor(
3711    journal: &HistorySession,
3712    pending_id: &PendingId,
3713    descriptor: &mut Value,
3714) -> anyhow::Result<String> {
3715    let (file_name, media_type, size_bytes) =
3716        authoritative_staged_file_metadata(journal, pending_id)?;
3717    if !descriptor.is_object() {
3718        *descriptor = json!({});
3719    }
3720    descriptor["pendingId"] = json!(pending_id.to_string());
3721    descriptor["fileName"] = json!(file_name);
3722    descriptor["extension"] = json!(file_name_extension(&file_name));
3723    descriptor["mimeType"] = json!(media_type);
3724    descriptor["sizeBytes"] = json!(size_bytes);
3725    Ok(file_name)
3726}
3727
3728fn append_user_file_metadata(
3729    journal: &HistorySession,
3730    content: &mut BoxContent,
3731) -> anyhow::Result<()> {
3732    let mut blocks = Vec::with_capacity(content.objects.len());
3733    for (index, object_id) in content.objects.iter().enumerate() {
3734        let pending_id = PendingId::parse(object_id.clone())?;
3735        let (file_name, media_type, size_bytes) =
3736            authoritative_staged_file_metadata(journal, &pending_id)?;
3737        blocks.push(render_user_file_metadata(
3738            index + 1,
3739            object_id,
3740            &file_name,
3741            &media_type,
3742            size_bytes,
3743        ));
3744    }
3745    if blocks.is_empty() {
3746        return Ok(());
3747    }
3748    if !content.text.is_empty() && !content.text.ends_with('\n') {
3749        content.text.push_str("\n\n");
3750    }
3751    content.text.push_str(&blocks.join("\n\n"));
3752    Ok(())
3753}
3754
3755fn authoritative_object_filename(metadata: &ObjectMetadata) -> anyhow::Result<&str> {
3756    metadata
3757        .file_name
3758        .as_deref()
3759        .filter(|value| !value.trim().is_empty())
3760        .with_context(|| {
3761            format!(
3762                "staged object {} has no authoritative filename",
3763                metadata.pending_id
3764            )
3765        })
3766}
3767
3768fn staged_telegram_group_media(
3769    journal: &HistorySession,
3770    chat_id: i64,
3771    message_id: i64,
3772) -> Option<(PendingId, ObjectMetadata, u64)> {
3773    journal.objects().iter().find_map(|(pending_id, location)| {
3774        let transport = &location.metadata.transport;
3775        (transport.get("source").and_then(Value::as_str) == Some("telegram-group")
3776            && transport.get("chatId").and_then(Value::as_i64) == Some(chat_id)
3777            && transport.get("messageId").and_then(Value::as_i64) == Some(message_id))
3778        .then(|| {
3779            (
3780                pending_id.clone(),
3781                location.metadata.clone(),
3782                location.payload_len,
3783            )
3784        })
3785    })
3786}
3787
3788fn render_staged_telegram_group_media(
3789    pending_id: &PendingId,
3790    metadata: &ObjectMetadata,
3791    size_bytes: u64,
3792    message_id: i64,
3793    reused: bool,
3794) -> anyhow::Result<String> {
3795    Ok(format!(
3796        "{} Telegram group media\nMessage ID: {message_id}\nObject: {pending_id}\nKind: {}\nOriginal filename: {}\nExtension: {}\nMIME type: {}\nSize: {size_bytes} bytes\n\nUse Object {pending_id} with AnnotateMedia, GenerateImage (for images), TranscribeAudio, or ExtractDocumentText as appropriate.",
3797        if reused {
3798            "Reused already-staged"
3799        } else {
3800            "Staged"
3801        },
3802        metadata
3803            .transport
3804            .get("kind")
3805            .and_then(Value::as_str)
3806            .unwrap_or("media"),
3807        authoritative_object_filename(metadata)?,
3808        file_name_extension(authoritative_object_filename(metadata)?),
3809        normalized_media_type(&metadata.media_type),
3810    ))
3811}
3812
3813fn normalized_media_type(value: &str) -> String {
3814    value
3815        .split(';')
3816        .next()
3817        .unwrap_or(value)
3818        .trim()
3819        .to_ascii_lowercase()
3820}
3821
3822fn validate_annotation_media(model: &str, media_type: &str) -> anyhow::Result<()> {
3823    match model {
3824        "gpt-5.6" | "gpt-5.6-sol" | "gpt-5.6-terra" | "gpt-5.6-luna" => anyhow::ensure!(
3825            media_type.starts_with("image/"),
3826            "{model} annotations accept images only"
3827        ),
3828        "gemini-2.5-flash" | "gemini-3.1-flash-lite" | "gemini-3.1-pro-preview" => anyhow::ensure!(
3829            media_type.starts_with("image/")
3830                || media_type.starts_with("audio/")
3831                || media_type.starts_with("video/")
3832                || media_type == "application/ogg",
3833            "{model} annotations accept images, audio, or video only"
3834        ),
3835        _ => anyhow::bail!("unsupported exact annotation model {model}"),
3836    }
3837    Ok(())
3838}
3839
3840fn validate_image_model(model: &str) -> anyhow::Result<()> {
3841    anyhow::ensure!(
3842        matches!(model, "gpt-image-2" | "gemini-3-pro-image"),
3843        "unsupported exact image model {model}; use gpt-image-2 or gemini-3-pro-image"
3844    );
3845    Ok(())
3846}
3847
3848fn image_extension(media_type: &str) -> &'static str {
3849    match normalized_media_type(media_type).as_str() {
3850        "image/jpeg" => "jpg",
3851        "image/webp" => "webp",
3852        _ => "png",
3853    }
3854}
3855
3856fn optional_object_id_array(
3857    value: &Value,
3858    key: &str,
3859    maximum: usize,
3860) -> anyhow::Result<Vec<String>> {
3861    let Some(values) = value.get(key) else {
3862        return Ok(Vec::new());
3863    };
3864    let values = values
3865        .as_array()
3866        .with_context(|| format!("{key} must be an array"))?;
3867    anyhow::ensure!(
3868        values.len() <= maximum,
3869        "{key} must contain at most {maximum} object IDs"
3870    );
3871    let ids = values
3872        .iter()
3873        .map(|value| {
3874            let id = value
3875                .as_str()
3876                .with_context(|| format!("{key} entries must be strings"))?;
3877            anyhow::ensure!(
3878                !id.trim().is_empty() && id.chars().count() <= 64,
3879                "{key} entries must contain between 1 and 64 characters"
3880            );
3881            Ok(id.to_owned())
3882        })
3883        .collect::<anyhow::Result<Vec<_>>>()?;
3884    anyhow::ensure!(
3885        ids.iter().collect::<HashSet<_>>().len() == ids.len(),
3886        "{key} must not contain duplicate object IDs"
3887    );
3888    Ok(ids)
3889}
3890
3891fn optional_delivery_file_name(value: &Value, key: &str) -> anyhow::Result<Option<String>> {
3892    let Some(file_name) = value.get(key) else {
3893        return Ok(None);
3894    };
3895    let file_name = file_name
3896        .as_str()
3897        .with_context(|| format!("{key} must be a string"))?;
3898    kcode_telegram_session_coordinator::validate_file_name(file_name)?;
3899    Ok(Some(file_name.to_owned()))
3900}
3901
3902fn validate_transcription_model(model: &str) -> anyhow::Result<()> {
3903    anyhow::ensure!(
3904        matches!(
3905            model,
3906            "gpt-4o-transcribe"
3907                | "gemini-2.5-flash"
3908                | "gemini-3.1-flash-lite"
3909                | "gemini-3.1-pro-preview"
3910        ),
3911        "unsupported exact transcription model {model}"
3912    );
3913    Ok(())
3914}
3915
3916fn validate_transcribable_audio(media_type: &str) -> anyhow::Result<()> {
3917    anyhow::ensure!(
3918        matches!(
3919            media_type,
3920            "audio/flac"
3921                | "audio/x-flac"
3922                | "audio/m4a"
3923                | "audio/mp3"
3924                | "audio/mp4"
3925                | "audio/mpeg"
3926                | "audio/mpga"
3927                | "audio/ogg"
3928                | "audio/opus"
3929                | "audio/wav"
3930                | "audio/x-wav"
3931                | "audio/webm"
3932                | "application/ogg"
3933        ),
3934        "TranscribeAudio accepts a supported FLAC, MP3, MP4, M4A, OGG, WAV, or WebM audio object only"
3935    );
3936    Ok(())
3937}
3938
3939fn validate_extractable_document(media_type: &str, file_name: &str) -> anyhow::Result<()> {
3940    let media_type = normalized_media_type(media_type);
3941    let extension = file_name
3942        .rsplit_once('.')
3943        .map(|(_, extension)| extension)
3944        .map(str::to_ascii_lowercase);
3945    let supported_media_type = media_type.starts_with("text/")
3946        || matches!(
3947            media_type.as_str(),
3948            "application/pdf"
3949                | "application/msword"
3950                | "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
3951                | "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
3952                | "application/vnd.ms-excel"
3953                | "application/vnd.ms-excel.sheet.binary.macroenabled.12"
3954                | "application/vnd.oasis.opendocument.spreadsheet"
3955                | "application/json"
3956                | "application/xml"
3957                | "application/yaml"
3958                | "application/x-yaml"
3959        );
3960    anyhow::ensure!(
3961        supported_media_type
3962            || matches!(
3963                extension.as_deref(),
3964                Some(
3965                    "pdf"
3966                        | "doc"
3967                        | "docx"
3968                        | "xlsx"
3969                        | "xls"
3970                        | "xlsb"
3971                        | "ods"
3972                        | "csv"
3973                        | "tsv"
3974                        | "txt"
3975                        | "md"
3976                        | "json"
3977                        | "yaml"
3978                        | "yml"
3979                        | "xml"
3980                )
3981            ),
3982        "ExtractDocumentText accepts supported PDF, Word, spreadsheet, and text-family objects only"
3983    );
3984    Ok(())
3985}
3986
3987fn decode_data_url(value: &str) -> anyhow::Result<(String, Vec<u8>)> {
3988    let value = value
3989        .strip_prefix("data:")
3990        .context("object data URL must begin with data:")?;
3991    let (header, data) = value.split_once(',').context("invalid object data URL")?;
3992    let media_type = header
3993        .strip_suffix(";base64")
3994        .context("object data URL must use Base64")?;
3995    Ok((media_type.into(), BASE64.decode(data)?))
3996}
3997
3998fn attachment_metadata_without_payload(value: &Value) -> Value {
3999    let mut value = value.clone();
4000    if let Some(object) = value.as_object_mut() {
4001        object.remove("dataUrl");
4002        object.remove("text");
4003    }
4004    value
4005}
4006
4007fn message_metadata_without_attachment_payloads(value: &Value) -> Value {
4008    let mut value = value.clone();
4009    let Some(object) = value.as_object_mut() else {
4010        return value;
4011    };
4012    if let Some(attachments) = object.get_mut("attachments").and_then(Value::as_array_mut) {
4013        for attachment in attachments {
4014            *attachment = attachment_metadata_without_payload(attachment);
4015        }
4016    }
4017    if let Some(media) = object.get_mut("media") {
4018        *media = attachment_metadata_without_payload(media);
4019    }
4020    value
4021}
4022
4023#[derive(Debug, Eq, PartialEq)]
4024struct BoxTextObject {
4025    box_id: BoxId,
4026    pending_id: PendingId,
4027    reused: bool,
4028}
4029
4030fn stage_box_text_objects(
4031    journal: &mut HistorySession,
4032    box_ids: &[BoxId],
4033    recorded_at: &str,
4034) -> anyhow::Result<Vec<BoxTextObject>> {
4035    let selections = box_ids
4036        .iter()
4037        .map(|box_id| {
4038            let state = journal
4039                .state()
4040                .box_state(*box_id)
4041                .with_context(|| format!("box {box_id} does not exist"))?;
4042            anyhow::ensure!(state.active, "box {box_id} is not active");
4043            anyhow::ensure!(
4044                !state.canonical.content.text.is_empty(),
4045                "box {box_id} has no text content"
4046            );
4047            Ok((
4048                *box_id,
4049                state.name.clone(),
4050                state.canonical.event_id,
4051                state.canonical.content.text.clone(),
4052            ))
4053        })
4054        .collect::<anyhow::Result<Vec<_>>>()?;
4055
4056    let mut objects = Vec::with_capacity(selections.len());
4057    for (box_id, box_name, canonical_event_id, text) in selections {
4058        if let Some(pending_id) = existing_box_text_object(journal, box_id, canonical_event_id) {
4059            objects.push(BoxTextObject {
4060                box_id,
4061                pending_id,
4062                reused: true,
4063            });
4064            continue;
4065        }
4066        let pending_id = journal.stage_object(
4067            recorded_at,
4068            BOX_TEXT_MEDIA_TYPE,
4069            Some(format!("box-{box_id}.txt")),
4070            json!({
4071                "source":BOX_TEXT_OBJECT_SOURCE,
4072                "boxId":box_id.0,
4073                "boxName":box_name,
4074                "canonicalEventId":canonical_event_id.0,
4075            }),
4076            text.as_bytes(),
4077        )?;
4078        objects.push(BoxTextObject {
4079            box_id,
4080            pending_id,
4081            reused: false,
4082        });
4083    }
4084    Ok(objects)
4085}
4086
4087fn existing_box_text_object(
4088    journal: &HistorySession,
4089    box_id: BoxId,
4090    canonical_event_id: EventId,
4091) -> Option<PendingId> {
4092    journal
4093        .objects()
4094        .iter()
4095        .find(|(_, location)| {
4096            let transport = &location.metadata.transport;
4097            transport.get("source").and_then(Value::as_str) == Some(BOX_TEXT_OBJECT_SOURCE)
4098                && transport.get("boxId").and_then(Value::as_u64) == Some(box_id.0)
4099                && transport.get("canonicalEventId").and_then(Value::as_u64)
4100                    == Some(canonical_event_id.0)
4101        })
4102        .map(|(pending_id, _)| pending_id.clone())
4103}
4104
4105fn render_box_text_objects(objects: &[BoxTextObject]) -> String {
4106    let mut text = String::from("Box text objects:");
4107    for object in objects {
4108        text.push_str(&format!("\nBox {}: {}", object.box_id, object.pending_id));
4109        if object.reused {
4110            text.push_str(" (already staged)");
4111        }
4112    }
4113    text.push_str(
4114        "\nThese pending object references resolve to canonical object IDs when the logical session commits.",
4115    );
4116    text
4117}
4118
4119fn call_ktool_description() -> &'static str {
4120    "Call one Kennedy Ktool. The provider function remains registered even if its explaining system-prompt box is dehydrated. Kennedy may display an object with an optional recipient-visible filename using {\"name\":\"EmitObject\",\"arguments\":{\"objectId\":\"AAECAwQF\",\"fileName\":\"report.pdf\"}}. She may make an out-of-band cold delivery to an authorized user's private Telegram chat, optionally with Kweb object attachments and per-attachment delivery filenames, from any session with {\"name\":\"SendTelegramDM\",\"arguments\":{\"user\":{\"telegramUserId\":42},\"message\":\"Exact message text.\",\"attachments\":[\"pending:1\",{\"objectId\":\"AAECAwQF\",\"fileName\":\"report.pdf\"}]}}. Kennedy may likewise send text and attachments to any known Telegram group, addressed by its canonical Kweb root, with {\"name\":\"SendTelegramGroupMessage\",\"arguments\":{\"group\":{\"rootNodeId\":\"AAAAAAAE\"},\"message\":\"Exact message text.\",\"attachments\":[\"pending:1\"]}}."
4121}
4122
4123fn validate_arguments(value: &Value, required: &[&str], optional: &[&str]) -> anyhow::Result<()> {
4124    let map = value
4125        .as_object()
4126        .context("arguments must be a JSON object")?;
4127    let allowed = required
4128        .iter()
4129        .chain(optional)
4130        .copied()
4131        .collect::<HashSet<_>>();
4132    anyhow::ensure!(
4133        required.iter().all(|key| map.contains_key(*key))
4134            && map.keys().all(|key| allowed.contains(key.as_str())),
4135        "expected exactly: {}{}",
4136        required.join(", "),
4137        if optional.is_empty() {
4138            String::new()
4139        } else {
4140            format!(" (optional: {})", optional.join(", "))
4141        }
4142    );
4143    Ok(())
4144}
4145
4146fn positive_integer(value: &Value, key: &str) -> anyhow::Result<u64> {
4147    value
4148        .get(key)
4149        .and_then(Value::as_u64)
4150        .filter(|value| *value > 0)
4151        .with_context(|| format!("{key} must be a positive integer"))
4152}
4153
4154fn box_id(value: &Value, key: &str) -> anyhow::Result<BoxId> {
4155    positive_integer(value, key).map(BoxId)
4156}
4157
4158fn box_id_array(value: &Value, key: &str) -> anyhow::Result<Vec<BoxId>> {
4159    let ids = value
4160        .get(key)
4161        .and_then(Value::as_array)
4162        .with_context(|| format!("{key} must be an array"))?
4163        .iter()
4164        .map(|value| {
4165            value
4166                .as_u64()
4167                .filter(|value| *value > 0)
4168                .map(BoxId)
4169                .with_context(|| format!("{key} must contain only positive integers"))
4170        })
4171        .collect::<anyhow::Result<Vec<_>>>()?;
4172    anyhow::ensure!(!ids.is_empty(), "{key} must contain at least one box ID");
4173    let unique = ids.iter().copied().collect::<HashSet<_>>();
4174    anyhow::ensure!(
4175        unique.len() == ids.len(),
4176        "{key} must not contain duplicate box IDs"
4177    );
4178    Ok(ids)
4179}
4180
4181fn canonical_id(value: &str) -> anyhow::Result<String> {
4182    value
4183        .parse::<NodeId>()
4184        .with_context(|| format!("{value:?} is not a canonical node ID"))?;
4185    Ok(value.into())
4186}
4187
4188fn canonical_node_id_array(
4189    value: &Value,
4190    key: &str,
4191    maximum: usize,
4192) -> anyhow::Result<Vec<String>> {
4193    let ids = canonical_node_ids(value, key)?;
4194    anyhow::ensure!(
4195        ids.len() <= maximum,
4196        "{key} must contain at most {maximum} identifiers"
4197    );
4198    Ok(ids)
4199}
4200
4201fn canonical_node_id_list(value: &Value, key: &str) -> anyhow::Result<Vec<String>> {
4202    let ids = canonical_node_ids(value, key)?;
4203    anyhow::ensure!(
4204        !ids.is_empty(),
4205        "{key} must contain at least one identifier"
4206    );
4207    Ok(ids)
4208}
4209
4210fn canonical_node_ids(value: &Value, key: &str) -> anyhow::Result<Vec<String>> {
4211    let ids = value
4212        .get(key)
4213        .and_then(Value::as_array)
4214        .with_context(|| format!("{key} must be an array"))?
4215        .iter()
4216        .map(|value| {
4217            canonical_id(
4218                value
4219                    .as_str()
4220                    .with_context(|| format!("{key} entries must be canonical node IDs"))?,
4221            )
4222        })
4223        .collect::<anyhow::Result<Vec<_>>>()?;
4224    anyhow::ensure!(
4225        ids.iter().collect::<HashSet<_>>().len() == ids.len(),
4226        "{key} must not contain duplicate identifiers"
4227    );
4228    Ok(ids)
4229}
4230
4231fn parse_resource_id(value: &str) -> anyhow::Result<String> {
4232    if value.starts_with("pending:") {
4233        PendingId::parse(value.to_owned())?;
4234        Ok(value.into())
4235    } else if matches!(value, "self" | "unowned") {
4236        Ok(value.into())
4237    } else {
4238        canonical_id(value)
4239    }
4240}
4241
4242fn resource_id(value: &Value, key: &str) -> anyhow::Result<String> {
4243    parse_resource_id(
4244        value
4245            .get(key)
4246            .and_then(Value::as_str)
4247            .with_context(|| format!("{key} must be a node identifier"))?,
4248    )
4249}
4250
4251fn resource_id_array(value: &Value, key: &str, minimum: usize) -> anyhow::Result<Vec<String>> {
4252    let ids = value
4253        .get(key)
4254        .and_then(Value::as_array)
4255        .with_context(|| format!("{key} must be an array"))?
4256        .iter()
4257        .map(|value| parse_resource_id(value.as_str().context("node identifier must be a string")?))
4258        .collect::<anyhow::Result<Vec<_>>>()?;
4259    anyhow::ensure!(
4260        ids.len() >= minimum && ids.iter().collect::<HashSet<_>>().len() == ids.len(),
4261        "{key} has invalid length or duplicate identifiers"
4262    );
4263    Ok(ids)
4264}
4265
4266fn string_value(value: &Value, key: &str) -> anyhow::Result<String> {
4267    value
4268        .get(key)
4269        .and_then(Value::as_str)
4270        .map(str::to_owned)
4271        .with_context(|| format!("{key} must be a string"))
4272}
4273
4274fn node_text_arguments(
4275    value: &Value,
4276    short_name_key: &str,
4277    short_description_key: &str,
4278    long_description_key: &str,
4279) -> anyhow::Result<(String, String, String)> {
4280    let short_name = string_value(value, short_name_key)?;
4281    let short_description = string_value(value, short_description_key)?;
4282    let long_description = string_value(value, long_description_key)?;
4283    let short_name_characters = short_name.chars().count();
4284    let short_description_characters = short_description.chars().count();
4285    let long_description_characters = long_description.chars().count();
4286    anyhow::ensure!(
4287        (MIN_NODE_SHORT_NAME_CHARACTERS..=MAX_NODE_SHORT_NAME_CHARACTERS)
4288            .contains(&short_name_characters),
4289        "{short_name_key} must contain between {MIN_NODE_SHORT_NAME_CHARACTERS} and \
4290         {MAX_NODE_SHORT_NAME_CHARACTERS} characters; received {short_name_characters}. \
4291         Correct it and retry."
4292    );
4293    anyhow::ensure!(
4294        short_description_characters <= MAX_NODE_SHORT_DESCRIPTION_CHARACTERS,
4295        "{short_description_key} must be at most {MAX_NODE_SHORT_DESCRIPTION_CHARACTERS} \
4296         characters; received {short_description_characters}. Shorten it and retry."
4297    );
4298    anyhow::ensure!(
4299        long_description_characters <= MAX_NODE_LONG_DESCRIPTION_CHARACTERS,
4300        "{long_description_key} must be at most {MAX_NODE_LONG_DESCRIPTION_CHARACTERS} \
4301         characters; received {long_description_characters}. Shorten it and retry."
4302    );
4303    Ok((short_name, short_description, long_description))
4304}
4305
4306fn nonempty_string(value: &Value, key: &str, max: usize) -> anyhow::Result<String> {
4307    let value = string_value(value, key)?;
4308    let trimmed = value.trim();
4309    anyhow::ensure!(
4310        !trimmed.is_empty() && trimmed.chars().count() <= max,
4311        "{key} must contain between 1 and {max} characters"
4312    );
4313    Ok(trimmed.into())
4314}
4315
4316fn nonblank_string(value: &Value, key: &str) -> anyhow::Result<String> {
4317    let value = string_value(value, key)?;
4318    anyhow::ensure!(!value.trim().is_empty(), "{key} must not be blank");
4319    Ok(value)
4320}
4321
4322fn bounded_nonempty_string(value: &Value, key: &str, max: usize) -> anyhow::Result<String> {
4323    let value = string_value(value, key)?;
4324    anyhow::ensure!(
4325        !value.trim().is_empty() && value.chars().count() <= max,
4326        "{key} must contain between 1 and {max} characters"
4327    );
4328    Ok(value)
4329}
4330
4331fn now() -> String {
4332    Utc::now().to_rfc3339()
4333}
4334
4335fn runtime_description(runtime: &RuntimeModel, current_time: DateTime<Utc>) -> String {
4336    format!(
4337        "You are currently running on {} with {} thinking mode. The current date and time is {}.",
4338        runtime.model,
4339        runtime.reasoning_effort,
4340        human_utc_datetime(current_time)
4341    )
4342}
4343
4344fn human_utc_datetime(value: DateTime<Utc>) -> String {
4345    let day = value.day();
4346    let suffix = match day % 100 {
4347        11..=13 => "th",
4348        _ => match day % 10 {
4349            1 => "st",
4350            2 => "nd",
4351            3 => "rd",
4352            _ => "th",
4353        },
4354    };
4355    let hour = match value.hour() % 12 {
4356        0 => 12,
4357        hour => hour,
4358    };
4359    let period = if value.hour() < 12 { "am" } else { "pm" };
4360    format!(
4361        "{} {day}{suffix}, {}, {hour}:{:02}{period} UTC",
4362        value.format("%B"),
4363        value.year(),
4364        value.minute()
4365    )
4366}
4367
4368fn deadline(value: &Value) -> Option<DateTime<Utc>> {
4369    value
4370        .get("deadlineAt")
4371        .and_then(Value::as_str)
4372        .and_then(|value| DateTime::parse_from_rfc3339(value).ok())
4373        .map(|value| value.with_timezone(&Utc))
4374}
4375
4376fn free_time_schedule(value: &Value) -> String {
4377    deadline(value)
4378        .map(|deadline| {
4379            format!(
4380                "The self-time deadline is {}.",
4381                human_utc_datetime(deadline)
4382            )
4383        })
4384        .unwrap_or_else(|| "The self-time deadline was not supplied.".into())
4385}
4386
4387fn free_time_opening(value: &Value) -> String {
4388    let custom = value
4389        .get("customPrompt")
4390        .and_then(Value::as_str)
4391        .unwrap_or_default();
4392    if custom.trim().is_empty() {
4393        "Begin this self-time session.".into()
4394    } else {
4395        format!("Begin this self-time session.\n\nRequested focus:\n{custom}")
4396    }
4397}
4398
4399fn wakeup_opening(marker: DateTime<Utc>) -> String {
4400    format!(
4401        "The time is {} UTC on {}. Determine whether you have any messages you would like to send the user",
4402        marker.format("%H:%M"),
4403        marker.format("%Y-%m-%d"),
4404    )
4405}
4406
4407fn controller_box_name(mode: &AgentMode) -> &'static str {
4408    match mode {
4409        AgentMode::Conversation => "Turn continuation",
4410        AgentMode::FreeTime => "Self-time continuation",
4411        AgentMode::Wakeup => "Wakeup continuation",
4412        AgentMode::Ingress { .. } => "History-ingress continuation",
4413    }
4414}
4415
4416fn controller_message(mode: &AgentMode, free_time: &Value) -> String {
4417    match mode {
4418        AgentMode::Conversation => {
4419            "Continue the turn. Use tools if needed, then answer the user.".into()
4420        }
4421        AgentMode::FreeTime => format!("Continue self time. {}", free_time_schedule(free_time)),
4422        AgentMode::Wakeup => {
4423            "Continue this autonomous wakeup session. Sending no message is a valid outcome; call EndSession when you have finished.".into()
4424        }
4425        AgentMode::Ingress { .. } => {
4426            "You are in a solo history-ingress session; there is no user to receive a conversational response. If you have completed all useful memory work, call EndSession now through the native call_ktool function with no arguments. A normal response does not end this session. If work remains, continue it with tools, then call EndSession when finished.".into()
4427        }
4428    }
4429}
4430
4431#[cfg(test)]
4432mod tests {
4433    use super::*;
4434
4435    #[test]
4436    fn nonblank_string_accepts_long_input_unchanged_and_rejects_blank_input() {
4437        let prompt = "x".repeat(4_001);
4438        let arguments = json!({"prompt": prompt});
4439        assert_eq!(
4440            nonblank_string(&arguments, "prompt").unwrap(),
4441            arguments["prompt"].as_str().unwrap()
4442        );
4443
4444        assert!(nonblank_string(&json!({"prompt":" \n\t"}), "prompt").is_err());
4445    }
4446}