Skip to content

Commit e714961

Browse files
Kowserclaude
andcommitted
Fix the 2 CI spawn-probe failures: container-attr hop + Guardrail transport
The agent-e2e run on 770973e (2 failed / 130 passed) surfaced two transport gaps, both hit by idiomatic user code: - suite11 'multiply': langchain's @tool rebinds the module global to a StructuredTool holding the original in .func (async: .coroutine) with no __wrapped__ chain, so FunctionRef.of gave up and the raw function fell to fn_direct — whose reference pickling then found the container at the global name ("it's not the same object as …"). FunctionRef now records an attr_hop ("func"/"coroutine") taken before the __wrapped__ walk, so container-held functions travel by reference. - suite8 'safe_query': ToolWorkerEntry carries tool-attached Guardrail objects raw, and Guardrail.func is the function *extracted* from the @guardrail wraps-wrapper — pickling it by reference found the wrapper instead. Guardrail now travels via __getstate__/__setstate__ routing func through wrap_callable/unwrap_callable (agent-level guardrails already did this inside GuardrailEntry). Bound-method guardrails (RegexGuardrail/LLMGuardrail) and external refs pass through unchanged. Tests: container-hop matrix (sync/async, real langchain via importorskip, spawn-child round trip) + Guardrail transport (decorated, tool-attached, regex regression, external). tests/unit: 2536 passed, 1 skipped. Both previously-failing e2e tests plus their full suites (19 tests) pass against a live v0.4.0 server under default spawn. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 770973e commit e714961

5 files changed

Lines changed: 228 additions & 9 deletions

File tree

src/conductor/ai/agents/guardrail.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,27 @@ def __repr__(self) -> str:
192192
f"on_fail={self.on_fail!r}{extra})"
193193
)
194194

195+
# Spawn transport: ``self.func`` is often the function *extracted* from a
196+
# decorator (``@guardrail`` rebinds the module global to a wraps-wrapper
197+
# and keeps the original in ``_guardrail_def.func``), so pickling it by
198+
# reference finds the wrapper instead — "not the same object". Travel as
199+
# a FunctionRef where resolvable; other picklable callables (e.g. the
200+
# bound methods of RegexGuardrail/LLMGuardrail) pass through unchanged.
201+
# Late imports: guardrail.py loads early in the package import chain.
202+
203+
def __getstate__(self):
204+
from conductor.ai.agents.runtime._worker_entries import wrap_callable
205+
206+
state = dict(self.__dict__)
207+
state["func"] = wrap_callable(state.get("func"))
208+
return state
209+
210+
def __setstate__(self, state):
211+
from conductor.ai.agents.runtime._worker_entries import unwrap_callable
212+
213+
state["func"] = unwrap_callable(state.get("func"))
214+
self.__dict__.update(state)
215+
195216

196217
# ── Specialised guardrail types ────────────────────────────────────────
197218

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

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
import pickle
3939
import sys
4040
from dataclasses import dataclass
41-
from typing import Callable, Dict, FrozenSet
41+
from typing import Callable, Dict, FrozenSet, Optional
4242

4343
# Mirrors conductor.client.worker.worker._MAX_UNWRAP_DEPTH.
4444
_MAX_UNWRAP_DEPTH = 32
@@ -65,6 +65,23 @@ def _walk_qualname(module_obj, qualname: str):
6565
return obj
6666

6767

