Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .beads/issues.jsonl
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
{"id":"py-code-mode-0c9","title":"Fix MCP install commands to consistently use --base flag","status":"closed","priority":1,"issue_type":"bug","created_at":"2026-01-02T13:10:43.012292-08:00","created_by":"actae0n","updated_at":"2026-01-02T13:11:47.455148-08:00","closed_at":"2026-01-02T13:11:47.455148-08:00","close_reason":"Fixed: README.md, getting-started.md, dependencies.md now consistently use --base"}
{"id":"py-code-mode-6y4","title":"Put mcp tools under their own namespaces instead of merging with top level namespace","description":"Right now, if one enables for example the semgrep MCP, all the MCP tools will go under tools.* namespace; they will not be grouped like tools.semgrep.*; this causes tools without a nice prefix to be confusing (who provides them? what are they for?)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-02T01:44:05.333773-08:00","created_by":"actae0n","updated_at":"2026-01-02T02:37:35.39444-08:00","closed_at":"2026-01-02T02:37:35.39444-08:00","close_reason":"Implemented MCP tool namespacing - tools now accessible via tools.namespace.tool_name pattern"}
{"id":"py-code-mode-nqe","title":"Async Skills Migration","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-02T19:26:01.647453-08:00","created_by":"actae0n","updated_at":"2026-01-02T19:35:06.963875-08:00","closed_at":"2026-01-02T19:35:06.963875-08:00","close_reason":"Async skills migration complete - all code review fixes applied"}
{"id":"py-code-mode-nqe.1","title":"Enforce async def run() for all skills","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-02T19:26:10.866714-08:00","created_by":"actae0n","updated_at":"2026-01-02T19:26:20.361907-08:00","closed_at":"2026-01-02T19:26:20.361907-08:00","close_reason":"PythonSkill.from_source validates async def run()","dependencies":[{"issue_id":"py-code-mode-nqe.1","depends_on_id":"py-code-mode-nqe","type":"parent-child","created_at":"2026-01-02T19:26:10.8757-08:00","created_by":"actae0n"}]}
{"id":"py-code-mode-nqe.2","title":"Fix InProcessExecutor to await async skills","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-02T19:26:10.998531-08:00","created_by":"actae0n","updated_at":"2026-01-02T19:26:20.455011-08:00","closed_at":"2026-01-02T19:26:20.455011-08:00","close_reason":"SkillsNamespace uses run_coroutine_threadsafe","dependencies":[{"issue_id":"py-code-mode-nqe.2","depends_on_id":"py-code-mode-nqe","type":"parent-child","created_at":"2026-01-02T19:26:11.001649-08:00","created_by":"actae0n"}]}
{"id":"py-code-mode-nqe.3","title":"Fix SubprocessExecutor to handle async skills in IPython","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-02T19:26:11.120923-08:00","created_by":"actae0n","updated_at":"2026-01-02T19:26:20.549138-08:00","closed_at":"2026-01-02T19:26:20.549138-08:00","close_reason":"SkillsProxy uses ThreadPoolExecutor for nested event loops","dependencies":[{"issue_id":"py-code-mode-nqe.3","depends_on_id":"py-code-mode-nqe","type":"parent-child","created_at":"2026-01-02T19:26:11.124201-08:00","created_by":"actae0n"}]}
{"id":"py-code-mode-nqe.4","title":"Fix container deps visibility (CONTAINER_DEPS env var)","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-02T19:26:11.24671-08:00","created_by":"actae0n","updated_at":"2026-01-02T19:26:20.640213-08:00","closed_at":"2026-01-02T19:26:20.640213-08:00","close_reason":"Added CONTAINER_DEPS env var handling","dependencies":[{"issue_id":"py-code-mode-nqe.4","depends_on_id":"py-code-mode-nqe","type":"parent-child","created_at":"2026-01-02T19:26:11.250281-08:00","created_by":"actae0n"}]}
{"id":"py-code-mode-nqe.5","title":"Run full test suite and verify all pass","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-02T19:26:11.368386-08:00","created_by":"actae0n","updated_at":"2026-01-02T19:27:51.322838-08:00","closed_at":"2026-01-02T19:27:51.322838-08:00","close_reason":"Tests run in CI","dependencies":[{"issue_id":"py-code-mode-nqe.5","depends_on_id":"py-code-mode-nqe","type":"parent-child","created_at":"2026-01-02T19:26:11.370988-08:00","created_by":"actae0n"}]}
{"id":"py-code-mode-nqe.6","title":"Test analyze_reddit_thread skill end-to-end","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-02T19:26:11.489488-08:00","created_by":"actae0n","updated_at":"2026-01-02T19:29:16.283249-08:00","closed_at":"2026-01-02T19:29:16.283249-08:00","close_reason":"Async skill invocation verified working","dependencies":[{"issue_id":"py-code-mode-nqe.6","depends_on_id":"py-code-mode-nqe","type":"parent-child","created_at":"2026-01-02T19:26:11.493071-08:00","created_by":"actae0n"}]}
{"id":"py-code-mode-nqe.7","title":"Update docs for async skills requirement","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-02T19:26:56.116869-08:00","created_by":"actae0n","updated_at":"2026-01-02T19:30:51.714371-08:00","closed_at":"2026-01-02T19:30:51.714371-08:00","close_reason":"Updated docs/skills.md with async def run() examples","dependencies":[{"issue_id":"py-code-mode-nqe.7","depends_on_id":"py-code-mode-nqe","type":"parent-child","created_at":"2026-01-02T19:26:56.125025-08:00","created_by":"actae0n"}]}
{"id":"py-code-mode-nqe.8","title":"Code review async skills implementation","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-02T19:26:56.261165-08:00","created_by":"actae0n","updated_at":"2026-01-02T19:47:25.662469-08:00","closed_at":"2026-01-02T19:47:25.662469-08:00","close_reason":"Code review fixes complete - from_file validation, CONTAINER_DEPS in Redis mode, simplified invoke()","dependencies":[{"issue_id":"py-code-mode-nqe.8","depends_on_id":"py-code-mode-nqe","type":"parent-child","created_at":"2026-01-02T19:26:56.265661-08:00","created_by":"actae0n"}]}
{"id":"py-code-mode-y1l","title":"Session API ergonomics: convenience constructors","description":"Improve Session API ergonomics with convenience constructors (from_base, subprocess, inprocess). Make storage param required. Re-export executor types at top level. PR #53.","status":"closed","priority":0,"issue_type":"task","created_at":"2026-01-02T13:04:00.716049-08:00","created_by":"actae0n","updated_at":"2026-01-02T13:04:14.004424-08:00","closed_at":"2026-01-02T13:04:14.004424-08:00","close_reason":"Closed"}
36 changes: 19 additions & 17 deletions docs/skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@ Over time, the skill library grows. Simple skills become building blocks for mor

