agentic-python-coder 3.3.0__py3-none-any.whl → 3.4.1__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.
@@ -10,6 +10,7 @@ import logging
10
10
  import os
11
11
  import re
12
12
  import shutil
13
+ import signal
13
14
  import threading
14
15
  import time
15
16
  import uuid
@@ -219,13 +220,13 @@ except ImportError:
219
220
  self._execute_raw(startup_code)
220
221
 
221
222
  def _cleanup_on_error(self):
222
- """Clean up resources if initialization fails."""
223
+ """Clean up resources if initialization fails.
224
+
225
+ Routes through shutdown() so a partially-started kernel tree gets
226
+ the same defensive process-group kill as a healthy one.
227
+ """
223
228
  try:
224
- if hasattr(self, "kc") and self.kc:
225
- self.kc.stop_channels()
226
- if hasattr(self, "km") and self.km:
227
- if self.km.is_alive():
228
- self.km.shutdown_kernel(now=True)
229
+ self.shutdown()
229
230
  except Exception:
230
231
  pass # Best effort cleanup
231
232
 
@@ -311,7 +312,21 @@ except ImportError:
311
312
  return self._execute_raw(code, deadline_timeout)
312
313
 
313
314
  def shutdown(self):
314
- """Shutdown the kernel and clean up."""
315
+ """Shutdown the kernel and clean up.
316
+
317
+ Kernels run in their own process group (jupyter_client launches with
318
+ start_new_session=True). After the regular shutdown, defensively kill
319
+ the whole group: uv-wrapped kernels are a tree (uv parent + python
320
+ child) and a surviving member would be orphaned.
321
+ """
322
+ # Capture the kernel's process group before shutdown clears it
323
+ pgid = None
324
+ try:
325
+ if hasattr(self, "km") and self.km:
326
+ pgid = getattr(getattr(self.km, "provisioner", None), "pgid", None)
327
+ except Exception:
328
+ pass
329
+
315
330
  try:
316
331
  if hasattr(self, "kc") and self.kc:
317
332
  try:
@@ -330,6 +345,15 @@ except ImportError:
330
345
  # Ignore errors during shutdown
331
346
  pass
332
347
 
348
+ # Defensive: ensure no process-tree member survived. Never signal our
349
+ # own group (pgid could be bogus if start_new_session did not apply).
350
+ if pgid and pgid > 0:
351
+ try:
352
+ if pgid != os.getpgid(0):
353
+ os.killpg(pgid, signal.SIGKILL)
354
+ except (ProcessLookupError, PermissionError, OSError):
355
+ pass
356
+
333
357
 
334
358
  # =============================================================================
335
359
  # Core Registry Functions
@@ -603,6 +627,33 @@ def shutdown_all_kernels():
603
627
  pass # Best effort
604
628
 
605
629
 
630
+ def kill_all_kernel_process_groups():
631
+ """Hard-kill (SIGKILL) every registered kernel's process group.
632
+
633
+ Deliberately lock-free: this is the termination path, where a busy
634
+ kernel's _exec_lock may be held indefinitely by an in-flight execution
635
+ and the graceful shutdown_all_kernels() would block behind it. Snapshot
636
+ the registry and signal each group directly. Never signals our own
637
+ process group. Kernels mid-creation (registry slot still None) cannot
638
+ be reached here — that sub-second window is a known limitation.
639
+ """
640
+ try:
641
+ own_pgid = os.getpgid(0)
642
+ except OSError:
643
+ own_pgid = None
644
+
645
+ for state in list(_kernels.values()):
646
+ if state is None:
647
+ continue
648
+ try:
649
+ km = state.kernel.km
650
+ pgid = getattr(getattr(km, "provisioner", None), "pgid", None)
651
+ if pgid and pgid > 0 and pgid != own_pgid:
652
+ os.killpg(pgid, signal.SIGKILL)
653
+ except (ProcessLookupError, PermissionError, OSError, AttributeError):
654
+ pass # Best effort
655
+
656
+
606
657
  # =============================================================================
607
658
  # Backward Compatibility API
