1#![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#[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 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 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 "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 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 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}