Skip to content

Commit 6064d73

Browse files
Kowserclaude
andcommitted
Make code-execution tools spawn-safe: CodeExecutionEntry / ExecutorToolEntry
The '_make_code_execution_tool.<locals>.execute_code' closure was the single largest source of agent-e2e pickle failures under 'spawn' (14 of 20: suites 10/14/15 code-exec, stateful and skills paths all route through it). Both factories now build picklable module-level entries: - CodeExecutionEntry (code_execution_config): carries the executor by value plus allowed_languages/allowed_commands/timeout as plain data; the CommandValidator is rebuilt per call. Local/Docker/Serverless executors hold no live resources and pickle cleanly; Jupyter pickles pre-kernel only (documented). - ExecutorToolEntry (code_executor.as_tool()): same pattern for the public as_tool() API (examples 24, kitchen_sink). - schema_from_function and the dispatch annotation resolver learn a type(func).__call__ fallback so callable-instance tools keep real type hints under `from __future__ import annotations` modules. New tests: CodeExecutionEntry executes real code across a spawn boundary; as_tool() entries pickle. tests/unit: 2512 passed, 1 skipped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d9ce0da commit 6064d73

6 files changed

Lines changed: 168 additions & 70 deletions

File tree

src/conductor/ai/agents/_internal/schema_utils.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,12 @@ def schema_from_function(func: Callable[..., Any]) -> Dict[str, Any]:
113113
try:
114114
hints = get_type_hints(func)
115115
except Exception:
116-
hints = {}
116+
# Callable instances (spawn-safe worker entries): resolve hints from
117+
# the class's __call__ — get_type_hints rejects instances directly.
118+
try:
119+
hints = get_type_hints(type(func).__call__)
120+
except Exception:
121+
hints = {}
117122

118123
# Build input schema
119124
properties: Dict[str, Any] = {}

src/conductor/ai/agents/code_execution_config.py

Lines changed: 53 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -242,29 +242,30 @@ def _validate_bash(self, code: str) -> Optional[str]:
242242
# ── Tool factory ───────────────────────────────────────────────────────
243243

244244