608
659
  # =============================================================================
@@ -7,6 +7,9 @@ import ast
7
7
  import asyncio
8
8
  import json
9
9
  import logging
10
+ import os
11
+ import signal
12
+ import threading
10
13
 
11
14
  from mcp.server import Server
12
15
  from mcp.server.stdio import stdio_server
@@ -18,8 +21,10 @@ from agentic_python_coder.kernel import (
18
21
  execute_in_kernel,
19
22
  interrupt_kernel_by_id,
20
23
  kernel_exists,
24
+ kill_all_kernel_process_groups,
21
25
  list_kernels,
22
26
  restart_kernel,
27
+ shutdown_all_kernels,
23
28
  )
24
29
 
25
30
  # Configure logging
@@ -32,6 +37,7 @@ server = Server("ipython_mcp")
32
37
  MAX_OUTPUT = 100 * 1024 # 100KB truncation limit
33
38
  MAX_TIMEOUT = 300 # Maximum allowed timeout in seconds
34
39
  DEFAULT_TIMEOUT = 30 # Default timeout in seconds
40
+ STATUS_PROBE_TIMEOUT = 10 # Timeout for python_status introspection probes
35
41
  KERNEL_TIMEOUT_BUFFER = 5 # Extra seconds for kernel deadline vs asyncio timeout
36
42
 
37
43
  # Async lock for session operations (default kernel management)
@@ -240,6 +246,30 @@ No side effects - safe to call anytime.""",
240
246
  },
241
247
  },
242
248
  ),
249
+ Tool(
250
+ name="submit_code",
251
+ description="""Submit your final, verified, self-contained Python program.
252
+
253
+ Call this ONCE at the end of a solve, only after you have executed the program
254
+ via python_exec and your verification passed. The submission must be a
255
+ standalone program: all imports included, no reliance on session state.
256
+
257
+ The code is syntax-checked with compile():
258
+ - {"ok": true} if it parses
259
+ - {"ok": false, "error": "<detail>"} on a syntax error — fix and resubmit
260
+
261
+ The code is NOT executed and NOT written to disk; the client persists it.""",
262
+ inputSchema={
263
+ "type": "object",
264
+ "properties": {
265
+ "code": {
266
+ "type": "string",
267
+ "description": "Complete standalone Python program (final answer).",
268
+ },
269
+ },
270
+ "required": ["code"],
271
+ },
272
+ ),
243
273
  Tool(
244
274
  name="python_interrupt",
245
275
  description="""Interrupt running code in the session.
@@ -318,7 +348,9 @@ versions
318
348
  % packages
319
349
  )
320
350
  try:
321
- result = await execute_with_timeout(version_code, 30, kernel_id)
351
+ result = await execute_with_timeout(
352
+ version_code, DEFAULT_TIMEOUT, kernel_id
353
+ )
322
354
  if result.get("result"):
323
355
  versions = ast.literal_eval(result["result"])
324
356
  if isinstance(versions, dict):
@@ -391,6 +423,24 @@ versions
391
423
  result = await execute_with_timeout(code, timeout, kernel_id)
392
424
  return [TextContent(type="text", text=json.dumps(result))]
393
425
 
426
+ elif name == "submit_code":
427
+ code = arguments.get("code", "")
428
+ if not code.strip():
429
+ payload = {"ok": False, "error": "Empty submission"}
430
+ else:
431
+ try:
432
+ compile(code, "<submission>", "exec")
433
+ payload = {"ok": True}
434
+ except SyntaxError as e:
435
+ payload = {
436
+ "ok": False,
437
+ "error": f"Syntax error at line {e.lineno or '?'}: {e.msg}",
438
+ }
439
+ except ValueError as e:
440
+ # compile() raises ValueError e.g. for null bytes in source
441
+ payload = {"ok": False, "error": f"Invalid source: {e}"}
442
+ return [TextContent(type="text", text=json.dumps(payload))]
443
+
394
444
  elif name == "python_status":
395
445
  kernel_id = get_kernel_id(arguments)
396
446
  is_active = kernel_exists(kernel_id)
