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