Skip to main content

kcode_kennedy_sessions/
lib.rs

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