agentic-python-coder 3.4.4__py3-none-any.whl → 3.5.0__py3-none-any.whl

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.
@@ -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
 
@@ -170,11 +207,17 @@ class PythonKernel:
170
207
  if with_packages is not None:
171
208
  logger.info(f"Starting UV kernel with packages: {with_packages}")
172
209
  self.km = UVKernelManager(
173
- 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(),
174
214
  )
175
215
  else:
176
216
  logger.info("Starting standard kernel (no dynamic packages)")
177
- self.km = KernelManager(kernel_name="python3")
217
+ self.km = KernelManager(
218
+ kernel_name="python3",
219
+ kernel_spec_manager=_native_kernel_spec_manager(),
220
+ )
178
221
 
179
222
  # Start the kernel
180
223
  logger.debug(f"Starting kernel in directory: {cwd}")
@@ -200,30 +243,35 @@ class PythonKernel:
200
243
 
201
244
  def _inject_startup_code(self):
202
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)
203
248
  startup_code = """
204
- import sys
205
- import warnings
206
- import builtins
207
- from IPython.core.interactiveshell import InteractiveShell
208
-
209
- # Configure IPython to display last expression value
210
- InteractiveShell.ast_node_interactivity = 'last_expr'
211
-
212
- # Suppress pkg_resources deprecation warning from CPMpy
213
- warnings.filterwarnings('ignore', message='pkg_resources is deprecated', category=UserWarning)
214
- warnings.filterwarnings('ignore', message='.*pkg_resources.*', category=DeprecationWarning)
215
-
216
- # Block interactive input() - not supported in MCP/automated sessions
217
- def _blocked_input(*args, **kwargs):
218
- raise RuntimeError("Interactive input() not supported")
219
- builtins.input = _blocked_input
220
-
221
- # Set matplotlib to non-interactive backend if available
222
- try:
223
- import matplotlib
224
- matplotlib.use('Agg')
225
- except ImportError:
226
- 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
227
275
  """
228
276
  self._execute_raw(startup_code)
229
277
 
@@ -238,20 +286,53 @@ except ImportError:
238
286
  except Exception:
239
287
  pass # Best effort cleanup
240
288
 
241
- 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]:
242
301
  """Execute code without locking (internal use only).
243
302
 
244
303
  Args:
245
304
  code: Python code to execute
246
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
247
308
 
248
309
  Returns:
249
310
  Dict with stdout, stderr, result, and error fields
250
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
+
251
332
  # Send the execution request (silent=False to get execute_result)
252
333
  # allow_stdin=False prevents kernel from requesting input
253
334
  msg_id = self.kc.execute(
254
- code, silent=False, store_history=True, allow_stdin=False
335
+ code, silent=False, store_history=store_history, allow_stdin=False
255
336
  )
256
337
 
257
338
  # Collect output
@@ -279,17 +360,23 @@ except ImportError:
279
360
  elif msg_type == "execute_result":
280
361
  output["result"] = content["data"].get("text/plain", "")
281
362
  elif msg_type == "error":
282
- output["error"] = f"{content['ename']}: {content['evalue']}"
363
+ error = f"{content['ename']}: {content['evalue']}"
283
364
  # Include traceback for debugging
284
365
  if content.get("traceback"):
285
- output["error"] += "\n" + "\n".join(content["traceback"])
366
+ error += "\n" + "\n".join(content["traceback"])
367
+ output["error"] = _ANSI_RE.sub("", error)
286
368
  elif msg_type == "status" and content["execution_state"] == "idle":
287
369
  completed = True
288
370
  break # Only exit on idle status
289
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
290
377
  continue # Keep waiting, don't break
291
378
 
292
- if not completed:
379
+ if not completed and interrupt_on_timeout:
293
380
  # Deadline hit while the kernel was still running: report a
294
381
  # timeout instead of silently returning partial output as success,
295
382
  # and interrupt so the kernel doesn't block the next execution
@@ -304,7 +391,7 @@ except ImportError:
304
391
  except Exception:
305
392
  pass # Best effort
306
393
 
307
- return output
394
+ return output, completed
308
395
 
309
396
  def execute(self, code: str, deadline_timeout: int = 60) -> Dict[str, str]:
310
397
  """Execute code with thread-safe locking.
