Skip to main content

kcode_kennedy_sessions/
lib.rs

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