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