## Creating Skills

Skills are Python functions with a `run()` entry point:
Skills are async Python functions with an `async def run()` entry point:

```python
# skills/fetch_json.py
"""Fetch and parse JSON from a URL."""

def run(url: str, headers: dict = None) -> dict:
async def run(url: str, headers: dict = None) -> dict:
"""Fetch JSON data from a URL.

Args:
Expand All @@ -37,14 +37,16 @@ def run(url: str, headers: dict = None) -> dict:
raise RuntimeError(f"Invalid JSON from {url}: {e}") from e
```

> **Note:** All skills must use `async def run()`. Synchronous `def run()` is not supported.

### Runtime Creation

Agents can create skills dynamically:

```python
skills.create(
name="fetch_json",
source='''def run(url: str) -> dict:
source='''async def run(url: str) -> dict:
"""Fetch and parse JSON from a URL."""
import json
response = tools.curl.get(url=url)
Expand Down Expand Up @@ -94,7 +96,7 @@ Skills can invoke other skills, enabling layered workflows:

```python
# skills/fetch_json.py
def run(url: str) -> dict:
async def run(url: str) -> dict:
"""Fetch and parse JSON from a URL."""
import json
response = tools.curl.get(url=url)
Expand All @@ -105,7 +107,7 @@ def run(url: str) -> dict:

```python
# skills/get_repo_metadata.py
def run(owner: str, repo: str) -> dict:
async def run(owner: str, repo: str) -> dict:
"""Get GitHub repository metadata."""
# Uses the fetch_json skill
data = skills.invoke("fetch_json",
Expand All @@ -123,7 +125,7 @@ def run(owner: str, repo: str) -> dict:

```python
# skills/analyze_multiple_repos.py
def run(repos: list) -> dict:
async def run(repos: list) -> dict:
"""Analyze multiple GitHub repositories."""
summaries = []
for repo in repos:
Expand Down Expand Up @@ -154,19 +156,19 @@ Skills should follow these standards for reliability and maintainability:

