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