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