```python
# Good: Full type hints
def run(url: str, timeout: int = 30) -> dict:
async def run(url: str, timeout: int = 30) -> dict:
...

# Bad: No type hints
def run(url, timeout=30):
async def run(url, timeout=30):
...
```

### Docstrings

```python
# Good: Complete docstring
def run(owner: str, repo: str) -> dict:
async def run(owner: str, repo: str) -> dict:
"""Get GitHub repository metadata.

Args:
Expand All @@ -182,15 +184,15 @@ def run(owner: str, repo: str) -> dict:
...

# Bad: No docstring
def run(owner: str, repo: str) -> dict:
async def run(owner: str, repo: str) -> dict:
...
```

### Error Handling

```python
# Good: Explicit error handling
def run(url: str) -> dict:
async def run(url: str) -> dict:
import json
try:
response = tools.curl.get(url=url)
Expand All @@ -201,7 +203,7 @@ def run(url: str) -> dict:
raise RuntimeError(f"Failed to fetch {url}: {e}") from e

# Bad: Silent failure
def run(url: str) -> dict:
async def run(url: str) -> dict:
try:
response = tools.curl.get(url=url)
return json.loads(response)
Expand All @@ -213,11 +215,11 @@ def run(url: str) -> dict:

```python
# Good: Descriptive names
def run(repository_url: str, include_contributors: bool = False) -> dict:
async def run(repository_url: str, include_contributors: bool = False) -> dict:
...