@@ -412,7 +462,7 @@ versions
412
462
  # Get Python version
413
463
  try:
414
464
  result = await execute_with_timeout(
415
- "import sys; sys.version", 10, kernel_id
465
+ "import sys; sys.version", STATUS_PROBE_TIMEOUT, kernel_id
416
466
  )
417
467
  if result.get("result"):
418
468
  status["python_version"] = result["result"].strip("'\"")
@@ -426,7 +476,9 @@ import importlib.metadata
426
476
  [f"{d.metadata['Name']} {d.version}" for d in importlib.metadata.distributions()
427
477
  if d.metadata['Name'] not in ('pip', 'setuptools', 'wheel', 'ipykernel', 'jupyter-client')][:20]
428
478
  """
429
- result = await execute_with_timeout(pkg_code, 10, kernel_id)
479
+ result = await execute_with_timeout(
480
+ pkg_code, STATUS_PROBE_TIMEOUT, kernel_id
481
+ )
430
482
  if result.get("result"):
431
483
  status["packages"] = ast.literal_eval(result["result"])
432
484
  except Exception:
@@ -438,7 +490,9 @@ import importlib.metadata
438
490
  [name for name in dir() if not name.startswith('_')
439
491
  and name not in ('In', 'Out', 'get_ipython', 'exit', 'quit', 'open')]
440
492
  """
441
- result = await execute_with_timeout(var_code, 10, kernel_id)
493
+ result = await execute_with_timeout(
494
+ var_code, STATUS_PROBE_TIMEOUT, kernel_id
495
+ )
442
496
  if result.get("result"):
443
497
  status["variables"] = ast.literal_eval(result["result"])
444
498
  except Exception:
@@ -521,9 +575,51 @@ async def run_server():
521
575
  await server.run(read, write, server.create_initialization_options())
522
576
 
523
577
 
578
+ # Hard-exit fallback if kernel cleanup hangs after a termination signal
579
+ _TERMINATION_CLEANUP_TIMEOUT = 10.0
580
+
581
+ # Set on the first termination signal; later signals are ignored instead of
582
+ # stacking additional timers and cleanup threads
583
+ _terminating = threading.Event()
584
+
585
+
586
+ def _handle_termination(signum, frame):
587
+ """Shut down all kernels on SIGTERM/SIGINT, then exit.
588
+
589
+ Kernel process groups are hard-killed first (lock-free, cannot block on
590
+ a busy kernel's exec lock); the graceful registry cleanup then runs in a
591
+ thread, with a timer that hard-exits if it hangs.
592
+ """
593
+ if _terminating.is_set():
594
+ return
595
+ _terminating.set()
596
+
597
+ def _cleanup_and_exit():
598
+ try:
599
+ kill_all_kernel_process_groups()
600
+ shutdown_all_kernels()
601
+ finally:
602
+ os._exit(128 + signum)
603
+
604
+ threading.Timer(
605
+ _TERMINATION_CLEANUP_TIMEOUT, lambda: os._exit(128 + signum)
606
+ ).start()
607
+ threading.Thread(target=_cleanup_and_exit, daemon=True).start()
608
+
609
+
524
610
  def main():
525
611
  """Entry point for coder-mcp command."""
526
- asyncio.run(run_server())
612
+ signal.signal(signal.SIGTERM, _handle_termination)
613
+ signal.signal(signal.SIGINT, _handle_termination)
614
+ try:
615
+ asyncio.run(run_server())
616
+ finally:
617
+ # stdin EOF / transport close / normal exit: the client is gone, so
618
+ # hard-kill kernel groups first (immune to a busy kernel's exec
619
+ # lock), then clean up the registry (atexit also runs the graceful
620
+ # path, but only on clean interpreter shutdown)
621
+ kill_all_kernel_process_groups()
622
+ shutdown_all_kernels()
527
623
 
528
624
 
529
625
  if __name__ == "__main__":
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-python-coder
3
- Version: 3.3.0
3
+ Version: 3.4.1
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
@@ -1,9 +1,9 @@
1
1
  agentic_python_coder/__init__.py,sha256=e6o-k__u5xNIiQAkZbHtbZC2-NwpeOdi4MdQtAqJBjE,2112
