|
12 | 12 |
|
13 | 13 | import pytest |
14 | 14 |
|
| 15 | +from conductor.ai.agents.guardrail import Guardrail, OnFail, Position, RegexGuardrail |
15 | 16 | from conductor.ai.agents.runtime import _worker_entries as we |
16 | 17 | from conductor.ai.agents.runtime._worker_entries import ( |
17 | 18 | FunctionRef, |
18 | 19 | SpawnSafetyError, |
| 20 | + ToolWorkerEntry, |
19 | 21 | probe_spawn_safety, |
20 | 22 | ) |
21 | 23 | from conductor.ai.agents.tool import get_tool_def |
@@ -101,6 +103,109 @@ def test_cross_process_spawn_roundtrip(self): |
101 | 103 | assert p.exitcode == 0 |
102 | 104 |
|
103 | 105 |
|
| 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 | + |
104 | 209 | # ── ToolWorkerEntry across a real spawn boundary ───────────────────────── |
105 | 210 |
|
106 | 211 |
|
|
0 commit comments