agentic-python-coder 3.4.3__tar.gz → 3.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/PKG-INFO +4 -2
  2. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/kernel.py +209 -45
  3. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/mcp_server.py +167 -81
  4. agentic_python_coder-3.5.0/coder/src/agentic_python_coder/runner.py +235 -0
  5. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/pyproject.toml +9 -2
  6. agentic_python_coder-3.4.3/coder/src/agentic_python_coder/runner.py +0 -334
  7. agentic_python_coder-3.4.3/coder/tests/test_kernel.py +0 -212
  8. agentic_python_coder-3.4.3/coder/tests/test_library_api.py +0 -384
  9. agentic_python_coder-3.4.3/coder/tests/test_llm_config.py +0 -90
  10. agentic_python_coder-3.4.3/coder/tests/test_mcp_hardening.py +0 -275
  11. agentic_python_coder-3.4.3/coder/tests/test_mcp_server.py +0 -429
  12. agentic_python_coder-3.4.3/coder/tests/test_react_loop.py +0 -306
  13. agentic_python_coder-3.4.3/coder/tests/test_todo_flag_integration.py +0 -131
  14. agentic_python_coder-3.4.3/coder/tests/test_todo_tool_availability.py +0 -58
  15. agentic_python_coder-3.4.3/coder/tests/test_tool_descriptions.py +0 -74
  16. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/.gitignore +0 -0
  17. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/LICENSE +0 -0
  18. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/README.md +0 -0
  19. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/.gitignore +0 -0
  20. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/prompts/system.md +0 -0
  21. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/prompts/system_todo.md +0 -0
  22. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/__init__.py +0 -0
  23. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/agent.py +0 -0
  24. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/cli.py +0 -0
  25. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/examples/__init__.py +0 -0
  26. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/examples/clingo/README.md +0 -0
  27. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/examples/clingo/clingo.md +0 -0
  28. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/examples/clingo/sample_tasks/bird_reasoning.md +0 -0
  29. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/examples/clingo/sample_tasks/diagnosis.md +0 -0
  30. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/examples/clingo/sample_tasks/simple_coloring.md +0 -0
  31. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/examples/clingo/sample_tasks/stable_marriage.md +0 -0
  32. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/examples/clingo/sample_tasks/sudoku_mini.md +0 -0
  33. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/examples/cpmpy/README.md +0 -0
  34. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/examples/cpmpy/cpmpy.md +0 -0
  35. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/examples/cpmpy/sample_tasks/magic_square.md +0 -0
  36. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/examples/cpmpy/sample_tasks/n_queens.md +0 -0
  37. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/examples/regex/README.md +0 -0
  38. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/examples/regex/regex.md +0 -0
  39. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/examples/regex/sample_tasks/email_extraction.md +0 -0
  40. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/examples/regex/sample_tasks/phone_validation.md +0 -0
  41. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/examples/regex/sample_tasks/url_parsing.md +0 -0
  42. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/llm.py +0 -0
  43. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/models/deepseek31.json +0 -0
  44. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/models/gemini25.json +0 -0
  45. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/models/gemini31.json +0 -0
  46. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/models/gemini3flash.json +0 -0
  47. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/models/gemini4argon.json +0 -0
  48. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/models/gpt52.json +0 -0
  49. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/models/gpt56sol.json +0 -0
  50. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/models/gpt56terra.json +0 -0
  51. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/models/gpt6astra.json +0 -0
  52. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/models/gpt6sol.json +0 -0
  53. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/models/grok47.json +0 -0
  54. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/models/opus45.json +0 -0
  55. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/models/opus48.json +0 -0
  56. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/models/opus55.json +0 -0
  57. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/models/qwen3.json +0 -0
  58. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/models/sonnet45.json +0 -0
  59. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/models/sonnet46.json +0 -0
  60. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/models/sonnet5.json +0 -0
  61. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/models/sonnet55.json +0 -0
  62. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/project_md.py +0 -0
  63. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/coder/src/agentic_python_coder/tools.py +0 -0
  64. {agentic_python_coder-3.4.3 → agentic_python_coder-3.5.0}/examples/clingo/clingo.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentic-python-coder
3
- Version: 3.4.3
3
+ Version: 3.5.0
4
4
  Summary: A lightweight Python coding agent that writes, executes, and iterates on code through natural language instructions
5
5
  Author: Stefan Szeider
6
6
  License: Apache-2.0
@@ -17,9 +17,11 @@ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
17
17
  Classifier: Topic :: Software Development :: Code Generators
18
18
  Requires-Python: >=3.13
19
19
  Requires-Dist: ipykernel>=7.3.0