245-
def _make_code_execution_tool(
246-
executor: Any,
247-
allowed_languages: List[str],
248-
allowed_commands: List[str],
249-
timeout: int,
250-
agent_name: str = "",
251-
) -> Any:
252-
"""Create a ``@tool``-decorated function for code execution.
253-
254-
The returned function can be appended to ``Agent.tools`` directly.
255-
The tool name is prefixed with the agent name to avoid collisions
256-
when multiple agents define code execution with different configs.
245+
class CodeExecutionEntry:
246+
"""Execute code in a sandboxed environment.
247+
248+
Picklable replacement for the nested ``execute_code`` closure: a
249+
module-level class whose attrs are the executor (plain-data for
250+
Local/Docker/Serverless) and the validation config, so the worker
251+
survives the ``spawn`` start method's pickling (idea-5 spawn safety).
252+
The class docstring doubles as the tool description default.
257253
"""
258-
from conductor.ai.agents.code_executor import LocalCodeExecutor
259-
from conductor.ai.agents.tool import tool
260254

261-
validator = CommandValidator(allowed_commands) if allowed_commands else None
262-
langs_str = ", ".join(allowed_languages)
263-
task_name = f"{agent_name}_execute_code" if agent_name else "execute_code"
255+
def __init__(self, executor: Any, allowed_languages: List[str],
256+
allowed_commands: List[str], timeout: int):
257+
self.executor = executor
258+
self.allowed_languages = list(allowed_languages)
259+
self.allowed_commands = list(allowed_commands or [])
260+
self.timeout = timeout
261+
262+
def __call__(self, code: str, language: str = "python") -> dict:
263+
from conductor.ai.agents.code_executor import LocalCodeExecutor
264+
265+
# Rebuilt per call — cheap (frozenset), and keeps the pickled state
266+
# plain data.
267+
validator = CommandValidator(self.allowed_commands) if self.allowed_commands else None
264268

265-
@tool(name=task_name)
266-
def execute_code(code: str, language: str = "python") -> dict:
267-
"""Execute code in a sandboxed environment."""
268269
# Guard against bad parameters (LLM may omit args or send wrong types)
269270
if not code:
270271
return {
@@ -279,8 +280,10 @@ def execute_code(code: str, language: str = "python") -> dict:
279280
language = "python"
280281

281282
# Validate language
282-
if language not in allowed_languages:
283-
raise ValueError(f"Language '{language}' is not allowed. Allowed: {langs_str}")
283+
if language not in self.allowed_languages:
284+
raise ValueError(
285+
f"Language '{language}' is not allowed. Allowed: {', '.join(self.allowed_languages)}"
286+
)
284287

285288
# Validate commands
286289
if validator:
@@ -289,16 +292,16 @@ def execute_code(code: str, language: str = "python") -> dict:
289292
raise ValueError(error)
290293

291294
# Execute
292-
if isinstance(executor, LocalCodeExecutor):
295+
if isinstance(self.executor, LocalCodeExecutor):
293296
# LocalCodeExecutor is language-specific; create one per invocation
294297
lang_executor = LocalCodeExecutor(
295298
language=language,
296-
timeout=timeout,
297-
working_dir=executor.working_dir,
299+
timeout=self.timeout,
300+
working_dir=self.executor.working_dir,
298301
)
299302
result = lang_executor.execute(code)
300303
else:
301-
result = executor.execute(code)
304+
result = self.executor.execute(code)
302305

303306
# Always return structured result (never raise on code errors)
304307
if result.success:
@@ -312,14 +315,37 @@ def execute_code(code: str, language: str = "python") -> dict:
312315
if result.error:
313316
stderr_parts.append(result.error.rstrip())
314317
if result.timed_out:
315-
stderr_parts.append(f"TIMED OUT after {timeout}s")
318+
stderr_parts.append(f"TIMED OUT after {self.timeout}s")
316319
stderr_parts.append(f"Exit code: {result.exit_code}")
317320
return {
318321
"status": "error",
319322
"stdout": result.output or "",
320323
"stderr": "\n".join(stderr_parts),
321324
}
322325

326+
327+
def _make_code_execution_tool(
328+
executor: Any,
329+
allowed_languages: List[str],
330+
allowed_commands: List[str],
331+
timeout: int,
332+
agent_name: str = "",
333+
) -> Any:
334+
"""Create a ``@tool``-compatible code-execution tool.
335+
336+
The returned tool can be appended to ``Agent.tools`` directly. The tool
337+
name is prefixed with the agent name to avoid collisions when multiple
338+
agents define code execution with different configs. The callable is a
339+
picklable :class:`CodeExecutionEntry` (spawn-safe), not a closure.
340+
"""
341+
from conductor.ai.agents.tool import tool
342+
343+
langs_str = ", ".join(allowed_languages)
344+
task_name = f"{agent_name}_execute_code" if agent_name else "execute_code"
345+
346+
entry = CodeExecutionEntry(executor, allowed_languages, allowed_commands, timeout)
347+
execute_code = tool(name=task_name)(entry)
348+
323349
# Build dynamic description
324350
desc = f"Execute code in a sandboxed environment. Supported languages: {langs_str}. Timeout: {timeout}s."
325351
if allowed_commands:

src/conductor/ai/agents/code_executor.py

Lines changed: 45 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -97,49 +97,24 @@ def execute(self, code: str) -> ExecutionResult:
9797
def as_tool(self, name: Optional[str] = None, description: Optional[str] = None) -> Any:
9898
"""Create a ``@tool``-compatible function for this executor.
9999
100-
Returns a decorated function that can be passed directly to
101-
``Agent(tools=[...])``.
100+
Returns a tool callable that can be passed directly to
101+
``Agent(tools=[...])``. The callable is a picklable
102+
:class:`ExecutorToolEntry` carrying this executor by value — not a
103+
closure — so it survives 'spawn' worker pickling (idea-5).
102104
103105
Args:
104106
name: Override tool name (default: ``"execute_code"``).
105107
description: Override description.
106108
"""
107109
from conductor.ai.agents.tool import tool
108110

109-
executor = self
110111
tool_name = name or "execute_code"
111112
tool_desc = description or (
112113
f"Execute {self.language} code. Returns stdout, stderr, and exit code. "
113114
f"Timeout: {self.timeout}s."
114115
)
115116

116-
@tool(name=tool_name)
117-
def execute_code(code: str) -> dict:
118-
if not code:
119-
return {
120-
"status": "success",
121-
"stdout": "No code provided. Nothing to execute.",
122-
"stderr": "",
123-
}
124-
result = executor.execute(code)
125-
if result.success:
126-
return {
127-
"status": "success",
128-
"stdout": result.output or "",
129-
"stderr": result.error or "",
130-
}
131-
else:
132-
stderr_parts = []
133-
if result.error:
134-
stderr_parts.append(result.error.rstrip())
135-
if result.timed_out:
136-
stderr_parts.append(f"TIMED OUT after {executor.timeout}s")
137-
stderr_parts.append(f"Exit code: {result.exit_code}")
138-
return {
139-
"status": "error",
140-
"stdout": result.output or "",
141-
"stderr": "\n".join(stderr_parts),
142-
}
117+
execute_code = tool(name=tool_name)(ExecutorToolEntry(self))
143118

144119
# Override the description on the tool def
145120
execute_code._tool_def.description = tool_desc
@@ -149,6 +124,46 @@ def __repr__(self) -> str:
149124
return f"{type(self).__name__}(language={self.language!r}, timeout={self.timeout})"
150125

151126

127+
class ExecutorToolEntry:
128+
"""Execute code with the configured executor.
129+
130+
Picklable tool callable behind :meth:`CodeExecutor.as_tool` — carries the
131+
executor by value instead of closing over it, so the worker survives
132+
'spawn' pickling (idea-5 spawn safety). Note: ``JupyterCodeExecutor``
133+
only pickles before its kernel starts; register such tools before use.
134+
"""
135+
136+
def __init__(self, executor: "CodeExecutor"):
137+
self.executor = executor
138+
139+
def __call__(self, code: str) -> dict:
140+
if not code:
141+
return {
142+
"status": "success",
143+
"stdout": "No code provided. Nothing to execute.",
144+
"stderr": "",
145+
}
146+
result = self.executor.execute(code)
147+
if result.success:
148+
return {
149+
"status": "success",
150+
"stdout": result.output or "",
151+
"stderr": result.error or "",
152+
}
153+
else:
154+
stderr_parts = []
155+
if result.error:
156+
stderr_parts.append(result.error.rstrip())
157+
if result.timed_out:
158+
stderr_parts.append(f"TIMED OUT after {self.executor.timeout}s")
159+
stderr_parts.append(f"Exit code: {result.exit_code}")
160+
return {
161+
"status": "error",
162+
"stdout": result.output or "",
163+
"stderr": "\n".join(stderr_parts),
164+
}
165+
166+
152167
class LocalCodeExecutor(CodeExecutor):
153168
"""Execute code in a local subprocess.
154169