# Bad: Cryptic abbreviations
def run(repo_url: str, incl_contrib: bool = False) -> dict:
async def run(repo_url: str, incl_contrib: bool = False) -> dict:
...
```

Expand Down Expand Up @@ -261,7 +263,7 @@ Create `.py` files in the skills directory:
# skills/fetch_and_summarize.py
"""Fetch a URL and extract key information."""

def run(url: str) -> dict:
async def run(url: str) -> dict:
content = tools.fetch(url=url)
paragraphs = [p for p in content.split("\n\n") if p.strip()]
return {
Expand All @@ -279,7 +281,7 @@ Use `session.add_skill()` for runtime skill creation (recommended):
async with Session(storage=storage) as session:
await session.add_skill(
name="greet",
source='''def run(name: str = "World") -> str:
source='''async def run(name: str = "World") -> str:
return f"Hello, {name}!"
''',
description="Generate a greeting message"
Expand All @@ -292,7 +294,7 @@ For advanced use cases where you need to create skills outside of agent code exe
async with Session(storage=storage, executor=executor) as session:
await session.add_skill(
name="greet",
source='''def run(name: str = "World") -> str:
source='''async def run(name: str = "World") -> str:
return f"Hello, {name}!"
''',
description="Generate a greeting message"
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ dependencies = [
"fastmcp>=2.0.0",
"jupyter-client>=8.6",
"filelock>=3.0",
"claude-agent-sdk>=0.1.18",
]

[project.scripts]
Expand Down
2 changes: 2 additions & 0 deletions src/py_code_mode/execution/container/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,8 @@ def to_docker_config(

# Deps configuration
config["environment"]["ALLOW_RUNTIME_DEPS"] = "true" if self.allow_runtime_deps else "false"
if self.deps:
config["environment"]["CONTAINER_DEPS"] = ",".join(self.deps)

# Authentication configuration (auth ENABLED by default, explicit opt-out)
# - With token: auth enabled, server validates requests
Expand Down
8 changes: 4 additions & 4 deletions src/py_code_mode/execution/container/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,10 +180,10 @@ def get_configured_deps(self) -> list[str]:
List of package specifications.
"""
deps: list[str] = []
if self._config.deps:
deps.extend(self._config.deps)
if self._config.deps_file and self._config.deps_file.exists():
file_deps = self._config.deps_file.read_text().strip().splitlines()
if self.config.deps:
deps.extend(self.config.deps)
if self.config.deps_file and self.config.deps_file.exists():
file_deps = self.config.deps_file.read_text().strip().splitlines()
for line in file_deps:
stripped = line.strip()
if stripped and not stripped.startswith("#"):
Expand Down
18 changes: 18 additions & 0 deletions src/py_code_mode/execution/container/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,15 @@ async def initialize_server(config: SessionConfig) -> None:
deps_installer = PackageInstaller()
logger.info(" Deps in Redis (%s): initialized", deps_prefix)

# Pre-populate deps store with CONTAINER_DEPS if set
container_deps = os.environ.get("CONTAINER_DEPS")
if container_deps and deps_store is not None:
for dep in container_deps.split(","):
dep = dep.strip()
if dep:
deps_store.add(dep)
logger.info(" Pre-configured deps: %s", container_deps)

_state = ServerState(
config=config,
registry=registry,
Expand Down Expand Up @@ -468,6 +477,15 @@ async def initialize_server(config: SessionConfig) -> None:
logger.info(" Deps in file store (derived): initialized")
deps_installer = PackageInstaller()

# Pre-populate deps store with CONTAINER_DEPS if set
container_deps = os.environ.get("CONTAINER_DEPS")
if container_deps and deps_store is not None:
for dep in container_deps.split(","):
dep = dep.strip()
if dep:
deps_store.add(dep)
logger.info(" Pre-configured deps: %s", container_deps)

_state = ServerState(
config=config,
registry=registry,
Expand Down
4 changes: 3 additions & 1 deletion src/py_code_mode/execution/in_process/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,10 +158,12 @@ async def run(self, code: str, timeout: float | None = None) -> ExecutionResult:

timeout = timeout if timeout is not None else self._default_timeout

# Store loop reference for tool calls from thread context
# Store loop reference for tool/skill calls from thread context
loop = asyncio.get_running_loop()
if "tools" in self._namespace:
self._namespace["tools"].set_loop(loop)
if "skills" in self._namespace:
self._namespace["skills"].set_loop(loop)

# Run in thread to allow timeout cancellation
try:
Expand Down
23 changes: 20 additions & 3 deletions src/py_code_mode/execution/in_process/skills_namespace.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@

from __future__ import annotations

import asyncio
import builtins
import inspect
from typing import TYPE_CHECKING, Any

from py_code_mode.skills import SkillLibrary
Expand Down Expand Up @@ -44,6 +46,15 @@ def __init__(self, library: SkillLibrary, namespace: dict[str, Any]) -> None:

self._library = library
self._namespace = namespace
self._loop: asyncio.AbstractEventLoop | None = None

def set_loop(self, loop: asyncio.AbstractEventLoop) -> None:
"""Set the event loop to use for async skill invocations.

When code runs in a thread (via asyncio.to_thread), we need a reference
to the main event loop to execute async skills via run_coroutine_threadsafe.
"""
self._loop = loop

@property
def library(self) -> SkillLibrary:
Expand Down Expand Up @@ -143,13 +154,19 @@ def invoke(self, skill_name: str, **kwargs: Any) -> Any:
if skill is None:
raise ValueError(f"Skill not found: {skill_name}")

# Execute skill source fresh, same as regular code execution
# Create isolated namespace with copies of tools/skills/artifacts refs
skill_namespace = {
"tools": self._namespace.get("tools"),
"skills": self._namespace.get("skills"),
"artifacts": self._namespace.get("artifacts"),
}
code = compile(skill.source, f"<skill:{skill_name}>", "exec")
_run_code(code, skill_namespace)
return skill_namespace["run"](**kwargs)
result = skill_namespace["run"](**kwargs)

if inspect.iscoroutine(result):
if self._loop is not None:
future = asyncio.run_coroutine_threadsafe(result, self._loop)
return future.result()
return asyncio.run(result)

return result
4 changes: 2 additions & 2 deletions src/py_code_mode/execution/subprocess/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ class SubprocessConfig:
deps_file: Path to requirements.txt-style file for pre-configured deps.
None means no deps file.
ipc_timeout: Timeout for IPC queries (tool/skill/artifact) in seconds.
Default: 30.0.
None means unlimited (default).
"""

python_version: str | None = None
Expand All @@ -63,7 +63,7 @@ class SubprocessConfig:
tools_path: Path | None = None
deps: tuple[str, ...] | None = None
deps_file: Path | None = None
ipc_timeout: float = 30.0
ipc_timeout: float | None = None

def __post_init__(self) -> None:
"""Validate configuration values."""
Expand Down
Loading
Loading