Skip to main content

bamboo_agent_core/agent/
events.rs

1//! Agent event system for real-time streaming.
2//!
3//! This module defines the event types emitted during agent execution,
4//! which are streamed to clients via Server-Sent Events (SSE).
5//!
6//! # Event Types
7//!
8//! - [`AgentEvent`] - All possible agent execution events
9//! - [`TokenUsage`] - Token consumption statistics
10//! - [`TokenBudgetUsage`] - Detailed token budget information
11//!
12//! # Event Flow
13//!
14//! 1. **Token** events stream generated text
15//! 2. **ToolStart/ToolComplete** track tool execution
16//! 3. **TaskListUpdated** tracks progress
17//! 4. **TokenBudgetUpdated** reports context management
18//! 5. **Complete**, **Cancelled**, or **Error** ends the stream
19//!
20//! # Example
21//!
22//! ```javascript
23//! const eventSource = new EventSource('/api/v1/events/session-id');
24//! eventSource.onmessage = (event) => {
25//!   const data = JSON.parse(event.data);
26//!   switch (data.type) {
27//!     case 'token':
28//!       console.log('Token:', data.content);
29//!       break;
30//!     case 'complete':
31//!       console.log('Done!');
32//!       eventSource.close();
33//!       break;
34//!   }
35//! };
36//! ```
37
38use crate::tools::ToolResult;
39use bamboo_domain::{TaskItemStatus, TaskList};
40use chrono::{DateTime, Utc};
41use serde::{Deserialize, Serialize};
42
43/// Represents events emitted during agent execution.
44///
45/// These events are streamed to clients via SSE to provide real-time
46/// feedback on agent progress, tool execution, and completion.
47///
48/// # Variants
49///
50/// ## Text Generation
51/// - `Token` - Streaming text token
52/// - `ReasoningToken` - Streaming reasoning/thinking token (separate channel)
53///
54/// ## Tool Execution
55/// - `ToolStart` - Tool execution started
56/// - `ToolComplete` - Tool finished successfully
57/// - `ToolError` - Tool execution failed
58///
59/// ## User Interaction
60/// - `NeedClarification` - Agent needs user input
61///
62/// ## Progress Tracking
63/// - `TaskListUpdated` - Task list created or modified
64/// - `TaskListItemProgress` - Individual item progress
65/// - `TaskListCompleted` - All items completed
66/// - `TaskEvaluationStarted` - Task evaluation began
67/// - `TaskEvaluationCompleted` - Task evaluation finished
68///
69/// ## Context Management
70/// - `TokenBudgetUpdated` - Context budget changed
71/// - `ContextCompressionStatus` - Context compression lifecycle progress
72/// - `ContextSummarized` - Old messages summarized
73///
74/// ## Sub-agents (Async Spawn)
75/// - `SubAgentStarted` - A child session is created and scheduled to run
76/// - `SubAgentEvent` - Forwarded raw child event (full fidelity)
77/// - `SubAgentHeartbeat` - Periodic heartbeat while the child is running
78/// - `SubAgentCompleted` - Child session finished (completed/cancelled/error)
79///
80/// ## Terminal Events
81/// - `Complete` - Execution finished successfully
82/// - `Cancelled` - Execution was cancelled by the user
83/// - `Error` - Execution failed
84///
85/// # Serialization
86///
87/// Events are serialized as JSON with a `type` field for discrimination:
88/// ```json
89/// {"type": "token", "content": "Hello"}
90/// {"type": "complete", "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}}
91/// {"type": "cancelled", "message": "Agent execution cancelled by user"}
92/// ```
93#[derive(Debug, Clone, Serialize, Deserialize)]
94#[serde(tag = "type", rename_all = "snake_case")]
95pub enum AgentEvent {
96    /// Text token generated by the LLM.
97    Token {
98        /// Generated text content
99        content: String,
100    },
101
102    /// Reasoning/thinking token generated by the LLM.
103    ///
104    /// This is streamed separately from assistant answer tokens so the UI can
105    /// choose whether and how to display model reasoning traces.
106    ReasoningToken {
107        /// Generated reasoning content
108        content: String,
109    },
110
111    /// Streaming output emitted while a specific tool call is running.
112    ///
113    /// This is used to render "live output" inside a tool-call card in the UI
114    /// without mixing tool output into the assistant's main token stream.
115    ToolToken {
116        /// Tool call identifier that this output belongs to.
117        tool_call_id: String,
118        /// Output chunk.
119        content: String,
120    },
121
122    /// Tool execution started.
123    ToolStart {
124        /// Unique tool call identifier
125        tool_call_id: String,
126        /// Name of the tool being executed
127        tool_name: String,
128        /// Tool arguments (JSON)
129        arguments: serde_json::Value,
130    },
131
132    /// Tool execution completed successfully.
133    ToolComplete {
134        /// Tool call identifier
135        tool_call_id: String,
136        /// Tool execution result
137        result: ToolResult,
138    },
139
140    /// Tool execution failed.
141    ToolError {
142        /// Tool call identifier
143        tool_call_id: String,
144        /// Error message
145        error: String,
146    },
147
148    /// Structured lifecycle event for tool execution tracking.
149    ///
150    /// These events complement `ToolStart`/`ToolComplete`/`ToolError` with
151    /// richer metadata (mutability, auto-approval, wall-clock timing) and
152    /// are emitted by `ToolEmitter` (in `bamboo-agent-tools`).
153    ToolLifecycle {
154        /// Tool call identifier
155        tool_call_id: String,
156        /// Canonical tool name
157        tool_name: String,
158        /// Lifecycle phase: "begin", "finished", "error", "cancelled"
159        phase: String,
160        /// Wall-clock milliseconds since the call began (None for begin)
161        #[serde(skip_serializing_if = "Option::is_none")]
162        elapsed_ms: Option<u64>,
163        /// Whether the tool mutates state (writes files, runs commands)
164        is_mutating: bool,
165        /// Whether execution was auto-approved (no user prompt needed)
166        auto_approved: bool,
167        /// Human-readable summary
168        #[serde(skip_serializing_if = "Option::is_none")]
169        summary: Option<String>,
170        /// Error message (if phase == "error")
171        #[serde(skip_serializing_if = "Option::is_none")]
172        error: Option<String>,
173    },
174
175    /// Agent needs clarification from the user.
176    NeedClarification {
177        /// Question to ask the user
178        question: String,
179        /// Optional predefined options
180        options: Option<Vec<String>>,
181        /// Tool call identifier that triggered this clarification
182        #[serde(default, skip_serializing_if = "Option::is_none")]
183        tool_call_id: Option<String>,
184        /// Whether the user can provide a free-text response
185        #[serde(default = "default_allow_custom")]
186        allow_custom: bool,
187    },
188
189    /// Emitted when task list is created or updated.
190    TaskListUpdated {
191        /// Current task list state.
192        task_list: TaskList,
193    },
194
195    /// Emitted when a task item makes progress (delta update).
196    TaskListItemProgress {
197        /// Session identifier
198        session_id: String,
199        /// Item identifier
200        item_id: String,
201        /// New item status
202        status: TaskItemStatus,
203        /// Number of tool calls made
204        tool_calls_count: usize,
205        /// Item version (for optimistic concurrency)
206        version: u64,
207    },
208
209    /// Emitted when all task items are completed.
210    TaskListCompleted {
211        /// Session identifier
212        session_id: String,
213        /// Completion timestamp
214        completed_at: DateTime<Utc>,
215        /// Total agent rounds executed
216        total_rounds: u32,
217        /// Total tool calls made
218        total_tool_calls: usize,
219    },
220
221    /// Emitted when task evaluation starts.
222    TaskEvaluationStarted {
223        /// Session identifier
224        session_id: String,
225        /// Number of items to evaluate
226        items_count: usize,
227    },
228
229    /// Emitted when task evaluation completes.
230    TaskEvaluationCompleted {
231        /// Session identifier
232        session_id: String,
233        /// Number of items updated
234        updates_count: usize,
235        /// Evaluation reasoning
236        reasoning: String,
237    },
238
239    /// Emitted when token budget is prepared (after context truncation)
240    TokenBudgetUpdated {
241        /// Token budget details
242        usage: TokenBudgetUsage,
243    },
244
245    /// Emitted when host-side context compression lifecycle changes.
246    ContextCompressionStatus {
247        /// Compression phase label (for example: pre-turn, mid-turn).
248        phase: String,
249        /// Compression status: started | completed | failed | skipped
250        status: String,
251    },
252
253    /// Emitted when conversation context is summarized
254    ContextSummarized {
255        /// Generated summary text
256        summary: String,
257        /// Number of old messages summarized
258        messages_summarized: usize,
259        /// Tokens saved by summarization
260        tokens_saved: u32,
261        /// Context usage percentage before compression
262        #[serde(default)]
263        usage_before_percent: f64,
264        /// Context usage percentage after compression
265        #[serde(default)]
266        usage_after_percent: f64,
267        /// What triggered the compression: "auto" | "manual" | "critical"
268        #[serde(default)]
269        trigger_type: String,
270    },
271
272    /// Emitted when context pressure reaches warning or critical levels.
273    /// Frontend should display this to the user as a proactive notification.
274    ContextPressureNotification {
275        /// Context usage as a percentage of the context window.
276        percent: f64,
277        /// Severity level: "warning" (70%) or "critical" (90%).
278        level: String,
279        /// Human-readable message describing the pressure state.
280        message: String,
281    },
282
283    /// A child session was spawned from a parent session (async background job).
284    SubAgentStarted {
285        parent_session_id: String,
286        child_session_id: String,
287        /// Optional title (useful for UI lists).
288        #[serde(default, skip_serializing_if = "Option::is_none")]
289        title: Option<String>,
290    },
291
292    /// Forwarded raw child event to the parent session stream.
293    ///
294    /// Child sessions are not allowed to spawn further sessions, so this should not nest.
295    SubAgentEvent {
296        parent_session_id: String,
297        child_session_id: String,
298        event: Box<AgentEvent>,
299    },
300
301    /// Heartbeat emitted while a child session is running.
302    SubAgentHeartbeat {
303        parent_session_id: String,
304        child_session_id: String,
305        timestamp: DateTime<Utc>,
306    },
307
308    /// Child session finished (completed/cancelled/error).
309    SubAgentCompleted {
310        parent_session_id: String,
311        child_session_id: String,
312        /// One of: "completed" | "cancelled" | "error" | "skipped"
313        status: String,
314        #[serde(default, skip_serializing_if = "Option::is_none")]
315        error: Option<String>,
316    },
317
318    /// Plan mode was entered.
319    PlanModeEntered {
320        /// Session identifier
321        session_id: String,
322        /// Optional reason for entering plan mode
323        #[serde(default, skip_serializing_if = "Option::is_none")]
324        reason: Option<String>,
325        /// Previous permission mode before entering plan mode
326        pre_permission_mode: String,
327        /// RFC3339 timestamp when plan mode was entered.
328        entered_at: chrono::DateTime<chrono::Utc>,
329        /// Current plan mode phase/status.
330        status: bamboo_domain::PlanModeStatus,
331        /// Path to the persisted plan file, if already available.
332        #[serde(default, skip_serializing_if = "Option::is_none")]
333        plan_file_path: Option<String>,
334    },
335
336    /// Plan mode was exited.
337    PlanModeExited {
338        /// Session identifier
339        session_id: String,
340        /// Whether the exit was approved by the user
341        approved: bool,
342        /// The permission mode restored after exiting
343        restored_mode: String,
344        /// Plan content that was reviewed, if any
345        #[serde(default, skip_serializing_if = "Option::is_none")]
346        plan: Option<String>,
347    },
348
349    /// Plan file was updated.
350    PlanFileUpdated {
351        /// Session identifier
352        session_id: String,
353        /// Path to the plan file
354        file_path: String,
355        /// Summary of the plan content (truncated)
356        content_summary: String,
357    },
358
359    /// Runner progress update emitted at the start of each agent turn.
360    ///
361    /// Used to track live execution progress (round count, current activity)
362    /// for diagnostic visibility, especially for child sessions.
363    RunnerProgress {
364        /// Session identifier
365        session_id: String,
366        /// Current turn/round count
367        round_count: u32,
368    },
369
370    /// Session title was updated (auto-generated by backend or manually renamed via PATCH).
371    SessionTitleUpdated {
372        session_id: String,
373        title: String,
374        title_version: u64,
375        source: TitleSource,
376        updated_at: chrono::DateTime<chrono::Utc>,
377    },
378
379    /// Session pinned flag was toggled via PATCH.
380    ///
381    /// Replayable metadata event. `pinned` is an idempotent boolean so the
382    /// latest event wins; `updated_at` is used by the frontend to suppress
383    /// stale replays.
384    SessionPinnedUpdated {
385        session_id: String,
386        pinned: bool,
387        updated_at: chrono::DateTime<chrono::Utc>,
388    },
389
390    /// Execution run has started and the runner is now active.
391    ///
392    /// Emitted as the first event after a runner reservation succeeds,
393    /// before any token or tool events. Carries the `run_id` so the
394    /// frontend can correlate subsequent SSE events across reconnects.
395    ExecutionStarted {
396        /// Unique identifier for this execution run.
397        run_id: String,
398        /// Session identifier.
399        session_id: String,
400        /// ISO 8601 timestamp when the run started.
401        started_at: String,
402    },
403
404    /// Tool execution requires user approval before proceeding.
405    ///
406    /// Emitted when a permission checker determines that a tool call needs
407    /// explicit user confirmation (e.g., mutating operations in restricted
408    /// permission mode). The frontend should present the approval request and
409    /// either grant or deny it.
410    ToolApprovalRequested {
411        /// Unique identifier for the tool call awaiting approval.
412        tool_call_id: String,
413        /// Name of the tool being executed.
414        tool_name: String,
415        /// Parameters that were passed to the tool.
416        parameters: serde_json::Value,
417    },
418
419    /// Agent execution completed successfully.
420    Complete {
421        /// Final token usage statistics
422        usage: TokenUsage,
423    },
424
425    /// Agent execution was cancelled.
426    Cancelled {
427        /// Optional human-readable message explaining the cancellation.
428        #[serde(default, skip_serializing_if = "Option::is_none")]
429        message: Option<String>,
430    },
431
432    /// Agent execution failed.
433    Error {
434        /// Error message
435        message: String,
436    },
437}
438
439fn default_allow_custom() -> bool {
440    true
441}
442
443/// Source that triggered a session title update.
444#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
445#[serde(rename_all = "snake_case")]
446pub enum TitleSource {
447    Auto,
448    Manual,
449    Fallback,
450}
451
452/// Re-exported shared token usage type.
453///
454/// See [`bamboo_domain::TokenUsage`] for the canonical definition.
455pub use bamboo_domain::TokenUsage;
456
457pub use bamboo_domain::budget_types::TokenBudgetUsage;
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462    use bamboo_domain::{TaskItem, TaskItemStatus, TaskList};
463
464    fn sample_task_list() -> TaskList {
465        TaskList {
466            session_id: "session-1".to_string(),
467            title: "Task List".to_string(),
468            items: vec![TaskItem {
469                id: "task_1".to_string(),
470                description: "Implement event rename".to_string(),
471                status: TaskItemStatus::InProgress,
472                depends_on: Vec::new(),
473                notes: "Implementing".to_string(),
474                ..TaskItem::default()
475            }],
476            created_at: Utc::now(),
477            updated_at: Utc::now(),
478        }
479    }
480
481    #[test]
482    fn task_list_updated_serializes_with_task_names() {
483        let event = AgentEvent::TaskListUpdated {
484            task_list: sample_task_list(),
485        };
486
487        let value = serde_json::to_value(event).expect("event should serialize");
488        assert_eq!(value["type"], "task_list_updated");
489        assert!(value.get("task_list").is_some());
490        assert!(value.get("todo_list").is_none());
491    }
492
493    #[test]
494    fn cancelled_serializes_with_snake_case_type() {
495        let event = AgentEvent::Cancelled {
496            message: Some("Agent execution cancelled by user".to_string()),
497        };
498
499        let value = serde_json::to_value(event).expect("event should serialize");
500        assert_eq!(value["type"], "cancelled");
501        assert_eq!(
502            value["message"],
503            serde_json::Value::String("Agent execution cancelled by user".to_string())
504        );
505    }
506
507    #[test]
508    fn task_evaluation_completed_serializes_with_task_type() {
509        let event = AgentEvent::TaskEvaluationCompleted {
510            session_id: "session-1".to_string(),
511            updates_count: 2,
512            reasoning: "Updated statuses".to_string(),
513        };
514
515        let value = serde_json::to_value(event).expect("event should serialize");
516        assert_eq!(value["type"], "task_evaluation_completed");
517    }
518
519    #[test]
520    fn context_compression_status_serializes_with_phase_and_status() {
521        let event = AgentEvent::ContextCompressionStatus {
522            phase: "mid-turn".to_string(),
523            status: "started".to_string(),
524        };
525
526        let value = serde_json::to_value(event).expect("event should serialize");
527        assert_eq!(value["type"], "context_compression_status");
528        assert_eq!(value["phase"], "mid-turn");
529        assert_eq!(value["status"], "started");
530    }
531
532    #[test]
533    fn need_clarification_serializes_with_new_fields() {
534        let event = AgentEvent::NeedClarification {
535            question: "Continue?".to_string(),
536            options: Some(vec!["Yes".to_string(), "No".to_string()]),
537            tool_call_id: Some("tool-1".to_string()),
538            allow_custom: false,
539        };
540
541        let value = serde_json::to_value(event).expect("event should serialize");
542        assert_eq!(value["type"], "need_clarification");
543        assert_eq!(value["question"], "Continue?");
544        assert_eq!(value["options"], serde_json::json!(["Yes", "No"]));
545        assert_eq!(value["tool_call_id"], "tool-1");
546        assert_eq!(value["allow_custom"], false);
547    }
548
549    #[test]
550    fn need_clarification_deserializes_from_old_format_without_new_fields() {
551        let json = serde_json::json!({
552            "type": "need_clarification",
553            "question": "Continue?",
554            "options": ["Yes", "No"]
555        });
556
557        let event: AgentEvent =
558            serde_json::from_value(json).expect("should deserialize old format");
559        match event {
560            AgentEvent::NeedClarification {
561                question,
562                options,
563                tool_call_id,
564                allow_custom,
565            } => {
566                assert_eq!(question, "Continue?");
567                assert_eq!(options, Some(vec!["Yes".to_string(), "No".to_string()]));
568                assert_eq!(tool_call_id, None);
569                assert!(allow_custom); // default_allow_custom returns true
570            }
571            other => panic!("unexpected event: {other:?}"),
572        }
573    }
574
575    #[test]
576    fn need_clarification_deserializes_with_allow_custom_false() {
577        let json = serde_json::json!({
578            "type": "need_clarification",
579            "question": "Pick one",
580            "allow_custom": false
581        });
582
583        let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
584        match event {
585            AgentEvent::NeedClarification {
586                question,
587                options,
588                tool_call_id,
589                allow_custom,
590            } => {
591                assert_eq!(question, "Pick one");
592                assert_eq!(options, None);
593                assert_eq!(tool_call_id, None);
594                assert!(!allow_custom);
595            }
596            other => panic!("unexpected event: {other:?}"),
597        }
598    }
599
600    #[test]
601    fn plan_mode_entered_serializes_correctly() {
602        let entered_at = Utc::now();
603        let event = AgentEvent::PlanModeEntered {
604            session_id: "sess-1".to_string(),
605            reason: Some("Complex refactor".to_string()),
606            pre_permission_mode: "default".to_string(),
607            entered_at,
608            status: bamboo_domain::PlanModeStatus::Exploring,
609            plan_file_path: None,
610        };
611
612        let value = serde_json::to_value(event).expect("event should serialize");
613        assert_eq!(value["type"], "plan_mode_entered");
614        assert_eq!(value["session_id"], "sess-1");
615        assert_eq!(value["reason"], "Complex refactor");
616        assert_eq!(value["pre_permission_mode"], "default");
617        assert_eq!(value["status"], "exploring");
618        assert_eq!(value["entered_at"], entered_at.to_rfc3339());
619    }
620
621    #[test]
622    fn plan_mode_exited_serializes_correctly() {
623        let event = AgentEvent::PlanModeExited {
624            session_id: "sess-1".to_string(),
625            approved: true,
626            restored_mode: "accept_edits".to_string(),
627            plan: Some("# Plan\n1. Step one".to_string()),
628        };
629
630        let value = serde_json::to_value(event).expect("event should serialize");
631        assert_eq!(value["type"], "plan_mode_exited");
632        assert_eq!(value["session_id"], "sess-1");
633        assert_eq!(value["approved"], true);
634        assert_eq!(value["restored_mode"], "accept_edits");
635        assert_eq!(value["plan"], "# Plan\n1. Step one");
636    }
637
638    #[test]
639    fn plan_file_updated_serializes_correctly() {
640        let event = AgentEvent::PlanFileUpdated {
641            session_id: "sess-1".to_string(),
642            file_path: "/tmp/plans/sess-1.md".to_string(),
643            content_summary: "Implementation plan for feature X".to_string(),
644        };
645
646        let value = serde_json::to_value(event).expect("event should serialize");
647        assert_eq!(value["type"], "plan_file_updated");
648        assert_eq!(value["session_id"], "sess-1");
649        assert_eq!(value["file_path"], "/tmp/plans/sess-1.md");
650        assert_eq!(
651            value["content_summary"],
652            "Implementation plan for feature X"
653        );
654    }
655
656    #[test]
657    fn tool_approval_requested_serializes_correctly() {
658        let event = AgentEvent::ToolApprovalRequested {
659            tool_call_id: "call-abc".to_string(),
660            tool_name: "Write".to_string(),
661            parameters: serde_json::json!({"file_path": "/tmp/test.txt"}),
662        };
663
664        let value = serde_json::to_value(event).expect("event should serialize");
665        assert_eq!(value["type"], "tool_approval_requested");
666        assert_eq!(value["tool_call_id"], "call-abc");
667        assert_eq!(value["tool_name"], "Write");
668        assert_eq!(
669            value["parameters"],
670            serde_json::json!({"file_path": "/tmp/test.txt"})
671        );
672    }
673
674    #[test]
675    fn tool_approval_requested_deserializes_correctly() {
676        let json = serde_json::json!({
677            "type": "tool_approval_requested",
678            "tool_call_id": "call-xyz",
679            "tool_name": "Bash",
680            "parameters": {"command": "ls -la"}
681        });
682
683        let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
684        match event {
685            AgentEvent::ToolApprovalRequested {
686                tool_call_id,
687                tool_name,
688                parameters,
689            } => {
690                assert_eq!(tool_call_id, "call-xyz");
691                assert_eq!(tool_name, "Bash");
692                assert_eq!(parameters, serde_json::json!({"command": "ls -la"}));
693            }
694            other => panic!("unexpected event: {other:?}"),
695        }
696    }
697
698    #[test]
699    fn session_title_updated_round_trips_with_source_variants() {
700        use chrono::Utc;
701        let event = AgentEvent::SessionTitleUpdated {
702            session_id: "sess-1".to_string(),
703            title: "My title".to_string(),
704            title_version: 3,
705            source: TitleSource::Auto,
706            updated_at: Utc::now(),
707        };
708        let json = serde_json::to_string(&event).unwrap();
709        assert!(
710            json.contains("\"type\":\"session_title_updated\""),
711            "json: {json}"
712        );
713        assert!(json.contains("\"source\":\"auto\""), "json: {json}");
714        let _decoded: AgentEvent = serde_json::from_str(&json).unwrap();
715    }
716
717    #[test]
718    fn plan_mode_events_deserialize_without_optional_fields() {
719        let json = serde_json::json!({
720            "type": "plan_mode_entered",
721            "session_id": "sess-1",
722            "pre_permission_mode": "default",
723            "entered_at": "2025-01-01T00:00:00Z",
724            "status": "exploring"
725        });
726
727        let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
728        match event {
729            AgentEvent::PlanModeEntered {
730                session_id,
731                reason,
732                pre_permission_mode,
733                entered_at,
734                status,
735                plan_file_path,
736            } => {
737                assert_eq!(session_id, "sess-1");
738                assert_eq!(reason, None);
739                assert_eq!(pre_permission_mode, "default");
740                assert_eq!(entered_at.to_rfc3339(), "2025-01-01T00:00:00+00:00");
741                assert_eq!(status, bamboo_domain::PlanModeStatus::Exploring);
742                assert_eq!(plan_file_path, None);
743            }
744            other => panic!("unexpected event: {other:?}"),
745        }
746    }
747}