68+
# Attributes where container-style decorators keep the original function when
69+
# they rebind the module global: langchain's @tool → StructuredTool.func /
70+
# .coroutine; our Guardrail / ToolDef → .func.
71+
_CONTAINER_ATTRS = ("func", "coroutine")
72+
73+
74+
def _wrapped_depth_to(obj, fn) -> Optional[int]:
75+
"""Number of ``__wrapped__`` hops from *obj* down to *fn*, or ``None``."""
76+
depth, current = 0, obj
77+
while hasattr(current, "__wrapped__") and depth < _MAX_UNWRAP_DEPTH:
78+
current = current.__wrapped__
79+
depth += 1
80+
if current is fn:
81+
return depth
82+
return None
83+
84+
6885
# Per-process memo of resolved refs — repopulated naturally in each spawn
6986
# child on first use (this is process-local state, never pickled).
7087
_RESOLVE_CACHE: Dict["FunctionRef", Callable] = {}
@@ -78,11 +95,17 @@ class FunctionRef:
7895
to the referenced function — e.g. 1 for a ``@tool``-decorated function,
7996
where the module global is the ``functools.wraps`` wrapper and
8097
``ToolDef.func`` is the original underneath it.
98+
99+
``attr_hop`` handles decorators that rebind the global to a container
100+
object rather than a wraps-wrapper — e.g. langchain's ``@tool`` rebinds
101+
it to a ``StructuredTool`` holding the original in ``.func`` (sync) or
102+
``.coroutine`` (async). The hop is taken before the ``__wrapped__`` walk.
81103
"""
82104

83105
module: str
84106
qualname: str
85107
unwrap_depth: int = 0
108+
attr_hop: str = ""
86109

87110
@classmethod
88111
def of(cls, fn: Callable) -> "FunctionRef":
@@ -122,24 +145,37 @@ def of(cls, fn: Callable) -> "FunctionRef":
122145
return cls(module, qualname, 0)
123146
# The global was rebound (typically by a wrapping decorator like
124147
# @tool). Walk the __wrapped__ chain to find fn, recording the depth.
125-
depth, current = 0, obj
126-
while hasattr(current, "__wrapped__") and depth < _MAX_UNWRAP_DEPTH:
127-
current = current.__wrapped__
128-
depth += 1
129-
if current is fn:
130-
return cls(module, qualname, depth)
148+
depth = _wrapped_depth_to(obj, fn)
149+
if depth is not None:
150+
return cls(module, qualname, depth)
151+
# Not a wraps-style wrapper: the global may be a container object
152+
# holding the original in an attribute — langchain's @tool rebinds
153+
# the global to a StructuredTool (sync fn in .func, async in
154+
# .coroutine); our Guardrail/ToolDef hold theirs in .func.
155+
for attr in _CONTAINER_ATTRS:
156+
inner = getattr(obj, attr, None)
157+
if inner is None or inner is obj:
158+
continue
159+
if inner is fn:
160+
return cls(module, qualname, 0, attr)
161+
depth = _wrapped_depth_to(inner, fn)
162+
if depth is not None:
163+
return cls(module, qualname, depth, attr)
131164
raise SpawnSafetyError(
132165
f"'{module}.{qualname}' does not resolve back to {fn!r} (rebound "
133-
f"without a __wrapped__ chain). {_REMEDIES}"
166+
f"without a __wrapped__ chain or a func/coroutine container "
167+
f"attribute). {_REMEDIES}"
134168
)
135169

136170
def resolve(self) -> Callable:
137-
"""Import + walk + unwrap; memoized per process."""
171+
"""Import + walk + hop + unwrap; memoized per process."""
138172
cached = _RESOLVE_CACHE.get(self)
139173
if cached is not None:
140174
return cached
141175
module_obj = importlib.import_module(self.module)
142176
obj = _walk_qualname(module_obj, self.qualname)
177+
if self.attr_hop:
178+
obj = getattr(obj, self.attr_hop)
143179
for _ in range(self.unwrap_depth):
144180
obj = obj.__wrapped__
145181
_RESOLVE_CACHE[self] = obj

tests/unit/ai/test_worker_entries.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@
1212

1313
import pytest
1414

15+
from conductor.ai.agents.guardrail import Guardrail, OnFail, Position, RegexGuardrail
1516
from conductor.ai.agents.runtime import _worker_entries as we
1617
from conductor.ai.agents.runtime._worker_entries import (
1718
FunctionRef,
1819
SpawnSafetyError,
20+
ToolWorkerEntry,
1921
probe_spawn_safety,
2022
)
2123
from conductor.ai.agents.tool import get_tool_def
@@ -101,6 +103,109 @@ def test_cross_process_spawn_roundtrip(self):
101103
assert p.exitcode == 0
102104

103105

106+
# ── Container-attribute hop (langchain @tool → StructuredTool) ───────────
107+
108+
109+
class TestFunctionRefContainerHop:
110+
"""Decorators that rebind the global to a container object, not a
111+
wraps-wrapper — the suite11 CI failure (langchain's @tool)."""
112+
113+
def test_sync_container_func_attr(self):
114+
raw = helpers.container_sample.func
115+
ref = FunctionRef.of(raw)
116+
assert ref == FunctionRef(helpers.__name__, "container_sample", 0, "func")
117+
assert ref.resolve() is raw
118+
119+
def test_async_container_coroutine_attr(self):
120+
raw = helpers.async_container_sample.coroutine
121+
ref = FunctionRef.of(raw)
122+
assert ref.attr_hop == "coroutine"
123+
assert ref.resolve() is raw
124+
125+
def test_ref_with_hop_pickles(self):
126+
raw = helpers.container_sample.func
127+
ref = pickle.loads(pickle.dumps(FunctionRef.of(raw)))
128+
assert ref.resolve()(4) == 15
129+
130+
def test_entry_transports_container_held_fn_by_ref(self):
131+
# Pre-fix this fell to fn_direct, whose reference pickling then found
132+
# the container at the global name: "it's not the same object as …".
133+
raw = helpers.container_sample.func
134+
entry = ToolWorkerEntry.for_callable(raw, "container_tool")
135+
assert entry.fn_ref is not None
136+
clone = pickle.loads(pickle.dumps(entry))
137+
assert clone._target() is raw
138+
139+
def test_real_langchain_tool_roundtrip(self):
140+
pytest.importorskip("langchain_core")
141+
from tests.unit.resources import langchain_entry_helpers as lch
142+
143+
raw = lch.lc_multiply.func
144+
ref = FunctionRef.of(raw)
145+
assert ref.attr_hop == "func"
146+
assert pickle.loads(pickle.dumps(ref)).resolve() is raw
147+
148+
raw_async = lch.lc_multiply_async.coroutine
149+
ref_async = FunctionRef.of(raw_async)
150+
assert ref_async.attr_hop == "coroutine"
151+
assert ref_async.resolve() is raw_async
152+
153+
def test_cross_process_spawn_roundtrip_with_hop(self):
154+
ctx = multiprocessing.get_context("spawn")
155+
q = ctx.Queue()
156+
ref_bytes = pickle.dumps(FunctionRef.of(helpers.container_sample.func))
157+
p = ctx.Process(target=helpers.resolve_and_call_child, args=(ref_bytes, 4, q))
158+
p.start()
159+
try:
160+
assert q.get(timeout=30) == 15 # container_sample(4) == 4 + 11
161+
finally:
162+
p.join(timeout=30)
163+
assert p.exitcode == 0
164+
165+
166+
# ── Guardrail spawn transport ─────────────────────────────────────────────
167+
168+
169+
class TestGuardrailSpawnTransport:
170+
"""Guardrail.func travels via wrap_callable — the suite8 CI failure:
171+
@guardrail extracts the raw function (the global is the wraps-wrapper),
172+
so pickling it by reference found the wrapper instead."""
173+
174+
@staticmethod
175+
def _guard():
176+
return Guardrail(
177+
helpers.sample_guardrail, position=Position.INPUT, on_fail=OnFail.RAISE
178+
)
179+
180+
def test_decorated_guardrail_pickles(self):
181+
g = self._guard()
182+
clone = pickle.loads(pickle.dumps(g))
183+
assert clone.name == "no_marker"
184+
assert clone.func is g.func # FunctionRef resolves to the same object
185+
assert clone.check("has MARKER").passed is False
186+
assert clone.check("clean").passed is True
187+
188+
def test_tool_entry_with_guardrail_pickles(self):
189+
# The exact suite8 shape: @guardrail func attached to a tool worker.
190+
entry = ToolWorkerEntry.for_callable(
191+
helpers.plain_sample, "safe_query", guardrails=[self._guard()]
192+
)
193+
clone = pickle.loads(pickle.dumps(entry))
194+
assert clone.guardrails[0].check("MARKER!").passed is False
195+
196+
def test_regex_guardrail_bound_method_still_pickles(self):
197+
rg = RegexGuardrail(patterns=[r"foo"], name="no_foo", message="no foo")
198+
clone = pickle.loads(pickle.dumps(rg))
199+
assert clone.check("has foo").passed is False
200+
assert clone.check("clean").passed is True
201+
202+
def test_external_guardrail_pickles(self):
203+
g = Guardrail(name="external_ref")
204+
clone = pickle.loads(pickle.dumps(g))
205+
assert clone.external is True
206+
assert clone.func is None
207+
208+
104209
# ── ToolWorkerEntry across a real spawn boundary ─────────────────────────
105210

106211

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""
2+
Module-level langchain ``@tool`` subjects for FunctionRef container-hop tests.
3+
4+
Separate from worker_entry_helpers.py so environments without langchain can
5+
still import that module; tests importing THIS module must be gated with
6+
``pytest.importorskip("langchain_core")``.
7+
"""
8+
from langchain_core.tools import tool as lc_tool
9+
10+
11+
@lc_tool
12+
def lc_multiply(a: int, b: int) -> str:
13+
"""Multiply two numbers and return the product."""
14+
return str(a * b)
15+
16+
17+
@lc_tool
18+
async def lc_multiply_async(a: int, b: int) -> str:
19+
"""Multiply two numbers asynchronously and return the product."""
20+
return str(a * b)

tests/unit/resources/worker_entry_helpers.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"""
99
import pickle
1010

11+
from conductor.ai.agents.guardrail import GuardrailResult, guardrail
1112
from conductor.ai.agents.tool import tool
1213

1314

@@ -27,6 +28,42 @@ async def async_sample(x: int) -> int:
2728
return x * 3
2829

2930

31+
class FuncContainer:
32+
"""Mimics langchain's ``@tool`` container (StructuredTool): the decorator
33+
rebinds the module global to an object holding the original function in
34+
``.func`` (sync) or ``.coroutine`` (async), with no ``__wrapped__`` chain.
35+
"""
36+
37+
def __init__(self, func=None, coroutine=None):
38+
self.func = func
39+
self.coroutine = coroutine
40+
41+
42+
def container_sample(x: int) -> int:
43+
"""Rebound below: the global becomes a FuncContainer, .func is this fn."""
44+
return x + 11
45+
46+
47+
container_sample = FuncContainer(func=container_sample)
48+
49+
50+
async def async_container_sample(x: int) -> int:
51+
"""Rebound below: the global becomes a FuncContainer, .coroutine is this fn."""
52+
return x + 13
53+
54+
55+
async_container_sample = FuncContainer(coroutine=async_container_sample)
56+
57+
58+
@guardrail(name="no_marker")
59+
def sample_guardrail(content: str) -> GuardrailResult:
60+
"""@guardrail-decorated: global is the wraps-wrapper, GuardrailDef.func
61+
the original — the suite8 `_sql_check` shape."""
62+
if "MARKER" in content:
63+
return GuardrailResult(passed=False, message="marker blocked")
64+
return GuardrailResult(passed=True)
65+
66+
3067
class AsyncCallEntry:
3168
"""Callable instance with async __call__ — async-detection test subject."""
3269

0 commit comments

Comments
 (0)