agentic-python-coder 3.4.3__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
 
@@ -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:
@@ -10,19 +10,24 @@ import logging
10
10
  import os
11
11
  import signal
12
12
  import threading
13
+ from typing import List, Optional
13
14
 
15
+ import jsonschema
14
16
  from mcp.server import Server
15
17
  from mcp.server.stdio import stdio_server
16
- from mcp.types import TextContent, Tool
18
+ from mcp.types import CallToolResult, ListToolsResult, TextContent, Tool
19
+ from packaging.requirements import InvalidRequirement, Requirement
17
20
 
18
21
  from agentic_python_coder.kernel import (
19
22
  DEFAULT_KERNEL_ID,
20
23
  create_kernel,
21
24
  execute_in_kernel,
25
+ get_kernel_info,
22
26
  interrupt_kernel_by_id,
23
27
  kernel_exists,
24
28
  kill_all_kernel_process_groups,
25
29
  list_kernels,
30
+ probe_kernel,
26
31
  restart_kernel,
27
32
  shutdown_all_kernels,
28
33
  )
@@ -30,8 +35,7 @@ from agentic_python_coder.kernel import (
30
35
  # Configure logging
31
36
  logger = logging.getLogger(__name__)
32
37
 
33
- # Create MCP server
34
- server = Server("ipython_mcp")
38
+ # The MCP server object is created at the end of the module (see _make_server).
35
39
 
36
40
  # Constants
37
41
  MAX_OUTPUT = 100 * 1024 # 100KB truncation limit
@@ -57,6 +61,39 @@ def truncate_output(result: dict) -> dict:
57
61
  return result
58
62
 
59
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
+
60
97
  def get_kernel_id(arguments: dict) -> str:
61
98
  """Get kernel_id from arguments, defaulting to DEFAULT_KERNEL_ID."""
62
99
  return arguments.get("kernel_id") or DEFAULT_KERNEL_ID
@@ -147,7 +184,6 @@ async def execute_with_timeout(
147
184
  return {"success": False, "kernel_id": kernel_id, "error": error_msg}
148
185
 
149
186
 
150
- @server.list_tools()
151
187
  async def list_tools():
152
188
  """List available MCP tools."""
153
189
  return [
@@ -296,7 +332,6 @@ Note: Call this, then call python_exec to continue working.""",
296
332
  ]
297
333
 
298
334
 
299
- @server.call_tool()
300
335
  async def call_tool(name: str, arguments: dict):
301
336
  """Handle tool calls."""
302
337
  loop = asyncio.get_running_loop()
@@ -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
 
@@ -569,6 +602,59 @@ import importlib.metadata
569
602
  ]
570
603
 
571
604
 
605
+ async def _on_list_tools(ctx, params) -> ListToolsResult:
606
+ """Lowlevel handler: wrap the tool list in its result type."""
607
+ return ListToolsResult(tools=await list_tools())
608
+
609
+
610
+ async def _on_call_tool(ctx, params) -> CallToolResult:
611
+ """Lowlevel handler.
612
+
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.
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
+ )
640
+ try:
641
+ content = await call_tool(params.name, arguments)
642
+ except Exception as e: # error text reaches the model
643
+ return CallToolResult(
644
+ content=[TextContent(type="text", text=str(e))], is_error=True
645
+ )
646
+ return CallToolResult(content=content)
647
+
648
+
649
+ def _make_server() -> Server:
650
+ return Server(
651
+ "ipython_mcp", on_list_tools=_on_list_tools, on_call_tool=_on_call_tool
652
+ )
653
+
654
+
655
+ server = _make_server()
656
+
657
+
572
658
  async def run_server():
573
659
  """Run the MCP server."""
574
660
  async with stdio_server() as (read, write):
@@ -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.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
@@ -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=_gIvDvtLpYGClm5IxTAqijwHG-wi2OtKUaH2Jtk2v0Q,23511
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=Osdl8UnjkupXbPLiYCugiapGDJsryZfVai0o-jepuqw,22047
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.3.dist-info/METADATA,sha256=Yr9xBjQt73msWBT7C5YABEO3zXSvUp3r2rUOsTdpw74,13255
49
- agentic_python_coder-3.4.3.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
50
- agentic_python_coder-3.4.3.dist-info/entry_points.txt,sha256=XIW3nZzL4kJ-Igohilj_mTvBcCyHBouDq2jmGzDILrk,107
51
- agentic_python_coder-3.4.3.dist-info/licenses/LICENSE,sha256=R9-M_V-SPdN71IphOVzM7kCNpUYs_hwj7rtroTwDhs0,11362
52
- agentic_python_coder-3.4.3.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,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.32.4
2
+ Generator: hatchling 1.33.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any