@@ -346,6 +433,10 @@ except ImportError:
346
433
  try:
347
434
  if self.km.is_alive():
348
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()
349
440
  except Exception:
350
441
  pass
351
442
  self.km = None # Clear reference
@@ -368,6 +459,25 @@ except ImportError:
368
459
  # =============================================================================
369
460
 
370
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
+
371
481
  def create_kernel(
372
482
  kernel_id: Optional[str] = None,
373
483
  with_packages: Optional[List[str]] = None,
@@ -418,6 +528,7 @@ def create_kernel(
418
528
  )
419
529
  with _kernels_lock:
420
530
  _kernels[kernel_id] = state
531
+ _crashed.pop(kernel_id, None)
421
532
  logger.info(f"Created kernel {kernel_id}")
422
533
  return kernel_id
423
534
  except Exception:
@@ -453,9 +564,8 @@ def execute_in_kernel(
453
564
  raise KeyError(f"Kernel {kernel_id} is being created")
454
565
 
455
566
  # Check if alive before execution
456
- if not state.kernel.km.is_alive():
457
- with _kernels_lock:
458
- _kernels.pop(kernel_id, None)
567
+ if not state.kernel._alive():
568
+ _retire_dead_kernel(kernel_id, state)
459
569
  raise RuntimeError(f"Kernel {kernel_id} is dead")
460
570
 
461
571
  # Execute (uses per-kernel lock internally)
@@ -464,17 +574,54 @@ def execute_in_kernel(
464
574
  # Update last_used
465
575
  state.last_used = time.time()
466
576
 
467
- # Check if crashed during execution
468
- if not state.kernel.km.is_alive():
469
- with _kernels_lock:
470
- _kernels.pop(kernel_id, None)
471
- 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
+ )
472
584
 
473
585
  # Always include kernel_id in result
474
586
  result["kernel_id"] = kernel_id
475
587
  return result
476
588
 
477
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
+
478
625
  def shutdown_kernel_by_id(kernel_id: str) -> bool:
479
626
  """Shutdown specific kernel.
480
627
 
@@ -483,6 +630,7 @@ def shutdown_kernel_by_id(kernel_id: str) -> bool:
483
630
  """
484
631
  with _kernels_lock:
485
632
  state = _kernels.pop(kernel_id, None)
633
+ _crashed.pop(kernel_id, None) # an explicit shutdown forgets a crash
486
634
 
487
635
  if state is None:
488
636
  return False
@@ -508,15 +656,15 @@ def interrupt_kernel_by_id(kernel_id: str) -> bool:
508
656
  return False
509
657
  state = _kernels[kernel_id]
510
658
 
511
- 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():
512
661
  return False
513
662
 
514
- state.kernel.km.interrupt_kernel()
663
+ km.interrupt_kernel()
515
664
  time.sleep(0.5) # Grace period
516
665
 
517
- if not state.kernel.km.is_alive():
518
- with _kernels_lock:
519
- _kernels.pop(kernel_id, None)
666
+ if not state.kernel._alive():
667
+ _retire_dead_kernel(kernel_id, state)
520
668
  raise RuntimeError(f"Kernel {kernel_id} died from interrupt")
521
669
 
522
670
  return True
@@ -532,10 +680,21 @@ def restart_kernel(
532
680
  kernel_id: Kernel to restart.
533
681
  with_packages: New packages (None = keep existing).
534
682
 
683
+ A kernel that died (and was dropped from the registry) is restarted
684
+ with the packages and cwd it had.
685
+
535
686
  Raises:
536
687
  KeyError: If kernel not found.
537
688
  RuntimeError: If new kernel fails to start.
538
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
+
539
698
  with _kernels_lock:
540
699
  if kernel_id not in _kernels:
541
700
  raise KeyError(f"Unknown kernel: {kernel_id}")
@@ -627,6 +786,7 @@ def shutdown_all_kernels():
627
786
  """Shutdown all kernels. Called by atexit."""
628
787
  with _kernels_lock:
629
788
  kernel_ids = list(_kernels.keys())
789
+ _crashed.clear()
630
790
 
631
791
  for kid in kernel_ids:
632
792
  try:
@@ -5,31 +5,33 @@ Supports multiple concurrent kernels via kernel_id parameter.
5
5
 
6
6
  import ast
7
7
  import asyncio
8
- import importlib.metadata
9
8
  import json
10
9
  import logging
11
10
  import os
12
11
  import signal
13
12
  import threading
13
+ from typing import List, Optional
14
14
 
15
+ import jsonschema
15
16
  from mcp.server import Server
16
17
  from mcp.server.stdio import stdio_server
17
18
  from mcp.types import CallToolResult, ListToolsResult, TextContent, Tool
19
+ from packaging.requirements import InvalidRequirement, Requirement
18
20
 
19
21
  from agentic_python_coder.kernel import (
20
22
  DEFAULT_KERNEL_ID,
21
23
  create_kernel,
22
24
  execute_in_kernel,
25
+ get_kernel_info,
23
26
  interrupt_kernel_by_id,
24
27
  kernel_exists,
25
28
  kill_all_kernel_process_groups,
26
29
  list_kernels,
30
+ probe_kernel,
27
31
  restart_kernel,
28
32
  shutdown_all_kernels,
29
33
  )
30
34
 
31
- _MCP_V2 = int(importlib.metadata.version("mcp").split(".")[0]) >= 2
32
-
33
35
  # Configure logging
34
36
  logger = logging.getLogger(__name__)
35
37
 
@@ -59,6 +61,39 @@ def truncate_output(result: dict) -> dict:
59
61
  return result
60
62
 
61
63
 
64
+ def _status_probe(expr: str) -> str:
65
+ """Code that writes repr(expr) to stdout.
66
+
67
+ Writing (instead of a last-expression value) keeps the probe out of the
68
+ user's ``_``/``Out``, ``__import__`` keeps it from binding names, and
69
+ sys.stdout.write survives a user-shadowed print.
70
+ """
71
+ return f"if True:\n __import__('sys').stdout.write(repr({expr}) + '\\n')\n"
72
+
73
+
74
+ def _distribution_name(requirement: str) -> Optional[str]:
75
+ """Distribution name of a requirement string ("pkg>=1", "pkg[extra]").
76
+
77
+ None when the string is not a PEP 508 requirement, e.g. a local path or
78
+ a VCS URL, whose distribution name is unknown before installation.
79
+ """
80
+ try:
81
+ return Requirement(requirement).name
82
+ except InvalidRequirement:
83
+ return None
84
+
85
+
86
+ def _package_versions_probe(packages: List[str]) -> str:
87
+ """Probe code writing {requirement: version or "?"} to stdout."""
88
+ pairs = [(p, _distribution_name(p)) for p in packages]
89
+ return _status_probe(
90
+ "{req: '?' if name is None else getattr(next(iter("
91
+ "__import__('importlib.metadata').metadata.distributions(name=name)),"
92
+ " None), 'version', '?')"
93
+ f" for req, name in {pairs!r}}}"
94
+ )
95
+
96
+
62
97
  def get_kernel_id(arguments: dict) -> str:
63
98
  """Get kernel_id from arguments, defaulting to DEFAULT_KERNEL_ID."""
64
99
  return arguments.get("kernel_id") or DEFAULT_KERNEL_ID
@@ -312,60 +347,57 @@ async def call_tool(name: str, arguments: dict):
312
347
  None,
313
348
  lambda: create_kernel(None, packages if packages else None),
314
349
  )
315
- elif kernel_exists(kernel_id):
316
- # kernel_id provided and exists: RESET it
317
- await loop.run_in_executor(
318
- None,
319
- lambda: restart_kernel(kernel_id, packages if packages else None),
320
- )
321
350
  else:
322
- # kernel_id provided but doesn't exist: error
323
- return [
324
- TextContent(
325
- type="text",
326
- text=json.dumps(
327
- {
328
- "success": False,
329
- "error": f"Kernel {kernel_id} not found. Omit kernel_id to create a new kernel.",
330
- }
351
+ # kernel_id provided: RESET it (also brings back a kernel
352
+ # that crashed, with its packages and cwd)
353
+ try:
354
+ await loop.run_in_executor(
355
+ None,
356
+ lambda: restart_kernel(
357
+ kernel_id, packages if packages else None
331
358
  ),
332
359
  )
333
- ]
360
+ except KeyError:
361
+ # kernel_id provided but doesn't exist: error
362
+ return [
363
+ TextContent(
364
+ type="text",
365
+ text=json.dumps(
366
+ {
367
+ "success": False,
368
+ "error": f"Kernel {kernel_id} not found. Omit kernel_id to create a new kernel.",
369
+ }
370
+ ),
371
+ )
372
+ ]
373
+ # Without packages the kernel keeps the ones it had: report
374
+ # what it actually runs with
375
+ if not packages:
376
+ try:
377
+ packages = list(get_kernel_info(kernel_id)["packages"] or [])
378
+ except KeyError:
379
+ pass
334
380
 
335
381
  if packages:
336
- # Try to get package versions
337
- version_code = (
338
- """
339
- import importlib.metadata
340
- versions = {}
341
- for pkg in %r:
342
- try:
343
- versions[pkg] = importlib.metadata.version(pkg)
344
- except:
345
- versions[pkg] = "?"
346
- versions
347
- """
348
- % packages
349
- )
382
+ msg = f"Session started with packages: {', '.join(packages)}"
383
+ # Same probe discipline as python_status: no names bound, no
384
+ # history entry, _ untouched
350
385
  try:
351
- result = await execute_with_timeout(
352
- version_code, DEFAULT_TIMEOUT, kernel_id
386
+ probe = _package_versions_probe(packages)
387
+ results = await loop.run_in_executor(
388
+ None,
389
+ lambda: probe_kernel(kernel_id, [probe], DEFAULT_TIMEOUT),
353
390
  )
354
- if result.get("result"):
355
- versions = ast.literal_eval(result["result"])
391
+ result = results[0] if results else None
392
+ if result and result.get("error") is None and result.get("stdout"):
393
+ versions = ast.literal_eval(result["stdout"].strip())
356
394
  if isinstance(versions, dict):
357
395
  pkg_list = ", ".join(
358
396
  f"{p} {v}" for p, v in versions.items()
359
397
  )
360
398
  msg = f"Session started. Packages: {pkg_list}"
361
- else:
362
- msg = (
363
- f"Session started with packages: {', '.join(packages)}"
364
- )
365
- else:
366
- msg = f"Session started with packages: {', '.join(packages)}"
367
399
  except Exception:
368
- msg = f"Session started with packages: {', '.join(packages)}"
400
+ pass
369
401
  else:
370
402
  msg = "Session started (no packages)"
371
403
 
@@ -403,6 +435,8 @@ versions
403
435
  )
404
436
  except (TypeError, ValueError):
405
437
  timeout = DEFAULT_TIMEOUT
438
+ if not timeout > 0: # also catches NaN
439
+ timeout = DEFAULT_TIMEOUT
406
440
  kernel_id = get_kernel_id(arguments)
407
441
 
408
442
  if not code.strip():
@@ -459,44 +493,43 @@ versions
459
493
  }
460
494
 
461
495
  if is_active:
462
- # Get Python version
463
- try:
464
- result = await execute_with_timeout(
465
- "import sys; sys.version", STATUS_PROBE_TIMEOUT, kernel_id
466
- )
467
- if result.get("result"):
468
- status["python_version"] = result["result"].strip("'\"")
469
- except Exception:
470
- pass
471
-
472
- # Get installed packages
473
- try:
474
- pkg_code = """
475
- import importlib.metadata
476
- [f"{d.metadata['Name']} {d.version}" for d in importlib.metadata.distributions()
477
- if d.metadata['Name'] not in ('pip', 'setuptools', 'wheel', 'ipykernel', 'jupyter-client')][:20]
478
- """
479
- result = await execute_with_timeout(
480
- pkg_code, STATUS_PROBE_TIMEOUT, kernel_id
481
- )
482
- if result.get("result"):
483
- status["packages"] = ast.literal_eval(result["result"])
484
- except Exception:
485
- pass
486
-
487
- # Get user-defined variables
496
+ # Probes run only when the kernel is idle (a busy kernel keeps
497
+ # the defaults above); they never interrupt user code
498
+ probes = {
499
+ "python_version": _status_probe("__import__('sys').version"),
500
+ "packages": _status_probe(
501
+ "[f\"{d.metadata['Name']} {d.version}\" for d in"
502
+ " __import__('importlib.metadata').metadata.distributions()"
503
+ " if d.metadata['Name'] not in"
504
+ " ('pip', 'setuptools', 'wheel', 'ipykernel', 'jupyter-client')"
505
+ "][:20]"
506
+ ),
507
+ "variables": _status_probe(
508
+ "[name for name in dir() if not name.startswith('_')"
509
+ " and name not in"
510
+ " ('In', 'Out', 'get_ipython', 'exit', 'quit', 'open')]"
511
+ ),
512
+ }
488
513
  try:
489
- var_code = """
490
- [name for name in dir() if not name.startswith('_')
491
- and name not in ('In', 'Out', 'get_ipython', 'exit', 'quit', 'open')]
492
- """
493
- result = await execute_with_timeout(
494
- var_code, STATUS_PROBE_TIMEOUT, kernel_id
514
+ results = await loop.run_in_executor(
515
+ None,
516
+ lambda: probe_kernel(
517
+ kernel_id, list(probes.values()), STATUS_PROBE_TIMEOUT
518
+ ),
495
519
  )
496
- if result.get("result"):
497
- status["variables"] = ast.literal_eval(result["result"])
498
520
  except Exception:
499
- pass
521
+ results = None
522
+ for key, result in zip(probes, results or []):
523
+ if result.get("error") is None and result.get("stdout"):
524
+ try:
525
+ status[key] = ast.literal_eval(result["stdout"].strip())
526
+ except (ValueError, SyntaxError):
527
+ pass
528
+ # The probe retires a kernel found dead: report it like any
529
+ # kernel that is not in the registry
530
+ if not kernel_exists(kernel_id):
531
+ status["active"] = False
532
+ status["all_kernels"] = [k["id"] for k in list_kernels()]
500
533
 
501
534
  return [TextContent(type="text", text=json.dumps(status))]
502
535
 
@@ -570,20 +603,43 @@ import importlib.metadata
570
603
 
571
604
 
572
605
  async def _on_list_tools(ctx, params) -> ListToolsResult:
573
- """mcp>=2 lowlevel handler: wrap the tool list in its result type."""
606
+ """Lowlevel handler: wrap the tool list in its result type."""
574
607
  return ListToolsResult(tools=await list_tools())
575
608
 
576
609
 
577
610
  async def _on_call_tool(ctx, params) -> CallToolResult:
578
- """mcp>=2 lowlevel handler.
611
+ """Lowlevel handler.
579
612
 
580
- v2 no longer wraps a returned content list, and an exception would become
581
- a JSON-RPC error instead of an LLM-visible isError result (v1 behaviour),
582
- so both are done here.
613
+ The SDK does not wrap a returned content list, and an exception would
614
+ become a JSON-RPC error instead of an LLM-visible isError result, so both
615
+ are done here.
583
616
  """
617
+ arguments = params.arguments or {}
618
+ # The SDK does not validate arguments against inputSchema
619
+ tool = next((t for t in await list_tools() if t.name == params.name), None)
620
+ if tool is not None:
621
+ schema = tool.model_dump(by_alias=True)["inputSchema"]
622
+ # Strict function calling sends null for omitted optional arguments:
623
+ # treat those as absent so the defaults apply (null for a required
624
+ # argument still fails validation)
625
+ required = set(schema.get("required", []))
626
+ arguments = {
627
+ k: v for k, v in arguments.items() if v is not None or k in required
628
+ }
629
+ try:
630
+ jsonschema.validate(instance=arguments, schema=schema)
631
+ except jsonschema.ValidationError as e:
632
+ return CallToolResult(
633
+ content=[
634
+ TextContent(
635
+ type="text", text=f"Input validation error: {e.message}"
636
+ )
637
+ ],
638
+ is_error=True,
639
+ )
584
640
  try:
585
- content = await call_tool(params.name, params.arguments or {})
586
- except Exception as e: # keep v1 semantics: error text reaches the model
641
+ content = await call_tool(params.name, arguments)
642
+ except Exception as e: # error text reaches the model
587
643
  return CallToolResult(
588
644
  content=[TextContent(type="text", text=str(e))], is_error=True
589
645
  )
@@ -591,14 +647,9 @@ async def _on_call_tool(ctx, params) -> CallToolResult:
591
647
 
592
648
 
593
649
  def _make_server() -> Server:
594
- if _MCP_V2:
595
- return Server(
596
- "ipython_mcp", on_list_tools=_on_list_tools, on_call_tool=_on_call_tool
597
- )
598
- srv = Server("ipython_mcp")
599
- srv.list_tools()(list_tools)
600
- srv.call_tool()(call_tool)
601
- return srv
650
+ return Server(
651
+ "ipython_mcp", on_list_tools=_on_list_tools, on_call_tool=_on_call_tool
652
+ )
602
653
 
603
654
 
604
655
  server = _make_server()
@@ -2,7 +2,7 @@
2
2
 
3
3
  import json
4
4
  from pathlib import Path
5
- from typing import Optional, List, Any, Dict
5
+ from typing import Any, Dict, Iterator, List, Optional
6
6
 
7
7
  from agentic_python_coder.agent import create_coding_agent, run_agent
8
8
  from agentic_python_coder.llm import LLMConfig
@@ -35,6 +35,55 @@ def get_system_prompt_path(todo: bool = False) -> Path:
35
35
  return system_prompt_path
36
36
 
37
37
 
38
+ def _tool_call_event(tool_call: Dict[str, Any]) -> Dict[str, Any]:
39
+ """Log event for one tool call of an assistant message."""
40
+ func = tool_call.get("function", {})
41
+ tool_name = func.get("name", "unknown")
42
+ try:
43
+ tool_args = json.loads(func.get("arguments", "{}"))
44
+ except (json.JSONDecodeError, TypeError):
45
+ tool_args = {}
46
+
47
+ if tool_name == "todo_write":
48
+ todos = tool_args.get("todos", [])
49
+ return {
50
+ "event": "todo",
51
+ "action": "update",
52
+ "count": len(todos),
53
+ "tasks": [
54
+ {"content": t.get("content", ""), "status": t.get("status", "")}
55
+ for t in todos
56
+ ],
57
+ }
58
+ if tool_name in ("python_exec", "save_code"):
59
+ return {"event": tool_name, "code_length": len(tool_args.get("code", ""))}
60
+ return {"event": "tool_call", "tool": tool_name}
61
+
62
+
63
+ def _message_events(msg: Any) -> Iterator[Dict[str, Any]]:
64
+ """Log events for one run_agent message; run_agent only produces dicts."""
65
+ if not isinstance(msg, dict):
66
+ return
67
+ tool_calls = msg.get("tool_calls", [])
68
+ if tool_calls:
69
+ for tool_call in tool_calls:
70
+ yield _tool_call_event(tool_call)
71
+ # Tool responses (role=tool messages with JSON content carrying "success")
72
+ elif msg.get("role") == "tool" and isinstance(msg.get("content"), str):
73
+ try:
74
+ content_data = json.loads(msg["content"])
75
+ if "success" in content_data:
76
+ yield {
77
+ "event": "tool_response",
78
+ "success": content_data.get("success", False),
79
+ "error": content_data.get("error")
80
+ if not content_data.get("success")
81
+ else None,
82
+ }
83
+ except (json.JSONDecodeError, TypeError):
84
+ pass
85
+
86
+
38
87
  def save_conversation_log(
39
88
  working_dir: Path,
40
89
  messages: List[Any],
@@ -63,157 +112,9 @@ def save_conversation_log(
63
112
  json.dumps({"event": "start", "task": task_basename or "inline"}) + "\n"
64
113
  )
65
114
 
66
- # Process messages
67
115
  for msg in messages:
68
- # Handle dict messages (new format)
69
- if isinstance(msg, dict):
70
- tool_calls = msg.get("tool_calls", [])
71
- if tool_calls:
72
- for tool_call in tool_calls:
73
- func = tool_call.get("function", {})
74
- tool_name = func.get("name", "unknown")
75
- try:
76
- tool_args = json.loads(func.get("arguments", "{}"))
77
- except (json.JSONDecodeError, TypeError):
78
- tool_args = {}
79
-
80
- if tool_name == "todo_write":
81
- todos = tool_args.get("todos", [])
82
- f.write(
83
- json.dumps(
84
- {
85
- "event": "todo",
86
- "action": "update",
87
- "count": len(todos),
88
- "tasks": [
89
- {
90
- "content": t.get("content", ""),
91
- "status": t.get("status", ""),
92
- }
93
- for t in todos
94
- ],
95
- }
96
- )
97
- + "\n"
98
- )
99
- elif tool_name == "python_exec":
100
- f.write(
101
- json.dumps(
102
- {
103
- "event": "python_exec",
104
- "code_length": len(tool_args.get("code", "")),
105
- }
106
- )
107
- + "\n"
108
- )
109
- elif tool_name == "save_code":
110
- f.write(
111
- json.dumps(
112
- {
113
- "event": "save_code",
114
- "code_length": len(tool_args.get("code", "")),
115
- }
116
- )
117
- + "\n"
118
- )
119
- else:
120
- f.write(
121
- json.dumps({"event": "tool_call", "tool": tool_name})
122
- + "\n"
123
- )
124
-
125
- # Handle tool responses (role=tool messages with content)
126
- elif msg.get("role") == "tool" and isinstance(msg.get("content"), str):
127
- try:
128
- content_data = json.loads(msg["content"])
129
- if "success" in content_data:
130
- f.write(
131
- json.dumps(
132
- {
133
- "event": "tool_response",
134
- "success": content_data.get("success", False),
135
- "error": content_data.get("error")
136
- if not content_data.get("success")
137
- else None,
138
- }
139
- )
140
- + "\n"
141
- )
142
- except (json.JSONDecodeError, TypeError):
143
- pass
144
-
145
- # Handle object messages (backward compat)
146
- elif hasattr(msg, "tool_calls") and msg.tool_calls:
147
- for tool_call in msg.tool_calls:
148
- if isinstance(tool_call, dict):
149
- tool_name = tool_call.get("name", "unknown")
150
- tool_args = tool_call.get("args", {})
151
- else:
152
- tool_name = getattr(tool_call, "name", "unknown")
153
- tool_args = getattr(tool_call, "args", {})
154
-
155
- if tool_name == "todo_write":
156
- todos = tool_args.get("todos", [])
157
- f.write(
158
- json.dumps(
159
- {
160
- "event": "todo",
161
- "action": "update",
162
- "count": len(todos),
163
- "tasks": [
164
- {
165
- "content": t.get("content", ""),
166
- "status": t.get("status", ""),
167
- }
168
- for t in todos
169
- ],
170
- }
171
- )
172
- + "\n"
173
- )
174
- elif tool_name == "python_exec":
175
- f.write(
176
- json.dumps(
177
- {
178
- "event": "python_exec",
179
- "code_length": len(tool_args.get("code", "")),
180
- }
181
- )
182
- + "\n"
183
- )
184
- elif tool_name == "save_code":
185
- f.write(
186
- json.dumps(
187
- {
188
- "event": "save_code",
189
- "code_length": len(tool_args.get("code", "")),
190
- }
191
- )
192
- + "\n"
193
- )
194
- else:
195
- f.write(
196
- json.dumps({"event": "tool_call", "tool": tool_name}) + "\n"
197
- )
198
-
199
- elif hasattr(msg, "content") and isinstance(msg.content, str):
200
- try:
201
- content_data = json.loads(msg.content)
202
- if "success" in content_data:
203
- f.write(
204
- json.dumps(
205
- {
206
- "event": "tool_response",
207
- "success": content_data.get("success", False),
208
- "error": content_data.get("error")
209
- if not content_data.get("success")
210
- else None,
211
- }
212
- )
213
- + "\n"
214
- )
215
- except (json.JSONDecodeError, TypeError):
216
- pass
116
+ for event in _message_events(msg):
117
+ f.write(json.dumps(event) + "\n")
217
118
 
218
119
  # Statistics
219
120
  if stats:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentic-python-coder
3
- Version: 3.4.4
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<3,>=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
@@ -1,11 +1,11 @@
1
1
  agentic_python_coder/__init__.py,sha256=e6o-k__u5xNIiQAkZbHtbZC2-NwpeOdi4MdQtAqJBjE,2112
2
2
  agentic_python_coder/agent.py,sha256=0JfVNO-NEss9ZNEYkYvVlJSRzIxNa6sgRqLTdsD4zbs,16484
3
3
  agentic_python_coder/cli.py,sha256=NWc1LCEL-L2TVCT6Su9a0AQ3ub3zg5GrY6XG3j_qPgI,14877
4
- agentic_python_coder/kernel.py,sha256=14bfNvlay7QCeUiHrOptgj-fn8ZyXfyM08lDM_lUj1M,23803
4
+ agentic_python_coder/kernel.py,sha256=z-SgBqErdqAJdsG0SRobrFHKoN_jR3lTHufHPL6_CP8,29756
5
5
  agentic_python_coder/llm.py,sha256=ZK3b9wLzjDr6BIxUkfKhuiSDbXNR2XaQsE1J-960IzE,6998
6
- agentic_python_coder/mcp_server.py,sha256=aEcNWNiR8dtxQpapk72UPtEvfedIJSjRxJM1OEipcSU,23251
6
+ agentic_python_coder/mcp_server.py,sha256=Xb0MTdfe32dfQRYN26pdwgWiQ8BqD_VvLX6fU0UJGvk,26037
7
7
  agentic_python_coder/project_md.py,sha256=4xkQHlWb4cDPEkvegw1YwcfaTJ3aFbrl0CYcC_xxABY,3249
8
- agentic_python_coder/runner.py,sha256=Kbw1ceYJ4MVfUKgSIkiuBhMb8ZtbZuJwbBeTUsvPRts,13045
8
+ agentic_python_coder/runner.py,sha256=NoYRUyRusIcK-T7bEka_UoZjvcDxUzkONqe4AIXYPtU,7999
9
9
  agentic_python_coder/tools.py,sha256=tjhQbsF3n3bFIegnEOtE8czjzbtMSXy6B9gYeb-YAJ4,12088
10
10
  agentic_python_coder/examples/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
11
  agentic_python_coder/examples/clingo/README.md,sha256=SgWBqdJ2cHIfYWRV1YYqzfY0rth7mjCR5Act_hchcx0,2345
@@ -45,8 +45,8 @@ agentic_python_coder/models/sonnet5.json,sha256=kf6DIkQlcGVZ40IzRv3GXFtNncZF_gKq
45
45
  agentic_python_coder/models/sonnet55.json,sha256=ibqrXCS21NfgE95XBeyeNrbs6XmV00eKA586uf7o0ic,97
46
46
  agentic_python_coder/prompts/system.md,sha256=hREDVAhJXwCBAgJ3AE9JD6j-JRHREUPlkRSvThOvM3A,3807
47
47
  agentic_python_coder/prompts/system_todo.md,sha256=TuNeeUNS9KqyF62Da6t2mlUCYhKsgzwGe5ic_vFPDqw,5054
48
- agentic_python_coder-3.4.4.dist-info/METADATA,sha256=3k071sxanjm72mzxXtF7VhzKqH4y6Rs04_284Jll994,13255
49
- agentic_python_coder-3.4.4.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
50
- agentic_python_coder-3.4.4.dist-info/entry_points.txt,sha256=XIW3nZzL4kJ-Igohilj_mTvBcCyHBouDq2jmGzDILrk,107
51
- agentic_python_coder-3.4.4.dist-info/licenses/LICENSE,sha256=R9-M_V-SPdN71IphOVzM7kCNpUYs_hwj7rtroTwDhs0,11362
52
- agentic_python_coder-3.4.4.dist-info/RECORD,,
48
+ agentic_python_coder-3.5.0.dist-info/METADATA,sha256=07LE-Gb747kq413uiQlC9K71lQIkAAsNsaoEcoESWpM,13313
49
+ agentic_python_coder-3.5.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
50
+ agentic_python_coder-3.5.0.dist-info/entry_points.txt,sha256=XIW3nZzL4kJ-Igohilj_mTvBcCyHBouDq2jmGzDILrk,107
51
+ agentic_python_coder-3.5.0.dist-info/licenses/LICENSE,sha256=R9-M_V-SPdN71IphOVzM7kCNpUYs_hwj7rtroTwDhs0,11362
52
+ agentic_python_coder-3.5.0.dist-info/RECORD,,