2
2
  agentic_python_coder/agent.py,sha256=gzDFf4n7J1JdWawYPzWQFVwcHZ2rsRHUzVZVcGYjQWo,14451
3
3
  agentic_python_coder/cli.py,sha256=HS9nlqsiWZAvg0Hc8ExoVT8eEcy2t3Bux6UGukERWqQ,14663
4
- agentic_python_coder/kernel.py,sha256=PQrKMi0_mKVeG1q2wXTu3-W2ZYKwYNjtPI_3A4m4Gvg,21284
4
+ agentic_python_coder/kernel.py,sha256=Q-yLb8Il54kZv41qev2uzASFlr6bGjQ7ztBy7yD48GM,23273
5
5
  agentic_python_coder/llm.py,sha256=zjsk4-Kdx92hFSxywPGIkLv8zflWnE2cMEcpeXu6ttw,6998
6
- agentic_python_coder/mcp_server.py,sha256=2Z5O9n5QgZrgn3GdFljjF7Yq5e9tE1zNlAWBPyckrlI,18503
6
+ agentic_python_coder/mcp_server.py,sha256=Osdl8UnjkupXbPLiYCugiapGDJsryZfVai0o-jepuqw,22047
7
7
  agentic_python_coder/project_md.py,sha256=4xkQHlWb4cDPEkvegw1YwcfaTJ3aFbrl0CYcC_xxABY,3249
8
8
  agentic_python_coder/runner.py,sha256=Kbw1ceYJ4MVfUKgSIkiuBhMb8ZtbZuJwbBeTUsvPRts,13045
9
9
  agentic_python_coder/tools.py,sha256=tjhQbsF3n3bFIegnEOtE8czjzbtMSXy6B9gYeb-YAJ4,12088
@@ -41,8 +41,8 @@ agentic_python_coder/models/sonnet46.json,sha256=Ops1FpBoyx9PBGRhIXG8u-YGBgIui9D
41
41
  agentic_python_coder/models/sonnet5.json,sha256=kf6DIkQlcGVZ40IzRv3GXFtNncZF_gKqQeGzZAxE_KM,87
42
42
  agentic_python_coder/prompts/system.md,sha256=hREDVAhJXwCBAgJ3AE9JD6j-JRHREUPlkRSvThOvM3A,3807
43
43
  agentic_python_coder/prompts/system_todo.md,sha256=TuNeeUNS9KqyF62Da6t2mlUCYhKsgzwGe5ic_vFPDqw,5054
44
- agentic_python_coder-3.3.0.dist-info/METADATA,sha256=ec4FIlQitVNa_5bMQhu2NPpJV-5VwDRQy1hzK_4b_G8,12872
45
- agentic_python_coder-3.3.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
46
- agentic_python_coder-3.3.0.dist-info/entry_points.txt,sha256=XIW3nZzL4kJ-Igohilj_mTvBcCyHBouDq2jmGzDILrk,107
47
- agentic_python_coder-3.3.0.dist-info/licenses/LICENSE,sha256=R9-M_V-SPdN71IphOVzM7kCNpUYs_hwj7rtroTwDhs0,11362
48
- agentic_python_coder-3.3.0.dist-info/RECORD,,
44
+ agentic_python_coder-3.4.1.dist-info/METADATA,sha256=C9UTWTVtnf7-0Bgiu44LMEd16zA00T3UxkS92MVV_Ac,12872
45
+ agentic_python_coder-3.4.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
46
+ agentic_python_coder-3.4.1.dist-info/entry_points.txt,sha256=XIW3nZzL4kJ-Igohilj_mTvBcCyHBouDq2jmGzDILrk,107
47
+ agentic_python_coder-3.4.1.dist-info/licenses/LICENSE,sha256=R9-M_V-SPdN71IphOVzM7kCNpUYs_hwj7rtroTwDhs0,11362
48
+ agentic_python_coder-3.4.1.dist-info/RECORD,,