src/conductor/ai/agents/runtime/_dispatch.py

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,23 @@ def _needs_context(func):
266266
return False
267267

268268

269+
def _resolve_annotations_in_place(tool_func) -> None:
270+
"""Resolve PEP 563 string annotations to real types, best-effort.
271+
272+
For callable instances (spawn-safe worker entries) the hints come from
273+
the class's ``__call__`` — ``get_type_hints`` rejects instances directly.
274+
"""
275+
import typing
276+
277+
try:
278+
tool_func.__annotations__ = typing.get_type_hints(tool_func)
279+
except Exception:
280+
try:
281+
tool_func.__annotations__ = typing.get_type_hints(type(tool_func).__call__)
282+
except Exception:
283+
pass
284+
285+
269286
def make_tool_worker(tool_func, tool_name, guardrails=None, tool_def=None, credential_names=None):
270287
"""Create a spawn-safe Conductor worker for a @tool function.
271288
@@ -287,12 +304,7 @@ def make_tool_worker(tool_func, tool_name, guardrails=None, tool_def=None, crede
287304
# Resolve PEP 563 string annotations eagerly (documented factory behavior;
288305
# run_tool_task repeats this in the child, where re-imported functions
289306
# start over with string annotations).
290-
import typing
291-
292-
try:
293-
tool_func.__annotations__ = typing.get_type_hints(tool_func)
294-
except Exception:
295-
pass
307+
_resolve_annotations_in_place(tool_func)
296308
creds = list(credential_names) if credential_names else []
297309
if not creds and tool_def is not None:
298310
creds = [c for c in (getattr(tool_def, "credentials", None) or []) if isinstance(c, str)]
@@ -330,12 +342,7 @@ def run_tool_task(task, *, tool_name, tool_func, guardrails=None, credential_nam
330342
# Resolve PEP 563 string annotations (from __future__ import annotations)
331343
# to real types so downstream code can use isinstance(). Idempotent —
332344
# done per call because the resolution must happen in the child process.
333-
import typing
334-
335-
try:
336-
tool_func.__annotations__ = typing.get_type_hints(tool_func)
337-
except Exception:
338-
pass
345+
_resolve_annotations_in_place(tool_func)
339346

340347
from conductor.client.http.models import Task, TaskResult
341348
from conductor.client.http.models.task_result_status import TaskResultStatus

tests/unit/ai/test_worker_entries.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,45 @@ def test_entry_executes_tool_task_in_spawn_child(self):
128128
assert output == {"result": 12} # decorated_sample(5) == 5 + 7
129129

130130

131+
class TestCodeExecutionEntrySpawn:
132+
def test_code_execution_entry_runs_in_spawn_child(self):
133+
"""CodeExecutionEntry (was the _make_code_execution_tool closure)
134+
pickles and executes real code across a spawn boundary."""
135+
from conductor.ai.agents.code_execution_config import CodeExecutionEntry
136+
from conductor.ai.agents.code_executor import LocalCodeExecutor
137+
138+
entry = CodeExecutionEntry(
139+
LocalCodeExecutor(language="python", timeout=30),
140+
allowed_languages=["python"],
141+
allowed_commands=[],
142+
timeout=30,
143+
)
144+
entry_bytes = pickle.dumps(entry) # closure could never do this
145+
146+
ctx = multiprocessing.get_context("spawn")
147+
q = ctx.Queue()
148+
p = ctx.Process(
149+
target=helpers.run_code_entry_child,
150+
args=(entry_bytes, "print('spawn-ok')", q),
151+
)
152+
p.start()
153+
try:
154+
result = q.get(timeout=60)
155+
finally:
156+
p.join(timeout=60)
157+
assert p.exitcode == 0
158+
assert result["status"] == "success"
159+
assert "spawn-ok" in result["stdout"]
160+
161+
def test_as_tool_entry_is_picklable(self):
162+
from conductor.ai.agents.code_executor import LocalCodeExecutor
163+
164+
tool_fn = LocalCodeExecutor(language="python", timeout=10).as_tool()
165+
td = tool_fn._tool_def
166+
restored = pickle.loads(pickle.dumps(td.func))
167+
assert restored.executor.language == "python"
168+
169+
131170
# ── Registration-time probe ──────────────────────────────────────────────
132171

133172

tests/unit/resources/worker_entry_helpers.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ def resolve_and_call_child(ref_bytes: bytes, arg: int, q) -> None:
5454
q.put(fn(arg))
5555

5656

57+
def run_code_entry_child(entry_bytes: bytes, code: str, q) -> None:
58+
"""Spawn-child target: unpickle a CodeExecutionEntry and execute code."""
59+
entry = pickle.loads(entry_bytes)
60+
q.put(entry(code))
61+
62+
5763
def run_tool_entry_child(entry_bytes: bytes, x: int, q) -> None:
5864
"""Spawn-child target: unpickle a ToolWorkerEntry and execute a real Task.
5965

0 commit comments

Comments
 (0)