1#![allow(clippy::all)]
2
3use super::context::AgentContext;
27use super::tool_registry::ToolHandler;
28use async_trait::async_trait;
29use nexo_llm::ToolDef;
30use serde_json::{json, Map, Value};
31use std::path::PathBuf;
32
33pub const IPYNB_INDENT: usize = 1;
36
37pub struct NotebookEditTool;
38
39impl NotebookEditTool {
40 pub fn tool_def() -> ToolDef {
41 ToolDef {
42 name: "NotebookEdit".to_string(),
43 description: "Edit a single cell in a Jupyter notebook (.ipynb). Three edit modes: `replace` (default — overwrite the cell's source), `insert` (add a new cell after the anchor), `delete` (remove the cell). Round-trips through serde_json so unknown nbformat fields survive untouched. Code-cell replaces clear execution_count + outputs (the diff stays sane); markdown cells preserve all metadata.".to_string(),
44 parameters: json!({
45 "type": "object",
46 "properties": {
47 "notebook_path": {
48 "type": "string",
49 "description": "Absolute path to the .ipynb file."
50 },
51 "cell_id": {
52 "type": "string",
53 "description": "ID of the cell to operate on. Either a UUID-style id (notebook.cells[i].id) or a `cell-N` numeric index fallback. For `insert`, the new cell goes AFTER this anchor; omit (or empty) to insert at position 0."
54 },
55 "new_source": {
56 "type": "string",
57 "description": "Source code / markdown body. Required for `replace` + `insert`; ignored for `delete`."
58 },
59 "cell_type": {
60 "type": "string",
61 "enum": ["code", "markdown"],
62 "description": "Cell type — required for `insert`, optional for `replace` (defaults to the existing cell's type)."
63 },
64 "edit_mode": {
65 "type": "string",
66 "enum": ["replace", "insert", "delete"],
67 "description": "Defaults to `replace`."
68 }
69 },
70 "required": ["notebook_path"]
71 }),
72 }
73 }
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77enum EditMode {
78 Replace,
79 Insert,
80 Delete,
81}
82
83fn parse_edit_mode(s: Option<&str>) -> anyhow::Result<EditMode> {
84 match s.unwrap_or("replace") {
85 "replace" => Ok(EditMode::Replace),
86 "insert" => Ok(EditMode::Insert),
87 "delete" => Ok(EditMode::Delete),
88 other => Err(anyhow::anyhow!(
89 "edit_mode must be replace|insert|delete, got `{other}`"
90 )),
91 }
92}
93
94fn parse_cell_index(cell_id: &str) -> Option<usize> {
97 cell_id
98 .strip_prefix("cell-")
99 .and_then(|rest| rest.parse::<usize>().ok())
100}
101
102fn find_cell_index(cells: &[Value], cell_id: &str) -> Option<usize> {
106 for (idx, cell) in cells.iter().enumerate() {
107 if cell
108 .get("id")
109 .and_then(|v| v.as_str())
110 .map_or(false, |s| s == cell_id)
111 {
112 return Some(idx);
113 }
114 }
115 parse_cell_index(cell_id).filter(|&n| n < cells.len())
116}
117
118fn nbformat_supports_cell_id(notebook: &Value) -> bool {
119 let major = notebook
120 .get("nbformat")
121 .and_then(|v| v.as_u64())
122 .unwrap_or(0);
123 let minor = notebook
124 .get("nbformat_minor")
125 .and_then(|v| v.as_u64())
126 .unwrap_or(0);
127 major > 4 || (major == 4 && minor >= 5)
128}
129
130fn fresh_cell_id() -> String {
133 use std::time::{SystemTime, UNIX_EPOCH};
134 static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
138 let nanos = SystemTime::now()
139 .duration_since(UNIX_EPOCH)
140 .map(|d| d.subsec_nanos() as u64 ^ d.as_secs())
141 .unwrap_or(0);
142 let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
143 let mut value =
144 nanos.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ n.wrapping_mul(0xbf58_476d_1ce4_e5b9);
145 let alphabet: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
146 let mut out = String::with_capacity(12);
147 for _ in 0..12 {
148 let idx = (value % 36) as usize;
149 out.push(alphabet[idx] as char);
150 value /= 36;
151 if value == 0 {
152 value = nanos.wrapping_add(n).wrapping_mul(2654435769);
153 }
154 }
155 out
156}
157
158fn build_new_cell(cell_type: &str, source: &str, fresh_id: Option<String>) -> Value {
159 let mut cell = Map::new();
160 cell.insert("cell_type".to_string(), json!(cell_type));
161 if let Some(id) = fresh_id {
162 cell.insert("id".to_string(), json!(id));
163 }
164 cell.insert("source".to_string(), json!(source));
165 cell.insert("metadata".to_string(), json!({}));
166 if cell_type == "code" {
167 cell.insert("execution_count".to_string(), Value::Null);
168 cell.insert("outputs".to_string(), json!([]));
169 }
170 Value::Object(cell)
171}
172
173#[async_trait]
174impl ToolHandler for NotebookEditTool {
175 #[allow(unused_assignments)]
176 async fn call(&self, _ctx: &AgentContext, args: Value) -> anyhow::Result<Value> {
177 let notebook_path = args
178 .get("notebook_path")
179 .and_then(|v| v.as_str())
180 .ok_or_else(|| anyhow::anyhow!("NotebookEdit requires `notebook_path`"))?;
181 let path = PathBuf::from(notebook_path);
182 if !path.is_absolute() {
183 return Err(anyhow::anyhow!(
184 "NotebookEdit: `notebook_path` must be absolute"
185 ));
186 }
187 if path.extension().and_then(|s| s.to_str()) != Some("ipynb") {
188 return Err(anyhow::anyhow!(
189 "NotebookEdit: file must end in .ipynb (use FileEdit for other types)"
190 ));
191 }
192
193 let edit_mode = parse_edit_mode(args.get("edit_mode").and_then(|v| v.as_str()))?;
194 let cell_id = args.get("cell_id").and_then(|v| v.as_str()).unwrap_or("");
195 let new_source = args
196 .get("new_source")
197 .and_then(|v| v.as_str())
198 .unwrap_or("");
199 let cell_type = args.get("cell_type").and_then(|v| v.as_str());
200
201 if matches!(edit_mode, EditMode::Replace | EditMode::Insert)
202 && new_source.is_empty()
203 && args.get("new_source").is_none()
204 {
205 return Err(anyhow::anyhow!(
206 "NotebookEdit: `new_source` is required for replace/insert"
207 ));
208 }
209 if matches!(edit_mode, EditMode::Insert) && cell_type.is_none() {
210 return Err(anyhow::anyhow!(
211 "NotebookEdit: `cell_type` is required when edit_mode=insert"
212 ));
213 }
214
215 let raw = std::fs::read_to_string(&path)
216 .map_err(|e| anyhow::anyhow!("NotebookEdit: read failed: {e}"))?;
217 let mut notebook: Value = serde_json::from_str(&raw)
218 .map_err(|e| anyhow::anyhow!("NotebookEdit: invalid JSON: {e}"))?;
219 let nbformat_minor_5 = nbformat_supports_cell_id(¬ebook);
220
221 let language = notebook
222 .pointer("/metadata/language_info/name")
223 .and_then(|v| v.as_str())
224 .unwrap_or("python")
225 .to_string();
226
227 let cells = notebook
228 .get_mut("cells")
229 .and_then(|v| v.as_array_mut())
230 .ok_or_else(|| anyhow::anyhow!("NotebookEdit: notebook has no `cells` array"))?;
231
232 let original_cell_count = cells.len();
233 let mut effective_mode = edit_mode;
234 let mut effective_cell_type = cell_type.map(str::to_string);
235 let mut returned_cell_id: Option<String> = None;
236 let mut anchor_index: Option<usize> = None;
237
238 if cell_id.is_empty() {
242 if matches!(edit_mode, EditMode::Insert) {
243 anchor_index = Some(0);
244 } else {
245 return Err(anyhow::anyhow!(
246 "NotebookEdit: `cell_id` is required for {} (only insert may omit it)",
247 match edit_mode {
248 EditMode::Replace => "replace",
249 EditMode::Delete => "delete",
250 EditMode::Insert => unreachable!(),
251 }
252 ));
253 }
254 } else {
255 anchor_index = find_cell_index(cells, cell_id);
256 if anchor_index.is_none() {
257 let available_ids: Vec<String> = cells
258 .iter()
259 .enumerate()
260 .map(|(i, c)| {
261 c.get("id")
262 .and_then(|v| v.as_str())
263 .map(|s| s.to_string())
264 .unwrap_or_else(|| format!("cell-{i}"))
265 })
266 .take(10)
267 .collect();
268 return Err(anyhow::anyhow!(
269 "NotebookEdit: cell_id `{cell_id}` not found. Available (up to 10): {available}",
270 available = available_ids.join(", ")
271 ));
272 }
273 }
274
275 match effective_mode {
276 EditMode::Delete => {
277 let idx = anchor_index.unwrap();
278 cells.remove(idx);
279 returned_cell_id = Some(cell_id.to_string());
280 }
281 EditMode::Insert => {
282 let mut idx = anchor_index.unwrap();
283 if !cell_id.is_empty() {
284 idx += 1; }
286 let ct = effective_cell_type.clone().unwrap_or_else(|| "code".into());
287 let fresh_id = if nbformat_minor_5 {
288 Some(fresh_cell_id())
289 } else {
290 None
291 };
292 returned_cell_id = fresh_id.clone();
293 let cell = build_new_cell(&ct, new_source, fresh_id);
294 if idx > cells.len() {
295 return Err(anyhow::anyhow!(
296 "NotebookEdit: anchor index {idx} > total cells {}",
297 cells.len()
298 ));
299 }
300 cells.insert(idx, cell);
301 }
302 EditMode::Replace => {
303 let idx = anchor_index.unwrap();
304 if idx == cells.len() {
305 let ct = effective_cell_type.clone().unwrap_or_else(|| "code".into());
308 let fresh_id = if nbformat_minor_5 {
309 Some(fresh_cell_id())
310 } else {
311 None
312 };
313 returned_cell_id = fresh_id.clone();
314 cells.push(build_new_cell(&ct, new_source, fresh_id));
315 effective_mode = EditMode::Insert;
316 } else {
317 let target = cells.get_mut(idx).unwrap();
318 let target_obj = target
319 .as_object_mut()
320 .ok_or_else(|| anyhow::anyhow!("NotebookEdit: cell is not an object"))?;
321 target_obj.insert("source".to_string(), json!(new_source));
322 let current_type = target_obj
323 .get("cell_type")
324 .and_then(|v| v.as_str())
325 .unwrap_or("code")
326 .to_string();
327 if current_type == "code" {
328 target_obj.insert("execution_count".to_string(), Value::Null);
329 target_obj.insert("outputs".to_string(), json!([]));
330 }
331 if let Some(ct) = &effective_cell_type {
332 if ct != ¤t_type {
333 target_obj.insert("cell_type".to_string(), json!(ct));
334 }
335 } else {
336 effective_cell_type = Some(current_type);
337 }
338 returned_cell_id = target_obj
339 .get("id")
340 .and_then(|v| v.as_str())
341 .map(str::to_string);
342 }
343 }
344 }
345
346 let updated = pretty_indent(¬ebook, IPYNB_INDENT);
348 std::fs::write(&path, &updated)
349 .map_err(|e| anyhow::anyhow!("NotebookEdit: write failed: {e}"))?;
350
351 let total_cells = notebook
352 .get("cells")
353 .and_then(|v| v.as_array())
354 .map(|a| a.len())
355 .unwrap_or(0);
356
357 Ok(json!({
358 "notebook_path": path.display().to_string(),
359 "edit_mode": match effective_mode {
360 EditMode::Replace => "replace",
361 EditMode::Insert => "insert",
362 EditMode::Delete => "delete",
363 },
364 "cell_id": returned_cell_id,
365 "cell_type": effective_cell_type.unwrap_or_else(|| "code".into()),
366 "language": language,
367 "total_cells": total_cells,
368 "cells_delta": total_cells as i64 - original_cell_count as i64,
369 }))
370 }
371}
372
373fn pretty_indent(value: &Value, spaces: usize) -> String {
378 let indent = " ".repeat(spaces);
379 let mut out = Vec::new();
380 let mut ser = serde_json::Serializer::with_formatter(
381 &mut out,
382 serde_json::ser::PrettyFormatter::with_indent(indent.as_bytes()),
383 );
384 use serde::Serialize;
385 value.serialize(&mut ser).expect("serde to Vec never fails");
386 String::from_utf8(out).expect("serde emits valid UTF-8")
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392 use crate::session::SessionManager;
393 use nexo_broker::AnyBroker;
394 use nexo_config::types::agents::{
395 AgentConfig, AgentRuntimeConfig, DreamingYamlConfig, HeartbeatConfig, ModelConfig,
396 OutboundAllowlistConfig, WorkspaceGitConfig,
397 };
398 use std::sync::Arc;
399 use tempfile::TempDir;
400
401 fn ctx() -> AgentContext {
402 let cfg = AgentConfig {
403 id: "a".into(),
404 model: ModelConfig {
405 provider: "x".into(),
406 model: "y".into(),
407 },
408 plugins: Vec::new(),
409 heartbeat: HeartbeatConfig::default(),
410 config: AgentRuntimeConfig::default(),
411 system_prompt: String::new(),
412 workspace: String::new(),
413 skills: Vec::new(),
414 skills_dir: "./skills".into(),
415 skill_overrides: Default::default(),
416 transcripts_dir: String::new(),
417 dreaming: DreamingYamlConfig::default(),
418 workspace_git: WorkspaceGitConfig::default(),
419 tool_rate_limits: None,
420 tool_args_validation: None,
421 extra_docs: Vec::new(),
422 inbound_bindings: Vec::new(),
423 allowed_tools: Vec::new(),
424 sender_rate_limit: None,
425 allowed_delegates: Vec::new(),
426 accept_delegates_from: Vec::new(),
427 description: String::new(),
428 google_auth: None,
429 credentials: Default::default(),
430 link_understanding: serde_json::Value::Null,
431 web_search: serde_json::Value::Null,
432 pairing_policy: serde_json::Value::Null,
433 language: None,
434 locale_prompts: Default::default(),
435 outbound_allowlist: OutboundAllowlistConfig::default(),
436 context_optimization: None,
437 dispatch_policy: Default::default(),
438 plan_mode: Default::default(),
439 remote_triggers: Vec::new(),
440 lsp: nexo_config::types::lsp::LspPolicy::default(),
441 config_tool: nexo_config::types::config_tool::ConfigToolPolicy::default(),
442 team: nexo_config::types::team::TeamPolicy::default(),
443 proactive: Default::default(),
444 repl: Default::default(),
445 auto_dream: None,
446 assistant_mode: None,
447 away_summary: None,
448 brief: None,
449 channels: None,
450 auto_approve: false,
451 extract_memories: None,
452 event_subscribers: Vec::new(),
453 tenant_id: None,
454 extensions_config: std::collections::BTreeMap::new(),
455 active: true,
456 };
457 AgentContext::new(
458 "a",
459 Arc::new(cfg),
460 AnyBroker::local(),
461 Arc::new(SessionManager::new(std::time::Duration::from_secs(60), 8)),
462 )
463 }
464
465 fn sample_notebook() -> Value {
466 json!({
467 "cells": [
468 {
469 "cell_type": "code",
470 "id": "alpha",
471 "metadata": {},
472 "source": "print('hello')",
473 "execution_count": 7,
474 "outputs": [{"output_type": "stream", "name": "stdout", "text": "hello\n"}]
475 },
476 {
477 "cell_type": "markdown",
478 "id": "beta",
479 "metadata": {},
480 "source": "# header"
481 }
482 ],
483 "metadata": {
484 "language_info": {"name": "python"},
485 "kernelspec": {"name": "python3", "display_name": "Python 3"}
486 },
487 "nbformat": 4,
488 "nbformat_minor": 5,
489 "x_unknown_field": "must round-trip"
490 })
491 }
492
493 fn write_notebook(dir: &TempDir, name: &str, body: &Value) -> PathBuf {
494 let p = dir.path().join(name);
495 std::fs::write(&p, pretty_indent(body, IPYNB_INDENT)).unwrap();
496 p
497 }
498
499 fn read_notebook(p: &PathBuf) -> Value {
500 let raw = std::fs::read_to_string(p).unwrap();
501 serde_json::from_str(&raw).unwrap()
502 }
503
504 #[tokio::test]
505 async fn replace_clears_outputs_and_execution_count() {
506 let dir = TempDir::new().unwrap();
507 let p = write_notebook(&dir, "n.ipynb", &sample_notebook());
508 let res = NotebookEditTool
509 .call(
510 &ctx(),
511 json!({
512 "notebook_path": p.display().to_string(),
513 "cell_id": "alpha",
514 "new_source": "print('world')",
515 "edit_mode": "replace"
516 }),
517 )
518 .await
519 .unwrap();
520 assert_eq!(res["edit_mode"], "replace");
521 assert_eq!(res["cell_id"], "alpha");
522 let nb = read_notebook(&p);
523 let alpha = &nb["cells"][0];
524 assert_eq!(alpha["source"], "print('world')");
525 assert_eq!(alpha["execution_count"], Value::Null);
526 assert_eq!(alpha["outputs"].as_array().unwrap().len(), 0);
527 assert_eq!(nb["cells"][1]["source"], "# header");
529 assert_eq!(nb["x_unknown_field"], "must round-trip");
531 }
532
533 #[tokio::test]
534 async fn insert_after_anchor_grows_total_cells() {
535 let dir = TempDir::new().unwrap();
536 let p = write_notebook(&dir, "n.ipynb", &sample_notebook());
537 let res = NotebookEditTool
538 .call(
539 &ctx(),
540 json!({
541 "notebook_path": p.display().to_string(),
542 "cell_id": "alpha",
543 "new_source": "x = 1",
544 "cell_type": "code",
545 "edit_mode": "insert"
546 }),
547 )
548 .await
549 .unwrap();
550 assert_eq!(res["edit_mode"], "insert");
551 assert_eq!(res["cells_delta"], 1);
552 assert_eq!(res["total_cells"], 3);
553 let nb = read_notebook(&p);
554 assert_eq!(nb["cells"][0]["id"], "alpha");
556 assert_eq!(nb["cells"][1]["source"], "x = 1");
557 assert_eq!(nb["cells"][2]["id"], "beta");
558 assert!(nb["cells"][1]["id"].is_string());
560 }
561
562 #[tokio::test]
563 async fn insert_with_empty_cell_id_goes_to_position_zero() {
564 let dir = TempDir::new().unwrap();
565 let p = write_notebook(&dir, "n.ipynb", &sample_notebook());
566 NotebookEditTool
567 .call(
568 &ctx(),
569 json!({
570 "notebook_path": p.display().to_string(),
571 "cell_id": "",
572 "new_source": "import os",
573 "cell_type": "code",
574 "edit_mode": "insert"
575 }),
576 )
577 .await
578 .unwrap();
579 let nb = read_notebook(&p);
580 assert_eq!(nb["cells"][0]["source"], "import os");
581 assert_eq!(nb["cells"][1]["id"], "alpha");
582 }
583
584 #[tokio::test]
585 async fn delete_shrinks_total_cells() {
586 let dir = TempDir::new().unwrap();
587 let p = write_notebook(&dir, "n.ipynb", &sample_notebook());
588 let res = NotebookEditTool
589 .call(
590 &ctx(),
591 json!({
592 "notebook_path": p.display().to_string(),
593 "cell_id": "beta",
594 "edit_mode": "delete"
595 }),
596 )
597 .await
598 .unwrap();
599 assert_eq!(res["edit_mode"], "delete");
600 assert_eq!(res["cells_delta"], -1);
601 let nb = read_notebook(&p);
602 assert_eq!(nb["cells"].as_array().unwrap().len(), 1);
603 assert_eq!(nb["cells"][0]["id"], "alpha");
604 }
605
606 #[tokio::test]
607 async fn cell_n_index_fallback_when_no_uuid_id() {
608 let dir = TempDir::new().unwrap();
609 let mut nb = sample_notebook();
610 for cell in nb["cells"].as_array_mut().unwrap() {
613 cell.as_object_mut().unwrap().remove("id");
614 }
615 let p = write_notebook(&dir, "n.ipynb", &nb);
616 let res = NotebookEditTool
617 .call(
618 &ctx(),
619 json!({
620 "notebook_path": p.display().to_string(),
621 "cell_id": "cell-0",
622 "new_source": "y = 2",
623 "edit_mode": "replace"
624 }),
625 )
626 .await
627 .unwrap();
628 assert_eq!(res["edit_mode"], "replace");
629 let written = read_notebook(&p);
630 assert_eq!(written["cells"][0]["source"], "y = 2");
631 }
632
633 #[tokio::test]
634 async fn missing_cell_id_lists_available() {
635 let dir = TempDir::new().unwrap();
636 let p = write_notebook(&dir, "n.ipynb", &sample_notebook());
637 let err = NotebookEditTool
638 .call(
639 &ctx(),
640 json!({
641 "notebook_path": p.display().to_string(),
642 "cell_id": "imaginary",
643 "new_source": "noop",
644 "edit_mode": "replace"
645 }),
646 )
647 .await
648 .unwrap_err()
649 .to_string();
650 assert!(err.contains("not found"), "got: {err}");
651 assert!(err.contains("alpha"), "got: {err}");
652 }
653
654 #[tokio::test]
655 async fn refuses_non_ipynb_file() {
656 let dir = TempDir::new().unwrap();
657 let p = dir.path().join("plain.json");
658 std::fs::write(&p, "{}").unwrap();
659 let err = NotebookEditTool
660 .call(
661 &ctx(),
662 json!({
663 "notebook_path": p.display().to_string(),
664 "cell_id": "x",
665 "new_source": "y",
666 "edit_mode": "replace"
667 }),
668 )
669 .await
670 .unwrap_err()
671 .to_string();
672 assert!(err.contains(".ipynb"), "got: {err}");
673 }
674
675 #[tokio::test]
676 async fn refuses_relative_path() {
677 let err = NotebookEditTool
678 .call(
679 &ctx(),
680 json!({
681 "notebook_path": "notebooks/n.ipynb",
682 "cell_id": "x",
683 "new_source": "y",
684 "edit_mode": "replace"
685 }),
686 )
687 .await
688 .unwrap_err()
689 .to_string();
690 assert!(err.contains("absolute"), "got: {err}");
691 }
692
693 #[tokio::test]
694 async fn insert_requires_cell_type() {
695 let dir = TempDir::new().unwrap();
696 let p = write_notebook(&dir, "n.ipynb", &sample_notebook());
697 let err = NotebookEditTool
698 .call(
699 &ctx(),
700 json!({
701 "notebook_path": p.display().to_string(),
702 "cell_id": "alpha",
703 "new_source": "x",
704 "edit_mode": "insert"
705 }),
706 )
707 .await
708 .unwrap_err()
709 .to_string();
710 assert!(err.contains("cell_type"), "got: {err}");
711 }
712
713 #[tokio::test]
714 async fn parse_cell_index_works() {
715 assert_eq!(parse_cell_index("cell-3"), Some(3));
716 assert_eq!(parse_cell_index("cell-0"), Some(0));
717 assert_eq!(parse_cell_index("cell-foo"), None);
718 assert_eq!(parse_cell_index("alpha"), None);
719 }
720
721 #[tokio::test]
722 async fn fresh_cell_id_is_12_chars_lower_alnum() {
723 let id = fresh_cell_id();
724 assert_eq!(id.len(), 12);
725 assert!(id
726 .chars()
727 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()));
728 }
729
730 #[tokio::test]
731 async fn round_trip_preserves_unknown_fields() {
732 let dir = TempDir::new().unwrap();
733 let p = write_notebook(&dir, "n.ipynb", &sample_notebook());
734 NotebookEditTool
736 .call(
737 &ctx(),
738 json!({
739 "notebook_path": p.display().to_string(),
740 "cell_id": "alpha",
741 "new_source": "x = 1",
742 "edit_mode": "replace"
743 }),
744 )
745 .await
746 .unwrap();
747 let nb = read_notebook(&p);
748 assert_eq!(nb["x_unknown_field"], "must round-trip");
749 assert_eq!(nb["nbformat"], 4);
750 assert_eq!(nb["nbformat_minor"], 5);
751 assert_eq!(nb["metadata"]["kernelspec"]["name"], "python3");
752 }
753}