20
+ Requires-Dist: jsonschema>=4.20.0
20
21
  Requires-Dist: jupyter-client>=8.9.1
21
- Requires-Dist: mcp<2,>=1.28.1
22
+ Requires-Dist: mcp<3,>=2
22
23
  Requires-Dist: openai>=2.45.0
24
+ Requires-Dist: packaging>=22
23
25
  Requires-Dist: python-dotenv>=1.2.2
24
26
  Requires-Dist: pyyaml>=6.0.3
25
27
  Requires-Dist: rich>=15.0.0
@@ -21,6 +21,7 @@ from queue import Empty
21
21
  from typing import Any, Dict, List, Optional
22
22
 
23
23
  from jupyter_client import KernelManager
24
+ from jupyter_client.kernelspec import KernelSpecManager
24
25
 
25
26
  log_level = os.environ.get("CODER_LOG_LEVEL", "WARNING").upper()
26
27
  logging.basicConfig(
@@ -45,14 +46,50 @@ class KernelState:
45
46
  last_used: float = field(default_factory=time.time)
46
47
 
47
48
 
49
+ def _dead_output() -> Dict[str, Any]:
50
+ return {
51
+ "stdout": "",
52
+ "stderr": "",
53
+ "result": None,
54
+ "error": "Kernel died during execution",
55
+ }
56
+
57
+
48
58
  # Kernel registry and global lock
49
59
  _kernels: Dict[str, Optional[KernelState]] = {}
50
60
  _kernels_lock = threading.Lock()
51
61
 
62
+
63
+ @dataclass
64
+ class _CrashRecord:
65
+ """Restart configuration of a kernel whose process died."""
66
+
67
+ packages: List[str]
68
+ cwd: Optional[str]
69
+
70
+
71
+ # Kernels whose process died, by ID, so that restart_kernel() can bring them
72
+ # back with the same packages and cwd. Guarded by _kernels_lock.
73
+ _crashed: Dict[str, _CrashRecord] = {}
74
+
75
+ # ANSI escape sequences (IPython colours its tracebacks)
76
+ _ANSI_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
77
+
52
78
  # Suppress Jupyter warnings
53
79
  warnings.filterwarnings("ignore", category=DeprecationWarning)
54
80
 
55
81
 
82
+ def _native_kernel_spec_manager() -> KernelSpecManager:
83
+ """Kernelspec manager that only knows ipykernel's built-in "python3" spec.
84
+
85
+ With the default search path, a user or system kernelspec named
86
+ "python3" (Anaconda, Jupyter installs) would replace the interpreter,
87
+ environment and launch arguments. The built-in spec launches
88
+ ``sys.executable -m ipykernel_launcher -f {connection_file}``.
89
+ """
90
+ return KernelSpecManager(kernel_dirs=[])
91
+
92
+
56
93
  class UVKernelManager(KernelManager):
57
94
  """KernelManager that can wrap kernel launch with UV for dynamic packages."""
58
95
 
@@ -157,7 +194,11 @@ class PythonKernel:
157
194
  self.with_packages = with_packages or []
158
195
 
159
196
  # Set kernel's working directory
160
- kernel_kwargs = {}
197
+ # Send the kernel's fd 1 to our stderr: inherited stdout is the
198
+ # JSON-RPC pipe under ipython_mcp, and native code (e.g. solver
199
+ # progress output) writing to fd 1 would corrupt the protocol.
200
+ # print() is unaffected (ipykernel routes it over iopub).
201
+ kernel_kwargs = {"stdout": 2}
161
202
  if cwd:
162
203
  kernel_kwargs["cwd"] = cwd
163
204
 
@@ -166,11 +207,17 @@ class PythonKernel:
166
207
  if with_packages is not None:
167
208
  logger.info(f"Starting UV kernel with packages: {with_packages}")
168
209
  self.km = UVKernelManager(
169
- with_packages=with_packages, cwd=cwd, kernel_name="python3"
210
+ with_packages=with_packages,
211
+ cwd=cwd,
212
+ kernel_name="python3",
213
+ kernel_spec_manager=_native_kernel_spec_manager(),
170
214
  )
171
215
  else:
172
216
  logger.info("Starting standard kernel (no dynamic packages)")
173
- self.km = KernelManager(kernel_name="python3")
217
+ self.km = KernelManager(
218
+ kernel_name="python3",
219
+ kernel_spec_manager=_native_kernel_spec_manager(),
220
+ )
174
221
 
175
222
  # Start the kernel
176
223
  logger.debug(f"Starting kernel in directory: {cwd}")
@@ -196,30 +243,35 @@ class PythonKernel:
196
243
 
197
244
  def _inject_startup_code(self):
198
245
  """Inject safety and configuration code: block input(), set matplotlib, etc."""
246
+ # Runs inside a function so its imports do not leak into the user
247
+ # namespace (code relying on them would fail when run standalone)
199
248
  startup_code = """
200
- import sys
201
- import warnings
202
- import builtins
203
- from IPython.core.interactiveshell import InteractiveShell
204
-
205
- # Configure IPython to display last expression value
206
- InteractiveShell.ast_node_interactivity = 'last_expr'
207
-
208
- # Suppress pkg_resources deprecation warning from CPMpy
209
- warnings.filterwarnings('ignore', message='pkg_resources is deprecated', category=UserWarning)
210
- warnings.filterwarnings('ignore', message='.*pkg_resources.*', category=DeprecationWarning)
211
-
212
- # Block interactive input() - not supported in MCP/automated sessions
213
- def _blocked_input(*args, **kwargs):
214
- raise RuntimeError("Interactive input() not supported")
215
- builtins.input = _blocked_input
216
-
217
- # Set matplotlib to non-interactive backend if available
218
- try:
219
- import matplotlib
220
- matplotlib.use('Agg')
221
- except ImportError:
222
- pass
249
+ def __coder_startup():
250
+ import warnings
251
+ import builtins
252
+ from IPython.core.interactiveshell import InteractiveShell
253
+
254
+ # Configure IPython to display last expression value
255
+ InteractiveShell.ast_node_interactivity = 'last_expr'
256
+
257
+ # Suppress pkg_resources deprecation warning from CPMpy
258
+ warnings.filterwarnings('ignore', message='pkg_resources is deprecated', category=UserWarning)
259
+ warnings.filterwarnings('ignore', message='.*pkg_resources.*', category=DeprecationWarning)
260
+
261
+ # Block interactive input() - not supported in MCP/automated sessions
262
+ def _blocked_input(*args, **kwargs):
263
+ raise RuntimeError("Interactive input() not supported")
264
+ builtins.input = _blocked_input
265
+
266
+ # Set matplotlib to non-interactive backend if available
267
+ try:
268
+ import matplotlib
269
+ matplotlib.use('Agg')
270
+ except ImportError:
271
+ pass
272
+
273
+ __coder_startup()
274
+ del __coder_startup
223
275
  """
224
276
  self._execute_raw(startup_code)
225
277
 
@@ -234,20 +286,53 @@ except ImportError:
234
286
  except Exception:
235
287
  pass # Best effort cleanup
236
288
 
237
- def _execute_raw(self, code: str, deadline_timeout: int = 60) -> Dict[str, str]:
289
+ def _alive(self) -> bool:
290
+ """True if the kernel process runs (False once shut down/retired)."""
291
+ km = self.km if hasattr(self, "km") else None
292
+ return km is not None and km.is_alive()
293
+
294
+ def _execute_raw(
295
+ self,
296
+ code: str,
297
+ deadline_timeout: int = 60,
298
+ store_history: bool = True,
299
+ interrupt_on_timeout: bool = True,
300
+ ) -> Dict[str, str]:
238
301
  """Execute code without locking (internal use only).
239
302
 
240
303
  Args:
241
304
  code: Python code to execute
242
305
  deadline_timeout: Hard deadline in seconds for entire execution
306
+ store_history: Record the cell in IPython history
307
+ interrupt_on_timeout: Interrupt the kernel when the deadline hits
243
308
 
244
309
  Returns:
245
310
  Dict with stdout, stderr, result, and error fields
246
311
  """
312
+ return self._execute_collect(
313
+ code, deadline_timeout, store_history, interrupt_on_timeout
314
+ )[0]
315
+
316
+ def _execute_collect(
317
+ self,
318
+ code: str,
319
+ deadline_timeout: int = 60,
320
+ store_history: bool = True,
321
+ interrupt_on_timeout: bool = True,
322
+ ) -> tuple:
323
+ """Like _execute_raw, but returns (output, completed).
324
+
325
+ completed is False when the kernel died or the deadline hit before
326
+ the kernel reported idle.
327
+ """
328
+ if self.kc is None or not self._alive():
329
+ # Shut down or retired by another caller while we waited
330
+ return _dead_output(), False
331
+
247
332
  # Send the execution request (silent=False to get execute_result)
248
333
  # allow_stdin=False prevents kernel from requesting input
249
334
  msg_id = self.kc.execute(
250
- code, silent=False, store_history=True, allow_stdin=False
335
+ code, silent=False, store_history=store_history, allow_stdin=False
251
336
  )
252
337
 
253
338
  # Collect output
@@ -275,17 +360,23 @@ except ImportError:
275
360
  elif msg_type == "execute_result":
276
361
  output["result"] = content["data"].get("text/plain", "")
277
362
  elif msg_type == "error":
278
- output["error"] = f"{content['ename']}: {content['evalue']}"
363
+ error = f"{content['ename']}: {content['evalue']}"
279
364
  # Include traceback for debugging
280
365
  if content.get("traceback"):
281
- output["error"] += "\n" + "\n".join(content["traceback"])
366
+ error += "\n" + "\n".join(content["traceback"])
367
+ output["error"] = _ANSI_RE.sub("", error)
282
368
  elif msg_type == "status" and content["execution_state"] == "idle":
283
369
  completed = True
284
370
  break # Only exit on idle status
285
371
  except Empty:
372
+ # No idle status will ever come from a dead kernel: report
373
+ # the crash now instead of waiting for the deadline
374
+ if not self._alive():
375
+ output["error"] = "Kernel died during execution"
376
+ return output, False
286
377
  continue # Keep waiting, don't break
287
378
 
288
- if not completed:
379
+ if not completed and interrupt_on_timeout:
289
380
  # Deadline hit while the kernel was still running: report a
290
381
  # timeout instead of silently returning partial output as success,
291
382
  # and interrupt so the kernel doesn't block the next execution
@@ -300,7 +391,7 @@ except ImportError:
300
391
  except Exception:
301
392
  pass # Best effort
302
393
 
303
- return output
394
+ return output, completed
304
395
 
305
396
  def execute(self, code: str, deadline_timeout: int = 60) -> Dict[str, str]:
306
397
  """Execute code with thread-safe locking.
@@ -342,6 +433,10 @@ except ImportError:
342
433
  try:
343
434
  if self.km.is_alive():
344
435
  self.km.shutdown_kernel(now=True)
436
+ else:
437
+ # Dead kernel: still release the control socket,
438
+ # zmq context and connection file
439
+ self.km.cleanup_resources()
345
440
  except Exception:
346
441
  pass
347
442
  self.km = None # Clear reference
@@ -364,6 +459,25 @@ except ImportError:
364
459
  # =============================================================================
365
460
 
366
461
 
462
+ def _retire_dead_kernel(kernel_id: str, state: KernelState) -> None:
463
+ """Drop a dead kernel from the registry, keeping its config for restart.
464
+
465
+ Releases the dead kernel's client channels and manager resources.
466
+ Callers must not hold the kernel's exec lock.
467
+ """
468
+ with _kernels_lock:
469
+ # Record only if this call removed this exact state: a delayed
470
+ # caller must not resurrect an ID that was reset or shut down since
471
+ if _kernels.get(kernel_id) is state:
472
+ _kernels.pop(kernel_id)
473
+ _crashed[kernel_id] = _CrashRecord(list(state.packages), state.cwd)
474
+ try:
475
+ with state.kernel._exec_lock:
476
+ state.kernel.shutdown()
477
+ except Exception:
478
+ pass # Best effort
479
+
480
+
367
481
  def create_kernel(
368
482
  kernel_id: Optional[str] = None,
369
483
  with_packages: Optional[List[str]] = None,
@@ -414,6 +528,7 @@ def create_kernel(
414
528
  )
415
529
  with _kernels_lock:
416
530
  _kernels[kernel_id] = state
531
+ _crashed.pop(kernel_id, None)
417
532
  logger.info(f"Created kernel {kernel_id}")
418
533
  return kernel_id
419
534
  except Exception:
@@ -449,9 +564,8 @@ def execute_in_kernel(
449
564
  raise KeyError(f"Kernel {kernel_id} is being created")
450
565
 
451
566
  # Check if alive before execution
452
- if not state.kernel.km.is_alive():
453
- with _kernels_lock:
454
- _kernels.pop(kernel_id, None)
567
+ if not state.kernel._alive():
568
+ _retire_dead_kernel(kernel_id, state)
455
569
  raise RuntimeError(f"Kernel {kernel_id} is dead")
456
570
 
457
571
  # Execute (uses per-kernel lock internally)
@@ -460,17 +574,54 @@ def execute_in_kernel(
460
574
  # Update last_used
461
575
  state.last_used = time.time()
462
576
 
463
- # Check if crashed during execution
464
- if not state.kernel.km.is_alive():
465
- with _kernels_lock:
466
- _kernels.pop(kernel_id, None)
467
- result["error"] = f"Kernel {kernel_id} crashed during execution"
577
+ # Check if crashed during execution (or retired by another caller)
578
+ if not state.kernel._alive():
579
+ _retire_dead_kernel(kernel_id, state)
580
+ result["error"] = (
581
+ f"Kernel {kernel_id} crashed during execution."
582
+ " Call python_reset to restart it."
583
+ )
468
584
 
469
585
  # Always include kernel_id in result
470
586
  result["kernel_id"] = kernel_id
471
587
  return result
472
588
 
473
589
 
590
+ def probe_kernel(
591
+ kernel_id: str, codes: List[str], timeout: int
592
+ ) -> Optional[List[Dict[str, Any]]]:
593
+ """Run introspection snippets in an idle kernel (for python_status).
594
+
595
+ Never waits behind or interrupts running user code: returns None when
596
+ the kernel is unknown, dead or busy; a dead kernel is retired. Snippets
597
+ are not stored in the IPython history. If a snippet does not complete,
598
+ the remaining ones are not sent and the results so far are returned.
599
+ """
600
+ with _kernels_lock:
601
+ state = _kernels.get(kernel_id)
602
+ if state is None:
603
+ return None
604
+ kernel = state.kernel
605
+ if not kernel._exec_lock.acquire(blocking=False):
606
+ return None # busy
607
+ results: List[Dict[str, Any]] = []
608
+ try:
609
+ if kernel._alive():
610
+ for code in codes:
611
+ output, completed = kernel._execute_collect(
612
+ code, timeout, store_history=False, interrupt_on_timeout=False
613
+ )
614
+ if not completed:
615
+ break
616
+ results.append(output)
617
+ finally:
618
+ kernel._exec_lock.release()
619
+ if not kernel._alive():
620
+ _retire_dead_kernel(kernel_id, state)
621
+ return None
622
+ return results
623
+
624
+
474
625
  def shutdown_kernel_by_id(kernel_id: str) -> bool:
475
626
  """Shutdown specific kernel.
476
627
 
@@ -479,6 +630,7 @@ def shutdown_kernel_by_id(kernel_id: str) -> bool:
479
630
  """
480
631
  with _kernels_lock:
481
632
  state = _kernels.pop(kernel_id, None)
633
+ _crashed.pop(kernel_id, None) # an explicit shutdown forgets a crash
482
634
 
483
635
  if state is None:
484
636
  return False
@@ -504,15 +656,15 @@ def interrupt_kernel_by_id(kernel_id: str) -> bool:
504
656
  return False
505
657
  state = _kernels[kernel_id]
506
658
 
507
- if state is None or not state.kernel.km.is_alive():
659
+ km = state.kernel.km if state is not None else None
660
+ if km is None or not km.is_alive():
508
661
  return False
509
662
 
510
- state.kernel.km.interrupt_kernel()
663
+ km.interrupt_kernel()
511
664
  time.sleep(0.5) # Grace period
512
665
 
513
- if not state.kernel.km.is_alive():
514
- with _kernels_lock:
515
- _kernels.pop(kernel_id, None)
666
+ if not state.kernel._alive():
667
+ _retire_dead_kernel(kernel_id, state)
516
668
  raise RuntimeError(f"Kernel {kernel_id} died from interrupt")
517
669
 
518
670
  return True
@@ -528,10 +680,21 @@ def restart_kernel(
528
680
  kernel_id: Kernel to restart.
529
681
  with_packages: New packages (None = keep existing).
530
682
 
683
+ A kernel that died (and was dropped from the registry) is restarted
684
+ with the packages and cwd it had.
685
+
531
686
  Raises:
532
687
  KeyError: If kernel not found.
533
688
  RuntimeError: If new kernel fails to start.
534
689
  """
690
+ with _kernels_lock:
691
+ crashed = None if kernel_id in _kernels else _crashed.get(kernel_id)
692
+ if crashed is not None:
693
+ packages = with_packages if with_packages is not None else crashed.packages
694
+ create_kernel(kernel_id, packages, crashed.cwd)
695
+ logger.info(f"Restarted crashed kernel {kernel_id}")
696
+ return
697
+
535
698
  with _kernels_lock:
536
699
  if kernel_id not in _kernels:
537
700
  raise KeyError(f"Unknown kernel: {kernel_id}")
@@ -623,6 +786,7 @@ def shutdown_all_kernels():
623
786
  """Shutdown all kernels. Called by atexit."""
624
787
  with _kernels_lock:
625
788
  kernel_ids = list(_kernels.keys())
789
+ _crashed.clear()
626
790
 
627
791
  for kid in kernel_ids:
628
792
  try: